Skip to content

feat(jvm): pom.xml parser — literal versions and same-file properties only - #95

Open
justin13888 wants to merge 23 commits into
masterfrom
feat/84-pom-xml-parser
Open

feat(jvm): pom.xml parser — literal versions and same-file properties only#95
justin13888 wants to merge 23 commits into
masterfrom
feat/84-pom-xml-parser

Conversation

@justin13888

@justin13888 justin13888 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes #84.

The second declarative JVM slice. It reuses everything #82 built — Ecosystem::Jvm,
MavenCentralFetcher, semver/maven.rs, the [jvm] config — and adds only a parser.

What it reads

  • <dependencies> directly under <project>, with literal <version> elements.
    <dependencyManagement>, <build><plugins>, and <profiles> are deliberately not
    read: none of the three is a dependency of the project as written.
  • ${property} interpolated against <properties> in the same file, including a
    ${a}${b} → literal chain (bounded, so a cycle terminates).
  • Byte spans on the version text, so --fix rewrites in place. This is the one
    structural difference from the csproj precedent: a csproj version is an attribute
    (Attribute::range_value), a POM version is element text, so the span comes from
    Node::range() on the text node, trimmed of the whitespace a pretty-printed POM
    puts around it.
  • <scope> and <optional>, which are stated rather than guessed: testDev,
    <optional>true</optional>Optional, systemPackageSource::Local (a jar
    at a path on this machine, which no registry has heard of).
  • The POM's own coordinate as its project identity, spelled groupId:artifactId — the
    same string a dependency on it would use.

Following the Gradle catalog's rule, a property used by exactly one dependency carries
the <properties> span that governs it and is rewritable; one shared by several is
PackageSource::Inherited — resolved and checked, never written to — because a single
line cannot be rewritten to two different versions.

Out of scope

<parent> inheritance, <dependencyManagement>, and BOM imports. Resolving any of
them correctly can require fetching the parent POM from a registry, which makes it a
resolution engine rather than a parser — a separate decision, not a bigger version of
this one. Maven's built-in properties (${project.version}, ${revision}) are not
<properties> entries and are not resolved either.

The required behaviour, and where it landed

The issue requires that a POM whose version cannot be resolved be reported as unread
rather than skipped silently.

The unreadable_manifests notice surface #82 added does not fit: it is a directory
scan keyed on a file name, and a pom.xml is perfectly readable — it is individual
entries within one that are not. Rather than build a second mechanism, an unresolvable
dependency is reported through the vocabulary that already exists for exactly this
state: PackageSource::Inherited with an empty constraint, which is the shape a Cargo
member's unresolved dep.workspace = true already has. It is not checkable, has no
position, is never fixed, and output/list.rs already renders it as (unresolved).
The Cargo-specific warnings around that shape (undeclared_inheritance,
report_inherited_skips) are both gated on a workspace root, and PomXml has none —
so a POM gets a manifest-level warning of its own instead. See Review repairs.

The alternative — the csproj parser's if value.contains('$') { continue } — would
report a POM that inherits half its versions as depending on only the other half.

Stacking

Stacked on #91 (issue #82), which is itself stacked on #90. Base is
feat/82-gradle-version-catalogs. Merge order: #90#91 → this.

Review repairs

An independent review found one High and three Medium defects, plus four Low. All are
repaired here except the two recorded under Unresolved review notes below. The base
branch was merged in first (feat/82-gradle-version-catalogs, 4 commits, no conflicts).

A second review round found one further High and four further Mediums, all repaired —
see Second review round at the end of this section. The repaired base was merged in
first again (feat/82-gradle-version-catalogs at ba58e02, 5 commits, no conflicts).

The new status: DependencyStatus::Undetermined

High. evaluate_item sent every non-checkable, non-git item to
DependencyStatus::Local through a wildcard arm, so PackageSource::Inherited with an
empty constraint landed there. On a <parent>-inheriting POM — the dominant real-world
shape, along with same-file <dependencyManagement> — that produced a table of local
rows:

org.springframework.boot:spring-boot-starter-web   —  —  local
org.springframework.boot:spring-boot-starter-test  —  —  local
Totals: 2 skipped

local is what this tool prints for a Cargo path = "../x" and a Maven
<scope>system</scope> jar, and it means one thing: there is no registry behind this
package
. Said of spring-boot-starter-web, which is on Maven Central, it is a plain
false statement. list called the same entry (unresolved), so the two commands
contradicted each other, and "status": "LOCAL" was the wrong token for a CI consumer
to read.

DependencyStatus::Undetermined — label undetermined, token UNDETERMINED — means
whether this dependency is current could not be determined, and no claim is made
either way
. It is deliberately named for that fact rather than for Maven, and it is
distinct from all three of its neighbours on purpose:

status says
Local there is no registry for this package
Error the registry was asked and the request failed
UpToDate this package is current
Undetermined nothing was established, and nothing is claimed

Two situations produce it. The manifest names a real package but states no version this
tool can resolve — a POM deferring to <parent>, <dependencyManagement>, or an
undeclared property; a Cargo member inheriting a name its root never declares. Or the
ecosystem publishes no registry to compare against at all, so currency is not merely
unread but unknowable.

#85 (Swift) is expected to reuse this variant verbatim for that second case. Nothing
about it is Maven-shaped: it carries no ecosystem, no manifest kind, and no assumption
that a version exists somewhere to be found. The one thing it does expect of a producer
is that the run says why alongside it — see the warning below.

It is threaded through check's table (its own colour, not the muted one the skipped
statuses share), --format json (per-result token plus an additive
summary.undetermined counter), --format text, dependable-report's summary
counters, SARIF, the HTML badge, and the TUI's detail pane. Each surface was checked
rather than left to a _ => arm.

--fail-on: undetermined is clean under two gates and not under any

Medium. --fail-on any exited 0 over a POM where zero versions had been read,
because Local was on the clean list. The decision, and why:

  • --fail-on vulnerable — clean. The gate asks whether anything is vulnerable. An
    unread version answers neither yes nor no, and failing a build that asked about
    vulnerabilities because a POM defers to its <parent> would make the flag mean
    something other than what it says.
  • --fail-on outdated — clean. Same reasoning: the gate names a specific finding,
    and this is the absence of a finding rather than one.
  • --fail-on any — not clean, exits 1. This gate asks the general question: is
    every dependency checked and current. A dependency whose version was never read is not
    current; it is unestablished, and exiting 0 asserts something the run never
    determined. It is grouped with the failures rather than with Local and Git, which
    are clean because they were skipped on purpose — there is no registry behind them,
    so there is nothing a stricter run could ever learn.

