From 28e0e59e6f15ff15ce72e546643fea6a3f853822 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 9 Sep 2026 00:26:49 -0400 Subject: [PATCH] Report incomplete guidance loading --- .changeset/visible-load-diagnostics.md | 7 + packages/ghost/README.md | 5 +- packages/ghost/src/commands/gather-command.ts | 7 +- packages/ghost/src/commands/pull-command.ts | 8 + packages/ghost/src/embed/gather.ts | 7 +- packages/ghost/src/embed/pull.ts | 10 +- packages/ghost/src/embed/snapshot.ts | 20 +- packages/ghost/src/embed/types.ts | 6 +- .../ghost/src/internal/load-diagnostics.ts | 16 + packages/ghost/src/review/review-packet.ts | 7 + packages/ghost/src/scan/check-files.ts | 22 +- packages/ghost/src/scan/node-files.ts | 20 +- packages/ghost/src/skill-bundle/SKILL.md | 9 +- .../src/skill-bundle/references/ground.md | 4 + .../src/skill-bundle/references/schema.md | 23 +- packages/ghost/test/load-diagnostics.test.ts | 337 ++++++++++++++++++ 16 files changed, 483 insertions(+), 25 deletions(-) create mode 100644 .changeset/visible-load-diagnostics.md create mode 100644 packages/ghost/src/internal/load-diagnostics.ts create mode 100644 packages/ghost/test/load-diagnostics.test.ts diff --git a/.changeset/visible-load-diagnostics.md b/.changeset/visible-load-diagnostics.md new file mode 100644 index 00000000..40ec80db --- /dev/null +++ b/.changeset/visible-load-diagnostics.md @@ -0,0 +1,7 @@ +--- +"@design-intelligence/ghost": minor +--- + +Add load diagnostics to gather, pull, and review JSON and embedded results, with actionable warnings in Markdown. Gather and pull report skipped invalid guidance; review also reports skipped invalid checks. All-miss CLI pulls keep stdout empty and report load diagnostics on stderr before exiting with code 2. + +Breaking API change: gather's `contract.completeness.complete` changes from literal `true` to `boolean` and is `false` when invalid guidance was skipped. Consumers must check the value before treating the menu as complete. Healthy results include `diagnostics: []`; no on-disk schema migration is required. Unreadable directories and malformed present glossaries now fail loading instead of appearing absent. diff --git a/packages/ghost/README.md b/packages/ghost/README.md index 54df63f5..7fd9435a 100644 --- a/packages/ghost/README.md +++ b/packages/ghost/README.md @@ -80,7 +80,10 @@ Embedded hosts can use `@design-intelligence/ghost/embed` for the same semantic contract as CLI `gather` and `pull` without CLI-only presentation fields or event side effects. `loadGhostSnapshot` reads the package, resolved/absent/dangling cover state, glossary kinds, and checks. `gatherGhostPackage` returns the -complete unfiltered selectable menu without cover content; checks stay separate. +unfiltered selectable menu without cover content; checks stay separate. +Gather and pull return skipped guidance files in `diagnostics`. Check gather's +`contract.completeness.complete` before treating its menu as complete; these +loading diagnostics do not replace `ghost validate`. `pullGhostNodes` includes the resolved cover before validated, de-duplicated selected ids, returns misses with suggestions, stable concrete/prose ordering, stripped node bodies, extracted Skeletons, and material transport packets. Use diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index dee9331f..14b8af25 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -2,6 +2,7 @@ import type { CAC } from "cac"; import { type CatalogMenuEntry, UsageError } from "#ghost-core"; import type { GhostGatherResult } from "../embed/index.js"; import { gatherGhostPackage, loadGhostSnapshot } from "../embed/index.js"; +import { formatLoadDiagnostics } from "../internal/load-diagnostics.js"; import { appendGhostEvent, resolveRunId } from "../observability-events.js"; import { resolveGhostPackage } from "../package.js"; import { exitCli, failFromError } from "./errors.js"; @@ -73,6 +74,7 @@ function normalizeAskParts(askParts: string[] | undefined): string | undefined { function formatGatherJson(menu: GhostGatherResult): Record { return { kind: menu.kind, + diagnostics: menu.diagnostics, ...(menu.ask ? { ask: menu.ask } : {}), source: menu.source, contract: menu.contract, @@ -91,7 +93,10 @@ function formatMenuMarkdown(menu: GhostGatherResult): string { const lines: string[] = [ "# Guidance menu", "", - "This is the complete, unfiltered menu. For the task below, check every `Applies when` condition and pull every applicable ID. The entries have not been selected or ranked.", + ...(menu.diagnostics.length > 0 + ? [formatLoadDiagnostics(menu.diagnostics), ""] + : []), + `${menu.contract.completeness.complete ? "This is the complete, unfiltered menu." : "This menu is incomplete because invalid guidance was skipped."} For the task below, check every \`Applies when\` condition and pull every applicable ID. The entries have not been selected or ranked.`, "", "## Task", "", diff --git a/packages/ghost/src/commands/pull-command.ts b/packages/ghost/src/commands/pull-command.ts index 4e158c83..3b762b85 100644 --- a/packages/ghost/src/commands/pull-command.ts +++ b/packages/ghost/src/commands/pull-command.ts @@ -2,6 +2,7 @@ import type { CAC } from "cac"; import { inferMaterialMime, type TransportedMaterial } from "#ghost-core"; import type { GhostPulledNode, GhostPullResult } from "../embed/index.js"; import { loadGhostSnapshot, pullGhostNodes } from "../embed/index.js"; +import { formatLoadDiagnostics } from "../internal/load-diagnostics.js"; import { appendGhostEvent, resolveRunId } from "../observability-events.js"; import { GHOST_EVENTS_FILENAME, @@ -69,6 +70,9 @@ export function registerPullCommand(cli: CAC): void { } if (result.ids.length === 0 && result.missed.length > 0) { + if (result.diagnostics.length > 0) { + console.error(formatLoadDiagnostics(result.diagnostics)); + } await exitCli(2); return; } @@ -108,6 +112,7 @@ function formatPullJson( ): Record { return { kind: "pull", + diagnostics: result.diagnostics, requested: result.requested, ids: result.ids, ...(result.missed.length > 0 ? { missed: result.missed } : {}), @@ -136,6 +141,9 @@ function formatPullJson( function formatPullMarkdown(result: GhostPullResult): string { const sections: string[] = []; + if (result.diagnostics.length > 0) { + sections.push(formatLoadDiagnostics(result.diagnostics)); + } if (result.cover.state === "resolved") { sections.push(formatNodeMarkdown(result.cover.node)); } diff --git a/packages/ghost/src/embed/gather.ts b/packages/ghost/src/embed/gather.ts index e48df23d..e61ea620 100644 --- a/packages/ghost/src/embed/gather.ts +++ b/packages/ghost/src/embed/gather.ts @@ -29,12 +29,13 @@ export function gatherGhostPackage( return { kind: "menu", + diagnostics: snapshot.invalid, ...(ask ? { ask } : {}), source: { artifact: "ghost package", list: "Available guidance", }, - contract: gatherContract(), + contract: gatherContract(snapshot.invalid.length === 0), coverage: menuCoverage(menu), ...(kinds.length > 0 ? { kinds } : {}), nodes: menu, @@ -60,10 +61,10 @@ export const GATHER_IF_NONE_APPLY_INSTRUCTION = export const GATHER_NO_ASK_INSTRUCTION = "When no ask is supplied, this menu is not grounded to a task. Re-run `ghost gather ` before pulling for a task."; -export function gatherContract(): GhostGatherContract { +export function gatherContract(complete = true): GhostGatherContract { return { completeness: { - complete: true, + complete, filtered: false, ranked: false, selectedByGhost: false, diff --git a/packages/ghost/src/embed/pull.ts b/packages/ghost/src/embed/pull.ts index f87eebce..03bcb127 100644 --- a/packages/ghost/src/embed/pull.ts +++ b/packages/ghost/src/embed/pull.ts @@ -61,7 +61,12 @@ export async function pullGhostNodes( .map((id) => ({ requested: id, suggested: closestIds(id, selectableIds) })); if (known.length === 0 && missed.length > 0) { - return emptyMissResult(selectedRequested, missed, coverId); + return emptyMissResult( + selectedRequested, + missed, + coverId, + snapshot.invalid, + ); } const givenNodes = known.map( @@ -92,6 +97,7 @@ export async function pullGhostNodes( return { kind: "pull", + diagnostics: snapshot.invalid, requested: selectedRequested, ids: known, missed, @@ -114,9 +120,11 @@ function emptyMissResult( requested: readonly string[], missed: readonly PullMiss[], coverId: string | undefined, + diagnostics: GhostPullResult["diagnostics"], ): GhostPullResult { return { kind: "pull", + diagnostics, requested, ids: [], missed, diff --git a/packages/ghost/src/embed/snapshot.ts b/packages/ghost/src/embed/snapshot.ts index 4df45122..5d32b0b6 100644 --- a/packages/ghost/src/embed/snapshot.ts +++ b/packages/ghost/src/embed/snapshot.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import type { GhostCheckFrontmatter } from "#ghost-core"; -import { type GhostCatalogNode, parseGlossary } from "#ghost-core"; +import { type GhostCatalogNode, parseGlossary, UsageError } from "#ghost-core"; import { isMissingPathError } from "../internal/fs.js"; import type { LoadedCheck } from "../scan/check-files.js"; import type { GhostPackagePaths } from "../scan/ghost-package.js"; @@ -97,10 +97,22 @@ async function loadSnapshotGlossary( raw = await readFile(glossaryPath, "utf-8"); } catch (err) { if (isMissingPathError(err)) return undefined; - throw err; + throw new Error( + `Cannot read glossary "${glossaryPath}": ${err instanceof Error ? err.message : String(err)}. Check the path and read permissions, then retry.`, + { cause: err }, + ); + } + let result: ReturnType; + try { + result = parseGlossary(raw); + if (result.glossary === null) { + throw new Error(result.errors[0] ?? "invalid glossary"); + } + } catch (err) { + throw new UsageError( + `Cannot load glossary "${glossaryPath}": ${err instanceof Error ? err.message : String(err)}. Fix glossary.md frontmatter (for example, kinds: []), then run \`ghost validate\`.`, + ); } - const result = parseGlossary(raw); - if (result.glossary === null) return undefined; return { path: glossaryPath, kinds: result.glossary.kinds.map((kind) => ({ ...kind })), diff --git a/packages/ghost/src/embed/types.ts b/packages/ghost/src/embed/types.ts index a1b184ef..d39b432e 100644 --- a/packages/ghost/src/embed/types.ts +++ b/packages/ghost/src/embed/types.ts @@ -64,7 +64,7 @@ export interface GhostGatherCoverage { export interface GhostGatherContract { completeness: { - complete: true; + complete: boolean; filtered: false; ranked: false; selectedByGhost: false; @@ -83,6 +83,8 @@ export interface GhostGatherContract { export interface GhostGatherResult { kind: "menu"; + /** Invalid guidance files skipped during loading; never includes checks. */ + diagnostics: GhostEmbedSnapshot["invalid"]; ask?: string; source: { artifact: "ghost package"; @@ -134,6 +136,8 @@ export interface GhostPullFallback { export interface GhostPullResult { kind: "pull"; + /** Invalid guidance files skipped during loading, including all-miss pulls. */ + diagnostics: GhostEmbedSnapshot["invalid"]; /** Caller-selected ids after de-duplicating and removing a cover alias. */ requested: readonly string[]; ids: readonly string[]; diff --git a/packages/ghost/src/internal/load-diagnostics.ts b/packages/ghost/src/internal/load-diagnostics.ts new file mode 100644 index 00000000..84ee06b0 --- /dev/null +++ b/packages/ghost/src/internal/load-diagnostics.ts @@ -0,0 +1,16 @@ +/** Format the loader's existing skipped-file records without running lint again. */ +export function formatLoadDiagnostics( + diagnostics: readonly Readonly<{ file: string; message: string }>[], +): string { + if (diagnostics.length === 0) return ""; + return [ + "> Warning: invalid package files were skipped during loading. Fix the files below, then run `ghost validate` (use `--package ` for a custom package).", + ...diagnostics.map( + ({ file, message }) => `> - ${oneLine(file)}: ${oneLine(message)}`, + ), + ].join("\n"); +} + +function oneLine(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} diff --git a/packages/ghost/src/review/review-packet.ts b/packages/ghost/src/review/review-packet.ts index 89c1c6a1..8cf30d7f 100644 --- a/packages/ghost/src/review/review-packet.ts +++ b/packages/ghost/src/review/review-packet.ts @@ -5,6 +5,7 @@ import { materialLocator, normalizeMaterial, } from "#ghost-core"; +import { formatLoadDiagnostics } from "../internal/load-diagnostics.js"; import { GHOST_MATERIALS_DIR } from "../scan/constants.js"; import type { LoadedGhostPackage } from "../scan/ghost-package.js"; import { resolveGitRoot } from "../scan/package-paths.js"; @@ -40,6 +41,8 @@ export interface PacketCheck { export interface ReviewPacket { packageId: string; + /** Invalid guidance and check files skipped during loading. */ + diagnostics: ReadonlyArray>; touchedFiles: string[]; materialNodes: PacketMaterialNode[]; checks: PacketCheck[]; @@ -92,6 +95,7 @@ export async function buildReviewPacket( return { packageId: ghostPackage.manifest.id, + diagnostics: [...ghostPackage.invalid, ...ghostPackage.invalidChecks], touchedFiles: resolution.touchedFiles.map((file) => file.path), materialNodes, checks, @@ -120,6 +124,9 @@ function materialNodeFromMatch( export function formatReviewPacket(packet: ReviewPacket): string { const out: string[] = []; out.push(`# ghost review — package \`${packet.packageId}\``, ""); + if (packet.diagnostics.length > 0) { + out.push(formatLoadDiagnostics(packet.diagnostics), ""); + } out.push( "You are reviewing a diff against ghost package guidance. The command has", "assembled the touched files, matched material-backed nodes, and offered", diff --git a/packages/ghost/src/scan/check-files.ts b/packages/ghost/src/scan/check-files.ts index 594e20ae..1b2c7cb0 100644 --- a/packages/ghost/src/scan/check-files.ts +++ b/packages/ghost/src/scan/check-files.ts @@ -1,5 +1,6 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, join } from "node:path"; +import { YAMLParseError } from "yaml"; import { type GhostCheckDocument, lintGhostCheck, @@ -7,6 +8,8 @@ import { parseCheckMarkdown, } from "#ghost-core"; +import { isMissingPathError } from "../internal/fs.js"; + /** Reserved package-root directory holding review checks. */ export const GHOST_CHECKS_DIR = "checks"; @@ -40,8 +43,14 @@ export async function loadCheckFiles( let entries: Array<{ name: string; isDirectory(): boolean }>; try { entries = await readdir(checksDir, { withFileTypes: true }); - } catch { - return { hasChecksDir: false, checks, invalid }; + } catch (err) { + if (isMissingPathError(err)) { + return { hasChecksDir: false, checks, invalid }; + } + throw new Error( + `Cannot read checks directory "${checksDir}": ${err instanceof Error ? err.message : String(err)}. Check the path and directory permissions, then retry.`, + { cause: err }, + ); } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { @@ -66,7 +75,14 @@ export async function loadCheckFiles( } const raw = await readFile(join(checksDir, entry.name), "utf-8"); - const lint = lintGhostCheck(raw); + let lint: ReturnType; + try { + lint = lintGhostCheck(raw); + } catch (err) { + if (!(err instanceof YAMLParseError)) throw err; + invalid.push({ file: `checks/${entry.name}`, message: err.message }); + continue; + } if (lint.errors > 0) { const first = lint.issues.find((issue) => issue.severity === "error"); invalid.push({ diff --git a/packages/ghost/src/scan/node-files.ts b/packages/ghost/src/scan/node-files.ts index 84d4510e..d49ffd66 100644 --- a/packages/ghost/src/scan/node-files.ts +++ b/packages/ghost/src/scan/node-files.ts @@ -1,6 +1,8 @@ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; +import { YAMLParseError } from "yaml"; import { type PlacedNode, parseNode } from "#ghost-core"; +import { isMissingPathError } from "../internal/fs.js"; import { GHOST_GLOSSARY_FILENAME, GHOST_MANIFEST_FILENAME, @@ -67,8 +69,12 @@ async function walk( try { const dirents = await readdir(absDir, { withFileTypes: true }); entries = dirents.map((d) => ({ name: d.name, isDir: d.isDirectory() })); - } catch { - return; + } catch (err) { + if (isMissingPathError(err)) return; + throw new Error( + `Cannot read node directory "${absDir}": ${err instanceof Error ? err.message : String(err)}. Check the path and directory permissions, then retry.`, + { cause: err }, + ); } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { @@ -84,7 +90,15 @@ async function walk( if (!entry.name.endsWith(".md")) continue; const raw = await readFile(join(packageDir, relPath), "utf-8"); - const { node, report } = parseNode(raw); + let parsed: ReturnType; + try { + parsed = parseNode(raw); + } catch (err) { + if (!(err instanceof YAMLParseError)) throw err; + invalid.push({ file: relPath, message: err.message }); + continue; + } + const { node, report } = parsed; if (node === null || report.errors > 0) { const first = report.issues.find((issue) => issue.severity === "error"); invalid.push({ diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index fb4abefe..7cb5a401 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -76,8 +76,9 @@ ghost stats # summarize local gather/pull events while tuning surface: the task, then every selectable id and its applicability. Check the full list and pull every id that applies. If none apply, run bare `ghost pull`. Declared kind headings render in glossary order, undeclared kinds -alphabetically, and uncategorized guidance last. -Markdown omits package diagnostics that do not change the next action. JSON +alphabetically, and uncategorized guidance last. Loading failures appear in both +formats; do not treat excluded guidance as an authored absence. +Markdown omits other package diagnostics that do not change the next action. JSON retains the selection contract, coverage, materials, substantial fenced examples, Skeletons, and missing `for` payloads for integrations and audits. @@ -92,8 +93,8 @@ and diagnostic metadata for integrations. Pulls append structured events to tuning. `review` does no grading. It assembles the review packet: touched files, -matched material-backed nodes, offered checks, coverage gaps, and the diff. The -host agent renders findings. +matched material-backed nodes, offered checks, loading failures, coverage gaps, +and the diff. The host agent renders findings. For visual work, do not stop at generation: ground, make, then verify in two tracks, repair within budget, and review. See diff --git a/packages/ghost/src/skill-bundle/references/ground.md b/packages/ghost/src/skill-bundle/references/ground.md index d847c0e2..641562a6 100644 --- a/packages/ghost/src/skill-bundle/references/ground.md +++ b/packages/ghost/src/skill-bundle/references/ground.md @@ -15,6 +15,10 @@ supplied guidance, then check every item under `Available guidance`. Pull every id whose `Applies when` condition fits the task. Skip clear non-matches; topic overlap alone is not enough. +If the output reports excluded guidance, do not interpret it as silence. Run +`ghost validate` against the same package, resolve the reported failures, or +state the grounding gap before continuing provisionally. + The cover is not in this menu because every pull includes it automatically. If nothing in the list applies, run bare `ghost pull` for the cover and uncovered- guidance policy. diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 36748b4f..c1babfa4 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -119,9 +119,10 @@ it does not grade them. - `ghost gather ` emits agent-facing Markdown: the task, then every selectable id and its applicability. It groups declared kinds in glossary order, undeclared kinds alphabetically, and uncategorized guidance last. - Checks and diagnostic metadata are absent. `--format json` retains the - selection contract, coverage, kind metadata, and concrete payload metadata - for tooling. + Checks stay absent. Loading failures appear in Markdown and JSON; an + incomplete menu never means the package has no applicable guidance. + `--format json` also retains coverage, kind metadata, and concrete payload + metadata for tooling. - `ghost pull` emits the resolved cover before selected guidance in steering order, inlines eligible local text material once, marks included material as untrusted source data, leaves later duplicate references, gives direct actions @@ -129,5 +130,19 @@ it does not grade them. JSON retains node kinds and transport diagnostics omitted from agent-facing Markdown. - `ghost review` matches touched files to exact local material paths, offers - relevant checks, and emits a review packet for the host agent. + relevant checks, includes loading diagnostics, and emits a review packet for + the host agent. - `ghost stats` summarizes local gather and pull events. + +### Loading diagnostics + +Gather and pull expose excluded guidance as `diagnostics` records with `file` +and `message`. Review includes excluded checks in that list as well. +These describe loading failures, not a full validation pass. Gather's +`contract.completeness.complete` is false when guidance was excluded; invalid +checks do not change generation completeness or enter generation packets. + +Partial results retain their normal success status and show the exclusions. +Run `ghost validate` to diagnose the package before claiming complete grounding. +Unreadable directories and malformed present glossaries fail rather than +appearing empty. Missing optional glossaries and checks remain normal. diff --git a/packages/ghost/test/load-diagnostics.test.ts b/packages/ghost/test/load-diagnostics.test.ts new file mode 100644 index 00000000..5b0ba24c --- /dev/null +++ b/packages/ghost/test/load-diagnostics.test.ts @@ -0,0 +1,337 @@ +import * as fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { gatherGhostPackage } from "../src/embed/gather.js"; +import { pullGhostNodes } from "../src/embed/pull.js"; +import { loadGhostSnapshot } from "../src/embed/snapshot.js"; +import { loadGhostPackage, resolveGhostPackage } from "../src/package.js"; +import { + buildReviewPacket, + formatReviewPacket, +} from "../src/review/review-packet.js"; +import { loadCheckFiles } from "../src/scan/check-files.js"; +import { loadNodeFiles } from "../src/scan/node-files.js"; +import { runCli } from "./cli-test-utils.js"; + +// Mock directory failures, not chmod: permission tests must work as root too. +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, readdir: vi.fn(original.readdir) }; +}); + +const validNode = "---\nfor: Writing an interface.\n---\n\nKeep it clear.\n"; +const invalidNode = "---\nfor: [not, text]\n---\n\nBroken guidance.\n"; + +let dir: string; +let packageDir: string; + +beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), "ghost-load-diagnostics-")); + packageDir = join(dir, ".ghost"); + await fs.mkdir(packageDir); + await fs.writeFile( + join(packageDir, "manifest.yml"), + "schema: ghost.package/v1\nid: diagnostics\ncover: cover\n", + ); + await fs.writeFile(join(packageDir, "cover.md"), validNode); + await fs.writeFile(join(packageDir, "voice.md"), validNode); +}); + +afterEach(async () => { + vi.mocked(fs.readdir).mockReset(); + const original = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(fs.readdir).mockImplementation(original.readdir); + await fs.rm(dir, { recursive: true, force: true }); +}); + +async function addInvalidFiles(): Promise { + await fs.mkdir(join(packageDir, "nested")); + await fs.writeFile(join(packageDir, "nested", "bad.md"), invalidNode); + await fs.writeFile(join(packageDir, "broken.md"), invalidNode); + await fs.mkdir(join(packageDir, "checks")); + await fs.writeFile( + join(packageDir, "checks", "broken.md"), + "No frontmatter.\n", + ); +} + +function paths() { + return resolveGhostPackage(packageDir, dir); +} + +describe("load diagnostics", () => { + it("keeps healthy diagnostics explicit and tolerates absent optional files", async () => { + const snapshot = await loadGhostSnapshot(paths()); + expect(snapshot.glossary).toBeUndefined(); + const menu = gatherGhostPackage(snapshot); + expect(menu.diagnostics).toEqual([]); + expect(menu.contract.completeness.complete).toBe(true); + expect( + (await pullGhostNodes(snapshot, { repoRoot: dir })).diagnostics, + ).toEqual([]); + const packet = await buildReviewPacket( + await loadGhostPackage(paths()), + "", + { cwd: dir }, + ); + expect(packet.diagnostics).toEqual([]); + expect(formatReviewPacket(packet)).not.toContain("Warning:"); + }); + + it("carries only invalid nodes through embed gather and pull, including all misses", async () => { + await addInvalidFiles(); + const snapshot = await loadGhostSnapshot(paths()); + expect(snapshot.invalid.map((entry) => entry.file)).toEqual([ + "broken.md", + "nested/bad.md", + ]); + const menu = gatherGhostPackage(snapshot, { ask: "Write an interface" }); + expect(menu.diagnostics).toEqual(snapshot.invalid); + expect(menu.contract.completeness).toEqual({ + complete: false, + filtered: false, + ranked: false, + selectedByGhost: false, + }); + expect(menu.nodes.map((node) => node.id)).toEqual(["voice"]); + for (const ids of [[], ["voice"], ["voice", "broken"], ["broken"]]) { + const pull = await pullGhostNodes(snapshot, { ids, repoRoot: dir }); + expect(pull.diagnostics).toEqual(snapshot.invalid); + } + const miss = await pullGhostNodes(snapshot, { + ids: ["broken"], + repoRoot: dir, + }); + expect(miss.cover.state).toBe("not-emitted"); + expect(miss.nodes).toEqual([]); + }); + + it("distinguishes all-invalid guidance from a healthy empty package", async () => { + await fs.writeFile( + join(packageDir, "manifest.yml"), + "schema: ghost.package/v1\nid: diagnostics\n", + ); + await fs.rm(join(packageDir, "cover.md")); + await fs.writeFile(join(packageDir, "voice.md"), invalidNode); + const incomplete = gatherGhostPackage(await loadGhostSnapshot(paths())); + expect(incomplete.nodes).toEqual([]); + expect(incomplete.contract.completeness.complete).toBe(false); + expect(incomplete.diagnostics).toHaveLength(1); + + await fs.rm(join(packageDir, "voice.md")); + const empty = gatherGhostPackage(await loadGhostSnapshot(paths())); + expect(empty.nodes).toEqual([]); + expect(empty.contract.completeness.complete).toBe(true); + expect(empty.diagnostics).toEqual([]); + }); + + it("does not let invalid checks affect gather completeness or pull diagnostics", async () => { + await fs.mkdir(join(packageDir, "checks")); + await fs.writeFile( + join(packageDir, "checks", "broken.md"), + "No frontmatter.\n", + ); + const snapshot = await loadGhostSnapshot(paths()); + expect(snapshot.invalidChecks).toHaveLength(1); + const menu = gatherGhostPackage(snapshot); + expect(menu.diagnostics).toEqual([]); + expect(menu.contract.completeness.complete).toBe(true); + expect( + (await pullGhostNodes(snapshot, { ids: ["voice"], repoRoot: dir })) + .diagnostics, + ).toEqual([]); + }); + + it("collects malformed YAML as a file diagnostic without blocking valid guidance", async () => { + await fs.writeFile( + join(packageDir, "broken.md"), + "---\nfor: [\n---\n\nBroken.\n", + ); + await fs.mkdir(join(packageDir, "checks")); + await fs.writeFile( + join(packageDir, "checks", "broken.md"), + "---\nreferences: [\n---\n\nBroken check.\n", + ); + const snapshot = await loadGhostSnapshot(paths()); + expect(snapshot.invalid[0]?.file).toBe("broken.md"); + expect(snapshot.invalidChecks[0]?.file).toBe("checks/broken.md"); + const menu = gatherGhostPackage(snapshot); + expect(menu.nodes.map((node) => node.id)).toEqual(["voice"]); + expect(menu.contract.completeness.complete).toBe(false); + await fs.rm(join(packageDir, "broken.md")); + const checksOnly = gatherGhostPackage(await loadGhostSnapshot(paths())); + expect(checksOnly.contract.completeness.complete).toBe(true); + expect(checksOnly.diagnostics).toEqual([]); + }); + + it("includes invalid guidance and checks in review JSON and actionable markdown", async () => { + await addInvalidFiles(); + const loaded = await loadGhostPackage(paths()); + const packet = await buildReviewPacket(loaded, "", { cwd: dir }); + expect(packet.diagnostics).toEqual([ + ...loaded.invalid, + ...loaded.invalidChecks, + ]); + const markdown = formatReviewPacket(packet); + for (const diagnostic of packet.diagnostics) { + expect(markdown).toContain(diagnostic.file); + expect(markdown).toContain(diagnostic.message); + } + expect(markdown).toContain("Warning:"); + expect(markdown).toContain("ghost validate"); + await fs.writeFile(join(dir, "change.diff"), ""); + const result = await runCli( + ["review", "--diff", "change.diff", "--format", "json"], + dir, + ); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).diagnostics).toEqual(packet.diagnostics); + }); + + it.each([ + "json", + "markdown", + ])("reports partial gather and pull diagnostics in %s without failing", async (format) => { + await addInvalidFiles(); + const snapshot = await loadGhostSnapshot(paths()); + const gather = await runCli( + ["gather", "Write an interface", "--format", format], + dir, + ); + const pull = await runCli( + ["pull", "voice", "broken", "--no-events", "--format", format], + dir, + ); + expect(gather.code).toBe(0); + expect(pull.code).toBe(0); + expect(pull.stderr).toContain("unknown node `broken`"); + for (const result of [gather, pull]) { + if (format === "json") { + expect(JSON.parse(result.stdout).diagnostics).toEqual(snapshot.invalid); + } else { + expect(result.stdout).toContain("Warning:"); + expect(result.stdout).toContain("broken.md"); + expect(result.stdout).toContain("nested/bad.md"); + expect(result.stdout).toContain("ghost validate"); + expect(result.stdout).not.toContain("checks/broken.md"); + } + } + if (format === "json") { + expect(JSON.parse(gather.stdout).contract.completeness.complete).toBe( + false, + ); + } else { + expect(gather.stdout).not.toContain( + "This is the complete, unfiltered menu.", + ); + expect(gather.stdout).toContain("incomplete"); + } + }); + + it.each([ + "json", + "markdown", + ])("keeps all-miss %s stdout empty and emits load diagnostics before exiting 2", async (format) => { + await addInvalidFiles(); + const result = await runCli(["pull", "broken", "--format", format], dir); + expect(result.code).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("broken.md"); + expect(result.stderr).toContain("nested/bad.md"); + expect(result.stderr).toContain("ghost validate"); + expect(result.stderr).not.toContain("checks/broken.md"); + await expect(fs.stat(join(packageDir, ".events"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("preserves missing-package and dangling-cover failure exits", async () => { + const missing = await runCli( + ["gather", "task", "--package", "absent"], + dir, + ); + expect(missing.code).toBe(2); + expect(missing.stdout).toBe(""); + expect(missing.stderr).toContain("ghost init"); + await fs.writeFile(join(packageDir, "cover.md"), invalidNode); + const dangling = await runCli(["pull", "voice"], dir); + expect(dangling.code).toBe(2); + expect(dangling.stdout).toBe(""); + expect(dangling.stderr).toContain('manifest cover "cover"'); + }); +}); + +describe("load failures are not absence", () => { + it.each([ + "No frontmatter.", + "---\nkinds: invalid\n---\n", + "---\nkinds: [\n---\n", + ])("rejects a malformed present glossary: %s", async (raw) => { + await fs.writeFile(join(packageDir, "glossary.md"), raw); + await expect(loadGhostSnapshot(paths())).rejects.toThrow( + /glossary\.md.*ghost validate/s, + ); + const result = await runCli(["gather", "task", "--format", "json"], dir); + expect(result.code).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("glossary.md"); + expect(result.stderr).toContain("ghost validate"); + }); + + it.each([ + "EACCES", + "EIO", + "ENOTDIR", + ])("rejects %s when reading node or check directories", async (code) => { + const error = Object.assign(new Error(`filesystem ${code}`), { code }); + for (const load of [loadNodeFiles, loadCheckFiles]) { + vi.mocked(fs.readdir).mockRejectedValueOnce(error); + await expect(load(packageDir)).rejects.toThrow( + /Check .*permissions.*retry/s, + ); + } + }); + + it("propagates a nested directory failure rather than returning a partial catalog", async () => { + await fs.mkdir(join(packageDir, "nested")); + const original = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(fs.readdir).mockImplementationOnce(original.readdir); + vi.mocked(fs.readdir).mockRejectedValueOnce( + Object.assign(new Error("denied"), { code: "EACCES" }), + ); + await expect(loadNodeFiles(packageDir)).rejects.toThrow(/nested/); + }); + + it("treats only ENOENT as an absent directory", async () => { + const missing = Object.assign(new Error("missing"), { code: "ENOENT" }); + vi.mocked(fs.readdir).mockRejectedValueOnce(missing); + await expect(loadNodeFiles(packageDir)).resolves.toEqual({ + nodes: [], + invalid: [], + }); + vi.mocked(fs.readdir).mockRejectedValueOnce(missing); + await expect(loadCheckFiles(packageDir)).resolves.toEqual({ + hasChecksDir: false, + checks: new Map(), + invalid: [], + }); + }); + + it("fails CLI loading on readdir errors without presenting empty success", async () => { + vi.mocked(fs.readdir).mockRejectedValueOnce( + Object.assign(new Error("denied"), { code: "EACCES" }), + ); + const result = await runCli(["gather", "task", "--format", "json"], dir); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(packageDir); + expect(result.stderr).toContain("permissions"); + }); +});