Skip to content

Output Formats

Qualimetrix supports 11 output formats. Choose the one that fits your workflow.

bin/qmx check src/ --format=<format>

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 classes lists, 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 impact section: 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 a line in --format=json.
  • A project-level finding (no owning file at all — e.g. an architecture.unreachable-layer finding) 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=text prints 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.

{
    "violationGroups": {
        "App\\Service\\UserService": {
            "count": 3,
            "violations": [...]
        }
    }
}

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:

bin/qmx check src/ --format=json --no-progress > report.json

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:

bin/qmx check src/ --format=metrics --no-progress > metrics.json

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):

bin/qmx check src/ --format=checkstyle --no-progress > checkstyle.xml

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):

- name: Run Qualimetrix
  run: vendor/bin/qmx check src/ --format=github --no-progress

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 Coverage column, and one Computed 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 --namespace and --class drill-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):

bin/qmx check src/ --format=health --format-opt=contributors=5

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 in summary.healthCoverage; the viewer preserves null inputs and measured 0 without a JavaScript formula scorer
  • Health coverage beside each project health bar (n/a when coverage is undefined), from the summary.healthCoverage object the payload carries next to summary.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 the name in its composer.json, else its directory name
  • Self-contained single HTML file (no external dependencies)

Usage:

bin/qmx check src/ --format=html -o report.html

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:

bin/qmx check src/ --format=suppressed --no-progress > suppressed.json

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 Removed when 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.