From 3ab1ea6b8e539a26bb69fc18bff2411ddf4b061f Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Mon, 29 Jun 2026 09:39:17 -0400 Subject: [PATCH 1/2] feat(ghost): add ghost search and ERR_UNKNOWN_SURFACE grounding Add a ranked, cross-domain over nodes, surfaces, and checks: verbatim matches first, a whole-name typo fallback, then multi-word token coverage so a natural phrase like 'payment confirmation screen' still finds the right surface. Each hit carries its follow-up command. Emit the stable ERR_UNKNOWN_SURFACE code with closest-id suggestions when gather, checks, or review is given a surface absent from the package, instead of silently empty-routing. Add a provenance-based self-check skill recipe that probes grounding (what you gathered, Ghost-backed vs provisional, where it is silent) rather than presuming composition facets exist. --- .changeset/search-and-surface-guard.md | 8 + apps/docs/src/content/docs/cli-reference.mdx | 24 ++ apps/docs/src/generated/cli-manifest.json | 46 ++- install/manifest.json | 1 + packages/ghost/src/cli.ts | 14 + packages/ghost/src/commands/checks-command.ts | 5 + .../ghost/src/commands/command-discovery.ts | 7 + packages/ghost/src/commands/gather-command.ts | 25 +- packages/ghost/src/commands/review-packet.ts | 6 + packages/ghost/src/commands/search-command.ts | 114 +++++++ packages/ghost/src/commands/surface-guard.ts | 92 +++++ packages/ghost/src/ghost-core/graph/index.ts | 10 + packages/ghost/src/ghost-core/graph/search.ts | 319 ++++++++++++++++++ packages/ghost/src/ghost-core/index.ts | 8 + packages/ghost/src/skill-bundle/SKILL.md | 8 + .../src/skill-bundle/references/brief.md | 4 + .../src/skill-bundle/references/self-check.md | 42 +++ packages/ghost/test/cli.test.ts | 74 ++++ .../test/ghost-core/graph-search.test.ts | 166 +++++++++ 19 files changed, 969 insertions(+), 4 deletions(-) create mode 100644 .changeset/search-and-surface-guard.md create mode 100644 packages/ghost/src/commands/search-command.ts create mode 100644 packages/ghost/src/commands/surface-guard.ts create mode 100644 packages/ghost/src/ghost-core/graph/search.ts create mode 100644 packages/ghost/src/skill-bundle/references/self-check.md create mode 100644 packages/ghost/test/ghost-core/graph-search.test.ts diff --git a/.changeset/search-and-surface-guard.md b/.changeset/search-and-surface-guard.md new file mode 100644 index 00000000..ba079eea --- /dev/null +++ b/.changeset/search-and-surface-guard.md @@ -0,0 +1,8 @@ +--- +"@anarchitecture/ghost": minor +--- + +Add `ghost search` for ranked, cross-domain discovery of nodes, surfaces, and +checks, each tagged with the follow-up command to run, and emit the stable +`ERR_UNKNOWN_SURFACE` code with closest-id suggestions when `gather`, `checks`, +or `review` is given a surface that is not in the package. diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index 844f02d2..1045e6ee 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -127,6 +127,30 @@ ghost gather checkout --format json This is the pre-generation step: Ghost gives agents surface-composition context before they build, not only after a review finds drift. +### Find anything: `search` + +When you do not know whether what you need is a node, a surface, or a check, +search across all of them at once. Results are ranked: a verbatim match wins +(an exact id, then a name, description, or body substring), then a whole-name +typo fallback, then multi-word queries by how many of their words a candidate +covers — so a natural phrase like `payment confirmation screen` still finds the +right surface. Each result carries the follow-up command to run. Restrict with +`--type ` and cap with `--limit`. + + + +```bash +ghost search checkout +ghost search trust --type node +ghost search contrast --type check +ghost search marketing --format json +``` + +Naming a surface that is not in the package is an error, not a silent empty +result: `gather`, `checks`, and `review` emit the stable code +`ERR_UNKNOWN_SURFACE` with closest-id suggestions so an agent can retry without +a human. + diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 5c74ff8e..f3441358 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-28T21:25:38.799Z", + "generatedAt": "2026-06-29T12:20:15.926Z", "tools": [ { "tool": "ghost", @@ -135,6 +135,50 @@ } ] }, + { + "tool": "ghost", + "name": "search", + "rawName": "search ", + "description": "Search nodes, surfaces, and checks in one ranked, cross-domain result set, each tagged with the follow-up command.", + "group": "core", + "defaultHelp": true, + "compactName": "search", + "summary": "Search nodes, surfaces, and checks in one ranked result set.", + "options": [ + { + "rawName": "--type ", + "name": "type", + "description": "Restrict to one domain: node, surface, or check", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--limit ", + "name": "limit", + "description": "Cap the number of results (default 20)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--package ", + "name": "package", + "description": "Use this fingerprint package directory (default: ./.ghost)", + "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": "checks", diff --git a/install/manifest.json b/install/manifest.json index a5c27e6f..8a3df528 100644 --- a/install/manifest.json +++ b/install/manifest.json @@ -16,6 +16,7 @@ "references/remediate.md", "references/review.md", "references/schema.md", + "references/self-check.md", "references/verify.md" ] } diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 957239e3..3b0a9a94 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -14,7 +14,12 @@ import { buildReviewPacket, formatReviewPacketMarkdown, } from "./commands/review-packet.js"; +import { registerSearchCommand } from "./commands/search-command.js"; import { registerSkillCommand } from "./commands/skill-command.js"; +import { + UnknownSurfaceError, + writeUnknownSurfaceError, +} from "./commands/surface-guard.js"; const execFileAsync = promisify(execFile); @@ -26,6 +31,7 @@ export function buildCli(): ReturnType { registerFingerprintCommands(cli); registerGatherCommand(cli); + registerSearchCommand(cli); registerChecksCommand(cli); registerMigrateCommand(cli); registerSkillCommand(cli); @@ -87,6 +93,14 @@ export function buildCli(): ReturnType { } process.exit(0); } catch (err) { + if (err instanceof UnknownSurfaceError) { + writeUnknownSurfaceError( + err.unknown, + opts.format === "json" ? "json" : "markdown", + ); + process.exit(2); + return; + } console.error( `Error: ${err instanceof Error ? err.message : String(err)}`, ); diff --git a/packages/ghost/src/commands/checks-command.ts b/packages/ghost/src/commands/checks-command.ts index 487e38cc..d344c664 100644 --- a/packages/ghost/src/commands/checks-command.ts +++ b/packages/ghost/src/commands/checks-command.ts @@ -8,6 +8,7 @@ import { import { resolveFingerprintPackage } from "../fingerprint.js"; import { loadChecksDir } from "../scan/checks-dir.js"; import { loadFingerprintPackage } from "../scan/fingerprint-package.js"; +import { guardSurfaces } from "./surface-guard.js"; function parseSurfaceIds(value: unknown): string[] { const raw = Array.isArray(value) ? value : value === undefined ? [] : [value]; @@ -60,6 +61,10 @@ export function registerChecksCommand(cli: CAC): void { // routes + grounds for those surfaces; it does not infer from paths. const touched = parseSurfaceIds(opts.surface); + // A named surface absent from the graph is an error, not a silent + // empty route — emit ERR_UNKNOWN_SURFACE with suggestions and stop. + if (guardSurfaces(loaded.graph, touched, opts.format)) return; + const routed = selectChecksForSurfaces(checks, loaded.graph, touched); const incarnation = diff --git a/packages/ghost/src/commands/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts index d07b7222..f1fa539a 100644 --- a/packages/ghost/src/commands/command-discovery.ts +++ b/packages/ghost/src/commands/command-discovery.ts @@ -68,6 +68,13 @@ const COMMAND_DISCOVERY = [ compactName: "gather", summary: "Gather the composed context slice for a surface.", }, + { + name: "search", + group: "core", + defaultHelp: true, + compactName: "search", + summary: "Search nodes, surfaces, and checks in one ranked result set.", + }, { name: "checks", group: "core", diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index 5a2a9267..9cd77da0 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -1,6 +1,7 @@ import type { CAC } from "cac"; import { buildGraphMenu, + closestIds, GHOST_GRAPH_ROOT_ID, type GraphMenuEntry, type GraphSlice, @@ -51,12 +52,25 @@ export function registerGatherCommand(cli: CAC): void { // No node named, or an unknown one: return the menu, never the tree. const known = new Set(menu.map((entry) => entry.id)); if (!surface || !known.has(surface)) { + const suggestions = surface ? closestIds(surface, known) : []; if (opts.format === "json") { process.stdout.write( - `${JSON.stringify({ kind: "menu", surfaces: menu }, null, 2)}\n`, + `${JSON.stringify( + { + kind: "menu", + surfaces: menu, + ...(surface && !known.has(surface) + ? { code: "ERR_UNKNOWN_SURFACE", suggestions } + : {}), + }, + null, + 2, + )}\n`, ); } else { - process.stdout.write(formatMenuMarkdown(menu, surface)); + process.stdout.write( + formatMenuMarkdown(menu, surface, suggestions), + ); } // Unknown surface is an error (2); no surface at all is a valid menu // request (0). @@ -86,12 +100,17 @@ export function registerGatherCommand(cli: CAC): void { function formatMenuMarkdown( menu: GraphMenuEntry[], unknown: string | undefined, + suggestions: string[] = [], ): string { const lines: string[] = ["# Ghost Nodes"]; if (unknown) { + const didYouMean = + suggestions.length > 0 + ? ` Did you mean: ${suggestions.map((s) => `\`${s}\``).join(", ")}?` + : ""; lines.push( "", - `Node \`${unknown}\` is not in this package. Pick one of the nodes below.`, + `Node \`${unknown}\` is not in this package.${didYouMean} Pick one of the nodes below.`, ); } else { lines.push( diff --git a/packages/ghost/src/commands/review-packet.ts b/packages/ghost/src/commands/review-packet.ts index 80a9fab4..49449e8f 100644 --- a/packages/ghost/src/commands/review-packet.ts +++ b/packages/ghost/src/commands/review-packet.ts @@ -9,6 +9,7 @@ import { loadFingerprintPackage, resolveFingerprintPackage, } from "../scan/fingerprint-package.js"; +import { findUnknownSurfaces, UnknownSurfaceError } from "./surface-guard.js"; const DEFAULT_REVIEW_MAX_DIFF_BYTES = 200_000; @@ -33,6 +34,11 @@ 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))]; + // A named surface absent from the graph is an error, not a silent empty + // route. The command renders this with suggestions. + const unknown = findUnknownSurfaces(loaded.graph, touched); + if (unknown.length > 0) throw new UnknownSurfaceError(unknown); + const routed = selectChecksForSurfaces(checks, loaded.graph, touched); // Grounding is the gather slice: the prose nodes a finding can cite. const grounding = touched.map((surface) => diff --git a/packages/ghost/src/commands/search-command.ts b/packages/ghost/src/commands/search-command.ts new file mode 100644 index 00000000..94098121 --- /dev/null +++ b/packages/ghost/src/commands/search-command.ts @@ -0,0 +1,114 @@ +import type { CAC } from "cac"; +import { type SearchDomain, type SearchHit, searchGraph } from "#ghost-core"; +import { resolveFingerprintPackage } from "../fingerprint.js"; +import { loadChecksDir } from "../scan/checks-dir.js"; +import { loadFingerprintPackage } from "../scan/fingerprint-package.js"; + +const DOMAINS: SearchDomain[] = ["node", "surface", "check"]; + +export function registerSearchCommand(cli: CAC): void { + cli + .command( + "search ", + "Search nodes, surfaces, and checks in one ranked, cross-domain result set, each tagged with the follow-up command.", + ) + .option( + "--type ", + "Restrict to one domain: node, surface, or check", + ) + .option("--limit ", "Cap the number of results (default 20)") + .option( + "--package ", + "Use this fingerprint package directory (default: ./.ghost)", + ) + .option("--format ", "Output format: markdown or json", { + default: "markdown", + }) + .action(async (query: string, opts) => { + try { + if (opts.format !== "markdown" && opts.format !== "json") { + console.error("Error: --format must be 'markdown' or 'json'"); + process.exit(2); + return; + } + + const domain = parseDomainOption(opts.type); + const limit = parseLimitOption(opts.limit); + + const paths = resolveFingerprintPackage(opts.package, process.cwd()); + const loaded = await loadFingerprintPackage(paths); + const { checks } = await loadChecksDir(paths.dir); + + const results = searchGraph( + query, + { + graph: loaded.graph, + checks: checks.map((c) => ({ + name: c.frontmatter.name, + surface: c.frontmatter.surface ?? "core", + body: c.body, + ...(c.frontmatter.description + ? { description: c.frontmatter.description } + : {}), + })), + }, + { + ...(domain !== undefined ? { domain } : {}), + ...(limit !== undefined ? { limit } : {}), + }, + ); + + if (opts.format === "json") { + process.stdout.write( + `${JSON.stringify({ kind: "search", query, results }, null, 2)}\n`, + ); + } else { + process.stdout.write(formatSearchMarkdown(query, results)); + } + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }); +} + +function parseDomainOption(value: unknown): SearchDomain | undefined { + if (value === undefined) return undefined; + const candidate = String(value).trim(); + if ((DOMAINS as string[]).includes(candidate)) { + return candidate as SearchDomain; + } + throw new Error(`--type must be one of: ${DOMAINS.join(", ")}`); +} + +function parseLimitOption(value: unknown): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error("--limit must be a positive integer"); + } + return parsed; +} + +function formatSearchMarkdown(query: string, results: SearchHit[]): string { + const lines: string[] = [`# Ghost Search: \`${query}\``, ""]; + if (results.length === 0) { + lines.push( + "No matching nodes, surfaces, or checks. Run `ghost gather` to list the full node menu.", + ); + return `${lines.join("\n")}\n`; + } + + lines.push(`${results.length} result(s):`, ""); + for (const hit of results) { + lines.push(`- [${hit.domain}] \`${hit.id}\``); + if (hit.description) lines.push(` - ${hit.description}`); + for (const next of hit.next) { + lines.push(` - → \`${next}\``); + } + } + return `${lines.join("\n")}\n`; +} diff --git a/packages/ghost/src/commands/surface-guard.ts b/packages/ghost/src/commands/surface-guard.ts new file mode 100644 index 00000000..3c4a6078 --- /dev/null +++ b/packages/ghost/src/commands/surface-guard.ts @@ -0,0 +1,92 @@ +import { buildGraphMenu, closestIds, type GhostGraph } from "#ghost-core"; + +/** + * The single stable error code the surface-naming commands branch on. When an + * agent names a surface that is not in the graph, `checks`/`review`/`gather` + * emit this code with closest-id suggestions, so the agent can self-correct + * instead of silently routing to nothing. + */ +export const ERR_UNKNOWN_SURFACE = "ERR_UNKNOWN_SURFACE" as const; + +export interface UnknownSurface { + surface: string; + suggestions: string[]; +} + +/** + * Find any named surfaces absent from the graph, each with closest-id + * suggestions. Empty when every surface resolves. Pure: no I/O, no exit. + */ +export function findUnknownSurfaces( + graph: GhostGraph, + surfaces: string[], +): UnknownSurface[] { + const known = new Set(buildGraphMenu(graph).map((entry) => entry.id)); + const unknown: UnknownSurface[] = []; + for (const surface of surfaces) { + if (!known.has(surface)) { + unknown.push({ surface, suggestions: closestIds(surface, known) }); + } + } + return unknown; +} + +/** + * Thrown by library code (e.g. the review packet builder) that cannot itself + * decide output format or exit. The command catches it and renders via + * {@link writeUnknownSurfaceError}. + */ +export class UnknownSurfaceError extends Error { + readonly code = ERR_UNKNOWN_SURFACE; + constructor(readonly unknown: UnknownSurface[]) { + super(`unknown surface(s): ${unknown.map((u) => u.surface).join(", ")}`); + this.name = "UnknownSurfaceError"; + } +} + +/** Render an unknown-surface failure in the requested format (no exit). */ +export function writeUnknownSurfaceError( + unknown: UnknownSurface[], + format: "markdown" | "json", +): void { + if (format === "json") { + process.stdout.write( + `${JSON.stringify( + { + error: `unknown surface(s): ${unknown.map((u) => u.surface).join(", ")}`, + code: ERR_UNKNOWN_SURFACE, + unknown, + }, + null, + 2, + )}\n`, + ); + } else { + for (const { surface, suggestions } of unknown) { + const didYouMean = + suggestions.length > 0 + ? ` Did you mean: ${suggestions.map((s) => `\`${s}\``).join(", ")}?` + : ""; + process.stderr.write( + `Error [${ERR_UNKNOWN_SURFACE}]: unknown surface \`${surface}\`.${didYouMean}\n`, + ); + } + } +} + +/** + * Emit the unknown-surface error in the requested format and exit 2. Returns + * `true` when it handled (and the caller should stop); `false` when every + * surface is known. + */ +export function guardSurfaces( + graph: GhostGraph, + surfaces: string[], + format: "markdown" | "json", +): boolean { + const unknown = findUnknownSurfaces(graph, surfaces); + if (unknown.length === 0) return false; + writeUnknownSurfaceError(unknown, format); + process.exit(2); + return true; +} diff --git a/packages/ghost/src/ghost-core/graph/index.ts b/packages/ghost/src/ghost-core/graph/index.ts index 053b3498..958ba181 100644 --- a/packages/ghost/src/ghost-core/graph/index.ts +++ b/packages/ghost/src/ghost-core/graph/index.ts @@ -17,6 +17,16 @@ export { lintGraph, } from "./lint.js"; export { buildGraphMenu, type GraphMenuEntry } from "./menu.js"; +export { + closestIds, + type SearchCheck, + type SearchDomain, + type SearchHit, + type SearchInput, + type SearchOptions, + type SearchReason, + searchGraph, +} from "./search.js"; export { type GraphSlice, type GraphSliceNode, diff --git a/packages/ghost/src/ghost-core/graph/search.ts b/packages/ghost/src/ghost-core/graph/search.ts new file mode 100644 index 00000000..3b0cd97a --- /dev/null +++ b/packages/ghost/src/ghost-core/graph/search.ts @@ -0,0 +1,319 @@ +import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "./types.js"; + +/** + * Cross-domain discovery over a fingerprint package. Where `gather` with no + * argument dumps the full node menu sorted by id, `search` ranks nodes, + * surfaces, and checks against a query and tags each hit with the follow-up + * command an agent should run. Ranking is deterministic and LLM-free: a name + * match outranks a description match outranks an incidental body mention, with + * a fuzzy fallback for typos. This is selection machinery, not interpretation. + */ + +export type SearchDomain = "node" | "surface" | "check"; + +/** Why a hit matched, strongest first. Doubles as the ranking tier. */ +export type SearchReason = "exact" | "name" | "description" | "body" | "fuzzy"; + +export interface SearchHit { + domain: SearchDomain; + /** Node id or check name. */ + id: string; + description?: string; + /** Higher is more relevant; ties break on id ascending. */ + score: number; + reason: SearchReason; + /** Follow-up command(s) an agent should run to act on this hit. */ + next: string[]; +} + +/** A check projected into a searchable shape (the graph carries nodes only). */ +export interface SearchCheck { + name: string; + surface: string; + body: string; + description?: string; +} + +export interface SearchInput { + graph: GhostGraph; + checks: SearchCheck[]; +} + +export interface SearchOptions { + /** Cap the number of results. Default 20. */ + limit?: number; + /** Restrict to a single domain. */ + domain?: SearchDomain; +} + +const SCORE: Record = { + exact: 100, + name: 80, + description: 50, + body: 20, + fuzzy: 10, +}; + +const DEFAULT_LIMIT = 20; + +/** + * Rank package contents against `query`. Nodes and surfaces come from the + * graph (a node with children is a surface); checks come from `input.checks`. + * Inherited nodes are excluded — search lists what this package offers to + * anchor at, mirroring `buildGraphMenu`. + */ +export function searchGraph( + query: string, + input: SearchInput, + opts: SearchOptions = {}, +): SearchHit[] { + const needle = query.trim().toLowerCase(); + const limit = opts.limit ?? DEFAULT_LIMIT; + const hits: SearchHit[] = []; + + if (needle.length === 0) return []; + const tokens = tokenize(needle); + + const wantNode = opts.domain === undefined || opts.domain === "node"; + const wantSurface = opts.domain === undefined || opts.domain === "surface"; + + for (const node of input.graph.nodes.values()) { + if (node.origin === "inherited") continue; + if (node.id === GHOST_GRAPH_ROOT_ID) continue; + // A surface is a directory: its index node sits in its own folder + // (`folder === id`), or it has children placed under it. A leaf's folder is + // its parent directory, so it never matches. + const isSurface = + node.folder === node.id || + (input.graph.children.get(node.id)?.length ?? 0) > 0; + if (isSurface ? !wantSurface : !wantNode) continue; + + const scored = scoreCandidate( + needle, + tokens, + node.id, + node.description, + node.body, + ); + if (!scored) continue; + hits.push({ + domain: isSurface ? "surface" : "node", + id: node.id, + ...(node.description ? { description: node.description } : {}), + score: scored.score, + reason: scored.reason, + next: nextForNode(node.id, isSurface), + }); + } + + if (opts.domain === undefined || opts.domain === "check") { + for (const check of input.checks) { + const scored = scoreCandidate( + needle, + tokens, + check.name, + check.description, + check.body, + ); + if (!scored) continue; + hits.push({ + domain: "check", + id: check.name, + ...(check.description ? { description: check.description } : {}), + score: scored.score, + reason: scored.reason, + next: [`ghost checks --surface ${check.surface}`], + }); + } + } + + hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)); + return hits.slice(0, Math.max(0, limit)); +} + +/** + * Split a query into meaningful tokens: lowercase words of length >= 2 that are + * not common stopwords. An agent's natural query ("payment confirmation + * screen") is a phrase, not a node id, so search must match its words + * independently rather than as one verbatim string. + */ +function tokenize(needle: string): string[] { + return needle + .split(/[^a-z0-9]+/i) + .map((token) => token.toLowerCase()) + .filter((token) => token.length >= 2 && !STOPWORDS.has(token)); +} + +const STOPWORDS = new Set([ + "a", + "an", + "and", + "as", + "at", + "by", + "for", + "from", + "in", + "of", + "on", + "or", + "the", + "to", + "with", +]); + +function nextForNode(id: string, isSurface: boolean): string[] { + const next = [`ghost gather ${id}`]; + if (isSurface) next.push(`ghost checks --surface ${id}`); + return next; +} + +interface ScoredMatch { + score: number; + reason: SearchReason; +} + +/** + * Score a candidate against the query, strongest signal first: + * + * 1. Whole-query matches (the query, verbatim, in name/description/body) win — + * an exact id, then a name/description/body substring. These are the precise + * hits and keep single-word ranking sharp. + * 2. A whole-name typo gets the fuzzy tier (e.g. `markting` → `marketing`). + * 3. Otherwise, multi-word coverage: how many query tokens appear in the + * candidate (the agent typed a phrase, not an id). The tier follows the + * strongest field any token hit, and the score scales with the fraction of + * tokens covered, so a closer phrase match outranks a looser one. A single + * token hitting only the body is the weakest signal that still counts. + * + * Returns undefined when nothing matches. + */ +function scoreCandidate( + needle: string, + tokens: string[], + name: string, + description: string | undefined, + body: string, +): ScoredMatch | undefined { + const lowerName = name.toLowerCase(); + const lowerDesc = description?.toLowerCase(); + const lowerBody = body.toLowerCase(); + + // 1. Whole-query matches: precise, highest-ranked. + if (lowerName === needle) return { score: SCORE.exact, reason: "exact" }; + if (lowerName.includes(needle)) return { score: SCORE.name, reason: "name" }; + if (lowerDesc?.includes(needle)) { + return { score: SCORE.description, reason: "description" }; + } + if (lowerBody.includes(needle)) return { score: SCORE.body, reason: "body" }; + + // 2. Whole-name typo fallback. + const segment = lowerName.split("/").pop() ?? lowerName; + if (isFuzzyMatch(needle, segment) || isFuzzyMatch(needle, lowerName)) { + return { score: SCORE.fuzzy, reason: "fuzzy" }; + } + + // 3. Multi-word token coverage. Only meaningful for multi-token queries; a + // single token already had its verbatim shot above (a single-token body hit + // is the verbatim body case, already handled). + if (tokens.length < 2) return undefined; + + let covered = 0; + let strongest: SearchReason | undefined; + for (const token of tokens) { + const field = matchField(token, lowerName, lowerDesc, lowerBody); + if (!field) continue; + covered += 1; + if (!strongest || SCORE[field] > SCORE[strongest]) strongest = field; + } + if (covered === 0 || !strongest) return undefined; + + // Scale the field tier by the fraction of tokens covered so a full-phrase + // match outranks a partial one, but keep it below the verbatim tiers. + const coverage = covered / tokens.length; + return { score: Math.round(SCORE[strongest] * coverage), reason: strongest }; +} + +/** The strongest field a single token appears in, or undefined. */ +function matchField( + token: string, + lowerName: string, + lowerDesc: string | undefined, + lowerBody: string, +): SearchReason | undefined { + if (lowerName.includes(token)) return "name"; + if (lowerDesc?.includes(token)) return "description"; + if (lowerBody.includes(token)) return "body"; + return undefined; +} + +/** + * Suggest the ids closest to `query`, nearest first. Used for "did you mean" + * surface suggestions on an unknown name. Substring matches always rank above + * pure edit-distance neighbours. + */ +export function closestIds( + query: string, + ids: Iterable, + max = 3, +): string[] { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return []; + + const scored: { id: string; rank: number; distance: number }[] = []; + for (const id of ids) { + const lower = id.toLowerCase(); + const segment = lower.split("/").pop() ?? lower; + const distance = Math.min( + levenshtein(needle, lower), + levenshtein(needle, segment), + ); + const substring = lower.includes(needle) || needle.includes(lower); + if (substring) { + scored.push({ id, rank: 0, distance }); + } else if (distance <= fuzzyThreshold(needle)) { + scored.push({ id, rank: 1, distance }); + } + } + + scored.sort( + (a, b) => + a.rank - b.rank || a.distance - b.distance || a.id.localeCompare(b.id), + ); + return scored.slice(0, Math.max(0, max)).map((entry) => entry.id); +} + +/** A length-proportional edit-distance threshold for typo tolerance. */ +function fuzzyThreshold(needle: string): number { + if (needle.length <= 4) return 1; + if (needle.length <= 8) return 2; + return 3; +} + +function isFuzzyMatch(needle: string, candidate: string): boolean { + return levenshtein(needle, candidate) <= fuzzyThreshold(needle); +} + +/** Classic iterative Levenshtein distance. Dependency-free, O(n*m). */ +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + let curr = new Array(b.length + 1); + + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min( + (prev[j] ?? 0) + 1, + (curr[j - 1] ?? 0) + 1, + (prev[j - 1] ?? 0) + cost, + ); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length] ?? 0; +} diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index 237e6133..67856adc 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -25,6 +25,7 @@ export { ancestorChain, assembleGraph, buildGraphMenu, + closestIds, GHOST_GRAPH_ROOT_ID, type GhostGraph, type GhostGraphNode, @@ -40,6 +41,13 @@ export { type PlacedNode, type ResolveGraphSliceOptions, resolveGraphSlice, + type SearchCheck, + type SearchDomain, + type SearchHit, + type SearchInput, + type SearchOptions, + type SearchReason, + searchGraph, } from "./graph/index.js"; // --- Node (ghost.node/v1) — the markdown node artifact --- export { diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 3ee5ae28..b51e3842 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -64,6 +64,12 @@ node shape. edge hub's subtree. The agent reads the descriptions and pulls what it needs with a follow-up `gather`. +Naming a surface that is not in the package is an error, not a silent empty +result: `gather`, `checks`, and `review` emit the stable code +`ERR_UNKNOWN_SURFACE` with closest-id `suggestions` (in `--format json`) and a +"Did you mean" line otherwise. Branch on the code, retry with a suggested id, or +run `ghost search ` to find the right one. + Checks and review validate output; they are not generation input. `manifest.yml` anchors the package with `schema: ghost.fingerprint-package/v1`. @@ -98,6 +104,7 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one | `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] [--as ]` | Compose a surface's context slice (corridor spine + relates edges, plus spoke pointers), or list the surface menu. | +| `ghost search [--type ]` | Find nodes, surfaces, and checks in one ranked, cross-domain result set, each tagged with the follow-up command to run. | | `ghost skill install` | Install this unified skill bundle. | ## Advanced CLI Verbs @@ -110,6 +117,7 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one ## Workflows +- Self-check before generating: follow [references/self-check.md](references/self-check.md). - Collaborative authoring scenarios: follow [references/authoring-scenarios.md](references/authoring-scenarios.md). - Fingerprint capture: follow [references/capture.md](references/capture.md). - Recall surface-composition context: follow [references/recall.md](references/recall.md). diff --git a/packages/ghost/src/skill-bundle/references/brief.md b/packages/ghost/src/skill-bundle/references/brief.md index 042c2917..a420ec92 100644 --- a/packages/ghost/src/skill-bundle/references/brief.md +++ b/packages/ghost/src/skill-bundle/references/brief.md @@ -5,6 +5,10 @@ description: Build a concise pre-generation brief from a surface's gather slice. # Recipe: Brief Work From Ghost Fingerprint +0. Before building, run the [self-check](self-check.md): if you cannot name the + nodes you gathered, label each claim as Ghost-backed or provisional, and + point to where the fingerprint is silent, you are not grounded yet — gather + first. 1. Match the ask to a surface in the menu (`ghost gather --format json` with no surface lists the surfaces and their descriptions), then run `ghost gather --format json`. diff --git a/packages/ghost/src/skill-bundle/references/self-check.md b/packages/ghost/src/skill-bundle/references/self-check.md new file mode 100644 index 00000000..a8f33475 --- /dev/null +++ b/packages/ghost/src/skill-bundle/references/self-check.md @@ -0,0 +1,42 @@ +--- +name: self-check +description: A pre-generation probe that reveals whether you have actually gathered and reasoned about a surface's fingerprint, before you build or review its UI. +--- + +# Recipe: Self-Check Before Generating + +Before writing or reviewing UI for a surface, check whether you are grounded in +its fingerprint or about to fall back on generic instincts. These questions test +your **grounding and provenance**, not the surface's contents — so they hold for +any fingerprint, however sparse, whatever the product, whoever authored it. + +Ask yourself: + +1. **What did you gather?** Which Ghost nodes did you pull for this surface + (`ghost gather `), and can you cite them by id? If you have not + gathered, you are not grounded — gather first. +2. **What is Ghost-backed vs. provisional?** For each claim you are about to + encode in the UI, is it backed by a gathered node (cite the id), or is it + your own provisional local reasoning? You must be able to label every claim + as one or the other. +3. **Where is the fingerprint silent?** What does this surface's slice not + cover for your task, and what will carry the reasoning in those gaps? Naming + the silence is part of being grounded; pretending coverage you do not have is + not. + +Optionally, where the prose speaks to it: if the gathered nodes say anything +about hierarchy, density, restraint, repetition, or trust, state what they say. +If they do not, that is a valid answer — record it as silence, not as a failure. + +When you cannot answer 1–3: + +1. Run `ghost search ` to find the node, surface, or check that covers the + work, then follow the `→` command it prints. +2. Run `ghost gather --format json` to compose the surface slice and + read the gathered nodes' prose. +3. Re-ask the three questions, citing node ids. + +A genuinely silent fingerprint is an expected state, not a blocker. When the +slice does not cover the task, say so plainly and proceed with provisional local +reasoning when safe; label it non-Ghost-backed. Ask a human before +product-surface-defining choices. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 2a49ebb6..25ce780d 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -933,6 +933,80 @@ composition: expect(JSON.parse(result.stdout).kind).toBe("menu"); }); + it("suggests the closest surface for an unknown gather target", async () => { + await writeGatherPackage(dir); + + const result = await runCli( + ["gather", "checkou", "--package", ".ghost", "--format", "json"], + dir, + { allowNoExit: true }, + ); + + expect(result.code).toBe(2); + const payload = JSON.parse(result.stdout); + expect(payload.code).toBe("ERR_UNKNOWN_SURFACE"); + expect(payload.suggestions).toContain("checkout"); + }); + + it("searches nodes, surfaces, and checks in one ranked set", async () => { + await writeGatherPackage(dir); + + const result = await runCli( + ["search", "marketing", "--package", ".ghost", "--format", "json"], + dir, + ); + + expect(result.code).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.kind).toBe("search"); + const surface = payload.results.find( + (r: { id: string }) => r.id === "email/marketing", + ); + expect(surface).toBeDefined(); + expect(surface.next).toContain("ghost gather email/marketing"); + }); + + it("search markdown emits follow-up commands and exits 0 on no match", async () => { + await writeGatherPackage(dir); + + const hit = await runCli( + ["search", "marketing", "--package", ".ghost"], + dir, + ); + expect(hit.code).toBe(0); + expect(hit.stdout).toContain("→ `ghost gather email/marketing`"); + + const miss = await runCli( + ["search", "zzzzznope", "--package", ".ghost"], + dir, + ); + expect(miss.code).toBe(0); + expect(miss.stdout).toContain("No matching"); + }); + + it("errors with ERR_UNKNOWN_SURFACE when checks names an unknown surface", async () => { + await writeGatherPackage(dir); + + const result = await runCli( + [ + "checks", + "--surface", + "checkou", + "--package", + ".ghost", + "--format", + "json", + ], + dir, + { allowNoExit: true }, + ); + + expect(result.code).toBe(2); + const payload = JSON.parse(result.stdout); + expect(payload.code).toBe("ERR_UNKNOWN_SURFACE"); + expect(payload.unknown[0].suggestions).toContain("checkout"); + }); + it("migrates a legacy package to the surface model", async () => { const ghost = join(dir, ".ghost"); await mkdir(ghost, { recursive: true }); diff --git a/packages/ghost/test/ghost-core/graph-search.test.ts b/packages/ghost/test/ghost-core/graph-search.test.ts new file mode 100644 index 00000000..9bc9d156 --- /dev/null +++ b/packages/ghost/test/ghost-core/graph-search.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { + assembleGraph, + closestIds, + type PlacedNode, + searchGraph, +} from "../../src/ghost-core/index.js"; + +function root( + id: string, + fm: PlacedNode["doc"]["frontmatter"] = {}, +): PlacedNode { + return { id, parent: "core", folder: "", doc: { frontmatter: fm, body: id } }; +} +function dir( + id: string, + fm: PlacedNode["doc"]["frontmatter"] = {}, + body = id, +): PlacedNode { + const slash = id.lastIndexOf("/"); + const parent = slash === -1 ? "core" : id.slice(0, slash); + return { id, parent, folder: id, doc: { frontmatter: fm, body } }; +} +function leaf( + id: string, + fm: PlacedNode["doc"]["frontmatter"] = {}, + body = id, +): PlacedNode { + const slash = id.lastIndexOf("/"); + const folder = slash === -1 ? "" : id.slice(0, slash); + const parent = folder === "" ? "core" : folder; + return { id, parent, folder, doc: { frontmatter: fm, body } }; +} + +function fixture() { + return assembleGraph({ + placedNodes: [ + root("core"), + dir("marketing", { description: "Outbound brand surfaces." }), + leaf( + "marketing/email", + { description: "Lifecycle email." }, + "Restraint and a single call to action.", + ), + dir("checkout", { description: "The payment flow." }), + ], + }); +} + +describe("searchGraph", () => { + it("ranks exact name over description over body", () => { + const graph = fixture(); + // 'marketing' is an exact id and also appears in the email body? no — use + // a query that hits all tiers across different nodes. + const hits = searchGraph("marketing", { graph, checks: [] }); + expect(hits[0]?.id).toBe("marketing"); + expect(hits[0]?.reason).toBe("exact"); + }); + + it("tags a node with children as a surface and a leaf as a node", () => { + const graph = fixture(); + const hits = searchGraph("email", { graph, checks: [] }); + const email = hits.find((h) => h.id === "marketing/email"); + expect(email?.domain).toBe("node"); + expect(email?.next).toEqual(["ghost gather marketing/email"]); + + const surface = searchGraph("checkout", { graph, checks: [] })[0]; + expect(surface?.domain).toBe("surface"); + expect(surface?.next).toContain("ghost checks --surface checkout"); + }); + + it("matches a body mention at the lowest tier", () => { + const graph = fixture(); + const hits = searchGraph("restraint", { graph, checks: [] }); + expect(hits[0]?.id).toBe("marketing/email"); + expect(hits[0]?.reason).toBe("body"); + }); + + it("finds a typo via the fuzzy fallback", () => { + const graph = fixture(); + const hits = searchGraph("markting", { graph, checks: [] }); + expect(hits.map((h) => h.id)).toContain("marketing"); + expect(hits.find((h) => h.id === "marketing")?.reason).toBe("fuzzy"); + }); + + it("restricts to a domain with --type and caps with --limit", () => { + const graph = fixture(); + const onlyChecks = searchGraph( + "email", + { graph, checks: [] }, + { domain: "check" }, + ); + expect(onlyChecks).toEqual([]); + + const capped = searchGraph("e", { graph, checks: [] }, { limit: 1 }); + expect(capped.length).toBeLessThanOrEqual(1); + }); + + it("searches checks and points at their surface", () => { + const graph = fixture(); + const hits = searchGraph("contrast", { + graph, + checks: [ + { + name: "color-contrast", + surface: "checkout", + description: "Flag low contrast.", + body: "Ensure WCAG AA.", + }, + ], + }); + const check = hits.find((h) => h.domain === "check"); + expect(check?.id).toBe("color-contrast"); + expect(check?.next).toEqual(["ghost checks --surface checkout"]); + }); + + it("matches a multi-word phrase by token coverage", () => { + const graph = fixture(); + // "Outbound brand surfaces." — neither word is the id, but both are in the + // description, so the phrase should still find the marketing surface. + const hits = searchGraph("outbound brand", { graph, checks: [] }); + expect(hits[0]?.id).toBe("marketing"); + }); + + it("ranks fuller phrase coverage above partial coverage", () => { + const graph = fixture(); + // 'payment flow' — both words are in checkout's description; only 'payment' + // weakly elsewhere. Checkout should lead. + const hits = searchGraph("payment flow", { graph, checks: [] }); + expect(hits[0]?.id).toBe("checkout"); + }); + + it("drops stopwords so they do not force false coverage", () => { + const graph = fixture(); + // "the payment flow" — 'the' is a stopword; coverage is over payment+flow. + const withStop = searchGraph("the payment flow", { graph, checks: [] }); + const withoutStop = searchGraph("payment flow", { graph, checks: [] }); + expect(withStop[0]?.id).toBe(withoutStop[0]?.id); + expect(withStop[0]?.score).toBe(withoutStop[0]?.score); + }); + + it("excludes the implicit core root and returns nothing for an empty query", () => { + const graph = fixture(); + expect( + searchGraph("core", { graph, checks: [] }).every((h) => h.id !== "core"), + ).toBe(true); + expect(searchGraph(" ", { graph, checks: [] })).toEqual([]); + }); +}); + +describe("closestIds", () => { + const ids = ["marketing", "marketing/email", "checkout", "core"]; + + it("suggests the nearest id for a typo", () => { + expect(closestIds("markting", ids)[0]).toBe("marketing"); + }); + + it("ranks substring matches above pure edit-distance neighbours", () => { + expect(closestIds("check", ids)[0]).toBe("checkout"); + }); + + it("returns nothing for an empty query and respects max", () => { + expect(closestIds("", ids)).toEqual([]); + expect(closestIds("marketing", ids, 1).length).toBe(1); + }); +}); From 1edd0b19a6fbb93c2f21eaeb44786ec1cd573d9e Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Mon, 29 Jun 2026 10:20:27 -0400 Subject: [PATCH 2/2] refactor(ghost): fold search into gather's no-match path Collapse the standalone `ghost search` command into `gather`: an inexact `gather ` now ranks the closest nodes as candidates instead of dumping the whole menu, the same act as picking from the menu done intelligently. This drops a command in keeping with the project's one-road values, retains `searchGraph` as the ranking engine (node-only now; the weak check-keyword domain is removed since checks are routed by surface, not searched), and keeps `closestIds` feeding ERR_UNKNOWN_SURFACE suggestions on checks/review. --- .../gather-ranking-and-surface-guard.md | 9 ++ .changeset/search-and-surface-guard.md | 8 -- apps/docs/src/content/docs/cli-reference.mdx | 37 ++---- apps/docs/src/generated/cli-manifest.json | 46 +------ packages/ghost/src/cli.ts | 2 - .../ghost/src/commands/command-discovery.ts | 7 -- packages/ghost/src/commands/gather-command.ts | 92 ++++++++------ packages/ghost/src/commands/search-command.ts | 114 ------------------ packages/ghost/src/ghost-core/graph/index.ts | 4 - packages/ghost/src/ghost-core/graph/search.ts | 94 +++------------ packages/ghost/src/ghost-core/index.ts | 4 - packages/ghost/src/skill-bundle/SKILL.md | 14 +-- .../src/skill-bundle/references/self-check.md | 8 +- packages/ghost/test/cli.test.ts | 51 +++----- .../test/ghost-core/graph-search.test.ts | 68 +++-------- 15 files changed, 143 insertions(+), 415 deletions(-) create mode 100644 .changeset/gather-ranking-and-surface-guard.md delete mode 100644 .changeset/search-and-surface-guard.md delete mode 100644 packages/ghost/src/commands/search-command.ts diff --git a/.changeset/gather-ranking-and-surface-guard.md b/.changeset/gather-ranking-and-surface-guard.md new file mode 100644 index 00000000..fcdff64c --- /dev/null +++ b/.changeset/gather-ranking-and-surface-guard.md @@ -0,0 +1,9 @@ +--- +"@anarchitecture/ghost": minor +--- + +Rank the closest nodes when `ghost gather` is given an inexact query (matching +id, description, then body, single words or a phrase) instead of dumping the +whole menu, and emit the stable `ERR_UNKNOWN_SURFACE` code with closest-id +suggestions when `gather`, `checks`, or `review` is given a node or surface that +is not in the package. diff --git a/.changeset/search-and-surface-guard.md b/.changeset/search-and-surface-guard.md deleted file mode 100644 index ba079eea..00000000 --- a/.changeset/search-and-surface-guard.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@anarchitecture/ghost": minor ---- - -Add `ghost search` for ranked, cross-domain discovery of nodes, surfaces, and -checks, each tagged with the follow-up command to run, and emit the stable -`ERR_UNKNOWN_SURFACE` code with closest-id suggestions when `gather`, `checks`, -or `review` is given a surface that is not in the package. diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index 1045e6ee..0ae8df0b 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -101,8 +101,15 @@ ghost validate --format json ### Compose a surface slice: `gather` With no argument, list every node by id and description so an agent can match a -task to one. With a surface, compose its context slice. Folders are walls, -files fill the corridor: +task to one. With an exact node id, compose its context slice. With an inexact +query (`ghost gather payment`), `gather` ranks the closest nodes instead of +dumping the whole menu: a verbatim match wins (an exact id, then a name, +description, or body substring), then a whole-name typo fallback, then +multi-word phrases by how many of their words a node covers — so +`ghost gather payment confirmation` still finds the right surface. Each +candidate is returned for the agent to pick with a follow-up `ghost gather`. + +Composing a slice, folders are walls and files fill the corridor: - **spine** (full bodies): every file from the package root down to the surface's own folder. A sibling folder is a wall; its nodes never appear. @@ -120,6 +127,7 @@ nodes always pass. ```bash ghost gather ghost gather checkout +ghost gather payment # inexact: ranks the closest nodes ghost gather checkout --as email ghost gather checkout --format json ``` @@ -127,29 +135,10 @@ ghost gather checkout --format json This is the pre-generation step: Ghost gives agents surface-composition context before they build, not only after a review finds drift. -### Find anything: `search` - -When you do not know whether what you need is a node, a surface, or a check, -search across all of them at once. Results are ranked: a verbatim match wins -(an exact id, then a name, description, or body substring), then a whole-name -typo fallback, then multi-word queries by how many of their words a candidate -covers — so a natural phrase like `payment confirmation screen` still finds the -right surface. Each result carries the follow-up command to run. Restrict with -`--type ` and cap with `--limit`. - - - -```bash -ghost search checkout -ghost search trust --type node -ghost search contrast --type check -ghost search marketing --format json -``` - Naming a surface that is not in the package is an error, not a silent empty -result: `gather`, `checks`, and `review` emit the stable code -`ERR_UNKNOWN_SURFACE` with closest-id suggestions so an agent can retry without -a human. +result: `gather` returns ranked candidates, and `checks` and `review` emit the +stable code `ERR_UNKNOWN_SURFACE` with closest-id suggestions, so an agent can +retry without a human. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index f3441358..1b607ccb 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-29T12:20:15.926Z", + "generatedAt": "2026-06-29T14:19:25.804Z", "tools": [ { "tool": "ghost", @@ -135,50 +135,6 @@ } ] }, - { - "tool": "ghost", - "name": "search", - "rawName": "search ", - "description": "Search nodes, surfaces, and checks in one ranked, cross-domain result set, each tagged with the follow-up command.", - "group": "core", - "defaultHelp": true, - "compactName": "search", - "summary": "Search nodes, surfaces, and checks in one ranked result set.", - "options": [ - { - "rawName": "--type ", - "name": "type", - "description": "Restrict to one domain: node, surface, or check", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--limit ", - "name": "limit", - "description": "Cap the number of results (default 20)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--package ", - "name": "package", - "description": "Use this fingerprint package directory (default: ./.ghost)", - "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": "checks", diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 3b0a9a94..eeced85b 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -14,7 +14,6 @@ import { buildReviewPacket, formatReviewPacketMarkdown, } from "./commands/review-packet.js"; -import { registerSearchCommand } from "./commands/search-command.js"; import { registerSkillCommand } from "./commands/skill-command.js"; import { UnknownSurfaceError, @@ -31,7 +30,6 @@ export function buildCli(): ReturnType { registerFingerprintCommands(cli); registerGatherCommand(cli); - registerSearchCommand(cli); registerChecksCommand(cli); registerMigrateCommand(cli); registerSkillCommand(cli); diff --git a/packages/ghost/src/commands/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts index f1fa539a..d07b7222 100644 --- a/packages/ghost/src/commands/command-discovery.ts +++ b/packages/ghost/src/commands/command-discovery.ts @@ -68,13 +68,6 @@ const COMMAND_DISCOVERY = [ compactName: "gather", summary: "Gather the composed context slice for a surface.", }, - { - name: "search", - group: "core", - defaultHelp: true, - compactName: "search", - summary: "Search nodes, surfaces, and checks in one ranked result set.", - }, { name: "checks", group: "core", diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index 9cd77da0..522daa52 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -1,12 +1,13 @@ import type { CAC } from "cac"; import { buildGraphMenu, - closestIds, GHOST_GRAPH_ROOT_ID, type GraphMenuEntry, type GraphSlice, type GraphSliceProvenance, resolveGraphSlice, + type SearchHit, + searchGraph, } from "#ghost-core"; import { resolveFingerprintPackage } from "../fingerprint.js"; import { loadFingerprintPackage } from "../scan/fingerprint-package.js"; @@ -48,33 +49,43 @@ export function registerGatherCommand(cli: CAC): void { // The agent names the node (it analyzed the prompt + diff). Ghost // does not infer the anchor from repo paths. const surface = surfaceArg; - - // No node named, or an unknown one: return the menu, never the tree. const known = new Set(menu.map((entry) => entry.id)); - if (!surface || !known.has(surface)) { - const suggestions = surface ? closestIds(surface, known) : []; + + // No node named: list the full menu so the agent can match against it. + if (!surface) { + if (opts.format === "json") { + process.stdout.write( + `${JSON.stringify({ kind: "menu", surfaces: menu }, null, 2)}\n`, + ); + } else { + process.stdout.write(formatMenuMarkdown(menu)); + } + process.exit(0); + return; + } + + // An inexact query (not an exact node id): rank the closest nodes + // rather than dumping the whole menu. This is `gather`'s search front + // end — the same act as picking from the menu, done intelligently. + if (!known.has(surface)) { + const matches = searchGraph(surface, loaded.graph); if (opts.format === "json") { process.stdout.write( `${JSON.stringify( { - kind: "menu", - surfaces: menu, - ...(surface && !known.has(surface) - ? { code: "ERR_UNKNOWN_SURFACE", suggestions } - : {}), + kind: "candidates", + code: "ERR_UNKNOWN_SURFACE", + query: surface, + candidates: matches, }, null, 2, )}\n`, ); } else { - process.stdout.write( - formatMenuMarkdown(menu, surface, suggestions), - ); + process.stdout.write(formatCandidatesMarkdown(surface, matches)); } - // Unknown surface is an error (2); no surface at all is a valid menu - // request (0). - process.exit(surface && !known.has(surface) ? 2 : 0); + process.exit(2); return; } @@ -97,28 +108,13 @@ export function registerGatherCommand(cli: CAC): void { }); } -function formatMenuMarkdown( - menu: GraphMenuEntry[], - unknown: string | undefined, - suggestions: string[] = [], -): string { - const lines: string[] = ["# Ghost Nodes"]; - if (unknown) { - const didYouMean = - suggestions.length > 0 - ? ` Did you mean: ${suggestions.map((s) => `\`${s}\``).join(", ")}?` - : ""; - lines.push( - "", - `Node \`${unknown}\` is not in this package.${didYouMean} Pick one of the nodes below.`, - ); - } else { - lines.push( - "", - "No node selected. Match the ask to one of these nodes, then run `ghost gather `.", - ); - } - lines.push(""); +function formatMenuMarkdown(menu: GraphMenuEntry[]): string { + const lines: string[] = [ + "# Ghost Nodes", + "", + "No node selected. Match the ask to one of these nodes, then run `ghost gather `.", + "", + ]; for (const entry of menu) { const parent = entry.parent === entry.id ? "" : ` (under \`${entry.parent}\`)`; @@ -128,6 +124,26 @@ function formatMenuMarkdown( return `${lines.join("\n")}\n`; } +function formatCandidatesMarkdown(query: string, matches: SearchHit[]): string { + const lines: string[] = ["# Ghost Nodes", ""]; + if (matches.length === 0) { + lines.push( + `No node matches \`${query}\`. Run \`ghost gather\` to list every node.`, + ); + return `${lines.join("\n")}\n`; + } + lines.push( + `\`${query}\` is not a node id. Closest matches — run \`ghost gather \`:`, + "", + ); + for (const hit of matches) { + const kind = hit.surface ? "surface" : "node"; + lines.push(`- \`${hit.id}\` (${kind})`); + if (hit.description) lines.push(` - ${hit.description}`); + } + return `${lines.join("\n")}\n`; +} + function provenanceLabel(provenance: GraphSliceProvenance): string { switch (provenance.kind) { case "own": diff --git a/packages/ghost/src/commands/search-command.ts b/packages/ghost/src/commands/search-command.ts deleted file mode 100644 index 94098121..00000000 --- a/packages/ghost/src/commands/search-command.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { CAC } from "cac"; -import { type SearchDomain, type SearchHit, searchGraph } from "#ghost-core"; -import { resolveFingerprintPackage } from "../fingerprint.js"; -import { loadChecksDir } from "../scan/checks-dir.js"; -import { loadFingerprintPackage } from "../scan/fingerprint-package.js"; - -const DOMAINS: SearchDomain[] = ["node", "surface", "check"]; - -export function registerSearchCommand(cli: CAC): void { - cli - .command( - "search ", - "Search nodes, surfaces, and checks in one ranked, cross-domain result set, each tagged with the follow-up command.", - ) - .option( - "--type ", - "Restrict to one domain: node, surface, or check", - ) - .option("--limit ", "Cap the number of results (default 20)") - .option( - "--package ", - "Use this fingerprint package directory (default: ./.ghost)", - ) - .option("--format ", "Output format: markdown or json", { - default: "markdown", - }) - .action(async (query: string, opts) => { - try { - if (opts.format !== "markdown" && opts.format !== "json") { - console.error("Error: --format must be 'markdown' or 'json'"); - process.exit(2); - return; - } - - const domain = parseDomainOption(opts.type); - const limit = parseLimitOption(opts.limit); - - const paths = resolveFingerprintPackage(opts.package, process.cwd()); - const loaded = await loadFingerprintPackage(paths); - const { checks } = await loadChecksDir(paths.dir); - - const results = searchGraph( - query, - { - graph: loaded.graph, - checks: checks.map((c) => ({ - name: c.frontmatter.name, - surface: c.frontmatter.surface ?? "core", - body: c.body, - ...(c.frontmatter.description - ? { description: c.frontmatter.description } - : {}), - })), - }, - { - ...(domain !== undefined ? { domain } : {}), - ...(limit !== undefined ? { limit } : {}), - }, - ); - - if (opts.format === "json") { - process.stdout.write( - `${JSON.stringify({ kind: "search", query, results }, null, 2)}\n`, - ); - } else { - process.stdout.write(formatSearchMarkdown(query, results)); - } - process.exit(0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(1); - } - }); -} - -function parseDomainOption(value: unknown): SearchDomain | undefined { - if (value === undefined) return undefined; - const candidate = String(value).trim(); - if ((DOMAINS as string[]).includes(candidate)) { - return candidate as SearchDomain; - } - throw new Error(`--type must be one of: ${DOMAINS.join(", ")}`); -} - -function parseLimitOption(value: unknown): number | undefined { - if (value === undefined) return undefined; - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error("--limit must be a positive integer"); - } - return parsed; -} - -function formatSearchMarkdown(query: string, results: SearchHit[]): string { - const lines: string[] = [`# Ghost Search: \`${query}\``, ""]; - if (results.length === 0) { - lines.push( - "No matching nodes, surfaces, or checks. Run `ghost gather` to list the full node menu.", - ); - return `${lines.join("\n")}\n`; - } - - lines.push(`${results.length} result(s):`, ""); - for (const hit of results) { - lines.push(`- [${hit.domain}] \`${hit.id}\``); - if (hit.description) lines.push(` - ${hit.description}`); - for (const next of hit.next) { - lines.push(` - → \`${next}\``); - } - } - return `${lines.join("\n")}\n`; -} diff --git a/packages/ghost/src/ghost-core/graph/index.ts b/packages/ghost/src/ghost-core/graph/index.ts index 958ba181..7b479859 100644 --- a/packages/ghost/src/ghost-core/graph/index.ts +++ b/packages/ghost/src/ghost-core/graph/index.ts @@ -19,11 +19,7 @@ export { export { buildGraphMenu, type GraphMenuEntry } from "./menu.js"; export { closestIds, - type SearchCheck, - type SearchDomain, type SearchHit, - type SearchInput, - type SearchOptions, type SearchReason, searchGraph, } from "./search.js"; diff --git a/packages/ghost/src/ghost-core/graph/search.ts b/packages/ghost/src/ghost-core/graph/search.ts index 3b0cd97a..7d1ce2f5 100644 --- a/packages/ghost/src/ghost-core/graph/search.ts +++ b/packages/ghost/src/ghost-core/graph/search.ts @@ -1,49 +1,26 @@ import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "./types.js"; /** - * Cross-domain discovery over a fingerprint package. Where `gather` with no - * argument dumps the full node menu sorted by id, `search` ranks nodes, - * surfaces, and checks against a query and tags each hit with the follow-up - * command an agent should run. Ranking is deterministic and LLM-free: a name - * match outranks a description match outranks an incidental body mention, with - * a fuzzy fallback for typos. This is selection machinery, not interpretation. + * Node ranking for `gather`. Where `gather` with no argument lists the full + * node menu sorted by id, and `gather ` composes a slice, an inexact + * query (`gather payment`) needs the *closest* nodes ranked, not the whole menu + * dumped. This is that ranking: deterministic and LLM-free — a name match + * outranks a description match outranks an incidental body mention, a + * whole-name typo is tolerated, and a multi-word phrase matches by how many of + * its words a node covers. Selection machinery, not interpretation. */ -export type SearchDomain = "node" | "surface" | "check"; - /** Why a hit matched, strongest first. Doubles as the ranking tier. */ export type SearchReason = "exact" | "name" | "description" | "body" | "fuzzy"; export interface SearchHit { - domain: SearchDomain; - /** Node id or check name. */ id: string; description?: string; + /** True when the node is a directory/surface (vs. a leaf node). */ + surface: boolean; /** Higher is more relevant; ties break on id ascending. */ score: number; reason: SearchReason; - /** Follow-up command(s) an agent should run to act on this hit. */ - next: string[]; -} - -/** A check projected into a searchable shape (the graph carries nodes only). */ -export interface SearchCheck { - name: string; - surface: string; - body: string; - description?: string; -} - -export interface SearchInput { - graph: GhostGraph; - checks: SearchCheck[]; -} - -export interface SearchOptions { - /** Cap the number of results. Default 20. */ - limit?: number; - /** Restrict to a single domain. */ - domain?: SearchDomain; } const SCORE: Record = { @@ -57,15 +34,15 @@ const SCORE: Record = { const DEFAULT_LIMIT = 20; /** - * Rank package contents against `query`. Nodes and surfaces come from the - * graph (a node with children is a surface); checks come from `input.checks`. - * Inherited nodes are excluded — search lists what this package offers to - * anchor at, mirroring `buildGraphMenu`. + * Rank a package's local nodes against `query`, nearest first. A node with + * children (or whose index sits in its own folder) is flagged as a surface. + * Inherited (extended-package) nodes are excluded, mirroring `buildGraphMenu` — + * ranking lists what this package offers to anchor at. */ export function searchGraph( query: string, - input: SearchInput, - opts: SearchOptions = {}, + graph: GhostGraph, + opts: { limit?: number } = {}, ): SearchHit[] { const needle = query.trim().toLowerCase(); const limit = opts.limit ?? DEFAULT_LIMIT; @@ -74,19 +51,14 @@ export function searchGraph( if (needle.length === 0) return []; const tokens = tokenize(needle); - const wantNode = opts.domain === undefined || opts.domain === "node"; - const wantSurface = opts.domain === undefined || opts.domain === "surface"; - - for (const node of input.graph.nodes.values()) { + for (const node of graph.nodes.values()) { if (node.origin === "inherited") continue; if (node.id === GHOST_GRAPH_ROOT_ID) continue; // A surface is a directory: its index node sits in its own folder // (`folder === id`), or it has children placed under it. A leaf's folder is // its parent directory, so it never matches. - const isSurface = - node.folder === node.id || - (input.graph.children.get(node.id)?.length ?? 0) > 0; - if (isSurface ? !wantSurface : !wantNode) continue; + const surface = + node.folder === node.id || (graph.children.get(node.id)?.length ?? 0) > 0; const scored = scoreCandidate( needle, @@ -97,36 +69,14 @@ export function searchGraph( ); if (!scored) continue; hits.push({ - domain: isSurface ? "surface" : "node", id: node.id, ...(node.description ? { description: node.description } : {}), + surface, score: scored.score, reason: scored.reason, - next: nextForNode(node.id, isSurface), }); } - if (opts.domain === undefined || opts.domain === "check") { - for (const check of input.checks) { - const scored = scoreCandidate( - needle, - tokens, - check.name, - check.description, - check.body, - ); - if (!scored) continue; - hits.push({ - domain: "check", - id: check.name, - ...(check.description ? { description: check.description } : {}), - score: scored.score, - reason: scored.reason, - next: [`ghost checks --surface ${check.surface}`], - }); - } - } - hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)); return hits.slice(0, Math.max(0, limit)); } @@ -162,12 +112,6 @@ const STOPWORDS = new Set([ "with", ]); -function nextForNode(id: string, isSurface: boolean): string[] { - const next = [`ghost gather ${id}`]; - if (isSurface) next.push(`ghost checks --surface ${id}`); - return next; -} - interface ScoredMatch { score: number; reason: SearchReason; diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index 67856adc..772955ef 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -41,11 +41,7 @@ export { type PlacedNode, type ResolveGraphSliceOptions, resolveGraphSlice, - type SearchCheck, - type SearchDomain, type SearchHit, - type SearchInput, - type SearchOptions, type SearchReason, searchGraph, } from "./graph/index.js"; diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index b51e3842..6111775a 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -64,11 +64,12 @@ node shape. edge hub's subtree. The agent reads the descriptions and pulls what it needs with a follow-up `gather`. -Naming a surface that is not in the package is an error, not a silent empty -result: `gather`, `checks`, and `review` emit the stable code -`ERR_UNKNOWN_SURFACE` with closest-id `suggestions` (in `--format json`) and a -"Did you mean" line otherwise. Branch on the code, retry with a suggested id, or -run `ghost search ` to find the right one. +Naming a node that is not in the package is an error, not a silent empty +result. An inexact `gather ` ranks the closest nodes as `candidates` +(matching id, description, then body — single words or a phrase) under the +stable code `ERR_UNKNOWN_SURFACE`; `checks` and `review` emit the same code with +closest-id `suggestions` (in `--format json`) and a "Did you mean" line +otherwise. Branch on the code and retry with a ranked candidate or suggestion. Checks and review validate output; they are not generation input. @@ -103,8 +104,7 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one | `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] [--as ]` | Compose a surface's context slice (corridor spine + relates edges, plus spoke pointers), or list the surface menu. | -| `ghost search [--type ]` | Find nodes, surfaces, and checks in one ranked, cross-domain result set, each tagged with the follow-up command to run. | +| `ghost gather [node] [--as ]` | Compose a node's context slice (corridor spine + relates edges, plus spoke pointers), list the node menu, or rank the closest nodes for an inexact query. | | `ghost skill install` | Install this unified skill bundle. | ## Advanced CLI Verbs diff --git a/packages/ghost/src/skill-bundle/references/self-check.md b/packages/ghost/src/skill-bundle/references/self-check.md index a8f33475..a2e0f113 100644 --- a/packages/ghost/src/skill-bundle/references/self-check.md +++ b/packages/ghost/src/skill-bundle/references/self-check.md @@ -30,10 +30,10 @@ If they do not, that is a valid answer — record it as silence, not as a failur When you cannot answer 1–3: -1. Run `ghost search ` to find the node, surface, or check that covers the - work, then follow the `→` command it prints. -2. Run `ghost gather --format json` to compose the surface slice and - read the gathered nodes' prose. +1. Run `ghost gather` to list the node menu, or `ghost gather ` to rank + the closest nodes for a term the work is about. +2. Run `ghost gather --format json` to compose the slice and read the + gathered nodes' prose. 3. Re-ask the three questions, citing node ids. A genuinely silent fingerprint is an expected state, not a blocker. When the diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 25ce780d..f29c38fc 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -920,68 +920,51 @@ composition: ); }); - it("returns the menu and exits non-zero for an unknown surface", async () => { + it("ranks closest candidates for an inexact gather query", async () => { await writeGatherPackage(dir); const result = await runCli( - ["gather", "nope", "--package", ".ghost", "--format", "json"], + ["gather", "marketng", "--package", ".ghost", "--format", "json"], dir, { allowNoExit: true }, ); expect(result.code).toBe(2); - expect(JSON.parse(result.stdout).kind).toBe("menu"); + const payload = JSON.parse(result.stdout); + expect(payload.kind).toBe("candidates"); + expect(payload.code).toBe("ERR_UNKNOWN_SURFACE"); + expect(payload.candidates.map((c: { id: string }) => c.id)).toContain( + "email/marketing", + ); }); - it("suggests the closest surface for an unknown gather target", async () => { + it("matches a gather query by phrase against descriptions", async () => { await writeGatherPackage(dir); const result = await runCli( - ["gather", "checkou", "--package", ".ghost", "--format", "json"], + ["gather", "marketing email", "--package", ".ghost", "--format", "json"], dir, { allowNoExit: true }, ); expect(result.code).toBe(2); const payload = JSON.parse(result.stdout); - expect(payload.code).toBe("ERR_UNKNOWN_SURFACE"); - expect(payload.suggestions).toContain("checkout"); + expect(payload.candidates[0].id).toBe("email/marketing"); }); - it("searches nodes, surfaces, and checks in one ranked set", async () => { + it("returns an empty candidate set for a gather query with no match", async () => { await writeGatherPackage(dir); const result = await runCli( - ["search", "marketing", "--package", ".ghost", "--format", "json"], + ["gather", "zzzzznope", "--package", ".ghost", "--format", "json"], dir, + { allowNoExit: true }, ); - expect(result.code).toBe(0); + expect(result.code).toBe(2); const payload = JSON.parse(result.stdout); - expect(payload.kind).toBe("search"); - const surface = payload.results.find( - (r: { id: string }) => r.id === "email/marketing", - ); - expect(surface).toBeDefined(); - expect(surface.next).toContain("ghost gather email/marketing"); - }); - - it("search markdown emits follow-up commands and exits 0 on no match", async () => { - await writeGatherPackage(dir); - - const hit = await runCli( - ["search", "marketing", "--package", ".ghost"], - dir, - ); - expect(hit.code).toBe(0); - expect(hit.stdout).toContain("→ `ghost gather email/marketing`"); - - const miss = await runCli( - ["search", "zzzzznope", "--package", ".ghost"], - dir, - ); - expect(miss.code).toBe(0); - expect(miss.stdout).toContain("No matching"); + expect(payload.kind).toBe("candidates"); + expect(payload.candidates).toEqual([]); }); it("errors with ERR_UNKNOWN_SURFACE when checks names an unknown surface", async () => { diff --git a/packages/ghost/test/ghost-core/graph-search.test.ts b/packages/ghost/test/ghost-core/graph-search.test.ts index 9bc9d156..057c0758 100644 --- a/packages/ghost/test/ghost-core/graph-search.test.ts +++ b/packages/ghost/test/ghost-core/graph-search.test.ts @@ -50,75 +50,47 @@ function fixture() { describe("searchGraph", () => { it("ranks exact name over description over body", () => { const graph = fixture(); - // 'marketing' is an exact id and also appears in the email body? no — use - // a query that hits all tiers across different nodes. - const hits = searchGraph("marketing", { graph, checks: [] }); + const hits = searchGraph("marketing", graph); expect(hits[0]?.id).toBe("marketing"); expect(hits[0]?.reason).toBe("exact"); }); - it("tags a node with children as a surface and a leaf as a node", () => { + it("flags a directory node as a surface and a leaf as a node", () => { const graph = fixture(); - const hits = searchGraph("email", { graph, checks: [] }); - const email = hits.find((h) => h.id === "marketing/email"); - expect(email?.domain).toBe("node"); - expect(email?.next).toEqual(["ghost gather marketing/email"]); - - const surface = searchGraph("checkout", { graph, checks: [] })[0]; - expect(surface?.domain).toBe("surface"); - expect(surface?.next).toContain("ghost checks --surface checkout"); + const email = searchGraph("email", graph).find( + (h) => h.id === "marketing/email", + ); + expect(email?.surface).toBe(false); + + const surface = searchGraph("checkout", graph)[0]; + expect(surface?.surface).toBe(true); }); it("matches a body mention at the lowest tier", () => { const graph = fixture(); - const hits = searchGraph("restraint", { graph, checks: [] }); + const hits = searchGraph("restraint", graph); expect(hits[0]?.id).toBe("marketing/email"); expect(hits[0]?.reason).toBe("body"); }); it("finds a typo via the fuzzy fallback", () => { const graph = fixture(); - const hits = searchGraph("markting", { graph, checks: [] }); + const hits = searchGraph("markting", graph); expect(hits.map((h) => h.id)).toContain("marketing"); expect(hits.find((h) => h.id === "marketing")?.reason).toBe("fuzzy"); }); - it("restricts to a domain with --type and caps with --limit", () => { + it("caps the result count with limit", () => { const graph = fixture(); - const onlyChecks = searchGraph( - "email", - { graph, checks: [] }, - { domain: "check" }, - ); - expect(onlyChecks).toEqual([]); - - const capped = searchGraph("e", { graph, checks: [] }, { limit: 1 }); + const capped = searchGraph("e", graph, { limit: 1 }); expect(capped.length).toBeLessThanOrEqual(1); }); - it("searches checks and points at their surface", () => { - const graph = fixture(); - const hits = searchGraph("contrast", { - graph, - checks: [ - { - name: "color-contrast", - surface: "checkout", - description: "Flag low contrast.", - body: "Ensure WCAG AA.", - }, - ], - }); - const check = hits.find((h) => h.domain === "check"); - expect(check?.id).toBe("color-contrast"); - expect(check?.next).toEqual(["ghost checks --surface checkout"]); - }); - it("matches a multi-word phrase by token coverage", () => { const graph = fixture(); // "Outbound brand surfaces." — neither word is the id, but both are in the // description, so the phrase should still find the marketing surface. - const hits = searchGraph("outbound brand", { graph, checks: [] }); + const hits = searchGraph("outbound brand", graph); expect(hits[0]?.id).toBe("marketing"); }); @@ -126,25 +98,23 @@ describe("searchGraph", () => { const graph = fixture(); // 'payment flow' — both words are in checkout's description; only 'payment' // weakly elsewhere. Checkout should lead. - const hits = searchGraph("payment flow", { graph, checks: [] }); + const hits = searchGraph("payment flow", graph); expect(hits[0]?.id).toBe("checkout"); }); it("drops stopwords so they do not force false coverage", () => { const graph = fixture(); // "the payment flow" — 'the' is a stopword; coverage is over payment+flow. - const withStop = searchGraph("the payment flow", { graph, checks: [] }); - const withoutStop = searchGraph("payment flow", { graph, checks: [] }); + const withStop = searchGraph("the payment flow", graph); + const withoutStop = searchGraph("payment flow", graph); expect(withStop[0]?.id).toBe(withoutStop[0]?.id); expect(withStop[0]?.score).toBe(withoutStop[0]?.score); }); it("excludes the implicit core root and returns nothing for an empty query", () => { const graph = fixture(); - expect( - searchGraph("core", { graph, checks: [] }).every((h) => h.id !== "core"), - ).toBe(true); - expect(searchGraph(" ", { graph, checks: [] })).toEqual([]); + expect(searchGraph("core", graph).every((h) => h.id !== "core")).toBe(true); + expect(searchGraph(" ", graph)).toEqual([]); }); });