feat(adr0020): step 3 — per-profile reachability reports Verified

ADR 0020 step 3: profile manifest format + per-profile reports.

Two profiles seeded under docs/release-profiles/:

  full.lisp — every capability the cold binary exposes
    (capabilities
      public-root
      runtime-feature repl
      runtime-feature compiler
      runtime-feature image
      runtime-feature diagnostics)

  compiler-only.lisp — drops repl, image, diagnostics
    (capabilities
      public-root
      runtime-feature compiler)

Cold-side change: ag-tree-shake-emit also writes
build/cold-edge-graph.sexp — one `(CALLER CALLEE CLASS)' triple
per line, wrapped in `(EDGES ...)'.  SYNTHETIC-INSTALL-REFERENCE
edges are excluded (never traversed by any reachability gate;
omitting them halves the file size).  ~5937 edges, ~298 KiB on
cold-of-cold.

The per-profile BFS runs in the ert test
(funcl-cold-profile-reachability), not in cold itself.  Rationale:
the test consumes both the capability tag manifest (step 2) and
the edge graph; doing the BFS in elisp avoids requiring cold to
parse profile manifests at compile time.  When step 4 needs a
`--profile NAME' CLI flag for gated elimination, cold can grow
profile-parsing then.

Test:

  Each profile's reachable set =
    BFS(roots = tagged_with(cap ∈ profile.capabilities)
                ∪ profile.retained,
        edges = build/cold-edge-graph.sexp)

  Asserts:
    - main ∈ both (every profile has main as public-root).
    - ag-cli-compile-source ∈ both (compiler capability).
    - |co-reach| ≤ |full-reach| (monotone in roots).
    - Reports counts so the eventual step-4 delta is measurable.

Honest finding the test surfaces:

  full          : 1224 defuns
  compiler-only : 1224 defuns (100.0% of full)

  Both profiles reach the SAME set today, because main is
  tagged public-root and main itself dispatches on argv to every
  CLI entry point (image-save, load-warm, REPL, compile, ...).
  BFS from main therefore reaches every reachable defun
  regardless of profile.

  This isn't a bug — it's the analysis revealing that
  capability slicing as currently framed can't separate
  runtime-dispatch branches.  Step 4 (the next ADR) needs:
    (a) a per-profile main refactor (different dispatcher entries
        per profile build), OR
    (b) finer-grained tagging that splits main's dispatch into
        per-feature handler functions, where BFS reaches a
        branch only when its feature's capability is in the
        profile.

  ADR 0020 step 4 was originally framed as gated elimination via
  `--strict-link --profile NAME'.  Today's finding shifts the
  problem statement: the elimination mechanism is straightforward
  once the slicing actually carves out per-profile differences.
  Out of scope for ADR 0020; gets its own ADR.

Verified:
  - 199/199 GREEN
  - Profile reachability test runs in ~11s on cold-of-cold
  - Edge graph is 297892 bytes (5937 retained edges); ~300 KiB
    is fine for periodic CI emit, more than acceptable for
    interactive review
Anthony Green2026-06-10 11:32:57 -0400 228d13a651031b27ccb52e0ee08eadc6f23181f6
Verified: - 199/199 GREEN