diff --git a/.changeset/checks-review-on-graph.md b/.changeset/checks-review-on-graph.md new file mode 100644 index 00000000..969969af --- /dev/null +++ b/.changeset/checks-review-on-graph.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +`ghost checks` and `ghost review` now route by graph ancestry and ground from the prose graph slice: grounding is the surface's gathered nodes by provenance (own / ancestor / edge), replacing the typed why/what (principles, contracts, patterns, exemplars) split — the why and what now live in each node's prose. `ghost checks` gains `--as ` to filter grounding to one output form. Exemplar `path:` is dropped from grounding. diff --git a/.changeset/cross-package-extends.md b/.changeset/cross-package-extends.md new file mode 100644 index 00000000..0c891863 --- /dev/null +++ b/.changeset/cross-package-extends.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +Add cross-package inheritance via `extends`. A package's `manifest.yml` can declare `extends: { : }`, mapping another contract's identity to where it lives. Node refs then reference inherited context by identity, never path — `under: brand:core` or `relates: [{ to: brand:core-trust }]` (the `:` form replaces the earlier npm-style `#` ref grammar). Inherited nodes load read-only and flow into gather and validate like local ones. `ghost validate` resolves cross-package refs and reports unresolved refs, packages not declared in `extends`, identity mismatches, and cross-package cycles. This delivers the shared-brand story: one brand contract extended by many products, without copy-paste or merge. One level of `extends` in v1 (no transitive); location is an explicit relative dir (identity-based discovery is a future upgrade that keeps refs unchanged). diff --git a/.changeset/facet-removal.md b/.changeset/facet-removal.md new file mode 100644 index 00000000..30ccabff --- /dev/null +++ b/.changeset/facet-removal.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +Remove the facet model — the graph is now the only fingerprint model. The `intent.yml`/`inventory.yml`/`composition.yml` schemas, the `GhostFingerprintDocument`, the facet→node load-time projection, and the dormant facet slice/grounding are deleted; the loader folds `nodes/*.md` + `surfaces.yml` directly into the graph. `ghost lint` and `ghost verify` are replaced by one `ghost validate` verb (artifact shape pass + node-graph pass: links resolve, one root, acyclic); `ghost emit` is removed. `ghost scan` now reports node/surface contribution instead of facet contribution. Legacy facet packages no longer load directly — `ghost validate`/load fail with guidance to run `ghost migrate`. Structured exemplar-path and evidence verification is dropped (evidence lives in node prose, per the prose-node model). diff --git a/.changeset/gather-on-graph-incarnation.md b/.changeset/gather-on-graph-incarnation.md new file mode 100644 index 00000000..ed125fb7 --- /dev/null +++ b/.changeset/gather-on-graph-incarnation.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +`ghost gather` now composes its context slice by traversing the fingerprint node graph and emits nodes-by-provenance prose (own / ancestor / edge), and gains `--as ` to filter the slice to one output form (e.g. email, billboard, voice) while always keeping essence (untagged) nodes. diff --git a/.changeset/node-authoring.md b/.changeset/node-authoring.md new file mode 100644 index 00000000..d7fa23e9 --- /dev/null +++ b/.changeset/node-authoring.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +`ghost init` now scaffolds a node package (`manifest.yml` + `surfaces.yml` spine + a seed `nodes/*.md`) via a template registry (`--template `, `default` for now) instead of emitting `intent.yml`/`inventory.yml`/`composition.yml`; the `--reference` flag is removed. `ghost migrate` now performs a one-way conversion of legacy/facet packages into `surfaces.yml` + `nodes/*.md` (the facet→node projection becomes the writer) and removes the old facet files. The authoring skill (`capture.md`, `SKILL.md`) teaches node authoring with intent/inventory/composition as authoring lenses rather than facet files. diff --git a/.changeset/remove-compare-drift-fleet.md b/.changeset/remove-compare-drift-fleet.md new file mode 100644 index 00000000..d1b61dad --- /dev/null +++ b/.changeset/remove-compare-drift-fleet.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +Remove `compare`, `drift`, `ack`, `track`, and `diverge` commands and the direct `fingerprint.md` machinery (parser, writer, semantic diff, decisions/dimensions, embeddings, perceptual prior). These rested on a quantified visual-design-system model (fixed dimensions + decision embeddings) that the context-graph reframe abandoned; the concepts are parked for a graph-native rethink (see docs/ideas/compare-drift-fleet-rethink.md). The `./compare` and `./drift` package subpaths and the root `compare`/`drift` exports are removed. `ghost lint` now validates `.ghost/` packages and node/surface/check artifacts only (direct `fingerprint.md` is no longer linted); a `nodes/*.md` file lints as a `ghost.node/v1` node. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 7a96754d..f573c52e 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-06-27T06:23:34.307Z", + "generatedAt": "2026-06-28T03:43:13.793Z", "tools": [ { "tool": "ghost", "commands": [ { "tool": "ghost", - "name": "lint", - "rawName": "lint [file]", - "description": "Validate a root Ghost fingerprint package, split fingerprint artifacts, checks, or direct markdown — defaults to .ghost", + "name": "validate", + "rawName": "validate [file]", + "description": "Validate the Ghost fingerprint package — artifact shape and the node graph (links resolve, one root, acyclic). Defaults to .ghost.", "group": "core", "defaultHelp": true, - "compactName": "lint", - "summary": "Validate a fingerprint package or artifact.", + "compactName": "validate", + "summary": "Validate the fingerprint: artifact shape + the node graph.", "options": [ { "rawName": "--format ", @@ -28,7 +28,7 @@ "tool": "ghost", "name": "init", "rawName": "init", - "description": "Create a root .ghost split fingerprint package", + "description": "Create a root .ghost node fingerprint package", "group": "core", "defaultHelp": true, "compactName": "init", @@ -43,9 +43,9 @@ "negated": false }, { - "rawName": "--reference ", - "name": "reference", - "description": "Reference UI registry, library path, or fingerprint to record in inventory building blocks", + "rawName": "--template ", + "name": "template", + "description": "Init template to scaffold (default: default)", "default": null, "takesValue": true, "negated": false @@ -68,34 +68,6 @@ } ] }, - { - "tool": "ghost", - "name": "verify", - "rawName": "verify [dir]", - "description": "Verify a root Ghost fingerprint package: intent/composition evidence, inventory exemplars, and checks are grounded.", - "group": "core", - "defaultHelp": true, - "compactName": "verify", - "summary": "Verify evidence, exemplar paths, and typed refs.", - "options": [ - { - "rawName": "--root ", - "name": "root", - "description": "Optional target root used to resolve fingerprint evidence and exemplar paths (default: cwd)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, { "tool": "ghost", "name": "scan", @@ -127,294 +99,6 @@ "summary": "Emit raw repo signals for fingerprint authoring.", "options": [] }, - { - "tool": "ghost", - "name": "emit", - "rawName": "emit ", - "description": "Emit a derived artifact from the fingerprint package (review-command).", - "group": "core", - "defaultHelp": true, - "compactName": "emit", - "summary": "Emit review-command artifacts.", - "options": [ - { - "rawName": "--package ", - "name": "package", - "description": "Use exactly this fingerprint package directory (default: ./.ghost)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "-o, --out ", - "name": "out", - "description": "Output path (review-command → .claude/commands/design-review.md)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--stdout", - "name": "stdout", - "description": "Write to stdout instead of a file", - "default": null, - "takesValue": false, - "negated": false - } - ] - }, - { - "tool": "ghost", - "name": "compare", - "rawName": "compare [...fingerprints]", - "description": "Compare two or more fingerprints or root .ghost bundles. N=2 returns a pairwise delta; N≥3 returns a composite fingerprint.", - "group": "compare", - "defaultHelp": false, - "compactName": "compare", - "summary": "Compare fingerprint packages.", - "options": [ - { - "rawName": "--semantic", - "name": "semantic", - "description": "Qualitative diff of decisions + palette (N=2 only)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--temporal", - "name": "temporal", - "description": "Add velocity, trajectory, and ack bounds (N=2, reads .ghost/history.jsonl)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--history-dir ", - "name": "historyDir", - "description": "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--gate", - "name": "gate", - "description": "Reconcile against a sync manifest and emit a structured pass/fail verdict (N=2 only)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--sync ", - "name": "sync", - "description": "Sync manifest path for --gate (default: ./.ghost-sync.json)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--max-divergence-days ", - "name": "maxDivergenceDays", - "description": "For --gate: flag diverging dimensions older than this many days as uncovered", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost", - "name": "ack", - "rawName": "ack", - "description": "Acknowledge current drift — record intentional stance toward the tracked fingerprint", - "group": "compare", - "defaultHelp": false, - "compactName": "ack", - "summary": "Record stance toward tracked drift.", - "options": [ - { - "rawName": "-c, --config ", - "name": "config", - "description": "Path to ghost config file", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "-d, --dimension ", - "name": "dimension", - "description": "Acknowledge a specific dimension only", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--stance ", - "name": "stance", - "description": "Stance: aligned, accepted, or diverging", - "default": "accepted", - "takesValue": true, - "negated": false - }, - { - "rawName": "--reason ", - "name": "reason", - "description": "Reason for this acknowledgment", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost", - "name": "track", - "rawName": "track ", - "description": "Track another fingerprint as this repo's reference", - "group": "compare", - "defaultHelp": false, - "compactName": "track", - "summary": "Shift the tracked reference fingerprint.", - "options": [ - { - "rawName": "-d, --dimension ", - "name": "dimension", - "description": "Track only for a specific dimension", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost", - "name": "diverge", - "rawName": "diverge ", - "description": "Declare intentional divergence on a dimension", - "group": "compare", - "defaultHelp": false, - "compactName": "diverge", - "summary": "Declare intentional divergence on a dimension.", - "options": [ - { - "rawName": "-c, --config ", - "name": "config", - "description": "Path to ghost config file", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "-r, --reason ", - "name": "reason", - "description": "Why this dimension is intentionally diverging", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost", - "name": "drift", - "rawName": "drift ", - "description": "Inspect Ghost drift status or run the stance-ledger check.", - "group": "compare", - "defaultHelp": false, - "compactName": "drift check", - "summary": "Run the continuous design-loop drift check.", - "options": [ - { - "rawName": "--package ", - "name": "package", - "description": "Exact fingerprint package directory", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--config ", - "name": "config", - "description": "Path to ghost config file for tracked source", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--local ", - "name": "local", - "description": "Local fingerprint or bundle to check", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--tracked ", - "name": "tracked", - "description": "Tracked/reference fingerprint or bundle", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--sync ", - "name": "sync", - "description": "Sync manifest path (default: ./.ghost-sync.json)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--max-divergence-days ", - "name": "maxDivergenceDays", - "description": "Flag diverging dimensions older than this many days as uncovered", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: markdown or json", - "default": "markdown", - "takesValue": true, - "negated": false - } - ] - }, { "tool": "ghost", "name": "gather", @@ -433,6 +117,14 @@ "takesValue": true, "negated": false }, + { + "rawName": "--as ", + "name": "as", + "description": "Filter to one incarnation (e.g. email, billboard, voice). Essence (untagged) nodes always pass.", + "default": null, + "takesValue": true, + "negated": false + }, { "rawName": "--format ", "name": "format", @@ -469,10 +161,18 @@ "takesValue": true, "negated": false }, + { + "rawName": "--as ", + "name": "as", + "description": "Filter grounding to one incarnation (e.g. email, voice). Essence nodes always pass.", + "default": null, + "takesValue": true, + "negated": false + }, { "rawName": "--no-grounding", "name": "grounding", - "description": "Omit fingerprint grounding (why / what) and emit only the relevant checks", + "description": "Omit fingerprint grounding (the grounded nodes) and emit only the relevant checks", "default": true, "takesValue": false, "negated": true diff --git a/docs/ideas/compare-drift-fleet-rethink.md b/docs/ideas/compare-drift-fleet-rethink.md new file mode 100644 index 00000000..5c9ed538 --- /dev/null +++ b/docs/ideas/compare-drift-fleet-rethink.md @@ -0,0 +1,69 @@ +--- +status: parked +--- + +# Compare / drift / fleet — concepts parked, implementations removed + +The **concepts hold**; the **implementations did not**, so they were removed +(not frozen — dead code does not linger). This note preserves the intent and the +trigger to rebuild them graph-native. + +## Why the implementations went + +compare / drift / fleet were built when Ghost scanned code for an explicit +**design system** and quantified it. They rested on two things the context-graph +reframe (Option A, prose nodes) abandoned: + +1. **`decisions[]` with embeddings** — a structured, quantified design-decision + record, distance computed by cosine + color-space math + (`ghost-core/embedding/`). Graph nodes are prose; there is no + `decisions[].embedding` to diff. +2. **13 fixed visual dimensions** (`color-strategy`, `typography-voice`, + `elevation`, `font-sourcing`, `token-architecture`, …). These are the + vocabulary of a scanned visual design system. They are meaningless for the + media Ghost now serves — what is the `elevation` distance of a *voice* + incarnation? + +They were also a **parallel subsystem**: they never read the `.ghost/` package +graph, only a separate direct-`fingerprint.md` artifact (`### Dimension` blocks + +`decisions[]`) via `comparable-fingerprint.ts`. Removing them did not touch the +graph world. + +## What was removed + +- CLI verbs: `compare`, `drift`, `ack`, `track`, `diverge` (+ `--gate`). +- `compare.ts`, `drift-command.ts`, `evolution-commands.ts`, + `comparable-fingerprint.ts`. +- `core/` (compare, gate, `evolution/`, `reporters/`). +- `ghost-core/embedding/` (the quantified-visual distance engine). +- `decision-vocabulary.ts`, `perceptual-prior.ts`, `decisions[]` / dimension + schema fields. +- Direct-`fingerprint.md` machinery (`scan/parser.ts`, `scan/writer.ts`, + `scan/diff.ts`, `fingerprint-load.ts`, `loadFingerprint`) and its lint + affordance — its only real consumer was compare/drift. +- Public exports: root `compare`/`drift`, `./compare` + `./drift` subpaths. + +## The concepts (still valid, sharper under the graph) + +- **compare** → "how does surface A's expression relate to surface B's?" The + Scenario-E question: is checkout's voice consistent with email's? +- **drift** → "has this surface's expression diverged from what we intended, or + from its sibling that projects the same `core` node?" +- **fleet** → "what does our whole design world look like across products?" + +These are exactly the questions `context-graph.md` said the graph would answer +well — once the model is settled. + +## Rethink trigger + likely shape + +Rebuild **after authoring and cross-package land** (the node/edge model must be +stable to define graph diff/distance properly). Graph-native sketch: + +- **Structural diff**, not embedding distance: nodes/edges/incarnations added / + removed / moved; deterministic and explainable (the calculator grain). +- **Coherence over projection-siblings**: compare the nodes that descend from / + relate to the same `core` node across surfaces — the brand-consistency check. +- **Prose comparison stays optional**: if semantic distance is wanted, embed node + prose behind an opt-in flag; never the primary, deterministic path. +- **No fixed visual-dimension vocabulary** — the graph's own structure (surfaces, + incarnations, relates) is the axis set. diff --git a/docs/ideas/context-graph.md b/docs/ideas/context-graph.md index 0c30eece..e244652c 100644 --- a/docs/ideas/context-graph.md +++ b/docs/ideas/context-graph.md @@ -148,7 +148,12 @@ kinds, and one tag** — nothing more. | --- | --- | | **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`, …) | +| **incarnation** | an optional tag on a node (`email`, `voice`, `any`, …) — the form the intent takes; gather filters by it via `--as` | + +> Naming: this tag was called *medium* through early notes. Settled name is +> **`incarnation`** (field) with **`--as`** (gather flag): the fingerprint is +> disembodied intent; a tagged node is that intent incarnated in one output. +> Voice-safe (unlike render/form/look) and free of "medium"'s abstractness. Links come in two kinds: @@ -229,8 +234,8 @@ id: core/trust # unique, addressable 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/… +incarnation: any # omit or `any` = essence, 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. @@ -240,6 +245,16 @@ written and read, not fields. `under` / `relates` target resolves. Everything else defaults. The tree is the set of `under` links — there is no separate spine object. +**Granularity: a node is a purpose, not an atom.** A node's body is a +*purpose-coherent, frontmatter-uniform* block of **any length** — 1 or 100 prose +points about that purpose live in one node. Body length never forces a split; +what forces a second node is a **divergence in the handles**: a different `under` +(placement), a different `incarnation` (medium), or a genuinely different +`relates` role. So `core/voice` can be three paragraphs (one node), while +`launch/email` and `launch/billboard` are separate nodes *because their +`incarnation` differs* — not because they are different ideas. Findings cite a +node by id (the purpose), so a coherent purpose stays one citable thing. + ### Naming a node (refs) ``` diff --git a/docs/ideas/phase-1-node-schema.md b/docs/ideas/phase-1-node-schema.md new file mode 100644 index 00000000..a5f5280a --- /dev/null +++ b/docs/ideas/phase-1-node-schema.md @@ -0,0 +1,203 @@ +--- +status: exploring +--- + +# Phase 1: the node schema (the keystone) + +First build phase after one-road (shipped). Grounded in the current code, not a +greenfield sketch. Read `context-graph.md` (the model) and +`graph-implementation-plan.md` (the sequencing) first; this note is the +execution spec for Phase 1 only. + +## Goal and boundary + +Define the **node** — the single artifact every fingerprint is made of — as a +schema + types + parser, **in isolation**, with no loader and no consumer +rewiring. The phase is done when: + +- a `ghost.node/v1` markdown+frontmatter artifact has a Zod schema and types, +- it parses (reusing the check parser), validates per-node, and round-trips, +- it is unit-tested, +- **nothing else changes** — the existing facet loader, `resolveSurfaceSlice`, + checks, compare all still compile and pass against the old model. + +Phase 1 is additive. The node model lands beside the facet model; the loader +fold (Phase 2) is what switches the system over. This keeps Phase 1 reviewable +and green. + +## What a node is (the conformance envelope) + +A node is one markdown file: YAML frontmatter + prose body. The frontmatter is +the machinery's handle; the body is design expression (written through the +intent/inventory/composition lenses, which are authorship guidance only — never +schema). + +```yaml +--- +# REQUIRED +id: checkout/trust-signals # unique, addressable + +# OPTIONAL (defaults keep small scale invisible) +under: checkout # parent node — the tree + cascade (omitted at root) +relates: # lateral links, typed + optional qualifier + - to: core/trust + as: reinforces # reinforces | contrasts | variant (closed set) +medium: web # any | web | email | billboard | slide | voice | + # generated-screen | (default: manifest medium) +--- +Prose body. The design expression. Intent / inventory / composition are how it +is written, not fields. +``` + +**Valid iff:** has `id`, parses (frontmatter + body), and `under`/`relates` +targets are well-formed refs. Cross-node resolution (does the target exist? one +root? no cycles?) is **Phase 8 lint** — Zod cannot see other nodes, exactly as +`surfaces.yml` already defers graph rules. + +## Decisions locked before writing (from the design thread) + +1. **`node` is machinery vocabulary.** Schema id `ghost.node/v1`, types + `GhostNode*`. Never user-facing prose. +2. **intent/inventory/composition have zero schema footprint.** Free markdown + body. No conventional headings, no body validation. +3. **One node = one concept**, scaffolded one-file-per-node (a Phase 5 `init` + concern; the schema is layout-free). +4. **`relates` qualifier vocabulary (closed):** `reinforces`, `contrasts`, + `variant` to start. `governs`/`projects` are deferred (Scenario D / explicit + projection) — not in the v1 enum. +5. **`medium` is an open enum** (known media + custom string), single-valued for + v1 (multi-valued deferred). + +## The id grammar (permissive schema, opinionated guidance) + +Two existing id rules collide — fingerprint nodes (`SlugIdSchema`) allow dots, +surfaces (`SurfaceIdSchema`) ban them (a dotted id would pretend to be a `parent` +link). The resolution is **not** to pick a stricter grammar. It is to apply the +project philosophy: **conformance is machine-tractability; guidance steers taste; +Git review is the approval boundary — not strict lint.** + +- **The tree is `under`, and only `under`.** An id is just a name and carries no + structural meaning, ever. This is the one principle that actually matters, and + it holds regardless of the id's characters. So the surfaces concern (an id + encoding the tree) is dissolved by *contract*, not by banning characters. +- **Schema is permissive:** an id is a non-empty lowercase slug, unique within + the package. It does not mandate a separator style. + - Charset: `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric plus `.` `_` `-`). + Liberal on purpose — a hand-authored id that uses something other than the + default still validates. +- **Default convention = dashes** (`checkout-trust-signals`). This lives in the + **skill guidance, `init` scaffolding, and agent authoring** — the things that + *emit* ids steer to dashes. A human can hand-author otherwise; Git review is + the check, not an error-level lint rule. +- **No strict style lint.** At most a soft `info` nudge toward the dash + convention — never an error. (Style-Dictionary move: easy default, flexible + underneath.) +- **The worked scenarios' ids become dashed:** `checkout/trust-signals` → + `checkout-trust-signals`, `launch.billboard` → `launch-billboard`. Readable, + flat, no hierarchy mixing. + +Cross-package refs (`@scope/pkg#id`) are **parsed but not resolved** in Phase 1 +(resolution is Phase 6). The grammar should *accept* the `package#` prefix so +the schema doesn't reject valid future refs; resolution is a later phase. + +## Files to add (all additive, under ghost-core/node/) + +``` +ghost-core/node/ + types.ts # GHOST_NODE_SCHEMA, GhostNode, GhostNodeRelation, qualifier enum, + # medium type, lint report types (mirror surfaces/check shape) + schema.ts # Zod: node frontmatter schema + id/ref grammar + parse.ts # parseNode(raw) → { frontmatter, body } reusing parseCheckMarkdown + serialize.ts # serializeNode(node) → markdown (round-trip; needed by migrate/init later) + index.ts # public surface for the module +``` + +Reuse, do not duplicate: +- **`parseCheckMarkdown`** (ghost-core/check/parse.ts) is exactly the + frontmatter+body splitter — lift it to a shared helper or import it directly. +- Mirror the **lint report shape** (`{ issues, errors, warnings, info }`) used by + surfaces/check/fingerprint so the CLI treats all reports uniformly. + +## Schema sketch (ghost.node/v1) + +```ts +export const GHOST_NODE_SCHEMA = "ghost.node/v1" as const; + +const NodeIdSchema = z.string().regex(/^[a-z0-9][a-z0-9._-]*$/, …) // permissive slug +const NodeRefSchema = z.string()… // [#] (pkg accepted, not resolved) + +export const GHOST_NODE_RELATION_KINDS = ["reinforces", "contrasts", "variant"] as const; + +const NodeRelationSchema = z.object({ + to: NodeRefSchema, + as: z.enum(GHOST_NODE_RELATION_KINDS).optional(), // default: untyped relate +}).strict(); + +export const GhostNodeFrontmatterSchema = z.object({ + id: NodeIdSchema, + under: NodeRefSchema.optional(), + relates: z.array(NodeRelationSchema).optional(), + medium: z.string().min(1).optional(), // open enum; lint may warn on unknowns +}).strict(); +``` + +Plus a `parseNode` that returns `{ frontmatter: GhostNodeFrontmatter, body }` +and a thin `lintGhostNode(raw)` that reports per-node (missing id, malformed +ref, unknown qualifier) — graph rules deferred. + +## Tests (Phase 1 scope only) + +A `test/ghost-core/node-schema.test.ts`: +- valid minimal node (id only) parses and validates. +- id grammar (permissive): accepts `core`, `checkout-trust-signals`, and even + `email.marketing` (liberal charset); rejects only genuinely malformed ids — + uppercase, leading separator, empty. No separator-style is an error. +- `relates` qualifier: accepts the three kinds; rejects unknown. +- `under`/`relates` ref grammar: accepts local + `@scope/pkg#id`; rejects + malformed. +- `medium` optional; arbitrary string accepted. +- round-trip: `serializeNode(parseNode(x)) ≈ x` for a representative node. +- body is preserved verbatim (frontmatter stripped, prose intact). + +**No** loader test, **no** gather/checks change — those are later phases. + +## Wiring (minimal, additive) + +- Export the node module from `ghost-core/index.ts` (new `ghost.node/v1` block). +- Do **not** add it to `file-kind.ts` dispatch yet (that routes lint; wiring it + in is Phase 2/8 when the loader and lint actually consume nodes). Keep Phase 1 + free of consumer changes. +- `public-exports.test.ts`: add the node module's presence to the export + assertions only if we expose it on a public subpath now; otherwise defer the + export-surface decision to when a consumer needs it. + +## Explicitly NOT in Phase 1 + +- The loader fold (Phase 2) — nodes still are not read from disk into the graph. +- Removing the facet schemas/types — they stay until Phase 2 switches the loader. +- `medium` in gather/checks (Phase 3/4). +- Cross-package ref *resolution* (Phase 6) — grammar only. +- Graph-level lint: target-exists, one-root, no-cycles (Phase 8). +- The `surface`→`node` rename of existing symbols — that happens as the loader + and consumers move (Phase 2+), not in this additive phase. + +## Open micro-decisions (decide while building, low stakes) + +1. **Lift `parseCheckMarkdown` to a shared `ghost-core/markdown.ts`, or import + from check?** Lean: lift to shared — both checks and nodes are the same + envelope; one splitter. +2. **Default `relates.as` — untyped or required?** Lean optional (OKF's untyped + link is valid; the qualifier is the machinery handle when present). +3. **Should `id` segments cap depth?** Lean no cap; `under` carries hierarchy, + id is just a name. Lint can warn on absurd depth later. + +## Read-back + +Phase 1 succeeds if `ghost.node/v1` exists as schema + types + parser + +serializer, validates a node in isolation (id required, permissive lowercase +slug; the tree lives only in `under`; typed-and-optional `relates`; optional +`medium`; `package#` prefix accepted but unresolved), round-trips, is +unit-tested, and the rest of the system is untouched and green. Dashes are the +emitted convention (skill/init/agent), not a lint rule. The keystone is in place +for the Phase 2 loader fold to read nodes into the existing +`GhostFingerprintDocument` graph. diff --git a/docs/ideas/phase-2-loader-fold.md b/docs/ideas/phase-2-loader-fold.md new file mode 100644 index 00000000..5c7690f6 --- /dev/null +++ b/docs/ideas/phase-2-loader-fold.md @@ -0,0 +1,148 @@ +--- +status: exploring +--- + +# Phase 2: the loader fold (the hard phase) + +Second build phase after one-road + Phase 1 (node schema, shipped). This is the +one genuinely hard phase: where the system gains an in-memory **node graph** and +the loader learns to produce it. Read `context-graph.md`, +`graph-implementation-plan.md`, and `phase-1-node-schema.md` first. + +## The honest correction (grounded in the code) + +`context-graph.md` claimed the in-memory `GhostFingerprintDocument` and every +read consumer stay unchanged. **Reading the loader, that is too optimistic** and +the plan must say so: + +- The in-memory doc is **richly typed facets**: `intent.principles[]` (each with + `.principle` text, `guidance[]`, `evidence[]`, `check_refs[]`), + `intent.situations[]`, `intent.experience_contracts[]`, + `composition.patterns[]` (`.kind`, `.pattern`), `inventory` building blocks + + exemplars + sources. +- `resolveSurfaceSlice` and `groundSurface` read those **typed fields directly** + (`node.principle`, `entry.node.kind`). +- A Phase-1 **node is prose body + minimal frontmatter** (`id`, `under`, + `relates`, `medium`) — by design it has *no* `.principle` string, `guidance[]`, + or `evidence[]`. + +So a node and a facet entry are different shapes. The fold is not a reshuffle; +it forces the central decision below. What *is* true from `context-graph.md`: +there is a clean seam (`files → loader → in-memory → consumers`), and we can +keep the build green by making Phase 2 **additive** — the node graph lands +*beside* the facet doc; consumer migration is later phases. + +## The node content model: SETTLED — Option A (pure prose) + +A graph node is `{ id, under, relates, medium, body }`. The **body is the +expression**; there are **no** structured node fields. The facet affordances — +`guidance`, `evidence`, `check_refs`, pattern `kind`, the `.principle` / +`.pattern` / `.contract` text slots — are **not** node structure and go away as +the model migrates. This is the cleanest end-state and is truest to +"intent/inventory/composition are authorship lenses, not fields." + +What A means downstream (named honestly, so later phases own it): + +- **gather slice changes shape** (Phase 3): a slice is no longer typed sections + (`principles[]`, `patterns[]`); it is **nodes-by-provenance** — the relevant + nodes and their prose, each tagged own / inherited-from-ancestor / via-edge. +- **checks grounding is reconceived from prose** (Phase 4): `why` / `what` come + from the prose of the nodes on the surface + ancestors, not from `principle` + statements and `exemplar` rows. +- **verify loses evidence/exemplar path-checking** (its own later phase): nodes + have no `evidence` paths. That responsibility either disappears or moves; it is + not a node concern under A. +- **compare/drift is reconceived over prose + topology** (later): no structured + fields to diff; comparison works from the graph shape and node prose + (embeddings already exist for prose-level comparison). + +Phase 2 itself stays additive and green because **nothing reads the graph yet** — +the graph lands beside the facet doc, and consumers migrate one per later phase, +with the facet model deleted last. + +## The one sub-decision: lossy facet→node projection (transition scaffold) + +Existing packages and fixtures are facet-based. To keep the build green and +reuse fixtures, the fold projects facet entries into prose nodes during +transition: each `principle` / `pattern` / `contract` / `situation` / `exemplar` +becomes a node whose `id` is the entry id, `under` is its `surface:` tag (or +`core`), and whose **body is the entry's text** (`principle` / `pattern` / +`contract` string). This projection is **lossy on purpose** (it drops +`evidence` / `guidance` / `check_refs` — exactly the affordances A removes) and +is **explicit transition scaffolding, deleted in the facet-removal phase**. It +is not a permanent bridge. Decision: keep the projection (continuity + test +reuse) and mark it for deletion; do not let any new code depend on its lossy +output as if it were authoritative. + +## Phase 2 scope + +Additive. The facet loader, `resolveSurfaceSlice`, checks, compare are all +untouched and green at the end. + +1. **`GhostGraph` in-memory type** (`ghost-core/graph/`): the resolved graph — + `nodes` (id → `{ id, under, relates, medium, body }`), the `under` tree + (parent edges, root = `core`), and `relates` links. Mirror, don't fight, + `GhostSurfacesDocument` — surfaces already model a tree + typed edges; the + graph is surfaces + placed prose nodes unified. + +2. **`assembleGraph` — the fold.** Build a `GhostGraph` from two sources, unioned: + - **on-disk node files** discovered in the package (see discovery below), and + - **the lossy facet→node projection** above, so every existing package and + test produces a (prose) graph for free and Phase 3 gather can be exercised + against existing fixtures before facets are removed. + +3. **Node discovery (layout, decided minimally).** Per the model, layout is free + and the loader discovers. For Phase 2 pick one default and keep it simple: + nodes are `*.md` files under a `nodes/` directory in the package (mirrors how + `checks/*.md` already works via `loadChecksDir`). Loose-anywhere discovery and + custom layouts are a later refinement; do not over-build discovery now. + +4. **Attach additively:** `LoadedFingerprintPackage.graph?: GhostGraph`. The + existing `fingerprint` (facet doc) and `surfaces` fields stay exactly as they + are. Nothing that reads them changes. + +5. **Tests** (`test/ghost-core/graph-fold.test.ts` + a loader test): the fold + from node files; the lossy facet→node projection; the union; tree resolution + (parent chain, root); `relates` carried through; a package with only facets + still yields a prose graph; a package with node files yields a graph; medium + carried; on-disk node wins over a same-id projection. + +## Explicitly NOT in Phase 2 + +- Switching `gather` to traverse the graph (Phase 3, with `medium`). +- Switching `checks`/grounding to the graph (Phase 4). +- Switching compare/drift (later). +- Removing facet schemas/types/loader (the final phase, once every consumer is + off them). +- Graph-level lint (target-exists, one-root, no-cycles) — Phase 8 lint, though + the fold may surface obvious structural errors as thrown load errors like the + current loader does. +- Cross-package resolution (Phase 6) — the fold resolves within one package. +- The `surface`→`node` rename of existing symbols — happens as consumers move. + +## Open micro-decisions (decide while building) + +1. **Is `core` a real node or an implicit root?** Surfaces treat `core` as the + reserved implicit root. The graph should keep that: `under` omitted ⇒ child of + implicit `core`. Lean: `core` is implicit unless an author writes a `core` + node, in which case that node *is* the root content. +2. **Does the projection dedupe against on-disk nodes by id?** If an author has + written a `checkout-trust` node *and* a facet projects the same id, the + on-disk node wins (authored beats projected). Lean: yes, id-collision → + authored node wins, projection skipped, lint notes it later. +3. **Graph keyed by node-id or by surface?** Both: nodes indexed by id; the + surface tree (from `surfaces.yml` + node `under`) is the traversal spine. + Reconcile `surfaces.yml` (the current explicit tree) with node `under` — for + Phase 2, `surfaces.yml` remains the authoritative tree and nodes attach to it + by their `surface`/`under`; unifying the two is a later cut. + +## Read-back + +Phase 2 succeeds if a `GhostGraph` type exists and `assembleGraph` folds both +on-disk node files and the lossy facet→node projection into one in-memory graph +of **pure-prose nodes** (tree + nodes + links + medium + body), attached +additively to `LoadedFingerprintPackage`, unit-tested, with the entire existing +system (facet loader, gather, checks, compare) untouched and green. The graph is +then in place for Phase 3 to point `gather` at it. Node content model: **A +(pure prose)** — settled; the facet→node projection is explicit transition +scaffolding marked for deletion in the facet-removal phase. diff --git a/docs/ideas/phase-3-gather-graph.md b/docs/ideas/phase-3-gather-graph.md new file mode 100644 index 00000000..a47a18d9 --- /dev/null +++ b/docs/ideas/phase-3-gather-graph.md @@ -0,0 +1,221 @@ +--- +status: exploring +--- + +> **Naming (settled):** the per-node output axis is **`incarnation`** (node +> field) filtered by **`--as`** (gather flag). The fingerprint is disembodied +> intent; a tagged node is that intent *incarnated* in one output (email, +> billboard, voice — voice-safe, unlike render/form/look). `gather launch --as +> email` reads as "gather the launch context **as** an email." Untagged nodes +> are free essence (cascade to every incarnation). + +# Phase 3: point gather at the graph + introduce incarnation (`--as`) + +Third build phase. The **first phase where a consumer reads the graph**, and the +first user-visible shape change. Read `phase-2-loader-fold.md` first; this builds +directly on `GhostGraph` and `assembleGraph`. + +## Goal and boundary + +`ghost gather [--as ]` composes its context packet by +**traversing the graph** (Phase 2), not by reading facet sections. The slice +changes shape from typed facet sections to **nodes-by-provenance** (Option A: +nodes are prose). `incarnation` enters the model as a filter. + +Done when: + +- a new `resolveGraphSlice(graph, id, { incarnation })` returns + nodes-by-provenance, +- `gather` uses it and formats prose nodes (markdown + json), +- `--as` filters the slice by incarnation, +- the surface menu still works (built from the graph/surfaces), +- unit + CLI tests pass; everything else stays green. + +Because the Phase 2 fold projects facets into the graph, **existing fixtures +still produce a graph**, so gather can switch to the graph without authoring any +new node files — the projection carries the old packages through. + +## The mapping is the agent's; the gather is deterministic + +The seam is the **node id**. Above it is fuzzy and LLM-driven; below it is exact +and Ghost-driven. Ghost does zero NLP. + +``` +prompt ──▶ [ LLM: which node(s)? ] ──▶ id(s) ──▶ [ Ghost: gather ] ──▶ packet + the MAPPING (fuzzy, NL) the GATHER (graph traversal) +``` + +- The agent calls `gather --format json` with no id to get the **menu** (the + surfaces with authored descriptions), matches the prompt against it, and picks + the id. That matching is the agent's call — there is no path→surface + lookup (one-road deleted it). +- The agent then calls `gather --as `; Ghost traverses and + returns. Same input → same packet, always. This is what keeps trace / checks / + review explainable. + +## One gather is one region; multiplicity lives in the agent loop + +`gather` takes **one** id and returns **one** packet — but that packet is a whole +connected region (own + cascaded ancestors + one-hop `relates`), never a single +node. For most prompts, one region is the right answer. + +For prompts that touch **disjoint** regions (e.g. "make checkout *and* its +confirmation email reassuring"), the **agent gathers each id separately and +synthesizes** — each call with its own `--as`: + +``` +gather checkout --as web +gather email --as email +``` + +This is deliberate, not a gap: + +- Per-call `--as` is a feature — checkout wants `web`, the receipt wants `email`; + a single merged call would force one incarnation across both (wrong for + cross-channel prompts, Scenario E). +- Merge semantics (dedup shared `core` ancestors, re-base provenance per + requested id) are a rabbit hole we do not need to ship gather. +- The agent already owns the fuzzy mapping; looping N times is the same muscle. + +Note the deliberate asymmetry: `checks`/`review` take `--surface ` (plural, +because they produce one combined gate); `gather` stays **single-id atomic** +(context the agent reasons over region-by-region). Do not pluralize gather. + +## The slice shape change (the heart of Phase 3) + +Today `ResolvedSlice` is four typed arrays (`situations`, `principles`, +`experience_contracts`, `patterns`), each `SliceNode`. Under Option +A there are no typed facets — just prose nodes. So the new slice is **one list of +nodes, each with provenance**: + +```ts +interface GraphSliceNode { + id: string; + body: string; // the prose expression + incarnation?: string; // the node's tag, if any + provenance: + | { kind: "own" } + | { kind: "ancestor"; from: string } + | { kind: "edge"; via: GhostNodeRelationKind; from: string }; +} + +interface GraphSlice { + surface: string; // the requested node/surface id + ancestors: string[]; // chain up to (excl.) core, as today + incarnation?: string; // the --as filter applied, if any + nodes: GraphSliceNode[]; +} +``` + +The composition rules are **the same cascade semantics** that `resolveSurfaceSlice` +already encodes — only the node shape and the traversal source change: + +- **own**: nodes whose containment is the requested id. +- **ancestor**: nodes on each ancestor up to `core` cascade down. +- **edge**: one hop along `relates` — the related node's body is included, tagged + by qualifier (`reinforces`/`contrasts`/`variant`). (Maps the old `composes`/ + `governed-by` surface edges onto the node `relates` model.) + +Reuse `ancestorChain` from `graph/assemble.ts` (already built in Phase 2) instead +of the surfaces-specific `cascade.ts` chain. + +## The incarnation filter (`--as`, the new capability) + +`--as ` filters which nodes appear: + +- A node with **no incarnation** (or `any`) is essence → always included + (it cascades to every incarnation). This is the brand-soul behavior. +- A node tagged `incarnation: ` is included **only** when `--as ` matches. +- A node tagged a **different** incarnation is excluded. +- **No `--as`** → no filtering (every node, regardless of tag). The agent gets + the whole surface; incarnation is opt-in narrowing. + +Default incarnation: Phase 3 keeps it simple — `--as` is the only input; a +manifest default incarnation is a later refinement (note it, don't build it). + +## Files + +``` +ghost-core/graph/ + slice.ts # resolveGraphSlice(graph, id, opts) → GraphSlice (+ types) + index.ts # export it +``` +Update `gather-command.ts` to call `resolveGraphSlice(loaded.graph, …)` and +format prose nodes. Keep `buildSurfaceMenu` as the menu source for now (it reads +surfaces; the graph has the same tree — unifying menu onto the graph is a small +later cleanup, not required here). + +## gather output (markdown + json) + +- **json** is the agent contract: `{ surface, ancestors, incarnation?, nodes: [{ + id, body, incarnation?, provenance }] }`. This *replaces* the old + typed-section json. +- **markdown** is the human preview: a `# Ghost Context: ` header, the + cascade chain, then each node rendered as its id + provenance label + prose + body (trimmed/previewed). Drop the per-facet `## Situations / ## Principles` + sections — there are no facets now; it is one provenance-ordered list (own + first, then ancestors, then edges). + +## Tests + +- `test/ghost-core/graph-slice.test.ts`: own/ancestor/edge provenance from a + hand-built graph; `--as` filter (essence/untagged always in; matching in; + mismatched out; no-filter = all); ancestor cascade depth; edge one-hop only + (no recursion). +- Update the existing gather CLI tests (`gathers a composed slice…`, menu tests) + to the new json shape. The facet→node projection means the existing fixtures + keep working; assertions move from `slice.principles[…].provenance` to + `slice.nodes.find(n => n.id === …).provenance`. + +## Explicitly NOT in Phase 3 + +- Switching `checks`/grounding to the graph (Phase 4). +- Switching compare/drift (later). +- Removing facet schemas/types/loader or `resolveSurfaceSlice` (final phase). + `resolveSurfaceSlice` stays until checks/compare are also off facets; Phase 3 + just stops `gather` from using it. +- Manifest default incarnation, multi-valued incarnation (later). +- Multi-id / merged gather — gather stays single-id; the agent loops (see above). +- Cross-package gather (`@scope/pkg#id`) — Phase 6. +- The `surface`→`node` rename of symbols. + +## Prerequisite rename (do first, in this phase) + +The Phase 1/2 node model used the working name **`medium`**. Phase 3 settles it +as **`incarnation`**. Rename before adding the filter so there is one name in the +tree: + +- `GhostNodeFrontmatter.medium` → `incarnation`; schema key `medium` → + `incarnation` (still optional, open string). +- `GhostGraphNode.medium` → `incarnation`; projection + fold carry it through. +- Update Phase 1/2 tests that assert `medium`. + +This is a mechanical, contained rename (the field is barely consumed yet) and +keeps the model honest before `--as` lands. + +## Open micro-decisions (decide while building) + +1. **Edge mapping.** Phase 2 nodes carry `relates` (`reinforces`/`contrasts`/ + `variant`); legacy surfaces carry `composes`/`governed-by` edges that the + projection does not currently turn into `relates`. For Phase 3, the + projected graph has no `relates` (facets had surface-level edges, not + node-level). Decision: Phase 3 edge contributions come from node `relates` + only; the legacy surface-edge → slice behavior is **not** reproduced through + the graph (it was a surfaces-doc feature). If a fixture relied on + `composes`-edge slice contributions, port it to a `relates` node or accept the + simplification. Flag any test that breaks here as a real semantic decision, + not a bug. +2. **Body preview length in markdown.** Lean: full body in json; in markdown, + the whole body (nodes are short) — revisit only if output is huge. +3. **Provenance ordering.** own → ancestor (nearest first) → edge. Stable + matches + how an agent should weight them. + +## Read-back + +Phase 3 succeeds if `gather` composes its packet by traversing `GhostGraph` and +emits **nodes-by-provenance prose** (json + markdown), with `--as` filtering by +incarnation (essence always in, matching in, mismatched out, absent = all), the +menu intact, gather single-id (multiplicity in the agent loop), tests green, and +checks/compare/facet-loader untouched. This is the first consumer on the graph +and the first taste of the incarnation axis; Phase 4 follows by +routing checks through the same graph. diff --git a/docs/ideas/phase-4-checks-graph.md b/docs/ideas/phase-4-checks-graph.md new file mode 100644 index 00000000..ce2a5ef2 --- /dev/null +++ b/docs/ideas/phase-4-checks-graph.md @@ -0,0 +1,158 @@ +--- +status: exploring +--- + +# Phase 4: route checks through the graph + ground from prose + +Fourth build phase. The **second consumer migration** (after gather): checks +routing and review grounding move onto `GhostGraph`, and grounding is +**reconceived from prose** (Option A) rather than from typed +principles/patterns/exemplars. Read `phase-2-loader-fold.md` and +`phase-3-gather-graph.md` first. + +## Goal and boundary + +- `ghost checks --surface ` selects governing checks by **graph** cascade + (not the surfaces-doc `cascade.ts`). +- Grounding (`why` / `what`) becomes **the graph slice's prose nodes** — there + are no facet `principle`/`pattern`/`exemplar` types to split on anymore. +- `ghost review` consumes the same graph-based routing + grounding. + +Done when checks + review run on the graph, the facet-based `groundSurface` and +the surfaces-`cascade.ts` routing are no longer used by these commands, tests +pass, and the remaining facet consumer (compare) is still green. + +## The grounding reconception (the heart of Phase 4) + +Today grounding is **two typed lists**: + +- `why`: principles + experience contracts (the design intent a finding cites). +- `what`: composition patterns + inventory exemplars (what good looks like). + +Under Option A there are no such types — a node is prose with provenance. So the +honest question: does the why/what split survive? + +**Decision (settled): drop the why/what framing entirely; grounding is the prose +slice by provenance.** why/what is not a structure Ghost extracts — it is a +*quality of well-authored guidance*. A good intent node already says the why +("near payment, reduce felt risk"); a good guideline already gestures at what +good looks like — because the **authoring skill prompted the human** to cover it +(intent/inventory/composition as the ephemeral lenses). So Ghost does not pull +why/what into headers; it hands over the prose, and the why and what live *in* +the prose. The burden of ensuring nodes contain both moves to the authoring +skill (a later phase), which is the correct place for it — authoring-time +guidance, not review-time extraction. The new grounding is: + +```ts +interface GraphGrounding { + surface: string; + nodes: GraphSliceNode[]; // the slice (own + ancestors + one-hop edges) +} +``` + +i.e. **grounding = the gather slice**. A check that fires on a surface is +grounded by that surface's gathered nodes; the agent cites node ids and quotes +prose. This unifies "context for generation" (gather) and "grounding for review" +(checks/review) onto **one resolver** — which is the right simplification: they +were always the same slice, viewed for different purposes. + +Consumers that printed `## Why` / `## What good looks like` now print grounded +nodes by provenance (own → ancestor → edge), each as id + prose. The +`missing-fingerprint` / silent-grounding behavior is unchanged (empty slice = +silent). + +## Routing on the graph + +`selectChecksForSurfaces` currently walks the surfaces-doc parent map +(`buildParentMap` + surfaces `ancestorChain`). Repoint it at the graph: + +- a check's placement is its `surface:` frontmatter (unplaced ⇒ `core`); +- it governs a touched surface when its placement equals that surface (own) or + any **graph** ancestor of it (cascade), using `ancestorChain(graph, id)` from + Phase 2; +- `core` governs every diff (unchanged). + +The routing *logic* is identical — only the ancestry source changes from the +surfaces doc to the graph. Keep `RoutedCheck` / `CheckRelevance` shapes as-is +(they reference surface ids, which the graph still has). + +Note: checks themselves stay `ghost.check/v1` markdown with `surface:` +frontmatter — Phase 4 does not change the check artifact, only how routing finds +ancestors. (Renaming `surface:` to a node ref is a later cleanup, not this +phase.) + +## Files + +``` +ghost-core/graph/ + ground.ts # groundGraph(graph, id, opts?) → GraphGrounding (the slice) + index.ts # export it +ghost-core/check/ + route.ts # selectChecksForSurfaces: walk graph ancestry, not surfaces +``` +Update `checks-command.ts` and `review-packet.ts` to: +- call the graph-based `selectChecksForSurfaces(checks, graph, touched)`, +- call `groundGraph(graph, surface, { incarnation? })` instead of + `groundSurface(...)`, +- format grounded nodes by provenance. + +## Incarnation in checks (small, consistent) + +`checks` and `review` gain an optional `--as ` so grounding is +filtered to the relevant incarnation (same filter as gather). A check itself is +not incarnation-tagged in Phase 4 (check artifact unchanged); only its grounding +slice is filtered. Lean: add `--as` to both commands, pass through to +`groundGraph`. (Optional — can defer if it bloats the phase; routing does not +need it.) + +## Tests + +- `test/ghost-core/graph-ground.test.ts`: grounding = slice nodes by provenance; + silent when empty; incarnation filter applied. +- `test/ghost-core/check-route-graph.test.ts` (or update the existing route + test): own/ancestor cascade via graph ancestry; `core` governs always; + unplaced check ⇒ core. +- Update `cli.test.ts` checks/review assertions from `grounding[].why/what` to + `grounding[].nodes` (or the chosen output shape). The facet→node projection + keeps the fixtures producing grounded nodes. + +## Explicitly NOT in Phase 4 + +- Switching compare/drift to the graph (next). +- Removing facet schemas/types/loader, `resolveSurfaceSlice`, `groundSurface`, + surfaces-`cascade.ts` (final phase — compare still uses facets until then; + delete these once compare is migrated). +- Changing the `ghost.check/v1` artifact (surface frontmatter → node ref is + later). +- Cross-package routing/grounding (Phase 6). +- The `surface`→`node` rename of symbols. + +## Open micro-decisions (decide while building) + +1. **why/what — settled: dropped (see above).** One provenance-ordered prose + node list; no why/what headers, no provenance-derived relabeling. The why and + what live in the prose, ensured by the authoring skill, not by Ghost + extraction. The review prompt text should be reworded to "read the grounded + nodes" rather than "use why then what." +2. **Exemplar paths — DEPRECATE + flag for removal (settled).** Old grounding + surfaced exemplar `path:` (a concrete file to look at). Prose nodes have no + `path`. The facet→node projection stops carrying it now (grounding won't have + it), and the field is flagged for removal with the rest of the facet model in + the final deletion phase. Authors who want to point at a file write the path + in the node body, where the agent reads it as context anyway. +3. **`groundGraph` vs reuse `resolveGraphSlice` directly.** Grounding *is* the + slice — `groundGraph` may be a thin alias (slice + the surface label) rather + than a separate function. Lean: thin wrapper for naming clarity, or have + checks/review call `resolveGraphSlice` directly and drop `groundGraph` + entirely. Decide while wiring; fewer functions is better. + +## Read-back + +Phase 4 succeeds if `checks` and `review` route by graph ancestry and ground +from the **prose graph slice** (no why/what framing — provenance-tagged prose +nodes, with the why and what carried in the prose itself), with the facet-based +`groundSurface` + surfaces routing no longer used by these commands, optional +`--as` filtering grounding, tests green, and compare (the last facet consumer) +untouched. Exemplar `path:` is dropped from grounding and flagged for removal. +After this, only compare/drift remains on facets before the facet model can be +deleted. diff --git a/docs/ideas/phase-5-authoring.md b/docs/ideas/phase-5-authoring.md new file mode 100644 index 00000000..c4182933 --- /dev/null +++ b/docs/ideas/phase-5-authoring.md @@ -0,0 +1,168 @@ +--- +status: exploring +--- + +# Phase 5: node authoring (init, migrate, skill) + +Fifth build phase. Where Ghost packages start being **authored as nodes**, not +facets. This is the prerequisite for facet-removal: until `init` and `migrate` +emit nodes, the facet→node projection is load-bearing and cannot be deleted. +Read `phase-2-loader-fold.md`, `phase-3-gather-graph.md`, and +`phase-4-checks-graph.md` first. + +## Goal and boundary + +Make node packages first-class to author: + +- **`init`** scaffolds a node package: `manifest.yml`, `surfaces.yml` (the + spine), and `nodes/*.md` seeds — not the three facet files. +- **`migrate`** gains a facet→node re-filing path: an existing facet package + (or legacy package) is rewritten into `nodes/*.md` + `surfaces.yml`. +- **The authoring skill** (`capture.md` + friends) teaches node authoring: write + prose nodes through the intent/inventory/composition lenses, place with + `under`, link with `relates`, tag with `incarnation`. This is where the + why/what authoring burden (Phase 4) actually lives. + +Done when a freshly `init`-ed package is a node package, `migrate` converts facet +packages to node packages, the skill documents node authoring, and the whole +thing gathers/checks/reviews on the graph. Facet *removal* is the next phase +(this phase makes it possible by ending facet emission). + +## What `init` produces (the new scaffold) — templates, not questions + +Today: `manifest.yml` + `intent.yml`/`inventory.yml`/`composition.yml` (empty +facet files). New: + +```text +.ghost/ + manifest.yml # unchanged: schema + id + surfaces.yml # the spine — `core` is implicit, near-empty is valid + nodes/ + core-voice.md # seed node(s) showing the shape (prose + frontmatter) +``` + +**`init` is template-driven, not an interactive Q&A wizard (SETTLED).** A wizard +fights BYOA — the CLI is the deterministic calculator; the *skill* asks the +human in conversation. `init` deterministically stamps a named template: + +``` +ghost init # the `default` template +ghost init --template # (future) other starters +``` + +- **Template registry seam, built now (one template registered).** A template is + a pure function/record → a set of seed files (a `surfaces.yml` spine + a few + `nodes/*.md` written through the lenses). Structure the code so adding + `marketing` / `voice` / `dashboard` starters later is just registering another + template — no `init` rework. These map onto the worked scenarios (marketing + seeds campaign/email/billboard surfaces with incarnation-tagged nodes; voice + seeds modality/intent-class nodes; etc.). +- **`default` template seeds minimally:** the `surfaces.yml` spine (core + implicit) + one `core`-placed intent node, so a fresh package is + self-explanatory and immediately gatherable. Not a fake fingerprint. +- **`--reference` is DROPPED (SETTLED).** Facet-era plumbing + (`templateInventory(reference)`). Clean house. An author records design + materials by writing an inventory-nature node, guided by the skill. +- **`init` output** (json/cli summary) changes from `intent/inventory/ + composition` paths to `surfaces.yml` + `nodes/` — update `initCommandOutput`. + +## What `migrate` produces (facet → nodes) + +`migrate` currently re-files legacy coordinates into facet files + `surfaces.yml`. +Extend it to **emit nodes**: + +- For each facet entry (principle/pattern/contract/situation/exemplar), write a + `nodes/.md` whose frontmatter is `id` + `under: ` (+ `relates` + from `check_refs`/edges where translatable) and whose **body is the entry's + prose** (principle text / pattern text / etc.). This is the + `projectFacetsToNodes` logic (Phase 2) made *persistent* — the projection + becomes the migration writer. +- Keep `surfaces.yml` emission as-is (the spine). +- **Stop writing facet files.** After migrate, the package has `manifest.yml` + + `surfaces.yml` + `nodes/*.md` and no `intent.yml`/`inventory.yml`/ + `composition.yml`. +- Migration notes flag anything lossy (evidence/check_refs that don't translate + cleanly), consistent with the lossy-projection stance. + +This makes `migrate` the tool that converts *every existing facet package* +(including Ghost's own dogfood packages and fixtures) to nodes — which is what +lets the facet loader + projection be deleted next phase. + +## The authoring skill (the real home of why/what) + +Update `capture.md` (and the bundle) to teach node authoring: + +- A node is a markdown file in `nodes/`: frontmatter (`id`, `under?`, `relates?`, + `incarnation?`) + a prose body. +- The body is written through the **intent / inventory / composition lenses** — + the ephemeral authoring guidance: capture the *why* (intent), the *material* + (inventory, incl. pointers to component code), and the *composition* (patterns). + These are prompts to the author, never fields. +- Place with `under` (the tree / cascade); the brand soul lives at `core`. +- Link laterally with `relates` (`reinforces`/`contrasts`/`variant`) when a + relationship carries rationale; when the rationale is rich, write a + relationship-node (its body explains the tension). +- Tag with `incarnation` only for medium-bound expressions; leave essence + untagged. +- This is where Phase 4's "the why and what live in the prose" is *taught* — + the skill is what ensures grounded nodes actually contain both. + +## Files + +- `init-command.ts` + `initFingerprintPackage`: scaffold surfaces + nodes, drop + facet-file emission, update output shape. +- `scan/fingerprint-package.ts` templates: replace `templateIntent/Inventory/ + Composition` with `templateSurfaces` + `templateNode(s)`. +- `migrate-command.ts` + `scan/migrate-legacy.ts`: add the node-emitting writer + (reuse the projection mapping); stop writing facet files. +- `skill-bundle/references/capture.md` (+ SKILL.md, authoring-scenarios.md, + patterns.md, voice.md as needed): node-authoring guidance. + +## Tests + +- `init` produces `manifest.yml` + `surfaces.yml` + `nodes/*.md`; no facet files; + the result loads and gathers. +- `migrate` converts a facet package to nodes; bodies preserved; surfaces spine + intact; lossy items noted; no facet files remain. +- Skill bundle manifest updated (capture.md changes; install manifest still + matches). +- CLI: init → gather round-trips on the node package. + +## Explicitly NOT in Phase 5 + +- Deleting the facet loader / facet schemas / `projectFacetsToNodes` / + `resolveSurfaceSlice` / `groundSurface` — that is the **facet-removal phase**, + which this unblocks. (The loader keeps reading facets *and* nodes during the + transition so old packages still load until migrated.) +- Cross-package authoring (`@scope/pkg#id`) — Phase 6. +- The `surface`→`node` rename of symbols. +- Multi-node `init` templates / scaffolding wizards — keep `init` minimal. + +## Settled decisions + +1. **`init` is template-driven** (registry seam now, `default` template only; + no Q&A wizard). `default` seeds the spine + one `core` node. +2. **`--reference` dropped.** Clean house; materials become an inventory node. +3. **`migrate` is one-way (no `--keep-facets`).** It rewrites the package into + the node form and removes the facet files. Git history preserves the old + files; keeping both invites two-sources-of-truth drift. The transition loader + still reads any not-yet-migrated package. +4. **Node granularity: file = purpose, not atom (SETTLED).** A node is a + *purpose-coherent, frontmatter-uniform* body of **any length** — 1 or 100 + prose points about that purpose live in one node. The body length is + irrelevant; what forces a second file is a **divergence in the handles**: + a different `under` (placement), a different `incarnation` (medium), or a + genuinely different `relates` role (e.g. a relationship-node that connects two + others). So `core-voice.md` can be three paragraphs (one node); + `launch-email.md` and `launch-billboard.md` are separate *because their + `incarnation` differs*, not because they are different ideas. One node per + file; grouped-files remain a possible later authoring convenience, not now. + +## Read-back + +Phase 5 succeeds if `init` scaffolds a node package (`surfaces.yml` + `nodes/`), +`migrate` rewrites facet/legacy packages into nodes (bodies preserved, spine +intact, lossy items noted, no facet files left), and the skill teaches node +authoring with the intent/inventory/composition lenses as the why/what home — +all gathering/checking/reviewing on the graph, with the facet loader still +reading legacy packages until the facet-removal phase deletes it. diff --git a/docs/ideas/phase-6-facet-removal.md b/docs/ideas/phase-6-facet-removal.md new file mode 100644 index 00000000..f843ffee --- /dev/null +++ b/docs/ideas/phase-6-facet-removal.md @@ -0,0 +1,177 @@ +--- +status: exploring +--- + +# Phase 6: facet removal — the graph is the only model + +Sixth build phase. Delete the facet model now that authoring (Phase 5) emits +nodes and every read consumer (gather, checks, review) is on the graph. After +this, `GhostFingerprintDocument` and the `intent/inventory/composition` schemas +no longer exist; the loader folds **nodes + surfaces** into the graph directly. +Read phases 2–5 first. + +## Goal and boundary + +Remove the facet model end to end: + +- the facet schemas/types/lint (`ghost-core/fingerprint/`), +- the facet layer parsing in the loader (`assembleFingerprint`, `layerRaw`, + `parseLayer`), +- the facet→node projection scaffold (`projectFacetsToNodes`) — its job is done + (it lives on as `migrate`'s writer, not as a load-time bridge), +- the now-dead `resolveSurfaceSlice` / `groundSurface` / `ground.ts` and the + surfaces `cascade.ts` (gather/checks moved to the graph slice in phases 3–4), +- the facet `file-kind` branches (`fingerprint-intent/-inventory/-composition`). + +And **reconceive the commands still facet-shaped**: + +- **`lint` + `verify` → one public `validate` verb (SETTLED).** `validate` is + internal hygiene: "is the fingerprint correct?" It runs two passes and reports + both: a **shape pass** (each artifact well-formed on its own — the old `lint`, + which stays the internal engineering term) and a **graph pass** (the + ghost-specific network holds — links resolve, exactly one root, checks + reference real surfaces, `relates` point at real nodes; later, cross-package + refs). `verify` is absorbed (it *was* the graph pass). `lint` is no longer a + public verb. No separate parent command — `validate` is the parent. A single + `validate ` may short-circuit to the shape pass. `check`/`checks` stay + distinct (public agent checks against generated output — a different concern). + Capability note: the graph pass checks *reference* integrity, not *filesystem + reality* (exemplar paths on disk died with the facet fields, per Option A). +- **`scan`** — today reports facet *contribution* (intent/inventory/composition + counts). Re-aim at node/graph contribution. + +Done when the package model is **manifest + surfaces.yml + nodes/ + checks/** +only, the loader has no facet path, all reads/writes are graph-native, and tests +pass. Legacy facet packages no longer load directly — they must be `migrate`-d +first (Phase 5 made that one command). + +## The load-bearing change: the loader stops parsing facets + +Today `loadFingerprintPackage`: +1. reads intent/inventory/composition/surfaces, +2. `assembleFingerprint(...)` → `GhostFingerprintDocument`, +3. lints it, +4. folds `{ nodeFiles, fingerprint, surfaces }` → graph (fingerprint projected). + +New: +1. reads surfaces + `nodes/*.md`, +2. folds `{ nodeFiles, surfaces }` → graph, +3. lints the **graph** (nodes parse, links resolve, one root). + +`LoadedFingerprintPackage` drops `fingerprint` and `layerRaw`; keeps `manifest`, +`surfaces?`, `graph`. `assembleGraph` drops its `fingerprint` input (projection +gone). This is the moment the in-memory model becomes graph-only. + +### Migration safety + +Legacy facet packages stop loading once the facet parser is gone. That is +acceptable because Phase 5's `migrate` converts them in one command, and the +canonical form is already the node package. **Detect-and-guide:** if the loader +finds `intent.yml`/`inventory.yml`/`composition.yml` and no `nodes/`, fail with +a clear "run `ghost migrate` to convert this legacy package" message rather than +a parse error. (Small, high-value: keeps the cutover humane.) + +## `validate` — shape pass + graph pass + +`validate` is the one hygiene verb. It assembles the package and runs: + +- **shape pass** (internal `lint`): every artifact well-formed on its own — node + frontmatter parses, check frontmatter valid, `surfaces.yml` schema-correct, + `manifest.yml` valid. +- **graph pass** (the old `verify`'s surviving job): the network is correct — + every `under`/`relates` resolves, exactly one root, checks' `surface:` name + real surfaces, no orphan/dangling references. + +One report, both classes of problem, one exit code. The lost capability +(filesystem exemplar-path checking) is gone with the facet fields it operated on +— flag it in the changeset. `verify` and standalone public `lint` are removed. + +## Reconceiving `scan` + +`scan` reports per-facet contribution (intent/inventory/composition counts + +states). Re-aim at the graph: + +- **node contribution**: how many nodes, placed where (surfaces covered), how + many essence vs incarnation-tagged, sparse surfaces (declared but no nodes). +- keep the BYOA next-step guidance, re-pointed at "add nodes for these surfaces." + +`ScanFacet`/`fingerprint-contribution.ts` are rewritten to a node/surface +contribution report. This is the other real build item (not just deletion). + +## Files + +Delete: +- `ghost-core/fingerprint/` (schema, types, lint, index) — the facet model. +- `ghost-core/graph/project-facets.ts` (load-time projection; `migrate` keeps + its own copy of the mapping or imports a shared one — decide while building). +- `ghost-core/surfaces/resolve.ts`, `ground.ts`, `cascade.ts` (dead since + phases 3–4) + their tests (`surfaces-resolve`, `surfaces-ground`). + +Rewrite: +- `scan/fingerprint-package-layers.ts` → node+surfaces loader only. +- `scan/fingerprint-package.ts` → `LoadedFingerprintPackage` drops + `fingerprint`/`layerRaw`. +- `ghost-core/graph/assemble.ts` → drop the `fingerprint` projection input. +- `scan/verify-package.ts` → cross-artifact graph integrity. +- `scan/fingerprint-contribution.ts` → node/surface contribution. +- `scan/file-kind.ts` → drop facet-layer kinds; keep surfaces/check/node/manifest. +- `ghost-core/index.ts`, `fingerprint.ts` → drop facet exports. + +Keep: +- `surfaces/` schema + `buildSurfaceMenu` (the spine is still YAML). +- `node/`, `graph/` (assemble, slice), `check/`. + +## Tests + +- Loader: a node package loads to a graph with no `fingerprint` field; a legacy + facet package fails with the migrate-guidance message. +- `verify`: passes on a clean node package; flags a check referencing a missing + surface / a `relates` to a missing node. +- `scan`: reports node/surface contribution (counts, sparse surfaces) — rewrite + the existing scan assertions. +- Delete facet-model tests (`fingerprint-yml-schema` etc. — confirm which are + pure-facet) and the dead surfaces-resolve/ground tests. +- Migrate Ghost's own dogfood packages / fixtures to nodes (or assert they are + already node packages) so the suite runs without facet packages. + +## Explicitly NOT in Phase 6 + +- Cross-package refs (`@scope/pkg#id`) resolution — next. +- The `surface`→`node` symbol rename. +- Graph-native compare/drift/fleet (parked; their own future effort). +- Re-adding structured evidence/exemplar fields — Option A stands; evidence + lives in prose. + +## Settled decisions + +0. **`emit review-command` is DROPPED.** It is a pre-graph artifact: a frozen + codegen of `.claude/commands/design-review.md` from facet content — a stale + snapshot of what `review --surface` now produces live from the graph. It is + also the heaviest remaining facet consumer (`context/package-context.ts` + + `context/package-review-command.ts`, ~340 lines reading + `fingerprint.intent.summary` / `inventory.building_blocks`). Drop the `emit` + verb and both context modules outright — pure deletion, no port. Clean house. + +1. **One public `validate` verb** = shape pass (internal `lint`) + graph pass + (absorbed `verify`). `lint`/`verify` are not public verbs. `check`/`checks` + stay distinct (agent checks against output). +2. **`projectFacetsToNodes` dies as a load-time bridge.** The facet→node *mapping* + already lives in `migrate-legacy.ts` (`migratedNodeFiles`, Phase 5); delete + the graph copy. Decide while building whether any shared helper is worth + keeping (lean: no, migrate owns it). +3. **Legacy facet package → explicit `ghost migrate` guidance on load** (not a + parse error, not a silent skip). +4. **Test fixtures are updated, not migrated.** Rewrite the fixture helpers to + emit node packages directly (`surfaces.yml` + `nodes/*.md`); delete the + facet-writing helpers. No shelling out to `migrate`; generate node fixtures as + needed. Same for any of the repo's own `.ghost/` packages — regenerate as node + packages. + +## Read-back + +Phase 6 succeeds when the only fingerprint model is the graph (manifest + +surfaces + nodes + checks), the loader folds nodes+surfaces with no facet path, +`verify` and `scan` are reconceived for nodes, the facet schemas/projection/dead +slice+ground are deleted, legacy packages are guided to `migrate`, and the repo's +own packages are node packages. After this, only cross-package resolution and +the symbol rename remain before the graph model is fully consolidated. diff --git a/docs/ideas/phase-7-cross-package.md b/docs/ideas/phase-7-cross-package.md new file mode 100644 index 00000000..b2f03b5e --- /dev/null +++ b/docs/ideas/phase-7-cross-package.md @@ -0,0 +1,195 @@ +--- +status: exploring +--- + +# Phase 7: cross-package resolution + +Seventh build phase. Make `#` refs *resolve* — the last real +feature. This is what lets a shared brand contract be consumed across sibling +packages and repos (Scenarios B and E): a product's `core` node `relates` to +`@acme/brand#core-trust`, and gather/validate follow that link into the +installed brand package. Read `context-graph.md` (the scenarios) and phases 2–6 +first. + +## What already exists (parsed, not resolved) + +- The **ref grammar** accepts `#` and `@scope/pkg#` + (`NodeRefSchema`). So cross-package refs already *validate* in node files. +- `lintGraph` and `resolveGraphSlice` **explicitly skip** `#` refs today + ("later phase"). Nothing resolves them. +- There is **no `consumes`** in the manifest and no second-package loading. + +Phase 7 turns those skips into real resolution. + +## The model: a package `extends` others, by identity + +`extends` is cross-package inheritance — the same idea as the within-package +cascade (`under` inherits downward), now across a file boundary. (Note: `extends` +has precedent — the legacy direct `fingerprint.md` had an `extends:` field; this +reclaims it for the graph model.) + +The load-bearing principle: **reference by identity, never by path.** A package +already declares its identity in `manifest.yml` (`id:`). Cross-package refs carry +that identity; *where* the package lives on disk is resolved in one isolated, +swappable layer — never baked into a ref. This mirrors how the rest of Ghost +already separates "what" from "where" (gather names a node id; the binding death +stopped inferring intent from path). An alias-to-a-dir map would re-couple refs +to the file tree — exactly the trap one-road removed for surfaces. + +There is no separate "consumed dependency" concept: inherited nodes are just +*nodes you inherited*, in the same bucket as cascade. This is what dissolves the +namespacing / direct-addressing / cross-package-parent questions below. + +1. **A package declares what it extends — one `extends` map, key = identity, + value = where (for now):** + + ```yaml + # the brand contract's manifest + id: brand + + # the product contract's manifest + id: acme-checkout + extends: + brand: ../brand/.ghost # key `brand` is the identity refs use; value is location + ``` + + No double bookkeeping: the key is the public identity (`brand:core-trust` + references the *key*, never the path); the value is just where to find it + today. The discovery upgrade makes the value optional (omit → Ghost finds the + package whose manifest `id` matches the key); an explicit value stays a valid + override. So refs and the model never change when discovery lands. + +2. **Refs carry identity, with `:` as the qualifier** (Ghost's own lineage — + old typed refs were `intent.principle:foo`, `validate.check:bar`). A ref is + `:`; a bare `` is local: + + ```yaml + under: brand:core # inherit from the `brand` contract's core node + relates: + - to: brand:core-trust + as: reinforces + ``` + + `brand:core-trust` = "the `core-trust` node in the contract that declares + `id: brand`" — stable across moves, repos, and how it's installed. No path in + any ref. + +3. **Location resolution is the `extends` map value** — the path lives in exactly + one place, never in a ref. v1: explicit `id → dir`. Next: discovery makes the + value optional (match by manifest `id`); upgrading the resolver changes **no + ref**. + +4. **The loader resolves extended packages** into the graph as **read-only + inherited nodes**, keyed by their full ref id (`brand:core-trust`), tagged + `origin: "inherited"`. `under`/`relates` `:` refs resolve against + them. + +Cost to name: package `id`s become the public coordinate, so they must be +**stable and meaningful** (`brand`, not `acme-checkout-9f3`) — the same +discipline node ids already follow. + +## Resolution shape (the loader change) + +`assembleGraph` (or a wrapper) gains inherited-package input: + +``` +loadFingerprintPackage(paths): + manifest, surfaces, own nodes → as today + for each id in manifest.extends: + resolve id → dir (resolution map in v1; discovery later) + load that package (one level — no transitive extends in v1) + verify the loaded package's manifest id matches `id` + key its node ids as `id:`, mark origin: "inherited" + union into the graph (inherited nodes never override local) + lintGraph: now `:` refs must resolve to a loaded inherited node +``` + +Key rules: +- **Inherited nodes are read-only context** — they appear in gather slices + (cascade/relates reach them) but a package never *edits* an inherited node. +- **One level of extends in v1** (no transitive `extends` of extends) — keep it + bounded; revisit if a real need appears. +- **Identity mismatch** (resolved package's `id` ≠ the extended id) is an error. +- **Cycles across packages** are an error (validate catches them). +- **Unresolvable id** → validate/load fails with clear guidance + ("`brand` is extended but no package with that id could be resolved"). + +## What resolves where + +- **validate** (graph pass): `:` refs must now resolve to a loaded + inherited node; an unresolved ref is an error (was skipped). A ref whose + package id isn't in `extends` is a distinct, clearer error. +- **gather**: the slice traverses into inherited nodes via `under`/`relates` + (inherit from an extended brand `core`, or pull a related brand node). + Provenance is marked so the agent knows it's inherited from an extended + contract. +- **checks**: routing is unchanged (checks are local), but grounding slices may + now include inherited nodes — fine, same slice resolver. + +## Files + +- `ghost-core/package-manifest.ts`: add optional `extends` map + (`Record`; value optional once discovery lands) to the schema. +- `ghost-core/node/schema.ts`: change `NodeRefSchema` from `#` / + `@scope/pkg#id` to `:` (both slugs); a bare slug stays + local. +- `scan/` resolver: an `id → dir` resolution step (map for v1), isolated so + discovery can replace it later without touching refs. +- `scan/fingerprint-package.ts` / `-layers.ts`: resolve each extended id, load + the package, verify its manifest id matches, pass inherited nodes to + `assembleGraph`. +- `ghost-core/graph/assemble.ts`: accept `inheritedNodes` (ids already the full + `id:` ref, `origin: "inherited"`), union them in (local wins, inherited + never overrides). +- `ghost-core/graph/lint.ts`: `:` refs resolve against inherited + nodes; add `unresolved-cross-package` / `package-not-extended` / + `extends-identity-mismatch` rules; cross-package cycle detection. +- `ghost-core/graph/slice.ts`: stop skipping qualified refs; resolve + tag + inherited provenance. +- `GhostGraphNode.origin`: add `"inherited"`. + +## Tests + +- An extending package with `extends: { brand: ... }` (resolved to a sibling package + whose manifest is `id: brand`) and a `relates: brand:core-trust` resolves; + gather includes the inherited node tagged inherited. +- `under: brand:core` inherits brand context into the extender. +- Unresolved ref (package id not in `extends`) → validate error. +- Unresolvable extended id (no package found) → load/validate error w/ guidance. +- Identity mismatch (resolved package id ≠ extended id) → validate error. +- Cross-package cycle → validate error. +- A package with no `extends` behaves exactly as today (no regression). + +## Explicitly NOT in Phase 7 + +- Transitive extends (extends-of-extends) — one level in v1. +- Editing inherited nodes / write-back — inherited is read-only. +- The `surface`→`node` symbol rename. +- Versioning/compat checks between extender and extended (a future concern; + Git/npm version the extended package). + +## Settled (the identity framing dissolved the earlier open questions) + +Reference by identity (`:`), resolve location separately, inherited +nodes are *just nodes* — so the prior questions fold away: + +1. **Refs are path-free** (`brand:core-trust`); the one path (if any) lives in + the v1 resolution map, replaceable by discovery without touching refs. +2. **Inherited node ids:** the full ref *is* the id (`brand:core-trust`) — no + separate namespace bucket. +3. **Direct cross-package `gather`:** a ref resolves the same whether local or + `id:node`, so `gather` accepts either; no special addressing mode. (The menu + may still default to local surfaces.) +4. **Cross-package `under`/parent:** a node's `under` may point at `id:node` — it + inherits from a node in the extended contract. One tree; some edges cross a + package boundary. Scenario E's product-tree-under-brand-`core` is the natural + case. + +## Read-back + +Phase 7 succeeds when a package can declare `extends`, `#` refs in +`under`/`relates` resolve to read-only inherited nodes loaded from the extended +package, gather traverses into them with inherited provenance, validate catches +unresolved/un-extended/cyclic cross-package refs, and packages with no `extends` +are unaffected. This delivers the Scenario-B/E shared-brand story: one brand +contract, extended by many products, without copy-paste or merge. diff --git a/install/manifest.json b/install/manifest.json index 40fa991b..38410f00 100644 --- a/install/manifest.json +++ b/install/manifest.json @@ -11,7 +11,6 @@ "references/authoring-scenarios.md", "references/brief.md", "references/capture.md", - "references/compare.md", "references/critique.md", "references/patterns.md", "references/recall.md", diff --git a/packages/ghost/package.json b/packages/ghost/package.json index abf32304..5c1e3185 100644 --- a/packages/ghost/package.json +++ b/packages/ghost/package.json @@ -52,14 +52,6 @@ "import": "./dist/fingerprint.js" }, - "./compare": { - "types": "./dist/compare.d.ts", - "import": "./dist/compare.js" - }, - "./drift": { - "types": "./dist/core/index.d.ts", - "import": "./dist/core/index.js" - }, "./scan": { "types": "./dist/scan/index.d.ts", "import": "./dist/scan/index.js" diff --git a/packages/ghost/src/checks-command.ts b/packages/ghost/src/checks-command.ts index 79d9a9aa..afed5457 100644 --- a/packages/ghost/src/checks-command.ts +++ b/packages/ghost/src/checks-command.ts @@ -1,8 +1,8 @@ import type { CAC } from "cac"; import { - groundSurface, + type GraphSlice, type RoutedCheck, - type SurfaceGrounding, + resolveGraphSlice, selectChecksForSurfaces, } from "#ghost-core"; import { resolveFingerprintPackage } from "./fingerprint.js"; @@ -32,9 +32,13 @@ export function registerChecksCommand(cli: CAC): void { "--package ", "Use this fingerprint package directory (default: ./.ghost)", ) + .option( + "--as ", + "Filter grounding to one incarnation (e.g. email, voice). Essence nodes always pass.", + ) .option( "--no-grounding", - "Omit fingerprint grounding (why / what) and emit only the relevant checks", + "Omit fingerprint grounding (the grounded nodes) and emit only the relevant checks", ) .option("--format ", "Output format: markdown or json", { default: "markdown", @@ -56,17 +60,21 @@ export function registerChecksCommand(cli: CAC): void { // routes + grounds for those surfaces; it does not infer from paths. const touched = parseSurfaceIds(opts.surface); - const routed = selectChecksForSurfaces( - checks, - loaded.surfaces, - touched, - ); + const routed = selectChecksForSurfaces(checks, loaded.graph, touched); + + const incarnation = + typeof opts.as === "string" && opts.as.length > 0 + ? opts.as + : undefined; // grounding defaults on; cac sets opts.grounding=false for --no-grounding. + // Grounding is the gather slice: the prose nodes a finding can cite. const withGrounding = opts.grounding !== false; - const grounding: SurfaceGrounding[] = withGrounding + const grounding: GraphSlice[] = withGrounding ? touched.map((surface) => - groundSurface(loaded.surfaces, loaded.fingerprint, surface), + resolveGraphSlice(loaded.graph, surface, { + ...(incarnation !== undefined ? { incarnation } : {}), + }), ) : []; @@ -103,10 +111,27 @@ export function registerChecksCommand(cli: CAC): void { }); } +const PROVENANCE_RANK = { own: 0, ancestor: 1, edge: 2 } as const; + +function provenanceLabel( + provenance: GraphSlice["nodes"][number]["provenance"], +): string { + switch (provenance.kind) { + case "own": + return "own"; + case "ancestor": + return `from \`${provenance.from}\``; + case "edge": + return provenance.via + ? `${provenance.via} \`${provenance.from}\`` + : `relates \`${provenance.from}\``; + } +} + function formatChecksMarkdown( touched: string[], routed: RoutedCheck[], - grounding: SurfaceGrounding[], + grounding: GraphSlice[], invalid: Array<{ file: string; message: string }>, ): string { const lines = ["# Relevant Checks", ""]; @@ -128,21 +153,21 @@ function formatChecksMarkdown( } } - for (const surface of grounding) { - if (surface.why.length === 0 && surface.what.length === 0) continue; - lines.push("", `## Grounding: \`${surface.surface}\``); - if (surface.why.length > 0) { - lines.push("", "Why:"); - for (const item of surface.why) { - lines.push(`- ${item.statement} (\`${item.ref}\`)`); - } - } - if (surface.what.length > 0) { - lines.push("", "What good looks like:"); - for (const item of surface.what) { - const where = item.path ? ` — \`${item.path}\`` : ""; - lines.push(`- ${item.statement}${where} (\`${item.ref}\`)`); - } + for (const slice of grounding) { + if (slice.nodes.length === 0) continue; + lines.push("", `## Grounding: \`${slice.surface}\``); + const ordered = [...slice.nodes].sort( + (a, b) => + PROVENANCE_RANK[a.provenance.kind] - PROVENANCE_RANK[b.provenance.kind], + ); + for (const node of ordered) { + const tag = node.incarnation ? ` _(as ${node.incarnation})_` : ""; + lines.push( + "", + `### \`${node.id}\` — ${provenanceLabel(node.provenance)}${tag}`, + "", + node.body, + ); } } diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index c5104e26..d2569028 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -7,26 +7,6 @@ import { promisify } from "node:util"; import { cac } from "cac"; import { registerChecksCommand } from "./checks-command.js"; import { formatGhostHelp } from "./command-discovery.js"; -import { loadComparableFingerprint } from "./comparable-fingerprint.js"; -import { - compare, - formatComparison, - formatComparisonJSON, - formatCompositeComparison, - formatCompositeComparisonJSON, - formatTemporalComparison, - formatTemporalComparisonJSON, - readHistory, - readSyncManifest, - runGateCli, -} from "./core/index.js"; -import { registerDriftCommand } from "./drift-command.js"; -import { - registerAckCommand, - registerDivergeCommand, - registerTrackCommand, -} from "./evolution-commands.js"; -import { formatSemanticDiff } from "./fingerprint.js"; import { registerFingerprintCommands } from "./fingerprint-commands.js"; import { registerGatherCommand } from "./gather-command.js"; import { registerMigrateCommand } from "./migrate-command.js"; @@ -45,116 +25,6 @@ export function buildCli(): ReturnType { registerFingerprintCommands(cli); - // --- compare --- - cli - .command( - "compare [...fingerprints]", - "Compare two or more fingerprints or root .ghost bundles. N=2 returns a pairwise delta; N≥3 returns a composite fingerprint.", - ) - .option("--semantic", "Qualitative diff of decisions + palette (N=2 only)") - .option( - "--temporal", - "Add velocity, trajectory, and ack bounds (N=2, reads .ghost/history.jsonl)", - ) - .option( - "--history-dir ", - "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", - ) - .option( - "--gate", - "Reconcile against a sync manifest and emit a structured pass/fail verdict (N=2 only)", - ) - .option( - "--sync ", - "Sync manifest path for --gate (default: ./.ghost-sync.json)", - ) - .option( - "--max-divergence-days ", - "For --gate: flag diverging dimensions older than this many days as uncovered", - ) - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (fingerprints: string[], opts) => { - try { - if (opts.gate) { - await runGateCli({ - fingerprints, - cwd: process.cwd(), - sync: opts.sync, - format: opts.format, - maxDivergenceDays: opts.maxDivergenceDays, - loadFingerprint: loadComparableFingerprint, - compare, - }); - return; - } - - const exprs = await Promise.all( - fingerprints.map((path) => loadComparableFingerprint(path)), - ); - - let history: Awaited> | undefined; - let manifest: Awaited> | null = - null; - if (opts.temporal) { - const historyDir = opts.historyDir ?? process.cwd(); - [history, manifest] = await Promise.all([ - readHistory(historyDir), - readSyncManifest(historyDir), - ]); - } - - const result = compare(exprs, { - semantic: Boolean(opts.semantic), - history, - manifest, - }); - - const isJson = opts.format === "json"; - - if (result.mode === "composite") { - const output = isJson - ? formatCompositeComparisonJSON(result.composite) - : formatCompositeComparison(result.composite); - process.stdout.write(`${output}\n`); - process.exit(0); - } - - if (result.semantic) { - if (isJson) { - process.stdout.write( - `${JSON.stringify(result.semantic, null, 2)}\n`, - ); - } else { - process.stdout.write(formatSemanticDiff(result.semantic)); - } - process.exit(result.semantic.unchanged ? 0 : 1); - } - - if (result.temporal) { - const output = isJson - ? formatTemporalComparisonJSON(result.temporal) - : formatTemporalComparison(result.temporal); - process.stdout.write(`${output}\n`); - process.exit(result.temporal.distance > 0.5 ? 1 : 0); - } - - const output = isJson - ? formatComparisonJSON(result.comparison) - : formatComparison(result.comparison); - process.stdout.write(`${output}\n`); - process.exit(result.comparison.distance > 0.5 ? 1 : 0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); - - registerAckCommand(cli); - registerTrackCommand(cli); - registerDivergeCommand(cli); - registerDriftCommand(cli); registerGatherCommand(cli); registerChecksCommand(cli); registerMigrateCommand(cli); diff --git a/packages/ghost/src/command-discovery.ts b/packages/ghost/src/command-discovery.ts index 45dec3ac..d91306f0 100644 --- a/packages/ghost/src/command-discovery.ts +++ b/packages/ghost/src/command-discovery.ts @@ -5,11 +5,7 @@ type HelpSection = { body: string; }; -export type CommandDiscoveryGroup = - | "core" - | "advanced" - | "compare" - | "maintenance"; +export type CommandDiscoveryGroup = "core" | "advanced" | "maintenance"; export type CommandDiscoveryMetadata = { name: string; @@ -26,7 +22,6 @@ const GROUPS: ReadonlyArray<{ }> = [ { group: "core", title: "Core workflow" }, { group: "advanced", title: "Advanced/package inspection" }, - { group: "compare", title: "Compare/stance" }, { group: "maintenance", title: "Maintenance/legacy" }, ]; @@ -46,18 +41,11 @@ const COMMAND_DISCOVERY = [ summary: "Report fingerprint contribution facets.", }, { - name: "lint", + name: "validate", group: "core", defaultHelp: true, - compactName: "lint", - summary: "Validate a fingerprint package or artifact.", - }, - { - name: "verify", - group: "core", - defaultHelp: true, - compactName: "verify", - summary: "Verify evidence, exemplar paths, and typed refs.", + compactName: "validate", + summary: "Validate the fingerprint: artifact shape + the node graph.", }, { name: "check", @@ -87,13 +75,6 @@ const COMMAND_DISCOVERY = [ compactName: "checks", summary: "Select and ground the checks relevant to a diff, by surface.", }, - { - name: "emit", - group: "core", - defaultHelp: true, - compactName: "emit", - summary: "Emit review-command artifacts.", - }, { name: "skill", group: "core", @@ -108,41 +89,6 @@ const COMMAND_DISCOVERY = [ compactName: "signals", summary: "Emit raw repo signals for fingerprint authoring.", }, - { - name: "compare", - group: "compare", - defaultHelp: false, - compactName: "compare", - summary: "Compare fingerprint packages.", - }, - { - name: "drift", - group: "compare", - defaultHelp: false, - compactName: "drift check", - summary: "Run the continuous design-loop drift check.", - }, - { - name: "ack", - group: "compare", - defaultHelp: false, - compactName: "ack", - summary: "Record stance toward tracked drift.", - }, - { - name: "track", - group: "compare", - defaultHelp: false, - compactName: "track", - summary: "Shift the tracked reference fingerprint.", - }, - { - name: "diverge", - group: "compare", - defaultHelp: false, - compactName: "diverge", - summary: "Declare intentional divergence on a dimension.", - }, { name: "migrate", group: "maintenance", diff --git a/packages/ghost/src/comparable-fingerprint.ts b/packages/ghost/src/comparable-fingerprint.ts deleted file mode 100644 index 4b72bc08..00000000 --- a/packages/ghost/src/comparable-fingerprint.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { parse as parseYaml } from "yaml"; -import { - computeEmbedding, - type DesignDecision, - type Fingerprint, - type GhostFingerprintDocument, - type GhostFingerprintPackageManifest, - type GhostPatternsDocument, - type Survey, -} from "#ghost-core"; -import { - loadFingerprint, - loadFingerprintPackage, - resolveFingerprintPackage, -} from "./fingerprint.js"; - -const PACKAGE_DECISION_EMBEDDING_SIZE = 64; - -export async function loadComparableFingerprint( - path: string, -): Promise { - const target = resolve(process.cwd(), path); - if (target.endsWith(".md")) { - return (await loadFingerprint(target)).fingerprint; - } - - const paths = resolveFingerprintPackage( - normalizeFingerprintPackageInput(path), - process.cwd(), - ); - if (existsSync(paths.manifest)) { - const { manifest, fingerprint } = await loadFingerprintPackage(paths); - return synthesizeFingerprintFromPackage(paths.dir, manifest, fingerprint); - } - - if (target === paths.dir && existsSync(paths.fingerprint)) { - return (await loadFingerprint(paths.fingerprint)).fingerprint; - } - - try { - const [surveyRaw, patternsRaw] = await Promise.all([ - readFile(paths.survey, "utf-8"), - readFile(paths.patterns, "utf-8"), - ]); - return synthesizeFingerprintFromBundle( - paths.dir, - JSON.parse(surveyRaw) as Survey, - parseYaml(patternsRaw) as GhostPatternsDocument, - ); - } catch { - return (await loadFingerprint(target)).fingerprint; - } -} - -function normalizeFingerprintPackageInput(path: string): string { - const normalized = path.replace(/\\/g, "/"); - return /(^|\/)manifest\.ya?ml$/i.test(normalized) - ? dirname(normalized) - : path; -} - -function synthesizeFingerprintFromPackage( - path: string, - manifest: GhostFingerprintPackageManifest, - document: GhostFingerprintDocument, -): Fingerprint { - const decisions: DesignDecision[] = [ - ...packageDigestDecisions(document), - { - dimension: "summary", - dimension_kind: "experience-summary", - decision: compactJoin([ - document.intent.summary.product, - ...(document.intent.summary.audience ?? []), - ...(document.intent.summary.goals ?? []), - ...(document.intent.summary.anti_goals ?? []), - ...(document.intent.summary.tradeoffs ?? []), - ...(document.intent.summary.tone ?? []), - ]), - evidence: [], - }, - ...document.intent.situations.map((situation) => ({ - dimension: situation.id, - dimension_kind: "experience-situation", - decision: compactJoin([ - situation.title, - situation.user_intent, - situation.product_obligation, - ]), - evidence: evidenceStrings(situation.evidence), - })), - ...document.intent.principles.map((principle) => ({ - dimension: principle.id, - dimension_kind: "experience-principle", - decision: principle.principle, - evidence: evidenceStrings(principle.evidence), - })), - ...document.intent.experience_contracts.map((contract) => ({ - dimension: contract.id, - dimension_kind: "experience-contract", - decision: contract.contract, - evidence: evidenceStrings(contract.evidence), - })), - ...document.composition.patterns.map((pattern) => ({ - dimension: pattern.id, - dimension_kind: `composition-${pattern.kind}`, - decision: pattern.pattern, - evidence: evidenceStrings(pattern.evidence), - })), - ...buildingBlockDecisions(document), - ...document.inventory.exemplars.map((exemplar) => ({ - dimension: exemplar.id, - dimension_kind: "inventory-exemplar", - decision: compactJoin([ - exemplar.title, - exemplar.surface, - exemplar.note, - exemplar.why, - exemplar.path, - ]), - evidence: [exemplar.path], - })), - ].map((decision) => ({ - ...decision, - embedding: deterministicTextEmbedding( - `${decision.dimension} ${decision.dimension_kind ?? ""} ${decision.decision}`, - ), - })); - - const fingerprint: Fingerprint = { - id: manifest.id, - source: "extraction", - timestamp: new Date(0).toISOString(), - sources: [path], - observation: { - summary: document.intent.summary.product ?? manifest.id, - personality: document.intent.summary.tone ?? [], - resembles: document.inventory.building_blocks.libraries ?? [], - }, - decisions, - palette: { - dominant: [], - neutrals: { steps: [], count: 0 }, - semantic: [], - saturationProfile: "mixed", - contrast: "moderate", - }, - spacing: { - scale: [], - regularity: 0, - baseUnit: null, - }, - typography: { - families: [], - sizeRamp: [], - weightDistribution: {}, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: [], - shadowComplexity: "deliberate-none", - borderUsage: "minimal", - }, - embedding: [], - }; - fingerprint.embedding = computeEmbedding(fingerprint); - return fingerprint; -} - -function compactJoin(values: Array): string { - const joined = values - .filter((value): value is string => Boolean(value)) - .join(" — "); - return joined || "No situation decision recorded."; -} - -function evidenceStrings( - evidence: GhostFingerprintDocument["intent"]["principles"][number]["evidence"], -): string[] { - return ( - evidence?.map((entry) => entry.locator ?? entry.path ?? entry.note ?? "") ?? - [] - ).filter(Boolean); -} - -function packageDigestDecisions( - document: GhostFingerprintDocument, -): DesignDecision[] { - const digest = stableHash( - JSON.stringify({ - intent: document.intent, - inventory: document.inventory, - composition: document.composition, - }), - ).toString(16); - return Array.from({ length: 16 }, (_, index) => { - const token = `pkgdigest-${index + 1}-${digest}`; - return { - dimension: token, - dimension_kind: "package-digest", - decision: `${token} ${token} ${token} ${token}`, - evidence: [], - }; - }); -} - -function buildingBlockDecisions( - document: GhostFingerprintDocument, -): DesignDecision[] { - const blocks = document.inventory.building_blocks; - return [ - ["tokens", blocks.tokens], - ["components", blocks.components], - ["libraries", blocks.libraries], - ["assets", blocks.assets], - ["routes", blocks.routes], - ["files", blocks.files], - ["notes", blocks.notes], - ].flatMap(([dimension, values]) => { - if (!Array.isArray(values) || values.length === 0) return []; - return [ - { - dimension: `building-blocks-${dimension}`, - dimension_kind: "inventory-building-blocks", - decision: values.join(", "), - evidence: [], - }, - ]; - }); -} - -function deterministicTextEmbedding(text: string): number[] { - const vector = new Array(PACKAGE_DECISION_EMBEDDING_SIZE).fill(0); - const tokens = text.toLowerCase().match(/[a-z0-9_-]+/g) ?? []; - for (const token of tokens) { - vector[stableHash(token) % PACKAGE_DECISION_EMBEDDING_SIZE] += 1; - } - const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)); - if (norm === 0) return vector; - return vector.map((value) => value / norm); -} - -function stableHash(value: string): number { - let hash = 2166136261; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; -} - -function synthesizeFingerprintFromBundle( - path: string, - survey: Survey, - patterns: GhostPatternsDocument, -): Fingerprint { - const colors = survey.values - .filter((row) => row.kind === "color") - .slice() - .sort((a, b) => b.occurrences - a.occurrences) - .slice(0, 8) - .map((row, index) => ({ - role: row.role_hypothesis ?? `color-${index + 1}`, - value: row.value, - })); - const spacingScale = survey.values - .filter((row) => row.kind === "spacing") - .map((row) => scalarValue(row.value)) - .filter((value): value is number => value !== null) - .sort((a, b) => a - b); - const typographySizes = survey.values - .filter((row) => row.kind === "typography") - .map((row) => scalarValue(row.value)) - .filter((value): value is number => value !== null) - .sort((a, b) => a - b); - const radii = survey.values - .filter((row) => row.kind === "radius") - .map((row) => scalarValue(row.value)) - .filter((value): value is number => value !== null) - .sort((a, b) => a - b); - - const fingerprint: Fingerprint = { - id: patterns.id, - source: "extraction", - timestamp: survey.sources[0]?.scanned_at ?? new Date(0).toISOString(), - sources: [path], - observation: { - summary: `Root Ghost bundle synthesized from ${survey.ui_surfaces.length} surveyed surfaces and ${patterns.composition_patterns.length} composition patterns.`, - personality: [], - resembles: [], - }, - decisions: patterns.composition_patterns.map((pattern) => ({ - dimension: pattern.id, - dimension_kind: "composition-patterns", - decision: pattern.intent ?? pattern.title ?? pattern.id, - evidence: - pattern.evidence?.map( - (entry) => - entry.locator ?? entry.path ?? entry.surface_id ?? pattern.id, - ) ?? [], - })), - palette: { - dominant: colors, - neutrals: { steps: [], count: 0 }, - semantic: [], - saturationProfile: "mixed", - contrast: "moderate", - }, - spacing: { - scale: uniqueNumbers(spacingScale), - regularity: spacingScale.length > 0 ? 1 : 0, - baseUnit: spacingScale[0] ?? null, - }, - typography: { - families: [], - sizeRamp: uniqueNumbers(typographySizes), - weightDistribution: {}, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: uniqueNumbers(radii), - shadowComplexity: survey.values.some((row) => row.kind === "shadow") - ? "subtle" - : "deliberate-none", - borderUsage: "minimal", - }, - embedding: [], - }; - fingerprint.embedding = computeEmbedding(fingerprint); - return fingerprint; -} - -function scalarValue(value: string): number | null { - const match = value.match(/-?\d+(?:\.\d+)?/); - return match ? Number(match[0]) : null; -} - -function uniqueNumbers(values: number[]): number[] { - return [...new Set(values)]; -} diff --git a/packages/ghost/src/compare.ts b/packages/ghost/src/compare.ts deleted file mode 100644 index 865b2816..00000000 --- a/packages/ghost/src/compare.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type { - CompareOptions, - CompareResult, - CompositeComparison, - EnrichedComparison, - FingerprintComparison, - TemporalComparison, -} from "./core/index.js"; -export { - compare, - compareFingerprints, - formatComparison, - formatComparisonJSON, - formatCompositeComparison, - formatCompositeComparisonJSON, - formatTemporalComparison, - formatTemporalComparisonJSON, -} from "./core/index.js"; diff --git a/packages/ghost/src/context/package-context.ts b/packages/ghost/src/context/package-context.ts deleted file mode 100644 index 8f0058db..00000000 --- a/packages/ghost/src/context/package-context.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { parse as parseYaml } from "yaml"; -import type { GhostFingerprintDocument } from "#ghost-core"; -import { readOptionalUtf8 } from "../internal/fs.js"; -import { - type FingerprintPackagePaths, - loadFingerprintPackage, -} from "../scan/fingerprint-package.js"; - -export interface PackageContext { - name: string; - packageDir?: string; - targetPaths?: string[]; - stackDirs?: string[]; - fingerprint: GhostFingerprintDocument; - fingerprintRaw: string; - fingerprintLayers?: { - manifest: string; - intent?: string; - inventory?: string; - composition?: string; - }; -} - -export async function loadPackageContext( - paths: FingerprintPackagePaths, - nameOverride?: string, -): Promise { - const loaded = await loadFingerprintPackage(paths); - - const fingerprint = loaded.fingerprint; - return { - name: sanitizeName(nameOverride ?? inferPackageName(fingerprint)), - packageDir: paths.dir, - fingerprint, - fingerprintRaw: JSON.stringify(fingerprint, null, 2), - fingerprintLayers: { - manifest: loaded.manifestRaw, - ...loaded.layerRaw, - }, - }; -} - -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) - }`, - ); - } -} - -const _readOptional = readOptionalUtf8; - -function inferPackageName(fingerprint: GhostFingerprintDocument): string { - if (fingerprint.intent.summary.product) - return fingerprint.intent.summary.product; - return "ghost-package"; -} - -function sanitizeName(value: string): string { - const name = value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - return name || "ghost-package"; -} diff --git a/packages/ghost/src/context/package-review-command.ts b/packages/ghost/src/context/package-review-command.ts deleted file mode 100644 index 869754e4..00000000 --- a/packages/ghost/src/context/package-review-command.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { isAbsolute, relative } from "node:path"; -import type { - GhostFingerprintExemplar, - GhostFingerprintExperienceContract, - GhostFingerprintPattern, - GhostFingerprintPrinciple, - GhostFingerprintSituation, -} from "#ghost-core"; -import type { PackageContext } from "./package-context.js"; - -export interface EmitPackageReviewInput { - context: PackageContext; -} - -const REVIEW_FINDING_CATEGORIES = [ - "fix", - "intentional-divergence", - "missing-fingerprint", - "experience-gap", - "eval-uncertainty", -] as const; - -/** - * Emit a repo-local slash command from split fingerprint intent/inventory/composition. - * - * The command stays intentionally light: it tells the host agent which Ghost - * files and CLI packets to use, then includes a compact fingerprint index. - * Full canonical truth remains in facet files and deterministic checks. - */ -export function emitPackageReviewCommand( - input: EmitPackageReviewInput, -): string { - const { context } = input; - const product = context.fingerprint.intent.summary.product ?? context.name; - const heading = - product.toLowerCase() === "ghost" - ? "# Ghost review" - : `# ${product} Ghost review`; - const parts = [ - packageFrontmatter(product), - heading, - packageModeSection(), - packageWorkflowSection(context), - packageFindingPolicySection(), - packageFingerprintIndex(context), - packageReviewFooter(context), - ]; - return `${parts.filter(Boolean).join("\n\n").trim()}\n`; -} - -function packageFrontmatter(product: string): string { - return `--- -description: Ghost surface-composition review for ${product} - grounded in fingerprint facets ----`; -} - -function packageModeSection(): string { - return `## Mode - -If \`$ARGUMENTS\` is provided, review that file, path, or diff range. If it is empty, inspect the current working-tree or PR diff first, then choose the relevant changed surfaces.`; -} - -function packageWorkflowSection(context: PackageContext): string { - const packageDir = displayPackageDir(context); - return `## Review Workflow - -1. Run \`ghost review --diff \` for the advisory packet, or \`ghost checks --diff \` for the routed checks and grounding. If reviewing manually, read \`${packageDir}/intent.yml\`, \`${packageDir}/inventory.yml\`, and \`${packageDir}/composition.yml\`. -2. Start from the touched surfaces' intent and obligations before assessing UI, copy, flow, disclosure, recovery, trust, or interaction behavior. -3. Apply composition guidance before choosing implementation details. -4. Inspect inventory exemplars and building blocks as evidence/material, not as authority over intent. -5. Evaluate the routed \`ghost.check/v1\` markdown checks against the diff; cite the surface they govern. -6. When a surface's grounding is silent, label provisional reasoning or report \`missing-fingerprint\` / \`experience-gap\`. -7. Cite the diff location, the touched surface, grounding refs, and the routed check when a finding blocks.`; -} - -function packageFindingPolicySection(): string { - return `## Finding Policy - -Use these categories: ${REVIEW_FINDING_CATEGORIES.map((category) => `\`${category}\``).join(", ")}. - -Only findings backed by a routed check should be treated as blocking. Everything else is advisory surface-composition critique. - -Review only what fingerprint facets or routed checks make relevant to the product surface. - -When fingerprint facets are silent, local evidence can still support advisory critique. Label those findings as provisional and non-Ghost-backed, and ground them in nearby product surfaces, local components, or token and copy conventions. Ask the human before assessing high-risk, irreversible, privacy/security/legal, or product-surface-defining choices. - -If the diff reveals missing fingerprint grounding or facet coverage, report \`missing-fingerprint\` or \`experience-gap\` as a review finding. Do not silently rewrite the Ghost package during review; fingerprint edits are ordinary edits that go through normal Git review.`; -} - -function packageFingerprintIndex(context: PackageContext): string { - const { fingerprint } = context; - const summary = formatSummary(context); - const situations = formatSituations(fingerprint.intent.situations); - const principles = formatPrinciples(fingerprint.intent.principles); - const contracts = formatExperienceContracts( - fingerprint.intent.experience_contracts, - ); - const exemplars = formatExemplars(fingerprint.inventory.exemplars); - const buildingBlocks = formatBuildingBlocks(context); - const patterns = formatPatterns(fingerprint.composition.patterns); - - return `## Fingerprint Index - -${summary} - -${situations} - -${principles} - -${contracts} - -${exemplars} - -${buildingBlocks} - -${patterns}`; -} - -function formatSummary(context: PackageContext): string { - const { summary } = context.fingerprint.intent; - const lines = ["### Summary"]; - lines.push(`- Product: ${summary.product ?? context.name}`); - pushJoined(lines, "Audience", summary.audience); - pushJoined(lines, "Goals", summary.goals); - pushJoined(lines, "Anti-goals", summary.anti_goals); - pushJoined(lines, "Tradeoffs", summary.tradeoffs); - pushJoined(lines, "Tone", summary.tone); - return lines.join("\n"); -} - -function formatSituations(situations: GhostFingerprintSituation[]): string { - if (situations.length === 0) { - return "### Situations\n- No situations recorded yet. Treat unclear obligations as `missing-fingerprint`."; - } - const lines = ["### Situations"]; - for (const situation of situations.slice(0, 8)) { - const label = situation.title ?? situation.id; - const detail = - situation.product_obligation ?? - situation.user_intent ?? - situation.surface ?? - "select when relevant"; - lines.push(`- \`${situation.id}\` - ${label}: ${detail}`); - } - return lines.join("\n"); -} - -function formatPrinciples(principles: GhostFingerprintPrinciple[]): string { - if (principles.length === 0) { - return "### Principles\n- No principles recorded yet."; - } - const lines = ["### Principles"]; - for (const principle of principles.slice(0, 10)) { - lines.push(`- \`${principle.id}\` - ${principle.principle}`); - for (const guidance of principle.guidance ?? []) { - lines.push(` - ${guidance}`); - } - } - return lines.join("\n"); -} - -function formatExperienceContracts( - contracts: GhostFingerprintExperienceContract[], -): string { - if (contracts.length === 0) { - return "### Experience Contracts\n- No experience contracts recorded yet."; - } - const lines = ["### Experience Contracts"]; - for (const contract of contracts.slice(0, 10)) { - lines.push(`- \`${contract.id}\` - ${contract.contract}`); - for (const obligation of contract.obligations ?? []) { - lines.push(` - ${obligation}`); - } - } - return lines.join("\n"); -} - -function formatPatterns(patterns: GhostFingerprintPattern[]): string { - if (patterns.length === 0) { - return "### Composition Patterns\n- No composition patterns recorded yet."; - } - const lines = ["### Composition Patterns"]; - for (const pattern of patterns.slice(0, 12)) { - lines.push(`- \`${pattern.id}\` (${pattern.kind}) - ${pattern.pattern}`); - for (const guidance of pattern.guidance ?? []) { - lines.push(` - ${guidance}`); - } - } - return lines.join("\n"); -} - -function formatBuildingBlocks(context: PackageContext): string { - const { building_blocks: blocks } = context.fingerprint.inventory; - const lines = ["### Inventory Building Blocks"]; - lines.push( - "- Use these as replaceable implementation material, not surface-composition authority.", - ); - pushJoined(lines, "Tokens", blocks.tokens, { code: true }); - pushJoined(lines, "Components", blocks.components, { code: true }); - pushJoined(lines, "Libraries", blocks.libraries, { code: true }); - pushJoined(lines, "Assets", blocks.assets, { code: true }); - pushJoined(lines, "Routes", blocks.routes, { code: true }); - pushJoined(lines, "Files", blocks.files, { code: true }); - pushJoined(lines, "Notes", blocks.notes); - if (lines.length === 2) { - lines.push("- No inventory building blocks recorded yet."); - } - return lines.join("\n"); -} - -function formatExemplars(exemplars: GhostFingerprintExemplar[]): string { - if (exemplars.length === 0) { - return "### Exemplars\n- No curated exemplars recorded yet."; - } - const lines = ["### Exemplars"]; - for (const exemplar of exemplars.slice(0, 12)) { - const detail = exemplar.title ?? exemplar.note ?? exemplar.surface; - lines.push( - `- \`${exemplar.id}\` - \`${exemplar.path}\`${detail ? `: ${detail}` : ""}`, - ); - if (exemplar.why) lines.push(` - Why: ${exemplar.why}`); - } - if (exemplars.length > 12) { - lines.push( - `- ${exemplars.length - 12} more exemplar(s); inspect \`inventory.yml\` before deciding.`, - ); - } - return lines.join("\n"); -} - -function packageReviewFooter(context: PackageContext): string { - const packageDir = displayPackageDir(context); - return `--- - -Generated from \`${packageDir}/\` for ${context.name}. Re-run \`ghost emit review-command\` after updating fingerprint facets or surface checks.`; -} - -function displayPackageDir(context: PackageContext): string { - return displayPath(context.packageDir ?? ".ghost"); -} - -function displayPath(path: string): string { - if (!isAbsolute(path)) return path; - const relativePath = relative(process.cwd(), path); - if (!relativePath) return "."; - if ( - relativePath === ".." || - relativePath.startsWith("../") || - relativePath.startsWith("..\\") - ) { - return normalizePath(path); - } - return normalizePath(relativePath); -} - -function normalizePath(path: string): string { - return path.replace(/\\/g, "/"); -} - -function pushJoined( - lines: string[], - label: string, - values: string[] | undefined, - options: { code?: boolean } = {}, -): void { - if (!values?.length) return; - const formatted = values.map((value) => - options.code ? `\`${value}\`` : value, - ); - lines.push(`- ${label}: ${formatted.join(", ")}`); -} diff --git a/packages/ghost/src/core/compare.ts b/packages/ghost/src/core/compare.ts deleted file mode 100644 index d507b01e..00000000 --- a/packages/ghost/src/core/compare.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { - CompositeComparison, - CompositeMember, - Fingerprint, - FingerprintComparison, - FingerprintHistoryEntry, - SyncManifest, - TemporalComparison, -} from "#ghost-core"; -import { compareFingerprints } from "#ghost-core"; -import type { SemanticDiff } from "../scan/diff.js"; -import { diffFingerprints } from "../scan/diff.js"; -import { compareComposite } from "./evolution/composite.js"; -import { computeTemporalComparison } from "./evolution/temporal.js"; - -export interface CompareOptions { - /** Include a qualitative semantic diff. N=2 only. */ - semantic?: boolean; - /** Enrich with drift velocity, trajectory, ack status. N=2 only. */ - history?: FingerprintHistoryEntry[]; - /** Companion to `history` — the ack manifest, if any. */ - manifest?: SyncManifest | null; - /** Explicit member ids for composite mode. Defaults to `fingerprint.id`. */ - ids?: string[]; -} - -export type CompareResult = - | { - mode: "pairwise"; - comparison: FingerprintComparison; - semantic?: SemanticDiff; - temporal?: TemporalComparison; - } - | { - mode: "composite"; - composite: CompositeComparison; - }; - -/** - * Unified fingerprint comparison. - * - * • N=2 → pairwise (distance + per-dimension delta). - * • N=2 + semantic → adds a qualitative diff (what decisions/colors changed). - * • N=2 + history → adds velocity, trajectory, ack bounds. - * • N≥3 → composite (pairwise matrix, centroid, spread, clusters). - * - * Rejects semantic/temporal in composite mode — both are pairwise concepts. - */ -export function compare( - fingerprints: Fingerprint[], - options: CompareOptions = {}, -): CompareResult { - if (fingerprints.length < 2) { - throw new Error("compare requires at least 2 fingerprints."); - } - - if (fingerprints.length >= 3) { - if (options.semantic || options.history) { - throw new Error( - "semantic and temporal require exactly 2 fingerprints (pairwise mode).", - ); - } - const ids = options.ids; - const members: CompositeMember[] = fingerprints.map((fingerprint, i) => ({ - id: ids?.[i] ?? fingerprint.id, - fingerprint, - })); - return { - mode: "composite", - composite: compareComposite(members, { cluster: true }), - }; - } - - const [a, b] = fingerprints; - const comparison = compareFingerprints(a, b); - - const semantic = options.semantic ? diffFingerprints(a, b) : undefined; - const temporal = - options.history !== undefined - ? computeTemporalComparison({ - comparison, - history: options.history, - manifest: options.manifest ?? null, - }) - : undefined; - - return { - mode: "pairwise", - comparison, - ...(semantic ? { semantic } : {}), - ...(temporal ? { temporal } : {}), - }; -} diff --git a/packages/ghost/src/core/config.ts b/packages/ghost/src/core/config.ts deleted file mode 100644 index f8b5239e..00000000 --- a/packages/ghost/src/core/config.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { createJiti } from "jiti"; -import type { GhostConfig, Target } from "#ghost-core"; -import { resolveTarget } from "#ghost-core"; - -export { resolveTarget }; - -const CONFIG_FILES = ["ghost.config.ts", "ghost.config.js", "ghost.config.mjs"]; - -const DEFAULT_CONFIG: GhostConfig = { - rules: { - "hardcoded-color": "error", - "token-override": "warn", - "missing-token": "warn", - "structural-divergence": "error", - "missing-component": "warn", - }, - ignore: [], -}; - -export function defineConfig(config: GhostConfig): GhostConfig { - return config; -} - -interface LoadConfigOptions { - configPath?: string; - cwd?: string; -} - -async function resolveConfigFile( - configPath: string | undefined, - cwd: string, -): Promise { - if (configPath) { - const resolved = resolve(cwd, configPath); - if (!existsSync(resolved)) { - throw new Error(`Config file not found: ${resolved}`); - } - return resolved; - } - - for (const file of CONFIG_FILES) { - const candidate = resolve(cwd, file); - if (existsSync(candidate)) return candidate; - } - - // Config is optional — return null if not found - return null; -} - -function normalizeTracked( - cwd: string, - value: Target | string | undefined, -): Target | undefined { - if (!value) return undefined; - if (typeof value === "string") { - if (existsSync(resolve(cwd, value))) { - return { type: "path", value }; - } - return resolveTarget(value); - } - return value; -} - -function mergeDefaults(raw: GhostConfig, cwd: string): GhostConfig { - return { - targets: raw.targets, - tracks: normalizeTracked(cwd, raw.tracks as Target | string | undefined), - rules: { ...DEFAULT_CONFIG.rules, ...raw.rules }, - ignore: raw.ignore ?? DEFAULT_CONFIG.ignore, - embedding: raw.embedding, - extractors: raw.extractors, - }; -} - -/** - * Load the ghost config file. Returns defaults if no config file exists. - */ -export async function loadConfig( - configPathOrOptions?: string | LoadConfigOptions, - cwd: string = process.cwd(), -): Promise { - let configPath: string | undefined; - - if (typeof configPathOrOptions === "object") { - configPath = configPathOrOptions.configPath; - cwd = configPathOrOptions.cwd ?? cwd; - } else { - configPath = configPathOrOptions; - } - - const resolvedPath = await resolveConfigFile(configPath, cwd); - - if (!resolvedPath) { - // No config file found — return defaults (zero-config mode) - return { ...DEFAULT_CONFIG }; - } - - const jiti = createJiti(resolvedPath); - const mod = await jiti.import(resolvedPath); - const raw = - (mod as { default?: GhostConfig }).default ?? (mod as GhostConfig); - - return mergeDefaults(raw, cwd); -} diff --git a/packages/ghost/src/core/evolution/composite.ts b/packages/ghost/src/core/evolution/composite.ts deleted file mode 100644 index a84f6dc2..00000000 --- a/packages/ghost/src/core/evolution/composite.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { - CompositeCluster, - CompositeComparison, - CompositeMember, - CompositePair, -} from "#ghost-core"; -import { compareFingerprints, embeddingDistance } from "#ghost-core"; - -export interface CompositeClusterOptions { - cluster?: boolean | { maxK?: number }; -} - -/** - * Compare N fingerprints as a composite (org-scale) view. - * Computes pairwise distances, centroid, spread, and optional clusters. - */ -export function compareComposite( - members: CompositeMember[], - options?: CompositeClusterOptions, -): CompositeComparison { - const pairwise = computePairwise(members); - const centroid = computeCentroid(members); - const spread = computeSpread(members, centroid); - - const result: CompositeComparison = { - members, - pairwise, - centroid, - spread, - }; - - const shouldCluster = - options?.cluster === true || - (typeof options?.cluster === "object" && options.cluster); - if (shouldCluster && members.length >= 3) { - const maxK = - typeof options?.cluster === "object" ? options.cluster.maxK : undefined; - result.clusters = clusterMembers(members, maxK); - } - - return result; -} - -/** - * Compute pairwise distances between all composite members. - */ -function computePairwise(members: CompositeMember[]): CompositePair[] { - const pairs: CompositePair[] = []; - - for (let i = 0; i < members.length; i++) { - for (let j = i + 1; j < members.length; j++) { - const a = members[i]; - const b = members[j]; - const comparison = compareFingerprints(a.fingerprint, b.fingerprint); - - const dimensions: Record = {}; - for (const [key, delta] of Object.entries(comparison.dimensions)) { - dimensions[key] = delta.distance; - } - - pairs.push({ - a: a.id, - b: b.id, - distance: comparison.distance, - dimensions, - }); - } - } - - return pairs.sort((a, b) => a.distance - b.distance); -} - -/** - * Compute the centroid (average embedding) of all composite members. - */ -function computeCentroid(members: CompositeMember[]): number[] { - if (members.length === 0) return []; - - const dim = members[0].fingerprint.embedding.length; - const centroid = new Array(dim).fill(0); - - for (const member of members) { - for (let i = 0; i < dim; i++) { - centroid[i] += member.fingerprint.embedding[i] ?? 0; - } - } - - for (let i = 0; i < dim; i++) { - centroid[i] /= members.length; - } - - return centroid; -} - -/** - * Compute the spread (average embedding distance from centroid). - */ -function computeSpread(members: CompositeMember[], centroid: number[]): number { - if (members.length === 0) return 0; - - let totalDistance = 0; - for (const member of members) { - totalDistance += embeddingDistance(member.fingerprint.embedding, centroid); - } - - return totalDistance / members.length; -} - -/** - * K-means++ initialization: select initial centroids with probability - * proportional to squared distance from nearest existing centroid. - */ -function kmeansppInit(embeddings: number[][], k: number): number[][] { - const centroids: number[][] = []; - - // First centroid: pick randomly (deterministically use first for reproducibility) - centroids.push([...embeddings[0]]); - - for (let c = 1; c < k; c++) { - // Compute squared distances to nearest centroid for each point - const distances = embeddings.map((emb) => { - let minDist = Infinity; - for (const centroid of centroids) { - const dist = embeddingDistance(emb, centroid); - minDist = Math.min(minDist, dist * dist); - } - return minDist; - }); - - // Pick the point with maximum distance (deterministic version of weighted random) - let maxDist = -1; - let maxIdx = 0; - for (let i = 0; i < distances.length; i++) { - if (distances[i] > maxDist) { - maxDist = distances[i]; - maxIdx = i; - } - } - centroids.push([...embeddings[maxIdx]]); - } - - return centroids; -} - -/** - * Compute within-cluster sum of squared distances (WCSS). - */ -function computeWCSS( - embeddings: number[][], - assignments: number[], - centroids: number[][], -): number { - let wcss = 0; - for (let i = 0; i < embeddings.length; i++) { - const dist = embeddingDistance(embeddings[i], centroids[assignments[i]]); - wcss += dist * dist; - } - return wcss; -} - -/** - * Run k-means with iterative refinement. - * Returns cluster assignments and final centroids. - */ -function runKMeans( - embeddings: number[][], - k: number, - maxIterations: number = 10, -): { assignments: number[]; centroids: number[][] } { - const dim = embeddings[0].length; - let centroids = kmeansppInit(embeddings, k); - let assignments = new Array(embeddings.length).fill(0); - - for (let iter = 0; iter < maxIterations; iter++) { - // Assignment step: assign each point to nearest centroid - const newAssignments = embeddings.map((emb) => { - let minDist = Infinity; - let minIdx = 0; - for (let c = 0; c < centroids.length; c++) { - const dist = embeddingDistance(emb, centroids[c]); - if (dist < minDist) { - minDist = dist; - minIdx = c; - } - } - return minIdx; - }); - - // Check convergence - const changed = newAssignments.some((a, i) => a !== assignments[i]); - assignments = newAssignments; - if (!changed) break; - - // Update step: recompute centroids - const newCentroids: number[][] = Array.from({ length: k }, () => - new Array(dim).fill(0), - ); - const counts = new Array(k).fill(0); - - for (let i = 0; i < embeddings.length; i++) { - const c = assignments[i]; - counts[c]++; - for (let d = 0; d < dim; d++) { - newCentroids[c][d] += embeddings[i][d]; - } - } - - for (let c = 0; c < k; c++) { - if (counts[c] > 0) { - for (let d = 0; d < dim; d++) { - newCentroids[c][d] /= counts[c]; - } - } - } - - centroids = newCentroids; - } - - return { assignments, centroids }; -} - -/** - * Adaptive clustering using elbow method to select optimal K. - * Falls back to K=2 if no clear elbow is found. - */ -function clusterMembers( - members: CompositeMember[], - maxK?: number, -): CompositeCluster[] { - if (members.length < 3) { - return [ - { - memberIds: members.map((m) => m.id), - centroid: computeCentroid(members), - }, - ]; - } - - const embeddings = members.map((m) => m.fingerprint.embedding); - const kMax = Math.min(maxK ?? 6, members.length - 1); - - // Run k-means for K=1 through kMax, collect WCSS - const results: { - k: number; - wcss: number; - assignments: number[]; - centroids: number[][]; - }[] = []; - - for (let k = 1; k <= kMax; k++) { - if (k === 1) { - // K=1: everything in one cluster - const centroid = computeCentroid(members); - const assignments = new Array(members.length).fill(0); - const wcss = computeWCSS(embeddings, assignments, [centroid]); - results.push({ k, wcss, assignments, centroids: [centroid] }); - } else { - const { assignments, centroids } = runKMeans(embeddings, k); - const wcss = computeWCSS(embeddings, assignments, centroids); - results.push({ k, wcss, assignments, centroids }); - } - } - - // Elbow method: find K where marginal WCSS decrease drops below 20% - let bestK = 2; - if (results.length >= 3) { - for (let i = 1; i < results.length - 1; i++) { - const prevDecrease = results[i - 1].wcss - results[i].wcss; - const nextDecrease = results[i].wcss - results[i + 1].wcss; - if (prevDecrease > 0 && nextDecrease / prevDecrease < 0.2) { - bestK = results[i].k; - break; - } - } - // If no elbow found, default to K=2 - if (bestK === 2 && results.length > 1) { - bestK = 2; - } - } - - const chosen = results.find((r) => r.k === bestK) ?? results[1] ?? results[0]; - - // Build clusters from assignments - const clusterMap = new Map(); - for (let i = 0; i < members.length; i++) { - const cluster = chosen.assignments[i]; - if (!clusterMap.has(cluster)) clusterMap.set(cluster, []); - clusterMap.get(cluster)?.push(members[i]); - } - - return [...clusterMap.values()] - .filter((group) => group.length > 0) - .map((group) => ({ - memberIds: group.map((m) => m.id), - centroid: computeCentroid(group), - })); -} diff --git a/packages/ghost/src/core/evolution/emit.ts b/packages/ghost/src/core/evolution/emit.ts deleted file mode 100644 index 28913354..00000000 --- a/packages/ghost/src/core/evolution/emit.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import type { Fingerprint } from "#ghost-core"; -import { - resolveFingerprintPackage, - serializeFingerprint, -} from "../../fingerprint.js"; - -/** - * Write a fingerprint as the publishable design-language prior inside the - * fingerprint package. Other projects can track this file as a reference. - */ -export async function emitFingerprint( - fingerprint: Fingerprint, - cwd: string = process.cwd(), -): Promise { - const paths = resolveFingerprintPackage(undefined, cwd); - await mkdir(paths.dir, { recursive: true }); - const target = paths.fingerprint; - await writeFile(target, serializeFingerprint(fingerprint), "utf-8"); - - return target; -} diff --git a/packages/ghost/src/core/evolution/history.ts b/packages/ghost/src/core/evolution/history.ts deleted file mode 100644 index ee907c7f..00000000 --- a/packages/ghost/src/core/evolution/history.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { existsSync } from "node:fs"; -import { appendFile, mkdir, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import type { FingerprintHistoryEntry } from "#ghost-core"; - -const GHOST_DIR = ".ghost"; -const HISTORY_FILE = "history.jsonl"; - -function historyPath(cwd: string): string { - return resolve(cwd, GHOST_DIR, HISTORY_FILE); -} - -/** - * Append a fingerprint history entry to .ghost/history.jsonl. - * Creates the .ghost directory if it doesn't exist. - */ -export async function appendHistory( - entry: FingerprintHistoryEntry, - cwd: string = process.cwd(), -): Promise { - const dir = resolve(cwd, GHOST_DIR); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } - const line = JSON.stringify(entry); - await appendFile(historyPath(cwd), `${line}\n`, "utf-8"); -} - -/** - * Read all history entries from .ghost/history.jsonl. - * Returns an empty array if no history exists. - */ -export async function readHistory( - cwd: string = process.cwd(), -): Promise { - const path = historyPath(cwd); - if (!existsSync(path)) return []; - - const content = await readFile(path, "utf-8"); - return content - .split("\n") - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line) as FingerprintHistoryEntry); -} - -/** - * Read the most recent N history entries. - */ -export async function readRecentHistory( - count: number, - cwd: string = process.cwd(), -): Promise { - const all = await readHistory(cwd); - return all.slice(-count); -} diff --git a/packages/ghost/src/core/evolution/index.ts b/packages/ghost/src/core/evolution/index.ts deleted file mode 100644 index fe7766b6..00000000 --- a/packages/ghost/src/core/evolution/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { computeDriftVectors, DIMENSION_RANGES } from "#ghost-core"; -export type { CompositeClusterOptions } from "./composite.js"; -export { compareComposite } from "./composite.js"; -export { emitFingerprint } from "./emit.js"; -export { appendHistory, readHistory, readRecentHistory } from "./history.js"; -export type { CheckBoundsOptions } from "./sync.js"; -export { - acknowledge, - checkBounds, - readSyncManifest, - writeSyncManifest, -} from "./sync.js"; -export { computeTemporalComparison } from "./temporal.js"; -export { - normalizeTrackedSource, - resolveTrackedFingerprint, -} from "./tracking.js"; diff --git a/packages/ghost/src/core/evolution/sync.ts b/packages/ghost/src/core/evolution/sync.ts deleted file mode 100644 index e79e7cd0..00000000 --- a/packages/ghost/src/core/evolution/sync.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import type { - DimensionAck, - DimensionStance, - Fingerprint, - FingerprintComparison, - SyncManifest, - Target, -} from "#ghost-core"; -import { compareFingerprints } from "#ghost-core"; - -const SYNC_FILENAME = ".ghost-sync.json"; - -function syncPath(cwd: string): string { - return resolve(cwd, SYNC_FILENAME); -} - -/** - * Read the sync manifest from .ghost-sync.json. - * Returns null if no manifest exists. - */ -export async function readSyncManifest( - cwd: string = process.cwd(), -): Promise { - const path = syncPath(cwd); - if (!existsSync(path)) return null; - const data = await readFile(path, "utf-8"); - return JSON.parse(data) as SyncManifest; -} - -/** - * Write the sync manifest to .ghost-sync.json. - */ -export async function writeSyncManifest( - manifest: SyncManifest, - cwd: string = process.cwd(), -): Promise { - const path = syncPath(cwd); - await writeFile(path, JSON.stringify(manifest, null, 2), "utf-8"); - return path; -} - -/** - * Acknowledge the current drift state. - * Compares the local fingerprint to the tracked fingerprint, recording - * per-dimension distances with stances. - * - * If dimension/stance are provided, only that dimension is updated — - * the rest are preserved from the existing manifest or set to "accepted". - */ -export async function acknowledge(opts: { - local: Fingerprint; - tracked: Fingerprint; - tracks: Target; - dimension?: string; - stance?: DimensionStance; - reason?: string; - tolerance?: number; - cwd?: string; -}): Promise<{ manifest: SyncManifest; comparison: FingerprintComparison }> { - const cwd = opts.cwd ?? process.cwd(); - const comparison = compareFingerprints(opts.tracked, opts.local); - const now = new Date().toISOString(); - - // Load existing manifest to preserve previous acks - const existing = await readSyncManifest(cwd); - - const dimensions: Record = {}; - - for (const [key, delta] of Object.entries(comparison.dimensions)) { - if (opts.dimension && key !== opts.dimension) { - // Preserve existing ack for this dimension, or default to accepted - dimensions[key] = existing?.dimensions[key] ?? { - distance: delta.distance, - stance: "accepted", - ackedAt: now, - }; - } else { - const stance = opts.stance ?? "accepted"; - dimensions[key] = { - distance: delta.distance, - stance, - ackedAt: now, - reason: key === opts.dimension ? opts.reason : undefined, - tolerance: key === opts.dimension ? opts.tolerance : undefined, - divergedAt: stance === "diverging" ? now : undefined, - }; - } - } - - const manifest: SyncManifest = { - tracks: opts.tracks, - ackedAt: now, - trackedFingerprintId: opts.tracked.id, - localFingerprintId: opts.local.id, - dimensions, - overallDistance: comparison.distance, - }; - - await writeSyncManifest(manifest, cwd); - - return { manifest, comparison }; -} - -export interface CheckBoundsOptions { - tolerance?: number; - maxDivergenceDays?: number; -} - -/** - * Check whether the current drift exceeds the acknowledged bounds. - * Returns dimensions that have drifted beyond what was acked. - * - * Improvements over the original: - * - Per-dimension tolerance (ack.tolerance overrides global tolerance) - * - Diverging dimensions are re-evaluated: if they've reconverged significantly - * (current distance < 50% of acked distance), they're flagged as "reconverging" - * - Optional maxDivergenceDays: flags diverging dimensions that have been diverging - * longer than the specified number of days - */ -export function checkBounds( - manifest: SyncManifest, - current: FingerprintComparison, - toleranceOrOptions?: number | CheckBoundsOptions, -): { exceeded: boolean; dimensions: string[]; reconverging: string[] } { - const opts: CheckBoundsOptions = - typeof toleranceOrOptions === "number" - ? { tolerance: toleranceOrOptions } - : (toleranceOrOptions ?? {}); - - const globalTolerance = opts.tolerance ?? 0.05; - const maxDivergenceDays = opts.maxDivergenceDays ?? null; - - const exceeded: string[] = []; - const reconverging: string[] = []; - - for (const [key, ack] of Object.entries(manifest.dimensions)) { - const currentDistance = current.dimensions[key]?.distance ?? 0; - const effectiveTolerance = ack.tolerance ?? globalTolerance; - - if (ack.stance === "diverging") { - // Re-evaluate diverging dimensions instead of permanently skipping them - // If the dimension has converged back to less than 50% of acked distance, flag it - if (currentDistance < ack.distance * 0.5) { - reconverging.push(key); - } - // If maxDivergenceDays is set, check if divergence has gone on too long - if (maxDivergenceDays !== null && ack.divergedAt) { - const divergedDate = new Date(ack.divergedAt); - const daysSinceDiverged = Math.floor( - (Date.now() - divergedDate.getTime()) / (1000 * 60 * 60 * 24), - ); - if (daysSinceDiverged > maxDivergenceDays) { - exceeded.push(key); - } - } - continue; - } - - if (currentDistance > ack.distance + effectiveTolerance) { - exceeded.push(key); - } - } - - return { - exceeded: exceeded.length > 0, - dimensions: exceeded, - reconverging, - }; -} diff --git a/packages/ghost/src/core/evolution/temporal.ts b/packages/ghost/src/core/evolution/temporal.ts deleted file mode 100644 index 13036f76..00000000 --- a/packages/ghost/src/core/evolution/temporal.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { - DriftVelocity, - FingerprintComparison, - FingerprintHistoryEntry, - SyncManifest, - TemporalComparison, -} from "#ghost-core"; -import { compareFingerprints, computeDriftVectors } from "#ghost-core"; -import { checkBounds } from "./sync.js"; - -/** - * Enrich a fingerprint comparison with temporal data: - * velocity, trajectory, ack status, and drift vectors. - */ -export function computeTemporalComparison(opts: { - comparison: FingerprintComparison; - history: FingerprintHistoryEntry[]; - manifest: SyncManifest | null; - stabilityThreshold?: number; -}): TemporalComparison { - const { comparison, history, manifest } = opts; - const stabilityThreshold = opts.stabilityThreshold ?? 0.01; - - const vectors = computeDriftVectors(comparison.source, comparison.target); - const velocity = computeVelocity(comparison, history, stabilityThreshold); - const trajectory = classifyTrajectory(velocity); - - let daysSinceAck: number | null = null; - let exceedsAckedBounds = false; - let exceedingDimensions: string[] = []; - - if (manifest) { - const ackDate = new Date(manifest.ackedAt); - daysSinceAck = Math.floor( - (Date.now() - ackDate.getTime()) / (1000 * 60 * 60 * 24), - ); - - const bounds = checkBounds(manifest, comparison); - exceedsAckedBounds = bounds.exceeded; - exceedingDimensions = bounds.dimensions; - } - - return { - ...comparison, - vectors, - velocity, - daysSinceAck, - exceedsAckedBounds, - exceedingDimensions, - trajectory, - }; -} - -/** - * Compute drift velocity per dimension from history entries. - * Uses the oldest and most recent entries to calculate rate of change. - */ -function computeVelocity( - current: FingerprintComparison, - history: FingerprintHistoryEntry[], - stabilityThreshold: number = 0.01, -): DriftVelocity[] { - if (history.length < 2) { - // Not enough history to compute velocity — return stable for all dimensions - return Object.keys(current.dimensions).map((dimension) => ({ - dimension, - rate: 0, - direction: "stable" as const, - windowDays: 0, - })); - } - - const oldest = history[0]; - const newest = history[history.length - 1]; - - const oldestDate = new Date(oldest.fingerprint.timestamp); - const newestDate = new Date(newest.fingerprint.timestamp); - const windowDays = Math.max( - (newestDate.getTime() - oldestDate.getTime()) / (1000 * 60 * 60 * 24), - 1, - ); - - // Compare the oldest history entry's fingerprint against the current source - // to get a "then" comparison, and use the current comparison as "now" - const oldComparison = compareFingerprints(current.source, oldest.fingerprint); - - return Object.keys(current.dimensions).map((dimension) => { - const oldDistance = oldComparison.dimensions[dimension]?.distance ?? 0; - const newDistance = current.dimensions[dimension]?.distance ?? 0; - const delta = newDistance - oldDistance; - const rate = Math.abs(delta) / windowDays; - - let direction: "converging" | "diverging" | "stable"; - if (Math.abs(delta) < stabilityThreshold) { - direction = "stable"; - } else if (delta < 0) { - direction = "converging"; - } else { - direction = "diverging"; - } - - return { dimension, rate, direction, windowDays }; - }); -} - -/** - * Classify overall trajectory from per-dimension velocities. - */ -function classifyTrajectory( - velocity: DriftVelocity[], -): "converging" | "diverging" | "stable" | "oscillating" { - if (velocity.length === 0) return "stable"; - - const converging = velocity.filter( - (v) => v.direction === "converging", - ).length; - const diverging = velocity.filter((v) => v.direction === "diverging").length; - const stable = velocity.filter((v) => v.direction === "stable").length; - const total = velocity.length; - - // If most dimensions are stable, overall is stable - if (stable / total >= 0.6) return "stable"; - // If dimensions are split between converging and diverging, it's oscillating - if ( - converging > 0 && - diverging > 0 && - Math.abs(converging - diverging) <= 1 - ) { - return "oscillating"; - } - // Otherwise, majority wins - return converging > diverging ? "converging" : "diverging"; -} diff --git a/packages/ghost/src/core/evolution/tracking.ts b/packages/ghost/src/core/evolution/tracking.ts deleted file mode 100644 index a085312e..00000000 --- a/packages/ghost/src/core/evolution/tracking.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { resolve } from "node:path"; -import type { Fingerprint, Target } from "#ghost-core"; -import { resolveTarget } from "#ghost-core"; -import { - FINGERPRINT_FILENAME, - loadFingerprint, - parseFingerprint, - resolveFingerprintPackage, -} from "../../fingerprint.js"; - -/** - * Resolve a Target to a Fingerprint. - * - * - "path": reads a local fingerprint.md, or a directory containing one. - * - "url": fetches a remote fingerprint.md - * - "npm": resolves node_modules//fingerprint.md - * - "github": not yet supported for direct resolution (use fingerprint flow instead) - */ -export async function resolveTrackedFingerprint( - target: Target, - cwd: string = process.cwd(), -): Promise { - switch (target.type) { - case "path": { - const resolved = resolve(cwd, target.value); - if (resolved.endsWith(".md")) { - return readFingerprintFile(resolved); - } - return readFingerprintFromDir(resolved); - } - - case "url": - case "registry": { - const response = await fetch(target.value); - if (!response.ok) { - throw new Error( - `Failed to fetch tracked fingerprint from ${target.value}: ${response.status}`, - ); - } - return parseFingerprint(await response.text()).fingerprint; - } - - case "npm": { - return readFingerprintFromDir(resolve(cwd, "node_modules", target.value)); - } - - default: - throw new Error( - `Cannot resolve tracked fingerprint from target type "${target.type}". Generate one first by running the fingerprint recipe in your host agent (install with "ghost skill install").`, - ); - } -} - -async function readFingerprintFile(path: string): Promise { - try { - return (await loadFingerprint(path)).fingerprint; - } catch (err) { - throw new Error( - `Could not read fingerprint at ${path}: ${err instanceof Error ? err.message : String(err)}`, - ); - } -} - -async function readFingerprintFromDir(dir: string): Promise { - try { - return await readFingerprintFile( - resolveFingerprintPackage(undefined, dir).fingerprint, - ); - } catch { - return readFingerprintFile(resolve(dir, FINGERPRINT_FILENAME)); - } -} - -/** - * Normalize a config tracks value to a Target. - * Accepts a Target directly, or a string shorthand resolved via resolveTarget(). - */ -export function normalizeTrackedSource( - value: Target | string | undefined, -): Target | undefined { - if (!value) return undefined; - if (typeof value === "string") { - return resolveTarget(value); - } - return value; -} diff --git a/packages/ghost/src/core/gate.ts b/packages/ghost/src/core/gate.ts deleted file mode 100644 index 5d8abee1..00000000 --- a/packages/ghost/src/core/gate.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import type { - DimensionAck, - Fingerprint, - FingerprintComparison, - SyncManifest, -} from "#ghost-core"; -import type { CompareResult } from "./compare.js"; -import { checkBounds } from "./evolution/sync.js"; - -const DEFAULT_SYNC_PATH = ".ghost-sync.json"; - -const GATE_SCHEMA = "ghost.compare.gate/v1" as const; -const ALIGNED_THRESHOLD = 0.01; -const DEFAULT_TOLERANCE = 0.05; - -export type GateDimensionVerdict = - | "aligned" - | "covered" - | "reconverging" - | "uncovered"; - -export type GateOverallVerdict = "aligned" | "covered" | "uncovered"; - -export interface GateDimensionReport { - distance: number; - ackDistance?: number; - stance?: DimensionAck["stance"]; - verdict: GateDimensionVerdict; - reason?: string; -} - -export interface GateReport { - schema: typeof GATE_SCHEMA; - trackedFingerprintId: string; - localFingerprintId: string; - overall: { - distance: number; - verdict: GateOverallVerdict; - }; - dimensions: Record; -} - -export interface BuildGateReportArgs { - comparison: FingerprintComparison; - manifest: SyncManifest; - tolerance?: number; - maxDivergenceDays?: number; -} - -/** - * Reconcile a pairwise comparison against a recorded sync manifest and - * produce a per-dimension verdict suitable for CI / programmatic gating. - * - * Composes over the existing `checkBounds` helper: a dimension is - * `uncovered` when checkBounds flags it (or when the comparison surfaces - * a dimension the manifest doesn't cover), `reconverging` when checkBounds - * marks it as such, `aligned` when the current distance is ~0, and - * otherwise `covered`. - */ -export function buildGateReport(args: BuildGateReportArgs): GateReport { - const { comparison, manifest } = args; - const tolerance = args.tolerance ?? DEFAULT_TOLERANCE; - - const bounds = checkBounds(manifest, comparison, { - tolerance, - maxDivergenceDays: args.maxDivergenceDays, - }); - const exceededSet = new Set(bounds.dimensions); - const reconvergingSet = new Set(bounds.reconverging); - - const dimensions: Record = {}; - - for (const [key, delta] of Object.entries(comparison.dimensions)) { - const ack = manifest.dimensions[key]; - const distance = delta.distance; - - if (!ack) { - dimensions[key] = { - distance, - verdict: "uncovered", - reason: "no ack recorded", - }; - continue; - } - - if (exceededSet.has(key)) { - // `effectiveTolerance` mirrors the per-dimension override that - // `checkBounds` already applied; we recompute it here only to surface - // the same number in the human-readable reason string. - const effectiveTolerance = ack.tolerance ?? tolerance; - const reason = - ack.stance === "diverging" - ? buildDivergenceExceededReason(ack, args.maxDivergenceDays) - : `current ${formatNumber(distance)} exceeds acked ${formatNumber( - ack.distance, - )} + tolerance ${formatNumber(effectiveTolerance)}`; - dimensions[key] = { - distance, - ackDistance: ack.distance, - stance: ack.stance, - verdict: "uncovered", - reason, - }; - continue; - } - - if (reconvergingSet.has(key)) { - dimensions[key] = { - distance, - ackDistance: ack.distance, - stance: ack.stance, - verdict: "reconverging", - }; - continue; - } - - if ( - ack.stance === "aligned" && - distance < ALIGNED_THRESHOLD && - ack.distance < ALIGNED_THRESHOLD - ) { - dimensions[key] = { - distance, - ackDistance: ack.distance, - stance: ack.stance, - verdict: "aligned", - }; - continue; - } - - dimensions[key] = { - distance, - ackDistance: ack.distance, - stance: ack.stance, - verdict: "covered", - }; - } - - const verdicts = Object.values(dimensions).map((d) => d.verdict); - let overall: GateOverallVerdict; - if (verdicts.some((v) => v === "uncovered")) { - overall = "uncovered"; - } else if (verdicts.length > 0 && verdicts.every((v) => v === "aligned")) { - overall = "aligned"; - } else { - overall = "covered"; - } - - return { - schema: GATE_SCHEMA, - trackedFingerprintId: comparison.source.id, - localFingerprintId: comparison.target.id, - overall: { - distance: comparison.distance, - verdict: overall, - }, - dimensions, - }; -} - -/** - * Map a gate report to its CI exit code. - * - 0 when no uncovered drift (aligned, covered, or reconverging). - * - 1 when any dimension is uncovered. - */ -export function gateExitCode(report: GateReport): 0 | 1 { - return report.overall.verdict === "uncovered" ? 1 : 0; -} - -export function formatGateReportJSON(report: GateReport): string { - return JSON.stringify(report); -} - -const MARKERS: Record = { - aligned: "✓", - covered: "=", - reconverging: "~", - uncovered: "✗", -}; - -export function formatGateReportCLI(report: GateReport): string { - const lines: string[] = []; - lines.push( - `Gate: ${report.trackedFingerprintId} vs ${report.localFingerprintId}`, - ); - - const dimEntries = Object.entries(report.dimensions); - const nameWidth = dimEntries.reduce( - (max, [name]) => Math.max(max, name.length), - 0, - ); - - for (const [name, dim] of dimEntries) { - const marker = MARKERS[dim.verdict]; - const distance = formatPercent(dim.distance); - const tail = dim.reason ? ` ${dim.reason}` : ""; - lines.push( - ` ${marker} ${name.padEnd(nameWidth)} ${distance.padStart(6)} ${dim.verdict}${tail}`, - ); - } - - lines.push(""); - lines.push( - `Overall: ${report.overall.verdict} (${formatPercent(report.overall.distance)})`, - ); - return `${lines.join("\n")}\n`; -} - -function formatNumber(n: number): string { - return Number.isFinite(n) ? n.toFixed(3).replace(/\.?0+$/, "") : String(n); -} - -function formatPercent(n: number): string { - return `${(n * 100).toFixed(1)}%`; -} - -function buildDivergenceExceededReason( - ack: DimensionAck, - maxDivergenceDays?: number, -): string { - if (maxDivergenceDays === undefined || !ack.divergedAt) { - return "diverging stance exceeded recorded bounds"; - } - const divergedDate = new Date(ack.divergedAt); - const days = Math.floor( - (Date.now() - divergedDate.getTime()) / (1000 * 60 * 60 * 24), - ); - return `diverging for ${days} days exceeds --max-divergence-days ${maxDivergenceDays}`; -} - -// --- CLI runner --- - -export interface RunGateCliOptions { - fingerprints: string[]; - cwd: string; - /** From `--sync `; defaults to `./.ghost-sync.json`. */ - sync?: string; - /** From `--format `; "cli" (default) or "json". */ - format?: string; - /** - * From `--max-divergence-days `. cac may forward this as a number - * when it parses cleanly or as the raw string, so both shapes are - * accepted; `parseMaxDivergenceDays` validates inside this module. - */ - maxDivergenceDays?: number | string; - loadFingerprint: (path: string) => Promise; - compare: (fingerprints: Fingerprint[]) => CompareResult; -} - -type GateRunResult = - | { kind: "error"; code: 2; message: string } - | { kind: "ok"; code: 0 | 1; stdout: string }; - -/** - * CLI adapter for `ghost-drift compare --gate`. Validates inputs, loads - * the sync manifest, runs the comparison, and writes the verdict to - * stdout. Calls `process.exit` exactly once at the end with the gate - * exit code (or 2 on any validation/error path). - */ -export async function runGateCli(opts: RunGateCliOptions): Promise { - const result = await computeGateRun(opts); - if (result.kind === "error") { - console.error(`Error: ${result.message}`); - } else { - await writeAndFlush(`${result.stdout}\n`); - } - process.exit(result.code); -} - -async function computeGateRun(opts: RunGateCliOptions): Promise { - if (opts.fingerprints.length !== 2) { - return { - kind: "error", - code: 2, - message: `--gate requires exactly 2 fingerprints (got ${opts.fingerprints.length}).`, - }; - } - - const syncPath = resolve(opts.cwd, opts.sync ?? DEFAULT_SYNC_PATH); - if (!existsSync(syncPath)) { - return { - kind: "error", - code: 2, - message: `sync manifest not found at ${syncPath}. Run \`ghost-drift ack\` first or pass --sync .`, - }; - } - - let manifest: SyncManifest; - try { - manifest = JSON.parse(await readFile(syncPath, "utf-8")) as SyncManifest; - } catch (err) { - return { - kind: "error", - code: 2, - message: `failed to load sync manifest at ${syncPath}: ${ - err instanceof Error ? err.message : String(err) - }`, - }; - } - - if (!manifest || typeof manifest !== "object" || !manifest.dimensions) { - return { - kind: "error", - code: 2, - message: `sync manifest at ${syncPath} is malformed (missing dimensions).`, - }; - } - - const maxDivergenceDays = parseMaxDivergenceDays(opts.maxDivergenceDays); - if (maxDivergenceDays === "invalid") { - return { - kind: "error", - code: 2, - message: "--max-divergence-days must be a non-negative integer.", - }; - } - - let fingerprints: Fingerprint[]; - try { - fingerprints = await Promise.all( - opts.fingerprints.map((path) => opts.loadFingerprint(path)), - ); - } catch (err) { - return { - kind: "error", - code: 2, - message: `failed to load fingerprints: ${ - err instanceof Error ? err.message : String(err) - }`, - }; - } - - const compared = opts.compare(fingerprints); - if (compared.mode !== "pairwise") { - return { - kind: "error", - code: 2, - message: "--gate requires pairwise comparison.", - }; - } - - const report = buildGateReport({ - comparison: compared.comparison, - manifest, - maxDivergenceDays, - }); - - const stdout = - opts.format === "json" - ? formatGateReportJSON(report) - : formatGateReportCLI(report); - - return { kind: "ok", code: gateExitCode(report), stdout }; -} - -/** - * Write to stdout and wait for the stream to flush before resolving. - * `process.exit` does not drain async stdout (e.g., when the gate - * report is piped into another command on Unix), so the explicit - * callback flush prevents truncated JSON output. - */ -async function writeAndFlush(text: string): Promise { - await new Promise((resolve) => { - process.stdout.write(text, () => resolve()); - }); -} - -function parseMaxDivergenceDays( - raw: number | string | undefined, -): number | undefined | "invalid" { - if (raw === undefined) return undefined; - const n = typeof raw === "number" ? raw : Number(raw); - if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return "invalid"; - return n; -} diff --git a/packages/ghost/src/core/index.ts b/packages/ghost/src/core/index.ts deleted file mode 100644 index f94b5258..00000000 --- a/packages/ghost/src/core/index.ts +++ /dev/null @@ -1,112 +0,0 @@ -export type { - ColorRamp, - ComponentMeta, - CompositeCluster, - CompositeComparison, - CompositeMember, - CompositePair, - CSSToken, - CSSVarsMap, - DesignDecision, - DesignObservation, - DetectedFormat, - DimensionAck, - DimensionDelta, - DimensionStance, - DivergenceClass, - DriftVector, - DriftVelocity, - EmbeddingConfig, - EnrichedComparison, - EnrichedFingerprint, - ExtractedFile, - ExtractedMaterial, - Extractor, - ExtractorOptions, - Fingerprint, - FingerprintComparison, - FingerprintHistoryEntry, - FontDescriptor, - GhostConfig, - NormalizedToken, - Registry, - RegistryFile, - RegistryItem, - RegistryItemType, - ResolvedRegistry, - RoleCandidate, - RuleSeverity, - SampledFile, - SampledMaterial, - SemanticColor, - SourceInfo, - StructureDrift, - SyncManifest, - Target, - TargetOptions, - TargetType, - TemporalComparison, - TokenCategory, - TokenFormat, - ValueDrift, -} from "#ghost-core"; -export { - compareFingerprints, - computeEmbedding, - computeSemanticEmbedding, - describeFingerprint, - embeddingDistance, - inferSemanticRole, -} from "#ghost-core"; -export type { CompareOptions, CompareResult } from "./compare.js"; -export { compare } from "./compare.js"; -export { defineConfig, loadConfig, resolveTarget } from "./config.js"; -export type { - CheckBoundsOptions, - CompositeClusterOptions, -} from "./evolution/index.js"; -export { - acknowledge, - appendHistory, - checkBounds, - compareComposite, - computeDriftVectors, - computeTemporalComparison, - DIMENSION_RANGES, - emitFingerprint, - normalizeTrackedSource, - readHistory, - readRecentHistory, - readSyncManifest, - resolveTrackedFingerprint, - writeSyncManifest, -} from "./evolution/index.js"; -export type { - BuildGateReportArgs, - GateDimensionReport, - GateDimensionVerdict, - GateOverallVerdict, - GateReport, - RunGateCliOptions, -} from "./gate.js"; -export { - buildGateReport, - formatGateReportCLI, - formatGateReportJSON, - gateExitCode, - runGateCli, -} from "./gate.js"; -export { - formatCompositeComparison, - formatCompositeComparisonJSON, -} from "./reporters/composite.js"; -export { - formatComparison, - formatComparisonJSON, - formatFingerprint, - formatFingerprintJSON, -} from "./reporters/fingerprint.js"; -export { - formatTemporalComparison, - formatTemporalComparisonJSON, -} from "./reporters/temporal.js"; diff --git a/packages/ghost/src/core/reporters/composite.ts b/packages/ghost/src/core/reporters/composite.ts deleted file mode 100644 index a6c29205..00000000 --- a/packages/ghost/src/core/reporters/composite.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { CompositeComparison } from "#ghost-core"; - -const BOLD = "\x1b[1m"; -const DIM = "\x1b[2m"; -const RESET = "\x1b[0m"; -const YELLOW = "\x1b[33m"; -const GREEN = "\x1b[32m"; -const RED = "\x1b[31m"; -const CYAN = "\x1b[36m"; - -const useColor = - process.env.NO_COLOR === undefined && process.stdout.isTTY !== false; - -function c(code: string, text: string): string { - return useColor ? `${code}${text}${RESET}` : text; -} - -export function formatCompositeComparison( - composite: CompositeComparison, -): string { - const lines: string[] = []; - - lines.push( - c(BOLD, `Composite Fingerprint: ${composite.members.length} members`), - ); - lines.push(""); - - // Spread - const spreadPct = (composite.spread * 100).toFixed(1); - const spreadColor = - composite.spread < 0.1 ? GREEN : composite.spread < 0.3 ? YELLOW : RED; - lines.push(`Spread: ${c(spreadColor, `${spreadPct}%`)}`); - lines.push(""); - - // Members - lines.push(c(BOLD, "Members")); - for (const member of composite.members) { - const trackedStr = - member.distanceToTracked != null - ? ` (${(member.distanceToTracked * 100).toFixed(1)}% from tracked)` - : ""; - lines.push(` ${member.id}${c(DIM, trackedStr)}`); - } - lines.push(""); - - // Pairwise distances (sorted by distance) - lines.push(c(BOLD, "Pairwise Distances")); - for (const pair of composite.pairwise) { - const pct = (pair.distance * 100).toFixed(1); - const color = - pair.distance < 0.1 ? GREEN : pair.distance < 0.3 ? YELLOW : RED; - lines.push(` ${pair.a} ${c(DIM, "<>")} ${pair.b} ${c(color, `${pct}%`)}`); - } - lines.push(""); - - // Clusters - if (composite.clusters && composite.clusters.length > 1) { - lines.push(c(CYAN, "Clusters")); - for (let i = 0; i < composite.clusters.length; i++) { - const cluster = composite.clusters[i]; - lines.push( - ` ${c(BOLD, `Cluster ${i + 1}:`)} ${cluster.memberIds.join(", ")}`, - ); - } - lines.push(""); - } - - return `${lines.join("\n")}\n`; -} - -export function formatCompositeComparisonJSON( - composite: CompositeComparison, -): string { - return JSON.stringify( - { - memberCount: composite.members.length, - members: composite.members.map((m) => ({ - id: m.id, - distanceToTracked: m.distanceToTracked, - })), - pairwise: composite.pairwise, - spread: composite.spread, - clusters: composite.clusters, - }, - null, - 2, - ); -} diff --git a/packages/ghost/src/core/reporters/fingerprint.ts b/packages/ghost/src/core/reporters/fingerprint.ts deleted file mode 100644 index 1c03553f..00000000 --- a/packages/ghost/src/core/reporters/fingerprint.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { Fingerprint, FingerprintComparison } from "#ghost-core"; - -const BOLD = "\x1b[1m"; -const DIM = "\x1b[2m"; -const RESET = "\x1b[0m"; -const YELLOW = "\x1b[33m"; -const GREEN = "\x1b[32m"; -const RED = "\x1b[31m"; - -const useColor = - process.env.NO_COLOR === undefined && process.stdout.isTTY !== false; - -function c(code: string, text: string): string { - return useColor ? `${code}${text}${RESET}` : text; -} - -export function formatFingerprint(fp: Fingerprint): string { - const lines: string[] = []; - - lines.push(c(BOLD, `Fingerprint: ${fp.id}`)); - lines.push(c(DIM, `Source: ${fp.source} | ${fp.timestamp}`)); - if (fp.sources?.length) { - lines.push(c(DIM, `Synthesized from: ${fp.sources.join(", ")}`)); - } - lines.push(""); - - // Observation (Layer 1) - if (fp.observation) { - lines.push(c(BOLD, "Observation")); - lines.push(` ${fp.observation.summary}`); - if (fp.observation.personality.length > 0) { - lines.push(` Personality: ${fp.observation.personality.join(", ")}`); - } - if (fp.observation.resembles.length > 0) { - lines.push(` Resembles: ${fp.observation.resembles.join(", ")}`); - } - lines.push(""); - } - - // Design Decisions (Layer 2) - if (fp.decisions && fp.decisions.length > 0) { - lines.push(c(BOLD, "Design Decisions")); - for (const d of fp.decisions) { - lines.push(` ${c(YELLOW, d.dimension.padEnd(22))} ${d.decision}`); - } - lines.push(""); - } - - // Palette - lines.push(c(BOLD, "Palette")); - if (fp.palette.dominant.length > 0) { - lines.push( - ` Dominant: ${fp.palette.dominant.map((co) => `${co.role} (${co.value})`).join(", ")}`, - ); - } - if (fp.palette.semantic.length > 0) { - lines.push( - ` Semantic: ${fp.palette.semantic.map((co) => co.role).join(", ")}`, - ); - } - lines.push(` Neutrals: ${fp.palette.neutrals.count} steps`); - lines.push(` Saturation: ${fp.palette.saturationProfile}`); - lines.push(` Contrast: ${fp.palette.contrast}`); - lines.push(""); - - // Spacing - lines.push(c(BOLD, "Spacing")); - lines.push( - ` Scale: ${fp.spacing.scale.length > 0 ? fp.spacing.scale.join(", ") : "(none detected)"}`, - ); - lines.push( - ` Base Unit: ${fp.spacing.baseUnit ? `${fp.spacing.baseUnit}px` : "(none)"}`, - ); - lines.push(` Regularity: ${(fp.spacing.regularity * 100).toFixed(0)}%`); - lines.push(""); - - // Typography - lines.push(c(BOLD, "Typography")); - lines.push( - ` Families: ${fp.typography.families.length > 0 ? fp.typography.families.join(", ") : "(none detected)"}`, - ); - lines.push( - ` Size Ramp: ${fp.typography.sizeRamp.length > 0 ? fp.typography.sizeRamp.join(", ") : "(none detected)"}`, - ); - lines.push(` Line Height: ${fp.typography.lineHeightPattern}`); - lines.push(""); - - // Surfaces - lines.push(c(BOLD, "Surfaces")); - lines.push( - ` Radii: ${fp.surfaces.borderRadii.length > 0 ? fp.surfaces.borderRadii.map((r) => `${r}px`).join(", ") : "(none)"}`, - ); - lines.push(` Shadows: ${fp.surfaces.shadowComplexity}`); - lines.push(` Borders: ${fp.surfaces.borderUsage}`); - lines.push(""); - - return `${lines.join("\n")}\n`; -} - -export function formatComparison(comp: FingerprintComparison): string { - const lines: string[] = []; - - lines.push(c(BOLD, `Comparison: ${comp.source.id} vs ${comp.target.id}`)); - lines.push(""); - - // Overall distance - const distPct = (comp.distance * 100).toFixed(1); - const distColor = - comp.distance < 0.1 ? GREEN : comp.distance < 0.3 ? YELLOW : RED; - lines.push(`Overall Distance: ${c(distColor, `${distPct}%`)}`); - lines.push(""); - - // Per-dimension breakdown - lines.push(c(BOLD, "Dimensions")); - for (const [key, delta] of Object.entries(comp.dimensions)) { - const pct = (delta.distance * 100).toFixed(1); - const color = - delta.distance < 0.1 ? GREEN : delta.distance < 0.3 ? YELLOW : RED; - lines.push( - ` ${key.padEnd(14)} ${c(color, `${pct}%`.padStart(6))} ${c(DIM, delta.description)}`, - ); - } - lines.push(""); - - // Summary - if (comp.summary) { - lines.push(c(DIM, comp.summary)); - lines.push(""); - } - - return `${lines.join("\n")}\n`; -} - -export function formatFingerprintJSON(fp: Fingerprint): string { - return JSON.stringify(fp, null, 2); -} - -export function formatComparisonJSON(comp: FingerprintComparison): string { - // Omit full fingerprints from JSON comparison to keep it concise - return JSON.stringify( - { - source: comp.source.id, - target: comp.target.id, - distance: comp.distance, - dimensions: comp.dimensions, - summary: comp.summary, - }, - null, - 2, - ); -} diff --git a/packages/ghost/src/core/reporters/temporal.ts b/packages/ghost/src/core/reporters/temporal.ts deleted file mode 100644 index a2ba837e..00000000 --- a/packages/ghost/src/core/reporters/temporal.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { TemporalComparison } from "#ghost-core"; - -const BOLD = "\x1b[1m"; -const DIM = "\x1b[2m"; -const RESET = "\x1b[0m"; -const YELLOW = "\x1b[33m"; -const GREEN = "\x1b[32m"; -const RED = "\x1b[31m"; -const CYAN = "\x1b[36m"; - -const useColor = - process.env.NO_COLOR === undefined && process.stdout.isTTY !== false; - -function c(code: string, text: string): string { - return useColor ? `${code}${text}${RESET}` : text; -} - -export function formatTemporalComparison(comp: TemporalComparison): string { - const lines: string[] = []; - - lines.push( - c(BOLD, `Temporal Comparison: ${comp.source.id} vs ${comp.target.id}`), - ); - lines.push(""); - - // Overall distance + trajectory - const distPct = (comp.distance * 100).toFixed(1); - const distColor = - comp.distance < 0.1 ? GREEN : comp.distance < 0.3 ? YELLOW : RED; - const trajColor = - comp.trajectory === "converging" - ? GREEN - : comp.trajectory === "diverging" - ? RED - : comp.trajectory === "oscillating" - ? YELLOW - : DIM; - - lines.push( - `Distance: ${c(distColor, `${distPct}%`)} Trajectory: ${c(trajColor, comp.trajectory)}`, - ); - lines.push(""); - - // Ack status - if (comp.daysSinceAck !== null) { - const ackColor = comp.daysSinceAck > 30 ? YELLOW : DIM; - lines.push(c(CYAN, "Acknowledgment")); - lines.push(` Last acked: ${c(ackColor, `${comp.daysSinceAck} days ago`)}`); - if (comp.exceedsAckedBounds) { - lines.push( - ` ${c(RED, "Exceeded bounds:")} ${comp.exceedingDimensions.join(", ")}`, - ); - } else { - lines.push(` ${c(GREEN, "Within acknowledged bounds")}`); - } - lines.push(""); - } - - // Per-dimension velocity - lines.push(c(BOLD, "Dimensions")); - for (const [key, delta] of Object.entries(comp.dimensions)) { - const pct = (delta.distance * 100).toFixed(1); - const color = - delta.distance < 0.1 ? GREEN : delta.distance < 0.3 ? YELLOW : RED; - - const vel = comp.velocity.find((v) => v.dimension === key); - let velStr = ""; - if (vel && vel.rate > 0) { - const arrow = - vel.direction === "converging" - ? c(GREEN, "\u2193") - : vel.direction === "diverging" - ? c(RED, "\u2191") - : c(DIM, "-"); - velStr = ` ${arrow} ${(vel.rate * 100).toFixed(2)}%/day`; - } - - lines.push( - ` ${key.padEnd(14)} ${c(color, `${pct}%`.padStart(6))}${velStr} ${c(DIM, delta.description)}`, - ); - } - lines.push(""); - - // Summary - if (comp.summary) { - lines.push(c(DIM, comp.summary)); - lines.push(""); - } - - return `${lines.join("\n")}\n`; -} - -export function formatTemporalComparisonJSON(comp: TemporalComparison): string { - return JSON.stringify( - { - source: comp.source.id, - target: comp.target.id, - distance: comp.distance, - trajectory: comp.trajectory, - daysSinceAck: comp.daysSinceAck, - exceedsAckedBounds: comp.exceedsAckedBounds, - exceedingDimensions: comp.exceedingDimensions, - dimensions: comp.dimensions, - velocity: comp.velocity, - vectors: comp.vectors, - summary: comp.summary, - }, - null, - 2, - ); -} diff --git a/packages/ghost/src/drift-command.ts b/packages/ghost/src/drift-command.ts deleted file mode 100644 index 45aa1221..00000000 --- a/packages/ghost/src/drift-command.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import type { CAC } from "cac"; -import type { Fingerprint, SyncManifest, Target } from "#ghost-core"; -import { compareFingerprints } from "#ghost-core"; -import { loadComparableFingerprint } from "./comparable-fingerprint.js"; -import { - buildGateReport, - formatGateReportCLI, - type GateReport, - gateExitCode, - loadConfig, - resolveTarget, - resolveTrackedFingerprint, -} from "./core/index.js"; -import { resolveFingerprintPackage } from "./fingerprint.js"; - -const DEFAULT_SYNC_PATH = ".ghost-sync.json"; -const DRIFT_STATUS_SCHEMA = "ghost.drift.status/v1" as const; -const DRIFT_CHECK_SCHEMA = "ghost.drift.check/v1" as const; - -export interface DriftStatusReport { - schema: typeof DRIFT_STATUS_SCHEMA; - packageDir: string; -} - -export interface DriftCheckReport { - schema: typeof DRIFT_CHECK_SCHEMA; - trackedFingerprintId: string; - localFingerprintId: string; - overall: GateReport["overall"]; - dimensions: GateReport["dimensions"]; - gate: GateReport; -} - -interface DriftStatusOptions { - cwd?: string; - packageDir?: string; -} - -interface DriftCheckOptions extends DriftStatusOptions { - config?: string; - local?: string; - tracked?: string; - sync?: string; - maxDivergenceDays?: number | string; -} - -export async function getDriftStatus( - options: DriftStatusOptions = {}, -): Promise { - const cwd = options.cwd ?? process.cwd(); - const paths = resolveFingerprintPackage(options.packageDir, cwd); - - return { - schema: DRIFT_STATUS_SCHEMA, - packageDir: paths.dir, - }; -} - -export async function runDriftCheck( - options: DriftCheckOptions = {}, -): Promise { - const cwd = options.cwd ?? process.cwd(); - const manifest = await readSyncManifest(cwd, options.sync); - const local = await loadLocalFingerprint( - cwd, - options.local, - options.packageDir, - ); - const tracked = await loadTrackedFingerprint(cwd, { - explicitPath: options.tracked, - configPath: options.config, - ledgerTarget: manifest.tracks, - }); - validateManifestFingerprintIds(manifest, { local, tracked }); - const maxDivergenceDays = parseMaxDivergenceDays(options.maxDivergenceDays); - if (maxDivergenceDays === "invalid") { - throw new Error("--max-divergence-days must be a non-negative integer."); - } - - const comparison = compareFingerprints(tracked, local); - const gate = buildGateReport({ - comparison, - manifest, - maxDivergenceDays, - }); - - return { - schema: DRIFT_CHECK_SCHEMA, - trackedFingerprintId: gate.trackedFingerprintId, - localFingerprintId: gate.localFingerprintId, - overall: gate.overall, - dimensions: gate.dimensions, - gate, - }; -} - -export function formatDriftStatusMarkdown(report: DriftStatusReport): string { - return ["# Ghost drift status", "", `Package: ${report.packageDir}`, ""].join( - "\n", - ); -} - -export function formatDriftCheckMarkdown(report: DriftCheckReport): string { - const lines = [ - "# Ghost drift check", - "", - formatGateReportCLI(report.gate).trimEnd(), - "", - ]; - return lines.join("\n"); -} - -export function registerDriftCommand(cli: CAC): void { - cli - .command( - "drift ", - "Inspect Ghost drift status or run the stance-ledger check.", - ) - .option("--package ", "Exact fingerprint package directory") - .option("--config ", "Path to ghost config file for tracked source") - .option("--local ", "Local fingerprint or bundle to check") - .option("--tracked ", "Tracked/reference fingerprint or bundle") - .option("--sync ", "Sync manifest path (default: ./.ghost-sync.json)") - .option( - "--max-divergence-days ", - "Flag diverging dimensions older than this many days as uncovered", - ) - .option("--format ", "Output format: markdown or json", { - default: "markdown", - }) - .action(async (action: string, opts) => { - try { - if (opts.format !== "markdown" && opts.format !== "json") { - console.error("Error: --format must be 'markdown' or 'json'"); - process.exit(2); - return; - } - - if (action === "status") { - const report = await getDriftStatus({ - packageDir: - typeof opts.package === "string" ? opts.package : undefined, - }); - await writeAndFlush( - opts.format === "json" - ? `${JSON.stringify(report, null, 2)}\n` - : formatDriftStatusMarkdown(report), - ); - process.exit(0); - return; - } - - if (action !== "check") { - console.error( - "Error: unknown drift action. Supported: status, check", - ); - process.exit(2); - return; - } - - const report = await runDriftCheck({ - packageDir: - typeof opts.package === "string" ? opts.package : undefined, - config: typeof opts.config === "string" ? opts.config : undefined, - local: typeof opts.local === "string" ? opts.local : undefined, - tracked: typeof opts.tracked === "string" ? opts.tracked : undefined, - sync: typeof opts.sync === "string" ? opts.sync : undefined, - maxDivergenceDays: opts.maxDivergenceDays, - }); - await writeAndFlush( - opts.format === "json" - ? `${JSON.stringify(report, null, 2)}\n` - : formatDriftCheckMarkdown(report), - ); - process.exit(driftCheckExitCode(report)); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); -} - -function driftCheckExitCode(report: DriftCheckReport): number { - return gateExitCode(report.gate); -} - -async function readSyncManifest( - cwd: string, - syncPathOption: string | undefined, -): Promise { - const syncPath = resolve(cwd, syncPathOption ?? DEFAULT_SYNC_PATH); - if (!existsSync(syncPath)) { - throw new Error( - `sync manifest not found at ${syncPath}. Run \`ghost track\` or \`ghost ack\` first, or pass --sync .`, - ); - } - const manifest = JSON.parse( - await readFile(syncPath, "utf-8"), - ) as SyncManifest; - if (!manifest || typeof manifest !== "object" || !manifest.dimensions) { - throw new Error( - `sync manifest at ${syncPath} is malformed (missing dimensions).`, - ); - } - return manifest; -} - -function validateManifestFingerprintIds( - manifest: SyncManifest, - fingerprints: { local: Fingerprint; tracked: Fingerprint }, -): void { - if ( - manifest.trackedFingerprintId && - manifest.trackedFingerprintId !== fingerprints.tracked.id - ) { - throw new Error( - `sync manifest tracks fingerprint "${manifest.trackedFingerprintId}" but resolved tracked fingerprint "${fingerprints.tracked.id}". Run \`ghost track\`/\`ghost ack\` for this tracked source, or pass the matching --sync manifest.`, - ); - } - if ( - manifest.localFingerprintId && - manifest.localFingerprintId !== fingerprints.local.id - ) { - throw new Error( - `sync manifest was recorded for local fingerprint "${manifest.localFingerprintId}" but resolved local fingerprint "${fingerprints.local.id}". Run \`ghost ack\` for the current local fingerprint, or pass the matching --sync manifest.`, - ); - } -} - -async function writeAndFlush(text: string): Promise { - await new Promise((resolve) => { - process.stdout.write(text, () => resolve()); - }); -} - -async function loadLocalFingerprint( - cwd: string, - localPath: string | undefined, - packageDir: string | undefined, -): Promise { - const source = localPath ?? packageDir ?? ".ghost"; - try { - return await loadComparableFingerprintFrom(cwd, source); - } catch (err) { - const defaultPackage = !localPath && !packageDir; - const manifestPath = resolve(cwd, source, "manifest.yml"); - if (!defaultPackage || existsSync(manifestPath)) throw err; - return await loadComparableFingerprintFrom(cwd, ".ghost/fingerprint.md"); - } -} - -async function loadTrackedFingerprint( - cwd: string, - options: { - explicitPath?: string; - configPath?: string; - ledgerTarget?: Target | string; - }, -): Promise { - if (options.explicitPath) { - return loadComparableFingerprintFrom(cwd, options.explicitPath); - } - - const config = await loadConfig({ configPath: options.configPath, cwd }); - const target = config.tracks ?? normalizeTarget(options.ledgerTarget); - if (!target) { - throw new Error( - "No tracked fingerprint declared. Set `tracks` in ghost.config.ts/js, run `ghost track`, or pass --tracked .", - ); - } - return loadTargetFingerprint(cwd, target); -} - -function normalizeTarget( - value: Target | string | undefined, -): Target | undefined { - if (!value) return undefined; - return typeof value === "string" ? resolveTarget(value) : value; -} - -async function loadTargetFingerprint( - cwd: string, - target: Target, -): Promise { - if (target.type === "path") { - return loadComparableFingerprintFrom(cwd, target.value); - } - if (target.type === "npm") { - const packageGhostDir = resolve( - cwd, - "node_modules", - target.value, - ".ghost", - ); - if ( - existsSync(resolve(packageGhostDir, "manifest.yml")) || - existsSync(resolve(packageGhostDir, "fingerprint.md")) - ) { - return loadComparableFingerprintFrom(cwd, packageGhostDir); - } - } - return resolveTrackedFingerprint(target, cwd); -} - -async function loadComparableFingerprintFrom( - cwd: string, - path: string, -): Promise { - return loadComparableFingerprint(resolve(cwd, path)); -} - -function parseMaxDivergenceDays( - raw: number | string | undefined, -): number | undefined | "invalid" { - if (raw === undefined) return undefined; - const n = typeof raw === "number" ? raw : Number(raw); - if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return "invalid"; - return n; -} diff --git a/packages/ghost/src/evolution-commands.ts b/packages/ghost/src/evolution-commands.ts deleted file mode 100644 index 89e3e92f..00000000 --- a/packages/ghost/src/evolution-commands.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import type { CAC } from "cac"; -import { loadComparableFingerprint } from "./comparable-fingerprint.js"; -import type { DimensionStance, Target } from "./core/index.js"; -import { - acknowledge, - loadConfig, - resolveTrackedFingerprint, -} from "./core/index.js"; - -async function loadLocalFingerprint() { - return loadComparableFingerprint(".ghost"); -} - -async function loadTrackedComparableFingerprint( - target: Target, -): Promise>> { - if (target.type === "path") return loadComparableFingerprint(target.value); - if (target.type === "npm") { - const packageGhostDir = resolve("node_modules", target.value, ".ghost"); - if ( - existsSync(resolve(packageGhostDir, "manifest.yml")) || - existsSync(resolve(packageGhostDir, "fingerprint.md")) - ) { - return loadComparableFingerprint(packageGhostDir); - } - return resolveTrackedFingerprint(target); - } - return resolveTrackedFingerprint(target); -} - -export function registerAckCommand(cli: CAC): void { - cli - .command( - "ack", - "Acknowledge current drift — record intentional stance toward the tracked fingerprint", - ) - .option("-c, --config ", "Path to ghost config file") - .option("-d, --dimension ", "Acknowledge a specific dimension only") - .option("--stance ", "Stance: aligned, accepted, or diverging", { - default: "accepted", - }) - .option("--reason ", "Reason for this acknowledgment") - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (opts) => { - try { - const config = await loadConfig(opts.config); - - if (!config.tracks) { - console.error( - "Error: No tracked fingerprint declared. Set `tracks` in ghost.config.ts.", - ); - process.exit(2); - } - - const trackedFingerprint = await loadTrackedComparableFingerprint( - config.tracks, - ); - const localFingerprint = await loadLocalFingerprint(); - - const { manifest, comparison } = await acknowledge({ - local: localFingerprint, - tracked: trackedFingerprint, - tracks: config.tracks, - dimension: opts.dimension, - stance: opts.stance as DimensionStance, - reason: opts.reason, - }); - - if (opts.format === "json") { - process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); - } else { - console.log( - `Acknowledged drift from "${manifest.trackedFingerprintId}"`, - ); - console.log(`Overall distance: ${comparison.distance.toFixed(3)}`); - console.log(); - for (const [key, ack] of Object.entries(manifest.dimensions)) { - const marker = - ack.stance === "aligned" - ? "=" - : ack.stance === "diverging" - ? "~" - : "*"; - const reasonSuffix = ack.reason ? ` (${ack.reason})` : ""; - console.log( - ` ${marker} ${key}: ${ack.distance.toFixed(3)} [${ack.stance}]${reasonSuffix}`, - ); - } - console.log(); - console.log("Written to .ghost-sync.json"); - } - - process.exit(0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); -} - -export function registerTrackCommand(cli: CAC): void { - cli - .command( - "track ", - "Track another fingerprint as this repo's reference", - ) - .option("-d, --dimension ", "Track only for a specific dimension") - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (source: string, opts) => { - try { - const trackedFingerprint = await loadComparableFingerprint(source); - const localFingerprint = await loadLocalFingerprint(); - - const tracks: Target = { type: "path", value: source }; - - const { manifest, comparison } = await acknowledge({ - local: localFingerprint, - tracked: trackedFingerprint, - tracks, - dimension: opts.dimension, - stance: "accepted", - }); - - if (opts.format === "json") { - process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); - } else { - console.log(`Now tracking "${trackedFingerprint.id}"`); - console.log(`New distance: ${comparison.distance.toFixed(3)}`); - console.log(); - for (const [key, delta] of Object.entries(comparison.dimensions)) { - console.log(` ${key}: ${delta.distance.toFixed(3)}`); - } - console.log(); - console.log("Updated .ghost-sync.json"); - } - - process.exit(0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); -} - -export function registerDivergeCommand(cli: CAC): void { - cli - .command( - "diverge ", - "Declare intentional divergence on a dimension", - ) - .option("-c, --config ", "Path to ghost config file") - .option( - "-r, --reason ", - "Why this dimension is intentionally diverging", - ) - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (dimension: string, opts) => { - try { - const config = await loadConfig(opts.config); - - if (!config.tracks) { - console.error( - "Error: No tracked fingerprint declared. Set `tracks` in ghost.config.ts.", - ); - process.exit(2); - } - - const trackedFingerprint = await loadTrackedComparableFingerprint( - config.tracks, - ); - const localFingerprint = await loadLocalFingerprint(); - - const { manifest } = await acknowledge({ - local: localFingerprint, - tracked: trackedFingerprint, - tracks: config.tracks, - dimension, - stance: "diverging", - reason: opts.reason, - }); - - const ack = manifest.dimensions[dimension]; - - if (opts.format === "json") { - process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); - } else { - console.log(`Marked "${dimension}" as intentionally diverging`); - if (ack) { - console.log(` Distance: ${ack.distance.toFixed(3)}`); - } - if (opts.reason) { - console.log(` Reason: ${opts.reason}`); - } - console.log(); - console.log("Updated .ghost-sync.json"); - } - - process.exit(0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); -} diff --git a/packages/ghost/src/fingerprint-commands.ts b/packages/ghost/src/fingerprint-commands.ts index 31ac83e1..215b25b3 100644 --- a/packages/ghost/src/fingerprint-commands.ts +++ b/packages/ghost/src/fingerprint-commands.ts @@ -1,24 +1,20 @@ import { readFile, stat } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { resolve } from "node:path"; import type { CAC } from "cac"; -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { stringify as stringifyYaml } from "yaml"; import type { GhostPatternsDocument, Survey, SurveySummaryBudget, } from "#ghost-core"; import { - formatVerifyFingerprintReport, - type lintFingerprint, + type LintReport, lintFingerprintPackage, - loadFingerprint, resolveFingerprintPackage, - verifyFingerprintPackage, } from "./fingerprint.js"; import { registerInitCommand } from "./init-command.js"; import { detectFileKind, lintDetectedFileKind } from "./scan/file-kind.js"; import { resolveGhostDirDefault, scanStatus, signals } from "./scan/index.js"; -import { registerEmitCommand } from "./scan-emit-command.js"; /** * Register fingerprint package commands on the unified Ghost CLI. @@ -33,11 +29,11 @@ import { registerEmitCommand } from "./scan-emit-command.js"; * operational pattern synthesis. */ export function registerFingerprintCommands(cli: CAC): void { - // --- lint --- + // --- validate (shape pass + graph pass) --- cli .command( - "lint [file]", - "Validate a root Ghost fingerprint package, split fingerprint artifacts, checks, or direct markdown — defaults to .ghost", + "validate [file]", + "Validate the Ghost fingerprint package — artifact shape and the node graph (links resolve, one root, acyclic). Defaults to .ghost.", ) .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (path: string | undefined, opts) => { @@ -48,7 +44,7 @@ export function registerFingerprintCommands(cli: CAC): void { packagePath, process.cwd(), ).dir; - let report: ReturnType; + let report: LintReport; if (path === undefined || (await isDirectory(target))) { report = await lintFingerprintPackage(packagePath, process.cwd()); writeLintReport(report, opts.format); @@ -61,19 +57,6 @@ export function registerFingerprintCommands(cli: CAC): void { const kind = detectFileKind(fileTarget, raw); report = lintDetectedFileKind(kind, raw); - if (kind === "fingerprint" && hasExtends(raw) && report.errors === 0) { - try { - await loadFingerprint(fileTarget, { noEmbeddingBackfill: true }); - } catch (err) { - report = appendLintError( - report, - "extends-resolution", - err instanceof Error ? err.message : String(err), - "extends", - ); - } - } - writeLintReport(report, opts.format); process.exit(report.errors > 0 ? 1 : 0); @@ -87,49 +70,6 @@ export function registerFingerprintCommands(cli: CAC): void { registerInitCommand(cli); - // --- verify --- - cli - .command( - "verify [dir]", - "Verify a root Ghost fingerprint package: intent/composition evidence, inventory exemplars, and checks are grounded.", - ) - .option( - "--root ", - "Optional target root used to resolve fingerprint evidence and exemplar paths (default: cwd)", - ) - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (dirArg: string | undefined, opts) => { - try { - if (opts.format !== "cli" && opts.format !== "json") { - console.error("Error: --format must be 'cli' or 'json'"); - process.exit(2); - return; - } - - const ghostDir = ghostDirFromEnv(); - 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`); - } else { - process.stdout.write(formatVerifyFingerprintReport(report)); - } - - process.exit(report.errors > 0 ? 1 : 0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); - // --- scan --- cli .command( @@ -161,49 +101,26 @@ export function registerFingerprintCommands(cli: CAC): void { ); } else { process.stdout.write( - "next: edit contributing fingerprint facets, then run ghost verify/check/review\n", - ); - } - process.stdout.write(`contribution: ${status.contribution.state}\n`); - for (const facet of ["intent", "inventory", "composition"] as const) { - const report = status.contribution.facets[facet]; - process.stdout.write( - ` ${facet}: ${report.state} (${report.count})\n`, - ); - } - if (status.contribution.contributing_facets.length > 0) { - process.stdout.write( - ` contributing facets: ${status.contribution.contributing_facets.join(", ")}\n`, + "next: author nodes, then run ghost check/review\n", ); } - if (status.contribution.empty_facets.length > 0) { - process.stdout.write( - ` empty facets: ${status.contribution.empty_facets.join(", ")}\n`, - ); - } - if (status.contribution.absent_facets.length > 0) { + const c = status.contribution; + process.stdout.write(`contribution: ${c.state}\n`); + process.stdout.write( + ` nodes: ${c.node_count} (${c.essence_count} essence, ${c.incarnation_count} incarnation-tagged)\n`, + ); + for (const surface of c.surfaces) { process.stdout.write( - ` absent facets: ${status.contribution.absent_facets.join(", ")}\n`, + ` surface ${surface.id}: ${surface.node_count} node(s)\n`, ); } - if (status.contribution.reasons[0]) { + if (c.sparse_surfaces.length > 0) { process.stdout.write( - ` reason: ${status.contribution.reasons[0]}\n`, + ` sparse surfaces: ${c.sparse_surfaces.join(", ")}\n`, ); } - const buildingBlockRows = status.contribution.building_block_rows; - const buildingBlockCount = - buildingBlockRows.tokens + - buildingBlockRows.components + - buildingBlockRows.libraries + - buildingBlockRows.assets + - buildingBlockRows.routes + - buildingBlockRows.files + - buildingBlockRows.notes; - if (buildingBlockCount > 0) { - process.stdout.write( - ` 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 (c.reasons[0]) { + process.stdout.write(` reason: ${c.reasons[0]}\n`); } } process.exit(0); @@ -234,18 +151,13 @@ export function registerFingerprintCommands(cli: CAC): void { process.exit(2); } }); - - registerEmitCommand(cli); } function ghostDirFromEnv(): string { return resolveGhostDirDefault(); } -function writeLintReport( - report: ReturnType, - format: unknown, -): void { +function writeLintReport(report: LintReport, format: unknown): void { if (format === "json") { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return; @@ -276,39 +188,6 @@ async function isDirectory(path: string): Promise { } } -function hasExtends(raw: string): boolean { - try { - const frontmatter = raw.match(/^---\n([\s\S]*?)\n---/)?.[1]; - if (!frontmatter) return false; - const parsed = parseYaml(frontmatter); - return Boolean( - parsed && - typeof parsed === "object" && - typeof (parsed as Record).extends === "string", - ); - } catch { - return false; - } -} - -function appendLintError( - report: ReturnType, - rule: string, - message: string, - path?: string, -): ReturnType { - const issues = [ - ...report.issues, - { severity: "error" as const, rule, message, ...(path ? { path } : {}) }, - ]; - return { - issues, - errors: report.errors + 1, - warnings: report.warnings, - info: report.info, - }; -} - function _isSurveySummaryBudget(value: unknown): value is SurveySummaryBudget { return value === "compact" || value === "standard" || value === "full"; } diff --git a/packages/ghost/src/fingerprint-load.ts b/packages/ghost/src/fingerprint-load.ts deleted file mode 100644 index 956057c5..00000000 --- a/packages/ghost/src/fingerprint-load.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { dirname, isAbsolute, resolve } from "node:path"; -import type { Fingerprint, SemanticColor } from "#ghost-core"; -import { computeEmbedding, parseColorToOklch } from "#ghost-core"; -import { mergeFingerprint } from "./scan/compose.js"; -import { mergeFrontmatter } from "./scan/frontmatter.js"; -import { type ParsedFingerprint, parseFingerprint } from "./scan/parser.js"; -import { validateFrontmatter } from "./scan/schema.js"; - -export interface LoadOptions { - /** Skip `extends:` resolution. Default: false (extends chains are resolved). */ - noExtends?: boolean; - /** - * Skip embedding backfill. When true, a missing `embedding` stays empty; - * useful for read-only tooling (lint, diff-on-disk) that doesn't need - * the vector. - */ - noEmbeddingBackfill?: boolean; -} - -/** - * Load a ParsedFingerprint from disk. - * - * If the file declares `extends:`, the base fingerprint is loaded recursively and - * merged per the rules in compose.ts: overlay wins, decisions merged by - * dimension, palette colors merged by role. - */ -export async function loadFingerprint( - path: string, - options: LoadOptions = {}, -): Promise { - assertMarkdownPath(path); - - const parsed = options.noExtends - ? await loadRaw(path) - : await loadWithExtends(path, new Set()); - - // Backfill `oklch` on palette colors that arrived hex-only. Deterministic - // (same hex -> same oklch), so re-parsing the same fingerprint always - // yields the same in-memory shape. - backfillPaletteOklch(parsed.fingerprint); - - if (!options.noEmbeddingBackfill) { - parsed.fingerprint.embedding = resolveEmbedding(parsed.fingerprint); - } - - return parsed; -} - -function assertMarkdownPath(path: string): void { - if (!path.endsWith(".md")) { - throw new Error( - `Fingerprint files must be Markdown (.md). Got: ${path}. The legacy JSON format has been removed — regenerate by running the fingerprint recipe in your host agent (install with \`ghost skill install\`).`, - ); - } -} - -function backfillPaletteOklch(fingerprint: Fingerprint): void { - if (!fingerprint.palette) return; - if (fingerprint.palette.dominant) { - fingerprint.palette.dominant = - fingerprint.palette.dominant.map(ensureOklch); - } - if (fingerprint.palette.semantic) { - fingerprint.palette.semantic = - fingerprint.palette.semantic.map(ensureOklch); - } -} - -function ensureOklch(color: SemanticColor): SemanticColor { - if (color.oklch && color.oklch.length === 3) return color; - const oklch = parseColorToOklch(color.value); - return oklch ? { ...color, oklch } : color; -} - -function resolveEmbedding(fingerprint: Fingerprint): number[] { - if (fingerprint.embedding && fingerprint.embedding.length > 0) { - return fingerprint.embedding; - } - if ( - fingerprint.palette && - fingerprint.spacing && - fingerprint.typography && - fingerprint.surfaces - ) { - return computeEmbedding(fingerprint); - } - return []; -} - -async function loadRaw(path: string): Promise { - assertMarkdownPath(path); - const raw = await readFile(path, "utf-8"); - return parseFingerprint(raw); -} - -async function loadWithExtends( - path: string, - visited: Set, -): Promise { - assertMarkdownPath(path); - const absolute = isAbsolute(path) ? path : resolve(path); - if (visited.has(absolute)) { - throw new Error( - `Cycle detected while resolving extends: chain — ${absolute} visited twice.`, - ); - } - visited.add(absolute); - - const raw = await readFile(absolute, "utf-8"); - const overlay = parseFingerprint(raw); - if (!overlay.meta.extends) { - return overlay; - } - - const basePath = resolve(dirname(absolute), overlay.meta.extends); - const base = await loadWithExtends(basePath, visited); - - const merged = mergeFingerprint(base.fingerprint, overlay.fingerprint); - validateFrontmatter(mergeFrontmatter(merged)); - - const { extends: _dropped, ...overlayMeta } = overlay.meta; - return { - fingerprint: merged, - meta: { ...base.meta, ...overlayMeta }, - body: overlay.body, - bodyRaw: overlay.bodyRaw, - }; -} diff --git a/packages/ghost/src/fingerprint.ts b/packages/ghost/src/fingerprint.ts index eac4a918..107f0890 100644 --- a/packages/ghost/src/fingerprint.ts +++ b/packages/ghost/src/fingerprint.ts @@ -1,9 +1,3 @@ -export type { LoadOptions } from "./fingerprint-load.js"; -export { loadFingerprint } from "./fingerprint-load.js"; -export type { BodyData } from "./scan/body.js"; -export { parseBody } from "./scan/body.js"; -export type { DesignDecision } from "./scan/compose.js"; -export { mergeFingerprint } from "./scan/compose.js"; export { CHECKS_FILENAME, FINGERPRINT_COMPOSITION_FILENAME, @@ -18,13 +12,6 @@ export { RESOURCES_FILENAME, SCOPE_SURVEYS_DIRNAME, } from "./scan/constants.js"; -export type { - ColorChange, - DecisionChange, - SemanticDiff, - TokenChange, -} from "./scan/diff.js"; -export { diffFingerprints, formatSemanticDiff } from "./scan/diff.js"; export type { FingerprintPackagePaths, LoadedFingerprintPackage, @@ -35,40 +22,10 @@ export { loadFingerprintPackage, resolveFingerprintPackage, } from "./scan/fingerprint-package.js"; -export type { FingerprintMeta, FrontmatterData } from "./scan/frontmatter.js"; -export type { - FingerprintLayout, - FingerprintLayoutSection, -} from "./scan/layout.js"; -export { formatLayout, layoutFingerprint } from "./scan/layout.js"; export type { LintIssue, LintOptions, LintReport, LintSeverity, } from "./scan/lint.js"; -export { lintFingerprint } from "./scan/lint.js"; export { normalizeReferenceInput } from "./scan/package-config.js"; -export type { ParsedFingerprint, ParseOptions } from "./scan/parser.js"; -export { parseFingerprint, splitRaw } from "./scan/parser.js"; -export type { FrontmatterShape } from "./scan/schema.js"; -export { - FrontmatterSchema, - PartialFrontmatterSchema, - toJsonSchema, - validateFrontmatter, -} from "./scan/schema.js"; -export type { - VerifyFingerprintIssue, - VerifyFingerprintOptions, - VerifyFingerprintReport, - VerifyFingerprintSeverity, -} from "./scan/verify-fingerprint.js"; -export { - formatVerifyFingerprintReport, - verifyFingerprint, -} from "./scan/verify-fingerprint.js"; -export type { VerifyFingerprintPackageOptions } from "./scan/verify-package.js"; -export { verifyFingerprintPackage } from "./scan/verify-package.js"; -export type { SerializeOptions } from "./scan/writer.js"; -export { serializeFingerprint } from "./scan/writer.js"; diff --git a/packages/ghost/src/gather-command.ts b/packages/ghost/src/gather-command.ts index bcf79897..ebf986b0 100644 --- a/packages/ghost/src/gather-command.ts +++ b/packages/ghost/src/gather-command.ts @@ -1,16 +1,15 @@ import type { CAC } from "cac"; import { buildSurfaceMenu, - type ResolvedSlice, - resolveSurfaceSlice, - type SliceProvenance, + GHOST_GRAPH_ROOT_ID, + type GraphSlice, + type GraphSliceProvenance, + resolveGraphSlice, type SurfaceMenuEntry, } from "#ghost-core"; import { resolveFingerprintPackage } from "./fingerprint.js"; import { loadFingerprintPackage } from "./scan/fingerprint-package.js"; -const GHOST_SURFACE_ROOT_ID = "core"; - export function registerGatherCommand(cli: CAC): void { cli .command( @@ -21,6 +20,10 @@ export function registerGatherCommand(cli: CAC): void { "--package ", "Use this fingerprint package directory (default: ./.ghost)", ) + .option( + "--as ", + "Filter to one incarnation (e.g. email, billboard, voice). Essence (untagged) nodes always pass.", + ) .option("--format ", "Output format: markdown or json", { default: "markdown", }) @@ -32,6 +35,11 @@ export function registerGatherCommand(cli: CAC): void { return; } + const incarnation = + typeof opts.as === "string" && opts.as.length > 0 + ? opts.as + : undefined; + const paths = resolveFingerprintPackage(opts.package, process.cwd()); const loaded = await loadFingerprintPackage(paths); const menu = buildSurfaceMenu(loaded.surfaces); @@ -56,11 +64,9 @@ export function registerGatherCommand(cli: CAC): void { return; } - const slice = resolveSurfaceSlice( - loaded.surfaces, - loaded.fingerprint, - surface, - ); + const slice = resolveGraphSlice(loaded.graph, surface, { + ...(incarnation !== undefined ? { incarnation } : {}), + }); if (opts.format === "json") { process.stdout.write(`${JSON.stringify(slice, null, 2)}\n`); @@ -106,57 +112,54 @@ function formatMenuMarkdown( return `${lines.join("\n")}\n`; } -function provenanceLabel(provenance: SliceProvenance): string { +function provenanceLabel(provenance: GraphSliceProvenance): string { switch (provenance.kind) { case "own": return "own"; case "ancestor": - return `from \`${provenance.surface}\``; + return `from \`${provenance.from}\``; case "edge": - return `${provenance.edge} \`${provenance.surface}\``; + return provenance.via + ? `${provenance.via} \`${provenance.from}\`` + : `relates \`${provenance.from}\``; } } -function formatSliceMarkdown(slice: ResolvedSlice): string { +const PROVENANCE_RANK: Record = { + own: 0, + ancestor: 1, + edge: 2, +}; + +function formatSliceMarkdown(slice: GraphSlice): string { const lines: string[] = [`# Ghost Context: \`${slice.surface}\``]; const chain = - slice.surface === GHOST_SURFACE_ROOT_ID + slice.surface === GHOST_GRAPH_ROOT_ID ? slice.surface : [slice.surface, ...slice.ancestors].join(" → "); lines.push("", `Cascade: ${chain}`); + if (slice.incarnation) lines.push(`As: ${slice.incarnation}`); - section(lines, "Situations", slice.situations, (entry) => { - const node = entry.node; - return `\`${node.id}\` — ${node.title ?? node.user_intent ?? node.id} (${provenanceLabel(entry.provenance)})`; - }); - section(lines, "Principles", slice.principles, (entry) => { - return `\`${entry.node.id}\` — ${entry.node.principle} (${provenanceLabel(entry.provenance)})`; - }); - section( - lines, - "Experience contracts", - slice.experience_contracts, - (entry) => { - return `\`${entry.node.id}\` — ${entry.node.contract} (${provenanceLabel(entry.provenance)})`; - }, + // Provenance-ordered: own first, then ancestors, then edges. + const ordered = [...slice.nodes].sort( + (a, b) => + PROVENANCE_RANK[a.provenance.kind] - PROVENANCE_RANK[b.provenance.kind], ); - section(lines, "Patterns", slice.patterns, (entry) => { - return `\`${entry.node.id}\` (${entry.node.kind}) — ${entry.node.pattern} (${provenanceLabel(entry.provenance)})`; - }); - return `${lines.join("\n")}\n`; -} - -function section( - lines: string[], - title: string, - entries: T[], - render: (entry: T) => string, -): void { - lines.push("", `## ${title}`); - if (entries.length === 0) { + lines.push("", "## Nodes"); + if (ordered.length === 0) { lines.push("- none"); - return; + } else { + for (const node of ordered) { + const tag = node.incarnation ? ` _(as ${node.incarnation})_` : ""; + lines.push( + "", + `### \`${node.id}\` — ${provenanceLabel(node.provenance)}${tag}`, + "", + node.body, + ); + } } - for (const entry of entries) lines.push(`- ${render(entry)}`); + + return `${lines.join("\n")}\n`; } diff --git a/packages/ghost/src/ghost-core/check/parse.ts b/packages/ghost/src/ghost-core/check/parse.ts index 8ebb40e5..8c8257ec 100644 --- a/packages/ghost/src/ghost-core/check/parse.ts +++ b/packages/ghost/src/ghost-core/check/parse.ts @@ -1,34 +1,15 @@ -import { parse as parseYaml } from "yaml"; +import { type ParsedMarkdown, splitMarkdownFrontmatter } from "../markdown.js"; -export interface ParsedCheckMarkdown { - /** Raw parsed frontmatter object (unvalidated), or null when absent. */ - frontmatter: Record | null; - body: string; -} +export type ParsedCheckMarkdown = ParsedMarkdown; /** * Split a markdown check into its YAML frontmatter and body. A check file is * `---\n\n---\n`. Returns `frontmatter: null` when there is no * leading frontmatter block (the caller's lint reports it as an error). + * + * Thin alias over the shared {@link splitMarkdownFrontmatter}; checks and nodes + * share one envelope splitter. */ export function parseCheckMarkdown(raw: string): ParsedCheckMarkdown { - const text = raw.replace(/^\uFEFF/, ""); - const lines = text.split(/\r?\n/); - if (lines[0]?.trim() !== "---") { - return { frontmatter: null, body: text }; - } - for (let i = 1; i < lines.length; i++) { - if (lines[i]?.trim() === "---") { - const yaml = lines.slice(1, i).join("\n"); - const body = lines.slice(i + 1).join("\n"); - const parsed = parseYaml(yaml); - const frontmatter = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; - return { frontmatter, body: body.replace(/^\n+/, "") }; - } - } - // Opening fence with no close: treat the whole thing as body, no frontmatter. - return { frontmatter: null, body: text }; + return splitMarkdownFrontmatter(raw); } diff --git a/packages/ghost/src/ghost-core/check/route.ts b/packages/ghost/src/ghost-core/check/route.ts index 5bd3066e..1bc30674 100644 --- a/packages/ghost/src/ghost-core/check/route.ts +++ b/packages/ghost/src/ghost-core/check/route.ts @@ -1,8 +1,5 @@ -import { ancestorChain, buildParentMap } from "../surfaces/cascade.js"; -import { - GHOST_SURFACE_ROOT_ID, - type GhostSurfacesDocument, -} from "../surfaces/types.js"; +import { ancestorChain } from "../graph/assemble.js"; +import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "../graph/types.js"; import type { GhostCheckDocument } from "./types.js"; /** Why a check is relevant to a diff: placed on a touched surface, or cascaded. */ @@ -18,27 +15,26 @@ export interface RoutedCheck { /** * Select the markdown checks relevant to a set of touched surfaces, * deterministically and with no LLM. A check governs a touched surface when its - * `surface:` equals that surface (own) or any ancestor of it (cascade) — the - * same rule the slice resolver uses for context. An unplaced check governs - * `core`, so it applies to every diff. + * `surface:` equals that surface (own) or any **graph** ancestor of it + * (cascade) — the same ancestry the slice resolver uses for context. An + * unplaced check governs `core`, so it applies to every diff. * * Ghost selects and emits; it never runs the check. The host agent evaluates * the markdown rule. */ export function selectChecksForSurfaces( checks: GhostCheckDocument[], - surfaces: GhostSurfacesDocument | undefined, + graph: GhostGraph, touchedSurfaces: string[], ): RoutedCheck[] { - const parentOf = buildParentMap(surfaces); - // For each touched surface, the set of surfaces whose checks apply: itself - // plus its ancestors (up to and including core). Track, per governing + // plus its graph ancestors (up to and including core). Track, per governing // surface, the nearest touched surface it cascades into (for provenance). const governing = new Map(); for (const touched of touchedSurfaces) { record(governing, touched, { kind: "own", surface: touched }); - for (const ancestor of ancestorChain(touched, parentOf)) { + for (const ancestor of ancestorChain(graph, touched)) { + if (ancestor === touched) continue; record(governing, ancestor, { kind: "ancestor", surface: ancestor, @@ -47,14 +43,14 @@ export function selectChecksForSurfaces( } } // core governs every diff even when no surface was touched. - record(governing, GHOST_SURFACE_ROOT_ID, { + record(governing, GHOST_GRAPH_ROOT_ID, { kind: "own", - surface: GHOST_SURFACE_ROOT_ID, + surface: GHOST_GRAPH_ROOT_ID, }); const routed: RoutedCheck[] = []; for (const check of checks) { - const placement = check.frontmatter.surface ?? GHOST_SURFACE_ROOT_ID; + const placement = check.frontmatter.surface ?? GHOST_GRAPH_ROOT_ID; const relevance = governing.get(placement); if (relevance) routed.push({ check, relevance }); } diff --git a/packages/ghost/src/ghost-core/decision-vocabulary.ts b/packages/ghost/src/ghost-core/decision-vocabulary.ts deleted file mode 100644 index b4abd291..00000000 --- a/packages/ghost/src/ghost-core/decision-vocabulary.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Canonical decision-dimension vocabulary. - * - * Free-form `decisions[].dimension` slugs are great for authoring but bad - * for fleet aggregation: ghost-ui's `color-strategy` and a hypothetical - * Cash app's `color-system` describe the same axis under different names, - * and N-way overlap on incidentally-shared labels is not a basis for - * cross-system distance. - * - * The fix is a small controlled vocabulary. Fingerprint authors pick from this list - * first; non-canonical slugs are still permitted (the schema allows any - * string), but the recommended pattern is to pair them with a - * `dimension_kind` that maps to a canonical slug. Lint warns when a - * non-canonical dimension has no canonical kind. Fleet-rollup primitives - * group by `dimension_kind` (or by `dimension` when it's already - * canonical) so the decision-overlap distance axis becomes meaningful. - * - * The list below started from the actual decisions produced by fingerprinting - * ghost-ui, then absorbed dogfood learnings where generated UI needed a - * first-class place for task-shaped composition rather than treating every - * answer as a generic card stack. - */ -export const CANONICAL_DECISION_DIMENSIONS = [ - "color-strategy", - "surface-hierarchy", - "shape-language", - "typography-voice", - "spatial-system", - "density", - "motion", - "elevation", - "theming-architecture", - "interactive-patterns", - "token-architecture", - "font-sourcing", - "composition-patterns", -] as const; - -export type CanonicalDecisionDimension = - (typeof CANONICAL_DECISION_DIMENSIONS)[number]; - -const CANONICAL_SET: ReadonlySet = new Set( - CANONICAL_DECISION_DIMENSIONS, -); - -/** - * Direct synonyms — common slug variants we've observed or expect, mapped - * to the canonical dimension. Lookup is exact-match (post-normalization). - */ -const SYNONYMS: Readonly> = { - // color-strategy - "color-system": "color-strategy", - "color-philosophy": "color-strategy", - "color-approach": "color-strategy", - "palette-strategy": "color-strategy", - "palette-system": "color-strategy", - "hue-strategy": "color-strategy", - // surface-hierarchy - "surface-vocabulary": "surface-hierarchy", - "surface-system": "surface-hierarchy", - "background-hierarchy": "surface-hierarchy", - "background-system": "surface-hierarchy", - // shape-language - "radius-philosophy": "shape-language", - "radius-strategy": "shape-language", - "corner-treatment": "shape-language", - "corner-radii": "shape-language", - geometry: "shape-language", - // typography-voice - "type-voice": "typography-voice", - "type-stack": "typography-voice", - "type-hierarchy": "typography-voice", - "typographic-voice": "typography-voice", - "typography-system": "typography-voice", - // spatial-system - spacing: "spatial-system", - "spacing-scale": "spatial-system", - "spacing-system": "spatial-system", - "layout-rhythm": "spatial-system", - // density - compactness: "density", - "control-density": "density", - // motion - animation: "motion", - "motion-language": "motion", - "motion-system": "motion", - "animation-philosophy": "motion", - // elevation - "shadow-system": "elevation", - "shadow-vocabulary": "elevation", - "depth-language": "elevation", - // theming-architecture - theming: "theming-architecture", - "theme-architecture": "theming-architecture", - "theme-system": "theming-architecture", - themeability: "theming-architecture", - // interactive-patterns - "interaction-patterns": "interactive-patterns", - "focus-treatment": "interactive-patterns", - "hover-system": "interactive-patterns", - "interaction-design": "interactive-patterns", - // token-architecture - "token-system": "token-architecture", - // font-sourcing - "font-stack": "font-sourcing", - "font-strategy": "font-sourcing", - "font-loading": "font-sourcing", - "font-bundling": "font-sourcing", - // composition-patterns - "composition-shape": "composition-patterns", - "composition-shapes": "composition-patterns", - "response-shape": "composition-patterns", - "response-shapes": "composition-patterns", - "output-shape": "composition-patterns", - "output-shapes": "composition-patterns", - "layout-patterns": "composition-patterns", - "exemplar-shapes": "composition-patterns", -}; - -/** - * Token-level affinity — when a slug has no direct synonym, score it by - * how strongly its dash-separated tokens evoke each canonical dimension. - * The token "color" alone is a strong signal for color-strategy; "shadow" - * is strong for elevation. Used by `closestCanonical` as a fallback. - * - * Each entry is `[token, dimension]`. A token may map to multiple - * dimensions (e.g. "font" hints both font-sourcing and typography-voice); - * the scorer sums signals across dimensions and returns the strongest. - */ -const TOKEN_HINTS: ReadonlyArray< - readonly [string, CanonicalDecisionDimension] -> = [ - ["color", "color-strategy"], - ["palette", "color-strategy"], - ["hue", "color-strategy"], - ["chroma", "color-strategy"], - ["surface", "surface-hierarchy"], - ["background", "surface-hierarchy"], - ["bg", "surface-hierarchy"], - ["radius", "shape-language"], - ["radii", "shape-language"], - ["corner", "shape-language"], - ["shape", "shape-language"], - ["pill", "shape-language"], - ["typography", "typography-voice"], - ["type", "typography-voice"], - ["typographic", "typography-voice"], - ["heading", "typography-voice"], - ["spacing", "spatial-system"], - ["space", "spatial-system"], - ["spatial", "spatial-system"], - ["layout", "spatial-system"], - ["rhythm", "spatial-system"], - ["density", "density"], - ["compact", "density"], - ["motion", "motion"], - ["animation", "motion"], - ["transition", "motion"], - ["shadow", "elevation"], - ["elevation", "elevation"], - ["depth", "elevation"], - ["theme", "theming-architecture"], - ["theming", "theming-architecture"], - ["themeable", "theming-architecture"], - ["interaction", "interactive-patterns"], - ["interactive", "interactive-patterns"], - ["focus", "interactive-patterns"], - ["hover", "interactive-patterns"], - ["token", "token-architecture"], - ["alias", "token-architecture"], - ["font", "font-sourcing"], - ["typeface", "font-sourcing"], - ["composition", "composition-patterns"], - ["response", "composition-patterns"], - ["output", "composition-patterns"], - ["article", "composition-patterns"], - ["tracker", "composition-patterns"], - ["comparison", "composition-patterns"], -]; - -/** - * Normalize a dimension slug for lookup: trim, lowercase, collapse - * separators (`_`, ` `, repeated `-`) into single dashes. - */ -function normalize(slug: string): string { - return slug - .trim() - .toLowerCase() - .replace(/[_\s]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); -} - -/** - * Returns true when `slug` is in the canonical vocabulary (after - * normalization). Use to gate fleet-aggregation paths that require - * commensurable dimension labels across members. - */ -export function isCanonicalDimension( - slug: string, -): slug is CanonicalDecisionDimension { - return CANONICAL_SET.has(normalize(slug)); -} - -/** - * Suggest the closest canonical dimension for a free-form slug. - * - * Resolution order: - * 1. Exact canonical match (after normalization). - * 2. Direct synonym lookup. - * 3. Token-affinity scoring across `TOKEN_HINTS` — wins when a single - * dimension scores strictly higher than all others. - * 4. `null` when there's no clear winner. Callers should treat null as - * "this slug is genuinely novel; lint warns and the fingerprint keeps it - * long-tail." - * - * Pure / deterministic. No I/O. - */ -export function closestCanonical( - slug: string, -): CanonicalDecisionDimension | null { - if (!slug) return null; - const norm = normalize(slug); - if (!norm) return null; - - if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; - - const synonym = SYNONYMS[norm]; - if (synonym) return synonym; - - const tokens = norm.split("-").filter(Boolean); - if (tokens.length === 0) return null; - - const scores = new Map(); - for (const token of tokens) { - for (const [hint, dim] of TOKEN_HINTS) { - if (hint === token) { - scores.set(dim, (scores.get(dim) ?? 0) + 1); - } - } - } - if (scores.size === 0) return null; - - let best: CanonicalDecisionDimension | null = null; - let bestScore = 0; - let tied = false; - for (const [dim, score] of scores) { - if (score > bestScore) { - best = dim; - bestScore = score; - tied = false; - } else if (score === bestScore) { - tied = true; - } - } - return tied ? null : best; -} - -/** - * Resolve a decision's effective canonical dimension for fleet rollup: - * prefer an explicit `dimension_kind` (when it's canonical), otherwise - * fall back to the slug if it's canonical, otherwise null. - * - * The fleet aggregator groups decisions by this resolved value; null - * means the decision lives in the long tail and is reported per-member, - * not aggregated. - */ -export function resolveDecisionKind(decision: { - dimension: string; - dimension_kind?: string; -}): CanonicalDecisionDimension | null { - if (decision.dimension_kind) { - const norm = normalize(decision.dimension_kind); - if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; - } - const norm = normalize(decision.dimension); - if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; - return null; -} diff --git a/packages/ghost/src/ghost-core/embedding/colors.ts b/packages/ghost/src/ghost-core/embedding/colors.ts deleted file mode 100644 index 1e8b6e8a..00000000 --- a/packages/ghost/src/ghost-core/embedding/colors.ts +++ /dev/null @@ -1,335 +0,0 @@ -import type { SemanticColor } from "../types.js"; - -// Parse hex color to RGB -function hexToRgb(hex: string): [number, number, number] | null { - const cleaned = hex.replace("#", ""); - let r: number; - let g: number; - let b: number; - - if (cleaned.length === 3) { - r = Number.parseInt(cleaned[0] + cleaned[0], 16); - g = Number.parseInt(cleaned[1] + cleaned[1], 16); - b = Number.parseInt(cleaned[2] + cleaned[2], 16); - } else if (cleaned.length === 6) { - r = Number.parseInt(cleaned.slice(0, 2), 16); - g = Number.parseInt(cleaned.slice(2, 4), 16); - b = Number.parseInt(cleaned.slice(4, 6), 16); - } else { - return null; - } - - return [r, g, b]; -} - -// Parse rgb()/rgba() to RGB -function parseRgbFunction(value: string): [number, number, number] | null { - const match = value.match(/rgba?\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)/); - if (!match) return null; - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} - -// Parse hsl()/hsla() to RGB -function parseHslFunction(value: string): [number, number, number] | null { - const match = value.match( - /hsla?\(\s*([\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?/, - ); - if (!match) return null; - - let h = Number(match[1]) % 360; - if (h < 0) h += 360; - const s = Math.min(Number(match[2]), 100) / 100; - const l = Math.min(Number(match[3]), 100) / 100; - - // HSL to RGB conversion - const c = (1 - Math.abs(2 * l - 1)) * s; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m = l - c / 2; - - let r1: number; - let g1: number; - let b1: number; - - if (h < 60) { - [r1, g1, b1] = [c, x, 0]; - } else if (h < 120) { - [r1, g1, b1] = [x, c, 0]; - } else if (h < 180) { - [r1, g1, b1] = [0, c, x]; - } else if (h < 240) { - [r1, g1, b1] = [0, x, c]; - } else if (h < 300) { - [r1, g1, b1] = [x, 0, c]; - } else { - [r1, g1, b1] = [c, 0, x]; - } - - return [ - Math.round((r1 + m) * 255), - Math.round((g1 + m) * 255), - Math.round((b1 + m) * 255), - ]; -} - -// Common CSS named colors (top 20 most used in real projects) -const CSS_NAMED_COLORS: Record = { - white: [255, 255, 255], - black: [0, 0, 0], - red: [255, 0, 0], - green: [0, 128, 0], - blue: [0, 0, 255], - yellow: [255, 255, 0], - orange: [255, 165, 0], - purple: [128, 0, 128], - pink: [255, 192, 203], - gray: [128, 128, 128], - grey: [128, 128, 128], - navy: [0, 0, 128], - teal: [0, 128, 128], - coral: [255, 127, 80], - salmon: [250, 128, 114], - tomato: [255, 99, 71], - gold: [255, 215, 0], - silver: [192, 192, 192], - maroon: [128, 0, 0], - aqua: [0, 255, 255], - cyan: [0, 255, 255], - lime: [0, 255, 0], - indigo: [75, 0, 130], - violet: [238, 130, 238], - crimson: [220, 20, 60], - magenta: [255, 0, 255], - turquoise: [64, 224, 208], - ivory: [255, 255, 240], - beige: [245, 245, 220], - khaki: [240, 230, 140], -}; - -// CSS system color defaults (mapped to sensible RGB values) -const SYSTEM_COLORS: Record = { - canvas: [255, 255, 255], - canvastext: [0, 0, 0], - linktext: [0, 0, 238], - visitedtext: [85, 26, 139], - activetext: [255, 0, 0], - buttonface: [240, 240, 240], - buttontext: [0, 0, 0], - buttonborder: [118, 118, 118], - field: [255, 255, 255], - fieldtext: [0, 0, 0], - highlight: [0, 120, 215], - highlighttext: [255, 255, 255], - graytext: [109, 109, 109], - mark: [255, 255, 0], - marktext: [0, 0, 0], -}; - -// Convert sRGB to linear RGB -function linearize(c: number): number { - const s = c / 255; - return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; -} - -// Convert linear RGB to OKLCH via OKLab -// Simplified implementation based on the OKLCH spec -function rgbToOklch(r: number, g: number, b: number): [number, number, number] { - const lr = linearize(r); - const lg = linearize(g); - const lb = linearize(b); - - // sRGB to LMS (using OKLab matrix) - const l = Math.cbrt( - 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb, - ); - const m = Math.cbrt( - 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb, - ); - const s = Math.cbrt( - 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb, - ); - - // LMS to OKLab - const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; - const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; - const bVal = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; - - // OKLab to OKLCH - const C = Math.sqrt(a * a + bVal * bVal); - let H = (Math.atan2(bVal, a) * 180) / Math.PI; - if (H < 0) H += 360; - - return [ - Math.round(L * 1000) / 1000, - Math.round(C * 1000) / 1000, - Math.round(H * 10) / 10, - ]; -} - -// Parse color-mix() in OKLCH space -function parseColorMix(value: string): [number, number, number] | null { - const match = value.match( - /color-mix\(\s*in\s+oklch\s*,\s*(.+?)\s+(\d+)%\s*,\s*(.+?)(?:\s+(\d+)%)?\s*\)/, - ); - if (!match) return null; - - const color1 = parseColorToOklch(match[1]); - const color2 = parseColorToOklch(match[3]); - if (!color1 || !color2) return null; - - const pct1 = Number(match[2]) / 100; - const pct2 = match[4] ? Number(match[4]) / 100 : 1 - pct1; - - // Normalize percentages - const total = pct1 + pct2; - const w1 = pct1 / total; - const w2 = pct2 / total; - - // Interpolate hue via shortest arc - const h1 = color1[2]; - const h2 = color2[2]; - let hDiff = h2 - h1; - if (hDiff > 180) hDiff -= 360; - if (hDiff < -180) hDiff += 360; - const hue = (((h1 + w2 * hDiff) % 360) + 360) % 360; - - return [ - Math.round((color1[0] * w1 + color2[0] * w2) * 1000) / 1000, - Math.round((color1[1] * w1 + color2[1] * w2) * 1000) / 1000, - Math.round(hue * 10) / 10, - ]; -} - -export function parseColorToOklch( - value: string, -): [number, number, number] | null { - const trimmed = value.trim().toLowerCase(); - - // Skip CSS variables and transparent - if ( - trimmed.startsWith("var(") || - trimmed === "transparent" || - trimmed === "currentcolor" - ) { - return null; - } - - // Try hex - if (trimmed.startsWith("#")) { - const rgb = hexToRgb(trimmed); - if (rgb) return rgbToOklch(...rgb); - } - - // Try rgb()/rgba() - if (trimmed.startsWith("rgb")) { - const rgb = parseRgbFunction(trimmed); - if (rgb) return rgbToOklch(...rgb); - } - - // Try hsl()/hsla() - if (trimmed.startsWith("hsl")) { - const rgb = parseHslFunction(trimmed); - if (rgb) return rgbToOklch(...rgb); - } - - // Try oklch() directly — handle both decimal and percentage lightness - const oklchMatch = trimmed.match( - /oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)/, - ); - if (oklchMatch) { - let L = Number(oklchMatch[1]); - if (oklchMatch[2] === "%") L /= 100; - return [L, Number(oklchMatch[3]), Number(oklchMatch[4])]; - } - - // Try color-mix(in oklch, ...) - if (trimmed.startsWith("color-mix(")) { - return parseColorMix(trimmed); - } - - // Try CSS system colors - const systemRgb = SYSTEM_COLORS[trimmed]; - if (systemRgb) return rgbToOklch(...systemRgb); - - // Try CSS named colors - const namedRgb = CSS_NAMED_COLORS[trimmed]; - if (namedRgb) return rgbToOklch(...namedRgb); - - return null; -} - -export function colorToSemanticColor( - role: string, - value: string, -): SemanticColor { - const oklch = parseColorToOklch(value); - return { role, value, oklch: oklch ?? undefined }; -} - -/** - * Resolve a color's oklch tuple, computing on-the-fly from `value` if the - * field is missing. Defensive backstop for palette comparisons — without - * this, hex-only colors land in the "unmatched" branch and contribute - * distance 1 even when both sides have the same hex. - * - * `loadFingerprint` (in ghost) already backfills oklch on read; - * this fallback covers third-party producers that emit hex-only. - */ -export function resolveColorOklch( - c: SemanticColor, -): [number, number, number] | null { - if (c.oklch && c.oklch.length === 3) return c.oklch; - return parseColorToOklch(c.value); -} - -export function classifySaturation( - colors: SemanticColor[], -): "muted" | "vibrant" | "mixed" { - const chromas = colors.map((c) => c.oklch?.[1] ?? 0).filter((c) => c > 0); - - if (chromas.length === 0) return "muted"; - - const avg = chromas.reduce((a, b) => a + b, 0) / chromas.length; - if (avg > 0.15) return "vibrant"; - if (avg < 0.05) return "muted"; - return "mixed"; -} - -export function classifyContrast( - colors: SemanticColor[], -): "high" | "moderate" | "low" { - const lightnesses = colors - .map((c) => c.oklch?.[0] ?? 0.5) - .filter((_, _i, arr) => arr.length > 1); - - if (lightnesses.length < 2) return "moderate"; - - const min = Math.min(...lightnesses); - const max = Math.max(...lightnesses); - const range = max - min; - - if (range > 0.7) return "high"; - if (range < 0.3) return "low"; - return "moderate"; -} - -/** - * Continuous saturation score (0-1) for embedding use. - * Avoids the lossy categorical→numeric mapping. - */ -export function saturationScore(colors: SemanticColor[]): number { - const chromas = colors.map((c) => c.oklch?.[1] ?? 0).filter((c) => c > 0); - if (chromas.length === 0) return 0; - const avg = chromas.reduce((a, b) => a + b, 0) / chromas.length; - return Math.min(avg / 0.25, 1); -} - -/** - * Continuous contrast score (0-1) for embedding use. - * Based on lightness range of the palette. - */ -export function contrastScore(colors: SemanticColor[]): number { - const lightnesses = colors.map((c) => c.oklch?.[0] ?? 0.5); - if (lightnesses.length < 2) return 0.5; - const range = Math.max(...lightnesses) - Math.min(...lightnesses); - return Math.min(range / 0.9, 1); -} diff --git a/packages/ghost/src/ghost-core/embedding/compare.ts b/packages/ghost/src/ghost-core/embedding/compare.ts deleted file mode 100644 index e2fbdeba..00000000 --- a/packages/ghost/src/ghost-core/embedding/compare.ts +++ /dev/null @@ -1,591 +0,0 @@ -import type { - DimensionDelta, - Fingerprint, - FingerprintComparison, -} from "../types.js"; -import { resolveColorOklch } from "./colors.js"; -import { computeDriftVectors } from "./vector.js"; - -export interface CompareOptions { - includeVectors?: boolean; -} - -const WEIGHTS: Record = { - palette: 0.35, - spacing: 0.25, - typography: 0.25, - surfaces: 0.15, -}; - -/** Redistributed weights when both fingerprints have design decisions */ -const WEIGHTS_WITH_DECISIONS: Record = { - decisions: 0.15, - palette: 0.3, - spacing: 0.2, - typography: 0.2, - surfaces: 0.15, -}; - -export function compareFingerprints( - source: Fingerprint, - target: Fingerprint, - options?: CompareOptions, -): FingerprintComparison { - const dimensions: Record = {}; - - // Compare decisions when both fingerprints have them. - // Decisions only contribute to the weighted distance when both sides have - // embeddings — otherwise we record a qualitative delta without a scalar - // that would pollute the number. - const bothHaveDecisions = - (source.decisions?.length ?? 0) > 0 && (target.decisions?.length ?? 0) > 0; - const bothEmbedded = - bothHaveDecisions && - (source.decisions ?? []).every((d) => Array.isArray(d.embedding)) && - (target.decisions ?? []).every((d) => Array.isArray(d.embedding)); - - if (bothHaveDecisions) { - dimensions.decisions = compareDecisions(source, target, bothEmbedded); - } - - dimensions.palette = comparePalette(source, target); - dimensions.spacing = compareSpacing(source, target); - dimensions.typography = compareTypography(source, target); - dimensions.surfaces = compareSurfaces(source, target); - - // Only use decision-inclusive weights when decisions are actually scored - const weights = bothEmbedded ? WEIGHTS_WITH_DECISIONS : WEIGHTS; - - // Weighted overall distance - let distance = 0; - for (const [key, weight] of Object.entries(weights)) { - distance += (dimensions[key]?.distance ?? 0) * weight; - } - - const summary = buildSummary(dimensions, distance); - - const result: FingerprintComparison = { - source, - target, - distance, - dimensions, - summary, - }; - - if (options?.includeVectors) { - result.vectors = computeDriftVectors(source, target); - } - - return result; -} - -function comparePalette(a: Fingerprint, b: Fingerprint): DimensionDelta { - const distances: number[] = []; - - // Compare dominant colors by role, then by position for unmatched - const aByRole = new Map(a.palette.dominant.map((c) => [c.role, c])); - const bByRole = new Map(b.palette.dominant.map((c) => [c.role, c])); - const allDominantRoles = new Set([...aByRole.keys(), ...bByRole.keys()]); - const matchedA = new Set(); - const matchedB = new Set(); - - // First pass: match by role name - for (const role of allDominantRoles) { - const ca = aByRole.get(role); - const cb = bByRole.get(role); - if (!ca || !cb) continue; - const oa = resolveColorOklch(ca); - const ob = resolveColorOklch(cb); - if (oa && ob) { - distances.push(oklchDistance(oa, ob)); - matchedA.add(role); - matchedB.add(role); - } else if (ca.value === cb.value) { - // Both hex-only on a non-parseable value — but the values match. - // Treat as identical rather than falling through to "unmatched". - distances.push(0); - matchedA.add(role); - matchedB.add(role); - } - } - - // Second pass: unmatched colors count as missing - const unmatchedA = a.palette.dominant.filter((c) => !matchedA.has(c.role)); - const unmatchedB = b.palette.dominant.filter((c) => !matchedB.has(c.role)); - const unmatchedCount = Math.max(unmatchedA.length, unmatchedB.length); - for (let i = 0; i < unmatchedCount; i++) { - const ca = unmatchedA[i]; - const cb = unmatchedB[i]; - if (!ca || !cb) { - distances.push(1); - continue; - } - const oa = resolveColorOklch(ca); - const ob = resolveColorOklch(cb); - if (oa && ob) { - distances.push(oklchDistance(oa, ob)); - } else if (ca.value === cb.value) { - distances.push(0); - } else { - distances.push(1); - } - } - - // Compare semantic role coverage - const aRoles = new Set(a.palette.semantic.map((c) => c.role)); - const bRoles = new Set(b.palette.semantic.map((c) => c.role)); - const allRoles = new Set([...aRoles, ...bRoles]); - const sharedRoles = [...allRoles].filter( - (r) => aRoles.has(r) && bRoles.has(r), - ); - const roleCoverage = - allRoles.size > 0 ? 1 - sharedRoles.length / allRoles.size : 0; - distances.push(roleCoverage); - - // Compare qualitative - if (a.palette.saturationProfile !== b.palette.saturationProfile) - distances.push(0.5); - if (a.palette.contrast !== b.palette.contrast) distances.push(0.5); - - // Compare semantic colors that exist in both - for (const role of sharedRoles) { - const ca = a.palette.semantic.find((c) => c.role === role); - const cb = b.palette.semantic.find((c) => c.role === role); - if (!ca || !cb) continue; - const oa = resolveColorOklch(ca); - const ob = resolveColorOklch(cb); - if (oa && ob) { - distances.push(oklchDistance(oa, ob)); - } else if (ca.value === cb.value) { - distances.push(0); - } - } - - const distance = avg(distances); - const description = describePaletteChange(a, b, distance); - - return { dimension: "palette", distance, description }; -} - -function compareSpacing(a: Fingerprint, b: Fingerprint): DimensionDelta { - const distances: number[] = []; - - // Scale similarity (Jaccard-like) - const aSet = new Set(a.spacing.scale); - const bSet = new Set(b.spacing.scale); - const union = new Set([...aSet, ...bSet]); - const intersection = [...union].filter((v) => aSet.has(v) && bSet.has(v)); - distances.push(union.size > 0 ? 1 - intersection.length / union.size : 0); - - // Regularity delta - distances.push(Math.abs(a.spacing.regularity - b.spacing.regularity)); - - // Base unit match - if (a.spacing.baseUnit && b.spacing.baseUnit) { - distances.push(a.spacing.baseUnit === b.spacing.baseUnit ? 0 : 0.5); - } - - const distance = avg(distances); - return { - dimension: "spacing", - distance, - description: - distance < 0.1 - ? "Spacing scales are nearly identical" - : distance < 0.3 - ? "Minor spacing differences" - : "Significant spacing divergence", - }; -} - -function compareTypography(a: Fingerprint, b: Fingerprint): DimensionDelta { - const distances: number[] = []; - - // Family match — fuzzy comparison - distances.push( - 1 - fontListSimilarity(a.typography.families, b.typography.families), - ); - - // Size ramp similarity - const aRamp = new Set(a.typography.sizeRamp); - const bRamp = new Set(b.typography.sizeRamp); - const rampUnion = new Set([...aRamp, ...bRamp]); - const rampIntersection = [...rampUnion].filter( - (v) => aRamp.has(v) && bRamp.has(v), - ); - distances.push( - rampUnion.size > 0 ? 1 - rampIntersection.length / rampUnion.size : 0, - ); - - // Line height pattern - if (a.typography.lineHeightPattern !== b.typography.lineHeightPattern) - distances.push(0.3); - - const distance = avg(distances); - return { - dimension: "typography", - distance, - description: - distance < 0.1 - ? "Typography systems match" - : distance < 0.3 - ? "Minor typographic differences" - : "Different typographic language", - }; -} - -function compareSurfaces(a: Fingerprint, b: Fingerprint): DimensionDelta { - const distances: number[] = []; - - // Border radii overlap - const aRadii = new Set(a.surfaces.borderRadii); - const bRadii = new Set(b.surfaces.borderRadii); - const radiiUnion = new Set([...aRadii, ...bRadii]); - const radiiIntersection = [...radiiUnion].filter( - (v) => aRadii.has(v) && bRadii.has(v), - ); - distances.push( - radiiUnion.size > 0 ? 1 - radiiIntersection.length / radiiUnion.size : 0, - ); - - // Shadow complexity - if (a.surfaces.shadowComplexity !== b.surfaces.shadowComplexity) - distances.push(0.5); - - // Border usage - if (a.surfaces.borderUsage !== b.surfaces.borderUsage) distances.push(0.3); - - const distance = avg(distances); - return { - dimension: "surfaces", - distance, - description: - distance < 0.1 - ? "Surface treatments align" - : distance < 0.3 - ? "Minor surface differences" - : "Distinct surface language", - }; -} - -// --- Decision matching --- - -/** Minimum cosine similarity to consider two decisions "the same dimension". */ -const DECISION_MATCH_THRESHOLD = 0.75; - -/** - * Compare design decisions between two fingerprints. - * - * When `bothEmbedded` is true: match decisions pairwise by cosine similarity - * of their embeddings. Distance blends unmatched coverage with the cosine - * distance of matched pairs. Deterministic and paraphrase-robust. - * - * When `bothEmbedded` is false: record a qualitative delta but return distance 0 - * so decisions don't pollute the weighted scalar. Callers exclude this dimension - * from the weighted distance (see `WEIGHTS` vs `WEIGHTS_WITH_DECISIONS`). - */ -function compareDecisions( - a: Fingerprint, - b: Fingerprint, - bothEmbedded: boolean, -): DimensionDelta { - const aDecs = a.decisions ?? []; - const bDecs = b.decisions ?? []; - - if (!bothEmbedded) { - return { - dimension: "decisions", - distance: 0, - description: `Decisions present (${aDecs.length} vs ${bDecs.length}) but embeddings missing — not scored`, - }; - } - - // Greedy one-to-one match: for each decision in A, find the best unmatched - // decision in B above threshold. Stable and O(n*m), which is fine for - // fingerprints with ~5–15 decisions. - const matchedB = new Set(); - const matchedCosines: number[] = []; - - for (const da of aDecs) { - let bestJ = -1; - let bestCos = DECISION_MATCH_THRESHOLD; - for (let j = 0; j < bDecs.length; j++) { - if (matchedB.has(j)) continue; - const cos = cosineSimilarity( - da.embedding as number[], - bDecs[j].embedding as number[], - ); - if (cos > bestCos) { - bestCos = cos; - bestJ = j; - } - } - if (bestJ >= 0) { - matchedB.add(bestJ); - matchedCosines.push(bestCos); - } - } - - const matchCount = matchedCosines.length; - const totalDecs = aDecs.length + bDecs.length; - - // Coverage: fraction of decisions that went unmatched (normalised across both sides). - const coverageDistance = totalDecs > 0 ? 1 - (2 * matchCount) / totalDecs : 0; - - // Agreement: mean cosine distance across matched pairs. - const agreementDistance = - matchCount > 0 - ? matchedCosines.reduce((sum, cos) => sum + (1 - cos), 0) / matchCount - : 1; - - const distance = coverageDistance * 0.4 + agreementDistance * 0.6; - - let description: string; - if (distance < 0.1) description = "Design decisions align closely"; - else if (distance < 0.3) - description = "Minor differences in design decisions"; - else if (distance < 0.5) - description = "Moderate divergence in design philosophy"; - else description = "Fundamentally different design decisions"; - - return { dimension: "decisions", distance, description }; -} - -/** Cosine similarity between two equal-length vectors. Returns 0 for zero-norm. */ -function cosineSimilarity(a: number[], b: number[]): number { - if (a.length !== b.length || a.length === 0) return 0; - let dot = 0; - let normA = 0; - let normB = 0; - for (let i = 0; i < a.length; i++) { - dot += a[i] * b[i]; - normA += a[i] * a[i]; - normB += b[i] * b[i]; - } - const denom = Math.sqrt(normA) * Math.sqrt(normB); - return denom > 0 ? dot / denom : 0; -} - -// --- Font matching --- - -const FONT_SUFFIXES = /\b(variable|var|vf|pro|new|next|display|text|mono)\b/gi; - -/** Normalize font family name for fuzzy comparison. - * - * `FONT_SUFFIXES` intentionally omits a leading `\s*` — combining it with - * `\b` and alternation gives CodeQL's polynomial-redos check an ambiguous - * split. The trailing `.replace(/\s+/g, " ").trim()` folds any whitespace - * the suffix strip left behind, so the result is equivalent. - */ -function normalizeFontFamily(name: string): string { - return name - .replace(/['"]/g, "") - .replace(FONT_SUFFIXES, "") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); -} - -/** Levenshtein distance between two strings */ -function levenshtein(a: string, b: string): number { - const m = a.length; - const n = b.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => - new Array(n + 1).fill(0), - ); - for (let i = 0; i <= m; i++) dp[i][0] = i; - for (let j = 0; j <= n; j++) dp[0][j] = j; - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - dp[i][j] = - a[i - 1] === b[j - 1] - ? dp[i - 1][j - 1] - : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); - } - } - return dp[m][n]; -} - -// Font category lookup for common fonts -const FONT_CATEGORIES: Record = { - // Sans-serif - inter: "sans-serif", - arial: "sans-serif", - helvetica: "sans-serif", - roboto: "sans-serif", - "open sans": "sans-serif", - lato: "sans-serif", - nunito: "sans-serif", - poppins: "sans-serif", - montserrat: "sans-serif", - raleway: "sans-serif", - ubuntu: "sans-serif", - manrope: "sans-serif", - geist: "sans-serif", - "dm sans": "sans-serif", - "plus jakarta sans": "sans-serif", - "source sans": "sans-serif", - "work sans": "sans-serif", - "hk grotesk": "sans-serif", - "cash sans": "sans-serif", - "sf pro": "sans-serif", - "system-ui": "sans-serif", - "sans-serif": "sans-serif", - // Serif - georgia: "serif", - "times new roman": "serif", - garamond: "serif", - "playfair display": "serif", - merriweather: "serif", - lora: "serif", - "source serif": "serif", - "dm serif": "serif", - serif: "serif", - // Monospace - "jetbrains mono": "monospace", - "fira code": "monospace", - "source code": "monospace", - "geist mono": "monospace", - "dm mono": "monospace", - "ibm plex mono": "monospace", - "sf mono": "monospace", - menlo: "monospace", - consolas: "monospace", - monaco: "monospace", - "courier new": "monospace", - monospace: "monospace", - // Display - playfair: "display", - "bebas neue": "display", - // Apple system fonts - "san francisco": "sans-serif", - "sf compact": "sans-serif", - "new york": "serif", - system: "sans-serif", -}; - -function getFontCategory(normalizedName: string): string | null { - // Exact match - if (FONT_CATEGORIES[normalizedName]) return FONT_CATEGORIES[normalizedName]; - // Partial match: check if any known font is a prefix - for (const [font, cat] of Object.entries(FONT_CATEGORIES)) { - if (normalizedName.startsWith(font) || font.startsWith(normalizedName)) { - return cat; - } - } - return null; -} - -/** - * Compute similarity between two font names (0 = no match, 1 = identical). - * Uses normalization, Levenshtein distance, and category fallback. - */ -function fontSimilarity(a: string, b: string): number { - const normA = normalizeFontFamily(a); - const normB = normalizeFontFamily(b); - - // Exact match after normalization - if (normA === normB) return 1.0; - - // Levenshtein-based similarity - const maxLen = Math.max(normA.length, normB.length); - if (maxLen === 0) return 1.0; - const dist = levenshtein(normA, normB); - const similarity = 1 - dist / maxLen; - - // If names are very similar (>= 0.7), use that score - if (similarity >= 0.7) return similarity; - - // Category fallback: same category = 0.3 floor - const catA = getFontCategory(normA); - const catB = getFontCategory(normB); - if (catA && catB && catA === catB) return Math.max(similarity, 0.3); - - return similarity; -} - -/** - * Compute font list similarity using best-match pairing. - * Each font in list A is matched to its best counterpart in list B. - */ -function fontListSimilarity(aFonts: string[], bFonts: string[]): number { - if (aFonts.length === 0 && bFonts.length === 0) return 1; - if (aFonts.length === 0 || bFonts.length === 0) return 0; - - // For each font in A, find best match in B - let totalSim = 0; - for (const fa of aFonts) { - let bestSim = 0; - for (const fb of bFonts) { - bestSim = Math.max(bestSim, fontSimilarity(fa, fb)); - } - totalSim += bestSim; - } - // Symmetric: also match B→A and average - let totalSimReverse = 0; - for (const fb of bFonts) { - let bestSim = 0; - for (const fa of aFonts) { - bestSim = Math.max(bestSim, fontSimilarity(fa, fb)); - } - totalSimReverse += bestSim; - } - - const avgA = totalSim / aFonts.length; - const avgB = totalSimReverse / bFonts.length; - return (avgA + avgB) / 2; -} - -// --- Helpers --- - -function oklchDistance( - a: [number, number, number], - b: [number, number, number], -): number { - // Weighted OKLCH distance (lightness matters most, then chroma, then hue) - const dL = Math.abs(a[0] - b[0]); // 0-1 - const dC = Math.abs(a[1] - b[1]); // 0-0.4 typical - const dH = Math.min(Math.abs(a[2] - b[2]), 360 - Math.abs(a[2] - b[2])) / 180; // normalized - - return Math.min(dL * 0.5 + dC * 2 + dH * 0.3, 1); -} - -function avg(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((a, b) => a + b, 0) / values.length; -} - -function describePaletteChange( - _a: Fingerprint, - _b: Fingerprint, - distance: number, -): string { - if (distance < 0.1) return "Color palettes are nearly identical"; - if (distance < 0.3) return "Minor palette variations"; - return "Significant palette divergence"; -} - -function buildSummary( - dimensions: Record, - distance: number, -): string { - if (distance < 0.05) return "These projects share the same design language."; - if (distance < 0.15) - return "Minor design differences — likely the same system with small customizations."; - if (distance < 0.3) - return "Moderate divergence — shared foundation but notable differences."; - if (distance < 0.5) - return "Significant divergence — different design decisions across multiple dimensions."; - - // Identify the biggest divergence - const sorted = Object.entries(dimensions).sort( - ([, a], [, b]) => b.distance - a.distance, - ); - const biggest = sorted[0]; - if (biggest) { - return `Fundamentally different design languages. Largest gap: ${biggest[0]} (${(biggest[1].distance * 100).toFixed(0)}%).`; - } - return "Fundamentally different design languages."; -} - -export { embeddingDistance } from "./embedding.js"; diff --git a/packages/ghost/src/ghost-core/embedding/describe.ts b/packages/ghost/src/ghost-core/embedding/describe.ts deleted file mode 100644 index 02044a52..00000000 --- a/packages/ghost/src/ghost-core/embedding/describe.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Fingerprint } from "../types.js"; - -/** - * Render a Fingerprint as a standardized natural language description. - * This text is fed to embedding models to produce semantic vectors. - * - * The description is structured to emphasize design-relevant signals - * and minimize noise from identifiers or timestamps. - */ -export function describeFingerprint(fp: Fingerprint): string { - const sections: string[] = []; - - // Observation (Layer 1) — prepend when available for richer semantic embedding - if (fp.observation) { - sections.push(fp.observation.summary); - } - - // Design decisions (Layer 2) - if (fp.decisions && fp.decisions.length > 0) { - const decisionText = fp.decisions - .map((d) => `${d.dimension}: ${d.decision}`) - .join(". "); - sections.push(`${decisionText}.`); - } - - // Values (Layer 3) - sections.push(describePalette(fp)); - sections.push(describeSpacing(fp)); - sections.push(describeTypography(fp)); - sections.push(describeSurfaces(fp)); - - return sections.filter(Boolean).join(" "); -} - -function describePalette(fp: Fingerprint): string { - const parts: string[] = []; - - const { palette } = fp; - - if (palette.dominant.length > 0) { - const colors = palette.dominant - .map((c) => { - const oklch = c.oklch - ? `oklch(${c.oklch[0]}, ${c.oklch[1]}, ${c.oklch[2]})` - : c.value; - return `${c.role}: ${oklch}`; - }) - .join(", "); - parts.push(`Dominant colors: ${colors}.`); - } - - if (palette.semantic.length > 0) { - const roles = palette.semantic - .map((c) => { - const oklch = c.oklch - ? `oklch(${c.oklch[0]}, ${c.oklch[1]}, ${c.oklch[2]})` - : c.value; - return `${c.role}: ${oklch}`; - }) - .join(", "); - parts.push(`Semantic colors: ${roles}.`); - } - - if (palette.neutrals.count > 0) { - parts.push(`${palette.neutrals.count}-step neutral gray ramp.`); - } - - parts.push( - `${palette.saturationProfile} saturation profile, ${palette.contrast} contrast.`, - ); - - return parts.join(" "); -} - -function describeSpacing(fp: Fingerprint): string { - const { spacing } = fp; - const parts: string[] = []; - - if (spacing.scale.length > 0) { - parts.push(`Spacing scale: ${spacing.scale.join(", ")}px.`); - } else { - parts.push("No spacing scale detected."); - } - - if (spacing.baseUnit) { - parts.push(`Base unit: ${spacing.baseUnit}px.`); - } - - const regularity = - spacing.regularity > 0.8 - ? "highly regular" - : spacing.regularity > 0.4 - ? "moderately regular" - : "irregular"; - parts.push(`Scale is ${regularity}.`); - - return parts.join(" "); -} - -function describeTypography(fp: Fingerprint): string { - const { typography } = fp; - const parts: string[] = []; - - if (typography.families.length > 0) { - parts.push(`Font families: ${typography.families.join(", ")}.`); - } - - if (typography.sizeRamp.length > 0) { - const min = typography.sizeRamp[0]; - const max = typography.sizeRamp[typography.sizeRamp.length - 1]; - parts.push( - `Type scale: ${typography.sizeRamp.length} sizes from ${min}px to ${max}px.`, - ); - } - - const weightEntries = Object.entries(typography.weightDistribution); - if (weightEntries.length > 0) { - const weights = weightEntries - .map(([w, count]) => `${w} (${count}x)`) - .join(", "); - parts.push(`Font weights: ${weights}.`); - } - - parts.push(`Line height: ${typography.lineHeightPattern}.`); - - return parts.join(" "); -} - -function describeSurfaces(fp: Fingerprint): string { - const { surfaces } = fp; - const parts: string[] = []; - - if (surfaces.borderRadii.length > 0) { - parts.push( - `Border radii: ${surfaces.borderRadii.map((r) => `${r}px`).join(", ")}.`, - ); - } else { - parts.push("No border radii detected."); - } - - parts.push(`Shadow complexity: ${surfaces.shadowComplexity}.`); - parts.push(`Border usage: ${surfaces.borderUsage}.`); - - return parts.join(" "); -} diff --git a/packages/ghost/src/ghost-core/embedding/embed-api.ts b/packages/ghost/src/ghost-core/embedding/embed-api.ts deleted file mode 100644 index 22476895..00000000 --- a/packages/ghost/src/ghost-core/embedding/embed-api.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { EmbeddingConfig, Fingerprint } from "../types.js"; -import { describeFingerprint } from "./describe.js"; - -/** - * Generate a semantic embedding for a fingerprint using an external API. - * - * Converts the structured fingerprint into a natural language description, - * then sends it to an embedding model. The resulting vector captures semantic - * similarity — two projects using `bg-slate-900` and `--color-gray-900: #0f172a` - * will land nearby because the model understands they express the same intent. - * - * Supported providers: - * - openai: Uses text-embedding-3-small (default). Set OPENAI_API_KEY env var. - * - voyage: Uses voyage-3 (default). Set VOYAGE_API_KEY env var. - */ -export async function computeSemanticEmbedding( - fingerprint: Fingerprint, - config: EmbeddingConfig, -): Promise { - const text = describeFingerprint(fingerprint); - const [vec] = await embedTexts([text], config); - return vec; -} - -/** - * Embed a batch of texts in one API call. - * - * Returns one vector per input in the same order. Used to embed design - * decisions at fingerprint authoring time so compare can match them by cosine similarity - * without making API calls during comparison. - */ -export async function embedTexts( - texts: string[], - config: EmbeddingConfig, -): Promise { - if (texts.length === 0) return []; - - switch (config.provider) { - case "openai": - return embedViaOpenAI(texts, config); - case "voyage": - return embedViaVoyage(texts, config); - default: - throw new Error(`Unknown embedding provider: ${config.provider}`); - } -} - -async function embedViaOpenAI( - texts: string[], - config: EmbeddingConfig, -): Promise { - const apiKey = config.apiKey ?? process.env.OPENAI_API_KEY; - if (!apiKey) { - throw new Error( - "OpenAI API key required for embeddings. Set OPENAI_API_KEY env var or embedding.apiKey in config.", - ); - } - - const model = config.model ?? "text-embedding-3-small"; - - const response = await fetch("https://api.openai.com/v1/embeddings", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ input: texts, model }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error(`OpenAI embedding API error (${response.status}): ${body}`); - } - - const data = (await response.json()) as { - data: { embedding: number[]; index: number }[]; - }; - - // OpenAI returns results with `index` matching input position - const ordered = new Array(texts.length); - for (const item of data.data) { - ordered[item.index] = item.embedding; - } - return ordered; -} - -async function embedViaVoyage( - texts: string[], - config: EmbeddingConfig, -): Promise { - const apiKey = config.apiKey ?? process.env.VOYAGE_API_KEY; - if (!apiKey) { - throw new Error( - "Voyage API key required for embeddings. Set VOYAGE_API_KEY env var or embedding.apiKey in config.", - ); - } - - const model = config.model ?? "voyage-3"; - - const response = await fetch("https://api.voyageai.com/v1/embeddings", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ input: texts, model }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error(`Voyage embedding API error (${response.status}): ${body}`); - } - - const data = (await response.json()) as { - data: { embedding: number[]; index?: number }[]; - }; - - // Voyage preserves input order in `data[]` - return data.data.map((d) => d.embedding); -} diff --git a/packages/ghost/src/ghost-core/embedding/embedding.ts b/packages/ghost/src/ghost-core/embedding/embedding.ts deleted file mode 100644 index eb756d2d..00000000 --- a/packages/ghost/src/ghost-core/embedding/embedding.ts +++ /dev/null @@ -1,333 +0,0 @@ -import type { Fingerprint } from "../types.js"; -import { contrastScore, saturationScore } from "./colors.js"; - -type FingerprintInput = Omit; - -// Fixed embedding size for comparability -const EMBEDDING_SIZE = 49; - -// Normalization constants — centralized for discoverability and tuning -const NORM = { - // Log-base for count normalization (count → log2(count+1) / log2(base)) - spacingCountLogBase: 32, - // Linear divisors - spacingValueMax: 100, - spacingSpreadMax: 50, - baseUnitMax: 32, - radiusMinMax: 64, - radiusMaxPill: 100, - radiusSpread: 64, - radiusMedian: 64, - sizeRampMax: 100, - familyCountMax: 5, - sizeRampCountMax: 10, - weightCountMax: 6, - sizeRangeRatioMax: 10, - radiiCountMax: 5, - stepRatioMax: 4, - spacingRangeRatioMax: 50, - semanticCountMax: 10, - neutralCountMax: 10, - neutralDensityMax: 20, - borderTokenCountMax: 10, -} as const; - -/** Logarithmic normalization: preserves ordering, avoids ceiling effects */ -function logNorm(count: number, logBase: number): number { - return Math.min(Math.log2(count + 1) / Math.log2(logBase), 1); -} - -/** - * Compute a deterministic numeric embedding from a structured fingerprint. - * This ensures fingerprints from different sources (LLM, registry, extraction) - * produce comparable vectors. - * - * Dimensions (49 total): - * [0-11] Palette: dominant colors OKLCH (up to 4 colors x 3 channels) - * [12-17] Palette: neutral ramp features (count, has neutrals, ramp density, lightness min/max/range) - * [18-20] Palette: qualitative (saturation profile, contrast, semantic count) - * [21-30] Spacing: scale features (count, min, max, regularity, base unit, median, spread, step ratio, density, range ratio) - * [31-40] Typography: families count, size ramp features, weight distribution, line height, weight spread, ramp range - * [41-48] Surfaces: radii features, shadow complexity, border usage, radii spread, radii median, max radius - */ -export function computeEmbedding(fingerprint: FingerprintInput): number[] { - const vec: number[] = new Array(EMBEDDING_SIZE).fill(0); - let i = 0; - - // --- Palette: dominant colors (12 dims) --- - const dominantSlots = 4; - for (let s = 0; s < dominantSlots; s++) { - const color = fingerprint.palette.dominant[s]; - if (color?.oklch) { - vec[i++] = color.oklch[0]; // L (0-1) - vec[i++] = color.oklch[1]; // C (0-0.4 typical) - vec[i++] = color.oklch[2] / 360; // H normalized to 0-1 - } else { - i += 3; - } - } - - // --- Palette: neutral ramp (6 dims) --- - const neutralCount = fingerprint.palette.neutrals.count; - vec[i++] = Math.min(neutralCount / NORM.neutralCountMax, 1); - vec[i++] = neutralCount > 0 ? 1 : 0; - vec[i++] = Math.min(neutralCount / NORM.neutralDensityMax, 1); - - // Estimate lightness range from neutral steps using semantic colors as proxy - const neutralLightnesses = fingerprint.palette.semantic - .filter( - (c) => - c.oklch && - (c.role.startsWith("surface") || - c.role.startsWith("text") || - c.role === "muted"), - ) - .map((c) => c.oklch?.[0]) - .filter((v): v is number => v != null); - if (neutralLightnesses.length >= 2) { - vec[i++] = Math.min(...neutralLightnesses); - vec[i++] = Math.max(...neutralLightnesses); - vec[i++] = - Math.max(...neutralLightnesses) - Math.min(...neutralLightnesses); - } else { - i += 3; - } - - // --- Palette: qualitative (3 dims) — continuous scoring --- - const allSemanticAndDominant = [ - ...fingerprint.palette.semantic, - ...fingerprint.palette.dominant, - ]; - vec[i++] = saturationScore(allSemanticAndDominant); - vec[i++] = contrastScore(allSemanticAndDominant); - vec[i++] = Math.min( - fingerprint.palette.semantic.length / NORM.semanticCountMax, - 1, - ); - - // --- Spacing (10 dims) --- - const spacing = fingerprint.spacing; - vec[i++] = logNorm(spacing.scale.length, NORM.spacingCountLogBase); - vec[i++] = - spacing.scale.length > 0 - ? Math.min(spacing.scale[0] / NORM.spacingValueMax, 1) - : 0; - vec[i++] = - spacing.scale.length > 0 - ? Math.min( - spacing.scale[spacing.scale.length - 1] / NORM.spacingValueMax, - 1, - ) - : 0; - vec[i++] = spacing.regularity; - vec[i++] = spacing.baseUnit - ? Math.min(spacing.baseUnit / NORM.baseUnitMax, 1) - : 0; - // Median value - const spacingMid = - spacing.scale.length > 0 - ? spacing.scale[Math.floor(spacing.scale.length / 2)] / - NORM.spacingValueMax - : 0; - vec[i++] = Math.min(spacingMid, 1); - // Spread (stddev-like): how varied is the scale? - if (spacing.scale.length >= 2) { - const mean = - spacing.scale.reduce((a, b) => a + b, 0) / spacing.scale.length; - const variance = - spacing.scale.reduce((sum, v) => sum + (v - mean) ** 2, 0) / - spacing.scale.length; - vec[i++] = Math.min(Math.sqrt(variance) / NORM.spacingSpreadMax, 1); - } else { - vec[i++] = 0; - } - // Step ratio: ratio between consecutive values (geometric vs linear) - if (spacing.scale.length >= 3) { - const ratios: number[] = []; - for (let s = 1; s < spacing.scale.length; s++) { - if (spacing.scale[s - 1] > 0) { - ratios.push(spacing.scale[s] / spacing.scale[s - 1]); - } - } - const avgRatio = - ratios.length > 0 ? ratios.reduce((a, b) => a + b, 0) / ratios.length : 1; - vec[i++] = Math.min(avgRatio / NORM.stepRatioMax, 1); - } else { - vec[i++] = 0; - } - // Density: values per unit range - if (spacing.scale.length >= 2) { - const range = spacing.scale[spacing.scale.length - 1] - spacing.scale[0]; - vec[i++] = range > 0 ? Math.min(spacing.scale.length / range, 1) : 0; - } else { - vec[i++] = 0; - } - // Range ratio: max/min - if (spacing.scale.length >= 2 && spacing.scale[0] > 0) { - vec[i++] = Math.min( - spacing.scale[spacing.scale.length - 1] / - spacing.scale[0] / - NORM.spacingRangeRatioMax, - 1, - ); - } else { - vec[i++] = 0; - } - - // --- Typography (10 dims) --- - const typo = fingerprint.typography; - vec[i++] = Math.min(typo.families.length / NORM.familyCountMax, 1); - vec[i++] = Math.min(typo.sizeRamp.length / NORM.sizeRampCountMax, 1); - // Size range - vec[i++] = - typo.sizeRamp.length > 0 - ? Math.min(typo.sizeRamp[0] / NORM.sizeRampMax, 1) - : 0; - vec[i++] = - typo.sizeRamp.length > 0 - ? Math.min(typo.sizeRamp[typo.sizeRamp.length - 1] / NORM.sizeRampMax, 1) - : 0; - // Weight distribution entropy - const weights = Object.values(typo.weightDistribution); - const totalWeights = weights.reduce((a, b) => a + b, 0); - vec[i++] = - totalWeights > 0 - ? -weights.reduce((ent, w) => { - const p = w / totalWeights; - return p > 0 ? ent + p * Math.log2(p) : ent; - }, 0) / Math.log2(Math.max(weights.length, 2)) - : 0; - // Line height - vec[i++] = - typo.lineHeightPattern === "tight" - ? 0 - : typo.lineHeightPattern === "normal" - ? 0.5 - : 1; - // Weight count: how many distinct weights are used - vec[i++] = Math.min( - Object.keys(typo.weightDistribution).length / NORM.weightCountMax, - 1, - ); - // Weight spread: range of weights used (100-900 scale) - const weightKeys = Object.keys(typo.weightDistribution).map(Number); - if (weightKeys.length >= 2) { - vec[i++] = (Math.max(...weightKeys) - Math.min(...weightKeys)) / 800; - } else { - vec[i++] = 0; - } - // Size ramp range ratio - if (typo.sizeRamp.length >= 2 && typo.sizeRamp[0] > 0) { - vec[i++] = Math.min( - typo.sizeRamp[typo.sizeRamp.length - 1] / - typo.sizeRamp[0] / - NORM.sizeRangeRatioMax, - 1, - ); - } else { - vec[i++] = 0; - } - // Size ramp median - if (typo.sizeRamp.length > 0) { - vec[i++] = Math.min( - typo.sizeRamp[Math.floor(typo.sizeRamp.length / 2)] / NORM.sizeRampMax, - 1, - ); - } else { - vec[i++] = 0; - } - - // --- Surfaces (8 dims) --- - const surfaces = fingerprint.surfaces; - vec[i++] = Math.min(surfaces.borderRadii.length / NORM.radiiCountMax, 1); - vec[i++] = - surfaces.borderRadii.length > 0 - ? Math.min(surfaces.borderRadii[0] / NORM.radiusMinMax, 1) - : 0; - vec[i++] = - surfaces.borderRadii.length > 0 - ? Math.min( - surfaces.borderRadii[surfaces.borderRadii.length - 1] / - NORM.radiusMinMax, - 1, - ) - : 0; - // shadowComplexity: deliberate-none → 0, subtle → 0.5, layered → 1. - // (Phase 4b renamed `none` to `deliberate-none`; the embedding axis is - // unchanged.) - vec[i++] = - surfaces.shadowComplexity === "layered" - ? 1 - : surfaces.shadowComplexity === "subtle" - ? 0.5 - : 0; - // Border usage — use continuous score if borderTokenCount available, else categorical fallback - if (surfaces.borderTokenCount !== undefined) { - vec[i++] = Math.min( - surfaces.borderTokenCount / NORM.borderTokenCountMax, - 1, - ); - } else { - vec[i++] = - surfaces.borderUsage === "heavy" - ? 1 - : surfaces.borderUsage === "moderate" - ? 0.5 - : 0; - } - // Radii spread: range of border radii - if (surfaces.borderRadii.length >= 2) { - vec[i++] = Math.min( - (surfaces.borderRadii[surfaces.borderRadii.length - 1] - - surfaces.borderRadii[0]) / - NORM.radiusSpread, - 1, - ); - } else { - vec[i++] = 0; - } - // Radii median - if (surfaces.borderRadii.length > 0) { - vec[i++] = Math.min( - surfaces.borderRadii[Math.floor(surfaces.borderRadii.length / 2)] / - NORM.radiusMedian, - 1, - ); - } else { - vec[i++] = 0; - } - // Max radius (signals "pill" shapes — high max radius is distinctive) - if (surfaces.borderRadii.length > 0) { - vec[i++] = Math.min( - surfaces.borderRadii[surfaces.borderRadii.length - 1] / - NORM.radiusMaxPill, - 1, - ); - } else { - vec[i++] = 0; - } - - return vec; -} - -/** - * Cosine similarity between two embedding vectors (0 = identical, 1 = orthogonal) - */ -export function embeddingDistance(a: number[], b: number[]): number { - const len = Math.min(a.length, b.length); - let dotProduct = 0; - let normA = 0; - let normB = 0; - - for (let i = 0; i < len; i++) { - dotProduct += a[i] * b[i]; - normA += a[i] * a[i]; - normB += b[i] * b[i]; - } - - const magnitude = Math.sqrt(normA) * Math.sqrt(normB); - if (magnitude === 0) return 1; - - // Convert similarity (1 = identical) to distance (0 = identical) - return 1 - dotProduct / magnitude; -} diff --git a/packages/ghost/src/ghost-core/embedding/index.ts b/packages/ghost/src/ghost-core/embedding/index.ts deleted file mode 100644 index 1d0b14df..00000000 --- a/packages/ghost/src/ghost-core/embedding/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - classifyContrast, - classifySaturation, - colorToSemanticColor, - contrastScore, - parseColorToOklch, - resolveColorOklch, - saturationScore, -} from "./colors.js"; -export type { CompareOptions } from "./compare.js"; -export { compareFingerprints } from "./compare.js"; -export { describeFingerprint } from "./describe.js"; -export { computeSemanticEmbedding, embedTexts } from "./embed-api.js"; -export { computeEmbedding, embeddingDistance } from "./embedding.js"; -export type { RoleCandidate } from "./semantic-roles.js"; -export { inferSemanticRole } from "./semantic-roles.js"; -export { computeDriftVectors, DIMENSION_RANGES } from "./vector.js"; diff --git a/packages/ghost/src/ghost-core/embedding/semantic-roles.ts b/packages/ghost/src/ghost-core/embedding/semantic-roles.ts deleted file mode 100644 index 614703d1..00000000 --- a/packages/ghost/src/ghost-core/embedding/semantic-roles.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { parseColorToOklch } from "./colors.js"; - -export interface RoleCandidate { - role: string; - confidence: number; // 0-1 -} - -// Exact token name → semantic role mapping (shadcn + common conventions) -const EXACT_ROLES: Record = { - // Surface/background tokens - "--background-default": "surface", - "--background-alt": "surface-alt", - "--background-accent": "accent", - "--background": "surface", - "--bg": "surface", - "--bg-accent": "accent", - // shadcn conventions - "--primary": "primary", - "--primary-foreground": "primary-foreground", - "--secondary": "secondary", - "--secondary-foreground": "secondary-foreground", - "--accent": "accent", - "--accent-foreground": "accent-foreground", - "--muted": "muted", - "--muted-foreground": "muted-foreground", - "--destructive": "destructive", - "--destructive-foreground": "destructive-foreground", - "--card": "surface", - "--card-foreground": "text", - "--popover": "surface-alt", - "--popover-foreground": "text", - "--foreground": "text", - "--input": "border", - "--ring": "ring", - // Text tokens - "--text-default": "text", - "--text-muted": "text-muted", - "--text-inverse": "text-inverse", - "--text-danger": "danger", - // Border tokens - "--border-default": "border", - "--border-strong": "border-strong", - "--border": "border", - // Brand tokens - "--brand": "primary", - "--brand-primary": "primary", - "--brand-secondary": "secondary", -}; - -// Pattern-based rules: regex → role derivation -const PATTERN_RULES: [ - RegExp, - (match: RegExpMatchArray, name: string) => string, -][] = [ - // Primary/brand - [/--(?:color-)?primary(?:-|$)/, () => "primary"], - [/--(?:color-)?brand(?:-|$)/, () => "primary"], - // Surface/background - [ - /--(?:bg|background|surface)(?:-(.+))?$/, - (m) => (m[1] ? `surface-${m[1]}` : "surface"), - ], - // Text/foreground - [ - /--(?:text|fg|foreground)(?:-(.+))?$/, - (m) => (m[1] ? `text-${m[1]}` : "text"), - ], - // Border/stroke - [ - /--(?:border|stroke|outline)(?:-(.+))?$/, - (m) => (m[1] ? `border-${m[1]}` : "border"), - ], - // Semantic states - [/--(?:color-)?(?:error|danger|destructive)/, () => "destructive"], - [/--(?:color-)?(?:warning|caution|alert)/, () => "warning"], - [/--(?:color-)?(?:success|positive|valid)/, () => "success"], - [/--(?:color-)?(?:info|notice|informative)/, () => "info"], - // Accent/highlight - [/--(?:color-)?(?:accent|highlight)/, () => "accent"], - // Muted/subtle - [/--(?:color-)?(?:muted|subtle|disabled)/, () => "muted"], - // Secondary - [/--(?:color-)?secondary/, () => "secondary"], - // Ring/focus - [/--(?:color-)?(?:ring|focus|outline)/, () => "ring"], - // Generic color- prefix: use the suffix as role - [/--color-(.+)/, (m) => m[1]], - // MUI-style: --mui-palette-- - [/--mui-palette-(\w+)-/, (m) => m[1]], - // Chakra-style: --chakra-colors-- - [/--chakra-colors-(\w+)-/, (m) => m[1]], -]; - -// Semantic keywords that appear in token names -const SEMANTIC_KEYWORDS: Record = { - primary: "primary", - secondary: "secondary", - accent: "accent", - brand: "primary", - destructive: "destructive", - danger: "destructive", - error: "destructive", - warning: "warning", - caution: "warning", - success: "success", - positive: "success", - info: "info", - muted: "muted", - subtle: "muted", - disabled: "muted", - background: "surface", - surface: "surface", - foreground: "text", - text: "text", - border: "border", - ring: "ring", - focus: "ring", -}; - -/** - * Infer the semantic role of a design token from its name and value. - * Uses a layered approach: exact match → pattern match → keyword extraction → value heuristic. - */ -export function inferSemanticRole( - tokenName: string, - tokenValue?: string, -): RoleCandidate | null { - // Layer 1: Exact match (confidence 1.0) - const exact = EXACT_ROLES[tokenName]; - if (exact) return { role: exact, confidence: 1.0 }; - - // Layer 2: Pattern match (confidence 0.9) - for (const [pattern, derive] of PATTERN_RULES) { - const match = tokenName.match(pattern); - if (match) return { role: derive(match, tokenName), confidence: 0.9 }; - } - - // Layer 3: Keyword extraction (confidence 0.7) - const parts = tokenName.replace(/^--/, "").split(/[-_]/); - for (const part of parts) { - const role = SEMANTIC_KEYWORDS[part.toLowerCase()]; - if (role) return { role, confidence: 0.7 }; - } - - // Layer 4: Value-based heuristic (confidence 0.6) - if (tokenValue) { - const oklch = parseColorToOklch(tokenValue); - if (oklch) { - const [L, C] = oklch; - // Near-white → likely surface - if (L > 0.9 && C < 0.02) return { role: "surface", confidence: 0.6 }; - // Near-black → likely text - if (L < 0.15 && C < 0.02) return { role: "text", confidence: 0.6 }; - // High chroma → likely a dominant/brand color - if (C > 0.15) return { role: "dominant", confidence: 0.6 }; - } - } - - return null; -} diff --git a/packages/ghost/src/ghost-core/embedding/vector.ts b/packages/ghost/src/ghost-core/embedding/vector.ts deleted file mode 100644 index f384074a..00000000 --- a/packages/ghost/src/ghost-core/embedding/vector.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { DriftVector, Fingerprint } from "../types.js"; - -/** - * Embedding dimension ranges per design dimension. - * Mirrors the layout in embedding/embedding.ts. - */ -export const DIMENSION_RANGES: Record = { - palette: [0, 21], // dominant (0-11) + neutrals (12-17) + qualitative (18-20) - spacing: [21, 31], - typography: [31, 41], - surfaces: [41, 49], -}; - -/** - * Compute per-dimension drift vectors from two fingerprints' embeddings. - * Each vector captures the direction and magnitude of change in embedding space - * for a specific design dimension. - */ -export function computeDriftVectors( - source: Fingerprint, - target: Fingerprint, -): DriftVector[] { - const vectors: DriftVector[] = []; - - for (const [dimension, [start, end]] of Object.entries(DIMENSION_RANGES)) { - const delta: number[] = []; - let sumSq = 0; - - for (let i = start; i < end; i++) { - const d = (target.embedding[i] ?? 0) - (source.embedding[i] ?? 0); - delta.push(d); - sumSq += d * d; - } - - vectors.push({ - dimension, - magnitude: Math.sqrt(sumSq), - embeddingDelta: delta, - }); - } - - return vectors; -} diff --git a/packages/ghost/src/ghost-core/fingerprint/index.ts b/packages/ghost/src/ghost-core/fingerprint/index.ts deleted file mode 100644 index fac73a56..00000000 --- a/packages/ghost/src/ghost-core/fingerprint/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -export { - type GhostFingerprintLintOptions, - lintGhostFingerprint, -} from "./lint.js"; -export { - GhostFingerprintCompositionSchema, - GhostFingerprintEvidenceSchema, - GhostFingerprintExemplarSchema, - GhostFingerprintExperienceContractSchema, - GhostFingerprintIntentSchema, - GhostFingerprintInventoryBuildingBlocksSchema, - GhostFingerprintInventorySchema, - GhostFingerprintInventorySourceKindSchema, - GhostFingerprintInventorySourceSchema, - GhostFingerprintLayerRefSchema, - GhostFingerprintPackageManifestSchema, - GhostFingerprintPatternKindSchema, - GhostFingerprintPatternSchema, - GhostFingerprintPrincipleSchema, - GhostFingerprintRefPrefixSchema, - GhostFingerprintRefSchema, - GhostFingerprintSchema, - GhostFingerprintSituationSchema, - GhostFingerprintSummarySchema, -} from "./schema.js"; -export type { - GhostFingerprintComposition, - GhostFingerprintDocument, - GhostFingerprintEvidence, - GhostFingerprintExemplar, - GhostFingerprintExperienceContract, - GhostFingerprintIntent, - GhostFingerprintInventory, - GhostFingerprintInventoryBuildingBlocks, - GhostFingerprintInventorySource, - GhostFingerprintInventorySourceKind, - GhostFingerprintLintIssue, - GhostFingerprintLintReport, - GhostFingerprintLintSeverity, - GhostFingerprintPackageManifest, - GhostFingerprintPattern, - GhostFingerprintPatternKind, - GhostFingerprintPrinciple, - GhostFingerprintRef, - GhostFingerprintRefPrefix, - GhostFingerprintSituation, - GhostFingerprintSummary, -} from "./types.js"; -export { - GHOST_FINGERPRINT_PACKAGE_SCHEMA, - GHOST_FINGERPRINT_SCHEMA, - GHOST_FINGERPRINT_YML_FILENAME, -} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/fingerprint/lint.ts b/packages/ghost/src/ghost-core/fingerprint/lint.ts deleted file mode 100644 index 4f538187..00000000 --- a/packages/ghost/src/ghost-core/fingerprint/lint.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type { ZodIssue } from "zod"; -import { GhostFingerprintSchema } from "./schema.js"; -import type { - GhostFingerprintDocument, - GhostFingerprintLintIssue, - GhostFingerprintLintReport, - GhostFingerprintRef, -} from "./types.js"; - -type RefTargetPrefix = - | "intent.principle" - | "intent.situation" - | "intent.experience_contract" - | "inventory.exemplar" - | "composition.pattern"; - -const REF_TARGET_PREFIXES = [ - "intent.principle", - "intent.situation", - "intent.experience_contract", - "inventory.exemplar", - "composition.pattern", -] as const satisfies readonly RefTargetPrefix[]; - -export interface GhostFingerprintLintOptions { - /** - * Surface ids declared in the sibling `surfaces.yml`. When provided, node - * `surface:` placements are validated against this set. When omitted (single- - * file lint with no package context), placement existence is not checked — - * matching how validate lint skips routing checks without a fingerprint. - */ - surfaceIds?: Iterable; -} - -export function lintGhostFingerprint( - input: unknown, - options: GhostFingerprintLintOptions = {}, -): GhostFingerprintLintReport { - const issues: GhostFingerprintLintIssue[] = []; - const result = GhostFingerprintSchema.safeParse(input); - if (!result.success) return finalize(zodIssues(result.error.issues)); - - const doc = result.data as GhostFingerprintDocument; - checkDuplicateIds("intent.situations", doc.intent.situations, issues); - checkDuplicateIds("intent.principles", doc.intent.principles, issues); - checkDuplicateIds( - "intent.experience_contracts", - doc.intent.experience_contracts, - issues, - ); - checkDuplicateIds("composition.patterns", doc.composition.patterns, issues); - checkDuplicateIds("inventory.exemplars", doc.inventory.exemplars, issues); - checkDuplicateIds("inventory.sources", doc.inventory.sources, issues); - checkPlacement(doc, options.surfaceIds, issues); - checkRefs(doc, issues); - - return finalize(issues); -} - -function checkDuplicateIds( - collectionPath: string, - entries: Array<{ id: string }>, - issues: GhostFingerprintLintIssue[], -): void { - const seen = new Map(); - entries.forEach((entry, index) => { - const previous = seen.get(entry.id); - if (previous !== undefined) { - issues.push({ - severity: "error", - rule: "duplicate-id", - message: `id '${entry.id}' is duplicated (also at ${collectionPath}[${previous}])`, - path: `${collectionPath}[${index}].id`, - }); - } else { - seen.set(entry.id, index); - } - }); -} - -function checkPlacement( - doc: GhostFingerprintDocument, - surfaceIds: Iterable | undefined, - issues: GhostFingerprintLintIssue[], -): void { - // `core` is always a valid placement (the implicit root) even when not - // explicitly declared in surfaces.yml. - const known = surfaceIds ? new Set(surfaceIds) : null; - if (known) known.add("core"); - const candidates = known ? [...known] : []; - - const visit = ( - surface: string | undefined, - path: string, - nodeLabel: string, - ) => { - if (surface === undefined) { - issues.push({ - severity: "warning", - rule: "fingerprint-node-unplaced", - message: `${nodeLabel} has no surface placement; place it on a surface so it does not implicitly reach everywhere.`, - path, - }); - return; - } - if (!known || known.has(surface)) return; - issues.push({ - severity: "error", - rule: "fingerprint-surface-unknown", - message: `surface '${surface}' is not declared in surfaces.yml.`, - path, - }); - const near = nearest(surface, candidates); - if (near) { - issues.push({ - severity: "warning", - rule: "fingerprint-surface-near-miss", - message: `surface '${surface}' is unknown; did you mean '${near}'?`, - path, - }); - } - }; - - doc.intent.situations.forEach((node, index) => { - visit(node.surface, `intent.situations[${index}].surface`, "situation"); - }); - doc.intent.principles.forEach((node, index) => { - visit(node.surface, `intent.principles[${index}].surface`, "principle"); - }); - doc.intent.experience_contracts.forEach((node, index) => { - visit( - node.surface, - `intent.experience_contracts[${index}].surface`, - "experience contract", - ); - }); - doc.composition.patterns.forEach((node, index) => { - visit(node.surface, `composition.patterns[${index}].surface`, "pattern"); - }); - doc.inventory.exemplars.forEach((node, index) => { - visit(node.surface, `inventory.exemplars[${index}].surface`, "exemplar"); - }); -} - -/** Nearest candidate within edit distance 2, or null. */ -function nearest(value: string, candidates: string[]): string | null { - let best: string | null = null; - let bestDistance = 3; - for (const candidate of candidates) { - const distance = levenshtein(value, candidate); - if (distance < bestDistance) { - bestDistance = distance; - best = candidate; - } - } - return bestDistance <= 2 ? best : null; -} - -function levenshtein(a: string, b: string): number { - const rows = a.length + 1; - const cols = b.length + 1; - const dist: number[][] = Array.from({ length: rows }, () => - new Array(cols).fill(0), - ); - for (let i = 0; i < rows; i++) dist[i][0] = i; - for (let j = 0; j < cols; j++) dist[0][j] = j; - for (let i = 1; i < rows; i++) { - for (let j = 1; j < cols; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - dist[i][j] = Math.min( - dist[i - 1][j] + 1, - dist[i][j - 1] + 1, - dist[i - 1][j - 1] + cost, - ); - } - } - return dist[a.length][b.length]; -} - -function checkRefs( - doc: GhostFingerprintDocument, - issues: GhostFingerprintLintIssue[], -): void { - const targets = collectTargets(doc); - doc.intent.situations.forEach((situation, index) => { - checkRefList( - situation.principles, - "intent.principle", - `intent.situations[${index}].principles`, - targets, - issues, - ); - checkRefList( - situation.experience_contracts, - "intent.experience_contract", - `intent.situations[${index}].experience_contracts`, - targets, - issues, - ); - checkRefList( - situation.patterns, - "composition.pattern", - `intent.situations[${index}].patterns`, - targets, - issues, - ); - }); - - doc.intent.principles.forEach((principle, index) => { - checkCheckRefs( - principle.check_refs, - `intent.principles[${index}].check_refs`, - issues, - ); - }); - doc.intent.experience_contracts.forEach((contract, index) => { - checkCheckRefs( - contract.check_refs, - `intent.experience_contracts[${index}].check_refs`, - issues, - ); - }); - doc.composition.patterns.forEach((pattern, index) => { - checkCheckRefs( - pattern.check_refs, - `composition.patterns[${index}].check_refs`, - issues, - ); - }); - doc.inventory.exemplars.forEach((exemplar, index) => { - checkLayerRefs( - exemplar.refs, - `inventory.exemplars[${index}].refs`, - targets, - issues, - ); - }); -} - -function collectTargets( - doc: GhostFingerprintDocument, -): Record> { - return { - "intent.principle": new Set(doc.intent.principles.map((entry) => entry.id)), - "intent.situation": new Set(doc.intent.situations.map((entry) => entry.id)), - "intent.experience_contract": new Set( - doc.intent.experience_contracts.map((entry) => entry.id), - ), - "inventory.exemplar": new Set( - doc.inventory.exemplars.map((entry) => entry.id), - ), - "composition.pattern": new Set( - doc.composition.patterns.map((entry) => entry.id), - ), - }; -} - -function checkRefList( - refs: GhostFingerprintRef[] | undefined, - expectedPrefix: RefTargetPrefix, - path: string, - targets: Record>, - issues: GhostFingerprintLintIssue[], -): void { - refs?.forEach((ref, index) => { - const parsed = parseRef(ref); - if (!parsed || parsed.prefix !== expectedPrefix) { - issues.push({ - severity: "error", - rule: "fingerprint-ref-prefix", - message: `Expected ${expectedPrefix}:* reference.`, - path: `${path}[${index}]`, - }); - return; - } - if (!targets[expectedPrefix].has(parsed.id)) { - issues.push({ - severity: "error", - rule: "fingerprint-ref-unknown", - message: `Reference '${ref}' does not exist in the fingerprint package.`, - path: `${path}[${index}]`, - }); - } - }); -} - -function checkCheckRefs( - refs: GhostFingerprintRef[] | undefined, - path: string, - issues: GhostFingerprintLintIssue[], -): void { - refs?.forEach((ref, index) => { - const parsed = parseRef(ref); - if (parsed?.prefix === "validate.check") return; - issues.push({ - severity: "error", - rule: "fingerprint-check-ref-prefix", - message: "check_refs entries must use validate.check:* references.", - path: `${path}[${index}]`, - }); - }); -} - -function checkLayerRefs( - refs: GhostFingerprintRef[] | undefined, - path: string, - targets: Record>, - issues: GhostFingerprintLintIssue[], -): void { - refs?.forEach((ref, index) => { - const parsed = parseRef(ref); - if (!parsed || parsed.prefix === "validate.check") { - issues.push({ - severity: "error", - rule: "fingerprint-ref-prefix", - message: - "Expected intent.*, inventory.exemplar:*, or composition.pattern:* reference.", - path: `${path}[${index}]`, - }); - return; - } - if (!targets[parsed.prefix].has(parsed.id)) { - issues.push({ - severity: "error", - rule: "fingerprint-ref-unknown", - message: `Reference '${ref}' does not exist in the fingerprint package.`, - path: `${path}[${index}]`, - }); - } - }); -} - -function parseRef(ref: GhostFingerprintRef): - | { - prefix: (typeof REF_TARGET_PREFIXES)[number] | "validate.check"; - id: string; - } - | undefined { - const [prefix, id] = ref.split(":"); - if (!prefix || !id) return undefined; - if (prefix === "validate.check") return { prefix, id }; - if (REF_TARGET_PREFIXES.includes(prefix as RefTargetPrefix)) { - return { prefix: prefix as RefTargetPrefix, id }; - } - return undefined; -} - -function zodIssues(issues: ZodIssue[]): GhostFingerprintLintIssue[] { - 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: GhostFingerprintLintIssue[], -): GhostFingerprintLintReport { - 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/fingerprint/schema.ts b/packages/ghost/src/ghost-core/fingerprint/schema.ts deleted file mode 100644 index 23b772b1..00000000 --- a/packages/ghost/src/ghost-core/fingerprint/schema.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { z } from "zod"; -import { - GHOST_FINGERPRINT_PACKAGE_SCHEMA, - GHOST_FINGERPRINT_SCHEMA, -} from "./types.js"; - -const SlugIdSchema = z - .string() - .min(1) - .regex(/^[a-z0-9][a-z0-9._-]*$/, { - message: - "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", - }); - -export const GhostFingerprintPatternKindSchema = z.enum([ - "rule", - "layout", - "structure", - "flow", - "state", - "visual", - "behavior", - "content", -]); - -export const GhostFingerprintRefPrefixSchema = z.enum([ - "intent.principle", - "intent.situation", - "intent.experience_contract", - "inventory.exemplar", - "composition.pattern", - "validate.check", -]); - -export const GhostFingerprintRefSchema = z - .string() - .min(1) - .regex( - /^(intent\.principle|intent\.situation|intent\.experience_contract|inventory\.exemplar|composition\.pattern|validate\.check):[a-z0-9][a-z0-9._-]*$/, - { - message: - "ref must be typed as facet.kind:slug, e.g. intent.principle:dense-workflows", - }, - ); - -export const GhostFingerprintLayerRefSchema = z - .string() - .min(1) - .regex( - /^(intent\.principle|intent\.situation|intent\.experience_contract|inventory\.exemplar|composition\.pattern):[a-z0-9][a-z0-9._-]*$/, - { - message: - "ref must be typed as facet.kind:slug, e.g. intent.principle:dense-workflows", - }, - ); - -export const GhostFingerprintEvidenceSchema = z - .object({ - path: z.string().min(1).optional(), - locator: z.string().min(1).optional(), - note: z.string().min(1).optional(), - }) - .strict(); - -export const GhostFingerprintSummarySchema = z - .object({ - product: z.string().min(1).optional(), - audience: z.array(z.string().min(1)).optional(), - goals: z.array(z.string().min(1)).optional(), - anti_goals: z.array(z.string().min(1)).optional(), - tradeoffs: z.array(z.string().min(1)).optional(), - tone: z.array(z.string().min(1)).optional(), - }) - .strict(); - -export const GhostFingerprintExemplarSchema = z - .object({ - id: SlugIdSchema, - path: z.string().min(1), - title: z.string().min(1).optional(), - surface: SlugIdSchema.optional(), - note: z.string().min(1).optional(), - why: z.string().min(1).optional(), - refs: z.array(GhostFingerprintLayerRefSchema).optional(), - }) - .strict(); - -export const GhostFingerprintSituationSchema = z - .object({ - id: SlugIdSchema, - title: z.string().min(1).optional(), - user_intent: z.string().min(1).optional(), - product_obligation: z.string().min(1).optional(), - surface: SlugIdSchema.optional(), - hierarchy: z.record(z.string(), z.string().min(1)).optional(), - refuses: z.array(z.string().min(1)).optional(), - principles: z.array(GhostFingerprintRefSchema).optional(), - experience_contracts: z.array(GhostFingerprintRefSchema).optional(), - patterns: z.array(GhostFingerprintRefSchema).optional(), - evidence: z.array(GhostFingerprintEvidenceSchema).optional(), - }) - .strict(); - -export const GhostFingerprintPrincipleSchema = z - .object({ - id: SlugIdSchema, - principle: z.string().min(1), - surface: SlugIdSchema.optional(), - guidance: z.array(z.string().min(1)).optional(), - evidence: z.array(GhostFingerprintEvidenceSchema).optional(), - counterexamples: z.array(z.string().min(1)).optional(), - check_refs: z.array(GhostFingerprintRefSchema).optional(), - }) - .strict(); - -export const GhostFingerprintExperienceContractSchema = z - .object({ - id: SlugIdSchema, - contract: z.string().min(1), - surface: SlugIdSchema.optional(), - obligations: z.array(z.string().min(1)).optional(), - evidence: z.array(GhostFingerprintEvidenceSchema).optional(), - check_refs: z.array(GhostFingerprintRefSchema).optional(), - }) - .strict(); - -export const GhostFingerprintPatternSchema = z - .object({ - id: SlugIdSchema, - kind: GhostFingerprintPatternKindSchema, - pattern: z.string().min(1), - surface: SlugIdSchema.optional(), - guidance: z.array(z.string().min(1)).optional(), - evidence: z.array(GhostFingerprintEvidenceSchema).optional(), - anti_patterns: z.array(z.string().min(1)).optional(), - check_refs: z.array(GhostFingerprintRefSchema).optional(), - }) - .strict(); - -export const GhostFingerprintInventoryBuildingBlocksSchema = z - .object({ - tokens: z.array(z.string().min(1)).optional(), - components: z.array(z.string().min(1)).optional(), - libraries: z.array(z.string().min(1)).optional(), - assets: z.array(z.string().min(1)).optional(), - routes: z.array(z.string().min(1)).optional(), - files: z.array(z.string().min(1)).optional(), - notes: z.array(z.string().min(1)).optional(), - }) - .strict(); - -export const GhostFingerprintInventorySourceKindSchema = z.enum([ - "registry", - "file", - "url", - "package", -]); - -export const GhostFingerprintInventorySourceSchema = z - .object({ - id: SlugIdSchema, - kind: GhostFingerprintInventorySourceKindSchema, - ref: z.string().min(1), - note: z.string().min(1).optional(), - }) - .strict(); - -export const GhostFingerprintIntentSchema = z - .object({ - summary: GhostFingerprintSummarySchema.optional().default({}), - situations: z.array(GhostFingerprintSituationSchema).optional().default([]), - principles: z.array(GhostFingerprintPrincipleSchema).optional().default([]), - experience_contracts: z - .array(GhostFingerprintExperienceContractSchema) - .optional() - .default([]), - }) - .strict(); - -export const GhostFingerprintInventorySchema = z - .object({ - building_blocks: - GhostFingerprintInventoryBuildingBlocksSchema.optional().default({}), - exemplars: z.array(GhostFingerprintExemplarSchema).optional().default([]), - sources: z - .array(GhostFingerprintInventorySourceSchema) - .optional() - .default([]), - }) - .strict(); - -export const GhostFingerprintCompositionSchema = z - .object({ - patterns: z.array(GhostFingerprintPatternSchema).optional().default([]), - }) - .strict(); - -export const GhostFingerprintSchema = z - .object({ - schema: z.literal(GHOST_FINGERPRINT_SCHEMA), - intent: GhostFingerprintIntentSchema.optional().default({ - summary: {}, - situations: [], - principles: [], - experience_contracts: [], - }), - inventory: GhostFingerprintInventorySchema.optional().default({ - building_blocks: {}, - exemplars: [], - sources: [], - }), - composition: GhostFingerprintCompositionSchema.optional().default({ - patterns: [], - }), - }) - .strict(); - -export const GhostFingerprintPackageManifestSchema = z - .object({ - schema: z.literal(GHOST_FINGERPRINT_PACKAGE_SCHEMA), - id: SlugIdSchema, - }) - .strict(); diff --git a/packages/ghost/src/ghost-core/fingerprint/types.ts b/packages/ghost/src/ghost-core/fingerprint/types.ts deleted file mode 100644 index 2a3d047e..00000000 --- a/packages/ghost/src/ghost-core/fingerprint/types.ts +++ /dev/null @@ -1,160 +0,0 @@ -export const GHOST_FINGERPRINT_SCHEMA = "ghost.fingerprint/v1" as const; -export const GHOST_FINGERPRINT_PACKAGE_SCHEMA = - "ghost.fingerprint-package/v1" as const; -export const GHOST_FINGERPRINT_YML_FILENAME = "fingerprint.yml" as const; - -export type GhostFingerprintPatternKind = - | "rule" - | "layout" - | "structure" - | "flow" - | "state" - | "visual" - | "behavior" - | "content"; -export type GhostFingerprintRefPrefix = - | "intent.principle" - | "intent.situation" - | "intent.experience_contract" - | "inventory.exemplar" - | "composition.pattern" - | "validate.check"; - -export type GhostFingerprintRef = `${GhostFingerprintRefPrefix}:${string}`; - -export interface GhostFingerprintEvidence { - path?: string; - locator?: string; - note?: string; -} - -export interface GhostFingerprintSummary { - product?: string; - audience?: string[]; - goals?: string[]; - anti_goals?: string[]; - tradeoffs?: string[]; - tone?: string[]; -} - -export interface GhostFingerprintExemplar { - id: string; - path: string; - title?: string; - surface?: string; - note?: string; - why?: string; - refs?: GhostFingerprintRef[]; -} - -export interface GhostFingerprintInventoryBuildingBlocks { - tokens?: string[]; - components?: string[]; - libraries?: string[]; - assets?: string[]; - routes?: string[]; - files?: string[]; - notes?: string[]; -} - -export type GhostFingerprintInventorySourceKind = - | "registry" - | "file" - | "url" - | "package"; - -export interface GhostFingerprintInventorySource { - id: string; - kind: GhostFingerprintInventorySourceKind; - ref: string; - note?: string; -} - -export interface GhostFingerprintIntent { - summary: GhostFingerprintSummary; - situations: GhostFingerprintSituation[]; - principles: GhostFingerprintPrinciple[]; - experience_contracts: GhostFingerprintExperienceContract[]; -} - -export interface GhostFingerprintInventory { - building_blocks: GhostFingerprintInventoryBuildingBlocks; - exemplars: GhostFingerprintExemplar[]; - sources: GhostFingerprintInventorySource[]; -} - -export interface GhostFingerprintComposition { - patterns: GhostFingerprintPattern[]; -} - -export interface GhostFingerprintSituation { - id: string; - title?: string; - user_intent?: string; - product_obligation?: string; - surface?: string; - hierarchy?: Record; - refuses?: string[]; - principles?: GhostFingerprintRef[]; - experience_contracts?: GhostFingerprintRef[]; - patterns?: GhostFingerprintRef[]; - evidence?: GhostFingerprintEvidence[]; -} - -export interface GhostFingerprintPrinciple { - id: string; - principle: string; - surface?: string; - guidance?: string[]; - evidence?: GhostFingerprintEvidence[]; - counterexamples?: string[]; - check_refs?: GhostFingerprintRef[]; -} - -export interface GhostFingerprintExperienceContract { - id: string; - contract: string; - surface?: string; - obligations?: string[]; - evidence?: GhostFingerprintEvidence[]; - check_refs?: GhostFingerprintRef[]; -} - -export interface GhostFingerprintPattern { - id: string; - kind: GhostFingerprintPatternKind; - pattern: string; - surface?: string; - guidance?: string[]; - evidence?: GhostFingerprintEvidence[]; - anti_patterns?: string[]; - check_refs?: GhostFingerprintRef[]; -} - -export interface GhostFingerprintDocument { - schema: typeof GHOST_FINGERPRINT_SCHEMA; - intent: GhostFingerprintIntent; - inventory: GhostFingerprintInventory; - composition: GhostFingerprintComposition; -} - -export interface GhostFingerprintPackageManifest { - schema: typeof GHOST_FINGERPRINT_PACKAGE_SCHEMA; - id: string; -} - -export type GhostFingerprintLintSeverity = "error" | "warning" | "info"; - -export interface GhostFingerprintLintIssue { - severity: GhostFingerprintLintSeverity; - rule: string; - message: string; - path?: string; -} - -export interface GhostFingerprintLintReport { - issues: GhostFingerprintLintIssue[]; - errors: number; - warnings: number; - info: number; -} diff --git a/packages/ghost/src/ghost-core/graph/assemble.ts b/packages/ghost/src/ghost-core/graph/assemble.ts new file mode 100644 index 00000000..309a7287 --- /dev/null +++ b/packages/ghost/src/ghost-core/graph/assemble.ts @@ -0,0 +1,99 @@ +import type { GhostNodeDocument } from "../node/types.js"; +import type { GhostSurfacesDocument } from "../surfaces/types.js"; +import { + GHOST_GRAPH_ROOT_ID, + type GhostGraph, + type GhostGraphNode, +} from "./types.js"; + +export interface AssembleGraphInput { + /** Authored on-disk node files (parsed `ghost.node/v1` documents). */ + nodeFiles?: GhostNodeDocument[]; + /** The explicit surface tree, which seeds tree nodes even when empty. */ + surfaces?: GhostSurfacesDocument; + /** + * Read-only nodes inherited from extended packages. Their ids are already + * qualified (`:`). Local nodes never override these and + * these never override local — they are a disjoint id space. + */ + inheritedNodes?: GhostGraphNode[]; +} + +/** + * Fold the package's sources into one in-memory prose-node graph. + * + * Authored node files are unioned with the surface tree (`surfaces.yml`), which + * seeds containment so a surface with no node still exists as a tree position, + * plus any read-only nodes inherited from extended packages. The implicit + * `core` root is never required to be declared. + */ +export function assembleGraph(input: AssembleGraphInput): GhostGraph { + const nodes = new Map(); + + // Inherited (extended-package) nodes first — lowest precedence, read-only. + for (const node of input.inheritedNodes ?? []) { + nodes.set(node.id, node); + } + + for (const doc of input.nodeFiles ?? []) { + const fm = doc.frontmatter; + nodes.set(fm.id, { + id: fm.id, + ...(fm.under !== undefined ? { under: fm.under } : {}), + relates: fm.relates ?? [], + ...(fm.incarnation !== undefined ? { incarnation: fm.incarnation } : {}), + body: doc.body, + origin: "node-file", + }); + } + + // Build the containment tree. Surfaces seed positions; node `under` edges and + // surface `parent` edges both contribute. The root (`core`) has no parent. + const parents = new Map(); + const children = new Map(); + + const link = (child: string, parent: string) => { + if (child === parent) return; + parents.set(child, parent); + const list = children.get(parent); + if (list) { + if (!list.includes(child)) list.push(child); + } else { + children.set(parent, [child]); + } + }; + + // Surface tree edges (the authoritative spine in Phase 2). + for (const surface of input.surfaces?.surfaces ?? []) { + if (surface.id === GHOST_GRAPH_ROOT_ID) continue; + link(surface.id, surface.parent ?? GHOST_GRAPH_ROOT_ID); + } + + // Node containment: a node `under` X is a child of X. A placed node whose + // `under` is itself a node id nests under that node; otherwise it attaches to + // the named surface (or core). + for (const node of nodes.values()) { + if (node.id === GHOST_GRAPH_ROOT_ID) continue; + if (node.under !== undefined) { + link(node.id, node.under); + } + } + + return { nodes, parents, children }; +} + +/** The ancestor chain for a node id, nearest parent first, ending at the root. */ +export function ancestorChain(graph: GhostGraph, id: string): string[] { + const chain: string[] = []; + let current = graph.parents.get(id); + const seen = new Set([id]); + while (current !== undefined && !seen.has(current)) { + chain.push(current); + seen.add(current); + current = graph.parents.get(current); + } + if (chain[chain.length - 1] !== GHOST_GRAPH_ROOT_ID) { + chain.push(GHOST_GRAPH_ROOT_ID); + } + return chain; +} diff --git a/packages/ghost/src/ghost-core/graph/index.ts b/packages/ghost/src/ghost-core/graph/index.ts new file mode 100644 index 00000000..a24cec95 --- /dev/null +++ b/packages/ghost/src/ghost-core/graph/index.ts @@ -0,0 +1,30 @@ +/** + * Public surface for the in-memory fingerprint graph — the only fingerprint + * model. The graph is folded from authored node files + the surface tree, and + * is what every consumer traverses (gather, checks, validate). + */ + +export { + type AssembleGraphInput, + ancestorChain, + assembleGraph, +} from "./assemble.js"; +export { + type GraphLintIssue, + type GraphLintReport, + type GraphLintSeverity, + lintGraph, +} from "./lint.js"; +export { + type GraphSlice, + type GraphSliceNode, + type GraphSliceProvenance, + type ResolveGraphSliceOptions, + resolveGraphSlice, +} from "./slice.js"; +export { + GHOST_GRAPH_ROOT_ID, + type GhostGraph, + type GhostGraphNode, + type GhostGraphNodeOrigin, +} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/graph/lint.ts b/packages/ghost/src/ghost-core/graph/lint.ts new file mode 100644 index 00000000..1b5df230 --- /dev/null +++ b/packages/ghost/src/ghost-core/graph/lint.ts @@ -0,0 +1,117 @@ +import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "./types.js"; + +export type GraphLintSeverity = "error" | "warning" | "info"; + +export interface GraphLintIssue { + severity: GraphLintSeverity; + rule: string; + message: string; + /** The node id the issue concerns, when applicable. */ + node?: string; +} + +export interface GraphLintReport { + issues: GraphLintIssue[]; + errors: number; + warnings: number; + info: number; +} + +/** + * The graph pass of `validate`: the ghost-specific network is correct. + * + * - every `under` parent resolves to a node or a declared surface tree position; + * - every local `relates` target resolves (cross-package `pkg#id` refs are + * skipped here — they are resolved in the cross-package phase); + * - exactly one root (no `under`) — the implicit `core`; + * - the containment graph is acyclic. + * + * Pure: operates on the assembled in-memory graph, no I/O. + */ +export function lintGraph(graph: GhostGraph): GraphLintReport { + const issues: GraphLintIssue[] = []; + const ids = new Set(graph.nodes.keys()); + // Valid containment targets: nodes, declared surface tree positions, and the + // implicit root. Surfaces are tree positions (in parents/children), not nodes. + const treePositions = new Set([ + GHOST_GRAPH_ROOT_ID, + ...graph.parents.keys(), + ...graph.children.keys(), + ]); + + for (const node of graph.nodes.values()) { + // under must resolve to a known node or surface tree position + if ( + node.under !== undefined && + !ids.has(node.under) && + !treePositions.has(node.under) + ) { + issues.push({ + severity: "error", + rule: "unresolved-parent", + message: `node '${node.id}' is under '${node.under}', which is not a known node or surface.`, + node: node.id, + }); + } + // relates targets must resolve. A `:` ref resolves to an + // inherited node (id-keyed the same way) — same lookup, no special case. + for (const relation of node.relates) { + if (!ids.has(relation.to)) { + issues.push({ + severity: "error", + rule: "unresolved-relation", + message: `node '${node.id}' relates to '${relation.to}', which does not exist (a cross-package ref needs the package in 'extends').`, + node: node.id, + }); + } + } + } + + // Exactly one root: the implicit core. Nodes with no `under` are roots. + // Inherited (extended-package) nodes are read-only context, not part of this + // package's tree — they are exempt from the single-root rule. + const roots = [...graph.nodes.values()].filter( + (node) => + node.under === undefined && + node.id !== GHOST_GRAPH_ROOT_ID && + node.origin !== "inherited", + ); + for (const root of roots) { + issues.push({ + severity: "error", + rule: "multiple-roots", + message: `node '${root.id}' has no 'under'; every node must descend from the implicit '${GHOST_GRAPH_ROOT_ID}' root (give it an 'under').`, + node: root.id, + }); + } + + // Cycle detection over containment. + for (const node of graph.nodes.values()) { + const seen = new Set(); + let cursor: string | undefined = node.id; + while (cursor !== undefined) { + if (seen.has(cursor)) { + issues.push({ + severity: "error", + rule: "containment-cycle", + message: `node '${node.id}' is part of an 'under' cycle.`, + node: node.id, + }); + break; + } + seen.add(cursor); + cursor = graph.nodes.get(cursor)?.under; + } + } + + return finalize(issues); +} + +function finalize(issues: GraphLintIssue[]): GraphLintReport { + return { + issues, + errors: issues.filter((i) => i.severity === "error").length, + warnings: issues.filter((i) => i.severity === "warning").length, + info: issues.filter((i) => i.severity === "info").length, + }; +} diff --git a/packages/ghost/src/ghost-core/graph/slice.ts b/packages/ghost/src/ghost-core/graph/slice.ts new file mode 100644 index 00000000..d28464ef --- /dev/null +++ b/packages/ghost/src/ghost-core/graph/slice.ts @@ -0,0 +1,136 @@ +import type { GhostNodeRelationKind } from "../node/types.js"; +import { ancestorChain } from "./assemble.js"; +import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "./types.js"; + +/** + * Why a node is present in a resolved slice. + * - `own`: placed directly on the requested surface. + * - `ancestor`: placed on an ancestor and cascaded down the tree. + * - `edge`: contributed by a typed `relates` link from a slice node (one hop). + */ +export type GraphSliceProvenance = + | { kind: "own" } + | { kind: "ancestor"; from: string } + | { kind: "edge"; via?: GhostNodeRelationKind; from: string }; + +export interface GraphSliceNode { + id: string; + body: string; + incarnation?: string; + provenance: GraphSliceProvenance; +} + +export interface GraphSlice { + /** The requested node/surface id. */ + surface: string; + /** Ancestor chain from the surface up to (but excluding) the implicit root. */ + ancestors: string[]; + /** The `--as` incarnation filter applied, if any. */ + incarnation?: string; + nodes: GraphSliceNode[]; +} + +export interface ResolveGraphSliceOptions { + /** Filter to nodes whose incarnation matches, plus essence (untagged) nodes. */ + incarnation?: string; +} + +/** + * Compose a context slice for a surface by traversing the graph, deterministic + * and with no I/O or LLM: + * + * - own: nodes placed directly on the requested id; + * - ancestor: nodes on each `under` ancestor up to `core` cascade down; + * - edge: for each slice node's `relates`, the target node's body is included + * once (one hop, no recursion), tagged by the relation qualifier. + * + * The `incarnation` option filters: a node with no incarnation (essence) is + * always included; a tagged node is included only when it matches; absent + * option means no filtering. + */ +export function resolveGraphSlice( + graph: GhostGraph, + surfaceId: string, + options: ResolveGraphSliceOptions = {}, +): GraphSlice { + const ancestorsFull = ancestorChain(graph, surfaceId); + // Exclude the implicit root from the reported chain (parity with the old + // resolver, which reported up to but not labeling core specially); keep it in + // the cascade set so root/essence nodes still cascade. + const ancestors = ancestorsFull.filter((id) => id !== GHOST_GRAPH_ROOT_ID); + + const cascadeIds = new Set([ + surfaceId, + ...ancestorsFull, + GHOST_GRAPH_ROOT_ID, + ]); + + const passesIncarnation = (incarnation?: string): boolean => { + if (options.incarnation === undefined) return true; + if (incarnation === undefined || incarnation === "any") return true; + return incarnation === options.incarnation; + }; + + const slice: GraphSlice = { + surface: surfaceId, + ancestors, + ...(options.incarnation !== undefined + ? { incarnation: options.incarnation } + : {}), + nodes: [], + }; + + const seen = new Set(); + const add = (id: string, provenance: GraphSliceProvenance) => { + if (seen.has(id)) return; + const node = graph.nodes.get(id); + if (!node) return; + if (!passesIncarnation(node.incarnation)) return; + seen.add(id); + slice.nodes.push({ + id: node.id, + body: node.body, + ...(node.incarnation !== undefined + ? { incarnation: node.incarnation } + : {}), + provenance, + }); + }; + + // Placement of a node: nodes attach to a surface via `under`; nodes whose id + // *is* a surface in the cascade are themselves placed there. We resolve + // placement as: a node belongs to surface S if its containment parent chain + // reaches S directly (its `under` is S), or the node id equals S. + const placementOf = (nodeUnder?: string): string => + nodeUnder ?? GHOST_GRAPH_ROOT_ID; + + // Own + ancestor: walk every node, place it, decide provenance by cascade. + for (const node of graph.nodes.values()) { + const placement = + node.id === surfaceId ? surfaceId : placementOf(node.under); + if (placement === surfaceId || node.id === surfaceId) { + add(node.id, { kind: "own" }); + } else if (cascadeIds.has(placement)) { + add(node.id, { kind: "ancestor", from: placement }); + } + } + + // Edge contributions: one hop along `relates` from the nodes already in the + // slice. The target's body is included, tagged by qualifier. + const ownAndAncestor = [...slice.nodes]; + for (const sliceNode of ownAndAncestor) { + const source = graph.nodes.get(sliceNode.id); + if (!source) continue; + for (const relation of source.relates) { + // A `:` ref resolves to an inherited node, keyed the + // same way in graph.nodes — `add` no-ops if it isn't present. + add(relation.to, { + kind: "edge", + ...(relation.as !== undefined ? { via: relation.as } : {}), + from: sliceNode.id, + }); + } + } + + return slice; +} diff --git a/packages/ghost/src/ghost-core/graph/types.ts b/packages/ghost/src/ghost-core/graph/types.ts new file mode 100644 index 00000000..ac8a2a4f --- /dev/null +++ b/packages/ghost/src/ghost-core/graph/types.ts @@ -0,0 +1,43 @@ +import type { GhostNodeRelation } from "../node/types.js"; +import { GHOST_SURFACE_ROOT_ID } from "../surfaces/types.js"; + +/** The implicit root every node ultimately descends from (shared with surfaces). */ +export const GHOST_GRAPH_ROOT_ID = GHOST_SURFACE_ROOT_ID; + +/** + * Where a node in the resolved graph came from. The fold unions authored + * on-disk node files with a transition projection of the legacy facet model; + * `origin` records which, so later phases and lint can treat them differently + * (and so the projection can be deleted cleanly in the facet-removal phase). + */ +export type GhostGraphNodeOrigin = "node-file" | "inherited"; + +/** + * A resolved graph node — pure prose (Option A). The body is the design + * expression; there are no structured node fields. `under` is the single + * containment parent (absent ⇒ child of the implicit `core` root); `relates` + * are the typed lateral links; `incarnation` is the optional projection tag. + */ +export interface GhostGraphNode { + id: string; + under?: string; + relates: GhostNodeRelation[]; + incarnation?: string; + body: string; + origin: GhostGraphNodeOrigin; +} + +/** + * The in-memory fingerprint graph: prose nodes indexed by id, plus the + * containment tree (`under` parent edges, root = `core`) that is the traversal + * spine. This is the shape later phases (gather, checks, compare) traverse; + * disk layout is just one serialization of it. + */ +export interface GhostGraph { + /** Every node, indexed by id. */ + nodes: Map; + /** child id → parent id (the `under` tree). The root has no entry. */ + parents: Map; + /** parent id → child ids, for downward traversal. */ + children: Map; +} diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index 51febe5c..36efff89 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -18,81 +18,6 @@ export { type RoutedCheck, selectChecksForSurfaces, } from "./check/index.js"; -// --- Decision vocabulary (controlled list for fleet aggregation) --- -export { - CANONICAL_DECISION_DIMENSIONS, - type CanonicalDecisionDimension, - closestCanonical, - isCanonicalDimension, - resolveDecisionKind, -} from "./decision-vocabulary.js"; -export type { CompareOptions, RoleCandidate } from "./embedding/index.js"; -export { - classifyContrast, - classifySaturation, - colorToSemanticColor, - compareFingerprints, - computeDriftVectors, - computeEmbedding, - computeSemanticEmbedding, - contrastScore, - DIMENSION_RANGES, - describeFingerprint, - embeddingDistance, - embedTexts, - inferSemanticRole, - parseColorToOklch, - saturationScore, -} from "./embedding/index.js"; -// --- Fingerprint.yml (ghost.fingerprint/v1) --- -export type { - GhostFingerprintComposition, - GhostFingerprintDocument, - GhostFingerprintEvidence, - GhostFingerprintExemplar, - GhostFingerprintExperienceContract, - GhostFingerprintIntent, - GhostFingerprintInventory, - GhostFingerprintInventoryBuildingBlocks, - GhostFingerprintInventorySource, - GhostFingerprintInventorySourceKind, - GhostFingerprintLintIssue, - GhostFingerprintLintReport, - GhostFingerprintLintSeverity, - GhostFingerprintPackageManifest, - GhostFingerprintPattern, - GhostFingerprintPatternKind, - GhostFingerprintPrinciple, - GhostFingerprintRef, - GhostFingerprintRefPrefix, - GhostFingerprintSituation, - GhostFingerprintSummary, -} from "./fingerprint/index.js"; -export { - GHOST_FINGERPRINT_PACKAGE_SCHEMA, - GHOST_FINGERPRINT_SCHEMA, - GHOST_FINGERPRINT_YML_FILENAME, - GhostFingerprintCompositionSchema, - GhostFingerprintEvidenceSchema, - GhostFingerprintExemplarSchema, - GhostFingerprintExperienceContractSchema, - GhostFingerprintIntentSchema, - GhostFingerprintInventoryBuildingBlocksSchema, - GhostFingerprintInventorySchema, - GhostFingerprintInventorySourceKindSchema, - GhostFingerprintInventorySourceSchema, - GhostFingerprintLayerRefSchema, - GhostFingerprintPackageManifestSchema, - GhostFingerprintPatternKindSchema, - GhostFingerprintPatternSchema, - GhostFingerprintPrincipleSchema, - GhostFingerprintRefPrefixSchema, - GhostFingerprintRefSchema, - GhostFingerprintSchema, - GhostFingerprintSituationSchema, - GhostFingerprintSummarySchema, - lintGhostFingerprint, -} from "./fingerprint/index.js"; // --- Fingerprint package filenames --- export { FINGERPRINT_COMPOSITION_FILENAME, @@ -106,6 +31,50 @@ export { PATTERNS_FILENAME, RESOURCES_FILENAME, } from "./fingerprint-package.js"; +// --- Graph (in-memory fingerprint node graph) --- +export { + type AssembleGraphInput, + ancestorChain, + assembleGraph, + GHOST_GRAPH_ROOT_ID, + type GhostGraph, + type GhostGraphNode, + type GhostGraphNodeOrigin, + type GraphLintIssue, + type GraphLintReport, + type GraphLintSeverity, + type GraphSlice, + type GraphSliceNode, + type GraphSliceProvenance, + lintGraph, + type ResolveGraphSliceOptions, + resolveGraphSlice, +} from "./graph/index.js"; +// --- Node (ghost.node/v1) — the markdown node artifact --- +export { + GHOST_NODE_RELATION_KINDS, + GHOST_NODE_SCHEMA, + type GhostNodeDocument, + type GhostNodeFrontmatter, + GhostNodeFrontmatterSchema, + type GhostNodeLintIssue, + type GhostNodeLintReport, + type GhostNodeLintSeverity, + type GhostNodeRelation, + type GhostNodeRelationKind, + lintGhostNode, + NodeIdSchema, + NodeRefSchema, + type ParseNodeResult, + parseNode, + serializeNode, +} from "./node/index.js"; +// --- Fingerprint package manifest (ghost.fingerprint-package/v1) --- +export type { GhostFingerprintPackageManifest } from "./package-manifest.js"; +export { + GHOST_FINGERPRINT_PACKAGE_SCHEMA, + GhostFingerprintPackageManifestSchema, +} from "./package-manifest.js"; // --- Patterns (ghost.patterns/v1) --- export type { GhostCompositionAnatomy, @@ -127,20 +96,6 @@ export { GhostSurfaceTypePatternSchema, lintGhostPatterns, } from "./patterns/index.js"; -// --- Perceptual prior (drift severity calibration) --- -export { - computeCheckSeverity, - DEFAULT_MATCH, - DEFAULT_TOLERANCE, - escalateForPresence, - escalateTier, - PERCEPTUAL_TIER, - type PerceptualTier, - resolveMatchShape, - resolveTolerance, - TIER_SEVERITY, - tierForCanonical, -} from "./perceptual-prior.js"; // --- Resources (ghost.resources/v1) --- export type { GhostResourceRef, @@ -183,14 +138,7 @@ export { type GhostSurfacesLintReport, type GhostSurfacesLintSeverity, GhostSurfacesSchema, - type GroundingItem, - groundSurface, lintGhostSurfaces, - type ResolvedSlice, - resolveSurfaceSlice, - type SliceNode, - type SliceProvenance, - type SurfaceGrounding, type SurfaceMenuEntry, } from "./surfaces/index.js"; // --- Survey (ghost.survey/v1) --- @@ -275,63 +223,3 @@ export { ValueSpecSchema, valueRowId, } from "./survey/index.js"; -// --- Target resolution --- -export { resolveTarget } from "./target-resolver.js"; - -// --- Shared types --- -export type { - Check, - CheckKind, - CheckMatchShape, - ColorRamp, - ComponentMeta, - CompositeCluster, - CompositeComparison, - CompositeMember, - CompositePair, - CSSToken, - CSSVarsMap, - DesignDecision, - DesignObservation, - DetectedFormat, - DimensionAck, - DimensionDelta, - DimensionStance, - DivergenceClass, - DriftSeverity, - DriftVector, - DriftVelocity, - EmbeddingConfig, - EnrichedComparison, - EnrichedFingerprint, - ExtractedFile, - ExtractedMaterial, - Extractor, - ExtractorOptions, - Fingerprint, - FingerprintComparison, - FingerprintHistoryEntry, - FingerprintReferences, - FontDescriptor, - GhostConfig, - NormalizedToken, - Registry, - RegistryFile, - RegistryItem, - RegistryItemType, - ResolvedRegistry, - RuleSeverity, - SampledFile, - SampledMaterial, - SemanticColor, - SourceInfo, - StructureDrift, - SyncManifest, - Target, - TargetOptions, - TargetType, - TemporalComparison, - TokenCategory, - TokenFormat, - ValueDrift, -} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/markdown.ts b/packages/ghost/src/ghost-core/markdown.ts new file mode 100644 index 00000000..6a25fb24 --- /dev/null +++ b/packages/ghost/src/ghost-core/markdown.ts @@ -0,0 +1,37 @@ +import { parse as parseYaml } from "yaml"; + +export interface ParsedMarkdown { + /** Raw parsed frontmatter object (unvalidated), or null when absent. */ + frontmatter: Record | null; + body: string; +} + +/** + * Split a markdown artifact into its YAML frontmatter and body. The artifact is + * `---\n\n---\n`. Returns `frontmatter: null` when there is no + * leading frontmatter block (callers report it as an error). + * + * Shared by every Ghost markdown+frontmatter artifact (checks, nodes): one + * envelope, one splitter. + */ +export function splitMarkdownFrontmatter(raw: string): ParsedMarkdown { + const text = raw.replace(/^\uFEFF/, ""); + const lines = text.split(/\r?\n/); + if (lines[0]?.trim() !== "---") { + return { frontmatter: null, body: text }; + } + for (let i = 1; i < lines.length; i++) { + if (lines[i]?.trim() === "---") { + const yaml = lines.slice(1, i).join("\n"); + const body = lines.slice(i + 1).join("\n"); + const parsed = parseYaml(yaml); + const frontmatter = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + return { frontmatter, body: body.replace(/^\n+/, "") }; + } + } + // Opening fence with no close: treat the whole thing as body, no frontmatter. + return { frontmatter: null, body: text }; +} diff --git a/packages/ghost/src/ghost-core/node/index.ts b/packages/ghost/src/ghost-core/node/index.ts new file mode 100644 index 00000000..22bb0573 --- /dev/null +++ b/packages/ghost/src/ghost-core/node/index.ts @@ -0,0 +1,26 @@ +/** + * Public surface for `ghost.node/v1` — the node artifact: markdown + + * frontmatter, the single unit a fingerprint graph is made of. Phase 1 ships + * schema + types + parse + serialize only. The loader fold (reading nodes into + * the in-memory graph) and graph-level lint are later phases. See + * docs/ideas/phase-1-node-schema.md. + */ + +export { lintGhostNode, type ParseNodeResult, parseNode } from "./parse.js"; +export { + GhostNodeFrontmatterSchema, + NodeIdSchema, + NodeRefSchema, +} from "./schema.js"; +export { serializeNode } from "./serialize.js"; +export { + GHOST_NODE_RELATION_KINDS, + GHOST_NODE_SCHEMA, + type GhostNodeDocument, + type GhostNodeFrontmatter, + type GhostNodeLintIssue, + type GhostNodeLintReport, + type GhostNodeLintSeverity, + type GhostNodeRelation, + type GhostNodeRelationKind, +} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/node/parse.ts b/packages/ghost/src/ghost-core/node/parse.ts new file mode 100644 index 00000000..038161cb --- /dev/null +++ b/packages/ghost/src/ghost-core/node/parse.ts @@ -0,0 +1,75 @@ +import { splitMarkdownFrontmatter } from "../markdown.js"; +import { GhostNodeFrontmatterSchema } from "./schema.js"; +import type { + GhostNodeDocument, + GhostNodeLintIssue, + GhostNodeLintReport, +} from "./types.js"; + +export interface ParseNodeResult { + /** The validated node, or null when the artifact failed to parse/validate. */ + node: GhostNodeDocument | null; + report: GhostNodeLintReport; +} + +function finalize(issues: GhostNodeLintIssue[]): GhostNodeLintReport { + return { + issues, + errors: issues.filter((i) => i.severity === "error").length, + warnings: issues.filter((i) => i.severity === "warning").length, + info: issues.filter((i) => i.severity === "info").length, + }; +} + +/** + * Parse and validate a single `ghost.node/v1` markdown artifact (frontmatter + + * prose body) in isolation. Per-node only: identity, well-formed links, + * incarnation + * shape. Cross-node graph rules (targets exist, one root, no cycles) are a + * later phase. + */ +export function parseNode(raw: string): ParseNodeResult { + const { frontmatter, body } = splitMarkdownFrontmatter(raw); + if (frontmatter === null) { + return { + node: null, + report: finalize([ + { + severity: "error", + rule: "node-missing-frontmatter", + message: + "node must begin with a YAML frontmatter block (---\\n\\n---)", + }, + ]), + }; + } + + // The body is design-expression content; surrounding blank lines are not + // meaningful. Normalize them so serialize → parse round-trips are stable. + const normalizedBody = body.replace(/^\n+/, "").replace(/\s+$/, ""); + + const result = GhostNodeFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + return { + node: null, + report: finalize( + result.error.issues.map((issue) => ({ + severity: "error" as const, + rule: `schema/${issue.code}`, + message: issue.message, + path: issue.path.length ? issue.path.join(".") : undefined, + })), + ), + }; + } + + return { + node: { frontmatter: result.data, body: normalizedBody }, + report: finalize([]), + }; +} + +/** Lint a node artifact, returning only the report (per-node validation). */ +export function lintGhostNode(raw: string): GhostNodeLintReport { + return parseNode(raw).report; +} diff --git a/packages/ghost/src/ghost-core/node/schema.ts b/packages/ghost/src/ghost-core/node/schema.ts new file mode 100644 index 00000000..631915f3 --- /dev/null +++ b/packages/ghost/src/ghost-core/node/schema.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { GHOST_NODE_RELATION_KINDS } from "./types.js"; + +/** + * A node id is a permissive lowercase slug, unique within the package. The + * charset is liberal on purpose (lowercase alphanumeric plus `.` `_` `-`): the + * schema enforces machine-tractability, not a separator style. Dashes are the + * emitted convention (skill / init / agent authoring), nudged in guidance — not + * a lint rule. The tree lives only in `under`; an id never encodes hierarchy. + */ +const NodeIdSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, { + message: + "node id must be a lowercase slug (alphanumeric plus . _ -, leading alphanumeric)", + }); + +/** + * A node ref points at another node: a local id (``), or a cross-package + * ref `:` where `` is a key declared in the + * package manifest's `extends` map. Reference is by identity, never by path — + * `:` is Ghost's qualifier lineage (e.g. the old `intent.principle:foo` refs). + */ +const NodeRefSchema = z + .string() + .min(1) + .regex(/^(?:[a-z0-9][a-z0-9._-]*:)?[a-z0-9][a-z0-9._-]*$/, { + message: + "node ref must be a local id '' or a cross-package ref ':'", + }); + +const NodeRelationSchema = z + .object({ + to: NodeRefSchema, + as: z.enum(GHOST_NODE_RELATION_KINDS).optional(), + }) + .strict(); + +/** + * Zod schema for a `ghost.node/v1` frontmatter block. + * + * Validates a node in isolation. Graph-level rules that need the whole package + * — `under` / `relates` targets exist, exactly one incarnation-agnostic root, no + * cycles, cross-package resolution — are deferred to later-phase lint, because + * Zod cannot see other nodes from a single frontmatter. + */ +export const GhostNodeFrontmatterSchema = z + .object({ + id: NodeIdSchema, + under: NodeRefSchema.optional(), + relates: z.array(NodeRelationSchema).optional(), + incarnation: z.string().min(1).optional(), + }) + .strict(); + +export { NodeIdSchema, NodeRefSchema }; diff --git a/packages/ghost/src/ghost-core/node/serialize.ts b/packages/ghost/src/ghost-core/node/serialize.ts new file mode 100644 index 00000000..3679376a --- /dev/null +++ b/packages/ghost/src/ghost-core/node/serialize.ts @@ -0,0 +1,27 @@ +import { stringify as stringifyYaml } from "yaml"; +import type { GhostNodeDocument, GhostNodeFrontmatter } from "./types.js"; + +/** + * Serialize a node back to its `---\n\n---\n` markdown form. Keys + * are emitted in a stable order (id, under, relates, incarnation) so round-trips and + * diffs are deterministic. Undefined fields are omitted. + */ +export function serializeNode(node: GhostNodeDocument): string { + const fm = node.frontmatter; + const ordered: Record = { id: fm.id }; + if (fm.under !== undefined) ordered.under = fm.under; + if (fm.relates !== undefined) { + ordered.relates = fm.relates.map((relation) => { + const entry: Record = { to: relation.to }; + if (relation.as !== undefined) entry.as = relation.as; + return entry; + }); + } + if (fm.incarnation !== undefined) ordered.incarnation = fm.incarnation; + + const yaml = stringifyYaml(ordered).trimEnd(); + const body = node.body.replace(/^\n+/, ""); + return `---\n${yaml}\n---\n${body.length ? `\n${body}\n` : "\n"}`; +} + +export type { GhostNodeFrontmatter }; diff --git a/packages/ghost/src/ghost-core/node/types.ts b/packages/ghost/src/ghost-core/node/types.ts new file mode 100644 index 00000000..f82c7db4 --- /dev/null +++ b/packages/ghost/src/ghost-core/node/types.ts @@ -0,0 +1,73 @@ +export const GHOST_NODE_SCHEMA = "ghost.node/v1" as const; + +/** + * The closed `relates` qualifier vocabulary: how one node relates laterally to + * another. Closed by design (mirrors the surface edge vocabulary): an open set + * would make Ghost a general graph database and lose the design-composition + * focus. `governs` / `projects` are deliberately deferred (Scenario D and + * explicit medium projection) — not in v1. A relation may also be untyped + * (qualifier omitted), matching OKF's untyped-link default; the qualifier is the + * machinery handle when the author states it. + */ +export const GHOST_NODE_RELATION_KINDS = [ + "reinforces", + "contrasts", + "variant", +] as const; +export type GhostNodeRelationKind = (typeof GHOST_NODE_RELATION_KINDS)[number]; + +/** A lateral link from one node to another, optionally typed. */ +export interface GhostNodeRelation { + /** Target node ref: `` (local) or `#` (cross-package). */ + to: string; + /** The relation kind. Absent means an untyped relate. */ + as?: GhostNodeRelationKind; +} + +/** + * A node's frontmatter: the machinery's handle (identity, tree, links, + * incarnation). + * The prose body carries the design expression; intent / inventory / + * composition are authorship lenses, never fields. + */ +export interface GhostNodeFrontmatter { + /** Unique, addressable id within the package. */ + id: string; + /** + * The single containment parent (the tree + the cascade). Absent means a + * top-level node under the implicit `core` root. The tree lives only here; + * the id never encodes hierarchy. + */ + under?: string; + /** Typed lateral links to other nodes (composition graph). */ + relates?: GhostNodeRelation[]; + /** + * The incarnation this node's expression takes — the form the intent appears + * in (email, billboard, voice, web…). Absent / `any` means essence: + * incarnation-agnostic, cascades to every incarnation. Open enum: known + * incarnations plus custom strings. Filtered at gather time by `--as`. + */ + incarnation?: string; +} + +export interface GhostNodeDocument { + frontmatter: GhostNodeFrontmatter; + /** The markdown body: prose design expression. */ + body: string; +} + +export type GhostNodeLintSeverity = "error" | "warning" | "info"; + +export interface GhostNodeLintIssue { + severity: GhostNodeLintSeverity; + rule: string; + message: string; + path?: string; +} + +export interface GhostNodeLintReport { + issues: GhostNodeLintIssue[]; + errors: number; + warnings: number; + info: number; +} diff --git a/packages/ghost/src/ghost-core/package-manifest.ts b/packages/ghost/src/ghost-core/package-manifest.ts new file mode 100644 index 00000000..97170bae --- /dev/null +++ b/packages/ghost/src/ghost-core/package-manifest.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const GHOST_FINGERPRINT_PACKAGE_SCHEMA = + "ghost.fingerprint-package/v1" as const; + +const SlugIdSchema = z + .string() + .min(1) + .regex( + /^[a-z0-9][a-z0-9._-]*$/, + "id must be a lowercase slug (a-z, 0-9, '.', '_', '-')", + ); + +/** + * `extends` maps a package identity (the key, used in `:` refs) to + * where that package's `.ghost/` lives. The value is the location for now; once + * discovery lands it becomes optional (omit → resolve by matching manifest id). + */ +const ExtendsSchema = z.record(SlugIdSchema, z.string().min(1)); + +/** `manifest.yml` — anchors a `.ghost/` package. */ +export const GhostFingerprintPackageManifestSchema = z + .object({ + schema: z.literal(GHOST_FINGERPRINT_PACKAGE_SCHEMA), + id: SlugIdSchema, + extends: ExtendsSchema.optional(), + }) + .strict(); + +export interface GhostFingerprintPackageManifest { + schema: typeof GHOST_FINGERPRINT_PACKAGE_SCHEMA; + id: string; + extends?: Record; +} diff --git a/packages/ghost/src/ghost-core/perceptual-prior.ts b/packages/ghost/src/ghost-core/perceptual-prior.ts deleted file mode 100644 index 90fddba5..00000000 --- a/packages/ghost/src/ghost-core/perceptual-prior.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Ghost's perceptual prior — the opinionated stance that drift severity - * should track *how loudly a change registers visually*, not just whether - * it deviates from a recorded value. - * - * Three perceptual tiers: - * - * - **loud**: visible at first glance, no inspection required. Color - * and typeface family are loud — a new color or font is the change - * everyone notices. - * - **structural**: visible on inspection or interaction. Radius - * philosophy (pill vs. boxy), elevation vocabulary, focus treatment. - * Pill among boxes screams; the wrong shadow on a flat system jars. - * - **rhythmic**: visible only as a system property. Spacing scale - * adherence, density, motion duration. Individual deviations are - * nearly imperceptible — the rhythm matters in aggregate. - * - * Two cross-cutting checks: - * - * 1. **Match shape** is per-`CheckKind`: color is `exact`, spacing is - * `band`, type-size is `percent`, radius/shadow are `structural`. - * Defaults are sensible; per-check overrides remain available. - * 2. **Presence/absence escalation**: when survey count for a - * guarded phenomenon is ≤ `presence_floor`, escalate the check one - * tier. Sparsity is the design decision — adding to a silent pattern - * is louder than tweaking a populated one. - * - * Tier membership is a position: projects can override per-check severity - * but cannot remap a dimension's tier. The tiers are the product. - */ - -import type { CanonicalDecisionDimension } from "./decision-vocabulary.js"; -import type { - Check, - CheckKind, - CheckMatchShape, - DriftSeverity, -} from "./types.js"; - -// --- Tier table --------------------------------------------------------- - -export type PerceptualTier = "loud" | "structural" | "rhythmic"; - -/** - * Maps each canonical dimension to its perceptual tier. The mapping is a - * position, not configuration — see module docstring. - * - * Notes on a few placements: - * - `typography-voice` is structural at the dimension level; a foreign - * font *family* is loud (handled by `CheckKind: "type-family"`), while - * size-detail drift is rhythmic (handled by `CheckKind: "type-size"`). - * Per-rule kind escalation handles that split. - * - `interactive-patterns` is structural — focus rings register on - * interaction, not at first glance. - * - `composition-patterns` is structural — article, tracker, - * comparison, and card shapes change hierarchy and scanning behavior. - * - `theming-architecture` and `token-architecture` are rhythmic — - * they're plumbing, perceptible only via downstream symptoms. - */ -export const PERCEPTUAL_TIER: Readonly< - Record -> = { - "color-strategy": "loud", - "font-sourcing": "loud", - "typography-voice": "structural", - "shape-language": "structural", - elevation: "structural", - "surface-hierarchy": "structural", - "interactive-patterns": "structural", - "spatial-system": "rhythmic", - density: "rhythmic", - motion: "rhythmic", - "theming-architecture": "rhythmic", - "token-architecture": "rhythmic", - "composition-patterns": "structural", -}; - -/** - * Per-tier default severity for emitted reviewer checks. The emitter writes - * the resolved severity into the slash command so the reader sees a flat - * Critical / Serious / Nit grouping rather than a per-dimension layout. - */ -export const TIER_SEVERITY: Readonly> = { - loud: "critical", - structural: "serious", - rhythmic: "nit", -}; - -// --- Match shape and tolerance defaults -------------------------------- - -/** - * Default match shape per check kind. Color demands exact equality (any - * non-allowed hex is drift). Spacing tolerates a small absolute band - * because 7px-vs-8px is invisible. Type size uses a percentage band - * because 14→15px is invisible but 14→24px is loud. Radius and shadow - * are structural — pill vs. non-pill matters more than 999 vs. 998. - */ -export const DEFAULT_MATCH: Readonly> = { - color: "exact", - radius: "structural", - spacing: "band", - "type-size": "percent", - "type-family": "exact", - "type-weight": "exact", - shadow: "structural", - motion: "exact", -}; - -/** - * Default tolerance for each match shape. Absent for `exact` and - * `structural` (no tolerance applies). Used when a check selects a match - * shape but doesn't specify a tolerance. - */ -export const DEFAULT_TOLERANCE: Readonly< - Record -> = { - exact: undefined, - structural: undefined, - band: 2, // ±2 in source unit (typically px) - percent: 0.1, // ±10% relative -}; - -// --- Severity computation ---------------------------------------------- - -const TIER_ORDER: PerceptualTier[] = ["rhythmic", "structural", "loud"]; - -/** - * Escalate a tier one step toward `loud`. `loud` saturates — escalating - * a loud check against an absent dimension is still critical. - */ -export function escalateTier(tier: PerceptualTier): PerceptualTier { - const idx = TIER_ORDER.indexOf(tier); - if (idx < 0) return tier; - return TIER_ORDER[Math.min(idx + 1, TIER_ORDER.length - 1)] as PerceptualTier; -} - -/** - * Resolve a canonical dimension to its perceptual tier. Returns - * `structural` for unknown / non-canonical inputs — the conservative - * default. The emitter / lint should warn on non-canonical checks so - * they're caught at authoring time. - */ -export function tierForCanonical( - canonical: string | undefined, -): PerceptualTier { - if (!canonical) return "structural"; - const tier = (PERCEPTUAL_TIER as Record)[ - canonical - ]; - return tier ?? "structural"; -} - -/** - * Apply presence/absence escalation: when `surveyCount <= presenceFloor`, - * the dimension is silent (or near-silent) in the project, so any check - * guarding it is one tier louder than its base. - * - * `presenceFloor` defaults to 0 — only completely-absent guarded patterns - * trigger escalation by default. Checks that want softer escalation - * (motion in a system with 1–2 structural transitions, say) can set a - * higher floor. - */ -export function escalateForPresence( - base: PerceptualTier, - surveyCount: number, - presenceFloor = 0, -): PerceptualTier { - if (surveyCount <= presenceFloor) return escalateTier(base); - return base; -} - -/** - * Compute the final severity for a check, given its canonical dimension - * and the survey count for the guarded pattern in the current fingerprint. - * - * Resolution order: - * 1. Explicit `check.severity` wins outright. - * 2. Otherwise, base tier from `check.canonical` → `tierForCanonical`. - * 3. Apply presence/absence escalation against `check.presence_floor` - * (default 0) and the supplied `surveyCount`. - * 4. Map tier → severity via `TIER_SEVERITY`. - * - * Pure / deterministic. - */ -export function computeCheckSeverity( - check: Pick, - surveyCount: number, -): DriftSeverity { - if (check.severity) return check.severity; - const baseTier = tierForCanonical(check.canonical); - const finalTier = escalateForPresence( - baseTier, - surveyCount, - check.presence_floor ?? 0, - ); - return TIER_SEVERITY[finalTier]; -} - -/** - * Compute the final match shape for a check. Explicit `check.match` wins; - * otherwise the default for the check's kind. Returns `exact` when neither - * is set — the most conservative shape. - */ -export function resolveMatchShape( - check: Pick, -): CheckMatchShape { - if (check.match) return check.match; - if (check.kind) return DEFAULT_MATCH[check.kind]; - return "exact"; -} - -/** - * Compute the final tolerance for a check. Explicit `check.tolerance` wins; - * otherwise the default for the resolved match shape. Returns `undefined` - * for exact/structural matches, where tolerance doesn't apply. - */ -export function resolveTolerance( - check: Pick, -): number | undefined { - if (check.tolerance !== undefined) return check.tolerance; - const shape = resolveMatchShape(check); - return DEFAULT_TOLERANCE[shape]; -} diff --git a/packages/ghost/src/ghost-core/surfaces/cascade.ts b/packages/ghost/src/ghost-core/surfaces/cascade.ts deleted file mode 100644 index 2f92eb60..00000000 --- a/packages/ghost/src/ghost-core/surfaces/cascade.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { GHOST_SURFACE_ROOT_ID, type GhostSurfacesDocument } from "./types.js"; - -/** Build a child→parent lookup from a surfaces document. */ -export function buildParentMap( - surfaces: GhostSurfacesDocument | undefined, -): Map { - const parentOf = new Map(); - for (const surface of surfaces?.surfaces ?? []) { - parentOf.set(surface.id, surface.parent); - } - return parentOf; -} - -/** - * The parent chain from `surfaceId` up to the implicit `core` root, excluding - * the surface itself. `core` is always the final ancestor (the cascade root) - * unless the surface *is* core. Guards against cycles defensively (lint already - * rejects them). - * - * This is the single definition of "what cascades down to a surface" — used by - * both the slice resolver (context) and check routing (governance). - */ -export function ancestorChain( - surfaceId: string, - parentOf: Map, -): string[] { - const chain: string[] = []; - const seen = new Set([surfaceId]); - let current = parentOf.get(surfaceId); - while (current !== undefined && current !== GHOST_SURFACE_ROOT_ID) { - if (seen.has(current)) break; - chain.push(current); - seen.add(current); - if (!parentOf.has(current)) break; - current = parentOf.get(current); - } - if (surfaceId !== GHOST_SURFACE_ROOT_ID) chain.push(GHOST_SURFACE_ROOT_ID); - return chain; -} diff --git a/packages/ghost/src/ghost-core/surfaces/ground.ts b/packages/ghost/src/ghost-core/surfaces/ground.ts deleted file mode 100644 index c5e024f2..00000000 --- a/packages/ghost/src/ghost-core/surfaces/ground.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { GhostFingerprintDocument } from "../fingerprint/types.js"; -import { resolveSurfaceSlice, type SliceProvenance } from "./resolve.js"; -import type { GhostSurfacesDocument } from "./types.js"; - -/** A single grounding item, carrying its slice provenance (own | ancestor | edge). */ -export interface GroundingItem { - ref: string; - kind: "principle" | "contract" | "pattern" | "exemplar"; - statement: string; - /** Concrete source path (exemplars only). */ - path?: string; - provenance: SliceProvenance; -} - -export interface SurfaceGrounding { - surface: string; - /** Design intent a finding can cite: principles + experience contracts. */ - why: GroundingItem[]; - /** What good looks like: composition patterns + inventory exemplars. */ - what: GroundingItem[]; -} - -/** - * Project a surface's composed slice into review grounding — the *why* - * (principles, contracts) and the *what to change* (patterns, exemplars). Pure: - * reuses `resolveSurfaceSlice` (own + inherited ancestors + edges) and maps it; - * no new traversal, no I/O, no LLM. - * - * A check that fires on a surface is grounded here: the agent cites the why and - * points at the what. Inherited (ancestor) items carry their provenance so the - * consumer can show brand-wide vs. surface-specific grounding. - */ -export function groundSurface( - surfaces: GhostSurfacesDocument | undefined, - fingerprint: GhostFingerprintDocument, - surfaceId: string, -): SurfaceGrounding { - const slice = resolveSurfaceSlice(surfaces, fingerprint, surfaceId); - - const why: GroundingItem[] = [ - ...slice.principles.map((entry) => ({ - ref: `intent.principle:${entry.node.id}`, - kind: "principle" as const, - statement: entry.node.principle, - provenance: entry.provenance, - })), - ...slice.experience_contracts.map((entry) => ({ - ref: `intent.experience_contract:${entry.node.id}`, - kind: "contract" as const, - statement: entry.node.contract, - provenance: entry.provenance, - })), - ]; - - const what: GroundingItem[] = [ - ...slice.patterns.map((entry) => ({ - ref: `composition.pattern:${entry.node.id}`, - kind: "pattern" as const, - statement: entry.node.pattern, - provenance: entry.provenance, - })), - ...exemplarsForSurface(fingerprint, slice.surface, slice.ancestors), - ]; - - return { surface: surfaceId, why, what }; -} - -/** - * Exemplars are inventory nodes; the slice resolver covers intent/composition, - * so gather exemplars here by the same placement rule (own surface or any - * ancestor, unplaced → core). - */ -function exemplarsForSurface( - fingerprint: GhostFingerprintDocument, - surfaceId: string, - ancestors: string[], -): GroundingItem[] { - const cascade = new Set([surfaceId, ...ancestors]); - const items: GroundingItem[] = []; - for (const exemplar of fingerprint.inventory.exemplars) { - const placement = exemplar.surface ?? "core"; - if (!cascade.has(placement)) continue; - items.push({ - ref: `inventory.exemplar:${exemplar.id}`, - kind: "exemplar", - statement: exemplar.title ?? exemplar.why ?? exemplar.id, - path: exemplar.path, - provenance: - placement === surfaceId - ? { kind: "own" } - : { kind: "ancestor", surface: placement }, - }); - } - return items; -} diff --git a/packages/ghost/src/ghost-core/surfaces/index.ts b/packages/ghost/src/ghost-core/surfaces/index.ts index c08cf7ac..e34b683d 100644 --- a/packages/ghost/src/ghost-core/surfaces/index.ts +++ b/packages/ghost/src/ghost-core/surfaces/index.ts @@ -5,19 +5,8 @@ * disk loader and CLI wiring come later. See docs/ideas/phase-1-plan.md. */ -export { - type GroundingItem, - groundSurface, - type SurfaceGrounding, -} from "./ground.js"; export { lintGhostSurfaces } from "./lint.js"; export { buildSurfaceMenu, type SurfaceMenuEntry } from "./menu.js"; -export { - type ResolvedSlice, - resolveSurfaceSlice, - type SliceNode, - type SliceProvenance, -} from "./resolve.js"; export { GhostSurfacesSchema } from "./schema.js"; export { GHOST_SURFACE_EDGE_KINDS, diff --git a/packages/ghost/src/ghost-core/surfaces/resolve.ts b/packages/ghost/src/ghost-core/surfaces/resolve.ts deleted file mode 100644 index d6cda06b..00000000 --- a/packages/ghost/src/ghost-core/surfaces/resolve.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { - GhostFingerprintDocument, - GhostFingerprintExperienceContract, - GhostFingerprintPattern, - GhostFingerprintPrinciple, - GhostFingerprintSituation, -} from "../fingerprint/types.js"; -import { ancestorChain, buildParentMap } from "./cascade.js"; -import { - GHOST_SURFACE_ROOT_ID, - type GhostSurfaceEdgeKind, - type GhostSurfacesDocument, -} from "./types.js"; - -/** - * Why a node is present in a resolved slice. - * - `own`: placed directly on the requested surface. - * - `ancestor:`: placed on an ancestor and cascaded down the tree. - * - `edge::`: contributed by a typed composition edge (one hop). - */ -export type SliceProvenance = - | { kind: "own" } - | { kind: "ancestor"; surface: string } - | { kind: "edge"; edge: GhostSurfaceEdgeKind; surface: string }; - -export interface SliceNode { - node: T; - provenance: SliceProvenance; -} - -export interface ResolvedSlice { - /** The requested surface id. */ - surface: string; - /** Ancestor chain from the surface up to (but excluding) the implicit root. */ - ancestors: string[]; - situations: SliceNode[]; - principles: SliceNode[]; - experience_contracts: SliceNode[]; - patterns: SliceNode[]; -} - -/** - * Compose the slice for a surface, deterministically and with no I/O or LLM: - * - * - own nodes: every fingerprint/check node whose `surface:` equals the id; - * - cascaded ancestors: nodes placed on each `parent` up to the implicit `core` - * root contribute to descendants (the only inheritance — down the tree only, - * no mixins, no priority weights); - * - typed edges: for each edge on the requested surface, the target surface's - * own nodes are included once (one hop, no recursion), tagged by edge kind. - * - * Unplaced nodes (no `surface:`) belong to the implicit `core` root, so they - * cascade to every surface; lint still nudges authors to place them. - * - * Checks (`validate.yml`) are not placed on surfaces — they route by - * `applies_to.paths` (the governance/path road), which is rebuilt in Phase 7. - * The prompt-road slice is description facets only. - */ -export function resolveSurfaceSlice( - surfaces: GhostSurfacesDocument | undefined, - fingerprint: GhostFingerprintDocument, - surfaceId: string, -): ResolvedSlice { - const parentOf = buildParentMap(surfaces); - - // Ancestor chain: surfaceId's parents up to (and including) core, excluding - // the surface itself. `core` is the implicit root every chain ends at. - const ancestors = ancestorChain(surfaceId, parentOf); - - // The set of surfaces whose own nodes cascade in: the surface plus ancestors. - // A node placed on any of these is "own" (for the surface) or "ancestor". - const cascadeIds = new Set([surfaceId, ...ancestors]); - - // Edge targets on the requested surface (one hop). - const edges = - surfaces?.surfaces.find((surface) => surface.id === surfaceId)?.edges ?? []; - - const slice: ResolvedSlice = { - surface: surfaceId, - ancestors, - situations: [], - principles: [], - experience_contracts: [], - patterns: [], - }; - - const placementOf = (surface: string | undefined): string => - surface ?? GHOST_SURFACE_ROOT_ID; - - const provenanceFor = (placement: string): SliceProvenance | null => { - if (placement === surfaceId) return { kind: "own" }; - if (cascadeIds.has(placement)) { - return { kind: "ancestor", surface: placement }; - } - return null; - }; - - // Own + cascaded ancestor nodes. - for (const node of fingerprint.intent.situations) { - const provenance = provenanceFor(placementOf(node.surface)); - if (provenance) slice.situations.push({ node, provenance }); - } - for (const node of fingerprint.intent.principles) { - const provenance = provenanceFor(placementOf(node.surface)); - if (provenance) slice.principles.push({ node, provenance }); - } - for (const node of fingerprint.intent.experience_contracts) { - const provenance = provenanceFor(placementOf(node.surface)); - if (provenance) slice.experience_contracts.push({ node, provenance }); - } - for (const node of fingerprint.composition.patterns) { - const provenance = provenanceFor(placementOf(node.surface)); - if (provenance) slice.patterns.push({ node, provenance }); - } - - // Typed-edge contributions: the target surface's OWN nodes (one hop), tagged - // by edge kind. Cascade and edges do not compose recursively. - for (const edge of edges) { - const edgeProvenance: SliceProvenance = { - kind: "edge", - edge: edge.kind, - surface: edge.to, - }; - for (const node of fingerprint.intent.situations) { - if (node.surface === edge.to) { - slice.situations.push({ node, provenance: edgeProvenance }); - } - } - for (const node of fingerprint.intent.principles) { - if (node.surface === edge.to) { - slice.principles.push({ node, provenance: edgeProvenance }); - } - } - for (const node of fingerprint.intent.experience_contracts) { - if (node.surface === edge.to) { - slice.experience_contracts.push({ node, provenance: edgeProvenance }); - } - } - for (const node of fingerprint.composition.patterns) { - if (node.surface === edge.to) { - slice.patterns.push({ node, provenance: edgeProvenance }); - } - } - } - - return slice; -} diff --git a/packages/ghost/src/ghost-core/target-resolver.ts b/packages/ghost/src/ghost-core/target-resolver.ts deleted file mode 100644 index 350ab4a0..00000000 --- a/packages/ghost/src/ghost-core/target-resolver.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import type { Target } from "./types.js"; - -/** - * Resolve a target string into a typed Target. - * - * Explicit prefixes (recommended): - * github:owner/repo → GitHub clone - * npm:package-name → npm pack - * figma:file-url → Figma API - * - * Unambiguous patterns (no prefix needed): - * /absolute/path → local path - * ./relative/path → local path - * ../tracked/path → local path - * https://... → URL - * - * Ambiguous inputs without a prefix will throw an error - * with a suggestion to use a prefix. - */ -export function resolveTarget(input: string): Target { - // Explicit prefixes — unambiguous, preferred - const prefixMatch = input.match(/^(github|npm|figma|path|url):(.+)$/); - if (prefixMatch) { - const [, prefix, value] = prefixMatch; - return { type: prefix as Target["type"], value }; - } - - // Unambiguous: absolute or relative paths - if ( - input.startsWith("/") || - input.startsWith("./") || - input.startsWith("../") - ) { - return { type: "path", value: input }; - } - - // Unambiguous: exists as local path - if (existsSync(resolve(process.cwd(), input))) { - return { type: "path", value: input }; - } - - // Unambiguous: URLs - if (input.startsWith("http://") || input.startsWith("https://")) { - if (input.includes("figma.com")) { - return { type: "figma", value: input }; - } - return { type: "url", value: input }; - } - - // Unambiguous: npm scoped packages (@scope/name) - if (input.startsWith("@") && input.includes("/")) { - return { type: "npm", value: input }; - } - - // Ambiguous — require a prefix - const suggestions: string[] = []; - if (input.includes("/")) { - suggestions.push(` github:${input} (GitHub repo)`); - suggestions.push(` path:${input} (local path)`); - } else { - suggestions.push(` npm:${input} (npm package)`); - suggestions.push(` github:owner/${input} (GitHub repo)`); - } - - throw new Error( - `Ambiguous target "${input}". Use an explicit prefix:\n${suggestions.join("\n")}`, - ); -} diff --git a/packages/ghost/src/ghost-core/types.ts b/packages/ghost/src/ghost-core/types.ts deleted file mode 100644 index e87ac9a4..00000000 --- a/packages/ghost/src/ghost-core/types.ts +++ /dev/null @@ -1,634 +0,0 @@ -// --- Target --- - -export type TargetType = - | "path" - | "url" - | "registry" - | "npm" - | "github" - | "figma" - | "doc-site"; - -export interface TargetOptions { - branch?: string; - crawlDepth?: number; - figmaToken?: string; -} - -export interface Target { - type: TargetType; - value: string; - name?: string; - options?: TargetOptions; -} - -// --- Registry types (mirrors shadcn registry schema) --- - -export type RegistryItemType = - | "registry:ui" - | "registry:style" - | "registry:lib" - | "registry:base" - | "registry:font" - | "registry:block" - | "registry:component" - | "registry:hook" - | "registry:theme" - | "registry:file" - | "registry:page" - | "registry:item"; - -export interface FontDescriptor { - family: string; - provider: string; - import: string; - variable: string; - weight?: string[]; - subsets?: string[]; - selector?: string; - dependency?: string; -} - -export interface CSSVarsMap { - theme?: Record; - light?: Record; - dark?: Record; -} - -export interface Registry { - $schema?: string; - name: string; - homepage?: string; - items: RegistryItem[]; -} - -export interface RegistryItem { - name: string; - type: RegistryItemType; - dependencies?: string[]; - devDependencies?: string[]; - registryDependencies?: string[]; - files: RegistryFile[]; - categories?: string[]; - // v4 fields - font?: FontDescriptor; - cssVars?: CSSVarsMap; - css?: string; - meta?: Record; - title?: string; - description?: string; - author?: string; -} - -export interface ComponentMeta { - name: string; - description?: string; - categories: string[]; - exports: string[]; - variants: { name: string; options: string[] }[]; - dataSlots: string[]; - dependencies: string[]; - registryDependencies: string[]; -} - -export interface RegistryFile { - path: string; - content?: string; - type: string; - target: string; -} - -export interface ResolvedRegistry { - name: string; - homepage?: string; - items: RegistryItem[]; - tokens: CSSToken[]; -} - -// --- Token types --- - -export type TokenCategory = - | "background" - | "border" - | "text" - | "shadow" - | "radius" - | "spacing" - | "typography" - | "animation" - | "color" - | "font" - | "font-face" - | "chart" - | "sidebar" - | "other"; - -export interface CSSToken { - name: string; - value: string; - resolvedValue?: string; - selector: string; - category: TokenCategory; -} - -// --- Format detection --- - -export type TokenFormat = - | "css-custom-properties" - | "tailwind-config" - | "style-dictionary" - | "w3c-design-tokens" - | "shadcn-registry" - | "figma-variables" - | "unknown"; - -export interface DetectedFormat { - format: TokenFormat; - confidence: number; - evidence: string; - files: string[]; -} - -export interface NormalizedToken extends CSSToken { - originalFormat: TokenFormat; - sourceFile?: string; -} - -// --- Config types --- - -export type RuleSeverity = "error" | "warn" | "off"; - -export interface GhostConfig { - targets?: Target[]; - tracks?: Target; - rules: Record; - ignore: string[]; - embedding?: EmbeddingConfig; - extractors?: string[]; -} - -// --- Fingerprint types --- - -export interface SemanticColor { - role: string; - value: string; - oklch?: [number, number, number]; -} - -export interface ColorRamp { - steps: string[]; - count: number; -} - -// --- Check types (reviewer drift checks; perceptual-prior-aware) --- - -/** - * Perceptual severity for a drift violation. Calibrated to how loudly a - * change registers visually, not to engineering hygiene. See - * `perceptual-prior.ts` for the tier table that drives defaults. - * - * Distinct from `RuleSeverity` (`"error" | "warn" | "off"`) which is the - * config-level severity for `GhostConfig.rules`. The two never mix — - * `DriftSeverity` is for emitted reviewer checks; `RuleSeverity` gates lint - * configuration. - */ -export type DriftSeverity = "critical" | "serious" | "nit"; - -/** - * How a check's pattern is matched against violators. Color is exact; - * spacing tolerates small absolute drift; type-size tolerates relative - * drift; radius/shadow care about structural shape (pill vs. non-pill), - * not exact px. - */ -export type CheckMatchShape = "exact" | "band" | "percent" | "structural"; - -/** - * The dimension-of-value a check guards. Used to look up default match - * shape and tolerance. Distinct from canonical dimension because one - * canonical dimension (e.g. `typography-voice`) can host multiple check - * kinds (family, weight, size). - */ -export type CheckKind = - | "color" - | "radius" - | "spacing" - | "type-size" - | "type-family" - | "type-weight" - | "shadow" - | "motion"; - -export interface Check { - /** Stable id, slug-style. Used as anchor in emitted reviewer + diff. */ - id: string; - /** - * Canonical dimension this check belongs to. Drives perceptual-tier - * lookup. Optional — non-canonical checks are emitted but don't roll up - * at fleet aggregation. - */ - canonical?: string; - /** What kind of value the check guards. Drives default match shape. */ - kind?: CheckKind; - /** One-line summary the reviewer surfaces alongside violations. */ - summary?: string; - /** Regex (or fixed string) the reviewer greps for. */ - pattern: string; - /** - * Repo-relative filesystem scopes used by `verify-fingerprint` when checking - * calibrated `observed_count` values. - */ - paths?: string[]; - /** - * Reviewer/generator guidance for where the pattern usually appears. - * Open vocabulary; common values: `className`, `css_var`, - * `inline_style`, `import`. - */ - contexts?: string[]; - /** - * Optional explicit severity override. When absent, the emitter computes - * severity from `canonical` (perceptual tier), `observed_count`, and - * `presence_floor` (escalation against the survey). - */ - severity?: DriftSeverity; - /** Optional explicit match-shape override. */ - match?: CheckMatchShape; - /** Tolerance for `band` (px) or `percent` (0–1). Override of default. */ - tolerance?: number; - /** - * Survey-count threshold below which severity escalates one tier. The - * default is `0` — only when the guarded phenomenon is wholly absent - * does adding to it cross a presence boundary. Set to `2` (or higher) - * for cases like motion where a couple of structural transitions don't - * count as "this system uses motion." - */ - presence_floor?: number; - /** - * Observed count for the phenomenon this check guards, taken from the - * survey or a documented grep. When present, the review emitter - * uses this count for `presence_floor` escalation instead of falling - * back to coarse frontmatter-derived proxies. - */ - observed_count?: number; - /** - * Surveyor-computed support score: fraction of observed cases that - * already conform to this check. Used by the human curator to triage — - * <0.85 typically indicates the check isn't yet load-bearing in the - * codebase. Consumed at lint time as a soft warning. - */ - support?: number; -} - -export interface FingerprintReferences { - /** Source-of-truth spec/token/theme files worth opening during generation or drift review. */ - specs?: string[]; - /** Component directories, registries, or local libraries worth using before inventing UI. */ - components?: string[]; - /** Canonical examples, docs, or registry exemplars that show fingerprint in practice. */ - examples?: string[]; -} - -// --- Observation & decision types (three-layer fingerprint) --- - -export interface DesignObservation { - /** Holistic summary of the design language */ - summary: string; - /** Personality traits (e.g. "utilitarian", "restrained", "playful") */ - personality: string[]; - /** Closest well-known design languages for reference */ - resembles: string[]; -} - -export interface DesignDecision { - /** Freeform dimension name — LLM chooses what's relevant (e.g. "color-strategy", "motion", "density") */ - dimension: string; - /** - * Optional canonical dimension this decision rolls up under. When present, - * fleet-aggregation primitives group by this value. When absent, they - * fall back to `dimension` if it happens to be canonical, otherwise the - * decision is treated as long-tail. - * - * Authoring rule (see `closestCanonical` in `@anarchitecture/ghost/core`): when - * `dimension` itself is one of `CANONICAL_DECISION_DIMENSIONS`, omit - * `dimension_kind`. Set it only when you've chosen a project-flavored - * slug that's better described by an existing canonical dimension. - */ - dimension_kind?: string; - /** The decision stated abstractly, implementation-agnostic */ - decision: string; - /** Evidence from the source code supporting this decision */ - evidence: string[]; - /** - * Semantic embedding of `${dimension}: ${decision}`. - * Computed at fingerprint authoring time when an embedding provider is configured, - * and used by compareDecisions for paraphrase-robust matching. - * - * Runtime-only. `fingerprint.md` no longer stores decision embeddings. - */ - embedding?: number[]; -} - -export interface Fingerprint { - id: string; - source: "registry" | "extraction" | "llm" | "unknown"; - timestamp: string; - /** When fingerprinted from multiple sources, lists what was combined */ - sources?: string[]; - - // --- Three-layer model: observation → decisions → values --- - - /** Layer 1: Holistic read of the design language */ - observation?: DesignObservation; - /** Body-owned signature moves that make this design language recognizable. */ - signature?: string; - /** Direct pointers to living sources agents should read; map.md stays scan-only. */ - references?: FingerprintReferences; - /** Layer 2: Abstract design decisions, implementation-agnostic */ - decisions?: DesignDecision[]; - /** - * Human-promoted review checks — grep-friendly, severity computed - * by the perceptual prior at emit time. Coexists with `decisions[]` - * while fingerprint intent remains the primary generation surface. - */ - checks?: Check[]; - - // --- Layer 3: Concrete values --- - - palette: { - dominant: SemanticColor[]; - neutrals: ColorRamp; - semantic: SemanticColor[]; - saturationProfile: "muted" | "vibrant" | "mixed"; - contrast: "high" | "moderate" | "low"; - }; - - spacing: { - scale: number[]; - regularity: number; - baseUnit: number | null; - }; - - typography: { - families: string[]; - sizeRamp: number[]; - weightDistribution: Record; - lineHeightPattern: "tight" | "normal" | "loose"; - }; - - surfaces: { - borderRadii: number[]; - shadowComplexity: "deliberate-none" | "subtle" | "layered"; - borderUsage: "minimal" | "moderate" | "heavy"; - borderTokenCount?: number; - }; - - embedding: number[]; -} - -// --- Sampled material (LLM-first pipeline) --- - -export interface SampledFile { - path: string; - content: string; - reason: string; - /** Which source this file came from (multi-source fingerprinting) */ - sourceLabel?: string; -} - -export interface SourceInfo { - label: string; - targetType: TargetType; - fileCount: number; - sampledCount: number; -} - -export interface SampledMaterial { - files: SampledFile[]; - metadata: { - totalFiles: number; - sampledFiles: number; - targetType: TargetType; - /** When fingerprinted from multiple sources, per-source breakdown */ - sources?: SourceInfo[]; - packageJson?: { - name?: string; - dependencies?: Record; - devDependencies?: Record; - }; - packageSwift?: { - name?: string; - dependencies?: string[]; - }; - }; -} - -// --- AI enrichment types --- - -export interface EnrichedFingerprint extends Fingerprint { - detectedFormats?: DetectedFormat[]; - targetType: TargetType; -} - -export type DivergenceClass = - | "accidental-drift" - | "intentional-variant" - | "evolution-lag" - | "incompatible"; - -export interface EnrichedComparison extends FingerprintComparison { - classification: DivergenceClass; - explanations: Record; -} - -// --- Extractor types --- - -export interface ExtractedFile { - path: string; - content: string; - type: - | "css" - | "scss" - | "tailwind-config" - | "component" - | "config" - | "json-tokens" - | "style-dictionary" - | "w3c-tokens" - | "figma-variables" - | "documentation" - | "swift" - | "xcassets" - | "xcconfig" - | "other"; -} - -export interface ExtractedMaterial { - styleFiles: ExtractedFile[]; - componentFiles: ExtractedFile[]; - configFiles: ExtractedFile[]; - metadata: { - framework: string | null; - componentLibrary: string | null; - tokenCount: number; - componentCount: number; - targetType?: TargetType; - detectedFormats?: DetectedFormat[]; - sourceUrl?: string; - }; -} - -export interface ExtractorOptions { - ignore?: string[]; - maxFiles?: number; - componentDir?: string; - styleEntry?: string; -} - -export interface Extractor { - name: string; - detect: (cwd: string) => Promise; - extract: ( - cwd: string, - options?: ExtractorOptions, - ) => Promise; -} - -// --- Embedding config (used by the semantic-roles helper in embed-api.ts) --- - -export interface EmbeddingConfig { - provider: "openai" | "voyage"; - model?: string; - apiKey?: string; -} - -// --- History types --- - -export interface FingerprintHistoryEntry { - fingerprint: Fingerprint; - trackedRef?: Target; - comparisonToTracked?: { - distance: number; - dimensions: Record; - }; -} - -// --- Sync / acknowledgment types --- - -export type DimensionStance = - | "aligned" - | "accepted" - | "diverging" - | "reconverging"; - -export interface DimensionAck { - distance: number; - stance: DimensionStance; - ackedAt: string; - reason?: string; - tolerance?: number; - divergedAt?: string; -} - -export interface SyncManifest { - tracks: Target; - ackedAt: string; - trackedFingerprintId: string; - localFingerprintId: string; - dimensions: Record; - overallDistance: number; -} - -// --- Comparison types --- - -export interface DimensionDelta { - dimension: string; - distance: number; - description: string; -} - -export interface FingerprintComparison { - source: Fingerprint; - target: Fingerprint; - distance: number; - dimensions: Record; - summary: string; - vectors?: DriftVector[]; -} - -// --- Temporal / drift vector types --- - -export interface DriftVector { - dimension: string; - magnitude: number; - embeddingDelta: number[]; -} - -export interface DriftVelocity { - dimension: string; - rate: number; - direction: "converging" | "diverging" | "stable"; - windowDays: number; -} - -export interface TemporalComparison extends FingerprintComparison { - velocity: DriftVelocity[]; - daysSinceAck: number | null; - exceedsAckedBounds: boolean; - exceedingDimensions: string[]; - trajectory: "converging" | "diverging" | "stable" | "oscillating"; -} - -// --- Composite types (N≥3 fingerprint comparison) --- - -export interface CompositeMember { - id: string; - fingerprint: Fingerprint; - trackedRef?: Target; - distanceToTracked?: number; -} - -export interface CompositePair { - a: string; - b: string; - distance: number; - dimensions: Record; -} - -export interface CompositeCluster { - memberIds: string[]; - centroid: number[]; -} - -export interface CompositeComparison { - members: CompositeMember[]; - pairwise: CompositePair[]; - centroid: number[]; - spread: number; - clusters?: CompositeCluster[]; -} - -// --- Drift report types --- - -export interface ValueDrift { - token: string; - rule: string; - severity: RuleSeverity; - message: string; - fingerprintValue?: string; - implementationValue?: string; - selector?: string; - file?: string; - line?: number; - suggestion?: string; -} - -export interface StructureDrift { - component: string; - rule: string; - severity: RuleSeverity; - message: string; - diff?: string; - linesAdded: number; - linesRemoved: number; - fingerprintFile?: string; - implementationFile?: string; -} diff --git a/packages/ghost/src/index.ts b/packages/ghost/src/index.ts index 5959b6a7..840a0d28 100644 --- a/packages/ghost/src/index.ts +++ b/packages/ghost/src/index.ts @@ -1,11 +1,3 @@ -import * as compareApi from "./compare.js"; -import { compare as compareFunction } from "./core/index.js"; - -/** @deprecated Use `compare` or `@anarchitecture/ghost/compare`. */ -export * as drift from "./core/index.js"; -export * from "./core/index.js"; -export const compare = Object.assign(compareFunction, compareApi); -export * as driftCommand from "./drift-command.js"; export * as fingerprint from "./fingerprint.js"; export * as core from "./ghost-core/index.js"; /** @deprecated Use `fingerprint` or `@anarchitecture/ghost/fingerprint`. */ diff --git a/packages/ghost/src/init-command.ts b/packages/ghost/src/init-command.ts index 02925c48..f2d4a9ea 100644 --- a/packages/ghost/src/init-command.ts +++ b/packages/ghost/src/init-command.ts @@ -1,21 +1,15 @@ import type { CAC } from "cac"; -import { - initFingerprintPackage, - type resolveFingerprintPackage, -} from "./fingerprint.js"; +import { initFingerprintPackage } from "./fingerprint.js"; import { resolveGhostDirDefault } from "./scan/index.js"; export function registerInitCommand(cli: CAC): void { cli - .command("init", "Create a root .ghost split fingerprint package") + .command("init", "Create a root .ghost node fingerprint package") .option( "--package ", "Exact fingerprint package directory to initialize", ) - .option( - "--reference ", - "Reference UI registry, library path, or fingerprint to record in inventory building blocks", - ) + .option("--template ", "Init template to scaffold (default: default)") .option("--force", "Overwrite existing Ghost fingerprint files") .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (opts) => { @@ -31,28 +25,31 @@ export function registerInitCommand(cli: CAC): void { typeof opts.package === "string" ? opts.package : undefined; const ghostDir = exactPackage === undefined ? ghostDirFromEnv() : undefined; - const initOptions = { - reference: - typeof opts.reference === "string" ? opts.reference : undefined, - force: Boolean(opts.force), - }; - const paths = await initFingerprintPackage( + const result = await initFingerprintPackage( exactPackage ?? ghostDir, process.cwd(), - initOptions, + { + ...(typeof opts.template === "string" + ? { template: opts.template } + : {}), + force: Boolean(opts.force), + }, ); if (opts.format === "json") { process.stdout.write( - `${JSON.stringify(initCommandOutput(paths), null, 2)}\n`, + `${JSON.stringify( + { dir: result.paths.dir, written: result.written }, + null, + 2, + )}\n`, ); } else { process.stdout.write( - `Initialized Ghost fingerprint package: ${paths.dir}\n`, + `Initialized Ghost fingerprint package: ${result.paths.dir}\n`, ); - process.stdout.write(` manifest.yml: ${paths.manifest}\n`); - process.stdout.write(` intent.yml: ${paths.intent}\n`); - process.stdout.write(` inventory.yml: ${paths.inventory}\n`); - process.stdout.write(` composition.yml: ${paths.composition}\n`); + for (const relativePath of result.written) { + process.stdout.write(` ${relativePath}\n`); + } } process.exit(0); } catch (err) { @@ -67,15 +64,3 @@ export function registerInitCommand(cli: CAC): void { function ghostDirFromEnv(): string { return resolveGhostDirDefault(); } - -function initCommandOutput( - paths: ReturnType, -): Record { - return { - dir: paths.dir, - manifest: paths.manifest, - intent: paths.intent, - inventory: paths.inventory, - composition: paths.composition, - }; -} diff --git a/packages/ghost/src/migrate-command.ts b/packages/ghost/src/migrate-command.ts index 594236ca..39c472d4 100644 --- a/packages/ghost/src/migrate-command.ts +++ b/packages/ghost/src/migrate-command.ts @@ -1,4 +1,5 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; import type { CAC } from "cac"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { resolveFingerprintPackage } from "./fingerprint.js"; @@ -6,6 +7,7 @@ import { looksLegacy, type MigrationNote, type MigrationResult, + migratedNodeFiles, migrateLegacyPackage, } from "./scan/index.js"; @@ -58,10 +60,9 @@ export function registerMigrateCommand(cli: CAC): void { await writeMigrated( { + packageDir: paths.packageDir, surfaces: paths.surfaces, - intent: paths.intent, - inventory: paths.inventory, - composition: paths.composition, + facetFiles: [paths.intent, paths.inventory, paths.composition], }, result, Boolean(opts.force), @@ -94,24 +95,28 @@ async function readYaml( async function writeMigrated( paths: { + packageDir: string; surfaces: string; - intent: string; - inventory: string; - composition: string; + facetFiles: string[]; }, result: MigrationResult, force: boolean, ): Promise { + // One-way conversion to the node form: surfaces.yml (spine) + nodes/*.md. + // Facet files are removed; Git history preserves the old form. + const nodeFiles = migratedNodeFiles(result); const writes: Array<[string, string]> = [ [paths.surfaces, stringifyYaml(result.surfaces)], + ...nodeFiles.map((file): [string, string] => [ + join(paths.packageDir, file.relativePath), + file.content, + ]), ]; - if (result.intent) writes.push([paths.intent, stringifyYaml(result.intent)]); - if (result.inventory) { - writes.push([paths.inventory, stringifyYaml(result.inventory)]); - } - if (result.composition) { - writes.push([paths.composition, stringifyYaml(result.composition)]); - } + + // Ensure nested dirs (nodes/) exist. + const dirs = new Set(writes.map(([path]) => dirname(path))); + await Promise.all([...dirs].map((dir) => mkdir(dir, { recursive: true }))); + await Promise.all( writes.map(([path, content]) => writeFile(path, content, { @@ -127,6 +132,9 @@ async function writeMigrated( }), ), ); + + // Remove the legacy facet files (one-way migration). + await Promise.all(paths.facetFiles.map((path) => rm(path, { force: true }))); } function isExisting(err: unknown): boolean { diff --git a/packages/ghost/src/review-packet.ts b/packages/ghost/src/review-packet.ts index 5999e098..470600da 100644 --- a/packages/ghost/src/review-packet.ts +++ b/packages/ghost/src/review-packet.ts @@ -1,7 +1,7 @@ import { - groundSurface, + type GraphSlice, type RoutedCheck, - type SurfaceGrounding, + resolveGraphSlice, selectChecksForSurfaces, } from "#ghost-core"; import { loadChecksDir } from "./scan/checks-dir.js"; @@ -33,9 +33,10 @@ export async function buildReviewPacket(options: { // 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 routed = selectChecksForSurfaces(checks, loaded.graph, touched); + // Grounding is the gather slice: the prose nodes a finding can cite. const grounding = touched.map((surface) => - groundSurface(loaded.surfaces, loaded.fingerprint, surface), + resolveGraphSlice(loaded.graph, surface), ); return { @@ -153,7 +154,7 @@ interface ReviewPacketBase { interface ReviewPacket extends ReviewPacketBase { touched_surfaces: string[]; routed_checks: RoutedCheck[]; - grounding: SurfaceGrounding[]; + grounding: GraphSlice[]; invalid_checks: Array<{ file: string; message: string }>; } @@ -162,9 +163,9 @@ export function formatReviewPacketMarkdown(packet: ReviewPacket): string { Package: ${packet.package_dir} -Review this diff as a non-blocking design-language critic. Advisory findings must be evidence-routed and must cite: ${packet.required_finding_citations.join(", ")}. Do not fail the build unless the issue is tied to a routed check. Keep findings grounded in the touched surfaces' principles, contracts, patterns, exemplars, and routed checks; do not expand the review into unrelated audit categories. +Review this diff as a non-blocking design-language critic. Advisory findings must be evidence-routed and must cite: ${packet.required_finding_citations.join(", ")}. Do not fail the build unless the issue is tied to a routed check. Keep findings grounded in the touched surfaces' grounded nodes and routed checks; do not expand the review into unrelated audit categories. -Use the surface grounding first: why (principles, contracts) → what good looks like (patterns, exemplars). When a surface's grounding is silent, label the reasoning provisional or report missing-fingerprint / experience-gap instead of pretending the fingerprint is more specific than it is. +Read the grounded nodes for each touched surface (own first, then inherited from ancestors, then related). When a surface's grounding is silent, label the reasoning provisional or report missing-fingerprint / experience-gap instead of pretending the fingerprint is more specific than it is. Use these finding categories: ${packet.finding_categories.join(", ")}. @@ -236,29 +237,42 @@ function formatRoutedChecksSection(packet: ReviewPacket): string { return lines.join("\n"); } +const GROUNDING_PROVENANCE_RANK = { own: 0, ancestor: 1, edge: 2 } as const; + +function groundingProvenanceLabel( + provenance: GraphSlice["nodes"][number]["provenance"], +): string { + switch (provenance.kind) { + case "own": + return "own"; + case "ancestor": + return `from \`${provenance.from}\``; + case "edge": + return provenance.via + ? `${provenance.via} \`${provenance.from}\`` + : `relates \`${provenance.from}\``; + } +} + function formatGroundingSection(packet: ReviewPacket): string { const lines = ["## Grounding", ""]; - if ( - packet.grounding.every((g) => g.why.length === 0 && g.what.length === 0) - ) { + if (packet.grounding.every((slice) => slice.nodes.length === 0)) { lines.push("No fingerprint grounding for the touched surfaces."); return lines.join("\n"); } - for (const surface of packet.grounding) { - if (surface.why.length === 0 && surface.what.length === 0) continue; - lines.push(`### \`${surface.surface}\``); - if (surface.why.length > 0) { - lines.push("", "Why:"); - for (const item of surface.why) { - lines.push(`- ${item.statement} (\`${item.ref}\`)`); - } - } - if (surface.what.length > 0) { - lines.push("", "What good looks like:"); - for (const item of surface.what) { - const where = item.path ? ` — \`${item.path}\`` : ""; - lines.push(`- ${item.statement}${where} (\`${item.ref}\`)`); - } + for (const slice of packet.grounding) { + if (slice.nodes.length === 0) continue; + lines.push(`### \`${slice.surface}\``, ""); + const ordered = [...slice.nodes].sort( + (a, b) => + GROUNDING_PROVENANCE_RANK[a.provenance.kind] - + GROUNDING_PROVENANCE_RANK[b.provenance.kind], + ); + for (const node of ordered) { + const tag = node.incarnation ? ` _(as ${node.incarnation})_` : ""; + lines.push( + `- \`${node.id}\` (${groundingProvenanceLabel(node.provenance)})${tag}: ${node.body}`, + ); } lines.push(""); } diff --git a/packages/ghost/src/scan-emit-command.ts b/packages/ghost/src/scan-emit-command.ts deleted file mode 100644 index a8413108..00000000 --- a/packages/ghost/src/scan-emit-command.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import type { CAC } from "cac"; -import { - loadPackageContext, - type PackageContext, -} from "./context/package-context.js"; -import { emitPackageReviewCommand } from "./context/package-review-command.js"; -import { resolveFingerprintPackage } from "./fingerprint.js"; - -const DEFAULT_REVIEW_OUT = ".claude/commands/design-review.md"; - -export const SUPPORTED_KINDS = ["review-command"] as const; -export type EmitKind = (typeof SUPPORTED_KINDS)[number]; - -export type ParseEmitKindResult = - | { ok: true; kind: EmitKind } - | { ok: false; error: string }; - -/** - * Validate the positional emit kind against the supported set. - * Exported for unit testing. - */ -export function parseEmitKind(raw: string): ParseEmitKindResult { - if ((SUPPORTED_KINDS as readonly string[]).includes(raw)) { - return { ok: true, kind: raw as EmitKind }; - } - return { - ok: false, - error: `unknown emit kind '${raw}'. Supported: ${SUPPORTED_KINDS.join(", ")}`, - }; -} - -export function registerEmitCommand(cli: CAC): void { - cli - .command( - "emit ", - "Emit a derived artifact from the fingerprint package (review-command).", - ) - .option( - "--package ", - "Use exactly this fingerprint package directory (default: ./.ghost)", - ) - .option( - "-o, --out ", - `Output path (review-command → ${DEFAULT_REVIEW_OUT})`, - ) - .option("--stdout", "Write to stdout instead of a file") - .action(async (kind: string, opts) => { - try { - const parsed = parseEmitKind(kind); - if (!parsed.ok) { - console.error(`Error: ${parsed.error}`); - process.exit(2); - return; - } - - const context = await loadEmitPackageContext(opts); - const content = emitPackageReviewCommand({ - context, - }); - - if (opts.stdout) { - process.stdout.write(content); - process.exit(0); - return; - } - - const outPath = resolve(process.cwd(), opts.out ?? DEFAULT_REVIEW_OUT); - await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, content, "utf-8"); - console.log(`Wrote ${outPath}`); - process.exit(0); - return; - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); -} - -async function loadEmitPackageContext(opts: { - package?: unknown; -}): Promise { - return loadPackageContext( - resolveFingerprintPackage( - typeof opts.package === "string" ? opts.package : undefined, - process.cwd(), - ), - ); -} diff --git a/packages/ghost/src/scan/body.ts b/packages/ghost/src/scan/body.ts deleted file mode 100644 index 8b961d6c..00000000 --- a/packages/ghost/src/scan/body.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { DesignDecision } from "#ghost-core"; - -/** - * Structured read of a fingerprint.md body. The body is authoritative for - * intent — # Character, # Signature, and per-dimension rationale under # Decisions. - * Frontmatter carries the machine index and token digest; body evidence is - * parsed from each `### dimension` block and joined in by `applyBody`. - */ -export interface BodyData { - /** From `# Character` — authoritative source for DesignObservation.summary */ - character?: string; - /** From `# Signature` — recognizable output posture and dominant moves */ - signature?: string; - /** From `# Decisions` `### slug` blocks — dimension + intent rationale + evidence */ - decisions?: DesignDecision[]; -} - -type Section = { heading: string; level: number; body: string }; - -/** - * Split a markdown string into sections at exactly the requested heading level. - * Deeper headings (e.g. `##`, `###` when level=1) stay inside the section body; - * shallower headings end the section. Content before the first matching heading - * is discarded. - */ -function sectionsAt(md: string, level: number): Section[] { - const lines = md.split("\n"); - const out: Section[] = []; - let current: Section | null = null; - const buf: string[] = []; - const flush = () => { - if (current) { - current.body = buf.join("\n").trim(); - out.push(current); - buf.length = 0; - } - }; - for (const line of lines) { - const m = /^(#{1,6})\s+(.*?)\s*$/.exec(line); - if (m && m[1].length === level) { - flush(); - current = { heading: m[2], level, body: "" }; - } else if (m && m[1].length < level) { - flush(); - current = null; - } else if (current) { - buf.push(line); - } - } - flush(); - return out; -} - -/** Pull bullet items (`- foo`, `* foo`) from a block of markdown. */ -function parseBullets(block: string): string[] { - return block - .split("\n") - .map((l) => l.match(/^\s*[-*]\s+(.*)$/)?.[1]) - .filter((x): x is string => !!x && x.length > 0) - .map((s) => s.replace(/\s+$/, "")); -} - -function slug(s: string): string { - return s - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -/** - * Parse a `### Dimension\nintent…\n**Evidence:**\n- …` block. - * - * Schema 5: evidence lives in the body as a `**Evidence:**` bullet list - * following the rationale intent. Backtick fencing used for citation - * formatting is stripped so the serialized value matches the in-memory - * one (round-trip safe). - */ -function parseDecision(sec: Section): DesignDecision { - const evidenceRe = /\*\*Evidence:\*\*\s*([\s\S]*)$/i; - const match = sec.body.match(evidenceRe); - const intent = sec.body.replace(evidenceRe, "").trim(); - const evidence = match ? parseBullets(match[1]).map(unfence) : []; - return { - dimension: slug(sec.heading), - decision: intent, - evidence, - }; -} - -/** Remove surrounding backticks (citation fencing) added by the writer. */ -function unfence(s: string): string { - const trimmed = s.trim(); - if (trimmed.length >= 2 && trimmed.startsWith("`") && trimmed.endsWith("`")) { - return trimmed.slice(1, -1).replace(/\\`/g, "`"); - } - return trimmed; -} - -/** Parse a markdown body into structured BodyData. */ -export function parseBody(md: string): BodyData { - const out: BodyData = {}; - - for (const sec of sectionsAt(md, 1)) { - const h = sec.heading.toLowerCase(); - if (h.startsWith("character")) { - out.character = sec.body; - } else if (h.startsWith("signature")) { - out.signature = sec.body; - } else if (h.startsWith("decisions")) { - const blocks = sectionsAt(sec.body, 3); - if (blocks.length) out.decisions = blocks.map(parseDecision); - } - // Other H1 sections are ignored. - } - return out; -} diff --git a/packages/ghost/src/scan/compose.ts b/packages/ghost/src/scan/compose.ts deleted file mode 100644 index ad3b2507..00000000 --- a/packages/ghost/src/scan/compose.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { DesignDecision, Fingerprint } from "#ghost-core"; - -/** - * Merge an overlay fingerprint on top of a base fingerprint. Precedence rules: - * - * • Scalars / arrays → overlay replaces when present, else base - * • decisions → merged by `dimension` slug; overlay wins per-dim, - * base-only decisions are preserved - * • palette.dominant/semantic → merged by `role`; overlay wins per-role, - * base-only roles preserved - * - * This mirrors the intent of declaring "this fingerprint is based on that one, - * with these specific changes" — untouched base decisions remain, while - * overrides swap in cleanly. - */ -export function mergeFingerprint( - base: Fingerprint, - overlay: Partial, -): Fingerprint { - const merged: Fingerprint = { - ...base, - ...stripUndefined(overlay), - }; - - if (base.decisions || overlay.decisions) { - merged.decisions = mergeByKey( - base.decisions ?? [], - overlay.decisions ?? [], - (d) => d.dimension, - ); - } - - if (base.palette || overlay.palette) { - const basePalette = base.palette; - const overlayPalette = overlay.palette; - merged.palette = { - ...(basePalette ?? emptyPalette()), - ...(overlayPalette ?? {}), - dominant: mergeByKey( - basePalette?.dominant ?? [], - overlayPalette?.dominant ?? [], - (c) => c.role, - ), - semantic: mergeByKey( - basePalette?.semantic ?? [], - overlayPalette?.semantic ?? [], - (c) => c.role, - ), - // neutrals / saturationProfile / contrast: overlay replaces if present - neutrals: overlayPalette?.neutrals ?? - basePalette?.neutrals ?? { steps: [], count: 0 }, - saturationProfile: - overlayPalette?.saturationProfile ?? - basePalette?.saturationProfile ?? - "muted", - contrast: overlayPalette?.contrast ?? basePalette?.contrast ?? "moderate", - }; - } - - return merged; -} - -function mergeByKey(base: T[], overlay: T[], key: (item: T) => string): T[] { - const overlayByKey = new Map(overlay.map((item) => [key(item), item])); - const out: T[] = []; - const seen = new Set(); - - // Base order first, with overlay overrides slotted in place - for (const item of base) { - const k = key(item); - seen.add(k); - const override = overlayByKey.get(k); - out.push(override ?? item); - } - // Overlay-only entries appended at the end - for (const item of overlay) { - const k = key(item); - if (!seen.has(k)) out.push(item); - } - return out; -} - -function stripUndefined(obj: T): Partial { - const out: Partial = {}; - for (const [k, v] of Object.entries(obj)) { - if (v !== undefined) (out as Record)[k] = v; - } - return out; -} - -function emptyPalette(): Fingerprint["palette"] { - return { - dominant: [], - neutrals: { steps: [], count: 0 }, - semantic: [], - saturationProfile: "muted", - contrast: "moderate", - }; -} - -// Re-export the decision type so callers writing their own merges don't -// need to reach into ../types. -export type { DesignDecision }; diff --git a/packages/ghost/src/scan/diff.ts b/packages/ghost/src/scan/diff.ts deleted file mode 100644 index 31a07ff2..00000000 --- a/packages/ghost/src/scan/diff.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type { DesignDecision, Fingerprint } from "#ghost-core"; - -export interface DecisionChange { - dimension: string; - decisionChanged: boolean; - fromDecision?: string; - toDecision?: string; - evidenceAdded: string[]; - evidenceRemoved: string[]; -} - -export interface TokenChange { - field: string; - from: unknown; - to: unknown; -} - -export interface ColorChange { - role: string; - from: string; - to: string; -} - -export interface SemanticDiff { - decisions: { - added: DesignDecision[]; - removed: DesignDecision[]; - modified: DecisionChange[]; - }; - palette: { - dominantAdded: Array<{ role: string; value: string }>; - dominantRemoved: Array<{ role: string; value: string }>; - dominantChanged: ColorChange[]; - semanticAdded: Array<{ role: string; value: string }>; - semanticRemoved: Array<{ role: string; value: string }>; - semanticChanged: ColorChange[]; - neutralsChanged: boolean; - }; - tokens: TokenChange[]; - unchanged: boolean; -} - -/** - * Produce a semantic diff between two fingerprints — decisions added/ - * removed/modified (matched by dimension slug), palette role swaps, and - * token-scale changes. This is *not* a vector distance calculation (see - * compareFingerprints for that) — it's the qualitative "what changed in - * meaning" that shows up in PR reviews. - */ -export function diffFingerprints(a: Fingerprint, b: Fingerprint): SemanticDiff { - const decisions = diffDecisions(a.decisions ?? [], b.decisions ?? []); - const palette = diffPalette(a, b); - const tokens = diffTokens(a, b); - - const unchanged = - decisions.added.length === 0 && - decisions.removed.length === 0 && - decisions.modified.length === 0 && - palette.dominantAdded.length === 0 && - palette.dominantRemoved.length === 0 && - palette.dominantChanged.length === 0 && - palette.semanticAdded.length === 0 && - palette.semanticRemoved.length === 0 && - palette.semanticChanged.length === 0 && - !palette.neutralsChanged && - tokens.length === 0; - - return { decisions, palette, tokens, unchanged }; -} - -function diffDecisions( - a: DesignDecision[], - b: DesignDecision[], -): SemanticDiff["decisions"] { - const aMap = new Map(a.map((d) => [d.dimension, d])); - const bMap = new Map(b.map((d) => [d.dimension, d])); - - const added: DesignDecision[] = []; - const removed: DesignDecision[] = []; - const modified: DecisionChange[] = []; - - for (const [dim, dec] of bMap) { - if (!aMap.has(dim)) added.push(dec); - } - for (const [dim, dec] of aMap) { - if (!bMap.has(dim)) removed.push(dec); - } - for (const [dim, before] of aMap) { - const after = bMap.get(dim); - if (!after) continue; - const decisionChanged = before.decision.trim() !== after.decision.trim(); - const beforeEv = new Set(before.evidence ?? []); - const afterEv = new Set(after.evidence ?? []); - const evidenceAdded = [...afterEv].filter((e) => !beforeEv.has(e)); - const evidenceRemoved = [...beforeEv].filter((e) => !afterEv.has(e)); - if (decisionChanged || evidenceAdded.length || evidenceRemoved.length) { - modified.push({ - dimension: dim, - decisionChanged, - fromDecision: decisionChanged ? before.decision : undefined, - toDecision: decisionChanged ? after.decision : undefined, - evidenceAdded, - evidenceRemoved, - }); - } - } - - return { added, removed, modified }; -} - -function diffPalette(a: Fingerprint, b: Fingerprint): SemanticDiff["palette"] { - const fromDominant = byRole(a.palette?.dominant ?? []); - const toDominant = byRole(b.palette?.dominant ?? []); - const fromSemantic = byRole(a.palette?.semantic ?? []); - const toSemantic = byRole(b.palette?.semantic ?? []); - - const neutralsA = (a.palette?.neutrals?.steps ?? []).join(","); - const neutralsB = (b.palette?.neutrals?.steps ?? []).join(","); - - return { - dominantAdded: addedColors(fromDominant, toDominant), - dominantRemoved: addedColors(toDominant, fromDominant), - dominantChanged: changedColors(fromDominant, toDominant), - semanticAdded: addedColors(fromSemantic, toSemantic), - semanticRemoved: addedColors(toSemantic, fromSemantic), - semanticChanged: changedColors(fromSemantic, toSemantic), - neutralsChanged: neutralsA !== neutralsB, - }; -} - -function byRole(list: { role: string; value: string }[]): Map { - return new Map(list.map((c) => [c.role, c.value])); -} - -function addedColors( - from: Map, - to: Map, -): Array<{ role: string; value: string }> { - const out: Array<{ role: string; value: string }> = []; - for (const [role, value] of to) - if (!from.has(role)) out.push({ role, value }); - return out; -} - -function changedColors( - from: Map, - to: Map, -): ColorChange[] { - const out: ColorChange[] = []; - for (const [role, toValue] of to) { - const fromValue = from.get(role); - if (fromValue !== undefined && fromValue !== toValue) { - out.push({ role, from: fromValue, to: toValue }); - } - } - return out; -} - -function diffTokens(a: Fingerprint, b: Fingerprint): TokenChange[] { - const out: TokenChange[] = []; - const pairs: Array<[string, unknown, unknown]> = [ - ["signature", a.signature, b.signature], - ["references", a.references, b.references], - ["checks", a.checks, b.checks], - ["spacing.scale", a.spacing?.scale, b.spacing?.scale], - ["spacing.baseUnit", a.spacing?.baseUnit, b.spacing?.baseUnit], - ["typography.sizeRamp", a.typography?.sizeRamp, b.typography?.sizeRamp], - ["typography.families", a.typography?.families, b.typography?.families], - [ - "typography.lineHeightPattern", - a.typography?.lineHeightPattern, - b.typography?.lineHeightPattern, - ], - ["surfaces.borderRadii", a.surfaces?.borderRadii, b.surfaces?.borderRadii], - [ - "surfaces.shadowComplexity", - a.surfaces?.shadowComplexity, - b.surfaces?.shadowComplexity, - ], - ["surfaces.borderUsage", a.surfaces?.borderUsage, b.surfaces?.borderUsage], - [ - "palette.saturationProfile", - a.palette?.saturationProfile, - b.palette?.saturationProfile, - ], - ["palette.contrast", a.palette?.contrast, b.palette?.contrast], - ]; - for (const [field, from, to] of pairs) { - if (JSON.stringify(from) !== JSON.stringify(to)) { - out.push({ field, from, to }); - } - } - return out; -} - -/** Render a SemanticDiff as a human-readable terminal report. */ -export function formatSemanticDiff(diff: SemanticDiff): string { - if (diff.unchanged) return "No semantic changes.\n"; - - const lines: string[] = []; - - const { added, removed, modified } = diff.decisions; - if (added.length || removed.length || modified.length) { - lines.push("Decisions:"); - for (const d of added) lines.push(` + ${d.dimension}: ${d.decision}`); - for (const d of removed) lines.push(` - ${d.dimension}: ${d.decision}`); - for (const m of modified) { - lines.push(` ~ ${m.dimension}`); - if (m.decisionChanged) { - lines.push( - ` decision: "${truncate(m.fromDecision ?? "")}" → "${truncate(m.toDecision ?? "")}"`, - ); - } - if (m.evidenceAdded.length) { - lines.push(` + evidence: ${m.evidenceAdded.join(", ")}`); - } - if (m.evidenceRemoved.length) { - lines.push(` - evidence: ${m.evidenceRemoved.join(", ")}`); - } - } - lines.push(""); - } - - const p = diff.palette; - if ( - p.dominantAdded.length || - p.dominantRemoved.length || - p.dominantChanged.length || - p.semanticAdded.length || - p.semanticRemoved.length || - p.semanticChanged.length || - p.neutralsChanged - ) { - lines.push("Palette:"); - for (const c of p.dominantAdded) - lines.push(` + dominant ${c.role}: ${c.value}`); - for (const c of p.dominantRemoved) - lines.push(` - dominant ${c.role}: ${c.value}`); - for (const c of p.dominantChanged) - lines.push(` ~ dominant ${c.role}: ${c.from} → ${c.to}`); - for (const c of p.semanticAdded) - lines.push(` + semantic ${c.role}: ${c.value}`); - for (const c of p.semanticRemoved) - lines.push(` - semantic ${c.role}: ${c.value}`); - for (const c of p.semanticChanged) - lines.push(` ~ semantic ${c.role}: ${c.from} → ${c.to}`); - if (p.neutralsChanged) lines.push(" ~ neutrals ramp changed"); - lines.push(""); - } - - if (diff.tokens.length) { - lines.push("Tokens:"); - for (const t of diff.tokens) { - const from = JSON.stringify(t.from); - const to = JSON.stringify(t.to); - lines.push(` ~ ${t.field}: ${from} → ${to}`); - } - lines.push(""); - } - - return `${lines.join("\n").trimEnd()}\n`; -} - -function truncate(s: string, max = 60): string { - const clean = s.replace(/\s+/g, " ").trim(); - return clean.length > max ? `${clean.slice(0, max)}…` : clean; -} diff --git a/packages/ghost/src/scan/file-kind.ts b/packages/ghost/src/scan/file-kind.ts index ee40a12a..9ef1f26f 100644 --- a/packages/ghost/src/scan/file-kind.ts +++ b/packages/ghost/src/scan/file-kind.ts @@ -1,37 +1,25 @@ import { parse as parseYaml } from "yaml"; import { - GhostFingerprintCompositionSchema, - type GhostFingerprintDocument, - GhostFingerprintIntentSchema, - GhostFingerprintInventorySchema, GhostFingerprintPackageManifestSchema, lintGhostCheck, - lintGhostFingerprint, + lintGhostNode, lintGhostPatterns, lintGhostResources, lintGhostSurfaces, lintSurvey, type SurveyLintReport, } from "#ghost-core"; -import { lintFingerprint } from "./lint.js"; +import type { LintReport } from "./lint.js"; export type DetectedFileKind = | "survey" - | "fingerprint" - | "fingerprint-yml" | "fingerprint-manifest" - | "fingerprint-intent" - | "fingerprint-inventory" - | "fingerprint-composition" | "resources" | "patterns" | "surfaces" | "check" - | "unsupported-yaml"; - -export interface LintDetectedFileKindOptions { - fingerprint?: GhostFingerprintDocument; -} + | "node" + | "unsupported"; /** * Decide whether a file is a bundle artifact. JSON paths/contents route to @@ -43,36 +31,12 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { const lowerPath = path.toLowerCase(); const filename = lowerPath.split(/[\\/]/).pop() ?? lowerPath; if (lowerPath.endsWith(".json")) return "survey"; - if (filename === "fingerprint.yml") { - return "fingerprint-yml"; - } - if (filename === "fingerprint.yaml") { - return "fingerprint-yml"; - } if (filename === "manifest.yml") { return "fingerprint-manifest"; } if (filename === "manifest.yaml") { return "fingerprint-manifest"; } - if (filename === "intent.yml") { - return "fingerprint-intent"; - } - if (filename === "intent.yaml") { - return "fingerprint-intent"; - } - if (filename === "inventory.yml") { - return "fingerprint-inventory"; - } - if (filename === "inventory.yaml") { - return "fingerprint-inventory"; - } - if (filename === "composition.yml") { - return "fingerprint-composition"; - } - if (filename === "composition.yaml") { - return "fingerprint-composition"; - } if (filename === "resources.yml") return "resources"; if (filename === "resources.yaml") return "resources"; if (filename === "patterns.yml") return "patterns"; @@ -84,50 +48,39 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { if (filename.endsWith(".md") && /(^|[\\/])checks[\\/]/.test(lowerPath)) { return "check"; } - if (raw.trimStart().startsWith("{")) return "survey"; - if (/^\s*schema:\s*ghost\.fingerprint\/v[12]\b/m.test(raw)) { - return "fingerprint-yml"; + // A markdown node lives under a `nodes/` directory (ghost.node/v1). + if (filename.endsWith(".md") && /(^|[\\/])nodes[\\/]/.test(lowerPath)) { + return "node"; } + if (raw.trimStart().startsWith("{")) return "survey"; if (/^\s*schema:\s*ghost\.fingerprint-package\/v1\b/m.test(raw)) { return "fingerprint-manifest"; } 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 (lowerPath.endsWith(".yml") || lowerPath.endsWith(".yaml")) { - return "unsupported-yaml"; - } - return "fingerprint"; + return "unsupported"; } export function lintDetectedFileKind( kind: DetectedFileKind, raw: string, - _options: LintDetectedFileKindOptions = {}, -): ReturnType { +): LintReport { return kind === "survey" ? lintSurveyFile(raw) - : kind === "fingerprint-yml" - ? lintFingerprintYmlFile(raw) - : kind === "fingerprint-manifest" - ? lintFingerprintManifestFile(raw) - : kind === "fingerprint-intent" - ? lintFingerprintLayerFile(raw, "intent") - : kind === "fingerprint-inventory" - ? lintFingerprintLayerFile(raw, "inventory") - : kind === "fingerprint-composition" - ? lintFingerprintLayerFile(raw, "composition") - : kind === "resources" - ? lintResourcesFile(raw) - : kind === "patterns" - ? lintPatternsFile(raw) - : kind === "surfaces" - ? lintSurfacesFile(raw) - : kind === "check" - ? lintGhostCheck(raw) - : kind === "unsupported-yaml" - ? lintUnsupportedYamlFile() - : lintFingerprint(raw); + : kind === "fingerprint-manifest" + ? lintFingerprintManifestFile(raw) + : kind === "resources" + ? lintResourcesFile(raw) + : kind === "patterns" + ? lintPatternsFile(raw) + : kind === "surfaces" + ? lintSurfacesFile(raw) + : kind === "check" + ? lintGhostCheck(raw) + : kind === "node" + ? lintGhostNode(raw) + : lintUnsupportedFile(); } function lintSurveyFile(raw: string): SurveyLintReport { @@ -151,19 +104,7 @@ function lintSurveyFile(raw: string): SurveyLintReport { return lintSurvey(json); } -function lintFingerprintYmlFile( - raw: string, -): ReturnType { - try { - return lintGhostFingerprint(parseYaml(raw)); - } catch (err) { - return yamlErrorReport("fingerprint-yml-not-yaml", "fingerprint.yml", err); - } -} - -function lintFingerprintManifestFile( - raw: string, -): ReturnType { +function lintFingerprintManifestFile(raw: string): LintReport { try { return zodLintReport( GhostFingerprintPackageManifestSchema.safeParse(parseYaml(raw)), @@ -177,32 +118,10 @@ function lintFingerprintManifestFile( } } -function lintFingerprintLayerFile( - raw: string, - facet: "intent" | "inventory" | "composition", -): ReturnType { - try { - const parsed = parseYaml(raw); - const result = - facet === "intent" - ? GhostFingerprintIntentSchema.safeParse(parsed) - : facet === "inventory" - ? GhostFingerprintInventorySchema.safeParse(parsed) - : GhostFingerprintCompositionSchema.safeParse(parsed); - return zodLintReport(result); - } catch (err) { - return yamlErrorReport( - `fingerprint-${facet}-not-yaml`, - `${facet}.yml`, - err, - ); - } -} - function zodLintReport(result: { success: boolean; error?: { issues: Array<{ code: string; message: string; path: unknown[] }> }; -}): ReturnType { +}): LintReport { if (result.success) { return { issues: [], errors: 0, warnings: 0, info: 0 }; } @@ -221,7 +140,7 @@ function zodLintReport(result: { }; } -function lintResourcesFile(raw: string): ReturnType { +function lintResourcesFile(raw: string): LintReport { try { return lintGhostResources(parseYaml(raw)); } catch (err) { @@ -229,7 +148,7 @@ function lintResourcesFile(raw: string): ReturnType { } } -function lintPatternsFile(raw: string): ReturnType { +function lintPatternsFile(raw: string): LintReport { try { return lintGhostPatterns(parseYaml(raw)); } catch (err) { @@ -237,7 +156,7 @@ function lintPatternsFile(raw: string): ReturnType { } } -function lintSurfacesFile(raw: string): ReturnType { +function lintSurfacesFile(raw: string): LintReport { try { return lintGhostSurfaces(parseYaml(raw)); } catch (err) { @@ -245,14 +164,14 @@ function lintSurfacesFile(raw: string): ReturnType { } } -function lintUnsupportedYamlFile(): ReturnType { +function lintUnsupportedFile(): LintReport { return { issues: [ { severity: "error", - rule: "unsupported-yaml", + rule: "unsupported-artifact", message: - "YAML file is not a recognized Ghost artifact. Use manifest.yml, intent.yml, inventory.yml, composition.yml, resources.yml, patterns.yml, fingerprint.yml, or include a supported ghost.* schema.", + "File is not a recognized Ghost artifact. Use manifest.yml, surfaces.yml, resources.yml, patterns.yml, a checks/*.md check, or a nodes/*.md node.", }, ], errors: 1, @@ -265,7 +184,7 @@ function yamlErrorReport( rule: string, label: string, err: unknown, -): ReturnType { +): LintReport { return { issues: [ { diff --git a/packages/ghost/src/scan/fingerprint-contribution.ts b/packages/ghost/src/scan/fingerprint-contribution.ts index da38b74a..6ac1037c 100644 --- a/packages/ghost/src/scan/fingerprint-contribution.ts +++ b/packages/ghost/src/scan/fingerprint-contribution.ts @@ -1,4 +1,4 @@ -import type { GhostFingerprintDocument } from "#ghost-core"; +import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "#ghost-core"; export type ScanContributionState = | "missing" @@ -6,242 +6,104 @@ export type ScanContributionState = | "empty" | "contributing"; -export type ScanFacet = "intent" | "inventory" | "composition"; -export type ScanFacetState = "absent" | "empty" | "useful"; - -export interface ScanFacetFileState { - path: string; - present: boolean; -} - -export interface ScanFacetReport { - state: ScanFacetState; - /** Absolute path to the facet file. */ - path: string; - file_present: boolean; - count: number; - reasons: string[]; -} - -export interface ScanBuildingBlockRows { - tokens: number; - components: number; - libraries: number; - assets: number; - routes: number; - files: number; - notes: number; +export interface ScanSurfaceCoverage { + /** Surface id (tree position). */ + id: string; + /** Number of nodes placed directly on this surface. */ + node_count: number; } export interface ScanContributionReport { state: ScanContributionState; - facets: Record; - contributing_facets: ScanFacet[]; - empty_facets: ScanFacet[]; - absent_facets: ScanFacet[]; + /** Total authored nodes in the graph. */ + node_count: number; + /** Nodes with no incarnation tag (essence). */ + essence_count: number; + /** Nodes carrying an incarnation tag. */ + incarnation_count: number; + /** Declared surfaces and how many nodes each holds. */ + surfaces: ScanSurfaceCoverage[]; + /** Declared surfaces with zero nodes placed on them. */ + sparse_surfaces: string[]; reasons: string[]; - product_surface_count: number; - demo_surface_count: number; - building_block_rows: ScanBuildingBlockRows; } -const FACETS: ScanFacet[] = ["intent", "inventory", "composition"]; - +/** + * Summarize what a package contributes, as node/surface contribution over the + * graph (the only model). A package is "contributing" when it has at least one + * node beyond the implicit root. + */ export function summarizeFingerprintContribution(input: { - fingerprint?: GhostFingerprintDocument; - files: Record; + graph?: GhostGraph; + /** Declared surface ids from surfaces.yml (excluding the implicit root). */ + surfaceIds?: string[]; missing?: boolean; invalidReason?: string; }): ScanContributionReport { - const buildingBlockRows = countBuildingBlocks(input.fingerprint); - const counts: Record = { - intent: countIntent(input.fingerprint), - inventory: countInventory(input.fingerprint, buildingBlockRows), - composition: countComposition(input.fingerprint), - }; - const facets = Object.fromEntries( - FACETS.map((facet) => [ - facet, - facetReport(facet, input.files[facet], counts[facet]), - ]), - ) as Record; - const contributingFacets = FACETS.filter( - (facet) => facets[facet].state === "useful", + if (input.missing) { + return emptyReport("missing", [ + "No manifest.yml found; this is not a Ghost package.", + ]); + } + if (input.invalidReason || !input.graph) { + return emptyReport("invalid", [ + `Package did not load: ${input.invalidReason ?? "unknown error"}.`, + ]); + } + + const graph = input.graph; + const nodes = [...graph.nodes.values()].filter( + (node) => node.id !== GHOST_GRAPH_ROOT_ID, ); - const emptyFacets = FACETS.filter((facet) => facets[facet].state === "empty"); - const absentFacets = FACETS.filter( - (facet) => facets[facet].state === "absent", + const essence = nodes.filter((node) => node.incarnation === undefined); + const tagged = nodes.filter((node) => node.incarnation !== undefined); + + // Surface coverage: count nodes whose `under` is each declared surface. + const placement = new Map(); + for (const node of nodes) { + const under = node.under ?? GHOST_GRAPH_ROOT_ID; + placement.set(under, (placement.get(under) ?? 0) + 1); + } + const surfaceIds = (input.surfaceIds ?? []).filter( + (id) => id !== GHOST_GRAPH_ROOT_ID, ); + const surfaces: ScanSurfaceCoverage[] = surfaceIds + .map((id) => ({ id, node_count: placement.get(id) ?? 0 })) + .sort((a, b) => a.id.localeCompare(b.id)); + const sparse = surfaces.filter((s) => s.node_count === 0).map((s) => s.id); - const state: ScanContributionState = input.missing - ? "missing" - : input.invalidReason - ? "invalid" - : contributingFacets.length > 0 - ? "contributing" - : "empty"; - - return { - state, - facets, - contributing_facets: contributingFacets, - empty_facets: emptyFacets, - absent_facets: absentFacets, - reasons: contributionReasons(state, { - contributingFacets, - emptyFacets, - absentFacets, - invalidReason: input.invalidReason, - }), - product_surface_count: input.fingerprint?.inventory.exemplars.length ?? 0, - demo_surface_count: 0, - building_block_rows: buildingBlockRows, - }; -} + const state: ScanContributionState = + nodes.length > 0 ? "contributing" : "empty"; -function facetReport( - facet: ScanFacet, - file: ScanFacetFileState, - count: number, -): ScanFacetReport { - const state: ScanFacetState = !file.present - ? "absent" - : count > 0 - ? "useful" - : "empty"; return { state, - path: file.path, - file_present: file.present, - count, - reasons: facetReasons(facet, state, count), + node_count: nodes.length, + essence_count: essence.length, + incarnation_count: tagged.length, + surfaces, + sparse_surfaces: sparse, + reasons: + state === "contributing" + ? sparse.length > 0 + ? [`Add nodes for sparse surfaces: ${sparse.join(", ")}.`] + : ["Package contributes nodes across its declared surfaces."] + : [ + "Package is valid but has no nodes yet. Add nodes/*.md to contribute.", + ], }; } -function facetReasons( - facet: ScanFacet, - state: ScanFacetState, - count: number, -): string[] { - if (state === "useful") { - return [`${facet}.yml contributes ${count} useful item(s).`]; - } - if (state === "empty") { - return [ - `${facet}.yml is present but does not contribute useful items yet.`, - ]; - } - return [ - `${facet}.yml is absent; this package contributes no ${facet} facet.`, - ]; -} - -function contributionReasons( +function emptyReport( state: ScanContributionState, - input: { - contributingFacets: ScanFacet[]; - emptyFacets: ScanFacet[]; - absentFacets: ScanFacet[]; - invalidReason?: string; - }, -): string[] { - if (state === "missing") { - return [ - "manifest.yml is missing, so no package contribution can be resolved.", - ]; - } - if (state === "invalid") { - return [ - `fingerprint package could not be read: ${input.invalidReason ?? "invalid fingerprint package"}`, - ]; - } - if (state === "empty") { - const detail = input.emptyFacets.length - ? ` Empty facets: ${input.emptyFacets.join(", ")}.` - : ""; - const absent = input.absentFacets.length - ? ` Absent facets may be inherited from broader stack context: ${input.absentFacets.join(", ")}.` - : ""; - return [ - `Ghost package is valid but this package contributes no useful facets yet.${detail}${absent}`, - ]; - } - - const absent = input.absentFacets.length - ? ` Absent facets may be inherited from broader stack context: ${input.absentFacets.join(", ")}.` - : ""; - const empty = input.emptyFacets.length - ? ` Empty facets: ${input.emptyFacets.join(", ")}.` - : ""; - return [ - `Ghost package contributes ${input.contributingFacets.join(", ")}.${empty}${absent}`, - ]; -} - -function countIntent( - fingerprint: GhostFingerprintDocument | undefined, -): number { - if (!fingerprint) return 0; - return ( - summaryFieldCount(fingerprint.intent.summary) + - fingerprint.intent.situations.length + - fingerprint.intent.principles.length + - fingerprint.intent.experience_contracts.length - ); -} - -function countInventory( - fingerprint: GhostFingerprintDocument | undefined, - buildingBlockRows: ScanBuildingBlockRows, -): number { - if (!fingerprint) return 0; - return ( - fingerprint.inventory.exemplars.length + - fingerprint.inventory.sources.length + - buildingBlockRows.tokens + - buildingBlockRows.components + - buildingBlockRows.libraries + - buildingBlockRows.assets + - buildingBlockRows.routes + - buildingBlockRows.files + - buildingBlockRows.notes - ); -} - -function countComposition( - fingerprint: GhostFingerprintDocument | undefined, -): number { - return fingerprint?.composition.patterns.length ?? 0; -} - -function countBuildingBlocks( - fingerprint: GhostFingerprintDocument | undefined, -): ScanBuildingBlockRows { - const buildingBlocks = fingerprint?.inventory.building_blocks; + reasons: string[], +): ScanContributionReport { return { - tokens: buildingBlocks?.tokens?.length ?? 0, - components: buildingBlocks?.components?.length ?? 0, - libraries: buildingBlocks?.libraries?.length ?? 0, - assets: buildingBlocks?.assets?.length ?? 0, - routes: buildingBlocks?.routes?.length ?? 0, - files: buildingBlocks?.files?.length ?? 0, - notes: buildingBlocks?.notes?.length ?? 0, + state, + node_count: 0, + essence_count: 0, + incarnation_count: 0, + surfaces: [], + sparse_surfaces: [], + reasons, }; } - -function summaryFieldCount( - summary: GhostFingerprintDocument["intent"]["summary"], -): number { - let count = 0; - if (summary.product?.trim()) count += 1; - for (const field of [ - summary.audience, - summary.goals, - summary.anti_goals, - summary.tradeoffs, - summary.tone, - ]) { - count += field?.length ?? 0; - } - return count; -} diff --git a/packages/ghost/src/scan/fingerprint-package-layers.ts b/packages/ghost/src/scan/fingerprint-package-layers.ts index 700bc72c..365496ab 100644 --- a/packages/ghost/src/scan/fingerprint-package-layers.ts +++ b/packages/ghost/src/scan/fingerprint-package-layers.ts @@ -1,82 +1,136 @@ -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; -import type { ZodIssue, ZodType } from "zod"; import { - GHOST_FINGERPRINT_PACKAGE_SCHEMA, - GHOST_FINGERPRINT_SCHEMA, - GhostFingerprintCompositionSchema, - type GhostFingerprintDocument, - GhostFingerprintIntentSchema, - GhostFingerprintInventorySchema, + assembleGraph, type GhostFingerprintPackageManifest, GhostFingerprintPackageManifestSchema, - GhostFingerprintSchema, + type GhostGraphNode, type GhostSurfacesDocument, GhostSurfacesSchema, - lintGhostFingerprint, + lintGraph, } from "#ghost-core"; -import { readOptionalUtf8 } from "../internal/fs.js"; -import type { - FingerprintPackagePaths, - LoadedFingerprintPackage, +import { isMissingPathError, readOptionalUtf8 } from "../internal/fs.js"; +import { + type FingerprintPackagePaths, + type LoadedFingerprintPackage, + resolveFingerprintPackage, } from "./fingerprint-package.js"; import type { LintIssue } from "./lint.js"; -import { normalizeReferenceInput } from "./package-config.js"; +import { loadNodesDir } from "./nodes-dir.js"; + +const LEGACY_FACET_FILES = ["intent.yml", "inventory.yml", "composition.yml"]; export async function loadFingerprintPackage( paths: FingerprintPackagePaths, ): Promise { - const [manifestRaw, intentRaw, inventoryRaw, compositionRaw, surfacesRaw] = - await Promise.all([ - readFile(paths.manifest, "utf-8"), - readOptional(paths.intent), - readOptional(paths.inventory), - readOptional(paths.composition), - readOptional(paths.surfaces), - ]); + const [manifestRaw, surfacesRaw] = await Promise.all([ + readFile(paths.manifest, "utf-8"), + readOptional(paths.surfaces), + ]); const manifest = parseManifest(manifestRaw, "manifest.yml"); const surfaces = parseSurfaces(surfacesRaw); - const fingerprint = assembleFingerprint({ - intent: parseLayer( - intentRaw, - "intent.yml", - GhostFingerprintIntentSchema, - emptyIntent(), - ), - inventory: parseLayer( - inventoryRaw, - "inventory.yml", - GhostFingerprintInventorySchema, - emptyInventory(), - ), - composition: parseLayer( - compositionRaw, - "composition.yml", - GhostFingerprintCompositionSchema, - emptyComposition(), - ), - }); - const report = lintGhostFingerprint(fingerprint); + + // Legacy facet packages no longer load directly — guide to `ghost migrate`. + await assertNotLegacyFacetPackage(paths); + + const { nodes: nodeFiles } = await loadNodesDir(paths.dir); + const inheritedNodes = await loadInheritedNodes(manifest, paths); + const graph = assembleGraph({ nodeFiles, surfaces, inheritedNodes }); + + const report = lintGraph(graph); if (report.errors > 0) { const first = report.issues.find((issue) => issue.severity === "error"); - const suffix = first?.path ? ` @ ${splitFingerprintPath(first.path)}` : ""; + const suffix = first?.node ? ` (node '${first.node}')` : ""; throw new Error( - `fingerprint package failed lint: ${first?.message ?? "invalid fingerprint"}${suffix}`, + `fingerprint package graph is invalid: ${first?.message ?? "invalid graph"}${suffix}`, ); } + return { manifest, manifestRaw, - fingerprint, + graph, ...(surfaces ? { surfaces } : {}), - layerRaw: { - ...(intentRaw !== undefined ? { intent: intentRaw } : {}), - ...(inventoryRaw !== undefined ? { inventory: inventoryRaw } : {}), - ...(compositionRaw !== undefined ? { composition: compositionRaw } : {}), - }, }; } +/** + * Resolve the package's `extends` map into read-only inherited nodes. Each + * entry maps a package identity (the key, used in `:` refs) to where + * that package's `.ghost/` lives. Inherited node ids are qualified with the + * identity; their internal containment is *not* re-rooted into this package + * (it was validated in their own package) — they enter as referenceable, + * read-only context. One level deep (no transitive extends in v1). + */ +async function loadInheritedNodes( + manifest: GhostFingerprintPackageManifest, + paths: FingerprintPackagePaths, +): Promise { + const out: GhostGraphNode[] = []; + for (const [id, location] of Object.entries(manifest.extends ?? {})) { + const dir = isAbsolute(location) + ? location + : resolve(paths.packageDir, location); + let loaded: LoadedFingerprintPackage; + try { + loaded = await loadFingerprintPackage(resolveFingerprintPackage(dir)); + } catch (err) { + throw new Error( + `extends '${id}': could not load package at ${location} (${ + err instanceof Error ? err.message : String(err) + }).`, + ); + } + if (loaded.manifest.id !== id) { + throw new Error( + `extends '${id}': resolved package at ${location} declares id '${loaded.manifest.id}'. The extends key must match the extended package's manifest id.`, + ); + } + for (const node of loaded.graph.nodes.values()) { + if (node.origin === "inherited") continue; // no transitive extends in v1 + out.push({ + id: `${id}:${node.id}`, + relates: [], + ...(node.incarnation !== undefined + ? { incarnation: node.incarnation } + : {}), + body: node.body, + origin: "inherited", + }); + } + } + return out; +} + +/** + * If a package still ships the legacy facet files and has no `nodes/`, fail + * with migrate guidance rather than a confusing graph error. + */ +async function assertNotLegacyFacetPackage( + paths: FingerprintPackagePaths, +): Promise { + const hasNodes = await pathExists(paths.nodes); + if (hasNodes) return; + for (const facet of LEGACY_FACET_FILES) { + if (await pathExists(`${paths.packageDir}/${facet}`)) { + throw new Error( + `This is a legacy facet package (found ${facet}, no nodes/). Run \`ghost migrate\` to convert it to the node model.`, + ); + } + } +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (err) { + if (isMissingPathError(err)) return false; + throw err; + } +} + function parseSurfaces( raw: string | undefined, ): GhostSurfacesDocument | undefined { @@ -101,99 +155,18 @@ export function lintFingerprintPackageManifest( GhostFingerprintPackageManifestSchema.safeParse(manifest); if (!manifestResult.success) { issues.push( - ...prefixIssues( - "manifest.yml", - zodLikeIssues(manifestResult.error.issues), - ), + ...manifestResult.error.issues.map((issue) => ({ + severity: "error" as const, + rule: `schema/${issue.code}`, + message: issue.message, + path: issue.path.length + ? `manifest.yml.${issue.path.join(".")}` + : "manifest.yml", + })), ); } } -export function parseSplitFingerprintForLint( - input: { - intentRaw?: string; - inventoryRaw?: string; - compositionRaw?: string; - }, - issues: LintIssue[], -): GhostFingerprintDocument | undefined { - const intent = parseLayerForLint( - input.intentRaw, - "intent.yml", - GhostFingerprintIntentSchema, - emptyIntent(), - issues, - ); - const inventory = parseLayerForLint( - input.inventoryRaw, - "inventory.yml", - GhostFingerprintInventorySchema, - emptyInventory(), - issues, - ); - const composition = parseLayerForLint( - input.compositionRaw, - "composition.yml", - GhostFingerprintCompositionSchema, - emptyComposition(), - issues, - ); - if (!intent || !inventory || !composition) return undefined; - - const fingerprint = assembleFingerprint({ intent, inventory, composition }); - const fingerprintReport = lintGhostFingerprint(fingerprint); - issues.push( - ...fingerprintReport.issues.map((issue) => ({ - ...issue, - path: issue.path ? splitFingerprintPath(issue.path) : "fingerprint", - })), - ); - return fingerprintReport.errors === 0 ? fingerprint : undefined; -} - -export function templateManifest(): string { - return `schema: ${GHOST_FINGERPRINT_PACKAGE_SCHEMA} -id: local -`; -} - -export function templateIntent(): string { - return `summary: {} -situations: [] -principles: [] -experience_contracts: [] -`; -} - -export function templateInventory(reference?: string): string { - const referenceInput = reference - ? normalizeReferenceInput(reference) - : undefined; - if (referenceInput) { - return `building_blocks: - libraries: - - ${referenceInput.id} -exemplars: [] -sources: - - id: ${referenceInput.id} - kind: ${sourceKindForReference(referenceInput.source)} - ref: ${referenceInput.source} -`; - } - - return `building_blocks: {} -exemplars: [] -sources: [] -`; -} - -export function templateComposition(): string { - return `patterns: [] -`; -} - -const readOptional = readOptionalUtf8; - function parseManifest( raw: string, label: string, @@ -204,71 +177,6 @@ function parseManifest( ) as GhostFingerprintPackageManifest; } -function parseLayer( - raw: string | undefined, - label: string, - schema: ZodType, - empty: T, -): T { - if (raw === undefined || raw.trim().length === 0) return empty; - const parsed = parseYamlStrict(raw, label); - return schema.parse(parsed) as T; -} - -function parseLayerForLint( - raw: string | undefined, - label: string, - schema: ZodType, - empty: T, - issues: LintIssue[], -): T | undefined { - if (raw === undefined || raw.trim().length === 0) return empty; - const parsed = parseYamlSafe(raw, label, issues); - if (parsed === undefined) return undefined; - const result = schema.safeParse(parsed); - if (!result.success) { - issues.push(...prefixIssues(label, zodLikeIssues(result.error.issues))); - return undefined; - } - return result.data as T; -} - -function assembleFingerprint(input: { - intent: GhostFingerprintDocument["intent"]; - inventory: GhostFingerprintDocument["inventory"]; - composition: GhostFingerprintDocument["composition"]; -}): GhostFingerprintDocument { - return GhostFingerprintSchema.parse({ - schema: GHOST_FINGERPRINT_SCHEMA, - intent: input.intent, - inventory: input.inventory, - composition: input.composition, - }) as GhostFingerprintDocument; -} - -function emptyIntent(): GhostFingerprintDocument["intent"] { - return { - summary: {}, - situations: [], - principles: [], - experience_contracts: [], - }; -} - -function emptyInventory(): GhostFingerprintDocument["inventory"] { - return { - building_blocks: {}, - exemplars: [], - sources: [], - }; -} - -function emptyComposition(): GhostFingerprintDocument["composition"] { - return { - patterns: [], - }; -} - function parseYamlStrict(raw: string, label: string): unknown { try { return parseYaml(raw); @@ -301,67 +209,4 @@ function parseYamlSafe( } } -function splitFingerprintPath(path: string): string { - if (path === "intent") return "intent.yml"; - if (path.startsWith("intent.")) { - return `intent.yml.${path.slice("intent.".length)}`; - } - if (path === "inventory") return "inventory.yml"; - if (path.startsWith("inventory.")) { - return `inventory.yml.${path.slice("inventory.".length)}`; - } - if (path === "composition") return "composition.yml"; - if (path.startsWith("composition.")) { - return `composition.yml.${path.slice("composition.".length)}`; - } - return `fingerprint/${path}`; -} - -function zodLikeIssues(issues: ZodIssue[]): Array<{ - severity: "error"; - rule: string; - message: string; - path?: string; -}> { - return issues.map((issue) => ({ - severity: "error", - 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 prefixIssues( - label: string, - input: Array<{ - severity: "error" | "warning" | "info"; - rule: string; - message: string; - path?: string; - }>, -): LintIssue[] { - return input.map((issue) => ({ - severity: issue.severity, - rule: issue.rule, - message: issue.message, - path: issue.path ? `${label}.${issue.path}` : label, - })); -} - -function sourceKindForReference( - source: string, -): "cache" | "registry" | "file" | "url" | "package" { - if (source.startsWith("registry:")) return "registry"; - if (/^https?:\/\//i.test(source)) return "url"; - if (source.startsWith("npm:")) return "package"; - return "file"; -} +const readOptional = readOptionalUtf8; diff --git a/packages/ghost/src/scan/fingerprint-package.ts b/packages/ghost/src/scan/fingerprint-package.ts index 5c491080..e0174919 100644 --- a/packages/ghost/src/scan/fingerprint-package.ts +++ b/packages/ghost/src/scan/fingerprint-package.ts @@ -1,75 +1,73 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { parse as parseYaml } from "yaml"; +import { dirname, join, resolve } from "node:path"; import { GHOST_SURFACES_YML_FILENAME, - type GhostFingerprintDocument, type GhostFingerprintPackageManifest, + type GhostGraph, type GhostSurfacesDocument, + lintGraph, SURVEY_FILENAME, } from "#ghost-core"; -import { - isExistingPathError, - isMissingPathError, - readOptionalUtf8, -} from "../internal/fs.js"; +import { isExistingPathError, isMissingPathError } from "../internal/fs.js"; import { FINGERPRINT_COMPOSITION_FILENAME, - FINGERPRINT_FILENAME, FINGERPRINT_INTENT_FILENAME, FINGERPRINT_INVENTORY_FILENAME, FINGERPRINT_MANIFEST_FILENAME, FINGERPRINT_PACKAGE_DIR, - FINGERPRINT_YML_FILENAME, PATTERNS_FILENAME, RESOURCES_FILENAME, } from "./constants.js"; import { lintFingerprintPackageManifest, - parseSplitFingerprintForLint, - templateComposition, - templateIntent, - templateInventory, - templateManifest, + loadFingerprintPackage, } from "./fingerprint-package-layers.js"; import type { LintIssue, LintReport } from "./lint.js"; +import { + DEFAULT_TEMPLATE_NAME, + getInitTemplate, + listInitTemplates, +} from "./templates.js"; -export { loadFingerprintPackage } from "./fingerprint-package-layers.js"; +export { loadFingerprintPackage }; export interface FingerprintPackagePaths { dir: string; packageDir: string; manifest: string; - intent: string; - inventory: string; - composition: string; surfaces: string; - fingerprintYml: string; + /** The `nodes/` directory holding `ghost.node/v1` markdown nodes. */ + nodes: string; resources: string; survey: string; patterns: string; - /** Legacy direct markdown path; not part of the canonical root bundle. */ - fingerprint: string; + /** Legacy facet paths — used only to detect legacy packages for migration. */ + intent: string; + inventory: string; + composition: string; } export interface LoadedFingerprintPackage { manifest: GhostFingerprintPackageManifest; manifestRaw: string; - fingerprint: GhostFingerprintDocument; /** Parsed `surfaces.yml`, or `undefined` when the package has no surfaces file. */ surfaces?: GhostSurfacesDocument; - layerRaw: { - intent?: string; - inventory?: string; - composition?: string; - }; + /** The in-memory node graph — the only fingerprint model. */ + graph: GhostGraph; } export interface InitFingerprintPackageOptions { - reference?: string; + /** Init template name (default: "default"). */ + template?: string; force?: boolean; } +export interface InitFingerprintPackageResult { + paths: FingerprintPackagePaths; + /** Package-relative paths of the files the template wrote. */ + written: string[]; +} + export function resolveFingerprintPackage( dirArg: string | undefined, cwd = process.cwd(), @@ -80,15 +78,14 @@ export function resolveFingerprintPackage( dir, packageDir, manifest: join(packageDir, FINGERPRINT_MANIFEST_FILENAME), - intent: join(packageDir, FINGERPRINT_INTENT_FILENAME), - inventory: join(packageDir, FINGERPRINT_INVENTORY_FILENAME), - composition: join(packageDir, FINGERPRINT_COMPOSITION_FILENAME), surfaces: join(packageDir, GHOST_SURFACES_YML_FILENAME), - fingerprintYml: join(dir, FINGERPRINT_YML_FILENAME), + nodes: join(packageDir, "nodes"), resources: join(dir, RESOURCES_FILENAME), survey: join(dir, SURVEY_FILENAME), patterns: join(dir, PATTERNS_FILENAME), - fingerprint: join(dir, FINGERPRINT_FILENAME), + intent: join(packageDir, FINGERPRINT_INTENT_FILENAME), + inventory: join(packageDir, FINGERPRINT_INVENTORY_FILENAME), + composition: join(packageDir, FINGERPRINT_COMPOSITION_FILENAME), }; } @@ -96,22 +93,37 @@ export async function initFingerprintPackage( dirArg: string | undefined, cwd = process.cwd(), options: InitFingerprintPackageOptions = {}, -): Promise { +): Promise { + const templateName = options.template ?? DEFAULT_TEMPLATE_NAME; + const template = getInitTemplate(templateName); + if (!template) { + throw new Error( + `Unknown init template '${templateName}'. Available: ${listInitTemplates().join(", ")}.`, + ); + } + const paths = resolveFingerprintPackage(dirArg, cwd); await mkdir(paths.packageDir, { recursive: true }); - const files = [ - { path: paths.manifest, content: templateManifest() }, - { path: paths.intent, content: templateIntent() }, - { path: paths.inventory, content: templateInventory(options.reference) }, - { path: paths.composition, content: templateComposition() }, - ]; + + const files = template.files().map((file) => ({ + relativePath: file.relativePath, + path: join(paths.packageDir, file.relativePath), + content: file.content, + })); + if (!options.force) { await assertInitDoesNotOverwrite(files.map((file) => file.path)); } + + // Create any nested directories the template needs (e.g. nodes/). + const dirs = new Set(files.map((file) => dirname(file.path))); + await Promise.all([...dirs].map((dir) => mkdir(dir, { recursive: true }))); + await Promise.all( files.map((file) => writeInitFile(file.path, file.content, options.force)), ); - return paths; + + return { paths, written: files.map((file) => file.relativePath) }; } async function writeInitFile( @@ -153,6 +165,12 @@ async function assertInitDoesNotOverwrite(paths: string[]): Promise { } } +/** + * `validate` for a package: shape pass (manifest well-formed) + graph pass + * (the node network is correct — links resolve, one root, acyclic). Loading the + * package already runs the graph pass and throws on error; here we surface both + * passes as a structured report. + */ export async function lintFingerprintPackage( dirArg: string | undefined, cwd = process.cwd(), @@ -165,17 +183,30 @@ export async function lintFingerprintPackage( "manifest.yml", issues, ); - const intentRaw = await readOptional(paths.intent); - const inventoryRaw = await readOptional(paths.inventory); - const compositionRaw = await readOptional(paths.composition); - let _fingerprint: GhostFingerprintDocument | undefined; if (manifestRaw !== undefined) { + // shape pass: manifest well-formed. lintFingerprintPackageManifest(manifestRaw, issues); - _fingerprint = parseSplitFingerprintForLint( - { intentRaw, inventoryRaw, compositionRaw }, - issues, - ); + // graph pass: fold + validate the node network. + try { + const { graph } = await loadFingerprintPackage(paths); + const graphReport = lintGraph(graph); + issues.push( + ...graphReport.issues.map((issue) => ({ + severity: issue.severity, + rule: issue.rule, + message: issue.message, + ...(issue.node ? { path: `nodes/${issue.node}` } : {}), + })), + ); + } catch (err) { + issues.push({ + severity: "error", + rule: "package-graph-invalid", + message: err instanceof Error ? err.message : String(err), + path: ".ghost", + }); + } } return finalize(issues); @@ -199,45 +230,6 @@ async function readRequired( } } -const readOptional = readOptionalUtf8; - -function _parseYamlSafe( - raw: string, - label: string, - issues: LintIssue[], -): unknown | undefined { - try { - return parseYaml(raw); - } catch (err) { - issues.push({ - severity: "error", - rule: "package-yaml-invalid", - message: `${label} is not valid YAML: ${ - err instanceof Error ? err.message : String(err) - }`, - path: label, - }); - return undefined; - } -} - -function _prefixIssues( - label: string, - input: Array<{ - severity: "error" | "warning" | "info"; - rule: string; - message: string; - path?: string; - }>, -): LintIssue[] { - return input.map((issue) => ({ - severity: issue.severity, - rule: issue.rule, - message: issue.message, - path: issue.path ? `${label}.${issue.path}` : label, - })); -} - function finalize(issues: LintIssue[]): LintReport { return { issues, diff --git a/packages/ghost/src/scan/frontmatter.ts b/packages/ghost/src/scan/frontmatter.ts deleted file mode 100644 index 46c49854..00000000 --- a/packages/ghost/src/scan/frontmatter.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { Fingerprint } from "#ghost-core"; - -/** - * Fingerprint-level metadata — lives in the frontmatter alongside the - * machine-layer of Fingerprint but is not part of the structured content. - */ -export interface FingerprintMeta { - name?: string; - slug?: string; - generator?: string; - confidence?: number; - /** Path to a base fingerprint.md to inherit from. Resolved by loadFingerprint. */ - extends?: string; - /** - * Loose passthrough bag for LLM-authored extensions that don't fit the - * strict structural blocks. Opaque to comparisons — never feeds the - * embedding. Typical use: `{ tone: "magazine", era: "2020s-editorial" }`. - */ - metadata?: Record; -} - -export interface FrontmatterData { - meta: FingerprintMeta; - fingerprint: Fingerprint; -} - -/** - * Fingerprint fields that are populated from YAML frontmatter. Intent - * fields (observation.summary, decisions[].decision) are populated from - * the markdown body by `applyBody` — they are deliberately NOT listed - * here. - */ -const FINGERPRINT_KEYS = new Set([ - "id", - "source", - "timestamp", - "sources", - "references", - "observation", - "decisions", - "palette", - "spacing", - "typography", - "surfaces", -]); - -/** - * Split a frontmatter object into the Fingerprint proper - * and fingerprint-level metadata (name, slug, etc.). - */ -export function splitFrontmatter( - raw: Record, -): FrontmatterData { - const meta: FingerprintMeta = {}; - const fp: Record = {}; - - for (const [k, v] of Object.entries(raw)) { - if (FINGERPRINT_KEYS.has(k as keyof Fingerprint)) { - fp[k] = v; - } else if ( - k === "name" || - k === "slug" || - k === "generator" || - k === "extends" - ) { - meta[k] = v as string; - } else if (k === "confidence") { - meta.confidence = v as number; - } else if (k === "metadata" && v && typeof v === "object") { - meta.metadata = v as Record; - } else if (k === "generated" && typeof v === "string" && !fp.timestamp) { - // Accept `generated:` as a friendly alias for `timestamp` - fp.timestamp = v; - } - // Unknown keys silently ignored (zod strict catches them upstream). - } - - if (!fp.id && meta.slug) fp.id = meta.slug; - if (!fp.timestamp) fp.timestamp = new Date().toISOString(); - if (!fp.source) fp.source = "unknown"; - - return { - meta, - fingerprint: fp as unknown as Fingerprint, - }; -} - -/** - * Build a plain object for YAML serialization from a fingerprint + meta. - * Meta comes first for readability; then fingerprint fields, with intent - * fields stripped — those belong in the markdown body. - */ -export function mergeFrontmatter( - fingerprint: Fingerprint, - meta: FingerprintMeta = {}, -): Record { - const out: Record = {}; - if (meta.name) out.name = meta.name; - if (meta.slug) out.slug = meta.slug; - if (meta.generator) out.generator = meta.generator; - if (meta.confidence !== undefined) out.confidence = meta.confidence; - if (meta.metadata && Object.keys(meta.metadata).length > 0) { - out.metadata = meta.metadata; - } - - const ordered: (keyof Fingerprint)[] = [ - "id", - "source", - "timestamp", - "sources", - "references", - "observation", - "decisions", - "palette", - "spacing", - "typography", - "surfaces", - ]; - for (const key of ordered) { - const v = fingerprint[key]; - if (v === undefined) continue; - if (key === "observation") { - const stripped = stripObservationIntent(v as Fingerprint["observation"]); - if (stripped) out.observation = stripped; - } else if (key === "decisions") { - const stripped = stripDecisionIntent(v as Fingerprint["decisions"]); - if (stripped?.length) out.decisions = stripped; - } else { - out[key] = v; - } - } - return out; -} - -function stripObservationIntent( - obs: Fingerprint["observation"], -): Record | undefined { - if (!obs) return undefined; - const out: Record = {}; - if (obs.personality?.length) out.personality = obs.personality; - if (obs.resembles?.length) out.resembles = obs.resembles; - return Object.keys(out).length ? out : undefined; -} - -function stripDecisionIntent( - decisions: Fingerprint["decisions"], -): Array> | undefined { - if (!decisions?.length) return undefined; - return decisions.map((d) => { - const out: Record = { dimension: d.dimension }; - if (d.dimension_kind) out.dimension_kind = d.dimension_kind; - return out; - }); -} diff --git a/packages/ghost/src/scan/index.ts b/packages/ghost/src/scan/index.ts index b66b61ba..00e34ba1 100644 --- a/packages/ghost/src/scan/index.ts +++ b/packages/ghost/src/scan/index.ts @@ -5,20 +5,22 @@ export { } from "./checks-dir.js"; export { FINGERPRINT_PACKAGE_DIR } from "./constants.js"; export type { - ScanBuildingBlockRows, ScanContributionReport, ScanContributionState, - ScanFacet, - ScanFacetReport, - ScanFacetState, + ScanSurfaceCoverage, } from "./fingerprint-contribution.js"; export { signals } from "./inventory.js"; export type { LegacyPackageInput, + MigratedNodeFile, MigrationNote, MigrationResult, } from "./migrate-legacy.js"; -export { looksLegacy, migrateLegacyPackage } from "./migrate-legacy.js"; +export { + looksLegacy, + migratedNodeFiles, + migrateLegacyPackage, +} from "./migrate-legacy.js"; export { fingerprintPackageDisplayPath, GHOST_PACKAGE_DIR_ENV, diff --git a/packages/ghost/src/scan/layout.ts b/packages/ghost/src/scan/layout.ts deleted file mode 100644 index ececf437..00000000 --- a/packages/ghost/src/scan/layout.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { parse as parseYaml } from "yaml"; - -/** - * A single addressable region of a fingerprint.md file. `start`/`end` are - * 1-indexed line numbers (inclusive), chosen so they plug directly into - * the Read tool's `offset`/`limit` pair (`limit = end - start + 1`). - * - * `tokens` is a char/4 approximation — cheap, stable, and sufficient for - * an agent to budget context before loading a section. - */ -export interface FingerprintLayoutSection { - kind: "frontmatter" | "body" | "decision"; - /** For body sections, the H1 heading text. For decisions, the H3 text. */ - heading?: string; - /** For decisions, the slugged dimension name (matches frontmatter `decisions[].dimension`). */ - dimension?: string; - /** Frontmatter partitions present in this section (only set for `kind: "frontmatter"`). */ - partitions?: string[]; - start: number; - end: number; - tokens: number; -} - -export interface FingerprintLayout { - lines: number; - tokens: number; - sections: FingerprintLayoutSection[]; -} - -/** - * Produce a section map of a raw fingerprint.md string. The map is the - * structural index an agent can use to selectively read only the parts - * it needs — frontmatter alone, a single `### dimension` decision block, - * etc. — without loading the whole file. - * - * The scan is line-oriented and deliberately tolerant: a malformed or - * partial fingerprint still produces a usable layout. Validation belongs - * to `lint`, not here. - */ -export function layoutFingerprint(raw: string): FingerprintLayout { - const lines = raw.split(/\r?\n/); - const sections: FingerprintLayoutSection[] = []; - - const frontmatter = scanFrontmatter(lines); - const bodyStart = frontmatter ? frontmatter.end + 1 : 1; - - if (frontmatter) { - sections.push({ - kind: "frontmatter", - start: frontmatter.start, - end: frontmatter.end, - tokens: approxTokens( - sliceLines(lines, frontmatter.start, frontmatter.end), - ), - partitions: frontmatter.partitions, - }); - } - - // H1 body sections: # Character, # Signature, # Decisions, … - const h1s = scanHeadings(lines, 1, bodyStart); - for (let i = 0; i < h1s.length; i++) { - const h = h1s[i]; - const end = (h1s[i + 1]?.lineNumber ?? lines.length + 1) - 1; - sections.push({ - kind: "body", - heading: h.text, - start: h.lineNumber, - end, - tokens: approxTokens(sliceLines(lines, h.lineNumber, end)), - }); - - // If this is the Decisions section, split by H3. - if (h.text.trim().toLowerCase().startsWith("decisions")) { - const h3s = scanHeadings(lines, 3, h.lineNumber + 1, end); - for (let j = 0; j < h3s.length; j++) { - const d = h3s[j]; - const dEnd = (h3s[j + 1]?.lineNumber ?? end + 1) - 1; - sections.push({ - kind: "decision", - heading: d.text, - dimension: slug(d.text), - start: d.lineNumber, - end: dEnd, - tokens: approxTokens(sliceLines(lines, d.lineNumber, dEnd)), - }); - } - } - } - - return { - lines: lines.length, - tokens: approxTokens(raw), - sections, - }; -} - -// --- helpers --- - -function scanFrontmatter( - lines: string[], -): { start: number; end: number; partitions: string[] } | null { - let i = 0; - while (i < lines.length && lines[i].trim() === "") i++; - if (i >= lines.length || !isDelimiter(lines[i])) return null; - const start = i + 1; // 1-indexed, line of opening `---` - const openIdx = i; - let closeIdx = -1; - for (let j = openIdx + 1; j < lines.length; j++) { - if (isDelimiter(lines[j])) { - closeIdx = j; - break; - } - } - if (closeIdx === -1) return null; - const end = closeIdx + 1; // 1-indexed, line of closing `---` - - const yamlText = lines.slice(openIdx + 1, closeIdx).join("\n"); - const partitions = detectPartitions(yamlText); - return { start, end, partitions }; -} - -function detectPartitions(yamlText: string): string[] { - // Cheap top-level-key scan. Trying to parse + fall back on scan keeps the - // layout resilient when the frontmatter is a work-in-progress. - const candidates = [ - "palette", - "spacing", - "typography", - "surfaces", - "references", - "observation", - "decisions", - "checks", - ]; - let keys: string[] = []; - try { - const obj = parseYaml(yamlText) as Record | null; - if (obj && typeof obj === "object") keys = Object.keys(obj); - } catch { - // fall through to regex scan - } - if (keys.length === 0) { - const topLevel = new Set(); - for (const line of yamlText.split("\n")) { - const m = /^([a-zA-Z_][\w-]*)\s*:/.exec(line); - if (m) topLevel.add(m[1]); - } - keys = Array.from(topLevel); - } - return candidates.filter((c) => keys.includes(c)); -} - -interface Heading { - lineNumber: number; // 1-indexed - level: number; - text: string; -} - -function scanHeadings( - lines: string[], - level: number, - startLine = 1, - endLine = lines.length, -): Heading[] { - const out: Heading[] = []; - for (let i = startLine - 1; i < endLine; i++) { - // `\s` rather than `\s+` avoids an ambiguous split with the following - // `.*` (both match spaces) that CodeQL flags as polynomial. `.trim()` - // on the captured group folds extra whitespace either side. - const m = /^(#{1,6})\s(.*)$/.exec(lines[i]); - if (!m) continue; - if (m[1].length === level) { - out.push({ lineNumber: i + 1, level, text: m[2].trim() }); - } else if (m[1].length < level) { - // A shallower heading ends the region when scanning nested headings - // inside a bounded section. - if (endLine !== lines.length) break; - } - } - return out; -} - -function sliceLines(lines: string[], start: number, end: number): string { - return lines.slice(start - 1, end).join("\n"); -} - -function approxTokens(text: string): number { - return Math.max(1, Math.round(text.length / 4)); -} - -function isDelimiter(line: string): boolean { - return /^---\s*$/.test(line); -} - -function slug(s: string): string { - // Imperative rather than regex-chained because CodeQL flagged the - // three-stage /[^a-z0-9]+/g → /^-+/ → /-+$/ pipeline as polynomial on - // inputs with many '-' repetitions. Single O(n) pass, same semantics. - let out = ""; - let lastDash = true; - for (let i = 0; i < s.length; i++) { - const c = s.charCodeAt(i); - const lower = c >= 65 && c <= 90 ? c + 32 : c; - const isAlnum = - (lower >= 97 && lower <= 122) || (lower >= 48 && lower <= 57); - if (isAlnum) { - out += String.fromCharCode(lower); - lastDash = false; - } else if (!lastDash) { - out += "-"; - lastDash = true; - } - } - if (out.length > 0 && out.charCodeAt(out.length - 1) === 45) { - out = out.slice(0, -1); - } - return out; -} - -/** - * Render a layout as a short, human-readable table. Designed to be the - * default output an agent streams into its context when it wants to - * decide which sections to load. - */ -export function formatLayout(layout: FingerprintLayout, path?: string): string { - const header = `${path ? `${path} — ` : ""}${layout.lines} lines, ~${layout.tokens.toLocaleString()} tokens`; - const rows: string[] = [header, ""]; - for (const s of layout.sections) { - rows.push(formatRow(s)); - } - return rows.join("\n"); -} - -function formatRow(s: FingerprintLayoutSection): string { - const range = `${s.start}–${s.end}`; - const tok = `~${s.tokens.toLocaleString()} tok`; - if (s.kind === "frontmatter") { - const parts = s.partitions?.length ? ` [${s.partitions.join(", ")}]` : ""; - return `FRONTMATTER ${pad(range, 10)} ${pad(tok, 14)}${parts}`; - } - if (s.kind === "body") { - return `# ${pad(s.heading ?? "", 14)} ${pad(range, 10)} ${tok}`; - } - // decision - return ` ### ${pad(s.dimension ?? s.heading ?? "", 24)} ${pad(range, 10)} ${tok}`; -} - -function pad(s: string, width: number): string { - return s.length >= width ? s : s + " ".repeat(width - s.length); -} diff --git a/packages/ghost/src/scan/lint.ts b/packages/ghost/src/scan/lint.ts index 7c473c4e..fe00579d 100644 --- a/packages/ghost/src/scan/lint.ts +++ b/packages/ghost/src/scan/lint.ts @@ -1,20 +1,10 @@ -import { parse as parseYaml } from "yaml"; -import { - closestCanonical, - type Fingerprint, - isCanonicalDimension, -} from "#ghost-core"; -import type { BodyData } from "./body.js"; -import { parseFingerprint, splitRaw } from "./parser.js"; -import { FrontmatterSchema, PartialFrontmatterSchema } from "./schema.js"; - export type LintSeverity = "error" | "warning" | "info"; export interface LintIssue { severity: LintSeverity; rule: string; message: string; - /** Dotted path in the file (e.g. "decisions[0].evidence"). */ + /** Dotted path in the file (e.g. "intent.principles[0].evidence"). */ path?: string; } @@ -31,300 +21,3 @@ export interface LintOptions { /** Silence these rules entirely. */ off?: string[]; } - -/** - * Lint a fingerprint.md string for schema correctness and partition - * violations. Unlike parseFingerprint, this never throws — every problem - * surfaces as a structured issue. - * - * Under schema 3 the body/frontmatter partition is enforced by zod-strict. - * Lint adds softer rules: orphan intent (body block with no frontmatter - * entry), missing rationale (frontmatter entry with no body block), malformed - * Decisions sections, missing body evidence, and broken palette citations. - */ -export function lintFingerprint( - raw: string, - options: LintOptions = {}, -): LintReport { - const rawIssues: LintIssue[] = []; - const strict = new Set(options.strict ?? []); - const off = new Set(options.off ?? []); - - let parsed: ReturnType | null = null; - try { - parsed = parseFingerprint(raw, { skipValidation: true }); - } catch (err) { - rawIssues.push({ - severity: "error", - rule: "parse", - message: err instanceof Error ? err.message : String(err), - }); - return finalize(rawIssues, strict, off); - } - - const { fingerprint, body } = parsed; - const rawYaml = toRawFrontmatter(raw); - const { body: bodyText } = splitRawSafe(raw); - - checkSchemaValidity(rawYaml, rawIssues); - checkDecisionPartition(fingerprint, body, rawIssues); - checkDecisionBodyShape(bodyText, body, rawIssues); - checkStrayEvidenceInBody(bodyText, rawIssues); - checkEvidenceHexes(fingerprint, rawIssues); - checkUnusedPalette(fingerprint, rawIssues); - checkNonCanonicalDimensions(fingerprint, rawIssues); - - return finalize(rawIssues, strict, off); -} - -function finalize( - issues: LintIssue[], - strict: Set, - off: Set, -): LintReport { - const filtered = issues - .filter((i) => !off.has(i.rule)) - .map((i) => - strict.has(i.rule) ? { ...i, severity: "error" as const } : i, - ); - return { - issues: filtered, - errors: filtered.filter((i) => i.severity === "error").length, - warnings: filtered.filter((i) => i.severity === "warning").length, - info: filtered.filter((i) => i.severity === "info").length, - }; -} - -function toRawFrontmatter(raw: string): Record { - try { - const { frontmatter } = splitRaw(raw); - return (parseYaml(frontmatter) ?? {}) as Record; - } catch { - return {}; - } -} - -function splitRawSafe(raw: string): { frontmatter: string; body: string } { - try { - return splitRaw(raw); - } catch { - return { frontmatter: "", body: "" }; - } -} - -function checkSchemaValidity( - raw: Record, - issues: LintIssue[], -): void { - const schema = - typeof raw.extends === "string" - ? PartialFrontmatterSchema - : FrontmatterSchema; - const result = schema.safeParse(raw); - if (result.success) return; - for (const issue of result.error.issues) { - issues.push({ - severity: "error", - rule: "schema-invalid", - message: issue.message, - path: issue.path.length ? issue.path.join(".") : undefined, - }); - } -} - -/** - * Schema 5: each dimension lives in exactly one place — a `### Dimension` - * body block carrying intent + optional `**Evidence:**` bullets. Frontmatter - * `decisions[]` only carries the dimension slug. Warn - * when a dimension appears in frontmatter but not the body (orphan slug) or - * when a body block has no rationale at all. - */ -function checkDecisionPartition( - fp: Fingerprint, - body: BodyData, - issues: LintIssue[], -): void { - const merged = fp.decisions ?? []; - const bodyDims = new Set((body.decisions ?? []).map((d) => d.dimension)); - merged.forEach((d, idx) => { - const hasIntent = Boolean(d.decision?.trim()); - const fromBody = bodyDims.has(d.dimension); - if (!fromBody) { - issues.push({ - severity: "warning", - rule: "orphan-dimension", - message: `Decision \`${d.dimension}\` is declared in frontmatter but has no \`### ${d.dimension}\` block in the body.`, - path: `decisions[${idx}]`, - }); - } else if (!hasIntent) { - issues.push({ - severity: "warning", - rule: "missing-rationale", - message: `Body has \`### ${d.dimension}\` but no rationale intent.`, - path: `decisions[${idx}]`, - }); - } - }); -} - -// Schema 5 allows `**Evidence:**` in the body — it's where evidence now lives. -// The lint check that used to flag it as legacy is retired. -function checkStrayEvidenceInBody( - _bodyText: string, - _issues: LintIssue[], -): void { - // no-op; body evidence is canonical as of schema 5. -} - -function checkDecisionBodyShape( - bodyText: string, - body: BodyData, - issues: LintIssue[], -): void { - const decisionsSection = h1SectionBody(bodyText, "Decisions"); - if ( - decisionsSection?.trim() && - !(body.decisions?.length ?? 0) && - !/^###\s+/m.test(decisionsSection) - ) { - issues.push({ - severity: "warning", - rule: "missing-decision-headings", - message: - "`# Decisions` has intent but no `### ` blocks, so no decisions are parseable.", - path: "decisions", - }); - } - - (body.decisions ?? []).forEach((decision, index) => { - if (decision.evidence?.length) return; - issues.push({ - severity: "warning", - rule: "missing-evidence", - message: `Decision \`${decision.dimension}\` has no \`**Evidence:**\` bullet list.`, - path: `decisions[${index}].evidence`, - }); - }); -} - -function h1SectionBody(bodyText: string, heading: string): string | null { - const lines = bodyText.split(/\r?\n/); - const target = heading.toLowerCase(); - const buf: string[] = []; - let collecting = false; - - for (const line of lines) { - const match = /^#\s+(.*?)\s*$/.exec(line); - if (match) { - if (collecting) break; - collecting = match[1]?.toLowerCase() === target; - continue; - } - if (collecting) buf.push(line); - } - - return collecting || buf.length ? buf.join("\n") : null; -} - -const HEX_RE = /#[0-9a-f]{3,8}\b/gi; - -function checkEvidenceHexes(fp: Fingerprint, issues: LintIssue[]): void { - const paletteHexes = collectPaletteHexes(fp); - if (paletteHexes.size === 0) return; - - const decisions = fp.decisions ?? []; - decisions.forEach((d, di) => { - d.evidence?.forEach((ev, ei) => { - const hexes = ev.match(HEX_RE) ?? []; - for (const hex of hexes) { - const norm = hex.toLowerCase(); - if (!paletteHexes.has(norm)) { - issues.push({ - severity: "warning", - rule: "broken-evidence", - message: `Evidence cites ${hex} but no matching palette entry exists.`, - path: `decisions[${di}].evidence[${ei}]`, - }); - } - } - }); - }); -} - -/** - * Flag palette colors that don't appear anywhere a reader could justify - * them — i.e. not cited as a hex literal in any decision's body Evidence - * bullets or rationale intent. Severity is `info` (a soft hint, not an - * error) because some palette entries are honestly load-bearing without - * decision-level commentary (every neutral step in a wide ramp). - */ -function checkUnusedPalette(fp: Fingerprint, issues: LintIssue[]): void { - const paletteHexes = collectPaletteHexes(fp); - if (paletteHexes.size === 0) return; - - const evidenceText = (fp.decisions ?? []) - .flatMap((d) => d.evidence ?? []) - .join("\n") - .toLowerCase(); - const decisionText = (fp.decisions ?? []) - .map((d) => d.decision) - .join("\n") - .toLowerCase(); - const haystack = `${evidenceText}\n${decisionText}`; - - for (const hex of paletteHexes) { - if (haystack.includes(hex)) continue; - issues.push({ - severity: "info", - rule: "unused-palette", - message: `Palette color ${hex} is not cited in any decision.`, - }); - } -} - -/** - * Soft validate.check: a `decisions[].dimension` slug should either be in the - * canonical vocabulary or pair with a canonical `dimension_kind`. Anything - * else lives in the long tail and won't roll up at fleet scale. This - * never errors — it suggests, so authoring stays free-form by default. - */ -function checkNonCanonicalDimensions( - fp: Fingerprint, - issues: LintIssue[], -): void { - const decisions = fp.decisions ?? []; - decisions.forEach((d, idx) => { - if (isCanonicalDimension(d.dimension)) return; - if (d.dimension_kind && isCanonicalDimension(d.dimension_kind)) return; - const suggestion = closestCanonical(d.dimension); - if (d.dimension_kind && !isCanonicalDimension(d.dimension_kind)) { - const fix = closestCanonical(d.dimension_kind) ?? suggestion; - issues.push({ - severity: "warning", - rule: "non-canonical-dimension", - message: `Decision \`${d.dimension}\` has \`dimension_kind: ${d.dimension_kind}\`, which is also not in the canonical vocabulary${ - fix ? ` (closest: \`${fix}\`)` : "" - }. Set \`dimension_kind\` to a canonical slug so fleet aggregation can group this decision.`, - path: `decisions[${idx}].dimension_kind`, - }); - return; - } - issues.push({ - severity: "warning", - rule: "non-canonical-dimension", - message: `Decision \`${d.dimension}\` is not a canonical dimension${ - suggestion ? ` (closest: \`${suggestion}\`)` : "" - }. Either rename, or add \`dimension_kind: \` so fleet aggregation can group this decision.`, - path: `decisions[${idx}].dimension`, - }); - }); -} - -function collectPaletteHexes(fp: Fingerprint): Set { - const out = new Set(); - for (const c of fp.palette?.dominant ?? []) out.add(c.value.toLowerCase()); - for (const c of fp.palette?.semantic ?? []) out.add(c.value.toLowerCase()); - for (const step of fp.palette?.neutrals?.steps ?? []) - out.add(step.toLowerCase()); - return out; -} diff --git a/packages/ghost/src/scan/migrate-legacy.ts b/packages/ghost/src/scan/migrate-legacy.ts index 3e37fdf4..f9604e95 100644 --- a/packages/ghost/src/scan/migrate-legacy.ts +++ b/packages/ghost/src/scan/migrate-legacy.ts @@ -1,4 +1,9 @@ -import { GHOST_SURFACE_ROOT_ID, GHOST_SURFACES_SCHEMA } from "#ghost-core"; +import { + GHOST_SURFACE_ROOT_ID, + GHOST_SURFACES_SCHEMA, + type GhostNodeDocument, + serializeNode, +} from "#ghost-core"; /** * One-shot migration of a legacy `.ghost/` package (pre-surface coordinates) @@ -211,3 +216,77 @@ export function looksLegacy(input: LegacyPackageInput): boolean { } return false; } + +/** A node file the migration writes, relative to the package dir. */ +export interface MigratedNodeFile { + relativePath: string; + content: string; +} + +/** + * Convert the migrated facet docs into `nodes/*.md` files — the persistent form + * of the Phase 2 facet→node projection. Each facet entry becomes one prose node + * whose body is the entry's primary text and whose `under` is its placement + * (`surface`, omitted when unplaced ⇒ cascades from core). Lossy by design: + * structured affordances (evidence, check_refs, exemplar paths) are dropped, in + * line with Option A. Returns one file per node (`nodes/.md`). + */ +export function migratedNodeFiles(result: MigrationResult): MigratedNodeFile[] { + const files: MigratedNodeFile[] = []; + const seen = new Set(); + + const emit = (entry: Yaml, body: string) => { + const id = typeof entry.id === "string" ? entry.id : undefined; + if (!id || seen.has(id)) return; + seen.add(id); + const under = typeof entry.surface === "string" ? entry.surface : undefined; + const doc: GhostNodeDocument = { + frontmatter: { id, ...(under !== undefined ? { under } : {}) }, + body: body.trim(), + }; + files.push({ + relativePath: `nodes/${id}.md`, + content: serializeNode(doc), + }); + }; + + collect(result.intent, "situations", (e) => + emit( + e, + str(e.user_intent) ?? str(e.product_obligation) ?? str(e.title) ?? id(e), + ), + ); + collect(result.intent, "principles", (e) => + emit(e, str(e.principle) ?? id(e)), + ); + collect(result.intent, "experience_contracts", (e) => + emit(e, str(e.contract) ?? id(e)), + ); + collect(result.composition, "patterns", (e) => + emit(e, str(e.pattern) ?? id(e)), + ); + collect(result.inventory, "exemplars", (e) => + emit(e, str(e.why) ?? str(e.note) ?? str(e.title) ?? str(e.path) ?? id(e)), + ); + + return files; +} + +function collect( + doc: Yaml | undefined, + key: string, + visit: (entry: Yaml) => void, +): void { + if (!doc) return; + const list = doc[key]; + if (!Array.isArray(list)) return; + for (const entry of list) if (isRecord(entry)) visit(entry); +} + +function str(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function id(entry: Yaml): string { + return typeof entry.id === "string" ? entry.id : "node"; +} diff --git a/packages/ghost/src/scan/nodes-dir.ts b/packages/ghost/src/scan/nodes-dir.ts new file mode 100644 index 00000000..652fe848 --- /dev/null +++ b/packages/ghost/src/scan/nodes-dir.ts @@ -0,0 +1,50 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { type GhostNodeDocument, parseNode } from "#ghost-core"; + +export const GHOST_NODES_DIRNAME = "nodes"; + +export interface LoadedNodesDir { + nodes: GhostNodeDocument[]; + /** Files that failed lint, with their first error message. */ + invalid: Array<{ file: string; message: string }>; +} + +/** + * Load authored prose nodes from `/nodes/*.md`. Each file is parsed + * and validated per-node; a file with errors is collected in `invalid` (with + * its first error) and skipped rather than throwing, so one bad node does not + * block folding the rest. Absent directory → no nodes. + * + * Phase 2 keeps discovery deliberately minimal (one default `nodes/` directory, + * mirroring `checks/`). Loose-anywhere and custom layouts are a later + * refinement. + */ +export async function loadNodesDir( + packageDir: string, +): Promise { + const dir = join(packageDir, GHOST_NODES_DIRNAME); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return { nodes: [], invalid: [] }; + } + + const nodes: GhostNodeDocument[] = []; + const invalid: LoadedNodesDir["invalid"] = []; + + for (const name of entries.sort()) { + if (!name.endsWith(".md")) continue; + const raw = await readFile(join(dir, name), "utf-8"); + const { node, report } = parseNode(raw); + if (node === null || report.errors > 0) { + const first = report.issues.find((issue) => issue.severity === "error"); + invalid.push({ file: name, message: first?.message ?? "invalid node" }); + continue; + } + nodes.push(node); + } + + return { nodes, invalid }; +} diff --git a/packages/ghost/src/scan/package-paths.ts b/packages/ghost/src/scan/package-paths.ts index f9cefccc..f9608151 100644 --- a/packages/ghost/src/scan/package-paths.ts +++ b/packages/ghost/src/scan/package-paths.ts @@ -9,7 +9,7 @@ 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, + * fingerprint-commands, init-command, * monorepo-init-command, and the scan/index re-exports. */ diff --git a/packages/ghost/src/scan/parser.ts b/packages/ghost/src/scan/parser.ts deleted file mode 100644 index 4da772bc..00000000 --- a/packages/ghost/src/scan/parser.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { parse as parseYaml } from "yaml"; -import type { - DesignDecision, - DesignObservation, - Fingerprint, -} from "#ghost-core"; -import { type BodyData, parseBody } from "./body.js"; -import { type FingerprintMeta, splitFrontmatter } from "./frontmatter.js"; -import { validateFrontmatter } from "./schema.js"; - -export interface ParsedFingerprint { - fingerprint: Fingerprint; - meta: FingerprintMeta; - /** - * Structured view of the body as it was read from disk. Kept for lint - * tooling that wants to check orphan intent or missing rationale against - * the frontmatter machine-layer. - */ - body: BodyData; - /** - * The raw markdown body (everything after the frontmatter). Surfaced for - * layout/lint tooling that needs the source text. - */ - bodyRaw: string; -} - -export interface ParseOptions { - /** - * Skip zod validation of the frontmatter. Only useful for tools that want - * to read partial or in-progress fingerprint files (e.g. lint). Default: false. - */ - skipValidation?: boolean; -} - -/** - * Split a raw fingerprint.md string into its YAML frontmatter and markdown body. - * - * A frontmatter block is delimited by two lines that are *exactly* `---` - * (trailing whitespace tolerated). The opening delimiter must be the first - * non-empty line of the file. This line-oriented scan is robust against - * `---` appearing inside a YAML block scalar — indented `---` is part of - * the scalar, not a delimiter. - * - * Throws if the frontmatter block is missing or unterminated. - */ -export function splitRaw(raw: string): { frontmatter: string; body: string } { - const lines = raw.split(/\r?\n/); - let i = 0; - // Skip leading blank lines so a fingerprint.md with a BOM / stray newline - // before `---` still parses. - while (i < lines.length && lines[i].trim() === "") i++; - if (i >= lines.length || !isDelimiter(lines[i])) { - throw new Error( - "Fingerprint is missing a YAML frontmatter block (--- … ---).", - ); - } - const startOfYaml = i + 1; - let endOfYaml = -1; - for (let j = startOfYaml; j < lines.length; j++) { - if (isDelimiter(lines[j])) { - endOfYaml = j; - break; - } - } - if (endOfYaml === -1) { - throw new Error( - "Fingerprint frontmatter is unterminated — missing closing `---`.", - ); - } - const frontmatter = lines.slice(startOfYaml, endOfYaml).join("\n"); - const body = lines.slice(endOfYaml + 1).join("\n"); - return { frontmatter, body }; -} - -function isDelimiter(line: string): boolean { - return /^---\s*$/.test(line); -} - -/** - * Parse a raw fingerprint.md string into a Fingerprint plus metadata and - * structured body. - * - * Contract: frontmatter and body own disjoint fields. - * • Frontmatter owns machine-facts: id, tokens, dimension slugs, - * personality/resembles tags, references, checks, and compact values. - * • Body owns intent: `# Character` → summary, `# Signature` → - * recognizable output posture, `### dimension` → decision rationale. - * - * The returned fingerprint unions both sources. Since the two sides never - * carry the same field, there is no precedence rule — each field has one - * home. - * - * Parse-time check (unless `skipValidation`): zod validation — throws a - * readable error listing bad fields. - */ -export function parseFingerprint( - raw: string, - options: ParseOptions = {}, -): ParsedFingerprint { - const { frontmatter, body: bodyText } = splitRaw(raw); - const yamlObj = (parseYaml(frontmatter) ?? {}) as Record; - - if (!options.skipValidation) { - // Files that extend a base fingerprint may omit fields they inherit. Final - // validation happens after extends resolution (see loadFingerprint). - const partial = typeof yamlObj.extends === "string"; - validateFrontmatter(yamlObj, { partial }); - } - - const { meta, fingerprint } = splitFrontmatter(yamlObj); - const body = parseBody(bodyText); - const merged = applyBody(fingerprint, body); - return { fingerprint: merged, meta, body, bodyRaw: bodyText }; -} - -/** - * Fold body-owned intent fields into the fingerprint. The body provides - * Character intent for `observation`, Signature intent for `signature`, and - * rationale for `decisions` (keyed by dimension). Frontmatter-only - * dimensions keep their evidence but get no body intent (decision text left - * empty). - */ -export function applyBody(fp: Fingerprint, body: BodyData): Fingerprint { - const observation = mergeObservation(fp.observation, body); - const decisions = mergeDecisions(fp.decisions, body.decisions ?? []); - - const out: Fingerprint = { ...fp }; - if (observation) out.observation = observation; - else delete out.observation; - if (body.signature?.trim()) out.signature = body.signature.trim(); - else delete out.signature; - if (decisions?.length) out.decisions = decisions; - else delete out.decisions; - return out; -} - -function mergeObservation( - yamlObs: DesignObservation | undefined, - body: BodyData, -): DesignObservation | undefined { - const summary = body.character?.trim() ?? ""; - const personality = yamlObs?.personality ?? []; - const resembles = yamlObs?.resembles ?? []; - if (!summary && personality.length === 0 && resembles.length === 0) { - return undefined; - } - return { summary, personality, resembles }; -} - -/** - * Merge the frontmatter decision skeletons (dimension + optional kind) with - * the body's rationale and evidence (keyed by `### dimension`). - * Frontmatter order wins; body-only decisions append at the end. - * - * Evidence comes from the body. Runtime decision embeddings are derived, not - * read from `fingerprint.md`. - */ -function mergeDecisions( - fromYaml: DesignDecision[] | undefined, - fromBody: DesignDecision[], -): DesignDecision[] | undefined { - const hasYaml = (fromYaml?.length ?? 0) > 0; - const hasBody = fromBody.length > 0; - if (!hasYaml && !hasBody) return undefined; - - const bodyByDim = new Map(fromBody.map((d) => [d.dimension, d])); - const seen = new Set(); - const out: DesignDecision[] = []; - - for (const y of fromYaml ?? []) { - seen.add(y.dimension); - const b = bodyByDim.get(y.dimension); - out.push({ - dimension: y.dimension, - decision: b?.decision ?? "", - evidence: b?.evidence ?? [], - ...(y.dimension_kind ? { dimension_kind: y.dimension_kind } : {}), - }); - } - for (const b of fromBody) { - if (seen.has(b.dimension)) continue; - out.push({ - dimension: b.dimension, - decision: b.decision, - evidence: b.evidence ?? [], - }); - } - return out; -} diff --git a/packages/ghost/src/scan/scan-status.ts b/packages/ghost/src/scan/scan-status.ts index 52df4435..27d2cf93 100644 --- a/packages/ghost/src/scan/scan-status.ts +++ b/packages/ghost/src/scan/scan-status.ts @@ -39,28 +39,13 @@ export async function scanStatus(dirPath: string): Promise { const paths = resolveFingerprintPackage(dir, process.cwd()); const fingerprintPath = paths.packageDir; - const [ - fingerprintPresent, - intentPresent, - inventoryPresent, - compositionPresent, - ] = await Promise.all([ - pathExists(paths.manifest, "file"), - pathExists(paths.intent, "file"), - pathExists(paths.inventory, "file"), - pathExists(paths.composition, "file"), - ]); + const fingerprintPresent = await pathExists(paths.manifest, "file"); const fingerprint: ScanStageReport = { state: fingerprintPresent ? "present" : "missing", path: fingerprintPath, }; - const contribution = await scanContribution(paths, { - fingerprintPresent, - intentPresent, - inventoryPresent, - compositionPresent, - }); + const contribution = await scanContribution(paths, fingerprintPresent); const status: ScanStatus = { dir, @@ -74,35 +59,20 @@ export async function scanStatus(dirPath: string): Promise { async function scanContribution( paths: FingerprintPackagePaths, - present: { - fingerprintPresent: boolean; - intentPresent: boolean; - inventoryPresent: boolean; - compositionPresent: boolean; - }, + fingerprintPresent: boolean, ): Promise { - const files = { - intent: { path: paths.intent, present: present.intentPresent }, - inventory: { path: paths.inventory, present: present.inventoryPresent }, - composition: { - path: paths.composition, - present: present.compositionPresent, - }, - } as const; - - if (!present.fingerprintPresent) { - return summarizeFingerprintContribution({ files, missing: true }); + if (!fingerprintPresent) { + return summarizeFingerprintContribution({ missing: true }); } try { const loaded = await loadFingerprintPackage(paths); return summarizeFingerprintContribution({ - fingerprint: loaded.fingerprint, - files, + graph: loaded.graph, + surfaceIds: (loaded.surfaces?.surfaces ?? []).map((s) => s.id), }); } catch (err) { return summarizeFingerprintContribution({ - files, invalidReason: err instanceof Error ? err.message : String(err), }); } diff --git a/packages/ghost/src/scan/templates.ts b/packages/ghost/src/scan/templates.ts new file mode 100644 index 00000000..5cf56b66 --- /dev/null +++ b/packages/ghost/src/scan/templates.ts @@ -0,0 +1,93 @@ +import { GHOST_FINGERPRINT_PACKAGE_SCHEMA } from "#ghost-core"; + +/** + * A single seed file an `init` template writes, relative to the package dir. + */ +export interface TemplateFile { + /** Path relative to the package directory (e.g. "nodes/core-voice.md"). */ + relativePath: string; + content: string; +} + +/** + * An `init` template: a pure description of the seed files a fresh node package + * starts with. Templates are the extension seam — adding a `marketing` / `voice` + * / `dashboard` starter later is just registering another entry here; `init` + * needs no change. + */ +export interface GhostInitTemplate { + name: string; + description: string; + files(): TemplateFile[]; +} + +function manifestFile(): TemplateFile { + return { + relativePath: "manifest.yml", + content: `schema: ${GHOST_FINGERPRINT_PACKAGE_SCHEMA}\nid: local\n`, + }; +} + +/** + * The default starter: the surfaces spine (the implicit `core` root needs no + * declaration, so the file starts empty) plus one `core`-placed intent node + * that demonstrates the shape — frontmatter handles + prose body written + * through the intent/inventory/composition lenses. + */ +const DEFAULT_TEMPLATE: GhostInitTemplate = { + name: "default", + description: "Minimal node package: surfaces spine + one core intent node.", + files() { + return [ + manifestFile(), + { + relativePath: "surfaces.yml", + content: `schema: ghost.surfaces/v1 +# The implicit \`core\` root needs no declaration. Add surfaces as you author, +# e.g.: +# surfaces: +# - id: checkout +# parent: core +surfaces: [] +`, + }, + { + relativePath: "nodes/core-voice.md", + content: `--- +id: core-voice +under: core +--- + +Replace this with your product's voice. A node is prose written through the +intent / inventory / composition lenses — they guide what to capture, they are +not fields: + +- intent — the why and the stance (e.g. "calm, direct, never breathless"). +- inventory — the material you have (tokens, components, pointers to code). +- composition — how it is assembled (the patterns that make it feel intentional). + +This node sits at \`core\`, so it cascades to every surface. Place +surface-specific nodes with \`under: \`, link related nodes with +\`relates\`, and tag medium-bound expressions with \`incarnation\` (e.g. email, +billboard, voice). Leave essence untagged. +`, + }, + ]; + }, +}; + +const TEMPLATES = new Map([ + [DEFAULT_TEMPLATE.name, DEFAULT_TEMPLATE], +]); + +export const DEFAULT_TEMPLATE_NAME = DEFAULT_TEMPLATE.name; + +/** Look up a registered init template by name. */ +export function getInitTemplate(name: string): GhostInitTemplate | undefined { + return TEMPLATES.get(name); +} + +/** All registered init template names, for help and validation. */ +export function listInitTemplates(): string[] { + return [...TEMPLATES.keys()]; +} diff --git a/packages/ghost/src/scan/verify-fingerprint.ts b/packages/ghost/src/scan/verify-fingerprint.ts deleted file mode 100644 index d4a9fcb1..00000000 --- a/packages/ghost/src/scan/verify-fingerprint.ts +++ /dev/null @@ -1,845 +0,0 @@ -import type { Fingerprint, SemanticColor, Survey, ValueRow } from "#ghost-core"; -import { lintSurvey } from "#ghost-core"; -import { lintFingerprint } from "./lint.js"; -import { parseFingerprint } from "./parser.js"; - -export type VerifyFingerprintSeverity = "error" | "warning" | "info"; - -export interface VerifyFingerprintIssue { - severity: VerifyFingerprintSeverity; - rule: string; - message: string; - path?: string; - expected?: unknown; - actual?: unknown; -} - -export interface VerifyFingerprintReport { - issues: VerifyFingerprintIssue[]; - errors: number; - warnings: number; - info: number; -} - -export interface VerifyFingerprintOptions { - root?: string; - /** - * Resolved fingerprint after applying `extends:`. CLI callers should pass - * this for scoped overlays so provenance checks run against the effective - * design contract instead of the partial child frontmatter. - */ - resolvedFingerprint?: Fingerprint; -} - -const HIGH_SALIENCE_ROLE_TOKENS = [ - "background", - "foreground", - "brand", - "border", - "card", -] as const; - -const HIGH_SALIENCE_VALUE_THRESHOLD = 5; - -/** - * Deterministically verify that a fingerprinted design language is faithful to the - * survey that produced it. `lint` remains the shape/schema gate; this verifier - * checks scan-stage provenance for the non-enforcing design-language prior. - * Enforceable checks live in `validate.yml` and are validated separately. - */ -export function verifyFingerprint( - fingerprintRaw: string, - surveyInput: unknown, - options: VerifyFingerprintOptions = {}, -): VerifyFingerprintReport { - const issues: VerifyFingerprintIssue[] = []; - - const fingerprintLint = lintFingerprint(fingerprintRaw); - issues.push( - ...fingerprintLint.issues.map((issue) => - fromLintIssue(issue, "fingerprint"), - ), - ); - if (fingerprintLint.errors > 0) return finalize(issues); - - let fingerprint: Fingerprint; - try { - const parsed = parseFingerprint(fingerprintRaw); - if (parsed.meta.extends && !options.resolvedFingerprint) { - issues.push({ - severity: "error", - rule: "fingerprint-extends-unresolved", - message: - "Fingerprint declares `extends:` but no resolved fingerprint was provided for verification.", - }); - return finalize(issues); - } - fingerprint = options.resolvedFingerprint ?? parsed.fingerprint; - } catch (err) { - issues.push({ - severity: "error", - rule: "fingerprint-parse-failed", - message: err instanceof Error ? err.message : String(err), - }); - return finalize(issues); - } - - const surveyLint = lintSurvey(surveyInput); - issues.push( - ...surveyLint.issues.map((issue) => fromLintIssue(issue, "survey")), - ); - if (surveyLint.errors > 0) return finalize(issues); - - const survey = surveyInput as Survey; - const evidence = collectSurveyEvidence(survey); - checkPaletteProvenance(fingerprint, evidence.colors, issues); - checkRoleTokenAgreement(fingerprint, survey, issues); - checkStructuredValueProvenance(fingerprint, evidence, issues); - checkHighSalienceOmissions(fingerprint, evidence, issues); - - return finalize(issues); -} - -export function formatVerifyFingerprintReport( - report: VerifyFingerprintReport, -): string { - const lines: string[] = []; - for (const issue of report.issues) { - const prefix = - issue.severity === "error" - ? "ERROR" - : issue.severity === "warning" - ? "WARN " - : "INFO "; - const pathSuffix = issue.path ? ` @ ${issue.path}` : ""; - const countSuffix = - issue.expected !== undefined || issue.actual !== undefined - ? ` (expected ${String(issue.expected)}, actual ${String(issue.actual)})` - : ""; - lines.push( - `${prefix} [${issue.rule}] ${issue.message}${pathSuffix}${countSuffix}`, - ); - } - lines.push( - "", - `${report.errors} error(s), ${report.warnings} warning(s), ${report.info} info`, - ); - return `${lines.join("\n")}\n`; -} - -function fromLintIssue( - issue: { - severity: VerifyFingerprintSeverity; - rule: string; - message: string; - path?: string; - }, - source: "fingerprint" | "survey", -): VerifyFingerprintIssue { - return { - severity: issue.severity, - rule: `${source}/${issue.rule}`, - message: issue.message, - path: issue.path ? `${source}.${issue.path}` : source, - }; -} - -interface SurveyValueEvidence { - colors: Map; - spacing: Map; - radii: Map; - typographyFamilies: Map; - typographySizes: Map; - typographyWeights: Map; - shadowValues: Map; - rows: SurveyValueEvidenceRow[]; -} - -interface SurveyValueEvidenceRow { - kind: string; - value: string; - occurrences: number; - files_count: number; - path: string; - color?: string; - scalarPx?: number; - typographyFamily?: string; - typographySizePx?: number; - typographyWeight?: number; -} - -function collectSurveyEvidence(survey: Survey): SurveyValueEvidence { - const evidence: SurveyValueEvidence = { - colors: new Map(), - spacing: new Map(), - radii: new Map(), - typographyFamilies: new Map(), - typographySizes: new Map(), - typographyWeights: new Map(), - shadowValues: new Map(), - rows: [], - }; - - const add = (value: string | undefined, path: string) => { - if (!value) return; - for (const color of extractHexColors(value)) { - const paths = evidence.colors.get(color) ?? []; - paths.push(path); - evidence.colors.set(color, paths); - } - }; - - survey.values.forEach((row, index) => { - const path = `survey.values[${index}]`; - const kind = canonicalSurveyValueKind(row); - const entry: SurveyValueEvidenceRow = { - kind, - value: row.value, - occurrences: row.occurrences, - files_count: row.files_count, - path: `${path}.value`, - }; - - if (kind === "color") { - add(row.value, `${path}.value`); - const spec = row.spec; - if (isRecord(spec) && typeof spec.hex === "string") { - add(spec.hex, `${path}.spec.hex`); - } - entry.color = firstHexColor(row.value) ?? specHex(row.spec); - } else if (kind === "spacing") { - const scalar = rowScalarPx(row); - if (scalar !== null) { - addNumberEvidence(evidence.spacing, scalar, `${path}.value`); - entry.scalarPx = scalar; - } - } else if (kind === "radius") { - const scalar = rowScalarPx(row); - if (scalar !== null) { - addNumberEvidence(evidence.radii, scalar, `${path}.value`); - entry.scalarPx = scalar; - } - } else if (kind === "typography") { - const family = rowTypographyFamily(row); - if (family) { - addTextEvidence(evidence.typographyFamilies, family, `${path}.value`); - entry.typographyFamily = normalizeFamily(family); - } - const size = rowTypographySizePx(row); - if (size !== null) { - addNumberEvidence(evidence.typographySizes, size, `${path}.value`); - entry.typographySizePx = size; - } - const weight = rowTypographyWeight(row); - if (weight !== null) { - addNumberEvidence(evidence.typographyWeights, weight, `${path}.value`); - entry.typographyWeight = weight; - } - } else if (kind === "shadow") { - addTextEvidence(evidence.shadowValues, row.value, `${path}.value`); - } - evidence.rows.push(entry); - }); - - survey.tokens.forEach((row, index) => { - add(row.resolved_value, `survey.tokens[${index}].resolved_value`); - }); - - return evidence; -} - -function checkPaletteProvenance( - fingerprint: Fingerprint, - colorEvidence: Map, - issues: VerifyFingerprintIssue[], -): void { - fingerprint.palette.dominant.forEach((color, index) => { - checkPaletteColor( - color.value, - `palette.dominant[${index}].value`, - colorEvidence, - issues, - ); - }); - fingerprint.palette.semantic.forEach((color, index) => { - checkPaletteColor( - color.value, - `palette.semantic[${index}].value`, - colorEvidence, - issues, - ); - }); - fingerprint.palette.neutrals.steps.forEach((step, index) => { - checkPaletteColor( - step, - `palette.neutrals.steps[${index}]`, - colorEvidence, - issues, - ); - }); -} - -function checkPaletteColor( - value: string, - path: string, - colorEvidence: Map, - issues: VerifyFingerprintIssue[], -): void { - const normalized = normalizeHexColor(value); - if (!normalized) { - issues.push({ - severity: "error", - rule: "palette-color-not-hex", - message: `Palette value '${value}' is not a hex color and cannot be verified against survey color evidence.`, - path, - }); - return; - } - if (colorEvidence.has(normalized)) return; - issues.push({ - severity: "error", - rule: "palette-color-not-in-survey", - message: `Palette color ${normalized} is absent from survey color values and token resolved values.`, - path, - expected: "survey-backed color", - actual: normalized, - }); -} - -function checkRoleTokenAgreement( - fingerprint: Fingerprint, - survey: Survey, - issues: VerifyFingerprintIssue[], -): void { - const paletteByRole = new Map(); - collectSemanticPalette(fingerprint.palette.dominant, "dominant").forEach( - (entry) => { - addPaletteRole(paletteByRole, entry); - }, - ); - collectSemanticPalette(fingerprint.palette.semantic, "semantic").forEach( - (entry) => { - addPaletteRole(paletteByRole, entry); - }, - ); - - for (const role of HIGH_SALIENCE_ROLE_TOKENS) { - const paletteEntries = paletteByRole.get(role); - if (!paletteEntries?.length) continue; - const token = survey.tokens.find((row) => row.name === `--${role}`); - if (!token) continue; - const tokenColor = firstHexColor(token.resolved_value); - if (!tokenColor) continue; - - for (const entry of paletteEntries) { - if (entry.color === tokenColor) continue; - issues.push({ - severity: "warning", - rule: "palette-role-token-mismatch", - message: `Palette role '${role}' uses ${entry.color}, but survey token --${role} resolves to ${tokenColor}.`, - path: entry.path, - expected: tokenColor, - actual: entry.color, - }); - } - } -} - -function collectSemanticPalette( - colors: SemanticColor[], - section: "dominant" | "semantic", -): { role: string; color: string; path: string }[] { - return colors.flatMap((color, index) => { - const normalized = normalizeHexColor(color.value); - if (!normalized) return []; - return [ - { - role: normalizeRole(color.role), - color: normalized, - path: `palette.${section}[${index}].value`, - }, - ]; - }); -} - -function addPaletteRole( - roles: Map, - entry: { role: string; color: string; path: string }, -): void { - const entries = roles.get(entry.role) ?? []; - entries.push({ color: entry.color, path: entry.path }); - roles.set(entry.role, entries); -} - -function checkStructuredValueProvenance( - fingerprint: Fingerprint, - evidence: SurveyValueEvidence, - issues: VerifyFingerprintIssue[], -): void { - fingerprint.spacing.scale.forEach((value, index) => { - checkNumberEvidence( - value, - `spacing.scale[${index}]`, - "spacing-value-not-in-survey", - "Spacing value is absent from survey spacing values.", - evidence.spacing, - issues, - ); - }); - - fingerprint.typography.sizeRamp.forEach((value, index) => { - checkNumberEvidence( - value, - `typography.sizeRamp[${index}]`, - "typography-size-not-in-survey", - "Typography size is absent from survey typography values.", - evidence.typographySizes, - issues, - ); - }); - - fingerprint.typography.families.forEach((family, index) => { - const normalized = normalizeFamily(family); - if (evidence.typographyFamilies.has(normalized)) return; - issues.push({ - severity: "error", - rule: "typography-family-not-in-survey", - message: `Typography family '${family}' is absent from survey typography values.`, - path: `typography.families[${index}]`, - expected: "survey-backed typography family", - actual: family, - }); - }); - - Object.keys(fingerprint.typography.weightDistribution).forEach((weight) => { - const parsed = Number(weight); - if (!Number.isFinite(parsed)) return; - checkNumberEvidence( - parsed, - `typography.weightDistribution.${weight}`, - "typography-weight-not-in-survey", - "Typography weight is absent from survey typography values.", - evidence.typographyWeights, - issues, - 0, - ); - }); - - fingerprint.surfaces.borderRadii.forEach((value, index) => { - checkNumberEvidence( - value, - `surfaces.borderRadii[${index}]`, - "radius-value-not-in-survey", - "Radius value is absent from survey radius values.", - evidence.radii, - issues, - ); - }); - - checkShadowPosture(fingerprint.surfaces.shadowComplexity, evidence, issues); -} - -function checkNumberEvidence( - value: number, - path: string, - rule: string, - message: string, - evidence: Map, - issues: VerifyFingerprintIssue[], - decimals = 3, -): void { - const key = numberKey(value, decimals); - if (evidence.has(key)) return; - issues.push({ - severity: "error", - rule, - message, - path, - expected: "survey-backed value", - actual: value, - }); -} - -function checkShadowPosture( - shadowComplexity: Fingerprint["surfaces"]["shadowComplexity"], - evidence: SurveyValueEvidence, - issues: VerifyFingerprintIssue[], -): void { - const distinct = evidence.shadowValues.size; - const matches = - shadowComplexity === "deliberate-none" - ? distinct === 0 - : shadowComplexity === "subtle" - ? distinct >= 1 && distinct <= 2 - : distinct >= 3; - if (matches) return; - issues.push({ - severity: "error", - rule: "shadow-posture-not-in-survey", - message: `Shadow posture '${shadowComplexity}' is not backed by survey shadow values.`, - path: "surfaces.shadowComplexity", - expected: - shadowComplexity === "deliberate-none" - ? "0 survey shadow values" - : shadowComplexity === "subtle" - ? "1-2 distinct survey shadow values" - : "3+ distinct survey shadow values", - actual: distinct, - }); -} - -function checkHighSalienceOmissions( - fingerprint: Fingerprint, - evidence: SurveyValueEvidence, - issues: VerifyFingerprintIssue[], -): void { - const fingerprintValues = { - colors: new Set([ - ...fingerprint.palette.dominant.flatMap((color) => { - const normalized = normalizeHexColor(color.value); - return normalized ? [normalized] : []; - }), - ...fingerprint.palette.neutrals.steps.flatMap((color) => { - const normalized = normalizeHexColor(color); - return normalized ? [normalized] : []; - }), - ...fingerprint.palette.semantic.flatMap((color) => { - const normalized = normalizeHexColor(color.value); - return normalized ? [normalized] : []; - }), - ]), - spacing: new Set( - fingerprint.spacing.scale.map((value) => numberKey(value)), - ), - radii: new Set( - fingerprint.surfaces.borderRadii.map((value) => numberKey(value)), - ), - typographySizes: new Set( - fingerprint.typography.sizeRamp.map((value) => numberKey(value)), - ), - typographyFamilies: new Set( - fingerprint.typography.families.map(normalizeFamily), - ), - typographyWeights: new Set( - Object.keys(fingerprint.typography.weightDistribution).map((value) => - numberKey(Number(value), 0), - ), - ), - }; - - const rowsByKind = new Map(); - for (const row of evidence.rows) { - if ( - !["color", "spacing", "radius", "typography"].includes(row.kind) || - row.occurrences < HIGH_SALIENCE_VALUE_THRESHOLD - ) { - continue; - } - const rows = rowsByKind.get(row.kind) ?? []; - rows.push(row); - rowsByKind.set(row.kind, rows); - } - - for (const [kind, rows] of rowsByKind.entries()) { - for (const row of rows.sort(sortEvidenceRows).slice(0, 3)) { - const omitted = isHighSalienceRowOmitted(row, fingerprintValues); - if (!omitted) continue; - issues.push({ - severity: "warning", - rule: "survey-high-salience-value-omitted", - message: `High-salience survey ${kind} value '${row.value}' is not represented in fingerprint.md.`, - path: row.path, - expected: "represented in fingerprint compact value digest", - actual: row.value, - }); - } - } -} - -function isHighSalienceRowOmitted( - row: SurveyValueEvidenceRow, - fingerprintValues: { - colors: Set; - spacing: Set; - radii: Set; - typographySizes: Set; - typographyFamilies: Set; - typographyWeights: Set; - }, -): boolean { - if (row.kind === "color" && row.color) { - return !fingerprintValues.colors.has(row.color); - } - if (row.kind === "spacing" && row.scalarPx !== undefined) { - return !fingerprintValues.spacing.has(numberKey(row.scalarPx)); - } - if (row.kind === "radius" && row.scalarPx !== undefined) { - return !fingerprintValues.radii.has(numberKey(row.scalarPx)); - } - if (row.kind === "typography") { - if ( - row.typographyFamily && - !fingerprintValues.typographyFamilies.has(row.typographyFamily) - ) { - return true; - } - if ( - row.typographySizePx !== undefined && - !fingerprintValues.typographySizes.has(numberKey(row.typographySizePx)) - ) { - return true; - } - if ( - row.typographyWeight !== undefined && - !fingerprintValues.typographyWeights.has( - numberKey(row.typographyWeight, 0), - ) - ) { - return true; - } - } - return false; -} - -function normalizeRole(role: string): string { - return role.trim().toLowerCase(); -} - -function addNumberEvidence( - evidence: Map, - value: number, - path: string, - decimals = 3, -): void { - const key = numberKey(value, decimals); - const paths = evidence.get(key) ?? []; - paths.push(path); - evidence.set(key, paths); -} - -function addTextEvidence( - evidence: Map, - value: string, - path: string, -): void { - const key = normalizeFamily(value); - const paths = evidence.get(key) ?? []; - paths.push(path); - evidence.set(key, paths); -} - -function rowScalarPx(row: ValueRow): number | null { - const spec = row.spec; - if (isRecord(spec)) { - const scalar = - typeof spec.scalar === "number" - ? spec.scalar - : typeof spec.number === "number" - ? spec.number - : null; - if (scalar !== null) { - const unit = typeof spec.unit === "string" ? spec.unit : "px"; - const px = scalarUnitToPx(scalar, unit); - if (px !== null) return px; - } - } - return parseLengthPx(row.value); -} - -function rowTypographyFamily(row: ValueRow): string | null { - const spec = row.spec; - if (isRecord(spec) && typeof spec.family === "string") return spec.family; - const declaredFamily = declarationValue(row.value, "font-family"); - if (declaredFamily) return declaredFamily; - if (looksLikeDeclaration(row.value)) return null; - if (!parseLengthPx(row.value) && rowTypographyWeight(row) === null) { - return row.value; - } - return null; -} - -function rowTypographySizePx(row: ValueRow): number | null { - const spec = row.spec; - if (isRecord(spec) && isRecord(spec.size)) { - const scalar = spec.size.scalar; - const unit = spec.size.unit; - if (typeof scalar === "number" && typeof unit === "string") { - return scalarUnitToPx(scalar, unit); - } - } - const value = declarationValue(row.value, "font-size") ?? row.value; - if (/^[1-9]00$/.test(value.trim())) return null; - return parseLengthPx(value); -} - -function rowTypographyWeight(row: ValueRow): number | null { - const spec = row.spec; - if (isRecord(spec) && spec.weight !== undefined) { - const parsed = Number(spec.weight); - return Number.isFinite(parsed) ? parsed : null; - } - const value = declarationValue(row.value, "font-weight") ?? row.value; - if (/^[1-9]00$/.test(value.trim())) return Number(value.trim()); - return null; -} - -function scalarUnitToPx(scalar: number, unit: string): number | null { - const normalized = unit.trim().toLowerCase(); - if (normalized === "px") return scalar; - if (normalized === "dp" || normalized === "sp") return scalar; - if (normalized === "rem" || normalized === "em") return scalar * 16; - if (normalized === "") return scalar; - return null; -} - -function parseLengthPx(value: string): number | null { - const match = value.trim().match(/^(-?\d+(?:\.\d+)?)(px|rem|em|dp|sp)?$/i); - if (!match) return null; - const scalar = Number(match[1]); - const unit = match[2] ?? "px"; - return scalarUnitToPx(scalar, unit); -} - -function canonicalSurveyValueKind(row: ValueRow): string { - const raw = row as ValueRow & { category?: unknown }; - const kind = - typeof raw.kind === "string" ? raw.kind.trim().toLowerCase() : ""; - const category = - typeof raw.category === "string" ? raw.category.trim().toLowerCase() : ""; - - if (isCanonicalValueKind(kind)) return kind; - if (isCanonicalValueKind(category)) return category; - if ( - category === "color" && - ["hex-color", "rgba-color", "keyword"].includes(kind) - ) { - return "color"; - } - if ( - category === "spacing" && - ["length", "keyword", "number"].includes(kind) - ) { - return "spacing"; - } - if (category === "radius" && ["length", "number"].includes(kind)) { - return "radius"; - } - if ( - category === "typography" && - ["font-stack", "length", "number", "keyword"].includes(kind) - ) { - return "typography"; - } - if (category === "shadow") return "shadow"; - return kind || category; -} - -function isCanonicalValueKind(kind: string): boolean { - return [ - "color", - "spacing", - "typography", - "radius", - "shadow", - "breakpoint", - "motion", - "layout-primitive", - ].includes(kind); -} - -function declarationValue(value: string, property: string): string | null { - const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = value - .trim() - .match(new RegExp(`^${escaped}\\s*:\\s*(.+)$`, "i")); - return match ? (match[1]?.trim() ?? null) : null; -} - -function looksLikeDeclaration(value: string): boolean { - return /^[a-z-]+\s*:/i.test(value.trim()); -} - -function specHex(spec: unknown): string | undefined { - if (isRecord(spec) && typeof spec.hex === "string") { - return normalizeHexColor(spec.hex) ?? undefined; - } - return undefined; -} - -function normalizeFamily(value: string): string { - return value - .split(",") - .map((part) => - part - .trim() - .replace(/^['"]|['"]$/g, "") - .toLowerCase(), - ) - .filter(Boolean) - .join(","); -} - -function numberKey(value: number, decimals = 3): string { - return Number(value.toFixed(decimals)).toString(); -} - -function sortEvidenceRows( - a: SurveyValueEvidenceRow, - b: SurveyValueEvidenceRow, -): number { - return ( - compareNumbers(b.occurrences, a.occurrences) || - compareNumbers(b.files_count, a.files_count) || - compareStrings(a.value, b.value) - ); -} - -function firstHexColor(value: string): string | null { - return extractHexColors(value)[0] ?? null; -} - -function extractHexColors(value: string): string[] { - const matches = value.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []; - return matches.flatMap((match) => { - const normalized = normalizeHexColor(match); - return normalized ? [normalized] : []; - }); -} - -function normalizeHexColor(value: string): string | null { - const trimmed = value.trim(); - const match = trimmed.match(/^#([0-9a-fA-F]{3,8})$/); - if (!match) return null; - const hex = match[1].toLowerCase(); - if (hex.length === 3) { - return `#${hex - .split("") - .map((char) => `${char}${char}`) - .join("")}`; - } - return `#${hex}`; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function compareNumbers(a: number, b: number): number { - return a === b ? 0 : a < b ? -1 : 1; -} - -function compareStrings(a: string, b: string): number { - return a.localeCompare(b); -} - -function finalize(issues: VerifyFingerprintIssue[]): VerifyFingerprintReport { - let errors = 0; - let warnings = 0; - let info = 0; - for (const issue of issues) { - if (issue.severity === "error") errors += 1; - else if (issue.severity === "warning") warnings += 1; - else info += 1; - } - return { issues, errors, warnings, info }; -} diff --git a/packages/ghost/src/scan/verify-package.ts b/packages/ghost/src/scan/verify-package.ts deleted file mode 100644 index 596db43c..00000000 --- a/packages/ghost/src/scan/verify-package.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { access, readFile } from "node:fs/promises"; -import { isAbsolute, join, resolve } from "node:path"; -import type { - GhostFingerprintDocument, - GhostFingerprintEvidence, -} from "#ghost-core"; -import { - type LoadedFingerprintPackage, - lintFingerprintPackage, - loadFingerprintPackage, - resolveFingerprintPackage, -} from "./fingerprint-package.js"; -import type { - VerifyFingerprintIssue, - VerifyFingerprintReport, -} from "./verify-fingerprint.js"; - -export interface VerifyFingerprintPackageOptions { - root?: string; -} - -export async function verifyFingerprintPackage( - dirArg: string | undefined, - cwd = process.cwd(), - options: VerifyFingerprintPackageOptions = {}, -): Promise { - const paths = resolveFingerprintPackage(dirArg, cwd); - const root = resolve(cwd, options.root ?? "."); - const issues: VerifyFingerprintIssue[] = []; - - const packageLint = await lintFingerprintPackage(dirArg, cwd); - issues.push( - ...packageLint.issues.map((issue) => ({ - severity: issue.severity, - rule: `package/${issue.rule}`, - message: issue.message, - path: issue.path, - })), - ); - if (packageLint.errors > 0) return finalize(issues); - - const loaded = await readFingerprintPackage(paths, issues); - const fingerprint = loaded?.fingerprint; - if (fingerprint) { - await verifyFingerprintEvidence(fingerprint, root, issues); - await verifyFingerprintExemplars(fingerprint, root, issues); - } - - return finalize(issues); -} - -async function verifyFingerprintExemplars( - fingerprint: GhostFingerprintDocument, - root: string, - issues: VerifyFingerprintIssue[], -): Promise { - await Promise.all( - fingerprint.inventory.exemplars.map(async (entry, index) => { - const exemplarPath = isAbsolute(entry.path) - ? entry.path - : resolve(root, entry.path); - if (await pathExists(exemplarPath)) return; - issues.push({ - severity: "warning", - rule: "fingerprint-exemplar-unreachable", - message: `fingerprint exemplar path '${entry.path}' could not be resolved from ${root}.`, - path: `inventory.yml.exemplars[${index}].path`, - }); - }), - ); -} - -async function readFingerprintPackage( - paths: ReturnType, - issues: VerifyFingerprintIssue[], -): Promise { - try { - return await loadFingerprintPackage(paths); - } catch (err) { - issues.push({ - severity: "error", - rule: "verify-fingerprint-read-failed", - message: `fingerprint package could not be read: ${ - err instanceof Error ? err.message : String(err) - }`, - path: "fingerprint", - }); - return undefined; - } -} - -async function verifyFingerprintEvidence( - fingerprint: GhostFingerprintDocument, - root: string, - issues: VerifyFingerprintIssue[], -): Promise { - const evidenceLists: Array<[string, GhostFingerprintEvidence[] | undefined]> = - [ - ...fingerprint.intent.situations.map( - (entry, index) => - [`intent.yml.situations[${index}].evidence`, entry.evidence] as [ - string, - GhostFingerprintEvidence[] | undefined, - ], - ), - ...fingerprint.intent.principles.map( - (entry, index) => - [`intent.yml.principles[${index}].evidence`, entry.evidence] as [ - string, - GhostFingerprintEvidence[] | undefined, - ], - ), - ...fingerprint.intent.experience_contracts.map( - (entry, index) => - [ - `intent.yml.experience_contracts[${index}].evidence`, - entry.evidence, - ] as [string, GhostFingerprintEvidence[] | undefined], - ), - ...fingerprint.composition.patterns.map( - (entry, index) => - [`composition.yml.patterns[${index}].evidence`, entry.evidence] as [ - string, - GhostFingerprintEvidence[] | undefined, - ], - ), - ]; - - for (const [path, evidence] of evidenceLists) { - if (!evidence) continue; - await Promise.all( - evidence.map(async (entry, index) => { - if (!entry.path) return; - const evidencePath = isAbsolute(entry.path) - ? entry.path - : resolve(root, entry.path); - if (await pathExists(evidencePath)) return; - issues.push({ - severity: "warning", - rule: "fingerprint-evidence-unreachable", - message: `fingerprint evidence path '${entry.path}' could not be resolved from ${root}.`, - path: `${path}[${index}].path`, - }); - }), - ); - } -} - -async function pathExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function _isMissingFileError(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: string }).code === "ENOENT" - ); -} - -function finalize(issues: VerifyFingerprintIssue[]): VerifyFingerprintReport { - 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/scan/writer.ts b/packages/ghost/src/scan/writer.ts deleted file mode 100644 index e3764ef9..00000000 --- a/packages/ghost/src/scan/writer.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { stringify as stringifyYaml } from "yaml"; -import type { - DesignDecision, - DesignObservation, - Fingerprint, -} from "#ghost-core"; -import { type FingerprintMeta, mergeFrontmatter } from "./frontmatter.js"; - -export interface SerializeOptions { - meta?: FingerprintMeta; - /** Omit the human-readable body (frontmatter-only output). Default: false. */ - frontmatterOnly?: boolean; -} - -/** - * Serialize a Fingerprint to a fingerprint.md string. - * - * Contract: frontmatter and body own disjoint fields. - * • Frontmatter carries the machine-layer (id, tokens, dimension slugs, - * personality/resembles tags, references, checks, compact values). - * • Body carries intent (# Character, # Signature, # Decisions rationale). - * - * Each field has exactly one home — so there is no precedence rule and no - * way for the two sides to drift. - */ -export function serializeFingerprint( - fingerprint: Fingerprint, - options: SerializeOptions = {}, -): string { - const meta: FingerprintMeta = { ...options.meta }; - const obj = mergeFrontmatter(fingerprint, meta); - const yaml = stringifyYaml(obj, { lineWidth: 0 }).trimEnd(); - - if (options.frontmatterOnly) { - return `---\n${yaml}\n---\n`; - } - - const body = buildBody( - fingerprint.observation, - fingerprint.signature, - fingerprint.decisions, - ); - return body ? `---\n${yaml}\n---\n\n${body}\n` : `---\n${yaml}\n---\n`; -} - -function buildBody( - observation: DesignObservation | undefined, - signature: string | undefined, - decisions: DesignDecision[] | undefined, -): string { - const parts: string[] = []; - if (observation?.summary?.trim()) { - parts.push(`# Character\n\n${observation.summary.trim()}`); - } - if (signature?.trim()) { - parts.push(`# Signature\n\n${signature.trim()}`); - } - if (decisions?.length) { - const blocks = decisions - .filter((d) => d.decision?.trim()) - .map(formatDecision) - .join("\n\n"); - if (blocks) parts.push(`# Decisions\n\n${blocks}`); - } - return parts.join("\n\n"); -} - -/** - * Body carries the full per-dimension story: rationale intent followed by an - * `**Evidence:**` bullet list (schema 5). Each evidence string becomes one - * bullet, wrapped in backticks so token-name citations render as code. - * Evidence is skipped entirely when empty. - */ -function formatDecision(d: DesignDecision): string { - const title = unslug(d.dimension); - const intent = d.decision.trim(); - const evidence = d.evidence?.filter((e) => e?.trim()) ?? []; - if (!evidence.length) return `### ${title}\n${intent}`; - const bullets = evidence.map((e) => `- ${fenceEvidence(e)}`).join("\n"); - return `### ${title}\n${intent}\n\n**Evidence:**\n${bullets}`; -} - -/** Wrap evidence strings in backticks when they aren't already fenced. */ -function fenceEvidence(text: string): string { - const trimmed = text.trim(); - if (trimmed.startsWith("`") && trimmed.endsWith("`")) return trimmed; - return `\`${trimmed.replace(/`/g, "\\`")}\``; -} - -function unslug(s: string): string { - return s - .split(/[-_\s]+/) - .filter(Boolean) - .map((w, i) => (i === 0 ? w[0].toUpperCase() + w.slice(1) : w)) - .join(" "); -} diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 4e52fcdc..91755983 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -1,6 +1,6 @@ --- name: ghost -description: Author, validate, and review repo-local Ghost fingerprints. Use when the user wants to set up a product-surface fingerprint, update .ghost, brief work from surface-composition context, review drift, verify generated UI, or compare fingerprint packages. +description: Author, validate, and review repo-local Ghost fingerprints. Use when the user wants to set up a product-surface fingerprint, update .ghost, brief work from surface-composition context, review changes, or verify generated UI. license: Apache-2.0 metadata: homepage: https://github.com/block/ghost @@ -14,12 +14,10 @@ materials it draws from, and the patterns that make it feel intentional. ```text .ghost/ - manifest.yml - intent.yml - inventory.yml - composition.yml - surfaces.yml - checks/*.md + manifest.yml # schema + id + surfaces.yml # the spine: surfaces + their parent (core is implicit) + nodes/*.md # prose nodes — the design expression + checks/*.md # optional ghost.check/v1 checks ``` The checked-in `.ghost/` package is the source of truth. Ordinary Git @@ -28,24 +26,28 @@ are drafts, and committed fingerprint changes are canonical for Ghost. Checks ar markdown rules an agent evaluates. Ghost is not a lifecycle manager, proposal system, design-system registry, or screenshot archive. -Generation uses **intent + inventory + composition**: +The fingerprint is a graph of **nodes**. A node is a markdown file: +frontmatter (`id`, `under`, `relates`, `incarnation`) + a prose body. +**Intent + inventory + composition** are the authoring lenses the body is +written through — they guide what to capture, they are not fields or node types: -- `intent.yml` captures the intent behind the surface. -- `inventory` points to building blocks and precedents the agent can inspect - or use, including exemplars. -- `composition.yml` captures the patterns that make the surface feel - intentional. +- intent — the why and the stance. +- inventory — the materials and pointers to implementation the agent can inspect. +- composition — the patterns that make the surface feel intentional. + +`under` places a node so it is inherited downward (`core` is the implicit root +that reaches every surface); `relates` links nodes laterally; `incarnation` tags +a medium-bound expression (essence is untagged). See +[references/capture.md](references/capture.md) for the full node shape. Checks and review validate output; they are not generation input. -`manifest.yml` anchors the package with -`schema: ghost.fingerprint-package/v1`. Add only sections that contain real -facet content; Ghost normalizes omitted facet files or sections internally for -checks, review, emit, and surface resolution. +`manifest.yml` anchors the package with `schema: ghost.fingerprint-package/v1`. +The tree is declared in `surfaces.yml`, never inferred from filenames or paths. Optional `ghost.check/v1` markdown checks live in `checks/*.md`, routed by surface. Use `ghost signals` as a stdout-only reconnaissance helper when an agent needs -raw repo observations while authoring curated fingerprint facets. +raw repo observations while authoring curated nodes. 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 @@ -54,18 +56,22 @@ the child `ghost` process when they need repo-local Ghost files outside raw one product in a monorepo). Ghost stays adapter-neutral: wrappers consume JSON and map severities into their own review or check format. +A package can **extend** another by identity — the shared-brand pattern. The +manifest's `extends` maps a package id to where it lives: +`extends: { brand: ../brand/.ghost }`. Then nodes reference inherited context by +identity, never path: `under: brand:core` or `relates: [{ to: brand:core-trust }]`. +Inherited nodes are read-only and flow into gather/validate like local ones. + ## Core CLI Verbs | Verb | Purpose | |---|---| -| `ghost init` | Create `.ghost/` with manifest and facets. | -| `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 init [--template ]` | Scaffold `.ghost/` with manifest, surfaces spine, and a seed node. | +| `ghost scan [dir] [--format json]` | Report node/surface contribution. | +| `ghost validate [file-or-dir]` | Validate the package — artifact shape and the node graph (links resolve, one root, acyclic). | | `ghost checks --surface ` | Select and ground the markdown checks governing the named surfaces. | | `ghost review --surface [--diff ]` | Emit an advisory review packet: touched surfaces, routed checks, and fingerprint grounding (diff embedded verbatim). | -| `ghost gather [surface]` | Compose a surface's context slice (own + inherited + edge), or list the surface menu. | -| `ghost emit ` | Emit `review-command`. | +| `ghost gather [surface] [--as ]` | Compose a surface's context slice (own + inherited + edge), or list the surface menu. | | `ghost skill install` | Install this unified skill bundle. | ## Advanced CLI Verbs @@ -75,8 +81,6 @@ and map severities into their own review or check format. | `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 compare [...more]` | Compare root fingerprint packages. | -| `ghost ack` / `track` / `diverge` | Record stance toward tracked drift. | ## Workflows @@ -87,10 +91,9 @@ and map severities into their own review or check format. - Recall surface-composition context: follow [references/recall.md](references/recall.md). - Shape a pre-generation brief: follow [references/brief.md](references/brief.md). - Critique generated or changed work: follow [references/critique.md](references/critique.md). -- Review drift: follow [references/review.md](references/review.md). +- Review changes: follow [references/review.md](references/review.md). - Verify generation: follow [references/verify.md](references/verify.md). -- Remediate drift: follow [references/remediate.md](references/remediate.md). -- Advanced compare bundles: follow [references/compare.md](references/compare.md). +- Remediate findings: follow [references/remediate.md](references/remediate.md). When the user asks to set up a fingerprint with `auto-draft`, treat that as an agent authoring mode, not a Ghost CLI command. Follow the auto-draft branch in @@ -102,19 +105,19 @@ 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. - 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. +- Use local evidence as provisional when the fingerprint is silent. - Treat auto-drafted fingerprint edits as ordinary uncommitted draft work until the human curates them and Git review accepts them. - Treat fingerprint edits as ordinary Git-reviewed edits. -- Validate with `ghost lint` and `ghost verify --root ` before declaring - fingerprint facets useful. +- Validate with `ghost validate` before declaring + fingerprint nodes useful. - Run `ghost checks` to route checks and `ghost review` for the advisory packet. - Use a custom package dir (`--package` / `GHOST_PACKAGE_DIR`) only when present or requested. ## When Fingerprint Facets Are Silent -Silent fingerprint facets do not require stopping by default. When the fingerprint does +Silent fingerprint nodes do not require stopping by default. When the fingerprint does not cover the task, proceed from nearby product surfaces, local components, token and copy conventions, and ordinary UX reasoning when safe. Label that reasoning as provisional and non-Ghost-backed. diff --git a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md index 9dd216a1..ec497bcf 100644 --- a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md +++ b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md @@ -138,8 +138,7 @@ Place local obligations on the surface that owns them. Validate before calling facets useful: ```bash -ghost lint .ghost -ghost verify .ghost --root +ghost validate .ghost ghost check --base HEAD ``` diff --git a/packages/ghost/src/skill-bundle/references/brief.md b/packages/ghost/src/skill-bundle/references/brief.md index 32d5bede..69a62f0a 100644 --- a/packages/ghost/src/skill-bundle/references/brief.md +++ b/packages/ghost/src/skill-bundle/references/brief.md @@ -9,17 +9,17 @@ description: Build a concise pre-generation brief from a surface's gather slice. 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). -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 + prose `nodes`, each with `provenance` (own, inherited from an ancestor, or + contributed by a typed `relates` edge). The intent, the material, and the + composition live in each node's prose. +3. Add `--as ` (e.g. email, voice) to filter the slice to one + output form; essence (untagged) nodes always pass. +4. Run `ghost signals ` when raw repo observations would help you find evidence. -6. Run `ghost checks --surface ` (the surfaces you determined the change +5. 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 +6. 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 @@ -33,10 +33,9 @@ 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 guessing. -Return a short human-facing brief synthesized from the slice: relevant -principles and contracts (the why), patterns and inventory exemplars to inspect -(what good looks like), checks to avoid, and provisional assumptions when the -surface is silent. +Return a short human-facing brief synthesized from the slice: the relevant +grounded nodes (their prose carries the why and what good looks like), checks to +avoid, and provisional assumptions when the surface is silent. Fingerprint edits are ordinary Git-reviewed edits to the split fingerprint package. diff --git a/packages/ghost/src/skill-bundle/references/capture.md b/packages/ghost/src/skill-bundle/references/capture.md index c843c8a9..8d1c408b 100644 --- a/packages/ghost/src/skill-bundle/references/capture.md +++ b/packages/ghost/src/skill-bundle/references/capture.md @@ -1,10 +1,10 @@ --- name: capture -description: Author repo-local Ghost fingerprints. +description: Author repo-local Ghost fingerprints as nodes. handoffs: - - label: Inspect fingerprint facets + - label: Inspect the package command: ghost scan - prompt: What fingerprint facets does this package contribute, and which facets are absent? + prompt: What does this fingerprint package contribute, and what is absent? - label: Run deterministic checks command: ghost check prompt: Run ghost check against this bundle @@ -12,147 +12,128 @@ handoffs: # Recipe: Author Ghost Fingerprint -**Goal:** record durable product-surface composition in `.ghost/`. -If a change is uncommitted or unmerged, it is draft work. If it is checked in, -Ghost treats the fingerprint package as canonical. +**Goal:** record durable product-surface composition in `.ghost/` as a graph of +prose **nodes**. If a change is uncommitted or unmerged, it is draft work. If it +is checked in, Ghost treats the fingerprint package as canonical. ```text .ghost/ - manifest.yml - intent.yml - inventory.yml - composition.yml - validate.yml + manifest.yml # schema + id + surfaces.yml # the spine: surfaces + their `parent` (core is implicit) + nodes/ # one prose node per file + core-voice.md + checkout-trust.md + checks/ # optional ghost.check/v1 markdown checks ``` -`intent.yml` captures the intent behind the surface. `inventory.yml` records -curated materials, exemplars, and source links. `composition.yml` records the -patterns that make the surface feel intentional. Checks validate output after -generation; they are not generation input. +A **node** is a markdown file: YAML frontmatter (the machine handles) + a prose +body (the design expression). The fingerprint is the graph of nodes the loader +folds together; `ghost gather ` traverses it. -## Steps - -### 1. Classify The Authoring Scenario - -Before initialization or edits, decide which authoring posture fits this repo. -Follow [authoring-scenarios.md](authoring-scenarios.md) when the user is setting -up or substantially revising a fingerprint. - -Common starting points: - -- Net new repos are mostly human-led because the repo has little evidence. -- Net new repos with a UI library combine human intent with library scans. -- Existing repos combine human intent with code, docs, route, story, and - exemplar scans. -- Existing repos with mixed quality require curation before repeated patterns - become canonical. -- 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 -accepts it. +## The node shape -If the user asks for `auto-draft`, keep this same boundary: the mode may create -starter edits, but it does not make scan output canonical. - -### 2. Initialize +```markdown +--- +id: checkout-trust # required: unique, stable +under: checkout # optional: parent surface/node — inherited downward +relates: # optional: lateral links + - to: core-trust + as: reinforces # reinforces | contrasts | variant +incarnation: web # optional: email | billboard | voice | … (omit = essence) +--- -```bash -ghost init -ghost scan +Near the moment of payment, reduce felt risk. Proximity of reassurance to the +action beats completeness… ``` -Use `--reference ` when a reference UI registry or library -should seed `inventory.yml`. - -### 3. Auto-Draft Mode +- **`under`** places the node — a node inherits everything it sits under. The + brand soul lives at `core` (implicit root), so `core`-placed nodes reach every + surface. +- **`relates`** links laterally when a relationship carries rationale. When the + rationale is rich (e.g. "checkout and item-detail disagree on density on + purpose"), write a **relationship node** whose body explains the tension. +- **`incarnation`** tags a node only when its expression is bound to one output + form. Leave medium-agnostic essence untagged. -Use this branch only when the user explicitly asks for auto-draft, such as: +## Write the body through three lenses -```text -Set up the Ghost fingerprint for this repo with auto-draft. -``` +Intent / inventory / composition are **authoring lenses**, not fields and not +node types. They are the things worth thinking through as you write a node's +prose — a node may lean entirely on one: -Auto-draft is a skill workflow, not a Ghost CLI action or flag. +- **intent** — the why and the stance. +- **inventory** — the material you have (tokens, components, and pointers to the + actual implementation in code). +- **composition** — how it is assembled (the patterns that make it intentional). -1. If `.ghost/manifest.yml` is missing, run `ghost init`. -2. Run `ghost scan --format json`. -3. Gather raw repo signals: +A finding cites a node by id, so keep a node **purpose-coherent**: one purpose, +any length. Split into a second node only when a handle diverges — a different +`under`, a different `incarnation`, or a genuinely different `relates` role. - ```bash - ghost signals . - ``` - -4. Inspect high-signal files from the signals, plus routes, docs, stories, - tests, tokens, registries, assets, screenshots, and exemplars. -5. Write only the smallest evidence-backed starter entries into - `intent.yml`, `inventory.yml`, and `composition.yml`. -6. Ask the human to keep, soften, reject, scope, record, or convert important - claims before treating them as durable fingerprint guidance. +## Steps -If evidence is thin, contradictory, or mostly implementation plumbing, write -less and ask more. Do not fill facets with speculative product claims. +### 1. Classify the authoring scenario -### 4. Orient +Decide which posture fits the repo before scaffolding. Follow +[authoring-scenarios.md](authoring-scenarios.md) when setting up or substantially +revising a fingerprint. Human intent anchors composition; scans provide +evidence; agent synthesis is draft work until a human curates it and Git review +accepts it. -Read the product, not just the component library. Look for surfaces, docs, -tests, stories, routes, screenshots, or examples that reveal hierarchy, -behavior, copy, accessibility, trust, and flow. +Monorepos and product suites run **one contract per package**: surfaces are how +a single contract organizes locality. -Optional helper: +### 2. Initialize ```bash -ghost signals . +ghost init # scaffolds manifest + surfaces.yml + a seed node +ghost scan ``` -Treat signals as scratch observations. Do not copy raw signals into -`inventory.yml` without curation. +`ghost init` is template-driven (`--template ` selects a starter). The +default template seeds the spine plus one `core` node demonstrating the shape. -### 5. Write Sparse Facets +### 3. Shape the spine -Edit the smallest useful durable facet content: +Edit `surfaces.yml` to declare the surfaces this product has and their `parent` +(containment). `core` is implicit. The tree is always declared here — never +inferred from node filenames or repo paths. -- Before writing, ask whether the draft will help future agents choose what - matters most, avoid plausible-but-wrong defaults, resolve competing - priorities, route guidance by situation, inspect concrete exemplars, and - review whether generated work preserved the intended experience. -- `intent.yml`: summary, situations, principles, and experience contracts. -- `inventory.yml`: building blocks, exemplars, and `sources[]` links. -- `composition.yml`: rules, layouts, structures, flows, states, content, - behavior, and visual arrangements. +### 4. Orient -Prefer a few high-confidence entries over a comprehensive but noisy catalog. -Ask the human to keep, soften, reject, scope, or record important claims before -treating draft content as durable fingerprint guidance. +Read the product, not just the component library. Look for surfaces, docs, +tests, stories, routes, screenshots, or examples that reveal hierarchy, +behavior, copy, accessibility, trust, and flow. `ghost signals .` emits raw +scratch observations — curate, never copy verbatim into a node. -### 6. Add Checks Sparingly +### 5. Write sparse nodes -`validate.yml` is the executable appendix. Add only -deterministic checks with typed derivation refs: +Add the smallest useful set of `nodes/*.md`, each a purpose-coherent prose body +written through the lenses, placed with `under` and linked with `relates` where +a relationship carries meaning. Prefer a few high-confidence nodes over a noisy +catalog. Ask the human to keep, soften, reject, or re-place important claims +before treating draft nodes as durable. -```yaml -derivation: - composition: - - composition.pattern:resource-index-stays-tabular -``` +### 6. Add checks sparingly -Ref-backed checks are preferred. Missing or unresolved derivation refs lint as -warnings. Inventory can support a check, but inventory-only grounding is not -surface-composition guidance by itself. +`checks/*.md` are `ghost.check/v1` markdown, placed by `surface:` frontmatter +(unplaced = core = everywhere). They validate output after generation; they are +not generation input. Add only deterministic checks. ### 7. Validate ```bash -ghost lint .ghost -ghost verify .ghost --root +ghost validate .ghost ghost check --base HEAD ``` ## Never - Never describe any file outside `.ghost/` as canonical package input. -- Never treat raw `ghost signals` output as canonical inventory. -- Never treat auto-draft as a CLI feature or a replacement for human curation. -- Never invent surface-composition obligations absent from evidence or human direction. -- Never promote subjective taste directly into checks; make it deterministic or keep it advisory. +- Never treat raw `ghost signals` output as a node without curation. +- Never infer the surface tree from filenames or repo paths — declare it in + `surfaces.yml`. +- Never invent surface-composition obligations absent from evidence or human + direction. +- Never promote subjective taste directly into checks; make it deterministic or + keep it advisory. diff --git a/packages/ghost/src/skill-bundle/references/compare.md b/packages/ghost/src/skill-bundle/references/compare.md deleted file mode 100644 index 71926534..00000000 --- a/packages/ghost/src/skill-bundle/references/compare.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: compare -description: Interpret ghost compare output — pairwise distance or composite (N≥3) analysis. -handoffs: - - label: Accept the drift as aligned reality - command: ghost ack - prompt: Accept current drift across the board - - label: Track the other fingerprint - command: ghost track - prompt: Track the other fingerprint as the new reference - - label: Declare a dimension intentionally divergent - command: ghost diverge - prompt: Record an intentional divergence on a specific dimension ---- - -# Recipe: Compare fingerprints - -**Goal:** answer "how different are these design languages?" or "how has ours drifted over time?" - -## Steps - -### Pairwise (N=2) - - ghost compare a/.ghost b/.ghost - -Output: distance (0 = identical, 1 = unrelated) and per-dimension deltas. Package inputs use canonical `.ghost/` packages. - -Flags: -- `--temporal` — add drift velocity, trajectory, and ack bounds (reads `.ghost/history.jsonl`) - -### Composite (N≥3) - - ghost compare a/.ghost b/.ghost c/.ghost d/.ghost - -Output: pairwise distance matrix, centroid, spread, and cluster assignments. The centroid is the composite (org-scale) fingerprint: what the members average out to. - -Use for: comparing a collection of fingerprints at the same elevation: which are closest, which are far apart, and whether they cluster into coherent families. - -### Interpreting output - -- **Distance < 0.2**: effectively the same system. -- **0.2 – 0.5**: recognizable drift; worth a qualitative review. -- **> 0.5**: the two fingerprints represent meaningfully different systems. Either one has diverged intentionally, or they were never the same. - -If the user asks "why did it change", inspect the compared fingerprint facets -and summarize the surface-composition differences directly. diff --git a/packages/ghost/src/skill-bundle/references/patterns.md b/packages/ghost/src/skill-bundle/references/patterns.md index 86e3e4ac..78b90329 100644 --- a/packages/ghost/src/skill-bundle/references/patterns.md +++ b/packages/ghost/src/skill-bundle/references/patterns.md @@ -3,7 +3,7 @@ name: patterns description: Author surface-composition patterns inside .ghost/composition.yml. handoffs: - label: Verify fingerprint package - command: ghost verify .ghost --root . + command: ghost validate .ghost prompt: Verify the root fingerprint package --- @@ -77,8 +77,7 @@ Allowed `kind` values: ## Validate ```bash -ghost lint .ghost -ghost verify .ghost --root . +ghost validate .ghost ``` If a pattern is speculative, do not add it as canonical composition. Leave it in diff --git a/packages/ghost/src/skill-bundle/references/remediate.md b/packages/ghost/src/skill-bundle/references/remediate.md index d882bd3e..4445cdba 100644 --- a/packages/ghost/src/skill-bundle/references/remediate.md +++ b/packages/ghost/src/skill-bundle/references/remediate.md @@ -15,8 +15,7 @@ description: Suggest minimal code or fingerprint edits after Ghost drift finding 5. If the finding is actually intentional divergence, say so and ask whether to update the checked-in fingerprint. -Use `ghost check` after implementation changes. Use `ghost lint` and -`ghost verify` after fingerprint edits. +Use `ghost check` after implementation changes. Use `ghost validate` after fingerprint edits. Do not broaden the patch into unrelated refactors. Do not edit the Ghost package silently unless the user asks to update the split fingerprint package, checks, or optional diff --git a/packages/ghost/src/skill-bundle/references/review.md b/packages/ghost/src/skill-bundle/references/review.md index 63ac94dd..668c7f29 100644 --- a/packages/ghost/src/skill-bundle/references/review.md +++ b/packages/ghost/src/skill-bundle/references/review.md @@ -21,8 +21,9 @@ 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) -- `grounding`: per surface, the *why* (principles, contracts) and the *what good - looks like* (patterns, exemplars with paths) +- `grounding`: per surface, the slice's prose `nodes`, each with `provenance` + (own / ancestor / edge). The why and the what live in each node's prose — read + the grounded nodes, own first, then inherited, then related. Ghost selects and grounds the checks; it does not run them. Evaluate each markdown check's instructions against the diff yourself. diff --git a/packages/ghost/src/skill-bundle/references/verify.md b/packages/ghost/src/skill-bundle/references/verify.md index b4614441..767ea82e 100644 --- a/packages/ghost/src/skill-bundle/references/verify.md +++ b/packages/ghost/src/skill-bundle/references/verify.md @@ -5,7 +5,7 @@ description: Verify generated UI or fingerprint edits against Ghost. # Recipe: Verify Ghost Work -1. Run `ghost lint .ghost` and `ghost verify .ghost --root ` after +1. Run `ghost validate .ghost` after fingerprint edits. 2. Run `ghost check --base ` after implementation changes. 3. For advisory review, run `ghost checks --surface ` (the surfaces the diff --git a/packages/ghost/src/skill-bundle/references/voice.md b/packages/ghost/src/skill-bundle/references/voice.md index 107aac81..e56767e8 100644 --- a/packages/ghost/src/skill-bundle/references/voice.md +++ b/packages/ghost/src/skill-bundle/references/voice.md @@ -35,7 +35,7 @@ language flow through `intent.yml` (tone, voice principles, wording contracts), - Contextual guidance stays in composition only. - Give each check a `derivation` ref back to the intent or composition entry it enforces. -5. Validate with `ghost lint` and `ghost verify --root `, then hand +5. Validate with `ghost validate`, then hand the draft to the human to curate. Fingerprint edits stay ordinary uncommitted draft work until Git review accepts them. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 8145df1d..bc1a4463 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { buildCli } from "../src/cli.js"; -import { runDriftCheck } from "../src/drift-command.js"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -168,13 +167,11 @@ describe("ghost CLI", () => { for (const command of [ "init", "scan", - "lint", - "verify", + "validate", "check", "review", "gather", "checks", - "emit", "skill install", ]) { expect(result.stdout).toContain(command); @@ -197,557 +194,22 @@ describe("ghost CLI", () => { expect(result.code).toBe(0); expect(result.stdout).toContain("Core workflow"); expect(result.stdout).toContain("Advanced/package inspection"); - expect(result.stdout).toContain("Compare/stance"); expect(result.stdout).toContain("Maintenance/legacy"); for (const command of [ - "lint [file]", + "validate [file]", "init", - "verify [dir]", "scan [dir]", "signals [path]", "gather", "checks", "migrate", - "emit ", - "compare [...fingerprints]", - "drift ", - "ack", - "track ", - "diverge ", "skill ", - "check", "review", ]) { expect(result.stdout).toContain(command); } }); - 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")); - - const result = await runCli( - ["compare", "a.fingerprint.md", "b.fingerprint.md"], - dir, - ); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("Distance"); - }); - - it("compares root fingerprint bundle directories", async () => { - await writeComparableBundle(join(dir, "a", ".ghost"), "sectioned-form"); - await writeComparableBundle(join(dir, "b", ".ghost"), "data-table"); - - const result = await runCli(["compare", "a/.ghost", "b/.ghost"], dir); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("Distance"); - }); - - it("track writes the neutral sync manifest shape", async () => { - await mkdir(join(dir, ".ghost"), { recursive: true }); - await writeFile( - join(dir, ".ghost", "fingerprint.md"), - fingerprintWithId("local"), - ); - await writeFile( - join(dir, "tracked.fingerprint.md"), - fingerprintWithId("tracked"), - ); - - const result = await runCli(["track", "tracked.fingerprint.md"], dir); - const manifest = JSON.parse( - await readFile(join(dir, ".ghost-sync.json"), "utf-8"), - ) as Record; - - expect(result.code).toBe(0); - expect(manifest.tracks).toEqual({ - type: "path", - value: "tracked.fingerprint.md", - }); - expect(manifest.trackedFingerprintId).toBe("tracked"); - expect(manifest.localFingerprintId).toBe("local"); - const legacyRelationFields = [ - "parent", - ["parent", "FingerprintId"].join(""), - ["child", "FingerprintId"].join(""), - ]; - for (const field of legacyRelationFields) { - expect(manifest).not.toHaveProperty(field); - } - }); - - it("track bootstraps the sync manifest for canonical fingerprint packages", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - - const track = await runCli(["track", "tracked/.ghost"], dir); - const check = await runCli(["drift", "check", "--format", "json"], dir); - - expect(track.code).toBe(0); - const manifest = JSON.parse( - await readFile(join(dir, ".ghost-sync.json"), "utf-8"), - ); - expect(manifest.tracks).toEqual({ type: "path", value: "tracked/.ghost" }); - expect(manifest.trackedFingerprintId).toBe("tracked"); - expect(manifest.localFingerprintId).toBe("local"); - expect(check.code).toBe(0); - expect(JSON.parse(check.stdout).overall.verdict).toBe("covered"); - }); - - it("ack and diverge write stance updates from the unified cli", async () => { - await mkdir(join(dir, ".ghost"), { recursive: true }); - await writeFile( - join(dir, ".ghost", "fingerprint.md"), - fingerprintWithId("local"), - ); - await writeFile( - join(dir, "tracked.fingerprint.md"), - fingerprintWithId("tracked"), - ); - await writeFile( - join(dir, "ghost.config.js"), - "export default { tracks: './tracked.fingerprint.md' };\n", - ); - - const ack = await runCli( - [ - "ack", - "--stance", - "aligned", - "--reason", - "baseline", - "--format", - "json", - ], - dir, - ); - const diverge = await runCli( - ["diverge", "typography", "--reason", "editorial", "--format", "json"], - dir, - ); - - expect(ack.code).toBe(0); - expect(JSON.parse(ack.stdout).trackedFingerprintId).toBe("tracked"); - expect(diverge.code).toBe(0); - const manifest = JSON.parse(diverge.stdout); - expect(manifest.dimensions.typography.stance).toBe("diverging"); - expect(manifest.dimensions.typography.reason).toBe("editorial"); - }); - - it("ack reads canonical fingerprint packages from config tracks", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeFile( - join(dir, "ghost.config.js"), - "export default { tracks: './tracked/.ghost' };\n", - ); - - const ack = await runCli(["ack", "--format", "json"], dir); - - expect(ack.code).toBe(0); - const manifest = JSON.parse(ack.stdout); - expect(manifest.trackedFingerprintId).toBe("tracked"); - expect(manifest.localFingerprintId).toBe("local"); - }); - - it("ack preserves npm tracks that expose a .ghost fingerprint", async () => { - await mkdir(join(dir, ".ghost"), { recursive: true }); - await writeFile( - join(dir, ".ghost", "fingerprint.md"), - fingerprintWithId("local"), - ); - await mkdir(join(dir, "node_modules", "@scope", "tracked", ".ghost"), { - recursive: true, - }); - await writeFile( - join( - dir, - "node_modules", - "@scope", - "tracked", - ".ghost", - "fingerprint.md", - ), - fingerprintWithId("tracked"), - ); - await writeFile( - join(dir, "ghost.config.js"), - "export default { tracks: 'npm:@scope/tracked' };\n", - ); - - const ack = await runCli(["ack", "--format", "json"], dir); - - expect(ack.code).toBe(0); - const manifest = JSON.parse(ack.stdout); - expect(manifest.tracks).toEqual({ type: "npm", value: "@scope/tracked" }); - expect(manifest.trackedFingerprintId).toBe("tracked"); - expect(manifest.localFingerprintId).toBe("local"); - }); - - it("drift check resolves npm tracks that expose canonical fingerprint packages", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "node_modules", "@scope", "tracked"), { - checks: false, - }); - await writeFile( - join(dir, "node_modules", "@scope", "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeFile( - join(dir, "ghost.config.js"), - "export default { tracks: 'npm:@scope/tracked' };\n", - ); - - const ack = await runCli(["ack", "--format", "json"], dir); - const check = await runCli(["drift", "check", "--format", "json"], dir); - - expect(ack.code).toBe(0); - expect(check.code).toBe(0); - const report = JSON.parse(check.stdout); - expect(report.trackedFingerprintId).toBe("tracked"); - expect(report.localFingerprintId).toBe("local"); - expect(report.overall.verdict).toBe("covered"); - }); - - it("omits removed design-loop status by default", async () => { - const result = await runCli(["drift", "status", "--format", "json"], dir); - - expect(result.code).toBe(0); - const status = JSON.parse(result.stdout); - expect(status.schema).toBe("ghost.drift.status/v1"); - expect(status.designLoop).toBeUndefined(); - }); - - it("runs the Ghost-owned drift check contract through the stance ledger", async () => { - await mkdir(join(dir, ".ghost"), { recursive: true }); - await writeFile( - join(dir, ".ghost", "fingerprint.md"), - fingerprintWithId("local"), - ); - await writeFile( - join(dir, "tracked.fingerprint.md"), - fingerprintWithId("tracked"), - ); - await writeFile( - join(dir, "ghost.config.js"), - "export default { tracks: './tracked.fingerprint.md' };\n", - ); - await runCli(["track", "tracked.fingerprint.md"], dir); - - const result = await runCli(["drift", "check", "--format", "json"], dir); - - expect(result.code).toBe(0); - const report = JSON.parse(result.stdout); - expect(report.schema).toBe("ghost.drift.check/v1"); - expect(report.designLoop).toBeUndefined(); - expect(report.trackedFingerprintId).toBe("tracked"); - expect(report.localFingerprintId).toBe("local"); - expect(report.overall.verdict).toBe("covered"); - expect(report.gate.schema).toBe("ghost.compare.gate/v1"); - }); - - it("drift check loads canonical fingerprint packages", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeFile( - join(dir, ".ghost-sync.json"), - JSON.stringify({ - tracks: { type: "path", value: "tracked/.ghost" }, - ackedAt: "2026-06-16T00:00:00.000Z", - trackedFingerprintId: "tracked", - localFingerprintId: "local", - overallDistance: 0, - dimensions: { - spacing: { - distance: 0, - stance: "accepted", - ackedAt: "2026-06-16T00:00:00.000Z", - }, - palette: { - distance: 0, - stance: "accepted", - ackedAt: "2026-06-16T00:00:00.000Z", - }, - typography: { - distance: 0, - stance: "accepted", - ackedAt: "2026-06-16T00:00:00.000Z", - }, - surfaces: { - distance: 0, - stance: "accepted", - ackedAt: "2026-06-16T00:00:00.000Z", - }, - decisions: { - distance: 0, - stance: "accepted", - ackedAt: "2026-06-16T00:00:00.000Z", - }, - }, - }), - ); - - const result = await runCli( - [ - "drift", - "check", - "--package", - ".ghost", - "--tracked", - "tracked/.ghost", - "--format", - "json", - ], - dir, - ); - - expect(result.code).toBe(0); - const report = JSON.parse(result.stdout); - expect(report.schema).toBe("ghost.drift.check/v1"); - expect(report.trackedFingerprintId).toBe("tracked"); - expect(report.localFingerprintId).toBe("local"); - }); - - it("drift check uses ledger tracks when no config is present", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); - - const result = await runCli(["drift", "check", "--format", "json"], dir); - - expect(result.code).toBe(0); - const report = JSON.parse(result.stdout); - expect(report.trackedFingerprintId).toBe("tracked"); - expect(report.localFingerprintId).toBe("local"); - }); - - it("drift check resolves config tracks that point at canonical packages", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeFile( - join(dir, "ghost.config.js"), - "export default { tracks: './tracked/.ghost' };\n", - ); - await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); - - const result = await runCli(["drift", "check", "--format", "json"], dir); - - expect(result.code).toBe(0); - const report = JSON.parse(result.stdout); - expect(report.trackedFingerprintId).toBe("tracked"); - expect(report.localFingerprintId).toBe("local"); - }); - - it("runDriftCheck resolves config tracks relative to the supplied cwd", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeFile( - join(dir, "ghost.config.js"), - "export default { tracks: 'tracked/.ghost' };\n", - ); - await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); - - const report = await runDriftCheck({ cwd: dir, config: "ghost.config.js" }); - - expect(report.trackedFingerprintId).toBe("tracked"); - expect(report.localFingerprintId).toBe("local"); - expect(report.overall.verdict).toBe("covered"); - }); - - it("drift check resolves tracked canonical package manifest paths", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); - - const result = await runCli( - [ - "drift", - "check", - "--tracked", - "tracked/.ghost/manifest.yml", - "--format", - "json", - ], - dir, - ); - - expect(result.code).toBe(0); - const report = JSON.parse(result.stdout); - expect(report.trackedFingerprintId).toBe("tracked"); - expect(report.localFingerprintId).toBe("local"); - }); - - it("drift check rejects tracked fingerprints that do not match the ledger", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeCheckPackage(join(dir, "other"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeFile( - join(dir, "other", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: other\n", - ); - await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); - - const result = await runCli( - ["drift", "check", "--tracked", "other/.ghost", "--format", "json"], - dir, - ); - - expect(result.code).toBe(2); - expect(result.stderr).toContain( - 'sync manifest tracks fingerprint "tracked" but resolved tracked fingerprint "other"', - ); - }); - - it("drift check reports uncovered canonical package changes", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeCheckPackage(join(dir, "tracked"), { checks: false }); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeFile( - join(dir, ".ghost", "intent.yml"), - `summary: - product: Cash iOS -situations: [] -principles: - - id: tokenized-ui-color - principle: Use celebratory spring motion and playful transitions throughout lending. -experience_contracts: [] -`, - ); - await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); - - const result = await runCli(["drift", "check", "--format", "json"], dir); - - expect(result.code).toBe(1); - const report = JSON.parse(result.stdout); - expect(report.overall.verdict).toBe("uncovered"); - expect(report.dimensions.decisions.verdict).toBe("uncovered"); - }); - - it("drift check reports digest-only canonical package changes", async () => { - const manyPrinciples = Array.from( - { length: 30 }, - (_, index) => ` - id: principle-${index + 1} - principle: Preserve durable product surface rule ${index + 1}. -`, - ).join(""); - const fingerprintRaw = `schema: ghost.fingerprint/v1 -intent: - summary: - product: Cash iOS - situations: [] - principles: -${manyPrinciples} experience_contracts: [] -inventory: - building_blocks: {} - exemplars: [] - sources: [] -composition: - patterns: [] -`; - await mkdir(join(dir, ".ghost"), { recursive: true }); - await mkdir(join(dir, "tracked", ".ghost"), { recursive: true }); - await writeSplitFingerprintPackage(join(dir, ".ghost"), fingerprintRaw); - await writeSplitFingerprintPackage( - join(dir, "tracked", ".ghost"), - fingerprintRaw, - ); - await writeFile( - join(dir, "tracked", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: tracked\n", - ); - await writeFile( - join(dir, ".ghost", "inventory.yml"), - `building_blocks: - notes: [digest-only-change] -exemplars: [] -sources: [] -`, - ); - await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); - - const result = await runCli(["drift", "check", "--format", "json"], dir); - - expect(result.code).toBe(1); - const report = JSON.parse(result.stdout); - expect(report.overall.verdict).toBe("uncovered"); - expect(report.dimensions.decisions.verdict).toBe("uncovered"); - }); - - it("exits with uncovered drift when current distance exceeds the stance ledger", async () => { - await mkdir(join(dir, ".ghost"), { recursive: true }); - await writeFile( - join(dir, ".ghost", "fingerprint.md"), - fingerprintWithId("local"), - ); - await writeFile( - join(dir, "tracked.fingerprint.md"), - fingerprintWithId("tracked"), - ); - await runCli(["track", "tracked.fingerprint.md"], dir); - await writeFile( - join(dir, ".ghost", "fingerprint.md"), - fingerprintWithId("local").replace( - "spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 }", - "spacing: { scale: [2, 3, 5, 7, 11, 13], baseUnit: 2, regularity: 0.1 }", - ), - ); - - const result = await runCli( - [ - "drift", - "check", - "--tracked", - "tracked.fingerprint.md", - "--format", - "json", - ], - dir, - ); - - expect(result.code).toBe(1); - const report = JSON.parse(result.stdout); - expect(report.schema).toBe("ghost.drift.check/v1"); - expect(report.overall.verdict).toBe("uncovered"); - expect(report.dimensions.spacing.verdict).toBe("uncovered"); - }); - it("initializes the default fingerprint package without cache", async () => { const init = await runCli(["init", "--format", "json"], dir); const scan = await runCli(["scan", "--format", "json"], dir); @@ -758,29 +220,25 @@ sources: [] expect(init.code).toBe(0); const initOutput = JSON.parse(init.stdout); - expect(Object.keys(initOutput).sort()).toEqual([ - "composition", - "dir", - "intent", - "inventory", - "manifest", - ]); + expect(Object.keys(initOutput).sort()).toEqual(["dir", "written"]); + // Node package: manifest + surfaces spine + a seed node, no facet files. + expect(initOutput.written).toContain("manifest.yml"); + expect(initOutput.written).toContain("surfaces.yml"); + expect(initOutput.written.some((p: string) => p.startsWith("nodes/"))).toBe( + true, + ); await expect( readFile(join(dir, ".ghost", "manifest.yml"), "utf-8"), ).resolves.toContain("schema: ghost.fingerprint-package/v1"); const status = JSON.parse(scan.stdout); expect(status.cache).toBeUndefined(); - const lint = await runCli(["lint"], dir); - const verify = await runCli(["verify", ".ghost", "--root", "."], dir); + const validate = await runCli(["validate"], dir); const review = await runCli(["review", "--diff", "change.patch"], dir); - const reviewCommand = await runCli(["emit", "review-command"], dir); - expect(lint.code).toBe(0); - expect(verify.code).toBe(0); + expect(validate.code).toBe(0); expect(review.code).toBe(0); expect(review.stdout).toContain("## Touched Surfaces"); - expect(reviewCommand.code).toBe(0); }); it("uses GHOST_PACKAGE_DIR as the default fingerprint package directory for init", async () => { @@ -866,8 +324,8 @@ sources: [] it("refuses to overwrite existing fingerprint files unless forced", async () => { await runCli(["init"], dir); await writeFile( - join(dir, ".ghost", "intent.yml"), - "summary:\n product: Curated Surface\n", + join(dir, ".ghost", "nodes", "core-voice.md"), + "---\nid: core-voice\nunder: core\n---\n\nCurated Surface voice.\n", ); const refused = await runCli(["init"], dir); @@ -877,29 +335,29 @@ sources: [] "Refusing to overwrite existing Ghost fingerprint file(s)", ); await expect( - readFile(join(dir, ".ghost", "intent.yml"), "utf-8"), + readFile(join(dir, ".ghost", "nodes", "core-voice.md"), "utf-8"), ).resolves.toContain("Curated Surface"); const forced = await runCli(["init", "--force"], dir); expect(forced.code).toBe(0); await expect( - readFile(join(dir, ".ghost", "intent.yml"), "utf-8"), - ).resolves.toContain("summary: {}"); + readFile(join(dir, ".ghost", "nodes", "core-voice.md"), "utf-8"), + ).resolves.toContain("intent / inventory / composition"); }); it("does not guess arbitrary YAML files are validate.yml", async () => { await writeFile(join(dir, "workflow.yml"), "name: ci\non: push\n"); const lint = await runCli( - ["lint", "workflow.yml", "--format", "json"], + ["validate", "workflow.yml", "--format", "json"], dir, ); expect(lint.code).toBe(1); expect(JSON.parse(lint.stdout).issues[0]).toMatchObject({ severity: "error", - rule: "unsupported-yaml", + rule: "unsupported-artifact", }); }); @@ -910,7 +368,7 @@ sources: [] ); const lint = await runCli( - ["lint", "package-anchor.yml", "--format", "json"], + ["validate", "package-anchor.yml", "--format", "json"], dir, ); @@ -924,10 +382,9 @@ sources: [] const scanHuman = await runCli(["scan"], dir); expect(init.code).toBe(0); - expect(init.stdout).toContain("manifest.yml:"); - expect(init.stdout).toContain("intent.yml:"); - expect(init.stdout).toContain("inventory.yml:"); - expect(init.stdout).toContain("composition.yml:"); + expect(init.stdout).toContain("manifest.yml"); + expect(init.stdout).toContain("surfaces.yml"); + expect(init.stdout).toContain("nodes/"); expect(init.stdout).not.toContain("cache/:"); expect(init.stdout).not.toContain("memory/intent.md:"); expect( @@ -938,21 +395,15 @@ sources: [] expect(status.fingerprint.state).toBe("present"); expect(status.proposals).toBeUndefined(); expect(status.cache).toBeUndefined(); - expect(status.intent).toBeUndefined(); expect(status.readiness).toBeUndefined(); expect(status.checks).toBeUndefined(); - expect(status.contribution.state).toBe("empty"); - expect(status.contribution.contributing_facets).toEqual([]); - expect(status.contribution.empty_facets).toEqual([ - "intent", - "inventory", - "composition", - ]); + // The default template seeds one core node, so the package contributes. + expect(status.contribution.state).toBe("contributing"); + expect(status.contribution.node_count).toBe(1); expect(scanHuman.stdout).toContain("package dir:"); - expect(scanHuman.stdout).toContain("contribution: empty"); - expect(scanHuman.stdout).toContain("intent: empty (0)"); + expect(scanHuman.stdout).toContain("contribution: contributing"); + expect(scanHuman.stdout).toContain("nodes: 1"); expect(scanHuman.stdout).not.toContain("readiness:"); - expect(scanHuman.stdout).not.toContain("missing facets:"); expect(scanHuman.stdout).not.toContain("memory dir:"); }); @@ -962,152 +413,38 @@ sources: [] ); }); - it("initializes a blank product scaffold with reference inventory wiring", async () => { - const init = await runCli( - ["init", "--reference", "packages/ghost-ui/.ghost", "--format", "json"], - dir, - ); - const scan = await runCli(["scan", "--format", "json"], dir); - const signals = await runCli(["signals"], dir); - await mkdir(join(dir, "packages", "ghost-ui", ".ghost"), { - recursive: true, - }); - await mkdir(join(dir, "packages", "ghost-ui", "public", "r"), { - recursive: true, - }); - await writeFile( - join(dir, "packages", "ghost-ui", ".ghost", "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: ghost-ui\n", - ); - await writeFile( - join(dir, "packages", "ghost-ui", "public", "r", "registry.json"), - "{}\n", - ); - const verify = await runCli(["verify", ".ghost", "--root", "."], dir); - - expect(init.code).toBe(0); - const initOutput = JSON.parse(init.stdout); - expect(initOutput.cache).toBeUndefined(); - - const fingerprint = parseYaml( - await readFile(join(dir, ".ghost", "inventory.yml"), "utf-8"), - ) as Record; - expect(fingerprint).not.toHaveProperty("implementation_vocabulary"); - expect(fingerprint).not.toHaveProperty("patterns"); - expect(fingerprint).toMatchObject({ - building_blocks: { - libraries: ["ghost-ui"], - }, - sources: [ - { - id: "ghost-ui", - kind: "registry", - ref: "registry:packages/ghost-ui/public/r/registry.json", - }, - ], - }); + it("rejects the removed --reference init flag", async () => { await expect( - readFile(join(dir, ".ghost", "config.yml"), "utf-8"), - ).rejects.toThrow(); + runCli(["init", "--reference", "packages/ghost-ui/.ghost"], dir), + ).rejects.toThrow("Unknown option `--reference`"); + }); - const status = JSON.parse(scan.stdout); - expect(status.config).toBeUndefined(); - expect(status.contribution.state).toBe("contributing"); - expect(status.contribution.contributing_facets).toEqual(["inventory"]); - expect(status.contribution.absent_facets).toEqual([]); - expect(status.contribution.empty_facets).toEqual(["intent", "composition"]); + it("init --force gathers cleanly on the scaffolded node package", async () => { + const init = await runCli(["init", "--format", "json"], dir); + expect(init.code).toBe(0); + const lint = await runCli(["validate"], dir); + expect(lint.code).toBe(0); - const signalsOutput = JSON.parse(signals.stdout); - expect(signalsOutput.config).toBeUndefined(); - expect(verify.code).toBe(0); + // The seed node lives at core, so it cascades to a gather of any surface. + const gather = await runCli(["gather", "core", "--format", "json"], dir); + expect(gather.code).toBe(0); + const slice = JSON.parse(gather.stdout); + expect(slice.nodes.some((n: { id: string }) => n.id === "core-voice")).toBe( + true, + ); }); - it("runs signals, lint, and verify from the unified cli", async () => { + it("runs signals and validate from the unified cli", async () => { await writeCheckPackage(dir); const signals = await runCli(["signals"], dir); - const lint = await runCli(["lint"], dir); - const verify = await runCli(["verify", ".ghost", "--root", "."], dir); + const validate = await runCli(["validate"], dir); expect(signals.code).toBe(0); expect(await realpath(JSON.parse(signals.stdout).root)).toBe( await realpath(dir), ); - expect(lint.code).toBe(0); - expect(lint.stdout).toContain("0 error"); - expect(verify.code).toBe(0); - expect(verify.stdout).toContain("0 error"); - }); - - it("lints, verifies, and scans the Ghost UI reference bundle", async () => { - const lint = await runCli(["lint", "packages/ghost-ui/.ghost"], REPO_ROOT); - const verify = await runCli( - ["verify", "packages/ghost-ui/.ghost", "--root", "packages/ghost-ui"], - REPO_ROOT, - ); - const scan = await runCli( - ["scan", "packages/ghost-ui/.ghost", "--format", "json"], - REPO_ROOT, - ); - - expect(lint.code).toBe(0); - expect(verify.code).toBe(0); - expect(scan.code).toBe(0); - const status = JSON.parse(scan.stdout); - expect(status.fingerprint.state).toBe("present"); - expect(status.proposals).toBeUndefined(); - expect(status.cache).toBeUndefined(); - expect(status.readiness).toBeUndefined(); - expect(status.checks).toBeUndefined(); - expect(status.contribution.state).toBe("contributing"); - expect(status.contribution.contributing_facets).toEqual([ - "intent", - "inventory", - ]); - expect(status.contribution.empty_facets).toEqual([]); - expect(status.contribution.absent_facets).toEqual(["composition"]); - expect(status.contribution.reasons[0]).toContain( - "Absent facets may be inherited", - ); - }); - - it("emits review commands from the unified cli", async () => { - await writeCheckPackage(dir); - await writeFile( - join(dir, ".ghost", "fingerprint.md"), - fingerprintWithId("local"), - ); - - const reviewCommand = await runCli(["emit", "review-command"], dir); - - expect(reviewCommand.code).toBe(0); - expect(reviewCommand.stdout).toContain("design-review.md"); - const emittedReviewCommand = await readFile( - join(dir, ".claude", "commands", "design-review.md"), - "utf-8", - ); - expect(emittedReviewCommand).toContain( - ".ghost/intent.yml`, `.ghost/inventory.yml`, and `.ghost/composition.yml", - ); - expect(emittedReviewCommand).toContain("Exemplars"); - expect(emittedReviewCommand).toContain("lending-tokenized-screen"); - expect(emittedReviewCommand).toContain("provisional and non-Ghost-backed"); - expect(emittedReviewCommand).not.toContain("Proposal Threshold"); - expect(emittedReviewCommand).not.toContain("recommend-proposal"); - expect(emittedReviewCommand).toContain("experience-gap"); - expect(emittedReviewCommand).not.toContain( - "deprecated legacy direct-markdown", - ); - }); - - it("rejects removed context-bundle emit kind", async () => { - await writeCheckPackage(dir); - - const contextBundle = await runCli(["emit", "context-bundle"], dir); - - expect(contextBundle.code).toBe(2); - expect(contextBundle.stderr).toContain( - "unknown emit kind 'context-bundle'", - ); + expect(validate.code).toBe(0); + expect(validate.stdout).toContain("0 error"); }); // Phase 3: asserts path/scope/surface_type selection reasons (dormant Job 2, @@ -1212,44 +549,6 @@ sources: [] expect(json.brief).toContain("## Context Hits"); }); - it("warns when fingerprint exemplar paths are unreachable", async () => { - await writeCheckPackage(dir); - - const verify = await runCli( - ["verify", ".ghost", "--root", ".", "--format", "json"], - dir, - ); - - expect(verify.code).toBe(0); - const report = JSON.parse(verify.stdout); - expect(report.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - rule: "fingerprint-exemplar-unreachable", - path: "inventory.yml.exemplars[0].path", - }), - ]), - ); - }); - - it("rejects removed legacy direct markdown emit flags", () => { - const cli = buildCli(); - - expect(() => - cli.parse([ - "node", - "ghost", - "emit", - "review-command", - "--fingerprint", - "legacy.fingerprint.md", - "--stdout", - ]), - ).toThrow("Unknown option `--fingerprint`"); - expect(() => - cli.parse(["node", "ghost", "emit", "context-bundle", "--no-tokens"]), - ).toThrow("Unknown option `--tokens`"); - }); it("installs the unified ghost skill bundle", async () => { const result = await runCli( ["skill", "install", "--dest", "skills/ghost"], @@ -1324,7 +623,7 @@ sources: [] expect(result.stdout).toContain( "grounding ref (why / what) or local-evidence rationale when the surface is silent", ); - expect(result.stdout).toContain("Use the surface grounding first"); + expect(result.stdout).toContain("Read the grounded nodes"); expect(result.stdout).toContain("routed check when blocking"); expect(result.stdout).not.toContain("Proposal Threshold"); expect(result.stdout).toContain("provisional and non-Ghost-backed"); @@ -1513,20 +812,106 @@ composition: const slice = JSON.parse(result.stdout); expect(slice.surface).toBe("email-marketing"); const byId = Object.fromEntries( - slice.principles.map( - (entry: { node: { id: string }; provenance: unknown }) => [ - entry.node.id, - entry.provenance, - ], - ), + slice.nodes.map((node: { id: string; provenance: unknown }) => [ + node.id, + node.provenance, + ]), ); - expect(byId["brand-voice"]).toEqual({ kind: "ancestor", surface: "core" }); + // Graph slice (Option A, prose nodes): own + cascaded ancestors. + expect(byId["brand-voice"]).toEqual({ kind: "ancestor", from: "core" }); expect(byId["marketing-urgency"]).toEqual({ kind: "own" }); - expect(byId["checkout-clarity"]).toEqual({ - kind: "edge", - edge: "composes", - surface: "checkout", - }); + // Phase 3 decision: edge contributions come from node `relates`, not from + // legacy `composes` surface edges. checkout-clarity sits on a sibling + // surface with no `relates` link in, so it is no longer pulled in. + expect(byId["checkout-clarity"]).toBeUndefined(); + }); + + it("filters the gather slice by incarnation via --as", async () => { + await writeIncarnationPackage(dir); + + const web = await runCli( + [ + "gather", + "launch", + "--as", + "web", + "--package", + ".ghost", + "--format", + "json", + ], + dir, + ); + expect(web.code).toBe(0); + const slice = JSON.parse(web.stdout); + expect(slice.incarnation).toBe("web"); + const ids = slice.nodes.map((n: { id: string }) => n.id).sort(); + // essence (untagged) + matching web; the email node is filtered out. + expect(ids).toContain("launch"); + expect(ids).toContain("launch-web"); + expect(ids).not.toContain("launch-email"); + }); + + it("inherits nodes from an extended package via extends", async () => { + // Brand contract. + await mkdir(join(dir, "brand", "nodes"), { recursive: true }); + await writeFile( + join(dir, "brand", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: brand\n", + ); + await writeFile( + join(dir, "brand", "nodes", "core-trust.md"), + "---\nid: core-trust\nunder: core\n---\n\nReduce felt risk.\n", + ); + // Product contract extends the brand. + await mkdir(join(dir, "product", "nodes"), { recursive: true }); + await writeFile( + join(dir, "product", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: acme-checkout\nextends:\n brand: ../brand\n", + ); + await writeFile( + join(dir, "product", "surfaces.yml"), + "schema: ghost.surfaces/v1\nsurfaces:\n - id: checkout\n parent: core\n", + ); + await writeFile( + join(dir, "product", "nodes", "checkout-trust.md"), + "---\nid: checkout-trust\nunder: checkout\nrelates:\n - to: brand:core-trust\n as: reinforces\n---\n\nReassure at payment.\n", + ); + + const validate = await runCli( + ["validate", "product", "--format", "json"], + dir, + ); + expect(validate.code).toBe(0); + + const gather = await runCli( + ["gather", "checkout", "--package", "product", "--format", "json"], + dir, + ); + expect(gather.code).toBe(0); + const slice = JSON.parse(gather.stdout); + const inherited = slice.nodes.find( + (n: { id: string }) => n.id === "brand:core-trust", + ); + // The cross-package relation pulled the inherited brand node into the slice. + expect(inherited).toBeDefined(); + expect(inherited.body).toContain("Reduce felt risk"); + }); + + it("fails validate when a cross-package ref is not in extends", async () => { + await mkdir(join(dir, "nodes"), { recursive: true }); + await writeFile( + join(dir, "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: solo\n", + ); + await writeFile( + join(dir, "nodes", "n.md"), + "---\nid: n\nunder: core\nrelates:\n - to: brand:core-trust\n---\n\nBody.\n", + ); + + const validate = await runCli(["validate", "."], dir); + expect(validate.code).toBe(1); + expect(validate.stdout).toContain("brand:core-trust"); }); it("returns the surface menu when no surface is named", async () => { @@ -1600,7 +985,7 @@ experience_contracts: [] ]); // The migrated package must lint clean and gather correctly. - const lint = await runCli(["lint", ".ghost/surfaces.yml"], dir, { + const lint = await runCli(["validate", ".ghost/surfaces.yml"], dir, { allowNoExit: true, }); expect(lint.stdout).toContain("0 error(s)"); @@ -1611,9 +996,8 @@ experience_contracts: [] ); const slice = JSON.parse(gather.stdout); expect( - slice.principles.find( - (entry: { node: { id: string } }) => entry.node.id === "scoped", - )?.provenance, + slice.nodes.find((node: { id: string }) => node.id === "scoped") + ?.provenance, ).toEqual({ kind: "own" }); }); @@ -1682,6 +1066,7 @@ surfaces: it("grounds routed checks in the fingerprint slice", async () => { const ghost = join(dir, ".ghost"); await mkdir(join(ghost, "checks"), { recursive: true }); + await mkdir(join(ghost, "nodes"), { recursive: true }); await writeFile( join(ghost, "manifest.yml"), "schema: ghost.fingerprint-package/v1\nid: c4\n", @@ -1691,15 +1076,12 @@ surfaces: "schema: ghost.surfaces/v1\nsurfaces:\n - id: checkout\n parent: core\n", ); await writeFile( - join(ghost, "intent.yml"), - `principles: - - id: brand-voice - principle: Warm everywhere. - surface: core - - id: checkout-clarity - principle: Checkout copy is plain. - surface: checkout -`, + join(ghost, "nodes", "brand-voice.md"), + "---\nid: brand-voice\nunder: core\n---\n\nWarm everywhere.\n", + ); + await writeFile( + join(ghost, "nodes", "checkout-clarity.md"), + "---\nid: checkout-clarity\nunder: checkout\n---\n\nCheckout copy is plain.\n", ); await writeFile( join(ghost, "checks", "checkout.md"), @@ -1724,9 +1106,14 @@ surfaces: const checkout = payload.grounding.find( (g: { surface: string }) => g.surface === "checkout", ); - const whyRefs = checkout.why.map((i: { ref: string }) => i.ref); - expect(whyRefs).toContain("intent.principle:checkout-clarity"); // own - expect(whyRefs).toContain("intent.principle:brand-voice"); // inherited from core + // Grounding is the gather slice: prose nodes by provenance (Phase 4). + const ids = checkout.nodes.map((n: { id: string }) => n.id); + expect(ids).toContain("checkout-clarity"); // own + expect(ids).toContain("brand-voice"); // inherited from core + const own = checkout.nodes.find( + (n: { id: string }) => n.id === "checkout-clarity", + ); + expect(own.provenance).toEqual({ kind: "own" }); }); it("omits grounding with --no-grounding", async () => { @@ -1761,9 +1148,39 @@ surfaces: }); }); +async function writeIncarnationPackage(dir: string): Promise { + const ghost = join(dir, ".ghost"); + await mkdir(join(ghost, "nodes"), { recursive: true }); + await writeFile( + join(ghost, "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: incarnation-demo\n", + ); + await writeFile( + join(ghost, "surfaces.yml"), + `schema: ghost.surfaces/v1 +surfaces: + - id: launch + description: Launch announcement. + parent: core +`, + ); + await writeFile( + join(ghost, "nodes", "launch.md"), + "---\nid: launch\nunder: core\n---\n\nOne idea, stated with confidence.\n", + ); + await writeFile( + join(ghost, "nodes", "launch-web.md"), + "---\nid: launch-web\nunder: launch\nincarnation: web\n---\n\nHero with one CTA.\n", + ); + await writeFile( + join(ghost, "nodes", "launch-email.md"), + "---\nid: launch-email\nunder: launch\nincarnation: email\n---\n\nSubject is the headline.\n", + ); +} + async function writeGatherPackage(dir: string): Promise { const ghost = join(dir, ".ghost"); - await mkdir(ghost, { recursive: true }); + await mkdir(join(ghost, "nodes"), { recursive: true }); await writeFile( join(ghost, "manifest.yml"), "schema: ghost.fingerprint-package/v1\nid: gather-demo\n", @@ -1778,27 +1195,22 @@ surfaces: - id: email-marketing description: Marketing email. parent: email - edges: - - kind: composes - to: checkout - id: checkout description: Checkout. parent: core `, ); await writeFile( - join(ghost, "intent.yml"), - `principles: - - id: brand-voice - principle: Warm and concise. - surface: core - - id: marketing-urgency - principle: Marketing may use urgency. - surface: email-marketing - - id: checkout-clarity - principle: Checkout copy is plain. - surface: checkout -`, + join(ghost, "nodes", "brand-voice.md"), + "---\nid: brand-voice\nunder: core\n---\n\nWarm and concise.\n", + ); + await writeFile( + join(ghost, "nodes", "marketing-urgency.md"), + "---\nid: marketing-urgency\nunder: email-marketing\n---\n\nMarketing may use urgency.\n", + ); + await writeFile( + join(ghost, "nodes", "checkout-clarity.md"), + "---\nid: checkout-clarity\nunder: checkout\n---\n\nCheckout copy is plain.\n", ); } @@ -2009,76 +1421,45 @@ units: ); } -async function writeCoveredSyncManifest( - dir: string, - options: { tracked: string }, -): Promise { - const ackedAt = "2026-06-16T00:00:00.000Z"; - await writeFile( - join(dir, ".ghost-sync.json"), - JSON.stringify( - { - tracks: { type: "path", value: options.tracked }, - ackedAt, - trackedFingerprintId: "tracked", - localFingerprintId: "local", - overallDistance: 0, - dimensions: { - spacing: { distance: 0, stance: "accepted", ackedAt }, - palette: { distance: 0, stance: "accepted", ackedAt }, - typography: { distance: 0, stance: "accepted", ackedAt }, - surfaces: { distance: 0, stance: "accepted", ackedAt }, - decisions: { distance: 0, stance: "accepted", ackedAt }, - }, - }, - null, - 2, - ), - ); -} - async function writeSplitFingerprintPackage( pkg: string, fingerprintRaw: string, checksRaw?: string, ): Promise { + // Node package: derive prose nodes from the legacy facet doc's + // principles/patterns so check-routing/grounding fixtures keep working. const packageDir = pkg; - const doc = parseYaml(fingerprintRaw) as Record; - await mkdir(packageDir, { recursive: true }); - await Promise.all([ + const doc = parseYaml(fingerprintRaw) as { + intent?: { principles?: Array<{ id: string; principle?: string }> }; + composition?: { patterns?: Array<{ id: string; pattern?: string }> }; + }; + await mkdir(join(packageDir, "nodes"), { recursive: true }); + const writes: Array> = [ 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: [], - }, + ]; + for (const p of doc.intent?.principles ?? []) { + writes.push( + writeFile( + join(packageDir, "nodes", `${p.id}.md`), + `---\nid: ${p.id}\nunder: core\n---\n\n${p.principle ?? p.id}\n`, ), - ), - writeFile( - join(packageDir, "inventory.yml"), - stringifyYaml( - doc.inventory ?? { - building_blocks: {}, - exemplars: [], - sources: [], - }, + ); + } + for (const p of doc.composition?.patterns ?? []) { + writes.push( + writeFile( + join(packageDir, "nodes", `${p.id}.md`), + `---\nid: ${p.id}\nunder: core\n---\n\n${p.pattern ?? p.id}\n`, ), - ), - writeFile( - join(packageDir, "composition.yml"), - stringifyYaml(doc.composition ?? { patterns: [] }), - ), - ...(checksRaw - ? [writeFile(join(packageDir, "validate.yml"), checksRaw)] - : []), - ]); + ); + } + if (checksRaw) { + writes.push(writeFile(join(packageDir, "validate.yml"), checksRaw)); + } + await Promise.all(writes); } function _checksFileWithDerivation(intentRef: string): string { @@ -2115,78 +1496,6 @@ index 1111111..2222222 100644 `; } -async function writeComparableBundle( - pkg: string, - patternId: string, -): Promise { - await mkdir(pkg, { recursive: true }); - await writeSplitFingerprintPackage( - pkg, - `schema: ghost.fingerprint/v1 -intent: - summary: - product: ${patternId} -inventory: - building_blocks: - tokens: [${patternId}-token] -composition: - patterns: - - id: ${patternId} - kind: layout - pattern: ${patternId} uses a settings-oriented layout. -`, - ); - await writeFile( - join(pkg, "survey.json"), - JSON.stringify({ - schema: "ghost.survey/v1", - sources: [ - { id: patternId, target: ".", scanned_at: "2026-05-10T00:00:00Z" }, - ], - values: [ - { - id: `value_${patternId}`, - source: { target: ".", scanned_at: "2026-05-10T00:00:00Z" }, - kind: "spacing", - value: "8px", - raw: "p-2", - occurrences: 4, - files_count: 2, - }, - ], - tokens: [], - components: [], - ui_surfaces: [ - { - id: `surface_${patternId}`, - source: { target: ".", scanned_at: "2026-05-10T00:00:00Z" }, - name: patternId, - kind: "route", - locator: `/${patternId}`, - renderability: "source-only", - files: [`src/${patternId}.tsx`], - classification: { surface_type: "settings" }, - signals: { layout_patterns: [patternId] }, - }, - ], - }), - ); - await writeFile( - join(pkg, "patterns.yml"), - `schema: ghost.patterns/v1 -id: ${patternId} -surface_types: - - id: settings - preferred_patterns: [${patternId}] -composition_patterns: - - id: ${patternId} - surface_types: [settings] - evidence: - - locator: /${patternId} -`, - ); -} - function lendingPatch(line: string): string { return `diff --git a/Code/Features/Lending/View.swift b/Code/Features/Lending/View.swift --- a/Code/Features/Lending/View.swift diff --git a/packages/ghost/test/compare.test.ts b/packages/ghost/test/compare.test.ts deleted file mode 100644 index 54599b26..00000000 --- a/packages/ghost/test/compare.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Fingerprint } from "#ghost-core"; -import { compare } from "../src/core/compare.js"; - -const BASE: Fingerprint = { - id: "base", - source: "llm", - timestamp: "2026-04-17T00:00:00.000Z", - palette: { - dominant: [{ role: "accent", value: "#c96442" }], - neutrals: { steps: ["#141413", "#4d4c48"], count: 2 }, - semantic: [{ role: "error", value: "#b53333" }], - saturationProfile: "muted", - contrast: "moderate", - }, - spacing: { scale: [4, 8, 16], baseUnit: 8, regularity: 0.85 }, - typography: { - families: ["Serif"], - sizeRamp: [14, 16, 20], - weightDistribution: { 400: 1 }, - lineHeightPattern: "loose", - }, - surfaces: { - borderRadii: [8, 12], - shadowComplexity: "subtle", - borderUsage: "moderate", - }, - embedding: [0.1, 0.2], -}; - -function variant( - id: string, - overrides: Partial = {}, -): Fingerprint { - return { ...structuredClone(BASE), id, ...overrides }; -} - -describe("compare dispatch", () => { - it("throws when given fewer than 2 fingerprints", () => { - expect(() => compare([variant("a")])).toThrow(/at least 2/); - expect(() => compare([])).toThrow(/at least 2/); - }); - - it("pairwise by default for N=2", () => { - const result = compare([variant("a"), variant("b")]); - expect(result.mode).toBe("pairwise"); - if (result.mode !== "pairwise") throw new Error("unreachable"); - expect(result.comparison.distance).toBeGreaterThanOrEqual(0); - expect(result.semantic).toBeUndefined(); - expect(result.temporal).toBeUndefined(); - }); - - it("adds a semantic diff when opts.semantic is set (N=2)", () => { - const result = compare([variant("a"), variant("b")], { semantic: true }); - expect(result.mode).toBe("pairwise"); - if (result.mode !== "pairwise") throw new Error("unreachable"); - expect(result.semantic).toBeDefined(); - expect(result.semantic?.unchanged).toBeDefined(); - }); - - it("adds a temporal block when history is passed (N=2)", () => { - const result = compare([variant("a"), variant("b")], { - history: [], - manifest: null, - }); - expect(result.mode).toBe("pairwise"); - if (result.mode !== "pairwise") throw new Error("unreachable"); - expect(result.temporal).toBeDefined(); - expect(result.temporal?.trajectory).toBeDefined(); - }); - - it("composite mode when N≥3", () => { - const result = compare([variant("a"), variant("b"), variant("c")]); - expect(result.mode).toBe("composite"); - if (result.mode !== "composite") throw new Error("unreachable"); - expect(result.composite.members).toHaveLength(3); - expect(result.composite.pairwise).toHaveLength(3); - }); - - it("composite rejects --semantic / --temporal", () => { - const exprs = [variant("a"), variant("b"), variant("c")]; - expect(() => compare(exprs, { semantic: true })).toThrow(/pairwise/); - expect(() => compare(exprs, { history: [] })).toThrow(/pairwise/); - }); - - it("composite uses provided ids, falls back to fingerprint.id", () => { - const result = compare([variant("a"), variant("b"), variant("c")], { - ids: ["alpha", "beta", "gamma"], - }); - if (result.mode !== "composite") throw new Error("unreachable"); - expect(result.composite.members.map((m) => m.id)).toEqual([ - "alpha", - "beta", - "gamma", - ]); - }); -}); diff --git a/packages/ghost/test/embedding/colors.test.ts b/packages/ghost/test/embedding/colors.test.ts deleted file mode 100644 index 4afcec11..00000000 --- a/packages/ghost/test/embedding/colors.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SemanticColor } from "#ghost-core"; -import { - classifyContrast, - classifySaturation, - contrastScore, - parseColorToOklch, - saturationScore, -} from "#ghost-core"; - -describe("parseColorToOklch", () => { - // --- Hex --- - it("parses 6-digit hex", () => { - const result = parseColorToOklch("#ff0000"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeCloseTo(0.628, 1); // L - expect(result?.[1]).toBeGreaterThan(0.2); // C (red is saturated) - }); - - it("parses 3-digit hex", () => { - const result = parseColorToOklch("#fff"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeCloseTo(1, 1); // L ~1 for white - expect(result?.[1]).toBeCloseTo(0, 1); // C ~0 for white - }); - - // --- RGB --- - it("parses rgb()", () => { - const result = parseColorToOklch("rgb(0, 128, 0)"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeGreaterThan(0.4); - expect(result?.[1]).toBeGreaterThan(0.1); - }); - - it("parses rgba()", () => { - const result = parseColorToOklch("rgba(255, 0, 0, 0.5)"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeCloseTo(0.628, 1); - }); - - // --- HSL --- - it("parses hsl()", () => { - const result = parseColorToOklch("hsl(0, 100%, 50%)"); - expect(result).not.toBeNull(); - // Pure red: same as #ff0000 - expect(result?.[0]).toBeCloseTo(0.628, 1); - expect(result?.[1]).toBeGreaterThan(0.2); - }); - - it("parses hsla()", () => { - const result = parseColorToOklch("hsla(120, 100%, 25%, 0.8)"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeGreaterThan(0.3); - }); - - it("parses hsl with deg unit", () => { - const result = parseColorToOklch("hsl(240deg, 50%, 50%)"); - expect(result).not.toBeNull(); - }); - - it("parses hsl with modern space syntax", () => { - const result = parseColorToOklch("hsl(200 80% 50%)"); - expect(result).not.toBeNull(); - }); - - // --- OKLCH --- - it("parses oklch() with decimal lightness", () => { - const result = parseColorToOklch("oklch(0.7 0.15 240)"); - expect(result).toEqual([0.7, 0.15, 240]); - }); - - it("parses oklch() with percentage lightness", () => { - const result = parseColorToOklch("oklch(70% 0.15 240)"); - expect(result).toEqual([0.7, 0.15, 240]); - }); - - // --- color-mix --- - it("parses color-mix(in oklch, ...)", () => { - const result = parseColorToOklch( - "color-mix(in oklch, #ff0000 50%, #0000ff 50%)", - ); - expect(result).not.toBeNull(); - // Midpoint between red and blue in OKLCH - expect(result?.[0]).toBeGreaterThan(0.2); - expect(result?.[1]).toBeGreaterThan(0.1); - }); - - it("parses color-mix with implicit second percentage", () => { - const result = parseColorToOklch( - "color-mix(in oklch, #ff0000 75%, #0000ff)", - ); - expect(result).not.toBeNull(); - }); - - // --- Named colors --- - it("parses named color: white", () => { - const result = parseColorToOklch("white"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeCloseTo(1, 1); - }); - - it("parses named color: black", () => { - const result = parseColorToOklch("black"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeCloseTo(0, 1); - }); - - it("parses named color: coral", () => { - const result = parseColorToOklch("coral"); - expect(result).not.toBeNull(); - expect(result?.[1]).toBeGreaterThan(0.1); - }); - - // --- System colors --- - it("parses system color: Canvas", () => { - const result = parseColorToOklch("Canvas"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeCloseTo(1, 1); // maps to white - }); - - it("parses system color: CanvasText", () => { - const result = parseColorToOklch("CanvasText"); - expect(result).not.toBeNull(); - expect(result?.[0]).toBeCloseTo(0, 1); // maps to black - }); - - // --- Skipped values --- - it("returns null for var()", () => { - expect(parseColorToOklch("var(--primary)")).toBeNull(); - }); - - it("returns null for transparent", () => { - expect(parseColorToOklch("transparent")).toBeNull(); - }); - - it("returns null for currentColor", () => { - expect(parseColorToOklch("currentColor")).toBeNull(); - }); - - it("returns null for garbage input", () => { - expect(parseColorToOklch("not-a-color")).toBeNull(); - expect(parseColorToOklch("")).toBeNull(); - expect(parseColorToOklch("123px")).toBeNull(); - }); - - // --- Case insensitivity --- - it("handles uppercase hex", () => { - expect(parseColorToOklch("#FF0000")).not.toBeNull(); - }); - - it("handles mixed case named colors", () => { - expect(parseColorToOklch("White")).not.toBeNull(); - expect(parseColorToOklch("BLACK")).not.toBeNull(); - }); -}); - -describe("continuous scoring", () => { - function makeColors(chromas: number[]): SemanticColor[] { - return chromas.map((c, i) => ({ - role: `color-${i}`, - value: "", - oklch: [0.5, c, 180] as [number, number, number], - })); - } - - describe("saturationScore", () => { - it("returns 0 for no colors", () => { - expect(saturationScore([])).toBe(0); - }); - - it("returns continuous values", () => { - const low = saturationScore(makeColors([0.03, 0.04])); - const mid = saturationScore(makeColors([0.1, 0.12])); - const high = saturationScore(makeColors([0.2, 0.25])); - expect(low).toBeLessThan(mid); - expect(mid).toBeLessThan(high); - }); - - it("caps at 1.0", () => { - expect(saturationScore(makeColors([0.3, 0.35]))).toBeLessThanOrEqual(1); - }); - }); - - describe("contrastScore", () => { - function makeContrast(lightnesses: number[]): SemanticColor[] { - return lightnesses.map((l, i) => ({ - role: `color-${i}`, - value: "", - oklch: [l, 0.1, 180] as [number, number, number], - })); - } - - it("returns 0.5 for single color", () => { - expect(contrastScore(makeContrast([0.5]))).toBe(0.5); - }); - - it("returns continuous values", () => { - const low = contrastScore(makeContrast([0.4, 0.5])); - const mid = contrastScore(makeContrast([0.2, 0.7])); - const high = contrastScore(makeContrast([0.05, 0.95])); - expect(low).toBeLessThan(mid); - expect(mid).toBeLessThan(high); - }); - }); - - // Verify categorical functions still work (API stability) - describe("categorical classification (API stability)", () => { - it("classifySaturation returns valid categories", () => { - const result = classifySaturation(makeColors([0.2, 0.25])); - expect(["muted", "vibrant", "mixed"]).toContain(result); - }); - - it("classifyContrast returns valid categories", () => { - const colors: SemanticColor[] = [ - { role: "a", value: "", oklch: [0.1, 0.1, 0] }, - { role: "b", value: "", oklch: [0.9, 0.1, 0] }, - ]; - const result = classifyContrast(colors); - expect(["high", "moderate", "low"]).toContain(result); - }); - }); -}); diff --git a/packages/ghost/test/embedding/compare-decisions.test.ts b/packages/ghost/test/embedding/compare-decisions.test.ts deleted file mode 100644 index ccab67ec..00000000 --- a/packages/ghost/test/embedding/compare-decisions.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DesignDecision, Fingerprint } from "#ghost-core"; -import { compareFingerprints } from "#ghost-core"; - -/** - * Minimal fingerprint skeleton — identical palette/spacing/typography/surfaces - * across fixtures so only the decisions layer affects the compare output. - */ -function baseFingerprint( - id: string, - decisions: DesignDecision[] = [], -): Fingerprint { - return { - id, - source: "llm", - timestamp: "2026-04-16T00:00:00.000Z", - decisions, - palette: { - dominant: [{ role: "primary", value: "#000", oklch: [0, 0, 0] }], - neutrals: { steps: ["#fff", "#000"], count: 2 }, - semantic: [{ role: "text", value: "#000", oklch: [0, 0, 0] }], - saturationProfile: "muted", - contrast: "high", - }, - spacing: { scale: [4, 8, 16], regularity: 1, baseUnit: 4 }, - typography: { - families: ["Inter"], - sizeRamp: [14, 16, 24], - weightDistribution: { 400: 1 }, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: [4, 8], - shadowComplexity: "deliberate-none", - borderUsage: "minimal", - }, - }; -} - -/** Make a unit vector biased toward a named "concept" index. Used to simulate - * that paraphrased decisions produce embeddings close in cosine space. */ -function conceptVector(concept: number, dims = 8, jitter = 0): number[] { - const v = new Array(dims).fill(0.1); - v[concept] = 1 + jitter; - const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)); - return v.map((x) => x / norm); -} - -describe("compareFingerprints — decisions", () => { - it("paraphrased decisions match when embeddings are close", () => { - // Two fingerprints describe the same design decision with different words, - // but the embeddings (simulated here) cluster near the same concept index. - const a = baseFingerprint("a", [ - { - dimension: "color-strategy", - decision: "Achromatic chrome with chromatic accents", - evidence: ["--color-accent: oklch(0.55 0.2 250)"], - embedding: conceptVector(0, 8, 0.01), - }, - { - dimension: "spatial-system", - decision: "Strict 4px base grid", - evidence: ["--space-1: 4px"], - embedding: conceptVector(1, 8, 0.01), - }, - ]); - - const b = baseFingerprint("b", [ - { - dimension: "color-usage", - decision: "Neutral UI, saturated accents only", - evidence: ["accent-color: #3b82f6"], - embedding: conceptVector(0, 8, -0.01), - }, - { - dimension: "spacing-scale", - decision: "Four-pixel spacing atom", - evidence: ["spacing.1 = 4"], - embedding: conceptVector(1, 8, -0.01), - }, - ]); - - const result = compareFingerprints(a, b); - const decisionsDim = result.dimensions.decisions; - - expect(decisionsDim).toBeDefined(); - expect(decisionsDim.distance).toBeLessThan(0.1); - expect(decisionsDim.description).toMatch(/align closely/i); - }); - - it("unrelated decisions score as divergent", () => { - const a = baseFingerprint("a", [ - { - dimension: "color-strategy", - decision: "Achromatic chrome", - evidence: [], - embedding: conceptVector(0), - }, - { - dimension: "motion", - decision: "No animation", - evidence: [], - embedding: conceptVector(1), - }, - ]); - - const b = baseFingerprint("b", [ - { - dimension: "density", - decision: "Generous whitespace", - evidence: [], - embedding: conceptVector(4), - }, - { - dimension: "elevation", - decision: "Heavy shadow hierarchy", - evidence: [], - embedding: conceptVector(5), - }, - ]); - - const result = compareFingerprints(a, b); - expect(result.dimensions.decisions.distance).toBeGreaterThan(0.5); - expect(result.dimensions.decisions.description).toMatch( - /fundamentally|divergence/i, - ); - }); - - it("missing embeddings: decisions recorded but not scored", () => { - // Decisions exist on both sides but neither has embeddings (pre-embedding - // fingerprint or no embedding provider was configured). The dimension - // should be reported qualitatively and contribute 0 to the weighted score. - const a = baseFingerprint("a", [ - { dimension: "color-strategy", decision: "X", evidence: [] }, - ]); - const b = baseFingerprint("b", [ - { dimension: "color-strategy", decision: "Y", evidence: [] }, - ]); - - const result = compareFingerprints(a, b); - expect(result.dimensions.decisions.distance).toBe(0); - expect(result.dimensions.decisions.description).toMatch(/not scored/i); - - // Overall distance should match what you'd get with no decisions at all - // (since other dimensions are identical, overall distance should be ~0). - expect(result.distance).toBeLessThan(0.01); - }); - - it("no decisions on either side: decisions dimension absent", () => { - const a = baseFingerprint("a", []); - const b = baseFingerprint("b", []); - - const result = compareFingerprints(a, b); - expect(result.dimensions.decisions).toBeUndefined(); - }); -}); diff --git a/packages/ghost/test/embedding/compare-oklch-fallback.test.ts b/packages/ghost/test/embedding/compare-oklch-fallback.test.ts deleted file mode 100644 index 3ab203fc..00000000 --- a/packages/ghost/test/embedding/compare-oklch-fallback.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Fingerprint } from "#ghost-core"; -import { compareFingerprints } from "#ghost-core"; - -/** - * Regression coverage for the oklch fallback in `comparePalette`. Authored - * fingerprints can carry palette colors as bare hex (`{ role, value: "#hex" }`) - * with no `oklch` tuple. Without the fallback, such colors landed in the - * "unmatched" branch and contributed distance 1 — even when comparing the - * same fingerprint to itself. - * - * Two layers of defense: - * - `loadFingerprint` backfills oklch on read (in ghost). - * - `comparePalette` computes oklch on-the-fly when missing AND falls - * back to hex equality when even on-the-fly compute can't resolve. - */ - -function makeFingerprint( - paletteOverrides: Partial = {}, -): Fingerprint { - return { - id: "test", - source: "llm", - timestamp: "2026-04-29T00:00:00Z", - palette: { - dominant: [{ role: "primary", value: "#1a1a1a" }], - neutrals: { steps: ["#ffffff", "#1a1a1a"], count: 2 }, - semantic: [ - { role: "danger", value: "#f94b4b" }, - { role: "success", value: "#91cb80" }, - ], - saturationProfile: "muted", - contrast: "high", - ...paletteOverrides, - }, - spacing: { scale: [4, 8, 16], regularity: 1, baseUnit: 4 }, - typography: { - families: ["Inter"], - sizeRamp: [12, 16, 24], - weightDistribution: { 400: 1 }, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: [4, 8], - shadowComplexity: "deliberate-none", - borderUsage: "minimal", - }, - embedding: [], - }; -} - -describe("comparePalette — oklch missing fallback", () => { - it("self-comparison of hex-only palette returns distance 0", () => { - const expr = makeFingerprint(); - const result = compareFingerprints(expr, expr); - expect(result.dimensions.palette.distance).toBe(0); - }); - - it("identical hex-only palettes (different objects) return distance 0", () => { - const a = makeFingerprint(); - const b = makeFingerprint(); - const result = compareFingerprints(a, b); - expect(result.dimensions.palette.distance).toBe(0); - }); - - it("different hex-only palettes return non-zero distance", () => { - const a = makeFingerprint(); - const b = makeFingerprint({ - dominant: [{ role: "primary", value: "#0066cc" }], - }); - const result = compareFingerprints(a, b); - expect(result.dimensions.palette.distance).toBeGreaterThan(0); - }); - - it("hex-only on one side, oklch-populated on the other still matches when value is identical", () => { - const a = makeFingerprint(); - const b = makeFingerprint({ - dominant: [ - { role: "primary", value: "#1a1a1a", oklch: [0.218, 0, 89.9] }, - ], - }); - const result = compareFingerprints(a, b); - // The on-the-fly parse should resolve a's hex to roughly the same - // oklch — distance should be near 0, not 1. - expect(result.dimensions.palette.distance).toBeLessThan(0.01); - }); - - it("hex-only colors that are non-parseable but identical strings still match", () => { - const a = makeFingerprint({ - dominant: [{ role: "primary", value: "var(--upstream-brand)" }], - }); - const b = makeFingerprint({ - dominant: [{ role: "primary", value: "var(--upstream-brand)" }], - }); - const result = compareFingerprints(a, b); - // CSS vars don't parse to oklch — fall through to hex equality. - expect(result.dimensions.palette.distance).toBe(0); - }); -}); diff --git a/packages/ghost/test/embedding/embedding.test.ts b/packages/ghost/test/embedding/embedding.test.ts deleted file mode 100644 index 124c262e..00000000 --- a/packages/ghost/test/embedding/embedding.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Fingerprint } from "#ghost-core"; -import { computeEmbedding, embeddingDistance } from "#ghost-core"; - -function makeFingerprint( - overrides: Partial> = {}, -): Omit { - return { - id: "test", - source: "registry", - timestamp: new Date().toISOString(), - palette: { - dominant: [ - { role: "primary", value: "#3b82f6", oklch: [0.623, 0.214, 259.8] }, - ], - neutrals: { steps: ["#fff", "#f5f5f5", "#e5e5e5", "#ccc"], count: 4 }, - semantic: [ - { role: "primary", value: "#3b82f6", oklch: [0.623, 0.214, 259.8] }, - { role: "surface", value: "#ffffff", oklch: [1, 0, 0] }, - { role: "text", value: "#0a0a0a", oklch: [0.07, 0, 0] }, - ], - saturationProfile: "mixed", - contrast: "high", - }, - spacing: { - scale: [4, 8, 12, 16, 24, 32, 48, 64], - regularity: 0.8, - baseUnit: 4, - }, - typography: { - families: ["Inter", "Geist Mono"], - sizeRamp: [12, 14, 16, 18, 20, 24, 30, 36, 48], - weightDistribution: { 400: 3, 500: 2, 600: 1, 700: 1 }, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: [2, 4, 8, 12], - shadowComplexity: "subtle", - borderUsage: "moderate", - borderTokenCount: 2, - }, - architecture: { - tokenization: 0.85, - methodology: ["css-custom-properties", "tailwind"], - componentCount: 45, - componentCategories: { ui: 30, layout: 10, feedback: 5 }, - namingPattern: "kebab-case", - }, - ...overrides, - }; -} - -describe("computeEmbedding", () => { - it("produces 49-dimensional vector", () => { - const fp = makeFingerprint(); - const embedding = computeEmbedding(fp); - expect(embedding).toHaveLength(49); - }); - - it("all values are between 0 and 1", () => { - const fp = makeFingerprint(); - const embedding = computeEmbedding(fp); - for (const v of embedding) { - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThanOrEqual(1.01); // small tolerance for floating point - } - }); - - it("identical fingerprints produce identical embeddings", () => { - const fp = makeFingerprint(); - const e1 = computeEmbedding(fp); - const e2 = computeEmbedding(fp); - expect(e1).toEqual(e2); - }); -}); - -describe("log-scaled normalization", () => { - it("spacing count: log-scaling differentiates scale sizes", () => { - const small = computeEmbedding( - makeFingerprint({ - spacing: { ...makeFingerprint().spacing, scale: [4, 8] }, - }), - ); - const large = computeEmbedding( - makeFingerprint({ - spacing: { - ...makeFingerprint().spacing, - scale: [4, 8, 12, 16, 24, 32, 48, 64], - }, - }), - ); - - // Spacing count is at index 21 - expect(small[21]).toBeLessThan(large[21]); - }); -}); - -describe("border usage continuous scoring", () => { - it("uses borderTokenCount when available", () => { - const withCount = computeEmbedding( - makeFingerprint({ - surfaces: { - ...makeFingerprint().surfaces, - borderUsage: "moderate", - borderTokenCount: 5, - }, - }), - ); - const withHighCount = computeEmbedding( - makeFingerprint({ - surfaces: { - ...makeFingerprint().surfaces, - borderUsage: "moderate", - borderTokenCount: 8, - }, - }), - ); - - // Border usage dimension is at index 45 (surfaces start at 41, borderUsage is 5th) - expect(withHighCount[45]).toBeGreaterThan(withCount[45]); - }); -}); - -describe("embedding is purely visual", () => { - it("architecture changes do not affect embedding", () => { - const a = computeEmbedding(makeFingerprint()); - const b = computeEmbedding( - makeFingerprint({ - architecture: { - tokenization: 0.1, - methodology: ["scss"], - componentCount: 200, - componentCategories: { widgets: 200 }, - namingPattern: "PascalCase", - }, - }), - ); - - expect(a).toEqual(b); - }); -}); - -describe("embeddingDistance", () => { - it("identical vectors have distance 0", () => { - const v = [0.5, 0.3, 0.7, 0.1]; - expect(embeddingDistance(v, v)).toBeCloseTo(0, 5); - }); - - it("orthogonal vectors have distance ~1", () => { - const a = [1, 0, 0, 0]; - const b = [0, 1, 0, 0]; - expect(embeddingDistance(a, b)).toBeCloseTo(1, 5); - }); -}); diff --git a/packages/ghost/test/embedding/semantic-roles.test.ts b/packages/ghost/test/embedding/semantic-roles.test.ts deleted file mode 100644 index 1164cc5c..00000000 --- a/packages/ghost/test/embedding/semantic-roles.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { inferSemanticRole } from "#ghost-core"; - -describe("inferSemanticRole", () => { - describe("Layer 1: exact match (shadcn)", () => { - it("maps --primary to primary", () => { - const result = inferSemanticRole("--primary"); - expect(result).toEqual({ role: "primary", confidence: 1.0 }); - }); - - it("maps --foreground to text", () => { - const result = inferSemanticRole("--foreground"); - expect(result).toEqual({ role: "text", confidence: 1.0 }); - }); - - it("maps --card to surface", () => { - const result = inferSemanticRole("--card"); - expect(result).toEqual({ role: "surface", confidence: 1.0 }); - }); - - it("maps --destructive to destructive", () => { - const result = inferSemanticRole("--destructive"); - expect(result).toEqual({ role: "destructive", confidence: 1.0 }); - }); - - it("maps --muted-foreground to muted-foreground", () => { - const result = inferSemanticRole("--muted-foreground"); - expect(result).toEqual({ role: "muted-foreground", confidence: 1.0 }); - }); - }); - - describe("Layer 2: pattern match", () => { - it("handles MUI-style tokens", () => { - const result = inferSemanticRole("--mui-palette-primary-main"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("primary"); - expect(result?.confidence).toBe(0.9); - }); - - it("handles Chakra-style tokens", () => { - const result = inferSemanticRole("--chakra-colors-brand-500"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("brand"); - expect(result?.confidence).toBe(0.9); - }); - - it("handles background patterns", () => { - const result = inferSemanticRole("--bg-primary"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("surface-primary"); - }); - - it("handles error/danger/destructive", () => { - expect(inferSemanticRole("--color-error")?.role).toBe("destructive"); - expect(inferSemanticRole("--color-danger")?.role).toBe("destructive"); - expect(inferSemanticRole("--color-destructive")?.role).toBe( - "destructive", - ); - }); - - it("handles warning/caution", () => { - expect(inferSemanticRole("--color-warning")?.role).toBe("warning"); - expect(inferSemanticRole("--color-caution")?.role).toBe("warning"); - }); - - it("handles success/positive", () => { - expect(inferSemanticRole("--color-success")?.role).toBe("success"); - expect(inferSemanticRole("--color-positive")?.role).toBe("success"); - }); - - it("handles accent/highlight", () => { - expect(inferSemanticRole("--color-accent")?.role).toBe("accent"); - expect(inferSemanticRole("--color-highlight")?.role).toBe("accent"); - }); - - it("handles text/foreground patterns", () => { - const result = inferSemanticRole("--text-secondary"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("text-secondary"); - }); - - it("handles border patterns", () => { - const result = inferSemanticRole("--border-subtle"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("border-subtle"); - }); - }); - - describe("Layer 3: keyword extraction", () => { - it("extracts from custom naming", () => { - const result = inferSemanticRole("--app-primary-color"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("primary"); - expect(result?.confidence).toBe(0.7); - }); - - it("extracts surface from custom naming", () => { - const result = inferSemanticRole("--my-surface-color"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("surface"); - }); - }); - - describe("Layer 4: value-based heuristic", () => { - it("classifies near-white as surface", () => { - const result = inferSemanticRole("--custom-unknown", "#fafafa"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("surface"); - expect(result?.confidence).toBe(0.6); - }); - - it("classifies near-black as text", () => { - const result = inferSemanticRole("--custom-unknown", "#0a0a0a"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("text"); - expect(result?.confidence).toBe(0.6); - }); - - it("classifies high-chroma as dominant", () => { - const result = inferSemanticRole("--custom-unknown", "#ff0000"); - expect(result).not.toBeNull(); - expect(result?.role).toBe("dominant"); - expect(result?.confidence).toBe(0.6); - }); - - it("returns null for unknown token with no color value", () => { - expect(inferSemanticRole("--custom-unknown", "16px")).toBeNull(); - }); - }); - - describe("returns null when nothing matches", () => { - it("returns null for completely unknown token", () => { - expect(inferSemanticRole("--xyz-abc")).toBeNull(); - }); - }); -}); diff --git a/packages/ghost/test/evolution/composite.test.ts b/packages/ghost/test/evolution/composite.test.ts deleted file mode 100644 index 5f61c638..00000000 --- a/packages/ghost/test/evolution/composite.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { CompositeMember, Fingerprint } from "#ghost-core"; -import { compareComposite } from "../../src/core/evolution/composite.js"; - -function makeCompositeMember( - id: string, - embeddingOverrides: Partial> = {}, -): CompositeMember { - const embedding = new Array(64).fill(0.5); - for (const [idx, val] of Object.entries(embeddingOverrides)) { - embedding[Number(idx)] = val; - } - - const fp: Fingerprint = { - id, - source: "registry", - timestamp: new Date().toISOString(), - palette: { - dominant: [{ role: "primary", value: "#000", oklch: [0.5, 0.15, 240] }], - neutrals: { steps: [], count: 0 }, - semantic: [{ role: "primary", value: "#000", oklch: [0.5, 0.15, 240] }], - saturationProfile: "mixed", - contrast: "moderate", - }, - spacing: { scale: [4, 8, 16], regularity: 0.8, baseUnit: 4 }, - typography: { - families: ["Inter"], - sizeRamp: [14, 16, 18], - weightDistribution: { 400: 1, 700: 1 }, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: [4, 8], - shadowComplexity: "subtle", - borderUsage: "moderate", - }, - architecture: { - tokenization: 0.8, - methodology: ["css-custom-properties"], - componentCount: 20, - componentCategories: { ui: 20 }, - namingPattern: "kebab-case", - }, - embedding, - }; - - return { id, fingerprint: fp }; -} - -describe("compareComposite", () => { - it("computes pairwise distances", () => { - const members = [ - makeCompositeMember("a"), - makeCompositeMember("b"), - makeCompositeMember("c"), - ]; - const result = compareComposite(members); - expect(result.pairwise).toHaveLength(3); // 3 choose 2 - }); - - it("computes centroid and spread", () => { - const members = [makeCompositeMember("a"), makeCompositeMember("b")]; - const result = compareComposite(members); - expect(result.centroid).toHaveLength(64); - expect(result.spread).toBeGreaterThanOrEqual(0); - }); - - it("K=2 clustering works (backward compatible)", () => { - const members = [ - makeCompositeMember("a", { 0: 0.0, 1: 0.0 }), - makeCompositeMember("b", { 0: 0.05, 1: 0.05 }), - makeCompositeMember("c", { 0: 1.0, 1: 1.0 }), - ]; - const result = compareComposite(members, { cluster: true }); - expect(result.clusters).toBeDefined(); - expect(result.clusters?.length).toBeGreaterThanOrEqual(1); - expect(result.clusters?.length).toBeLessThanOrEqual(3); - }); - - it("adaptive K: detects 3 natural clusters", () => { - // Create 3 clearly separated groups - const members = [ - // Group 1: embedding near 0 - makeCompositeMember("a1", { 0: 0.0, 1: 0.0, 2: 0.0 }), - makeCompositeMember("a2", { 0: 0.05, 1: 0.05, 2: 0.05 }), - // Group 2: embedding near 0.5 - makeCompositeMember("b1", { 0: 0.5, 1: 0.5, 2: 0.5 }), - makeCompositeMember("b2", { 0: 0.55, 1: 0.55, 2: 0.55 }), - // Group 3: embedding near 1 - makeCompositeMember("c1", { 0: 1.0, 1: 1.0, 2: 1.0 }), - makeCompositeMember("c2", { 0: 0.95, 1: 0.95, 2: 0.95 }), - ]; - const result = compareComposite(members, { cluster: { maxK: 5 } }); - expect(result.clusters).toBeDefined(); - // Should detect >= 2 clusters (ideally 3) - expect(result.clusters?.length).toBeGreaterThanOrEqual(2); - }); - - it("maxK option limits cluster count", () => { - const members = [ - makeCompositeMember("a", { 0: 0.0 }), - makeCompositeMember("b", { 0: 0.5 }), - makeCompositeMember("c", { 0: 1.0 }), - makeCompositeMember("d", { 0: 0.25 }), - makeCompositeMember("e", { 0: 0.75 }), - ]; - const result = compareComposite(members, { cluster: { maxK: 3 } }); - expect(result.clusters).toBeDefined(); - expect(result.clusters?.length).toBeLessThanOrEqual(3); - }); - - it("does not cluster with fewer than 3 members", () => { - const members = [makeCompositeMember("a"), makeCompositeMember("b")]; - const result = compareComposite(members, { cluster: true }); - expect(result.clusters).toBeUndefined(); - }); - - it("all members appear in exactly one cluster", () => { - const members = [ - makeCompositeMember("a"), - makeCompositeMember("b"), - makeCompositeMember("c"), - makeCompositeMember("d"), - ]; - const result = compareComposite(members, { cluster: true }); - const allIds = result.clusters?.flatMap((c) => c.memberIds); - expect(allIds.sort()).toEqual(["a", "b", "c", "d"]); - }); -}); diff --git a/packages/ghost/test/evolution/sync.test.ts b/packages/ghost/test/evolution/sync.test.ts deleted file mode 100644 index f5aef28e..00000000 --- a/packages/ghost/test/evolution/sync.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - DimensionAck, - Fingerprint, - FingerprintComparison, - SyncManifest, -} from "#ghost-core"; -import { checkBounds } from "../../src/core/evolution/sync.js"; - -function makeManifest( - dimensions: Record>, -): SyncManifest { - const fullDimensions: Record = {}; - for (const [key, partial] of Object.entries(dimensions)) { - fullDimensions[key] = { - distance: 0.1, - stance: "accepted", - ackedAt: new Date().toISOString(), - ...partial, - }; - } - - return { - tracks: { type: "path", value: "./tracked.fingerprint.md" }, - ackedAt: new Date().toISOString(), - trackedFingerprintId: "tracked", - localFingerprintId: "local", - dimensions: fullDimensions, - overallDistance: 0.2, - }; -} - -function makeComparison( - dimensions: Record, -): FingerprintComparison { - const fp: Fingerprint = { - id: "test", - source: "registry", - timestamp: new Date().toISOString(), - palette: { - dominant: [], - neutrals: { steps: [], count: 0 }, - semantic: [], - saturationProfile: "muted", - contrast: "moderate", - }, - spacing: { scale: [], regularity: 0, baseUnit: null }, - typography: { - families: [], - sizeRamp: [], - weightDistribution: {}, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: [], - shadowComplexity: "deliberate-none", - borderUsage: "minimal", - }, - embedding: [], - }; - - return { - source: fp, - target: fp, - distance: - Object.values(dimensions).reduce((a, b) => a + b, 0) / - Object.keys(dimensions).length, - dimensions: Object.fromEntries( - Object.entries(dimensions).map(([key, dist]) => [ - key, - { dimension: key, distance: dist, description: "" }, - ]), - ), - summary: "", - }; -} - -describe("checkBounds", () => { - it("detects exceeded dimensions with default tolerance", () => { - const manifest = makeManifest({ - palette: { distance: 0.1, stance: "accepted" }, - spacing: { distance: 0.05, stance: "accepted" }, - }); - const comparison = makeComparison({ palette: 0.2, spacing: 0.05 }); - - const result = checkBounds(manifest, comparison); - expect(result.exceeded).toBe(true); - expect(result.dimensions).toContain("palette"); - expect(result.dimensions).not.toContain("spacing"); - }); - - it("uses per-dimension tolerance", () => { - const manifest = makeManifest({ - palette: { distance: 0.1, stance: "accepted", tolerance: 0.02 }, - spacing: { distance: 0.1, stance: "accepted", tolerance: 0.2 }, - }); - // Both increased by 0.05 - const comparison = makeComparison({ palette: 0.15, spacing: 0.15 }); - - const result = checkBounds(manifest, comparison); - // Palette: 0.15 > 0.1 + 0.02 = 0.12 → exceeded - // Spacing: 0.15 < 0.1 + 0.2 = 0.3 → not exceeded - expect(result.dimensions).toContain("palette"); - expect(result.dimensions).not.toContain("spacing"); - }); - - it("detects reconverging dimensions", () => { - const manifest = makeManifest({ - palette: { distance: 0.4, stance: "diverging" }, - }); - // Distance has dropped to less than 50% of acked - const comparison = makeComparison({ palette: 0.15 }); - - const result = checkBounds(manifest, comparison); - expect(result.reconverging).toContain("palette"); - }); - - it("does not flag reconverging if still far from tracked fingerprint", () => { - const manifest = makeManifest({ - palette: { distance: 0.4, stance: "diverging" }, - }); - // Distance still at 60% of acked — not reconverging - const comparison = makeComparison({ palette: 0.25 }); - - const result = checkBounds(manifest, comparison); - expect(result.reconverging).not.toContain("palette"); - }); - - it("flags diverging dimensions past maxDivergenceDays", () => { - const oldDate = new Date(); - oldDate.setDate(oldDate.getDate() - 100); // 100 days ago - - const manifest = makeManifest({ - palette: { - distance: 0.4, - stance: "diverging", - divergedAt: oldDate.toISOString(), - }, - }); - const comparison = makeComparison({ palette: 0.5 }); - - const result = checkBounds(manifest, comparison, { - maxDivergenceDays: 30, - }); - expect(result.dimensions).toContain("palette"); - }); - - it("does not flag diverging within maxDivergenceDays", () => { - const recentDate = new Date(); - recentDate.setDate(recentDate.getDate() - 10); // 10 days ago - - const manifest = makeManifest({ - palette: { - distance: 0.4, - stance: "diverging", - divergedAt: recentDate.toISOString(), - }, - }); - const comparison = makeComparison({ palette: 0.5 }); - - const result = checkBounds(manifest, comparison, { - maxDivergenceDays: 30, - }); - expect(result.dimensions).not.toContain("palette"); - }); - - it("backward compatible: number tolerance still works", () => { - const manifest = makeManifest({ - palette: { distance: 0.1, stance: "accepted" }, - }); - const comparison = makeComparison({ palette: 0.25 }); - - const result = checkBounds(manifest, comparison, 0.1); - expect(result.exceeded).toBe(true); - }); -}); diff --git a/packages/ghost/test/fingerprint-package.test.ts b/packages/ghost/test/fingerprint-package.test.ts index 07f17ab7..74f170e6 100644 --- a/packages/ghost/test/fingerprint-package.test.ts +++ b/packages/ghost/test/fingerprint-package.test.ts @@ -23,7 +23,7 @@ describe("split fingerprint package", () => { await rm(dir, { recursive: true, force: true }); }); - it("loads manifest and normalizes missing raw facet files", async () => { + it("loads a manifest-only package as an empty graph", async () => { await writeManifest(dir); const loaded = await loadFingerprintPackage(resolveFingerprintPackage(dir)); @@ -32,97 +32,39 @@ describe("split fingerprint package", () => { schema: "ghost.fingerprint-package/v1", id: "local", }); - expect(loaded.fingerprint).toMatchObject({ - schema: "ghost.fingerprint/v1", - intent: { - summary: {}, - situations: [], - principles: [], - experience_contracts: [], - }, - inventory: { - building_blocks: {}, - exemplars: [], - sources: [], - }, - composition: { patterns: [] }, - }); + // Only the implicit root, no authored nodes. + expect([...loaded.graph.nodes.keys()]).toEqual([]); }); - it("accepts inventory source links without making source material canonical", async () => { + it("folds authored nodes/*.md into the graph", async () => { await writeManifest(dir); + await mkdir(join(dir, "nodes"), { recursive: true }); await writeFile( - join(dir, "inventory.yml"), - `building_blocks: {} -exemplars: [] -sources: - - id: repo-signals - kind: file - ref: docs/architecture.md - note: Human-curated source material. -`, + join(dir, "nodes", "checkout-trust.md"), + "---\nid: checkout-trust\nunder: core\nincarnation: web\n---\n\nReduce felt risk near payment.\n", ); - const report = await lintFingerprintPackage(dir); const loaded = await loadFingerprintPackage(resolveFingerprintPackage(dir)); - expect(report.errors).toBe(0); - expect(loaded.fingerprint.inventory.sources[0]).toMatchObject({ - id: "repo-signals", - kind: "file", - ref: "docs/architecture.md", - }); - }); - - it("reports duplicate inventory source ids", async () => { - await writeManifest(dir); - await writeFile( - join(dir, "inventory.yml"), - `building_blocks: {} -exemplars: [] -sources: - - id: repo-signals - kind: file - ref: docs/architecture.md - - id: repo-signals - kind: file - ref: tmp/inventory.json -`, - ); - - const report = await lintFingerprintPackage(dir); - - expect(report.errors).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "duplicate-id", - path: "inventory.yml.sources[1].id", - }); - }); - - it("reports invalid raw layer YAML at the split path", async () => { - await writeManifest(dir); - await writeFile(join(dir, "intent.yml"), "{nope"); - - const report = await lintFingerprintPackage(dir); - - expect(report.errors).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "package-yaml-invalid", - path: "intent.yml", - }); + const authored = loaded.graph.nodes.get("checkout-trust"); + expect(authored?.origin).toBe("node-file"); + expect(authored?.body).toBe("Reduce felt risk near payment."); + expect(authored?.incarnation).toBe("web"); }); - it("does not silently treat unreadable optional layer paths as missing", async () => { + it("guides legacy facet packages to migrate", async () => { await writeManifest(dir); - await mkdir(join(dir, "intent.yml")); + await writeFile(join(dir, "intent.yml"), "summary: {}\nprinciples: []\n"); - await expect(lintFingerprintPackage(dir)).rejects.toThrow(); + await expect( + loadFingerprintPackage(resolveFingerprintPackage(dir)), + ).rejects.toThrow(/ghost migrate/); }); - it("does not discover old .ghost.yml alone as a package", async () => { + it("reports a missing manifest", async () => { await writeFile( - join(dir, "fingerprint.yml"), - "schema: ghost.fingerprint/v1\n", + join(dir, "surfaces.yml"), + "schema: ghost.surfaces/v1\nsurfaces: []\n", ); const report = await lintFingerprintPackage(dir); diff --git a/packages/ghost/test/fingerprint-yml-schema.test.ts b/packages/ghost/test/fingerprint-yml-schema.test.ts deleted file mode 100644 index 59dfea31..00000000 --- a/packages/ghost/test/fingerprint-yml-schema.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - GHOST_FINGERPRINT_SCHEMA, - GhostFingerprintSchema, - lintGhostFingerprint, -} from "../src/ghost-core/fingerprint/index.js"; - -const SURFACE_IDS = ["core", "dashboard", "docs"]; - -describe("ghost.fingerprint/v1", () => { - it("accepts a minimal fingerprint.yml document", () => { - const result = GhostFingerprintSchema.safeParse(minimalFingerprint()); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("minimal fingerprint should parse"); - expect(result.data).toEqual({ - schema: GHOST_FINGERPRINT_SCHEMA, - intent: { - summary: {}, - situations: [], - principles: [], - experience_contracts: [], - }, - inventory: { - building_blocks: {}, - exemplars: [], - sources: [], - }, - composition: { - patterns: [], - }, - }); - }); - - it("accepts a full OSS-friendly fingerprint.yml document", () => { - const report = lintGhostFingerprint(fullFingerprint(), { - surfaceIds: SURFACE_IDS, - }); - - expect(report.errors).toBe(0); - expect(report.issues).toEqual([]); - }); - - it("rejects v1 flat top-level fields", () => { - const result = GhostFingerprintSchema.safeParse({ - ...minimalFingerprint(), - principles: [], - implementation_vocabulary: {}, - }); - - expect(result.success).toBe(false); - }); - - it("rejects the removed topology subtree", () => { - const input = fullFingerprint(); - (input.inventory as Record).topology = { - scopes: [{ id: "dashboard", paths: ["apps/dashboard/**"] }], - }; - - const result = GhostFingerprintSchema.safeParse(input); - - expect(result.success).toBe(false); - }); - - it("rejects the removed applies_to coordinate on a principle", () => { - const input = fullFingerprint(); - (input.intent.principles[0] as Record).applies_to = { - scopes: ["dashboard"], - }; - - const result = GhostFingerprintSchema.safeParse(input); - - expect(result.success).toBe(false); - }); - - it("rejects the removed surface_type/scope coordinates on an exemplar", () => { - const withSurfaceType = fullFingerprint(); - ( - withSurfaceType.inventory.exemplars[0] as Record - ).surface_type = "dense-dashboard"; - expect(GhostFingerprintSchema.safeParse(withSurfaceType).success).toBe( - false, - ); - - const withScope = fullFingerprint(); - (withScope.inventory.exemplars[0] as Record).scope = - "dashboard"; - expect(GhostFingerprintSchema.safeParse(withScope).success).toBe(false); - }); - - it("accepts surface placement on every placeable node", () => { - const result = GhostFingerprintSchema.safeParse(fullFingerprint()); - - expect(result.success).toBe(true); - }); - - it("rejects implementation vocabulary as a typed ref target", () => { - const input = fullFingerprint(); - input.intent.situations[0].patterns = [ - "implementation_vocabulary:semantic-tokens", - ]; - - const result = GhostFingerprintSchema.safeParse(input); - - expect(result.success).toBe(false); - }); - - it("rejects legacy status fields in canonical fingerprint.yml entries", () => { - const principle = fullFingerprint(); - principle.intent.principles[0].status = "accepted" as never; - expect(GhostFingerprintSchema.safeParse(principle).success).toBe(false); - - const contract = fullFingerprint(); - contract.intent.experience_contracts[0].status = "accepted" as never; - expect(GhostFingerprintSchema.safeParse(contract).success).toBe(false); - - const pattern = fullFingerprint(); - pattern.composition.patterns[0].status = "accepted" as never; - expect(GhostFingerprintSchema.safeParse(pattern).success).toBe(false); - }); - - it("reports unknown typed refs inside the fingerprint", () => { - const input = fullFingerprint(); - input.intent.situations[0].principles = [ - "intent.principle:missing-principle", - ]; - - const report = lintGhostFingerprint(input, { surfaceIds: SURFACE_IDS }); - - expect(report.errors).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "fingerprint-ref-unknown", - path: "intent.situations[0].principles[0]", - }); - }); - - it("reports mismatched typed ref prefixes", () => { - const input = fullFingerprint(); - input.intent.situations[0].patterns = [ - "intent.principle:dense-workflows-prioritize-scanning", - ]; - - const report = lintGhostFingerprint(input, { surfaceIds: SURFACE_IDS }); - - expect(report.errors).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "fingerprint-ref-prefix", - path: "intent.situations[0].patterns[0]", - }); - }); - - it("reports duplicate ids by collection", () => { - const input = fullFingerprint(); - input.composition.patterns.push({ ...input.composition.patterns[0] }); - - const report = lintGhostFingerprint(input, { surfaceIds: SURFACE_IDS }); - - expect(report.errors).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "duplicate-id", - path: "composition.patterns[1].id", - }); - }); - - it("errors on a placement that is not a declared surface", () => { - const input = fullFingerprint(); - input.intent.principles[0].surface = "unknown-surface"; - - const report = lintGhostFingerprint(input, { surfaceIds: SURFACE_IDS }); - - expect( - report.issues.some( - (issue) => issue.rule === "fingerprint-surface-unknown", - ), - ).toBe(true); - }); - - it("warns on an unplaced node", () => { - const input = fullFingerprint(); - input.intent.principles[0].surface = undefined; - - const report = lintGhostFingerprint(input, { surfaceIds: SURFACE_IDS }); - - expect( - report.issues.some((issue) => issue.rule === "fingerprint-node-unplaced"), - ).toBe(true); - }); - - it("skips placement existence checks when no surfaces are provided", () => { - const input = fullFingerprint(); - input.intent.principles[0].surface = "unknown-surface"; - - const report = lintGhostFingerprint(input); - - expect( - report.issues.some( - (issue) => issue.rule === "fingerprint-surface-unknown", - ), - ).toBe(false); - }); - - it("reports unknown exemplar refs", () => { - const input = fullFingerprint(); - input.inventory.exemplars[0].refs = ["composition.pattern:missing-pattern"]; - - const report = lintGhostFingerprint(input, { surfaceIds: SURFACE_IDS }); - - expect( - report.issues.some( - (issue) => - issue.rule === "fingerprint-ref-unknown" && - issue.path === "inventory.exemplars[0].refs[0]", - ), - ).toBe(true); - }); - - it("requires check refs to use validate.check:*", () => { - const input = fullFingerprint(); - input.intent.principles[0].check_refs = [ - "composition.pattern:compact-filter-toolbar", - ]; - - const report = lintGhostFingerprint(input, { surfaceIds: SURFACE_IDS }); - - expect( - report.issues.some( - (issue) => - issue.rule === "fingerprint-check-ref-prefix" && - issue.path === "intent.principles[0].check_refs[0]", - ), - ).toBe(true); - }); -}); - -function minimalFingerprint() { - return { - schema: GHOST_FINGERPRINT_SCHEMA, - }; -} - -function fullFingerprint() { - return { - schema: GHOST_FINGERPRINT_SCHEMA, - intent: { - summary: { - product: "Example dashboard", - audience: ["operators"], - goals: ["preserve scan speed"], - anti_goals: ["turn dense workflows into marketing pages"], - tradeoffs: ["density versus explanation"], - tone: ["plain", "task-fit"], - }, - situations: [ - { - id: "user-is-filtering-an-operations-table", - user_intent: "find and compare records quickly", - product_obligation: - "preserve scan speed and reduce accidental changes", - surface: "dashboard", - hierarchy: { - primary: "table readability and filtering", - secondary: "bulk actions and record detail", - }, - refuses: ["oversized marketing hero"], - principles: ["intent.principle:dense-workflows-prioritize-scanning"], - experience_contracts: [ - "intent.experience_contract:destructive-actions-require-clear-confirmation", - ], - patterns: ["composition.pattern:compact-filter-toolbar"], - }, - ], - principles: [ - { - id: "dense-workflows-prioritize-scanning", - principle: - "Dense operational workflows should optimize for comparison, speed, and recovery before visual novelty.", - surface: "dashboard", - guidance: ["keep controls close to the table or list they affect"], - evidence: [ - { - path: "apps/dashboard/src/routes/orders/page.tsx", - }, - ], - counterexamples: [ - "marketing pages may use larger narrative composition", - ], - check_refs: [ - "validate.check:no-decorative-card-grid-for-dense-table", - ], - }, - ], - experience_contracts: [ - { - id: "destructive-actions-require-clear-confirmation", - contract: - "Destructive actions need explicit confirmation and a clear recovery path.", - surface: "core", - obligations: ["confirm intent", "explain consequence"], - }, - ], - }, - inventory: { - building_blocks: { - tokens: ["use semantic color tokens"], - components: ["prefer shared table primitives"], - libraries: ["local dashboard primitives"], - assets: ["status icons"], - routes: ["/orders"], - files: ["apps/dashboard/src/routes/orders/page.tsx"], - notes: ["current vocabulary is replaceable implementation material"], - }, - exemplars: [ - { - id: "orders-table", - path: "apps/dashboard/src/routes/orders/page.tsx", - title: "Order review table", - surface: "dashboard", - note: "Dense filtering and comparison surface.", - why: "Shows the compact hierarchy future dashboard work should preserve.", - refs: [ - "intent.principle:dense-workflows-prioritize-scanning", - "composition.pattern:compact-filter-toolbar", - ], - }, - ], - }, - composition: { - patterns: [ - { - id: "compact-filter-toolbar", - kind: "layout", - pattern: "Filters stay visually attached to the table they affect.", - surface: "dashboard", - guidance: ["keep primary filters before secondary actions"], - }, - ], - }, - }; -} diff --git a/packages/ghost/test/gate.test.ts b/packages/ghost/test/gate.test.ts deleted file mode 100644 index 68c6e62e..00000000 --- a/packages/ghost/test/gate.test.ts +++ /dev/null @@ -1,468 +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, vi } from "vitest"; -import type { - DimensionAck, - Fingerprint, - FingerprintComparison, - SyncManifest, -} from "#ghost-core"; -import { buildCli } from "../src/cli.js"; -import { - buildGateReport, - formatGateReportCLI, - formatGateReportJSON, - gateExitCode, -} from "../src/core/gate.js"; - -function makeFingerprint(id: string): Fingerprint { - return { - id, - source: "registry", - timestamp: new Date().toISOString(), - palette: { - dominant: [], - neutrals: { steps: [], count: 0 }, - semantic: [], - saturationProfile: "muted", - contrast: "moderate", - }, - spacing: { scale: [], regularity: 0, baseUnit: null }, - typography: { - families: [], - sizeRamp: [], - weightDistribution: {}, - lineHeightPattern: "normal", - }, - surfaces: { - borderRadii: [], - shadowComplexity: "deliberate-none", - borderUsage: "minimal", - }, - embedding: [], - }; -} - -function makeComparison( - dimensions: Record, - ids: { source: string; target: string } = { - source: "tracked", - target: "local", - }, -): FingerprintComparison { - const distances = Object.values(dimensions); - const distance = - distances.length === 0 - ? 0 - : distances.reduce((a, b) => a + b, 0) / distances.length; - return { - source: makeFingerprint(ids.source), - target: makeFingerprint(ids.target), - distance, - dimensions: Object.fromEntries( - Object.entries(dimensions).map(([key, dist]) => [ - key, - { dimension: key, distance: dist, description: "" }, - ]), - ), - summary: "", - }; -} - -function makeManifest( - dimensions: Record>, -): SyncManifest { - const fullDimensions: Record = {}; - for (const [key, partial] of Object.entries(dimensions)) { - fullDimensions[key] = { - distance: 0.1, - stance: "accepted", - ackedAt: new Date().toISOString(), - ...partial, - }; - } - return { - tracks: { type: "path", value: "./tracked.fingerprint.md" }, - ackedAt: new Date().toISOString(), - trackedFingerprintId: "tracked", - localFingerprintId: "local", - dimensions: fullDimensions, - overallDistance: 0, - }; -} - -describe("buildGateReport", () => { - it("schema field is present and correctly versioned", () => { - const report = buildGateReport({ - comparison: makeComparison({ palette: 0 }), - manifest: makeManifest({ palette: { distance: 0, stance: "aligned" } }), - }); - expect(report.schema).toBe("ghost.compare.gate/v1"); - }); - - it("aligned: zero distances and matching aligned acks", () => { - const comparison = makeComparison({ palette: 0, spacing: 0 }); - const manifest = makeManifest({ - palette: { distance: 0, stance: "aligned" }, - spacing: { distance: 0, stance: "aligned" }, - }); - const report = buildGateReport({ comparison, manifest }); - expect(report.dimensions.palette.verdict).toBe("aligned"); - expect(report.dimensions.spacing.verdict).toBe("aligned"); - expect(report.overall.verdict).toBe("aligned"); - expect(gateExitCode(report)).toBe(0); - }); - - it("covered (accepted): distances match acks, stance accepted", () => { - const comparison = makeComparison({ palette: 0.2, spacing: 0.1 }); - const manifest = makeManifest({ - palette: { distance: 0.2, stance: "accepted" }, - spacing: { distance: 0.1, stance: "accepted" }, - }); - const report = buildGateReport({ comparison, manifest }); - expect(report.dimensions.palette.verdict).toBe("covered"); - expect(report.dimensions.spacing.verdict).toBe("covered"); - expect(report.overall.verdict).toBe("covered"); - expect(gateExitCode(report)).toBe(0); - }); - - it("diverging covered: current ≤ acked, stance diverging", () => { - const comparison = makeComparison({ - palette: 0.2, - decisions: 0.0, - }); - const manifest = makeManifest({ - palette: { distance: 0.2, stance: "accepted" }, - decisions: { distance: 0.0, stance: "diverging" }, - }); - const report = buildGateReport({ comparison, manifest }); - expect(report.dimensions.decisions.verdict).toBe("covered"); - expect(report.dimensions.decisions.stance).toBe("diverging"); - expect(report.overall.verdict).toBe("covered"); - expect(gateExitCode(report)).toBe(0); - }); - - it("reconverging surfaced: diverging dim with current < 50% of acked", () => { - const comparison = makeComparison({ palette: 0.1 }); - const manifest = makeManifest({ - palette: { distance: 0.4, stance: "diverging" }, - }); - const report = buildGateReport({ comparison, manifest }); - expect(report.dimensions.palette.verdict).toBe("reconverging"); - expect(report.overall.verdict).toBe("covered"); - expect(gateExitCode(report)).toBe(0); - }); - - it("uncovered (exceeded tolerance): exit 1, reason explains exceedance", () => { - const comparison = makeComparison({ palette: 0.95, spacing: 0.1 }); - const manifest = makeManifest({ - palette: { distance: 0.875, stance: "accepted" }, - spacing: { distance: 0.1, stance: "accepted" }, - }); - const report = buildGateReport({ comparison, manifest }); - expect(report.dimensions.palette.verdict).toBe("uncovered"); - expect(report.dimensions.palette.reason).toContain("exceeds"); - expect(report.dimensions.spacing.verdict).toBe("covered"); - expect(report.overall.verdict).toBe("uncovered"); - expect(gateExitCode(report)).toBe(1); - }); - - it("uncovered (new dimension): comparison has dimension manifest doesn't cover", () => { - const comparison = makeComparison({ - palette: 0.1, - newDimension: 0.4, - }); - const manifest = makeManifest({ - palette: { distance: 0.1, stance: "accepted" }, - }); - const report = buildGateReport({ comparison, manifest }); - expect(report.dimensions.newDimension.verdict).toBe("uncovered"); - expect(report.dimensions.newDimension.reason).toBe("no ack recorded"); - expect(report.overall.verdict).toBe("uncovered"); - expect(gateExitCode(report)).toBe(1); - }); - - it("diverging exceeded --max-divergence-days flagged uncovered", () => { - const oldDate = new Date(); - oldDate.setDate(oldDate.getDate() - 100); - const comparison = makeComparison({ palette: 0.5 }); - const manifest = makeManifest({ - palette: { - distance: 0.4, - stance: "diverging", - divergedAt: oldDate.toISOString(), - }, - }); - const report = buildGateReport({ - comparison, - manifest, - maxDivergenceDays: 30, - }); - expect(report.dimensions.palette.verdict).toBe("uncovered"); - expect(report.dimensions.palette.reason).toContain("max-divergence-days"); - expect(gateExitCode(report)).toBe(1); - }); - - it("CLI output: per-dimension lines with markers and final overall verdict", () => { - const comparison = makeComparison({ - palette: 0.95, - spacing: 0.0, - }); - const manifest = makeManifest({ - palette: { distance: 0.875, stance: "accepted" }, - spacing: { distance: 0.0, stance: "aligned" }, - }); - const report = buildGateReport({ comparison, manifest }); - const text = formatGateReportCLI(report); - expect(text).toContain("✓"); // aligned - expect(text).toContain("✗"); // uncovered - expect(text).toMatch(/Overall: uncovered/); - expect(text).toMatch(/palette/); - expect(text).toMatch(/spacing/); - }); - - it("JSON output: structured shape with schema field", () => { - const comparison = makeComparison({ palette: 0.2 }); - const manifest = makeManifest({ - palette: { distance: 0.2, stance: "accepted" }, - }); - const json = formatGateReportJSON( - buildGateReport({ comparison, manifest }), - ); - expect(json).toContain('"schema":"ghost.compare.gate/v1"'); - const parsed = JSON.parse(json); - expect(parsed.dimensions.palette.verdict).toBe("covered"); - }); -}); - -// --- CLI integration tests --- - -const FINGERPRINT = `--- -id: local -source: llm -timestamp: 2026-04-24T00:00:00.000Z -palette: - dominant: - - { role: primary, value: "#111111" } - neutrals: { steps: ["#ffffff", "#111111"], count: 2 } - semantic: [] - saturationProfile: muted - contrast: high -spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 } -typography: - families: ["Inter"] - sizeRamp: [12, 16, 24] - weightDistribution: { 400: 1 } - lineHeightPattern: normal -surfaces: - borderRadii: [4, 8] - shadowComplexity: deliberate-none - borderUsage: minimal ---- - -# Character - -Quiet and direct. - -# Decisions - -### shape-language -Use modest radii. -`; - -function fingerprintWithId(id: string): string { - return FINGERPRINT.replace("id: local", `id: ${id}`); -} - -async function runCli(argv: string[], cwd: string) { - const cli = buildCli(); - const previousCwd = process.cwd(); - let stdout = ""; - let stderr = ""; - let exitCode: number | undefined; - let finish: () => void = () => {}; - const done = new Promise((resolve) => { - finish = resolve; - }); - - const stdoutSpy = vi - .spyOn(process.stdout, "write") - .mockImplementation((chunk: string | Uint8Array, ...rest: unknown[]) => { - stdout += chunk.toString(); - // Honor the optional flush callback so writers using the - // `write(chunk, cb)` signature (see runGateCli's writeAndFlush) - // resolve instead of hanging on the test's mocked stdout. - const cb = rest[rest.length - 1]; - if (typeof cb === "function") cb(); - return true; - }); - const stderrSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation((chunk: string | Uint8Array) => { - stderr += chunk.toString(); - return true; - }); - const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => { - stdout += `${args.join(" ")}\n`; - }); - const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { - stderr += `${args.join(" ")}\n`; - }); - const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { - exitCode = typeof code === "number" ? code : 0; - finish(); - return undefined as never; - }); - - try { - process.chdir(cwd); - cli.parse(["node", "ghost-drift", ...argv]); - await Promise.race([ - done, - new Promise((_, reject) => - setTimeout(() => reject(new Error("CLI command did not exit")), 5000), - ), - ]); - } finally { - process.chdir(previousCwd); - stdoutSpy.mockRestore(); - stderrSpy.mockRestore(); - logSpy.mockRestore(); - errorSpy.mockRestore(); - exitSpy.mockRestore(); - } - - return { stdout, stderr, code: exitCode ?? 0 }; -} - -describe("ghost-drift compare --gate (CLI)", () => { - let dir: string; - - beforeEach(async () => { - dir = await import("node:fs/promises").then((fs) => - fs.mkdtemp(join(tmpdir(), "ghost-drift-gate-")), - ); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it("missing sync file with --gate exits 2 and mentions the path", async () => { - await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); - await writeFile(join(dir, "b.fingerprint.md"), fingerprintWithId("b")); - - const { stderr, code } = await runCli( - ["compare", "a.fingerprint.md", "b.fingerprint.md", "--gate"], - dir, - ); - - expect(code).toBe(2); - expect(stderr).toMatch(/sync manifest not found/); - expect(stderr).toContain(join(dir, ".ghost-sync.json")); - }); - - it("--gate --format json emits ghost.compare.gate/v1", async () => { - await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); - await writeFile(join(dir, "b.fingerprint.md"), fingerprintWithId("b")); - const manifest: SyncManifest = { - tracks: { type: "path", value: "./a.fingerprint.md" }, - ackedAt: new Date().toISOString(), - trackedFingerprintId: "a", - localFingerprintId: "b", - // Pre-populate every dimension with a generous tolerance so identical - // fingerprints come back fully covered. - dimensions: { - decisions: { - distance: 0, - stance: "aligned", - ackedAt: new Date().toISOString(), - tolerance: 1, - }, - palette: { - distance: 0, - stance: "aligned", - ackedAt: new Date().toISOString(), - tolerance: 1, - }, - spacing: { - distance: 0, - stance: "aligned", - ackedAt: new Date().toISOString(), - tolerance: 1, - }, - typography: { - distance: 0, - stance: "aligned", - ackedAt: new Date().toISOString(), - tolerance: 1, - }, - surfaces: { - distance: 0, - stance: "aligned", - ackedAt: new Date().toISOString(), - tolerance: 1, - }, - }, - overallDistance: 0, - }; - await writeFile( - join(dir, ".ghost-sync.json"), - JSON.stringify(manifest, null, 2), - ); - - const { stdout, code } = await runCli( - [ - "compare", - "a.fingerprint.md", - "b.fingerprint.md", - "--gate", - "--format", - "json", - ], - dir, - ); - - expect(code).toBe(0); - expect(stdout).toContain('"schema":"ghost.compare.gate/v1"'); - const parsed = JSON.parse(stdout.trim()); - expect(parsed.overall.verdict).toMatch(/aligned|covered/); - }); - - it("--gate with N≠2 exits 2", async () => { - await mkdir(dir, { recursive: true }); - await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); - const { code, stderr } = await runCli( - ["compare", "a.fingerprint.md", "--gate"], - dir, - ); - expect(code).toBe(2); - expect(stderr).toMatch(/--gate requires exactly 2/); - }); -}); - -describe("uncovered JSON schema snippet (visible to caller)", () => { - it("matches the documented gate report shape for an uncovered dim", () => { - const comparison = makeComparison( - { - spacing: 0.73, - palette: 0.95, - decisions: 0.0, - newDimension: 0.4, - }, - { source: "market-theme", target: "example-app" }, - ); - const manifest = makeManifest({ - spacing: { distance: 0.73, stance: "accepted" }, - palette: { distance: 0.875, stance: "accepted" }, - decisions: { distance: 0.0, stance: "diverging" }, - }); - const report = buildGateReport({ comparison, manifest }); - expect(report.dimensions.palette.verdict).toBe("uncovered"); - expect(report.dimensions.newDimension.verdict).toBe("uncovered"); - expect(report.overall.verdict).toBe("uncovered"); - // Dump for the caller to verify the produced shape. - process.stdout.write(`${formatGateReportJSON(report)}\n`); - }); -}); diff --git a/packages/ghost/test/ghost-core/check-route.test.ts b/packages/ghost/test/ghost-core/check-route.test.ts index 7cf08b3f..a28e3f5d 100644 --- a/packages/ghost/test/ghost-core/check-route.test.ts +++ b/packages/ghost/test/ghost-core/check-route.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + assembleGraph, GHOST_SURFACES_SCHEMA, type GhostCheckDocument, type GhostSurfacesDocument, @@ -27,6 +28,8 @@ const SURFACES: GhostSurfacesDocument = { ], }; +const GRAPH = assembleGraph({ surfaces: SURFACES }); + const CHECKS = [ check("brand", "core"), check("checkout-color", "checkout"), @@ -41,20 +44,18 @@ function names(routed: ReturnType): string[] { describe("selectChecksForSurfaces", () => { it("selects own + ancestor (core) checks for a touched surface", () => { - const routed = selectChecksForSurfaces(CHECKS, SURFACES, ["checkout"]); + const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["checkout"]); expect(names(routed)).toEqual(["brand", "checkout-color", "unplaced"]); }); it("excludes checks on sibling branches", () => { - const routed = selectChecksForSurfaces(CHECKS, SURFACES, ["checkout"]); + const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["checkout"]); expect(names(routed)).not.toContain("email-links"); expect(names(routed)).not.toContain("marketing-unsub"); }); it("cascades multiple ancestor levels", () => { - const routed = selectChecksForSurfaces(CHECKS, SURFACES, [ - "email-marketing", - ]); + const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["email-marketing"]); // own marketing + ancestor email + ancestor core (brand, unplaced) expect(names(routed)).toEqual([ "brand", @@ -65,9 +66,7 @@ describe("selectChecksForSurfaces", () => { }); it("tags provenance own vs. ancestor", () => { - const routed = selectChecksForSurfaces(CHECKS, SURFACES, [ - "email-marketing", - ]); + const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["email-marketing"]); const byName = Object.fromEntries( routed.map((r) => [r.check.frontmatter.name, r.relevance]), ); @@ -83,12 +82,12 @@ describe("selectChecksForSurfaces", () => { }); it("with no touched surfaces, only core checks apply", () => { - const routed = selectChecksForSurfaces(CHECKS, SURFACES, []); + const routed = selectChecksForSurfaces(CHECKS, GRAPH, []); expect(names(routed)).toEqual(["brand", "unplaced"]); }); it("an unplaced check governs core and applies to every diff", () => { - const routed = selectChecksForSurfaces(CHECKS, SURFACES, ["checkout"]); + const routed = selectChecksForSurfaces(CHECKS, GRAPH, ["checkout"]); expect(names(routed)).toContain("unplaced"); }); }); diff --git a/packages/ghost/test/ghost-core/decision-vocabulary.test.ts b/packages/ghost/test/ghost-core/decision-vocabulary.test.ts deleted file mode 100644 index 1178e6ed..00000000 --- a/packages/ghost/test/ghost-core/decision-vocabulary.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - CANONICAL_DECISION_DIMENSIONS, - closestCanonical, - isCanonicalDimension, - resolveDecisionKind, -} from "#ghost-core"; - -describe("CANONICAL_DECISION_DIMENSIONS", () => { - it("contains the documented 13 dimensions", () => { - expect(CANONICAL_DECISION_DIMENSIONS).toHaveLength(13); - expect(CANONICAL_DECISION_DIMENSIONS).toContain("color-strategy"); - expect(CANONICAL_DECISION_DIMENSIONS).toContain("font-sourcing"); - expect(CANONICAL_DECISION_DIMENSIONS).toContain("composition-patterns"); - }); - - it("has no duplicates", () => { - const set = new Set(CANONICAL_DECISION_DIMENSIONS); - expect(set.size).toBe(CANONICAL_DECISION_DIMENSIONS.length); - }); -}); - -describe("isCanonicalDimension", () => { - it("accepts every canonical slug", () => { - for (const slug of CANONICAL_DECISION_DIMENSIONS) { - expect(isCanonicalDimension(slug)).toBe(true); - } - }); - - it("rejects unknown slugs", () => { - expect(isCanonicalDimension("warm-neutrals")).toBe(false); - expect(isCanonicalDimension("color-system")).toBe(false); - }); - - it("normalizes whitespace and underscores before checking", () => { - expect(isCanonicalDimension("color_strategy")).toBe(true); - expect(isCanonicalDimension(" Color-Strategy ")).toBe(true); - }); -}); - -describe("closestCanonical", () => { - it("returns the slug itself when already canonical", () => { - expect(closestCanonical("color-strategy")).toBe("color-strategy"); - expect(closestCanonical("motion")).toBe("motion"); - }); - - it("resolves direct synonyms", () => { - expect(closestCanonical("color-system")).toBe("color-strategy"); - expect(closestCanonical("palette-strategy")).toBe("color-strategy"); - expect(closestCanonical("type-stack")).toBe("typography-voice"); - expect(closestCanonical("radius-philosophy")).toBe("shape-language"); - expect(closestCanonical("corner-treatment")).toBe("shape-language"); - expect(closestCanonical("shadow-system")).toBe("elevation"); - expect(closestCanonical("theme-system")).toBe("theming-architecture"); - expect(closestCanonical("response-shapes")).toBe("composition-patterns"); - }); - - it("falls back to token affinity for novel slugs", () => { - expect(closestCanonical("color-cadence")).toBe("color-strategy"); - expect(closestCanonical("custom-shadow-language")).toBe("elevation"); - expect(closestCanonical("fancy-motion-rules")).toBe("motion"); - expect(closestCanonical("article-patterns")).toBe("composition-patterns"); - }); - - it("returns null when no canonical wins clearly", () => { - expect(closestCanonical("entirely-novel-decision")).toBeNull(); - expect(closestCanonical("")).toBeNull(); - }); - - it("returns null on a tie between dimensions", () => { - // "color" → color-strategy, "shadow" → elevation: tied at 1 each - expect(closestCanonical("color-shadow")).toBeNull(); - }); - - it("normalizes input before matching", () => { - expect(closestCanonical("Color_Strategy")).toBe("color-strategy"); - expect(closestCanonical(" shadow_system ")).toBe("elevation"); - }); -}); - -describe("resolveDecisionKind", () => { - it("prefers explicit dimension_kind when canonical", () => { - expect( - resolveDecisionKind({ - dimension: "system-color-deference", - dimension_kind: "color-strategy", - }), - ).toBe("color-strategy"); - }); - - it("falls back to dimension when canonical and kind absent", () => { - expect(resolveDecisionKind({ dimension: "shape-language" })).toBe( - "shape-language", - ); - }); - - it("returns null when neither is canonical", () => { - expect(resolveDecisionKind({ dimension: "warm-neutrals" })).toBeNull(); - expect( - resolveDecisionKind({ - dimension: "warm-neutrals", - dimension_kind: "also-not-canonical", - }), - ).toBeNull(); - }); - - it("ignores a non-canonical kind even when dimension is canonical", () => { - // dimension_kind is opt-in metadata; if author typoed it, fall through - // to the dimension itself rather than silently failing rollup. - expect( - resolveDecisionKind({ - dimension: "color-strategy", - dimension_kind: "typo-here", - }), - ).toBe("color-strategy"); - }); -}); diff --git a/packages/ghost/test/ghost-core/graph-fold.test.ts b/packages/ghost/test/ghost-core/graph-fold.test.ts new file mode 100644 index 00000000..ac6f87a3 --- /dev/null +++ b/packages/ghost/test/ghost-core/graph-fold.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + ancestorChain, + assembleGraph, + GHOST_GRAPH_ROOT_ID, + type GhostNodeDocument, +} from "../../src/ghost-core/index.js"; + +function nodeDoc( + frontmatter: GhostNodeDocument["frontmatter"], + body = "Prose.", +): GhostNodeDocument { + return { frontmatter, body }; +} + +describe("assembleGraph (node + surfaces fold)", () => { + it("folds authored node files into the graph", () => { + const graph = assembleGraph({ + nodeFiles: [ + nodeDoc( + { + id: "checkout-trust", + under: "checkout", + relates: [{ to: "core-trust", as: "reinforces" }], + incarnation: "web", + }, + "Reduce felt risk near payment.", + ), + ], + }); + const node = graph.nodes.get("checkout-trust"); + expect(node?.origin).toBe("node-file"); + expect(node?.body).toBe("Reduce felt risk near payment."); + expect(node?.incarnation).toBe("web"); + expect(node?.relates).toEqual([{ to: "core-trust", as: "reinforces" }]); + }); + + it("seeds the containment tree from surfaces and resolves ancestors", () => { + const graph = assembleGraph({ + surfaces: { + schema: "ghost.surfaces/v1", + surfaces: [ + { id: "checkout", parent: "core" }, + { id: "payment", parent: "checkout" }, + ], + }, + }); + expect(graph.parents.get("payment")).toBe("checkout"); + expect(graph.parents.get("checkout")).toBe(GHOST_GRAPH_ROOT_ID); + expect(ancestorChain(graph, "payment")).toEqual([ + "checkout", + GHOST_GRAPH_ROOT_ID, + ]); + }); + + it("attaches an under-less node to the implicit core root", () => { + const graph = assembleGraph({ + nodeFiles: [nodeDoc({ id: "top-level" })], + }); + expect(ancestorChain(graph, "top-level")).toEqual([GHOST_GRAPH_ROOT_ID]); + }); + + it("records children for downward traversal", () => { + const graph = assembleGraph({ + surfaces: { + schema: "ghost.surfaces/v1", + surfaces: [ + { id: "checkout", parent: "core" }, + { id: "email", parent: "core" }, + ], + }, + }); + expect(graph.children.get(GHOST_GRAPH_ROOT_ID)?.sort()).toEqual([ + "checkout", + "email", + ]); + }); +}); diff --git a/packages/ghost/test/ghost-core/graph-slice.test.ts b/packages/ghost/test/ghost-core/graph-slice.test.ts new file mode 100644 index 00000000..6caff93c --- /dev/null +++ b/packages/ghost/test/ghost-core/graph-slice.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + assembleGraph, + type GhostNodeDocument, + resolveGraphSlice, +} from "../../src/ghost-core/index.js"; + +function nodeDoc( + frontmatter: GhostNodeDocument["frontmatter"], + body = "Prose.", +): GhostNodeDocument { + return { frontmatter, body }; +} + +const surfaces = { + schema: "ghost.surfaces/v1" as const, + surfaces: [ + { id: "checkout", parent: "core" }, + { id: "payment", parent: "checkout" }, + ], +}; + +function provenanceOf(slice: ReturnType, id: string) { + return slice.nodes.find((n) => n.id === id)?.provenance; +} + +describe("resolveGraphSlice", () => { + it("tags own, ancestor, and edge provenance", () => { + const graph = assembleGraph({ + surfaces, + nodeFiles: [ + nodeDoc({ id: "brand-voice", under: "core" }, "Calm everywhere."), + nodeDoc( + { + id: "checkout-trust", + under: "checkout", + relates: [{ to: "density", as: "contrasts" }], + }, + "Reduce felt risk.", + ), + nodeDoc({ id: "density", under: "dashboard" }, "Pack it in."), + ], + }); + const slice = resolveGraphSlice(graph, "checkout"); + + expect(provenanceOf(slice, "checkout-trust")).toEqual({ kind: "own" }); + expect(provenanceOf(slice, "brand-voice")).toEqual({ + kind: "ancestor", + from: "core", + }); + expect(provenanceOf(slice, "density")).toEqual({ + kind: "edge", + via: "contrasts", + from: "checkout-trust", + }); + }); + + it("cascades through multiple ancestor levels", () => { + const graph = assembleGraph({ + surfaces, + nodeFiles: [ + nodeDoc({ id: "brand-voice", under: "core" }, "Calm."), + nodeDoc({ id: "checkout-clarity", under: "checkout" }, "Plain."), + nodeDoc({ id: "pay-now", under: "payment" }, "One tap."), + ], + }); + const slice = resolveGraphSlice(graph, "payment"); + expect(provenanceOf(slice, "pay-now")).toEqual({ kind: "own" }); + expect(provenanceOf(slice, "checkout-clarity")).toEqual({ + kind: "ancestor", + from: "checkout", + }); + expect(provenanceOf(slice, "brand-voice")).toEqual({ + kind: "ancestor", + from: "core", + }); + expect(slice.ancestors).toEqual(["checkout"]); + }); + + it("filters by incarnation: essence always in, matching in, mismatched out", () => { + const graph = assembleGraph({ + surfaces, + nodeFiles: [ + nodeDoc({ id: "brand-voice", under: "core" }, "Calm."), // essence + nodeDoc( + { id: "checkout-web", under: "checkout", incarnation: "web" }, + "Inline.", + ), + nodeDoc( + { id: "checkout-mail", under: "checkout", incarnation: "email" }, + "Subject.", + ), + ], + }); + const slice = resolveGraphSlice(graph, "checkout", { incarnation: "web" }); + const ids = slice.nodes.map((n) => n.id).sort(); + expect(ids).toContain("brand-voice"); // essence + expect(ids).toContain("checkout-web"); // matches + expect(ids).not.toContain("checkout-mail"); // mismatched + expect(slice.incarnation).toBe("web"); + }); + + it("includes every node when no incarnation filter is given", () => { + const graph = assembleGraph({ + surfaces, + nodeFiles: [ + nodeDoc( + { id: "checkout-web", under: "checkout", incarnation: "web" }, + "x", + ), + nodeDoc( + { id: "checkout-mail", under: "checkout", incarnation: "email" }, + "y", + ), + ], + }); + const slice = resolveGraphSlice(graph, "checkout"); + const ids = slice.nodes.map((n) => n.id).sort(); + expect(ids).toEqual(["checkout-mail", "checkout-web"]); + expect(slice.incarnation).toBeUndefined(); + }); + + it("follows relates edges one hop only (no recursion)", () => { + const graph = assembleGraph({ + surfaces, + nodeFiles: [ + nodeDoc( + { id: "a", under: "checkout", relates: [{ to: "b" }] }, + "node a", + ), + nodeDoc({ id: "b", under: "dashboard", relates: [{ to: "c" }] }, "b"), + nodeDoc({ id: "c", under: "dashboard" }, "c"), + ], + }); + const slice = resolveGraphSlice(graph, "checkout"); + const ids = slice.nodes.map((n) => n.id); + expect(ids).toContain("a"); // own + expect(ids).toContain("b"); // one hop from a + expect(ids).not.toContain("c"); // two hops — excluded + }); +}); diff --git a/packages/ghost/test/ghost-core/node-schema.test.ts b/packages/ghost/test/ghost-core/node-schema.test.ts new file mode 100644 index 00000000..5ea3c89a --- /dev/null +++ b/packages/ghost/test/ghost-core/node-schema.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + GHOST_NODE_RELATION_KINDS, + type GhostNodeDocument, + lintGhostNode, + parseNode, + serializeNode, +} from "../../src/ghost-core/node/index.js"; + +function node(frontmatter: string, body = "Prose body."): string { + return `---\n${frontmatter}\n---\n\n${body}\n`; +} + +describe("ghost.node/v1 schema", () => { + it("parses and validates a minimal node (id only)", () => { + const { node: doc, report } = parseNode(node("id: checkout")); + expect(report.errors).toBe(0); + expect(doc?.frontmatter.id).toBe("checkout"); + expect(doc?.body).toBe("Prose body."); + }); + + it("accepts dashed and dotted ids (permissive charset)", () => { + for (const id of ["core", "checkout-trust-signals", "email.marketing"]) { + expect(lintGhostNode(node(`id: ${id}`)).errors).toBe(0); + } + }); + + it("rejects only genuinely malformed ids", () => { + for (const id of ["Checkout", "-leading", "_leading"]) { + expect(lintGhostNode(node(`id: ${id}`)).errors).toBeGreaterThan(0); + } + }); + + it("errors when frontmatter is missing", () => { + const report = lintGhostNode("# just a heading\n\nno frontmatter"); + expect(report.errors).toBe(1); + expect(report.issues[0]?.rule).toBe("node-missing-frontmatter"); + }); + + it("accepts the closed relates qualifier set and rejects unknowns", () => { + for (const as of GHOST_NODE_RELATION_KINDS) { + const report = lintGhostNode( + node(`id: a\nrelates:\n - to: core\n as: ${as}`), + ); + expect(report.errors).toBe(0); + } + const bad = lintGhostNode( + node("id: a\nrelates:\n - to: core\n as: governs"), + ); + expect(bad.errors).toBeGreaterThan(0); + }); + + it("allows untyped relations (qualifier omitted)", () => { + const report = lintGhostNode(node("id: a\nrelates:\n - to: core")); + expect(report.errors).toBe(0); + }); + + it("accepts local and cross-package refs in under/relates", () => { + const report = lintGhostNode( + node( + "id: checkout-trust\nunder: checkout\nrelates:\n - to: 'brand:core-trust'\n as: reinforces", + ), + ); + expect(report.errors).toBe(0); + }); + + it("rejects malformed refs", () => { + expect( + lintGhostNode(node("id: a\nunder: 'Bad Ref'")).errors, + ).toBeGreaterThan(0); + }); + + it("accepts an arbitrary incarnation string", () => { + expect(lintGhostNode(node("id: a\nincarnation: billboard")).errors).toBe(0); + expect(lintGhostNode(node("id: a\nincarnation: voice-kiosk")).errors).toBe( + 0, + ); + }); + + it("rejects unknown frontmatter keys (strict)", () => { + expect(lintGhostNode(node("id: a\nsurface: checkout")).errors).toBe(1); + }); + + it("round-trips through serialize/parse", () => { + const original: GhostNodeDocument = { + frontmatter: { + id: "checkout-trust-signals", + under: "checkout", + relates: [ + { to: "core-trust", as: "reinforces" }, + { to: "checkout-density" }, + ], + incarnation: "web", + }, + body: "Near payment, reduce felt risk.", + }; + const reparsed = parseNode(serializeNode(original)); + expect(reparsed.report.errors).toBe(0); + expect(reparsed.node?.frontmatter).toEqual(original.frontmatter); + expect(reparsed.node?.body).toBe(original.body); + }); + + it("preserves the body verbatim, stripping only frontmatter", () => { + const body = "# Heading\n\n- a list item\n\nA paragraph with `code`."; + const { node: doc } = parseNode(node("id: a", body)); + expect(doc?.body).toBe(body); + }); +}); diff --git a/packages/ghost/test/ghost-core/perceptual-prior.test.ts b/packages/ghost/test/ghost-core/perceptual-prior.test.ts deleted file mode 100644 index 8cb2f9de..00000000 --- a/packages/ghost/test/ghost-core/perceptual-prior.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - CANONICAL_DECISION_DIMENSIONS, - computeCheckSeverity, - DEFAULT_MATCH, - DEFAULT_TOLERANCE, - escalateForPresence, - escalateTier, - PERCEPTUAL_TIER, - type PerceptualTier, - resolveMatchShape, - resolveTolerance, - TIER_SEVERITY, - tierForCanonical, -} from "#ghost-core"; - -describe("PERCEPTUAL_TIER", () => { - it("covers every canonical dimension", () => { - for (const dim of CANONICAL_DECISION_DIMENSIONS) { - expect(PERCEPTUAL_TIER[dim]).toBeDefined(); - } - }); - - it("places color-strategy and font-sourcing in loud", () => { - expect(PERCEPTUAL_TIER["color-strategy"]).toBe("loud"); - expect(PERCEPTUAL_TIER["font-sourcing"]).toBe("loud"); - }); - - it("places shape-language and elevation in structural", () => { - expect(PERCEPTUAL_TIER["shape-language"]).toBe("structural"); - expect(PERCEPTUAL_TIER.elevation).toBe("structural"); - expect(PERCEPTUAL_TIER["composition-patterns"]).toBe("structural"); - }); - - it("places spatial-system, density, motion in rhythmic", () => { - expect(PERCEPTUAL_TIER["spatial-system"]).toBe("rhythmic"); - expect(PERCEPTUAL_TIER.density).toBe("rhythmic"); - expect(PERCEPTUAL_TIER.motion).toBe("rhythmic"); - }); -}); - -describe("TIER_SEVERITY", () => { - it("maps tiers to drift severities in perceptual order", () => { - expect(TIER_SEVERITY.loud).toBe("critical"); - expect(TIER_SEVERITY.structural).toBe("serious"); - expect(TIER_SEVERITY.rhythmic).toBe("nit"); - }); -}); - -describe("escalateTier", () => { - it("rhythmic → structural", () => { - expect(escalateTier("rhythmic")).toBe("structural"); - }); - - it("structural → loud", () => { - expect(escalateTier("structural")).toBe("loud"); - }); - - it("loud saturates at loud", () => { - expect(escalateTier("loud")).toBe("loud"); - }); -}); - -describe("tierForCanonical", () => { - it("returns the canonical tier for a known slug", () => { - expect(tierForCanonical("motion")).toBe("rhythmic"); - expect(tierForCanonical("color-strategy")).toBe("loud"); - }); - - it("returns structural for unknown / undefined", () => { - expect(tierForCanonical(undefined)).toBe("structural"); - expect(tierForCanonical("not-a-real-dimension")).toBe("structural"); - }); -}); - -describe("escalateForPresence", () => { - it("escalates when survey count is below floor", () => { - expect(escalateForPresence("rhythmic", 0, 0)).toBe("structural"); - expect(escalateForPresence("rhythmic", 1, 2)).toBe("structural"); - }); - - it("does not escalate when survey count is above floor", () => { - expect(escalateForPresence("rhythmic", 5, 2)).toBe("rhythmic"); - expect(escalateForPresence("structural", 10, 0)).toBe("structural"); - }); - - it("treats count == floor as triggering escalation", () => { - // floor is the boundary at which escalation kicks in (≤ floor → escalate) - expect(escalateForPresence("rhythmic", 2, 2)).toBe("structural"); - }); - - it("defaults presence floor to 0", () => { - expect(escalateForPresence("rhythmic", 0)).toBe("structural"); - expect(escalateForPresence("rhythmic", 1)).toBe("rhythmic"); - }); - - it("loud saturates even with escalation triggered", () => { - expect(escalateForPresence("loud", 0, 0)).toBe("loud"); - }); -}); - -describe("computeCheckSeverity", () => { - it("honors explicit severity override", () => { - expect( - computeCheckSeverity( - { canonical: "spatial-system", severity: "critical" }, - 100, - ), - ).toBe("critical"); - }); - - it("derives from canonical tier when no override", () => { - expect(computeCheckSeverity({ canonical: "color-strategy" }, 50)).toBe( - "critical", - ); - expect(computeCheckSeverity({ canonical: "shape-language" }, 50)).toBe( - "serious", - ); - expect(computeCheckSeverity({ canonical: "spatial-system" }, 50)).toBe( - "nit", - ); - }); - - it("escalates a rhythmic check when survey count crosses floor", () => { - // motion at 2 occurrences with floor of 2 → escalates rhythmic → structural → serious - expect( - computeCheckSeverity({ canonical: "motion", presence_floor: 2 }, 2), - ).toBe("serious"); - }); - - it("does not escalate when survey count exceeds floor", () => { - expect( - computeCheckSeverity({ canonical: "motion", presence_floor: 2 }, 12), - ).toBe("nit"); - }); - - it("escalates structural to loud (critical) at zero presence", () => { - expect( - computeCheckSeverity({ canonical: "elevation", presence_floor: 0 }, 0), - ).toBe("critical"); - }); - - it("treats unknown canonical as structural with conservative escalation", () => { - expect(computeCheckSeverity({ canonical: "novel-dimension" }, 5)).toBe( - "serious", - ); - expect(computeCheckSeverity({ canonical: "novel-dimension" }, 0)).toBe( - "critical", - ); - }); -}); - -describe("DEFAULT_MATCH", () => { - it("color is exact", () => { - expect(DEFAULT_MATCH.color).toBe("exact"); - }); - - it("spacing is band", () => { - expect(DEFAULT_MATCH.spacing).toBe("band"); - }); - - it("type-size is percent; type-family and type-weight are exact", () => { - expect(DEFAULT_MATCH["type-size"]).toBe("percent"); - expect(DEFAULT_MATCH["type-family"]).toBe("exact"); - expect(DEFAULT_MATCH["type-weight"]).toBe("exact"); - }); - - it("radius and shadow are structural", () => { - expect(DEFAULT_MATCH.radius).toBe("structural"); - expect(DEFAULT_MATCH.shadow).toBe("structural"); - }); -}); - -describe("DEFAULT_TOLERANCE", () => { - it("exact and structural have no tolerance", () => { - expect(DEFAULT_TOLERANCE.exact).toBeUndefined(); - expect(DEFAULT_TOLERANCE.structural).toBeUndefined(); - }); - - it("band defaults to ±2", () => { - expect(DEFAULT_TOLERANCE.band).toBe(2); - }); - - it("percent defaults to ±10%", () => { - expect(DEFAULT_TOLERANCE.percent).toBeCloseTo(0.1); - }); -}); - -describe("resolveMatchShape", () => { - it("explicit match wins", () => { - expect(resolveMatchShape({ match: "percent", kind: "color" })).toBe( - "percent", - ); - }); - - it("falls back to kind default", () => { - expect(resolveMatchShape({ kind: "spacing" })).toBe("band"); - }); - - it("returns exact when no signal", () => { - expect(resolveMatchShape({})).toBe("exact"); - }); -}); - -describe("resolveTolerance", () => { - it("explicit tolerance wins", () => { - expect(resolveTolerance({ tolerance: 4, kind: "spacing" })).toBe(4); - }); - - it("derives from match shape default", () => { - expect(resolveTolerance({ kind: "spacing" })).toBe(2); - expect(resolveTolerance({ kind: "type-size" })).toBeCloseTo(0.1); - }); - - it("returns undefined for exact / structural", () => { - expect(resolveTolerance({ kind: "color" })).toBeUndefined(); - expect(resolveTolerance({ kind: "radius" })).toBeUndefined(); - }); -}); - -describe("perceptual-prior tier coverage", () => { - it("every canonical dimension lands in one of three tiers", () => { - const tiers = new Set(["loud", "structural", "rhythmic"]); - for (const dim of CANONICAL_DECISION_DIMENSIONS) { - expect(tiers.has(PERCEPTUAL_TIER[dim])).toBe(true); - } - }); -}); diff --git a/packages/ghost/test/ghost-core/surfaces-ground.test.ts b/packages/ghost/test/ghost-core/surfaces-ground.test.ts deleted file mode 100644 index 1e8ea169..00000000 --- a/packages/ghost/test/ghost-core/surfaces-ground.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - GHOST_FINGERPRINT_SCHEMA, - GHOST_SURFACES_SCHEMA, - type GhostFingerprintDocument, - type GhostSurfacesDocument, - groundSurface, -} from "../../src/ghost-core/index.js"; - -const SURFACES: GhostSurfacesDocument = { - schema: GHOST_SURFACES_SCHEMA, - surfaces: [{ id: "checkout", parent: "core" }], -}; - -function fingerprint(): GhostFingerprintDocument { - return { - schema: GHOST_FINGERPRINT_SCHEMA, - intent: { - summary: {}, - situations: [], - principles: [ - { id: "brand", principle: "Warm everywhere.", surface: "core" }, - { - id: "co-clarity", - principle: "Checkout is plain.", - surface: "checkout", - }, - ], - experience_contracts: [], - }, - inventory: { - building_blocks: {}, - exemplars: [ - { - id: "good-checkout", - path: "apps/checkout/good.tsx", - title: "Good checkout", - surface: "checkout", - }, - { id: "elsewhere", path: "x.tsx", surface: "email" }, - ], - sources: [], - }, - composition: { - patterns: [ - { - id: "co-token", - kind: "visual", - pattern: "Tokens.", - surface: "checkout", - }, - ], - }, - }; -} - -describe("groundSurface", () => { - it("projects principles/contracts into why, with inheritance", () => { - const g = groundSurface(SURFACES, fingerprint(), "checkout"); - const refs = g.why.map((i) => i.ref); - expect(refs).toContain("intent.principle:co-clarity"); // own - expect(refs).toContain("intent.principle:brand"); // inherited from core - }); - - it("projects patterns and exemplars into what, with paths", () => { - const g = groundSurface(SURFACES, fingerprint(), "checkout"); - const pattern = g.what.find((i) => i.kind === "pattern"); - const exemplar = g.what.find((i) => i.kind === "exemplar"); - expect(pattern?.ref).toBe("composition.pattern:co-token"); - expect(exemplar?.ref).toBe("inventory.exemplar:good-checkout"); - expect(exemplar?.path).toBe("apps/checkout/good.tsx"); - }); - - it("tags inherited grounding by provenance", () => { - const g = groundSurface(SURFACES, fingerprint(), "checkout"); - const brand = g.why.find((i) => i.ref === "intent.principle:brand"); - expect(brand?.provenance).toEqual({ kind: "ancestor", surface: "core" }); - }); - - it("excludes nodes from sibling surfaces", () => { - const g = groundSurface(SURFACES, fingerprint(), "checkout"); - expect(g.what.map((i) => i.ref)).not.toContain( - "inventory.exemplar:elsewhere", - ); - }); - - it("returns an empty-but-valid grounding for a surface with no nodes", () => { - const empty: GhostFingerprintDocument = { - schema: GHOST_FINGERPRINT_SCHEMA, - intent: { - summary: {}, - situations: [], - principles: [], - experience_contracts: [], - }, - inventory: { building_blocks: {}, exemplars: [], sources: [] }, - composition: { patterns: [] }, - }; - const g = groundSurface(SURFACES, empty, "checkout"); - expect(g.surface).toBe("checkout"); - expect(g.why).toEqual([]); - expect(g.what).toEqual([]); - }); -}); diff --git a/packages/ghost/test/ghost-core/surfaces-resolve.test.ts b/packages/ghost/test/ghost-core/surfaces-resolve.test.ts deleted file mode 100644 index 7cca9977..00000000 --- a/packages/ghost/test/ghost-core/surfaces-resolve.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildSurfaceMenu, - GHOST_FINGERPRINT_SCHEMA, - GHOST_SURFACES_SCHEMA, - type GhostFingerprintDocument, - type GhostSurfacesDocument, - resolveSurfaceSlice, -} from "../../src/ghost-core/index.js"; - -function surfaces( - list: GhostSurfacesDocument["surfaces"], -): GhostSurfacesDocument { - return { schema: GHOST_SURFACES_SCHEMA, surfaces: list }; -} - -function fingerprint( - principles: Array<{ id: string; principle: string; surface?: string }>, -): GhostFingerprintDocument { - return { - schema: GHOST_FINGERPRINT_SCHEMA, - intent: { - summary: {}, - situations: [], - principles, - experience_contracts: [], - }, - inventory: { building_blocks: {}, exemplars: [], sources: [] }, - composition: { patterns: [] }, - }; -} - -const TREE = surfaces([ - { id: "email", description: "Email.", parent: "core" }, - { - id: "email-marketing", - description: "Marketing email.", - parent: "email", - edges: [{ kind: "composes", to: "checkout" }], - }, - { id: "checkout", description: "Checkout.", parent: "core" }, -]); - -describe("resolveSurfaceSlice", () => { - it("includes own nodes placed on the surface", () => { - const slice = resolveSurfaceSlice( - TREE, - fingerprint([{ id: "p", principle: "x", surface: "checkout" }]), - "checkout", - ); - expect(slice.principles).toHaveLength(1); - expect(slice.principles[0].provenance).toEqual({ kind: "own" }); - }); - - it("cascades ancestor nodes down the tree", () => { - const slice = resolveSurfaceSlice( - TREE, - fingerprint([ - { id: "root", principle: "everywhere", surface: "core" }, - { id: "mid", principle: "email-wide", surface: "email" }, - { id: "leaf", principle: "marketing", surface: "email-marketing" }, - ]), - "email-marketing", - ); - const byId = Object.fromEntries( - slice.principles.map((entry) => [entry.node.id, entry.provenance]), - ); - expect(byId.leaf).toEqual({ kind: "own" }); - expect(byId.mid).toEqual({ kind: "ancestor", surface: "email" }); - expect(byId.root).toEqual({ kind: "ancestor", surface: "core" }); - }); - - it("does not include sibling/descendant nodes", () => { - const slice = resolveSurfaceSlice( - TREE, - fingerprint([ - { id: "leaf", principle: "marketing", surface: "email-marketing" }, - ]), - "email", - ); - // email should not pull in its child's nodes via cascade. - expect(slice.principles.map((entry) => entry.node.id)).not.toContain( - "leaf", - ); - }); - - it("includes one-hop typed-edge contributions tagged by kind", () => { - const slice = resolveSurfaceSlice( - TREE, - fingerprint([ - { id: "co", principle: "checkout copy", surface: "checkout" }, - ]), - "email-marketing", - ); - const co = slice.principles.find((entry) => entry.node.id === "co"); - expect(co?.provenance).toEqual({ - kind: "edge", - edge: "composes", - surface: "checkout", - }); - }); - - it("treats unplaced nodes as core (cascades everywhere)", () => { - const slice = resolveSurfaceSlice( - TREE, - fingerprint([{ id: "loose", principle: "no placement" }]), - "checkout", - ); - const loose = slice.principles.find((entry) => entry.node.id === "loose"); - expect(loose?.provenance).toEqual({ kind: "ancestor", surface: "core" }); - }); - - it("returns an empty-but-valid slice for a surface with no nodes", () => { - const slice = resolveSurfaceSlice(TREE, fingerprint([]), "checkout"); - expect(slice.surface).toBe("checkout"); - expect(slice.principles).toEqual([]); - }); - - it("works with no surfaces document (core only)", () => { - const slice = resolveSurfaceSlice( - undefined, - fingerprint([{ id: "p", principle: "x", surface: "core" }]), - "core", - ); - expect(slice.principles).toHaveLength(1); - expect(slice.ancestors).toEqual([]); - }); -}); - -describe("buildSurfaceMenu", () => { - it("lists surfaces with descriptions and the implicit core, sorted by id", () => { - const menu = buildSurfaceMenu(TREE); - expect(menu.map((entry) => entry.id)).toEqual([ - "checkout", - "core", - "email", - "email-marketing", - ]); - const core = menu.find((entry) => entry.id === "core"); - expect(core?.description).toBeTruthy(); - }); - - it("returns just core when there is no surfaces document", () => { - const menu = buildSurfaceMenu(undefined); - expect(menu).toHaveLength(1); - expect(menu[0].id).toBe("core"); - }); - - it("carries edges on the menu entry", () => { - const menu = buildSurfaceMenu(TREE); - const marketing = menu.find((entry) => entry.id === "email-marketing"); - expect(marketing?.edges).toEqual([{ kind: "composes", to: "checkout" }]); - }); -}); diff --git a/packages/ghost/test/migrate-legacy.test.ts b/packages/ghost/test/migrate-legacy.test.ts index 93d2ee53..7fb597c2 100644 --- a/packages/ghost/test/migrate-legacy.test.ts +++ b/packages/ghost/test/migrate-legacy.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from "vitest"; import { GhostSurfacesSchema, - lintGhostFingerprint, + lintGhostNode, lintGhostSurfaces, } from "../src/ghost-core/index.js"; import { type LegacyPackageInput, looksLegacy, + migratedNodeFiles, migrateLegacyPackage, } from "../src/scan/migrate-legacy.js"; @@ -125,22 +126,18 @@ describe("migrateLegacyPackage", () => { expect(principles[0]).toHaveProperty("applies_to"); }); - it("produces a package that passes surfaces and fingerprint lint", () => { + it("produces a node package: valid surfaces + parseable nodes", () => { const result = migrateLegacyPackage(legacy()); - const surfaceIds = (result.surfaces.surfaces as Array<{ id: string }>).map( - (s) => s.id, - ); expect(lintGhostSurfaces(result.surfaces).errors).toBe(0); - const fingerprint = { - schema: "ghost.fingerprint/v1", - intent: result.intent ?? {}, - inventory: result.inventory ?? {}, - composition: result.composition ?? {}, - }; - const report = lintGhostFingerprint(fingerprint, { surfaceIds }); - expect(report.errors).toBe(0); + // The migration emits one prose node per facet entry. + const files = migratedNodeFiles(result); + expect(files.length).toBeGreaterThan(0); + for (const file of files) { + expect(file.relativePath).toMatch(/^nodes\/.+\.md$/); + expect(lintGhostNode(file.content).errors).toBe(0); + } }); }); diff --git a/packages/ghost/test/public-exports.test.ts b/packages/ghost/test/public-exports.test.ts index c599ffa7..70dc1e18 100644 --- a/packages/ghost/test/public-exports.test.ts +++ b/packages/ghost/test/public-exports.test.ts @@ -10,10 +10,9 @@ const hasBuiltExports = existsSync( describe.runIf(hasBuiltExports)("built public exports", () => { it("exposes fingerprint-first package subpaths", async () => { - const [fingerprint, scan, compareApi] = await Promise.all([ + const [fingerprint, scan] = await Promise.all([ import("@anarchitecture/ghost/fingerprint"), import("@anarchitecture/ghost/scan"), - import("@anarchitecture/ghost/compare"), ]); const fingerprintApi = fingerprint as Record; @@ -21,8 +20,9 @@ describe.runIf(hasBuiltExports)("built public exports", () => { expect(fingerprintApi.initFingerprintPackage).toBeTypeOf("function"); expect(fingerprintApi.lintFingerprintPackage).toBeTypeOf("function"); - expect(fingerprintApi.verifyFingerprintPackage).toBeTypeOf("function"); - expect(fingerprintApi.loadFingerprint).toBeTypeOf("function"); + expect(fingerprintApi.loadFingerprintPackage).toBeTypeOf("function"); + // Direct fingerprint.md loading was removed with compare/drift/fleet. + expect(fingerprintApi.loadFingerprint).toBeUndefined(); expect(fingerprintApi.writePackageContextBundle).toBeUndefined(); expect(fingerprintApi.writeContextBundle).toBeUndefined(); @@ -32,9 +32,5 @@ describe.runIf(hasBuiltExports)("built public exports", () => { expect(scanApi.initFingerprintPackage).toBeUndefined(); expect(scanApi.lintFingerprintPackage).toBeUndefined(); expect(scanApi.writePackageContextBundle).toBeUndefined(); - - expect(compareApi.compare).toBeTypeOf("function"); - expect(compareApi.compareFingerprints).toBeTypeOf("function"); - expect(compareApi.formatComparison).toBeTypeOf("function"); }); }); diff --git a/packages/ghost/test/scan-status.test.ts b/packages/ghost/test/scan-status.test.ts index 3d58778d..c7459b69 100644 --- a/packages/ghost/test/scan-status.test.ts +++ b/packages/ghost/test/scan-status.test.ts @@ -20,249 +20,74 @@ describe("scanStatus contribution", () => { }); it("reports missing before manifest.yml exists", async () => { - const status = await scanStatus(dir); + const status = await scanStatus(join(dir, ".ghost")); expect(status.fingerprint.state).toBe("missing"); expect(status.recommended_next).toBe("fingerprint"); expect(status.contribution.state).toBe("missing"); - expect(status.contribution.contributing_facets).toEqual([]); - expect(status.contribution.absent_facets).toEqual([ - "intent", - "inventory", - "composition", - ]); - expect(status.contribution.reasons.join(" ")).toContain( - "manifest.yml is missing", - ); + expect(status.contribution.node_count).toBe(0); }); - it("reports empty contribution for manifest-only packages", async () => { - await writePackage(dir, {}); + it("reports empty contribution for a manifest-only package", async () => { + await writePackage(dir); - const status = await scanStatus(dir); + const status = await scanStatus(join(dir, ".ghost")); expect(status.fingerprint.state).toBe("present"); - expect("cache" in status).toBe(false); - expect("readiness" in status).toBe(false); - expect("checks" in status).toBe(false); expect(status.recommended_next).toBeNull(); expect(status.contribution.state).toBe("empty"); - expect(status.contribution.contributing_facets).toEqual([]); - expect(status.contribution.empty_facets).toEqual([]); - expect(status.contribution.absent_facets).toEqual([ - "intent", - "inventory", - "composition", - ]); - expect(status.contribution.facets.intent).toMatchObject({ - state: "absent", - count: 0, - file_present: false, - }); - }); - - it("reports empty facets when starter facet files are present but blank", async () => { - await writePackage(dir, { - intent: `summary: {} -situations: [] -principles: [] -experience_contracts: [] -`, - inventory: `building_blocks: {} -exemplars: [] -sources: [] -`, - composition: `patterns: [] -`, - }); - - const status = await scanStatus(dir); - - expect(status.contribution.state).toBe("empty"); - expect(status.contribution.contributing_facets).toEqual([]); - expect(status.contribution.empty_facets).toEqual([ - "intent", - "inventory", - "composition", - ]); - expect(status.contribution.absent_facets).toEqual([]); - }); - - it("does not report sources cache as package contribution", async () => { - await mkdir(join(dir, "sources", "cache"), { - recursive: true, - }); - await writeFile(join(dir, "sources", "cache", "inventory.json"), "{}\n"); - await writePackage(dir, {}); - - const status = await scanStatus(dir); - - expect("cache" in status).toBe(false); - expect(status.contribution.state).toBe("empty"); - expect(status.contribution.facets.inventory.count).toBe(0); + expect(status.contribution.node_count).toBe(0); }); - it("reports intent contribution without requiring inventory or composition", async () => { - await writePackage(dir, { - intent: `summary: - product: Cash iOS + it("reports node contribution and surface coverage", async () => { + await writePackage( + dir, + `schema: ghost.surfaces/v1 +surfaces: + - id: checkout + parent: core + - id: email + parent: core `, - }); - - const status = await scanStatus(dir); - - expect(status.contribution.state).toBe("contributing"); - expect(status.contribution.contributing_facets).toEqual(["intent"]); - expect(status.contribution.absent_facets).toEqual([ - "inventory", - "composition", - ]); - expect(status.contribution.facets.intent).toMatchObject({ - state: "useful", - count: 1, - file_present: true, - }); - }); - - it("reports inventory contribution and counts curated sources", async () => { - await writePackage(dir, { - inventory: `building_blocks: - tokens: - - color.background - components: - - DataTable -exemplars: [] -sources: - - id: writing-guide - kind: file - ref: docs/writing.md -`, - }); - - const status = await scanStatus(dir); - - expect(status.contribution.state).toBe("contributing"); - expect(status.contribution.contributing_facets).toEqual(["inventory"]); - expect(status.contribution.facets.inventory).toMatchObject({ - state: "useful", - count: 3, - }); - expect(status.contribution.building_block_rows.tokens).toBe(1); - expect(status.contribution.building_block_rows.components).toBe(1); - expect(status.contribution.absent_facets).toEqual([ - "intent", - "composition", - ]); - }); - - it("reports composition contribution without requiring sibling facets", async () => { - await writePackage(dir, { - composition: `patterns: - - id: preserve-table-density - kind: layout - pattern: Keep dense operational tables scannable. -`, - }); - - const status = await scanStatus(dir); - - expect(status.contribution.state).toBe("contributing"); - expect(status.contribution.contributing_facets).toEqual(["composition"]); - expect(status.contribution.facets.composition).toMatchObject({ - state: "useful", - count: 1, - }); - expect(status.contribution.absent_facets).toEqual(["intent", "inventory"]); - }); - - it("reports multiple sparse contributions without calling absent facets missing", async () => { - await writePackage(dir, { - intent: `principles: - - id: dense-workflows-prioritize-scanning - principle: Dense workflows optimize for comparison and recovery. -`, - inventory: `building_blocks: - tokens: - - color.background -`, - }); - - const status = await scanStatus(dir); - - expect(status.contribution.state).toBe("contributing"); - expect(status.contribution.contributing_facets).toEqual([ - "intent", - "inventory", - ]); - expect(status.contribution.absent_facets).toEqual(["composition"]); - expect(status.contribution.reasons[0]).toContain( - "Absent facets may be inherited", + { + "core-voice.md": "---\nid: core-voice\nunder: core\n---\n\nCalm.\n", + "checkout-trust.md": + "---\nid: checkout-trust\nunder: checkout\nincarnation: web\n---\n\nReassure.\n", + }, ); - }); - - it("reports all useful facets when the package contributes the full local set", async () => { - await writePackage(dir, { - intent: `principles: - - id: dense-workflows-prioritize-scanning - principle: Dense workflows optimize for comparison and recovery. -`, - inventory: `exemplars: - - id: orders-table - path: apps/dashboard/orders.tsx - surface: dashboard - refs: - - composition.pattern:preserve-table-density -building_blocks: - tokens: - - color.background - components: - - DataTable -sources: [] -`, - composition: `patterns: - - id: preserve-table-density - kind: layout - pattern: Keep dense operational tables scannable. -`, - }); - const status = await scanStatus(dir); + const status = await scanStatus(join(dir, ".ghost")); - expect("cache" in status).toBe(false); expect(status.contribution.state).toBe("contributing"); - expect(status.contribution.contributing_facets).toEqual([ - "intent", - "inventory", - "composition", - ]); - expect(status.contribution.facets).toMatchObject({ - intent: { state: "useful", count: 1 }, - inventory: { state: "useful", count: 3 }, - composition: { state: "useful", count: 1 }, - }); - expect(status.contribution.building_block_rows.tokens).toBe(1); - expect(status.contribution.building_block_rows.components).toBe(1); - expect(status.contribution.product_surface_count).toBe(1); + expect(status.contribution.node_count).toBe(2); + expect(status.contribution.essence_count).toBe(1); + expect(status.contribution.incarnation_count).toBe(1); + const checkout = status.contribution.surfaces.find( + (s) => s.id === "checkout", + ); + expect(checkout?.node_count).toBe(1); + // email surface declared but has no nodes → sparse. + expect(status.contribution.sparse_surfaces).toContain("email"); }); }); async function writePackage( dir: string, - facets: { - intent?: string; - inventory?: string; - composition?: string; - }, + surfacesYml?: string, + nodes?: Record, ): Promise { - const packageDir = dir; - await mkdir(packageDir, { recursive: true }); + await mkdir(join(dir, ".ghost"), { recursive: true }); await writeFile( - join(packageDir, "manifest.yml"), - "schema: ghost.fingerprint-package/v1\nid: test\n", - ); - await Promise.all( - Object.entries(facets).map(([facet, content]) => - writeFile(join(packageDir, `${facet}.yml`), content), - ), + join(dir, ".ghost", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: local\n", ); + if (surfacesYml) { + await writeFile(join(dir, ".ghost", "surfaces.yml"), surfacesYml); + } + if (nodes) { + await mkdir(join(dir, ".ghost", "nodes"), { recursive: true }); + for (const [name, content] of Object.entries(nodes)) { + await writeFile(join(dir, ".ghost", "nodes", name), content); + } + } } diff --git a/packages/ghost/test/terminology-public.test.ts b/packages/ghost/test/terminology-public.test.ts index abfeb1e2..5355aee0 100644 --- a/packages/ghost/test/terminology-public.test.ts +++ b/packages/ghost/test/terminology-public.test.ts @@ -17,10 +17,7 @@ const PUBLIC_TEXT_ROOTS = [ ".changeset", ] as const; -const EMITTED_TEXT_FILES = [ - "packages/ghost/src/context/package-review-command.ts", - "packages/ghost/src/review-packet.ts", -] as const; +const EMITTED_TEXT_FILES = ["packages/ghost/src/review-packet.ts"] as const; const FORBIDDEN_TERMS = [ /\bcascade\b/i, diff --git a/scripts/check-file-sizes.mjs b/scripts/check-file-sizes.mjs index 0b44b79f..b6d4c8a4 100644 --- a/scripts/check-file-sizes.mjs +++ b/scripts/check-file-sizes.mjs @@ -5,36 +5,21 @@ const DEFAULT_LIMIT = 500; // Add narrowly scoped exceptions here with justification const EXCEPTIONS = { - "packages/ghost/src/ghost-core/types.ts": { - limit: 780, - justification: - "Canonical type barrel — all shared types in one file for discoverability, including three-layer fingerprint types and role bindings", - }, "packages/ghost/src/cli.ts": { limit: 580, justification: - "Unified CLI command registry — review/check/compare plus drift stance verbs live together for one public bin", + "Unified CLI command registry — all verbs live together for one public bin", }, "packages/ghost/src/fingerprint-commands.ts": { limit: 1135, justification: - "Fingerprint package command registry — temporarily holds package lifecycle, legacy markdown, survey/cache, scan readiness, and adapter-neutral package-dir routing until command groups are split further", + "Fingerprint package command registry — temporarily holds package lifecycle, survey/cache, scan readiness, and adapter-neutral package-dir routing until command groups are split further", }, "packages/ghost/src/scan/inventory.ts": { limit: 1120, justification: "Deterministic repository inventory collector — intentionally broad because map authoring depends on one cohesive raw signal pass", }, - "packages/ghost/src/scan/verify-fingerprint.ts": { - limit: 900, - justification: - "Fingerprint fidelity verifier — schema, reference, and survey evidence checks stay together so reports share one issue model", - }, - "packages/ghost/src/ghost-core/embedding/compare.ts": { - limit: 600, - justification: - "Fingerprint comparison — cosine-based decision matching alongside existing value comparison", - }, }; const DIRS_TO_CHECK = [{ dir: "packages/ghost/src", glob: /\.[jt]sx?$/ }]; diff --git a/scripts/check-packed-package.mjs b/scripts/check-packed-package.mjs index dc73bd86..3f5af933 100644 --- a/scripts/check-packed-package.mjs +++ b/scripts/check-packed-package.mjs @@ -18,9 +18,7 @@ const PUBLIC_IMPORTS = [ "@anarchitecture/ghost/cli", "@anarchitecture/ghost/fingerprint", "@anarchitecture/ghost/scan", - "@anarchitecture/ghost/compare", "@anarchitecture/ghost/core", - "@anarchitecture/ghost/drift", ]; function fail(message) { @@ -115,8 +113,12 @@ try { cwd: consumerDir, }); const initOutput = JSON.parse(init); - if (!initOutput.manifest?.endsWith(".ghost/manifest.yml")) { - fail("packed ghost init did not emit the expected manifest path"); + if ( + !initOutput.dir?.endsWith(".ghost") || + !Array.isArray(initOutput.written) || + !initOutput.written.includes("manifest.yml") + ) { + fail("packed ghost init did not scaffold the expected node package"); } run("pnpm", ["exec", "ghost", "lint", ".ghost"], { cwd: consumerDir }); diff --git a/scripts/check-release-tarball.mjs b/scripts/check-release-tarball.mjs index d1c9e1b9..bf03c1b8 100644 --- a/scripts/check-release-tarball.mjs +++ b/scripts/check-release-tarball.mjs @@ -93,8 +93,14 @@ try { { cwd: tmpRoot }, ); const initOutput = JSON.parse(init); - if (!initOutput.manifest?.endsWith(".ghost/manifest.yml")) { - fail("release tarball ghost init did not emit the expected manifest path"); + if ( + !initOutput.dir?.endsWith(".ghost") || + !Array.isArray(initOutput.written) || + !initOutput.written.includes("manifest.yml") + ) { + fail( + "release tarball ghost init did not scaffold the expected node package", + ); } const topLevelEntries = readdirSync(extractDir);