An unreadable version is admittedly not a vulnerability, and failing silently on one
would be its own defect. So the failure never travels alone: check now emits a
manifest-level warning naming the dependencies and where their versions come from
("N dependencies take their version from a <parent>, <dependencyManagement>, or a
property this file does not declare … nothing was checked: a:b, c:d"). Before this,
stderr was empty. It stays separate from Cargo's undeclared_inheritance, because a
Cargo member inheriting a name its root never declared is a manifest Cargo refuses to
build, while a POM deferring to its <parent> is ordinary and valid — the same status,
two different things to tell a reader.

The totals line counts undetermined apart from skipped for the same reason.

A <profiles> block is announced, not parsed

Medium. A POM with all its dependencies inside <profiles> listed as
(0 dependencies). Excluding conditional dependencies stays the decision — a profile
applies on a JDK version or an activated property, so its dependencies are not this
project's as written. Being silent about it is what made a list read as complete when it
was not.

ParsedManifest gains notices: what a parser saw and deliberately did not interpret,
in its own words. They go on stderr, where list and check both already put
manifest-level notes, so the same sentence reaches a reader whichever --format they
chose and no machine-readable document changes shape. The POM parser is the only one
with anything to declare so far.

--fix no longer moves a version it never counted

Low. Sole ownership of a <properties> line was decided by counting references from
the one top-level <dependencies> list. A property that list reads once but a
<profiles> block, a <dependencyManagement> entry, or a plugin <version> also reads
was still marked rewritable. Demonstrated: <lib.version>32.1.3-jre</lib.version> read
by top-level guava and by profile-only guava-gwtfix --all printed "Updated 1
dependency" and silently moved guava-gwt, never fetched, never validated, never named
in the fix record.

Ownership is a fact about the line, not about <dependencies>, so the count now walks
the whole document — the same rule count_version_refs applies in gradle_catalog.rs
for a [versions] alias a [plugins] entry shares. A <properties> value that is
entirely one ${…} is a chain link rather than a reader and is skipped, or no chained
property could ever be rewritten; a composed value is a reader and is counted.

Two smaller repairs

  • A version split by a comment is read whole. text_of took the first text node and
    dropped only the span when there were several, so <version>1.0<!--x-->.0</version>
    yielded the constraint 1.0 — a version the file never declares — and reported it as
    checkable, whereupon dependable queried Maven Central and evaluated against it. Every
    text node is now concatenated, which is the value Maven itself reads. The sibling
    spellings were already correct and are now pinned by a test: 1.0&#46;0 and a CDATA
    section both yield 1.0.0 with the span dropped.
  • MAX_PROPERTY_HOPS follows the number it documents. The loop spent one iteration
    arriving at the first property, which is not a hop, so an eight-hop chain that
    terminates returned nothing. Fails safe, but the constant and the code disagreed about
    what the number meant; both now say the same thing.
  • source and inherited no longer contradict each other. list --format json
    emitted "source": "inherited" beside "inherited": false, because the boolean was
    filled in only from Cargo workspace resolution. Both describe one fact, so the boolean
    is read from the source. No schema change. The PackageSource::Inherited,
    Item::is_checkable, and Item::has_position doc comments, which still described a
    Cargo workspace = true and nothing else though three parsers now emit the variant,
    say what the variant means instead.

Tests

Every repair has a regression test that was confirmed to fail with the repair reverted
and the test kept. fixture_maven.rs previously exercised list --format json only —
which is exactly why the check mislabel went unseen, since list renders the parser's
output directly and the mislabel happened a stage later. It now drives the binary end to
end against a <parent>-only POM and a same-file <dependencyManagement> one,
hermetically: the JVM registry points at a port nothing listens on, so nothing under
test depends on what a registry would have said. A <scope>system</scope> jar is
asserted to still be LOCAL and still clean, pinning the "no registry" / "nothing read"
distinction from both sides.

Second review round

The repaired base (ba58e02) was merged in by merge commit before any of this;
git merge reported no conflicts.

A dependency whose <groupId> cannot be resolved was dropped silently

High. <groupId>${project.groupId}</groupId> is the standard idiom for a sibling
module in a multi-module build, and it names a Maven built-in this file does not state.
The coordinate could not be resolved, so the whole entry was dropped — a POM declaring
three dependencies, two of them siblings, listed as depending on one, with empty stderr.
A missing <groupId> element behaved the same.

That is the silent omission this PR exists to prevent, reached through the other half of
the coordinate, and the asymmetry sat inside one function: an unresolvable <version>
was reported anyway with an empty constraint, an unresolvable <groupId> deleted the
entry even though artifactId was known.

Each half is now read as text first and resolved second: the resolved value where this
file states one, the literal as written where it does not. Either half unresolved means
no constraint either, since a coordinate this file cannot state is one nothing can be
fetched for — so the entry lands in Undetermined, beside the parent-deferred ones.
Only a <dependency> naming neither half is skipped, having stated nothing to report.

Tests: a_group_this_file_cannot_resolve_is_reported_not_dropped and
a_missing_coordinate_half_is_still_reported_under_the_other in pom_xml.rs,
a_group_this_file_cannot_resolve_is_still_listed end to end in fixture_maven.rs
(3 dependencies listed, 2 UNDETERMINED). a_coordinate_may_be_stated_by_property
codified the old drop and was updated to assert the report.

A detached Cargo package failed --fail-on any with nothing on stderr

Medium, regression. Reporting an unread version as Undetermined rather than
Local changed what --fail-on any does to a Cargo.toml declaring
serde = { workspace = true } with no workspace root above it: Local is on that
flag's clean list, Undetermined is not, so an existing Rust user's CI flipped green to
red — with nothing anywhere saying why, because undeclared_inheritance only ran once a
root had been found and deferred_versions speaks only for POMs. That broke the promise
result.rs makes about itself, that a run says why alongside an undetermined result.

The status is right; the silence was not. The walk running out is now reported the way
finding the wrong root already was, naming every such entry once on stderr. Cargo only,
since workspace = true is the only spelling that promises a root there might be none
of; a POM deferring to its <parent> has none to be missing.

Test: a_package_inheriting_from_no_workspace_at_all_says_so in cli_workspace.rs,
which asserts exit 1 and that stderr names serde and the missing root.

inherited() discarded the entry's source, sending a system jar to a registry

Medium. A comment, CDATA section, or character reference inside <version> costs the
entry its byte-faithful span, and the span-less path rebuilt the item with
PackageSource::Inherited hardcoded — discarding the Local that <scope>system</scope>
had already established. Inherited is checkable, so the run asked Maven Central about a
jar sitting at a path on this machine and reported ERROR, failing --fail-on any. Two
dependencies identical but for a comment disagreed about their own status.

The entry's own source now survives; only a registry entry becomes Inherited, which is
the one case the variant describes.

Test: a_system_scoped_jar_stays_local_when_its_version_is_reconstructed, in both
pom_xml.rs and fixture_maven.rs — the latter pinning plainsys and cmtsys to the
same LOCAL and --fail-on any staying green. The pre-existing
a_system_scoped_jar_is_still_local covered only the byte-faithful spelling.

The HTML report's status table omitted Undetermined

Medium. §1's status table listed eight statuses and Undetermined was not among
them, so an executive summary reading "6 dependencies" sat over rows summing to 5, and a
POM whose only dependency defers to its <parent> reported one dependency over a table
accounting for none of it. §3 and the ecosystem table were right all along.

Summary has carried the count since the status was added; SummaryView never passed it
to the template — and an undeclared template variable renders as the empty string rather
than failing, so the row would have rendered blank even once added. Both halves are fixed.

No golden held an Undetermined result, which is why three byte-for-byte fixtures did not
notice. The full-report fixture now carries one, and the test additionally asserts the
rendered row carries its count as well as its heading, and that the rows account for
every dependency the summary totals.

A version split by a comment across lines yielded a whitespace-laden constraint

Medium. Concatenating every text node is what reads
<version>1.0<!--x-->.0</version> as the 1.0.0 Maven reads, and only the ends of the
join were trimmed. Broken over lines the same construct keeps the pretty-printer's
indentation between its halves, joining to 1.0\n \n .0 — a value nobody
wrote, that Maven would reject, and that is_checkable waved through to the fetch layer
as a real constraint.

Stitching the halves together would state a version the file does not, so a joined value
with whitespace inside it now states nothing at all: the dependency is reported
unresolved, which is what it is. The single-line form is untouched.

Test: a_version_split_across_lines_states_no_version in pom_xml.rs.

Validation

env -u FORCE_COLOR mise run fmt:check   # clean
env -u FORCE_COLOR mise run lint        # clean (clippy -D warnings)
env -u FORCE_COLOR mise run test        # 873 passed, 0 failed, across 40 binaries

Nothing added is network-dependent, so mise run test stays hermetic. (FORCE_COLOR
breaks an unrelated tree test on every branch — a known environment artifact, not
this change.)

Unresolved review notes

Findings deliberately not repaired here. The first two are from the first review
round; the next several are from the second; the last two are from the third, and are
listed in round order rather than severity order, so a re-reviewer can see what each
round left behind.

Also noted, outside both lists: --format text pads the status token to 8 characters and
UNDETERMINED is 12, so that column no longer aligns when an undetermined result is
present.

The TUI shows a green ok for a non-Cargo package

  • Severity: Medium.
  • Location: crates/dependable-tui/src/ui/tree.rsstatus_badge, fed by
    direct_graph.
  • Claim: direct_graph zeroes the version for a non-Cargo ecosystem, so the badge
    is computed against an empty version and can render ok for a package whose currency
    was never established.
  • Scenario: open the TUI on a Maven project and a dependency reads as up to date on
    a version the tree does not have.
  • Remediation direction: direct_graph should carry the real declared version per
    ecosystem rather than zeroing it, so the badge is derived from the same data the check
    used.
  • Why not repaired here: it predates this branch and spans csproj, mix, and Gradle
    as well as Maven, so it is neither caused by nor confined to the POM parser, and
    fixing it would put a cross-ecosystem TUI change inside a parser PR. Filed as fix(tui): a dependency whose version was never read renders as up to date #96
    and left to it. The status threading in this PR reaches the TUI's detail pane, which
    renders Undetermined correctly; only the tree badge is affected.

A second <dependencies> under <project> is silently dropped

  • Severity: Low.
  • Location: crates/dependable-core/src/parsers/pom_xml.rs, read_dependencies
    child() returns the first match only.
  • Claim: a POM with two <dependencies> elements directly under <project> has the
    second one's dependencies read as zero, with no notice.
  • Scenario: hand-write a POM with a duplicate <dependencies> tag and the entries
    in the second block never appear in a list or a check.
  • Remediation direction: either read every direct <dependencies> child, or emit a
    parser notice when more than one is present.
  • Why not repaired here: Maven 3 rejects a duplicate <dependencies> under
    <project> outright — such a POM does not build, so no POM that is actually in use
    can hit it. Repairing it would mean either changing what the parser reads or adding a
    notice for a file Maven itself refuses, neither of which improves any real project's
    output.

profile_notice counts more than profile-scoped dependencies

  • Severity: Low.
  • Location: crates/dependable-core/src/parsers/pom_xml.rs, profile_notice.
  • Claim: the count includes every <dependency> under a profile's
    <dependencyManagement> and <build><plugins>, not just the profile's own
    <dependencies>.
  • Scenario: a profile-scoped BOM import produces a misleading "1 dependency …
    is not listed", naming something that was never a dependency of this project.
  • Remediation direction: count only <dependency> whose parent <dependencies> is
    a direct child of a <profile>.
  • Why not repaired here: it overstates a notice rather than misstating a dependency —
    nothing is dropped and no status is wrong — and the fix belongs with the wider
    <profiles> handling rather than inside a round of correctness repairs.

PackageSource::Inherited is used for any version needing reconstruction

  • Severity: Low.
  • Location: crates/dependable-core/src/parsers/pom_xml.rs, iteminherited.
  • Claim: <version>1.0<!--x-->.0</version> reports inherited: true though the
    version is declared on that entry, contradicting the doc this PR added.
  • Scenario: a POM with a comment inside a version shows the dependency as inheriting
    a version it states itself.
  • Remediation direction: separate "no span to rewrite" from "the version is declared
    elsewhere"; they are currently the same signal.
  • Why not repaired here: it shares a root with the system-jar repair above but is
    not cured by it, and separating the two concepts changes a public field's meaning
    across three parsers. Tracked as refactor(core): PackageSource::Inherited now means four different things #98.

child() is namespace-blind

  • Severity: Low.
  • Location: crates/dependable-core/src/parsers/pom_xml.rs, child.
  • Claim: a foreign-namespace <x:dependencies> preceding the real one wins, and a
    second sibling <dependencies> is dropped.
  • Scenario: hand-write either shape and the wrong block, or no block, is read.
  • Remediation direction: match on the POM namespace as well as the local name.
  • Why not repaired here: Maven rejects both shapes, so no POM that actually builds
    can reach it. (This subsumes the duplicate-<dependencies> note above.)

count_property_refs ignores ${…} in attributes

  • Severity: Low.
  • Location: crates/dependable-core/src/parsers/pom_xml.rs, count_property_refs.
  • Claim: it scans text nodes only, so its doc's "every ${…} reference in the
    document" is slightly overstated.
  • Scenario: a property referenced only from an attribute is counted as having one
    fewer reader, which could make a shared <properties> line look sole-owned.
  • Remediation direction: scan attribute values too, or narrow the doc's claim.
  • Why not repaired here: attributes are vanishingly rare in the POM schema and carry
    no version, so the undercount is theoretical; the claim is the part worth tightening,
    and that belongs with the wider <profiles> work.

<properties><project.version> shadows Maven's built-in

  • Severity: Low.
  • Location: crates/dependable-core/src/parsers/pom_xml.rs, read_properties.
  • Claim: a <properties> entry literally named project.version is resolved as an
    ordinary property, where Maven's built-in takes precedence.
  • Scenario: a POM declaring that property gets a version this tool resolves and Maven
    does not.
  • Remediation direction: refuse project.* and other reserved prefixes as
    <properties> keys.
  • Why not repaired here: declaring it is already a mistake Maven warns about, and
    handling built-in precedence properly is the start of a resolution engine — the line
    this parser deliberately does not cross.

Undetermined produces no GitHub Actions annotation

  • Severity: Low.
  • Location: crates/dependable/src/output/github.rs, level_of.
  • Claim: level_of returns None for Undetermined, so under --fail-on any a job
    exits 1 with zero annotations.
  • Scenario: a <parent>-inheriting POM in CI fails the job with nothing annotated in
    the diff view — though the manifest-level warning is still on stderr.
  • Remediation direction: map it to Level::Notice, whose title is already literally
    "dependable: dependency could not be checked".
  • Why not repaired here: it is an output-surface addition rather than a correctness
    defect, and the stderr warning this PR added means the run is no longer silent about
    it. Worth its own change alongside the annotation work generally.

An unresolvable coordinate is reported as an unread version

  • Severity: Medium.
  • Location: crates/dependable-core/src/parsers/pom_xml.rs, read_dependencies
    (the known gate) → unresolvedinherited(entry, ""); surfaced by
    deferred_versions in crates/dependable-fetch/src/check.rs.
  • Claim: when either coordinate half is unresolvable, version is forced to
    Source::Unknown whatever version sits beside it, so the entry becomes
    PackageSource::Inherited with an empty version_constraint. Two things follow.
    The version the file plainly states is discarded — a <dependency> with
    <groupId>${project.groupId}</groupId> and a literal <version>2.0.13</version>
    reports no constraint at all, and the 2.0.13 the reader can see in their own file
    appears nowhere in the output. And because deferred_versions selects on exactly
    Inherited && version_constraint.is_empty(), that entry is swept into a message
    that says it "takes its version from a <parent>, <dependencyManagement>, or a
    property this file does not declare, so no version was read for it" — which is
    false twice over: the version was read, and the parent is not why it is
    unreported. The coordinate is what could not be resolved.
  • Scenario: a multi-module build whose modules depend on each other by the
    standard ${project.groupId} idiom, pinning a literal version on each. Every such
    line reports (unresolved) with no version, and stderr explains the absence with a
    <parent> story that does not apply — pointing a reader at their parent POM to fix
    something their parent POM has nothing to do with.
  • Remediation direction: separate the two reasons an entry cannot be checked.
    Keep reading the version independently of the coordinate, so a stated version is
    reported even when the name is not fetchable, and give an unresolvable coordinate
    its own notice ("the coordinate could not be resolved, so nothing was fetched")
    rather than borrowing deferred_versions'. That likely means a third Source
    variant, or a flag on Declared, so the two conditions stop sharing one signal.
  • Why not repaired here: it is the same root as the PackageSource::Inherited
    overload already tracked as refactor(core): PackageSource::Inherited now means four different things #98 — "no version to report" and "no version to
    rewrite" and now "no coordinate to fetch" are all one state — and repairing it
    properly means adding a distinguishing signal to a core type and threading a new
    notice through the fetch layer and both output surfaces. That is a change to the
    parser's contract rather than a round of correctness repairs, and it should land
    with refactor(core): PackageSource::Inherited now means four different things #98 rather than being half-made here. Nothing is silently dropped in the
    meantime: the dependency is still listed, still reported Undetermined, and still
    counted — only its stated version and the reason given are wrong.

detached_inheritance asserts a workspace lookup that never ran

  • Severity: Medium.
  • Location: crates/dependable-fetch/src/check.rs, check_inner's
    else { warnings.extend(detached_inheritance(...)) } branch, reached from
    Checker::check_manifest.
  • Claim: the else branch fires whenever workspace is None, and it does not
    distinguish "the walk upwards ran and found nothing" from "no walk was ever
    attempted". Checker::check_manifest — the content-only IDE API — always passes
    None, by construction: it is given a buffer with no path behind it, so there is no
    tree to search. Its warning nonetheless states "no workspace root was found above
    this manifest", asserting the result of a search that did not happen.
  • Scenario: an IDE integration calls check_manifest on an open Cargo.toml
    buffer from a perfectly ordinary workspace member. Every serde.workspace = true
    line produces a warning claiming the member is detached from any workspace root —
    which is untrue of the file on disk, and would send a user looking for a missing
    root that is sitting one directory up. Checker::check_path on the same file
    resolves the root and emits nothing.
  • Remediation direction: make the distinction explicit rather than inferring it
    from Option::None. Either pass check_inner a value that separates "looked up,
    found nothing" from "not looked up" (an enum, or a separate flag), or move the
    detached_inheritance call into the check_path side that actually performed the
    walk, leaving check_manifest to report the entries as Undetermined without
    claiming to know why.
  • Why not repaired here: the warning is correct for check_path, which is the CLI
    path and the one this branch's a_package_inheriting_from_no_workspace_at_all_says_so
    test covers; the defect is confined to a library API the CLI never calls, so no
    dependable run can reach it. The fix changes check_inner's signature — a shared
    seam both public entry points go through — which is a wider blast radius than the
    bug, and it belongs with the workspace-resolution work rather than inside the POM
    parser's PR. The IDE consumer is not silently misled about what is unchecked, only
    about why.

A Maven POM is declarative XML, so the dependencies it states can be read
without running anything. `<dependencies>` directly under `<project>` yields
one `groupId:artifactId` per entry, with the byte span of the `<version>`
text so `--fix` can rewrite it where it is written.

`${property}` resolves against `<properties>` in the same file, following the
Gradle catalog's rule for the same reason: a property used by exactly one
dependency carries the `<properties>` span that governs it, while one several
dependencies share carries none, because a single line cannot be rewritten to
two versions.

`<parent>` inheritance, `<dependencyManagement>`, and BOM imports stay out:
resolving any of them can require fetching the parent POM from a registry,
which would make this a resolution engine rather than a parser. A dependency
those rules leave unresolved is reported with no constraint instead of being
dropped, so a POM that inherits half its versions is never presented as
depending on only the other half.
The notice surface a Gradle build script uses is a directory scan keyed on a
file name, and it does not fit here: a `pom.xml` is perfectly readable, and it
is individual entries within one that are not. Rather than add a second
mechanism, an unresolvable dependency is reported through the vocabulary that
already describes exactly this state — `PackageSource::Inherited` with no
constraint, the shape a Cargo member's unresolved `dep.workspace = true`
already has. It is never fetched, positioned, or fixed, and `list` already
renders it as `(unresolved)`.

`sample-maven/pom.xml` covers all of it in one file: a literal version, a
property used once, a property two dependencies share, a version supplied by
the `<parent>`, and a `${revision}` that is Maven's rather than the file's,
beside a `<dependencyManagement>` entry and a plugin dependency that must not
leak into the artifact's list. The CLI test asserts the unresolvable entry is
present with a null constraint, which is what "reported rather than skipped"
has to mean at the surface a user sees.
`DependencyStatus` had no word for "nothing was learned about this". A
dependency that names no version this tool can resolve fell into `Local`,
which states something quite different and quite false: that there is no
registry behind the package at all. That is what `local` means for a Cargo
`path = "../x"` and for a Maven `<scope>system</scope>` jar, and applying it
to `spring-boot-starter-web` — an artifact on Maven Central whose version
this run simply did not read — is a claim, not a shrug.

`Undetermined` is that word. It is deliberately named for the fact rather
than for the ecosystem that surfaced it, because two situations produce the
same fact: a manifest that defers its version somewhere this parser does not
follow, and an ecosystem that publishes no registry to compare a version
against at all. The second has no producer yet.

`PackageSource::Inherited` and the two `Item` predicates that gate on it
still described a Cargo `workspace = true` and nothing else, though three
parsers now emit the variant. They say what the variant means instead.
`MAX_PROPERTY_HOPS = 8` is a count of hops, but the loop spent one iteration
on arriving at the first property, which is not a hop. An eight-hop chain
that terminates perfectly well therefore returned nothing, and the dependency
reading it was reported with no version.

It fails safe, so nothing was ever mis-stated — but the constant and the code
disagreed about what the number meant, and the doc comment now says which one
it is: the longest chain that resolves is `MAX_PROPERTY_HOPS + 1` properties.
`text_of` took the first text node's value and dropped only the *span* when
there were several, so `<version>1.0<!--x-->.0</version>` yielded the
constraint `1.0` — a version the file never declares — and reported it as
checkable. dependable then asked Maven Central about the artifact and
evaluated it against a constraint of its own invention.

`--fix` could not corrupt the file, because the span was already dropped, so
this was a wrong claim rather than a wrong write. It is still a wrong claim.
Every text node is now concatenated, which is the value Maven itself reads.

The sibling spellings were already correct and stay so: `1.0&#46;0` and a
`CDATA` section both yield `1.0.0` with the span dropped, since the source
bytes are not the value's bytes and an offset into one is not an offset into
the other. A test pins all three together.
…ties line

Sole ownership of a `<properties>` line was decided by counting only the
references made from the one top-level `<dependencies>` list. A property that
list reads once but a `<profiles>` block, a `<dependencyManagement>` entry, or
a plugin's own `<version>` also reads was still marked rewritable, and the
rewrite reached all of them.

Demonstrated with `<lib.version>32.1.3-jre</lib.version>` read by the
top-level `guava` and by a profile-only `guava-gwt`: `fix --all` printed
"Updated 1 dependency", rewrote the line, and silently moved `guava-gwt` — a
different artifact, never fetched, never validated, never named in the fix
record.

Sole ownership is a fact about the line, not about `<dependencies>`, so the
count now walks the whole document. A `<properties>` value that is *entirely*
one `${…}` is a link in a chain rather than a reader of it and is skipped, or
no chained property could ever be rewritten; a composed value is a reader and
is counted.

This is the `count_version_refs` rule from `gradle_catalog`, which repairs the
identical defect for a `[versions]` alias a `[plugins]` entry shares.
A POM that declares every one of its dependencies inside `<profiles>` listed
as `(0 dependencies)`. Excluding conditional dependencies is defensible — a
profile applies on a JDK version or an activated property, so its
dependencies are not this project's as written, and reading them would state
as fact something that holds only under a condition this file does not
evaluate. A list that reads as complete and is not is the failure the POM
parser exists to prevent, and being silent is what made this one that.

`ParsedManifest` gains `notices`: what the parser saw and deliberately did not
interpret, in the parser's own words. Not an error and not a warning about any
dependency in `items` — the words go on stderr, where `list` and `check` both
already put manifest-level notes, so the same sentence reaches a reader
whichever `--format` they chose and no machine-readable document changes
shape.

The POM parser is the only one with anything to declare so far. Every other
parser leaves it empty.
`evaluate_item` sent every non-checkable, non-git item to
`DependencyStatus::Local`, so an `Inherited` entry that never found a version
landed there through the wildcard arm. On a `<parent>`-inheriting POM — the
dominant real-world shape, along with same-file `<dependencyManagement>` —
that produced a whole table of `local` rows:

    org.springframework.boot:spring-boot-starter-web   —  —  local
    org.springframework.boot:spring-boot-starter-test  —  —  local
    Totals: 2 skipped

`local` is what this tool prints for a Cargo `path = "../x"` and a Maven
`<scope>system</scope>` jar, and it means there is no registry behind the
package. `spring-boot-starter-web` is on Maven Central; only its version went
unread. `list` calls the same entry `(unresolved)`, so the two commands
contradicted each other, and `"status": "LOCAL"` was the wrong token for a CI
consumer to read. It now reports `Undetermined`, which claims nothing.

An unresolved Cargo `workspace = true` reaches the same arm and is the same
mistake, so it is corrected with it.

A check also said none of this out loud: stderr was empty, and a reader
looking at a column of dashes had to infer that nothing had been read. A
manifest-level warning now names the dependencies and where their versions
come from. It stays separate from `undeclared_inheritance` on purpose: a
Cargo member inheriting a name its root never declared is a manifest Cargo
refuses to build, while a POM deferring to its `<parent>` is ordinary, valid,
and extremely common — the same status, two different things to tell a reader.

Parser notices are forwarded into the same warnings, so a `<profiles>` block
`check` did not read is announced exactly as `list` announces it.
…ped one

The status now reaches every surface a `check` speaks through: the table (a
colour of its own, since a gap in the report is not the same as a row there
was nothing to say about), the totals line, `--format json`'s per-result
token and its `summary.undetermined` counter, and `--format text`.

The totals count it apart from `skipped`. A `path` dependency was passed over
deliberately and there is nothing a stricter run could ever learn about it; an
undetermined one is a version this run failed to read, and folding the two
together is how a POM that yielded nothing came to print "2 skipped".

--fail-on
---------
`--fail-on any` no longer exits 0 over a manifest whose versions were never
read; `--fail-on vulnerable` and `--fail-on outdated` still do.

The two narrow gates ask a specific question — is anything vulnerable, is
anything behind — and an unread version answers neither. Failing a build that
asked about vulnerabilities because a POM defers to its `<parent>` would make
the flag mean something other than what it says.

`--fail-on any` asks the general one: is every dependency checked and current.
A dependency whose version was never read is not current, it is unestablished,
and exiting 0 asserts something the run never determined — which is exactly
how a parent-inheriting POM used to go green while dependable had read
nothing at all. An unreadable version is admittedly not a vulnerability, so
the failure never travels alone: `check` names the dependencies on stderr, and
a job that fails says what to fix.
`list --format json` emitted `"source": "inherited"` beside
`"inherited": false` on the same object. The boolean was filled in only from
Cargo workspace resolution, so every entry the Gradle-catalog and POM parsers
mark inherited arrived contradicting the field next to it, and a consumer
reading both got two answers.

Both fields describe one fact — this dependency's version is declared
somewhere other than its own entry — so the boolean is now read from the
source. The workspace-root list still contributes, because a root declaring a
crate by `path` replaces `source` outright and the fact that it was inherited
would otherwise be lost.

No schema change: the key, its type, and its meaning for a Cargo manifest are
all unchanged.
`Summary::checkable` subtracted only the path and git dependencies, so an
undetermined one sat below the line as if the run had checked it and found it
behind. On a parent-inheriting POM that silently depressed the up-to-date
percentage toward nothing. It is now subtracted too, and counted in a field of
its own beside `local` and `git`.

SARIF emits no finding for it, which is the same decision `Error` gets and for
the same reason: nothing was learned, so there is nothing to report about the
code — and there is no line to pin a finding to either, since a dependency
that defers its version elsewhere records no span. The CLI's manifest-level
warning is where that belongs.

The HTML badge and the TUI's detail pane both give it a colour of its own
rather than the muted one the deliberately-skipped statuses share.

The TUI *tree* still shows a green `ok` for a non-Cargo package, for an
unrelated reason that predates this branch and spans csproj, mix, and Gradle
as well: `direct_graph` zeroes the version. Filed as #96 and deliberately not
touched here.
The existing Maven fixture exercised `list --format json` only, which is why
`check` calling every unresolvable entry `local` went unseen: `list` renders
the parser's output directly, and the mislabel happened a stage later.

These drive the binary end to end against a `<parent>`-only POM and a
same-file `<dependencyManagement>` one, hermetically — the JVM registry points
at a port nothing listens on, so the connection is refused at once and nothing
here depends on what a registry would have said. What is under test is decided
before any request is made.

Covered: the status in `--format json`, in the table, and in `--format text`;
the `summary.undetermined` counter; that `--fail-on any` fails while
`--fail-on outdated` and `--fail-on vulnerable` stay green; that the warning
naming the dependencies reaches stderr; that a `<scope>system</scope>` jar is
still `LOCAL` and still clean, so the distinction between "no registry" and
"nothing read" is pinned from both sides; that a profiles-only POM says why it
lists nothing; and that `source` and `inherited` agree on every object.
`<groupId>${project.groupId}</groupId>` is the standard way a multi-module
build names a sibling module, and it names a Maven built-in that is not a
`<properties>` entry — so the coordinate could not be resolved and the whole
dependency was dropped. A POM declaring three dependencies, two of them
siblings, listed as depending on one, with nothing on stderr to say otherwise.
A missing `<groupId>` element went the same way.

That is the silent omission this parser already refuses for a `<version>` a
`<parent>` supplies, reached through the other half of the coordinate — and the
asymmetry was inside one function: an unresolvable version was reported with an
empty constraint, an unresolvable group deleted the entry even though the
artifact was named.

Each half is now read as text first and resolved second: the resolved value
where this file states one, the literal as written where it does not. An entry
either half of which is unresolved reports no constraint either, since a
coordinate this file cannot state is one nothing can be fetched for. It lands
in `Undetermined`, beside the parent-deferred ones. Only a `<dependency>`
naming neither half is skipped, having stated nothing to report.
…ucted

A comment, a CDATA section, or a character reference inside `<version>` costs
the entry its byte-faithful span, and the span-less path rebuilt the item with
`PackageSource::Inherited` hardcoded — discarding the `Local` that
`<scope>system</scope>` had already established. `Inherited` is checkable, so
the run went and asked Maven Central about a jar sitting at a path on this
machine, and reported `ERROR` for it: a false statement of exactly the kind
this branch set out to remove, pointed the other way.

Two dependencies identical but for a comment inside the version, both
`<scope>system</scope>`, disagreed about their own status and about whether
`--fail-on any` passed.

The entry's own source now survives: only a registry entry becomes `Inherited`,
which is the one case the variant describes.
Concatenating every text node is what reads `<version>1.0<!--x-->.0</version>`
as the `1.0.0` Maven reads, and only the ends of the join were trimmed. Broken
over lines the same construct keeps the pretty-printer's indentation between
its halves:

    <version>
      1.0
      <!--x-->
      .0
    </version>

which joined to `1.0\n        \n        .0` — a value nobody wrote, that Maven
would reject, and that `is_checkable` nonetheless waved through to the fetch
layer as a real constraint.

Stitching the halves together would state a version the file does not, so a
joined value with whitespace inside it now states nothing at all: the
dependency is reported unresolved, which is what it is. The single-line form is
untouched and still reads whole.
…read

Reporting an unread version as `Undetermined` rather than `Local` changed what
`--fail-on any` does to a Cargo package that declares `serde = { workspace =
true }` with no workspace root above it: `Local` is on that flag's clean list,
`Undetermined` is not, so an existing user's job flipped green to red. With
nothing on stderr, because `undeclared_inheritance` only runs once a root has
been found and `deferred_versions` speaks only for POMs.

The status is right — the crate is on crates.io and this run read no version
for it — but it broke the promise `result.rs` makes about itself: that a run
says why alongside one.

So the walk running out is now reported the way finding the wrong root already
was, with every such entry named once on stderr. Cargo only, since `workspace =
true` is the only spelling that promises a root there might be none of; a POM
deferring to its `<parent>` has none to be missing, and stays
`deferred_versions`' story to tell.
`§1`'s status table listed eight statuses and `Undetermined` was not among
them, so an executive summary reading "6 dependencies" sat over rows summing to
5 — and a POM whose only dependency defers to its `<parent>` reported one
dependency over a table accounting for none of it. `Summary` has carried the
count since the status was added; `SummaryView` never passed it to the
template, and an undeclared template variable renders as the empty string
rather than failing, so the row would have been blank even once added.

§3 and the ecosystem table were right all along — it is the summary above them
that did not add up.

No golden held an `Undetermined` result, which is why three byte-for-byte
fixtures did not notice. The full report now carries one, and the test asserts
the rendered row carries its count as well as its heading, plus that the rows
account for every dependency the summary totals.
@justin13888
justin13888 changed the base branch from feat/82-gradle-version-catalogs to master September 1, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(jvm): pom.xml parser — literal versions and same-file properties only

1 participant