Linkage-blind removal — attempted twice, reverted twice. The evidence keeps proving something adjacent to the invariant¶
Index: Known gaps
Status (re-verified 2026-10-09 at 456989f): NEGATIVE_RESULT. Records two reverted demotion attempts, which should not be re-attempted. The comdat sub-item is still open: COMDAT collection remains opt-in (build_evidence.py:426, env_flag ABICHECK_COLLECT_COMDAT), and adapters/compile_db.py has no output/-o handling (grep finds no 'output').
A symbol vanishing from
the export table is reported as func_removed (and, on the same symbol,
func_deleted_elf_fallback) regardless of its linkage, so a weak
vague-linkage export reads as a hard break. The demotion's invariant is
"every consumer already emitted its own copy", and both attempts
established something else:
Function.is_inlineproves the declaration's inline linkage, not that a definition exists. Verified against real clang:inline int f();yieldsinline=True, has_body=False. This stays true after the implicit-inline fix (extract/headers/clang/inline_semantics.py), which widened the field to coverconstexpr/consteval, in-class definitions and in-class= defaultso the two backends agree: those are all still linkage facts about the library's own declaration, and none of them says a consumer emitted a copy — which is the shared shape this entry exists to name. Read it as "may have vague linkage", never as "every consumer already has its own definition".- COMDAT-group membership proves the library used vague linkage, not
that its consumers did.
extern templateis the counterexample, and it is ordinary code: a public header carryingextern template struct Box<int>;tells every consumer TU not to instantiate, while the library's own explicit instantiation still emits a weak COMDAT definition. Verified against g++ — the library object has_ZNK3BoxIiE3getEvinside a COMDAT group while the consumer object has it asNOTYPE GLOBAL UNDwith an empty COMDAT set. Dropping that export breaks the consumer, and the predicate demoted it (Codex review).
The shared shape is worth naming, because it is what a third attempt will
hit too: a fact about the library's own build was read as a fact about
its consumers. Nothing in two library snapshots, and nothing in one
library's object files, distinguishes "the consumer emitted a copy" from
"the consumer holds an undefined reference". Only consumer-side evidence, or
a header fact recording extern template (which castxml does not expose —
checked through 0.7.0), can separate them.
Kept, because it is sound and answers a real question:
buildsource/comdat_groups.py — an ELF SHT_GROUP/GRP_COMDAT parser
(byte-order correct, ELF-only, degrading to diagnostics on unreadable
objects) and its BuildEvidence.comdat collection. It answers "did this
build emit this symbol vaguely", which is genuinely useful and was already
reserved vocabulary in graph_facts.py (comdat_group) for a future
linker-artifact extractor. It is simply not sufficient for the demotion.
Separately open, and independent of the above: the L3 collection path
does not deliver usable object paths. CompileUnit.output is a label
normalized for persistence, not a path — home paths redacted to ~/...
(ADR-032 D7), Ninja/Make/Bazel outputs relative to CompileUnit.directory —
and CompileDbAdapter never assigns it at all, discarding the compile
database's output field and -o alike. Half-closed on the reading
side: build_evidence._resolved_object now expands ~ and joins a
relative label onto the unit's own directory, and skips a label naming no
file that exists, so an adapter that does record an output is readable
from outside the build directory. That also makes the scan conservative
where it used to be destructive: a build whose objects resolve to nothing
leaves comdat untouched rather than replacing a scan loaded from an
existing pack with an empty, unresolvable one, and a fresh scan that
established nothing never displaces one that did. Still open: the
producing side — CompileDbAdapter recording an output at all, and every
adapter keeping the raw resolved path alongside the redacted label, so a
pack collected on one machine can be scanned on another. Until then the
scan is opt-in (ABICHECK_COLLECT_COMDAT=1 at inline.py's call site):
parsing every object's symbol table is real I/O, and no detector consumes
the result.