diff --git a/.changeset/delete-dormant-entrypoint.md b/.changeset/delete-dormant-entrypoint.md new file mode 100644 index 00000000..a7a974e0 --- /dev/null +++ b/.changeset/delete-dormant-entrypoint.md @@ -0,0 +1,8 @@ +--- +"@anarchitecture/ghost": patch +--- + +Remove the dormant context-selection machinery (the Job 2 path-selection graph, +`buildContextEntrypoint`, `buildSelectedContext`, and selection-reasons) that was +inert since the coordinate removal and orphaned once `review` moved onto the +surface rails. Internal cleanup; no public surface change. diff --git a/.changeset/external-contract-references.md b/.changeset/external-contract-references.md new file mode 100644 index 00000000..59cf64b7 --- /dev/null +++ b/.changeset/external-contract-references.md @@ -0,0 +1,9 @@ +--- +"@anarchitecture/ghost": minor +--- + +Bindings can reference an external contract: a `.ghost.bind.yml` `contract:` now +accepts an npm package name (`@scope/brand`) in addition to `.` (in-repo), +resolved from `node_modules`. `ghost verify` checks the external contract +resolves and that each bound surface exists in it. External fingerprint loading +for grounding remains a follow-up. diff --git a/.changeset/remove-validate-one-check-format.md b/.changeset/remove-validate-one-check-format.md new file mode 100644 index 00000000..7bbbdd53 --- /dev/null +++ b/.changeset/remove-validate-one-check-format.md @@ -0,0 +1,9 @@ +--- +"@anarchitecture/ghost": minor +--- + +Collapse to one check format. Remove `ghost.validate/v1`, the `validate.yml` +facet, the `ghost check` deterministic gate, and the `./govern` export. Ghost +now has a single check format — markdown `ghost.check/v1`, routed by surface +(`ghost checks`) and grounded by the fingerprint. `parseUnifiedDiff` moved to a +neutral module; the `drift` stance ledger is unchanged. diff --git a/.changeset/review-on-surfaces.md b/.changeset/review-on-surfaces.md new file mode 100644 index 00000000..adf57a34 --- /dev/null +++ b/.changeset/review-on-surfaces.md @@ -0,0 +1,10 @@ +--- +"@anarchitecture/ghost": minor +--- + +Rebuild `ghost review` on the surface rails: it now resolves the diff's touched +surfaces (via bindings), selects the markdown checks governing them, and grounds +each in the fingerprint slice — instead of emitting `validate.yml` and a +path-selection context packet. The advisory-review JSON replaces +`fingerprint` / `context_markdown` / `checks` / `stacks` with `touched_surfaces`, +`routed_checks`, and `grounding`. `ghost check` remains the deterministic gate. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index e47f9e39..3e7b9474 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-26T05:19:12.918Z", + "generatedAt": "2026-06-26T12:58:29.867Z", "tools": [ { "tool": "ghost", @@ -140,7 +140,7 @@ "tool": "ghost", "name": "scan", "rawName": "scan [dir]", - "description": "Report sparse fingerprint package contribution facets: intent, inventory, composition, validate, and the next BYOA step.", + "description": "Report sparse fingerprint package contribution facets: intent, inventory, composition, and the next BYOA step.", "group": "core", "defaultHelp": true, "compactName": "scan", @@ -631,50 +631,6 @@ } ] }, - { - "tool": "ghost", - "name": "check", - "rawName": "check", - "description": "Run active ghost.validate/v1 gates from the resolved fingerprint stack against a git diff.", - "group": "core", - "defaultHelp": true, - "compactName": "check", - "summary": "Run active deterministic gates 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": "Exact fingerprint package directory; bypasses stack discovery", - "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": "review", diff --git a/docs/ideas/README.md b/docs/ideas/README.md index 82269182..9955add2 100644 --- a/docs/ideas/README.md +++ b/docs/ideas/README.md @@ -135,7 +135,15 @@ buildable Layer 2 design. They agree; read them as a sequence. entanglements: `relay` and `review` share `context/` machinery (partition, don't delete wholesale), and `survey` is a command *and* a module (delete the command surface only). `review` / `emit` / `validate-v1` / the survey module - left for later cuts. + left for later cuts. **Shipped** (`c12f8f1`) — the cutover (Phases 1–8) is + complete. +- `polish-roadmap.md` — sequences the four deferred post-cutover cuts. Key + finding: they are not independent. `review`/`emit` sit on both `validate.yml` + and the dormant Job 2 entrypoint, so **Cut A** (move `review`/`emit` onto + `gather`+`checks`) is the keystone that unblocks **Cut B** (delete the dormant + entrypoint) and **Cut C** (`validate/v1` positioning). **Cut D** (external + contract references in bindings) is independent. The `ghost-core/survey` module + removal is held back as a deeper, separate excavation. ## Independent, still live @@ -154,3 +162,14 @@ buildable Layer 2 design. They agree; read them as a sequence. - Delete notes that only describe superseded package splits, removed commands, or dead routing/coordinate models after their useful decisions are folded into current docs. +- `polish-cut-c-plan.md` — execution spec for Cut C, escalated to full removal: + one check format. Deletes `ghost.validate/v1`, `validate.yml`, the `ghost + check` detector gate, and the `./govern` export; rescues `parseUnifiedDiff` + into a neutral module first; preserves the `drift` stance ledger (cleanly + separable from the detector gate). Markdown `ghost.check/v1` becomes the single + check format. +- `polish-cut-d-plan.md` — execution spec for Cut D: external contract references + in bindings. A `.ghost.bind.yml` `contract:` accepts `.` (in-repo) or an npm + package name resolved from `node_modules`; `ghost verify` checks the external + contract resolves and its bound surfaces exist. Resolution + validation only; + external fingerprint loading for grounding is deferred. diff --git a/docs/ideas/polish-cut-c-plan.md b/docs/ideas/polish-cut-c-plan.md new file mode 100644 index 00000000..6f81e01e --- /dev/null +++ b/docs/ideas/polish-cut-c-plan.md @@ -0,0 +1,108 @@ +--- +status: exploring +--- + +# Polish Cut C plan: collapse to one check format (remove ghost.validate/v1) + +Decision (settled by the user): **two check formats make no sense — default down +to one, the markdown `ghost.check/v1`.** This escalates Cut C from the roadmap's +"keep the deterministic gate" (Option 1) to **full removal of `ghost.validate/v1` +and the `ghost check` deterministic-gate command** (Option 2). + +Governance is now entirely: `ghost checks` (route + ground markdown checks) and +`ghost review` (the advisory packet over the same). The agent evaluates; Ghost +never runs a detector. + +## What dies + +- **`ghost.validate/v1`**: `ghost-core/checks/{schema,types,lint,routing}.ts`, + `GhostValidateSchema`, `GhostCheck`, `routeGhostValidateForPath`, the + `validate.yml` facet and its file-kind/dispatch. +- **`ghost check`** (the deterministic gate) and **`ghost drift ... check`'s** + detector path — `core/check.ts`'s detector evaluation, `runGhostDriftCheck`, + `inline-color-literals`, `gate.ts` if detector-only. +- The **`./govern`** public export and `govern.ts` (it re-exports the check + runner). +- `validate.yml` from `ghost init` scaffolding and the package paths/loader. + +## The two things to rescue first (read before deleting) + +1. **`parseUnifiedDiff` lives in `core/check.ts`** and is imported by the *new* + `review-packet.ts` and `checks-command.ts`. It is generic diff parsing, not + validate logic. **Move it** to a neutral home (e.g. `scan/diff.ts` or + `core/diff.ts`) before deleting `core/check.ts`, and repoint the two callers. +2. **`drift` is two things.** `ghost drift status` / `ghost drift check` operate + on the **stance ledger** (`.ghost-sync.json`, tracked-fingerprint identity) — + that is *not* the detector gate and must survive. Only the + `validate.yml`-detector evaluation inside `core/check.ts` dies. Confirm which + parts of `core/` are detector-only vs. drift-ledger before cutting. + +## Open decision in this cut + +**Does `ghost check` (the command name) survive, repurposed?** Today `check` +runs deterministic detectors against a diff. With detectors gone, the natural +"check a diff" verb is `ghost checks` (markdown routing + grounding). +Recommendation: **delete `ghost check`** (singular, the detector gate) and let +`ghost checks` (plural, the markdown router) be the diff-checking verb. Note the +near-collision in the changeset; it is intentional (the plural replaces the +singular). + +## Steps + +1. **Rescue `parseUnifiedDiff`** to a neutral module; repoint `review-packet` and + `checks-command`; drop it from the `core` public surface if it was exported. +2. **Delete the detector gate:** `ghost check` command block in `cli.ts`, + `core/check.ts`'s detector path, `inline-color-literals.ts`, and any + detector-only helpers in `core/`. Preserve the drift-ledger path + (`drift status` / `drift check` over `.ghost-sync.json`). +3. **Delete `ghost-core/checks/`** (schema, types, lint, routing) and its + `#ghost-core` re-exports. +4. **Remove `validate.yml`** from `ghost init` scaffolding, `FingerprintPackagePaths`, + the loader, file-kind detection + dispatch, scan-status/contribution, and + verify-package. +5. **Remove the `./govern` export** from `package.json` and delete `govern.ts`; + update `public-exports.test.ts`. +6. **Update the skill bundle / docs** to state one check format: markdown + `ghost.check/v1`, routed by surface and grounded by the fingerprint. +7. Regenerate the manifest; fill the major changeset. + +## Scope boundary (what Cut C does NOT do) + +- **Keeps `ghost checks` / `ghost review` / `ghost.check/v1`** — the surviving + governance surface. +- **Keeps `drift` (stance ledger)** — unrelated to detectors. +- **Keeps `ghost-core/survey`** — still its own deferred excavation. +- No new check behavior; this is removal + the diff-parser rescue. + +## Tests + +- Delete `ghost check` / `validate.yml` test cases (`checks.test.ts`, + `checks-grounding.test.ts`, the cli detector cases). +- `public-exports.test.ts`: drop `./govern` and validate exports. +- Confirm `review` / `checks` still parse diffs after the `parseUnifiedDiff` + move. +- `drift status` / `drift check` (ledger) stay green. +- Full `pnpm test` + `pnpm check` green. + +## Changeset + +`major` — removes the `ghost check` command, the `./govern` export, the +`ghost.validate/v1` schema and `validate.yml` facet. Note that `ghost checks` +(markdown) is the single remaining check format. + +## Process notes + +- **Rescue `parseUnifiedDiff` first, as its own step**, so the new commands never + break during the deletion. +- Separate the drift-ledger code from the detector code in `core/` before + cutting — the compiler is the worklist once the gate command is gone. +- This is the largest polish cut (~24 files reference the surface); expect a + Phase-3-style ripple. Delete, then chase to green. +- Mind the terminology guard on changeset/skill prose. + +## Read-back + +Cut C succeeds if `ghost.validate/v1`, `validate.yml`, the `ghost check` +detector gate, and the `./govern` export are gone; `parseUnifiedDiff` survives in +a neutral home so `review`/`checks` still work; the drift stance ledger is +untouched; and Ghost has exactly one check format — markdown `ghost.check/v1`. diff --git a/docs/ideas/polish-cut-d-plan.md b/docs/ideas/polish-cut-d-plan.md new file mode 100644 index 00000000..556aa3da --- /dev/null +++ b/docs/ideas/polish-cut-d-plan.md @@ -0,0 +1,79 @@ +--- +status: exploring +--- + +# Polish Cut D plan: external contract references in bindings + +The last deferred cut. Today a `.ghost.bind.yml` only supports `contract: .` +(the in-repo root contract); lint hard-rejects anything else. Cut D lets a +binding reference an **external contract** — a published brand package — so a +repo can bind its local paths to surfaces defined by `@scope/brand` in +`node_modules`. + +## Scope (from the roadmap, held tight) + +- **npm-name references only.** `contract: @scope/brand` or `contract: brand`. + Arbitrary resource-id resolvers (needing host config) are deferred. +- **No new version machinery.** `ack` / `track` already model stance toward a + moving reference; do not reinvent pinning here. +- The cut is **resolution + validation**, not a new runtime. + +## The finding that bounds it + +The `contract:` field is currently *informational*: lint only checks it is `.`, +and discovery (`readExplicitBinding`) takes the binding's surface ids on faith — +it never cross-checks them against the contract's `surfaces.yml`. And +`gather`/`checks`/`review` operate on the *local* package; composing an external +contract's content already works via `gather --package node_modules//.ghost`. + +So Cut D's real, bounded value is: **resolve the referenced contract and validate +that the bound surfaces exist in it.** Nothing else needs to change. + +## What it builds + +1. **Schema/lint** accept a contract reference: `.` (in-repo) or an npm package + name (`@scope/name` or `name`). Replace the hard `binding-contract-unsupported` + error with: `.` is always fine; an npm-name is fine *syntactically*; + anything else (a path, a URL, a resource id) is still rejected for now. +2. **A contract resolver** (`scan/contract-resolver.ts`): given a reference and a + starting dir, return the contract's `.ghost/` directory. + - `.` → the in-repo contract (root `.ghost/`, the existing behavior). + - npm name → the nearest `node_modules//.ghost/` walking up from the + binding's directory. Returns `null` when unresolved. +3. **Verify integration**: a binding with an external `contract:` is validated — + the referenced package resolves and each bound `surface` exists in that + contract's `surfaces.yml`. Unresolved package or unknown surface → a verify + error (`binding-contract-unresolved` / `binding-surface-unknown`). + +## What it does NOT do + +- **No external fingerprint loading in `gather`/`checks`/`review`.** They stay + local; `--package` already reaches an external package's `.ghost/`. Following a + binding to auto-load an external contract's *content* for grounding is a larger + follow-up, explicitly deferred. +- **No resource-id resolvers, no version pinning, no network fetch.** npm + resolution is filesystem-only (`node_modules`); installing the package is the + host's job. +- The in-repo `contract: .` path is unchanged. + +## Steps + +1. Add an npm-name matcher to the binding schema/lint; relax the contract check + to accept `.` or a valid npm name, reject the rest. +2. Write `resolveContractDir(reference, fromDir, repoRoot)` in `scan/` — `.` and + npm-name resolution, filesystem-only, `null` on miss. +3. In `verify-package` (or a binding verifier), for each `.ghost.bind.yml` with a + non-`.` contract: resolve it, read its `surfaces.yml`, and assert each bound + surface exists; emit verify errors otherwise. +4. Tests: npm-name lint accept/reject; resolver finds `node_modules//.ghost` + and returns null when absent; verify flags an unknown surface / unresolved + package; `contract: .` still works unchanged. +5. Update the binding docstring + skill/schema reference to document external + references. Changeset `minor` (additive). + +## Read-back + +Cut D succeeds if a `.ghost.bind.yml` can declare `contract: @scope/brand`, +Ghost resolves it from `node_modules` and validates the bound surfaces exist in +that contract, the in-repo `.` path is unchanged, and external fingerprint +loading for grounding is explicitly left as a follow-up. diff --git a/docs/ideas/polish-roadmap.md b/docs/ideas/polish-roadmap.md new file mode 100644 index 00000000..0a062d70 --- /dev/null +++ b/docs/ideas/polish-roadmap.md @@ -0,0 +1,118 @@ +--- +status: exploring +--- + +# Polish roadmap: the four deferred cuts + +The cutover (Phases 1–8) is complete. Four items were deliberately parked. They +are **not independent** — a full read shows a dependency chain, and doing them in +the wrong order means rewiring the same consumers twice. This note settles the +order and scopes each as its own cut. + +## The dependency finding + +- **`review` / `emit` still depend on two legacy things at once:** the + `validate.yml` checks (`context.checksRaw`) **and** the dormant Job 2 selection + in `context/entrypoint.ts` (`matchScopes`, `globalFallbackRefs`, + `appliesTo.scopes/surfaceTypes` — the path-selection made inert in Phase 3). +- **`validate/v1`** is consumed by `review`, `core/check.ts`, `fingerprint-stack`, + `verify-package`, and the `checks/` module. +- **`survey`** is a large module (`ghost-core/survey/*`) still imported by + `verify-fingerprint`, `comparable-fingerprint`, `patterns/lint`, + `perceptual-prior`, `fingerprint-package`, and `file-kind` — far beyond the + deleted command. +- **External contract references** in bindings are self-contained — they touch + only the binding schema/lint/resolver, nothing the other three need. + +So: **`review`/`emit` sit on top of both `validate` and the dormant entrypoint.** +You cannot cleanly remove `validate/v1` while `review` still emits its checks. +And moving `review`/`emit` onto `gather`/`checks` is what *frees* `validate` and +the dormant entrypoint to be deleted. That dictates the order. + +## The order + +### Cut A — move `review` / `emit` onto `gather` + `checks` (do first) + +The keystone. Until `review` stops consuming `validate.yml` and the Job 2 +entrypoint, neither can be removed. + +- Rebuild `review` on the surface-native path: resolve the diff's surfaces + (Phase 7a binding), select governing markdown checks (Cut 3), and ground them + (Cut 4) — i.e. `review` becomes a formatting wrapper over what `ghost checks` + already computes, plus the diff. Drop `buildContextEntrypoint` / + `buildSelectedContext` (the dormant Job 2 path). +- Reframe `emit review-command` to emit from the surface slice + (`package-review-command.ts` currently builds from the merged/legacy context). +- Decide: keep `review` as a command (advisory packet) or fold it into + `ghost checks --review`. Recommendation: keep `review` as the human-facing + advisory command, reimplemented on the new rails; `emit` stays for the + review-command artifact. +- This is the one with real design in it — the others are deletions. + +### Cut B — delete the dormant Job 2 entrypoint (after A) + +Once `review` no longer calls `buildContextEntrypoint`, the Job 2 selection +machinery (`matchScopes`, `globalFallbackRefs`, `appliesTo` scoring, +`selected-context`, the `graph.ts` applicability half) has **no live caller**. +Delete it. Keep `graph.ts`'s structure/content half only if something still uses +it; otherwise delete `entrypoint.ts`, `selected-context.ts`, `selection-reasons.ts` +too. The compiler is the worklist. + +### Cut C — deprecate / remove `ghost.validate/v1` (after A) + +With `review` off `validate.yml`, the only remaining consumers are `core/check.ts` +(the legacy deterministic gate), `verify-package`, and `fingerprint-stack`. +Decide the end state: + +- **Option 1 (recommended): keep `ghost check` as the deterministic gate**, but + stop treating `validate/v1` as the *governance future* — it coexists with + `ghost.check/v1` markdown checks (deterministic gate vs. agent-evaluated + review). Document the split; remove nothing. +- **Option 2: full removal** — delete `validate/v1`, the `ghost check` command, + `checks/` module, and migrate any deterministic checks to markdown. Bigger, + and loses the only no-LLM gate. Defer unless there is a reason. + +Lead with Option 1 (a docs/positioning cut, not a deletion) and only escalate to +Option 2 if you decide deterministic checks have no place. + +### Cut D — external contract references in bindings (independent, anytime) + +Self-contained; can land before or after the others. Extend `ghost.binding/v1` +`contract:` beyond in-repo `.`: + +- Accept an npm package name or a resource id; resolve the referenced contract's + `surfaces.yml` (npm: from `node_modules`; resource id: a configured resolver). +- Version pinning / stance: `ack` / `track` already model stance toward a moving + reference — reuse, do not reinvent. +- Lint: relax `binding-contract-unsupported`; validate the reference resolves. +- Scope guard: ship npm-name resolution first; defer arbitrary resource-id + resolvers to a follow-up if they need host config. + +## Sequence summary + +``` +A (review/emit → gather/checks) ← keystone; unblocks B and C +├─ B (delete dormant Job 2 entrypoint) +└─ C (validate/v1 positioning, Option 1) +D (external contract refs) ← independent, anytime +``` + +Do **A first**, then B and C (either order), and D whenever. Each is its own +plan + build + green commit — no bundling. + +## What stays out of scope + +- The `ghost-core/survey` module removal is **not** in this roadmap as a near-term + cut: it is imported by `comparable-fingerprint`, `patterns/lint`, + `perceptual-prior`, and `verify-fingerprint` — removing it is a deep, separate + excavation with its own questions (does `compare`/`verify` still need survey + evidence?). **Flag it; do not sequence it here.** It earns its own investigation + note when/if survey truly has no consumer. + +## Read-back + +This roadmap is right if: `review`/`emit` move onto the surface rails first +(Cut A), which frees the dormant Job 2 entrypoint (Cut B) and the `validate/v1` +positioning (Cut C) to follow; external contract references (Cut D) land +independently; and the survey-module removal is explicitly held back as a deeper, +separate excavation rather than rushed in. diff --git a/packages/ghost/package.json b/packages/ghost/package.json index 3f461f82..abf32304 100644 --- a/packages/ghost/package.json +++ b/packages/ghost/package.json @@ -51,10 +51,7 @@ "types": "./dist/fingerprint.d.ts", "import": "./dist/fingerprint.js" }, - "./govern": { - "types": "./dist/govern.d.ts", - "import": "./dist/govern.js" - }, + "./compare": { "types": "./dist/compare.d.ts", "import": "./dist/compare.js" diff --git a/packages/ghost/src/checks-command.ts b/packages/ghost/src/checks-command.ts index a3470690..4bab5a3e 100644 --- a/packages/ghost/src/checks-command.ts +++ b/packages/ghost/src/checks-command.ts @@ -9,11 +9,11 @@ import { type SurfaceGrounding, selectChecksForSurfaces, } from "#ghost-core"; -import { parseUnifiedDiff } from "./core/check.js"; import { resolveFingerprintPackage } from "./fingerprint.js"; import { discoverBindingsForPath } from "./scan/binding-discovery.js"; import { loadChecksDir } from "./scan/checks-dir.js"; import { loadFingerprintPackage } from "./scan/fingerprint-package.js"; +import { parseUnifiedDiff } from "./scan/unified-diff.js"; const execFileAsync = promisify(execFile); diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 5cc91507..51e201e8 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -14,13 +14,11 @@ import { formatComparisonJSON, formatCompositeComparison, formatCompositeComparisonJSON, - formatGhostDriftCheckMarkdown, formatTemporalComparison, formatTemporalComparisonJSON, readHistory, readSyncManifest, runGateCli, - runGhostDriftCheck, } from "./core/index.js"; import { registerDriftCommand } from "./drift-command.js"; import { @@ -162,56 +160,6 @@ export function buildCli(): ReturnType { registerMigrateCommand(cli); registerSkillCommand(cli); - // --- check --- - cli - .command( - "check", - "Run active ghost.validate/v1 gates from the resolved fingerprint stack 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 ", - "Exact fingerprint package directory; bypasses stack discovery", - ) - .option("--format ", "Output format: markdown or json", { - default: "markdown", - }) - .action(async (opts) => { - try { - if (opts.format !== "markdown" && opts.format !== "json") { - console.error("Error: --format must be 'markdown' or 'json'"); - process.exit(2); - return; - } - const diffText = - typeof opts.diff === "string" - ? await readDiffInput(opts.diff) - : undefined; - const report = await runGhostDriftCheck({ - cwd: process.cwd(), - packageDir: - typeof opts.package === "string" ? opts.package : undefined, - base: typeof opts.base === "string" ? opts.base : undefined, - diffText, - }); - if (opts.format === "json") { - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - } else { - process.stdout.write(formatGhostDriftCheckMarkdown(report)); - } - process.exit(report.result === "fail" ? 1 : 0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); - // --- review --- cli .command( diff --git a/packages/ghost/src/context/entrypoint.ts b/packages/ghost/src/context/entrypoint.ts deleted file mode 100644 index e8208827..00000000 --- a/packages/ghost/src/context/entrypoint.ts +++ /dev/null @@ -1,405 +0,0 @@ -import type { GhostFingerprintDocument } from "#ghost-core"; -import { - buildFingerprintGraph, - type FingerprintGraph, - type FingerprintGraphNode, - intersects, - matchScopes, - type NodeRef, - nodeMatchesTargets, - normalizeTargetPaths, - sortNodes, - unique, -} from "./graph.js"; -import type { PackageContext } from "./package-context.js"; -import { - addSelectionReason, - directSelectionReasons, - expandOneHopWithReasons, - globalFallbackRefs, - type SelectionReason, -} from "./selection-reasons.js"; - -export type { - FingerprintGraph, - FingerprintGraphEdge, - FingerprintGraphNode, - FingerprintGraphScope, -} from "./graph.js"; -export { buildFingerprintGraph } from "./graph.js"; - -export interface ContextEntrypoint { - name: string; - match: { - status: "path-match" | "global-fallback"; - requestedPaths: string[]; - matchedScopes: string[]; - matchedSurfaceTypes: string[]; - sourceStack: string[]; - reasons: string[]; - }; - identity: { - product: string; - audience: string[]; - goals: string[]; - antiGoals: string[]; - tradeoffs: string[]; - tone: string[]; - }; - actionContract: { - preserve: string[]; - inspect: Array<{ path: string; reason: string }>; - avoid: string[]; - validate: string[]; - }; - selected: { - intent: FingerprintGraphNode[]; - composition: FingerprintGraphNode[]; - exemplars: FingerprintGraphNode[]; - checks: FingerprintGraphNode[]; - }; - selectionReasons: Record; - suggestedReads: Array<{ path: string; reason: string }>; - omissions: Array<{ label: string; omitted: number; source: string }>; -} - -export type { SelectionReason } from "./selection-reasons.js"; - -export interface BuildContextEntrypointOptions { - targetPaths?: string[]; -} - -const CAPS = { - intent: 6, - composition: 6, - exemplars: 3, - checks: 6, -} as const; -const ACTION_CONTRACT_CAP = 5; - -export function buildContextEntrypoint( - context: PackageContext, - options: BuildContextEntrypointOptions = {}, -): ContextEntrypoint { - const graph = buildFingerprintGraph(context); - const requestedPaths = normalizeTargetPaths( - options.targetPaths ?? context.targetPaths ?? [], - ); - const matchedScopes = matchScopes(graph.scopes, requestedPaths); - const matchedScopeIds = matchedScopes.map((scope) => scope.id); - const matchedSurfaceTypes = unique( - matchedScopes.flatMap((scope) => scope.surfaceTypes), - ); - const directRefs = new Set(); - const selectionReasons = new Map(); - - for (const node of graph.nodes) { - const reasons = directSelectionReasons(node, { - requestedPaths, - matchedScopeIds, - matchedSurfaceTypes, - }); - if (reasons.length > 0) { - directRefs.add(node.ref); - for (const reason of reasons) { - addSelectionReason(selectionReasons, node.ref, reason); - } - } - } - - const status = directRefs.size > 0 ? "path-match" : "global-fallback"; - const selectedRefs = - directRefs.size > 0 - ? expandOneHopWithReasons(directRefs, graph, selectionReasons) - : globalFallbackRefs(graph, requestedPaths, selectionReasons); - const selected = selectNodes(graph, selectedRefs, { - directRefs, - matchedScopeIds, - matchedSurfaceTypes, - requestedPaths, - useRelevance: status === "path-match", - }); - const identity = identityFromFingerprint(context.fingerprint, context.name); - const suggestedReads = buildSuggestedReads(context, selected); - - return { - name: context.name, - match: { - status, - requestedPaths, - matchedScopes: matchedScopeIds, - matchedSurfaceTypes, - sourceStack: context.stackDirs ?? [], - reasons: matchReasons(status, requestedPaths, matchedScopeIds), - }, - identity, - actionContract: buildActionContract(identity, selected, suggestedReads), - selected, - selectionReasons: Object.fromEntries(selectionReasons), - suggestedReads, - omissions: buildOmissions(graph, selected), - }; -} - -function matchReasons( - status: ContextEntrypoint["match"]["status"], - requestedPaths: string[], - matchedScopeIds: string[], -): string[] { - if (status === "path-match") { - return [ - requestedPaths.length - ? `Matched requested path(s): ${requestedPaths.join(", ")}.` - : "Matched resolved fingerprint context.", - matchedScopeIds.length - ? `Matched scope(s): ${matchedScopeIds.join(", ")}.` - : "Selected directly applicable fingerprint refs.", - "Expanded selection by one explicit ref hop.", - ]; - } - return [ - requestedPaths.length - ? `No fingerprint scope matched: ${requestedPaths.join(", ")}.` - : "No target path was supplied.", - "Using compact global context; inspect full fingerprint files when the task is broad.", - ]; -} - -function identityFromFingerprint( - fingerprint: GhostFingerprintDocument, - name: string, -): ContextEntrypoint["identity"] { - const summary = fingerprint.intent.summary; - return { - product: summary.product ?? name, - audience: summary.audience ?? [], - goals: summary.goals ?? [], - antiGoals: summary.anti_goals ?? [], - tradeoffs: summary.tradeoffs ?? [], - tone: summary.tone ?? [], - }; -} - -function selectNodes( - graph: FingerprintGraph, - selectedRefs: Set, - ranking: SelectionRanking, -): ContextEntrypoint["selected"] { - const selectedNodes = sortSelectedNodes( - graph, - graph.nodes.filter((node) => selectedRefs.has(node.ref)), - ranking, - ); - return { - intent: selectedNodes - .filter((node) => - ["situation", "principle", "experience_contract"].includes(node.kind), - ) - .slice(0, CAPS.intent), - composition: selectedNodes - .filter((node) => node.kind === "pattern") - .slice(0, CAPS.composition), - exemplars: selectedNodes - .filter((node) => node.kind === "exemplar") - .slice(0, CAPS.exemplars), - checks: selectedNodes - .filter((node) => node.kind === "check") - .slice(0, CAPS.checks), - }; -} - -interface SelectionRanking { - directRefs: Set; - matchedScopeIds: string[]; - matchedSurfaceTypes: string[]; - requestedPaths: string[]; - useRelevance: boolean; -} - -function sortSelectedNodes( - graph: FingerprintGraph, - nodes: FingerprintGraphNode[], - ranking: SelectionRanking, -): FingerprintGraphNode[] { - const sorted = sortNodes(nodes); - if (!ranking.useRelevance) return sorted; - return sorted.sort( - (a, b) => - relevanceScore(b, graph, ranking) - relevanceScore(a, graph, ranking) || - a.order - b.order, - ); -} - -function relevanceScore( - node: FingerprintGraphNode, - graph: FingerprintGraph, - ranking: SelectionRanking, -): number { - let score = 0; - if (nodeMatchesTargets(node, ranking.requestedPaths)) score += 100; - if (intersects(node.appliesTo.scopes, ranking.matchedScopeIds)) score += 50; - if (intersects(node.appliesTo.surfaceTypes, ranking.matchedSurfaceTypes)) { - score += 20; - } - if (isConnectedToDirectRef(node.ref, graph, ranking.directRefs)) score += 10; - return score; -} - -function isConnectedToDirectRef( - ref: NodeRef, - graph: FingerprintGraph, - directRefs: Set, -): boolean { - return graph.edges.some( - (edge) => - (edge.from === ref && directRefs.has(edge.to)) || - (edge.to === ref && directRefs.has(edge.from)), - ); -} - -function buildSuggestedReads( - _context: PackageContext, - selected: ContextEntrypoint["selected"], -): ContextEntrypoint["suggestedReads"] { - const reads = new Map(); - if (selected.intent.length > 0) { - reads.set("intent.yml", "selected intent anchors and full intent"); - } - if (selected.composition.length > 0) { - reads.set( - "composition.yml", - "selected composition patterns and neighboring patterns", - ); - } - if (selected.exemplars.length > 0) { - reads.set( - "inventory.yml", - "selected exemplars, topology, and building blocks", - ); - } - if (selected.checks.length > 0) { - reads.set("validate.yml", "active deterministic validation rules"); - } - for (const exemplar of selected.exemplars) { - const path = exemplar.appliesTo.paths[0]; - if (path) reads.set(path, `source surface for ${exemplar.ref}`); - } - if (reads.size === 0) { - reads.set("intent.yml", "global fingerprint intent"); - reads.set("inventory.yml", "topology and exemplars"); - reads.set("composition.yml", "composition patterns"); - } - return [...reads.entries()].map(([path, reason]) => ({ path, reason })); -} - -function buildActionContract( - identity: ContextEntrypoint["identity"], - selected: ContextEntrypoint["selected"], - suggestedReads: ContextEntrypoint["suggestedReads"], -): ContextEntrypoint["actionContract"] { - const intent = sortNodes(selected.intent); - const composition = sortNodes(selected.composition); - const preserve = uniqueCapped([ - ...intent.map((node) => node.summary), - ...composition.map((node) => node.summary), - ...intent.flatMap((node) => - node.details.filter((detail) => !isAvoidanceDetail(detail)), - ), - ]); - const inspect = uniqueInspectReads([ - ...selected.exemplars - .map((exemplar) => { - const path = exemplar.appliesTo.paths[0]; - return path - ? { path, reason: `source surface for ${exemplar.ref}` } - : undefined; - }) - .filter((read): read is { path: string; reason: string } => - Boolean(read), - ), - ...suggestedReads, - ]); - const avoid = uniqueCapped([ - ...identity.antiGoals, - ...intent.flatMap((node) => node.details.filter(isAvoidanceDetail)), - ...composition.flatMap((node) => node.details.filter(isAvoidanceDetail)), - ]); - const validate = - selected.checks.length > 0 - ? uniqueCapped( - selected.checks.map((node) => `${node.ref} - ${node.summary}`), - ) - : [ - "No selected active checks. Proposed or disabled checks are not blocking validation.", - ]; - - return { preserve, inspect, avoid, validate }; -} - -function buildOmissions( - graph: FingerprintGraph, - selected: ContextEntrypoint["selected"], -): ContextEntrypoint["omissions"] { - const totals = { - intent: graph.nodes.filter((node) => - ["situation", "principle", "experience_contract"].includes(node.kind), - ).length, - composition: graph.nodes.filter((node) => node.kind === "pattern").length, - exemplars: graph.nodes.filter((node) => node.kind === "exemplar").length, - checks: graph.nodes.filter((node) => node.kind === "check").length, - }; - return [ - { - label: "Intent anchors", - omitted: Math.max(0, totals.intent - selected.intent.length), - source: "intent.yml", - }, - { - label: "Composition patterns", - omitted: Math.max(0, totals.composition - selected.composition.length), - source: "composition.yml", - }, - { - label: "Exemplars", - omitted: Math.max(0, totals.exemplars - selected.exemplars.length), - source: "inventory.yml", - }, - { - label: "Active checks", - omitted: Math.max(0, totals.checks - selected.checks.length), - source: "validate.yml", - }, - ]; -} - -function uniqueCapped(values: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const value of values) { - const normalized = value.trim(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - out.push(normalized); - if (out.length >= ACTION_CONTRACT_CAP) break; - } - return out; -} - -function uniqueInspectReads( - reads: Array<{ path: string; reason: string }>, -): Array<{ path: string; reason: string }> { - const seen = new Set(); - const out: Array<{ path: string; reason: string }> = []; - for (const read of reads) { - const path = read.path.trim(); - if (!path || seen.has(path)) continue; - seen.add(path); - out.push({ path, reason: read.reason }); - if (out.length >= ACTION_CONTRACT_CAP) break; - } - return out; -} - -function isAvoidanceDetail(detail: string): boolean { - return /^(Refuses|Counterexample|Avoid):/.test(detail); -} diff --git a/packages/ghost/src/context/graph.ts b/packages/ghost/src/context/graph.ts deleted file mode 100644 index 8433ed62..00000000 --- a/packages/ghost/src/context/graph.ts +++ /dev/null @@ -1,357 +0,0 @@ -import type { - GhostCheck, - GhostFingerprintDocument, - GhostFingerprintRef, -} from "#ghost-core"; -import type { PackageContext } from "./package-context.js"; - -export type NodeKind = - | "situation" - | "principle" - | "experience_contract" - | "pattern" - | "exemplar" - | "check"; - -export type NodeRef = GhostFingerprintRef; - -export interface Applicability { - paths: string[]; - scopes: string[]; - surfaceTypes: string[]; -} - -export interface FingerprintGraphNode { - ref: NodeRef; - id: string; - kind: NodeKind; - label: string; - summary: string; - details: string[]; - sourceFile: string; - order: number; - appliesTo: Applicability; -} - -export interface FingerprintGraphEdge { - from: NodeRef; - to: NodeRef; - reason: string; -} - -export interface FingerprintGraphScope { - id: string; - paths: string[]; - surfaceTypes: string[]; -} - -export interface FingerprintGraph { - nodes: FingerprintGraphNode[]; - edges: FingerprintGraphEdge[]; - scopes: FingerprintGraphScope[]; - nodeByRef: Map; -} - -const KIND_ORDER: Record = { - situation: 0, - principle: 1, - experience_contract: 2, - pattern: 3, - exemplar: 4, - check: 5, -}; - -export function buildFingerprintGraph( - context: PackageContext, -): FingerprintGraph { - const nodes: FingerprintGraphNode[] = []; - const pendingEdges: FingerprintGraphEdge[] = []; - let order = 0; - - const addNode = ( - node: Omit & { - appliesTo?: Partial; - }, - ) => { - nodes.push({ - ...node, - order: order++, - appliesTo: normalizeApplicability(node.appliesTo), - }); - }; - - const fingerprint = context.fingerprint; - for (const situation of fingerprint.intent.situations) { - const ref = refFor("intent.situation", situation.id); - addNode({ - ref, - id: situation.id, - kind: "situation", - label: situation.title ?? situation.id, - summary: - situation.product_obligation ?? - situation.user_intent ?? - situation.surface ?? - "Recorded situation.", - details: [ - situation.user_intent ? `User intent: ${situation.user_intent}` : "", - situation.product_obligation - ? `Product obligation: ${situation.product_obligation}` - : "", - ...(situation.refuses ?? []).map((entry) => `Refuses: ${entry}`), - ].filter(Boolean), - sourceFile: "intent.yml", - appliesTo: { - paths: evidencePaths(situation.evidence), - }, - }); - addRefEdges(ref, situation.principles, "situation principle"); - addRefEdges( - ref, - situation.experience_contracts, - "situation experience contract", - ); - addRefEdges(ref, situation.patterns, "situation composition pattern"); - } - - for (const principle of fingerprint.intent.principles) { - const ref = refFor("intent.principle", principle.id); - addNode({ - ref, - id: principle.id, - kind: "principle", - label: principle.id, - summary: principle.principle, - details: [ - ...(principle.guidance ?? []), - ...(principle.counterexamples ?? []).map( - (entry) => `Counterexample: ${entry}`, - ), - ], - sourceFile: "intent.yml", - appliesTo: {}, - }); - addRefEdges(ref, principle.check_refs, "principle check"); - } - - for (const contract of fingerprint.intent.experience_contracts) { - const ref = refFor("intent.experience_contract", contract.id); - addNode({ - ref, - id: contract.id, - kind: "experience_contract", - label: contract.id, - summary: contract.contract, - details: contract.obligations ?? [], - sourceFile: "intent.yml", - appliesTo: {}, - }); - addRefEdges(ref, contract.check_refs, "experience contract check"); - } - - for (const pattern of fingerprint.composition.patterns) { - const ref = refFor("composition.pattern", pattern.id); - addNode({ - ref, - id: pattern.id, - kind: "pattern", - label: `${pattern.id} (${pattern.kind})`, - summary: pattern.pattern, - details: [ - ...(pattern.guidance ?? []), - ...(pattern.anti_patterns?.length - ? [`Avoid: ${pattern.anti_patterns.join("; ")}`] - : []), - ], - sourceFile: "composition.yml", - appliesTo: {}, - }); - addRefEdges(ref, pattern.check_refs, "composition check"); - } - - for (const exemplar of fingerprint.inventory.exemplars) { - const ref = refFor("inventory.exemplar", exemplar.id); - addNode({ - ref, - id: exemplar.id, - kind: "exemplar", - label: exemplar.title ?? exemplar.id, - summary: exemplar.why ?? exemplar.note ?? exemplar.path, - details: [ - `Path: ${exemplar.path}`, - exemplar.surface ? `Surface: ${exemplar.surface}` : "", - ].filter(Boolean), - sourceFile: "inventory.yml", - appliesTo: { - paths: [exemplar.path], - }, - }); - addRefEdges(ref, exemplar.refs, "exemplar ref"); - } - - for (const check of activeChecks(context)) { - const ref = refFor("validate.check", check.id); - addNode({ - ref, - id: check.id, - kind: "check", - label: check.title, - summary: `${check.severity}: ${check.title}`, - details: [ - check.repair ? `Repair: ${check.repair}` : "", - detectorSummary(check), - ].filter(Boolean), - sourceFile: "validate.yml", - appliesTo: applicabilityFromCheck(check), - }); - addRefEdges(ref, check.derivation?.intent, "check intent derivation"); - addRefEdges(ref, check.derivation?.inventory, "check inventory derivation"); - addRefEdges( - ref, - check.derivation?.composition, - "check composition derivation", - ); - } - - const nodeByRef = new Map(nodes.map((node) => [node.ref, node])); - const edges = pendingEdges.filter( - (edge) => nodeByRef.has(edge.from) && nodeByRef.has(edge.to), - ); - - return { - nodes, - edges, - scopes: buildScopes(fingerprint), - nodeByRef, - }; - - function addRefEdges( - from: NodeRef, - refs: readonly GhostFingerprintRef[] | undefined, - reason: string, - ) { - for (const to of refs ?? []) { - pendingEdges.push({ from, to, reason }); - } - } -} - -export function matchScopes( - scopes: FingerprintGraphScope[], - targetPaths: string[], -): FingerprintGraphScope[] { - if (targetPaths.length === 0) return []; - return scopes.filter((scope) => - scope.paths.some((scopePath) => - targetPaths.some((targetPath) => pathsOverlap(scopePath, targetPath)), - ), - ); -} - -export function nodeMatchesTargets( - node: FingerprintGraphNode, - targetPaths: string[], -): boolean { - return ( - targetPaths.length > 0 && - node.appliesTo.paths.some((nodePath) => - targetPaths.some((targetPath) => pathsOverlap(nodePath, targetPath)), - ) - ); -} - -export function normalizeTargetPaths(paths: string[]): string[] { - return unique( - paths - .map(normalizePath) - .filter((path) => path && path !== "." && path !== "/"), - ); -} - -export function sortNodes( - nodes: FingerprintGraphNode[], -): FingerprintGraphNode[] { - return [...nodes].sort( - (a, b) => - KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || - a.order - b.order || - a.ref.localeCompare(b.ref), - ); -} - -export function intersects(a: string[], b: string[]): boolean { - if (a.length === 0 || b.length === 0) return false; - const values = new Set(a); - return b.some((value) => values.has(value)); -} - -export function unique(values: string[]): string[] { - return [...new Set(values.filter(Boolean))]; -} - -export function pathsOverlap(a: string, b: string): boolean { - const left = normalizePath(a); - const right = normalizePath(b); - if (!left || !right) return false; - if (left === right) return true; - if (left.endsWith("*")) return right.startsWith(left.slice(0, -1)); - if (right.endsWith("*")) return left.startsWith(right.slice(0, -1)); - return left.startsWith(`${right}/`) || right.startsWith(`${left}/`); -} - -// Phase 3: the topology-derived scope list is gone. Path/scope selection is -// rebuilt against surfaces in Phase 5/7; until then this is dormant (empty). -function buildScopes( - _fingerprint: GhostFingerprintDocument, -): FingerprintGraphScope[] { - return []; -} - -function normalizePath(path: string): string { - return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/g, ""); -} - -function normalizeApplicability( - value: Partial | undefined, -): Applicability { - return { - paths: unique((value?.paths ?? []).map(normalizePath).filter(Boolean)), - scopes: unique(value?.scopes ?? []), - surfaceTypes: unique(value?.surfaceTypes ?? []), - }; -} - -function applicabilityFromCheck(check: GhostCheck): Partial { - return { - paths: check.applies_to?.paths ?? [], - scopes: check.applies_to?.scopes ?? [], - surfaceTypes: check.applies_to?.surface_types ?? [], - }; -} - -function evidencePaths( - evidence: Array<{ path?: string }> | undefined, -): string[] { - return (evidence ?? []) - .map((entry) => entry.path) - .filter((path): path is string => Boolean(path)); -} - -function activeChecks(context: PackageContext): GhostCheck[] { - return ( - context.checks?.checks.filter((check) => check.status === "active") ?? [] - ); -} - -function detectorSummary(check: GhostCheck): string { - const detector = check.detector; - return detector.pattern - ? `${detector.type}: ${detector.pattern}` - : detector.value - ? `${detector.type}: ${detector.value}` - : detector.type; -} - -function refFor(prefix: string, id: string): NodeRef { - return `${prefix}:${id}` as NodeRef; -} diff --git a/packages/ghost/src/context/package-context.ts b/packages/ghost/src/context/package-context.ts index 094c29e9..8f0058db 100644 --- a/packages/ghost/src/context/package-context.ts +++ b/packages/ghost/src/context/package-context.ts @@ -1,10 +1,5 @@ import { parse as parseYaml } from "yaml"; -import { - type GhostFingerprintDocument, - type GhostValidateDocument, - GhostValidateSchema, - lintGhostValidate, -} from "#ghost-core"; +import type { GhostFingerprintDocument } from "#ghost-core"; import { readOptionalUtf8 } from "../internal/fs.js"; import { type FingerprintPackagePaths, @@ -24,21 +19,15 @@ export interface PackageContext { inventory?: string; composition?: string; }; - checks?: GhostValidateDocument; - checksRaw?: string; } export async function loadPackageContext( paths: FingerprintPackagePaths, nameOverride?: string, ): Promise { - const [loaded, checksRaw] = await Promise.all([ - loadFingerprintPackage(paths), - readOptional(paths.checks), - ]); + const loaded = await loadFingerprintPackage(paths); const fingerprint = loaded.fingerprint; - const checks = checksRaw ? parseChecks(checksRaw, fingerprint) : undefined; return { name: sanitizeName(nameOverride ?? inferPackageName(fingerprint)), packageDir: paths.dir, @@ -48,35 +37,10 @@ export async function loadPackageContext( manifest: loaded.manifestRaw, ...loaded.layerRaw, }, - checks, - checksRaw, }; } -function parseChecks( - raw: string, - fingerprint: GhostFingerprintDocument, -): GhostValidateDocument { - const parsed = parseYamlSafe(raw, "validate.yml"); - const report = lintGhostValidate(parsed, { fingerprint }); - if (report.errors > 0) { - const first = report.issues.find((issue) => issue.severity === "error"); - const suffix = first?.path ? ` @ ${first.path}` : ""; - throw new Error( - `validate.yml failed lint with ${report.errors} error(s): ${ - first?.message ?? "invalid checks" - }${suffix}`, - ); - } - - const result = GhostValidateSchema.safeParse(parsed); - if (!result.success) { - throw new Error("validate.yml failed schema validation."); - } - return result.data as GhostValidateDocument; -} - -function parseYamlSafe(raw: string, label: string): unknown { +function _parseYamlSafe(raw: string, label: string): unknown { try { return parseYaml(raw); } catch (err) { @@ -88,7 +52,7 @@ function parseYamlSafe(raw: string, label: string): unknown { } } -const readOptional = readOptionalUtf8; +const _readOptional = readOptionalUtf8; function inferPackageName(fingerprint: GhostFingerprintDocument): string { if (fingerprint.intent.summary.product) diff --git a/packages/ghost/src/context/package-review-command.ts b/packages/ghost/src/context/package-review-command.ts index a752c08d..869754e4 100644 --- a/packages/ghost/src/context/package-review-command.ts +++ b/packages/ghost/src/context/package-review-command.ts @@ -1,6 +1,5 @@ import { isAbsolute, relative } from "node:path"; import type { - GhostCheck, GhostFingerprintExemplar, GhostFingerprintExperienceContract, GhostFingerprintPattern, @@ -37,8 +36,6 @@ export function emitPackageReviewCommand( product.toLowerCase() === "ghost" ? "# Ghost review" : `# ${product} Ghost review`; - const activeChecks = - context.checks?.checks.filter((check) => check.status === "active") ?? []; const parts = [ packageFrontmatter(product), heading, @@ -46,7 +43,6 @@ export function emitPackageReviewCommand( packageWorkflowSection(context), packageFindingPolicySection(), packageFingerprintIndex(context), - packageChecksSection(activeChecks), packageReviewFooter(context), ]; return `${parts.filter(Boolean).join("\n\n").trim()}\n`; @@ -68,14 +64,13 @@ function packageWorkflowSection(context: PackageContext): string { const packageDir = displayPackageDir(context); return `## Review Workflow -1. Run \`ghost review\` for the advisory packet when you need full diff context and selected context excerpts. If reviewing manually, read \`${packageDir}/intent.yml\`, \`${packageDir}/inventory.yml\`, and \`${packageDir}/composition.yml\`. -2. Start from selected intent and active obligations before assessing UI, copy, flow, disclosure, recovery, trust, or interaction behavior. +1. Run \`ghost review --diff \` for the advisory packet, or \`ghost checks --diff \` for the routed checks and grounding. If reviewing manually, read \`${packageDir}/intent.yml\`, \`${packageDir}/inventory.yml\`, and \`${packageDir}/composition.yml\`. +2. Start from the touched surfaces' intent and obligations before assessing UI, copy, flow, disclosure, recovery, trust, or interaction behavior. 3. Apply composition guidance before choosing implementation details. 4. Inspect inventory exemplars and building blocks as evidence/material, not as authority over intent. -5. Treat validate checks as deterministic enforcement; only active checks can block. -6. Use selected-context gaps to label provisional reasoning or report \`missing-fingerprint\` / \`experience-gap\`. -7. Run \`ghost check\` when a diff is available. -8. Cite the diff location, fingerprint facet refs, relevant exemplars when useful, selected-context gaps when context is silent, and any active check when a finding blocks.`; +5. Evaluate the routed \`ghost.check/v1\` markdown checks against the diff; cite the surface they govern. +6. When a surface's grounding is silent, label provisional reasoning or report \`missing-fingerprint\` / \`experience-gap\`. +7. Cite the diff location, the touched surface, grounding refs, and the routed check when a finding blocks.`; } function packageFindingPolicySection(): string { @@ -83,9 +78,9 @@ function packageFindingPolicySection(): string { Use these categories: ${REVIEW_FINDING_CATEGORIES.map((category) => `\`${category}\``).join(", ")}. -Only findings backed by an active check should be treated as blocking. Everything else is advisory surface-composition critique. +Only findings backed by a routed check should be treated as blocking. Everything else is advisory surface-composition critique. -Review only what fingerprint facets or active checks make relevant to the product surface. +Review only what fingerprint facets or routed checks make relevant to the product surface. When fingerprint facets are silent, local evidence can still support advisory critique. Label those findings as provisional and non-Ghost-backed, and ground them in nearby product surfaces, local components, or token and copy conventions. Ask the human before assessing high-risk, irreversible, privacy/security/legal, or product-surface-defining choices. @@ -233,40 +228,11 @@ function formatExemplars(exemplars: GhostFingerprintExemplar[]): string { return lines.join("\n"); } -function packageChecksSection(activeChecks: GhostCheck[]): string { - if (activeChecks.length === 0) { - return `## Active Checks - -No active checks are recorded. Review remains advisory unless \`validate.yml\` adds deterministic active checks.`; - } - const lines = ["## Active Checks", ""]; - for (const check of activeChecks.slice(0, 12)) { - const refs = [ - ...(check.derivation?.intent ?? []), - ...(check.derivation?.composition ?? []), - ...(check.derivation?.inventory ?? []), - ]; - const derives = refs.length - ? ` from ${refs.map((ref) => `\`${ref}\``).join(", ")}` - : ""; - lines.push( - `- \`${check.id}\` (${check.severity})${derives}: ${check.title}`, - ); - if (check.repair) lines.push(` - Repair: ${check.repair}`); - } - if (activeChecks.length > 12) { - lines.push( - `- ${activeChecks.length - 12} more active check(s); read \`validate.yml\` before deciding whether a finding blocks.`, - ); - } - return lines.join("\n"); -} - function packageReviewFooter(context: PackageContext): string { const packageDir = displayPackageDir(context); return `--- -Generated from \`${packageDir}/\` for ${context.name}. Re-run \`ghost emit review-command\` after updating fingerprint facets or deterministic checks.`; +Generated from \`${packageDir}/\` for ${context.name}. Re-run \`ghost emit review-command\` after updating fingerprint facets or surface checks.`; } function displayPackageDir(context: PackageContext): string { diff --git a/packages/ghost/src/context/selected-context.ts b/packages/ghost/src/context/selected-context.ts deleted file mode 100644 index a1f83092..00000000 --- a/packages/ghost/src/context/selected-context.ts +++ /dev/null @@ -1,388 +0,0 @@ -import type { - ContextEntrypoint, - FingerprintGraphNode, - SelectionReason, -} from "./entrypoint.js"; -import type { PackageContext } from "./package-context.js"; - -export interface SelectedContext { - title: string; - target_paths: string[]; - stack: SelectedContextPackage[]; - match: { - status: ContextEntrypoint["match"]["status"]; - matched_scopes: string[]; - matched_surface_types: string[]; - reasons: string[]; - }; - posture: SelectedContextPosture; - context_hits: SelectedContextHit[]; - suggested_reads: SelectedContextRead[]; - omissions: SelectedContextOmission[]; - gaps: SelectedContextGap[]; -} - -export interface SelectedContextPackage { - dir: string; - label: string; -} - -export interface SelectedContextPosture { - product: string; - audience: string[]; - goals: string[]; - anti_goals: string[]; - tradeoffs: string[]; - tone: string[]; -} - -export interface SelectedContextHit { - ref: string; - kind: "intent" | "composition" | "inventory" | "validation"; - summary: string; - source_file: string; - details: string[]; - path?: string; - why_selected: SelectionReason[]; -} - -export interface SelectedContextRead { - path: string; - reason: string; -} - -export interface SelectedContextOmission { - label: string; - omitted: number; - source: string; -} - -export interface SelectedContextGap { - kind: - | "no-intent" - | "no-composition" - | "no-inventory" - | "no-validate" - | "unmatched-target" - | "low-specificity" - | "no-base-fingerprint" - | "request-unmatched" - | "request-ambiguous" - | "request-selector-gap"; - message: string; -} - -export function buildSelectedContext( - context: PackageContext, - entrypoint: ContextEntrypoint, -): SelectedContext { - const packageDirs = context.stackDirs?.length - ? context.stackDirs - : context.packageDir - ? [context.packageDir] - : []; - const stack = packageDirs.map((dir, index) => ({ - dir, - label: packageLabel(dir, index, packageDirs.length), - })); - const contextHits = [ - ...entrypoint.selected.intent, - ...entrypoint.selected.composition, - ...entrypoint.selected.exemplars, - ...entrypoint.selected.checks, - ].map((node) => contextHit(node, entrypoint)); - - return { - title: `${entrypoint.name} Relay Brief`, - target_paths: entrypoint.match.requestedPaths, - stack, - match: { - status: entrypoint.match.status, - matched_scopes: entrypoint.match.matchedScopes, - matched_surface_types: entrypoint.match.matchedSurfaceTypes, - reasons: entrypoint.match.reasons, - }, - posture: postureFromEntrypoint(entrypoint), - context_hits: contextHits, - suggested_reads: entrypoint.suggestedReads, - omissions: entrypoint.omissions, - gaps: gapsFromEntrypoint(entrypoint), - }; -} - -export function formatSelectedContextMarkdown( - context: SelectedContext, - options: { heading?: string; includeIntro?: boolean } = {}, -): string { - const heading = options.heading ?? "# Ghost Relay Brief"; - const sectionHeading = childHeading(heading); - const parts = [heading]; - if (options.includeIntro ?? true) { - parts.push( - `Product context: **${context.title.replace(/ Relay Brief$/, "")}**. Use this as compact, target-specific selected context from the resolved fingerprint stack. It does not replace the checked-in Ghost package facets.`, - ); - } - parts.push( - formatStack(context, sectionHeading), - formatMatch(context, sectionHeading), - formatPosture(context, sectionHeading), - formatContextHits(context, sectionHeading), - formatSuggestedReads(context, sectionHeading), - formatOmissions(context, sectionHeading), - formatGaps(context, sectionHeading), - formatUseThisContext(sectionHeading), - ); - return `${parts.filter(Boolean).join("\n\n").trim()}\n`; -} - -function childHeading(heading: string): string { - const hashes = heading.match(/^#+/)?.[0] ?? "#"; - return `${hashes}#`; -} - -function postureFromEntrypoint( - entrypoint: ContextEntrypoint, -): SelectedContextPosture { - return { - product: entrypoint.identity.product, - audience: entrypoint.identity.audience, - goals: entrypoint.identity.goals, - anti_goals: entrypoint.identity.antiGoals, - tradeoffs: entrypoint.identity.tradeoffs, - tone: entrypoint.identity.tone, - }; -} - -function packageLabel(_dir: string, index: number, count: number): string { - if (count === 1) return "package"; - if (index === 0) return "root"; - if (index === count - 1) return "leaf"; - return `package ${index + 1}`; -} - -function pathForNode(node: FingerprintGraphNode): string | undefined { - const directPath = node.appliesTo.paths[0]; - if (directPath) return directPath; - const pathDetail = node.details.find((detail) => detail.startsWith("Path: ")); - return pathDetail?.slice("Path: ".length).trim(); -} - -function contextHit( - node: FingerprintGraphNode, - entrypoint: ContextEntrypoint, -): SelectedContextHit { - const hit: SelectedContextHit = { - ref: node.ref, - kind: contextHitKind(node), - summary: node.summary, - source_file: node.sourceFile, - details: node.details, - why_selected: entrypoint.selectionReasons[node.ref] ?? [], - }; - const path = pathForNode(node); - if (path) hit.path = path; - return hit; -} - -function contextHitKind( - node: FingerprintGraphNode, -): SelectedContextHit["kind"] { - if (node.kind === "pattern") return "composition"; - if (node.kind === "exemplar") return "inventory"; - if (node.kind === "check") return "validation"; - return "intent"; -} - -function gapsFromEntrypoint( - entrypoint: ContextEntrypoint, -): SelectedContextGap[] { - const gaps: SelectedContextGap[] = []; - if (entrypoint.match.status === "global-fallback") { - gaps.push({ - kind: "low-specificity", - message: - "No path-specific fingerprint scope matched; treat this brief as broad context and inspect full fingerprint files if the task is narrow.", - }); - } - if (entrypoint.match.requestedPaths.length === 0) { - gaps.push({ - kind: "unmatched-target", - message: - "No target path was supplied; Relay selected a compact global context.", - }); - } - if (entrypoint.selected.intent.length === 0) { - gaps.push({ - kind: "no-intent", - message: - "No ref-backed intent anchors were selected; use posture as broad context and label product-surface-defining reasoning provisional.", - }); - } - if (entrypoint.selected.composition.length === 0) { - gaps.push({ - kind: "no-composition", - message: - "No composition patterns were selected; inspect composition.yml or nearby product surfaces if structure matters.", - }); - } - if (entrypoint.selected.exemplars.length === 0) { - gaps.push({ - kind: "no-inventory", - message: - "No inventory exemplars were selected; inspect local surfaces or inventory building blocks as provisional evidence.", - }); - } - if (entrypoint.selected.checks.length === 0) { - gaps.push({ - kind: "no-validate", - message: "No active validation checks were selected for this target.", - }); - } - return gaps; -} - -function formatStack(context: SelectedContext, heading: string): string { - const lines = [`${heading} Stack`]; - if (context.stack.length === 0) { - lines.push("- No stack recorded."); - return lines.join("\n"); - } - for (const pkg of context.stack) { - lines.push(`- ${pkg.label}: \`${pkg.dir}\``); - } - return lines.join("\n"); -} - -function formatMatch(context: SelectedContext, heading: string): string { - const lines = [`${heading} Match`]; - lines.push( - `- Status: ${context.match.status === "path-match" ? "path matched" : "global fallback"}`, - ); - pushJoined(lines, "Requested paths", context.target_paths, { code: true }); - pushJoined(lines, "Matched scopes", context.match.matched_scopes, { - code: true, - }); - pushJoined( - lines, - "Matched surface types", - context.match.matched_surface_types, - { code: true }, - ); - for (const reason of context.match.reasons) { - lines.push(`- Why: ${reason}`); - } - return lines.join("\n"); -} - -function formatPosture(context: SelectedContext, heading: string): string { - const lines = [`${heading} Posture`]; - if (context.posture.product) - lines.push(`- Product: ${context.posture.product}`); - pushPostureValues(lines, "Audience", context.posture.audience); - pushPostureValues(lines, "Goals", context.posture.goals); - pushPostureValues(lines, "Anti-goals", context.posture.anti_goals); - pushPostureValues(lines, "Tradeoffs", context.posture.tradeoffs); - pushPostureValues(lines, "Tone", context.posture.tone); - if (lines.length === 1) lines.push("- No posture summary recorded."); - return lines.join("\n"); -} - -function formatContextHits(context: SelectedContext, heading: string): string { - const lines = [`${heading} Context Hits`]; - if (context.context_hits.length === 0) { - lines.push("- None selected."); - return lines.join("\n"); - } - for (const hit of context.context_hits) { - const path = hit.path ? ` — \`${hit.path}\`` : ""; - lines.push(`- \`${hit.ref}\` (${hit.kind})${path} — ${hit.summary}`); - for (const reason of hit.why_selected) { - lines.push(` - why: ${reason.kind}=${reason.value}`); - } - for (const detail of hit.details - .filter((entry) => !entry.startsWith("Path: ")) - .slice(0, 2)) { - lines.push(` - ${detail}`); - } - } - return lines.join("\n"); -} - -function formatSuggestedReads( - context: SelectedContext, - heading: string, -): string { - const lines = [`${heading} Suggested Reads`]; - if (context.suggested_reads.length === 0) { - lines.push("- None selected."); - return lines.join("\n"); - } - for (const read of context.suggested_reads) { - lines.push(`- \`${read.path}\` - ${read.reason}`); - } - return lines.join("\n"); -} - -function formatOmissions(context: SelectedContext, heading: string): string { - const lines = [`${heading} Omissions`]; - for (const omission of context.omissions) { - if (omission.omitted === 0) { - lines.push(`- ${omission.label}: none omitted.`); - } else { - lines.push( - `- ${omission.label}: ${omission.omitted} omitted; inspect \`${omission.source}\` if the task widens.`, - ); - } - } - return lines.join("\n"); -} - -function formatGaps(context: SelectedContext, heading: string): string { - const lines = [`${heading} Gaps`]; - if (context.gaps.length === 0) { - lines.push("- No immediate gaps detected in selected context."); - return lines.join("\n"); - } - for (const gap of context.gaps) { - lines.push(`- ${gap.kind}: ${gap.message}`); - } - return lines.join("\n"); -} - -function formatUseThisContext(heading: string): string { - return `${heading} Use This Context -- Start from posture, then use context hits as the compact routing set for this task. -- Express intent through composition hits: shape hierarchy, flow, state, behavior, and content from the selected evidence. -- Inspect inventory hits as evidence and material; do not let available components override intent. -- Treat validation hits as deterministic enforcement; only active checks can block. -- When gaps are present, label local reasoning as provisional and non-Ghost-backed.`; -} - -function pushPostureValues( - lines: string[], - label: string, - values: string[] | undefined, -): void { - if (!values?.length) return; - if (values.length === 1) { - lines.push(`- ${label}: ${values[0]}`); - return; - } - lines.push(`- ${label}:`); - for (const value of values) { - lines.push(` - ${value}`); - } -} - -function pushJoined( - lines: string[], - label: string, - values: string[] | undefined, - options: { code?: boolean } = {}, -): void { - if (!values?.length) return; - const formatted = values - .map((value) => (options.code ? `\`${value}\`` : value)) - .join(", "); - lines.push(`- ${label}: ${formatted}`); -} diff --git a/packages/ghost/src/context/selection-reasons.ts b/packages/ghost/src/context/selection-reasons.ts deleted file mode 100644 index 12fdd238..00000000 --- a/packages/ghost/src/context/selection-reasons.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { - FingerprintGraph, - FingerprintGraphNode, - NodeRef, -} from "./graph.js"; -import { pathsOverlap, unique } from "./graph.js"; - -export interface SelectionReason { - kind: "path" | "scope" | "surface_type" | "linked_ref" | "global_fallback"; - value: string; -} - -export function directSelectionReasons( - node: FingerprintGraphNode, - options: { - requestedPaths: string[]; - matchedScopeIds: string[]; - matchedSurfaceTypes: string[]; - }, -): SelectionReason[] { - const reasons: SelectionReason[] = []; - for (const path of matchingPaths( - node.appliesTo.paths, - options.requestedPaths, - )) { - reasons.push({ kind: "path", value: path }); - } - for (const scope of matchingValues( - node.appliesTo.scopes, - options.matchedScopeIds, - )) { - reasons.push({ kind: "scope", value: scope }); - } - for (const surfaceType of matchingValues( - node.appliesTo.surfaceTypes, - options.matchedSurfaceTypes, - )) { - reasons.push({ kind: "surface_type", value: surfaceType }); - } - return reasons; -} - -export function expandOneHopWithReasons( - refs: Set, - graph: FingerprintGraph, - selectionReasons: Map, -): Set { - const expanded = new Set(refs); - for (const edge of graph.edges) { - if (refs.has(edge.from)) { - expanded.add(edge.to); - if (!refs.has(edge.to)) { - addSelectionReason(selectionReasons, edge.to, { - kind: "linked_ref", - value: edge.from, - }); - } - } - if (refs.has(edge.to)) { - expanded.add(edge.from); - if (!refs.has(edge.from)) { - addSelectionReason(selectionReasons, edge.from, { - kind: "linked_ref", - value: edge.to, - }); - } - } - } - return expanded; -} - -export function globalFallbackRefs( - graph: FingerprintGraph, - requestedPaths: string[], - selectionReasons: Map, -): Set { - const refs = new Set(); - const value = requestedPaths.join(", ") || "."; - for (const node of graph.nodes) { - refs.add(node.ref); - addSelectionReason(selectionReasons, node.ref, { - kind: "global_fallback", - value, - }); - } - return refs; -} - -export function addSelectionReason( - reasons: Map, - ref: NodeRef, - reason: SelectionReason, -): void { - const current = reasons.get(ref) ?? []; - if ( - !current.some( - (entry) => entry.kind === reason.kind && entry.value === reason.value, - ) - ) { - current.push(reason); - } - reasons.set(ref, current); -} - -function matchingPaths(paths: string[], requestedPaths: string[]): string[] { - const out: string[] = []; - for (const targetPath of requestedPaths) { - if (paths.some((path) => pathsOverlap(path, targetPath))) { - out.push(targetPath); - } - } - return unique(out); -} - -function matchingValues(values: string[], candidates: string[]): string[] { - const candidateSet = new Set(candidates); - return values.filter((value) => candidateSet.has(value)); -} diff --git a/packages/ghost/src/core/check.ts b/packages/ghost/src/core/check.ts deleted file mode 100644 index 50481090..00000000 --- a/packages/ghost/src/core/check.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { parse as parseYaml } from "yaml"; -import { - GHOST_VALIDATE_SCHEMA, - type GhostCheck, - type GhostValidateDocument, - GhostValidateSchema, - lintGhostValidate, - routeGhostValidateForPath, -} from "#ghost-core"; -import { readOptionalUtf8 } from "../internal/fs.js"; -import { - loadFingerprintPackage, - resolveFingerprintPackage, -} from "../scan/fingerprint-package.js"; -import { - groupFingerprintStacksForPaths, - resolveGhostDirDefault, -} from "../scan/fingerprint-stack.js"; -import { - INLINE_COLOR_LITERAL_PATTERN, - isInlineColorDetector, -} from "./inline-color-literals.js"; - -const execFileAsync = promisify(execFile); - -export interface GhostDriftCheckOptions { - cwd?: string; - packageDir?: string; - ghostDir?: string; - base?: string; - diffText?: string; -} - -export interface GhostDriftChangedLine { - path: string; - line: number; - text: string; -} - -export interface GhostDriftChangedFile { - path: string; - added_lines: GhostDriftChangedLine[]; -} - -export interface GhostDriftRoutedFile { - path: string; - scopes: string[]; - checks: string[]; -} - -export interface GhostDriftCheckFinding { - check_id: string; - title: string; - severity: GhostCheck["severity"]; - path: string; - line: number; - detector: GhostCheck["detector"]["type"]; - message: string; - repair?: string; - match?: string; -} - -export interface GhostDriftCheckReport { - schema: "ghost.check-report/v1"; - result: "pass" | "fail"; - package_dir: string; - ghost_dir?: string; - base?: string; - changed_files: string[]; - routed_files: GhostDriftRoutedFile[]; - findings: GhostDriftCheckFinding[]; - stacks?: GhostDriftCheckStack[]; -} - -export interface GhostDriftCheckStack { - target_path: string; - package_dir: string; - ghost_dir: string; - changed_files: string[]; - stack_dirs: string[]; - provenance: { - stack: Array<{ - dir: string; - root: string; - relative_root: string; - }>; - }; -} - -interface LoadedCheckPackage { - dir: string; - checks: GhostValidateDocument; -} - -export async function runGhostDriftCheck( - options: GhostDriftCheckOptions = {}, -): Promise { - const cwd = options.cwd ?? process.cwd(); - const diffText = - options.diffText ?? - (await readGitDiff(cwd, options.base ?? "HEAD", "--unified=0")); - const changedFiles = parseUnifiedDiff(diffText); - - if (options.packageDir) { - const pkg = await loadCheckPackage(options.packageDir, cwd); - const evaluated = evaluateChangedFiles(changedFiles, pkg); - return { - schema: "ghost.check-report/v1", - result: evaluated.findings.length > 0 ? "fail" : "pass", - package_dir: pkg.dir, - ...(options.base ? { base: options.base } : {}), - changed_files: changedFiles.map((file) => file.path), - routed_files: evaluated.routedFiles, - findings: evaluated.findings, - }; - } - - const groups = await groupFingerprintStacksForPaths( - changedFiles.map((file) => file.path), - cwd, - { ghostDir: resolveGhostDirDefault(options.ghostDir) }, - ); - const routedFiles: GhostDriftRoutedFile[] = []; - const findings: GhostDriftCheckFinding[] = []; - const stacks: GhostDriftCheckStack[] = []; - - for (const group of groups) { - const filesForStack = changedFiles.filter((file) => - group.changed_files.includes(file.path), - ); - const leaf = group.stack.layers.at(-1); - const pkg: LoadedCheckPackage = { - dir: leaf?.dir ?? group.stack.layers[0].dir, - checks: group.stack.contract.checks, - }; - const evaluated = evaluateChangedFiles(filesForStack, pkg); - routedFiles.push(...evaluated.routedFiles); - findings.push(...evaluated.findings); - stacks.push({ - target_path: group.stack.target_path, - package_dir: pkg.dir, - ghost_dir: group.stack.ghost_dir, - changed_files: group.changed_files, - stack_dirs: group.stack.layers.map((layer) => layer.dir), - provenance: { - stack: group.stack.provenance.layers, - }, - }); - } - - return { - schema: "ghost.check-report/v1", - result: findings.length > 0 ? "fail" : "pass", - package_dir: - stacks.length === 1 - ? stacks[0].package_dir - : "fingerprint-stack/multiple", - ghost_dir: stacks[0]?.ghost_dir ?? options.ghostDir, - ...(options.base ? { base: options.base } : {}), - changed_files: changedFiles.map((file) => file.path), - routed_files: routedFiles, - findings, - stacks, - }; -} - -export function parseUnifiedDiff(diffText: string): GhostDriftChangedFile[] { - const files = new Map(); - let current: GhostDriftChangedFile | undefined; - let newLine = 0; - - for (const rawLine of diffText.split(/\r?\n/)) { - if (rawLine.startsWith("diff --git ")) { - current = undefined; - continue; - } - - if (rawLine.startsWith("+++ ")) { - const file = rawLine.replace(/^\+\+\+\s+/, ""); - if (file === "/dev/null") { - current = undefined; - continue; - } - const path = file.replace(/^b\//, ""); - current = files.get(path) ?? { path, added_lines: [] }; - files.set(path, current); - continue; - } - - const hunk = rawLine.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); - if (hunk) { - newLine = Number(hunk[1]); - continue; - } - - if (!current) continue; - if (rawLine.startsWith("+")) { - current.added_lines.push({ - path: current.path, - line: newLine, - text: rawLine.slice(1), - }); - newLine += 1; - } else if (rawLine.startsWith("-")) { - } else { - newLine += 1; - } - } - - return [...files.values()]; -} - -export function formatGhostDriftCheckMarkdown( - report: GhostDriftCheckReport, -): string { - const lines = [ - `Design Check: ${report.result === "pass" ? "PASS" : "FAIL"}`, - `Package: ${report.package_dir}`, - `Changed files: ${report.changed_files.length}`, - `Findings: ${report.findings.length}`, - "", - ]; - - if (report.routed_files.length > 0) { - lines.push("## Routed Files", ""); - for (const file of report.routed_files) { - const scopes = file.scopes.length > 0 ? file.scopes.join(", ") : "none"; - const checks = file.checks.length > 0 ? file.checks.join(", ") : "none"; - lines.push(`- ${file.path}: scopes ${scopes}; checks ${checks}`); - } - lines.push(""); - } - - if (report.findings.length === 0) { - lines.push("No active deterministic check failures."); - return `${lines.join("\n")}\n`; - } - - lines.push("## Issues", ""); - report.findings.forEach((finding, index) => { - lines.push( - `${index + 1}. [${finding.severity}] ${finding.title} (${finding.check_id})`, - ` ${finding.path}:${finding.line} — ${finding.message}`, - ); - if (finding.match) lines.push(` Match: \`${finding.match}\``); - if (finding.repair) lines.push(` Repair: ${finding.repair}`); - }); - return `${lines.join("\n")}\n`; -} - -async function loadCheckPackage( - packageDir: string | undefined, - cwd: string, -): Promise { - const paths = resolveFingerprintPackage(packageDir, cwd); - const [loaded, checksRaw] = await Promise.all([ - loadFingerprintPackage(paths), - readOptional(paths.checks), - ]); - const fingerprint = loaded.fingerprint; - if (checksRaw === undefined) { - return { - dir: paths.dir, - checks: { - schema: GHOST_VALIDATE_SCHEMA, - id: "none", - checks: [], - }, - }; - } - const checksInput = parseYaml(checksRaw); - const checksResult = GhostValidateSchema.safeParse(checksInput); - if (!checksResult.success) { - throw new Error( - `validate.yml failed schema validation: ${checksResult.error.issues - .map((issue) => `${issue.path.join(".") || ""}: ${issue.message}`) - .join("; ")}`, - ); - } - const checks = checksResult.data as GhostValidateDocument; - const checkLint = lintGhostValidate(checks, { fingerprint }); - if (checkLint.errors > 0) { - throw new Error( - `validate.yml failed lint with ${checkLint.errors} error(s): ${checkLint.issues - .filter((issue) => issue.severity === "error") - .map((issue) => `[${issue.rule}] ${issue.message}`) - .join("; ")}`, - ); - } - return { dir: paths.dir, checks }; -} - -function evaluateChangedFiles( - changedFiles: GhostDriftChangedFile[], - pkg: LoadedCheckPackage, -): { - routedFiles: GhostDriftRoutedFile[]; - findings: GhostDriftCheckFinding[]; -} { - const routedFiles: GhostDriftRoutedFile[] = []; - const findings: GhostDriftCheckFinding[] = []; - - for (const file of changedFiles) { - const routed = routeGhostValidateForPath(pkg.checks.checks, file.path); - routedFiles.push({ - path: file.path, - scopes: [], - checks: routed.map((entry) => entry.check.id), - }); - - for (const entry of routed) { - if (!detectorAppliesToPath(entry.check, file.path)) continue; - findings.push(...evaluateCheck(entry.check, file)); - } - } - - return { routedFiles, findings }; -} - -const readOptional = readOptionalUtf8; - -async function readGitDiff( - cwd: string, - base: string, - contextFlag: string, -): Promise { - const { stdout } = await execFileAsync("git", ["diff", contextFlag, base], { - cwd, - maxBuffer: 1024 * 1024 * 20, - }); - return stdout; -} - -function evaluateCheck( - check: GhostCheck, - file: GhostDriftChangedFile, -): GhostDriftCheckFinding[] { - const regexes = detectorRegexes(check); - if (regexes.length === 0) return []; - - if (isRequiredDetector(check)) { - if (file.added_lines.length === 0) return []; - const hasMatch = file.added_lines.some((line) => { - return regexes.some((regex) => { - regex.lastIndex = 0; - return regex.test(line.text); - }); - }); - if (hasMatch) return []; - const firstLine = file.added_lines[0]; - return [ - { - check_id: check.id, - title: check.title, - severity: check.severity, - path: file.path, - line: firstLine.line, - detector: check.detector.type, - message: requiredMessage(check), - ...(check.repair ? { repair: check.repair } : {}), - }, - ]; - } - - const findings: GhostDriftCheckFinding[] = []; - const seen = new Set(); - for (const line of file.added_lines) { - for (const regex of regexes) { - regex.lastIndex = 0; - let match = regex.exec(line.text); - while (match !== null) { - const key = `${line.line}:${match.index}:${match[0]}`; - if (!seen.has(key)) { - findings.push({ - check_id: check.id, - title: check.title, - severity: check.severity, - path: file.path, - line: line.line, - detector: check.detector.type, - message: forbiddenMessage(check), - match: match[0], - ...(check.repair ? { repair: check.repair } : {}), - }); - seen.add(key); - } - if (match[0] === "") regex.lastIndex += 1; - match = regex.exec(line.text); - } - } - } - return findings; -} - -function detectorRegexes(check: GhostCheck): RegExp[] { - const source = - check.detector.pattern ?? - (check.detector.value ? escapeRegExp(check.detector.value) : undefined); - if (!source) return []; - - const regexes = [new RegExp(source, "g")]; - if (isInlineColorDetector(check, source)) { - regexes.push(new RegExp(INLINE_COLOR_LITERAL_PATTERN, "gi")); - } - return regexes; -} - -function detectorAppliesToPath(check: GhostCheck, path: string): boolean { - const contexts = check.detector.contexts; - if (!contexts?.length) return true; - const context = contextForPath(path); - return context ? contexts.includes(context) : false; -} - -function contextForPath(path: string): string | undefined { - const lower = path.toLowerCase(); - if (lower.endsWith(".swift")) return "swift"; - if (lower.endsWith(".tsx") || lower.endsWith(".jsx")) return "react"; - if (lower.endsWith(".ts") || lower.endsWith(".js")) return "typescript"; - if (lower.endsWith(".css") || lower.endsWith(".scss")) return "css"; - return undefined; -} - -function isRequiredDetector(check: GhostCheck): boolean { - return ( - check.detector.type === "required-regex" || - check.detector.type === "required-token" - ); -} - -function requiredMessage(check: GhostCheck): string { - if (check.detector.type === "required-token") { - return "Added UI code did not use the required design token."; - } - return "Added UI code did not match the required pattern."; -} - -function forbiddenMessage(check: GhostCheck): string { - if (check.detector.type === "banned-import") { - return "Added UI code imports a banned dependency."; - } - if (check.detector.type === "banned-component") { - return "Added UI code uses a banned component."; - } - return "Added UI code matched a forbidden pattern."; -} - -function escapeRegExp(value: string): string { - return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); -} diff --git a/packages/ghost/src/core/index.ts b/packages/ghost/src/core/index.ts index a70b2881..f94b5258 100644 --- a/packages/ghost/src/core/index.ts +++ b/packages/ghost/src/core/index.ts @@ -58,20 +58,6 @@ export { embeddingDistance, inferSemanticRole, } from "#ghost-core"; -export type { - GhostDriftChangedFile, - GhostDriftChangedLine, - GhostDriftCheckFinding, - GhostDriftCheckOptions, - GhostDriftCheckReport, - GhostDriftCheckStack, - GhostDriftRoutedFile, -} from "./check.js"; -export { - formatGhostDriftCheckMarkdown, - parseUnifiedDiff, - runGhostDriftCheck, -} from "./check.js"; export type { CompareOptions, CompareResult } from "./compare.js"; export { compare } from "./compare.js"; export { defineConfig, loadConfig, resolveTarget } from "./config.js"; diff --git a/packages/ghost/src/core/inline-color-literals.ts b/packages/ghost/src/core/inline-color-literals.ts deleted file mode 100644 index d9f5ed25..00000000 --- a/packages/ghost/src/core/inline-color-literals.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { GhostCheck } from "#ghost-core"; - -const CSS_NAMED_COLOR_NAMES = [ - "white", - "black", - "red", - "green", - "blue", - "yellow", - "orange", - "purple", - "pink", - "gray", - "grey", - "navy", - "teal", - "coral", - "salmon", - "tomato", - "gold", - "silver", - "maroon", - "aqua", - "cyan", - "lime", - "indigo", - "violet", - "crimson", - "magenta", - "turquoise", - "ivory", - "beige", - "khaki", -]; - -const CSS_NAMED_COLOR_PATTERN = CSS_NAMED_COLOR_NAMES.join("|"); - -export const INLINE_COLOR_LITERAL_PATTERN = [ - "#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])", - `\\b(?:Color|UIColor|NSColor)\\.(?:${CSS_NAMED_COLOR_PATTERN})\\b`, - `\\b(?:${CSS_NAMED_COLOR_PATTERN})\\b`, -].join("|"); - -export function isInlineColorDetector( - check: Pick, - source: string, -): boolean { - if (check.detector.type !== "forbidden-regex") return false; - const description = [ - check.id, - check.title, - check.repair, - check.detector.value, - check.detector.pattern, - ] - .filter(Boolean) - .join(" "); - return ( - /\bcolou?r\b|\bhex\b/i.test(description) || - /#[0-9a-fA-F]{3,8}\b/.test(source) || - /#(?:[0-9a-fA-F]|\[[^\]]*(?:a-f|0-9)[^\]]*\]|\(\?:)/i.test(source) - ); -} diff --git a/packages/ghost/src/fingerprint-commands.ts b/packages/ghost/src/fingerprint-commands.ts index 17f4c206..cd59d432 100644 --- a/packages/ghost/src/fingerprint-commands.ts +++ b/packages/ghost/src/fingerprint-commands.ts @@ -3,7 +3,6 @@ import { dirname, resolve } from "node:path"; import type { CAC } from "cac"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import type { - GhostFingerprintDocument, GhostPatternsDocument, Survey, SurveySummaryBudget, @@ -14,7 +13,6 @@ import { type lintFingerprint, lintFingerprintPackage, loadFingerprint, - loadFingerprintPackage, resolveFingerprintPackage, verifyAllFingerprintStacks, verifyFingerprintPackage, @@ -84,12 +82,7 @@ export function registerFingerprintCommands(cli: CAC): void { const fileTarget = resolve(process.cwd(), path ?? target); const raw = await readFile(fileTarget, "utf-8"); const kind = detectFileKind(fileTarget, raw); - const fingerprint = - kind === "validate" - ? await loadSiblingFingerprintForValidateLint(fileTarget) - : undefined; - - report = lintDetectedFileKind(kind, raw, { fingerprint }); + report = lintDetectedFileKind(kind, raw); if (kind === "fingerprint" && hasExtends(raw) && report.errors === 0) { try { @@ -171,7 +164,7 @@ export function registerFingerprintCommands(cli: CAC): void { cli .command( "scan [dir]", - "Report sparse fingerprint package contribution facets: intent, inventory, composition, validate, and the next BYOA step.", + "Report sparse fingerprint package contribution facets: intent, inventory, composition, and the next BYOA step.", ) .option( "--include-nested", @@ -207,9 +200,6 @@ export function registerFingerprintCommands(cli: CAC): void { process.stdout.write( ` package (manifest.yml): ${fmt(status.fingerprint.state)}\n`, ); - process.stdout.write( - ` validate (validate.yml): ${fmt(status.validate.state)}\n`, - ); process.stdout.write("\n"); if (status.recommended_next) { process.stdout.write( @@ -221,12 +211,7 @@ export function registerFingerprintCommands(cli: CAC): void { ); } process.stdout.write(`contribution: ${status.contribution.state}\n`); - for (const facet of [ - "intent", - "inventory", - "composition", - "validate", - ] as const) { + for (const facet of ["intent", "inventory", "composition"] as const) { const report = status.contribution.facets[facet]; process.stdout.write( ` ${facet}: ${report.state} (${report.count})\n`, @@ -322,7 +307,6 @@ async function nestedPackageStatus( return { ...pkg, fingerprint: status.fingerprint, - validate: status.validate, contribution: status.contribution, }; }), @@ -335,7 +319,6 @@ interface NestedPackageStatus { relative_root: string; ghost_dir: string; fingerprint: Awaited>["fingerprint"]; - validate: Awaited>["validate"]; contribution: Awaited>["contribution"]; } @@ -354,19 +337,6 @@ function ghostDirFromEnv(): string { return resolveGhostDirDefault(); } -async function loadSiblingFingerprintForValidateLint( - fileTarget: string, -): Promise { - const validateDir = dirname(fileTarget); - try { - return ( - await loadFingerprintPackage(resolveFingerprintPackage(validateDir)) - ).fingerprint; - } catch { - return undefined; - } -} - function writeLintReport( report: ReturnType, format: unknown, diff --git a/packages/ghost/src/ghost-core/binding/contract-ref.ts b/packages/ghost/src/ghost-core/binding/contract-ref.ts new file mode 100644 index 00000000..2a235b75 --- /dev/null +++ b/packages/ghost/src/ghost-core/binding/contract-ref.ts @@ -0,0 +1,30 @@ +/** The in-repo root contract reference. */ +export const IN_REPO_CONTRACT = "." as const; + +/** + * npm package name: optional `@scope/`, then a lowercase name. Matches the npm + * naming rules closely enough to distinguish a package reference from a path, + * URL, or arbitrary resource id. + */ +const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; + +export type ContractReferenceKind = "in-repo" | "npm" | "unsupported"; + +/** + * Classify a binding `contract:` reference. `.` is the in-repo root contract; an + * npm package name resolves from `node_modules`; anything else (a path, URL, or + * resource id) is not yet supported (see docs/ideas/polish-cut-d-plan.md). + */ +export function classifyContractReference( + reference: string, +): ContractReferenceKind { + if (reference === IN_REPO_CONTRACT) return "in-repo"; + // Exclude path-like and protocol-like references before the npm-name test. + if (reference.includes("/") && !reference.startsWith("@")) { + return "unsupported"; + } + if (reference.includes(":") || reference.startsWith(".")) { + return "unsupported"; + } + return NPM_NAME.test(reference) ? "npm" : "unsupported"; +} diff --git a/packages/ghost/src/ghost-core/binding/index.ts b/packages/ghost/src/ghost-core/binding/index.ts index 0b125675..7de74fc4 100644 --- a/packages/ghost/src/ghost-core/binding/index.ts +++ b/packages/ghost/src/ghost-core/binding/index.ts @@ -4,6 +4,11 @@ * docs/ideas/surface-binding.md. */ +export { + type ContractReferenceKind, + classifyContractReference, + IN_REPO_CONTRACT, +} from "./contract-ref.js"; export { lintGhostBinding } from "./lint.js"; export { type BindingCandidate, diff --git a/packages/ghost/src/ghost-core/binding/lint.ts b/packages/ghost/src/ghost-core/binding/lint.ts index c116c739..d945388c 100644 --- a/packages/ghost/src/ghost-core/binding/lint.ts +++ b/packages/ghost/src/ghost-core/binding/lint.ts @@ -1,4 +1,5 @@ import type { ZodIssue } from "zod"; +import { classifyContractReference } from "./contract-ref.js"; import { GhostBindingSchema } from "./schema.js"; import type { GhostBindingDocument, @@ -9,11 +10,11 @@ import type { /** * Lint a `ghost.binding/v1` document. Schema-level validity (shape, slug ids, * non-empty paths) is enforced by Zod; this adds document-level checks the - * schema cannot express: only the in-repo `contract: .` is supported for now, - * and a surface should not be bound twice in one file. + * schema cannot express: the contract reference is `.` (in-repo) or an npm + * package name, and a surface should not be bound twice in one file. * * Cross-referencing surface ids against the contract's surfaces happens at - * resolution, not here — the binding file cannot see the contract. + * resolution/verify, not here — the binding file cannot see the contract. */ export function lintGhostBinding(input: unknown): GhostBindingLintReport { const result = GhostBindingSchema.safeParse(input); @@ -22,11 +23,11 @@ export function lintGhostBinding(input: unknown): GhostBindingLintReport { const doc = result.data as GhostBindingDocument; const issues: GhostBindingLintIssue[] = []; - if (doc.contract !== ".") { + if (classifyContractReference(doc.contract) === "unsupported") { issues.push({ severity: "error", rule: "binding-contract-unsupported", - message: `contract '${doc.contract}' is not supported; only the in-repo contract '.' is supported.`, + message: `contract '${doc.contract}' is not supported; use '.' (in-repo) or an npm package name.`, path: "contract", }); } diff --git a/packages/ghost/src/ghost-core/binding/types.ts b/packages/ghost/src/ghost-core/binding/types.ts index 9d1bbe40..57d9fa1e 100644 --- a/packages/ghost/src/ghost-core/binding/types.ts +++ b/packages/ghost/src/ghost-core/binding/types.ts @@ -14,9 +14,11 @@ export interface GhostBindingEntry { export interface GhostBindingDocument { schema: typeof GHOST_BINDING_SCHEMA; /** - * Reference to the contract this binding instantiates. Only `.` (the in-repo - * root contract) is supported now; external references (npm name, resource - * id) are deferred (see docs/ideas/surface-binding.md open fork 1). + * Reference to the contract this binding instantiates: `.` (the in-repo root + * contract) or an npm package name (`@scope/brand`), resolved from + * `node_modules`. Other references (paths, URLs, resource ids) are not yet + * supported. `verify` checks an external contract resolves and the bound + * surfaces exist in it (see docs/ideas/polish-cut-d-plan.md). */ contract: string; bindings: GhostBindingEntry[]; diff --git a/packages/ghost/src/ghost-core/checks/index.ts b/packages/ghost/src/ghost-core/checks/index.ts deleted file mode 100644 index ac0c9769..00000000 --- a/packages/ghost/src/ghost-core/checks/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -export { lintGhostValidate } from "./lint.js"; -export { - matchesGhostPath, - normalizeGhostPath, - routeGhostValidateForPath, -} from "./routing.js"; -export { - GhostCheckDerivationSchema, - GhostCheckSchema, - GhostValidateSchema, -} from "./schema.js"; -export type { - GhostCheck, - GhostCheckAppliesTo, - GhostCheckDerivation, - GhostCheckDerivationCompositionRef, - GhostCheckDerivationIntentRef, - GhostCheckDerivationInventoryRef, - GhostCheckDetector, - GhostCheckDetectorType, - GhostCheckEvidence, - GhostCheckSeverity, - GhostCheckStatus, - GhostValidateDocument, - GhostValidateLintIssue, - GhostValidateLintOptions, - GhostValidateLintReport, - GhostValidateLintSeverity, - RoutedGhostValidateCheck, -} from "./types.js"; -export { - GHOST_VALIDATE_FILENAME, - GHOST_VALIDATE_SCHEMA, -} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/checks/lint.ts b/packages/ghost/src/ghost-core/checks/lint.ts deleted file mode 100644 index 62180984..00000000 --- a/packages/ghost/src/ghost-core/checks/lint.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { GhostValidateSchema } from "./schema.js"; -import type { - GhostCheck, - GhostValidateDocument, - GhostValidateLintIssue, - GhostValidateLintOptions, - GhostValidateLintReport, -} from "./types.js"; - -const SUPPORT_FLOOR = 0.85; -const GROUNDING_PREFIXES = [ - "intent.principle", - "intent.situation", - "intent.experience_contract", - "inventory.exemplar", - "composition.pattern", -] as const; -type GroundingPrefix = (typeof GROUNDING_PREFIXES)[number]; -type DerivationGroup = "intent" | "inventory" | "composition"; - -export function lintGhostValidate( - input: unknown, - options: GhostValidateLintOptions = {}, -): GhostValidateLintReport { - const issues: GhostValidateLintIssue[] = []; - const result = GhostValidateSchema.safeParse(input); - if (!result.success) { - for (const issue of result.error.issues) { - issues.push({ - severity: "error", - rule: `schema/${issue.code}`, - message: issue.message, - path: issue.path.length ? issue.path.join(".") : undefined, - }); - } - return finalize(issues); - } - - const doc = result.data as GhostValidateDocument; - checkDuplicateIds(doc.checks, issues); - doc.checks.forEach((check, index) => { - checkOne(check, index, options, issues); - }); - - return finalize(issues); -} - -function checkDuplicateIds( - checks: GhostCheck[], - issues: GhostValidateLintIssue[], -): void { - const seen = new Map(); - checks.forEach((check, index) => { - const previous = seen.get(check.id); - if (previous !== undefined) { - issues.push({ - severity: "error", - rule: "duplicate-check-id", - message: `check id '${check.id}' is duplicated (also at checks[${previous}])`, - path: `checks[${index}].id`, - }); - } else { - seen.set(check.id, index); - } - }); -} - -function checkOne( - check: GhostCheck, - index: number, - options: GhostValidateLintOptions, - issues: GhostValidateLintIssue[], -): void { - const path = `checks[${index}]`; - checkDetector(check, path, issues); - checkGrounding(check, path, options, issues); - checkAppliesToTargets(check, path, options, issues); - - if (check.status === "disabled") return; - - if (!check.applies_to?.paths?.length && !check.applies_to?.scopes?.length) { - issues.push({ - severity: check.status === "active" ? "error" : "warning", - rule: "check-scope-missing", - message: - "Checks must declare applies_to.paths or applies_to.scopes so routing is deterministic.", - path: `${path}.applies_to`, - }); - } - - if (!check.evidence) { - issues.push({ - severity: check.status === "active" ? "error" : "warning", - rule: "check-evidence-missing", - message: - "Checks must include evidence with support, observed_count, and examples before they can be trusted.", - path: `${path}.evidence`, - }); - return; - } - - if (typeof check.evidence.support !== "number") { - issues.push({ - severity: check.status === "active" ? "error" : "warning", - rule: "check-support-missing", - message: "Check evidence must include support.", - path: `${path}.evidence.support`, - }); - } else if (check.evidence.support < SUPPORT_FLOOR) { - issues.push({ - severity: "warning", - rule: "check-support-low", - message: `Check support ${check.evidence.support.toFixed(2)} is below ${SUPPORT_FLOOR}; promote only if the curator intentionally accepts noise.`, - path: `${path}.evidence.support`, - }); - } - - if (typeof check.evidence.observed_count !== "number") { - issues.push({ - severity: check.status === "active" ? "error" : "warning", - rule: "check-observed-count-missing", - message: "Check evidence must include observed_count.", - path: `${path}.evidence.observed_count`, - }); - } - - if (!check.evidence.examples?.length) { - issues.push({ - severity: check.status === "active" ? "error" : "warning", - rule: "check-examples-missing", - message: "Check evidence must cite at least one precedent example.", - path: `${path}.evidence.examples`, - }); - } -} - -function checkAppliesToTargets( - check: GhostCheck, - path: string, - options: GhostValidateLintOptions, - issues: GhostValidateLintIssue[], -): void { - if (!check.applies_to || !options.fingerprint) return; - - const severity = check.status === "active" ? "error" : "warning"; - const targets = collectFingerprintRoutingTargets(options.fingerprint); - - // Phase 3: scope/surface_type routing targets came from the removed topology. - // Check routing against surfaces is rebuilt in Phase 4/7; until then only - // pattern_id targets are validated. - - check.applies_to.pattern_ids?.forEach((patternId, patternIndex) => { - if (targets.patterns.has(patternId)) return; - issues.push({ - severity, - rule: "check-pattern-unknown", - message: `Check references unknown fingerprint pattern '${patternId}'.`, - path: `${path}.applies_to.pattern_ids[${patternIndex}]`, - }); - }); -} - -function checkGrounding( - check: GhostCheck, - path: string, - options: GhostValidateLintOptions, - issues: GhostValidateLintIssue[], -): void { - const derivation = check.derivation; - const intentRefs = derivation?.intent ?? []; - const compositionRefs = derivation?.composition ?? []; - const inventoryRefs = derivation?.inventory ?? []; - const hasAuthoritativeGrounding = - intentRefs.length > 0 || compositionRefs.length > 0; - const hasAnyDerivation = - hasAuthoritativeGrounding || inventoryRefs.length > 0; - - if (check.status === "disabled") return; - - if (!hasAnyDerivation) { - issues.push({ - severity: "warning", - rule: "check-grounding-missing", - message: - "Checks should declare derivation refs when they enforce surface-composition rules.", - path: `${path}.derivation`, - }); - return; - } - - if (!hasAuthoritativeGrounding) { - issues.push({ - severity: "warning", - rule: "check-grounding-inventory-only", - message: - "Inventory refs can support a check, but intent or composition refs are preferred for surface-composition enforcement.", - path: `${path}.derivation`, - }); - } - - if (!options.fingerprint) { - issues.push({ - severity: "info", - rule: "check-grounding-unverified", - message: - "Check derivation refs were not verified because no fingerprint package context was provided; run ghost lint on the bundle.", - path: `${path}.derivation`, - }); - return; - } - - const targets = collectFingerprintTargets(options.fingerprint); - checkDerivationRefs(intentRefs, "intent", path, targets, issues); - checkDerivationRefs(compositionRefs, "composition", path, targets, issues); - checkDerivationRefs(inventoryRefs, "inventory", path, targets, issues); -} - -function checkDerivationRefs( - refs: string[], - group: DerivationGroup, - path: string, - targets: Record>, - issues: GhostValidateLintIssue[], -): void { - refs.forEach((ref, index) => { - const parsed = parseGroundingRef(ref); - if (!parsed) return; - if (targets[parsed.prefix].has(parsed.id)) return; - issues.push({ - severity: "warning", - rule: "check-grounding-unknown", - message: `Check derivation references unknown fingerprint ref '${ref}'.`, - path: `${path}.derivation.${group}[${index}]`, - }); - }); -} - -function collectFingerprintRoutingTargets( - fingerprint: NonNullable, -): { - patterns: Set; -} { - return { - patterns: new Set( - fingerprint.composition.patterns.map((entry) => entry.id), - ), - }; -} - -function parseGroundingRef( - ref: string, -): { prefix: GroundingPrefix; id: string } | undefined { - const [prefix, id] = ref.split(":"); - if (!prefix || !id) return undefined; - if (!GROUNDING_PREFIXES.includes(prefix as GroundingPrefix)) return undefined; - return { prefix: prefix as GroundingPrefix, id }; -} - -function collectFingerprintTargets( - fingerprint: NonNullable, -): Record> { - return { - "intent.principle": new Set( - fingerprint.intent.principles.map((entry) => entry.id), - ), - "intent.situation": new Set( - fingerprint.intent.situations.map((entry) => entry.id), - ), - "intent.experience_contract": new Set( - fingerprint.intent.experience_contracts.map((entry) => entry.id), - ), - "inventory.exemplar": new Set( - fingerprint.inventory.exemplars.map((entry) => entry.id), - ), - "composition.pattern": new Set( - fingerprint.composition.patterns.map((entry) => entry.id), - ), - }; -} - -function checkDetector( - check: GhostCheck, - path: string, - issues: GhostValidateLintIssue[], -): void { - const { detector } = check; - if ( - detector.type === "forbidden-regex" || - detector.type === "required-regex" - ) { - if (!detector.pattern) { - issues.push({ - severity: "error", - rule: "check-detector-pattern-missing", - message: `${detector.type} detectors must include pattern.`, - path: `${path}.detector.pattern`, - }); - return; - } - compileRegex(detector.pattern, `${path}.detector.pattern`, issues); - return; - } - - if (!detector.pattern && !detector.value) { - issues.push({ - severity: "error", - rule: "check-detector-value-missing", - message: `${detector.type} detectors must include pattern or value.`, - path: `${path}.detector`, - }); - return; - } - if (detector.pattern) { - compileRegex(detector.pattern, `${path}.detector.pattern`, issues); - } -} - -function compileRegex( - pattern: string, - path: string, - issues: GhostValidateLintIssue[], -): void { - try { - new RegExp(pattern); - } catch (err) { - issues.push({ - severity: "error", - rule: "check-detector-pattern-invalid", - message: `Detector pattern is not a valid JavaScript regular expression: ${ - err instanceof Error ? err.message : String(err) - }`, - path, - }); - } -} - -function finalize(issues: GhostValidateLintIssue[]): GhostValidateLintReport { - return { - issues, - errors: issues.filter((issue) => issue.severity === "error").length, - warnings: issues.filter((issue) => issue.severity === "warning").length, - info: issues.filter((issue) => issue.severity === "info").length, - }; -} diff --git a/packages/ghost/src/ghost-core/checks/routing.ts b/packages/ghost/src/ghost-core/checks/routing.ts deleted file mode 100644 index 9e7d7fdb..00000000 --- a/packages/ghost/src/ghost-core/checks/routing.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { GhostCheck, RoutedGhostValidateCheck } from "./types.js"; - -export function normalizeGhostPath(path: string): string { - return path.replaceAll("\\", "/").replace(/^\.\//, ""); -} - -export function matchesGhostPath(path: string, scopePath: string): boolean { - const changedPath = normalizeGhostPath(path); - const pattern = normalizeGhostPath(scopePath); - if (pattern.includes("*")) { - return globToRegExp(pattern).test(changedPath); - } - - const normalized = pattern.replace(/\/$/, ""); - return changedPath === normalized || changedPath.startsWith(`${normalized}/`); -} - -/** - * Route active checks to a changed path by `applies_to.paths` alone. - * - * Phase 4: the map scope layer is gone. Surface-based routing is rebuilt in - * Phase 7; until then a check applies if it declares no paths (global) or one - * of its path globs matches the changed file. - */ -export function routeGhostValidateForPath( - checks: GhostCheck[], - changedPath: string, -): RoutedGhostValidateCheck[] { - return checks - .filter((check) => check.status === "active") - .flatMap((check) => { - const applies = check.applies_to; - const pathMatched = - !applies?.paths?.length || - applies.paths.some((pattern) => matchesGhostPath(changedPath, pattern)); - return pathMatched ? [{ check }] : []; - }); -} - -function globToRegExp(glob: string): RegExp { - let out = "^"; - for (let i = 0; i < glob.length; i++) { - const char = glob[i]; - const next = glob[i + 1]; - if (char === "*" && next === "*") { - out += ".*"; - i += 1; - } else if (char === "*") { - out += "[^/]*"; - } else { - out += escapeRegExp(char); - } - } - out += "$"; - return new RegExp(out); -} - -function escapeRegExp(value: string): string { - return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); -} diff --git a/packages/ghost/src/ghost-core/checks/schema.ts b/packages/ghost/src/ghost-core/checks/schema.ts deleted file mode 100644 index 2d0dc5f8..00000000 --- a/packages/ghost/src/ghost-core/checks/schema.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { z } from "zod"; -import { GHOST_VALIDATE_SCHEMA } from "./types.js"; - -const GhostCheckStatusSchema = z.enum(["active", "proposed", "disabled"]); -const GhostCheckSeveritySchema = z.enum(["critical", "serious", "nit"]); - -const GhostCheckDerivationIntentRefSchema = z - .string() - .min(1) - .regex( - /^(intent\.principle|intent\.situation|intent\.experience_contract):[a-z0-9][a-z0-9._-]*$/, - { - message: - "intent derivation refs must use intent.:slug, e.g. intent.principle:dense-workflows", - }, - ); - -const GhostCheckDerivationInventoryRefSchema = z - .string() - .min(1) - .regex(/^inventory\.exemplar:[a-z0-9][a-z0-9._-]*$/, { - message: "inventory derivation refs must use inventory.exemplar:slug", - }); - -const GhostCheckDerivationCompositionRefSchema = z - .string() - .min(1) - .regex(/^composition\.pattern:[a-z0-9][a-z0-9._-]*$/, { - message: "composition derivation refs must use composition.pattern:slug", - }); - -export const GhostCheckDerivationSchema = z - .object({ - intent: z.array(GhostCheckDerivationIntentRefSchema).optional(), - inventory: z.array(GhostCheckDerivationInventoryRefSchema).optional(), - composition: z.array(GhostCheckDerivationCompositionRefSchema).optional(), - }) - .strict(); - -const GhostCheckAppliesToSchema = z - .object({ - scopes: z.array(z.string().min(1)).optional(), - paths: z.array(z.string().min(1)).optional(), - surface_types: z.array(z.string().min(1)).optional(), - pattern_ids: z.array(z.string().min(1)).optional(), - }) - .strict(); - -const GhostCheckDetectorSchema = z - .object({ - type: z.enum([ - "forbidden-regex", - "required-regex", - "banned-import", - "banned-component", - "required-token", - ]), - pattern: z.string().min(1).optional(), - value: z.string().min(1).optional(), - contexts: z.array(z.string().min(1)).optional(), - }) - .strict(); - -const GhostCheckEvidenceExampleSchema = z.union([ - z.string().min(1), - z - .object({ - path: z.string().min(1), - note: z.string().min(1).optional(), - }) - .strict(), -]); - -const GhostCheckEvidenceSchema = z - .object({ - support: z.number().min(0).max(1).optional(), - observed_count: z.number().int().nonnegative().optional(), - examples: z.array(GhostCheckEvidenceExampleSchema).optional(), - }) - .strict(); - -export const GhostCheckSchema = z - .object({ - id: z - .string() - .min(1) - .regex(/^[a-z0-9][a-z0-9._-]*$/, { - message: - "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", - }), - title: z.string().min(1), - status: GhostCheckStatusSchema, - severity: GhostCheckSeveritySchema, - derivation: GhostCheckDerivationSchema.optional(), - applies_to: GhostCheckAppliesToSchema.optional(), - detector: GhostCheckDetectorSchema, - evidence: GhostCheckEvidenceSchema.optional(), - repair: z.string().min(1).optional(), - }) - .strict(); - -export const GhostValidateSchema = z - .object({ - schema: z.literal(GHOST_VALIDATE_SCHEMA), - id: z - .string() - .min(1) - .regex(/^[a-z0-9][a-z0-9._-]*$/, { - message: - "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", - }), - checks: z.array(GhostCheckSchema), - }) - .strict(); diff --git a/packages/ghost/src/ghost-core/checks/types.ts b/packages/ghost/src/ghost-core/checks/types.ts deleted file mode 100644 index a952d5c5..00000000 --- a/packages/ghost/src/ghost-core/checks/types.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { - GhostFingerprintDocument, - GhostFingerprintRef, -} from "../fingerprint/index.js"; - -export const GHOST_VALIDATE_SCHEMA = "ghost.validate/v1" as const; -export const GHOST_VALIDATE_FILENAME = "validate.yml" as const; - -export type GhostCheckStatus = "active" | "proposed" | "disabled"; -export type GhostCheckSeverity = "critical" | "serious" | "nit"; -export type GhostCheckDerivationIntentRef = Extract< - GhostFingerprintRef, - | `intent.principle:${string}` - | `intent.situation:${string}` - | `intent.experience_contract:${string}` ->; -export type GhostCheckDerivationInventoryRef = Extract< - GhostFingerprintRef, - `inventory.exemplar:${string}` ->; -export type GhostCheckDerivationCompositionRef = Extract< - GhostFingerprintRef, - `composition.pattern:${string}` ->; - -export interface GhostCheckDerivation { - intent?: GhostCheckDerivationIntentRef[]; - inventory?: GhostCheckDerivationInventoryRef[]; - composition?: GhostCheckDerivationCompositionRef[]; -} - -export type GhostCheckDetectorType = - | "forbidden-regex" - | "required-regex" - | "banned-import" - | "banned-component" - | "required-token"; - -export interface GhostCheckAppliesTo { - scopes?: string[]; - paths?: string[]; - surface_types?: string[]; - pattern_ids?: string[]; -} - -export interface GhostCheckDetector { - type: GhostCheckDetectorType; - pattern?: string; - value?: string; - contexts?: string[]; -} - -export interface GhostCheckEvidence { - support?: number; - observed_count?: number; - examples?: Array; -} - -export interface GhostCheck { - id: string; - title: string; - status: GhostCheckStatus; - severity: GhostCheckSeverity; - derivation?: GhostCheckDerivation; - applies_to?: GhostCheckAppliesTo; - detector: GhostCheckDetector; - evidence?: GhostCheckEvidence; - repair?: string; -} - -export interface GhostValidateDocument { - schema: typeof GHOST_VALIDATE_SCHEMA; - id: string; - checks: GhostCheck[]; -} - -export type GhostValidateLintSeverity = "error" | "warning" | "info"; - -export interface GhostValidateLintIssue { - severity: GhostValidateLintSeverity; - rule: string; - message: string; - path?: string; -} - -export interface GhostValidateLintReport { - issues: GhostValidateLintIssue[]; - errors: number; - warnings: number; - info: number; -} - -export interface GhostValidateLintOptions { - fingerprint?: GhostFingerprintDocument; -} - -export interface RoutedGhostValidateCheck { - check: GhostCheck; -} diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index ecbc1646..d1593541 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -3,6 +3,8 @@ // --- Binding (ghost.binding/v1) --- export { type BindingCandidate, + type ContractReferenceKind, + classifyContractReference, GHOST_BINDING_FILENAME, GHOST_BINDING_SCHEMA, type GhostBindingDocument, @@ -11,6 +13,7 @@ export { type GhostBindingLintReport, type GhostBindingLintSeverity, GhostBindingSchema, + IN_REPO_CONTRACT, lintGhostBinding, type PathResolution, type PathResolutionReason, @@ -34,37 +37,6 @@ export { type RoutedCheck, selectChecksForSurfaces, } from "./check/index.js"; -export type { - GhostCheck, - GhostCheckAppliesTo, - GhostCheckDerivation, - GhostCheckDerivationCompositionRef, - GhostCheckDerivationIntentRef, - GhostCheckDerivationInventoryRef, - GhostCheckDetector, - GhostCheckDetectorType, - GhostCheckEvidence, - GhostCheckSeverity, - GhostCheckStatus, - GhostValidateDocument, - GhostValidateLintIssue, - GhostValidateLintOptions, - GhostValidateLintReport, - GhostValidateLintSeverity, - RoutedGhostValidateCheck, -} from "./checks/index.js"; -// --- Checks (ghost.validate/v1) --- -export { - GHOST_VALIDATE_FILENAME, - GHOST_VALIDATE_SCHEMA, - GhostCheckDerivationSchema, - GhostCheckSchema, - GhostValidateSchema, - lintGhostValidate, - matchesGhostPath, - normalizeGhostPath, - routeGhostValidateForPath, -} from "./checks/index.js"; // --- Decision vocabulary (controlled list for fleet aggregation) --- export { CANONICAL_DECISION_DIMENSIONS, diff --git a/packages/ghost/src/govern.ts b/packages/ghost/src/govern.ts deleted file mode 100644 index 82da9822..00000000 --- a/packages/ghost/src/govern.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type { - GhostDriftChangedFile as GhostCheckChangedFile, - GhostDriftChangedLine as GhostCheckChangedLine, - GhostDriftCheckFinding as GhostCheckFinding, - GhostDriftCheckOptions as GhostCheckOptions, - GhostDriftCheckReport as GhostCheckReport, - GhostDriftCheckStack as GhostCheckStack, - GhostDriftRoutedFile as GhostCheckRoutedFile, -} from "./core/index.js"; -export * from "./core/index.js"; -export { - formatGhostDriftCheckMarkdown as formatGhostCheckMarkdown, - runGhostDriftCheck as runGhostCheck, -} from "./core/index.js"; diff --git a/packages/ghost/src/index.ts b/packages/ghost/src/index.ts index ce7ed477..5959b6a7 100644 --- a/packages/ghost/src/index.ts +++ b/packages/ghost/src/index.ts @@ -1,13 +1,12 @@ import * as compareApi from "./compare.js"; import { compare as compareFunction } from "./core/index.js"; -/** @deprecated Use `govern`, `compare`, `@anarchitecture/ghost/govern`, or `@anarchitecture/ghost/compare`. */ +/** @deprecated Use `compare` or `@anarchitecture/ghost/compare`. */ export * as drift from "./core/index.js"; export * from "./core/index.js"; export const compare = Object.assign(compareFunction, compareApi); export * as driftCommand from "./drift-command.js"; export * as fingerprint from "./fingerprint.js"; export * as core from "./ghost-core/index.js"; -export * as govern from "./govern.js"; /** @deprecated Use `fingerprint` or `@anarchitecture/ghost/fingerprint`. */ export * as scan from "./scan/index.js"; diff --git a/packages/ghost/src/init-command.ts b/packages/ghost/src/init-command.ts index f488385a..95c1ae99 100644 --- a/packages/ghost/src/init-command.ts +++ b/packages/ghost/src/init-command.ts @@ -112,7 +112,6 @@ export function registerInitCommand(cli: CAC): void { process.stdout.write(` intent.yml: ${paths.intent}\n`); process.stdout.write(` inventory.yml: ${paths.inventory}\n`); process.stdout.write(` composition.yml: ${paths.composition}\n`); - process.stdout.write(` validate.yml: ${paths.checks}\n`); } process.exit(0); } catch (err) { @@ -137,6 +136,5 @@ function initCommandOutput( intent: paths.intent, inventory: paths.inventory, composition: paths.composition, - checks: paths.checks, }; } diff --git a/packages/ghost/src/monorepo-init-command.ts b/packages/ghost/src/monorepo-init-command.ts index eb743c0f..c32f0dc9 100644 --- a/packages/ghost/src/monorepo-init-command.ts +++ b/packages/ghost/src/monorepo-init-command.ts @@ -209,6 +209,5 @@ function initPackageOutput( intent: paths.intent, inventory: paths.inventory, composition: paths.composition, - checks: paths.checks, }; } diff --git a/packages/ghost/src/review-packet.ts b/packages/ghost/src/review-packet.ts index 35f33a3b..ba27690b 100644 --- a/packages/ghost/src/review-packet.ts +++ b/packages/ghost/src/review-packet.ts @@ -1,106 +1,66 @@ -import { stringify as stringifyYaml } from "yaml"; -import { buildContextEntrypoint } from "./context/entrypoint.js"; import { - loadPackageContext, - type PackageContext, -} from "./context/package-context.js"; + groundSurface, + type RoutedCheck, + resolvePathToSurface, + type SurfaceGrounding, + selectChecksForSurfaces, +} from "#ghost-core"; +import { discoverBindingsForPath } from "./scan/binding-discovery.js"; +import { loadChecksDir } from "./scan/checks-dir.js"; import { - buildSelectedContext, - formatSelectedContextMarkdown, -} from "./context/selected-context.js"; -import { parseUnifiedDiff } from "./core/index.js"; -import { resolveFingerprintPackage } from "./scan/fingerprint-package.js"; -import { - fingerprintStackToPackageContext, - type GhostFingerprintStack, - groupFingerprintStacksForPaths, - resolveGhostDirDefault, -} from "./scan/fingerprint-stack.js"; + loadFingerprintPackage, + resolveFingerprintPackage, +} from "./scan/fingerprint-package.js"; +import { parseUnifiedDiff } from "./scan/unified-diff.js"; const DEFAULT_REVIEW_MAX_DIFF_BYTES = 200_000; +/** + * Build an advisory review packet on the surface rails: resolve the diff's + * changed paths to the surfaces that own them (bindings), select the markdown + * checks governing those surfaces and their ancestors, and ground each in the + * surface's fingerprint slice. No `validate.yml`, no dormant context entrypoint. + */ export async function buildReviewPacket(options: { packageDir?: string; - ghostDir?: string; diffText: string; maxDiffBytes?: number; }): Promise { - return options.packageDir - ? buildSinglePackageReviewPacket(options) - : buildStackReviewPacket(options); -} + const cwd = process.cwd(); + const paths = resolveFingerprintPackage(options.packageDir, cwd); + const loaded = await loadFingerprintPackage(paths); + const { checks, invalid } = await loadChecksDir(paths.dir); -async function buildSinglePackageReviewPacket(options: { - packageDir?: string; - diffText: string; - maxDiffBytes?: number; -}): Promise { - const paths = resolveFingerprintPackage(options.packageDir, process.cwd()); - const changedFiles = parseUnifiedDiff(options.diffText).map( + const changedPaths = parseUnifiedDiff(options.diffText).map( (file) => file.path, ); - const context = await loadPackageContext(paths); - context.targetPaths = changedFiles; - const packet: ReviewPacket = { + + // Resolve each changed path to its surface via bindings; union them. + const touched = new Set(); + for (const path of changedPaths) { + const discovered = await discoverBindingsForPath(path, cwd); + const resolution = resolvePathToSurface( + discovered.target_path, + discovered.candidates, + { hasRootContract: discovered.hasRootContract || !!loaded.surfaces }, + ); + if (resolution.surface) touched.add(resolution.surface); + } + + const routed = selectChecksForSurfaces(checks, loaded.surfaces, [...touched]); + const grounding = [...touched].map((surface) => + groundSurface(loaded.surfaces, loaded.fingerprint, surface), + ); + + return { ...baseReviewPacket(paths.dir, options.diffText, { maxDiffBytes: options.maxDiffBytes, }), - fingerprint: context.fingerprint, - context_markdown: formatReviewContextMarkdown([ - { - title: paths.dir, - markdown: formatReviewSelectedContextMarkdown(context, changedFiles), - }, - ]), - checks: context.checksRaw ?? null, - }; - return packet; -} - -async function buildStackReviewPacket(options: { - ghostDir?: string; - diffText: string; - maxDiffBytes?: number; -}): Promise { - const changedFiles = parseUnifiedDiff(options.diffText).map( - (file) => file.path, - ); - const groups = await groupFingerprintStacksForPaths( - changedFiles, - process.cwd(), - { ghostDir: resolveGhostDirDefault(options.ghostDir) }, - ); - const stacks = groups.map((group) => - reviewStackFromFingerprintStack(group.stack, group.changed_files), - ); - const contextSections = groups.map((group) => { - const context = fingerprintStackToPackageContext( - group.stack, - undefined, - group.changed_files, - ); - return { - title: group.stack.layers.at(-1)?.dir ?? group.stack.ghost_dir, - markdown: formatReviewSelectedContextMarkdown( - context, - group.changed_files, - groups.length > 1 ? "#### Selected Context" : "### Selected Context", - ), - }; - }); - const first = stacks[0]; - const packet: ReviewPacket = { - ...baseReviewPacket( - stacks.length === 1 ? first.package_dir : "fingerprint-stack/multiple", - options.diffText, - { maxDiffBytes: options.maxDiffBytes }, - ), - fingerprint: first.contract.fingerprint, - context_markdown: formatReviewContextMarkdown(contextSections), - checks: stringifyYaml(first.contract.checks, { lineWidth: 0 }), - stacks, + touched_surfaces: [...touched], + routed_checks: routed, + grounding, + invalid_checks: invalid, }; - return packet; } function baseReviewPacket( @@ -124,27 +84,14 @@ function baseReviewPacket( ], required_finding_citations: [ "diff location", - "fingerprint facet refs", - "active check when blocking", - "selected-context gap or local-evidence rationale when context is silent", + "surface the change touches", + "routed check when blocking", + "grounding ref (why / what) or local-evidence rationale when the surface is silent", "repair or intentional-divergence rationale", ], }; } -function formatReviewSelectedContextMarkdown( - context: PackageContext, - targetPaths: string[], - heading = "### Selected Context", -): string { - const entrypoint = buildContextEntrypoint(context, { targetPaths }); - const selectedContext = buildSelectedContext(context, entrypoint); - return formatSelectedContextMarkdown(selectedContext, { - heading, - includeIntro: false, - }); -} - function budgetDiff( diffText: string, maxDiffBytes = DEFAULT_REVIEW_MAX_DIFF_BYTES, @@ -201,27 +148,6 @@ function endsWithHighSurrogate(value: string): boolean { return code >= 0xd800 && code <= 0xdbff; } -function reviewStackFromFingerprintStack( - stack: GhostFingerprintStack, - changedFiles: string[], -): ReviewStackPacket { - const leaf = stack.layers.at(-1); - return { - target_path: stack.target_path, - package_dir: leaf?.dir ?? stack.layers[0].dir, - ghost_dir: stack.ghost_dir, - changed_files: changedFiles, - stack_dirs: stack.layers.map((layer) => layer.dir), - contract: { - fingerprint: stack.contract.fingerprint, - checks: stack.contract.checks, - }, - provenance: { - stack: stack.provenance.layers, - }, - }; -} - interface ReviewPacketBudgets { diff_bytes: number; max_diff_bytes: number; @@ -238,33 +164,11 @@ interface ReviewPacketBase { required_finding_citations: string[]; } -interface ReviewPacket { - schema: "ghost.advisory-review/v1"; - package_dir: string; - fingerprint: unknown; - context_markdown: string; - checks: string | null; - stacks?: ReviewStackPacket[]; - diff: string; - budgets: ReviewPacketBudgets; - truncated: boolean; - finding_categories: string[]; - required_finding_citations: string[]; -} - -interface ReviewStackPacket { - target_path: string; - package_dir: string; - ghost_dir: string; - changed_files: string[]; - stack_dirs: string[]; - contract: { - fingerprint: unknown; - checks: unknown; - }; - provenance: { - stack: GhostFingerprintStack["provenance"]["layers"]; - }; +interface ReviewPacket extends ReviewPacketBase { + touched_surfaces: string[]; + routed_checks: RoutedCheck[]; + grounding: SurfaceGrounding[]; + invalid_checks: Array<{ file: string; message: string }>; } export function formatReviewPacketMarkdown(packet: ReviewPacket): string { @@ -272,21 +176,23 @@ export function formatReviewPacketMarkdown(packet: ReviewPacket): string { Package: ${packet.package_dir} -Review this diff as a non-blocking design-language critic. Advisory findings must be evidence-routed and must cite: ${packet.required_finding_citations.join(", ")}. Do not fail the build unless the issue is tied to an active deterministic check in validate.yml. Keep findings grounded in intent.yml, inventory.yml, composition.yml, active deterministic checks, and diff evidence; do not expand the review into unrelated audit categories. +Review this diff as a non-blocking design-language critic. Advisory findings must be evidence-routed and must cite: ${packet.required_finding_citations.join(", ")}. Do not fail the build unless the issue is tied to a routed check. Keep findings grounded in the touched surfaces' principles, contracts, patterns, exemplars, and routed checks; do not expand the review into unrelated audit categories. -Use the selected context first: intent → composition → inventory → validation. When selected context exposes gaps, label the reasoning provisional or report missing-fingerprint / experience-gap instead of pretending the fingerprint is more specific than it is. +Use the surface grounding first: why (principles, contracts) → what good looks like (patterns, exemplars). When a surface's grounding is silent, label the reasoning provisional or report missing-fingerprint / experience-gap instead of pretending the fingerprint is more specific than it is. -Use these finding categories: ${packet.finding_categories.join(", ")}. +Use these finding categories: ${packet.finding_categories.join(", ")}. ${formatReviewBudgetSection(packet)} -When fingerprint facets are silent, local evidence can still support advisory critique. Label those findings as provisional and non-Ghost-backed, and ground them in nearby product surfaces, local components, token or copy conventions. Ask the human before assessing high-risk, irreversible, privacy/security/legal, or product-surface-defining choices. +When a surface's grounding is silent, local evidence can still support advisory critique. Label those findings as provisional and non-Ghost-backed, and ground them in nearby product surfaces, local components, token or copy conventions. Ask the human before assessing high-risk, irreversible, privacy/security/legal, or product-surface-defining choices. -If the diff exposes missing fingerprint grounding or facet coverage, report it as missing-fingerprint or experience-gap. Do not silently rewrite the Ghost package during review; fingerprint and check edits are ordinary Git-reviewed edits. +If the diff exposes missing fingerprint grounding or surface coverage, report it as missing-fingerprint or experience-gap. Do not silently rewrite the Ghost package during review; fingerprint and check edits are ordinary Git-reviewed edits. -${formatReviewStacksSection(packet.stacks ?? null)} +${formatTouchedSurfacesSection(packet)} -${packet.context_markdown} +${formatRoutedChecksSection(packet)} + +${formatGroundingSection(packet)} ## Diff @@ -313,30 +219,62 @@ function formatReviewBudgetSection(packet: ReviewPacket): string { return lines.join("\n"); } -function formatReviewStacksSection(stacks: ReviewStackPacket[] | null): string { - if (!stacks?.length) return ""; +function formatTouchedSurfacesSection(packet: ReviewPacket): string { + const surfaces = packet.touched_surfaces.length + ? packet.touched_surfaces.map((s) => `\`${s}\``).join(", ") + : "none (core only)"; + return `## Touched Surfaces\n\n${surfaces}`; +} - const lines = ["## Resolved Fingerprint Stacks", ""]; - for (const [index, stack] of stacks.entries()) { - lines.push(`### Stack ${index + 1}: ${stack.package_dir}`); - lines.push(""); - lines.push(`Changed files: ${stack.changed_files.join(", ") || "none"}`); - lines.push(`Stack: ${stack.stack_dirs.join(" -> ")}`); - lines.push(""); +function formatRoutedChecksSection(packet: ReviewPacket): string { + const lines = ["## Routed Checks", ""]; + if (packet.routed_checks.length === 0) { + lines.push("No checks govern the touched surfaces."); + } else { + for (const { check, relevance } of packet.routed_checks) { + const why = + relevance.kind === "own" + ? `own \`${relevance.surface}\`` + : `inherited from \`${relevance.surface}\` (via \`${relevance.via}\`)`; + lines.push( + `- **${check.frontmatter.name}** (${check.frontmatter.severity}) — ${why}`, + ); + } } - - return `${lines.join("\n")}\n`; + if (packet.invalid_checks.length > 0) { + lines.push("", "Skipped (invalid):"); + for (const { file, message } of packet.invalid_checks) { + lines.push(`- \`${file}\`: ${message}`); + } + } + return lines.join("\n"); } -function formatReviewContextMarkdown( - sections: Array<{ title: string; markdown: string }>, -): string { - const lines = ["## Selected Context", ""]; - for (const [index, section] of sections.entries()) { - if (sections.length > 1) { - lines.push(`### Context ${index + 1}: ${section.title}`, ""); +function formatGroundingSection(packet: ReviewPacket): string { + const lines = ["## Grounding", ""]; + if ( + packet.grounding.every((g) => g.why.length === 0 && g.what.length === 0) + ) { + lines.push("No fingerprint grounding for the touched surfaces."); + return lines.join("\n"); + } + for (const surface of packet.grounding) { + if (surface.why.length === 0 && surface.what.length === 0) continue; + lines.push(`### \`${surface.surface}\``); + if (surface.why.length > 0) { + lines.push("", "Why:"); + for (const item of surface.why) { + lines.push(`- ${item.statement} (\`${item.ref}\`)`); + } + } + if (surface.what.length > 0) { + lines.push("", "What good looks like:"); + for (const item of surface.what) { + const where = item.path ? ` — \`${item.path}\`` : ""; + lines.push(`- ${item.statement}${where} (\`${item.ref}\`)`); + } } - lines.push(section.markdown); + lines.push(""); } - return lines.join("\n").trim(); + return lines.join("\n").trimEnd(); } diff --git a/packages/ghost/src/scan/contract-resolver.ts b/packages/ghost/src/scan/contract-resolver.ts new file mode 100644 index 00000000..4ad9136e --- /dev/null +++ b/packages/ghost/src/scan/contract-resolver.ts @@ -0,0 +1,61 @@ +import { access } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { classifyContractReference } from "#ghost-core"; +import { FINGERPRINT_PACKAGE_DIR } from "./constants.js"; + +export interface ResolveContractOptions { + /** The package dir name to look for inside a resolved contract (default `.ghost`). */ + ghostDir?: string; +} + +/** + * Resolve a binding `contract:` reference to the contract's `.ghost/` directory. + * + * - `.` → the in-repo root contract at `/`. + * - npm name → the nearest `node_modules//` walking up from + * `fromDir` to `repoRoot`. + * + * Filesystem-only: installing the package is the host's job. Returns `null` when + * the contract cannot be resolved or the reference kind is unsupported. + */ +export async function resolveContractDir( + reference: string, + fromDir: string, + repoRoot: string, + options: ResolveContractOptions = {}, +): Promise { + const ghostDir = options.ghostDir ?? FINGERPRINT_PACKAGE_DIR; + const kind = classifyContractReference(reference); + + if (kind === "in-repo") { + const dir = resolve(repoRoot, ghostDir); + return (await exists(dir)) ? dir : null; + } + + if (kind === "npm") { + let current = isAbsolute(fromDir) ? fromDir : resolve(repoRoot, fromDir); + while (isWithinOrEqual(repoRoot, current)) { + const candidate = join(current, "node_modules", reference, ghostDir); + if (await exists(candidate)) return candidate; + if (current === repoRoot) break; + current = dirname(current); + } + return null; + } + + return null; +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function isWithinOrEqual(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} diff --git a/packages/ghost/src/scan/file-kind.ts b/packages/ghost/src/scan/file-kind.ts index 17b81bae..0dda7a49 100644 --- a/packages/ghost/src/scan/file-kind.ts +++ b/packages/ghost/src/scan/file-kind.ts @@ -11,7 +11,6 @@ import { lintGhostPatterns, lintGhostResources, lintGhostSurfaces, - lintGhostValidate, lintSurvey, type SurveyLintReport, } from "#ghost-core"; @@ -25,7 +24,6 @@ export type DetectedFileKind = | "fingerprint-intent" | "fingerprint-inventory" | "fingerprint-composition" - | "validate" | "resources" | "patterns" | "surfaces" @@ -77,9 +75,6 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { if (filename === "composition.yaml") { return "fingerprint-composition"; } - if (filename === "validate.yml" || filename === "validate.yaml") { - return "validate"; - } if (filename === "resources.yml") return "resources"; if (filename === "resources.yaml") return "resources"; if (filename === "patterns.yml") return "patterns"; @@ -105,7 +100,6 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { if (/^\s*schema:\s*ghost\.patterns\/v1\b/m.test(raw)) return "patterns"; if (/^\s*schema:\s*ghost\.surfaces\/v1\b/m.test(raw)) return "surfaces"; if (/^\s*schema:\s*ghost\.binding\/v1\b/m.test(raw)) return "binding"; - if (/^\s*schema:\s*ghost\.validate\/v[12]\b/m.test(raw)) return "validate"; if (lowerPath.endsWith(".yml") || lowerPath.endsWith(".yaml")) { return "unsupported-yaml"; } @@ -115,7 +109,7 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { export function lintDetectedFileKind( kind: DetectedFileKind, raw: string, - options: LintDetectedFileKindOptions = {}, + _options: LintDetectedFileKindOptions = {}, ): ReturnType { return kind === "survey" ? lintSurveyFile(raw) @@ -139,11 +133,9 @@ export function lintDetectedFileKind( ? lintBindingFile(raw) : kind === "check" ? lintGhostCheck(raw) - : kind === "validate" - ? lintValidateFile(raw, options.fingerprint) - : kind === "unsupported-yaml" - ? lintUnsupportedYamlFile() - : lintFingerprint(raw); + : kind === "unsupported-yaml" + ? lintUnsupportedYamlFile() + : lintFingerprint(raw); } function lintSurveyFile(raw: string): SurveyLintReport { @@ -167,20 +159,6 @@ function lintSurveyFile(raw: string): SurveyLintReport { return lintSurvey(json); } -function lintValidateFile( - raw: string, - fingerprint?: GhostFingerprintDocument, -): ReturnType { - try { - return lintGhostValidate( - parseYaml(raw), - fingerprint ? { fingerprint } : {}, - ); - } catch (err) { - return yamlErrorReport("validate-not-yaml", "validate file", err); - } -} - function lintFingerprintYmlFile( raw: string, ): ReturnType { @@ -290,7 +268,7 @@ function lintUnsupportedYamlFile(): ReturnType { severity: "error", rule: "unsupported-yaml", message: - "YAML file is not a recognized Ghost artifact. Use manifest.yml, intent.yml, inventory.yml, composition.yml, validate.yml, resources.yml, patterns.yml, fingerprint.yml, or include a supported ghost.* schema.", + "YAML file is not a recognized Ghost artifact. Use manifest.yml, intent.yml, inventory.yml, composition.yml, resources.yml, patterns.yml, fingerprint.yml, or include a supported ghost.* schema.", }, ], errors: 1, diff --git a/packages/ghost/src/scan/fingerprint-contribution.ts b/packages/ghost/src/scan/fingerprint-contribution.ts index 4f5fe549..da38b74a 100644 --- a/packages/ghost/src/scan/fingerprint-contribution.ts +++ b/packages/ghost/src/scan/fingerprint-contribution.ts @@ -1,7 +1,4 @@ -import type { - GhostFingerprintDocument, - GhostValidateDocument, -} from "#ghost-core"; +import type { GhostFingerprintDocument } from "#ghost-core"; export type ScanContributionState = | "missing" @@ -9,7 +6,7 @@ export type ScanContributionState = | "empty" | "contributing"; -export type ScanFacet = "intent" | "inventory" | "composition" | "validate"; +export type ScanFacet = "intent" | "inventory" | "composition"; export type ScanFacetState = "absent" | "empty" | "useful"; export interface ScanFacetFileState { @@ -36,12 +33,6 @@ export interface ScanBuildingBlockRows { notes: number; } -export interface ScanValidateCounts { - active: number; - proposed: number; - disabled: number; -} - export interface ScanContributionReport { state: ScanContributionState; facets: Record; @@ -52,14 +43,12 @@ export interface ScanContributionReport { product_surface_count: number; demo_surface_count: number; building_block_rows: ScanBuildingBlockRows; - validate_counts: ScanValidateCounts; } -const FACETS: ScanFacet[] = ["intent", "inventory", "composition", "validate"]; +const FACETS: ScanFacet[] = ["intent", "inventory", "composition"]; export function summarizeFingerprintContribution(input: { fingerprint?: GhostFingerprintDocument; - validate?: GhostValidateDocument; files: Record; missing?: boolean; invalidReason?: string; @@ -69,9 +58,7 @@ export function summarizeFingerprintContribution(input: { intent: countIntent(input.fingerprint), inventory: countInventory(input.fingerprint, buildingBlockRows), composition: countComposition(input.fingerprint), - validate: countValidate(input.validate), }; - const validateCounts = countValidateStatuses(input.validate); const facets = Object.fromEntries( FACETS.map((facet) => [ facet, @@ -109,7 +96,6 @@ export function summarizeFingerprintContribution(input: { product_surface_count: input.fingerprint?.inventory.exemplars.length ?? 0, demo_surface_count: 0, building_block_rows: buildingBlockRows, - validate_counts: validateCounts, }; } @@ -228,20 +214,6 @@ function countComposition( return fingerprint?.composition.patterns.length ?? 0; } -function countValidate(validate: GhostValidateDocument | undefined): number { - return validate?.checks.length ?? 0; -} - -function countValidateStatuses( - validate: GhostValidateDocument | undefined, -): ScanValidateCounts { - const counts: ScanValidateCounts = { active: 0, proposed: 0, disabled: 0 }; - for (const check of validate?.checks ?? []) { - counts[check.status] += 1; - } - return counts; -} - function countBuildingBlocks( fingerprint: GhostFingerprintDocument | undefined, ): ScanBuildingBlockRows { diff --git a/packages/ghost/src/scan/fingerprint-package-layers.ts b/packages/ghost/src/scan/fingerprint-package-layers.ts index ee52223b..700bc72c 100644 --- a/packages/ghost/src/scan/fingerprint-package-layers.ts +++ b/packages/ghost/src/scan/fingerprint-package-layers.ts @@ -4,7 +4,6 @@ import type { ZodIssue, ZodType } from "zod"; import { GHOST_FINGERPRINT_PACKAGE_SCHEMA, GHOST_FINGERPRINT_SCHEMA, - GHOST_VALIDATE_SCHEMA, GhostFingerprintCompositionSchema, type GhostFingerprintDocument, GhostFingerprintIntentSchema, @@ -193,13 +192,6 @@ export function templateComposition(): string { `; } -export function templateChecks(): string { - return `schema: ${GHOST_VALIDATE_SCHEMA} -id: local -checks: [] -`; -} - const readOptional = readOptionalUtf8; function parseManifest( diff --git a/packages/ghost/src/scan/fingerprint-package.ts b/packages/ghost/src/scan/fingerprint-package.ts index 7e0a0412..5c491080 100644 --- a/packages/ghost/src/scan/fingerprint-package.ts +++ b/packages/ghost/src/scan/fingerprint-package.ts @@ -3,11 +3,9 @@ import { join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import { GHOST_SURFACES_YML_FILENAME, - GHOST_VALIDATE_FILENAME, type GhostFingerprintDocument, type GhostFingerprintPackageManifest, type GhostSurfacesDocument, - lintGhostValidate, SURVEY_FILENAME, } from "#ghost-core"; import { @@ -29,7 +27,6 @@ import { import { lintFingerprintPackageManifest, parseSplitFingerprintForLint, - templateChecks, templateComposition, templateIntent, templateInventory, @@ -53,7 +50,6 @@ export interface FingerprintPackagePaths { patterns: string; /** Legacy direct markdown path; not part of the canonical root bundle. */ fingerprint: string; - checks: string; } export interface LoadedFingerprintPackage { @@ -93,7 +89,6 @@ export function resolveFingerprintPackage( survey: join(dir, SURVEY_FILENAME), patterns: join(dir, PATTERNS_FILENAME), fingerprint: join(dir, FINGERPRINT_FILENAME), - checks: join(packageDir, GHOST_VALIDATE_FILENAME), }; } @@ -109,7 +104,6 @@ export async function initFingerprintPackage( { path: paths.intent, content: templateIntent() }, { path: paths.inventory, content: templateInventory(options.reference) }, { path: paths.composition, content: templateComposition() }, - { path: paths.checks, content: templateChecks() }, ]; if (!options.force) { await assertInitDoesNotOverwrite(files.map((file) => file.path)); @@ -174,25 +168,16 @@ export async function lintFingerprintPackage( const intentRaw = await readOptional(paths.intent); const inventoryRaw = await readOptional(paths.inventory); const compositionRaw = await readOptional(paths.composition); - const checksRaw = await readOptional(paths.checks); - let fingerprint: GhostFingerprintDocument | undefined; + let _fingerprint: GhostFingerprintDocument | undefined; if (manifestRaw !== undefined) { lintFingerprintPackageManifest(manifestRaw, issues); - fingerprint = parseSplitFingerprintForLint( + _fingerprint = parseSplitFingerprintForLint( { intentRaw, inventoryRaw, compositionRaw }, issues, ); } - if (checksRaw !== undefined) { - const checks = parseYamlSafe(checksRaw, "validate.yml", issues); - if (checks !== undefined) { - const checksReport = lintGhostValidate(checks, { fingerprint }); - issues.push(...prefixIssues("validate.yml", checksReport.issues)); - } - } - return finalize(issues); } @@ -216,7 +201,7 @@ async function readRequired( const readOptional = readOptionalUtf8; -function parseYamlSafe( +function _parseYamlSafe( raw: string, label: string, issues: LintIssue[], @@ -236,7 +221,7 @@ function parseYamlSafe( } } -function prefixIssues( +function _prefixIssues( label: string, input: Array<{ severity: "error" | "warning" | "info"; diff --git a/packages/ghost/src/scan/fingerprint-stack.ts b/packages/ghost/src/scan/fingerprint-stack.ts index 13502e15..b8015561 100644 --- a/packages/ghost/src/scan/fingerprint-stack.ts +++ b/packages/ghost/src/scan/fingerprint-stack.ts @@ -5,13 +5,9 @@ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { - GHOST_VALIDATE_SCHEMA, type GhostFingerprintDocument, type GhostFingerprintEvidence, - type GhostValidateDocument, - GhostValidateSchema, lintGhostFingerprint, - lintGhostValidate, } from "#ghost-core"; import type { PackageContext } from "../context/package-context.js"; import { readOptionalUtf8 } from "../internal/fs.js"; @@ -61,8 +57,6 @@ export interface GhostFingerprintStackLayer extends GhostFingerprintStackLayerRef { fingerprint: GhostFingerprintDocument; fingerprint_raw: string; - checks?: GhostValidateDocument; - checks_raw?: string; } export interface GhostFingerprintStack { @@ -80,7 +74,6 @@ export interface GhostFingerprintStack { /** Directory of the contract package (the root-most discovered package). */ dir: string; fingerprint: GhostFingerprintDocument; - checks: GhostValidateDocument; }; provenance: { layers: GhostFingerprintStackLayerRef[]; @@ -249,11 +242,6 @@ export function buildFingerprintStack( // model; see docs/ideas/surface-binding.md). const contractLayer = layers[0]; const fingerprint = contractLayer.fingerprint; - const checks = contractLayer.checks ?? { - schema: GHOST_VALIDATE_SCHEMA, - id: "contract", - checks: [], - }; return { target_path: targetPath, @@ -263,7 +251,6 @@ export function buildFingerprintStack( contract: { dir: contractLayer.dir, fingerprint, - checks, }, provenance: { layers: layers.map(layerRef), @@ -279,39 +266,18 @@ export async function loadFingerprintStackLayer( const paths = resolveFingerprintPackage(packageDir, process.cwd()); const normalizedGhostDir = normalizeGhostDir(ghostDir); const root = rootForFingerprintPackageDir(paths.dir, normalizedGhostDir); - const [loaded, checksRaw] = await Promise.all([ - loadFingerprintPackage(paths), - readOptional(paths.checks), - ]); + const loaded = await loadFingerprintPackage(paths); const fingerprint = normalizeFingerprintPaths( loaded.fingerprint, root, repoRoot, ); - const checks = checksRaw - ? normalizeChecksPaths(parseChecks(checksRaw), root, repoRoot) - : undefined; - - if (checks) { - const checksReport = lintGhostValidate(checks); - if (checksReport.errors > 0) { - const first = checksReport.issues.find( - (issue) => issue.severity === "error", - ); - const suffix = first?.path ? ` @ ${first.path}` : ""; - throw new Error( - `${paths.checks} failed checks lint: ${first?.message ?? "invalid checks"}${suffix}`, - ); - } - } return { ...packageRef(paths.dir, repoRoot, normalizedGhostDir), fingerprint, fingerprint_raw: stringifyYaml(fingerprint, { lineWidth: 0 }), - ...(checks ? { checks } : {}), - ...(checksRaw ? { checks_raw: checksRaw } : {}), }; } @@ -333,8 +299,6 @@ export function fingerprintStackToPackageContext( stackDirs: stack.layers.map((layer) => layer.dir), fingerprint: stack.contract.fingerprint, fingerprintRaw: stringifyYaml(stack.contract.fingerprint, { lineWidth: 0 }), - checks: stack.contract.checks, - checksRaw: stringifyYaml(stack.contract.checks, { lineWidth: 0 }), }; } @@ -375,15 +339,6 @@ export async function lintAllFingerprintStacks( fingerprintReport.issues, ), ); - const checksReport = lintGhostValidate(stack.contract.checks, { - fingerprint: stack.contract.fingerprint, - }); - issues.push( - ...prefixIssues( - `${fingerprintPackageDisplayPath(pkg.relative_root, ghostDir)}/contract.validate.yml`, - checksReport.issues, - ), - ); } return finalizeLint(issues); @@ -456,11 +411,6 @@ async function resolveAndInit( return initFingerprintPackage(normalizeGhostDir(ghostDir), root, initOptions); } -function parseChecks(raw: string): GhostValidateDocument { - const parsed = parseYamlSafe(raw, "validate.yml"); - return GhostValidateSchema.parse(parsed) as GhostValidateDocument; -} - function normalizeFingerprintPaths( input: GhostFingerprintDocument, baseRoot: string, @@ -515,39 +465,6 @@ function normalizeFingerprintPaths( return fingerprint; } -function normalizeChecksPaths( - input: GhostValidateDocument, - baseRoot: string, - repoRoot: string, -): GhostValidateDocument { - const checks = clone(input); - checks.checks = checks.checks.map((check) => ({ - ...check, - applies_to: check.applies_to - ? { - ...check.applies_to, - paths: check.applies_to.paths?.map((path) => - normalizePath(path, baseRoot, repoRoot), - ), - } - : undefined, - evidence: check.evidence - ? { - ...check.evidence, - examples: check.evidence.examples?.map((example) => - typeof example === "string" - ? normalizePath(example, baseRoot, repoRoot) - : { - ...example, - path: normalizePath(example.path, baseRoot, repoRoot), - }, - ), - } - : undefined, - })); - return checks; -} - function normalizeFingerprintEvidence( evidence: GhostFingerprintEvidence[] | undefined, baseRoot: string, @@ -699,9 +616,9 @@ function rootForFingerprintPackageDir( return root; } -const readOptional = readOptionalUtf8; +const _readOptional = readOptionalUtf8; -function parseYamlSafe(raw: string, label: string): unknown { +function _parseYamlSafe(raw: string, label: string): unknown { try { return parseYaml(raw); } catch (err) { diff --git a/packages/ghost/src/scan/index.ts b/packages/ghost/src/scan/index.ts index a01f2a59..02271143 100644 --- a/packages/ghost/src/scan/index.ts +++ b/packages/ghost/src/scan/index.ts @@ -9,6 +9,10 @@ export { loadChecksDir, } from "./checks-dir.js"; export { FINGERPRINT_PACKAGE_DIR } from "./constants.js"; +export { + type ResolveContractOptions, + resolveContractDir, +} from "./contract-resolver.js"; export type { ScanBuildingBlockRows, ScanContributionReport, @@ -16,7 +20,6 @@ export type { ScanFacet, ScanFacetReport, ScanFacetState, - ScanValidateCounts, } from "./fingerprint-contribution.js"; export type { DiscoveredGhostPackage, @@ -54,3 +57,8 @@ export type { ScanStatus, } from "./scan-status.js"; export { scanStatus } from "./scan-status.js"; +export { + type ChangedFile, + type ChangedLine, + parseUnifiedDiff, +} from "./unified-diff.js"; diff --git a/packages/ghost/src/scan/scan-status.ts b/packages/ghost/src/scan/scan-status.ts index 4efb5e4c..52df4435 100644 --- a/packages/ghost/src/scan/scan-status.ts +++ b/packages/ghost/src/scan/scan-status.ts @@ -1,7 +1,5 @@ -import { readFile, stat } from "node:fs/promises"; +import { stat } from "node:fs/promises"; import { resolve } from "node:path"; -import { parse as parseYaml } from "yaml"; -import { type GhostValidateDocument, GhostValidateSchema } from "#ghost-core"; import { type ScanContributionReport, summarizeFingerprintContribution, @@ -26,7 +24,6 @@ export interface ScanStatus { /** Absolute path to the Ghost package directory. */ dir: string; fingerprint: ScanStageReport; - validate: ScanStageReport; contribution: ScanContributionReport; recommended_next: ScanStage | null; } @@ -47,35 +44,27 @@ export async function scanStatus(dirPath: string): Promise { intentPresent, inventoryPresent, compositionPresent, - validatePresent, ] = await Promise.all([ pathExists(paths.manifest, "file"), pathExists(paths.intent, "file"), pathExists(paths.inventory, "file"), pathExists(paths.composition, "file"), - pathExists(paths.checks, "file"), ]); const fingerprint: ScanStageReport = { state: fingerprintPresent ? "present" : "missing", path: fingerprintPath, }; - const validate: ScanStageReport = { - state: validatePresent ? "present" : "missing", - path: paths.checks, - }; const contribution = await scanContribution(paths, { fingerprintPresent, intentPresent, inventoryPresent, compositionPresent, - validatePresent, }); const status: ScanStatus = { dir, fingerprint, - validate, contribution, recommended_next: fingerprintPresent ? null : "fingerprint", }; @@ -90,7 +79,6 @@ async function scanContribution( intentPresent: boolean; inventoryPresent: boolean; compositionPresent: boolean; - validatePresent: boolean; }, ): Promise { const files = { @@ -100,7 +88,6 @@ async function scanContribution( path: paths.composition, present: present.compositionPresent, }, - validate: { path: paths.checks, present: present.validatePresent }, } as const; if (!present.fingerprintPresent) { @@ -108,13 +95,9 @@ async function scanContribution( } try { - const [loaded, validate] = await Promise.all([ - loadFingerprintPackage(paths), - readOptionalValidate(paths.checks, present.validatePresent), - ]); + const loaded = await loadFingerprintPackage(paths); return summarizeFingerprintContribution({ fingerprint: loaded.fingerprint, - validate, files, }); } catch (err) { @@ -125,23 +108,6 @@ async function scanContribution( } } -async function readOptionalValidate( - path: string, - present: boolean, -): Promise { - if (!present) return undefined; - const parsed = parseYaml(await readFile(path, "utf-8")); - const result = GhostValidateSchema.safeParse(parsed); - if (!result.success) { - throw new Error( - `validate.yml failed schema validation: ${result.error.issues - .map((issue) => `${issue.path.join(".") || ""}: ${issue.message}`) - .join("; ")}`, - ); - } - return result.data as GhostValidateDocument; -} - async function pathExists( path: string, kind: "file" | "directory" = "file", diff --git a/packages/ghost/src/scan/unified-diff.ts b/packages/ghost/src/scan/unified-diff.ts new file mode 100644 index 00000000..3008e9a1 --- /dev/null +++ b/packages/ghost/src/scan/unified-diff.ts @@ -0,0 +1,64 @@ +/** One added line in a parsed unified diff. */ +export interface ChangedLine { + path: string; + line: number; + text: string; +} + +/** A changed file in a parsed unified diff, with its added lines. */ +export interface ChangedFile { + path: string; + added_lines: ChangedLine[]; +} + +/** + * Parse a unified diff into changed files and their added lines. Generic diff + * parsing — no governance logic. Used by `review` and `checks` to resolve which + * paths a diff touches. + */ +export function parseUnifiedDiff(diffText: string): ChangedFile[] { + const files = new Map(); + let current: ChangedFile | undefined; + let newLine = 0; + + for (const rawLine of diffText.split(/\r?\n/)) { + if (rawLine.startsWith("diff --git ")) { + current = undefined; + continue; + } + + if (rawLine.startsWith("+++ ")) { + const file = rawLine.replace(/^\+\+\+\s+/, ""); + if (file === "/dev/null") { + current = undefined; + continue; + } + const path = file.replace(/^b\//, ""); + current = files.get(path) ?? { path, added_lines: [] }; + files.set(path, current); + continue; + } + + const hunk = rawLine.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); + if (hunk) { + newLine = Number(hunk[1]); + continue; + } + + if (!current) continue; + if (rawLine.startsWith("+")) { + current.added_lines.push({ + path: current.path, + line: newLine, + text: rawLine.slice(1), + }); + newLine += 1; + } else if (rawLine.startsWith("-")) { + // Removed line: does not advance the new-file line counter. + } else { + newLine += 1; + } + } + + return [...files.values()]; +} diff --git a/packages/ghost/src/scan/verify-package.ts b/packages/ghost/src/scan/verify-package.ts index 3ee79757..82aba902 100644 --- a/packages/ghost/src/scan/verify-package.ts +++ b/packages/ghost/src/scan/verify-package.ts @@ -1,19 +1,22 @@ import { access, readFile } from "node:fs/promises"; -import { isAbsolute, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; -import type { - GhostCheck, - GhostFingerprintDocument, - GhostFingerprintEvidence, - GhostValidateDocument, +import { + classifyContractReference, + GHOST_BINDING_FILENAME, + GhostBindingSchema, + type GhostFingerprintDocument, + type GhostFingerprintEvidence, + GhostSurfacesSchema, } from "#ghost-core"; -import { GhostValidateSchema } from "#ghost-core"; +import { resolveContractDir } from "./contract-resolver.js"; import { type LoadedFingerprintPackage, lintFingerprintPackage, loadFingerprintPackage, resolveFingerprintPackage, } from "./fingerprint-package.js"; +import { resolveGitRoot } from "./fingerprint-stack.js"; import type { VerifyFingerprintIssue, VerifyFingerprintReport, @@ -43,23 +46,89 @@ export async function verifyFingerprintPackage( ); if (packageLint.errors > 0) return finalize(issues); - const [loaded, checks] = await Promise.all([ - readFingerprintPackage(paths, issues), - readOptionalChecks(paths.checks, issues), - ]); + const loaded = await readFingerprintPackage(paths, issues); const fingerprint = loaded?.fingerprint; if (fingerprint) { await verifyFingerprintEvidence(fingerprint, root, issues); await verifyFingerprintExemplars(fingerprint, root, issues); } - if (fingerprint && checks) { - verifyFingerprintCheckRefs(fingerprint, checks.checks, issues); - } + // Verify an adjacent .ghost.bind.yml: an external contract must resolve and + // the bound surfaces must exist in it. + await verifyBindingContract(dirname(paths.dir), cwd, issues); return finalize(issues); } +async function verifyBindingContract( + bindingDir: string, + cwd: string, + issues: VerifyFingerprintIssue[], +): Promise { + const bindingPath = join(bindingDir, GHOST_BINDING_FILENAME); + let raw: string; + try { + raw = await readFile(bindingPath, "utf-8"); + } catch { + return; // no binding to verify + } + + const parsed = GhostBindingSchema.safeParse(parseYaml(raw)); + if (!parsed.success) return; // lint reports schema problems separately + + const { contract, bindings } = parsed.data; + // The in-repo contract is validated by the package's own lint/verify. + if (classifyContractReference(contract) !== "npm") return; + + const repoRoot = await resolveGitRoot(cwd); + const contractDir = await resolveContractDir(contract, bindingDir, repoRoot); + if (!contractDir) { + issues.push({ + severity: "error", + rule: "binding-contract-unresolved", + message: `binding contract '${contract}' could not be resolved from node_modules.`, + path: GHOST_BINDING_FILENAME, + }); + return; + } + + const surfaceIds = await readContractSurfaceIds(contractDir); + if (surfaceIds === null) { + issues.push({ + severity: "error", + rule: "binding-contract-unresolved", + message: `binding contract '${contract}' has no readable surfaces.yml.`, + path: GHOST_BINDING_FILENAME, + }); + return; + } + + bindings.forEach((entry, index) => { + if (entry.surface === "core") return; // implicit root + if (!surfaceIds.has(entry.surface)) { + issues.push({ + severity: "error", + rule: "binding-surface-unknown", + message: `binding references surface '${entry.surface}' not declared in contract '${contract}'.`, + path: `bindings[${index}].surface`, + }); + } + }); +} + +async function readContractSurfaceIds( + contractDir: string, +): Promise | null> { + try { + const raw = await readFile(join(contractDir, "surfaces.yml"), "utf-8"); + const parsed = GhostSurfacesSchema.safeParse(parseYaml(raw)); + if (!parsed.success) return null; + return new Set(parsed.data.surfaces.map((surface) => surface.id)); + } catch { + return null; + } +} + async function verifyFingerprintExemplars( fingerprint: GhostFingerprintDocument, root: string, @@ -100,35 +169,6 @@ async function readFingerprintPackage( } } -async function readOptionalChecks( - path: string, - issues: VerifyFingerprintIssue[], -): Promise { - try { - const parsed = parseYaml(await readFile(path, "utf-8")); - const result = GhostValidateSchema.safeParse(parsed); - if (result.success) return result.data as GhostValidateDocument; - issues.push({ - severity: "error", - rule: "verify-checks-read-failed", - message: "validate.yml failed schema validation after package lint.", - path: "validate.yml", - }); - return undefined; - } catch (err) { - if (isMissingFileError(err)) return undefined; - issues.push({ - severity: "error", - rule: "verify-checks-read-failed", - message: `validate.yml could not be read as YAML: ${ - err instanceof Error ? err.message : String(err) - }`, - path: "validate.yml", - }); - return undefined; - } -} - async function verifyFingerprintEvidence( fingerprint: GhostFingerprintDocument, root: string, @@ -186,50 +226,6 @@ async function verifyFingerprintEvidence( } } -function verifyFingerprintCheckRefs( - fingerprint: GhostFingerprintDocument, - checks: GhostCheck[], - issues: VerifyFingerprintIssue[], -): void { - const checkIds = new Set(checks.map((check) => check.id)); - const checkRefLists: Array<[string, string[] | undefined]> = [ - ...fingerprint.intent.principles.map( - (entry, index) => - [`intent.yml.principles[${index}].check_refs`, entry.check_refs] as [ - string, - string[] | undefined, - ], - ), - ...fingerprint.intent.experience_contracts.map( - (entry, index) => - [ - `intent.yml.experience_contracts[${index}].check_refs`, - entry.check_refs, - ] as [string, string[] | undefined], - ), - ...fingerprint.composition.patterns.map( - (entry, index) => - [`composition.yml.patterns[${index}].check_refs`, entry.check_refs] as [ - string, - string[] | undefined, - ], - ), - ]; - - checkRefLists.forEach(([path, refs]) => { - refs?.forEach((ref, index) => { - const [, id] = ref.split(":"); - if (id && checkIds.has(id)) return; - issues.push({ - severity: "error", - rule: "fingerprint-check-unknown", - message: `fingerprint facet references unknown check '${ref}'.`, - path: `${path}[${index}]`, - }); - }); - }); -} - async function pathExists(path: string): Promise { try { await access(path); @@ -239,7 +235,7 @@ async function pathExists(path: string): Promise { } } -function isMissingFileError(err: unknown): boolean { +function _isMissingFileError(err: unknown): boolean { return ( typeof err === "object" && err !== null && diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index ba974233..c3cefd49 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -18,13 +18,14 @@ materials it draws from, and the patterns that make it feel intentional. intent.yml inventory.yml composition.yml - validate.yml + surfaces.yml + checks/*.md ``` The checked-in `.ghost/` package is the source of truth. Ordinary Git workflow is the staging and approval boundary: uncommitted or unmerged changes -are drafts, and committed fingerprint changes are canonical for Ghost. Checks are optional -deterministic gates. Ghost is not a lifecycle manager, proposal system, +are drafts, and committed fingerprint changes are canonical for Ghost. Checks are +markdown rules an agent evaluates. Ghost is not a lifecycle manager, proposal system, design-system registry, or screenshot archive. Generation uses **intent + inventory + composition**: @@ -42,7 +43,7 @@ Checks and review validate output; they are not generation input. facet content; Ghost normalizes omitted facet files or sections internally for checks, review, emit, and surface resolution. -Optional deterministic gates live in `validate.yml`. +Optional `ghost.check/v1` markdown checks live in `checks/*.md`, routed by surface. Use `ghost signals` as a stdout-only reconnaissance helper when an agent needs raw repo observations while authoring curated fingerprint facets. @@ -56,12 +57,12 @@ own review or check format. | Verb | Purpose | |---|---| -| `ghost init` | Create `.ghost/` with manifest, facets, and deterministic checks. | +| `ghost init` | Create `.ghost/` with manifest and facets. | | `ghost scan [dir] [--format json]` | Report sparse fingerprint contribution facets. | | `ghost lint [file-or-dir]` | Validate a fingerprint package or artifact. | | `ghost verify [dir] --root ` | Validate evidence paths, exemplar paths, and typed check refs. | -| `ghost check --base ` | Run active deterministic gates against a diff. | -| `ghost review --base ` | Emit an advisory review packet grounded in fingerprint facets, exemplars, checks, and diff evidence. | +| `ghost checks --diff ` | Select and ground the markdown checks governing a diff's surfaces. | +| `ghost review --diff ` | Emit an advisory review packet: touched surfaces, routed checks, and fingerprint grounding. | | `ghost gather [surface]` | Compose a surface's context slice (own + inherited + edge), or list the surface menu. | | `ghost checks --diff ` | Select and ground the markdown checks governing a diff's surfaces. | | `ghost emit ` | Emit `review-command`. | @@ -101,14 +102,14 @@ evidence-backed facet entries, then ask the human to curate the claims. - Treat checked-in Ghost package facet files as the source of truth. - Generate from intent, inventory, and composition. -- Run active checks from `validate.yml`; only active deterministic checks block. +- Route a diff with `ghost checks`; the agent evaluates the markdown checks it governs. - Use local evidence as provisional when fingerprint facets are silent. - Treat auto-drafted fingerprint edits as ordinary uncommitted draft work until the human curates them and Git review accepts them. - Treat fingerprint edits as ordinary Git-reviewed edits. - Validate with `ghost lint` and `ghost verify --root ` before declaring fingerprint facets useful. -- Run `ghost check` for deterministic gates and `ghost review` for advisory critique. +- Run `ghost checks` to route checks and `ghost review` for the advisory packet. - Use nested stacks and custom package dirs only when present or requested. diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 9678c403..51c077f4 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -20,7 +20,10 @@ canonical, and uncommitted or unmerged edits are draft work. nodes are placed on (`surface:`) and the containment tree (`parent`) plus typed composition edges. The contract carries no paths. A repo binds paths to surfaces with `.ghost.bind.yml` (`ghost.binding/v1`) or by directory location; a nested -`.ghost/` binds its subtree, it does not carry its own merged fingerprint. +`.ghost/` binds its subtree, it does not carry its own merged fingerprint. A +binding's `contract:` is `.` (the in-repo contract) or an npm package name +(`@scope/brand`, resolved from `node_modules`); `ghost verify` checks an external +contract resolves and its bound surfaces exist. `ghost gather ` composes a surface's slice (own nodes + inherited ancestors + edge contributions). `ghost gather --path ` resolves the diff --git a/packages/ghost/test/checks-grounding.test.ts b/packages/ghost/test/checks-grounding.test.ts deleted file mode 100644 index 64e0fe07..00000000 --- a/packages/ghost/test/checks-grounding.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - GHOST_FINGERPRINT_SCHEMA, - GHOST_VALIDATE_SCHEMA, - type GhostFingerprintDocument, - type GhostValidateDocument, - lintGhostValidate, -} from "../src/ghost-core/index.js"; - -describe("ghost.validate/v1 grounding", () => { - it("warns when active checks do not declare derivation", () => { - const doc = checksDocument({ - derivation: undefined, - }); - - const report = lintGhostValidate(doc); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "warning", - rule: "check-grounding-missing", - path: "checks[0].derivation", - }); - }); - - it("accepts active checks grounded in intent refs", () => { - const report = lintGhostValidate(checksDocument(), { - fingerprint: fingerprintDocument(), - }); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); - }); - - it("marks derivation refs unverified without fingerprint context", () => { - const report = lintGhostValidate(checksDocument()); - - expect(report.errors).toBe(0); - expect(report.info).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "info", - rule: "check-grounding-unverified", - path: "checks[0].derivation", - }); - }); - - it("accepts active checks grounded in composition refs", () => { - const report = lintGhostValidate( - checksDocument({ - derivation: { - composition: ["composition.pattern:tokenized-ui-color"], - }, - }), - { fingerprint: fingerprintDocument() }, - ); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); - }); - - it("accepts active checks with inventory as supporting derivation", () => { - const report = lintGhostValidate( - checksDocument({ - derivation: { - intent: ["intent.principle:dense-workflows-prioritize-scanning"], - inventory: ["inventory.exemplar:orders-table"], - }, - }), - { fingerprint: fingerprintDocument() }, - ); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); - }); - - it("warns on inventory-only derivation for active checks", () => { - const report = lintGhostValidate( - checksDocument({ - derivation: { - inventory: ["inventory.exemplar:orders-table"], - }, - }), - { fingerprint: fingerprintDocument() }, - ); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "warning", - rule: "check-grounding-inventory-only", - path: "checks[0].derivation", - }); - }); - - it("accepts active checks whose pattern_ids match the fingerprint", () => { - const report = lintGhostValidate( - checksDocument({ - applies_to: { - paths: ["apps/dashboard/**"], - pattern_ids: ["tokenized-ui-color"], - }, - }), - { fingerprint: fingerprintDocument() }, - ); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); - }); - - it("warns for active checks grounded in missing fingerprint intent/inventory/composition", () => { - const doc = checksDocument({ - derivation: { - intent: ["intent.principle:missing-principle"], - }, - }); - - const report = lintGhostValidate(doc, { - fingerprint: fingerprintDocument(), - }); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "warning", - rule: "check-grounding-unknown", - path: "checks[0].derivation.intent[0]", - }); - }); - - it("reports active checks with unknown pattern_id targets", () => { - const report = lintGhostValidate( - checksDocument({ - applies_to: { - paths: ["apps/dashboard/**"], - pattern_ids: ["unknown-pattern"], - }, - }), - { fingerprint: fingerprintDocument() }, - ); - - expect( - report.issues.some( - (issue) => - issue.rule === "check-pattern-unknown" && - issue.path === "checks[0].applies_to.pattern_ids[0]", - ), - ).toBe(true); - }); - - // Phase 3: scope/surface_type check-routing grounding is dormant (topology - // removed). Routing against surfaces is rebuilt in Phase 4/7; scope targets - // are no longer validated, so unknown scopes/surface_types no longer error. - it("downgrades proposed check pattern misses to warnings", () => { - const report = lintGhostValidate( - checksDocument({ - status: "proposed", - applies_to: { - paths: ["apps/dashboard/**"], - pattern_ids: ["unknown-pattern"], - }, - }), - { fingerprint: fingerprintDocument() }, - ); - - expect(report.warnings).toBeGreaterThanOrEqual(1); - expect( - report.issues.some((issue) => issue.rule === "check-pattern-unknown"), - ).toBe(true); - }); - - it("downgrades proposed check grounding misses to warnings", () => { - const doc = checksDocument({ - status: "proposed", - derivation: { - intent: ["intent.principle:missing-principle"], - }, - }); - - const report = lintGhostValidate(doc, { - fingerprint: fingerprintDocument(), - }); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "check-grounding-unknown", - }); - }); - - it("downgrades missing proposed derivation to a warning", () => { - const doc = checksDocument({ - status: "proposed", - derivation: undefined, - }); - - const report = lintGhostValidate(doc); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "check-grounding-missing", - }); - }); - - it("rejects untyped derivation references at schema level", () => { - const doc = checksDocument({ - derivation: { - intent: ["dense-workflows-prioritize-scanning"] as never, - }, - }); - - const report = lintGhostValidate(doc); - - expect(report.errors).toBe(1); - expect(report.issues[0]?.rule).toBe("schema/invalid_format"); - }); - - it("rejects mismatched derivation references at schema level", () => { - const doc = checksDocument({ - derivation: { - inventory: ["composition.pattern:tokenized-ui-color"] as never, - }, - }); - - const report = lintGhostValidate(doc); - - expect(report.errors).toBe(1); - expect(report.issues[0]?.rule).toBe("schema/invalid_format"); - }); -}); - -function checksDocument( - overrides: Partial = {}, -): GhostValidateDocument { - return { - schema: GHOST_VALIDATE_SCHEMA, - id: "example", - checks: [ - { - id: "no-decorative-card-grid-for-dense-table", - title: "Do not replace dense tables with decorative cards", - status: "active", - severity: "serious", - derivation: { - intent: ["intent.principle:dense-workflows-prioritize-scanning"], - }, - applies_to: { - paths: ["apps/dashboard/**"], - }, - detector: { - type: "forbidden-regex", - pattern: "decorativeCardGrid", - }, - evidence: { - support: 0.94, - observed_count: 3, - examples: ["apps/dashboard/src/routes/orders/page.tsx"], - }, - ...overrides, - }, - ], - }; -} - -function fingerprintDocument( - overrides: Partial = {}, -): GhostFingerprintDocument { - return { - schema: GHOST_FINGERPRINT_SCHEMA, - intent: { - summary: {}, - situations: [], - principles: [ - { - id: "dense-workflows-prioritize-scanning", - principle: - "Dense workflows optimize for comparison, speed, and recovery.", - }, - ], - experience_contracts: [], - }, - inventory: { - building_blocks: {}, - exemplars: [ - { - id: "orders-table", - path: "apps/dashboard/src/routes/orders/page.tsx", - }, - ], - sources: [], - }, - composition: { - patterns: [ - { - id: "tokenized-ui-color", - kind: "visual", - pattern: "Use semantic colors.", - }, - ], - }, - ...overrides, - }; -} diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 3eebf2ca..355b2017 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -775,7 +775,6 @@ sources: [] expect(init.code).toBe(0); const initOutput = JSON.parse(init.stdout); expect(Object.keys(initOutput).sort()).toEqual([ - "checks", "composition", "dir", "intent", @@ -785,23 +784,18 @@ sources: [] await expect( readFile(join(dir, ".ghost", "manifest.yml"), "utf-8"), ).resolves.toContain("schema: ghost.fingerprint-package/v1"); - await expect( - readFile(join(dir, ".ghost", "validate.yml"), "utf-8"), - ).resolves.toContain("schema: ghost.validate/v1"); const status = JSON.parse(scan.stdout); expect(status.cache).toBeUndefined(); const lint = await runCli(["lint"], dir); const verify = await runCli(["verify", ".ghost", "--root", "."], dir); - const check = await runCli(["check", "--diff", "change.patch"], dir); const review = await runCli(["review", "--diff", "change.patch"], dir); const reviewCommand = await runCli(["emit", "review-command"], dir); expect(lint.code).toBe(0); expect(verify.code).toBe(0); - expect(check.code).toBe(0); expect(review.code).toBe(0); - expect(review.stdout).toContain("## Selected Context"); + expect(review.stdout).toContain("## Touched Surfaces"); expect(reviewCommand.code).toBe(0); }); @@ -1230,112 +1224,6 @@ sources: [] ).resolves.toContain("summary: {}"); }); - it("warns for checks grounded in omitted sparse fingerprint refs", async () => { - await runCli(["init"], dir); - await writeFile( - join(dir, ".ghost", "validate.yml"), - `schema: ghost.validate/v1 -id: local -checks: - - id: missing-fingerprint-check - title: Missing fingerprint check - status: active - severity: serious - derivation: - intent: [intent.principle:not-recorded] - applies_to: - paths: [Code/Features/Lending] - detector: - type: forbidden-regex - pattern: '#[0-9a-fA-F]{3,8}' - evidence: - support: 0.94 - observed_count: 47 - examples: - - Code/Features/Lending/LendingUI -`, - ); - - const lint = await runCli(["lint", ".ghost", "--format", "json"], dir); - - expect(lint.code).toBe(0); - const report = JSON.parse(lint.stdout); - expect(report.issues[0]).toMatchObject({ - severity: "warning", - rule: "check-grounding-unknown", - path: "validate.yml.checks[0].derivation.intent[0]", - }); - }); - - it("validates standalone validate.yml derivation refs with a valid sibling fingerprint", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeFile( - join(dir, ".ghost", "validate.yml"), - checksFileWithDerivation("intent.principle:not-recorded"), - ); - - const lint = await runCli( - ["lint", ".ghost/validate.yml", "--format", "json"], - dir, - ); - - expect(lint.code).toBe(0); - const report = JSON.parse(lint.stdout); - expect(report.issues[0]).toMatchObject({ - severity: "warning", - rule: "check-grounding-unknown", - path: "checks[0].derivation.intent[0]", - }); - expect(report.issues).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ rule: "check-grounding-unverified" }), - ]), - ); - }); - - it("marks standalone validate.yml grounding unverified when no sibling fingerprint exists", async () => { - await writeFile( - join(dir, "validate.yml"), - checksFileWithDerivation("intent.principle:tokenized-ui-color"), - ); - - const lint = await runCli( - ["lint", "validate.yml", "--format", "json"], - dir, - ); - - expect(lint.code).toBe(0); - const report = JSON.parse(lint.stdout); - expect(report.info).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "info", - rule: "check-grounding-unverified", - path: "checks[0].derivation", - }); - }); - - it("keeps standalone validate.yml lint non-blocking when the sibling fingerprint is invalid", async () => { - await mkdir(join(dir, ".ghost"), { recursive: true }); - await writeFile(join(dir, ".ghost", "manifest.yml"), "not: draft\n"); - await writeFile( - join(dir, ".ghost", "validate.yml"), - checksFileWithDerivation("intent.principle:tokenized-ui-color"), - ); - - const lint = await runCli( - ["lint", ".ghost/validate.yml", "--format", "json"], - dir, - ); - - expect(lint.code).toBe(0); - const report = JSON.parse(lint.stdout); - expect(report.issues[0]).toMatchObject({ - severity: "info", - rule: "check-grounding-unverified", - path: "checks[0].derivation", - }); - }); - it("does not guess arbitrary YAML files are validate.yml", async () => { await writeFile(join(dir, "workflow.yml"), "name: ci\non: push\n"); @@ -1376,7 +1264,6 @@ checks: expect(init.stdout).toContain("intent.yml:"); expect(init.stdout).toContain("inventory.yml:"); expect(init.stdout).toContain("composition.yml:"); - expect(init.stdout).toContain("validate.yml:"); expect(init.stdout).not.toContain("cache/:"); expect(init.stdout).not.toContain("memory/intent.md:"); expect( @@ -1390,19 +1277,16 @@ checks: expect(status.intent).toBeUndefined(); expect(status.readiness).toBeUndefined(); expect(status.checks).toBeUndefined(); - expect(status.validate.state).toBe("present"); expect(status.contribution.state).toBe("empty"); expect(status.contribution.contributing_facets).toEqual([]); expect(status.contribution.empty_facets).toEqual([ "intent", "inventory", "composition", - "validate", ]); expect(scanHuman.stdout).toContain("package dir:"); expect(scanHuman.stdout).toContain("contribution: empty"); expect(scanHuman.stdout).toContain("intent: empty (0)"); - expect(scanHuman.stdout).toContain("validate: empty (0)"); expect(scanHuman.stdout).not.toContain("readiness:"); expect(scanHuman.stdout).not.toContain("missing facets:"); expect(scanHuman.stdout).not.toContain("memory dir:"); @@ -1467,11 +1351,7 @@ checks: expect(status.contribution.state).toBe("contributing"); expect(status.contribution.contributing_facets).toEqual(["inventory"]); expect(status.contribution.absent_facets).toEqual([]); - expect(status.contribution.empty_facets).toEqual([ - "intent", - "composition", - "validate", - ]); + expect(status.contribution.empty_facets).toEqual(["intent", "composition"]); const signalsOutput = JSON.parse(signals.stdout); expect(signalsOutput.config).toBeUndefined(); @@ -1510,7 +1390,6 @@ checks: expect(scan.code).toBe(0); const status = JSON.parse(scan.stdout); expect(status.fingerprint.state).toBe("present"); - expect(status.validate.state).toBe("missing"); expect(status.proposals).toBeUndefined(); expect(status.cache).toBeUndefined(); expect(status.readiness).toBeUndefined(); @@ -1521,10 +1400,7 @@ checks: "inventory", ]); expect(status.contribution.empty_facets).toEqual([]); - expect(status.contribution.absent_facets).toEqual([ - "composition", - "validate", - ]); + expect(status.contribution.absent_facets).toEqual(["composition"]); expect(status.contribution.reasons[0]).toContain( "Absent facets may be inherited", ); @@ -1554,7 +1430,6 @@ checks: expect(emittedReviewCommand).not.toContain("Proposal Threshold"); expect(emittedReviewCommand).not.toContain("recommend-proposal"); expect(emittedReviewCommand).toContain("experience-gap"); - expect(emittedReviewCommand).toContain("no-hardcoded-ui-color"); expect(emittedReviewCommand).not.toContain( "deprecated legacy direct-markdown", ); @@ -1769,74 +1644,6 @@ checks: ).rejects.toThrow(); }); - it("check fails when an active deterministic check matches added lines", async () => { - await writeCheckPackage(dir); - await writeFile( - join(dir, "change.patch"), - lendingPatch("UIColor(#ffffff)"), - ); - - const result = await runCli( - ["check", "--diff", "change.patch", "--format", "json"], - dir, - ); - - expect(result.code, result.stderr).toBe(1); - const report = JSON.parse(result.stdout); - expect(report.result).toBe("fail"); - expect(report.findings[0]).toMatchObject({ - check_id: "no-hardcoded-ui-color", - path: "Code/Features/Lending/View.swift", - line: 1, - }); - }); - - it("check treats inline color detectors as literal patterns, not exact values", async () => { - await writeCheckPackage(dir, { detectorPattern: "#000000" }); - await writeFile( - join(dir, "change.patch"), - lendingPatch("let colors = [Color(#000), Color.black]"), - ); - - const result = await runCli( - ["check", "--diff", "change.patch", "--format", "json"], - dir, - ); - - expect(result.code, result.stderr).toBe(1); - const report = JSON.parse(result.stdout); - expect(report.result).toBe("fail"); - expect( - report.findings.map((finding: { match: string }) => finding.match), - ).toEqual(["#000", "Color.black"]); - }); - - it("check passes when active scoped checks do not match", async () => { - await writeCheckPackage(dir); - await writeFile( - join(dir, "change.patch"), - lendingPatch("let color = CashTheme.primary"), - ); - - const result = await runCli(["check", "--diff", "change.patch"], dir); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("Design Check: PASS"); - }); - - it("check passes when optional validate.yml is absent", async () => { - await writeCheckPackage(dir, { checks: false }); - await writeFile( - join(dir, "change.patch"), - lendingPatch("UIColor(#ffffff)"), - ); - - const result = await runCli(["check", "--diff", "change.patch"], dir); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("No active deterministic check failures."); - }); - it("review emits an advisory packet with required citation fields", async () => { await writeCheckPackage(dir); await writeFile( @@ -1848,22 +1655,16 @@ checks: expect(result.code).toBe(0); expect(result.stdout).toContain("# Ghost Advisory Review"); - expect(result.stdout).toContain("## Selected Context"); - expect(result.stdout).toContain("### Selected Context"); - expect(result.stdout).toContain("#### Stack"); - expect(result.stdout).toContain("#### Match"); - expect(result.stdout).toContain("#### Context Hits"); - expect(result.stdout).toContain("#### Suggested Reads"); - expect(result.stdout).toContain("#### Omissions"); - expect(result.stdout).toContain("#### Gaps"); - // Phase 3: path-based scope matching is dormant (rebuilt Phase 5/7). + expect(result.stdout).toContain("## Touched Surfaces"); + expect(result.stdout).toContain("## Routed Checks"); + expect(result.stdout).toContain("## Grounding"); expect(result.stdout).toContain("diff location"); - expect(result.stdout).toContain("fingerprint facet refs"); + expect(result.stdout).toContain("surface the change touches"); expect(result.stdout).toContain( - "selected-context gap or local-evidence rationale when context is silent", + "grounding ref (why / what) or local-evidence rationale when the surface is silent", ); - expect(result.stdout).toContain("Use the selected context first"); - expect(result.stdout).toContain("active check when blocking"); + expect(result.stdout).toContain("Use the surface grounding first"); + expect(result.stdout).toContain("routed check when blocking"); expect(result.stdout).not.toContain("Proposal Threshold"); expect(result.stdout).toContain("provisional and non-Ghost-backed"); expect(result.stdout).not.toContain("recommend-proposal"); @@ -1954,8 +1755,11 @@ checks: expect(result.code).toBe(0); const packet = JSON.parse(result.stdout); - expect(packet.fingerprint.schema).toBe("ghost.fingerprint/v1"); + expect(packet.schema).toBe("ghost.advisory-review/v1"); expect(packet.finding_categories).toContain("experience-gap"); + expect(Array.isArray(packet.touched_surfaces)).toBe(true); + expect(Array.isArray(packet.routed_checks)).toBe(true); + expect(Array.isArray(packet.grounding)).toBe(true); expect(packet.proposal_types).toBeUndefined(); expect(packet.open_proposals).toBeUndefined(); expect(packet.accepted_decisions).toBeUndefined(); @@ -1985,92 +1789,7 @@ checks: ).rejects.toThrow("Unknown option `--includeMemory`"); }); - it("routes changed files through the root contract; a child cannot disable an inherited check (Leak E)", async () => { - await writeNestedCheckPackage(dir); - await writeFile( - join(dir, "change.patch"), - webPatch("apps/checkout/review/page.tsx", 'const color = "#ffffff";'), - ); - - const result = await runCli( - ["check", "--diff", "change.patch", "--format", "json"], - dir, - { allowNoExit: true }, - ); - - const report = JSON.parse(result.stdout); - expect(report.schema).toBe("ghost.check-report/v1"); - // The child package's `status: disabled` no longer wins by merge — the - // root contract's active check governs, so the hardcoded color fails. - expect(report.result).toBe("fail"); - expect(report.ghost_dir).toBe(".ghost"); - expect(report.stacks[0].stack_dirs).toHaveLength(2); - }); - - it("--package keeps check in exact single-bundle mode", async () => { - await writeNestedCheckPackage(dir); - await writeFile( - join(dir, "change.patch"), - webPatch("apps/checkout/review/page.tsx", 'const color = "#ffffff";'), - ); - - const result = await runCli( - [ - "check", - "--diff", - "change.patch", - "--package", - ".ghost", - "--format", - "json", - ], - dir, - ); - - expect(result.code).toBe(1); - const report = JSON.parse(result.stdout); - expect(report.schema).toBe("ghost.check-report/v1"); - expect(report.findings[0].check_id).toBe("no-hardcoded-color"); - expect(report.findings[0]).toMatchObject({ - path: "apps/checkout/review/page.tsx", - line: 1, - title: "No hardcoded colors", - severity: "serious", - detector: "forbidden-regex", - message: "Added UI code matched a forbidden pattern.", - match: "#ffffff", - }); - expect(report.stacks).toBeUndefined(); - }); - - it("resolves stack checks from a custom package directory", async () => { - await writeNestedCheckPackage(dir, ".design/memory"); - await writeFile( - join(dir, "change.patch"), - webPatch("apps/checkout/review/page.tsx", 'const color = "#ffffff";'), - ); - - const result = await runCli( - ["check", "--diff", "change.patch", "--format", "json"], - dir, - { env: { GHOST_PACKAGE_DIR: ".design/memory" }, allowNoExit: true }, - ); - - const report = JSON.parse(result.stdout); - expect(report.ghost_dir).toBe(".design/memory"); - expect(report.memory_dir).toBeUndefined(); - expect(report.stacks[0]).toMatchObject({ - ghost_dir: ".design/memory", - changed_files: ["apps/checkout/review/page.tsx"], - }); - expect(report.stacks[0].memory_dir).toBeUndefined(); - expect(report.stacks[0].stack_dirs).toEqual([ - await realpath(join(dir, ".design", "memory")), - await realpath(join(dir, "apps", "checkout", ".design", "memory")), - ]); - }); - - it("review emits stack packets for mixed diffs", async () => { + it("review resolves touched surfaces for a mixed diff", async () => { await writeNestedCheckPackage(dir); await writeFile( join(dir, "change.patch"), @@ -2087,15 +1806,11 @@ checks: expect(result.code).toBe(0); const packet = JSON.parse(result.stdout); - expect(packet.stacks).toHaveLength(2); - expect(packet.stacks[0].ghost_dir).toBe(".ghost"); - expect(packet.stacks[0].memory_dir).toBeUndefined(); - // contract is the root package, used as-is (no merge). - expect(packet.stacks[0].contract.fingerprint.intent.summary.product).toBe( - "Root Product", - ); - expect(packet.stacks[0].stack_dirs).toHaveLength(2); - expect(packet.stacks[1].stack_dirs).toHaveLength(1); + // Review is now surface-based: no merged stacks, just touched surfaces + + // routed checks + grounding from the root contract. + expect(packet.stacks).toBeUndefined(); + expect(Array.isArray(packet.touched_surfaces)).toBe(true); + expect(Array.isArray(packet.grounding)).toBe(true); }); it("emit review-command resolves the root contract for --path (no child merge)", async () => { @@ -2805,7 +2520,7 @@ async function writeSplitFingerprintPackage( ]); } -function checksFileWithDerivation(intentRef: string): string { +function _checksFileWithDerivation(intentRef: string): string { return `schema: ghost.validate/v1 id: local checks: diff --git a/packages/ghost/test/contract-resolver.test.ts b/packages/ghost/test/contract-resolver.test.ts new file mode 100644 index 00000000..0fa3c196 --- /dev/null +++ b/packages/ghost/test/contract-resolver.test.ts @@ -0,0 +1,57 @@ +import { execFile } from "node:child_process"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveContractDir } from "../src/scan/contract-resolver.js"; + +const execFileAsync = promisify(execFile); + +describe("resolveContractDir", () => { + let dir: string; + + beforeEach(async () => { + dir = join( + tmpdir(), + `ghost-contract-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + await execFileAsync("git", ["init", "-q"], { cwd: dir }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("resolves the in-repo contract `.` to /.ghost", async () => { + await mkdir(join(dir, ".ghost"), { recursive: true }); + const resolved = await resolveContractDir(".", dir, dir); + expect(resolved).toBe(join(dir, ".ghost")); + }); + + it("returns null for `.` when there is no root .ghost", async () => { + expect(await resolveContractDir(".", dir, dir)).toBeNull(); + }); + + it("resolves an npm name from node_modules", async () => { + const contractDir = join(dir, "node_modules", "@acme", "brand", ".ghost"); + await mkdir(contractDir, { recursive: true }); + await mkdir(join(dir, "apps", "web"), { recursive: true }); + const resolved = await resolveContractDir( + "@acme/brand", + join(dir, "apps", "web"), + dir, + ); + expect(resolved).toBe(contractDir); + }); + + it("returns null for an unresolved npm name", async () => { + expect(await resolveContractDir("@acme/missing", dir, dir)).toBeNull(); + }); + + it("returns null for an unsupported reference kind", async () => { + await writeFile(join(dir, "marker"), "x"); + expect(await resolveContractDir("../brand", dir, dir)).toBeNull(); + }); +}); diff --git a/packages/ghost/test/ghost-core/binding-schema.test.ts b/packages/ghost/test/ghost-core/binding-schema.test.ts index bfad7b25..c8031ae9 100644 --- a/packages/ghost/test/ghost-core/binding-schema.test.ts +++ b/packages/ghost/test/ghost-core/binding-schema.test.ts @@ -44,8 +44,17 @@ describe("lintGhostBinding", () => { expect(lintGhostBinding(doc()).errors).toBe(0); }); - it("errors on an unsupported external contract reference", () => { + it("accepts an npm-name external contract reference", () => { const report = lintGhostBinding(doc({ contract: "@scope/brand" })); + expect( + report.issues.some( + (issue) => issue.rule === "binding-contract-unsupported", + ), + ).toBe(false); + }); + + it("errors on a path-like contract reference", () => { + const report = lintGhostBinding(doc({ contract: "../brand" })); expect( report.issues.some( (issue) => issue.rule === "binding-contract-unsupported", diff --git a/packages/ghost/test/ghost-core/checks.test.ts b/packages/ghost/test/ghost-core/checks.test.ts deleted file mode 100644 index 3fe9797b..00000000 --- a/packages/ghost/test/ghost-core/checks.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type GhostValidateDocument, - type GhostValidateFingerprintContext, - lintGhostValidate, - routeGhostValidateForPath, -} from "#ghost-core"; - -function checks( - overrides: Partial = {}, -): GhostValidateDocument { - return { - schema: "ghost.validate/v1", - id: "cash-ios", - checks: [ - { - id: "no-hardcoded-ui-color", - title: "Use design tokens for UI color", - status: "active", - severity: "serious", - derivation: { - intent: ["intent.principle:tokenized-ui-color"], - composition: ["composition.pattern:tokenized-ui-color"], - }, - applies_to: { - paths: ["Code/Features/Lending"], - }, - detector: { - type: "forbidden-regex", - pattern: "#[0-9a-fA-F]{3,8}", - contexts: ["swift"], - }, - evidence: { - support: 0.94, - observed_count: 47, - examples: ["Code/Features/Lending/LendingUI"], - }, - repair: "Replace literals with Arcade/Cash semantic tokens.", - ...overrides, - }, - ], - }; -} - -describe("ghost.validate/v1", () => { - it("validates an active human-promoted check", () => { - const report = lintGhostValidate(checks()); - - expect(report.errors).toBe(0); - }); - - it("marks derivation refs unverified without fingerprint context", () => { - const report = lintGhostValidate(checks()); - - expect(report.errors).toBe(0); - expect(report.info).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "info", - rule: "check-grounding-unverified", - path: "checks[0].derivation", - }); - }); - - it("warns when active checks do not declare derivation", () => { - const report = lintGhostValidate(checks({ derivation: undefined })); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "warning", - rule: "check-grounding-missing", - path: "checks[0].derivation", - }); - }); - - it("accepts active checks grounded in fingerprint refs", () => { - const report = lintGhostValidate(checks(), { - fingerprint: fingerprintContext(), - }); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); - }); - - it("warns when active checks reference missing fingerprint refs", () => { - const report = lintGhostValidate( - checks({ - derivation: { - intent: ["intent.principle:not-recorded"], - }, - }), - { - fingerprint: fingerprintContext(), - }, - ); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "warning", - rule: "check-grounding-unknown", - path: "checks[0].derivation.intent[0]", - }); - }); - - it("rejects untyped derivation references at schema level", () => { - const report = lintGhostValidate( - checks({ - derivation: { - intent: ["tokenized-ui-color" as never], - }, - }), - ); - - expect(report.errors).toBe(1); - expect(report.issues[0]?.rule).toBe("schema/invalid_format"); - }); - - it("warns on inventory-only active checks", () => { - const report = lintGhostValidate( - checks({ - derivation: { - inventory: ["inventory.exemplar:lending-tokenized-screen"], - }, - }), - { fingerprint: fingerprintContext() }, - ); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "warning", - rule: "check-grounding-inventory-only", - path: "checks[0].derivation", - }); - }); - - it("warns for proposed checks with incomplete derivation", () => { - const report = lintGhostValidate( - checks({ status: "proposed", derivation: undefined }), - ); - - expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "check-grounding-missing", - path: "checks[0].derivation", - }); - }); - - // Phase 3: scope/surface_type check-grounding is dormant (topology removed); - // rebuilt against surfaces in Phase 4/7. Only pattern_id targets validate. - it("fails active checks that reference unknown fingerprint pattern targets", () => { - const report = lintGhostValidate( - checks({ - applies_to: { - paths: ["Code/Features/Lending"], - pattern_ids: ["unknown-pattern"], - }, - }), - { fingerprint: fingerprintContext() }, - ); - - expect( - report.issues.some((issue) => issue.rule === "check-pattern-unknown"), - ).toBe(true); - }); - - it("fails invalid detector regex", () => { - const report = lintGhostValidate( - checks({ detector: { type: "forbidden-regex", pattern: "[" } }), - ); - - expect(report.errors).toBe(1); - expect(report.issues[0].rule).toBe("check-detector-pattern-invalid"); - }); - - // Phase 4: map scopes are deleted; routing is path-only. Surface-based - // routing is rebuilt in Phase 7. - it("routes checks to a path matching their applies_to.paths", () => { - const routed = routeGhostValidateForPath( - checks().checks, - "Code/Features/Lending/Sources/View.swift", - ); - - expect(routed).toHaveLength(1); - expect(routed[0].check.id).toBe("no-hardcoded-ui-color"); - }); - - it("does not route checks outside their declared paths", () => { - const routed = routeGhostValidateForPath( - checks().checks, - "Code/Features/Investing/Sources/View.swift", - ); - - expect(routed).toEqual([]); - }); -}); - -function fingerprintContext(): GhostValidateFingerprintContext { - return { - intent: { - principles: [{ id: "tokenized-ui-color" }], - situations: [], - experience_contracts: [], - }, - inventory: { - topology: { - scopes: [ - { - id: "lending", - surface_types: ["native-feature"], - }, - ], - surface_types: ["native-feature"], - }, - exemplars: [{ id: "lending-tokenized-screen" }], - }, - composition: { - patterns: [{ id: "tokenized-ui-color" }], - }, - }; -} diff --git a/packages/ghost/test/ghost-core/contract-ref.test.ts b/packages/ghost/test/ghost-core/contract-ref.test.ts new file mode 100644 index 00000000..94226058 --- /dev/null +++ b/packages/ghost/test/ghost-core/contract-ref.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + classifyContractReference, + GhostBindingSchema, + lintGhostBinding, +} from "../../src/ghost-core/index.js"; + +describe("classifyContractReference", () => { + it("treats `.` as in-repo", () => { + expect(classifyContractReference(".")).toBe("in-repo"); + }); + + it("treats npm names (scoped and unscoped) as npm", () => { + expect(classifyContractReference("@acme/brand")).toBe("npm"); + expect(classifyContractReference("brand")).toBe("npm"); + expect(classifyContractReference("brand-tokens")).toBe("npm"); + }); + + it("rejects paths, urls, and resource ids", () => { + expect(classifyContractReference("./brand")).toBe("unsupported"); + expect(classifyContractReference("../brand")).toBe("unsupported"); + expect(classifyContractReference("packages/brand")).toBe("unsupported"); + expect(classifyContractReference("https://x.example")).toBe("unsupported"); + expect(classifyContractReference("registry:brand")).toBe("unsupported"); + }); +}); + +describe("lintGhostBinding contract reference", () => { + function doc(contract: string) { + return { + schema: "ghost.binding/v1", + contract, + bindings: [{ surface: "checkout", paths: ["apps/checkout"] }], + }; + } + + it("accepts an npm-name contract", () => { + expect(lintGhostBinding(doc("@acme/brand")).errors).toBe(0); + }); + + it("accepts the in-repo contract", () => { + expect(lintGhostBinding(doc(".")).errors).toBe(0); + }); + + it("rejects a path-like contract", () => { + const report = lintGhostBinding(doc("../brand")); + expect( + report.issues.some((i) => i.rule === "binding-contract-unsupported"), + ).toBe(true); + }); + + it("still parses the schema regardless of contract value", () => { + expect(GhostBindingSchema.safeParse(doc("@acme/brand")).success).toBe(true); + }); +}); diff --git a/packages/ghost/test/public-exports.test.ts b/packages/ghost/test/public-exports.test.ts index 2a063801..998f52cf 100644 --- a/packages/ghost/test/public-exports.test.ts +++ b/packages/ghost/test/public-exports.test.ts @@ -10,10 +10,9 @@ const hasBuiltExports = existsSync( describe.runIf(hasBuiltExports)("built public exports", () => { it("exposes fingerprint-first package subpaths", async () => { - const [fingerprint, scan, govern, compareApi] = await Promise.all([ + const [fingerprint, scan, compareApi] = await Promise.all([ import("@anarchitecture/ghost/fingerprint"), import("@anarchitecture/ghost/scan"), - import("@anarchitecture/ghost/govern"), import("@anarchitecture/ghost/compare"), ]); @@ -34,13 +33,6 @@ describe.runIf(hasBuiltExports)("built public exports", () => { expect(scanApi.lintFingerprintPackage).toBeUndefined(); expect(scanApi.writePackageContextBundle).toBeUndefined(); - expect(govern.runGhostCheck).toBeTypeOf("function"); - expect(govern.runGhostCheck).toBe(govern.runGhostDriftCheck); - expect(govern.formatGhostCheckMarkdown).toBeTypeOf("function"); - expect(govern.formatGhostCheckMarkdown).toBe( - govern.formatGhostDriftCheckMarkdown, - ); - expect(compareApi.compare).toBeTypeOf("function"); expect(compareApi.compareFingerprints).toBeTypeOf("function"); expect(compareApi.formatComparison).toBeTypeOf("function"); diff --git a/packages/ghost/test/scan-status.test.ts b/packages/ghost/test/scan-status.test.ts index b5285b6d..3d58778d 100644 --- a/packages/ghost/test/scan-status.test.ts +++ b/packages/ghost/test/scan-status.test.ts @@ -23,7 +23,6 @@ describe("scanStatus contribution", () => { const status = await scanStatus(dir); expect(status.fingerprint.state).toBe("missing"); - expect(status.validate.state).toBe("missing"); expect(status.recommended_next).toBe("fingerprint"); expect(status.contribution.state).toBe("missing"); expect(status.contribution.contributing_facets).toEqual([]); @@ -31,7 +30,6 @@ describe("scanStatus contribution", () => { "intent", "inventory", "composition", - "validate", ]); expect(status.contribution.reasons.join(" ")).toContain( "manifest.yml is missing", @@ -55,7 +53,6 @@ describe("scanStatus contribution", () => { "intent", "inventory", "composition", - "validate", ]); expect(status.contribution.facets.intent).toMatchObject({ state: "absent", @@ -76,23 +73,17 @@ exemplars: [] sources: [] `, composition: `patterns: [] -`, - validate: `schema: ghost.validate/v1 -id: test -checks: [] `, }); const status = await scanStatus(dir); - expect(status.validate.state).toBe("present"); expect(status.contribution.state).toBe("empty"); expect(status.contribution.contributing_facets).toEqual([]); expect(status.contribution.empty_facets).toEqual([ "intent", "inventory", "composition", - "validate", ]); expect(status.contribution.absent_facets).toEqual([]); }); @@ -125,7 +116,6 @@ checks: [] expect(status.contribution.absent_facets).toEqual([ "inventory", "composition", - "validate", ]); expect(status.contribution.facets.intent).toMatchObject({ state: "useful", @@ -162,7 +152,6 @@ sources: expect(status.contribution.absent_facets).toEqual([ "intent", "composition", - "validate", ]); }); @@ -183,42 +172,7 @@ sources: state: "useful", count: 1, }); - expect(status.contribution.absent_facets).toEqual([ - "intent", - "inventory", - "validate", - ]); - }); - - it("reports validate contribution from deterministic checks", async () => { - await writePackage(dir, { - validate: `schema: ghost.validate/v1 -id: test -checks: - - id: no-hardcoded-color - title: Use semantic colors - status: active - severity: serious - detector: - type: forbidden-regex - pattern: '#[0-9a-fA-F]{6}' -`, - }); - - const status = await scanStatus(dir); - - expect(status.validate.state).toBe("present"); - expect(status.contribution.state).toBe("contributing"); - expect(status.contribution.contributing_facets).toEqual(["validate"]); - expect(status.contribution.facets.validate).toMatchObject({ - state: "useful", - count: 1, - }); - expect(status.contribution.validate_counts).toEqual({ - active: 1, - proposed: 0, - disabled: 0, - }); + expect(status.contribution.absent_facets).toEqual(["intent", "inventory"]); }); it("reports multiple sparse contributions without calling absent facets missing", async () => { @@ -240,10 +194,7 @@ checks: "intent", "inventory", ]); - expect(status.contribution.absent_facets).toEqual([ - "composition", - "validate", - ]); + expect(status.contribution.absent_facets).toEqual(["composition"]); expect(status.contribution.reasons[0]).toContain( "Absent facets may be inherited", ); @@ -272,17 +223,6 @@ sources: [] - id: preserve-table-density kind: layout pattern: Keep dense operational tables scannable. -`, - validate: `schema: ghost.validate/v1 -id: test -checks: - - id: no-hardcoded-color - title: Use semantic colors - status: proposed - severity: serious - detector: - type: forbidden-regex - pattern: '#[0-9a-fA-F]{6}' `, }); @@ -294,18 +234,15 @@ checks: "intent", "inventory", "composition", - "validate", ]); expect(status.contribution.facets).toMatchObject({ intent: { state: "useful", count: 1 }, inventory: { state: "useful", count: 3 }, composition: { state: "useful", count: 1 }, - validate: { state: "useful", count: 1 }, }); expect(status.contribution.building_block_rows.tokens).toBe(1); expect(status.contribution.building_block_rows.components).toBe(1); expect(status.contribution.product_surface_count).toBe(1); - expect(status.contribution.validate_counts.proposed).toBe(1); }); }); @@ -315,7 +252,6 @@ async function writePackage( intent?: string; inventory?: string; composition?: string; - validate?: string; }, ): Promise { const packageDir = dir; diff --git a/packages/ghost/test/terminology-public.test.ts b/packages/ghost/test/terminology-public.test.ts index 7bd4eb89..abfeb1e2 100644 --- a/packages/ghost/test/terminology-public.test.ts +++ b/packages/ghost/test/terminology-public.test.ts @@ -18,7 +18,6 @@ const PUBLIC_TEXT_ROOTS = [ ] as const; const EMITTED_TEXT_FILES = [ - "packages/ghost/src/context/selected-context.ts", "packages/ghost/src/context/package-review-command.ts", "packages/ghost/src/review-packet.ts", ] as const; diff --git a/scripts/check-packed-package.mjs b/scripts/check-packed-package.mjs index 99083a65..dc73bd86 100644 --- a/scripts/check-packed-package.mjs +++ b/scripts/check-packed-package.mjs @@ -19,7 +19,6 @@ const PUBLIC_IMPORTS = [ "@anarchitecture/ghost/fingerprint", "@anarchitecture/ghost/scan", "@anarchitecture/ghost/compare", - "@anarchitecture/ghost/govern", "@anarchitecture/ghost/core", "@anarchitecture/ghost/drift", ];