diff --git a/graphify/manifest_ingest.py b/graphify/manifest_ingest.py
index 313465334..a36071930 100644
--- a/graphify/manifest_ingest.py
+++ b/graphify/manifest_ingest.py
@@ -289,17 +289,39 @@ def _parse_pom(text: str) -> dict | None:
text = re.sub(r'\sxmlns="[^"]*"', '', text, count=1)
root = ET.fromstring(text)
aid = root.findtext("artifactId")
- gid = root.findtext("groupId")
+ gid = root.findtext("groupId") or root.findtext("parent/groupId")
+ version = root.findtext("version") or root.findtext("parent/version")
if not aid:
return None
+ props: dict[str, str] = {}
+ for prop in root.findall("properties/*"):
+ if isinstance(prop.tag, str) and prop.text:
+ props[prop.tag] = prop.text.strip()
+ for key, value in (
+ ("project.groupId", gid),
+ ("project.artifactId", aid),
+ ("project.version", version),
+ ("project.parent.groupId", root.findtext("parent/groupId")),
+ ("project.parent.version", root.findtext("parent/version")),
+ ):
+ if value:
+ props.setdefault(key, value.strip())
+
+ def _resolve(value: str | None) -> str | None:
+ if not value:
+ return value
+ return re.sub(r"\$\{([^}]+)\}", lambda m: props.get(m.group(1), m.group(0)), value.strip())
+
+ gid = _resolve(gid)
+ version = _resolve(version)
name = f"{gid}:{aid}" if gid else aid
deps: list[str] = []
for dep in root.findall(".//dependencies/dependency"):
- da = dep.findtext("artifactId")
- dg = dep.findtext("groupId")
+ da = _resolve(dep.findtext("artifactId"))
+ dg = _resolve(dep.findtext("groupId"))
if da:
deps.append(f"{dg}:{da}" if dg else da)
- return {"name": name, "version": root.findtext("version"), "deps": deps}
+ return {"name": name, "version": version, "deps": deps}
_PARSERS = {
diff --git a/tests/test_manifest_ingest.py b/tests/test_manifest_ingest.py
index 9f01675c4..6407e477b 100644
--- a/tests/test_manifest_ingest.py
+++ b/tests/test_manifest_ingest.py
@@ -163,3 +163,42 @@ def test_cargo_workspace_inherited_version_does_not_crash(tmp_path):
'[package]\nname = "member"\nversion.workspace = true\n')
pkg = _pkg_nodes(extract_package_manifest(p))[0]
assert pkg["label"] == "member" and "version" not in pkg
+
+
+# ── #3806: inherited groupId and ${...} properties in pom.xml ────────────────
+
+def test_pom_inherits_groupid_and_resolves_properties(tmp_path):
+ _write(tmp_path / "rsc/pom.xml",
+ '\n'
+ ' org.acmemain1.0\n'
+ ' org.acme\n acme-rsc\n\n')
+ _write(tmp_path / "server/pom.xml",
+ '\n'
+ ' org.acmemain1.0\n'
+ ' acme-server\n'
+ ' 2.12\n'
+ ' \n'
+ ' ${project.groupId}acme-rsc\n'
+ ' org.acmeacme-core_${core.suffix}\n'
+ ' \n\n')
+ _write(tmp_path / "web/pom.xml",
+ '\n'
+ ' org.acmemain1.0\n'
+ ' acme-web\n'
+ ' \n'
+ ' org.acmeacme-server\n'
+ ' \n\n')
+
+ server = extract_package_manifest(tmp_path / "server/pom.xml")
+ pkg = _pkg_nodes(server)[0]
+ assert pkg["label"] == "org.acme:acme-server" and pkg["version"] == "1.0"
+ targets = {e["target"] for e in server["edges"] if e["relation"] == "depends_on"}
+ assert {"pkg_org_acme_acme_rsc", "pkg_org_acme_acme_core_2_12"} <= targets
+
+ result = extract(sorted(tmp_path.rglob("pom.xml")), cache_root=tmp_path)
+ g = build_from_json(result)
+ labels = {n: d.get("label") for n, d in g.nodes(data=True)}
+ dep_edges = {frozenset((labels[u], labels[v])) for u, v, d in g.edges(data=True)
+ if d.get("relation") == "depends_on"}
+ assert frozenset(("org.acme:acme-server", "org.acme:acme-rsc")) in dep_edges
+ assert frozenset(("org.acme:acme-web", "org.acme:acme-server")) in dep_edges