diff --git a/.changeset/ghost-check-json-report.md b/.changeset/ghost-check-json-report.md new file mode 100644 index 00000000..d52d22f6 --- /dev/null +++ b/.changeset/ghost-check-json-report.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +Add `ghost check --format json` for detector-backed markdown checks, producing `ghost.check-report/v1` findings that review orchestrators can convert into PR comments. diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index ab1d0491..0b88c6cb 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -141,6 +141,46 @@ A mixed pull partially succeeds: known ids are emitted, unknown ids warn with cl + + +### Run deterministic checks: `check` + +Run detector-backed `ghost.check/v1` checks against a unified diff and emit a +stable report. Checks without `detector:` remain agent-evaluated and are +surfaced by `ghost review`, not run by `ghost check`. + + + +```bash +ghost check --diff change.patch --format json +ghost check --base origin/main --format json +``` + +The JSON report is intended for review orchestrators that own PR posting +identity: + +```json +{ + "schema": "ghost.check-report/v1", + "result": "fail", + "findings": [ + { + "check_id": "no-palette-utilities", + "title": "No raw palette utilities", + "severity": "high", + "path": "src/features/chat/ui/GhostReviewDemo.tsx", + "line": 20, + "detector": "forbidden-regex", + "message": "Added UI code matched a forbidden pattern.", + "match": "bg-gray-100", + "repair": "Use semantic token utilities." + } + ] +} +``` + + + ### Install the skill: `skill` diff --git a/apps/docs/src/content/docs/integrations-enterprise-review.mdx b/apps/docs/src/content/docs/integrations-enterprise-review.mdx new file mode 100644 index 00000000..c85744e1 --- /dev/null +++ b/apps/docs/src/content/docs/integrations-enterprise-review.mdx @@ -0,0 +1,89 @@ +--- +title: Enterprise review integration +slug: integrations/enterprise-review +description: How Ghost deterministic findings flow into enterprise PR review systems. +section: guide +order: 50 +--- + + + +Ghost is not a PR bot. Ghost emits deterministic design findings; your review +orchestrator owns PR discovery, diff-line mapping, status updates, and the +GitHub posting identity. + +A typical enterprise lane is: + +```text +PR opened or updated + ↓ +Enterprise code-review routine + ↓ +ghost check --package --diff --format json + ↓ +Ghost emits deterministic findings + ↓ +Review orchestrator maps findings to inline PR comments + ↓ +The organization's approved review bot posts comments +``` + +Repo-local scripts should not call `gh pr comment` directly for Ghost review +findings when a first-party review system owns comment identity, auditability, +and branch-protection behavior. + + + + + +An enterprise review lane can invoke Ghost as a deterministic check engine: + +```bash +ghost check --package --diff --format json +``` + +For environments that do not have `ghost` preinstalled, use a pinned npm +invocation: + +```bash +npx --yes @anarchitecture/ghost@0.18.1 check \ + --package \ + --diff \ + --format json +``` + +The JSON shape is stable for consumers: + +```json +{ + "schema": "ghost.check-report/v1", + "result": "fail", + "findings": [ + { + "check_id": "no-palette-utilities", + "title": "No raw palette utilities", + "severity": "high", + "path": "src/features/chat/ui/GhostReviewDemo.tsx", + "line": 20, + "detector": "forbidden-regex", + "message": "Added UI code matched a forbidden pattern.", + "match": "bg-gray-100", + "repair": "Use semantic token utilities." + } + ] +} +``` + + + + + +Use the base branch Ghost package as trusted review policy and the PR diff as +reviewed input. A PR should not be able to change the policy that evaluates +itself by default. + +For demos where a PR introduces its first Ghost package, make any head-branch +policy mode explicit in the host review system and clearly label findings as +demo-grounded. Production review lanes should use the base-branch package. + + diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index cf285923..0999c0be 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-03T05:32:38.719Z", + "generatedAt": "2026-07-06T18:55:13.135Z", "tools": [ { "tool": "ghost", @@ -76,6 +76,50 @@ } ] }, + { + "tool": "ghost", + "name": "check", + "rawName": "check", + "description": "Run deterministic ghost.check/v1 gates with detectors against a git diff.", + "group": "core", + "defaultHelp": true, + "compactName": "check", + "summary": "Run deterministic detector-backed checks against a diff.", + "options": [ + { + "rawName": "--base ", + "name": "base", + "description": "Git ref to diff against (default: HEAD)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--diff ", + "name": "diff", + "description": "Unified diff file to check instead of running git diff. Use '-' for stdin.", + "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": "gather", diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 29cb055e..26111a82 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { cac } from "cac"; +import { registerCheckCommand } from "./commands/check-command.js"; import { formatGhostHelp } from "./commands/command-discovery.js"; import { registerFingerprintCommands } from "./commands/fingerprint-commands.js"; import { registerGatherCommand } from "./commands/gather-command.js"; @@ -21,6 +22,7 @@ export function buildCli(): ReturnType { const cli = cac("ghost"); registerFingerprintCommands(cli); + registerCheckCommand(cli); registerGatherCommand(cli); registerPullCommand(cli); registerPulseCommand(cli); diff --git a/packages/ghost/src/commands/check-command.ts b/packages/ghost/src/commands/check-command.ts new file mode 100644 index 00000000..f1058daa --- /dev/null +++ b/packages/ghost/src/commands/check-command.ts @@ -0,0 +1,363 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import type { CAC } from "cac"; +import { UsageError } from "#ghost-core"; +import { matchesGlob } from "../review/glob.js"; +import { resolveFingerprintPackage } from "../scan/fingerprint-package.js"; +import { loadHauntTree } from "../scan/haunt-tree.js"; +import { resolveGhostDirDefault } from "../scan/package-paths.js"; +import { failFromError } from "./errors.js"; + +const execFileAsync = promisify(execFile); + +const CHECK_REPORT_SCHEMA = "ghost.check-report/v1" as const; + +type CheckResult = "pass" | "fail"; + +type CheckDetectorType = "forbidden-regex" | "required-regex"; + +type CheckDetector = { + type: CheckDetectorType; + pattern: string; + flags?: string; + paths?: string[]; +}; + +type CheckFrontmatterExtras = { + id?: unknown; + name?: unknown; + title?: unknown; + description?: unknown; + severity?: unknown; + detector?: unknown; + message?: unknown; + repair?: unknown; +}; + +export interface GhostCheckReport { + schema: typeof CHECK_REPORT_SCHEMA; + result: CheckResult; + findings: GhostCheckFinding[]; +} + +export interface GhostCheckFinding { + check_id: string; + title: string; + severity: string; + path?: string; + line?: number; + detector: CheckDetectorType; + message: string; + match?: string; + repair?: string; +} + +type AddedLine = { + path: string; + line: number; + text: string; +}; + +type AddedFile = { + path: string; + lines: AddedLine[]; +}; + +/** Register `ghost check`, the deterministic diff gate used by external review lanes. */ +export function registerCheckCommand(cli: CAC): void { + cli + .command( + "check", + "Run deterministic ghost.check/v1 gates with detectors against a git diff.", + ) + .option("--base ", "Git ref to diff against (default: HEAD)") + .option( + "--diff ", + "Unified diff file to check instead of running git diff. Use '-' for stdin.", + ) + .option( + "--package ", + "Use this fingerprint package directory (default: ./.ghost)", + ) + .option("--format ", "Output format: markdown or json", { + default: "markdown", + }) + .action(async (opts) => { + try { + if (opts.format !== "markdown" && opts.format !== "json") { + throw new UsageError("--format must be 'markdown' or 'json'"); + } + const packageDir = resolveFingerprintPackage( + typeof opts.package === "string" + ? opts.package + : resolveGhostDirDefault(), + process.cwd(), + ).dir; + const diffText = + typeof opts.diff === "string" + ? await readDiffInput(opts.diff) + : await readGitDiff(process.cwd(), opts.base ?? "HEAD"); + const report = await runDeterministicChecks({ packageDir, diffText }); + + if (opts.format === "json") { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else { + process.stdout.write(formatCheckReportMarkdown(report)); + } + process.exit(report.result === "fail" ? 1 : 0); + } catch (err) { + failFromError(err); + } + }); +} + +export async function runDeterministicChecks({ + packageDir, + diffText, +}: { + packageDir: string; + diffText: string; +}): Promise { + const hauntTree = await loadHauntTree(packageDir); + const checks = [...hauntTree.checks.values()]; + const addedFiles = parseAddedFiles(diffText); + const findings: GhostCheckFinding[] = []; + + for (const check of checks) { + const frontmatter = check.doc.frontmatter as CheckFrontmatterExtras; + const detector = parseDetector(frontmatter.detector); + if (!detector) continue; + + const regex = compileDetectorRegex(detector); + const checkId = check.id || checkIdFor(frontmatter); + const title = titleFor(frontmatter); + const severity = stringOr(frontmatter.severity, "medium"); + const message = + stringOr(frontmatter.message, undefined) ?? + stringOr(frontmatter.description, undefined) ?? + "Added diff content matched a Ghost deterministic check."; + const repair = stringOr(frontmatter.repair, undefined); + + const targetFiles = addedFiles.filter((file) => + detectorMatchesPath(detector, file.path), + ); + + if (detector.type === "forbidden-regex") { + for (const file of targetFiles) { + for (const line of file.lines) { + regex.lastIndex = 0; + for (const match of line.text.matchAll(regex)) { + findings.push({ + check_id: checkId, + title, + severity, + path: line.path, + line: line.line, + detector: detector.type, + message, + ...(match[0] ? { match: match[0] } : {}), + ...(repair ? { repair } : {}), + }); + if (!regex.global) break; + } + } + } + continue; + } + + if (detector.type === "required-regex") { + for (const file of targetFiles) { + if (file.lines.length === 0) continue; + const hasRequired = file.lines.some((line) => { + regex.lastIndex = 0; + return regex.test(line.text); + }); + if (!hasRequired) { + findings.push({ + check_id: checkId, + title, + severity, + path: file.path, + line: file.lines[0]?.line, + detector: detector.type, + message, + ...(repair ? { repair } : {}), + }); + } + } + } + } + + return { + schema: CHECK_REPORT_SCHEMA, + result: findings.length > 0 ? "fail" : "pass", + findings, + }; +} + +async function readDiffInput(input: string): Promise { + if (input === "-") return readStdin(); + return readFile(resolve(process.cwd(), input), "utf-8"); +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +async function readGitDiff(cwd: string, base: unknown): Promise { + const { stdout } = await execFileAsync( + "git", + ["diff", "--unified=80", typeof base === "string" ? base : "HEAD"], + { + cwd, + maxBuffer: 1024 * 1024 * 20, + }, + ); + return stdout; +} + +function parseAddedFiles(diffText: string): AddedFile[] { + const files = new Map(); + let currentPath: string | undefined; + let newLine: number | undefined; + + for (const line of diffText.split(/\r?\n/)) { + if (line.startsWith("+++ ")) { + const path = normalizeDiffPath(line.slice(4).trim()); + currentPath = path === "/dev/null" ? undefined : path; + newLine = undefined; + if (currentPath && !files.has(currentPath)) { + files.set(currentPath, { path: currentPath, lines: [] }); + } + continue; + } + + if (line.startsWith("@@")) { + const match = /\+(\d+)(?:,(\d+))?/.exec(line); + newLine = match ? Number(match[1]) : undefined; + continue; + } + + if (!currentPath || newLine === undefined) continue; + if (line.startsWith("+")) { + files.get(currentPath)?.lines.push({ + path: currentPath, + line: newLine, + text: line.slice(1), + }); + newLine += 1; + continue; + } + if (line.startsWith("-")) continue; + if (line.startsWith("\\")) continue; + newLine += 1; + } + + return [...files.values()].filter((file) => file.lines.length > 0); +} + +function normalizeDiffPath(raw: string): string { + const withoutQuotes = raw.replace(/^"|"$/g, ""); + if (withoutQuotes === "/dev/null") return withoutQuotes; + return withoutQuotes.replace(/^[ab]\//, ""); +} + +function parseDetector(value: unknown): CheckDetector | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const raw = value as Record; + if (raw.type !== "forbidden-regex" && raw.type !== "required-regex") return; + if (typeof raw.pattern !== "string" || raw.pattern.length === 0) return; + const flags = typeof raw.flags === "string" ? raw.flags : undefined; + const paths = Array.isArray(raw.paths) + ? raw.paths.filter((path): path is string => typeof path === "string") + : undefined; + return { + type: raw.type, + pattern: raw.pattern, + ...(flags ? { flags } : {}), + ...(paths && paths.length > 0 ? { paths } : {}), + }; +} + +function compileDetectorRegex(detector: CheckDetector): RegExp { + const flags = new Set((detector.flags ?? "").split("")); + flags.add("g"); + try { + return new RegExp(detector.pattern, [...flags].join("")); + } catch (err) { + throw new UsageError( + `Invalid ${detector.type} detector regex '${detector.pattern}': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +} + +function detectorMatchesPath(detector: CheckDetector, path: string): boolean { + if (!detector.paths || detector.paths.length === 0) return true; + return detector.paths.some((pattern) => matchesGlob(pattern, path)); +} + +function checkIdFor(frontmatter: CheckFrontmatterExtras): string { + return ( + stringOr(frontmatter.id, undefined) ?? + slugify(stringOr(frontmatter.name, undefined) ?? "ghost-check") + ); +} + +function titleFor(frontmatter: CheckFrontmatterExtras): string { + return ( + stringOr(frontmatter.title, undefined) ?? + stringOr(frontmatter.description, undefined) ?? + stringOr(frontmatter.name, "Ghost check") + ); +} + +function stringOr(value: unknown, fallback: string | undefined): string; +function stringOr(value: unknown, fallback: string): string; +function stringOr( + value: unknown, + fallback: string | undefined, +): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : fallback; +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function formatCheckReportMarkdown(report: GhostCheckReport): string { + const lines = ["# Ghost Check Report", "", `Result: ${report.result}`, ""]; + if (report.findings.length === 0) { + lines.push("No deterministic check findings.", ""); + return lines.join("\n"); + } + + lines.push("## Findings", ""); + for (const finding of report.findings) { + const location = finding.path + ? `${finding.path}${finding.line ? `:${finding.line}` : ""}` + : "PR-level"; + lines.push(`- [${finding.severity}] ${finding.title}`); + lines.push(` - Location: ${location}`); + lines.push(` - Check: ${finding.check_id}`); + lines.push(` - Detector: ${finding.detector}`); + if (finding.match) lines.push(` - Match: \`${finding.match}\``); + lines.push(` - Message: ${finding.message}`); + if (finding.repair) lines.push(` - Repair: ${finding.repair}`); + } + lines.push(""); + return lines.join("\n"); +} diff --git a/packages/ghost/src/commands/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts index ba6b56e0..65fd6023 100644 --- a/packages/ghost/src/commands/command-discovery.ts +++ b/packages/ghost/src/commands/command-discovery.ts @@ -140,6 +140,13 @@ const COMMAND_DISCOVERY = [ compactName: "pulse", summary: "Summarize local gather/pull events from .ghost/.events.", }, + { + name: "check", + group: "core", + defaultHelp: true, + compactName: "check", + summary: "Run deterministic detector-backed checks against a diff.", + }, { name: "review", group: "core", diff --git a/packages/ghost/src/ghost-core/check/index.ts b/packages/ghost/src/ghost-core/check/index.ts index c14b29f5..392bcad3 100644 --- a/packages/ghost/src/ghost-core/check/index.ts +++ b/packages/ghost/src/ghost-core/check/index.ts @@ -1,8 +1,7 @@ /** * Public surface for `ghost.check/v1` — markdown + frontmatter checks an agent - * evaluates (Ghost never runs them). Every check is offered to the reviewer; - * the agent judges relevance against the diff and the grounded prose. A check's - * optional `source:` names the fingerprint prose it enforces. + * evaluates. Detector-backed checks can also be run deterministically by + * `ghost check`; checks without detectors remain agent-evaluated in review. */ export { lintGhostCheck } from "./lint.js"; @@ -14,8 +13,11 @@ export { sliceNodeSection, } from "./source-ref.js"; export { + GHOST_CHECK_DETECTOR_TYPES, GHOST_CHECK_SCHEMA, GHOST_CHECK_SEVERITIES, + type GhostCheckDetector, + type GhostCheckDetectorType, type GhostCheckDocument, type GhostCheckFrontmatter, type GhostCheckLintIssue, diff --git a/packages/ghost/src/ghost-core/check/lint.ts b/packages/ghost/src/ghost-core/check/lint.ts index e4a0b1a9..ec207a00 100644 --- a/packages/ghost/src/ghost-core/check/lint.ts +++ b/packages/ghost/src/ghost-core/check/lint.ts @@ -1,6 +1,7 @@ import { parseCheckMarkdown } from "./parse.js"; import { parseSourceRef } from "./source-ref.js"; import { + GHOST_CHECK_DETECTOR_TYPES, GHOST_CHECK_SEVERITIES, type GhostCheckLintIssue, type GhostCheckLintReport, @@ -93,6 +94,23 @@ export function lintGhostCheck(raw: string): GhostCheckLintReport { } } + const detector = frontmatter.detector; + if (detector !== undefined) { + lintDetector(detector, issues); + } + + for (const key of ["id", "title", "message", "repair"]) { + const value = frontmatter[key]; + if (value !== undefined && typeof value !== "string") { + issues.push({ + severity: "error", + rule: `check-${key}-invalid`, + message: `${key} must be a string when provided`, + path: key, + }); + } + } + if (body.trim().length === 0) { issues.push({ severity: "error", @@ -129,3 +147,72 @@ function finalize(issues: GhostCheckLintIssue[]): GhostCheckLintReport { info: issues.filter((issue) => issue.severity === "info").length, }; } + +function lintDetector(detector: unknown, issues: GhostCheckLintIssue[]): void { + if (!detector || typeof detector !== "object" || Array.isArray(detector)) { + issues.push({ + severity: "error", + rule: "check-detector-invalid", + message: "detector must be an object with type and pattern", + path: "detector", + }); + return; + } + + const raw = detector as Record; + if ( + typeof raw.type !== "string" || + !GHOST_CHECK_DETECTOR_TYPES.includes(raw.type as never) + ) { + issues.push({ + severity: "error", + rule: "check-detector-type-invalid", + message: `detector.type must be one of: ${GHOST_CHECK_DETECTOR_TYPES.join(", ")}`, + path: "detector.type", + }); + } + + if (typeof raw.pattern !== "string" || raw.pattern.length === 0) { + issues.push({ + severity: "error", + rule: "check-detector-pattern-missing", + message: "detector.pattern must be a non-empty regex string", + path: "detector.pattern", + }); + } else { + try { + new RegExp(raw.pattern); + } catch (err) { + issues.push({ + severity: "error", + rule: "check-detector-pattern-invalid", + message: `detector.pattern is not a valid regex: ${ + err instanceof Error ? err.message : String(err) + }`, + path: "detector.pattern", + }); + } + } + + if (raw.flags !== undefined && typeof raw.flags !== "string") { + issues.push({ + severity: "error", + rule: "check-detector-flags-invalid", + message: "detector.flags must be a string when provided", + path: "detector.flags", + }); + } + + if ( + raw.paths !== undefined && + (!Array.isArray(raw.paths) || + raw.paths.some((path) => typeof path !== "string")) + ) { + issues.push({ + severity: "error", + rule: "check-detector-paths-invalid", + message: "detector.paths must be a string array when provided", + path: "detector.paths", + }); + } +} diff --git a/packages/ghost/src/ghost-core/check/load.ts b/packages/ghost/src/ghost-core/check/load.ts index 49abf44d..f06cc5a6 100644 --- a/packages/ghost/src/ghost-core/check/load.ts +++ b/packages/ghost/src/ghost-core/check/load.ts @@ -1,5 +1,6 @@ import { parseCheckMarkdown } from "./parse.js"; import type { + GhostCheckDetector, GhostCheckDocument, GhostCheckMarkdownSeverity, } from "./types.js"; @@ -39,6 +40,14 @@ export function loadGhostCheck(raw: string): GhostCheckDocument { : undefined; const source = typeof frontmatter.source === "string" ? frontmatter.source : undefined; + const id = typeof frontmatter.id === "string" ? frontmatter.id : undefined; + const title = + typeof frontmatter.title === "string" ? frontmatter.title : undefined; + const message = + typeof frontmatter.message === "string" ? frontmatter.message : undefined; + const repair = + typeof frontmatter.repair === "string" ? frontmatter.repair : undefined; + const detector = loadDetector(frontmatter.detector); return { frontmatter: { @@ -49,7 +58,29 @@ export function loadGhostCheck(raw: string): GhostCheckDocument { ...(turnLimit !== undefined ? { turn_limit: turnLimit } : {}), ...(references ? { references } : {}), ...(source ? { source } : {}), + ...(id ? { id } : {}), + ...(title ? { title } : {}), + ...(message ? { message } : {}), + ...(repair ? { repair } : {}), + ...(detector ? { detector } : {}), }, body, }; } + +function loadDetector(value: unknown): GhostCheckDetector | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const raw = value as Record; + if (raw.type !== "forbidden-regex" && raw.type !== "required-regex") return; + if (typeof raw.pattern !== "string" || raw.pattern.length === 0) return; + const flags = typeof raw.flags === "string" ? raw.flags : undefined; + const paths = Array.isArray(raw.paths) + ? raw.paths.filter((path): path is string => typeof path === "string") + : undefined; + return { + type: raw.type, + pattern: raw.pattern, + ...(flags ? { flags } : {}), + ...(paths && paths.length > 0 ? { paths } : {}), + }; +} diff --git a/packages/ghost/src/ghost-core/check/types.ts b/packages/ghost/src/ghost-core/check/types.ts index 7fca514b..62c02351 100644 --- a/packages/ghost/src/ghost-core/check/types.ts +++ b/packages/ghost/src/ghost-core/check/types.ts @@ -1,5 +1,20 @@ export const GHOST_CHECK_SCHEMA = "ghost.check/v1" as const; +export const GHOST_CHECK_DETECTOR_TYPES = [ + "forbidden-regex", + "required-regex", +] as const; +export type GhostCheckDetectorType = + (typeof GHOST_CHECK_DETECTOR_TYPES)[number]; + +export interface GhostCheckDetector { + type: GhostCheckDetectorType; + pattern: string; + flags?: string; + /** Optional glob-like path filters within the reviewed diff. */ + paths?: string[]; +} + /** Severity vocabulary, matching the established agent-check format. */ export const GHOST_CHECK_SEVERITIES = ["high", "medium", "low"] as const; export type GhostCheckMarkdownSeverity = @@ -27,6 +42,16 @@ export interface GhostCheckFrontmatter { references?: string[]; /** Deprecated single-reference alias retained for artifact-level linting. */ source?: string; + /** Optional stable id for deterministic check reports. Defaults to name slug. */ + id?: string; + /** Optional PR-comment title. Defaults to description, then name. */ + title?: string; + /** Optional message for deterministic check reports. Defaults to description. */ + message?: string; + /** Optional repair guidance surfaced with deterministic findings. */ + repair?: string; + /** Optional deterministic detector. If omitted, Ghost routes the check but does not run it. */ + detector?: GhostCheckDetector; } export interface GhostCheckDocument { diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index 11bba608..33ea20da 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -13,8 +13,11 @@ export { } from "./catalog/index.js"; // --- Check (ghost.check/v1) — markdown checks, agent-evaluated --- export { + GHOST_CHECK_DETECTOR_TYPES, GHOST_CHECK_SCHEMA, GHOST_CHECK_SEVERITIES, + type GhostCheckDetector, + type GhostCheckDetectorType, type GhostCheckDocument, type GhostCheckFrontmatter, type GhostCheckLintIssue, diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 18941eb7..cacb8750 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -128,6 +128,7 @@ describe("ghost CLI", () => { "init", "validate", "gather", + "check", "pull", "pulse", "review", @@ -153,6 +154,7 @@ describe("ghost CLI", () => { "validate [file]", "init", "gather [...ask]", + "check", "pull <...ids>", "pulse", "review", @@ -179,6 +181,7 @@ describe("ghost CLI", () => { (command: { name: string }) => command.name, ); expect(names).toContain("gather"); + expect(names).toContain("check"); expect(names).toContain("pulse"); expect(names).toContain("review"); expect(names).toContain("haunt"); @@ -828,6 +831,113 @@ describe("ghost CLI", () => { expect(Object.keys(byId)).toContain("email/marketing/index"); }); + it("check emits deterministic JSON findings for detector-backed checks", async () => { + const ghost = join(dir, ".ghost"); + await mkdir(join(ghost, "haunts", "checks"), { recursive: true }); + await writeFile( + join(ghost, "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: c-check\n", + ); + await writeFile(join(ghost, "glossary.md"), "---\n---\n\n# Glossary\n"); + await writeFile(join(ghost, "index.md"), "---\n---\n\nCore.\n"); + await writeFile( + join(ghost, "haunts", "checks", "haunt.yml"), + "schema: ghost.haunt/v1\nid: checks\n", + ); + await writeFile( + join(ghost, "haunts", "checks", "palette.md"), + `--- +name: palette utilities +description: No raw palette utilities. +severity: high +references: [index] +title: No raw palette utilities +message: Added UI code matched a forbidden pattern. +repair: Use semantic token utilities. +detector: + type: forbidden-regex + pattern: 'bg-gray-100' + paths: ['src/**/*.tsx'] +--- +Flag raw palette utilities. +`, + ); + await writeFile( + join(dir, "change.patch"), + webPatch( + "src/features/chat/ui/GhostReviewDemo.tsx", + '
', + ), + ); + + const result = await runCli( + ["check", "--diff", "change.patch", "--format", "json"], + dir, + ); + + expect(result.code).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.schema).toBe("ghost.check-report/v1"); + expect(report.result).toBe("fail"); + expect(report.findings[0]).toMatchObject({ + check_id: "palette", + title: "No raw palette utilities", + severity: "high", + path: "src/features/chat/ui/GhostReviewDemo.tsx", + line: 1, + detector: "forbidden-regex", + message: "Added UI code matched a forbidden pattern.", + match: "bg-gray-100", + repair: "Use semantic token utilities.", + }); + }); + + it("check passes when required regex is present in each changed file", async () => { + const ghost = join(dir, ".ghost"); + await mkdir(join(ghost, "haunts", "checks"), { recursive: true }); + await writeFile( + join(ghost, "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: c-required\n", + ); + await writeFile(join(ghost, "glossary.md"), "---\n---\n\n# Glossary\n"); + await writeFile(join(ghost, "index.md"), "---\n---\n\nCore.\n"); + await writeFile( + join(ghost, "haunts", "checks", "haunt.yml"), + "schema: ghost.haunt/v1\nid: checks\n", + ); + await writeFile( + join(ghost, "haunts", "checks", "token.md"), + `--- +name: token use +description: Require semantic tokens. +severity: medium +references: [index] +detector: + type: required-regex + pattern: 'bg-accent' + paths: ['src/**/*.tsx'] +--- +Require semantic token utilities. +`, + ); + await writeFile( + join(dir, "change.patch"), + webPatch("src/app/page.tsx", '
'), + ); + + const result = await runCli( + ["check", "--diff", "change.patch", "--format", "json"], + dir, + ); + + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + schema: "ghost.check-report/v1", + result: "pass", + findings: [], + }); + }); + it("review matches diff files to node materials and offers checks", async () => { await runCli(["init", "--with", "checks"], dir); await writeFile( @@ -1271,3 +1381,13 @@ Native Swift app. Use feature scopes. `; } + +function webPatch(path: string, added: string): string { + return `diff --git a/${path} b/${path} +index 1111111..2222222 100644 +--- a/${path} ++++ b/${path} +@@ -0,0 +1 @@ ++${added} +`; +} diff --git a/packages/ghost/test/ghost-core/check-md.test.ts b/packages/ghost/test/ghost-core/check-md.test.ts index 3fb2b35a..e8235efe 100644 --- a/packages/ghost/test/ghost-core/check-md.test.ts +++ b/packages/ghost/test/ghost-core/check-md.test.ts @@ -13,6 +13,13 @@ tools: [Read, Grep] turn-limit: 20 references: - principle.trust +title: Use design tokens +message: Added UI code matched a forbidden pattern. +repair: Replace raw values with semantic tokens. +detector: + type: forbidden-regex + pattern: '#[0-9a-fA-F]{3,8}' + paths: ['**/*.tsx'] --- ## Purpose @@ -112,15 +119,19 @@ describe("loadGhostCheck", () => { severity: "high", tools: ["Read", "Grep"], turn_limit: 20, + references: ["principle.trust"], + title: "Use design tokens", + message: "Added UI code matched a forbidden pattern.", + repair: "Replace raw values with semantic tokens.", + detector: { + type: "forbidden-regex", + pattern: "#[0-9a-fA-F]{3,8}", + paths: ["**/*.tsx"], + }, }); expect(doc.body).toContain("Flag hex literals"); }); - it("carries references through", () => { - const doc = loadGhostCheck(VALID); - expect(doc.frontmatter.references).toEqual(["principle.trust"]); - }); - it("carries an optional source pointer through", () => { const doc = loadGhostCheck( VALID.replace(