From 70e38164fbb6bf1287567939e5986a4eaeb71a4c Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 23 Apr 2026 06:59:45 -0400 Subject: [PATCH 1/2] feat(ghost-drift): add describe verb for selective fingerprint reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ghost-drift describe` prints a section map of fingerprint.md (frontmatter range, body sections, per-dimension decision blocks) with line ranges and token estimates. Lets host agents load only the sections they need instead of the whole file — typical fingerprints run 3–5k tokens with 60–80% inside # Decisions, and most reviews don't need every dimension. Review and generate skill recipes now open with `describe` and teach a recall safety rule: when uncertain which decisions are relevant, load the whole `# Decisions` block — cheaper than missing a constraint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/add-describe-verb.md | 5 + apps/docs/src/app/docs/page.tsx | 2 +- apps/docs/src/content/docs/cli-reference.mdx | 52 +++- .../docs/src/content/docs/getting-started.mdx | 2 +- apps/docs/src/generated/cli-manifest.json | 17 +- packages/ghost-drift/src/cli.ts | 30 +++ .../ghost-drift/src/core/fingerprint/index.ts | 5 + .../src/core/fingerprint/layout.ts | 232 ++++++++++++++++++ packages/ghost-drift/src/core/index.ts | 4 + .../ghost-drift/src/skill-bundle/SKILL.md | 3 +- .../src/skill-bundle/references/generate.md | 15 +- .../src/skill-bundle/references/review.md | 10 +- .../test/fingerprint/layout.test.ts | 213 ++++++++++++++++ 13 files changed, 582 insertions(+), 8 deletions(-) create mode 100644 .changeset/add-describe-verb.md create mode 100644 packages/ghost-drift/src/core/fingerprint/layout.ts create mode 100644 packages/ghost-drift/test/fingerprint/layout.test.ts diff --git a/.changeset/add-describe-verb.md b/.changeset/add-describe-verb.md new file mode 100644 index 0000000..3b70cba --- /dev/null +++ b/.changeset/add-describe-verb.md @@ -0,0 +1,5 @@ +--- +"ghost-drift": minor +--- + +Add `ghost-drift describe` — prints a section map of `fingerprint.md` (frontmatter range, body sections, per-dimension decision blocks) with line ranges and token estimates, so host agents can selectively load only the sections they need instead of the whole file. The review and generate skill recipes now open with `describe` and teach a "load whole `# Decisions` block if uncertain" recall safety rule. diff --git a/apps/docs/src/app/docs/page.tsx b/apps/docs/src/app/docs/page.tsx index 48a9633..e4001b1 100644 --- a/apps/docs/src/app/docs/page.tsx +++ b/apps/docs/src/app/docs/page.tsx @@ -31,7 +31,7 @@ const sections: { name: "CLI Reference", href: "/tools/drift/cli", description: - "Six deterministic primitives — compare, lint, ack, adopt, diverge, emit. Plus the skill recipes the host agent runs.", + "Seven deterministic primitives — compare, lint, describe, ack, adopt, diverge, emit. Plus the skill recipes the host agent runs.", icon: , }, ]; diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index 757383f..bda3280 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -1,6 +1,6 @@ --- title: CLI Reference -description: Six deterministic primitives. Everything interpretive lives in the skill bundle. +description: Seven deterministic primitives. Everything interpretive lives in the skill bundle. kicker: Docs section: drift order: 20 @@ -69,6 +69,56 @@ ghost-drift lint path/to/fingerprint.md --format json + + +Print a section map of `fingerprint.md` — frontmatter range, body sections +(`# Character`, `# Signature`, `# Decisions`, `# Fragments`), and each +`### dimension` block under Decisions, with line ranges and token estimates. +The host agent uses this to load only the sections it needs instead of the +whole file. + +A typical `fingerprint.md` runs 3–5k tokens. The `# Decisions` block alone +is usually 60–80% of that, and an agent reviewing a single component change +rarely needs every dimension. `describe` is the deterministic answer to +"what's in this file and where" — the recall safety rule (when in doubt, +load the whole `# Decisions` block) lives in the review/generate skill +recipes. + + + +```bash +# Default — reads ./fingerprint.md +ghost-drift describe + +# Specific file +ghost-drift describe path/to/fingerprint.md + +# Machine-readable for agents +ghost-drift describe --format json +``` + +Sample output (against `packages/ghost-ui/fingerprint.md`): + +```text +fingerprint.md — 309 lines, ~3,955 tokens + +FRONTMATTER 1–164 ~973 tok [palette, spacing, typography, surfaces, roles, observation, decisions] +# Character 166–169 ~159 tok +# Signature 170–179 ~298 tok +# Decisions 180–305 ~2,503 tok + ### color-strategy 182–192 ~250 tok + ### shape-language 205–216 ~181 tok + ### typography-voice 217–229 ~258 tok + … +# Fragments 306–309 ~21 tok +``` + +Line ranges are 1-indexed and inclusive — they plug directly into a Read +tool's `offset` / `limit = end - start + 1`. Token counts are a `chars / 4` +approximation, sufficient for context budgeting. + + + These three verbs write per-dimension stances to `.ghost-sync.json`. `ack` diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index e4614b1..48a42ac 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -10,7 +10,7 @@ slug: getting-started Ghost is split across two surfaces. The **CLI** is a set of deterministic -primitives — six verbs that never call an LLM. The **skill bundle** is a set +primitives — seven verbs that never call an LLM. The **skill bundle** is a set of [agentskills.io](https://agentskills.io)-compatible recipes your host agent (Claude Code, Cursor, Goose, Codex, …) follows for anything interpretive: profile, review, verify, generate, discover. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 6c5f880..6679157 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-04-22T13:46:27.308Z", + "generatedAt": "2026-04-23T10:40:33.455Z", "commands": [ { "name": "compare", @@ -55,6 +55,21 @@ } ] }, + { + "name": "describe", + "rawName": "describe [fingerprint]", + "description": "Print a section map of fingerprint.md (line ranges + token estimates) so agents can selectively load only the sections they need.", + "options": [ + { + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", + "takesValue": true, + "negated": false + } + ] + }, { "name": "ack", "rawName": "ack", diff --git a/packages/ghost-drift/src/cli.ts b/packages/ghost-drift/src/cli.ts index ffec744..e8a68f4 100644 --- a/packages/ghost-drift/src/cli.ts +++ b/packages/ghost-drift/src/cli.ts @@ -10,9 +10,11 @@ import { formatComparisonJSON, formatCompositeComparison, formatCompositeComparisonJSON, + formatLayout, formatSemanticDiff, formatTemporalComparison, formatTemporalComparisonJSON, + layoutFingerprint, lintFingerprint, loadFingerprint, readHistory, @@ -152,6 +154,34 @@ export function buildCli(): ReturnType { } }); + // --- describe --- + cli + .command( + "describe [fingerprint]", + "Print a section map of fingerprint.md (line ranges + token estimates) so agents can selectively load only the sections they need.", + ) + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (path: string | undefined, opts) => { + try { + const target = resolve(process.cwd(), path ?? FINGERPRINT_FILENAME); + const raw = await readFile(target, "utf-8"); + const layout = layoutFingerprint(raw); + if (opts.format === "json") { + process.stdout.write( + `${JSON.stringify({ path: target, ...layout }, null, 2)}\n`, + ); + } else { + process.stdout.write(`${formatLayout(layout, target)}\n`); + } + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + registerAckCommand(cli); registerAdoptCommand(cli); registerDivergeCommand(cli); diff --git a/packages/ghost-drift/src/core/fingerprint/index.ts b/packages/ghost-drift/src/core/fingerprint/index.ts index 487c564..da24366 100644 --- a/packages/ghost-drift/src/core/fingerprint/index.ts +++ b/packages/ghost-drift/src/core/fingerprint/index.ts @@ -41,6 +41,11 @@ export { serializeEmbeddingFragment, } from "./fragments.js"; export type { FingerprintMeta, FrontmatterData } from "./frontmatter.js"; +export type { + FingerprintLayout, + FingerprintLayoutSection, +} from "./layout.js"; +export { formatLayout, layoutFingerprint } from "./layout.js"; export type { LintIssue, LintOptions, diff --git a/packages/ghost-drift/src/core/fingerprint/layout.ts b/packages/ghost-drift/src/core/fingerprint/layout.ts new file mode 100644 index 0000000..6d698d5 --- /dev/null +++ b/packages/ghost-drift/src/core/fingerprint/layout.ts @@ -0,0 +1,232 @@ +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, # Fragments, … + 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", + "roles", + "observation", + "decisions", + "embedding", + ]; + 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++) { + const m = /^(#{1,6})\s+(.*?)\s*$/.exec(lines[i]); + if (!m) continue; + if (m[1].length === level) { + out.push({ lineNumber: i + 1, level, text: m[2] }); + } else if (m[1].length < level) { + // A shallower heading ends the region when scanning nested headings + // inside a bounded parent. + 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 { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * 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 && 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-drift/src/core/index.ts b/packages/ghost-drift/src/core/index.ts index d95401e..65fa2d0 100644 --- a/packages/ghost-drift/src/core/index.ts +++ b/packages/ghost-drift/src/core/index.ts @@ -46,6 +46,8 @@ export type { BodyData, ColorChange, DecisionChange, + FingerprintLayout, + FingerprintLayoutSection, FingerprintMeta, FrontmatterData, FrontmatterShape, @@ -64,7 +66,9 @@ export { FINGERPRINT_FILENAME, FrontmatterSchema, findFragmentLinks, + formatLayout, formatSemanticDiff, + layoutFingerprint, lintFingerprint, loadEmbeddingFragment, loadFingerprint, diff --git a/packages/ghost-drift/src/skill-bundle/SKILL.md b/packages/ghost-drift/src/skill-bundle/SKILL.md index 5a07f4c..4ac29b6 100644 --- a/packages/ghost-drift/src/skill-bundle/SKILL.md +++ b/packages/ghost-drift/src/skill-bundle/SKILL.md @@ -19,10 +19,11 @@ Ghost's CLI is a set of **deterministic primitives**. It never calls an LLM. Syn |---|---| | `ghost-drift compare [...more]` | Pairwise distance + per-dimension delta (N=2) or composite (N≥3: pairwise matrix, centroid, spread, clusters). Pure math over fingerprint embeddings. `--semantic` and `--temporal` flags add qualitative enrichment for N=2. | | `ghost-drift lint [fingerprint.md]` | Validate schema + body/frontmatter coherence. Use this before declaring a fingerprint valid. | +| `ghost-drift describe [fingerprint.md]` | Print a section map (line ranges + token estimates) so you can selectively read only the sections you need instead of loading the whole file. Use before review/generate when the fingerprint is large. | | `ghost-drift ack` / `ghost-drift adopt ` / `ghost-drift diverge ` | Record stance toward parent (aligned / accepted / diverging) in `.ghost-sync.json`. Reads the local `fingerprint.md`. | | `ghost-drift emit review-command` / `ghost-drift emit context-bundle` / `ghost-drift emit skill` | Derive per-project artifacts from `fingerprint.md`. | -That's it. Six verbs. If you find yourself reaching for `ghost review` or `ghost profile` — those are *your* workflows, not CLI commands. Follow the recipes below. +That's it. Seven verbs. If you find yourself reaching for `ghost review` or `ghost profile` — those are *your* workflows, not CLI commands. Follow the recipes below. ## Workflows (your job, not the CLI's) diff --git a/packages/ghost-drift/src/skill-bundle/references/generate.md b/packages/ghost-drift/src/skill-bundle/references/generate.md index bb89672..da6d6f2 100644 --- a/packages/ghost-drift/src/skill-bundle/references/generate.md +++ b/packages/ghost-drift/src/skill-bundle/references/generate.md @@ -17,7 +17,20 @@ Ghost's CLI does not generate code — you do. The fingerprint is the constraint ### 1. Load the fingerprint -Read `fingerprint.md` from the project. The key constraints are: +Start with a section map: + + ghost-drift describe fingerprint.md + +Generation always needs the **frontmatter** (palette, spacing.scale, typography.families/sizeRamp, surfaces.borderRadii, roles[]) — read that whole range. Then layer on decision sections by relevance to what you're generating: + +- Building an interactive surface (button, input, badge) → `### shape-language`, `### interactive-patterns`, `### density`, plus any role binding for that component name in `roles[]` +- Building a structural surface (card, modal, page) → `### surface-hierarchy`, `### elevation`, `### spatial-system` +- Building anything text-heavy → `### typography-voice` +- Anything with state or feedback (alerts, toasts, charts) → `### color-strategy` + +If the component spans multiple categories, or you're unsure, **read the entire `# Decisions` block** — typically 2–4k tokens, cheaper than generating something that contradicts a decision. + +The key constraints surfaced in the frontmatter are: - `palette` — which colors are allowed, and what role each plays - `spacing.scale` — which spacing values are allowed diff --git a/packages/ghost-drift/src/skill-bundle/references/review.md b/packages/ghost-drift/src/skill-bundle/references/review.md index a6d4c48..b33e875 100644 --- a/packages/ghost-drift/src/skill-bundle/references/review.md +++ b/packages/ghost-drift/src/skill-bundle/references/review.md @@ -26,9 +26,15 @@ Ghost has no `ghost review` CLI command. You — the host agent — are the revi ### 1. Read the fingerprint - cat fingerprint.md + ghost-drift describe fingerprint.md -Absorb: `palette` (allowed colors), `spacing.scale` (allowed spacing values), `typography.families`/`sizeRamp`, `surfaces.borderRadii`, `decisions` (the patterns), `roles` (slot bindings). +This prints a section map — frontmatter range, body sections (`# Character`, `# Signature`, `# Decisions`, `# Fragments`), and each `### dimension` block under Decisions, with line ranges and token estimates. Use it to plan what to load. + +Then read selectively: + +- **Always read the frontmatter.** It carries the structural budget — `palette`, `spacing.scale`, `typography.families`/`sizeRamp`, `surfaces.borderRadii`, `roles[]` — that you'll match diff values against. +- **Read decision sections by dimension name.** If the diff touches colors, you'll want `### color-strategy` (and any other `color-*` / `palette-*` dimension). If it touches radii, `### shape-language`, `### surface-hierarchy`, `### elevation`. Match on slug. +- **If you're not confident which decisions are relevant — or the diff spans more than two partitions — read the entire `# Decisions` block.** It's typically 2–4k tokens; cheaper than missing a constraint. The describe output tells you the exact line range. If no `fingerprint.md` exists, tell the user. Offer to generate one via the [profile recipe](profile.md). Don't guess. diff --git a/packages/ghost-drift/test/fingerprint/layout.test.ts b/packages/ghost-drift/test/fingerprint/layout.test.ts new file mode 100644 index 0000000..6da6fe5 --- /dev/null +++ b/packages/ghost-drift/test/fingerprint/layout.test.ts @@ -0,0 +1,213 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { layoutFingerprint } from "../../src/core/fingerprint/layout.js"; + +const here = resolve(fileURLToPath(import.meta.url), ".."); + +const FRONTMATTER = `--- +id: test +source: llm +timestamp: 2026-04-22T00:00:00Z +palette: + dominant: [{ role: accent, value: '#000' }] + neutrals: { steps: ['#fff', '#000'], count: 2 } + semantic: [] + saturationProfile: muted + contrast: high +spacing: { scale: [4, 8], baseUnit: 4, regularity: 0.9 } +typography: + families: ['system-ui'] + sizeRamp: [12, 16] + weightDistribution: { '400': 1 } + lineHeightPattern: tight +surfaces: + borderRadii: [4] + shadowComplexity: none + borderUsage: minimal +---`; + +const SAMPLE = `${FRONTMATTER} + +# Character + +Brief. + +# Signature + +- bullet a +- bullet b + +# Decisions + +### color-strategy + +Prose. + +**Evidence:** +- thing + +### shape-language + +More prose. + +# Fragments + +- [embedding](embedding.md) +`; + +describe("layoutFingerprint", () => { + it("maps frontmatter, body sections, and decision H3s on a typical fingerprint", () => { + const layout = layoutFingerprint(SAMPLE); + + const fm = layout.sections.find((s) => s.kind === "frontmatter"); + expect(fm).toBeDefined(); + expect(fm?.start).toBe(1); + // Frontmatter ends at the closing `---` — line 21 in the FRONTMATTER constant. + expect(fm?.end).toBe(21); + expect(fm?.partitions).toEqual([ + "palette", + "spacing", + "typography", + "surfaces", + ]); + + const character = layout.sections.find( + (s) => s.kind === "body" && s.heading === "Character", + ); + const signature = layout.sections.find( + (s) => s.kind === "body" && s.heading === "Signature", + ); + const decisions = layout.sections.find( + (s) => s.kind === "body" && s.heading === "Decisions", + ); + const fragments = layout.sections.find( + (s) => s.kind === "body" && s.heading === "Fragments", + ); + expect(character?.start).toBeGreaterThan(fm?.end ?? 0); + expect(signature?.start).toBeGreaterThan(character?.end ?? 0); + expect(decisions?.start).toBeGreaterThan(signature?.end ?? 0); + expect(fragments?.start).toBeGreaterThan(decisions?.end ?? 0); + + const decisionBlocks = layout.sections.filter((s) => s.kind === "decision"); + expect(decisionBlocks.map((d) => d.dimension)).toEqual([ + "color-strategy", + "shape-language", + ]); + // Each decision must sit fully inside the parent Decisions section. + for (const d of decisionBlocks) { + expect(d.start).toBeGreaterThanOrEqual(decisions?.start ?? 0); + expect(d.end).toBeLessThanOrEqual(decisions?.end ?? 0); + } + // The two decisions must not overlap. + expect(decisionBlocks[0].end).toBeLessThan(decisionBlocks[1].start); + }); + + it("produces 1-indexed inclusive ranges suitable for the Read tool's offset/limit", () => { + const layout = layoutFingerprint(SAMPLE); + const lines = SAMPLE.split("\n"); + for (const s of layout.sections) { + // start line is the heading itself for body/decision, or `---` for frontmatter + const startLine = lines[s.start - 1]; + if (s.kind === "frontmatter") { + expect(startLine).toMatch(/^---/); + } else if (s.kind === "body") { + expect(startLine).toBe(`# ${s.heading}`); + } else { + expect(startLine).toBe(`### ${s.dimension}`); + } + } + }); + + it("returns no frontmatter section when the file lacks one", () => { + const layout = layoutFingerprint("# Character\n\nProse only.\n"); + expect( + layout.sections.find((s) => s.kind === "frontmatter"), + ).toBeUndefined(); + expect( + layout.sections.find( + (s) => s.kind === "body" && s.heading === "Character", + ), + ).toBeDefined(); + }); + + it("returns no frontmatter section when the YAML block is unterminated", () => { + const layout = layoutFingerprint( + `---\nid: x\npalette: foo\n# stray heading\n`, + ); + // No closing `---` → describe must not invent one. The H1 still surfaces. + expect( + layout.sections.find((s) => s.kind === "frontmatter"), + ).toBeUndefined(); + }); + + it("falls back to a regex key scan when the YAML cannot be parsed", () => { + const broken = `--- +id: test +palette: [unterminated +spacing: { scale: [1] } +--- + +# Character + +x +`; + const layout = layoutFingerprint(broken); + const fm = layout.sections.find((s) => s.kind === "frontmatter"); + expect(fm?.partitions).toContain("palette"); + expect(fm?.partitions).toContain("spacing"); + }); + + it("emits zero decision sections when # Decisions has no H3s", () => { + const md = `${FRONTMATTER}\n\n# Character\n\nx\n\n# Decisions\n\nNo subheadings yet.\n`; + const layout = layoutFingerprint(md); + expect(layout.sections.filter((s) => s.kind === "decision")).toHaveLength( + 0, + ); + expect( + layout.sections.find( + (s) => s.kind === "body" && s.heading === "Decisions", + ), + ).toBeDefined(); + }); + + it("matches structural expectations against the real ghost-ui fingerprint", async () => { + const path = resolve(here, "../../../ghost-ui/fingerprint.md"); + const raw = await readFile(path, "utf-8"); + const layout = layoutFingerprint(raw); + + const fm = layout.sections.find((s) => s.kind === "frontmatter"); + expect(fm?.start).toBe(1); + expect(fm?.partitions).toEqual( + expect.arrayContaining([ + "palette", + "spacing", + "typography", + "surfaces", + "roles", + ]), + ); + + const headings = layout.sections + .filter((s) => s.kind === "body") + .map((s) => s.heading); + expect(headings).toEqual( + expect.arrayContaining([ + "Character", + "Signature", + "Decisions", + "Fragments", + ]), + ); + + const dims = layout.sections + .filter((s) => s.kind === "decision") + .map((s) => s.dimension); + // Every decision dimension must round-trip through the slug normalizer. + expect(dims.length).toBeGreaterThan(0); + for (const d of dims) { + expect(d).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + } + }); +}); From 0f0c22fb274b19ba891cd081d5b56a36e79aa545 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 23 Apr 2026 07:00:07 -0400 Subject: [PATCH 2/2] chore(ghost-drift): use optional chaining in formatLayout Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ghost-drift/src/core/fingerprint/layout.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ghost-drift/src/core/fingerprint/layout.ts b/packages/ghost-drift/src/core/fingerprint/layout.ts index 6d698d5..b4e551a 100644 --- a/packages/ghost-drift/src/core/fingerprint/layout.ts +++ b/packages/ghost-drift/src/core/fingerprint/layout.ts @@ -214,10 +214,7 @@ 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 && s.partitions.length - ? ` [${s.partitions.join(", ")}]` - : ""; + const parts = s.partitions?.length ? ` [${s.partitions.join(", ")}]` : ""; return `FRONTMATTER ${pad(range, 10)} ${pad(tok, 14)}${parts}`; } if (s.kind === "body") {