diff --git a/.changeset/composition-graph-gaps.md b/.changeset/composition-graph-gaps.md new file mode 100644 index 00000000..f1c6a06a --- /dev/null +++ b/.changeset/composition-graph-gaps.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": major +--- + +Remove check surface routing: every check is now offered to the reviewer and the agent judges relevance, so the check `surface:` field, `selectChecksForSurfaces`, `RoutedCheck`, and `CheckRelevance` are gone. Checks bind to the fingerprint through an optional `source:` pointer (`node > Heading`) that `review` surfaces so a finding can cite the prose it enforces. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 2b20a39d..45ceda81 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-29T21:44:31.543Z", + "generatedAt": "2026-06-30T05:25:40.805Z", "tools": [ { "tool": "ghost", @@ -139,7 +139,7 @@ "tool": "ghost", "name": "checks", "rawName": "checks", - "description": "Select the markdown checks (ghost.check/v1) relevant to the named surfaces.", + "description": "List the markdown checks (ghost.check/v1) and ground the named surfaces.", "group": "core", "defaultHelp": true, "compactName": "checks", @@ -148,7 +148,7 @@ { "rawName": "--surface ", "name": "surface", - "description": "Surface id(s) the change touches (comma-separated or repeated). The agent names them.", + "description": "Surface id(s) the change touches (comma-separated or repeated). The agent names them; used to ground, not to filter checks.", "default": null, "takesValue": true, "negated": false diff --git a/docs/ideas/composition-graph-gaps.md b/docs/ideas/composition-graph-gaps.md new file mode 100644 index 00000000..6004d6dd --- /dev/null +++ b/docs/ideas/composition-graph-gaps.md @@ -0,0 +1,318 @@ +--- +status: exploring +companion: context-graph.md, scenarios-worked.md, contract-storage.md, phase-7b-grounded-checks.md +source: external POC (block-as-intelligence / .ghost) exercising Ghost as a multi-brand, multi-medium composition graph +--- + +# Composition-graph gaps: three schema changes a real composition graph forces + +An exploration, not a decision. A large external POC +(`block-as-intelligence/.ghost`) used Ghost 0.18 not as "the fingerprint of one +product surface" but as Ghost's *stated* next identity — a **curated, opinionated +context graph queried by traversal** (`context-graph.md`, +`fingerprint-first-architecture.md`). It modelled a whole company's interface +composition: `core → brand → audience → product` on the containment spine, with +`mediums/` and `contracts/` as lateral axes, and a scenario suite that resolves +**never-encoded signals** into traced outputs where *every interface decision +cites a node*. + +That use exercised the graph harder than any first-party fixture, and it surfaced +three gaps. Each is **already anticipated in our own idea docs** — this note +pins them to code, separates the genuine schema gaps from things that are +correctly the consumer's job, and traces what each nets out as. + +## Triage: what is ours vs. theirs + +The POC wanted several things. Most are **correctly out of scope** for Ghost and +belong in the consuming package's prose — Ghost's BYOA line ("CLI does +deterministic work; the agent interprets") is right to refuse them: + +| Want | Verdict | +|---|---| +| Conflict-precedence ("when two contracts collide, which wins?") | **Consumer's job.** Interpretation. Never a Ghost primitive. | +| Coverage-gaps / declared silence | **Consumer's job.** A prose node with a `description`; discoverable by traversal already. | +| Mode axis (`shape/implement/review/...`) | **Consumer's job.** A resolution-time concern, not graph structure. | + +The remaining three are **genuine schema gaps** — the consumer literally cannot +author around them, because they live in the layer Ghost computes on: + +1. **Sub-node decision identity** — the graph's smallest addressable unit is the + file; design decision is finer. +2. **Composition-fit edge types** (`governs`, `projects`) — the `relates` vocab + is from the abandoned similarity model; a composition graph needs functional + edges. *(Already flagged deferred in `node/types.ts`.)* +3. **Tree-aware check routing** — `surface:` is a flat slug, so checks cannot + follow the very tree we made load-bearing. + +--- + +## Gap 1 — Sub-node decision identity (`anchors`) + +### The problem, in code + +`ghost-core/node/types.ts` reads exactly three frontmatter keys into the graph +(`description`, `relates`, `incarnation`); `schema.ts` is `.passthrough()`, so +any other key is parsed and **dropped from `GhostGraphNode`**. The smallest thing +Ghost can address, gather, route, or lint is therefore the **node (file)**. + +But design decision-making is naturally **sub-node**. One persona node legitimately +holds a dozen distinct, separately-citeable decisions ("size to need not +eligibility", "verify before money action", "non-offer is a valid output"). The +POC encoded these as a passthrough `anchors:` block and cited them across its +scenario suite as `node-id#anchor`: + +```yaml +# resolution.yml +owned_by: cash-app/buyer/afterpay#size-to-need-not-eligibility +``` + +This is the load-bearing move of their entire proof — "every decision traces to +the node that owns it." And Ghost is **blind to all of it**: `ghost validate` +returns 0 errors whether that anchor exists or is a typo. The headline claim of a +composition graph — *traceability* — currently rests on references no Ghost verb +can resolve. + +### Why this is a real gap, not consumer over-reach + +`decisions[]` were **first-class in Ghost 0.2–0.4** and were dropped in the +node-graph rewrite (per `compare-drift-fleet-rethink.md` lineage). The POC is not +inventing a new primitive; it is **re-implementing one Ghost removed**, in prose, +because the graph still needs addressable units below the file. A "context graph +that captures *why*" whose finest addressable unit cannot name a single *why* has +a granularity gap. + +### Proposal + +Promote a fourth reserved key, `anchors`, to a **first-class, addressable** +node member — the named decisions a node owns. + +```yaml +# ghost.node/v1 frontmatter +anchors: + - id: size-to-need-not-eligibility + kind: required # stance | required | forbidden | interface_choice | holds + source: squareup/writing/references/.../fit.md # optional provenance (Gap 1b) +``` + +- `node/schema.ts`: add an optional `AnchorSchema[]`; ids are slugs unique within + the node. (Keep `.passthrough()` for everything else.) +- `graph/types.ts`: add `anchors: GhostAnchor[]` to `GhostGraphNode`. +- New resolvable ref form `#`, validated by graph-phase lint + exactly like `relates` targets: **unresolved `#anchor` is a lint error.** +- `gather` emits a node's anchors as an addressable list; `review` can ground a + finding on a specific `#anchor`. + +**Nets out as:** the citation `node#anchor` becomes a *checked fact*, not an +honor-system string. Traceability — the thing a composition graph exists to +provide — gets the same validation guarantee `relates` already has. This also +gives `compare`/`drift` (parked) a finer diff unit than the file when they return +graph-native. + +### Gap 1b — provenance on the decision (folded in) + +Old facet schema had `inventory.sources[] {kind: registry|file|url|package}` — +also dropped. The POC re-creates it ad-hoc by citing `squareup/writing/...` in +prose. If `anchors` land, an optional `source:` per anchor (above) re-lands +structured provenance at the decision grain, and `ghost verify --root` can check +those paths resolve — exactly what `verify` already does for exemplar paths. + +--- + +## Gap 2 — Composition-fit edge types (`governs`, `projects`) + +### The problem, in code + +`node/types.ts` closes the `relates` vocab to `reinforces | contrasts | variant` +and says, in a comment: + +> `governs` / `projects` are deliberately deferred (Scenario D and explicit +> medium projection) — not in v1. + +`scenarios-worked.md` confirms the workaround in the wild: *"a real **governs** +relationship (deferred; faked as a qualified relate)."* The POC hit this exactly: +**~95% of its edges are `reinforces`**, because that vocab is *similarity* +(descended from the deleted embeddings + 13-visual-dimension model), while a +composition graph's edges are *functional*: + +| Edge the POC needed | Forced to write | Should be | +|---|---|---| +| persona → the brands/mediums it can be expressed in | `reinforces` | **`projects`** (selectable axis) | +| persona/medium → a contract it must obey | `reinforces` | **`governs`** (obligation) | +| contract → contract it builds on | `reinforces` | `reinforces` ✅ (genuine) | +| notification ↔ conversational | `contrasts` | `contrasts` ✅ (genuine) | + +When one qualifier covers 95% of edges it has stopped discriminating and become +punctuation. The consumer cannot fix this — the vocab is closed in Ghost. + +### Proposal + +Land the two already-named-deferred kinds: + +```ts +export const GHOST_NODE_RELATION_KINDS = [ + "reinforces", "contrasts", "variant", + "governs", // obligation: target is a contract/decision the source must satisfy + "projects", // selectable axis: target is a form (brand/medium) the source can take +] as const; +``` + +- `governs` gives `review`/`checks` a **typed obligation edge** to ground on — a + finding can say "violates the `disclosure-surface` contract this node is + *governed by*", deterministically. +- `projects` makes the **selection axes** (brand, medium) machine-distinguishable + from lateral reinforcement. `gather` can then split a corridor's edges into + "forms this node can take" vs "contracts it must obey" — which is precisely the + resolution-time decision (*which brand? which medium?*) a generation pipeline + makes. + +Note `projects` overlaps conceptually with `incarnation` (both about "the form +the expression takes"); the design question is whether `projects` is an edge or +whether brand becomes a second projection tag alongside `incarnation`. Worth +resolving as part of "explicit medium projection" already noted in `types.ts`. + +**Nets out as:** the edge vocabulary stops being decorative. The flat-`reinforces` +smell disappears not by removing edges but by giving the two structural roles +(obligation, projection) real names. Composition becomes legible: gather a +persona and you can see, by edge type, what it *must obey* vs what *forms it can +take*. + +--- + +## Gap 3 — Tree-aware check routing + +### The problem, in code + +`check/lint.ts`: `const SURFACE_ID = /^[a-z0-9][a-z0-9_-]*$/` — a check's +`surface:` **must be a flat slug, no slashes.** But `route.ts` +(`selectChecksForSurfaces`) matches that slug against `ancestorChain`, which +returns full **path ids** (`cash-app/buyer/afterpay → cash-app/buyer → cash-app +→ core`). A flat slug can therefore only ever equal `core` or a **top-level** +node id. + +Consequence, hit live in the POC: a check meant to govern `buyer` worked while +`buyer` was top-level; the moment a `brand` tier nested it to `cash-app/buyer`, +**no legal `surface:` value could target it.** The tightest scope available +collapsed up to `cash-app` (also catching unrelated siblings). *The deeper and +more correct the graph gets, the coarser its governance gets* — a direct +contradiction of "the directory tree **is** the graph." + +### Proposal + +Allow `surface:` to be a **node ref (path id)**, not a flat slug: + +- `check/lint.ts`: replace `SURFACE_ID` with the existing `NodeIdSchema` / + `NodeRefSchema` from `node/schema.ts` (which already permits `/`). One-line + swap of the validator; routing in `route.ts` already compares against full ids, + so **`selectChecksForSurfaces` needs no change** — it starts working the moment + lint stops rejecting the slash. +- `surface-guard.ts` already validates a named surface against the graph menu and + suggests closest ids, so unknown/typo'd nested surfaces get the existing + `ERR_UNKNOWN_SURFACE` treatment for free. + +**Nets out as:** check placement regains parity with the node graph. A check can +govern any node at any depth and cascade to its descendants exactly as context +slices already do. This is the smallest change of the three (effectively a +validator swap) and arguably a **bug fix**, not a feature — routing was already +written to be path-aware; only lint was holding it to flat slugs. + +--- + +## Summary — what the three net out as + +| Gap | Change | Surface area | Nets out as | +|---|---|---|---| +| 1. Decision identity | `anchors[]` as reserved, addressable, `#`-citeable + lint-resolved | `node/schema.ts`, `node/types.ts`, `graph/types.ts`, graph-lint | **Traceability becomes verifiable.** Re-lands dropped `decisions[]` at file-grain; gives parked `compare`/`drift` a sub-file unit. | +| 1b. Provenance | optional `source:` per anchor + `verify` path check | as above + `verify` | Re-lands dropped `inventory.sources[]` at decision-grain. | +| 2. Edge types | add `governs`, `projects` to the relation vocab | `node/types.ts` (+ consumers that branch on kind) | **Edges stop being punctuation.** Obligation vs projection become machine-legible; ends the "fake it as `reinforces`" workaround the idea docs already admit. | +| 3. Check routing | `surface:` accepts a path id, not a flat slug | `check/lint.ts` (validator swap) | **Governance follows the graph.** Bug-fix-shaped; routing was already path-aware. | + +Common thread: **all three are Ghost finishing its own migration.** The pivot +from a similarity model (`fingerprint.yml` + embeddings + fixed visual +dimensions) to a graph model (prose nodes + typed edges) dropped `decisions[]`, +`inventory.sources[]`, and a functional edge vocab, and left check routing on the +pre-tree slug. The external POC didn't push Ghost past its design — it stress- +tested Ghost *toward* the identity Ghost's own docs already claim, and landed in +the crater the rewrite left. Closing these three makes "a curated context graph +queried by traversal" true for traceability, composition, and governance — not +just for retrieval. + +## Open questions + +- **`projects` vs `incarnation`:** one mechanism or two? Brand and medium are + both "forms the expression takes" — does brand become a second projection tag, + or is `projects` the general edge and `incarnation` its medium-specialization? +- **Anchor kinds:** is the kind vocabulary (`stance|required|forbidden|...`) part + of `ghost.node/v1`, or free-form passthrough on the anchor like `relates` is + optional? Leaning closed-but-small, mirroring the relation-kind decision. +- **Migration:** if `decisions[]` semantics return as `anchors`, should + `migrate-legacy.ts` map the old 0.2–0.4 `decisions[]` onto them? + +--- + +## Revision (post-implementation triage) + +The three proposals above were prototyped, then re-evaluated against two +minimalist references: the Open Knowledge Format (OKF) spec — *"the specific +kind of relationship is conveyed by the surrounding prose, not by the link +itself … consumers MUST tolerate broken links"* — and Vercel's *Teaching agents +product design*, whose Skill Integrity rule keeps deterministic checks mechanical +and keeps everything that needs interpretation in prose, with its evidence and +degree of freedom. The bar shifted from *"is this a real +gap?"* to *"does this earn schema an LLM doesn't need?"* Result: + +- **Gap 3 — superseded by removing routing entirely.** The fix (accept a node + path id as a check `surface:`) shipped briefly, then a sharper question landed: + in the internal agent-check flow, *every check always fires* — the agent judges + relevance. So the routing gate (`check.surface` → `selectChecksForSurfaces`) + had no live consumer. Routing was removed wholesale: `check.surface`, + `selectChecksForSurfaces`, `RoutedCheck`, and `CheckRelevance` are gone. The + `--surface` flag survives, but only to *ground* the named surfaces (it feeds + `resolveGraphSlice`), never to filter checks. This is the same lens applied to + the system itself: a whole subsystem built for a gating step that doesn't + happen. +- **Gap 2 — rejected.** The motivating problem (95% of edges typed + `reinforces`) is solved more cheaply by the *untyped* edge Ghost already + supports, plus one prose sentence. Typing `governs`/`projects` adds closed + vocabulary to a system whose cited ancestor (OKF) deliberately keeps edges + untyped, with no deterministic consumer that needs the split. Pulled. +- **Gap 1 — rejected as built; idea partially survives.** The `anchors[]` + array (closed `kind` enum, floating ids that bound to no text span, + unresolved-citation-as-hard-error) was the wrong *form* and was removed. But + the *citation idea* has real support — Vercel gives rules stable IDs that cite + their source (`Source: copy.md > Actionable`), and Ghost already does the same + one level coarser (review requires findings to cite grounding nodes; + `review-packet.ts` `required_finding_citations`). The chain + *finding → check → surface grounding* exists today at node granularity. + +### The correct successor to Gap 1 (built — and now the check's only graph binding) + +This shipped as an optional `source:` on the check, consumed by `review`. +It mirrors Vercel's `Source: copy.md > +Actionable` literally — as a **soft, optional pointer on the check**, not a +schema on every node. With routing gone (see Gap 3 above), `source:` is the +check's *only* binding to the graph: it tells the reviewer which prose the check +enforces, so a finding can cite the section it derives from. + +```yaml +# checks/destructive-names-action.md +name: destructive-names-action +description: Destructive CTAs follow Verb + Noun. +severity: high +source: checkout/payment > Confirmation # optional: node path + heading anchor +``` + +Design constraints (the lessons from this thread, encoded): + +- **Soft, OKF-tolerant.** An unresolved `source:` is a *warning*, never a hard + `validate` error. It "may simply represent not-yet-written knowledge." +- **Heading-anchored, not id-keyed.** `node > Heading` points at a real span of + prose (the markdown heading), so it can't drift into the "floating label that + binds to nothing" incoherence that sank `anchors[]`. No new id space, no + closed `kind` enum. +- **On the check (or finding), not on every node.** It rides the artifact that + makes a claim, exactly where Vercel puts it — not as a frontmatter array + bolted onto authoring. +- **Only when a consumer needs it.** `review` is that consumer: the routed-checks + section now renders `— enforces \`\`` and the prompt instructs the + agent to cite the check's `source:` section when it declares one. A future + `compare`/`drift` can consume the same field. diff --git a/packages/ghost/src/commands/checks-command.ts b/packages/ghost/src/commands/checks-command.ts index 7f7d7ce4..a61eac42 100644 --- a/packages/ghost/src/commands/checks-command.ts +++ b/packages/ghost/src/commands/checks-command.ts @@ -1,9 +1,8 @@ import type { CAC } from "cac"; import { + type GhostCheckDocument, type GraphSlice, - type RoutedCheck, resolveGraphSlice, - selectChecksForSurfaces, } from "#ghost-core"; import { resolveFingerprintPackage } from "../fingerprint.js"; import { loadChecksDir } from "../scan/checks-dir.js"; @@ -24,11 +23,11 @@ export function registerChecksCommand(cli: CAC): void { cli .command( "checks", - "Select the markdown checks (ghost.check/v1) relevant to the named surfaces.", + "List the markdown checks (ghost.check/v1) and ground the named surfaces.", ) .option( "--surface ", - "Surface id(s) the change touches (comma-separated or repeated). The agent names them.", + "Surface id(s) the change touches (comma-separated or repeated). The agent names them; used to ground, not to filter checks.", ) .option( "--package ", @@ -59,15 +58,14 @@ export function registerChecksCommand(cli: CAC): void { const { checks, invalid } = await loadChecksDir(paths.dir); // The agent names the touched surfaces (it analyzed the diff). Ghost - // routes + grounds for those surfaces; it does not infer from paths. + // grounds those surfaces; it does not infer from paths. Every check is + // offered — the agent judges relevance. const touched = parseSurfaceIds(opts.surface); // A named surface absent from the graph is an error, not a silent - // empty route — emit ERR_UNKNOWN_SURFACE with suggestions and stop. + // empty slice — emit ERR_UNKNOWN_SURFACE with suggestions and stop. if (guardSurfaces(loaded.graph, touched, opts.format)) return; - const routed = selectChecksForSurfaces(checks, loaded.graph, touched); - const incarnation = typeof opts.as === "string" && opts.as.length > 0 ? opts.as @@ -89,11 +87,12 @@ export function registerChecksCommand(cli: CAC): void { `${JSON.stringify( { touched_surfaces: touched, - checks: routed.map((r) => ({ - name: r.check.frontmatter.name, - severity: r.check.frontmatter.severity, - surface: r.check.frontmatter.surface ?? "core", - relevance: r.relevance, + checks: checks.map((check) => ({ + name: check.frontmatter.name, + severity: check.frontmatter.severity, + ...(check.frontmatter.source + ? { source: check.frontmatter.source } + : {}), })), ...(withGrounding ? { grounding } : {}), invalid, @@ -104,7 +103,7 @@ export function registerChecksCommand(cli: CAC): void { ); } else { process.stdout.write( - formatChecksMarkdown(touched, routed, grounding, invalid), + formatChecksMarkdown(touched, checks, grounding, invalid), ); } process.exit(0); @@ -133,25 +132,24 @@ function provenanceLabel( function formatChecksMarkdown( touched: string[], - routed: RoutedCheck[], + checks: GhostCheckDocument[], grounding: GraphSlice[], invalid: Array<{ file: string; message: string }>, ): string { - const lines = ["# Relevant Checks", ""]; + const lines = ["# Checks", ""]; lines.push( `Touched surfaces: ${touched.length ? touched.map((s) => `\`${s}\``).join(", ") : "none (core only)"}`, "", ); - if (routed.length === 0) { - lines.push("No checks govern the touched surfaces."); + if (checks.length === 0) { + lines.push("No checks defined."); } else { - for (const { check, relevance } of routed) { - const why = - relevance.kind === "own" - ? `own \`${relevance.surface}\`` - : `inherited from \`${relevance.surface}\` (via \`${relevance.via}\`)`; + for (const check of checks) { + const source = check.frontmatter.source + ? ` — enforces \`${check.frontmatter.source}\`` + : ""; lines.push( - `- **${check.frontmatter.name}** (${check.frontmatter.severity}) — ${why}`, + `- **${check.frontmatter.name}** (${check.frontmatter.severity})${source}`, ); } } diff --git a/packages/ghost/src/commands/review-packet.ts b/packages/ghost/src/commands/review-packet.ts index 27bb241d..23d67362 100644 --- a/packages/ghost/src/commands/review-packet.ts +++ b/packages/ghost/src/commands/review-packet.ts @@ -1,8 +1,7 @@ import { + type GhostCheckDocument, type GraphSlice, - type RoutedCheck, resolveGraphSlice, - selectChecksForSurfaces, UsageError, } from "#ghost-core"; import { loadChecksDir } from "../scan/checks-dir.js"; @@ -15,11 +14,12 @@ import { findUnknownSurfaces, UnknownSurfaceError } from "./surface-guard.js"; const DEFAULT_REVIEW_MAX_DIFF_BYTES = 200_000; /** - * Build an advisory review packet on the surface rails: for the agent-stated - * surfaces the change touches, select the markdown checks governing those - * surfaces and their ancestors, and ground each in the surface's fingerprint - * slice. The diff is embedded verbatim for the reviewer; it is not used to - * resolve surfaces (the agent already analyzed it and names the surfaces). + * Build an advisory review packet on the surface rails: ground the agent-stated + * touched surfaces in their fingerprint slices, and offer every markdown check + * for the reviewer to apply (the agent judges relevance against the diff and the + * grounded prose; a check's `source:` names the prose it enforces). The diff is + * embedded verbatim; it is not used to resolve surfaces (the agent already + * analyzed it and names the surfaces). */ export async function buildReviewPacket(options: { packageDir?: string; @@ -40,7 +40,6 @@ export async function buildReviewPacket(options: { const unknown = findUnknownSurfaces(loaded.graph, touched); if (unknown.length > 0) throw new UnknownSurfaceError(unknown); - const routed = selectChecksForSurfaces(checks, loaded.graph, touched); // Grounding is the gather slice: the prose nodes a finding can cite. const grounding = touched.map((surface) => resolveGraphSlice(loaded.graph, surface), @@ -51,7 +50,7 @@ export async function buildReviewPacket(options: { maxDiffBytes: options.maxDiffBytes, }), touched_surfaces: touched, - routed_checks: routed, + checks, grounding, invalid_checks: invalid, }; @@ -79,7 +78,7 @@ function baseReviewPacket( required_finding_citations: [ "diff location", "surface the change touches", - "routed check when blocking", + "the applicable check when blocking (cite its `source:` section when the check declares one)", "grounding ref (why / what) or local-evidence rationale when the surface is silent", "repair or intentional-divergence rationale", ], @@ -160,7 +159,7 @@ interface ReviewPacketBase { interface ReviewPacket extends ReviewPacketBase { touched_surfaces: string[]; - routed_checks: RoutedCheck[]; + checks: GhostCheckDocument[]; grounding: GraphSlice[]; invalid_checks: Array<{ file: string; message: string }>; } @@ -170,7 +169,7 @@ export function formatReviewPacketMarkdown(packet: ReviewPacket): string { Package: ${packet.package_dir} -Review this diff as a non-blocking design-language critic. Advisory findings must be evidence-routed and must cite: ${packet.required_finding_citations.join(", ")}. Do not fail the build unless the issue is tied to a routed check. Keep findings grounded in the touched surfaces' grounded nodes and routed checks; do not expand the review into unrelated audit categories. +Review this diff as a non-blocking design-language critic. Advisory findings must be evidence-routed and must cite: ${packet.required_finding_citations.join(", ")}. Do not fail the build unless the issue is tied to a check. Every check below is offered; judge which apply to this diff and the grounded prose, and ignore the rest. Keep findings grounded in the touched surfaces' grounded nodes and the applicable checks; do not expand the review into unrelated audit categories. Read the grounded nodes for each touched surface (own first, then inherited from ancestors, then related). When a surface's grounding is silent, label the reasoning provisional or report missing-fingerprint / experience-gap instead of pretending the fingerprint is more specific than it is. @@ -184,7 +183,7 @@ If the diff exposes missing fingerprint grounding or surface coverage, report it ${formatTouchedSurfacesSection(packet)} -${formatRoutedChecksSection(packet)} +${formatChecksSection(packet)} ${formatGroundingSection(packet)} @@ -220,18 +219,17 @@ function formatTouchedSurfacesSection(packet: ReviewPacket): string { return `## Touched Surfaces\n\n${surfaces}`; } -function formatRoutedChecksSection(packet: ReviewPacket): string { - const lines = ["## Routed Checks", ""]; - if (packet.routed_checks.length === 0) { - lines.push("No checks govern the touched surfaces."); +function formatChecksSection(packet: ReviewPacket): string { + const lines = ["## Checks", ""]; + if (packet.checks.length === 0) { + lines.push("No checks defined."); } else { - for (const { check, relevance } of packet.routed_checks) { - const why = - relevance.kind === "own" - ? `own \`${relevance.surface}\`` - : `inherited from \`${relevance.surface}\` (via \`${relevance.via}\`)`; + for (const check of packet.checks) { + const source = check.frontmatter.source + ? ` — enforces \`${check.frontmatter.source}\`` + : ""; lines.push( - `- **${check.frontmatter.name}** (${check.frontmatter.severity}) — ${why}`, + `- **${check.frontmatter.name}** (${check.frontmatter.severity})${source}`, ); } } diff --git a/packages/ghost/src/ghost-core/check/index.ts b/packages/ghost/src/ghost-core/check/index.ts index b272a293..8d37d87b 100644 --- a/packages/ghost/src/ghost-core/check/index.ts +++ b/packages/ghost/src/ghost-core/check/index.ts @@ -1,17 +1,13 @@ /** * Public surface for `ghost.check/v1` — markdown + frontmatter checks an agent - * evaluates (Ghost never runs them). Ghost routes them by surface and grounds - * their findings in the fingerprint. See docs/ideas/phase-7b-grounded-checks.md. + * evaluates (Ghost never runs them). Every check is offered to the reviewer; + * the agent judges relevance against the diff and the grounded prose. A check's + * optional `source:` names the fingerprint prose it enforces. */ export { lintGhostCheck } from "./lint.js"; export { loadGhostCheck } from "./load.js"; export { type ParsedCheckMarkdown, parseCheckMarkdown } from "./parse.js"; -export { - type CheckRelevance, - type RoutedCheck, - selectChecksForSurfaces, -} from "./route.js"; export { GHOST_CHECK_SCHEMA, GHOST_CHECK_SEVERITIES, diff --git a/packages/ghost/src/ghost-core/check/lint.ts b/packages/ghost/src/ghost-core/check/lint.ts index d615be45..b1e96f7c 100644 --- a/packages/ghost/src/ghost-core/check/lint.ts +++ b/packages/ghost/src/ghost-core/check/lint.ts @@ -1,3 +1,4 @@ +import { NodeIdSchema } from "../node/schema.js"; import { parseCheckMarkdown } from "./parse.js"; import { GHOST_CHECK_SEVERITIES, @@ -5,13 +6,11 @@ import { type GhostCheckLintReport, } from "./types.js"; -const SURFACE_ID = /^[a-z0-9][a-z0-9_-]*$/; - /** * Lint a Ghost check markdown file (`ghost.check/v1`): required frontmatter - * (`name`, `description`, `severity`), a known severity, a flat-slug `surface` - * when present, and a non-empty body. Ghost never executes the check — this only - * validates that it is well-formed and routable. + * (`name`, `description`, `severity`), an optional `source:` provenance pointer, + * and a non-empty body. Ghost never executes the check — this only validates + * that it is well-formed. */ export function lintGhostCheck(raw: string): GhostCheckLintReport { const issues: GhostCheckLintIssue[] = []; @@ -51,25 +50,26 @@ export function lintGhostCheck(raw: string): GhostCheckLintReport { }); } - const surface = frontmatter.surface; - if (surface !== undefined) { - if (typeof surface !== "string" || !SURFACE_ID.test(surface)) { + const source = frontmatter.source; + if (source !== undefined) { + // `source:` is a soft provenance pointer: `` with an optional + // `> ` anchor. The node-id part should resolve like a path id; a + // malformed shape is a *warning*, never an error, since it may name + // not-yet-written prose (OKF-style tolerance). + const nodePart = + typeof source === "string" ? source.split(">")[0].trim() : ""; + if ( + typeof source !== "string" || + !NodeIdSchema.safeParse(nodePart).success + ) { issues.push({ - severity: "error", - rule: "check-surface-invalid", + severity: "warning", + rule: "check-source-malformed", message: - "surface must be a flat slug (lowercase alphanumeric plus _ -, no dots)", - path: "surface", + "source should be a node path id with an optional `> Heading` anchor (e.g. 'checkout/payment > Confirmation')", + path: "source", }); } - } else { - issues.push({ - severity: "warning", - rule: "check-surface-unplaced", - message: - "check has no surface; it will govern the implicit `core` (applies everywhere). Add `surface:` to scope it.", - path: "surface", - }); } if (body.trim().length === 0) { diff --git a/packages/ghost/src/ghost-core/check/load.ts b/packages/ghost/src/ghost-core/check/load.ts index abafc654..08037135 100644 --- a/packages/ghost/src/ghost-core/check/load.ts +++ b/packages/ghost/src/ghost-core/check/load.ts @@ -32,8 +32,8 @@ export function loadGhostCheck(raw: string): GhostCheckDocument { : typeof frontmatter.turn_limit === "number" ? (frontmatter.turn_limit as number) : undefined; - const surface = - typeof frontmatter.surface === "string" ? frontmatter.surface : undefined; + const source = + typeof frontmatter.source === "string" ? frontmatter.source : undefined; return { frontmatter: { @@ -42,7 +42,7 @@ export function loadGhostCheck(raw: string): GhostCheckDocument { severity: severity as GhostCheckMarkdownSeverity, ...(tools ? { tools } : {}), ...(turnLimit !== undefined ? { turn_limit: turnLimit } : {}), - ...(surface ? { surface } : {}), + ...(source ? { source } : {}), }, body, }; diff --git a/packages/ghost/src/ghost-core/check/route.ts b/packages/ghost/src/ghost-core/check/route.ts deleted file mode 100644 index 1bc30674..00000000 --- a/packages/ghost/src/ghost-core/check/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ancestorChain } from "../graph/assemble.js"; -import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "../graph/types.js"; -import type { GhostCheckDocument } from "./types.js"; - -/** Why a check is relevant to a diff: placed on a touched surface, or cascaded. */ -export type CheckRelevance = - | { kind: "own"; surface: string } - | { kind: "ancestor"; surface: string; via: string }; - -export interface RoutedCheck { - check: GhostCheckDocument; - relevance: CheckRelevance; -} - -/** - * Select the markdown checks relevant to a set of touched surfaces, - * deterministically and with no LLM. A check governs a touched surface when its - * `surface:` equals that surface (own) or any **graph** ancestor of it - * (cascade) — the same ancestry the slice resolver uses for context. An - * unplaced check governs `core`, so it applies to every diff. - * - * Ghost selects and emits; it never runs the check. The host agent evaluates - * the markdown rule. - */ -export function selectChecksForSurfaces( - checks: GhostCheckDocument[], - graph: GhostGraph, - touchedSurfaces: string[], -): RoutedCheck[] { - // For each touched surface, the set of surfaces whose checks apply: itself - // plus its graph ancestors (up to and including core). Track, per governing - // surface, the nearest touched surface it cascades into (for provenance). - const governing = new Map(); - for (const touched of touchedSurfaces) { - record(governing, touched, { kind: "own", surface: touched }); - for (const ancestor of ancestorChain(graph, touched)) { - if (ancestor === touched) continue; - record(governing, ancestor, { - kind: "ancestor", - surface: ancestor, - via: touched, - }); - } - } - // core governs every diff even when no surface was touched. - record(governing, GHOST_GRAPH_ROOT_ID, { - kind: "own", - surface: GHOST_GRAPH_ROOT_ID, - }); - - const routed: RoutedCheck[] = []; - for (const check of checks) { - const placement = check.frontmatter.surface ?? GHOST_GRAPH_ROOT_ID; - const relevance = governing.get(placement); - if (relevance) routed.push({ check, relevance }); - } - return routed; -} - -/** - * Record a governing surface, preferring "own" over "ancestor" if both arise - * (a surface that is both touched and an ancestor of another touched surface - * reports as own). - */ -function record( - governing: Map, - surface: string, - relevance: CheckRelevance, -): void { - const existing = governing.get(surface); - if (existing && existing.kind === "own") return; - if (existing && relevance.kind === "ancestor") return; - governing.set(surface, relevance); -} diff --git a/packages/ghost/src/ghost-core/check/types.ts b/packages/ghost/src/ghost-core/check/types.ts index 04383706..64c80fc2 100644 --- a/packages/ghost/src/ghost-core/check/types.ts +++ b/packages/ghost/src/ghost-core/check/types.ts @@ -8,8 +8,8 @@ export type GhostCheckMarkdownSeverity = /** * A Ghost check: markdown + frontmatter, evaluated by an agent — never run by * Ghost. Shape-compatible with the established `.agents/checks` format, plus the - * Ghost addition `surface:` (the placement that routes the check, mirroring node - * placement). See docs/ideas/phase-7b-grounded-checks.md. + * Ghost addition `source:` (the fingerprint prose the check enforces). Every + * check is offered to the reviewer; the agent judges relevance. */ export interface GhostCheckFrontmatter { name: string; @@ -20,11 +20,17 @@ export interface GhostCheckFrontmatter { /** Max tool-use turns the check should spend (passthrough). */ turn_limit?: number; /** - * The surface this check governs. Ghost routes a diff to surfaces and selects - * checks placed on the touched surfaces and their ancestors. Absent means the - * check governs the implicit `core` (applies everywhere). + * Optional provenance: the fingerprint prose this check enforces, as a node + * path id with an optional `> Heading` anchor (`checkout/payment > Confirmation`). + * A soft pointer — `review` surfaces it so a finding can cite which section it + * derives from. An unresolved `source:` is tolerated: it may name + * not-yet-written prose. + * + * This is the check's only binding to the graph. Checks always fire; the host + * agent judges relevance against the diff and the grounded prose. `source:` + * tells it which prose the check enforces. */ - surface?: string; + source?: string; } export interface GhostCheckDocument { diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index 8abbcedb..63d79701 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -2,7 +2,6 @@ // --- Check (ghost.check/v1) — markdown checks, agent-evaluated --- export { - type CheckRelevance, GHOST_CHECK_SCHEMA, GHOST_CHECK_SEVERITIES, type GhostCheckDocument, @@ -15,8 +14,6 @@ export { loadGhostCheck, type ParsedCheckMarkdown, parseCheckMarkdown, - type RoutedCheck, - selectChecksForSurfaces, } from "./check/index.js"; // --- CLI exit-code contract --- export { EXIT, UsageError } from "./errors.js"; diff --git a/packages/ghost/src/scan/node-tree.ts b/packages/ghost/src/scan/node-tree.ts index 6d5066b1..2a312ae7 100644 --- a/packages/ghost/src/scan/node-tree.ts +++ b/packages/ghost/src/scan/node-tree.ts @@ -6,7 +6,7 @@ import { FINGERPRINT_MANIFEST_FILENAME } from "./constants.js"; /** * Reserved package-root entries that are never nodes. `checks/` is a reserved - * top-level subtree (the markdown checks that govern surfaces). The manifest is + * top-level subtree (the markdown checks an agent evaluates). The manifest is * the package anchor. * * NOTE: `checks/` is reserved at the package root only. Internal/nested reuse diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index b1af3ffc..efcc5714 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -78,7 +78,8 @@ The tree is the layout itself: ids and parents come from where files sit, so moving a node is a rename. Reserved at the package root: `manifest.yml` and the `checks/` subtree; every other `*.md` is a node. -Optional `ghost.check/v1` markdown checks live in `checks/*.md`, routed by surface. +Optional `ghost.check/v1` markdown checks live in `checks/*.md`; every check is +offered to the reviewer and the agent judges which apply. Use `ghost signals` as a stdout-only reconnaissance helper when an agent needs raw repo observations while authoring curated nodes. @@ -102,8 +103,8 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one | `ghost init [--template ]` | Scaffold `.ghost/` with a manifest and a core `index.md` node. | | `ghost scan [dir] [--format json]` | Report node/surface contribution. | | `ghost validate [file-or-dir]` | Validate the package: artifact shape and the node graph (links resolve, one root, acyclic). | -| `ghost checks --surface ` | Select and ground the markdown checks governing the named surfaces. | -| `ghost review --surface [--diff ]` | Emit an advisory review packet: touched surfaces, routed checks, and fingerprint grounding (diff embedded verbatim). | +| `ghost checks --surface ` | List the markdown checks and ground the named surfaces. | +| `ghost review --surface [--diff ]` | Emit an advisory review packet: touched surfaces, the offered checks, and fingerprint grounding (diff embedded verbatim). | | `ghost gather [node] [--as ]` | Compose a node's context slice (corridor spine + relates edges, plus spoke pointers), list the node menu, or rank the closest nodes for an inexact query. | | `ghost skill install` | Install this unified skill bundle. | @@ -138,14 +139,14 @@ evidence-backed node drafts, then ask the human to curate the claims. - Treat checked-in Ghost package nodes as the source of truth. - Generate from intent, inventory, and composition. -- Name touched surfaces to `ghost checks --surface`; the agent evaluates the markdown checks it governs. +- Name touched surfaces to `ghost checks --surface` to ground them; the agent evaluates which markdown checks apply. - Use local evidence as provisional when the fingerprint is silent. - Treat auto-drafted fingerprint edits as ordinary uncommitted draft work until the human curates them and Git review accepts them. - Treat fingerprint edits as ordinary Git-reviewed edits. - Validate with `ghost validate` before declaring fingerprint nodes useful. -- Run `ghost checks` to route checks and `ghost review` for the advisory packet. +- Run `ghost checks` to list checks and ground surfaces, and `ghost review` for the advisory packet. - Use a custom package dir (`--package` / `GHOST_PACKAGE_DIR`) only when present or requested. diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 5cdb0fb3..d751b6a0 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -89,6 +89,11 @@ the ask against. The agent names the node; Ghost never infers it from a path. ## Checks -`checks/*.md` are `ghost.check/v1` markdown, placed by `surface:` frontmatter -(unplaced = core = everywhere), routed to touched nodes. They validate generated -output; they are not generation input. Keep them deterministic. +`checks/*.md` are `ghost.check/v1` markdown. Every check is offered to the +reviewer; the host agent judges which apply to the diff and the grounded prose. +An optional `source:` names the fingerprint prose the check enforces — a node +path id with an optional `> Heading` anchor (`checkout/payment > Confirmation`) — +and `review` surfaces it so a finding can cite which section it derives from. +`source:` is a soft pointer: an unresolved one is a warning, not an error. +Checks validate generated output; they are not generation input. Keep them +deterministic. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index aff7907b..1d8a681a 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -627,7 +627,7 @@ describe("ghost CLI", () => { expect(result.code).toBe(0); expect(result.stdout).toContain("# Ghost Advisory Review"); expect(result.stdout).toContain("## Touched Surfaces"); - expect(result.stdout).toContain("## Routed Checks"); + expect(result.stdout).toContain("## Checks"); expect(result.stdout).toContain("## Grounding"); expect(result.stdout).toContain("diff location"); expect(result.stdout).toContain("surface the change touches"); @@ -635,7 +635,7 @@ describe("ghost CLI", () => { "grounding ref (why / what) or local-evidence rationale when the surface is silent", ); expect(result.stdout).toContain("Read the grounded nodes"); - expect(result.stdout).toContain("routed check when blocking"); + expect(result.stdout).toContain("the applicable check when blocking"); expect(result.stdout).not.toContain("Proposal Threshold"); expect(result.stdout).toContain("provisional and non-Ghost-backed"); expect(result.stdout).not.toContain("recommend-proposal"); @@ -729,7 +729,7 @@ describe("ghost CLI", () => { expect(packet.schema).toBe("ghost.advisory-review/v1"); expect(packet.finding_categories).toContain("experience-gap"); expect(Array.isArray(packet.touched_surfaces)).toBe(true); - expect(Array.isArray(packet.routed_checks)).toBe(true); + expect(Array.isArray(packet.checks)).toBe(true); expect(Array.isArray(packet.grounding)).toBe(true); expect(packet.proposal_types).toBeUndefined(); expect(packet.open_proposals).toBeUndefined(); @@ -1075,7 +1075,7 @@ experience_contracts: [] expect(result.stderr).toContain("Nothing to migrate"); }); - it("routes markdown checks to agent-stated surfaces", async () => { + it("lists every check (checks always fire) and grounds the named surface", async () => { const ghost = join(dir, ".ghost"); await mkdir(join(ghost, "checks"), { recursive: true }); await writeFile( @@ -1092,15 +1092,15 @@ experience_contracts: [] await writeFile(join(ghost, "email", "index.md"), "---\n---\n\nEmail.\n"); await writeFile( join(ghost, "checks", "brand.md"), - "---\nname: brand\ndescription: Brand voice.\nseverity: medium\nsurface: core\n---\n## Instructions\nVoice.\n", + "---\nname: brand\ndescription: Brand voice.\nseverity: medium\n---\n## Instructions\nVoice.\n", ); await writeFile( join(ghost, "checks", "checkout.md"), - "---\nname: checkout-color\ndescription: No raw color.\nseverity: high\nsurface: checkout\n---\n## Instructions\nFlag hex.\n", + "---\nname: checkout-color\ndescription: No raw color.\nseverity: high\nsource: checkout > Color\n---\n## Instructions\nFlag hex.\n", ); await writeFile( join(ghost, "checks", "email.md"), - "---\nname: email-links\ndescription: Email links.\nseverity: low\nsurface: email\n---\n## Instructions\nLinks.\n", + "---\nname: email-links\ndescription: Email links.\nseverity: low\n---\n## Instructions\nLinks.\n", ); const result = await runCli( @@ -1119,12 +1119,16 @@ experience_contracts: [] expect(result.code).toBe(0); const payload = JSON.parse(result.stdout); expect(payload.touched_surfaces).toContain("checkout"); + // Every check is offered — routing is gone; the agent judges relevance. const names = payload.checks.map((c: { name: string }) => c.name).sort(); - expect(names).toEqual(["brand", "checkout-color"]); - expect(names).not.toContain("email-links"); + expect(names).toEqual(["brand", "checkout-color", "email-links"]); + const checkoutColor = payload.checks.find( + (c: { name: string }) => c.name === "checkout-color", + ); + expect(checkoutColor.source).toBe("checkout > Color"); }); - it("grounds routed checks in the fingerprint slice", async () => { + it("grounds the named surface in the fingerprint slice", async () => { const ghost = join(dir, ".ghost"); await mkdir(join(ghost, "checks"), { recursive: true }); await mkdir(join(ghost, "nodes"), { recursive: true }); @@ -1141,7 +1145,7 @@ experience_contracts: [] ); await writeFile( join(ghost, "checks", "checkout.md"), - "---\nname: checkout-color\ndescription: No raw color.\nseverity: high\nsurface: checkout\n---\n## Instructions\nFlag hex.\n", + "---\nname: checkout-color\ndescription: No raw color.\nseverity: high\nsource: checkout > Color\n---\n## Instructions\nFlag hex.\n", ); const result = await runCli( diff --git a/packages/ghost/test/ghost-core/check-md.test.ts b/packages/ghost/test/ghost-core/check-md.test.ts index 6167802f..174c49fc 100644 --- a/packages/ghost/test/ghost-core/check-md.test.ts +++ b/packages/ghost/test/ghost-core/check-md.test.ts @@ -9,7 +9,6 @@ const VALID = `--- name: design-token description: Flag hardcoded colors. severity: high -surface: checkout tools: [Read, Grep] turn-limit: 20 --- @@ -57,18 +56,24 @@ describe("lintGhostCheck", () => { ); }); - it("errors on a dotted surface", () => { + it("accepts a source pointer with a heading anchor", () => { const report = lintGhostCheck( - VALID.replace("surface: checkout", "surface: email.marketing"), + VALID.replace( + "severity: high\n", + "severity: high\nsource: checkout/payment > Confirmation\n", + ), ); - expect(report.issues.some((i) => i.rule === "check-surface-invalid")).toBe( - true, + expect(report.issues.some((i) => i.rule === "check-source-malformed")).toBe( + false, ); }); - it("warns when a check has no surface (governs core)", () => { - const report = lintGhostCheck(VALID.replace("surface: checkout\n", "")); - expect(report.issues.some((i) => i.rule === "check-surface-unplaced")).toBe( + it("warns (does not error) on a malformed source", () => { + const report = lintGhostCheck( + VALID.replace("severity: high\n", "severity: high\nsource: /bad\n"), + ); + expect(report.errors).toBe(0); + expect(report.issues.some((i) => i.rule === "check-source-malformed")).toBe( true, ); }); @@ -78,7 +83,6 @@ describe("lintGhostCheck", () => { name: x description: y severity: low -surface: core --- `); expect(report.issues.some((i) => i.rule === "check-body-empty")).toBe(true); @@ -92,10 +96,19 @@ describe("loadGhostCheck", () => { name: "design-token", description: "Flag hardcoded colors.", severity: "high", - surface: "checkout", tools: ["Read", "Grep"], turn_limit: 20, }); expect(doc.body).toContain("Flag hex literals"); }); + + it("carries an optional source pointer through", () => { + const doc = loadGhostCheck( + VALID.replace( + "severity: high\n", + "severity: high\nsource: checkout/payment > Confirmation\n", + ), + ); + expect(doc.frontmatter.source).toBe("checkout/payment > Confirmation"); + }); }); diff --git a/packages/ghost/test/ghost-core/check-route.test.ts b/packages/ghost/test/ghost-core/check-route.test.ts deleted file mode 100644 index fd2d66a9..00000000 --- a/packages/ghost/test/ghost-core/check-route.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - assembleGraph, - type GhostCheckDocument, - type PlacedNode, - selectChecksForSurfaces, -} from "../../src/ghost-core/index.js"; - -function check(name: string, surface?: string): GhostCheckDocument { - return { - frontmatter: { - name, - description: `${name} desc`, - severity: "medium", - ...(surface ? { surface } : {}), - }, - body: "## Instructions\nDo the thing.", - }; -} - -function placed(id: string, parent: string): PlacedNode { - // Directory/index node: its file folder is its own id. - return { id, parent, folder: id, doc: { frontmatter: {}, body: "Prose." } }; -} - -// The directory tree that establishes the surfaces: -// checkout/ email/ email/marketing/ -const GRAPH = assembleGraph({ - placedNodes: [ - placed("checkout", "core"), - placed("email", "core"), - placed("email/marketing", "email"), - ], -}); - -const CHECKS = [ - check("brand", "core"), - check("checkout-color", "checkout"), - check("email-links", "email"), - check("marketing-unsub", "email/marketing"), - check("unplaced"), // governs core -]; - -function names(routed: ReturnType): string[] { - return routed.map((r) => r.check.frontmatter.name).sort(); -} - -describe("selectChecksForSurfaces", () => { - it("selects own + ancestor (core) checks for a touched surface", () => { - const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["checkout"]); - expect(names(routed)).toEqual(["brand", "checkout-color", "unplaced"]); - }); - - it("excludes checks on sibling branches", () => { - const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["checkout"]); - expect(names(routed)).not.toContain("email-links"); - expect(names(routed)).not.toContain("marketing-unsub"); - }); - - it("cascades multiple ancestor levels", () => { - const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["email/marketing"]); - // own marketing + ancestor email + ancestor core (brand, unplaced) - expect(names(routed)).toEqual([ - "brand", - "email-links", - "marketing-unsub", - "unplaced", - ]); - }); - - it("tags provenance own vs. ancestor", () => { - const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["email/marketing"]); - const byName = Object.fromEntries( - routed.map((r) => [r.check.frontmatter.name, r.relevance]), - ); - expect(byName["marketing-unsub"]).toEqual({ - kind: "own", - surface: "email/marketing", - }); - expect(byName["email-links"]).toMatchObject({ - kind: "ancestor", - surface: "email", - via: "email/marketing", - }); - }); - - it("with no touched surfaces, only core checks apply", () => { - const routed = selectChecksForSurfaces(CHECKS, GRAPH, []); - expect(names(routed)).toEqual(["brand", "unplaced"]); - }); - - it("an unplaced check governs core and applies to every diff", () => { - const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["checkout"]); - expect(names(routed)).toContain("unplaced"); - }); -});