diff --git a/.changeset/one-road-remove-binding-and-nesting.md b/.changeset/one-road-remove-binding-and-nesting.md new file mode 100644 index 00000000..229b2d87 --- /dev/null +++ b/.changeset/one-road-remove-binding-and-nesting.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +Remove the path→surface binding (`ghost.binding/v1`, `.ghost.bind.yml`) and all nesting (fingerprint stacks, cross-package discovery): one contract per package, surfaces are the only locality. `checks` and `review` now take agent-stated `--surface ` instead of resolving surfaces from a diff; `gather` takes only a surface or returns the menu. Removed `gather --path`, `checks --diff`, `lint --all`, `verify --all`, `scan --include-nested`, `emit --path`, `init --scope`, and `init --monorepo`. The agent names the touched surfaces; Ghost no longer infers intent from repo location. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 3e7b9474..7a96754d 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-26T12:58:29.867Z", + "generatedAt": "2026-06-27T06:23:34.307Z", "tools": [ { "tool": "ghost", @@ -21,14 +21,6 @@ "default": "cli", "takesValue": true, "negated": false - }, - { - "rawName": "--all", - "name": "all", - "description": "Validate every nested fingerprint package and its resolved fingerprint stack", - "default": null, - "takesValue": false, - "negated": false } ] }, @@ -42,14 +34,6 @@ "compactName": "init", "summary": "Create .ghost/ package facets.", "options": [ - { - "rawName": "--scope ", - "name": "scope", - "description": "Create a scoped /.ghost fingerprint package", - "default": null, - "takesValue": true, - "negated": false - }, { "rawName": "--package ", "name": "package", @@ -66,22 +50,6 @@ "takesValue": true, "negated": false }, - { - "rawName": "--monorepo", - "name": "monorepo", - "description": "Detect monorepo child package roots and propose scoped Ghost packages", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--apply", - "name": "apply", - "description": "With --monorepo, create detected child scoped packages", - "default": null, - "takesValue": false, - "negated": false - }, { "rawName": "--force", "name": "force", @@ -125,14 +93,6 @@ "default": "cli", "takesValue": true, "negated": false - }, - { - "rawName": "--all", - "name": "all", - "description": "Verify every nested fingerprint package and its resolved fingerprint stack", - "default": null, - "takesValue": false, - "negated": false } ] }, @@ -146,14 +106,6 @@ "compactName": "scan", "summary": "Report fingerprint contribution facets.", "options": [ - { - "rawName": "--include-nested", - "name": "includeNested", - "description": "Also list nested fingerprint packages and contribution state", - "default": null, - "takesValue": false, - "negated": false - }, { "rawName": "--format ", "name": "format", @@ -185,18 +137,10 @@ "compactName": "emit", "summary": "Emit review-command artifacts.", "options": [ - { - "rawName": "--path ", - "name": "path", - "description": "Resolve a nested fingerprint stack for this repo path", - "default": null, - "takesValue": true, - "negated": false - }, { "rawName": "--package ", "name": "package", - "description": "Use exactly this fingerprint package directory instead of resolving a stack", + "description": "Use exactly this fingerprint package directory (default: ./.ghost)", "default": null, "takesValue": true, "negated": false @@ -489,14 +433,6 @@ "takesValue": true, "negated": false }, - { - "rawName": "--path ", - "name": "path", - "description": "Resolve the surface that owns a repo path via its binding, then gather", - "default": null, - "takesValue": true, - "negated": false - }, { "rawName": "--format ", "name": "format", @@ -511,24 +447,16 @@ "tool": "ghost", "name": "checks", "rawName": "checks", - "description": "Select the markdown checks (ghost.check/v1) relevant to a diff, routed by surface.", + "description": "Select the markdown checks (ghost.check/v1) relevant to the named surfaces.", "group": "core", "defaultHelp": true, "compactName": "checks", "summary": "Select and ground the checks relevant to a diff, by surface.", "options": [ { - "rawName": "--base ", - "name": "base", - "description": "Git ref to diff against (default: HEAD)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--diff ", - "name": "diff", - "description": "Unified diff file to route instead of running git diff. Use '-' for stdin.", + "rawName": "--surface ", + "name": "surface", + "description": "Surface id(s) the change touches (comma-separated or repeated). The agent names them.", "default": null, "takesValue": true, "negated": false @@ -652,7 +580,15 @@ { "rawName": "--diff ", "name": "diff", - "description": "Unified diff file to review instead of running git diff. Use '-' for stdin.", + "description": "Unified diff file to embed in the review instead of running git diff. Use '-' for stdin.", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--surface ", + "name": "surface", + "description": "Surface id(s) the change touches (comma-separated or repeated). The agent names them.", "default": null, "takesValue": true, "negated": false diff --git a/docs/ideas/README.md b/docs/ideas/README.md index 9955add2..76a8d358 100644 --- a/docs/ideas/README.md +++ b/docs/ideas/README.md @@ -173,3 +173,37 @@ buildable Layer 2 design. They agree; read them as a sequence. package name resolved from `node_modules`; `ghost verify` checks the external contract resolves and its bound surfaces exist. Resolution + validation only; external fingerprint loading for grounding is deferred. +- `parked-survey-module.md` — a deliberate decision **not** to act: the + `ghost.survey/v1` module is isolated, works, and is unexposed, so it stays + parked. Removal is an excavation (compare/perceptual-prior may depend on survey + evidence), not a deletion — surfaced only if a concrete reason appears. +- `one-road.md` — a provocation turned decision: remove the binding + (`ghost.binding/v1`, path→surface, Cut D contract resolution) and drive + everything from the prompt. The agent already analyzes the whole repo, so it + states the touched surfaces; Ghost stops inferring intent from location. Four + outcomes collapse into one flow (prompt → menu → `gather `). + `checks`/`review` take agent-stated `--surface`; external contracts via + `gather --package`. Surface engine + nested-package discovery untouched. + Supersedes `surface-binding.md` / Phase 7a / `polish-cut-d-plan.md`. +- `contract-storage.md` — open exploration: the unexamined fork is **facet-first + vs. surface-first** storage, not "one giant yml." Storage is a projection too; + the loader (`assembleFingerprint`) is the only structural boundary that moves, + and the model + every read consumer are untouched. Surface-first colocates each + concept (a surface = a directory), makes `surface:` implicit-by-location + (inside the contract, not the repo), and mirrors the cascade with `core/` as + the cross-cutting home. Lands after one-road. Not decided. + +- `context-graph.md` — the reframe that subsumes the storage question: Ghost is + a **curated, opinionated context graph** queried by traversal, not a + file/bucket layout. The substrate (markdown + frontmatter folding into a graph) + is an **OKF-family** convergence we adopt; our deliberate divergences — **typed + links (`under` / `relates`) and the `medium` tag** — are the value. The whole + vocabulary is three nouns (node, link, medium), two link kinds, one tag; + `intent`/`inventory`/`composition` are how the body is written, not types. + See `scenarios-worked.md` for these as fully fleshed-out fingerprints (real + node files, bodies, links, `gather` packets). Includes the full conformance + schema. See `graph-implementation-plan.md` for the sequenced build (grounded in + the current code: the loader seam, `resolveSurfaceSlice` = gather, + `surfaces.yml` = the tree). Includes five + stress-test scenarios (dashboard, monorepo, marketing, voice super app, and one + brand spanning all of them). Downstream of one-road; not decided. diff --git a/docs/ideas/context-graph.md b/docs/ideas/context-graph.md new file mode 100644 index 00000000..0c30eece --- /dev/null +++ b/docs/ideas/context-graph.md @@ -0,0 +1,329 @@ +--- +status: exploring +--- + +# The context graph: Ghost as a curated, opinionated graph for generation + +This note records a shift in how we frame Ghost's model. It is downstream of +`one-road.md` (remove the binding + nesting) and `contract-storage.md` +(facet-first vs surface-first storage), and it reframes both: the real shape of +the problem is a **curated, opinionated context graph**, and the right context +for an agent to generate an interaction is found by **traversing** it. + +It composes with the build order already set: **one-road first**, storage and +anything here after. Nothing here is committed to code. + +## The shift + +We kept circling "which files, which buckets." That was the wrong altitude. The +question underneath is: **what is the model, really?** The answer the scenarios +forced: + +> A Ghost contract is a **curated graph of design-context nodes**, semantically +> connected by **typed links**, on a **tree** (the `under` links), folded from +> free-form files into one in-memory document, and **queried by traversal** to +> compute the right context packet for a generative act. + +Two things make it *Ghost* and not a generic knowledge graph: + +1. **It is authored / editorial, not extracted.** We take a stance — *"here's + what we think is the right pieces for a customer with a certain stance"* — not + "here's what statistically exists." (The explicit rejection of the + GraphRAG/extraction posture.) +2. **It serves generation across any medium.** Email, billboard, slide deck, + product page, profile, checkout, and eventually voice systems and AI-generated + screens. The unit is an **interaction**, not a "page." + +## What is and is not load-bearing + +- **`intent` / `inventory` / `composition`** are **ephemeral authorship + guidance** — three things a good author keeps in mind while building the + fingerprint (*have you captured the intent? do you have the inventory? is the + composition expressed?*). They live in the author's head (prompted by the + skill), guide the writing, then **dissolve.** They are **not** types, not a + `nature`, not a field, not a heading, not a node kind. Once the fingerprint + exists you cannot point at a node and say "that's the inventory one" — there + are just nodes, written well, that collectively happen to cover the three + because the author was thinking about them. **Zero presence in the schema, + loader, graph, or lint.** Their entire footprint is the skill's authoring + guidance (and maybe an `init` nudge). +- **`node` is machinery vocabulary, not public.** The graph is made of nodes; + the code, loader, schema, and lint speak in nodes. But a *user* never needs the + word — they speak the design language (the node ids and the prose) and run + `gather `. Like Git's "blobs/trees" backstage and "files/folders" up front. + ("surface" is retired from both layers — it was the old overloaded container + word; `node` replaces it in the machinery, the design prose replaces it for + users.) +- **"Conforms to a schema"** means **machine-tractability**, not conceptual + classification: a node has identity, resolvable links, and parses. That is the + entire conformance gate. +- **The core job** is to serve the best **context packet** for a task. The + surrounding machinery — trace, inspect, observability, lint, checks, loops, + compare, drift — exists to make that packet trustworthy and improvable. + Conformance exists to serve the machinery, not to constrain the guidance. + +``` + ┌─────────────────────────────────────┐ + prompt / task ──▶│ CORE JOB: serve the best CONTEXT │──▶ generation + (or system │ PACKET for this task, crafted for │ (any medium) + trigger) │ design generation │ + └─────────────────────────────────────┘ + ▲ + ┌──────────────┴──────────────────────┐ + │ MACHINERY: trace · inspect · │ + │ observability · lint · checks · │ + │ loops · compare · drift │ + └──────────────────────────────────────┘ +``` + +## Prior art: the substrate is converging (OKF) + +We are not alone in this shape. Google Cloud's **Open Knowledge Format (OKF)** +(`GoogleCloudPlatform/knowledge-catalog`, `okf/SPEC.md`, v0.1 draft) is the same +*substrate*, arrived at independently for *data* knowledge: a directory of +markdown files with YAML frontmatter, folded into a graph, queried by traversal, +shippable by `git clone`, with no registry and no server. Vercel's product-design +skill (markdown + frontmatter references) sits in the same family. + +This convergence is a strong signal: **"a directory of markdown+frontmatter that +self-describes and folds into a graph" is an emerging cross-industry standard.** +We should sit inside that family and stay `cat`-able and `git`-shippable rather +than over-inventing a substrate. + +### Where we agree with OKF (adopt, we already chose most of this) + +| Decision | OKF | Ghost | +| --- | --- | --- | +| Markdown + YAML frontmatter as the artifact | ✅ | ✅ | +| Envelope (frontmatter) + free body | ✅ | ✅ | +| `id` = file path with suffix removed | ✅ `tables/users` | ✅ `core/trust` | +| Folds into a graph, queried by traversal | ✅ | ✅ | +| Free organization; conformance is minimal | ✅ | ✅ | +| Permissive consumption (tolerate unknowns, broken links) | ✅ | ✅ | +| `git clone` = ship it; no registry, no server | ✅ | ✅ | +| Progressive disclosure (`index.md`) | ✅ | ≈ our `gather` menu | + +Worth stealing outright: OKF's **`index.md`** (optional, synthesizable +progressive disclosure — the static cousin of our `gather` menu) and **`log.md`** +(scoped, date-grouped change history — a lightweight way to carry "why did this +intent change" without a database). + +### Where we diverge from OKF, deliberately (this is our value) + +OKF deliberately stops where it does because it catalogs *static data knowledge* +(a table is a table). Ghost's value starts exactly there: + +1. **Typed links, not untyped prose links.** OKF §5.3: relationships are + "conveyed by the surrounding prose, not by the link itself … treated as + directed edges of an untyped relationship." Our links are typed — `under` + (containment) and `relates` (lateral). `trace` ("why is this in the packet?") + and `drift` ("has voice drifted across media?") need typed links to answer + structurally. +2. **An explicit tree — not directory-implied hierarchy.** OKF derives + parent/child from folders (§3). We reject that (the flat-vs-nested trap): the + tree is the **declared `under` links**, with exactly one medium-agnostic root + (the brand soul). Layout never implies hierarchy. +3. **A medium tag OKF has no concept of.** OKF's `type` is presentation/routing + metadata for a static asset. We carry **medium** so one intent (a parent node) + cascades into medium-bound children — the axis design generation lives on and + data catalogs do not. +4. **Editorial + runtime, not descriptive.** OKF describes what exists; Ghost + asserts what *should* be, carries **stances**, and has **medium-conditional + checks** consumable at **runtime** (not just authoring time). + +### Positioning + +Ghost is **OKF-family, specialized for design generation.** Strip our typed +links and medium tag and a Ghost contract degrades gracefully into a readable +OKF-ish bundle — a generic markdown-knowledge consumer can still `cat` our +nodes. We do not catalog what exists; we compute the right context to *generate* +an interaction in a given medium. + +## Vocabulary (and what we refused to add) + +Terminology sprawl is a real risk. The whole model needs **three nouns, two link +kinds, and one tag** — nothing more. + +| Word | Is | +| --- | --- | +| **node** | one markdown file: frontmatter + body | +| **link** | a typed pointer from one node to another | +| **medium** | an optional tag on a node (`email`, `voice`, `any`, …) | + +Links come in two kinds: + +- **`under`** — containment. Builds the tree; drives the cascade (a node inherits + from everything it sits under). +- **`relates`** — lateral composition (reinforces / contrasts / is-a-variant-of). + The flavor lives in prose or an optional qualifier, not as separate link types. + +**Refused, on purpose:** + +- *spine / backbone / envelope* — not objects. The "spine" is just the `under` + links; the "envelope" is just frontmatter. We use the plain words. +- *the `projects` edge* — collapsed away. A medium-specific expression is just a + **child node tagged with a `medium`** — i.e. `under` + a `medium` tag. One + mechanism, not two. No special projection edge. +- *a big edge vocabulary* — start with `under` and `relates` only. `governs` + (stances) is deferred until the runtime/voice scenario (D) is real. +- *intent / inventory / composition as types* — they are how the body is written + and read (guidance), never frontmatter types. + +## The Ghost-native extension + +One thing the OKF-family substrate lacks and we add: the **`medium` tag.** + +A node is either medium-agnostic (`medium: any` or omitted → cascades everywhere) +or medium-bound (`web`, `email`, `billboard`, `slide`, `voice`, +`generated-screen`, …). A contract declares whether medium matters at all +(single-medium products pay no medium tax — they never write `medium`). + +The "one brand, every medium" power — consistent yet varied, and traceable — is +not a new link type. It is just: shared intent lives in a parent node; each +medium-bound child sits `under` it and carries its own `medium`. The cascade +gives consistency; the child + tag gives the per-medium expression; the `under` +link gives traceability back to intent. + +## Scenarios that stress-tested the shape + +Drafted in full in chat; summarized here for the lessons they forced. + +- **A — simple dashboard.** The model must be near-invisible at small scale: + optional `medium`, a ~6-line tree. The first real payoff is a `relates` link + encoding a *considered exception* (item-detail bends the global density rule on + purpose). +- **B — monorepo, 3 products, shared visuals.** "One contract per package" + (one-road) is correct but incomplete: it needs a **cross-package ref grammar** + (`package#ref`) so siblings share brand DNA via an installed brand contract, + not copy-paste. Also: product / section / page are **one recursive node** at + different depths, not distinct kinds. +- **C — marketing (email, flyer, billboard, slides).** "A node = a place" dies; a + node is a *bounded intent* that may render into zero screens. The `medium` tag + becomes the heart of the value. Checks become **medium-conditional**. +- **D — generative voice-first super app.** The contract's job inverts: from + *describing* nodes to **governing generation at runtime**. Nodes become + *classes* the runtime instantiates. Would need a **`governs` link** (deferred) + for conditional decision rules, a **runtime check mode**, and the generation + trigger generalizes from "user prompt" to "any trigger, human or system." +- **E — all of the above are one brand (the superset).** The root is the brand + soul and **must be medium-agnostic**, or consistency collapses. The cascade + + medium tag is the unifying mechanism across A–D. E is realistically a *fleet + with a shared root* — proving the **cross-package ref grammar is mandatory, not + optional**. `compare`/`drift` find their highest purpose: coherence across the + children of one intent ("has marketing voice drifted from product voice?"). + +## The schema it conforms to + +Derived from the scenarios, kept to the minimal vocabulary above: nodes, two +link kinds (`under` / `relates`), and an optional `medium` tag. The substrate is +OKF-family; the `medium` tag is ours. + +### A node + +```yaml +--- +# REQUIRED (the conformance minimum) +id: core/trust # unique, addressable + +# OPTIONAL (defaults keep small scale invisible) +under: core # parent node — builds the tree, drives the cascade + # (omitted at the root) +relates: [checkout/payment] # lateral links (optional) +medium: any # omit or `any` = applies everywhere; else web/email/ + # billboard/slide/voice/generated-screen/… +--- +Prose body. The guidance. Intent / inventory / composition are how it is +written and read, not fields. +``` + +**A node is valid iff** it has an `id`, parses (frontmatter + body), and every +`under` / `relates` target resolves. Everything else defaults. The tree is the +set of `under` links — there is no separate spine object. + +### Naming a node (refs) + +``` + ::= ("/" )* # core/trust + ::= "#" # @acme/brand#core/trust +``` + +In-context the package prefix is omitted. `@acme/brand#core/trust` reaches a +node in an installed brand contract — how a brand spans repos/teams/cadences +without merge or stacks. Dangling refs are a lint error. + +### Checks + +A check is a node whose body is an assertion (the existing `ghost.check/v1` +form). It uses the same `under` (routing) and optional `medium` (when the +assertion is medium-specific). + +```yaml +--- +id: checks/billboard-brevity +under: marketing # applies to this node and everything under it +medium: billboard # only fires for the billboard medium +--- +≤ 6 words. Readable at 70mph. (agent-evaluated) +``` + +### Manifest (the defaults-that-extend seam) + +```yaml +package: "@acme/product" # this contract's id (for cross-package refs) +medium: web # default medium for nodes that omit it; omit if N/A +consumes: ["@acme/brand"] # installed brand/sibling contracts +``` + +### The whole thing in one frame + +``` +CONTRACT +├── manifest package, medium (default), consumes +└── nodes markdown files: { id, under?, relates?, medium? } + prose body + · the tree = the `under` links + · cascade = inherit from everything you're under + · medium = optional tag; absent = applies everywhere + · checks = nodes whose body is an assertion +``` + +**Three invariants make it gatherable** (format-free — the real spec): + +1. **Identity** — every node has an `id`. +2. **Resolvable links** — every `under` / `relates` target resolves (local or + `package#ref`); the union folds losslessly into one graph. +3. **One root** — exactly one node with no `under` (the brand soul), and it is + medium-agnostic. + +Everything else — file names, dirs, one-file-vs-many — is a **free projection** +over this. + +## Open questions + +1. **The rename.** "Surface" now means screen (A), product (B), message (C), and + class (D) at once. The unit wants one medium- and instance-agnostic name: + **node** (or **interaction**), with "surface" dropped or demoted to "a + web-medium node." E forces this. +2. **Is `medium` ever multi-valued?** D suggests a voice turn that also summons a + screen. Leaning single-valued to start; revisit only if D becomes real. +3. **Runtime consumption.** D needs the packet compact and machine-actionable + enough to inject into a live generation loop (latency, token budget). A new + requirement the current authoring-time framing does not contemplate. +4. **Cross-package ref resolution.** The `package#ref` grammar is mandatory + (B, E) but unspecified: how are `consumes` packages located and version-pinned? +5. **How loose is conformance allowed to be?** Pure invariants (lint is the only + guardrail) vs. recommended-shape-with-escape-hatches. Leaning the latter + (pragmatic): ship templates/defaults, tolerate deviation that holds the three + invariants. (The Style-Dictionary lesson: easy defaults, deep customization, + stable contract.) + +## Read-back + +This note is right if it establishes that the model is a **curated, opinionated +context graph queried by traversal**, not a file/bucket layout; that the +substrate (markdown + frontmatter folding into a graph) is an OKF-family +convergence we adopt; that our deliberate divergences from OKF — **typed links +(`under` / `relates`) and the `medium` tag** — are precisely our value; that +`intent`/`inventory`/`composition` are how the body is written, not types; that +the whole vocabulary is **three nouns (node, link, medium), two link kinds, one +tag**; and that the schema above is one projection of a model defined by three +invariants (identity, resolvable links, one medium-agnostic root). diff --git a/docs/ideas/contract-storage.md b/docs/ideas/contract-storage.md new file mode 100644 index 00000000..045bd745 --- /dev/null +++ b/docs/ideas/contract-storage.md @@ -0,0 +1,180 @@ +--- +status: exploring +--- + +# Contract storage: facet-first vs. surface-first + +An open exploration, not a decision. The question underneath "do we need one +giant yml": **how should the contract organize itself on disk?** We never chose +this — the facet files (`intent.yml`, `inventory.yml`, `composition.yml`) predate +surfaces, and `surface:` was bolted on. This note examines the real fork and +traces every domino, so the choice gets made on purpose. + +## The principle this rests on + +`purposes.md`: *one model, many projections.* We applied it to reads (consumers) +but never to **storage**. Storage is a projection too. The model is "surfaces + +placed nodes + edges"; the on-disk layout is one serialization of it. The +**loader** is the boundary — change the layout, change only the loader, and the +in-memory `GhostFingerprintDocument` stays identical. + +This is the load-bearing architectural fact (verified in code): + +``` +files on disk ──(loader: assembleFingerprint)──▶ GhostFingerprintDocument ──▶ everything +``` + +Every consumer — `resolveSurfaceSlice`, `gather`, `selectChecksForSurfaces`, +`groundSurface`, lint, verify, compare — operates on the **assembled in-memory +object**. None of them read files. So storage layout is a loader concern and +nothing else. That is what makes this change tractable rather than sweeping. + +## The two layouts + +### Facet-first (today, inherited) + +``` +.ghost/ + manifest.yml + intent.yml # ALL principles/contracts/situations, each tagged surface: + inventory.yml # ALL exemplars/building-blocks, each tagged surface: + composition.yml # ALL patterns, each tagged surface: + surfaces.yml # the tree + edges + checks/*.md +``` + +- ✅ Cross-cutting coherence at a glance ("is our voice consistent across the + product?" — read one file). +- ❌ A surface is scattered across three files; "everything about checkout" is a + filter, not a place. Editing one surface touches shared files → merge + conflicts, fuzzy ownership. +- ❌ `surface:` is a tag repeated on every node — the awkwardness already noticed. + +### Surface-first (the alternative) + +``` +.ghost/ + manifest.yml + surfaces.yml # the tree + edges (the spine stays) + core/ # cross-cutting: voice, trust, accessibility + intent.yml + inventory.yml + composition.yml + email/ + intent.yml # everything email, colocated + ... + checkout/ + intent.yml # independently ownable / reviewable + ... + checks/*.md +``` + +- ✅ A surface is a **place**: "everything about checkout" is a directory. +- ✅ Independent edit / review / ownership per surface (CODEOWNERS-friendly). +- ✅ **`surface:` disappears** — a principle in `checkout/intent.yml` is on + checkout because of where it lives *in the contract*. Placement becomes + implicit-by-location, but location *inside the portable artifact*, never the + repo. (The ergonomic win of folders with none of the binding's repo-coupling.) +- ❌ Cross-cutting coherence now spans directories (mitigated by `core/` holding + the cross-cutting facets). + +## Why surface-first fits the model better + +The realization that drove this: **surfaces are concepts, and a concept is +coherent.** "Email" is one idea; its intent, material, and patterns belong +together. Facet-first shreds each concept across three files for the sake of a +cross-cutting view that is the *rarer* need. Surface-first colocates the concept +and puts the cross-cutting stuff at `core/` — which is exactly the cascade shape +(`core` is the universal ancestor). **The storage mirrors the model.** + +## The domino effect (traced through the code) + +The boundary holds beautifully — the blast radius is the loader and the things +that *write/scaffold* files, not the things that *read the model*. + +### Changes (the loader + writers) + +1. **`scan/fingerprint-package-layers.ts` (the loader)** — the real work. + Today: read three files, `assembleFingerprint`. New: read `surfaces.yml`, + then for each surface dir (`core/`, `email/`, …) read its facet files, + stamp each node's `surface` from the dir name, and merge into one + `GhostFingerprintDocument`. **`assembleFingerprint` becomes a fold over + surface dirs instead of three fixed files.** This is the keystone change. +2. **`scan/fingerprint-package.ts`** — `FingerprintPackagePaths` stops being + fixed file paths; becomes "the package dir + a way to enumerate surface + dirs." `init` scaffolds `core/` instead of flat facet files. +3. **`init` / templates** — scaffold `surfaces.yml` + `core/{intent,inventory, + composition}.yml` instead of flat facets. +4. **`migrate`** — gains a second job: not just legacy→surface placement, but + facet-first→surface-first re-filing (read tagged nodes, write them into their + surface's dir). Natural fit; the migrator already groups by surface. +5. **`lint` / `file-kind`** — a facet file's *kind* no longer implies its + surface; lint reads the dir context. The `surface:` field on nodes is + removed from the schema (location replaces it). +6. **`scan` status / contribution** — "which facets contribute" becomes "which + surfaces contribute," reported per-surface dir. + +### Does NOT change (the model + all read consumers) + +- **`GhostFingerprintDocument`** in-memory shape — identical. The whole point. +- **`resolveSurfaceSlice`, `ancestorChain`, `buildSurfaceMenu`, + `groundSurface`, `selectChecksForSurfaces`** — untouched. They consume the + assembled object; they never knew how it was stored. +- **`gather`, `checks`, `review`** — untouched. +- **`surfaces.yml`** — unchanged. The tree/edge spine stays a flat id-ref list + (the right call from the flat-vs-nested discussion: one referencing mechanism + for both containment and composition). +- **compare / drift / fleet** — read the assembled doc; untouched. + +So: **one hard change (the loader), a handful of writer/scaffold updates, zero +change to the model or any read path.** The `assembleFingerprint` seam is what +contains it. + +## The schema consequence worth naming + +Surface-first **removes `surface:` from the node schemas** — it's now implied by +file location. That is a real schema change (and a `major`), but it's a +*simplification*: nodes get smaller, the "unplaced = core" rule becomes +"lives in core/ = core," and the placement-lint warning ("add a surface:") +disappears because placement is structural. + +The one subtlety: a node that genuinely applies to several surfaces. Facet-first +"solved" this by... not (you picked one surface or core). Surface-first: it lives +in `core/` (cascades to all) or, for the rare diagonal, the surface that owns it +plus a typed `edge`. Same answer as today, no worse. + +## Interaction with one-road + +These compose cleanly and should land in order: **one-road first** (remove the +binding/nesting — it touches commands and `fingerprint-stack.ts`), **then +storage** (reorganize the contract's internals). One-road removes repo-coupling; +storage improves the artifact's own filing. They do not overlap — different +files, different concerns — but doing storage first would mean re-touching the +loader twice. + +## Open questions (genuinely undecided) + +1. **Surface-first vs. facet-first** — the core fork. Surface-first fits the + "concepts are coherent" model; facet-first keeps cross-cutting coherence. + Leading candidate: **surface-first with `core/` as the cross-cutting home.** +2. **One facet file per surface dir, or one merged file per surface?** + `checkout/intent.yml` + `checkout/composition.yml`, vs. a single + `checkout.yml` with intent/inventory/composition sections. The latter is + fewer files; the former matches today's facet split. (A single file per + surface may be the real "small shape" — one file = one concept.) +3. **Does `surfaces.yml` stay separate, or does the tree become implied by the + directory layout?** Directory nesting *could* imply `parent` — but that + reintroduces the flat-vs-nested trap (structure as a second encoding of + hierarchy, fighting edges). Recommendation: **keep `surfaces.yml` flat and + explicit**; dirs are just where nodes live, not where the tree is declared. +4. **Empty/sparse surfaces** — a surface in `surfaces.yml` with no dir yet. + Fine (it contributes nothing); but lint should probably note it. + +## Read-back + +This note is right if it establishes that "one giant yml" was never the +question; the real, unexamined fork is **facet-first vs. surface-first storage**; +the loader (`assembleFingerprint`) is the only structural boundary that moves; +the model and every read consumer are untouched; surface-first makes `surface:` +implicit-by-location (inside the contract, not the repo) and mirrors the +cascade; and it composes with one-road by landing after it. diff --git a/docs/ideas/graph-implementation-plan.md b/docs/ideas/graph-implementation-plan.md new file mode 100644 index 00000000..292aa77f --- /dev/null +++ b/docs/ideas/graph-implementation-plan.md @@ -0,0 +1,228 @@ +--- +status: exploring +--- + +# Implementation plan: the context-graph model in code + +Turns `context-graph.md` + `scenarios-worked.md` into a sequenced build. Grounded +in the **actual** current code, not a greenfield sketch. Read those two notes +first for the model; this note is the *how*. + +## The load-bearing code fact (verified) + +The current code already has the shape's bones: + +``` +files ──(loadFingerprintPackage → assembleFingerprint)──▶ GhostFingerprintDocument ──▶ everything + ▲ the ONE structural seam +``` + +- `GhostFingerprintDocument` (ghost-core/fingerprint/types.ts) — the in-memory graph. +- `resolveSurfaceSlice` (ghost-core/surfaces/resolve.ts) — **this is `gather`**: walks + the ancestor chain + one-hop typed edges, already tracks `SliceProvenance` + ("own" / "ancestor" / "edge"). +- `surfaces.yml` (ghost-core/surfaces/types.ts) — already the tree: `parent` + (= our `under`), typed `edges` (= our `relates`, closed vocab + `composes`/`governed-by`), implicit `core` root. +- Checks already route separately (check/route.ts, selectChecksForSurfaces, + groundSurface). + +**Every read consumer works on the in-memory object and never reads files.** So +the model change is contained to: the node shape, the loader, and the writers — +exactly as `contract-storage.md` predicted. + +## Concept → code mapping + +| Model | Today | Change | +| --- | --- | --- | +| node | typed YAML sub-objects (principle/situation/pattern/exemplar) in 3 facet files | **markdown file: frontmatter + prose body** | +| `under` | `GhostSurface.parent` + `core` root | keep; rename surface→node later | +| `relates` | `GhostSurfaceEdge` (2 kinds) | keep; widen vocab + add a qualifier | +| relationship-node | (none — only edges) | **new: a node whose body is the relationship** | +| `medium` | (none) | **new: optional frontmatter tag** | +| `gather` | `resolveSurfaceSlice` | extend with medium filter; otherwise reuse | +| checks | check/route.ts (markdown already) | add `medium` + `when` frontmatter | +| in-memory graph | `GhostFingerprintDocument` | keep shape; nodes carry body + medium | + +## The three real gaps (everything else is rename/extend) + +1. **Node bodies become markdown.** Today intent/inventory/composition are + separate YAML files with typed schemas per node. New: one node = one markdown + file; intent/inventory/composition are **body headings**, not files or types. + The loader stops parsing typed facet objects and starts parsing + frontmatter+body nodes. +2. **`medium` tag.** New optional frontmatter field; threads through gather + (filter), checks (scoping), and lint (root must be medium-agnostic). +3. **Relationship-nodes.** The OKF "joins" borrow: a node that *is* a + relationship, with endpoints in frontmatter and rationale in the body. + +## Sequencing — each phase green, each shippable + +### Phase 0 — one-road (prerequisite, already planned) + +Build `one-road.md` first. Removes the binding + nesting, frees the path +helpers, makes `checks`/`review` take agent-stated nodes. **Do not start the +graph work until one-road lands** — it touches the same command surface and the +loader's neighbours. No overlap if sequenced; double-work if not. + +### Phase 1 — the node model (schema + types, no loader yet) + +The keystone, done in isolation so it can be reviewed before anything depends on +it. + +- Define the **node frontmatter schema**: `id` (required), `under?`, `relates?` + (with optional qualifier), `medium?`, plus body. One schema for *all* nodes — + the role (principle/pattern/exemplar) is inferred from body headings, not a + typed kind. +- Define the **relationship-node**: same envelope, frontmatter carries + `relates: [a, b]` with no `under`; body is the rationale. +- Add `medium` as an open string enum (`any` | known media | custom). +- Define the new in-memory shape: a flat `nodes: GhostNode[]` + the existing + tree, instead of `intent/inventory/composition` typed buckets. Keep a + `GhostFingerprintDocument` *facade* if it reduces consumer churn. +- Unit tests on the schema only. No I/O. + +### Phase 2 — the loader (the one hard change) + +Rewrite `loadFingerprintPackage` / `assembleFingerprint` as a **fold over node +files**: + +1. discover node markdown files in the package (glob; layout-free), +2. parse each (frontmatter + body) — reuse `scan/frontmatter.ts`, `scan/body.ts`, +3. resolve `under`/`relates` refs (local + `package#ref` — defer cross-package + to Phase 6; local first), +4. derive inverses, assemble the graph. + +Keep the output assignable to the consumer-facing document shape so +`resolveSurfaceSlice` and friends compile unchanged. **This phase is where the +"many projections" promise is paid: file layout is now free.** + +### Phase 3 — gather + medium + +- Extend `resolveSurfaceSlice` (→ rename `gatherNode` eventually) with an + optional `medium` filter: a node is included if its medium is `any`/absent or + matches the requested medium. Cascade + one-hop edges unchanged. +- Pull relationship-nodes into the slice when either endpoint is in scope + (they're just nodes with two `relates`). +- `gather [--medium m]` at the CLI. +- Provenance already exists — extend it with `medium` and `relationship-node` + reasons so `trace` stays structural. + +### Phase 4 — checks on the graph + +- Checks are already markdown. Add `medium` (scope) and `when: review|runtime` + to check frontmatter. +- `selectChecksForSurfaces` → route by `under` + medium. A check `under` a node + applies to it and descendants; medium narrows it. +- `when: runtime` is *parsed and routed* now; runtime *execution* is out of + scope (Scenario D future) — just don't drop it on the floor. + +### Phase 5 — authoring: init, migrate, the skill + +- `init` scaffolds a `core` node + 1–2 example nodes with the + intent/inventory/composition body template (Style-Dictionary default). +- `migrate` gains facet→node re-filing: read today's typed YAML nodes, emit + markdown nodes (carry `surface:`→`under`, fold typed fields into body + headings). +- **The authoring skill** (first-class, not afterthought — OKF's reference-agent + lesson): discover nodes, propose placement + links, weave links into prose, + follow the anti-over-linking discipline. Lint guards it. + +### Phase 6 — cross-package refs (B, E) + +- Implement `package#ref` resolution: a `relates`/`under` target in another + installed contract (`consumes` in manifest). Located via the surviving path + helpers + node_modules resolution. +- This unlocks the fleet (E) and shared-brand (B). Until now everything is + single-package. + +### Phase 7 — compare / drift on the graph + +- `compare` = graph diff (mostly reuses comparable-fingerprint machinery on the + new node set). +- `drift` highest purpose: compare **siblings of a shared intent** — nodes that + `relates` to the same parent node (E's "have these two expressions of clarity + drifted?"). New, but small once the graph exists. + +### Phase 8 — lint as the guardian + +Throughout, `lint` proves the three invariants (it becomes *the* thing holding a +free-layout graph together): + +1. **Identity** — every node has a unique `id`. +2. **Resolvable links** — every `under`/`relates` resolves (tolerant: dangling = + warn "not yet written", per OKF; hard-fail only on a missing/duplicate root). +3. **One medium-agnostic root** — exactly one node with no `under`, and it is + `medium: any`/absent. + +Plus the authoring-discipline checks (no self-links, no over-linking). + +## What gets deleted / folded + +- The per-facet typed schemas (`intent.principle`, `composition.pattern`, …) + collapse into one node schema. The typed sub-object types in + `ghost-core/fingerprint/types.ts` either go away or become *body-parsing + helpers*, not storage types. +- `survey/`, `patterns/`, `resources/` legacy modules: assess for removal once + nodes are markdown (much of their schema work is subsumed). +- Three fixed facet files (`intent.yml`/`inventory.yml`/`composition.yml`) stop + being the canonical input. `migrate` reads them; nothing else does. + +## What does NOT change + +- The seam (`files → loader → document → consumers`). +- `resolveSurfaceSlice`'s traversal logic (cascade + one-hop edges + provenance). +- Checks routing *concept* (markdown, route by placement). +- `--package` / `GHOST_PACKAGE_DIR` direct addressing. +- compare/drift's underlying comparison math. + +## The machinery ring (assume it, OKF-confirmed) + +OKF ships format **and** machinery; the format alone is inert. Their repo is +mostly an authorship agent (`reference_agent/`: tools + prompt), plus +parse/validate (`document.py`), an index/menu (`index.py`), an auto-summarizer +(`synthesizer.py`), a **visual viewer** (`viewer/`), and tests. Assume Ghost has +the same ring — and we already have most of it, specialized further for design. + +| OKF machinery | Ghost equivalent | Status | +| --- | --- | --- | +| reference_agent (authorship) | the **ghost skill** (discover nodes, propose links, weave prose, anti-over-linking discipline) | exists; reshape for graph (Phase 5) | +| `document.py` parse/validate | `scan/frontmatter.ts`, `scan/body.ts`, node schema | exists; extend (Phase 1–2) | +| §9 conformance | `lint` — the three invariants, tolerant | exists; refocus (Phase 8) | +| `index.py` menu | `gather` (no-arg) menu / `buildSurfaceMenu` | exists | +| `synthesizer.py` summaries | scan-status / contribution | exists, partial | +| **`viewer/` visual graph** | **— gap —** a visual render of the graph (tree, links, relationship-nodes) | **future; fits the "observable" goal** | +| sources / web ingestion | (skip — we are authored/editorial, not extractive) | deliberately out | + +Two takeaways: **(1) the authoring skill is first-class** (OKF's largest +component — nobody hand-authors a linked graph), and **(2) a viewer is a real +future item** — arguably more valuable for a *design* fingerprint than a data +catalog, and it serves Ghost's portable/extensible/**observable** goal. + +## Open decisions that gate the build + +1. **The rename — SETTLED.** Graph unit is **`node`** (machinery-only vocabulary, + never user-facing). "surface" retired from both layers — `node` replaces it in + code; the design prose + ids replace it for users. Wide but mechanical rename + of `surface*` symbols (`GhostSurface`, `resolveSurfaceSlice`, `surfaces.yml`, + `selectChecksForSurfaces`). +2. **Node body — SETTLED: free markdown, always.** intent/inventory/composition + are **ephemeral authorship guidance** (skill prompts + maybe an `init` nudge), + with **zero presence in schema/loader/graph/lint**. No conventional headings, + no body schema. Nothing to build for them. +3. **One file per node vs. grouped files.** Loader is layout-free (Phase 2), so + this is a *default-scaffold* taste call for `init`, not a parser constraint. + Leaning one-file-per-node (one node = one concept). +4. **Keep `GhostFingerprintDocument` facade or rename to `GhostGraph`?** Facade + reduces consumer churn; rename is honest. Lean facade during transition, + rename at the end. +5. **`relates` qualifier vocabulary** — `contrasts`/`reinforces`/`variant` + (+ `governs`, `expresses` seen in scenarios). Closed set, like edges today. + Decide the starting set. + +## Build order, one line + +**one-road → node schema → loader fold → gather+medium → checks → authoring +(init/migrate/skill) → cross-package → compare/drift → lint-as-guardian.** +Each phase green; the node-schema and loader phases are the only hard ones; the +rest is extend-or-rename of code that already exists. diff --git a/docs/ideas/one-road.md b/docs/ideas/one-road.md new file mode 100644 index 00000000..1d3fec04 --- /dev/null +++ b/docs/ideas/one-road.md @@ -0,0 +1,227 @@ +--- +status: exploring +--- + +# One road: remove the binding and nesting, drive everything from the prompt + +A decision, not a hedge. Ghost keeps the one thing only it can do — deterministically +compose the curated slice for a *named surface* — and drops everything that tried +to infer intent or context from repo location: the **binding** (`ghost.binding/v1`, +path→surface, Phase 7a + Cut D) **and nesting itself** (stacks, cross-package +discovery, `--all`/`--scope`/`--path`). One contract per package; surfaces are +the only locality. + +## The case + +- The agent never has only a path. It has the prompt **and** its own whole-repo + analysis — strictly more than a path glob. Binding had Ghost doing, badly, a + job the agent already does better (deciding what a change is about). +- The binding is the last "second source of truth that can drift from reality" — + the same pattern the reset killed in the merge (Leak E), the map, and relay. +- The determinism the binding protected — routing with no LLM — has had nothing + to protect since Cut C: checks are markdown, always agent-evaluated. There is + no no-agent path left to guard. +- Removing it **unifies all four outcomes into one flow**: prompt (+ the agent's + repo/diff analysis) → match the surface menu → `gather ` → slice. The + repo case becomes a special case of the brand case; the contract is portable by + default, not "the clean half of a split." + +## The single thing we give up (named honestly) + +Deterministic, prompt-free path→surface routing: "this file changed → these +checks always run, with no agent in the loop." That belongs to eslint/CI, not Ghost, +and post-Cut-C Ghost no longer offers it anyway. The *capability* people wanted +from it — run the right checks on a diff — survives: the agent names the touched +surfaces (it already analyzed the diff) and asks Ghost for those. + +External-contract use (Cut D) also survives via the **desire-survives test**: use +`gather --package node_modules/@scope/brand/.ghost ` to compose from an +installed brand package. The agent points at the package; no binding-side +resolution needed. Mechanism dies, capability stays. + +## Nesting goes too (the correction) + +An earlier draft of this note kept "nested-package discovery." That was wrong. +Nesting only ever meant two things: **merge** (federated child fingerprints, +killed in 7b Cut 1) and **binding** (nested `.ghost/` = path→surface, killed +here). Once both are gone, **nesting has no meaning left** — keeping discovery, +stacks, and `--all` is scaffolding for a concept that no longer exists. + +**Decision: one contract per package.** A repo's `.ghost/` is the contract. +A monorepo with genuinely independent products runs Ghost per-package (or points +`--package` at each) — those are parallel standalone contracts, not a nested +hierarchy. No stacks, no merge, no chain, no cross-package discovery. + +So this cut also removes the **stack machinery** and the nesting commands: + +- `loadFingerprintStackForPath`, `groupFingerprintStacksForPaths`, + `discoverFingerprintStack`, `buildFingerprintStack`, + `fingerprintStackToPackageContext`, `GhostFingerprintStack*` types, + `lintAllFingerprintStacks`, `verifyAllFingerprintStacks`, + `discoverGhostPackages`, `initScopedFingerprintPackage`. +- `lint --all`, `verify --all`, `scan --include-nested`, `emit --path`, + `init --scope`. + +## What stays untouched (the engine) + +Surfaces, the containment tree, cascade, typed edges, `gather `, the +surface menu, `ghost.check/v1`, `selectChecksForSurfaces`, grounding, +`resolveSurfaceSlice`. The core model does not move. + +**Load-bearing helpers in `fingerprint-stack.ts` survive** (they are not nesting): +`resolveGitRoot`, `normalizeGhostDir`, `resolveGhostDirDefault`, +`GHOST_PACKAGE_DIR_ENV`, `fingerprintPackageDisplayPath`. Move them to a neutral +home (e.g. `scan/package-paths.ts`) before deleting the rest of the file. + +**`--package` and `GHOST_PACKAGE_DIR` survive** — "use exactly this `.ghost/` +dir" is direct addressing, not nesting. This is how a monorepo targets one of its +independent contracts. + +## The new command shapes + +- **`gather `** — unchanged. **Drop `gather --path`.** +- **`gather`** (no surface) — unchanged: returns the menu for the agent to match. +- **`checks --surface `** — replaces `checks --diff`. The agent passes the + surfaces it already determined the change touches (comma-separated, or repeated + flag). Ghost routes + grounds for those surfaces. **Drop diff parsing + + path→surface from `checks`.** +- **`review --surface `** (+ `--diff` kept *only* as the patch to embed in + the packet, not to resolve surfaces from). The agent supplies the surfaces; the + diff is included verbatim for the reviewer. **Drop path→surface from `review`.** + +Rationale: a diff no longer *implies* surfaces (that was the binding's job). +The agent — which read the diff — states the surfaces. Ghost stops guessing. + +## Surgical removal plan (sequenced, each step green) + +### Step 0 — rescue the load-bearing path helpers FIRST (ordering fix) + +Pressure-test finding: `scan/binding-discovery.ts` and `scan/verify-package.ts` +both `import { resolveGitRoot } from "./fingerprint-stack.js"` — i.e. modules +deleted in Steps 2–3 depend on helpers the old plan didn't move until Step 4. +Deleting before moving creates a fragile window. So move the helpers **before any +deletion**, and every later step stays trivially green. + +- Create `scan/package-paths.ts` and move the five survivors out of + `fingerprint-stack.ts`: `resolveGitRoot`, `normalizeGhostDir`, + `resolveGhostDirDefault`, `GHOST_PACKAGE_DIR_ENV`, `fingerprintPackageDisplayPath`. +- Repoint **every** importer to the new home: `fingerprint-commands.ts`, + `verify-package.ts`, `binding-discovery.ts` (harmless — it dies in Step 3, but + keep the build green in between), `init-command.ts`, `scan-emit-command.ts`, + `monorepo-init-command.ts`, and the `scan/index.ts` re-exports. +- **`scan/index.ts` keeps these five re-exported** (now from `package-paths.ts`). + They are live public exports — do not drop them when the stack re-exports go. + +### Step 1 — reshape the consumers off path-resolution (before deleting it) + +Do this first so nothing imports the binding when we delete it. + +- **`gather-command.ts`**: remove `--path`, `discoverBindingsForPath`, + `resolvePathToSurface`. `gather` takes a surface arg or returns the menu. Done. +- **`checks-command.ts`**: replace `--diff` + diff→surface resolution with + `--surface `. Parse the id list, `selectChecksForSurfaces` + `groundSurface` + over them. Keep `--package`, `--format`, `--no-grounding`. Drop + `parseUnifiedDiff`, `discoverBindingsForPath`, `resolvePathToSurface`. +- **`review-packet.ts`**: `buildReviewPacket` takes `surfaces: string[]` instead + of resolving from the diff; keep the diff purely as embedded text. Drop the + binding imports + `parseUnifiedDiff`-for-resolution (diff text still included). +- **`cli.ts`**: update `review` to accept `--surface`; keep `--diff` as embed-only. + +> Nit (don't trip): the `item.path` field in `checks-command.ts:157` and +> `review-packet.ts:273` is a **display** field on grounding items, not +> path→surface resolution. Drop `parseUnifiedDiff` and the binding resolution; +> **keep `item.path`** — it's unrelated and survives. + +### Step 2 — delete the binding verify + file-kind dispatch + +- **`scan/verify-package.ts`**: delete `verifyBindingContract` / + `readContractSurfaceIds` and the `resolveContractDir` import. Verify goes back + to fingerprint evidence/exemplars only. +- **`scan/file-kind.ts`**: remove the `binding` kind, `.ghost.bind.yml` + detection, the `ghost.binding/v1` schema match, the dispatch branch, and + `lintBindingFile`. + +### Step 3 — delete the binding modules + +- `ghost-core/binding/` (schema, lint, types, resolve, contract-ref, index). +- `scan/binding-discovery.ts`, `scan/contract-resolver.ts`. +- Remove all binding/contract re-exports from `ghost-core/index.ts` and + `scan/index.ts`. + +### Step 4 — tear down nesting (the correction) + +Helpers are already rescued (Step 0), so `fingerprint-stack.ts` deletes cleanly. + +- **Delete the rest of `fingerprint-stack.ts`:** stack types, `discoverGhostPackages`, + `discoverFingerprintStack`, `loadFingerprintStackForPath`, + `groupFingerprintStacksForPaths`, `buildFingerprintStack`, + `loadFingerprintStackLayer`, `fingerprintStackToPackageContext`, + `lintAllFingerprintStacks`, `verifyAllFingerprintStacks`, + `initScopedFingerprintPackage`. (The file disappears entirely once the five + helpers are gone.) +- **`fingerprint.ts`:** drops imports of `initScopedFingerprintPackage`, + `lintAllFingerprintStacks`, `verifyAllFingerprintStacks` (lines 39–41). Missed + by the earlier draft — it is a real consumer of three deleted functions and + will break the build if skipped. +- **`fingerprint-commands.ts`:** remove `lint --all`, `verify --all`, + `scan --include-nested`, `nestedPackageStatus`. `lint`/`verify`/`scan` operate + on the single resolved package (or `--package`). +- **`scan-emit-command.ts`:** remove `--path` and the stack path; `emit` runs on + the resolved package or `--package`. +- **`init-command.ts`:** remove `init --scope`. +- **`monorepo-init-command.ts`:** this command exists only to scaffold nested + packages via `initScopedFingerprintPackage` — confirm whether the whole command + dies (likely) or just the scoped path. It also imports the surviving + `normalizeGhostDir`, so do not delete the file wholesale without repointing + that import (handled in Step 0) and checking for any non-nesting use. +- Remove the **stack** re-exports from `scan/index.ts` (the five path helpers + stay — see Step 0). + +### Step 5 — docs, skill, migrate note, changeset + +- **`migrate-legacy.ts`**: the `paths-not-migrated` note currently says + "path→surface binding is not part of placement." Reword to "paths are not part + of the surface model" (drop the binding reference). +- **Skill bundle / `schema.md`**: remove `.ghost.bind.yml` and binding/contract + guidance; teach the single flow (prompt → menu → `gather `; agent + names touched surfaces for `checks`/`review`; external contract via + `gather --package`). +- Mark `surface-binding.md`, `phase-7-plan.md`/`7a`, `polish-cut-d-plan.md` + superseded with a one-line header pointing here. +- `major` changeset: removes `ghost.binding/v1`, `.ghost.bind.yml`, + `gather --path`, `checks --diff`, `lint --all`, `verify --all`, + `scan --include-nested`, `emit --path`, `init --scope`, and nested-package + stacks. `checks`/`review` take `--surface`; one contract per package. + +## Tests + +- Delete `binding-resolve`, `binding-schema`, `contract-ref`, `contract-resolver` + test files. +- `cli.test.ts`: replace `gather --path` and `checks --diff` cases with + `checks --surface`; rework the `review ... mixed diff` case to pass `--surface`; + drop the external-contract verify case (or move it to a `gather --package` case). +- `surfaces-*`, `check-route`, `surfaces-ground` are unaffected (they never used + the binding). +- Full `pnpm test` + `pnpm check` green. + +## Scope boundary (what this does NOT do) + +- Does **not** touch the surface model, cascade, gather slice, checks routing + logic, or grounding — only how *which surfaces* is determined (agent-stated, not + path-resolved). +- Does **not** remove `--package` / `GHOST_PACKAGE_DIR` — direct addressing of a + single package survives; it is how a monorepo targets one of its independent + contracts. +- Does **not** add NLP to Ghost — the agent still does all matching; Ghost gains + no understanding, it just stops guessing from paths. + +## Read-back + +One road succeeds if: the binding (`ghost.binding/v1`, path→surface, contract +resolution) **and** all nesting (stacks, merge-era discovery, `--all`, +`--include-nested`, `--path`, `--scope`) are gone; one contract per package; +`gather` takes only a surface or returns the menu; `checks` and `review` take +agent-stated `--surface` ids (diff is embed-only); external contracts and +monorepo sub-contracts are reached via `--package`; the load-bearing path +helpers survive in a neutral home; the surface engine is untouched; and Ghost no +longer infers intent from repo location anywhere. diff --git a/docs/ideas/parked-survey-module.md b/docs/ideas/parked-survey-module.md new file mode 100644 index 00000000..ec9e0ad8 --- /dev/null +++ b/docs/ideas/parked-survey-module.md @@ -0,0 +1,63 @@ +--- +status: parked +--- + +# Parked: the `ghost.survey/v1` module + +This note records a deliberate decision **not** to act. Survey is isolated, works, +and hurts nothing — so it stays, undocumented in the user-facing surface, until +there is a concrete reason to revisit. This note exists so the reasoning is found, +not rediscovered. + +## What survey is + +`ghost.survey/v1` is a **machine-scan cache** — a `survey.json` a scanner emits +with raw repo observations (sources, value rows, tokens, components, +ui_surfaces). It predates the surface model and is the last surviving piece of +the pre-reset world (same era as `map.md`, `resources.yml`, the old `relay`). It +lives in `packages/ghost/src/ghost-core/survey/` (~14 files). + +The `ghost survey ` **command** was removed in Phase 8. The **module** +remained because other code still imports it. + +## Why it is parked, not removed + +The importers split in two: + +- **Vestigial (mechanical to cut):** `scan/file-kind.ts` routes `.json` to the + survey linter; `scan/fingerprint-package.ts` / `scan/constants.ts` carry a + `survey` path slot; `fingerprint-commands.ts` has leftover refs. These only + *recognize* survey files. +- **Load-bearing (the real question):** `comparable-fingerprint.ts` reads + `survey.json` to build comparison input, and `ghost-core/perceptual-prior.ts` + uses `surveyCount` for presence/absence escalation. So **`ghost compare` may + depend on survey evidence.** + +That makes removal an *excavation*, not a deletion. The open question at its +center: + +> Does `ghost compare` still need survey evidence, or can it compare from the +> fingerprint's own `evidence` / `exemplars` alone? + +Answering it is a change to how comparison works — its own design call, in a +corner of Ghost (compare / perceptual-prior) the surface reset never touched. +Rushing it would either silently degrade `compare` or invent a new +compare-evidence path without a plan. That violates the read-first-then-cut +discipline that held the whole reset together. + +## Stance + +- **Not debt.** Survey is isolated and functional; nothing is blocked. +- **Not exposed.** No user-facing command or doc points at it; it is internal + plumbing only. +- **Surfaced only if a reason appears** — e.g. survey genuinely loses its last + consumer, or comparison is reworked and the evidence-source question comes up + on its own. + +## If it is ever revisited + +First move is a read of `comparable-fingerprint.ts` + `perceptual-prior.ts` to +answer the compare-evidence question. Only then decide whether survey lives +(and is re-justified in the surface world) or is removed (vestigial importers +first, then the load-bearing two, then the module). Do not start by deleting +files. diff --git a/docs/ideas/scenarios-worked.md b/docs/ideas/scenarios-worked.md new file mode 100644 index 00000000..88be04a7 --- /dev/null +++ b/docs/ideas/scenarios-worked.md @@ -0,0 +1,504 @@ +--- +status: exploring +--- + +# Worked scenarios: what a Ghost fingerprint actually looks like + +Companion to `context-graph.md`. The model there is abstract; this note makes it +concrete. Each scenario is a real fingerprint — actual node files, actual bodies, +the three relationship tiers, the `medium` tag — and the `gather` that turns it +into a context packet for generation. + +The model, restated in one breath: **a folder of markdown nodes; nodes link +`under` a parent (the tree, the cascade) and `relates` laterally; nuanced +relationships become their own nodes (the OKF "joins" borrow); a `medium` tag is +optional; bodies are design expression written through intent / inventory / +composition; nobody hand-authors links — a skill does, lint guards it.** + +The three relationship tiers (the sharpening from OKF): + +1. **`under`** — containment. Cheap. Builds the tree and the cascade. +2. **`relates: [ref] (qualifier)`** — light lateral. A link + one-word handle + (`contrasts` / `reinforces` / `variant`) for the machinery; the *why* is brief + prose. +3. **relationship-node** — when the *why* is rich, the relationship becomes a + node: a title, its two endpoints, and a body explaining the tension and what + it protects. This is where design rationale lives. + +--- + +## Scenario A — a simple dashboard + +Analytics, views, settings, profiles, item-detail. Single medium (web), so no +node ever writes `medium`. The model should be nearly invisible. + +``` +.ghost/ + manifest.yml + core.md + analytics.md + item-detail.md + rel/density-tension.md # a relationship-node (tier 3) + checks/numbers-first.md +``` + +`manifest.yml` +```yaml +package: "@acme/console" +medium: web # the default; nodes omit medium entirely +``` + +`core.md` — the root. The brand soul. No `under`. +```markdown +--- +id: core +--- +# Intent +This is a working tool, not a marketing surface. People are here to scan, +compare, and act — reward fast eyes. We are calm, dense, and honest; we never +decorate a number we can't stand behind. + +# Composition +Default to information density. Whitespace earns its place only when it speeds +comprehension, never for "breathing room." +``` + +`analytics.md` +```markdown +--- +id: analytics +under: core +--- +# Intent +The headline metric is legible in under a second. The chart explains the +number; it never replaces it. + +# Inventory +- One hero metric, period-over-period delta beside it. +- Chart below the fold of the number, not above. + +# Composition +Numbers before charts. Default to the last meaningful period, not the +prettiest range. +``` + +`item-detail.md` — note it deliberately *breaks* the global density rule. +```markdown +--- +id: item-detail +under: core +relates: [analytics] (contrasts) +--- +# Intent +One item, fully expressed. This is the one place the product is allowed to +breathe — the user has chosen this thing and deserves its whole story. + +# Composition +Lead with identity + status; details on demand. Generous spacing here is +correct, not a violation. +``` + +`rel/density-tension.md` — the relationship-node (tier 3). The *most teachable +content in the whole fingerprint*: it explains why two surfaces disagree on +purpose. +```markdown +--- +id: rel/density-tension +relates: [analytics, item-detail] +--- +# The tension +Analytics and item-detail pull opposite directions on density, and that is +intentional. Analytics serves comparison across many things — density wins. +Item-detail serves attention on one thing — space wins. + +# What it protects +If a future contributor "fixes" item-detail to match the dashboard's density, +they will have destroyed the one place the product slows down. This node exists +so that divergence reads as a decision, not an inconsistency. +``` + +`checks/numbers-first.md` — a check is just a node whose body is an assertion, +placed `under` what it governs. +```markdown +--- +id: checks/numbers-first +under: analytics +--- +The hero metric is rendered as a number before any chart in the DOM order, and +is legible without interaction. +``` + +**`gather analytics`** walks up: `core` → `analytics`, pulls the `contrasts` +neighbor's headline + the density-tension relationship-node (because it touches +analytics), and the check under analytics. The packet an agent gets: + +> Calm, dense, honest (core). Numbers before charts, hero metric in <1s, +> last meaningful period (analytics). Note: density here deliberately contrasts +> item-detail — see the tension (don't homogenize). Check: hero metric is a +> number before any chart, legible without interaction. + +**What A teaches now:** the model is genuinely invisible at small scale — five +content nodes, zero `medium`, one tiny tree. And the first real payoff is the +**relationship-node**: the dashboard's hardest-won knowledge ("these two +disagree on purpose") finally has a home that a flat rules list or a bare typed +edge could never give it. + +--- + +## Scenario B — a monorepo: 3 products, shared visuals + +Three products doing different things, one shared visual language. The shared +brand is its own contract; each product *consumes* it and links across the +package boundary. + +``` +packages/ + brand/.ghost/ # @acme/brand (published, shared) + core.md # voice, color, trust DNA + table-language.md + ops/.ghost/ # @acme/ops (data-dense tool) + core.md + data-table.md + studio/.ghost/ # @acme/studio (creative canvas) + core.md + canvas.md +``` + +`packages/brand/.ghost/core.md` +```markdown +--- +id: core +--- +# Intent +One company speaking. Calm, direct, never breathless. Trust is shown by +provenance, not by badges. This holds in every product that consumes this brand. +``` + +`packages/ops/.ghost/core.md` — a product root that links *into* the brand +package (the cross-package ref grammar, `package#ref`). +```markdown +--- +id: core +relates: ["@acme/brand#core"] (inherits) +--- +# Intent +Ops is the data-dense end of the brand. We inherit the company voice and trust +stance, and push information density harder than any sibling — operators live +here all day. +``` + +`packages/ops/.ghost/data-table.md` +```markdown +--- +id: data-table +under: core +relates: ["@acme/brand#table-language"] (variant) +--- +# Intent +The shared table language, pushed to maximum density: tighter rows, inline +sparklines, no decorative padding. + +# Composition +Inherit the brand's column rhythm and sort affordances; override only spacing. +Provenance stays visible on every figure (brand trust stance). +``` + +**What B teaches now:** "one contract per package" (one-road) holds — but the +**cross-package `relates` link** (`@acme/brand#table-language`) is what lets +siblings share DNA without copy-paste. The `(variant)` qualifier tells `drift` +these are *meant* to be related-but-different, so divergence between ops's table +and the brand table reads as intentional. `compare @acme/ops @acme/studio` can +now ask "do both still sound like one company?" by comparing what each links to +in `@acme/brand`. + +--- + +## Scenario C — marketing: email, flyer, billboard, slides + +No "pages." A node is a *bounded message* that renders into several media. This +is where `medium` becomes the heart of the value. + +``` +.ghost/ + manifest.yml + core.md + launch.md # the message (medium-agnostic intent) + launch.email.md # rendered for email + launch.billboard.md # rendered for billboard + launch.slides.md # rendered for a deck + rel/glance-discipline.md # relationship-node across media + checks/billboard-brevity.md +``` + +`manifest.yml` +```yaml +package: "@acme/marketing" +# multi-medium: nodes carry their own medium; no single default +``` + +`launch.md` — the intent. `medium: any`, so it cascades to every rendering. +```markdown +--- +id: launch +under: core +medium: any +--- +# Intent +One idea, stated with confidence: what changed and why it matters to *you*. +Never a feature list. Tone is assured, not breathless. +``` + +`launch.billboard.md` — a child rendering, medium-bound. (This is what we +*almost* invented a `projects` edge for — it's just `under` + a `medium` tag.) +```markdown +--- +id: launch.billboard +under: launch +medium: billboard +--- +# Composition +Six words maximum. One focal image. Readable at 70mph from 100 meters. If it +needs explaining, it is not a billboard. +``` + +`launch.email.md` +```markdown +--- +id: launch.email +under: launch +medium: email +--- +# Composition +Subject line is the headline — it must stand alone in an inbox. One CTA above +the fold. The deep story is a click away, never in the body. +``` + +`rel/glance-discipline.md` — a relationship-node tying the billboard rendering +to a constraint that, surprisingly, recurs elsewhere in the brand. +```markdown +--- +id: rel/glance-discipline +relates: [launch.billboard, launch.slides] +--- +# The shared discipline +The billboard and the opening slide are the same problem: one idea, read in a +glance, no second chance. They are not "a billboard" and "a slide" — they are +two expressions of *glanceability*. Treat a change to one as a question about +the other. +``` + +`checks/billboard-brevity.md` — a medium-scoped check. +```markdown +--- +id: checks/billboard-brevity +under: launch +medium: billboard +--- +≤ 6 words of copy. Exactly one focal element. No URL longer than the brand name. +``` + +**`gather launch --medium billboard`** walks `core` → `launch` (medium: any, +included) → `launch.billboard` (medium matches), pulls the glance-discipline +relationship-node and the billboard-only check, and **excludes** `launch.email` +(wrong medium). The agent generating the billboard never sees "above the fold." + +**What C teaches now:** "a node = a place" is fully dead — `launch` renders into +zero screens; it's pure intent. The `medium` tag does all the routing, and the +cascade (`launch` is `medium: any`) carries the shared voice into every medium +while each child holds its own discipline. The relationship-node captures a +non-obvious brand truth (billboard ≈ opening slide) that no per-medium rule list +could surface. + +--- + +## Scenario D — a generative voice-first super app + +Voice-driven; the AI generates screens on the fly; it infers context and can act +proactively. There is no fixed screen inventory. The fingerprint stops +*describing surfaces* and starts *governing generation*. + +``` +.ghost/ + manifest.yml + core.md + voice.md # the primary modality + generated-screen.md # ephemeral visual, summoned by voice + proactive.md # system-initiated, no prompt + rel/screen-is-an-aside.md + checks/never-proactive-transact.md # when: runtime +``` + +`core.md` +```markdown +--- +id: core +--- +# Intent +We respect attention as the scarcest resource. We state what is true and what +matters, then get out of the way — whether spoken, shown, or offered unasked. +``` + +`proactive.md` — this is the new center of gravity for D: a *stance*, not a +description. (Deferred `governs` link noted; modelled as `relates (governs)` for +now.) +```markdown +--- +id: proactive +under: core +medium: voice +relates: [generated-screen] (governs) +--- +# Intent (stance) +The system may act before being asked only when all three hold: confidence is +high, the action is reversible, and the cost of being wrong is low. Otherwise it +*offers*; it does not *do*. + +# Composition +Never proactively transact. Interrupt only at the user's stated thresholds, +never the system's convenience. +``` + +`generated-screen.md` +```markdown +--- +id: generated-screen +under: core +medium: generated-screen +--- +# Intent +A summoned screen is read in the gap between two spoken sentences. One answer, +one optional action. It is a visual aside to a conversation, not a destination. + +# Composition +Design for transience: it disappears when the conversation moves on. No +navigation, no dwelling. +``` + +`checks/never-proactive-transact.md` — a **runtime** check, evaluated at +generation time, not in CI. +```markdown +--- +id: checks/never-proactive-transact +under: proactive +medium: voice +when: runtime +--- +No generated action that moves money or makes a commitment fires without an +explicit, in-the-moment user confirmation. +``` + +**`gather proactive --medium voice`** at runtime returns: core's attention +stance → the proactive stance (the three-part rule) → the governs link to +generated-screen → the runtime check. The generating system uses this packet as +the *grammar* for the action it is about to take. + +**What D teaches now:** the contract's job inverts — it *governs generation at +runtime*. Two new needs surface honestly: a **runtime check mode** (`when: +runtime`) and a real **`governs`** relationship (deferred; faked as a qualified +`relates`). The node-and-link model holds; it just gets consumed live instead of +at review time. Surfaces here are *classes* the runtime instantiates — the tree +holds the types, the runtime makes the instances. + +--- + +## Scenario E — all of the above are one brand (the superset) + +Dashboard + sibling apps + marketing + voice future, one design soul. Realized +as a **fleet with a shared brand contract**: each regime is its own package, +all consuming `@acme/brand`, all linking back to the one root. + +``` +packages/ + brand/.ghost/ # @acme/brand — the soul. core/voice, core/trust, core/clarity + console/.ghost/ # Scenario A + ops|studio/.ghost/ # Scenario B + marketing/.ghost/ # Scenario C + super/.ghost/ # Scenario D +``` + +`packages/brand/.ghost/core.md` — medium-agnostic, the ancestor of everything. +```markdown +--- +id: core/voice +--- +# Intent +Calm, direct, never breathless. We respect attention as the scarcest resource. +This holds whether it is a billboard, a settings toggle, an email subject, or a +proactive voice nudge. +``` + +The power move — one intent, expressed across four regimes, all traceable to it. +In `marketing`, `super`, and `console`, a node links to the *same* brand node: + +```markdown +# packages/marketing/.ghost/launch.billboard.md +relates: ["@acme/brand#core/clarity"] (expresses) +``` +```markdown +# packages/super/.ghost/generated-screen.md +relates: ["@acme/brand#core/clarity"] (expresses) +``` + +And the relationship-node that *only* E can express — kinship across the +marketing↔voice boundary, the single most valuable node in the whole fleet: + +`packages/brand/.ghost/rel/glanceable-everywhere.md` +```markdown +--- +id: rel/glanceable-everywhere +relates: + - "@acme/marketing#launch.billboard" + - "@acme/super#generated-screen" +--- +# The shared idea +The billboard and the AI-generated screen are the same problem at opposite ends +of the brand: one idea, read in a glance, no second chance. They inherit the +billboard's discipline, not the dashboard's density. This kinship is a brand +truth — when one changes, ask whether the other should. +``` + +**`compare`/`drift` find their highest purpose here:** because the marketing +billboard and the voice screen both `relates → @acme/brand#core/clarity`, drift +can ask *"have these two expressions of clarity drifted apart?"* — a question +only askable because they share a parent intent in the graph. That is the entire +thesis of Ghost, shown at full scale: **provably consistent (one source node), +appropriately varied (per-medium children), and traceable (every expression +links home).** + +**What E teaches now:** the brand soul *must* live at a medium-agnostic root and +everything else expresses it via cross-package links. The fleet is not one giant +file or one giant tree — it is **many small contracts sharing one root**, held +together by `relates` links across package boundaries and a handful of +relationship-nodes that capture cross-regime brand truths. The model scales by +*staying small per package and linking*, never by growing one monolith. + +--- + +## What we are building (the read-back across all five) + +- **A node is design expression** — a body written through intent / inventory / + composition, not a row in a schema. +- **The tree (`under`) carries the cascade** — brand soul → regime → message → + rendering. Consistency comes for free from inheritance. +- **`medium` is the one Ghost-native tag** — absent at small scale (A), central + for marketing/voice (C, D), and the thing that lets one intent render many + ways (E). +- **Relationships come in three tiers** — cheap `under`, light qualified + `relates`, and rich **relationship-nodes** where design *rationale* lives. The + relationship-nodes are where Ghost's editorial depth actually sits. +- **Cross-package `relates` (`package#ref`)** is how a brand spans repos, teams, + and release cadences without merge or stacks (B, E). +- **Checks are nodes** placed `under` what they govern, optionally `medium`- + scoped, optionally `when: runtime` (D). They map back to the fingerprint + structurally — a violation names the node it broke. +- **`gather` is a traversal** — walk up the tree, filter by medium, pull + relationship-nodes and checks, hand the agent a packet. The context grabber, + unchanged in spirit from relay. +- **Nobody hand-authors the graph** — a skill discovers nodes, proposes + placement and links, weaves them into prose (OKF's authorship discipline), and + lint guards the invariants tolerantly. + +The shape, in one sentence: **a fleet of small, linked, markdown design-context +graphs that an agent traverses to assemble exactly the right design guidance for +the thing it is about to generate — in any medium, traceable all the way back to +one brand soul.** diff --git a/packages/ghost/src/checks-command.ts b/packages/ghost/src/checks-command.ts index 4bab5a3e..79d9a9aa 100644 --- a/packages/ghost/src/checks-command.ts +++ b/packages/ghost/src/checks-command.ts @@ -1,32 +1,32 @@ -import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { promisify } from "node:util"; import type { CAC } from "cac"; import { groundSurface, type RoutedCheck, - resolvePathToSurface, type SurfaceGrounding, selectChecksForSurfaces, } from "#ghost-core"; import { resolveFingerprintPackage } from "./fingerprint.js"; -import { discoverBindingsForPath } from "./scan/binding-discovery.js"; import { loadChecksDir } from "./scan/checks-dir.js"; import { loadFingerprintPackage } from "./scan/fingerprint-package.js"; -import { parseUnifiedDiff } from "./scan/unified-diff.js"; -const execFileAsync = promisify(execFile); +function parseSurfaceIds(value: unknown): string[] { + const raw = Array.isArray(value) ? value : value === undefined ? [] : [value]; + const ids = raw + .flatMap((entry) => String(entry).split(",")) + .map((id) => id.trim()) + .filter((id) => id.length > 0); + return [...new Set(ids)]; +} export function registerChecksCommand(cli: CAC): void { cli .command( "checks", - "Select the markdown checks (ghost.check/v1) relevant to a diff, routed by surface.", + "Select the markdown checks (ghost.check/v1) relevant to the named surfaces.", ) - .option("--base ", "Git ref to diff against (default: HEAD)") .option( - "--diff ", - "Unified diff file to route instead of running git diff. Use '-' for stdin.", + "--surface ", + "Surface id(s) the change touches (comma-separated or repeated). The agent names them.", ) .option( "--package ", @@ -52,34 +52,20 @@ export function registerChecksCommand(cli: CAC): void { const loaded = await loadFingerprintPackage(paths); const { checks, invalid } = await loadChecksDir(paths.dir); - const diffText = - typeof opts.diff === "string" - ? await readDiffInput(opts.diff) - : await readGitDiff(cwd, opts.base ?? "HEAD"); - const changedPaths = parseUnifiedDiff(diffText).map((f) => f.path); + // The agent names the touched surfaces (it analyzed the diff). Ghost + // routes + grounds for those surfaces; it does not infer from paths. + const touched = parseSurfaceIds(opts.surface); - // Resolve each changed path to its surface via bindings; union them. - const touched = new Set(); - for (const path of changedPaths) { - const discovered = await discoverBindingsForPath(path, cwd); - const resolution = resolvePathToSurface( - discovered.target_path, - discovered.candidates, - { - hasRootContract: discovered.hasRootContract || !!loaded.surfaces, - }, - ); - if (resolution.surface) touched.add(resolution.surface); - } - - const routed = selectChecksForSurfaces(checks, loaded.surfaces, [ - ...touched, - ]); + const routed = selectChecksForSurfaces( + checks, + loaded.surfaces, + touched, + ); // grounding defaults on; cac sets opts.grounding=false for --no-grounding. const withGrounding = opts.grounding !== false; const grounding: SurfaceGrounding[] = withGrounding - ? [...touched].map((surface) => + ? touched.map((surface) => groundSurface(loaded.surfaces, loaded.fingerprint, surface), ) : []; @@ -88,7 +74,7 @@ export function registerChecksCommand(cli: CAC): void { process.stdout.write( `${JSON.stringify( { - touched_surfaces: [...touched], + touched_surfaces: touched, checks: routed.map((r) => ({ name: r.check.frontmatter.name, severity: r.check.frontmatter.severity, @@ -104,7 +90,7 @@ export function registerChecksCommand(cli: CAC): void { ); } else { process.stdout.write( - formatChecksMarkdown([...touched], routed, grounding, invalid), + formatChecksMarkdown(touched, routed, grounding, invalid), ); } process.exit(0); @@ -168,20 +154,3 @@ function formatChecksMarkdown( } return `${lines.join("\n")}\n`; } - -async function readDiffInput(input: string): Promise { - if (input === "-") { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString("utf-8"); - } - return readFile(input, "utf-8"); -} - -async function readGitDiff(cwd: string, base: string): Promise { - const { stdout } = await execFileAsync("git", ["diff", base, "--unified=0"], { - cwd, - maxBuffer: 64 * 1024 * 1024, - }); - return stdout; -} diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 51e201e8..c5104e26 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -169,7 +169,11 @@ export function buildCli(): ReturnType { .option("--base ", "Git ref to diff against (default: HEAD)") .option( "--diff ", - "Unified diff file to review instead of running git diff. Use '-' for stdin.", + "Unified diff file to embed in the review instead of running git diff. Use '-' for stdin.", + ) + .option( + "--surface ", + "Surface id(s) the change touches (comma-separated or repeated). The agent names them.", ) .option( "--package ", @@ -195,6 +199,7 @@ export function buildCli(): ReturnType { opts.maxDiffBytes, "--max-diff-bytes", ); + const surfaces = parseSurfaceIdsOption(opts.surface); const diffText = typeof opts.diff === "string" ? await readDiffInput(opts.diff) @@ -202,6 +207,7 @@ export function buildCli(): ReturnType { const packet = await buildReviewPacket({ packageDir, diffText, + surfaces, maxDiffBytes, }); if (opts.format === "json") { @@ -232,6 +238,15 @@ function readPackageVersion(): string { return pkg.version as string; } +function parseSurfaceIdsOption(value: unknown): string[] { + const raw = Array.isArray(value) ? value : value === undefined ? [] : [value]; + const ids = raw + .flatMap((entry) => String(entry).split(",")) + .map((id) => id.trim()) + .filter((id) => id.length > 0); + return [...new Set(ids)]; +} + function parsePositiveIntegerOption( value: unknown, flagName: string, diff --git a/packages/ghost/src/fingerprint-commands.ts b/packages/ghost/src/fingerprint-commands.ts index cd59d432..31ac83e1 100644 --- a/packages/ghost/src/fingerprint-commands.ts +++ b/packages/ghost/src/fingerprint-commands.ts @@ -9,24 +9,15 @@ import type { } from "#ghost-core"; import { formatVerifyFingerprintReport, - lintAllFingerprintStacks, type lintFingerprint, lintFingerprintPackage, loadFingerprint, resolveFingerprintPackage, - verifyAllFingerprintStacks, verifyFingerprintPackage, } from "./fingerprint.js"; import { registerInitCommand } from "./init-command.js"; import { detectFileKind, lintDetectedFileKind } from "./scan/file-kind.js"; -import { - discoverGhostPackages, - fingerprintPackageDisplayPath, - normalizeGhostDir, - resolveGhostDirDefault, - scanStatus, - signals, -} from "./scan/index.js"; +import { resolveGhostDirDefault, scanStatus, signals } from "./scan/index.js"; import { registerEmitCommand } from "./scan-emit-command.js"; /** @@ -49,23 +40,9 @@ export function registerFingerprintCommands(cli: CAC): void { "Validate a root Ghost fingerprint package, split fingerprint artifacts, checks, or direct markdown — defaults to .ghost", ) .option("--format ", "Output format: cli or json", { default: "cli" }) - .option( - "--all", - "Validate every nested fingerprint package and its resolved fingerprint stack", - ) .action(async (path: string | undefined, opts) => { try { const ghostDir = ghostDirFromEnv(); - if (opts.all) { - const report = await lintAllFingerprintStacks( - resolve(process.cwd(), path ?? "."), - { ghostDir }, - ); - writeLintReport(report, opts.format); - process.exit(report.errors > 0 ? 1 : 0); - return; - } - const packagePath = path ?? ghostDir; const target = resolveFingerprintPackage( packagePath, @@ -121,10 +98,6 @@ export function registerFingerprintCommands(cli: CAC): void { "Optional target root used to resolve fingerprint evidence and exemplar paths (default: cwd)", ) .option("--format ", "Output format: cli or json", { default: "cli" }) - .option( - "--all", - "Verify every nested fingerprint package and its resolved fingerprint stack", - ) .action(async (dirArg: string | undefined, opts) => { try { if (opts.format !== "cli" && opts.format !== "json") { @@ -134,16 +107,13 @@ export function registerFingerprintCommands(cli: CAC): void { } const ghostDir = ghostDirFromEnv(); - const report = opts.all - ? await verifyAllFingerprintStacks( - resolve(process.cwd(), dirArg ?? "."), - { - ghostDir, - }, - ) - : await verifyFingerprintPackage(dirArg ?? ghostDir, process.cwd(), { - root: opts.root ? resolve(process.cwd(), opts.root) : undefined, - }); + const report = await verifyFingerprintPackage( + dirArg ?? ghostDir, + process.cwd(), + { + root: opts.root ? resolve(process.cwd(), opts.root) : undefined, + }, + ); if (opts.format === "json") { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); @@ -166,10 +136,6 @@ export function registerFingerprintCommands(cli: CAC): void { "scan [dir]", "Report sparse fingerprint package contribution facets: intent, inventory, composition, and the next BYOA step.", ) - .option( - "--include-nested", - "Also list nested fingerprint packages and contribution state", - ) .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (dirArg: string | undefined, opts) => { try { @@ -179,20 +145,8 @@ export function registerFingerprintCommands(cli: CAC): void { process.cwd(), ).dir; const status = await scanStatus(dir); - const nested = opts.includeNested - ? await nestedPackageStatus( - dirnameForFingerprintPackageDir(dir, ghostDir), - ghostDir, - ) - : undefined; if (opts.format === "json") { - process.stdout.write( - `${JSON.stringify( - nested ? { ...status, nested_packages: nested } : status, - null, - 2, - )}\n`, - ); + process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); } else { const fmt = (state: string) => state === "present" ? "present" : "missing"; @@ -251,18 +205,6 @@ export function registerFingerprintCommands(cli: CAC): void { ` inventory building blocks: ${buildingBlockRows.tokens} token(s), ${buildingBlockRows.components} component(s), ${buildingBlockRows.libraries} libraries, ${buildingBlockRows.assets} asset(s), ${buildingBlockRows.routes} route(s), ${buildingBlockRows.files} file(s), ${buildingBlockRows.notes} note(s)\n`, ); } - if (nested) { - process.stdout.write("\nnested packages:\n"); - if (nested.length === 0) { - process.stdout.write(" none\n"); - } else { - for (const pkg of nested) { - process.stdout.write( - ` ${fingerprintPackageDisplayPath(pkg.relative_root, pkg.ghost_dir)}: ${pkg.contribution.state}\n`, - ); - } - } - } } process.exit(0); } catch (err) { @@ -296,43 +238,6 @@ export function registerFingerprintCommands(cli: CAC): void { registerEmitCommand(cli); } -async function nestedPackageStatus( - root: string, - ghostDir: string, -): Promise { - const packages = await discoverGhostPackages(root, { ghostDir }); - return Promise.all( - packages.map(async (pkg) => { - const status = await scanStatus(pkg.dir); - return { - ...pkg, - fingerprint: status.fingerprint, - contribution: status.contribution, - }; - }), - ); -} - -interface NestedPackageStatus { - dir: string; - root: string; - relative_root: string; - ghost_dir: string; - fingerprint: Awaited>["fingerprint"]; - contribution: Awaited>["contribution"]; -} - -function dirnameForFingerprintPackageDir( - dir: string, - ghostDir: string, -): string { - let root = dir; - for (const _segment of normalizeGhostDir(ghostDir).split("/")) { - root = dirname(root); - } - return root; -} - function ghostDirFromEnv(): string { return resolveGhostDirDefault(); } diff --git a/packages/ghost/src/fingerprint.ts b/packages/ghost/src/fingerprint.ts index 8a667bc0..eac4a918 100644 --- a/packages/ghost/src/fingerprint.ts +++ b/packages/ghost/src/fingerprint.ts @@ -35,11 +35,6 @@ export { loadFingerprintPackage, resolveFingerprintPackage, } from "./scan/fingerprint-package.js"; -export { - initScopedFingerprintPackage, - lintAllFingerprintStacks, - verifyAllFingerprintStacks, -} from "./scan/fingerprint-stack.js"; export type { FingerprintMeta, FrontmatterData } from "./scan/frontmatter.js"; export type { FingerprintLayout, diff --git a/packages/ghost/src/gather-command.ts b/packages/ghost/src/gather-command.ts index 0d9d41e2..bcf79897 100644 --- a/packages/ghost/src/gather-command.ts +++ b/packages/ghost/src/gather-command.ts @@ -2,13 +2,11 @@ import type { CAC } from "cac"; import { buildSurfaceMenu, type ResolvedSlice, - resolvePathToSurface, resolveSurfaceSlice, type SliceProvenance, type SurfaceMenuEntry, } from "#ghost-core"; import { resolveFingerprintPackage } from "./fingerprint.js"; -import { discoverBindingsForPath } from "./scan/binding-discovery.js"; import { loadFingerprintPackage } from "./scan/fingerprint-package.js"; const GHOST_SURFACE_ROOT_ID = "core"; @@ -23,10 +21,6 @@ export function registerGatherCommand(cli: CAC): void { "--package ", "Use this fingerprint package directory (default: ./.ghost)", ) - .option( - "--path ", - "Resolve the surface that owns a repo path via its binding, then gather", - ) .option("--format ", "Output format: markdown or json", { default: "markdown", }) @@ -42,22 +36,9 @@ export function registerGatherCommand(cli: CAC): void { const loaded = await loadFingerprintPackage(paths); const menu = buildSurfaceMenu(loaded.surfaces); - // The path road: resolve a repo path to its surface via bindings. - let surface = surfaceArg; - if (opts.path) { - const discovered = await discoverBindingsForPath( - opts.path, - process.cwd(), - ); - const resolution = resolvePathToSurface( - discovered.target_path, - discovered.candidates, - { - hasRootContract: discovered.hasRootContract || !!loaded.surfaces, - }, - ); - if (resolution.surface) surface = resolution.surface; - } + // The agent names the surface (it analyzed the prompt + diff). Ghost + // does not infer surfaces from repo paths. + const surface = surfaceArg; // No surface named, or an unknown one: return the menu, never the tree. const known = new Set(menu.map((entry) => entry.id)); diff --git a/packages/ghost/src/ghost-core/binding/contract-ref.ts b/packages/ghost/src/ghost-core/binding/contract-ref.ts deleted file mode 100644 index 2a235b75..00000000 --- a/packages/ghost/src/ghost-core/binding/contract-ref.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** The in-repo root contract reference. */ -export const IN_REPO_CONTRACT = "." as const; - -/** - * npm package name: optional `@scope/`, then a lowercase name. Matches the npm - * naming rules closely enough to distinguish a package reference from a path, - * URL, or arbitrary resource id. - */ -const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; - -export type ContractReferenceKind = "in-repo" | "npm" | "unsupported"; - -/** - * Classify a binding `contract:` reference. `.` is the in-repo root contract; an - * npm package name resolves from `node_modules`; anything else (a path, URL, or - * resource id) is not yet supported (see docs/ideas/polish-cut-d-plan.md). - */ -export function classifyContractReference( - reference: string, -): ContractReferenceKind { - if (reference === IN_REPO_CONTRACT) return "in-repo"; - // Exclude path-like and protocol-like references before the npm-name test. - if (reference.includes("/") && !reference.startsWith("@")) { - return "unsupported"; - } - if (reference.includes(":") || reference.startsWith(".")) { - return "unsupported"; - } - return NPM_NAME.test(reference) ? "npm" : "unsupported"; -} diff --git a/packages/ghost/src/ghost-core/binding/index.ts b/packages/ghost/src/ghost-core/binding/index.ts deleted file mode 100644 index 7de74fc4..00000000 --- a/packages/ghost/src/ghost-core/binding/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Public surface for `ghost.binding/v1` — the repo-native statement that a - * working tree realizes a contract's surfaces at given paths. See - * docs/ideas/surface-binding.md. - */ - -export { - type ContractReferenceKind, - classifyContractReference, - IN_REPO_CONTRACT, -} from "./contract-ref.js"; -export { lintGhostBinding } from "./lint.js"; -export { - type BindingCandidate, - type PathResolution, - type PathResolutionReason, - resolvePathToSurface, -} from "./resolve.js"; -export { GhostBindingSchema } from "./schema.js"; -export { - GHOST_BINDING_FILENAME, - GHOST_BINDING_SCHEMA, - type GhostBindingDocument, - type GhostBindingEntry, - type GhostBindingLintIssue, - type GhostBindingLintReport, - type GhostBindingLintSeverity, -} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/binding/lint.ts b/packages/ghost/src/ghost-core/binding/lint.ts deleted file mode 100644 index d945388c..00000000 --- a/packages/ghost/src/ghost-core/binding/lint.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ZodIssue } from "zod"; -import { classifyContractReference } from "./contract-ref.js"; -import { GhostBindingSchema } from "./schema.js"; -import type { - GhostBindingDocument, - GhostBindingLintIssue, - GhostBindingLintReport, -} from "./types.js"; - -/** - * Lint a `ghost.binding/v1` document. Schema-level validity (shape, slug ids, - * non-empty paths) is enforced by Zod; this adds document-level checks the - * schema cannot express: the contract reference is `.` (in-repo) or an npm - * package name, and a surface should not be bound twice in one file. - * - * Cross-referencing surface ids against the contract's surfaces happens at - * resolution/verify, not here — the binding file cannot see the contract. - */ -export function lintGhostBinding(input: unknown): GhostBindingLintReport { - const result = GhostBindingSchema.safeParse(input); - if (!result.success) return finalize(zodIssues(result.error.issues)); - - const doc = result.data as GhostBindingDocument; - const issues: GhostBindingLintIssue[] = []; - - if (classifyContractReference(doc.contract) === "unsupported") { - issues.push({ - severity: "error", - rule: "binding-contract-unsupported", - message: `contract '${doc.contract}' is not supported; use '.' (in-repo) or an npm package name.`, - path: "contract", - }); - } - - const seen = new Map(); - doc.bindings.forEach((entry, index) => { - const previous = seen.get(entry.surface); - if (previous !== undefined) { - issues.push({ - severity: "error", - rule: "binding-duplicate-surface", - message: `surface '${entry.surface}' is bound more than once (also at bindings[${previous}])`, - path: `bindings[${index}].surface`, - }); - } else { - seen.set(entry.surface, index); - } - }); - - return finalize(issues); -} - -function zodIssues(issues: ZodIssue[]): GhostBindingLintIssue[] { - return issues.map((issue) => ({ - severity: "error" as const, - rule: `schema/${issue.code}`, - message: issue.message, - path: formatZodPath(issue.path) ?? "", - })); -} - -function formatZodPath(path: ZodIssue["path"]): string | undefined { - if (path.length === 0) return undefined; - return path.reduce((formatted, segment) => { - if (typeof segment === "number") return `${formatted}[${segment}]`; - const key = String(segment); - return formatted ? `${formatted}.${key}` : key; - }, ""); -} - -function finalize(issues: GhostBindingLintIssue[]): GhostBindingLintReport { - return { - issues, - errors: issues.filter((issue) => issue.severity === "error").length, - warnings: issues.filter((issue) => issue.severity === "warning").length, - info: issues.filter((issue) => issue.severity === "info").length, - }; -} diff --git a/packages/ghost/src/ghost-core/binding/resolve.ts b/packages/ghost/src/ghost-core/binding/resolve.ts deleted file mode 100644 index 634b9e97..00000000 --- a/packages/ghost/src/ghost-core/binding/resolve.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { GHOST_SURFACE_ROOT_ID } from "../surfaces/types.js"; -import type { GhostBindingEntry } from "./types.js"; - -/** - * A binding candidate discovered along a path, normalized to a directory depth. - * `dir` is the POSIX-relative directory (from repo root) the binding governs; - * deeper dirs are nearer the leaf and win. - */ -export interface BindingCandidate { - /** POSIX-relative directory the binding sits in (e.g. "apps/checkout"). */ - dir: string; - /** True for an explicit .ghost.bind.yml, false for directory-implied. */ - explicit: boolean; - /** - * The bindings this candidate offers. For directory-implied bindings, this is - * derived from the scoped package's declared surfaces. For explicit bindings, - * it is the `.ghost.bind.yml` entries. - */ - entries: GhostBindingEntry[]; -} - -export type PathResolutionReason = - | "explicit" - | "directory" - | "root-core" - | "unbound"; - -export interface PathResolution { - /** The resolved surface id, or null when unbound and no root contract. */ - surface: string | null; - /** Directory of the winning binding, or null when none applied. */ - binding_dir: string | null; - reason: PathResolutionReason; -} - -/** - * Resolve a repo-relative path to the surface that owns it, deterministically. - * - * - Candidates are ranked by directory depth (nearest the leaf wins). At equal - * depth, an explicit `.ghost.bind.yml` beats a directory-implied binding. - * - The winning candidate's entry whose paths match the file names the surface. - * A candidate that offers exactly one entry binds unconditionally (the common - * directory-default case); when several entries compete, the file must match - * an entry's `paths`. - * - Unbound: `core` when a root contract exists, else null (caller emits menu). - * - * No LLM, no I/O. Discovery (walking the tree, reading files) is the caller's - * job; this is the pure ranking + matching core. - */ -export function resolvePathToSurface( - path: string, - candidates: BindingCandidate[], - options: { hasRootContract: boolean }, -): PathResolution { - const file = normalize(path); - - const ranked = [...candidates].sort((a, b) => { - const depthA = depthOf(a.dir); - const depthB = depthOf(b.dir); - if (depthA !== depthB) return depthB - depthA; // deeper (nearer leaf) first - if (a.explicit !== b.explicit) return a.explicit ? -1 : 1; // explicit wins - return 0; - }); - - for (const candidate of ranked) { - // The candidate only governs files under its directory. - if (!isUnder(file, candidate.dir)) continue; - - const match = matchEntry(file, candidate); - if (match) { - return { - surface: match, - binding_dir: candidate.dir, - reason: candidate.explicit ? "explicit" : "directory", - }; - } - } - - if (options.hasRootContract) { - return { - surface: GHOST_SURFACE_ROOT_ID, - binding_dir: null, - reason: "root-core", - }; - } - return { surface: null, binding_dir: null, reason: "unbound" }; -} - -/** - * Choose the surface a candidate binds for a file: - * - one entry → it binds unconditionally (directory-default common case); - * - many entries → the file must fall under an entry's `paths` (report-don't- - * guess: a multi-surface candidate with no path match does not bind). - */ -function matchEntry(file: string, candidate: BindingCandidate): string | null { - if (candidate.entries.length === 0) return null; - if (candidate.entries.length === 1 && !candidate.explicit) { - return candidate.entries[0].surface; - } - for (const entry of candidate.entries) { - for (const pattern of entry.paths) { - if (matchesPath(file, normalize(pattern))) return entry.surface; - } - } - // A single explicit entry with paths still requires a path match; a single - // directory-implied entry already returned above. - if (candidate.entries.length === 1 && candidate.explicit) { - const entry = candidate.entries[0]; - for (const pattern of entry.paths) { - if (matchesPath(file, normalize(pattern))) return entry.surface; - } - } - return null; -} - -function depthOf(dir: string): number { - if (dir === "" || dir === ".") return 0; - return dir.split("/").length; -} - -function isUnder(file: string, dir: string): boolean { - if (dir === "" || dir === ".") return true; - return file === dir || file.startsWith(`${dir}/`); -} - -function matchesPath(file: string, pattern: string): boolean { - if (pattern.includes("*")) return globToRegExp(pattern).test(file); - const normalized = pattern.replace(/\/$/, ""); - return file === normalized || file.startsWith(`${normalized}/`); -} - -function normalize(path: string): string { - return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/g, ""); -} - -function globToRegExp(glob: string): RegExp { - let out = "^"; - for (let i = 0; i < glob.length; i++) { - const char = glob[i]; - const next = glob[i + 1]; - if (char === "*" && next === "*") { - out += ".*"; - i += 1; - } else if (char === "*") { - out += "[^/]*"; - } else { - out += escapeRegExp(char); - } - } - out += "$"; - return new RegExp(out); -} - -function escapeRegExp(value: string): string { - return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); -} diff --git a/packages/ghost/src/ghost-core/binding/schema.ts b/packages/ghost/src/ghost-core/binding/schema.ts deleted file mode 100644 index add950a7..00000000 --- a/packages/ghost/src/ghost-core/binding/schema.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from "zod"; -import { GHOST_BINDING_SCHEMA } from "./types.js"; - -/** Flat surface-id slug — same discipline as surfaces.yml (no dotted hierarchy). */ -const SurfaceIdSchema = z - .string() - .min(1) - .regex(/^[a-z0-9][a-z0-9_-]*$/, { - message: - "surface id must be a flat slug (lowercase alphanumeric plus _ -, no dots)", - }); - -const BindingEntrySchema = z - .object({ - surface: SurfaceIdSchema, - paths: z.array(z.string().min(1)).min(1), - }) - .strict(); - -/** - * Zod schema for `.ghost.bind.yml` (`ghost.binding/v1`). - * - * Validates each entry in isolation. Cross-referencing surface ids against the - * contract's `surfaces.yml` happens at resolution time, not schema time, since - * the schema cannot see the contract from the binding file alone. - */ -export const GhostBindingSchema = z - .object({ - schema: z.literal(GHOST_BINDING_SCHEMA), - contract: z.string().min(1), - bindings: z.array(BindingEntrySchema).min(1), - }) - .strict(); diff --git a/packages/ghost/src/ghost-core/binding/types.ts b/packages/ghost/src/ghost-core/binding/types.ts deleted file mode 100644 index 57d9fa1e..00000000 --- a/packages/ghost/src/ghost-core/binding/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -export const GHOST_BINDING_SCHEMA = "ghost.binding/v1" as const; -export const GHOST_BINDING_FILENAME = ".ghost.bind.yml" as const; - -/** - * One binding entry: a surface in the contract, realized by these repo paths. - * `paths` live here on the binding, never on the surface — this is the home of - * the deleted `topology.scopes[].paths` (see docs/ideas/surface-binding.md). - */ -export interface GhostBindingEntry { - surface: string; - paths: string[]; -} - -export interface GhostBindingDocument { - schema: typeof GHOST_BINDING_SCHEMA; - /** - * Reference to the contract this binding instantiates: `.` (the in-repo root - * contract) or an npm package name (`@scope/brand`), resolved from - * `node_modules`. Other references (paths, URLs, resource ids) are not yet - * supported. `verify` checks an external contract resolves and the bound - * surfaces exist in it (see docs/ideas/polish-cut-d-plan.md). - */ - contract: string; - bindings: GhostBindingEntry[]; -} - -export type GhostBindingLintSeverity = "error" | "warning" | "info"; - -export interface GhostBindingLintIssue { - severity: GhostBindingLintSeverity; - rule: string; - message: string; - path: string; -} - -export interface GhostBindingLintReport { - issues: GhostBindingLintIssue[]; - errors: number; - warnings: number; - info: number; -} diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index d1593541..51febe5c 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -1,24 +1,5 @@ // --- Embedding primitives --- -// --- Binding (ghost.binding/v1) --- -export { - type BindingCandidate, - type ContractReferenceKind, - classifyContractReference, - GHOST_BINDING_FILENAME, - GHOST_BINDING_SCHEMA, - type GhostBindingDocument, - type GhostBindingEntry, - type GhostBindingLintIssue, - type GhostBindingLintReport, - type GhostBindingLintSeverity, - GhostBindingSchema, - IN_REPO_CONTRACT, - lintGhostBinding, - type PathResolution, - type PathResolutionReason, - resolvePathToSurface, -} from "./binding/index.js"; // --- Check (ghost.check/v1) — markdown checks, agent-evaluated --- export { type CheckRelevance, diff --git a/packages/ghost/src/init-command.ts b/packages/ghost/src/init-command.ts index 95c1ae99..02925c48 100644 --- a/packages/ghost/src/init-command.ts +++ b/packages/ghost/src/init-command.ts @@ -1,22 +1,13 @@ import type { CAC } from "cac"; import { initFingerprintPackage, - initScopedFingerprintPackage, type resolveFingerprintPackage, } from "./fingerprint.js"; -import { - initMonorepoFingerprintPackages, - writeMonorepoInitOutput, -} from "./monorepo-init-command.js"; import { resolveGhostDirDefault } from "./scan/index.js"; export function registerInitCommand(cli: CAC): void { cli .command("init", "Create a root .ghost split fingerprint package") - .option( - "--scope ", - "Create a scoped /.ghost fingerprint package", - ) .option( "--package ", "Exact fingerprint package directory to initialize", @@ -25,11 +16,6 @@ export function registerInitCommand(cli: CAC): void { "--reference ", "Reference UI registry, library path, or fingerprint to record in inventory building blocks", ) - .option( - "--monorepo", - "Detect monorepo child package roots and propose scoped Ghost packages", - ) - .option("--apply", "With --monorepo, create detected child scoped packages") .option("--force", "Overwrite existing Ghost fingerprint files") .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (opts) => { @@ -41,35 +27,6 @@ export function registerInitCommand(cli: CAC): void { process.exit(2); return; } - if (opts.monorepo && typeof opts.scope === "string") { - console.error( - "Error: use either init --scope or init --monorepo", - ); - process.exit(2); - return; - } - if (opts.apply && !opts.monorepo) { - console.error("Error: --apply can only be used with --monorepo"); - process.exit(2); - return; - } - if (opts.monorepo && typeof opts.package === "string") { - console.error( - "Error: use either init --package or init --monorepo", - ); - process.exit(2); - return; - } - if ( - typeof opts.scope === "string" && - typeof opts.package === "string" - ) { - console.error( - "Error: use either init --package or init --scope ", - ); - process.exit(2); - return; - } const exactPackage = typeof opts.package === "string" ? opts.package : undefined; const ghostDir = @@ -79,27 +36,11 @@ export function registerInitCommand(cli: CAC): void { typeof opts.reference === "string" ? opts.reference : undefined, force: Boolean(opts.force), }; - if (opts.monorepo) { - const output = await initMonorepoFingerprintPackages({ - ghostDir: ghostDir ?? ghostDirFromEnv(), - apply: Boolean(opts.apply), - initOptions, - }); - writeMonorepoInitOutput(output, opts.format); - process.exit(output.errors.length > 0 ? 2 : 0); - return; - } - const paths = - typeof opts.scope === "string" - ? await initScopedFingerprintPackage(opts.scope, process.cwd(), { - ...initOptions, - ghostDir: ghostDir ?? ghostDirFromEnv(), - }) - : await initFingerprintPackage( - exactPackage ?? ghostDir, - process.cwd(), - initOptions, - ); + const paths = await initFingerprintPackage( + exactPackage ?? ghostDir, + process.cwd(), + initOptions, + ); if (opts.format === "json") { process.stdout.write( `${JSON.stringify(initCommandOutput(paths), null, 2)}\n`, diff --git a/packages/ghost/src/monorepo-init-command.ts b/packages/ghost/src/monorepo-init-command.ts deleted file mode 100644 index c32f0dc9..00000000 --- a/packages/ghost/src/monorepo-init-command.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { stat } from "node:fs/promises"; -import { resolve } from "node:path"; -import { - type FingerprintPackagePaths, - initFingerprintPackage, - initScopedFingerprintPackage, - resolveFingerprintPackage, -} from "./fingerprint.js"; -import { - detectMonorepoInitCandidates, - type MonorepoInitCandidate, - normalizeGhostDir, -} from "./scan/index.js"; - -type InitOptions = NonNullable[2]>; - -type MonorepoInitCandidateState = MonorepoInitCandidate & { - state: "candidate" | "exists"; -}; - -interface MonorepoInitOutput { - root: Record; - rootState: "created" | "exists"; - mode: "plan" | "apply"; - ghostDir: string; - candidates: MonorepoInitCandidateState[]; - created: Array; - skipped: MonorepoInitCandidateState[]; - errors: Array<{ path: string; message: string }>; - commands: string[]; -} - -export async function initMonorepoFingerprintPackages(options: { - ghostDir: string; - apply: boolean; - initOptions: InitOptions; -}): Promise { - const cwd = process.cwd(); - const rootPaths = resolveFingerprintPackage(options.ghostDir, cwd); - const rootExists = await hasFingerprintPackage(rootPaths); - const rootState = - rootExists && !options.initOptions.force ? "exists" : "created"; - const root = - rootState === "exists" - ? rootPaths - : await initFingerprintPackage( - options.ghostDir, - cwd, - options.initOptions, - ); - - const candidates = await Promise.all( - (await detectMonorepoInitCandidates(cwd)).map( - async (candidate): Promise => ({ - ...candidate, - state: (await hasScopeFingerprintPackage( - candidate, - options.ghostDir, - cwd, - )) - ? "exists" - : "candidate", - }), - ), - ); - const commands = candidates - .filter((candidate) => candidate.state === "candidate") - .map((candidate) => - formatScopedInitCommand(candidate.path, options.ghostDir), - ); - const created: MonorepoInitOutput["created"] = []; - const skipped: MonorepoInitOutput["skipped"] = candidates.filter( - (candidate) => candidate.state === "exists", - ); - const errors: MonorepoInitOutput["errors"] = []; - - if (options.apply) { - for (const candidate of candidates) { - if (candidate.state === "exists" && !options.initOptions.force) continue; - try { - await initScopedFingerprintPackage(candidate.path, cwd, { - ...options.initOptions, - ghostDir: options.ghostDir, - }); - created.push({ ...stripCandidateState(candidate), state: "created" }); - } catch (err) { - errors.push({ - path: candidate.path, - message: err instanceof Error ? err.message : String(err), - }); - } - } - } - - return { - root: initPackageOutput(root), - rootState, - mode: options.apply ? "apply" : "plan", - ghostDir: options.ghostDir, - candidates, - created, - skipped, - errors, - commands, - }; -} - -export function writeMonorepoInitOutput( - output: MonorepoInitOutput, - format: unknown, -): void { - if (format === "json") { - process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); - return; - } - - const rootVerb = - output.rootState === "exists" ? "Using existing" : "Initialized"; - process.stdout.write(`${rootVerb} Ghost root package: ${output.root.dir}\n`); - if (output.candidates.length === 0) { - process.stdout.write("\nNo monorepo child package candidates found.\n"); - return; - } - - process.stdout.write("\nDetected monorepo child candidates:\n"); - for (const candidate of output.candidates) { - const suffix = candidate.state === "exists" ? " (exists)" : ""; - process.stdout.write(` ${candidate.path}${suffix}\n`); - } - - if (output.mode === "apply") { - process.stdout.write("\nCreated child packages:\n"); - if (output.created.length === 0) { - process.stdout.write(" none\n"); - } else { - for (const candidate of output.created) { - process.stdout.write(` ${candidate.path}\n`); - } - } - if (output.skipped.length > 0) { - process.stdout.write("\nSkipped existing child packages:\n"); - for (const candidate of output.skipped) { - process.stdout.write(` ${candidate.path}\n`); - } - } - return; - } - - if (output.commands.length > 0) { - process.stdout.write("\nNext:\n"); - for (const command of output.commands) { - process.stdout.write(` ${command}\n`); - } - process.stdout.write( - "\nRun ghost init --monorepo --apply to create these child packages.\n", - ); - } else { - process.stdout.write("\nAll detected child packages already have Ghost.\n"); - } -} - -async function hasScopeFingerprintPackage( - candidate: MonorepoInitCandidate, - ghostDir: string, - cwd: string, -): Promise { - return hasFingerprintPackage( - resolveFingerprintPackage(ghostDir, resolve(cwd, candidate.path)), - ); -} - -async function hasFingerprintPackage( - paths: Pick, -): Promise { - try { - return (await stat(paths.manifest)).isFile(); - } catch { - return false; - } -} - -function stripCandidateState( - candidate: MonorepoInitCandidateState, -): MonorepoInitCandidate { - return { - path: candidate.path, - source: candidate.source, - packageJson: candidate.packageJson, - }; -} - -function formatScopedInitCommand(path: string, ghostDir: string): string { - const base = `ghost init --scope ${formatCommandArg(path)}`; - return ghostDir === normalizeGhostDir() - ? base - : `GHOST_PACKAGE_DIR=${formatCommandArg(ghostDir)} ${base}`; -} - -function formatCommandArg(value: string): string { - return /^[A-Za-z0-9._/-]+$/.test(value) ? value : JSON.stringify(value); -} - -function initPackageOutput( - paths: FingerprintPackagePaths, -): Record { - return { - dir: paths.dir, - manifest: paths.manifest, - intent: paths.intent, - inventory: paths.inventory, - composition: paths.composition, - }; -} diff --git a/packages/ghost/src/review-packet.ts b/packages/ghost/src/review-packet.ts index ba27690b..5999e098 100644 --- a/packages/ghost/src/review-packet.ts +++ b/packages/ghost/src/review-packet.ts @@ -1,29 +1,28 @@ import { groundSurface, type RoutedCheck, - resolvePathToSurface, type SurfaceGrounding, selectChecksForSurfaces, } from "#ghost-core"; -import { discoverBindingsForPath } from "./scan/binding-discovery.js"; import { loadChecksDir } from "./scan/checks-dir.js"; import { loadFingerprintPackage, resolveFingerprintPackage, } from "./scan/fingerprint-package.js"; -import { parseUnifiedDiff } from "./scan/unified-diff.js"; const DEFAULT_REVIEW_MAX_DIFF_BYTES = 200_000; /** - * Build an advisory review packet on the surface rails: resolve the diff's - * changed paths to the surfaces that own them (bindings), select the markdown - * checks governing those surfaces and their ancestors, and ground each in the - * surface's fingerprint slice. No `validate.yml`, no dormant context entrypoint. + * 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). */ export async function buildReviewPacket(options: { packageDir?: string; diffText: string; + surfaces: string[]; maxDiffBytes?: number; }): Promise { const cwd = process.cwd(); @@ -31,24 +30,11 @@ export async function buildReviewPacket(options: { const loaded = await loadFingerprintPackage(paths); const { checks, invalid } = await loadChecksDir(paths.dir); - const changedPaths = parseUnifiedDiff(options.diffText).map( - (file) => file.path, - ); - - // Resolve each changed path to its surface via bindings; union them. - const touched = new Set(); - for (const path of changedPaths) { - const discovered = await discoverBindingsForPath(path, cwd); - const resolution = resolvePathToSurface( - discovered.target_path, - discovered.candidates, - { hasRootContract: discovered.hasRootContract || !!loaded.surfaces }, - ); - if (resolution.surface) touched.add(resolution.surface); - } + // The agent names the touched surfaces; dedupe and route. + const touched = [...new Set(options.surfaces.filter((s) => s.length > 0))]; - const routed = selectChecksForSurfaces(checks, loaded.surfaces, [...touched]); - const grounding = [...touched].map((surface) => + const routed = selectChecksForSurfaces(checks, loaded.surfaces, touched); + const grounding = touched.map((surface) => groundSurface(loaded.surfaces, loaded.fingerprint, surface), ); @@ -56,7 +42,7 @@ export async function buildReviewPacket(options: { ...baseReviewPacket(paths.dir, options.diffText, { maxDiffBytes: options.maxDiffBytes, }), - touched_surfaces: [...touched], + touched_surfaces: touched, routed_checks: routed, grounding, invalid_checks: invalid, diff --git a/packages/ghost/src/scan-emit-command.ts b/packages/ghost/src/scan-emit-command.ts index c5b1329f..a8413108 100644 --- a/packages/ghost/src/scan-emit-command.ts +++ b/packages/ghost/src/scan-emit-command.ts @@ -7,11 +7,6 @@ import { } from "./context/package-context.js"; import { emitPackageReviewCommand } from "./context/package-review-command.js"; import { resolveFingerprintPackage } from "./fingerprint.js"; -import { - fingerprintStackToPackageContext, - loadFingerprintStackForPath, - resolveGhostDirDefault, -} from "./scan/fingerprint-stack.js"; const DEFAULT_REVIEW_OUT = ".claude/commands/design-review.md"; @@ -42,13 +37,9 @@ export function registerEmitCommand(cli: CAC): void { "emit ", "Emit a derived artifact from the fingerprint package (review-command).", ) - .option( - "--path ", - "Resolve a nested fingerprint stack for this repo path", - ) .option( "--package ", - "Use exactly this fingerprint package directory instead of resolving a stack", + "Use exactly this fingerprint package directory (default: ./.ghost)", ) .option( "-o, --out ", @@ -64,17 +55,6 @@ export function registerEmitCommand(cli: CAC): void { return; } - const explicitPath = typeof opts.path === "string"; - const explicitPackage = typeof opts.package === "string"; - const explicitSources = [explicitPath, explicitPackage].filter( - Boolean, - ).length; - if (explicitSources > 1) { - console.error("Error: use only one of --path or --package"); - process.exit(2); - return; - } - const context = await loadEmitPackageContext(opts); const content = emitPackageReviewCommand({ context, @@ -102,21 +82,12 @@ export function registerEmitCommand(cli: CAC): void { } async function loadEmitPackageContext(opts: { - path?: unknown; package?: unknown; }): Promise { - if (typeof opts.package === "string") { - return loadPackageContext( - resolveFingerprintPackage(opts.package, process.cwd()), - ); - } - - const stack = await loadFingerprintStackForPath( - typeof opts.path === "string" ? opts.path : ".", - process.cwd(), - { - ghostDir: resolveGhostDirDefault(), - }, + return loadPackageContext( + resolveFingerprintPackage( + typeof opts.package === "string" ? opts.package : undefined, + process.cwd(), + ), ); - return fingerprintStackToPackageContext(stack); } diff --git a/packages/ghost/src/scan/binding-discovery.ts b/packages/ghost/src/scan/binding-discovery.ts deleted file mode 100644 index d49eca85..00000000 --- a/packages/ghost/src/scan/binding-discovery.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; -import { parse as parseYaml } from "yaml"; -import { - type BindingCandidate, - GHOST_BINDING_FILENAME, - GHOST_SURFACES_YML_FILENAME, - GhostBindingSchema, - GhostSurfacesSchema, -} from "#ghost-core"; -import { FINGERPRINT_PACKAGE_DIR } from "./constants.js"; -import { resolveGitRoot } from "./fingerprint-stack.js"; - -export interface DiscoverBindingsOptions { - ghostDir?: string; -} - -export interface DiscoveredBindings { - repo_root: string; - target_path: string; - candidates: BindingCandidate[]; - /** True when the repo root has a `/surfaces.yml` (a root contract). */ - hasRootContract: boolean; -} - -/** - * Walk from the repo root down to the directory containing `targetPath`, - * collecting binding candidates at each level: - * - * - directory-implied: a scoped `/surfaces.yml` binds its declared - * non-`core` surfaces to that directory's subtree; - * - explicit: a `.ghost.bind.yml` at that level binds the surfaces it names. - * - * No ranking here — that is `resolvePathToSurface`'s job. This only reads the - * filesystem and produces candidates. - */ -export async function discoverBindingsForPath( - targetPath: string, - cwd = process.cwd(), - options: DiscoverBindingsOptions = {}, -): Promise { - const repoRoot = await resolveGitRoot(cwd); - const ghostDir = options.ghostDir ?? FINGERPRINT_PACKAGE_DIR; - const target = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath); - - // Directories from repo root down to the file's directory, inclusive. - const dirs = directoriesFromRootToTarget(repoRoot, target); - - const candidates: BindingCandidate[] = []; - let hasRootContract = false; - - for (const dir of dirs) { - const relDir = posixRelative(repoRoot, dir); - - // Directory-implied binding from a scoped surfaces.yml. - const surfacesPath = resolve(dir, ghostDir, GHOST_SURFACES_YML_FILENAME); - const surfaceIds = await readSurfaceIds(surfacesPath); - if (surfaceIds !== null) { - if (relDir === "") hasRootContract = true; - const bound = surfaceIds.filter((id) => id !== "core"); - if (relDir !== "" && bound.length > 0) { - candidates.push({ - dir: relDir, - explicit: false, - entries: bound.map((surface) => ({ surface, paths: [relDir] })), - }); - } - } - - // Explicit binding. - const explicitPath = resolve(dir, GHOST_BINDING_FILENAME); - const explicit = await readExplicitBinding(explicitPath); - if (explicit) { - candidates.push({ - dir: relDir, - explicit: true, - entries: explicit, - }); - } - } - - return { - repo_root: repoRoot, - target_path: posixRelative(repoRoot, target), - candidates, - hasRootContract, - }; -} - -async function readSurfaceIds(path: string): Promise { - let raw: string; - try { - raw = await readFile(path, "utf-8"); - } catch { - return null; - } - const result = GhostSurfacesSchema.safeParse(parseYaml(raw)); - if (!result.success) return null; - return result.data.surfaces.map((surface) => surface.id); -} - -async function readExplicitBinding(path: string) { - let raw: string; - try { - raw = await readFile(path, "utf-8"); - } catch { - return null; - } - const result = GhostBindingSchema.safeParse(parseYaml(raw)); - if (!result.success) return null; - return result.data.bindings.map((entry) => ({ - surface: entry.surface, - paths: entry.paths, - })); -} - -function directoriesFromRootToTarget( - repoRoot: string, - target: string, -): string[] { - const dirs: string[] = []; - // Start at the target's directory (a file path) — but the target may itself be - // a directory; we conservatively include it and walk up to the root. - let current = target; - // If target looks like a file (has an extension), start at its directory. - if (/\.[^/\\]+$/.test(target)) current = dirname(target); - while (isWithinOrEqual(repoRoot, current)) { - dirs.push(current); - if (current === repoRoot) break; - current = dirname(current); - } - return dirs.reverse(); // root first -} - -function isWithinOrEqual(root: string, candidate: string): boolean { - const rel = relative(root, candidate); - return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); -} - -function posixRelative(root: string, target: string): string { - const rel = relative(root, target); - return rel.split(sep).join("/"); -} diff --git a/packages/ghost/src/scan/contract-resolver.ts b/packages/ghost/src/scan/contract-resolver.ts deleted file mode 100644 index 4ad9136e..00000000 --- a/packages/ghost/src/scan/contract-resolver.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { access } from "node:fs/promises"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { classifyContractReference } from "#ghost-core"; -import { FINGERPRINT_PACKAGE_DIR } from "./constants.js"; - -export interface ResolveContractOptions { - /** The package dir name to look for inside a resolved contract (default `.ghost`). */ - ghostDir?: string; -} - -/** - * Resolve a binding `contract:` reference to the contract's `.ghost/` directory. - * - * - `.` → the in-repo root contract at `/`. - * - npm name → the nearest `node_modules//` walking up from - * `fromDir` to `repoRoot`. - * - * Filesystem-only: installing the package is the host's job. Returns `null` when - * the contract cannot be resolved or the reference kind is unsupported. - */ -export async function resolveContractDir( - reference: string, - fromDir: string, - repoRoot: string, - options: ResolveContractOptions = {}, -): Promise { - const ghostDir = options.ghostDir ?? FINGERPRINT_PACKAGE_DIR; - const kind = classifyContractReference(reference); - - if (kind === "in-repo") { - const dir = resolve(repoRoot, ghostDir); - return (await exists(dir)) ? dir : null; - } - - if (kind === "npm") { - let current = isAbsolute(fromDir) ? fromDir : resolve(repoRoot, fromDir); - while (isWithinOrEqual(repoRoot, current)) { - const candidate = join(current, "node_modules", reference, ghostDir); - if (await exists(candidate)) return candidate; - if (current === repoRoot) break; - current = dirname(current); - } - return null; - } - - return null; -} - -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function isWithinOrEqual(root: string, candidate: string): boolean { - const rel = relative(root, candidate); - return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); -} diff --git a/packages/ghost/src/scan/file-kind.ts b/packages/ghost/src/scan/file-kind.ts index 0dda7a49..ee40a12a 100644 --- a/packages/ghost/src/scan/file-kind.ts +++ b/packages/ghost/src/scan/file-kind.ts @@ -5,7 +5,6 @@ import { GhostFingerprintIntentSchema, GhostFingerprintInventorySchema, GhostFingerprintPackageManifestSchema, - lintGhostBinding, lintGhostCheck, lintGhostFingerprint, lintGhostPatterns, @@ -27,7 +26,6 @@ export type DetectedFileKind = | "resources" | "patterns" | "surfaces" - | "binding" | "check" | "unsupported-yaml"; @@ -81,9 +79,6 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { if (filename === "patterns.yaml") return "patterns"; if (filename === "surfaces.yml") return "surfaces"; if (filename === "surfaces.yaml") return "surfaces"; - if (filename === ".ghost.bind.yml" || filename === ".ghost.bind.yaml") { - return "binding"; - } // A markdown check lives under a `checks/` directory. Detected by location so // the established agent-check format (no `schema:` field) is recognized. if (filename.endsWith(".md") && /(^|[\\/])checks[\\/]/.test(lowerPath)) { @@ -99,7 +94,6 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { if (/^\s*schema:\s*ghost\.resources\/v1\b/m.test(raw)) return "resources"; if (/^\s*schema:\s*ghost\.patterns\/v1\b/m.test(raw)) return "patterns"; if (/^\s*schema:\s*ghost\.surfaces\/v1\b/m.test(raw)) return "surfaces"; - if (/^\s*schema:\s*ghost\.binding\/v1\b/m.test(raw)) return "binding"; if (lowerPath.endsWith(".yml") || lowerPath.endsWith(".yaml")) { return "unsupported-yaml"; } @@ -129,13 +123,11 @@ export function lintDetectedFileKind( ? lintPatternsFile(raw) : kind === "surfaces" ? lintSurfacesFile(raw) - : kind === "binding" - ? lintBindingFile(raw) - : kind === "check" - ? lintGhostCheck(raw) - : kind === "unsupported-yaml" - ? lintUnsupportedYamlFile() - : lintFingerprint(raw); + : kind === "check" + ? lintGhostCheck(raw) + : kind === "unsupported-yaml" + ? lintUnsupportedYamlFile() + : lintFingerprint(raw); } function lintSurveyFile(raw: string): SurveyLintReport { @@ -253,14 +245,6 @@ function lintSurfacesFile(raw: string): ReturnType { } } -function lintBindingFile(raw: string): ReturnType { - try { - return lintGhostBinding(parseYaml(raw)); - } catch (err) { - return yamlErrorReport("binding-not-yaml", "binding file", err); - } -} - function lintUnsupportedYamlFile(): ReturnType { return { issues: [ diff --git a/packages/ghost/src/scan/fingerprint-stack.ts b/packages/ghost/src/scan/fingerprint-stack.ts deleted file mode 100644 index b8015561..00000000 --- a/packages/ghost/src/scan/fingerprint-stack.ts +++ /dev/null @@ -1,671 +0,0 @@ -import { execFile } from "node:child_process"; -import type { Dirent } from "node:fs"; -import { access, mkdir, readdir, stat } from "node:fs/promises"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; -import { promisify } from "node:util"; -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; -import { - type GhostFingerprintDocument, - type GhostFingerprintEvidence, - lintGhostFingerprint, -} from "#ghost-core"; -import type { PackageContext } from "../context/package-context.js"; -import { readOptionalUtf8 } from "../internal/fs.js"; -import { - FINGERPRINT_MANIFEST_FILENAME, - FINGERPRINT_PACKAGE_DIR, -} from "./constants.js"; -import type { FingerprintPackagePaths } from "./fingerprint-package.js"; -import { - lintFingerprintPackage, - loadFingerprintPackage, - resolveFingerprintPackage, -} from "./fingerprint-package.js"; -import type { LintIssue, LintReport } from "./lint.js"; -import type { - VerifyFingerprintIssue, - VerifyFingerprintReport, -} from "./verify-fingerprint.js"; -import { verifyFingerprintPackage } from "./verify-package.js"; - -const execFileAsync = promisify(execFile); - -const BASE_SKIP_DISCOVERY_DIRS = new Set([ - ".git", - ".ghost", - "node_modules", - "dist", - "dist-lib", - "build", - ".next", - ".turbo", - "coverage", -]); - -export interface FingerprintDirectoryOptions { - ghostDir?: string; -} - -export interface GhostFingerprintStackLayerRef { - dir: string; - root: string; - relative_root: string; - ghost_dir: string; -} - -export interface GhostFingerprintStackLayer - extends GhostFingerprintStackLayerRef { - fingerprint: GhostFingerprintDocument; - fingerprint_raw: string; -} - -export interface GhostFingerprintStack { - target_path: string; - repo_root: string; - ghost_dir: string; - layers: GhostFingerprintStackLayer[]; - /** - * The single source of truth for a path: the root contract's fingerprint and - * checks, used as-is. Nesting binds paths to surfaces (ghost.binding/v1); it - * no longer merges facets (the retired `child-wins-by-id` model). One - * contract, many bindings. - */ - contract: { - /** Directory of the contract package (the root-most discovered package). */ - dir: string; - fingerprint: GhostFingerprintDocument; - }; - provenance: { - layers: GhostFingerprintStackLayerRef[]; - }; -} - -export interface GhostFingerprintStackGroup { - key: string; - changed_files: string[]; - stack: GhostFingerprintStack; -} - -export interface DiscoveredGhostPackage { - dir: string; - root: string; - relative_root: string; - ghost_dir: string; -} - -export async function resolveGitRoot(cwd = process.cwd()): Promise { - try { - const { stdout } = await execFileAsync( - "git", - ["rev-parse", "--show-toplevel"], - { - cwd, - }, - ); - return resolve(stdout.trim()); - } catch { - return resolve(cwd); - } -} - -export async function discoverGhostPackages( - root = process.cwd(), - options: FingerprintDirectoryOptions = {}, -): Promise { - const repoRoot = await resolveGitRoot(root); - const ghostDir = normalizeGhostDir(options.ghostDir); - const skipDirs = skipDiscoveryDirs(ghostDir); - const packages: DiscoveredGhostPackage[] = []; - - async function walk(dir: string): Promise { - const packageDir = resolve(dir, ghostDir); - if (await hasSplitFingerprintPackage(packageDir)) { - packages.push(packageRef(packageDir, repoRoot, ghostDir)); - } - - let entries: Dirent[]; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - return; - } - - await Promise.all( - entries.map(async (entry) => { - if (!entry.isDirectory()) return; - if (skipDirs.has(entry.name)) return; - await walk(resolve(dir, entry.name)); - }), - ); - } - - await walk(repoRoot); - return packages.sort((a, b) => a.dir.localeCompare(b.dir)); -} - -export async function discoverFingerprintStack( - targetPath = ".", - cwd = process.cwd(), - options: FingerprintDirectoryOptions = {}, -): Promise<{ target_path: string; repo_root: string; packages: string[] }> { - const repoRoot = await resolveGitRoot(cwd); - const ghostDir = normalizeGhostDir(options.ghostDir); - const target = resolve(cwd, targetPath); - let current = await startingDirectory(target); - const packages: string[] = []; - - while (isWithinOrEqual(repoRoot, current)) { - const packageDir = resolve(current, ghostDir); - if (await hasSplitFingerprintPackage(packageDir)) { - packages.push(packageDir); - } - if (current === repoRoot) break; - current = dirname(current); - } - - return { - target_path: normalizeRelative(repoRoot, target), - repo_root: repoRoot, - packages: packages.reverse(), - }; -} - -export async function loadFingerprintStackForPath( - targetPath = ".", - cwd = process.cwd(), - options: FingerprintDirectoryOptions = {}, -): Promise { - const ghostDir = normalizeGhostDir(options.ghostDir); - const discovered = await discoverFingerprintStack(targetPath, cwd, { - ghostDir, - }); - if (discovered.packages.length === 0) { - throw new Error( - `No ${ghostDir}/${FINGERPRINT_MANIFEST_FILENAME} found for ${targetPath}.`, - ); - } - - const layers = await Promise.all( - discovered.packages.map((dir) => - loadFingerprintStackLayer(dir, discovered.repo_root, ghostDir), - ), - ); - return buildFingerprintStack( - discovered.target_path, - discovered.repo_root, - layers, - ghostDir, - ); -} - -export async function groupFingerprintStacksForPaths( - paths: string[], - cwd = process.cwd(), - options: FingerprintDirectoryOptions = {}, -): Promise { - const targets = paths.length > 0 ? paths : ["."]; - const ghostDir = normalizeGhostDir(options.ghostDir); - const groups = new Map(); - - for (const path of targets) { - const stack = await loadFingerprintStackForPath(path, cwd, { ghostDir }); - const key = stack.layers.map((layer) => layer.dir).join("|"); - const existing = groups.get(key); - if (existing) { - existing.changed_files.push(path); - } else { - groups.set(key, { - key, - changed_files: [path], - stack, - }); - } - } - - return [...groups.values()]; -} - -export function buildFingerprintStack( - targetPath: string, - repoRoot: string, - layers: GhostFingerprintStackLayer[], - ghostDir = FINGERPRINT_PACKAGE_DIR, -): GhostFingerprintStack { - const normalizedGhostDir = normalizeGhostDir(ghostDir); - if (layers.length === 0) { - throw new Error("Cannot build a Ghost fingerprint stack without layers."); - } - - // One contract, many bindings: the root-most layer is the contract. Nesting - // no longer merges facets — a nested package binds paths to surfaces, it does - // not contribute its own fingerprint data (the retired child-wins-by-id - // model; see docs/ideas/surface-binding.md). - const contractLayer = layers[0]; - const fingerprint = contractLayer.fingerprint; - - return { - target_path: targetPath, - repo_root: repoRoot, - ghost_dir: normalizedGhostDir, - layers, - contract: { - dir: contractLayer.dir, - fingerprint, - }, - provenance: { - layers: layers.map(layerRef), - }, - }; -} - -export async function loadFingerprintStackLayer( - packageDir: string, - repoRoot: string, - ghostDir = FINGERPRINT_PACKAGE_DIR, -): Promise { - const paths = resolveFingerprintPackage(packageDir, process.cwd()); - const normalizedGhostDir = normalizeGhostDir(ghostDir); - const root = rootForFingerprintPackageDir(paths.dir, normalizedGhostDir); - const loaded = await loadFingerprintPackage(paths); - - const fingerprint = normalizeFingerprintPaths( - loaded.fingerprint, - root, - repoRoot, - ); - - return { - ...packageRef(paths.dir, repoRoot, normalizedGhostDir), - fingerprint, - fingerprint_raw: stringifyYaml(fingerprint, { lineWidth: 0 }), - }; -} - -export function fingerprintStackToPackageContext( - stack: GhostFingerprintStack, - nameOverride?: string, - targetPaths: string[] = [stack.target_path], -): PackageContext { - const name = sanitizeName( - nameOverride ?? - stack.contract.fingerprint.intent.summary.product ?? - stack.layers.at(-1)?.relative_root ?? - "ghost-package", - ); - return { - name, - packageDir: stack.contract.dir, - targetPaths, - stackDirs: stack.layers.map((layer) => layer.dir), - fingerprint: stack.contract.fingerprint, - fingerprintRaw: stringifyYaml(stack.contract.fingerprint, { lineWidth: 0 }), - }; -} - -export async function lintAllFingerprintStacks( - root = process.cwd(), - options: FingerprintDirectoryOptions = {}, -): Promise { - const ghostDir = normalizeGhostDir(options.ghostDir); - const packages = await discoverGhostPackages(root, { ghostDir }); - const issues: LintIssue[] = []; - - for (const pkg of packages) { - const rawReport = await lintFingerprintPackage(pkg.dir, root); - issues.push( - ...prefixIssues( - fingerprintPackageDisplayPath(pkg.relative_root, ghostDir), - rawReport.issues, - ), - ); - if (rawReport.errors > 0) continue; - - let stack: GhostFingerprintStack; - try { - stack = await loadFingerprintStackForPath(pkg.root, root, { ghostDir }); - } catch (err) { - issues.push({ - severity: "error", - rule: "stack-merge-invalid", - message: err instanceof Error ? err.message : String(err), - path: fingerprintPackageDisplayPath(pkg.relative_root, ghostDir), - }); - continue; - } - const fingerprintReport = lintGhostFingerprint(stack.contract.fingerprint); - issues.push( - ...prefixIssues( - `${fingerprintPackageDisplayPath(pkg.relative_root, ghostDir)}/contract.fingerprint`, - fingerprintReport.issues, - ), - ); - } - - return finalizeLint(issues); -} - -export async function verifyAllFingerprintStacks( - root = process.cwd(), - options: FingerprintDirectoryOptions = {}, -): Promise { - const ghostDir = normalizeGhostDir(options.ghostDir); - const packages = await discoverGhostPackages(root, { ghostDir }); - const issues: VerifyFingerprintIssue[] = []; - - for (const pkg of packages) { - const report = await verifyFingerprintPackage(pkg.dir, root, { - root: pkg.root, - }); - issues.push( - ...report.issues.map((issue) => ({ - ...issue, - path: issue.path - ? `${fingerprintPackageDisplayPath(pkg.relative_root, ghostDir)}.${issue.path}` - : fingerprintPackageDisplayPath(pkg.relative_root, ghostDir), - })), - ); - try { - await loadFingerprintStackForPath(pkg.root, root, { ghostDir }); - } catch (err) { - issues.push({ - severity: "error", - rule: "stack-merge-invalid", - message: err instanceof Error ? err.message : String(err), - path: fingerprintPackageDisplayPath(pkg.relative_root, ghostDir), - }); - } - } - - return { - issues, - errors: issues.filter((issue) => issue.severity === "error").length, - warnings: issues.filter((issue) => issue.severity === "warning").length, - info: issues.filter((issue) => issue.severity === "info").length, - }; -} - -export async function initScopedFingerprintPackage( - scopePath: string, - cwd = process.cwd(), - options: { - reference?: string; - force?: boolean; - ghostDir?: string; - } = {}, -): Promise { - const root = resolve(cwd, scopePath); - await mkdir(root, { recursive: true }); - return resolveAndInit(root, options); -} - -async function resolveAndInit( - root: string, - options: { - reference?: string; - force?: boolean; - ghostDir?: string; - }, -): Promise { - const { initFingerprintPackage } = await import("./fingerprint-package.js"); - const { ghostDir, ...initOptions } = options; - return initFingerprintPackage(normalizeGhostDir(ghostDir), root, initOptions); -} - -function normalizeFingerprintPaths( - input: GhostFingerprintDocument, - baseRoot: string, - repoRoot: string, -): GhostFingerprintDocument { - const fingerprint = clone(input); - fingerprint.inventory.exemplars = fingerprint.inventory.exemplars.map( - (exemplar) => ({ - ...exemplar, - path: normalizePath(exemplar.path, baseRoot, repoRoot), - }), - ); - fingerprint.intent.situations = fingerprint.intent.situations.map( - (entry) => ({ - ...entry, - evidence: normalizeFingerprintEvidence( - entry.evidence, - baseRoot, - repoRoot, - ), - }), - ); - fingerprint.intent.principles = fingerprint.intent.principles.map( - (entry) => ({ - ...entry, - evidence: normalizeFingerprintEvidence( - entry.evidence, - baseRoot, - repoRoot, - ), - }), - ); - fingerprint.intent.experience_contracts = - fingerprint.intent.experience_contracts.map((entry) => ({ - ...entry, - evidence: normalizeFingerprintEvidence( - entry.evidence, - baseRoot, - repoRoot, - ), - })); - fingerprint.composition.patterns = fingerprint.composition.patterns.map( - (entry) => ({ - ...entry, - evidence: normalizeFingerprintEvidence( - entry.evidence, - baseRoot, - repoRoot, - ), - }), - ); - return fingerprint; -} - -function normalizeFingerprintEvidence( - evidence: GhostFingerprintEvidence[] | undefined, - baseRoot: string, - repoRoot: string, -): GhostFingerprintEvidence[] | undefined { - return evidence?.map((entry) => - entry.path - ? { ...entry, path: normalizePath(entry.path, baseRoot, repoRoot) } - : entry, - ); -} - -function normalizePath( - path: string, - baseRoot: string, - repoRoot: string, -): string { - if (isRemoteReference(path)) return path; - const absolute = isAbsolute(path) ? path : resolve(baseRoot, path); - return normalizeRelative(repoRoot, absolute); -} - -function normalizeRelative(root: string, path: string): string { - const rel = relative(root, path).replaceAll(sep, "/"); - return rel || "."; -} - -async function startingDirectory(target: string): Promise { - try { - const s = await stat(target); - return s.isDirectory() ? target : dirname(target); - } catch { - return dirname(target); - } -} - -function isWithinOrEqual(root: string, candidate: string): boolean { - const rel = relative(root, candidate); - return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); -} - -async function pathExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -async function hasSplitFingerprintPackage( - packageDir: string, -): Promise { - return pathExists(resolve(packageDir, FINGERPRINT_MANIFEST_FILENAME)); -} - -function packageRef( - dir: string, - repoRoot: string, - ghostDir: string, -): DiscoveredGhostPackage { - const root = rootForFingerprintPackageDir(dir, ghostDir); - return { - dir, - root, - relative_root: normalizeRelative(repoRoot, root), - ghost_dir: ghostDir, - }; -} - -function layerRef( - layer: GhostFingerprintStackLayer, -): GhostFingerprintStackLayerRef { - return { - dir: layer.dir, - root: layer.root, - relative_root: layer.relative_root, - ghost_dir: layer.ghost_dir, - }; -} - -export function normalizeGhostDir(ghostDir = FINGERPRINT_PACKAGE_DIR): string { - const normalized = ghostDir - .trim() - .replaceAll("\\", "/") - .replace(/\/+/g, "/") - .replace(/\/$/g, ""); - if (!normalized) { - throw new Error("GHOST_PACKAGE_DIR must not be empty"); - } - if ( - isAbsolute(ghostDir) || - normalized.startsWith("/") || - /^[A-Za-z]:/.test(normalized) - ) { - throw new Error("GHOST_PACKAGE_DIR must be a relative directory path"); - } - const segments = normalized.split("/"); - if ( - segments.some( - (segment) => segment === "." || segment === ".." || segment === "", - ) - ) { - throw new Error( - "GHOST_PACKAGE_DIR must not contain '.', '..', or empty path segments", - ); - } - return normalized; -} - -export const GHOST_PACKAGE_DIR_ENV = "GHOST_PACKAGE_DIR"; - -export function resolveGhostDirDefault( - explicitGhostDir?: unknown, - env: NodeJS.ProcessEnv = process.env, -): string { - return normalizeGhostDir( - typeof explicitGhostDir === "string" - ? explicitGhostDir - : env[GHOST_PACKAGE_DIR_ENV], - ); -} - -export function fingerprintPackageDisplayPath( - relativeRoot: string, - ghostDir = FINGERPRINT_PACKAGE_DIR, -): string { - const normalizedGhostDir = normalizeGhostDir(ghostDir); - return relativeRoot === "." - ? normalizedGhostDir - : `${relativeRoot}/${normalizedGhostDir}`; -} - -function skipDiscoveryDirs(ghostDir: string): Set { - return new Set([ - ...BASE_SKIP_DISCOVERY_DIRS, - normalizeGhostDir(ghostDir).split("/")[0], - ]); -} - -function rootForFingerprintPackageDir( - packageDir: string, - ghostDir: string, -): string { - let root = packageDir; - for (const _segment of normalizeGhostDir(ghostDir).split("/")) { - root = dirname(root); - } - return root; -} - -const _readOptional = readOptionalUtf8; - -function _parseYamlSafe(raw: string, label: string): unknown { - try { - return parseYaml(raw); - } catch (err) { - throw new Error( - `${label} is not valid YAML: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } -} - -function prefixIssues( - label: string, - issues: Array<{ - severity: "error" | "warning" | "info"; - rule: string; - message: string; - path?: string; - }>, -): LintIssue[] { - return issues.map((issue) => ({ - ...issue, - path: issue.path ? `${label}.${issue.path}` : label, - })); -} - -function finalizeLint(issues: LintIssue[]): LintReport { - return { - issues, - errors: issues.filter((issue) => issue.severity === "error").length, - warnings: issues.filter((issue) => issue.severity === "warning").length, - info: issues.filter((issue) => issue.severity === "info").length, - }; -} - -function clone(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function sanitizeName(value: string): string { - const name = value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - return name || "ghost-package"; -} - -function isRemoteReference(reference: string): boolean { - return /^https?:\/\//i.test(reference); -} diff --git a/packages/ghost/src/scan/index.ts b/packages/ghost/src/scan/index.ts index 02271143..b66b61ba 100644 --- a/packages/ghost/src/scan/index.ts +++ b/packages/ghost/src/scan/index.ts @@ -1,18 +1,9 @@ -export { - type DiscoverBindingsOptions, - type DiscoveredBindings, - discoverBindingsForPath, -} from "./binding-discovery.js"; export { GHOST_CHECKS_DIRNAME, type LoadedChecksDir, loadChecksDir, } from "./checks-dir.js"; export { FINGERPRINT_PACKAGE_DIR } from "./constants.js"; -export { - type ResolveContractOptions, - resolveContractDir, -} from "./contract-resolver.js"; export type { ScanBuildingBlockRows, ScanContributionReport, @@ -21,26 +12,6 @@ export type { ScanFacetReport, ScanFacetState, } from "./fingerprint-contribution.js"; -export type { - DiscoveredGhostPackage, - FingerprintDirectoryOptions, - GhostFingerprintStack, - GhostFingerprintStackGroup, - GhostFingerprintStackLayer, - GhostFingerprintStackLayerRef, -} from "./fingerprint-stack.js"; -export { - buildFingerprintStack, - discoverFingerprintStack, - discoverGhostPackages, - fingerprintPackageDisplayPath, - GHOST_PACKAGE_DIR_ENV, - groupFingerprintStacksForPaths, - loadFingerprintStackForPath, - normalizeGhostDir, - resolveGhostDirDefault, - resolveGitRoot, -} from "./fingerprint-stack.js"; export { signals } from "./inventory.js"; export type { LegacyPackageInput, @@ -48,8 +19,13 @@ export type { MigrationResult, } from "./migrate-legacy.js"; export { looksLegacy, migrateLegacyPackage } from "./migrate-legacy.js"; -export type { MonorepoInitCandidate } from "./monorepo-init.js"; -export { detectMonorepoInitCandidates } from "./monorepo-init.js"; +export { + fingerprintPackageDisplayPath, + GHOST_PACKAGE_DIR_ENV, + normalizeGhostDir, + resolveGhostDirDefault, + resolveGitRoot, +} from "./package-paths.js"; export type { ScanStage, ScanStageReport, @@ -57,8 +33,3 @@ export type { ScanStatus, } from "./scan-status.js"; export { scanStatus } from "./scan-status.js"; -export { - type ChangedFile, - type ChangedLine, - parseUnifiedDiff, -} from "./unified-diff.js"; diff --git a/packages/ghost/src/scan/migrate-legacy.ts b/packages/ghost/src/scan/migrate-legacy.ts index 93c234d7..3e37fdf4 100644 --- a/packages/ghost/src/scan/migrate-legacy.ts +++ b/packages/ghost/src/scan/migrate-legacy.ts @@ -151,7 +151,7 @@ function derivePlacement( path, ...(id ? { node_id: id } : {}), reason: "paths-not-migrated", - detail: `applies_to.paths preserved for review only; path→surface binding is not part of placement.`, + detail: `applies_to.paths preserved for review only; paths are not part of the surface model.`, }); } return scopes[0]; diff --git a/packages/ghost/src/scan/monorepo-init.ts b/packages/ghost/src/scan/monorepo-init.ts deleted file mode 100644 index 38181c01..00000000 --- a/packages/ghost/src/scan/monorepo-init.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join, relative, resolve, sep } from "node:path"; -import { glob } from "tinyglobby"; -import { parse as parseYaml } from "yaml"; - -const PACKAGE_JSON = "package.json"; - -export type MonorepoInitCandidateSource = "package-json" | "pnpm-workspace"; - -export interface MonorepoInitCandidate { - path: string; - source: MonorepoInitCandidateSource; - packageJson: string; -} - -export async function detectMonorepoInitCandidates( - root: string, -): Promise { - const repoRoot = resolve(root); - const candidates = new Map(); - - await addCandidates( - candidates, - repoRoot, - await readPackageJsonWorkspacePatterns(repoRoot), - "package-json", - ); - await addCandidates( - candidates, - repoRoot, - await readPnpmWorkspacePatterns(repoRoot), - "pnpm-workspace", - ); - - return [...candidates.values()].sort((a, b) => a.path.localeCompare(b.path)); -} - -async function addCandidates( - candidates: Map, - root: string, - patterns: string[], - source: MonorepoInitCandidateSource, -): Promise { - for (const path of await expandWorkspacePatterns(root, patterns)) { - if (candidates.has(path)) continue; - candidates.set(path, { - path, - source, - packageJson: `${path}/${PACKAGE_JSON}`, - }); - } -} - -async function readPackageJsonWorkspacePatterns( - root: string, -): Promise { - let raw: string; - try { - raw = await readFile(join(root, PACKAGE_JSON), "utf-8"); - } catch { - return []; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return []; - } - if (!parsed || typeof parsed !== "object") return []; - return normalizeWorkspacePatterns( - (parsed as { workspaces?: unknown }).workspaces, - ); -} - -async function readPnpmWorkspacePatterns(root: string): Promise { - let raw: string; - try { - raw = await readFile(join(root, "pnpm-workspace.yaml"), "utf-8"); - } catch { - return []; - } - - let parsed: unknown; - try { - parsed = parseYaml(raw); - } catch { - return []; - } - if (!parsed || typeof parsed !== "object") return []; - const packages = (parsed as { packages?: unknown }).packages; - return Array.isArray(packages) - ? packages.filter((value): value is string => typeof value === "string") - : []; -} - -function normalizeWorkspacePatterns(value: unknown): string[] { - if (Array.isArray(value)) { - return value.filter((entry): entry is string => typeof entry === "string"); - } - if (value && typeof value === "object") { - const packages = (value as { packages?: unknown }).packages; - if (Array.isArray(packages)) { - return packages.filter( - (entry): entry is string => typeof entry === "string", - ); - } - } - return []; -} - -async function expandWorkspacePatterns( - root: string, - patterns: string[], -): Promise { - const packageJsonPatterns = patterns - .map(workspacePatternToPackageJsonPattern) - .filter((pattern): pattern is string => Boolean(pattern)); - if (packageJsonPatterns.length === 0) return []; - - let packageJsonPaths: string[]; - try { - packageJsonPaths = await glob(packageJsonPatterns, { - absolute: false, - cwd: root, - dot: false, - ignore: ["**/node_modules/**", "**/.*/**"], - onlyFiles: true, - }); - } catch { - return []; - } - - const out: string[] = []; - for (const packageJsonPath of packageJsonPaths) { - const path = packageRootFromPackageJson(root, packageJsonPath); - if (path) out.push(path); - } - return [...new Set(out)].sort(); -} - -function workspacePatternToPackageJsonPattern( - pattern: string, -): string | undefined { - const trimmed = pattern.trim(); - const negated = trimmed.startsWith("!"); - const cleaned = cleanWorkspacePattern(negated ? trimmed.slice(1) : trimmed); - if (!cleaned) return undefined; - const packageJsonPattern = cleaned.endsWith(`/${PACKAGE_JSON}`) - ? cleaned - : `${cleaned}/${PACKAGE_JSON}`; - return negated ? `!${packageJsonPattern}` : packageJsonPattern; -} - -function cleanWorkspacePattern(pattern: string): string | undefined { - const cleaned = pattern - .trim() - .replaceAll("\\", "/") - .replace(/\/+/g, "/") - .replace(/\/$/g, "") - .replace(/^\.\//, ""); - if (!cleaned) return undefined; - if (cleaned.split("/").some(shouldSkipDir)) return undefined; - return cleaned; -} - -function packageRootFromPackageJson( - root: string, - packageJsonPath: string, -): string | undefined { - const normalized = packageJsonPath - .replaceAll("\\", "/") - .replace(/\/+/g, "/") - .replace(/^\.\//, ""); - if (normalized === PACKAGE_JSON) return undefined; - if (!normalized.endsWith(`/${PACKAGE_JSON}`)) return undefined; - - const path = normalized.slice(0, -`/${PACKAGE_JSON}`.length); - if (!path || path.split("/").some(shouldSkipDir)) return undefined; - if (!isInsideRoot(root, resolve(root, path))) return undefined; - return path; -} - -function shouldSkipDir(name: string): boolean { - return ( - name === "" || - name === "." || - name === ".." || - name === "node_modules" || - name.startsWith(".") - ); -} - -function isInsideRoot(root: string, candidate: string): boolean { - const rel = relative(root, candidate); - return rel !== "" && !rel.startsWith("..") && !rel.startsWith(`..${sep}`); -} diff --git a/packages/ghost/src/scan/package-paths.ts b/packages/ghost/src/scan/package-paths.ts new file mode 100644 index 00000000..f9cefccc --- /dev/null +++ b/packages/ghost/src/scan/package-paths.ts @@ -0,0 +1,81 @@ +import { execFile } from "node:child_process"; +import { isAbsolute, resolve } from "node:path"; +import { promisify } from "node:util"; +import { FINGERPRINT_PACKAGE_DIR } from "./constants.js"; + +const execFileAsync = promisify(execFile); + +/** + * Neutral home for the load-bearing package-path helpers. These survive the + * removal of nesting/stacks (see docs/ideas/one-road.md, Step 0): they are + * direct package addressing, not nesting machinery, and are consumed by + * fingerprint-commands, verify-package, init-command, scan-emit-command, + * monorepo-init-command, and the scan/index re-exports. + */ + +export async function resolveGitRoot(cwd = process.cwd()): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["rev-parse", "--show-toplevel"], + { + cwd, + }, + ); + return resolve(stdout.trim()); + } catch { + return resolve(cwd); + } +} + +export function normalizeGhostDir(ghostDir = FINGERPRINT_PACKAGE_DIR): string { + const normalized = ghostDir + .trim() + .replaceAll("\\", "/") + .replace(/\/+/g, "/") + .replace(/\/$/g, ""); + if (!normalized) { + throw new Error("GHOST_PACKAGE_DIR must not be empty"); + } + if ( + isAbsolute(ghostDir) || + normalized.startsWith("/") || + /^[A-Za-z]:/.test(normalized) + ) { + throw new Error("GHOST_PACKAGE_DIR must be a relative directory path"); + } + const segments = normalized.split("/"); + if ( + segments.some( + (segment) => segment === "." || segment === ".." || segment === "", + ) + ) { + throw new Error( + "GHOST_PACKAGE_DIR must not contain '.', '..', or empty path segments", + ); + } + return normalized; +} + +export const GHOST_PACKAGE_DIR_ENV = "GHOST_PACKAGE_DIR"; + +export function resolveGhostDirDefault( + explicitGhostDir?: unknown, + env: NodeJS.ProcessEnv = process.env, +): string { + return normalizeGhostDir( + typeof explicitGhostDir === "string" + ? explicitGhostDir + : env[GHOST_PACKAGE_DIR_ENV], + ); +} + +export function fingerprintPackageDisplayPath( + relativeRoot: string, + ghostDir = FINGERPRINT_PACKAGE_DIR, +): string { + const normalizedGhostDir = normalizeGhostDir(ghostDir); + return relativeRoot === "." + ? normalizedGhostDir + : `${relativeRoot}/${normalizedGhostDir}`; +} diff --git a/packages/ghost/src/scan/unified-diff.ts b/packages/ghost/src/scan/unified-diff.ts deleted file mode 100644 index 3008e9a1..00000000 --- a/packages/ghost/src/scan/unified-diff.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** One added line in a parsed unified diff. */ -export interface ChangedLine { - path: string; - line: number; - text: string; -} - -/** A changed file in a parsed unified diff, with its added lines. */ -export interface ChangedFile { - path: string; - added_lines: ChangedLine[]; -} - -/** - * Parse a unified diff into changed files and their added lines. Generic diff - * parsing — no governance logic. Used by `review` and `checks` to resolve which - * paths a diff touches. - */ -export function parseUnifiedDiff(diffText: string): ChangedFile[] { - const files = new Map(); - let current: ChangedFile | undefined; - let newLine = 0; - - for (const rawLine of diffText.split(/\r?\n/)) { - if (rawLine.startsWith("diff --git ")) { - current = undefined; - continue; - } - - if (rawLine.startsWith("+++ ")) { - const file = rawLine.replace(/^\+\+\+\s+/, ""); - if (file === "/dev/null") { - current = undefined; - continue; - } - const path = file.replace(/^b\//, ""); - current = files.get(path) ?? { path, added_lines: [] }; - files.set(path, current); - continue; - } - - const hunk = rawLine.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); - if (hunk) { - newLine = Number(hunk[1]); - continue; - } - - if (!current) continue; - if (rawLine.startsWith("+")) { - current.added_lines.push({ - path: current.path, - line: newLine, - text: rawLine.slice(1), - }); - newLine += 1; - } else if (rawLine.startsWith("-")) { - // Removed line: does not advance the new-file line counter. - } else { - newLine += 1; - } - } - - return [...files.values()]; -} diff --git a/packages/ghost/src/scan/verify-package.ts b/packages/ghost/src/scan/verify-package.ts index 82aba902..596db43c 100644 --- a/packages/ghost/src/scan/verify-package.ts +++ b/packages/ghost/src/scan/verify-package.ts @@ -1,22 +1,15 @@ import { access, readFile } from "node:fs/promises"; -import { dirname, isAbsolute, join, resolve } from "node:path"; -import { parse as parseYaml } from "yaml"; -import { - classifyContractReference, - GHOST_BINDING_FILENAME, - GhostBindingSchema, - type GhostFingerprintDocument, - type GhostFingerprintEvidence, - GhostSurfacesSchema, +import { isAbsolute, join, resolve } from "node:path"; +import type { + GhostFingerprintDocument, + GhostFingerprintEvidence, } from "#ghost-core"; -import { resolveContractDir } from "./contract-resolver.js"; import { type LoadedFingerprintPackage, lintFingerprintPackage, loadFingerprintPackage, resolveFingerprintPackage, } from "./fingerprint-package.js"; -import { resolveGitRoot } from "./fingerprint-stack.js"; import type { VerifyFingerprintIssue, VerifyFingerprintReport, @@ -53,82 +46,9 @@ export async function verifyFingerprintPackage( await verifyFingerprintExemplars(fingerprint, root, issues); } - // Verify an adjacent .ghost.bind.yml: an external contract must resolve and - // the bound surfaces must exist in it. - await verifyBindingContract(dirname(paths.dir), cwd, issues); - return finalize(issues); } -async function verifyBindingContract( - bindingDir: string, - cwd: string, - issues: VerifyFingerprintIssue[], -): Promise { - const bindingPath = join(bindingDir, GHOST_BINDING_FILENAME); - let raw: string; - try { - raw = await readFile(bindingPath, "utf-8"); - } catch { - return; // no binding to verify - } - - const parsed = GhostBindingSchema.safeParse(parseYaml(raw)); - if (!parsed.success) return; // lint reports schema problems separately - - const { contract, bindings } = parsed.data; - // The in-repo contract is validated by the package's own lint/verify. - if (classifyContractReference(contract) !== "npm") return; - - const repoRoot = await resolveGitRoot(cwd); - const contractDir = await resolveContractDir(contract, bindingDir, repoRoot); - if (!contractDir) { - issues.push({ - severity: "error", - rule: "binding-contract-unresolved", - message: `binding contract '${contract}' could not be resolved from node_modules.`, - path: GHOST_BINDING_FILENAME, - }); - return; - } - - const surfaceIds = await readContractSurfaceIds(contractDir); - if (surfaceIds === null) { - issues.push({ - severity: "error", - rule: "binding-contract-unresolved", - message: `binding contract '${contract}' has no readable surfaces.yml.`, - path: GHOST_BINDING_FILENAME, - }); - return; - } - - bindings.forEach((entry, index) => { - if (entry.surface === "core") return; // implicit root - if (!surfaceIds.has(entry.surface)) { - issues.push({ - severity: "error", - rule: "binding-surface-unknown", - message: `binding references surface '${entry.surface}' not declared in contract '${contract}'.`, - path: `bindings[${index}].surface`, - }); - } - }); -} - -async function readContractSurfaceIds( - contractDir: string, -): Promise | null> { - try { - const raw = await readFile(join(contractDir, "surfaces.yml"), "utf-8"); - const parsed = GhostSurfacesSchema.safeParse(parseYaml(raw)); - if (!parsed.success) return null; - return new Set(parsed.data.surfaces.map((surface) => surface.id)); - } catch { - return null; - } -} - async function verifyFingerprintExemplars( fingerprint: GhostFingerprintDocument, root: string, diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index c3cefd49..4e52fcdc 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -47,11 +47,12 @@ Optional `ghost.check/v1` markdown checks live in `checks/*.md`, routed by surfa Use `ghost signals` as a stdout-only reconnaissance helper when an agent needs raw repo observations while authoring curated fingerprint facets. -Advanced repos may contain nested fingerprint packages such as -`apps/checkout/.ghost/`. Host wrappers may set -`GHOST_PACKAGE_DIR=` on the child `ghost` process when they need -repo-local Ghost files outside raw `ghost`'s `.ghost` default. Ghost stays adapter-neutral: wrappers consume JSON and map severities into their -own review or check format. +One contract per package: a repo's `.ghost/` is the contract, and surfaces are +the only locality. Host wrappers may set `GHOST_PACKAGE_DIR=` on +the child `ghost` process when they need repo-local Ghost files outside raw +`ghost`'s `.ghost` default, and `--package ` targets an exact package (e.g. +one product in a monorepo). Ghost stays adapter-neutral: wrappers consume JSON +and map severities into their own review or check format. ## Core CLI Verbs @@ -61,10 +62,9 @@ own review or check format. | `ghost scan [dir] [--format json]` | Report sparse fingerprint contribution facets. | | `ghost lint [file-or-dir]` | Validate a fingerprint package or artifact. | | `ghost verify [dir] --root ` | Validate evidence paths, exemplar paths, and typed check refs. | -| `ghost checks --diff ` | Select and ground the markdown checks governing a diff's surfaces. | -| `ghost review --diff ` | Emit an advisory review packet: touched surfaces, routed checks, and fingerprint grounding. | +| `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 gather [surface]` | Compose a surface's context slice (own + inherited + edge), or list the surface menu. | -| `ghost checks --diff ` | Select and ground the markdown checks governing a diff's surfaces. | | `ghost emit ` | Emit `review-command`. | | `ghost skill install` | Install this unified skill bundle. | @@ -72,10 +72,9 @@ own review or check format. | Verb | Purpose | |---|---| -| `ghost init --scope ` / `GHOST_PACKAGE_DIR= ghost init` | Create or resolve scoped/custom fingerprint packages for nested packages or host wrappers. | +| `GHOST_PACKAGE_DIR= ghost init` / `ghost init --package ` | Create or resolve a custom fingerprint package directory for host wrappers or a monorepo package. | | `ghost signals [path]` | Emit raw repo signals for fingerprint authoring. | | `ghost migrate [dir]` | Migrate a legacy `.ghost/` package onto the surface model. | -| `ghost lint --all` / `ghost verify --all` | Validate nested fingerprint packages. | | `ghost compare [...more]` | Compare root fingerprint packages. | | `ghost ack` / `track` / `diverge` | Record stance toward tracked drift. | @@ -102,7 +101,7 @@ evidence-backed facet entries, then ask the human to curate the claims. - Treat checked-in Ghost package facet files as the source of truth. - Generate from intent, inventory, and composition. -- Route a diff with `ghost checks`; the agent evaluates the markdown checks it governs. +- Name touched surfaces to `ghost checks --surface`; the agent evaluates the markdown checks it governs. - Use local evidence as provisional when fingerprint facets are silent. - Treat auto-drafted fingerprint edits as ordinary uncommitted draft work until the human curates them and Git review accepts them. @@ -110,8 +109,8 @@ evidence-backed facet entries, then ask the human to curate the claims. - Validate with `ghost lint` and `ghost verify --root ` before declaring fingerprint facets useful. - Run `ghost checks` to route checks and `ghost review` for the advisory packet. -- Use nested stacks and custom package dirs only when - present or requested. +- Use a custom package dir (`--package` / `GHOST_PACKAGE_DIR`) only when present + or requested. ## When Fingerprint Facets Are Silent diff --git a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md index b99f1ae5..9dd216a1 100644 --- a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md +++ b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md @@ -5,9 +5,6 @@ handoffs: - label: Inspect fingerprint contribution command: ghost scan --format json prompt: Classify this repo's fingerprint authoring scenario and summarize absent facets. - - label: Inspect nested stacks - command: ghost stack - prompt: Decide whether this path needs local fingerprint guidance or can inherit the root package. --- # Recipe: Collaborative Fingerprint Authoring @@ -37,10 +34,10 @@ Choose the nearest scenario before writing fingerprint facets: | Rebrand, redesign, or migration | Human-led transition. Capture current, target, and migration cautions; use decisions for rationale. | | Prototype becoming product | Ratification-led. Preserve only the emergent patterns a human wants to keep. | | Fork, white label, or tenant variant | Shared base + local divergence. Keep common surface composition broad and local differences scoped. | -| Monorepo or nested surfaces | Stack-aware. Use root guidance for product-family composition and nested packages for surfaces assessed differently. | +| Monorepo or product suite | One contract per package. Use surfaces and the containment tree to organize locality within a single contract. | -If more than one scenario applies, start with the broad repo scenario, then run -the nested decision test for individual products, apps, or feature areas. +If more than one scenario applies, start with the broad repo scenario, then +distinguish individual products, apps, or feature areas as surfaces. Use auto-draft when an existing repo has enough product evidence to support a starter sketch. Avoid relying on auto-draft for net-new repos, thin prototypes, @@ -112,19 +109,19 @@ important claims: - soften into guidance - reject as accidental or legacy - move to scratch notes -- scope to a nested package +- place on a more specific surface - convert into a deterministic check Only add checks when the rule can be enforced deterministically. Subjective composition critique belongs in `composition.yml` or advisory review, not in a blocking gate. -## 6. Decide Nested Packages +## 6. Decide Surfaces -Create or update a local `.ghost/` only when a surface should be assessed -differently from the root package. +Add a distinct surface (placed in the containment tree) when part of the product +should be assessed differently from its parent. -Use a nested package when the local surface has distinct: +Use a separate surface when it has distinct: - users or jobs-to-be-done - density or information architecture @@ -133,15 +130,8 @@ Use a nested package when the local surface has distinct: - component grammar or UI library usage - review criteria for the same UI decision -Keep broad product-family guidance at the root. Put local obligations in the -nearest package that owns the surface. Validate nested repos with stack-aware -commands: - -```bash -ghost stack -ghost lint --all -ghost verify --all -``` +Keep broad product-family guidance on `core` (it cascades to every surface). +Place local obligations on the surface that owns them. ## 7. Validate And Ratify @@ -161,5 +151,5 @@ canonical package. - Never copy raw inventory into canonical facets without curation. - Never claim scan frequency is product authority. -- Never create nested packages just to mirror directory structure. +- Never create surfaces just to mirror directory structure. - Never turn advisory composition critique into a deterministic gate. diff --git a/packages/ghost/src/skill-bundle/references/brief.md b/packages/ghost/src/skill-bundle/references/brief.md index f781d952..32d5bede 100644 --- a/packages/ghost/src/skill-bundle/references/brief.md +++ b/packages/ghost/src/skill-bundle/references/brief.md @@ -5,22 +5,21 @@ description: Build a concise pre-generation brief from a surface's gather slice. # Recipe: Brief Work From Ghost Fingerprint -1. When a target path is known, run `ghost gather --path --format json` - to resolve the surface that owns it and compose its slice. -2. For prompt-shaped work, match the ask to a surface in the menu - (`ghost gather --format json` with no surface lists the surfaces and their - descriptions), then run `ghost gather --format json`. -3. Treat the gather slice as the agent contract: `surface`, `ancestors`, and the +1. Match the ask to a surface in the menu (`ghost gather --format json` with no + surface lists the surfaces and their descriptions), then run + `ghost gather --format json`. +2. Treat the gather slice as the agent contract: `surface`, `ancestors`, and the composed `principles`, `experience_contracts`, and `patterns`, each with `provenance` (own, inherited from an ancestor, or contributed by a typed edge). -4. Express the surface's intent through its composed patterns. -5. Inspect matching `inventory.exemplars` as concrete generation anchors. -6. Run `ghost signals ` when raw repo observations would help you find +3. Express the surface's intent through its composed patterns. +4. Inspect matching `inventory.exemplars` as concrete generation anchors. +5. Run `ghost signals ` when raw repo observations would help you find evidence. -7. Run `ghost checks --diff ` to see which checks govern the touched - surfaces and their grounding, so generation avoids known failures. -8. When the slice is sparse, label local reasoning provisional rather than +6. Run `ghost checks --surface ` (the surfaces you determined the change + touches) to see which checks govern them and their grounding, so generation + avoids known failures. +7. When the slice is sparse, label local reasoning provisional rather than inventing surface-specific rules. Plain `ghost gather ` is a compact human preview. Prefer `--format @@ -28,8 +27,7 @@ json` as the agent interface. The host agent owns natural-language matching: read the surface menu (each surface's authored description) and pick the surface the ask belongs to. Ghost -resolves a path to a surface deterministically via bindings, but it never does -the natural-language matching itself. +never infers a surface from a repo path — the agent names it. When no surface is selected (or an unknown one is named), `gather` returns the surface menu, never the whole tree — choose a surface from it rather than diff --git a/packages/ghost/src/skill-bundle/references/capture.md b/packages/ghost/src/skill-bundle/references/capture.md index 3daa1711..c843c8a9 100644 --- a/packages/ghost/src/skill-bundle/references/capture.md +++ b/packages/ghost/src/skill-bundle/references/capture.md @@ -46,8 +46,8 @@ Common starting points: exemplar scans. - Existing repos with mixed quality require curation before repeated patterns become canonical. -- Monorepos and product suites need a nested-package decision pass before local - surfaces inherit or add guidance. +- Monorepos and product suites run one contract per package: surfaces (not + nested packages) are how a single contract organizes locality. Human intent anchors surface composition. Scans provide evidence. Agent synthesis is draft work until a human curates it and ordinary Git review @@ -149,9 +149,6 @@ ghost verify .ghost --root ghost check --base HEAD ``` -Use `ghost lint --all` and `ghost verify --all` only when nested fingerprint -packages exist. - ## Never - Never describe any file outside `.ghost/` as canonical package input. diff --git a/packages/ghost/src/skill-bundle/references/recall.md b/packages/ghost/src/skill-bundle/references/recall.md index 890529d7..febaf872 100644 --- a/packages/ghost/src/skill-bundle/references/recall.md +++ b/packages/ghost/src/skill-bundle/references/recall.md @@ -8,8 +8,7 @@ description: Recall applicable Ghost fingerprint facets for a task or file path. 1. Read checked-in `intent.yml`, `inventory.yml`, and `composition.yml` entries. 2. Select relevant intent, inventory exemplars, composition patterns, and active checks. -3. Use `ghost stack ` when the repo has nested fingerprint packages. -4. Summarize only fingerprint refs that apply to the task. +3. Summarize only fingerprint refs that apply to the task. Return: diff --git a/packages/ghost/src/skill-bundle/references/review.md b/packages/ghost/src/skill-bundle/references/review.md index c9df22bc..63ac94dd 100644 --- a/packages/ghost/src/skill-bundle/references/review.md +++ b/packages/ghost/src/skill-bundle/references/review.md @@ -9,16 +9,15 @@ handoffs: # Recipe: Review Code Changes For Experience Drift -## 1. Route The Diff To Its Surfaces +## 1. Route The Change To Its Surfaces ```bash -ghost checks --diff --format json +ghost checks --surface --format json ``` -This resolves each changed path to the surface that owns it (via bindings), -selects the markdown checks governing those surfaces and their ancestors, and -grounds each in the surface's fingerprint slice. Use JSON as the agent contract. -It includes: +Name the surfaces the change touches (you analyzed the diff). Ghost selects the +markdown checks governing those surfaces and their ancestors, and grounds each +in the surface's fingerprint slice. Use JSON as the agent contract. It includes: - `touched_surfaces`: the surfaces the diff resolved to - `checks`: the relevant checks per surface, with `relevance` (own or inherited) diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 51c077f4..1062ad87 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -18,17 +18,13 @@ canonical, and uncommitted or unmerged edits are draft work. `surfaces.yml` declares the coordinate space — the surfaces a fingerprint's nodes are placed on (`surface:`) and the containment tree (`parent`) plus typed -composition edges. The contract carries no paths. A repo binds paths to surfaces -with `.ghost.bind.yml` (`ghost.binding/v1`) or by directory location; a nested -`.ghost/` binds its subtree, it does not carry its own merged fingerprint. A -binding's `contract:` is `.` (the in-repo contract) or an npm package name -(`@scope/brand`, resolved from `node_modules`); `ghost verify` checks an external -contract resolves and its bound surfaces exist. +composition edges. The contract carries no paths and infers nothing from repo +location. One contract per package; surfaces are the only locality. `ghost gather ` composes a surface's slice (own nodes + inherited -ancestors + edge contributions). `ghost gather --path ` resolves the -surface that owns a path via its binding. With no surface, `gather` returns the -surface menu for the host agent to match against. +ancestors + edge contributions). With no surface, `gather` returns the surface +menu for the host agent to match against. The agent names the surface from the +prompt and its own repo analysis; Ghost never infers a surface from a path. `manifest.yml`: diff --git a/packages/ghost/src/skill-bundle/references/verify.md b/packages/ghost/src/skill-bundle/references/verify.md index 13d66e2f..b4614441 100644 --- a/packages/ghost/src/skill-bundle/references/verify.md +++ b/packages/ghost/src/skill-bundle/references/verify.md @@ -8,11 +8,10 @@ description: Verify generated UI or fingerprint edits against Ghost. 1. Run `ghost lint .ghost` and `ghost verify .ghost --root ` after fingerprint edits. 2. Run `ghost check --base ` after implementation changes. -3. For advisory review, run `ghost checks --diff ` to route the diff to - its surfaces' checks with grounding. -4. For generation setup, run `ghost gather --path --format json` when a - target path is known. For prompt-shaped work, match the ask to a surface via - the menu (`ghost gather`) and run `ghost gather --format json`. +3. For advisory review, run `ghost checks --surface ` (the surfaces the + change touches) to route to those surfaces' checks with grounding. +4. For generation setup, match the ask to a surface via the menu + (`ghost gather`) and run `ghost gather --format json`. 5. Consume the gather slice: `surface`, `ancestors`, and the composed `principles`, `experience_contracts`, and `patterns` with `provenance`. 6. Inspect generated UI manually or with screenshots when visual fidelity diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 355b2017..8145df1d 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -222,22 +222,6 @@ describe("ghost CLI", () => { } }); - it("keeps command-specific --all help local to lint and verify", async () => { - const lint = await runCli(["lint", "--help"], dir, { allowNoExit: true }); - const verify = await runCli(["verify", "--help"], dir, { - allowNoExit: true, - }); - - expect(lint.code).toBe(0); - expect(lint.stdout).toContain("--all"); - expect(lint.stdout).toContain("Validate every nested fingerprint package"); - expect(lint.stdout).not.toContain("Core workflow"); - expect(verify.code).toBe(0); - expect(verify.stdout).toContain("--all"); - expect(verify.stdout).toContain("Verify every nested fingerprint package"); - expect(verify.stdout).not.toContain("Core workflow"); - }); - it("compares explicitly supplied fingerprint files", async () => { await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); await writeFile(join(dir, "b.fingerprint.md"), fingerprintWithId("b")); @@ -799,326 +783,6 @@ sources: [] expect(reviewCommand.code).toBe(0); }); - it("init --monorepo detects package.json array workspaces without creating children by default", async () => { - await mkdir(join(dir, "apps", "checkout"), { recursive: true }); - await writeFile( - join(dir, "package.json"), - JSON.stringify({ workspaces: ["apps/*"] }, null, 2), - ); - await writeFile( - join(dir, "apps", "checkout", "package.json"), - JSON.stringify({ name: "checkout" }, null, 2), - ); - - const result = await runCli( - ["init", "--monorepo", "--format", "json"], - dir, - ); - - expect(result.code).toBe(0); - const out = JSON.parse(result.stdout); - expect(out.mode).toBe("plan"); - expect(out.candidates).toEqual([ - { - path: "apps/checkout", - source: "package-json", - packageJson: "apps/checkout/package.json", - state: "candidate", - }, - ]); - expect(out.commands).toEqual(["ghost init --scope apps/checkout"]); - await expect( - readFile(join(dir, ".ghost", "manifest.yml"), "utf-8"), - ).resolves.toContain("ghost.fingerprint-package/v1"); - await expect( - readFile( - join(dir, "apps", "checkout", ".ghost", "manifest.yml"), - "utf-8", - ), - ).rejects.toThrow(); - }); - - it("init --monorepo detects pnpm workspace packages", async () => { - await mkdir(join(dir, "packages", "admin"), { recursive: true }); - await writeFile( - join(dir, "pnpm-workspace.yaml"), - "packages:\n - packages/*\n", - ); - await writeFile( - join(dir, "packages", "admin", "package.json"), - JSON.stringify({ name: "admin" }, null, 2), - ); - - const result = await runCli( - ["init", "--monorepo", "--format", "json"], - dir, - ); - - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).candidates).toEqual([ - { - path: "packages/admin", - source: "pnpm-workspace", - packageJson: "packages/admin/package.json", - state: "candidate", - }, - ]); - }); - - it("init --monorepo expands recursive workspace globs", async () => { - await mkdir(join(dir, "packages", "group", "admin"), { - recursive: true, - }); - await writeFile( - join(dir, "pnpm-workspace.yaml"), - "packages:\n - packages/**\n", - ); - await writeFile( - join(dir, "packages", "group", "admin", "package.json"), - JSON.stringify({ name: "admin" }, null, 2), - ); - - const result = await runCli( - ["init", "--monorepo", "--format", "json"], - dir, - ); - - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).candidates).toEqual([ - { - path: "packages/group/admin", - source: "pnpm-workspace", - packageJson: "packages/group/admin/package.json", - state: "candidate", - }, - ]); - }); - - it("init --monorepo expands brace workspace globs", async () => { - await mkdir(join(dir, "apps", "checkout"), { recursive: true }); - await mkdir(join(dir, "packages", "admin"), { recursive: true }); - await writeFile( - join(dir, "pnpm-workspace.yaml"), - "packages:\n - '{apps,packages}/*'\n", - ); - await writeFile( - join(dir, "apps", "checkout", "package.json"), - JSON.stringify({ name: "checkout" }, null, 2), - ); - await writeFile( - join(dir, "packages", "admin", "package.json"), - JSON.stringify({ name: "admin" }, null, 2), - ); - - const result = await runCli( - ["init", "--monorepo", "--format", "json"], - dir, - ); - - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).candidates).toEqual([ - { - path: "apps/checkout", - source: "pnpm-workspace", - packageJson: "apps/checkout/package.json", - state: "candidate", - }, - { - path: "packages/admin", - source: "pnpm-workspace", - packageJson: "packages/admin/package.json", - state: "candidate", - }, - ]); - }); - - it("init --monorepo honors negated workspace globs", async () => { - await mkdir(join(dir, "packages", "admin"), { recursive: true }); - await mkdir(join(dir, "packages", "fixtures", "example"), { - recursive: true, - }); - await writeFile( - join(dir, "pnpm-workspace.yaml"), - "packages:\n - packages/**\n - '!packages/fixtures/**'\n", - ); - await writeFile( - join(dir, "packages", "admin", "package.json"), - JSON.stringify({ name: "admin" }, null, 2), - ); - await writeFile( - join(dir, "packages", "fixtures", "example", "package.json"), - JSON.stringify({ name: "example" }, null, 2), - ); - - const result = await runCli( - ["init", "--monorepo", "--format", "json"], - dir, - ); - - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).candidates).toEqual([ - { - path: "packages/admin", - source: "pnpm-workspace", - packageJson: "packages/admin/package.json", - state: "candidate", - }, - ]); - }); - - it("init --monorepo --apply creates detected child packages", async () => { - await mkdir(join(dir, "apps", "checkout"), { recursive: true }); - await writeFile( - join(dir, "package.json"), - JSON.stringify({ workspaces: { packages: ["apps/*"] } }, null, 2), - ); - await writeFile( - join(dir, "apps", "checkout", "package.json"), - JSON.stringify({ name: "checkout" }, null, 2), - ); - - const result = await runCli( - ["init", "--monorepo", "--apply", "--format", "json"], - dir, - ); - - expect(result.code).toBe(0); - const out = JSON.parse(result.stdout); - expect(out.mode).toBe("apply"); - expect(out.created.map((entry: { path: string }) => entry.path)).toEqual([ - "apps/checkout", - ]); - await expect( - readFile( - join(dir, "apps", "checkout", ".ghost", "manifest.yml"), - "utf-8", - ), - ).resolves.toContain("ghost.fingerprint-package/v1"); - }); - - it("init --monorepo --apply skips existing child packages without force", async () => { - await mkdir(join(dir, "apps", "checkout"), { recursive: true }); - await writeFile( - join(dir, "package.json"), - JSON.stringify({ workspaces: ["apps/*"] }, null, 2), - ); - await writeFile( - join(dir, "apps", "checkout", "package.json"), - JSON.stringify({ name: "checkout" }, null, 2), - ); - await runCli(["init", "--scope", "apps/checkout"], dir); - - const result = await runCli( - ["init", "--monorepo", "--apply", "--format", "json"], - dir, - ); - - expect(result.code).toBe(0); - const out = JSON.parse(result.stdout); - expect(out.created).toEqual([]); - expect(out.skipped).toEqual([ - { - path: "apps/checkout", - source: "package-json", - packageJson: "apps/checkout/package.json", - state: "exists", - }, - ]); - }); - - it("init --monorepo applies GHOST_PACKAGE_DIR to root and child packages", async () => { - await mkdir(join(dir, "apps", "checkout"), { recursive: true }); - await writeFile( - join(dir, "package.json"), - JSON.stringify({ workspaces: ["apps/*"] }, null, 2), - ); - await writeFile( - join(dir, "apps", "checkout", "package.json"), - JSON.stringify({ name: "checkout" }, null, 2), - ); - - const result = await runCli( - ["init", "--monorepo", "--apply", "--format", "json"], - dir, - { env: { GHOST_PACKAGE_DIR: ".design/memory" } }, - ); - - expect(result.code).toBe(0); - const out = JSON.parse(result.stdout); - expect(out.ghostDir).toBe(".design/memory"); - expect(out.commands).toEqual([ - "GHOST_PACKAGE_DIR=.design/memory ghost init --scope apps/checkout", - ]); - await expect( - readFile(join(dir, ".design", "memory", "manifest.yml"), "utf-8"), - ).resolves.toContain("ghost.fingerprint-package/v1"); - await expect( - readFile( - join(dir, "apps", "checkout", ".design", "memory", "manifest.yml"), - "utf-8", - ), - ).resolves.toContain("ghost.fingerprint-package/v1"); - }); - - it("init --monorepo uses GHOST_PACKAGE_DIR for root and child packages", async () => { - await mkdir(join(dir, "apps", "checkout"), { recursive: true }); - await writeFile( - join(dir, "package.json"), - JSON.stringify({ workspaces: ["apps/*"] }, null, 2), - ); - await writeFile( - join(dir, "apps", "checkout", "package.json"), - JSON.stringify({ name: "checkout" }, null, 2), - ); - - const result = await runCli( - ["init", "--monorepo", "--apply", "--format", "json"], - dir, - { env: { GHOST_PACKAGE_DIR: ".agents/ghost" } }, - ); - - expect(result.code).toBe(0); - const out = JSON.parse(result.stdout); - expect(out.ghostDir).toBe(".agents/ghost"); - expect(out.commands).toEqual([ - "GHOST_PACKAGE_DIR=.agents/ghost ghost init --scope apps/checkout", - ]); - await expect( - readFile(join(dir, ".agents", "ghost", "manifest.yml"), "utf-8"), - ).resolves.toContain("ghost.fingerprint-package/v1"); - await expect( - readFile( - join(dir, "apps", "checkout", ".agents", "ghost", "manifest.yml"), - "utf-8", - ), - ).resolves.toContain("ghost.fingerprint-package/v1"); - }); - - it("init --monorepo rejects exact scope and dir combinations", async () => { - const withPackage = await runCli( - ["init", "--package", "custom-dir", "--monorepo"], - dir, - ); - const withScope = await runCli( - ["init", "--scope", "apps/checkout", "--monorepo"], - dir, - ); - const withApplyOnly = await runCli(["init", "--apply"], dir); - - expect(withPackage.code).toBe(2); - expect(withPackage.stderr).toContain( - "use either init --package or init --monorepo", - ); - expect(withScope.code).toBe(2); - expect(withScope.stderr).toContain( - "use either init --scope or init --monorepo", - ); - expect(withApplyOnly.code).toBe(2); - expect(withApplyOnly.stderr).toContain( - "--apply can only be used with --monorepo", - ); - }); - it("uses GHOST_PACKAGE_DIR as the default fingerprint package directory for init", async () => { const init = await runCli(["init", "--format", "json"], dir, { env: { GHOST_PACKAGE_DIR: ".agents/ghost" }, @@ -1618,9 +1282,6 @@ sources: [] "utf-8", ), ).resolves.toContain("grounding is silent"); - await expect( - readFile(join(dir, "skills", "ghost", "references", "brief.md"), "utf-8"), - ).resolves.toContain("ghost gather --path --format json"); await expect( readFile(join(dir, "skills", "ghost", "references", "brief.md"), "utf-8"), ).resolves.toContain("ghost gather --format json"); @@ -1629,13 +1290,13 @@ sources: [] join(dir, "skills", "ghost", "references", "verify.md"), "utf-8", ), - ).resolves.toContain("ghost gather --path --format json"); + ).resolves.toContain("ghost gather --format json"); await expect( readFile( join(dir, "skills", "ghost", "references", "review.md"), "utf-8", ), - ).resolves.toContain("ghost checks --diff --format json"); + ).resolves.toContain("ghost checks --surface --format json"); await expect( readFile( join(dir, "skills", "ghost", "references", "propose.md"), @@ -1789,8 +1450,26 @@ sources: [] ).rejects.toThrow("Unknown option `--includeMemory`"); }); - it("review resolves touched surfaces for a mixed diff", async () => { - await writeNestedCheckPackage(dir); + it("review uses agent-stated surfaces and embeds the diff", async () => { + await writeSplitFingerprintPackage( + join(dir, ".ghost"), + `schema: ghost.fingerprint/v1 +intent: + summary: + product: Root Product + situations: [] + principles: [] + experience_contracts: [] +inventory: + building_blocks: + tokens: [RootTheme] +composition: + patterns: + - id: root-token-pattern + kind: visual + pattern: Web UI color uses semantic product tokens. +`, + ); await writeFile( join(dir, "change.patch"), [ @@ -1800,117 +1479,26 @@ sources: [] ); const result = await runCli( - ["review", "--diff", "change.patch", "--format", "json"], + [ + "review", + "--diff", + "change.patch", + "--surface", + "core", + "--format", + "json", + ], dir, ); expect(result.code).toBe(0); const packet = JSON.parse(result.stdout); - // Review is now surface-based: no merged stacks, just touched surfaces + - // routed checks + grounding from the root contract. + // Review is surface-based and agent-stated: the agent names the surfaces; + // the diff is embedded verbatim, never used to resolve surfaces. expect(packet.stacks).toBeUndefined(); - expect(Array.isArray(packet.touched_surfaces)).toBe(true); + expect(packet.touched_surfaces).toEqual(["core"]); expect(Array.isArray(packet.grounding)).toBe(true); - }); - - it("emit review-command resolves the root contract for --path (no child merge)", async () => { - await writeNestedCheckPackage(dir); - - const result = await runCli( - [ - "emit", - "review-command", - "--path", - "apps/checkout/review/page.tsx", - "--stdout", - ], - dir, - ); - - expect(result.code).toBe(0); - // The contract is the root package — its inventory is present... - expect(result.stdout).toContain("RootTheme"); - // ...and the child package's own fingerprint data is NOT merged in. - expect(result.stdout).not.toContain("CheckoutTheme"); - }); - - it("init --scope creates a nested .ghost bundle", async () => { - const result = await runCli( - ["init", "--scope", "apps/checkout", "--format", "json"], - dir, - ); - - expect(result.code).toBe(0); - const out = JSON.parse(result.stdout); - expect(await realpath(out.dir)).toBe( - await realpath(join(dir, "apps", "checkout", ".ghost")), - ); - const manifest = await readFile( - join(dir, "apps", "checkout", ".ghost", "manifest.yml"), - "utf-8", - ); - expect(manifest).toContain("ghost.fingerprint-package/v1"); - const intent = await readFile( - join(dir, "apps", "checkout", ".ghost", "intent.yml"), - "utf-8", - ); - expect(intent).not.toContain("review_policy"); - expect(intent).not.toContain("proposal"); - }); - - it("init --scope creates a nested package under a custom package directory", async () => { - const result = await runCli( - ["init", "--scope", "apps/checkout", "--format", "json"], - dir, - { env: { GHOST_PACKAGE_DIR: ".design/memory" } }, - ); - - expect(result.code).toBe(0); - const out = JSON.parse(result.stdout); - expect(await realpath(out.dir)).toBe( - await realpath(join(dir, "apps", "checkout", ".design", "memory")), - ); - expect( - await readFile( - join(dir, "apps", "checkout", ".design", "memory", "manifest.yml"), - "utf-8", - ), - ).toContain("ghost.fingerprint-package/v1"); - }); - - it("lint --all and verify --all include nested packages", async () => { - await writeNestedCheckPackage(dir); - - const lint = await runCli(["lint", "--all", "--format", "json"], dir); - const verify = await runCli(["verify", "--all", "--format", "json"], dir); - const scan = await runCli( - ["scan", "--include-nested", "--format", "json"], - dir, - ); - - expect(lint.code).toBe(0); - expect(verify.code).toBe(0); - expect(JSON.parse(scan.stdout).nested_packages).toHaveLength(2); - }); - - it("lint, verify, and scan discover nested custom fingerprint directories", async () => { - await writeNestedCheckPackage(dir, ".design/memory"); - - const lint = await runCli(["lint", "--all", "--format", "json"], dir, { - env: { GHOST_PACKAGE_DIR: ".design/memory" }, - }); - const verify = await runCli(["verify", "--all", "--format", "json"], dir, { - env: { GHOST_PACKAGE_DIR: ".design/memory" }, - }); - const scan = await runCli( - ["scan", "--include-nested", "--format", "json"], - dir, - { env: { GHOST_PACKAGE_DIR: ".design/memory" } }, - ); - - expect(lint.code).toBe(0); - expect(verify.code).toBe(0); - expect(JSON.parse(scan.stdout).nested_packages).toHaveLength(2); + expect(packet.diff).toContain("CheckoutTheme"); }); it("gathers a composed slice for a surface", async () => { @@ -2040,7 +1628,7 @@ experience_contracts: [] expect(result.stderr).toContain("Nothing to migrate"); }); - it("routes markdown checks to a diff by surface", async () => { + it("routes markdown checks to agent-stated surfaces", async () => { const ghost = join(dir, ".ghost"); await mkdir(join(ghost, "checks"), { recursive: true }); await writeFile( @@ -2055,16 +1643,6 @@ surfaces: parent: core - id: email parent: core -`, - ); - // Directory-implied binding for apps/checkout. - await mkdir(join(dir, "apps", "checkout", ".ghost"), { recursive: true }); - await writeFile( - join(dir, "apps", "checkout", ".ghost", "surfaces.yml"), - `schema: ghost.surfaces/v1 -surfaces: - - id: checkout - parent: core `, ); await writeFile( @@ -2079,16 +1657,12 @@ surfaces: join(ghost, "checks", "email.md"), "---\nname: email-links\ndescription: Email links.\nseverity: low\nsurface: email\n---\n## Instructions\nLinks.\n", ); - await writeFile( - join(dir, "change.patch"), - webPatch("apps/checkout/page.tsx", 'const c = "#fff";'), - ); const result = await runCli( [ "checks", - "--diff", - "change.patch", + "--surface", + "checkout", "--package", ".ghost", "--format", @@ -2131,21 +1705,12 @@ surfaces: join(ghost, "checks", "checkout.md"), "---\nname: checkout-color\ndescription: No raw color.\nseverity: high\nsurface: checkout\n---\n## Instructions\nFlag hex.\n", ); - await mkdir(join(dir, "apps", "checkout", ".ghost"), { recursive: true }); - await writeFile( - join(dir, "apps", "checkout", ".ghost", "surfaces.yml"), - "schema: ghost.surfaces/v1\nsurfaces:\n - id: checkout\n parent: core\n", - ); - await writeFile( - join(dir, "change.patch"), - webPatch("apps/checkout/page.tsx", 'const c = "#fff";'), - ); const result = await runCli( [ "checks", - "--diff", - "change.patch", + "--surface", + "checkout", "--package", ".ghost", "--format", @@ -2175,16 +1740,12 @@ surfaces: join(ghost, "surfaces.yml"), "schema: ghost.surfaces/v1\nsurfaces:\n - id: checkout\n parent: core\n", ); - await writeFile( - join(dir, "change.patch"), - webPatch("apps/checkout/page.tsx", 'const c = "#fff";'), - ); const result = await runCli( [ "checks", - "--diff", - "change.patch", + "--surface", + "checkout", "--package", ".ghost", "--no-grounding", @@ -2544,96 +2105,6 @@ checks: `; } -async function writeNestedCheckPackage( - dir: string, - ghostDir = ".ghost", -): Promise { - const rootPackage = packagePath(dir, ghostDir); - const checkoutPackage = packagePath(join(dir, "apps", "checkout"), ghostDir); - await mkdir(join(dir, "apps", "checkout", "review"), { recursive: true }); - await mkdir(join(dir, "shared"), { recursive: true }); - await writeFile(join(dir, "apps", "checkout", "review", "page.tsx"), ""); - await writeFile(join(dir, "shared", "home.tsx"), ""); - - await writeSplitFingerprintPackage( - rootPackage, - `schema: ghost.fingerprint/v1 -intent: - summary: - product: Root Product - situations: [] - principles: [] - experience_contracts: [] -inventory: - building_blocks: - tokens: [RootTheme] -composition: - patterns: - - id: root-token-pattern - kind: visual - pattern: Web UI color uses semantic product tokens. -`, - `schema: ghost.validate/v1 -id: root -checks: - - id: no-hardcoded-color - title: No hardcoded colors - status: active - severity: serious - derivation: - composition: [composition.pattern:root-token-pattern] - applies_to: - paths: [apps, shared] - detector: - type: forbidden-regex - pattern: '#[0-9a-fA-F]{3,8}' - contexts: [react] - evidence: - support: 0.93 - observed_count: 8 - examples: - - shared/home.tsx -`, - ); - - await writeSplitFingerprintPackage( - checkoutPackage, - `schema: ghost.fingerprint/v1 -intent: - summary: - product: Checkout - situations: [] - principles: [] - experience_contracts: [] -inventory: - building_blocks: - tokens: [CheckoutTheme] -composition: - patterns: - - id: checkout-token-pattern - kind: visual - pattern: Checkout review uses checkout product tokens. - surface: checkout -`, - `schema: ghost.validate/v1 -id: checkout -checks: - - id: no-hardcoded-color - title: No hardcoded colors - status: disabled - severity: serious - detector: - type: forbidden-regex - pattern: '#[0-9a-fA-F]{3,8}' - contexts: [react] -`, - ); -} - -function packagePath(root: string, ghostDir: string): string { - return join(root, ...ghostDir.split("/")); -} - function webPatch(path: string, added: string): string { return `diff --git a/${path} b/${path} index 1111111..2222222 100644 diff --git a/packages/ghost/test/contract-resolver.test.ts b/packages/ghost/test/contract-resolver.test.ts deleted file mode 100644 index 0fa3c196..00000000 --- a/packages/ghost/test/contract-resolver.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveContractDir } from "../src/scan/contract-resolver.js"; - -const execFileAsync = promisify(execFile); - -describe("resolveContractDir", () => { - let dir: string; - - beforeEach(async () => { - dir = join( - tmpdir(), - `ghost-contract-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(dir, { recursive: true }); - await execFileAsync("git", ["init", "-q"], { cwd: dir }); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it("resolves the in-repo contract `.` to /.ghost", async () => { - await mkdir(join(dir, ".ghost"), { recursive: true }); - const resolved = await resolveContractDir(".", dir, dir); - expect(resolved).toBe(join(dir, ".ghost")); - }); - - it("returns null for `.` when there is no root .ghost", async () => { - expect(await resolveContractDir(".", dir, dir)).toBeNull(); - }); - - it("resolves an npm name from node_modules", async () => { - const contractDir = join(dir, "node_modules", "@acme", "brand", ".ghost"); - await mkdir(contractDir, { recursive: true }); - await mkdir(join(dir, "apps", "web"), { recursive: true }); - const resolved = await resolveContractDir( - "@acme/brand", - join(dir, "apps", "web"), - dir, - ); - expect(resolved).toBe(contractDir); - }); - - it("returns null for an unresolved npm name", async () => { - expect(await resolveContractDir("@acme/missing", dir, dir)).toBeNull(); - }); - - it("returns null for an unsupported reference kind", async () => { - await writeFile(join(dir, "marker"), "x"); - expect(await resolveContractDir("../brand", dir, dir)).toBeNull(); - }); -}); diff --git a/packages/ghost/test/fingerprint-stack.test.ts b/packages/ghost/test/fingerprint-stack.test.ts deleted file mode 100644 index 6ed0c23c..00000000 --- a/packages/ghost/test/fingerprint-stack.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; -import { - groupFingerprintStacksForPaths, - loadFingerprintStackForPath, -} from "../src/scan/index.js"; - -describe("nested Ghost fingerprint stacks", () => { - let dir: string; - - beforeEach(async () => { - dir = join( - tmpdir(), - `ghost-stack-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(dir, { recursive: true }); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it("discovers root-to-leaf layers; the contract is the root, not a merge", async () => { - await writeStackFixture(dir); - - const stack = await loadFingerprintStackForPath( - "apps/checkout/review/page.tsx", - dir, - ); - - // Layers are still discovered root-to-leaf (binding discovery). - expect(stack.layers.map((layer) => layer.relative_root)).toEqual([ - ".", - "apps/checkout", - ]); - expect(stack.provenance.layers).toHaveLength(2); - - // One contract, many bindings: the contract is the ROOT package's - // fingerprint, used as-is. Nesting binds; it does not merge child facets in. - expect(stack.contract.dir).toBe(stack.layers[0].dir); - expect(stack.contract.fingerprint.intent.summary.product).toBe( - "Root Product", - ); - // The child's own principle is NOT merged into the contract. - expect( - stack.contract.fingerprint.intent.principles.find( - (principle) => principle.id === "shared-principle", - )?.principle, - ).toBe("Parent product layer."); - }); - - it("groups changed files by resolved fingerprint stack", async () => { - await writeStackFixture(dir); - - const groups = await groupFingerprintStacksForPaths( - ["apps/checkout/review/page.tsx", "shared/home.tsx"], - dir, - ); - - expect(groups).toHaveLength(2); - expect(groups.map((group) => group.stack.layers.length).sort()).toEqual([ - 1, 2, - ]); - }); - - it("uses the root contract as-is; a child package does not contribute its fingerprint", async () => { - await mkdir(join(dir, ".ghost"), { recursive: true }); - await writeSplitFingerprintPackage( - join(dir, ".ghost"), - `schema: ghost.fingerprint/v1 -intent: - summary: - product: Root Product -`, - ); - await mkdir(join(dir, "apps", "checkout", ".ghost"), { recursive: true }); - await writeSplitFingerprintPackage( - join(dir, "apps", "checkout", ".ghost"), - `schema: ghost.fingerprint/v1 -intent: - summary: - product: Checkout - principles: - - id: checkout-review-stays-reversible - principle: Checkout review keeps reversal visible before payment. -`, - ); - - const stack = await loadFingerprintStackForPath( - "apps/checkout/review/page.tsx", - dir, - ); - - // Both packages are discovered as layers... - expect(stack.layers).toHaveLength(2); - // ...but the contract is the ROOT, used as-is. The child's product and - // principle are NOT merged in (nesting binds, it does not federate data). - expect(stack.contract.fingerprint.intent.summary.product).toBe( - "Root Product", - ); - expect(stack.contract.fingerprint.intent.principles).toEqual([]); - }); - - it("resolves root-to-leaf layers from a custom fingerprint directory", async () => { - await writeStackFixture(dir, ".design/memory"); - - const stack = await loadFingerprintStackForPath( - "apps/checkout/review/page.tsx", - dir, - { ghostDir: ".design/memory" }, - ); - const groups = await groupFingerprintStacksForPaths( - ["apps/checkout/review/page.tsx", "shared/home.tsx"], - dir, - { ghostDir: ".design/memory" }, - ); - - expect(stack.ghost_dir).toBe(".design/memory"); - expect(stack.layers.map((layer) => layer.relative_root)).toEqual([ - ".", - "apps/checkout", - ]); - expect(stack.layers.map((layer) => layer.ghost_dir)).toEqual([ - ".design/memory", - ".design/memory", - ]); - expect(groups).toHaveLength(2); - }); -}); - -async function writeStackFixture( - dir: string, - ghostDir = ".ghost", -): Promise { - await writeRootBundle(dir, ghostDir); - await writeChildBundle(join(dir, "apps", "checkout"), ghostDir); - await mkdir(join(dir, "shared"), { recursive: true }); - await writeFile(join(dir, "shared", "home.tsx"), ""); - await writeFile(join(dir, "apps", "checkout", "review", "page.tsx"), ""); -} - -async function writeRootBundle( - dir: string, - ghostDir = ".ghost", -): Promise { - const ghost = packagePath(dir, ghostDir); - await writeSplitFingerprintPackage( - ghost, - `schema: ghost.fingerprint/v1 -intent: - summary: - product: Root Product - audience: [operators] - situations: - - id: shared-situation - user_intent: use the broad product - product_obligation: preserve broad product continuity - principles: - - id: shared-principle - principle: Parent product layer. - experience_contracts: [] -inventory: - exemplars: - - id: shared-exemplar - path: apps/root.tsx - title: Parent exemplar - surface: app - refs: [composition.pattern:root-pattern] - building_blocks: - tokens: [RootTheme.color] -composition: - patterns: - - id: root-pattern - kind: visual - pattern: Root pattern. - - id: child-pattern - kind: visual - pattern: Parent version of child pattern. -`, - `schema: ghost.validate/v1 -id: root -checks: - - id: no-hardcoded-color - title: No hardcoded colors - status: active - severity: serious - derivation: - composition: [composition.pattern:root-pattern] - applies_to: - paths: [apps] - detector: - type: forbidden-regex - pattern: '#[0-9a-fA-F]{3,8}' - contexts: [react] - evidence: - support: 0.9 - observed_count: 3 - examples: - - apps/example.tsx -`, - ); -} - -async function writeChildBundle( - root: string, - ghostDir = ".ghost", -): Promise { - const ghost = packagePath(root, ghostDir); - await mkdir(join(root, "review"), { recursive: true }); - await writeSplitFingerprintPackage( - ghost, - `schema: ghost.fingerprint/v1 -intent: - summary: - product: Checkout - audience: [buyers] - situations: - - id: shared-situation - user_intent: review checkout before committing payment - product_obligation: make edit and reversal paths visible - surface: checkout - principles: - - id: shared-principle - principle: Checkout review must make reversal obvious. - surface: checkout - experience_contracts: [] -inventory: - exemplars: - - id: shared-exemplar - path: review/page.tsx - title: Child review exemplar - surface: checkout - refs: [composition.pattern:child-pattern] - building_blocks: - tokens: [CheckoutTheme.action] -composition: - patterns: - - id: child-pattern - kind: behavior - pattern: Checkout keeps review controls visible. - surface: checkout - evidence: - - path: review/page.tsx -`, - `schema: ghost.validate/v1 -id: checkout -checks: - - id: no-hardcoded-color - title: No hardcoded colors - status: disabled - severity: serious - detector: - type: forbidden-regex - pattern: '#[0-9a-fA-F]{3,8}' - - id: checkout-theme-token - title: Use checkout theme - status: active - severity: nit - derivation: - composition: [composition.pattern:child-pattern] - applies_to: - paths: [review] - detector: - type: required-token - value: CheckoutTheme - contexts: [react] - evidence: - support: 0.92 - observed_count: 4 - examples: - - review/page.tsx -`, - ); -} - -function packagePath(root: string, ghostDir: string): string { - return join(root, ...ghostDir.split("/")); -} - -async function writeSplitFingerprintPackage( - pkg: string, - fingerprintRaw: string, - checksRaw?: string, -): Promise { - const packageDir = pkg; - const doc = parseYaml(fingerprintRaw) as Record; - await mkdir(packageDir, { recursive: true }); - await Promise.all([ - writeFile( - join(packageDir, "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: local\n", - ), - writeFile( - join(packageDir, "intent.yml"), - stringifyYaml( - doc.intent ?? { - summary: {}, - situations: [], - principles: [], - experience_contracts: [], - }, - ), - ), - writeFile( - join(packageDir, "inventory.yml"), - stringifyYaml( - doc.inventory ?? { - building_blocks: {}, - exemplars: [], - sources: [], - }, - ), - ), - writeFile( - join(packageDir, "composition.yml"), - stringifyYaml(doc.composition ?? { patterns: [] }), - ), - ...(checksRaw - ? [writeFile(join(packageDir, "validate.yml"), checksRaw)] - : []), - ]); -} diff --git a/packages/ghost/test/ghost-core/binding-resolve.test.ts b/packages/ghost/test/ghost-core/binding-resolve.test.ts deleted file mode 100644 index 109af96d..00000000 --- a/packages/ghost/test/ghost-core/binding-resolve.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type BindingCandidate, - resolvePathToSurface, -} from "../../src/ghost-core/index.js"; - -function dirBinding(dir: string, surface: string): BindingCandidate { - return { dir, explicit: false, entries: [{ surface, paths: [dir] }] }; -} - -describe("resolvePathToSurface", () => { - it("resolves to the nearest (deepest) binding", () => { - const candidates = [ - dirBinding("apps", "web"), - dirBinding("apps/checkout", "checkout"), - ]; - const result = resolvePathToSurface("apps/checkout/page.tsx", candidates, { - hasRootContract: true, - }); - expect(result.surface).toBe("checkout"); - expect(result.reason).toBe("directory"); - expect(result.binding_dir).toBe("apps/checkout"); - }); - - it("lets an explicit binding beat a directory-implied one at the same level", () => { - const dir: BindingCandidate = dirBinding("apps/checkout", "checkout"); - const explicit: BindingCandidate = { - dir: "apps/checkout", - explicit: true, - entries: [{ surface: "checkout-explicit", paths: ["apps/checkout"] }], - }; - const result = resolvePathToSurface( - "apps/checkout/page.tsx", - [dir, explicit], - { hasRootContract: true }, - ); - expect(result.surface).toBe("checkout-explicit"); - expect(result.reason).toBe("explicit"); - }); - - it("falls back to core when unbound and a root contract exists", () => { - const result = resolvePathToSurface("README.md", [], { - hasRootContract: true, - }); - expect(result.surface).toBe("core"); - expect(result.reason).toBe("root-core"); - }); - - it("returns null (menu) when unbound and no root contract", () => { - const result = resolvePathToSurface("README.md", [], { - hasRootContract: false, - }); - expect(result.surface).toBeNull(); - expect(result.reason).toBe("unbound"); - }); - - it("a single directory-implied entry binds unconditionally under its dir", () => { - const result = resolvePathToSurface( - "apps/checkout/deep/nested/file.tsx", - [dirBinding("apps/checkout", "checkout")], - { hasRootContract: true }, - ); - expect(result.surface).toBe("checkout"); - }); - - it("a multi-entry explicit binding requires a path match (report, don't guess)", () => { - const explicit: BindingCandidate = { - dir: "apps/svc", - explicit: true, - entries: [ - { surface: "email-lifecycle", paths: ["apps/svc/src"] }, - { surface: "email-marketing", paths: ["apps/svc/campaigns"] }, - ], - }; - const matched = resolvePathToSurface( - "apps/svc/campaigns/promo.tsx", - [explicit], - { hasRootContract: true }, - ); - expect(matched.surface).toBe("email-marketing"); - - // A file under the dir but matching no entry path does not bind to a guess; - // it falls through to root core. - const unmatched = resolvePathToSurface( - "apps/svc/other/thing.tsx", - [explicit], - { hasRootContract: true }, - ); - expect(unmatched.surface).toBe("core"); - expect(unmatched.reason).toBe("root-core"); - }); - - it("ignores bindings whose directory does not contain the file", () => { - const result = resolvePathToSurface( - "apps/web/home.tsx", - [dirBinding("apps/checkout", "checkout")], - { hasRootContract: true }, - ); - expect(result.surface).toBe("core"); - }); -}); diff --git a/packages/ghost/test/ghost-core/binding-schema.test.ts b/packages/ghost/test/ghost-core/binding-schema.test.ts deleted file mode 100644 index c8031ae9..00000000 --- a/packages/ghost/test/ghost-core/binding-schema.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - GHOST_BINDING_SCHEMA, - GhostBindingSchema, - lintGhostBinding, -} from "../../src/ghost-core/index.js"; - -function doc(overrides: Record = {}) { - return { - schema: GHOST_BINDING_SCHEMA, - contract: ".", - bindings: [{ surface: "checkout", paths: ["apps/checkout"] }], - ...overrides, - }; -} - -describe("GhostBindingSchema", () => { - it("accepts a minimal in-repo binding", () => { - expect(GhostBindingSchema.safeParse(doc()).success).toBe(true); - }); - - it("rejects dotted surface ids", () => { - const result = GhostBindingSchema.safeParse( - doc({ bindings: [{ surface: "email.marketing", paths: ["a"] }] }), - ); - expect(result.success).toBe(false); - }); - - it("rejects an entry with no paths", () => { - const result = GhostBindingSchema.safeParse( - doc({ bindings: [{ surface: "checkout", paths: [] }] }), - ); - expect(result.success).toBe(false); - }); - - it("rejects unknown keys", () => { - const result = GhostBindingSchema.safeParse(doc({ extra: true })); - expect(result.success).toBe(false); - }); -}); - -describe("lintGhostBinding", () => { - it("passes a valid in-repo binding", () => { - expect(lintGhostBinding(doc()).errors).toBe(0); - }); - - it("accepts an npm-name external contract reference", () => { - const report = lintGhostBinding(doc({ contract: "@scope/brand" })); - expect( - report.issues.some( - (issue) => issue.rule === "binding-contract-unsupported", - ), - ).toBe(false); - }); - - it("errors on a path-like contract reference", () => { - const report = lintGhostBinding(doc({ contract: "../brand" })); - expect( - report.issues.some( - (issue) => issue.rule === "binding-contract-unsupported", - ), - ).toBe(true); - }); - - it("errors when a surface is bound twice", () => { - const report = lintGhostBinding( - doc({ - bindings: [ - { surface: "checkout", paths: ["a"] }, - { surface: "checkout", paths: ["b"] }, - ], - }), - ); - expect( - report.issues.some((issue) => issue.rule === "binding-duplicate-surface"), - ).toBe(true); - }); -}); diff --git a/packages/ghost/test/ghost-core/contract-ref.test.ts b/packages/ghost/test/ghost-core/contract-ref.test.ts deleted file mode 100644 index 94226058..00000000 --- a/packages/ghost/test/ghost-core/contract-ref.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - classifyContractReference, - GhostBindingSchema, - lintGhostBinding, -} from "../../src/ghost-core/index.js"; - -describe("classifyContractReference", () => { - it("treats `.` as in-repo", () => { - expect(classifyContractReference(".")).toBe("in-repo"); - }); - - it("treats npm names (scoped and unscoped) as npm", () => { - expect(classifyContractReference("@acme/brand")).toBe("npm"); - expect(classifyContractReference("brand")).toBe("npm"); - expect(classifyContractReference("brand-tokens")).toBe("npm"); - }); - - it("rejects paths, urls, and resource ids", () => { - expect(classifyContractReference("./brand")).toBe("unsupported"); - expect(classifyContractReference("../brand")).toBe("unsupported"); - expect(classifyContractReference("packages/brand")).toBe("unsupported"); - expect(classifyContractReference("https://x.example")).toBe("unsupported"); - expect(classifyContractReference("registry:brand")).toBe("unsupported"); - }); -}); - -describe("lintGhostBinding contract reference", () => { - function doc(contract: string) { - return { - schema: "ghost.binding/v1", - contract, - bindings: [{ surface: "checkout", paths: ["apps/checkout"] }], - }; - } - - it("accepts an npm-name contract", () => { - expect(lintGhostBinding(doc("@acme/brand")).errors).toBe(0); - }); - - it("accepts the in-repo contract", () => { - expect(lintGhostBinding(doc(".")).errors).toBe(0); - }); - - it("rejects a path-like contract", () => { - const report = lintGhostBinding(doc("../brand")); - expect( - report.issues.some((i) => i.rule === "binding-contract-unsupported"), - ).toBe(true); - }); - - it("still parses the schema regardless of contract value", () => { - expect(GhostBindingSchema.safeParse(doc("@acme/brand")).success).toBe(true); - }); -}); diff --git a/packages/ghost/test/public-exports.test.ts b/packages/ghost/test/public-exports.test.ts index 998f52cf..c599ffa7 100644 --- a/packages/ghost/test/public-exports.test.ts +++ b/packages/ghost/test/public-exports.test.ts @@ -28,7 +28,7 @@ describe.runIf(hasBuiltExports)("built public exports", () => { expect(scanApi.scanStatus).toBeTypeOf("function"); expect(scanApi.signals).toBeTypeOf("function"); - expect(scanApi.loadFingerprintStackForPath).toBeTypeOf("function"); + expect(scanApi.loadFingerprintStackForPath).toBeUndefined(); expect(scanApi.initFingerprintPackage).toBeUndefined(); expect(scanApi.lintFingerprintPackage).toBeUndefined(); expect(scanApi.writePackageContextBundle).toBeUndefined(); diff --git a/scripts/check-file-sizes.mjs b/scripts/check-file-sizes.mjs index 05d504da..0b44b79f 100644 --- a/scripts/check-file-sizes.mjs +++ b/scripts/check-file-sizes.mjs @@ -25,11 +25,6 @@ const EXCEPTIONS = { justification: "Deterministic repository inventory collector — intentionally broad because map authoring depends on one cohesive raw signal pass", }, - "packages/ghost/src/scan/fingerprint-stack.ts": { - limit: 1120, - justification: - "Canonical nested fingerprint stack loader — discovery, merge, path normalization, package-dir validation, and stack validation stay together so CLI routing shares one provenance model", - }, "packages/ghost/src/scan/verify-fingerprint.ts": { limit: 900, justification: