import type { ImplementationFact } from "../adapters/types.js"; import type { Component, ComponentRole } from "../config/components.js"; /** * `loopgraph topology`: a self-contained HTML architecture diagram rendered * PURELY from `components` config (declared, § config/components.ts) + facts * carrying a `topology` hint (§ adapters/types.ts `TopologyHint`) — issue #23 * P0. Deliberately the ONLY signal source: this view adds no model node kind * and reads no `.loopgraph/model/` file, so it needs none of the decisions * the behavioral model requires and can ship before any of them are made. * * "Draw the picture before building the model" is the point (see the issue's * own framing): if this page gets looked at and answers real questions, that * is the evidence a topology DIMENSION belongs in the model later. If it * doesn't get used, nothing here cost a schema change to undo. * * Same posture as `graph`/`overview`: projection into the gitignored * `.loopgraph/ws/` side-channel, PURE compute (`computeTopologyModel`) + * PURE render (`renderTopologyHtml`), zero external hosts in the output. */ export type TopologyNode = { id: string; label: string; kind: "component"; role: ComponentRole; } | { id: string; label: string; kind: "external"; toKind?: string; observedOnly?: true; } | { id: string; label: string; kind: "unattributed"; }; export interface TopologyEdgeEvidence { filePath: string; line: number; } export interface TopologyEdge { source: string; target: string; /** How many topology-tagged facts collapsed into this (source, target) pair. */ count: number; /** A capped sample of file:line sites — see EVIDENCE_CAP. Not exhaustive by design. */ evidence: TopologyEdgeEvidence[]; } export interface TopologyModel { nodes: TopologyNode[]; edges: TopologyEdge[]; /** Count of topology-tagged facts whose file matched no declared component. */ unattributedCount: number; /** false when facts extraction did not run at all (no --repo-root) — nodes-only render. */ factsRan: boolean; summary: { components: number; external: number; edges: number; }; } /** Node id for the "file matched no declared component" bucket. Not a real component id. */ export declare const UNATTRIBUTED_NODE_ID = "(unattributed)"; /** * PURE. Builds the render model from declared components + a fact list. * * Node set = every declared component (so an isolated component with zero * edges still appears — the graph is "here is the org chart", not "here is * only what we found calls") + every DISTINCT external id a topology-tagged * fact points `to` that isn't already a component id + the unattributed * bucket, iff used. * * `from` is derived HERE via `componentOf`, never read off the fact — see the * module doc comment on `TopologyHint`: the adapter names its target, the * engine (which alone holds the component declarations) names the source. * A fact with no `topology` field is simply skipped (adapters that predate * this hint, or facts unrelated to topology, draw no edge — not an error). */ export declare function computeTopologyModel(facts: readonly ImplementationFact[], components: readonly Component[], factsRan: boolean): TopologyModel; /** * `unobservable` is a bucket, not a footnote on `static-only` — see the * module doc's "possibly-unobservable" section for the incident that forced * this split (real trace showed 0 overlap; the actual cause was that * `agent-worker`/`pod-agent`'s OTLP exporter can't reach the collector at * all, not that those 6 edges are dead). Folding it into `static-only`'s * existing "dead path, or an observation gap" hedge was the exact failure * this whole engine exists to catch elsewhere: **a signal misreporting its * own cause**. A reader who sees "static-only" is entitled to read it as "we * looked and it wasn't there"; an edge this engine never had a chance of * observing must say so instead of quietly borrowing that same bucket. * * `queue-mediated` is a SEPARATE bucket from `observed-only`, added for the * same "don't misreport the cause" reason, for a DIFFERENT root cause: real * trace data from a target repo showed two of its services talking to each * other only via a queue (a producer's span parenting a `kind=consumer` * receive span on the other side, never a direct call). Pairing a queue's * producer and consumer into * one logical A→B edge was the ORIGINAL plan here — abandoned on * investigation: the consumer side is typically `deps.boss.work(deps. * queueName, handler)`, an indirect call the static extractor cannot resolve * to a literal queue name, and pairing by same-queue-NAME instead would * fabricate an edge between any two components that happen to touch a queue * with that name, including ones that never actually talk to each other. * So: no pairing, and no attempt to compare these against a static edge at * all — the observed side's own `viaQueue`/`kind === "consumer"` signal is * definitive and puts the pair straight into this bucket, never through the * confirmed/static-only/unobservable/observed-only classification below. * Excluded from `staticCoverage`'s denominator for a DIFFERENT reason than * `unobservable`: not "can't see it", but "can't safely say what static edge * it would even correspond to" — see `TopologyEdgeDiffSummary.staticCoverage`. */ export type EdgeDiffCategory = "confirmed" | "static-only" | "observed-only" | "unobservable" | "queue-mediated"; export interface TopologyEdgeDiffEdge { source: string; target: string; category: EdgeDiffCategory; /** * `static | observed | both` — deliberately named `origin`, not `source`: * this interface already uses `source`/`target` for the edge's endpoint * ids, so reusing that name for "which side(s) produced this edge" would * collide with them. Purely DERIVED from `category` (never independently * computed) — `both` for confirmed, `static` for static-only|unobservable, * `observed` for observed-only|queue-mediated. `category` carries the * detailed WHY; `origin` is the coarse, symmetric "static and observed are * two co-equal fact sources" tag the pivot to this shape asked for — see * the module's top doc comment. Present on every edge unconditionally * (unlike the optional fields below), because every edge has exactly one. */ origin: "static" | "observed" | "both"; /** The extractor's own fact count for this pair — present iff category is confirmed|static-only|unobservable. */ staticCount?: number; /** Same evidence sample the underlying TopologyEdge carries — present iff category is confirmed|static-only|unobservable. */ evidence?: TopologyEdgeEvidence[]; /** Total row occurrences of this pair in the observed-edges file — e.g. 3 if the same (from, to) pair appears in 3 rows, not just whether it appeared at all — present iff category is confirmed|observed-only|queue-mediated. */ observedCount?: number; /** * Only set when category === "observed-only": whether BOTH endpoints were * already known to the static side (a declared component, or an id a * static fact already named as a target) before this observed edge came * along. `true` → a real gap between two known units — the extractor * missed a call between things it already knows exist (fix: extend/re- * check the adapter's extraction). `false` → at least one endpoint is * something static's own vocabulary never mentioned at all — a whole * dependency the static side never modeled (fix: that is a config/ * declaration gap, not an extraction gap — the real trace data this bucket * was designed for surfaced several external config/auth services this * way, none of which were in the target repo's static model at all). * Keeping these two cases distinguishable is deliberate, not an * afterthought — see the module doc. */ observedOnlyKnownEndpoints?: boolean; /** * Only set when category === "queue-mediated": best-effort, NON- * authoritative corroboration that BOTH endpoints ALSO have their own * static edge toward a queue-kind node (`toKind === "queue"`) — i.e. the * static side independently shows both sides "do something with a queue", * even though it cannot prove it is the SAME queue or the same hop. This * NEVER drives classification (only the observed row's own `viaQueue`/ * `kind === "consumer"` does — see the category's own doc). Its absence * must read as "static didn't independently corroborate it" (the * extractor may simply not have resolved a dynamic queue name), never as * "this pairing is wrong" — the field is deliberately a hedge, not a * verdict. */ queueStaticEvidence?: boolean; /** * Only set when category === "unobservable": WHICH side of the * observability check failed — real trace data surfaced a case the * original source-only check couldn't distinguish. `"source"` — the * edge's source component isn't in `observableComponents` (the original, * PR44 reason). `"target-kind"` — the source WAS in scope, but the * target is an external dependency of a kind this observation method * cannot see at all regardless of source telemetry health (e.g. a direct * DB/queue connection never produces an HTTP client span to trace — see * `TopologyEdgeDiffOptions.observableTargetKinds`'s doc for the concrete * incident: `postgres`/`redis` edges from a perfectly-observable source * were being read as "maybe dead" when the real reason was "this * observation method structurally cannot see a non-HTTP call, no matter * how alive it is" — the same signal-lying-about-its-own-cause failure as * the original `unobservable` split, just on the other endpoint). * `"both"` — neither side qualifies. These stay ONE bucket (not a new * category) per the module's "reasons, not buckets" rule — see the * `unobservable` field's own doc on `TopologyEdgeDiffSummary`. */ unobservableReason?: "source" | "target-kind" | "both"; } export interface TopologyEdgeDiffSummary { confirmed: number; /** * Edges with NO observed counterpart whose source IS in the declared * observable scope — see `unobservable` below for the complementary case. * This count is the actual "maybe dead, maybe untested" signal; it no * longer includes anything this engine couldn't have seen regardless. */ staticOnly: number; /** * Observed pairs with no static counterpart, EXCLUDING queue-mediated ones * (those are `queueMediated` below, never this). See each edge's own * `observedOnlyKnownEndpoints` for the further split this count itself * does not carry: "a real gap between two known units" vs "static's * vocabulary never mentioned this dependency at all". */ observedOnly: number; /** * Edges with no observed counterpart that fail EITHER observability check * — source not in `observableComponents`, OR target an external kind not * in `observableTargetKinds` (see that option's doc for the incident that * added the second axis: a `postgres`/`redis` edge from a perfectly- * observable source was still unconfirmable, because no HTTP-span-based * observation method can ever see a direct DB connection). Deliberately * excluded from `staticOnly`: a reader scanning that count for "how many * dead paths do I have" must not have it inflated by edges this engine * had zero chance of confirming either way regardless of cause. WHICH * axis failed for a given edge is on that edge's own `unobservableReason` * — this aggregate count intentionally does not split source-caused from * target-caused (see that field's doc for why the split lives at the * per-edge, not per-bucket, level). Excluded from `staticCoverage`'s * denominator — see that field's doc for why, and for how this differs * from `queueMediated`'s exclusion. */ unobservable: number; /** * Observed pairs whose OWN evidence (`viaQueue`/`kind === "consumer"`) * says they were mediated by a queue, so no attempt was made to pair them * against a static edge at all — see the `"queue-mediated"` category's * doc on `EdgeDiffCategory` for why pairing was abandoned. Excluded from * `staticCoverage`'s denominator for a DIFFERENT reason than * `unobservable`: not "this engine couldn't see it", but "this engine * cannot safely say what static edge, if any, it corresponds to". The two * exclusions must never be merged into one vague "other" — see that * field's own doc. */ queueMediated: number; /** DISTINCT observed pairs, regardless of category — a trace with the same call 500 times counts once here. No longer the coverage denominator (see `staticCoverage`) — kept as context for how much observed data participated at all. */ observedTotal: number; /** * confirmed ÷ `staticCoverageDenominator` — THE number this feature exists * to produce (issue #23 §4). Deliberately NOT confirmed ÷ observedTotal * (PR8's original formula): that reading silently treats the OBSERVED side * as the baseline being graded against, which is exactly the asymmetry * this pivot exists to remove — static and observed are two co-equal fact * sources, each with its own blind spot (static can't see a runtime- * decided call; observed can't see anything whose telemetry never reaches * the collector, or a queue's other end). What this ratio actually answers * is narrower and honest about that: of the static edges that were even * ELIGIBLE for confirmation — comparable (not queue-mediated) AND * observable (not `unobservable`) — how many got one. `null` when the * denominator is 0, OR when the denominator is nonzero but too small to * support a meaningful percentage (below `MIN_COVERAGE_SAMPLE` inside * `computeTopologyEdgeDiff` — see `staticCoverageNaReason` for why) — * rather than defaulting to a 0%/100% that would misreport the cause. The * small-sample case is the SAME failure as the zero-denominator case, one * step later: `1/1 = 100%` reads exactly as authoritative as a real * sample would, even though a real incident showed it can be produced * purely by `observableTargetKinds` correctly shrinking the denominator * to just the confirmed edges — a reader screenshots "100% covered" and * the number lies about its own cause just as badly as the un-shrunk 5% * it replaced. */ staticCoverage: number | null; /** `confirmed + staticOnly` — always present, even when `staticCoverage` is `null`, so a caller/renderer never has to recompute it to explain the n/a (and, for the small-sample case, so the raw counts can still be shown even though the percentage is withheld). */ staticCoverageDenominator: number; /** * Set iff `staticCoverage` is `null` — WHY there was nothing to divide by * (or why what there was wasn't enough to divide meaningfully). Checked in * this order (the first that applies is reported): * 1. the static side produced no edges at all — nothing to compare full * stop, not a coverage question; * 2. NEITHER `observableComponents` NOR `observableTargetKinds` was * declared — both observability axes defaulted to "assume nothing is * observable" (see each field's own doc); * 3. `observableComponents` was never declared (but `observableTargetKinds` * was) — every static edge defaulted to unobservable on the source * axis regardless of its target; * 4. `observableTargetKinds` was never declared (but `observableComponents` * was) — every static edge whose target is an external dependency * defaulted to unobservable on the target-kind axis; * 5. both scopes WERE declared, but happen not to cover any actual static * edge (empty lists, or lists with no overlap) — everything still * landed in `unobservable`; * 6. the denominator IS nonzero but smaller than `MIN_COVERAGE_SAMPLE` — * a percentage would be technically computable but not meaningfully * representative (see `staticCoverage`'s own doc for the incident * this guards against). Distinct from 1-5: `staticCoverageDenominator` * is nonzero here, so a renderer should show the raw counts, not a * bare "n/a". * Never left for a reader to reverse-engineer from the other counts. */ staticCoverageNaReason?: string; /** * Whether the caller supplied an explicit `observableComponents` list (even * an empty one) — see `computeTopologyEdgeDiff`'s doc for why an * UNDECLARED scope's honest default is "assume nothing is observable" * (every would-be static-only edge becomes `unobservable`) rather than * "assume everything is observable" (which is the exact mistake that * produced the false "0 overlap ⇒ nothing works" reading this bucket * exists to prevent). This flag is what lets the render layer tell a * reader WHICH default is in effect, instead of leaving `unobservable === * staticOnly + unobservable` ambiguous between "I checked, none of this is * observable" and "nobody said". */ observableScopeDeclared: boolean; /** * Same idea as `observableScopeDeclared`, for the OTHER observability axis * — see `TopologyEdgeDiffOptions.observableTargetKinds`'s doc for why a * second axis exists at all (a real incident: `postgres`/`redis` edges * from an in-scope source were still unconfirmable, because the * observation method structurally cannot see a non-HTTP call — that is a * TARGET-kind blind spot, not a source-telemetry one, and conflating the * two would have reproduced the exact "signal lying about its own cause" * failure this whole feature exists to catch). */ targetKindsScopeDeclared: boolean; /** * Count of would-be static-only edges whose target is external but has NO * `toKind` at all, WHILE `observableTargetKinds` WAS declared AND the * edge's SOURCE is in `observableComponents` — i.e. edges where the * target-kind axis was the ONLY thing standing between this edge and * `static-only`, and it silently couldn't evaluate them. Distinct from * `!targetKindsScopeDeclared` (that means "nobody declared anything at * all"; this means "something WAS declared, but doesn't apply to every * edge because the adapter didn't tag all its targets"). The source-axis * precondition matters: an edge whose SOURCE is also out of scope is * `unobservable` regardless of whether its target ever gets a `toKind` — * the declared `observableTargetKinds` never had a chance to matter for * it either way, so counting it would inflate "your declaration didn't * reach these edges" with edges scope was never going to save in the * first place. Real risk this field catches: a caller declares * `observableTargetKinds` believing it now governs every external-target * edge whose source IS trustworthy, while some fraction of THOSE edges * keep getting classified purely on the SOURCE axis (see * `targetKindObservable`'s doc) with no visible sign that their scope * declaration didn't reach them — a silently narrower effect than * declared, the same class of failure as every other split in this * summary. Always computed (0 when nothing applies); a renderer should * only surface it as a warning when `targetKindsScopeDeclared` is true AND * this is nonzero — when the scope was never declared at all, the * existing `!targetKindsScopeDeclared` banner already covers it. */ targetKindsUncheckable: number; } /** One target-name pair the engine noticed LOOK alike but never actually matched as the same node — see `TopologyEdgeDiff.nameSimilarityHints`'s doc. */ export interface NameSimilarityHint { /** An id that only ever showed up on the observed side (never in the static model). */ observedId: string; /** An id the static side named that the observed side never confirmed. */ staticId: string; } export interface TopologyEdgeDiff { /** * `model.nodes` PLUS a node for every id an observed edge names that isn't * already one — see `TopologyNode`'s `observedOnly` marker. This is the * "组件未声明" degrade: an observed pair naming an id nothing else in this * command has ever heard of is neither dropped (that would hide a real * extractor blind spot or a stale/typo'd id) nor mis-attributed as a real * declared component — it renders as its own honestly-labeled node. */ nodes: TopologyNode[]; edges: TopologyEdgeDiffEdge[]; summary: TopologyEdgeDiffSummary; /** * A different id used on each side for what MIGHT be the same real * dependency — e.g. observed-only `"cache"` next to static-only * `"redis-cache"`. Real incident: the SAME dependency was being reported * as BOTH "a brand-new dependency we've never seen" (observed-only) AND "a * dead path" (static-only) purely because the adapter's static id and the * trace export's hostname-derived id disagree — the fifth instance of a * signal lying about its own cause in this feature's history. Deliberately * NOT auto-merged (see this array's computation for why: a hand-maintained * alias table is exactly the kind of thing this engine exists to avoid, * and a WRONG auto-merge would silently hide a real difference). Case- * insensitive SUBSTRING containment only (either direction) — no edit- * distance or other fuzzy metric: containment has near-zero false-positive * surface on real ids (`cache` ⊂ `redis-cache`) where edit-distance would * also fire on short, genuinely-unrelated ids. Only compares observed-only * target ids against static-only target ids — the two buckets whose * (in)ability to match is literally the question this hints at. A reader * decides whether to rename one side to align; the engine only ever * suggests, never merges. */ nameSimilarityHints: readonly NameSimilarityHint[]; /** * Count of rows (from EITHER side) with `from === to` that were excluded * from every bucket rather than silently entering one — see * `computeTopologyEdgeDiff`'s doc for the concrete incident (a browser RUM * span pointing at the app's own domain, misread by http.client-span * extraction as "this service calls itself"). The STATIC side's version * of this bug class was already fixed at the adapter layer (an env var * happening to hold the component's own address) — this check is * defense-in-depth for the OBSERVED side, which is externally-produced * data this engine does not control, not the primary guard. */ selfLoopEdgesExcluded: number; } /** One row of the `--compare-edges` file — see `loadObservedEdges`'s doc (cli/commands/topology.ts) for the file contract. Declared structurally here (not imported from the CLI layer) so this view module never depends downward on `cli/`. */ export interface ObservedEdgePair { from: string; to: string; /** * Queue-mediated discriminator — either this OR `kind === "consumer"` is * enough to route the pair straight to the `"queue-mediated"` category * (see that category's doc on `EdgeDiffCategory`). ANY row for a given * (from, to) pair carrying either flag makes the WHOLE pair queue-mediated * — even one that also happens to have a plain row for the same pair, * and even if a static edge exists for the exact same (source, target): * the observed evidence's own account of HOW the pair was seen wins over * an incidental endpoint-pair match (see `computeTopologyEdgeDiff`'s * classification loop for why treating that as "confirmed" would * fabricate a direct-call claim the data doesn't support). */ viaQueue?: boolean | undefined; /** Open string, not an enum — same "adapter-invented vocabulary" posture as `TopologyHint.toKind`. Only the literal `"consumer"` is currently interpreted (as an alternate queue-mediated signal to `viaQueue`); other values are accepted and ignored, not rejected. */ kind?: string | undefined; } export interface TopologyEdgeDiffOptions { /** * Component ids whose telemetry is trustworthy enough that "this edge * never showed up in the observed data" is real evidence, not silence — * see `TopologyEdgeDiffSummary.observableScopeDeclared`'s doc for the * (deliberately conservative) default when this is omitted entirely. * An id here that never appears as any static edge's source is harmless * (no classification depends on it) — this is a SOURCE-side allowlist, * not a claim that every listed id necessarily appears anywhere. */ observableComponents?: readonly string[]; /** * `toKind` values (same adapter-invented vocabulary as `TopologyNode`'s * `toKind`) whose EXTERNAL targets this observed dataset's collection * method can actually see — the TARGET-side counterpart to * `observableComponents`'s source-side allowlist. Real incident that * added this: an observed-edges file built from HTTP client spans could * see `toKind: "external"`/`"objectstore"` targets (real HTTP calls) but * structurally can NEVER see a `toKind: "datastore"` (direct Postgres/ * Redis connection) or `toKind: "queue"` (already handled separately — * see the `"queue-mediated"` category) edge, no matter how alive it is — * those protocols never produce an HTTP client span to trace. Without * this axis, those edges were landing in `static-only` ("maybe dead") * when the true reason was "this observation method never had a chance". * Same conservative-default posture as `observableComponents`: omitted * entirely → every external-target edge defaults to `unobservable` (see * `TopologyEdgeDiffSummary.targetKindsScopeDeclared`'s doc) rather than * assuming every external kind is covered. * * Only applies when the STATIC edge's target is itself an EXTERNAL node * (`TopologyNode.kind === "external"`) — a target that IS a declared * component is a plain service-to-service call, which the same parent- * child trace-chain methodology that produces `queue-mediated`'s sibling * "service" rows can always in principle observe; gating that on this * option too would conflate two different questions (component targets * are already covered by `observableComponents`'s SOURCE-side check). * * Also does not apply to an external target with NO `toKind` at all (the * adapter never tagged one) — that is a different situation from an * untrusted/undeclared KIND: this option's conservative default handles * "the adapter said X, but nobody declared X trustworthy"; an untagged * target gives it nothing to distrust, and gating on absence-of-a-kind * would silently change behavior for every adapter that never adopted * `toKind` at all (this option is scoped to adapters that DO tag it * meaningfully). An untagged external target's classification depends on * `observableComponents` alone, same as before this option existed. */ observableTargetKinds?: readonly string[]; } /** * PURE. Diffs `model.edges` (the extractor's own output) against * `observed` — see the module doc above for why this side, not a * hand-authored one. Bucketing is exactly the plan's own §4 wording: * both sides → confirmed; observed only → an extractor blind spot; * static only → EITHER a dead/untested path OR an observation gap, * and telling those two apart is exactly what `options.observableComponents` * is for (see `unobservable`'s doc on `EdgeDiffCategory` / `TopologyEdgeDiffSummary` * for the incident that made this a hard split rather than a footnote). */ export declare function computeTopologyEdgeDiff(model: TopologyModel, observed: readonly ObservedEdgePair[], options?: TopologyEdgeDiffOptions): TopologyEdgeDiff; /** * What `runTopology` hands `renderTopologyHtml` for the `--compare-edges` * outcome — a DISCRIMINATED result, not `TopologyEdgeDiff | undefined`, * because "the flag was never passed" and "the flag was passed but the file * was unreadable/invalid" must render (and log) differently: the latter is a * loud failure (see `loadObservedEdges`'s doc on why a bad file must never * silently degrade to "0 observed edges" — that would print a technically- * true but misleading `staticCoverage: null`/"n/a" instead of surfacing that * the comparison never ran at all). */ export type EdgeDiffOutcome = { status: "error"; message: string; } | { status: "ok"; diff: TopologyEdgeDiff; }; export interface TopologyHtmlMeta { title: string; stalenessBanner?: string | undefined; /** * Adapter-authored free text describing what fraction of real service * calls this adapter's `topology` hints actually cover (e.g. "17/62 (27%) * of non-test fetch call sites are named-env-URL reachable — this is the * subset this adapter tags"). ENGINE-AGNOSTIC BY DESIGN: this command has * no notion of "fetch call site" — only the adapter that wrote the * extractor knows its own coverage, so the note travels with the adapter * (an additive `Adapter.topologyCoverageNote` field), not hardcoded here. * Absent → a generic, still-honest fallback is shown instead of silence. */ coverageNote?: string | undefined; } /** * `edgeDiff` is the ONLY new parameter — omitted entirely (not just * `undefined`-valued) by every caller that doesn't pass `--compare-edges`, * so every branch below that reads it must produce EXACTLY the same output * as before this field existed. That is what keeps every pre-existing * `renderTopologyHtml(model, meta)` call (and the tests that assert on its * exact byte output) unaffected by this feature. */ export declare function renderTopologyHtml(model: TopologyModel, meta: TopologyHtmlMeta, edgeDiff?: EdgeDiffOutcome): string; //# sourceMappingURL=topology-html.d.ts.map