Output Formats¶
Qualimetrix supports 11 output formats. Choose the one that fits your workflow.
summary (default)¶
Health-oriented overview showing project health scores, worst offenders, and violation summary. This is the default CLI output designed for quick project assessment.
When to use: Local development, quick project health overview.
Key features:
- An overall health bar plus one bar per dimension (complexity, cohesion, coupling, typing, maintainability), each followed by a
↳decomposition breakdown of the metrics behind that score - Scores as percentages, not raw point values
- One-line
Worst namespaces/Worst classeslists, each entry prefixed by its score, with a trailing+N more (use ...)when the list was truncated. A repeated class name gets a(<file>)suffix, or(<file>#<n>)for a later declaration in the same file; the first declaration in each file omits the ordinal. - A
Top issues by impactsection: a ranked list of the highest-impact individual violations, each with severity, impact score, file, estimated fix time, rule channel, message, and symbol - Violation count with tech debt estimate (including debt density per 1K LOC)
- Multiple contextual
Hints:for next steps
Example output:
Qualimetrix 0.26.0 — 62 files analyzed, 0.8s
Analysis complete: 62 analyzed, 0 generated file(s) excluded.
Health █████████████████████░░░░░░░░░ 71.4% Fair
Complexity ████████████████████████░░░░░░ 79.2% Good
↳ Cyclomatic (avg): 3.1 (target: below 4) — manageable branching
↳ Cognitive (avg): 2.4 (target: below 5) — straightforward control flow
↳ Cyclomatic: 14 (target: below 4) — too many code paths
↳ Cognitive: 18 (target: below 5) — deeply nested, hard to follow
Cohesion ████████████████░░░░░░░░░░░░░░ 54.8% Poor
↳ TCC: 0.4 (target: above 0.5) — methods rarely share fields
↳ LCOM4: 3 (target: 1 or less) — class splits into unrelated clusters
Coupling ███████████████████░░░░░░░░░░░ 63.1% Fair
↳ Ce (avg): 4.7 (target: below 3) — elevated outgoing coupling
↳ Ce pkg (avg): 1.8 (target: below 1) — wide package dependencies
↳ Distance: 0.52 (target: below 0.3) — poor balance of abstraction and stability
Typing ███████████████████████████░░░ 91.3% Excellent
↳ Parameter types: 91 (target: 100%) — 251 of 275 typed (91.3%)
↳ Return types: 88 (target: 100%) — 231 of 262 typed (88.2%)
↳ Property types: 95 (target: 100%) — 118 of 124 typed (95.2%)
Maintainability █████████████████████████░░░░░ 82.0% Good
↳ MI (avg): 71.4 (target: above 65) — code is maintainable
↳ MI (p5): 52.6 (target: above 50) — even worst methods are maintainable
↳ MI: 41.8 (target: above 65) — code is hard to change safely
* Labels reflect per-dimension scales (e.g., Typing requires >80% for Acceptable)
Worst namespaces
48.2 App\Billing\Invoice (6 classes in subtree, 11 violations, 3.8/100 LOC)
55.9 App\Service\Order (4 classes in subtree, 7 violations, 2.1/100 LOC)
61.3 App\Repository (9 classes in subtree, 5 violations, 0.9/100 LOC)
+5 more (use --format-opt=top=8)
Worst classes
38.4 App\Billing\Invoice\InvoiceCalculator — low cohesion
45.1 App\Service\Order\OrderService — high coupling
52.7 App\Repository\OrderRepository
+9 more (use --format-opt=top=12)
Top issues by impact
1. [ERR] 4.12 src/Billing/Invoice/InvoiceCalculator.php [45min]
complexity.cognitive: Cognitive complexity: 24 (threshold: 15). Top: nested if +4 L88, nested foreach +3 L74, nested if +2 L91 — deeply nested, hard to follow (InvoiceCalculator::recalculate)
2. [ERR] 3.65 src/Service/Order/OrderService.php [30min]
coupling.cbo: CBO is 21 (threshold: 15) — too many collaborators (OrderService)
3. [WRN] 2.90 src/Repository/OrderRepository.php [20min]
complexity.ccn: Cyclomatic complexity: 13 (threshold: 10) — too many code paths (OrderRepository::findByCriteria)
82 violations (19 errors, 63 warnings) | Tech debt: 6h 20min (54.3 min/kLOC to fix)
Hints: --detail to list violations (up to 200; --detail=all for every one) | --namespace='subtree:App\Billing\Invoice' to drill down | --format=html -o report.html for full report
Docs: https://qualimetrix.dev · AI agents: https://qualimetrix.dev/llms.txt
See CLI Options for the flag that controls how many Top issues by impact entries are shown.
Drill-down with --namespace and --class:
# Show violations for a specific namespace subtree
bin/qmx check src/ --namespace='subtree:App\Service'
# Show violations for a specific class
bin/qmx check src/ --class=App\\Service\\UserService
A drill-down narrows what the report shows, not what decides the exit code.
The finding-count line therefore speaks about the selection ("… in this
scope"), names the violations outside it that decide the exit code, and takes
its colour from the whole run: a clean subtree of a failing project prints
No violations in this scope. 9 outside it (5 errors, 4 warnings) decide the exit code,
never a green No violations found.. --format=text does the same in its
closing summary line.
The structured formats list only the selection too, and each says what it left out in the channel it already uses for diagnostics about the document itself. A format with no such channel refuses the selection instead:
| Format | What the selection left out |
|---|---|
json |
Top-level outOfScope object: violationCount, errorCount, warningCount, infoCount, identities |
metrics |
The same outOfScope counts and identities as JSON |
sarif |
A note in runs[0].invocations[0].toolExecutionNotifications[] with descriptor QMX-DRILL-DOWN-OUT-OF-SCOPE |
gitlab |
Refused with exit 3 before the analysis: the merge-request widget counts every entry as an issue |
checkstyle |
Refused with exit 3 before the analysis: a Checkstyle reader counts every entry as an error |
github |
A ::notice title=drill-down.out-of-scope:: line |
html |
A banner above the report |
suppressed |
Refuses a selector: the document describes the whole run |
json and metrics carry outOfScope in every document: null without a
selection, and zero counts when the selection left nothing out. sarif,
github and html add their entry only when something lies outside the
selection. The
exit code is resolved over the selection and outOfScope together, so a clean
selection can exit 2.
Namespace selection uses a finding's declared namespace. For a file aggregate
(annotation.*, duplication.clone), it matches any namespace the file opens
or declares. A file with neither uses the global namespace.
--class never selects a file aggregate. suppress_namespaces deliberately
does not take this file step; use suppress_paths for a file aggregate.
Overlapping namespace exclusions keep credit for their first match.
suppressed --namespace/--class refuses with exit 3 because the composition
document describes the whole run. --show-suppressed remains allowed and
labels its stderr list as whole-run with the selector unapplied. The analysis
and exit verdict always cover the whole run.
Detail mode with --detail:
# Append grouped violation list (default limit: 200)
bin/qmx check src/ --detail
# Show all violations (no limit)
bin/qmx check src/ --detail=all
# Custom limit
bin/qmx check src/ --detail=50
--detail enables the finding list with an optional cap. --detail=N selects
the worst N by severity, then impact, before presentation grouping. Unranked
findings fall back to place. --detail=0 equals --detail=all; invalid values
refuse with exit 3 before analysis. --top controls the separate impact list.
Note
--detail is auto-enabled when using --namespace or --class. It also works with --format=text to append a grouped violation list after the one-line-per-violation output.
text¶
Compact, one-line-per-violation output. Compatible with GCC/Clang error format, so violations are clickable in most terminals and IDEs.
When to use: Local development, quick checks, piping to grep or wc.
Example output:
src/Repository/OrderRepository.php: error[coupling.class-rank]: ClassRank share is 12.98× uniform, exceeds threshold of 10.00×. This class is a critical hub — changes have wide impact (OrderRepository)
src/Repository/OrderRepository.php: warning[complexity.ccn]: Cyclomatic complexity is 10, reaches threshold of 10. Consider extracting methods or simplifying conditions (OrderRepository::findByCriteria)
src/Service/UserService.php:9: warning[code-smell.error-suppression]: Error suppression (@) on file_get_contents() - handle errors explicitly
src/Service/UserService.php: warning[complexity.ccn]: Cyclomatic complexity is 14, exceeds threshold of 10. Consider extracting methods or simplifying conditions (UserService::calculate)
Qualimetrix 0.26.0: 1 error(s), 3 warning(s) in 2 file(s)
Analysis complete: 2 analyzed, 0 generated file(s) excluded.
Technical debt: 1h 40min
Docs: https://qualimetrix.dev · AI agents: https://qualimetrix.dev/llms.txt
Format: there are three line shapes, depending on what the finding is about.
- A violation pinned to a specific statement carries a line number:
file:line: severity[violationCode]: message (symbol). - A class- or method-level finding whose rule judges the whole declaration rather than one statement — for example
complexity.ccn,complexity.wmc,coupling.class-rank— omits the line segment instead:file: severity[violationCode]: message (symbol), even though the same finding carries alinein--format=json. - A project-level finding (no owning file at all — e.g. an
architecture.unreachable-layerfinding) drops the file segment too:[project]: severity[violationCode]: message, with no trailing(symbol). On this project's own self-analysis this third form is common, not an edge case:bin/qmx check src/Analysis/Evidence/Complexity --format=textprints project-level lines for a majority of the output.
json¶
Machine-readable JSON output. Summary-oriented format with health scores, worst offenders, and all violations.
When to use: Custom scripts, dashboards, programmatic processing.
Top-level keys: meta, summary, outOfScope, coverage, projectScope (see Project scope in every format), configurationDiagnostics, computedMetricOutcomes, abstentions, inheritanceDepthOutcomes, health, worstNamespaces, worstClasses, topIssues, violations, violationsMeta, plus violationGroups when --group-by is passed — without it, the key is absent entirely, not an empty object.
configurationDiagnostics lists the warnings about the configuration the run accepted — the same ones check prints on stderr — and is [] when there are none. Each entry is {"message": "…", "source": [{"kind": "preset", "name": "…", "imported_by": null}, …]}: source names every layer the warning is about, lowest precedence first, in the form a configuration error's source uses. For example, only_rules: [] in qmx.yaml over a preset that filters the rules is lawful, and draws one entry naming both.
Every successful check JSON document includes computedMetricOutcomes, including
an empty array. Nonempty records group authored missing inputs and null results
by metric and level: metric, level, missingKeysCount, noValueCount,
missingKeys and subjects. The keys are the exact missing-key union; subjects
are at most three deterministic canonical exact identities. This summary is
separate from findings and configurationDiagnostics.
A successful check whose resolved format is exactly json and destination is
stdout writes the complete raw document at normal and quiet verbosity,
including --silent; answer-producing commands do not suppress that document. JSON selected in configuration follows the same route. Other answer-producing
formats also publish under quiet and silent; file targets keep their format.
meta identifies the tool that wrote the document: version, package, timestamp, and two documentation addresses — docs, the documentation site, and llmsTxt, the index written for AI agents. Every JSON report with an envelope object carries the same two addresses; see the exceptions in Documentation addresses in JSON reports.
Example output:
{
"meta": {
"version": "1.0.0",
"package": "qmx",
"timestamp": "2025-01-15T10:30:00+00:00",
"docs": "https://qualimetrix.dev",
"llmsTxt": "https://qualimetrix.dev/llms.txt"
},
"summary": {
"filesAnalyzed": 45,
"filesSkipped": 0,
"duration": 1.234,
"violationCount": 3,
"errorCount": 2,
"warningCount": 1,
"infoCount": 0,
"techDebtMinutes": 270,
"debtPer1kLoc": 2.1
},
"outOfScope": null,
"projectScope": {
"state": "covered",
"uncoveredAutoloadTargets": [],
"unjudgedChannels": [],
"unjudgedValues": [],
"reasons": []
},
"configurationDiagnostics": [],
"computedMetricOutcomes": [],
"abstentions": [],
"inheritanceDepthOutcomes": [],
"health": {
"complexity": {
"score": 78.0,
"label": "Good",
"threshold": {
"warning": 50,
"error": 30
},
"coverage": {
"state": "measured",
"measured": 2263,
"eligible": 2263,
"ratio": 1.0,
"unit": "callables",
"basis": "complexity.ccn.count",
"reason": null
},
"decomposition": [
{
"metric": "complexity.ccn.avg",
"humanName": "Cyclomatic complexity",
"value": 4.12,
"coverage": {
"state": "measured",
"measured": 2263,
"eligible": 2263,
"ratio": 1.0,
"unit": "callables",
"basis": "complexity.ccn.count",
"reason": null
},
"good": true,
"direction": "lower-is-better"
}
],
"worstContributors": [
{
"symbolPath": "App\\Service\\UserService",
"className": "App\\Service\\UserService",
"metrics": {
"complexity.ccn.sum": 96
}
}
]
},
"overall": {
"score": 72.0,
"label": "Good",
"threshold": {
"warning": 50,
"error": 30
},
"coverage": {
"state": "not-applicable",
"measured": null,
"eligible": null,
"ratio": null,
"unit": null,
"basis": null,
"reason": "health.overall composes the other dimensions; each of them publishes its own coverage"
},
"decomposition": [],
"worstContributors": []
}
},
"worstNamespaces": [
{
"symbolPath": "App\\Service",
"healthOverall": 52.0,
"label": "Fair",
"reason": "high coupling",
"violationCount": 15,
"violationDensity": 7.5,
"size.class-count.sum": 8,
"healthScores": {}
}
],
"worstClasses": [
{
"symbolPath": "App\\Service\\UserService",
"healthOverall": 45.0,
"label": "Poor",
"reason": "low cohesion",
"violationCount": 8,
"violationDensity": 4.0,
"file": "src/Service/UserService.php",
"metrics": {},
"healthScores": {}
}
],
"topIssues": [
{
"rank": 1,
"file": "src/Service/UserService.php",
"line": 42,
"subject": "declaration:callable:App\\Service\\UserService::calculate@src/Service/UserService.php",
"symbol": "App\\Service\\UserService::calculate",
"channel": "complexity.ccn",
"occurrence": null,
"edge": null,
"namespace": "App\\Service",
"namespaces": ["App\\Service"],
"rule": "complexity.ccn",
"code": "complexity.ccn",
"severity": "error",
"message": "Cyclomatic complexity: 15 (threshold: 10) — too many code paths",
"recommendation": null,
"metricValue": 15,
"threshold": 10,
"techDebtMinutes": 30,
"acceptedLevel": null,
"baselineVerdict": null,
"baselineReason": null,
"impactScore": 3.71,
"coupling.class-rank-share": 3.711,
"debtMinutes": 30
}
],
"violations": [
{
"file": "src/Service/UserService.php",
"line": 42,
"subject": "declaration:callable:App\\Service\\UserService::calculate@src/Service/UserService.php",
"symbol": "App\\Service\\UserService::calculate",
"channel": "complexity.ccn",
"occurrence": null,
"edge": null,
"namespace": "App\\Service",
"namespaces": ["App\\Service"],
"rule": "complexity.ccn",
"code": "complexity.ccn",
"severity": "error",
"message": "Cyclomatic complexity: 15 (threshold: 10) — too many code paths",
"recommendation": null,
"metricValue": 15,
"threshold": 10,
"techDebtMinutes": 30,
"acceptedLevel": null,
"baselineVerdict": null,
"baselineReason": null
}
],
"violationsMeta": {
"total": 3,
"shown": 3,
"limit": null,
"truncated": false,
"byRule": {
"complexity.ccn": 2,
"coupling.cbo": 1
}
},
"violationGroups": {}
}
The worstNamespaces and worstClasses entries include a violationDensity field: findings per 100 LOC. It is null when findings exist but LOC is absent or zero.
worstNamespaces includes namespaces declaring at least one own class, interface, trait or enum; containers with only child declarations are excluded. Every numeric field in a namespace record describes its subtree, including healthOverall, healthScores, violationCount, violationDensity and size.class-count.sum. A class record describes its own exact declaration. The global namespace is an isolated leaf; its counts do not include named namespaces.
healthScores contains available complexity, cohesion, coupling, typing and maintainability dimensions; it never contains overall. The composite is healthOverall. Reasons, labels and summary colors follow the effective computed-metric thresholds.
JSON and summary select from the complete population before ranking and limiting it. JSON/summary --class requires a unique exact declaration for the class health heading. A duplicate logical name refuses with exit 3 and lists its exact declarations; use --namespace to retain every exact offender record. --format-opt=rank-by=score is the default ascending health order; density orders density descending. count is refused with exit 3. Summary reports the true remaining population, for example +12 more (use --format-opt=top=15) after showing three of fifteen candidates. Namespace class counts read "N classes in subtree". HTML displays at most ten local children and names the full local available count; its list is not the global JSON ranking.
topIssues is the same ranked list the summary format prints as "Top issues
by impact"; no other format renders it. Each entry names the rule-specific
impactScore used for ranking and the estimated debtMinutes. The
coupling.class-rank-share key is always present. Class and method findings use
their class's measured share; file and namespace findings use the maximum share
among their classes. It is null when no finite share is available, and for
function and project findings. Ranking may use the measured median as a fallback
for impactScore without replacing that null. file and line are nullable
too, because a project-level finding has no source position. The share is raw
PageRank multiplied by the logical graph vertex count, in multiples of uniform
probability. Metrics exports retain raw coupling.class-rank and also
publish coupling.class-rank-share; ranking never substitutes raw probability
for missing share.
Each violation carries nullable acceptedLevel, baselineVerdict and
baselineReason. baselineVerdict: "breached" means a compared group exceeded
its cap and was promoted to Error. "not-compared" retains acceptedLevel and
normal severity with a scalar reason. Null verdict establishes neither; a null
acceptedLevel cannot distinguish no configured baseline from a new identity.
A non-null acceptedLevel carries {"shape":"magnitude","describe":"20, 30","count":2}; current metricValue
remains its own field. HTML carries the same judgement fields and renders both
breached and not-compared states.
violationsMeta
also reports shown — the number of violations actually included in this
payload, which can be lower than total when --format-opt=violations=N
truncates the list. A truncated list selects the N worst findings by severity, then impact
ranking, before presentation grouping. Unranked findings fall back to place.
message and recommendation mean the same in violations and in
topIssues: the finding's message, and its recommendation or null. Under
--namespace/--class the summary object keeps all its keys and counts only
the selection; debtPer1kLoc is null there, because the selection's debt
over the whole project's lines would mix two scopes. The findings the
selection left out are counted in outOfScope, which is null without a
selection, as the drill-down table under summary above shows for every format.
Invalid source bytes are published as %XX; valid UTF-8 remains unchanged.
Identity components always encode literal % as %25 and reserved separators
such as declaration-file # as %23. Display strings are different: a valid
literal Pa%FFth.php and an invalid byte path can display identically. Use
subject for exact identity, not file. SARIF uri percent-encodes the original
path bytes, so an invalid byte is %FF and a literal percent is %25.
Every format reports a positive repair count on stderr. JSON, metrics,
suppressed and HTML retain the invalidUtf8Replaced document marker; SARIF
also uses a tool notification. GitLab and Checkstyle contain actual findings
only, with no synthetic publication.invalid-utf8 record. Prose repairs the
whole body as one published string and preserves literal percentages; structured
formats count repaired fields. These counts need not be equal across formats.
json.violations, json.topIssues and HTML use the same finding record keys:
file, line, symbol, channel, subject, occurrence, edge, namespace, namespaces,
rule, code, severity, message, recommendation, metricValue, threshold,
techDebtMinutes, acceptedLevel, baselineVerdict, baselineReason.
HTML readers must use rule/code/symbol instead of
ruleName/violationCode/symbolPath. Diagnostic, advice and baseline status are
shown separately; a configured cap alone is not a measured breach.
namespaces contains the same namespace population used for selection and
grouping. A declaration keeps its own namespace; a file finding carries the
namespaces of measured exact declarations and logical classes in that file.
Namespace blocks without those declarations are absent from the index. Empty
membership uses [""] as a global reporting fallback, even when the source
contains a namespace block. Project
findings carry []. namespace is the sole name (including "" for global),
or null for multiple names and project findings. A multi-namespace file
finding belongs to one sorted group and is not duplicated.
outOfScope adds identities, an occurrence-preserving multiset of
channel, subject, occurrence, edge and severity. JSON and metrics use
violationCount/errorCount/warningCount/infoCount. SARIF carries identities
in its notification; GitHub retains its ::notice. Health prints selected
and outside counts even when no health scores are available.
The HTML viewer uses published repository bags, including the global namespace.
Class-like area uses size.class-loc; namespace/project area uses aggregate
LOC. Missing health remains unknown. The viewer does not recompute subtree
health or replace it with MI. Worst Sub-Namespaces shows the published
fractional score, and Martin coordinates use coupling.*.
violationGroups dictionary keys use total percent encoding, including
literal % as %25, to keep byte-distinct file groups separate. Decode a
key with rawurldecode; the finding's file remains display text.
For machine identity, use channel + subject + optional occurrence + optional
edge. symbol is the logical display projection; source line, message, and
display order are not stable identity. subject distinguishes exact
declarations from logical and aggregate subjects, occurrence distinguishes
semantic evidence within one channel, and edge contains a required dependency
target plus an optional reference type. An untyped edge is
{"target": "class:App\\Dependency"}; a typed edge is
{"type": "new", "target": "class:App\\Dependency"}. Formatter fingerprints
use the same tuple, so target-only edges differ by target and from a typed edge
to the same target. Existing no-edge and fully typed fingerprints are
unchanged.
When using --group-by=class or --group-by=namespace, violations are organized into a violationGroups object. Each group is {count, violations} — a violation count and the violations array; it does not carry its own errorCount, warningCount, or violationDensity.
The group keys are not always a class FQCN or namespace. For --group-by=class: the key is the class FQCN for a class-scoped finding, the file path for a file-level finding with no class context, and an empty string "" for a project-level finding (which has neither a class nor a file). For --group-by=namespace: the key joins the sorted names with ,, displays the empty name as (global), and uses [project] for a project-level finding.
Options:
# Limit violations in output (default: all)
bin/qmx check src/ --format=json --format-opt=violations=50
# Control number of worst offenders (default: 10)
bin/qmx check src/ --format=json --format-opt=top=20
Every --format-opt value is parsed before the analysis runs, by one grammar
per key whichever format reads it: violations and limit take a whole number
or all, top a whole number of 1 or more, contributors a whole number,
rank-by count or density, project-name a non-empty name. A value that
does not parse is refused with exit code 3 instead of falling back to a
default. violations and limit bound the same list — violations=0 shows
none, limit=0 shows all — so writing both, or limit beside --all, is
refused with exit code 3 rather than one of them being ignored.
# Group violations by class or namespace
bin/qmx check src/ --format=json --group-by=class
bin/qmx check src/ --format=json --group-by=namespace
CI usage:
metrics¶
Raw metric values for every symbol (file, class, namespace, method, function, project). Unlike json which outputs violations, metrics exports the underlying metric data that rules evaluate.
When to use: Custom dashboards, trend analysis, data science pipelines, or building your own quality gates on raw metrics.
Top-level keys: version, toolVersion, package, timestamp, docs, llmsTxt, symbols[] (each with type: file/class/namespace/method/function/project, name, subject, file, line, metrics: {...}), outOfScope, projectScope, coverage, summary. Under --namespace/--class the summary counts only the selection and outOfScope counts what it left out; symbols[] is never narrowed. There is no callable type; a single project entry aggregates project-wide statistical metrics (min/max/avg/p95 across all symbols) and has null file and line. Here version is the version of this export format and toolVersion the version of Qualimetrix; docs and llmsTxt are the documentation addresses json carries in its meta.
Each class or callable declaration is published separately. name may repeat; subject identifies the record. Logical graph values repeat in every declaration of their name, and class aggregates count declarations. Namespace and project records have null file and line. A .count beside .avg is its sample size; namespace size.*-count metrics no longer publish a separate file-count suffix. For namespace size.loc|lloc|cloc, .sum is the integer subtree total, .count the number of contributing files and .avg their ratio.
Example output (abbreviated):
{
"version": "1.0.0",
"toolVersion": "0.26.0",
"package": "qmx",
"timestamp": "2025-01-15T10:30:00+00:00",
"docs": "https://qualimetrix.dev",
"llmsTxt": "https://qualimetrix.dev/llms.txt",
"symbols": [
{
"type": "file",
"name": "src/Service/UserService.php",
"subject": "file:src/Service/UserService.php",
"file": "src/Service/UserService.php",
"line": 1,
"metrics": {
"size.loc": 150,
"size.lloc": 120,
"size.class-count": 1
}
},
{
"type": "class",
"name": "App\\Service\\UserService",
"subject": "declaration:class:App\\Service\\UserService@src/Service/UserService.php",
"file": "src/Service/UserService.php",
"line": 10,
"metrics": {
"size.method-count": 8,
"size.property-count": 3,
"cohesion.lcom": 2,
"complexity.wmc": 35,
"coupling.ca": 5,
"coupling.ce": 12,
"coupling.cbo": 17,
"coupling.instability": 0.71
}
},
{
"type": "method",
"name": "App\\Service\\UserService::calculate",
"subject": "declaration:callable:App\\Service\\UserService::calculate@src/Service/UserService.php",
"file": "src/Service/UserService.php",
"line": 42,
"metrics": {
"complexity.ccn": 15,
"complexity.cognitive": 22,
"maintainability.halstead.volume": 384.5,
"size.loc": 35
}
}
],
"projectScope": {"state": "covered", "uncoveredAutoloadTargets": [], "unjudgedChannels": [], "unjudgedValues": [], "reasons": []},
"summary": {
"filesAnalyzed": 45,
"filesSkipped": 0,
"duration": 1.234,
"violations": 3,
"errors": 2,
"warnings": 1,
"info": 0
},
"outOfScope": null
}
Usage:
Note
The metrics format exports all collected metrics, not just those that triggered violations. This makes it useful for tracking metric trends over time, even for code that passes all rules.
Line counts at namespace and project level measure different lines
size.loc, size.lloc and size.cloc count different populations at the two aggregate levels. A namespace counts the lines inside its namespace statement; the project counts every analysed file in full, including the opening tag, file header comments and declare above the namespace. The project's size.loc.sum is therefore larger than the sum of the namespaces' size.loc, and it is not a rounding or aggregation error. Within the namespace tree the sums do add up: a namespace's size.loc.sum equals its own size.loc plus its children's size.loc.sum.
checkstyle¶
Checkstyle XML format. Widely supported by CI tools.
When to use: Jenkins, SonarQube, or any tool that accepts Checkstyle XML.
Checkstyle 3.0 XML: <file name="..."> with nested <error line="" severity="error|warning|info" message="" source="qmx.<rule>"/>.
Every finding is published. Source findings retain their files; an unlocated
namespace uses the common containing directory of its measured declarations,
and a project aggregate uses .. Line 1 anchors that directory and does not
claim a source line. Baseline audits name the baseline document inside the
project. Coverage failures retain their separate projection.
A --namespace/--class selection is refused with this format (exit 3): every
<error> is an error to its reader, so nothing could say the report lists only
part of the run.
Example output:
<?xml version="1.0" encoding="UTF-8"?>
<checkstyle version="3.0">
<file name="src/Service/UserService.php">
<error line="42"
severity="error"
message="Cyclomatic complexity is 15, max allowed is 10"
source="qmx.complexity.ccn"/>
<error line="87"
severity="warning"
message="Class has 22 methods, max recommended is 20"
source="qmx.size.method-count"/>
</file>
</checkstyle>
CI usage (Jenkins):
sarif¶
SARIF (Static Analysis Results Interchange Format) 2.1.0. A standard for static analysis tools adopted by GitHub, Microsoft, and many IDE vendors.
When to use: GitHub Security tab, VS Code (with SARIF Viewer extension), JetBrains IDEs, Azure DevOps.
SARIF 2.1.0 spec — runs[].results[] entries with ruleId, ruleIndex (position of the rule in tool.driver.rules), level (error/warning/note), message.text, partialFingerprints.primaryLocationLineHash, and locations[].physicalLocation.{artifactLocation.{uri,uriBaseId}, region.{startLine,startColumn}}. locations is not present on every result: a project-level finding with no source position (e.g. architecture.unreachable-layer) has no locations array at all.
runs[].invocations[0] reports executionSuccessful (see the coverage table below), and runs[].originalUriBaseIds declares the %SRCROOT% base referenced by every artifactLocation.uriBaseId, resolving it to the analyzed project root as a file:// URI. Every artifactLocation.uri is a percent-encoded relative reference (a space is %20, # is %23), encoded the same way as that base. A related location without a file carries its message and no physicalLocation.
runs[].tool.driver describes the tool itself: name, version,
informationUri (the documentation site, https://qualimetrix.dev), a
properties bag whose properties.llmsTxt is the index written for AI agents,
and a rules[] catalogue that every result's ruleIndex points into. The
address rides in properties because SARIF closes driver to keys it does not
define, and properties is its extension point. Each rules entry is
{"id": "...", "name": "...", "shortDescription": {"text": "..."}, "fullDescription": {"text": "..."}, "helpUri": "...", "defaultConfiguration": {"level": "..."}}.
Each runs[].invocations[] entry is
{"executionSuccessful": true, "toolExecutionNotifications": []}, and every
notification in it is
{"descriptor": {"id": "..."}, "level": "...", "message": {"text": "..."}} —
this is where a file that failed to parse is reported. runs[].originalUriBaseIds
maps %SRCROOT% to {"uri": "file:///path/to/project/"}.
partialFingerprints.primaryLocationLineHash matters beyond spec completeness:
it is what GitHub's code-scanning alert de-duplication keys on across uploads,
so an alert stays the same tracked alert (not a new one) as long as its
fingerprint is stable between runs.
Example output (abbreviated):
{
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "Qualimetrix",
"version": "0.26.0",
"informationUri": "https://qualimetrix.dev",
"properties": {
"llmsTxt": "https://qualimetrix.dev/llms.txt"
},
"rules": [...]
}
},
"results": [
{
"ruleId": "complexity.ccn",
"ruleIndex": 0,
"level": "error",
"message": {
"text": "Cyclomatic complexity is 15, max allowed is 10"
},
"partialFingerprints": {
"primaryLocationLineHash": "complexity.ccn:declaration:callable:App\\Service\\UserService::calculate@src/Service/UserService.php:4e6e45ba70fb46d4"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/Service/UserService.php",
"uriBaseId": "%SRCROOT%"
},
"region": {
"startLine": 42,
"startColumn": 1
}
}
}
]
}
]
}
]
}
CI usage (GitHub Actions):
- name: Run Qualimetrix
run: bin/qmx check src/ --format=sarif --no-progress > results.sarif
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
Results appear in the Security tab of your repository and as inline annotations on pull requests.
gitlab¶
GitLab Code Quality JSON format. Shows violations directly in Merge Request diffs.
When to use: GitLab CI/CD with Code Quality reports.
Array of objects with description, check_name, fingerprint, severity (critical/major/info), location.{path,lines.begin}. Severity mapping: error → critical, warning → major, info → info.
Every finding is published. location.path retains the source file or uses
the directory anchor described for Checkstyle above. Directory anchors use
line 1 and cannot link to a source line in the GitLab UI. Baseline audits name
the baseline document inside the project. Coverage failures retain their
separate projection.
A --namespace/--class selection is refused with this format (exit 3): every
entry is an issue in the merge-request widget, so nothing could say the report
lists only part of the run.
Example output (abbreviated):
[
{
"description": "Cyclomatic complexity is 15, max allowed is 10",
"check_name": "complexity.ccn",
"fingerprint": "a1b2c3d4e5f6...",
"severity": "critical",
"location": {
"path": "src/Service/UserService.php",
"lines": {
"begin": 42
}
}
}
]
CI usage (GitLab CI):
code_quality:
stage: test
script:
- bin/qmx check src/ --format=gitlab --no-progress > gl-code-quality-report.json
artifacts:
reports:
codequality: gl-code-quality-report.json
Violations appear inline in the Changes tab of your Merge Request.
github¶
GitHub Actions workflow command format. Produces inline annotations that appear directly in PR diffs when running in GitHub Actions.
When to use: GitHub Actions CI. Simpler setup than SARIF — no upload step needed.
Workflow command format: ::<level> file=<path>,line=<n>,title=<rule>::<message> (one line per violation). Mapping: info → ::notice, warning → ::warning, error → ::error.
Only title= appears on every line. A project-level finding has no source
position, so it is annotated as ::<level> title=<rule>::<message> — without
file= and line=, which GitHub then shows against the workflow run rather
than against a line in the diff.
Report diagnostics also use ::notice with only title=: selected populations
left unjudged (rule-population.incomplete), project scope (run.project-scope)
and findings outside a selection (drill-down.out-of-scope). These notices
describe the report without adding violations or changing policy exit status.
Example output:
::warning file=src/Service/UserService.php,line=87,title=size.method-count::Class has 22 methods, max recommended is 20
::error file=src/Service/UserService.php,line=42,title=complexity.ccn::Cyclomatic complexity is 15, max allowed is 10
CI usage (GitHub Actions):
Annotations appear directly on the changed lines in your pull request — no SARIF upload needed. Only errors cause a non-zero exit code by default.
Tip
Use --format=github for quick inline annotations. Use --format=sarif if you also want results in the GitHub Security tab.
health¶
Text table of health scores for terminal output. Shows each dimension with its score, status label, thresholds, and decomposition details.
Enabled builtin project dimensions remain visible with unavailable scores and explicit coverage. Missing decomposition values are null, distinct from numeric 0, and show their measured/eligible share. Text, summary and health also publish bounded authored-absence lines when there are no health scores and without logger output. Class and namespace selection never substitutes a project score for an absent local score.
When to use: Quick health check from CLI, AI agent workflows, pipeline diagnostics.
Key features:
- Tabular display of all health dimensions (complexity, cohesion, coupling, typing, maintainability)
- Status labels with color coding (green/yellow/red)
- Threshold visibility (warning and error levels)
- Decomposition breakdown for each dimension
- A
Coveragecolumn, and oneComputed over N of M ...line per dimension in the decomposition — the share of the subject that score speaks for (see What a Score Covers) - Supports
--namespaceand--classdrill-down
Cohesion contributors follow TCC when it participates, otherwise measured LCOM with its lower-is-better direction.
Worst contributors per dimension:
The health output includes worst contributors for each dimension -- the classes or namespaces that drag down each health score the most. Control the number of contributors shown with --format-opt=contributors=N (default: 3):
Usage:
bin/qmx check src/ --format=health
bin/qmx check src/ --format=health --namespace='subtree:App\Service'
html¶
Interactive treemap report with D3.js visualization. Generates a self-contained single HTML file with namespace/class hierarchy.
When to use: Project-wide visualization, stakeholder reports, team reviews.
Key features:
- Namespace/class hierarchy with LOC-proportional sizing
- Color-coded health scores per node
- Click to drill down into namespaces
- Detail panel with metrics, violations, and decomposition
- Project decomposition is prepared by PHP in
summary.healthDecomposition, with coverage insummary.healthCoverage; the viewer preserves null inputs and measured 0 without a JavaScript formula scorer - Health coverage beside each project health bar (
n/awhen coverage is undefined), from thesummary.healthCoverageobject the payload carries next tosummary.healthScores - Every violation of the report sits on a node of the tree, so the tree's counts agree with
summary.totalViolations: a file-level violation sits on the one class its file declares when the file opens no other namespace, else on the namespace its record names (for a file opening several, their deepest common namespace; for a namespace without a node, its nearest ancestor that has one); a violation with no such node — a project-level finding, a file-level one in a file whose namespaces share none or include the global one — is listed on the project root; a global function, and a file opening only the global namespace, sit on the(no namespace)node - The report is named after the analysed project:
--format-opt=project-name=..., else thenamein itscomposer.json, else its directory name - Self-contained single HTML file (no external dependencies)
Usage:
Example workflow:
# Generate and open the report
bin/qmx check src/ --format=html -o report.html
open report.html # macOS
xdg-open report.html # Linux
Note
The -o (output) flag is recommended with html format. Without it, HTML content is written to stdout.
suppressed¶
Machine-readable JSON composition of what a run held back from its report and
why. A separate format rather than a section of json: an ordinary check
payload never changes shape for a feature you did not ask for, no matter which
format you selected it with.
When to use: Auditing why an expected finding is missing, reviewing what a
qmx.yaml exclusion actually silences, spotting a dead suppress_paths/
suppress_namespaces entry (a typo'd path, a file the project deleted).
Capture is armed the same way by two independent routes — passing
--show-suppressed, or selecting --format=suppressed itself, including via
format: suppressed in qmx.yaml. Either route arms the same per-rule
exclusion capture, so the counts each surface reports for that mechanism never
disagree.
The two surfaces are not otherwise equivalent. --show-suppressed on
--format=text prints inline @qmx-ignore suppressions and per-rule
exclusions as prose. Global path-suppression and namespace-suppression appear
there only as -v counts, not per finding; baseline and git-scope
removals are not listed at all; and there is no text equivalent of
neverMatched. suppressed is the only surface that publishes all eight
mechanisms as individual findings.
The composition is a multiset, not a set of findings. One finding can be
removed by more than one mechanism — for example, a finding an inline
@qmx-ignore would suppress may already have been removed earlier by a
namespace exclusion. There are eight mechanisms: suppression (inline
@qmx-ignore/@qmx-ignore-file/@qmx-ignore-next-line), path-suppression and
namespace-suppression (global suppress_paths/suppress_namespaces),
baseline (the accepted-level ceiling), git-scope (--report=git:*
narrowing), and the two halves of the per-rule exclusion ledger configured
under rules: {<rule-name>: {...}} — rule-namespace-suppression and
rule-path-suppression, plus produced-finding selection removals. byMechanism counts entries per mechanism; because the
same finding can appear under more than one, those counts do not sum to
the number of distinct findings suppressed — the format's own note field
says so.
A separate neverMatched list reports configured suppressors that excluded
nothing this run: without it, a stale suppress_paths entry pointing at a
deleted file is indistinguishable from one that was never written.
meta is the same block json carries, including docs and llmsTxt.
Top-level keys: meta, note, coverage (the same object json
carries, so an audit of an incomplete run says so), projectScope (the same
object json carries, so an audit of a narrowed run names the suppression
channels it did not judge), mechanisms (all eight,
always present), byMechanism (count per mechanism, including zero),
suppressed (the multiset), neverMatched, and notRun (producer metadata).
Each suppressed entry carries the identity json publishes — channel
(here the finding's code), subject, occurrence, edge — so it can be
joined to a json record by machine, and the finding's message and
recommendation under the same keys as json. It does not carry metricValue,
threshold, techDebtMinutes or acceptedLevel: the format audits what held
a finding back, and the identity leads to the finding's own record.
Example output (abbreviated, from this project's own self-analysis):
{
"meta": {
"version": "dev-main",
"package": "qmx",
"timestamp": "2026-08-29T09:14:02+00:00",
"docs": "https://qualimetrix.dev",
"llmsTxt": "https://qualimetrix.dev/llms.txt"
},
"note": "suppressed is a multiset of mechanism x finding, not a set of findings: one finding can appear under more than one mechanism, so byMechanism counts do not sum to the number of distinct findings suppressed.",
"coverage": {
"complete": true,
"discovered": 1204,
"analyzed": 1204,
"generatedExcluded": 0,
"excluded": 0,
"failed": 0,
"failures": []
},
"projectScope": {"state": "covered", "uncoveredAutoloadTargets": [], "unjudgedChannels": [], "unjudgedValues": [], "reasons": []},
"mechanisms": [
"suppression",
"path-suppression",
"namespace-suppression",
"baseline",
"git-scope",
"rule-namespace-suppression",
"rule-path-suppression",
"selection"
],
"byMechanism": {
"suppression": 12,
"path-suppression": 0,
"namespace-suppression": 0,
"baseline": 0,
"git-scope": 0,
"rule-namespace-suppression": 58,
"rule-path-suppression": 131,
"selection": 0
},
"suppressed": [
{
"mechanism": "suppression",
"suppressor": "src/Infrastructure/Ast/CachedFileParser.php:15",
"rule": "code-smell.empty-catch",
"channel": "code-smell.empty-catch",
"subject": "aggregate:file:src/Infrastructure/Ast/CachedFileParser.php",
"occurrence": "6f1c0e9b2a4d7e35",
"edge": null,
"file": "src/Infrastructure/Ast/CachedFileParser.php",
"line": 73,
"symbol": "src/Infrastructure/Ast/CachedFileParser.php",
"severity": "error",
"message": "Empty catch block detected - exceptions should not be silently ignored",
"recommendation": "Log or rethrow the exception, or handle it explicitly. A comment alone does not clear this finding; suppress an intentional ignore with `@qmx-ignore code-smell.empty-catch` and a reason."
},
{
"mechanism": "rule-path-suppression",
"suppressor": "code-smell.constructor-overinjection",
"rule": "code-smell.constructor-overinjection",
"channel": "code-smell.constructor-overinjection",
"subject": "declaration:callable:Qualimetrix\\Analysis\\Run\\Contract\\Collection\\SuccessfulFileProcessing::__construct@src/Analysis/Run/Contract/Collection/SuccessfulFileProcessing.php",
"occurrence": null,
"edge": null,
"file": "src/Analysis/Run/Contract/Collection/SuccessfulFileProcessing.php",
"line": 28,
"symbol": "Qualimetrix\\Analysis\\Run\\Contract\\Collection\\SuccessfulFileProcessing::__construct",
"severity": "warning",
"message": "Constructor of SuccessfulFileProcessing has 8 parameters (threshold 8). Consider using a parameter object or splitting responsibilities",
"recommendation": "Constructor parameters: 8 (threshold: 8) — consider splitting responsibilities"
}
],
"notRun": [],
"neverMatched": [
{
"mechanism": "rule-path-suppression",
"suppressor": "coupling.cbo: src/Analysis/Evidence/Design/*Visitor.php"
}
]
}
For the two ledger mechanisms (rule-namespace-suppression,
rule-path-suppression), suppressor names the producer rule; for
path-suppression/namespace-suppression it is the matched configured pattern;
for suppression it is the directive's file:line; for baseline it is the
accepted entry's description; for git-scope it is the configured git
reference.
Usage:
Directive audit JSON¶
bin/qmx directives --format=json publishes meta, scope,
selection, sweep, directives, summary and exit_code.
Each directive carries file, tag line, form, target, effect,
reason, masked_by, boundary_observable and required refusals.
Effects are effective, overrun, inert, unmeasured and refused;
reason is for Unmeasured (disabled producer or masked threshold).
A refused site has a nonempty [{channel, message}] list; every other effect
has []. Internal physical position and addressed producer are not published.
Two identical comments on one line may therefore yield two equal public rows.
Same-rule same-line threshold overrides still coalesce.
summary counts all five effects, including refused. A refusal remains
visible even when selection prevents its channel from moving exit_code.
Incomplete analysis returns 4 before any refusal/inert decision; otherwise a
publishable refusal or observable inert directive returns 2, and the remainder
returns 0. See the command reference.
For --format=suppressed, Inline supplies the first directive that actually
matched; Reporting does not infer placement again. The public suppressor
is still file:line, so distinct physical sites on one line share that label.
Documentation addresses in JSON reports¶
Each JSON report in the table below names where its documentation lives, so a
script or an AI agent that has only the output can find the rest: docs is the
documentation site and llmsTxt is the index written for AI agents.
| Document | Where the addresses are |
|---|---|
check --format=json |
meta.docs, meta.llmsTxt |
check --format=suppressed |
meta.docs, meta.llmsTxt |
check --format=metrics |
Top-level docs, llmsTxt, beside version (the export format) and toolVersion |
check --format=sarif |
runs[].tool.driver.informationUri and runs[].tool.driver.properties.llmsTxt |
directives --format=json |
meta.docs, meta.llmsTxt |
baseline:rename-channels --format=json |
meta.docs, meta.llmsTxt |
debug:layer-assignment --format=json |
meta.docs, meta.llmsTxt |
graph:export --format=json |
meta.docs, meta.llmsTxt, beside its own meta.version (the graph format) |
The three commands outside check open their document with the same meta
object json does — version, package, timestamp, docs, llmsTxt — ahead
of the keys that are their own. graph:export --format=json extends the same
meta block its envelope already carried, keeping its own version (the graph
format, not the tool's) the way metrics keeps its own.
Not every JSON output carries the addresses. gitlab is a bare array with no
object to hold them; graph:export's DOT output has no envelope at all; a
refusal is always exactly
{"error": ..., "exit_code": ..., "position": ..., "source": ...},
position being null unless the refusal was raised at a place in a
configuration document — a command-line value, a whole-file refusal or a
synthetic merged-input refusal carries null even when the message names a key.
A file's invalid memory_limit, fail_on or parallel.workers retains its
writing layer and document position.
When present, position locates the refused spot as the check found it: for a
required key that was left out, path ends at that key and written names it;
for a value outside a fixed list (format, fail_on, a rule option's words),
written is the value, accepted the list and closed is true.
source lists the configuration layers the refusal is about, lowest precedence
first — one for a value a single layer wrote, every contributing layer for a
constraint between keys — each as {"kind": ..., "name": ..., "imported_by": ...}.
kind is defaults, composer, preset, file, cli, baseline or
resolved; name is the preset name, file path or option. resolved is what a
refusal names when its owner has no authored provenance; its name is the
key when known, otherwise null. source is null for an internal error or a
refusal raised without a configuration source. Text refusals name the same
authored layers in their message or a separate Source: line, including under
--quiet; a synthetic merged origin is not labeled as an author.
imported_by is null for a layer that was not imported. For a file or a
built-in preset named by imports it is an object of the same shape — the file
that imported it — and it nests: a file imported by a file imported by
qmx.yaml carries two levels. A built-in preset imported by name is
{"kind": "preset", "name": "strict", "imported_by": {"kind": "file", "name": "qmx.yaml", "imported_by": null}};
the same preset given with --preset has no importer.
The baseline
file — written by baseline:generate, update, cleanup, and rewritten in
place by baseline:rename-channels — is a versioned input artifact the tool
reads back, with its own schema, not a report.
Selected rule populations¶
A rule can decline to judge a selected subject: a required metric was not published, its native exclusion applies, or the evidence needed to judge it is unknown. This differs from a healthy subject that was judged and produced no finding. Disabled and unselected channels contribute no population records.
Every successful check JSON document contains abstentions, including [].
Each nonempty group has producer, channel, level, gate, reason,
unit, count, examples and dataGap. The first failed declared gate owns the group;
examples are at most five sorted distinct canonical identities. Counts describe
that gate's native unit, such as declarations, namespaces, cycles or configured
selectors; do not add different units into a subject total. Independent analysis
calls retain separate populations when combined; adopting the same frozen
population twice does not double its counts.
dataGap is true only when missing evidence prevents judgement. Subjects
outside the rule's defined population remain in this metadata with a false
flag. Compact notices and banners include only groups whose flag is true.
| Format | Selected subjects left unjudged |
|---|---|
text, summary, health |
A compact indication at normal verbosity and bounded details under -v, including without findings |
json |
Full top-level abstentions groups, at normal and quiet verbosity and in output files |
sarif |
Invocation note with descriptor QMX-RULE-POPULATION-INCOMPLETE |
github |
A ::notice title=rule-population.incomplete:: line |
html |
A visible banner and full abstentions payload |
These records do not create violations, change severities or the policy exit
code, consume baseline ceilings, or increase suppression counts. They remain
separate from computedMetricOutcomes, analysis-file coverage and health-score
coverage. Metrics, Checkstyle, GitLab, suppressed reports and baselines receive
no synthetic abstention findings. Quiet and silent successful answers remain available on stdout;
writing a report file still preserves its format's population information.
Incomplete inheritance depth¶
Collected DIT can be a numeric lower bound when an ancestor cannot be read, or
have no finite depth when ancestry reaches a cycle. This is measurement
completeness, separate from selected-rule abstentions, authored
computedMetricOutcomes, findings and analysis-file coverage. Disabling
design.dit or narrowing the finding display does not remove collected outcomes.
JSON and HTML payloads always contain inheritanceDepthOutcomes, including
[]. Each nonempty group has status (lower-bound for unresolved numeric DIT,
absent-depth for unresolved declarations without numeric DIT), count and
examples. Counts cover distinct canonical exact declaration subjects;
examples retain file and declaration identity and contain at most five sorted
unique subjects per group. Declarations without collected unresolved evidence
are excluded; an empty list does not prove that uncollected ancestry is complete.
| Format | Publication |
|---|---|
json |
Top-level inheritanceDepthOutcomes, including normal and quiet output |
html |
The same payload and a visible banner |
sarif |
Invocation note with descriptor QMX-INHERITANCE-DEPTH-INCOMPLETE and the same groups in notification properties |
text, summary, health |
Bounded explanation, including reports without findings or health scores |
checkstyle, gitlab |
Ordinary findings only; no synthetic measurement finding |
metrics |
Existing design.dit and design.dit-unresolved evidence |
These outcomes do not change severity, technical debt or the exit verdict. A lower bound remains numeric; an absent depth must not be treated as zero.
Analysis coverage in every format¶
An entry ends analyzed, intentionally authored/generated excluded, or failed.
excluded counts named authored entries outside discovered. The latter sums
analyzed PHP, generatedExcluded PHP and failed selected terminal entries;
failures can name directories, links or special entries as well as PHP files.
Removing a directory does not count its unseen PHP descendants. Exclusion retains completeness; any failure
closes it. A complete intentionally empty set (analyzed=0, failed=0,
excluded + generatedExcluded > 0) differs from a truly undiscovered empty tree.
Incomplete exit 4 takes priority over success and policy findings. check
retains the selected diagnostic report, whose policy result is not authoritative.
| Format | Coverage representation |
|---|---|
summary |
Human coverage sentence after the header |
text |
Human coverage sentence after the violation summary |
health |
Human coverage sentence after the header |
json |
Top-level coverage object: complete, discovered, analyzed, generatedExcluded, excluded, failed, failures[] |
metrics |
The same top-level coverage object as json |
sarif |
runs[0].invocations[0].executionSuccessful; failures in toolExecutionNotifications[] |
gitlab |
One blocker issue per failed file with check_name: analysis.<kind>; a complete empty run is [] |
checkstyle |
Failed files are errors under synthetic file [analysis], with source qmx.analysis.<kind> |
github |
One ::error annotation per failed file; complete zero-finding runs emit no annotation |
html |
Embedded coverage data; incomplete runs also show a visible warning banner |
suppressed |
Top-level coverage object: complete, discovered, analyzed, generatedExcluded, excluded, failed, failures[] |
For json and metrics, each failures[] item has path, kind
and message. Human formats report measured counts without claiming every named
path was excluded when another root was merely empty.
kind |
Entry |
|---|---|
parse |
PHP with a syntax error |
processing |
PHP measurement failed |
unreadable-file |
Source snapshot could not be obtained before parser invocation |
directory-symlink |
A directory link met inside the walk and not followed |
not-regular-file |
A non-regular PHP entry: FIFO, socket, device or disallowed walked file link |
unreadable-directory |
A directory that cannot be listed |
Skips name the entry that never became a unit of analysis. An unknown kind
means incomplete input rather than a reason to discard the whole document.
Project scope in every format¶
Reports publish final measurement combining the captured Composer universe and actual entry selection. The initial target state before discovery is not the result: omitting an empty directory alone does not establish missing PHP.
state remains covered, narrowed, unknown or unmeasured. This enum
represents evidence rather than granting the same permission to every audit.
Two measured questions have separate answers:
- Declaration absence. Missing PHP, authored PHP removal, generated exclusions and an uncertain universe withhold namespace, class or method-name absence claims.
- Selector/path completeness. PHP-path completeness and universe certainty
are checked separately. Authored/generated removal are not inputs to this
question. A bound selector remains
Removedwhen the answer is withheld.
Seven channels ask the first question:
architecture.empty-template, architecture.unmatched-exclude,
architecture.unmatched-type, architecture.unreachable-layer,
cohesion.unmatched-exclude-method, coupling.unmatched-framework-namespace,
suppression.unmatched-namespace.
discovery.unmatched-exclude, suppression.unmatched-path and
suppression.unmatched-rule-ledger use the second; namespace values under the
last also ask the first. There are ten channels in total.
For suppression.unmatched-namespace and namespace values under
suppression.unmatched-rule-ledger, a literal (exact/subtree) value is judged
despite excluded PHP or generated removal when the namespaces those files declare
(read by a token scan of their namespace statements, or by a text scan that may withhold more for files over 64 KiB) cannot match it. Everything
else about the first question is unchanged, and the other six channels remain
all-or-nothing. See
Excluded and generated code.
A complete named PHP roster can cover a known universe. An observed regular
.php outside selection is missing PHP, even when an exclude matches it. An
excluded directory outside selection hides unseen descendants: named
incomplete-universe evidence withholds both questions without listing or PHP
search there. Assets and special entries are not inferred missing PHP. The
whole root can establish completeness without usable declared autoload code;
an arbitrary subset cannot.
An omitted-composer-root reason retains cause and visitedLevels. Its
candidate, startDirectory and lastDirectory fields appear only for paths
inside the project, relative to its root. An absent main manifest contributes
no omitted-root reason.
| Format | Project scope representation |
|---|---|
json, metrics, suppressed |
Top-level projectScope object in every document |
sarif |
Invocation notification with descriptor QMX-RUN-PROJECT-SCOPE |
github |
::notice title=run.project-scope:: line |
html |
Banner above the report |
summary, text, health |
A Project scope … line |
gitlab, checkstyle |
No scope entry: every entry is a finding to their consumers |
The scope notice in sarif, github, html, summary, text and health
names at most the first three skipped values, followed by a count of the
remaining values. The projectScope.unjudgedValues array in json, metrics
and suppressed lists every skipped value.
The object retains five fields: state, uncoveredAutoloadTargets[],
unjudgedChannels[], unjudgedValues[], reasons[]. Each skipped value has
{channel, option, pattern}. unjudgedChannels contains only channels with no
judged value; a partially judged channel may be absent while its skipped values
remain with their channel. An empty channel list therefore does not prove
that every value was judged: suppression.unmatched-namespace is absent as soon
as one of its values was judged. Reasons retain kind and named cause fields,
including removals and uncertain locations. Auxiliary Composer issues explain
ancestry limits without closing main-project coverage. Accepted PSR-4 facts still
place namespace values independently of the enum.
See Suppression rules.
When removed code (an exclusion or generated filtering) alone leaves literal
namespace suppression values unjudged, check also warns on stderr (silenced by
-q), naming the exclusions, the number of values and their channels. directives
and baseline:* print no such warning. Of those two only directives publishes a
projectScope object; baseline:* publish none.
For a selector hidden by another source, the skipped value keeps its three keys.
An Exclude reason links selector to coveredBy, names only hider sources
absent from that selector's origins, and supplies rerun advice. This explains
withholding without turning it into a finding or proof of remediation.
Unavailable search metadata inside an actual removed run entry also withholds
only declaration absence, with the actual unlistable evidence retained.
An exclude, suppress_paths, suppress_namespaces or suppress_namespace_channels
entry that matches nothing and that only files a Composer package installed wrote
keeps its three keys in unjudgedValues, and one
installed-configuration reason per such file names it as source with a
detail sentence (see Suppression rules).
Comparison table¶
| Format | Readable | Machine | Grouping | CI Integration |
|---|---|---|---|---|
summary |
Best | No | Health scores, drill-down | Any (exit code) |
text |
Good | Parseable | --group-by |
Any (exit code) |
json |
No | Yes | Built-in (by file) | Custom scripts |
metrics |
No | Yes | Built-in (by symbol) | Custom scripts, dashboards |
checkstyle |
No | Yes | Built-in (by file) | Jenkins, SonarQube |
sarif |
No | Yes | Built-in | GitHub, VS Code, JetBrains |
gitlab |
No | Yes | Flat list | GitLab MR widget |
github |
No | No | Flat list | GitHub Actions annotations |
health |
Good | No | Health dimensions | Quick checks, CI |
html |
Interactive | No | Treemap hierarchy | Reports, reviews |
suppressed |
No | Yes | Flat multiset by mechanism | Suppression auditing |
Exit codes¶
All formats use the same exit codes:
| Exit code | Meaning |
|---|---|
| 0 | No violations (or only warnings, under the default --fail-on=error) |
| 1 | At least one warning, under --fail-on=warning |
| 2 | At least one error-severity violation |
| 3 | Configuration, input or environment refusal |
| 4 | Analysis incomplete or coordinator OOM; policy result is not authoritative |
5 |
Internal tool error |
By default (--fail-on=error), warnings no longer cause exit code 1 — only errors trigger a non-zero exit. Use --fail-on=warning for the stricter behavior where warnings also fail. Exit 4 takes precedence over warning/error policy codes. Text, summary and health list up to ten unprocessed files, then say “and N more.” Worker stdout/stderr are captured rather than passed through raw; a worker death reason names its file. A bound coordinator OOM uses a JSON error envelope under JSON formats. graph:export with no PHP files writes an empty graph, exits 0 and explains the empty input on stderr.
A run is incomplete when it could not read part of the tree it was pointed at: a file it failed to parse, a directory it may not list, a symbolic link to a directory found inside the tree, or a *.php entry that is not a regular file. Such an entry used to be dropped silently, so these runs used to answer 0 or 2.
A symbolic link named as a scanned path is the one exception: qmx check src/linked analyzes what the link points at and the run is complete, while the same link met while walking src/ is reported as an entry that was not read. Naming a path is a request to analyze what is behind it; a link met inside a tree was never asked for, and following it would change which files the run measures, could leave the project root, and would not terminate on a cycle.
Note
All check diagnostics outside the selected report payload (configuration notices and errors, deprecation messages, logging, and output-file notices) are written to stderr, not stdout. This means you can safely pipe the analysis output to a file or another tool without interference: bin/qmx check src/ --format=json > results.json.
Selection removals and skipped producers¶
selection is the eighth suppression mechanism. It contains actual produced
findings removed by final publication selection. byMechanism.selection counts
only these multiset entries, never the producers that did not run.
notRun is always an array. Each entry has producer, reason (disabled or
filtered), the decisive statement, and its layer description. A skipped
producer contributes no fabricated finding or suppression count. A disabled
channel may still have a live sibling, so producer notRun and channel removal
are separate outcomes. The standard suppressed identity and multiset rules stay
unchanged; drill-down and formatter truncation remain presentation, not selection.
Directive JSON keeps selection.only and selection.disabled as string lists.
Only the effective filter and all tied decisive disabling texts are published;
repeated cells are deduplicated and lower disables canceled by a later enable
are omitted. qmx rules prints actual selection writer origins/layer indices on
its separate human listing; these do not silently change the directive JSON shape.
Baseline entry audit¶
EntryBinding\UnusedEntryAudit emits baseline.unused-entry project-level
Warnings for stale and inert entries after the full ceiling and before Git
projection. The rule's remediation estimate is 5 minutes. Audit findings never
enter the measured set, capture or accept-new; authored path/namespace
suppression and Git projection cannot hide them. When unselected, stderr reports
counts only. Uncompared entries likewise produce count diagnostics, not path dumps.
Nine finding formats publish the audit; Metrics, Health and Suppressed retain
their own subjects. All twelve preserve the ordinary failure policy: an isolated
audit warning exits 0 by default, with --fail-on=error or none, and 1 with
--fail-on=warning. Incomplete analysis has priority and exits 4.