import type { Adapter, ImplementationFact, SignalKind } from "../../adapters/types.js"; import { type CoverageRatioStats } from "../../query/backtest.js"; import { type Inv1CheckResult } from "../../validate/inv1/check.js"; import type { T0Result } from "../../validate/types.js"; import { type TopologyModel } from "../../views/topology-html.js"; import { type BacktestResult } from "./backtest.js"; /** * Bumped 1 → 2 when `backtest`(判据 A) and `coverageRatio`(判据 C) were added * as REQUIRED Snapshot fields. `loadSnapshot` rejects anything whose * `schemaVersion` doesn't match — so a v1 artifact written by an * already-published version now correctly falls through to "no readable * prior snapshot" instead of being accepted and then crashing `diffSnapshots` * on `prev.backtest.ran` (v1 objects have no `backtest` key at all). * * Bumped 2 → 3 when `topologyEdges` (issue #23 阶段2 Q2, edge-granularity * topology drift) was added as a REQUIRED Snapshot field. Same rejection * contract as the 1→2 bump: a v2 artifact (no `topologyEdges` key at all) * correctly falls through to "no readable prior snapshot" instead of being * accepted and then handing `diffSnapshots` an `undefined.map` on * `prev.topologyEdges`. * * NOT bumped when issue #38 changed whether `topologyEdges` feeds `clean` * (see `SnapshotDrift.clean`'s doc) — that's a pure `diffSnapshots` behavior * change, not a `Snapshot` shape change. `topologyEdges` itself still has * the exact same required, `TopologyEdgeDigest[] | null` shape a v3 reader * expects, so a v3 artifact written by the OLD `clean` semantics remains * fully readable and diffs identically under the NEW ones. `SnapshotDrift` * (what changed) is never persisted — only `Snapshot` (what didn't) is. */ export declare const SNAPSHOT_SCHEMA_VERSION: 3; /** * A fact's stable identity for drift comparison. Deliberately DROPS the source * `line` — the extractor documents line as "not a stable anchor", so a cosmetic * line shift must NOT register as drift. `detail`/`unanalyzable` ARE compared: * a queue that becomes env-suffixed, or a name that goes unanalyzable, is a real * change worth surfacing. */ export interface FactDigest { signal: SignalKind; name: string; filePath: string; detail?: string; unanalyzable?: boolean; } /** * A topology edge's stable identity for drift comparison (issue #23 阶段2 * Q2) — deliberately COARSER than `FactDigest`. `from` is a declared * component id (or the reserved unattributed-bucket id, see * `views/topology-html.ts`'s `UNATTRIBUTED_NODE_ID`) — never a raw * `filePath` — because the whole point of this grain is that a call site * moving between two files of the SAME component must not register as a * change. `to` is whatever the fact's `TopologyHint.to` named. `toKind` rides * along for display only (see `diffSnapshots`'s edge key — it is NOT part of * the comparison identity) and is present only when `to` resolved to an * EXTERNAL node with a known kind. */ export interface TopologyEdgeDigest { from: string; to: string; toKind?: string; } /** * The T2 nightly artifact (Proposal 006 D2). A machine-readable full-scan * snapshot: the facts inventory plus T0/INV-1 health, stamped with the adapter * version and the scanned commit. Two uses: (1) a PR job reads it as a warm * starting point instead of re-scanning from cold; (2) two snapshots diff into a * drift report. 001 §6 red line: T2 NEVER gates a PR — this command is * standalone and `check` never calls it. */ export interface Snapshot { schemaVersion: typeof SNAPSHOT_SCHEMA_VERSION; /** The adapter that produced the facts — a bump here explains fact churn that isn't real drift. */ adapterVersion: string; /** HEAD of the scanned repo when known (git rev-parse), else undefined. */ commit?: string; /** ISO timestamp; injected so the artifact is reproducible in tests. */ generatedAt: string; t0: { ok: boolean; errors: number; warnings: number; }; inv1: { ran: boolean; skippedReason?: string; violations: number; unanalyzable: number; writePoints: number; }; /** * Sorted fact inventory (identity fields only — see FactDigest). EXCLUDES * topology-tagged facts (anything with `fact.topology !== undefined`, * e.g. `outbound_edge`/`dependency_client`) — those participate in drift at * EDGE granularity instead, via `topologyEdges` below, not here. Keeping * them in both places would double-count the same real-world change at two * granularities, and would let call-site-level noise (a call site's file * moving within its own component) leak back into `clean` through THIS * list even though `topologyEdges` correctly stays silent about it. */ facts: FactDigest[]; factCount: number; /** * Topology edges (issue #23 阶段2 Q2), collapsed to (from, to) identity — * see `TopologyEdgeDigest`'s doc for why this is a DISTINCT, coarser grain * from `facts` above rather than a replacement for it: a topology-tagged * fact is emitted ONE PER CALL SITE, so moving a `new OssClient()` from * file A to file B inside the same component is architecturally a no-op * but would be a removed-fact-plus-added-fact pair at fact granularity. * This list collapses that down to whether the (from, to) EDGE itself * appeared or disappeared — a genuinely new call target still registers, * a call site just moving house does not. Sorted by (from, to) — same * ordering `computeTopologyModel` (views/topology-html.ts) already * produces its edges in, reused here rather than re-derived (CONTRIBUTING: * 派生判断只定义一处). * * `null` means the edges could NOT BE COMPUTED (see * `topologyEdgesUnavailable`) — deliberately not `[]`, because "this target * genuinely has no edges" and "we failed to work out its edges" are opposite * facts and collapsing them makes the next run report every edge as removed. * config-file.ts states the same rule for the layer below: "'no config' is a * normal state, 'config you meant to write but broke' must never degrade * into it." */ topologyEdges: TopologyEdgeDigest[] | null; /** * Why `topologyEdges` is `null`, in words. Present iff it is null. * * Carried IN the artifact, not just logged at write time: whoever reads a * drift report the next morning is usually not whoever broke the config, * and "edges unavailable" without a reason sends them looking for an * architecture change that never happened. */ topologyEdgesUnavailable?: string; /** * 判据 A (issue #23 阶段1 PR1): commit-side backtest — of the last N * `.ts`/`.tsx`-touching commits, how many touched a model-anchored file. * Always present (never omitted) so a nightly consumer can tell "ran but * found nothing" apart from "field doesn't exist in this schema version" — * same convention as `inv1` above. `ran: false` when no repoRoot was given * (the snapshot is model-only) or the repo/ref couldn't be resolved. */ backtest: BacktestSnapshotSummary; /** * 判据 C (issue #23 §1): node-to-covered-file ratio, the counterweight to A * — A can be inflated by declaring more anchors without real per-file * coverage ("灌节点"); C is the number that catches that. Deliberately a * TOP-LEVEL sibling of `backtest`, NOT nested inside it: C needs only the * model graph (no git at all), so it must survive exactly the failures * that make `backtest.ran` false (bad ref, no repoRoot, broken components * config) — nesting it inside `backtest` would silently lose C on precisely * the nights A's git-side plumbing has trouble, defeating "nightly watches * both judgements at once". */ coverageRatio: CoverageRatioStats; timingMs: number; } export interface BacktestPartitionSummary { id: string; label: string; hit: number; total: number; } export interface BacktestSnapshotSummary { ran: boolean; skippedReason?: string; ref: string; windowRequested: number; commitsScanned: number; scanCapped: boolean; anchoredFileCount: number; overall: { hit: number; total: number; }; /** See BacktestComputation's doc (src/query/backtest.ts): rows can overlap, * don't sum them expecting `overall.total`. */ byRole: BacktestPartitionSummary[]; byComponent: BacktestPartitionSummary[]; componentsDeclared: boolean; } export interface SnapshotDrift { /** true when the adapter version differs — fact changes below may be extractor churn, not real drift. */ adapterBumped: boolean; addedFacts: FactDigest[]; removedFacts: FactDigest[]; /** same identity (signal+name+filePath), different detail/unanalyzable. */ changedFacts: { before: FactDigest; after: FactDigest; }[]; /** * EDGE-granularity topology drift (issue #23 阶段2 Q2) — a STRUCTURAL peer * of `addedFacts`/`removedFacts` above (same shape: two arrays of "what * changed"), but NOT a `clean` peer — see `clean`'s doc for why issue #38 * pulled edges back out of `clean` six hours after PR #40 had put them in. * See `Snapshot.topologyEdges`'s doc for why edges are a separate * granularity from `facts` at all. Identity is (from, to) only — see * `diffSnapshots`'s edge key — so a `toKind` classification improving * between two snapshots for the SAME (from, to) pair is not itself * surfaced here as an add+remove. * * MACHINE-READABLE CONSUMPTION CONTRACT (issue #38 §3 — target-repo * PR-side delivery; NOT YET WIRED into either this repo's CLI or any * target repo's CI, tracked as two separate open follow-ups): * A PR job wanting "which edges did THIS PR add" needs two `Snapshot`s * — a base one for the PR's merge-base commit and a current one for the * PR head — and computes `diffSnapshots(base, current).addedEdges`. * Each element is a plain `{ from, to, toKind? }` object (see * `TopologyEdgeDigest`), directly `JSON.stringify`-able for a PR-comment * payload — no additional serialization step is needed. Two gaps stand * between that and a working target-repo integration: * 1. (this repo, open) `loopgraph snapshot --drift` today only prints * `renderDrift`'s prose to stdout (see run.ts's `snapshot` case) — * there is no flag that emits `SnapshotDrift` itself as JSON, the * way `loopgraph backtest --json` already does for its own report. * `package.json` also has no `main`/`exports`, only `bin` — a PR * job cannot `import` this module directly today, so a CLI flag * (not a library import) is the realistic path. * 2. (target repo, open) obtaining the BASE snapshot for a PR's * merge-base commit — nightly already writes one to `actions/cache` * per this issue's own accounting, so a PR job would restore that * cache entry (or otherwise produce a base snapshot) before it can * call `diffSnapshots`. How that CI wiring happens is entirely the * target repo's call and out of this repo's scope. */ addedEdges: TopologyEdgeDigest[]; removedEdges: TopologyEdgeDigest[]; /** * Set when the edge comparison was SKIPPED because one side's edges could * not be computed (see `Snapshot.topologyEdges`). While set, `addedEdges` * and `removedEdges` are empty because nothing was compared — NOT because * nothing changed. This has no bearing on `clean` either way (issue #38: * `clean` ignores edges unconditionally now, skipped or not — see that * field's doc) — but `renderDrift` still always prints this reason so a * broken config never silently reads as "edges checked, nothing changed". */ edgesSkippedReason?: string; t0Delta: { errors: number; warnings: number; }; /** * INV-1 deltas. `unanalyzable` matters as much as `violations`: a write point * the canonical-writer scan can no longer analyze is a real loss of coverage, * not a no-op — it must not report as "no drift". */ inv1Delta: { violations: number; unanalyzable: number; }; /** * 判据 A hit/total before and after, when BOTH snapshots ran a backtest. * Deliberately EXCLUDED from `clean` below (see that field's doc) — the * trailing-N-commit window shifts on every run (new commits enter, old ones * roll off), so treating any hit/total change as "drift" would make `clean` * false almost every single night and defeat the whole point of a * quiet-on-no-change signal. `renderDrift` still always surfaces it, the * same way `adapterBumped` is surfaced without affecting `clean`. */ backtestDelta?: { hitBefore: number; totalBefore: number; hitAfter: number; totalAfter: number; }; /** * 判据 C deltas (node counts + covered-file count, before/after — see * `computeCoverageRatio`). UNLIKE `backtestDelta`, this DOES feed `clean` * below: it changes only when the MODEL itself changes (nodes added/ * removed, anchors added/removed), never as a side effect of which commits * happen to fall in tonight's trailing window — so a change here is real * target drift, exactly the kind `clean` exists to catch (a t0Delta/ * inv1Delta peer, not a backtestDelta peer). Compared as raw integer counts, * not the derived `ratio` float — the ratio is what a reader looks at, but * the counts are what actually changed and what a robust equality check * should compare. `nodesAnchored` (not just `nodesTotal`) is tracked * because it's the ratio's actual numerator post-correction — anchoring an * EXISTING node (nodesTotal unchanged) is exactly the healthy move this * plan's next steps make, and it must register as drift even though * `nodesTotal` alone wouldn't catch it. */ coverageRatioDelta: { nodesTotalBefore: number; nodesTotalAfter: number; nodesAnchoredBefore: number; nodesAnchoredAfter: number; coveredFileCountBefore: number; coveredFileCountAfter: number; }; /** * true when nothing MODEL-side changed (facts, T0, INV-1, 判据 C * node/covered-file counts). An adapter version bump alone does NOT make * it dirty (it's a tooling change, not target drift) — and neither does a * backtest window shift, see `backtestDelta`, nor a topology edge * appearing/disappearing, see `addedEdges`/`removedEdges` — but * `renderDrift` still surfaces all three so none is ever hidden. * * General rule (issue #38): a fact that can change for a reason OTHER * than the model actually drifting does not feed `clean` — it gets its * own always-visible group in `renderDrift` instead, so it stays fully * reportable without being able to devalue the one signal `clean` exists * to protect. Three instances of this judgment call exist so far: * - `backtestDelta`: EXCLUDED. The trailing-N-commit window shifts on * every run regardless of the target, so treating any shift as drift * would make `clean` false almost every night (see that field's doc). * - `coverageRatioDelta`: INCLUDED. It moves only when the model itself * changes (nodes/anchors added or removed) — never as a side effect * of which commits happen to fall in tonight's window — so it IS real * target drift (see that field's doc). * - `addedEdges`/`removedEdges`: EXCLUDED (issue #38, reversing the * edge-into-`clean` design PR #40 shipped roughly six hours earlier). * A new outbound call target is normal, expected development * activity — one that recurs on essentially every feature PR that * starts talking to something new — not "something that shouldn't * have happened". Folding it into `clean` makes `clean` false on * ordinary weeks, which defeats the whole point of a quiet-on-no- * change signal exactly the way `reconcile` mixing topology facts * into its unregistered-count once did (unregistered count 6 → 69, * real signal drowned out, fixed by introducing * `reconcilableSignalKinds`) — this is that same failure mode's * second instance. Edge changes are NOT hidden by this exclusion: * `renderDrift` always prints them in their own group regardless of * `clean`, and the target-repo PR-side delivery (issue #38 §3, not * yet wired — see `SnapshotDrift.addedEdges`'s doc) is meant to be * where they get a claimable, same-day reader instead of a nightly * one. * * `SnapshotDrift` (this type) is never persisted — only `Snapshot` is * (see `writeSnapshot`) — so this is a pure behavior change with no * `SNAPSHOT_SCHEMA_VERSION` bump: a `Snapshot` artifact written before * this change diffs identically after it, because `topologyEdges`'s shape * never changed — only how `diffSnapshots` scores it into `clean` did. * * SCOPE CAVEAT (pre-existing, restated here because removing edges makes * this verdict the headline): `clean` compares implementation facts, T0 / * INV-1 counts and the judgement-C aggregates. It is NOT a model-graph * equivalence check — no stable digest of node identities or relations is * stored, so an edit that keeps every count identical (repointing a Flow * `traverses` from one loop to another, say) still reads `clean: true`. * Read it as "none of the tracked signals moved", not "the model did not * change". Closing that gap needs a model digest in `Snapshot` and a schema * bump — deliberately out of scope for issue #38, tracked separately. */ clean: boolean; } /** * Assemble a Snapshot from already-run scan results. PURE — all non-determinism * (commit, timestamp, adapter version) is passed in, so it is fully unit-testable. */ export declare function buildSnapshot(meta: { adapterVersion: string; commit?: string | undefined; generatedAt: string; timingMs: number; }, t0: T0Result, facts: ImplementationFact[], inv1: Inv1CheckResult | undefined, backtest?: BacktestResult | undefined, coverageRatio?: CoverageRatioStats, /** * Undefined when no topology model could be built. Pair it with * `topologyEdgesUnavailable` to say WHY: absent-because-nothing-to-compute * (no repo-root/adapter) is a normal empty result, while * absent-because-something-broke must be recorded as unavailable, never as * an empty edge list (see `Snapshot.topologyEdges`). */ topologyModel?: TopologyModel | undefined, /** Set only when edges could not be computed; makes `topologyEdges` null. */ topologyEdgesUnavailable?: string | undefined): Snapshot; /** * Diff two snapshots into a drift report. PURE. Compares the fact inventory by * stable identity (added/removed/value-changed), the topology edge set by * (from, to) identity, and the T0/INV-1 counts. */ export declare function diffSnapshots(prev: Snapshot, curr: Snapshot): SnapshotDrift; export interface SnapshotOptions { repoRoot?: string | undefined; /** Machine cache dir for facts (B3); null disables. Default: the shared cache. */ cacheDir?: string | null | undefined; /** The adapter supplying the facts (Proposal 010: no default — undefined means no facts). */ adapter?: Adapter | undefined; /** Injected for reproducibility; defaults to now. */ generatedAt?: string | undefined; /** Injected for tests; defaults to `git rev-parse HEAD` of repoRoot. */ commit?: string | undefined; } /** * Run a full T2 scan and build the snapshot (Proposal 006 D2): T0 over the * model, facts over the repo (which also WARMS the machine cache — the whole * point of the nightly run), and a full INV-1 pass. Returns the snapshot; the * CLI persists it. Never called from the PR-gate `check` path (001 §6). */ export declare function runSnapshot(targetDir: string, options?: SnapshotOptions): Promise; /** Load a previously-written snapshot (for drift comparison). undefined if absent/unreadable. */ export declare function loadSnapshot(path: string): Promise; /** One-line human summary of a snapshot's health. */ export declare function renderSnapshotSummary(s: Snapshot): string; /** Human drift report between two snapshots (Proposal 006 D2). */ export declare function renderDrift(drift: SnapshotDrift): string[]; /** Persist a snapshot as pretty JSON, atomically (temp file + rename). */ export declare function writeSnapshot(path: string, snapshot: Snapshot): Promise; //# sourceMappingURL=snapshot.d.ts.map