diff --git a/CHANGELOG.md b/CHANGELOG.md index 767110c..9c536cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.84] - 2026-07-29 + +### Added + +- **Local design corpus as the source of taste** (`src/policy/design/corpus.ts`, `corpus-resolve.ts`, `design-system-rules.ts`, `template-urls.ts`, `src/runtime/design-content-gate.ts`, `design-files-gate.ts`) — the `design-expert` plugin produced generic, interchangeable pages because its inspiration phase browsed ~100 Framer/Webflow templates. The harness now executes the corpus doctrine instead: corpus resolution per runtime (Claude marketplace tree vs Codex `plugins/cache////`, semver-selected, no `process.cwd()` fallback), corpus reads recorded as state progression, and a citation↔read join tolerant of both naming conventions and both layouts. The template-platform allowlist is inverted into a frozen denylist — reviving it would resurrect the doctrine this replaces. + +### Changed + +- **Design quotas** (`src/policy/design/state.ts`, `transitions.ts`, `gates-pipeline.ts`) — at least one screenshot in every mode, always; with the corpus present, `{ full: 2, page: 1, component: 1 }` plus the corpus reads. Corpus absent leaves the requirement waived (pre-doctrine quotas), so the branch stays dormant until `refs-design/` ships to the marketplace. +- **`apply_patch` brought into the design pipeline** (`src/runtime/design.ts`, `design-helpers.ts`, `handle-pre.ts`, `handle-post.ts`) — Codex's patch primitive now passes the PRE path/phase/quota gates; `op:"add"` is validated as a `Write`, `delete` is skipped, and `cwd` is wired so the POST re-reads the real file rather than the hunk. The POST is promote-only: a clean file promotes to phase 3, a dirty one changes nothing. + +### Fixed + +- **`pluginsWriteGuard` bypass on relative paths** (`src/runtime/pre-allow.ts`) — the guard anchored on absolute paths only; `corpusRoot` and `pluginsRoot` are now resolved independently, so a corpus override no longer decides where plugin writes are allowed. +- **Substitution bypass in the design content gate** (`src/runtime/design-files-gate.ts`) — `String.replace` treats `$&`, `` $` ``, `$'` and `$$` as substitution patterns; literal replacement now goes through `replace(from, () => to)` / `split().join()`, closing all four quadrants. + ## [0.1.83] - 2026-07-24 ### Fixed diff --git a/bun.lock b/bun.lock index 8235128..92c2832 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ }, "devDependencies": { "@types/bun": "latest", - "tsdown": "^0.22.13", + "tsdown": "^0.22.14", "typedoc": "^0.28.20", "typescript": "^7.0.2", }, @@ -236,7 +236,7 @@ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - "tsdown": ["tsdown@0.22.13", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.12", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.1.2" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.13", "@tsdown/exe": "0.22.13", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-XaYFhtiKRUvTpXv/YAehsHdbEb3LN/iMlzjSINbjlaATtXN2zVPKox2STKhcyFPlh++8Zg7suNN27E679IfAUA=="], + "tsdown": ["tsdown@0.22.14", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.13", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.3.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.14", "@tsdown/exe": "0.22.14", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -250,7 +250,7 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "verkit": ["verkit@0.1.2", "", {}, "sha512-WqkT8n3hqizuCu71W3bUzf5fjBmkbXcudsehe/NbxA8PgqoKnSOY5K0Ba2ckg1qaRaSpSz7as/n9K1R9JXjQKg=="], + "verkit": ["verkit@0.3.0", "", {}, "sha512-Njrh4U8UODGajoZ44QS2C/BsoEM9DTI/aCqY5swsizb+/ap0FamvnCMcZAxrR5+aoC0ZqkawEfpC/N2SBc+xeA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], diff --git a/package.json b/package.json index aa4a5db..011c9f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.83", + "version": "0.1.84", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts", @@ -155,7 +155,7 @@ }, "devDependencies": { "@types/bun": "latest", - "tsdown": "^0.22.13", + "tsdown": "^0.22.14", "typedoc": "^0.28.20", "typescript": "^7.0.2" }, diff --git a/src/policy/design/corpus-resolve.ts b/src/policy/design/corpus-resolve.ts new file mode 100644 index 0000000..f56ac5c --- /dev/null +++ b/src/policy/design/corpus-resolve.ts @@ -0,0 +1,99 @@ +/** + * Corpus/plugins root resolution (fs) and the write guard over it. Resolution + * is PER RUNTIME (Claude marketplaces, Codex versioned plugin cache — same + * pattern as rules-root.ts); runtimes without a plugin structure (kimi today) + * resolve to "" and the corpus gates stay dormant. There is NO cwd fallback + * anywhere: an agent-controlled directory can never become its own taste + * reference (self-attested proof — the failure this gate exists to prevent). + */ +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, normalize } from "node:path"; +import type { Prompt } from "../../prompt/types"; +import { detectHarness, type HarnessId } from "../../detect/harness"; +import { maxSemver } from "../../util/semver"; + +const CORPUS_SUFFIX = join("skills", "design-web", "references", "refs-design"); + +/** Immediate child dir names of `dir`, or [] when unreadable. */ +function children(dir: string): string[] { + try { + return readdirSync(dir).sort(); + } catch { + return []; + } +} + +/** Claude: `/.claude/plugins/marketplaces//plugins/design-expert` ("" when absent). */ +function probeClaude(home: string): string { + const markets = join(home, ".claude", "plugins", "marketplaces"); + for (const m of children(markets)) { + const de = join(markets, m, "plugins", "design-expert"); + if (existsSync(de)) return de; + } + return ""; +} + +/** Codex: `/plugins/cache//design-expert/` ("" when absent). */ +function probeCodex(env: Record, home: string): string { + const cache = join(env.CODEX_HOME ?? join(home, ".codex"), "plugins", "cache"); + for (const m of children(cache)) { + const de = join(cache, m, "design-expert"); + // Only real version dirs compete — no pre-release, no stray backup/ dir. + const latest = maxSemver(children(de).filter((v) => /^\d+\.\d+(\.\d+)?$/.test(v) && existsSync(join(de, v, "skills")))); + if (latest) return join(de, latest); + } + return ""; +} + +/** + * The design-expert plugin root for the ACTIVE runtime ("" = not installed). + * `pluginsDirOverride` short-circuits resolution (tests): a PLUGINS dir whose + * `design-expert` child is used when present, else itself. + */ +export function resolvePluginsRoot( + pluginsDirOverride?: string, + home: string = homedir(), + id: HarnessId = detectHarness().id, + env: Record = process.env, +): string { + if (pluginsDirOverride) { + const de = join(pluginsDirOverride, "design-expert"); + return existsSync(de) ? de : pluginsDirOverride; + } + if (id === "claude-code") return probeClaude(home); + if (id === "codex") return probeCodex(env, home); + return ""; +} + +/** Resolve the delivered refs-design/ root, or "" when absent/unusable (the fail-open signal). */ +export function resolveCorpusRoot( + pluginsDirOverride?: string, + home: string = homedir(), + id: HarnessId = detectHarness().id, + env: Record = process.env, +): string { + const root = resolvePluginsRoot(pluginsDirOverride, home, id, env); + if (!root) return ""; + const corpus = join(root, CORPUS_SUFFIX); + return existsSync(corpus) ? corpus : ""; +} + +/** + * Deny Write/Edit/apply_patch-file under the resolved plugins root: the corpus + * is the artefact the pipeline checks reads against, so it must stay + * agent-proof — including while refs-design/ is still absent. Relative paths + * (the normal form of Codex patches) are resolved against `cwd` first. SCOPE: + * no Bash, no symlinks, no sibling marketplace — the gate protects against + * oversight, not an adversary (the mandatory screenshot stays the unforgeable + * proof; forging the corpus only lowers a quota). + */ +export function pluginsWriteGuard(filePath: string, pluginsRoot: string, cwd = ""): Prompt | null { + const abs = isAbsolute(filePath) ? filePath : cwd ? normalize(join(cwd, filePath)) : filePath; + if (!pluginsRoot || !abs.startsWith(`${pluginsRoot}/`)) return null; + return { + kind: "block", title: "Design pipeline", + reason: "BLOCKED: the design-expert plugin dir (refs-design corpus) is read-only — it is the taste reference your reads are checked against. Never create or modify files there.", + actions: ["Read the corpus with the Read tool; write your own files in the project"], + }; +} diff --git a/src/policy/design/corpus.ts b/src/policy/design/corpus.ts new file mode 100644 index 0000000..f8e9b50 --- /dev/null +++ b/src/policy/design/corpus.ts @@ -0,0 +1,76 @@ +/** + * Corpus anchoring for the design pipeline: refs-design/ read classification, + * per-mode readiness, citation form, citation↔read jointure. Nothing about + * the corpus content (slugs, section titles, count) is hardcoded — everything + * is matched by shape or discovered on disk, so a new reference or a renamed + * section never requires reopening the harness. Pure string logic only; root + * resolution and the write guard live in `corpus-resolve.ts`. + */ +import { basename, join } from "node:path"; +import type { DesignMode } from "./state"; + +export { resolveCorpusRoot, resolvePluginsRoot, pluginsWriteGuard } from "./corpus-resolve"; + +/** What a read under the corpus root is: the index, or a tokens-* procedure file. */ +export type CorpusKind = "index" | "tokens"; + +const TOKENS_RE = /^tokens-.+\.md$/; +const CORPUS_LINE_RE = /^[-*]\s*Corpus:\s*(.+)$/gim; + +/** Classify a read path against the DELIVERED corpus root (null = not a corpus read). */ +export function classifyCorpusRead(filePath: string, corpusRoot: string): CorpusKind | null { + if (!corpusRoot) return null; + if (filePath === join(corpusRoot, "README.md")) return "index"; + if (filePath.startsWith(`${corpusRoot}/`) && TOKENS_RE.test(basename(filePath))) return "tokens"; + return null; +} + +/** Per-mode corpus-read threshold (component >= 1 file, page >= 2 files, full = index + 2 tokens). */ +export function corpusReady(reads: readonly string[], mode: DesignMode): boolean { + if (mode === "component") return reads.length >= 1; + if (mode === "page") return reads.length >= 2; + return reads.includes("README.md") && reads.filter((r) => TOKENS_RE.test(basename(r))).length >= 2; +} + +/** True when the content carries a `- Corpus: ref/section` citation line (form only). */ +export function hasCorpusCitation(content: string): boolean { + return citedCorpusRefs(content).length > 0; +} + +/** + * Extract the reference slugs cited on EVERY `- Corpus:` line (token before + * `/`, or a bare slug). Anchored at line start with NO indentation + * (fail-closed: a citation inside a code block does not count — an indented + * match would also recognize the examples inside the doctrine doc itself). + * The regex is fence-blind, documented: a `- Corpus:` line at column 0 inside + * a fenced block WILL be matched — its refs must join like any other. + */ +export function citedCorpusRefs(content: string): string[] { + const refs: string[] = []; + for (const m of content.matchAll(CORPUS_LINE_RE)) { + const line = m[1] ?? ""; + refs.push(...line.split(",").map((item) => item.trim().match(/^([\w][\w-]*)(?:\/|\s|$)/)?.[1] ?? "").filter(Boolean)); + } + return refs; +} + +/** + * True when the cited ref names a REFERENCE in a recorded corpus read, in + * either naming convention (`reve` README-style ↔ `reve-recode/` on disk — + * the `-recode` suffix is normalized away on BOTH sides) and either layout + * (per-reference directories, or flat `tokens-.md` files). Comparison is + * exact after normalization: no prefix matching, so `acme` never joins + * `acme-corp`, `tokens` never joins every tokens file, and `README` names no + * reference. + */ +export function citationJoinsReads(ref: string, reads: readonly string[]): boolean { + const norm = (s: string): string => s.replace(/\.md$/, "").replace(/-recode$/, ""); + const target = norm(ref); + return reads.some((r) => { + const segs = r.split("/"); + const file = segs[segs.length - 1] ?? ""; + const names = segs.slice(0, -1).map(norm); + if (file.startsWith("tokens-")) names.push(norm(file.slice("tokens-".length))); + return names.includes(target); + }); +} diff --git a/src/policy/design/design-system-rules.ts b/src/policy/design/design-system-rules.ts new file mode 100644 index 0000000..7ca6118 --- /dev/null +++ b/src/policy/design/design-system-rules.ts @@ -0,0 +1,37 @@ +/** + * Content rules for design-system.md — the four hard requirements the + * pipeline validates on the nominal write path (and the opt-in Gemini one). + * Split out of `gates.ts` to keep both files within the SOLID size budget. + */ +import { hasCorpusCitation } from "./corpus"; + +const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/; + +/** + * Forbidden fonts, matched in USAGE contexts — never in free prose: a design + * system may SAY "Inter" ("## Interaction states", "we never use Inter"), it + * may not USE it as a font. Covered forms: CSS declarations (`font:`, + * `font-family:` in any case, multiline values), JS config (`fontFamily:`), + * custom properties (`--*font*`/`--*ff*`/`--*type*`), `@import` URLs, and + * Markdown table cells (`| Body | Inter |`). Explicitly OUT of scope (chosen, + * not overlooked): unquoted prose in bullet lines — distinguishing "usage" + * from "mention" there is prose parsing, not a regex's job. + */ +const FONT_DECL_RE = /(?:font(?:-family)?|fontFamily|--[\w-]*(?:font|ff|type)[\w-]*)\s*:[^;\n]*(?:\n\s*)?[^;\n]*\b(?:Inter|Roboto|Arial|Open Sans)\b/i; +const FONT_IMPORT_RE = /family=[^&"')]*\b(?:Inter|Roboto|Arial|Open Sans)\b/i; +const FONT_TABLE_RE = /\|\s*\*{0,2}(?:Inter|Roboto|Arial|Open Sans)\*{0,2}\s*\|/; + +/** + * Return the requirements missing from a design-system.md (empty = valid). + * The corpus citation satisfies the source requirement ONLY when the corpus + * is actually delivered (`corpusCitationOk`) — absent corpus, the URL is + * mandatory again, exactly the pre-doctrine behavior (fallback never weaker). + */ +export function validateDesignSystem(content: string, corpusCitationOk = false): string[] { + const missing: string[] = []; + if (!content.includes("## Design Reference")) missing.push("## Design Reference section"); + if (!/https?:\/\//.test(content) && !(corpusCitationOk && hasCorpusCitation(content))) missing.push("reference URL (https://…) or Corpus citation"); + if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0"); + if (FONT_DECL_RE.test(content) || FONT_IMPORT_RE.test(content) || FONT_TABLE_RE.test(content)) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)"); + return missing; +} diff --git a/src/policy/design/gates-pipeline.ts b/src/policy/design/gates-pipeline.ts index d463f3a..bcc387d 100644 --- a/src/policy/design/gates-pipeline.ts +++ b/src/policy/design/gates-pipeline.ts @@ -1,35 +1,32 @@ import type { Prompt } from "../../prompt/types"; -import { type DesignState, MIN_SCREENSHOTS } from "./state"; +import { type DesignState, quotaFor } from "./state"; import { PLUGINS_DIR } from "../file-size"; import { deny } from "./gates"; +import { isTemplateUrl } from "./template-urls"; const SKILLS = `${PLUGINS_DIR}/design-expert/skills`; -const KNOWN_DOMAINS: readonly string[] = [ - "framer.website", "webflow.io", "awwwards.com", "godly.website", "lapa.ninja", - "onepagelove.com", "saasframe.io", "bestwebsite.gallery", "landingfolio.com", -]; - /** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. */ -export function designSystemWriteGate(filePath: string, state: DesignState): Prompt | null { +export function designSystemWriteGate(filePath: string, state: DesignState, corpusRequired = false): Prompt | null { if (!filePath.endsWith("design-system.md")) return null; if (state.currentPhase < 2) { return deny( `BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. ` + "RECOVERY: 1) Read identity templates from skills/design-system/ " + - "2) Read design-inspiration.md 3) Browse sites and take a screenshot with " + - "mcp__fuse-browser__browser_screenshot on a LIVE session " + + "2) Read design-inspiration.md 3) Read the refs-design corpus (README.md + relevant tokens-*.md) with the Read tool " + + "4) Screenshot real sector sites with mcp__fuse-browser__browser_screenshot on a LIVE session " + "(note: browser_shots_batch/browser_site_shots do NOT advance the phase) " + - "4) Then write design-system.md", + "5) Then write design-system.md", ); } - const needed = MIN_SCREENSHOTS[state.mode]; + const needed = quotaFor(state.mode, corpusRequired); if (state.screenshotsCount < needed) { return deny( `BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. ` + - `RECOVERY: 1) Take ${needed - state.screenshotsCount} more screenshots with ` + + `RECOVERY: 1) Read the refs-design corpus (README.md + tokens-*.md) if not done ` + + `2) Take ${needed - state.screenshotsCount} more screenshots of REAL sector sites with ` + "mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots do NOT count) " + - "2) Use browser_open + browser_navigate + browser_screenshot fullPage:true 3) Then write design-system.md", + "3) Use browser_open + browser_navigate + browser_screenshot fullPage:true 4) Then write design-system.md", ); } return null; @@ -40,32 +37,45 @@ export function geminiCreateGate(state: DesignState): Prompt | null { if (state.currentPhase < 3) { return deny( "BLOCKED: Cannot call Gemini create_frontend before phase 3. " + - "RECOVERY: 1) Complete screenshot browsing phase 2) Write a valid design-system.md " + + "RECOVERY: 1) Complete the inspiration phase (refs-design corpus + sector screenshots) 2) Write a valid design-system.md " + "3) Then call mcp__gemini-design__create_frontend", ); } if (!state.designSystemValid) { return deny( "BLOCKED: design-system.md not validated. " + - "RECOVERY: 1) Ensure design-system.md has ## Design Reference, OKLCH tokens, typography pair, reference URL " + + "RECOVERY: 1) Ensure design-system.md has ## Design Reference, OKLCH tokens, typography pair, reference URL or Corpus citation " + "2) Then retry mcp__gemini-design__create_frontend", ); } return null; } -/** Gate fuse-browser navigate: phase >= 1, inspiration read, URL in the catalog. */ +/** + * Gate fuse-browser navigate: phase >= 1, inspiration read, no template source in phase 1. + * The allowlist→denylist inversion is the DELIBERATE doctrine change (not a + * weakening): the gate was never the proof — the mandatory screenshot is, + * unchanged and unforgeable in both configurations. Corpus-absent has its + * counterpart already (pre-doctrine quotas + visible warning); what the + * denylist cannot cover (sector-browse quality) belongs to the doc, per the + * rule "the gate requires what is verifiable, the doc prescribes zeal". + */ export function browserNavigateGate(state: DesignState, url: string): Prompt | null { if (state.currentPhase < 1) { return deny(`BLOCKED: Phase 0 not done. READ: ${SKILLS}/design-system/SKILL.md first.`); } if (!state.inspirationRead) { return deny( - `BLOCKED: Read inspiration catalog first. READ: ${SKILLS}/design-web/references/design-inspiration.md + design-inspiration-urls.md`, + `BLOCKED: Read the inspiration doctrine first. READ: ${SKILLS}/design-web/references/design-inspiration.md, then the refs-design corpus (README.md + relevant tokens-*.md) with the Read tool`, ); } - if (state.currentPhase === 1 && url && !KNOWN_DOMAINS.some((d) => url.includes(d))) { - return deny(`BLOCKED: '${url}' not in catalog. Use URLs from design-inspiration-urls.md. Domains: ${KNOWN_DOMAINS.join(", ")}`); + if (state.currentPhase === 1 && url && isTemplateUrl(url)) { + return deny( + `BLOCKED: '${url}' is a template source — templates are BUILT to be interchangeable, ` + + "so extracting taste from them converges every page to the same body. " + + "RECOVERY: 1) taste comes from the refs-design corpus (README.md + relevant tokens-*.md) " + + "2) browse 1-2 REAL sites in the client's sector, for register only (award galleries are fine as outbound-link finders)", + ); } return null; } diff --git a/src/policy/design/gates.ts b/src/policy/design/gates.ts index 4633b39..34a568d 100644 --- a/src/policy/design/gates.ts +++ b/src/policy/design/gates.ts @@ -5,10 +5,10 @@ import { activeDesignAgent } from "./flag"; import { isUiWrite } from "./skill-gate"; import { SKILL_TRIGGERS } from "./skill-triggers"; +export { validateDesignSystem } from "./design-system-rules"; + export const ALLOWED_WRITE: RegExp = /\.(html|css|md|json)$/; export const EXEMPT_DIRS: readonly string[] = ["node_modules/", "dist/", "build/", ".claude/"]; -const FORBIDDEN_FONTS: readonly string[] = ["Inter", "Roboto", "Arial", "Open Sans"]; -const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/; const NAV = "mcp__fuse-browser__browser_navigate"; const SHOT = "mcp__fuse-browser__browser_screenshot"; const GEMINI = "mcp__gemini-design__create_frontend"; @@ -38,16 +38,6 @@ export function stateFileGate(filePath: string): Prompt | null { : null; } -/** Return the requirements missing from a design-system.md (empty = valid). */ -export function validateDesignSystem(content: string): string[] { - const missing: string[] = []; - if (!content.includes("## Design Reference")) missing.push("## Design Reference section"); - if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)"); - if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0"); - if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)"); - return missing; -} - /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */ export function screenshotScrollGate(state: DesignState): Prompt | null { return state.scrolledSinceNav @@ -72,7 +62,7 @@ export interface PassNoticeInput { agentId: string; tool: string; filePath: stri * null when nothing applies. The check-design-skill line fires for ANY agent * on a UI write; the rest only inside the active design-agent context. */ -export function designPassNotice(i: PassNoticeInput, cacheDir: string): Prompt | null { +export function designPassNotice(i: PassNoticeInput & { corpusMissing?: boolean }, cacheDir: string): Prompt | null { const isWrite = i.tool === "Write" || i.tool === "Edit"; const fp = i.filePath; const lines: string[] = []; @@ -82,6 +72,7 @@ export function designPassNotice(i: PassNoticeInput, cacheDir: string): Prompt | } const state = i.agentId && activeDesignAgent(cacheDir) === i.agentId ? loadDesignState(cacheDir, i.agentId) : null; if (state && i.phase === "pre") { + if (i.corpusMissing) lines.push("design-corpus: refs-design corpus not found — corpus requirement waived (screenshot-only fallback, install defect)"); if (isWrite && fp && !EXEMPT_DIRS.some((d) => fp.includes(d)) && ALLOWED_WRITE.test(fp)) lines.push(`enforce-html-css-only: allowed: ${basename(fp)}`); if (i.tool === NAV) lines.push(`check-inspiration-read: pass (${i.url})`); if (i.tool === GEMINI && geminiEnabled()) lines.push("validate-design-system: design-system.md ok"); diff --git a/src/policy/design/state.ts b/src/policy/design/state.ts index 335e448..a5bff58 100644 --- a/src/policy/design/state.ts +++ b/src/policy/design/state.ts @@ -14,13 +14,28 @@ export interface DesignState { inspirationRead: boolean; scrolledSinceNav: boolean; screenshotsCount: number; + /** Distinct refs-design files read (paths relative to the corpus root), deduped. */ + corpusReads: string[]; designSystemExists: boolean; designSystemValid: boolean; geminiCalls: number; } -/** Minimum fuse-browser screenshots required before writing design-system.md, per mode. */ -export const MIN_SCREENSHOTS: Readonly> = { full: 4, page: 2, component: 0 }; +/** Minimum fuse-browser screenshots required before writing design-system.md, per mode (corpus present). */ +export const MIN_SCREENSHOTS: Readonly> = { full: 2, page: 1, component: 1 }; + +/** + * Screenshot quotas when the refs-design corpus is ABSENT (install defect → + * fail-open): exactly today's effective behavior, so the fallback can never be + * weaker than the pre-doctrine gate. Component is 1 explicitly — the effective + * quota today (the first screenshot trips `1 >= 0`), no longer a hidden quirk. + */ +export const MIN_SCREENSHOTS_NO_CORPUS: Readonly> = { full: 4, page: 2, component: 1 }; + +/** Screenshot quota for `mode` — the SINGLE choice point between the two tables, driven by corpus availability. */ +export function quotaFor(mode: DesignMode, corpusRequired: boolean): number { + return (corpusRequired ? MIN_SCREENSHOTS : MIN_SCREENSHOTS_NO_CORPUS)[mode]; +} const stateFile = (cacheDir: string, agentId: string): string => join(cacheDir, `.design-state-${agentId}.json`); @@ -29,7 +44,11 @@ export function loadDesignState(cacheDir: string, agentId: string): DesignState const path = stateFile(cacheDir, agentId); if (!existsSync(path)) return null; try { - return JSON.parse(readFileSync(path, "utf8")) as DesignState; + const parsed = JSON.parse(readFileSync(path, "utf8")) as DesignState; + // Retrocompat: states persisted before corpusReads existed load with an + // empty list (never undefined — an undefined count would silently + // fail-open or fail-closed the corpus conjunction downstream). + return { ...parsed, corpusReads: parsed.corpusReads ?? [] }; } catch { return null; } @@ -44,7 +63,7 @@ export function saveDesignState(cacheDir: string, state: DesignState): void { export function initDesignState(agentId: string, mode: DesignMode, designSystemExists: boolean): DesignState { return { agentId, mode, currentPhase: 0, phasesCompleted: [], inspirationRead: false, - scrolledSinceNav: false, screenshotsCount: 0, designSystemExists, designSystemValid: false, geminiCalls: 0, + scrolledSinceNav: false, screenshotsCount: 0, corpusReads: [], designSystemExists, designSystemValid: false, geminiCalls: 0, }; } diff --git a/src/policy/design/template-urls.ts b/src/policy/design/template-urls.ts new file mode 100644 index 0000000..7e5ff2f --- /dev/null +++ b/src/policy/design/template-urls.ts @@ -0,0 +1,54 @@ +/** + * Template-platform detection for the phase-1 navigate gate + * (design-inspiration.md §FORBIDDEN Navigation Targets). Two rules, because + * neither alone matches the doctrine: + * - template SHOPS are banned outright, on the host (subdomains included); + * - the path rule (/templates, /themes) applies ONLY to real product + * platforms that also host a template catalog — never to the whole web, + * or sothebys.com/en/marketplace (register research) would be denied. + * The host is normalized (lowercase, port, userinfo, trailing dot stripped). + * HONEST SCOPE: this is a LEXICAL filter — it follows no redirect, is not and + * will never be exhaustive, and it is NOT the taste gate: the source of taste + * is the refs-design corpus. This list exists to catch the reflex, not to map + * the template economy. Frozen by owner decision (C5): no host or pattern may + * be added or removed without re-opening that decision. + * Award galleries are deliberately absent: they pass, as outbound-link finders. + */ + +/** Template shops: banned outright, any path (subdomains included). */ +const TEMPLATE_HOSTS: readonly string[] = [ + "themeforest.net", "themes.shopify.com", "marketplace.atlassian.com", + "templatemonster.com", "creativemarket.com", "ui8.net", "html5up.net", + "colorlib.com", "bootstrapmade.com", "startbootstrap.com", "cruip.com", + "uideck.com", "envato.com", "tailwindui.com", +]; + +/** Real-product platforms whose template CATALOGS are banned via the path rule. */ +const PLATFORM_HOSTS: readonly string[] = [ + "framer.com", "webflow.com", "vercel.com", "astro.build", "squarespace.com", "wix.com", +]; + +/** + * Template-catalog path segments — evaluated ONLY on PLATFORM_HOSTS, and + * deliberately WITHOUT `/marketplace`: those platforms run real, non-template + * marketplaces (vercel.com/marketplace is infra). Atlassian/Shopify template + * stores are already covered by the full host ban above. + */ +const TEMPLATE_PATH_RE = /^\/(templates|themes)(\/|$)/i; + +/** Host part of `url`, normalized: lowercase, no port, no userinfo, no trailing dot. */ +function hostOf(url: string): string { + return (/^https?:\/\/(?:[^@/?#]*@)?([^/:?#]+)/i.exec(url)?.[1] ?? "").replace(/\.$/, "").toLowerCase(); +} + +const under = (host: string, domain: string): boolean => host === domain || host.endsWith(`.${domain}`); + +/** True when `url` points at a banned template source (host rule, then platform path rule). */ +export function isTemplateUrl(url: string): boolean { + const host = hostOf(url); + if (!host) return false; + if (TEMPLATE_HOSTS.some((d) => under(host, d))) return true; + if (!PLATFORM_HOSTS.some((d) => under(host, d))) return false; + const path = /^https?:\/\/(?:[^@/?#]*@)?[^/?#]*(\/[^?#]*)/i.exec(url)?.[1] ?? "/"; + return TEMPLATE_PATH_RE.test(path); +} diff --git a/src/policy/design/transitions.ts b/src/policy/design/transitions.ts index 2a8a21a..0e5e849 100644 --- a/src/policy/design/transitions.ts +++ b/src/policy/design/transitions.ts @@ -1,4 +1,5 @@ -import type { DesignState, DesignMode } from "./state"; +import { type DesignState, type DesignMode, quotaFor } from "./state"; +import { corpusReady } from "./corpus"; /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */ export function detectMode(prompt: string, designSystemExists: boolean): DesignMode { @@ -7,15 +8,21 @@ export function detectMode(prompt: string, designSystemExists: boolean): DesignM return designSystemExists ? "page" : "full"; } -/** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */ -export function recordScreenshot(state: DesignState, needed: number): DesignState { - const screenshotsCount = state.screenshotsCount + 1; - const next: DesignState = { ...state, screenshotsCount }; - if (screenshotsCount >= needed && state.currentPhase < 2) { - next.currentPhase = 2; - next.phasesCompleted = [...new Set([...state.phasesCompleted, "identity", "research"])]; - } - return next; +/** + * Re-evaluate the phase-2 conjunction MONOTONICALLY: corpus reads (when the + * corpus is delivered) AND the screenshot quota must both hold, and the phase + * is never written downwards (a Read after phase 3 changes nothing). + */ +function maybePhase2(state: DesignState, corpusRequired: boolean): DesignState { + if (state.currentPhase >= 2) return state; + const corpusOk = !corpusRequired || corpusReady(state.corpusReads, state.mode); + if (!corpusOk || state.screenshotsCount < quotaFor(state.mode, corpusRequired)) return state; + return { ...state, currentPhase: 2, phasesCompleted: [...new Set([...state.phasesCompleted, "identity", "research"])] }; +} + +/** Record a screenshot: bump the count, then re-evaluate the phase-2 conjunction. */ +export function recordScreenshot(state: DesignState, corpusRequired: boolean): DesignState { + return maybePhase2({ ...state, screenshotsCount: state.screenshotsCount + 1 }, corpusRequired); } /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */ @@ -37,16 +44,25 @@ export function recordValidDesignSystem(state: DesignState): DesignState { }; } +/** + * Record a corpus read: distinct files only (a re-read never inflates the + * list), then re-evaluate the phase-2 conjunction. `relPath` is relative to + * the corpus root, keeping the persisted state small and prefix-independent. + */ +export function recordCorpusRead(state: DesignState, relPath: string, corpusRequired: boolean): DesignState { + return maybePhase2({ ...state, corpusReads: [...new Set([...state.corpusReads, relPath])] }, corpusRequired); +} + /** * Record a skill-file Read: reading the identity templates enters phase 1 (browsing * allowed); reading the inspiration catalog satisfies the browse prerequisite. */ -export function recordRead(state: DesignState, filePath: string): DesignState { +export function recordRead(state: DesignState, filePath: string, corpusRequired = false): DesignState { const next: DesignState = { ...state }; if (filePath.includes("design-system/SKILL.md")) { next.currentPhase = Math.max(state.currentPhase, 1); next.phasesCompleted = [...new Set([...state.phasesCompleted, "identity"])]; } if (filePath.includes("design-inspiration")) next.inspirationRead = true; - return next; + return maybePhase2(next, corpusRequired); } diff --git a/src/runtime/design-content-gate.ts b/src/runtime/design-content-gate.ts new file mode 100644 index 0000000..7c3a024 --- /dev/null +++ b/src/runtime/design-content-gate.ts @@ -0,0 +1,95 @@ +/** + * @module design-content-gate + * PRE content gate for design-system.md — the nominal path (validateDesignSystem + * used to run only behind the opt-in Gemini branch, so designSystemValid meant + * "a write happened", not "the content is valid"). Split out of + * `design-helpers.ts` to keep both files within the SOLID size budget (SRP). + * @packageDocumentation + */ +import { readFileSync, existsSync } from "node:fs"; +import type { Prompt } from "../prompt/types"; +import type { DesignState } from "../policy/design/state"; +import { deny, validateDesignSystem } from "../policy/design/gates"; +import { citedCorpusRefs, citationJoinsReads } from "../policy/design/corpus"; +import { substituteLiteral } from "./design-files-gate"; + +/** Inputs for {@link designSystemContentGate} — the PRE write event plus resolved corpus context. */ +interface ContentGateInput { + filePath: string; + tool: string; + content: string; + oldString?: string; + replaceAll: boolean; + state: DesignState; + corpusRoot: string; + corpusRequired: boolean; +} + +/** + * Every content violation of a design-system.md text, GRANULAR (one entry per + * unmet requirement and per unread cited reference — so a worsening check can + * diff two violation sets): the four hard requirements, plus the citation↔read + * jointure when the corpus is delivered. + */ +export function designSystemProblems(content: string, reads: readonly string[], corpusRequired: boolean): string[] { + const problems = validateDesignSystem(content, corpusRequired); + if (corpusRequired) { + for (const ref of citedCorpusRefs(content)) { + if (!citationJoinsReads(ref, reads)) problems.push(`cited corpus reference never read: ${ref}`); + } + } + return problems; +} + +/** Content check for a design-system.md already on disk (the Gemini create_frontend path). */ +export function checkDesignSystemContent(content: string, reads: readonly string[], corpusRequired: boolean): Prompt | null { + const problems = designSystemProblems(content, reads, corpusRequired); + return problems.length ? deny(`BLOCKED: design-system.md too generic. Missing: ${problems.join(", ")}.`) : null; +} + +/** + * The content a Write/Edit would LEAVE in design-system.md, and the on-disk + * original when the file exists (for BOTH tools). Edit reconstructs via + * literal substitution (first occurrence, or all under replace_all — the Edit + * tool's own semantics, mirroring edit-outcome.ts). Null when unverifiable + * (unreadable file or stale old_string) — fail-closed, such an Edit fails at + * tool level anyway. + */ +function effectiveContents(i: ContentGateInput): { result: string; original: string | null } | null { + if (i.tool !== "Edit") { + let original: string | null = null; + try { + if (existsSync(i.filePath)) original = readFileSync(i.filePath, "utf8"); + } catch { + original = null; + } + return { result: i.content, original }; + } + let disk: string; + try { + disk = readFileSync(i.filePath, "utf8"); + } catch { + return null; + } + if (!i.oldString || !disk.includes(i.oldString)) return null; + return { result: substituteLiteral(disk, i.oldString, i.content, i.replaceAll), original: disk }; +} + +/** + * Block only the violations the change INTRODUCES (`problems(result) ⊄ + * problems(original)`): a legacy design-system.md that predates the doctrine + * stays editable — and rewritable — including byte-identically or toward + * conformance; a change that adds a NEW problem (or writes an invalid file + * from scratch) is blocked. + */ +export function designSystemContentGate(i: ContentGateInput): Prompt | null { + if (!i.filePath.endsWith("design-system.md")) return null; + const pair = effectiveContents(i); + if (pair === null) return deny("BLOCKED: cannot verify the edit result (unreadable file or stale old_string). RECOVERY: re-read design-system.md, then retry."); + const result = designSystemProblems(pair.result, i.state.corpusReads, i.corpusRequired); + if (!result.length) return null; + const original = pair.original !== null ? designSystemProblems(pair.original, i.state.corpusReads, i.corpusRequired) : []; + const introduced = result.filter((p) => !original.includes(p)); + if (!introduced.length) return null; + return deny(`BLOCKED: design-system.md too generic. Missing: ${introduced.join(", ")}. RECOVERY: fix the content (read cited refs-design references with the Read tool), then retry.`); +} diff --git a/src/runtime/design-files-gate.ts b/src/runtime/design-files-gate.ts new file mode 100644 index 0000000..5a77af7 --- /dev/null +++ b/src/runtime/design-files-gate.ts @@ -0,0 +1,65 @@ +/** + * @module design-files-gate + * Gates for multi-file write primitives (Codex `apply_patch`, fanned into + * `event.files` by normalize.ts): apply_patch IS the Write tool under Codex, + * so the design pipeline must see it. Per op, because `content` means + * different things (apply-patch.ts:10-15): + * - add: content IS the full new document — content-gated like a Write; + * - update: content is the hunk's new side, APPROXIMATE, never the document — + * only content-independent gates run (path, state-file, phase/quota). + * No BLOCKING or DEGRADING validation on this path — promote-only: + * the POST re-reads the real file, a clean content promotes to + * phase 3, a dirty one changes nothing. And it would not bite + * anyway: designSystemValid is consumed ONLY under + * FUSE_DESIGN_GEMINI=1 (off by default — an informational flag), + * and designSystemExists is never read after init; + * - delete: skipped (parity with the sibling applyPatchGate). + * One violating file blocks the whole envelope. KNOWN GAPS (documented, out + * of scope): apply_patch also bypasses uiDesignSkillGate and the Gemini + * create_frontend precondition; htmlCssOnlyGate stays excluded (owner D2); + * and `Design-System.md` (case) escapes every endsWith check, here and on + * Write — a real bypass on case-insensitive macOS, not widened now. + * @packageDocumentation + */ +import type { Prompt } from "../prompt/types"; +import type { NormalizedFile } from "./normalize"; +import type { DesignState } from "../policy/design/state"; +import { pluginsWriteGuard } from "../policy/design/corpus"; +import { stateFileGate } from "../policy/design/gates"; +import { designSystemWriteGate } from "../policy/design/gates-pipeline"; +import { designSystemContentGate } from "./design-content-gate"; + +/** + * Literal substitution, first occurrence or all — NEVER String.replace's + * replacement-string semantics: an agent-controlled new_string of `$&`, `` $` ``, + * `$'` or `$$` must be written (and validated) literally, not interpreted as + * the matched text (that made the gate validate a document the tool would + * never write — the `$&` bypass). Used on both sides: PRE reconstruction and + * POST reverse-reconstruction. + */ +export function substituteLiteral(s: string, from: string, to: string, all: boolean): string { + return all ? s.split(from).join(to) : s.replace(from, () => to); +} + +/** Gate every file of a multi-file write primitive; the first violation blocks the envelope. */ +export function designFilesGate( + files: readonly NormalizedFile[], + state: DesignState, + pluginsRoot: string, + corpusRoot: string, + corpusRequired: boolean, + cwd: string, +): Prompt | null { + for (const f of files) { + const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath); + if (hit) return hit; + if (f.op === "delete") continue; + if (!f.filePath.endsWith("design-system.md")) continue; + const gate = designSystemWriteGate(f.filePath, state, corpusRequired) + ?? (f.op === "add" + ? designSystemContentGate({ filePath: f.filePath, tool: "Write", content: f.content, oldString: undefined, replaceAll: false, state, corpusRoot, corpusRequired }) + : null); + if (gate) return gate; + } + return null; +} diff --git a/src/runtime/design-helpers.ts b/src/runtime/design-helpers.ts index 8dc11f3..a42162b 100644 --- a/src/runtime/design-helpers.ts +++ b/src/runtime/design-helpers.ts @@ -6,10 +6,15 @@ * @packageDocumentation */ import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import type { NormalizedEvent } from "./normalize"; -import { type DesignState, saveDesignState, MIN_SCREENSHOTS } from "../policy/design/state"; -import { recordScreenshot, recordNavigate, recordScroll, recordValidDesignSystem, recordRead } from "../policy/design/transitions"; +import { type DesignState, saveDesignState } from "../policy/design/state"; +import { recordScreenshot, recordCorpusRead, recordNavigate, recordScroll, recordValidDesignSystem, recordRead } from "../policy/design/transitions"; +import { classifyCorpusRead } from "../policy/design/corpus"; +import { designSystemProblems } from "./design-content-gate"; +import { substituteLiteral } from "./design-files-gate"; + +export { designSystemContentGate } from "./design-content-gate"; const NAV = "mcp__fuse-browser__browser_navigate"; const SHOT = "mcp__fuse-browser__browser_screenshot"; @@ -36,13 +41,57 @@ export function findDesignSystem(cwd: string): string { } /** Apply a PostToolUse fuse-browser transition to the design state. */ -export function recordPost(event: NormalizedEvent, cacheDir: string, state: DesignState): void { - if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode])); +export function recordPost(event: NormalizedEvent, cacheDir: string, state: DesignState, corpusRoot = "", corpusRequired = false, cwd = ""): void { + if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, corpusRequired)); else if (event.tool === NAV) saveDesignState(cacheDir, recordNavigate(state)); else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state)); else if (event.tool === GEMINI) saveDesignState(cacheDir, { ...state, geminiCalls: state.geminiCalls + 1 }); - else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? "")); - else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) { - saveDesignState(cacheDir, recordValidDesignSystem(state)); + else if (event.tool === "Read") { + const fp = event.filePath ?? ""; + // A corpus read counts only when anchored under the delivered root AND the + // file exists (no tool_response reaches this hook — existsSync compensates). + if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) { + saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired)); + } else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired)); + } else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) { + // Write access ≠ validity, same rule both sides: POST validates only a + // zero-problem content and DEGRADES only what PRE would have blocked. + const fp = event.filePath ?? ""; + let content: string; + try { + content = readFileSync(fp, "utf8"); + } catch { + return; + } + const problems = designSystemProblems(content, state.corpusReads, corpusRequired); + if (!problems.length) { + saveDesignState(cacheDir, recordValidDesignSystem(state)); + return; + } + // Edit: degrade only on problems the edit INTRODUCED (original reverse-rebuilt + // with LITERAL substitution; a deletion edit is not reconstructable — D5). + if (event.tool === "Edit" && event.oldString && event.content && content.includes(event.content)) { + const original = substituteLiteral(content, event.content, event.oldString, event.input.replace_all === true); + const introduced = problems.filter((p) => !designSystemProblems(original, state.corpusReads, corpusRequired).includes(p)); + if (introduced.length) saveDesignState(cacheDir, { ...state, designSystemValid: false }); + } + // Write / unreconstructable Edit: the PRE gate enforced not-worsening — never degrade here. + } else if (event.files) { + // Codex apply_patch: PROMOTE-ONLY, never blocking nor degrading — the POST + // re-reads the REAL file (the hunk is never the document): clean → phase 3, + // dirty → nothing. designSystemValid is only consumed under FUSE_DESIGN_GEMINI. + const ds = event.files.find((f) => f.op !== "delete" && f.filePath.endsWith("design-system.md")); + if (ds) { + const abs = isAbsolute(ds.filePath) ? ds.filePath : join(cwd, ds.filePath); + let content: string; + try { + content = readFileSync(abs, "utf8"); + } catch { + return; + } + if (!designSystemProblems(content, state.corpusReads, corpusRequired).length) { + saveDesignState(cacheDir, recordValidDesignSystem(state)); + } + } } } diff --git a/src/runtime/design.ts b/src/runtime/design.ts index 7571eca..aac638b 100644 --- a/src/runtime/design.ts +++ b/src/runtime/design.ts @@ -6,9 +6,12 @@ import { activeDesignAgent } from "../policy/design/flag"; import { runDesignChecks } from "../policy/design/content-checks"; import { uiDesignSkillGate } from "../policy/design/skill-gate"; import { collectDesignEvidence } from "../policy/design/skill-evidence"; -import { findDesignSystem, recordPost } from "./design-helpers"; +import { findDesignSystem, recordPost, designSystemContentGate } from "./design-helpers"; +import { checkDesignSystemContent } from "./design-content-gate"; +import { designFilesGate } from "./design-files-gate"; +import { resolveCorpusRoot, resolvePluginsRoot, pluginsWriteGuard } from "../policy/design/corpus"; import { - htmlCssOnlyGate, stateFileGate, screenshotScrollGate, validateDesignSystem, + htmlCssOnlyGate, stateFileGate, screenshotScrollGate, geminiEnabled, } from "../policy/design/gates"; import { designSystemWriteGate, geminiCreateGate, browserNavigateGate } from "../policy/design/gates-pipeline"; @@ -21,7 +24,7 @@ const GEMINI = "mcp__gemini-design__create_frontend"; * Design-pipeline gate (effectful: reads/writes the design state + design-system.md). * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires. */ -export function designGate(payload: Record, event: NormalizedEvent, cacheDir: string, cwd: string): Prompt | null { +export function designGate(payload: Record, event: NormalizedEvent, cacheDir: string, cwd: string, corpusRootOverride?: string, pluginsRootOverride?: string): Prompt | null { // UI design-skill gate (ports check-design-skill.py): fires for ANY agent on a // UI write — requires a design-skill read + ANY doc source (Context7/Exa/web). // Gemini is NEVER required. Runs before the design-agent pipeline state logic. @@ -45,9 +48,15 @@ export function designGate(payload: Record, event: NormalizedEv state = initDesignState(id, dsExists ? "page" : "full", dsExists); saveDesignState(cacheDir, state); } + // Corpus + plugin roots, resolved INDEPENDENTLY (corpusRoot never decides + // pluginsRoot — F4). Corpus absent ⇒ requirement waived (pre-doctrine quotas); + // the plugin-root write guard stays active against refs-design fabrication. + const corpusRoot = corpusRootOverride ?? resolveCorpusRoot(); + const corpusRequired = corpusRoot !== ""; + const pluginsRoot = pluginsRootOverride ?? resolvePluginsRoot(); if (event.phase === "post") { - recordPost(event, cacheDir, state); + recordPost(event, cacheDir, state, corpusRoot, corpusRequired, cwd); if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) { const warnings = runDesignChecks(event.content ?? ""); if (warnings.length) return { kind: "inform", title: "Design review", reason: warnings.join(" "), actions: [] }; @@ -56,17 +65,17 @@ export function designGate(payload: Record, event: NormalizedEv } if (event.tool === "Write" || event.tool === "Edit") { const fp = event.filePath ?? ""; - // Parity: only design-system.md is screenshot-quota-gated (designSystemWriteGate, - // ports the live pipeline_checks.check_design_system_write). The old - // preScreenshotWriteGate ported check-browser-browsing.py, which is DEAD in the - // plugin (not wired in any hooks.json) and wrongly blocked plain .html/.css writes. - const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state); + // Parity: only design-system.md is screenshot-quota-gated (designSystemWriteGate). + const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state, corpusRequired) + ?? designSystemContentGate({ filePath: fp, tool: event.tool, content: event.content ?? "", oldString: event.oldString, replaceAll: event.input.replace_all === true, state, corpusRoot, corpusRequired }); if (base) return base; if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) { return { kind: "block", title: "Design pipeline", reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.", actions: ["Call mcp__gemini-design__create_frontend first"] }; } return null; } + // Codex apply_patch (D2): gate each fanned-out file like a Write. + if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd); if (event.tool === NAV) { return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : ""); } @@ -79,12 +88,12 @@ export function designGate(payload: Record, event: NormalizedEv // Parity validate-design-system.py DENY_NOT_FOUND: a MISSING file gets its own // recovery message, distinct from the "too generic" message for a present-but-thin one. if (ds === "") { - return { kind: "block", title: "Design pipeline", reason: "BLOCKED: design-system.md not found. RECOVERY: 1) Read the identity templates 2) Read design-inspiration.md 3) Browse 4 reference sites 4) Write design-system.md, then retry create_frontend.", actions: ["Create design-system.md via the pipeline, then retry create_frontend"] }; - } - const missing = validateDesignSystem(ds); - if (missing.length) { - return { kind: "block", title: "Design pipeline", reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`, actions: ["Fix design-system.md, then retry create_frontend"] }; + return { kind: "block", title: "Design pipeline", reason: "BLOCKED: design-system.md not found. RECOVERY: 1) Read the identity templates 2) Read design-inspiration.md 3) Read the refs-design corpus (README.md + relevant tokens-*.md) 4) Screenshot 1-2 real sector sites 5) Write design-system.md, then retry create_frontend.", actions: ["Create design-system.md via the pipeline, then retry create_frontend"] }; } + // Same content rules as the nominal write path (jointure included): the + // Gemini branch must not be weaker than the gate the Write just went through. + const contentBlock = checkDesignSystemContent(ds, state.corpusReads, corpusRequired); + if (contentBlock) return contentBlock; saveDesignState(cacheDir, recordValidDesignSystem(state)); } return null; diff --git a/src/runtime/handle-post.ts b/src/runtime/handle-post.ts index 1e2479d..30c31e2 100644 --- a/src/runtime/handle-post.ts +++ b/src/runtime/handle-post.ts @@ -34,7 +34,7 @@ export async function handlePost(ctx: PreContext): Promise { const { id, payload, event, framework, mcpDir, file, opts } = ctx; const response = payload.tool_response ?? payload.tool_output; mcpPostStore(event.tool, event.input, response, mcpDir); - const designWarn = designGate(payload, event, mcpDir, opts.cwd); + const designWarn = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot); const activities = activityFor({ tool: event.tool, input: event.input, sessionId: event.sessionId, framework, now: opts.now, responseLength: extractText(response).length }); for (const activity of activities) await recordActivity(file, activity); // Session-scoped evidence (parity track-subagent-research.py): sub-agent hooks diff --git a/src/runtime/handle-pre.ts b/src/runtime/handle-pre.ts index a83b396..f387d97 100644 --- a/src/runtime/handle-pre.ts +++ b/src/runtime/handle-pre.ts @@ -40,7 +40,7 @@ export async function handlePre(ctx: PreContext): Promise { return { stdout: intercept.stdout, exit: 0 }; } - const designBlock = designGate(payload, event, mcpDir, opts.cwd); + const designBlock = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot); if (designBlock) return { stdout: withDenyNotice(id, respond(id, designBlock), designBlock, event.sessionId, dirname(file), opts.now), exit: 0 }; // Security scope is advisory-only (ports check-security-skill.py): emit the @@ -96,5 +96,5 @@ export async function handlePre(ctx: PreContext): Promise { // Every gate allowed: hand off to the ALLOW-path assembly (pass notice + // decision-time lesson + evidence-fresh notice). A deny/ask already returned // above, so nothing it emits can block nor override a decision. - return allowOutcome(id, event, payload, mcpDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }); + return allowOutcome(id, event, payload, mcpDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }, opts.corpusRoot); } diff --git a/src/runtime/handle-types.ts b/src/runtime/handle-types.ts index 3c94d9b..5fdaac9 100644 --- a/src/runtime/handle-types.ts +++ b/src/runtime/handle-types.ts @@ -10,6 +10,12 @@ export interface HandleOptions { windowMs?: number; /** Which plugin's hooks.json invoked the harness (selects lifecycle behavior). */ scope?: PluginScope; + /** + * Test/integration seam: the resolved refs-design corpus root ("" = absent). + * Undefined = resolve from disk (the production behavior); supplied = used + * as-is, so tests can drive the corpus-present branch deterministically. + */ + corpusRoot?: string; } /** What the hook bin should print + exit with. */ diff --git a/src/runtime/pre-allow.ts b/src/runtime/pre-allow.ts index 272000b..414f92a 100644 --- a/src/runtime/pre-allow.ts +++ b/src/runtime/pre-allow.ts @@ -7,6 +7,7 @@ * Write/Edit that clears the freshness gate. */ import { designPassNotice } from "../policy/design/gates"; +import { resolveCorpusRoot } from "../policy/design/corpus"; import { lessonFor } from "../policy/lessons/lesson-gate"; import { lessonsFileFor } from "./lifecycle/lessons/state"; import { projectRoot } from "../util/project-root"; @@ -71,11 +72,14 @@ export async function allowOutcome( mcpDir: string, cwd: string, evidence?: EvidenceCheck, + corpusRoot?: string, ): Promise { + const agentId = typeof payload.agent_id === "string" ? payload.agent_id : ""; const notice = designPassNotice({ - agentId: typeof payload.agent_id === "string" ? payload.agent_id : "", + agentId, tool: event.tool, filePath: event.filePath ?? "", content: event.content ?? "", url: typeof event.input.url === "string" ? event.input.url : "", phase: "pre", + corpusMissing: agentId !== "" && (corpusRoot ?? resolveCorpusRoot()) === "", }, mcpDir); const lesson = lessonFor(event.tool, event.input, { file: lessonsFileFor(projectRoot(cwd)), once: oncePerWindow }); const evidenceNotice = evidence ? await freshEvidenceNotice(event, evidence, cwd) : null; diff --git a/test/apexauth-usermessage.test.ts b/test/apexauth-usermessage.test.ts index 7f61aae..7e3adc0 100644 --- a/test/apexauth-usermessage.test.ts +++ b/test/apexauth-usermessage.test.ts @@ -91,8 +91,9 @@ test("handleHook e2e: a PASSING design-agent navigate emits the user notice on c const cwd = tmp(); designCache("agE", 1, projectLayout(cwd).cacheDir); const payload = { hook_event_name: "PreToolUse", session_id: "sE", agent_id: "agE", tool_name: "mcp__fuse-browser__browser_navigate", tool_input: { url: "https://godly.website/site" } }; - const out = await handleHook("claude-code", payload, { now: 5000, cwd }); - expect((JSON.parse(out.stdout) as Record).systemMessage).toBe("check-inspiration-read: pass (https://godly.website/site)\npipeline-gate: phase 1 ok"); + // Corpus ABSENT (injected ""): the waived-requirement warning is user-visible — deliberate new line. + const out = await handleHook("claude-code", payload, { now: 5000, cwd, corpusRoot: "" }); + expect((JSON.parse(out.stdout) as Record).systemMessage).toBe("design-corpus: refs-design corpus not found — corpus requirement waived (screenshot-only fallback, install defect)\ncheck-inspiration-read: pass (https://godly.website/site)\npipeline-gate: phase 1 ok"); // Same passing event on a harness without a user channel (cline shape): no crash, plain allow. expect((await handleHook("cline", { preToolUse: { toolName: "x", parameters: {} }, taskId: "tE" }, { now: 5000, cwd })).stdout).toBe(""); }); diff --git a/test/design-apply-patch.test.ts b/test/design-apply-patch.test.ts new file mode 100644 index 0000000..99fb721 --- /dev/null +++ b/test/design-apply-patch.test.ts @@ -0,0 +1,89 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { designGate } from "../src/runtime/design"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; + +/** + * apply_patch through the REAL chain — parseApplyPatch → normalizeEvent → + * designGate. For op:"update" the patch content is a HUNK, never the + * document: the PRE must not content-gate it (validation deferred to POST), + * the POST validates the real file from disk. + */ +const URL_DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-ap-")); + +function setup(diskContent: string) { + const cache = tmp(); + const proj = tmp(); + const f = join(proj, "design-system.md"); + writeFileSync(f, diskContent); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 2, inspirationRead: true, screenshotsCount: 4 }); + return { cache, proj, f }; +} +const ev = (phase: string, patch: string) => + normalizeEvent("codex", { hook_event_name: phase === "pre" ? "PreToolUse" : "PostToolUse", tool_name: "apply_patch", tool_input: { command: patch }, session_id: "s", agent_id: "ag" }); + +test("update patch on a VALID design-system.md: PRE allows (the hunk is not the document), POST validates the real file", () => { + const { cache, proj, f } = setup(URL_DS); + const patch = `*** Begin Patch\n*** Update File: ${f}\n@@\n-Inspiration: https://boulangerie-dupont.fr\n+Inspiration: https://boulangerie-martin.fr\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("pre", patch), cache, proj, "")).toBeNull(); + // The tool lands: the real file now carries the new URL (still valid). + writeFileSync(f, URL_DS.replace("boulangerie-dupont", "boulangerie-martin")); + expect(designGate({ agent_id: "ag" }, ev("post", patch), cache, proj, "")).toBeNull(); + const s = loadDesignState(cache, "ag")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("POST promotes via a RELATIVE patch path — the cwd wiring is load-bearing, relative is the normal form", () => { + const cache = tmp(); + const proj = tmp(); + writeFileSync(join(proj, "design-system.md"), URL_DS); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 2, inspirationRead: true, screenshotsCount: 4 }); + const patch = `*** Begin Patch\n*** Update File: design-system.md\n@@\n-Inspiration: https://boulangerie-dupont.fr\n+Inspiration: https://boulangerie-martin.fr\n*** End Patch`; + writeFileSync(join(proj, "design-system.md"), URL_DS.replace("boulangerie-dupont", "boulangerie-martin")); + expect(designGate({ agent_id: "ag" }, ev("post", patch), cache, proj, "")).toBeNull(); + const s = loadDesignState(cache, "ag")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("a delete op never promotes — even when the file still exists and is clean", () => { + const cache = tmp(); + const proj = tmp(); + writeFileSync(join(proj, "design-system.md"), URL_DS); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 2, inspirationRead: true, screenshotsCount: 4 }); + const patch = `*** Begin Patch\n*** Delete File: design-system.md\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("post", patch), cache, proj, "")).toBeNull(); + const s = loadDesignState(cache, "ag")!; + expect(s.designSystemValid).toBe(false); + expect(s.currentPhase).toBe(2); +}); + +test("delete is skipped even at phase 0 — the write gate never sees it", () => { + const cache = tmp(); + const proj = tmp(); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, initDesignState("ag", "full", false)); // phase 0: designSystemWriteGate WOULD block + const patch = `*** Begin Patch\n*** Delete File: ${join(proj, "design-system.md")}\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("pre", patch), cache, proj, "")).toBeNull(); +}); + +test("add of a generic design-system.md is still blocked at PRE (add content IS the document)", () => { + const { cache, proj } = setup(URL_DS); + const patch = `*** Begin Patch\n*** Add File: ${join(proj, "design-system.md")}\n+TOTAL GARBAGE, zero design content\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("pre", patch), cache, proj, "")?.kind).toBe("block"); +}); + +test("a multi-file envelope is blocked by its LAST faulty file, not only its first", () => { + const { cache, proj } = setup(URL_DS); + const patch = `*** Begin Patch\n*** Add File: ${join(proj, "ok.css")}\n+div { color: red; }\n*** Add File: ${join(proj, "design-system.md")}\n+TOTAL GARBAGE\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("pre", patch), cache, proj, "")?.kind).toBe("block"); +}); diff --git a/test/design-characterization.test.ts b/test/design-characterization.test.ts new file mode 100644 index 0000000..2b3063d --- /dev/null +++ b/test/design-characterization.test.ts @@ -0,0 +1,73 @@ +import { test, expect } from "bun:test"; +import { browserNavigateGate, designSystemWriteGate, geminiCreateGate } from "../src/policy/design/gates-pipeline"; +import { htmlCssOnlyGate, stateFileGate, screenshotScrollGate, validateDesignSystem } from "../src/policy/design/gates"; +import { initDesignState, type DesignState } from "../src/policy/design/state"; + +/** + * Characterization tests: pin the behavior that must SURVIVE the inspiration-doctrine + * rework untouched. Green before AND after the change — if one turns red, the + * implementation moved something it was not supposed to. + */ +const st = (over: Partial = {}): DesignState => ({ ...initDesignState("a", "full", false), ...over }); +const VALID = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; + +test("navigate gate: phase 0 and inspiration prerequisite still deny", () => { + expect(browserNavigateGate(st({ currentPhase: 0 }), "https://x.com")?.kind).toBe("block"); + expect(browserNavigateGate(st({ currentPhase: 1, inspirationRead: false }), "https://x.com")?.kind).toBe("block"); +}); + +test("screenshot scroll guard unchanged", () => { + expect(screenshotScrollGate(st({ scrolledSinceNav: false }))?.kind).toBe("block"); + expect(screenshotScrollGate(st({ scrolledSinceNav: true }))).toBeNull(); +}); + +test("navigate gate scoping survives the catalog inversion: galleries pass in phase 1, any URL passes from phase 2", () => { + const p1 = st({ currentPhase: 1, inspirationRead: true }); + expect(browserNavigateGate(p1, "https://awwwards.com/websites/x")).toBeNull(); + expect(browserNavigateGate(p1, "https://godly.website")).toBeNull(); + expect(browserNavigateGate(st({ currentPhase: 2, inspirationRead: true }), "https://boulangerie-dupont.fr")).toBeNull(); +}); + +test("state-file and html/css-only guards unchanged", () => { + expect(stateFileGate("/p/.design-state-a.json")?.kind).toBe("block"); + expect(stateFileGate("/p/other.md")).toBeNull(); + expect(htmlCssOnlyGate("src/components/x.tsx")?.kind).toBe("block"); + expect(htmlCssOnlyGate("src/components/x.md")).toBeNull(); +}); + +test("gemini gate: phase >= 3 and validated design system still required", () => { + expect(geminiCreateGate(st({ currentPhase: 2, designSystemValid: true }))?.kind).toBe("block"); + expect(geminiCreateGate(st({ currentPhase: 3, designSystemValid: false }))?.kind).toBe("block"); + expect(geminiCreateGate(st({ currentPhase: 3, designSystemValid: true }))).toBeNull(); +}); + +test("write gate: still blocks at phase < 2 and below quota, allows above", () => { + expect(designSystemWriteGate("other.md", st())).toBeNull(); + expect(designSystemWriteGate("design-system.md", st({ currentPhase: 1 }))?.kind).toBe("block"); + expect(designSystemWriteGate("design-system.md", st({ currentPhase: 2, mode: "full", screenshotsCount: 0 }))?.kind).toBe("block"); + expect(designSystemWriteGate("design-system.md", st({ currentPhase: 2, mode: "full", screenshotsCount: 99 }))).toBeNull(); +}); + +test("validateDesignSystem: section, oklch chroma and forbidden-font checks survive", () => { + expect(validateDesignSystem("no section https://x.com oklch(0.6 0.2 250)")).toContain("## Design Reference section"); + expect(validateDesignSystem("## Design Reference\nhttps://x.com")).toContain("oklch() color with chroma > 0"); + expect(validateDesignSystem(VALID)).toEqual([]); +}); + +test("forbidden fonts: a DECLARATION is caught, prose mention is allowed", () => { + expect(validateDesignSystem(`${VALID}\n--font-body: "Inter", sans-serif;`)).toContain("forbidden font (Inter/Roboto/Arial/Open Sans)"); + expect(validateDesignSystem(`${VALID}\nfont-family: Roboto;`)).toContain("forbidden font (Inter/Roboto/Arial/Open Sans)"); + expect(validateDesignSystem(`${VALID}\n## Interaction states\nWe never use Inter here.`)).toEqual([]); +}); + +test("forbidden fonts: usage forms beyond one-line CSS declarations are caught", () => { + const forms = [ + "font: 16px/1.5 Inter, sans-serif;", + 'fontFamily: "Inter"', + "FONT-FAMILY: Inter;", + "font-family:\n Inter;", + "@import url('https://fonts.googleapis.com/css2?family=Inter');", + "| Body | Inter |", + ]; + for (const form of forms) expect(validateDesignSystem(`${VALID}\n${form}`)).toContain("forbidden font (Inter/Roboto/Arial/Open Sans)"); +}); diff --git a/test/design-content-gate-legacy.test.ts b/test/design-content-gate-legacy.test.ts new file mode 100644 index 0000000..d1175e2 --- /dev/null +++ b/test/design-content-gate-legacy.test.ts @@ -0,0 +1,48 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { designSystemContentGate } from "../src/runtime/design-helpers"; +import { initDesignState, type DesignState } from "../src/policy/design/state"; + +/** + * The not-worsening rule (D4): a legacy design-system.md that predates the + * doctrine stays editable AND rewritable — a change is blocked only when it + * INTRODUCES a violation the on-disk file did not already have. + */ +const ROOT = "/plugins/design-expert/skills/design-web/references/refs-design"; +const READS = ["README.md", "umbrel-recode/tokens-umbrel.md", "fora-recode/tokens-fora.md"]; +const state = (over: Partial = {}): DesignState => + ({ ...initDesignState("a", "full", false), currentPhase: 2, corpusReads: READS, ...over }); +const base = { filePath: "", tool: "Write", oldString: undefined, replaceAll: false, corpusRoot: ROOT, corpusRequired: true }; +const legacyFile = (content: string): string => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, content); + return f; +}; + +test("Edit on a legacy invalid file is NOT blocked when it introduces nothing new", () => { + const f = legacyFile("## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: #3366ff;\n--font: \"Fraunces\";"); + const benign = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "--b: #123456;", oldString: "--a: #3366ff;", state: state() }); + expect(benign).toBeNull(); +}); + +test("Edit that INTRODUCES new problems on an already-invalid file is blocked", () => { + // Already invalid (no reference source); the edit kills oklch AND adds a forbidden font declaration. + const f = legacyFile("## Design Reference\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"); + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "--a: #3366ff; --font-x: Inter;", oldString: "--a: oklch(0.62 0.19 250);", state: state() }); + expect(p?.kind).toBe("block"); +}); + +test("Write over an existing legacy file follows the same not-worsening rule", () => { + const legacy = "## Design Reference\n--a: #3366ff;\n--font: \"Fraunces\";"; + const f = legacyFile(legacy); + // Byte-identical rewrite: allowed (no new problem). + expect(designSystemContentGate({ ...base, filePath: f, content: legacy, state: state() })).toBeNull(); + // Improved but still imperfect (oklch added, source still missing): allowed. + const improved = legacy.replace("--a: #3366ff;", "--a: oklch(0.62 0.19 250);"); + expect(designSystemContentGate({ ...base, filePath: f, content: improved, state: state() })).toBeNull(); + // Same rewrite plus a NEW violation: blocked. + const degraded = `${legacy}\n--font-x: Inter;`; + expect(designSystemContentGate({ ...base, filePath: f, content: degraded, state: state() })?.kind).toBe("block"); +}); diff --git a/test/design-content-gate.test.ts b/test/design-content-gate.test.ts new file mode 100644 index 0000000..0b7aab1 --- /dev/null +++ b/test/design-content-gate.test.ts @@ -0,0 +1,93 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { designSystemContentGate } from "../src/runtime/design-helpers"; +import { initDesignState, type DesignState } from "../src/policy/design/state"; + +const ROOT = "/plugins/design-expert/skills/design-web/references/refs-design"; +const READS = ["README.md", "umbrel-recode/tokens-umbrel.md", "fora-recode/tokens-fora.md"]; +const state = (over: Partial = {}): DesignState => + ({ ...initDesignState("a", "full", false), currentPhase: 2, corpusReads: READS, ...over }); +const URL_DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const CORPUS_DS = "## Design Reference\n- Corpus: umbrel/## 4. Colors, fora/## 2. Colors\n--a: oklch(0.62 0.19 250);"; +const base = { filePath: "/proj/design-system.md", tool: "Write", oldString: undefined, replaceAll: false, corpusRoot: ROOT, corpusRequired: true }; + +test("non design-system.md paths are not content-gated; a valid Write passes", () => { + expect(designSystemContentGate({ ...base, filePath: "/proj/other.md", content: "", state: state() })).toBeNull(); + expect(designSystemContentGate({ ...base, content: URL_DS, state: state() })).toBeNull(); +}); + +test("Write with invalid content is blocked and names what is missing", () => { + const p = designSystemContentGate({ ...base, content: "# empty\n", state: state() }); + expect(p?.kind).toBe("block"); + expect(p?.reason).toContain("## Design Reference"); +}); + +test("jointure: a corpus citation naming references actually read passes", () => { + expect(designSystemContentGate({ ...base, content: CORPUS_DS, state: state() })).toBeNull(); +}); + +test("jointure: citing a reference never read is blocked and names it", () => { + const p = designSystemContentGate({ ...base, content: CORPUS_DS.replace("fora/", "xai/"), state: state() }); + expect(p?.kind).toBe("block"); + expect(p?.reason).toContain("xai"); +}); + +test("corpus absent: the URL is mandatory again (fallback never weaker), jointure waived with a URL", () => { + // Citation-only document, corpus absent → blocked: the citation door closes with the corpus. + const citationOnly = CORPUS_DS.replace("fora/", "xai/"); + expect(designSystemContentGate({ ...base, content: citationOnly, state: state(), corpusRoot: "", corpusRequired: false })?.kind).toBe("block"); + // URL-sourced document with a bogus citation line, corpus absent → passes: no join check without a corpus. + const withUrl = `${URL_DS}\n- Corpus: totally-made-up/whatever`; + expect(designSystemContentGate({ ...base, content: withUrl, state: state(), corpusRoot: "", corpusRequired: false })).toBeNull(); +}); + +test("Edit that BREAKS a valid file is blocked (worsening)", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, URL_DS); + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "--a: #3366ff;", oldString: "--a: oklch(0.62 0.19 250);", state: state() }); + expect(p?.kind).toBe("block"); +}); + +test("Edit validates the reconstructed result, not the fragment", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, URL_DS); + const ok = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "--b: oklch(0.5 0.1 200);", oldString: "--font: \"Fraunces\";", state: state() }); + expect(ok).toBeNull(); + const kill = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "## Renamed", oldString: "## Design Reference", state: state() }); + expect(kill?.kind).toBe("block"); +}); + +test("Edit replace_all substitutes every occurrence before validating", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, `${URL_DS}\n--font-x: "Inter"; --font-y: "Inter";`); + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "Fraunces", oldString: "Inter", replaceAll: true, state: state() }); + expect(p).toBeNull(); +}); + +test("Edit new_string with replacement metacharacters is validated LITERALLY ($& is not the match)", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, URL_DS); + // The real Edit writes "$&" literally — the URL is gone. A gate that uses + // String.replace's replacement semantics would reconstruct the UNCHANGED + // file and allow: the bypass. Literal substitution must see the deletion. + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "$&", oldString: "Inspiration: https://boulangerie-dupont.fr", state: state() }); + expect(p?.kind).toBe("block"); +}); + +test("Edit replace_all with a metachar new_string substitutes LITERALLY (replaceAll would no-op on $&)", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, `${URL_DS}\nInspiration: https://boulangerie-dupont.fr (again)`); + // Literal: both URLs become the text "$&" → no https:// left → invalid → block. + // replaceAll(old, "$&") would interpret $& as the match → no-op → allow (the bypass). + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "$&", oldString: "https://boulangerie-dupont.fr", replaceAll: true, state: state() }); + expect(p?.kind).toBe("block"); +}); + +test("Edit with a stale old_string is blocked (re-read), never validated blind", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, URL_DS); + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "x", oldString: "not in the file", state: state() }); + expect(p?.kind).toBe("block"); +}); diff --git a/test/design-corpus-join.test.ts b/test/design-corpus-join.test.ts new file mode 100644 index 0000000..1eabaf5 --- /dev/null +++ b/test/design-corpus-join.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from "bun:test"; +import { citedCorpusRefs, citationJoinsReads, hasCorpusCitation } from "../src/policy/design/corpus"; + +const REAL = ["umbrel", "linear", "cursor", "harness", "xai", "mainframe", "reve", "endlesstools", "supercommon", "fora", "elysian"]; + +test("jointure: the ELEVEN real references join — both layouts, both naming conventions", () => { + for (const s of REAL) { + const dirRead = s === "elysian" ? "elysian/tokens-elysian.md" : `${s}-recode/tokens-${s}.md`; + expect(citationJoinsReads(s, [dirRead])).toBe(true); // README convention (bare name), dir layout + expect(citationJoinsReads(s, [`tokens-${s}.md`])).toBe(true); // bare name, flat layout + if (s !== "elysian") { + expect(citationJoinsReads(`${s}-recode`, [dirRead])).toBe(true); // dir convention, dir layout + expect(citationJoinsReads(`${s}-recode`, [`tokens-${s}.md`])).toBe(true); // dir convention, flat layout + } + } +}); + +test("jointure: a reference never read never joins — widening must not reopen the forged door", () => { + expect(citationJoinsReads("acme-corp", REAL.map((s) => `tokens-${s}.md`))).toBe(false); + expect(citationJoinsReads("acme", ["acme-corp-recode/tokens-acme-corp.md"])).toBe(false); // near-miss stays out + expect(citationJoinsReads("acme-corp", ["acme-recode/tokens-acme.md"])).toBe(false); // and the other direction +}); + +test("citation form: EVERY Corpus line is parsed — a ref cited on a second line joins too", () => { + const content = "- Corpus: umbrel/## 4\n- Corpus: fora/## 2, reve/## 1"; + expect(citedCorpusRefs(content)).toEqual(["umbrel", "fora", "reve"]); +}); + +test("citation form: bare refs, asterisk bullets, lowercase corpus are recognized", () => { + expect(citedCorpusRefs("- Corpus: reve")).toEqual(["reve"]); + expect(citedCorpusRefs("* Corpus: umbrel/## 4. Colors, fora/## 2. Colors")).toEqual(["umbrel", "fora"]); + expect(citedCorpusRefs("- corpus: reve/## 4. Colors")).toEqual(["reve"]); +}); + +test("citationJoinsReads: filename prefixes and README never join", () => { + const reads = ["README.md", "tokens-a.md", "a-recode/tokens-a.md"]; + expect(citationJoinsReads("tokens", reads)).toBe(false); // would otherwise join EVERY tokens-*.md + expect(citationJoinsReads("README", reads)).toBe(false); // the index names no reference + expect(citationJoinsReads("a", reads)).toBe(true); +}); + +test("citationJoinsReads: -recode absorbed, unread ref rejected, flat layout joins", () => { + const reads = ["README.md", "reve-recode/tokens-reve.md", "elysian/tokens-elysian.md"]; + expect(citationJoinsReads("reve", reads)).toBe(true); + expect(citationJoinsReads("reve-recode", reads)).toBe(true); + expect(citationJoinsReads("xai", reads)).toBe(false); + expect(citationJoinsReads("b", ["tokens-b-recode.md"])).toBe(true); +}); + +test("hasCorpusCitation: a Corpus line with ref/section pairs, nothing else", () => { + expect(hasCorpusCitation("- Corpus: umbrel/## 4. Colors, fora/## 2. Colors")).toBe(true); + expect(hasCorpusCitation("Inspiration: https://example.com")).toBe(false); + expect(hasCorpusCitation("some prose mentioning corpus/ nothing")).toBe(false); +}); diff --git a/test/design-corpus-resolve.test.ts b/test/design-corpus-resolve.test.ts new file mode 100644 index 0000000..ba8f78e --- /dev/null +++ b/test/design-corpus-resolve.test.ts @@ -0,0 +1,65 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveCorpusRoot, resolvePluginsRoot } from "../src/policy/design/corpus"; + +/** A fake $HOME, realpath'd (macOS /var → /private/var). */ +const home = (): string => realpathSync(mkdtempSync(join(tmpdir(), "fh-res-"))); +const REFS = join("skills", "design-web", "references", "refs-design"); + +test("claude-code: a corpus under the marketplace tree is resolved", () => { + const h = home(); + const plugin = join(h, ".claude", "plugins", "marketplaces", "fusengine-plugins", "plugins", "design-expert"); + mkdirSync(join(plugin, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "claude-code", {})).toBe(join(plugin, REFS)); + expect(resolvePluginsRoot(undefined, h, "claude-code", {})).toBe(plugin); +}); + +test("codex: a corpus under the versioned plugin cache is resolved — highest semver wins", () => { + const h = home(); + const de = join(h, ".codex", "plugins", "cache", "fusengine-codex", "design-expert"); + mkdirSync(join(de, "2.1.9", "skills"), { recursive: true }); + mkdirSync(join(de, "2.1.38", REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "codex", {})).toBe(join(de, "2.1.38", REFS)); + expect(resolvePluginsRoot(undefined, h, "codex", {})).toBe(join(de, "2.1.38")); +}); + +test("codex: CODEX_HOME is honored when set", () => { + const h = home(); + const codexHome = home(); + const de = join(codexHome, "plugins", "cache", "mp", "design-expert", "1.0.0"); + mkdirSync(join(de, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "codex", { CODEX_HOME: codexHome })).toBe(join(de, REFS)); +}); + +test("runtimes without a plugin structure resolve to '' (kimi is not wired)", () => { + const h = home(); + expect(resolveCorpusRoot(undefined, h, "kimi", {})).toBe(""); + expect(resolvePluginsRoot(undefined, h, "kimi", {})).toBe(""); +}); + +test("codex without the plugin cache resolves to '' — never the cwd", () => { + const h = home(); + expect(resolveCorpusRoot(undefined, h, "codex", {})).toBe(""); + expect(resolvePluginsRoot(undefined, h, "codex", {})).toBe(""); +}); + +test("cross-runtime isolation: a corpus only in the OTHER runtime's tree is never resolved", () => { + const h = home(); + // Corpus present ONLY in the Claude tree → codex must NOT fall back to it. + const claudePlugin = join(h, ".claude", "plugins", "marketplaces", "mkt", "plugins", "design-expert"); + mkdirSync(join(claudePlugin, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "codex", {})).toBe(""); + // Corpus present ONLY in the Codex cache → claude-code must NOT fall back to it. + const h2 = home(); + mkdirSync(join(h2, ".codex", "plugins", "cache", "mp", "design-expert", "1.0.0", REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h2, "claude-code", {})).toBe(""); +}); + +test("codex version selection: non-semver dirs and pre-releases never beat the stable", () => { + const h = home(); + const de = join(h, ".codex", "plugins", "cache", "mp", "design-expert"); + for (const v of ["2.1.38", "3.0.0-rc1", "backup"]) mkdirSync(join(de, v, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "codex", {})).toBe(join(de, "2.1.38", REFS)); +}); diff --git a/test/design-corpus-transitions.test.ts b/test/design-corpus-transitions.test.ts new file mode 100644 index 0000000..b258e8d --- /dev/null +++ b/test/design-corpus-transitions.test.ts @@ -0,0 +1,65 @@ +import { test, expect } from "bun:test"; +import { recordCorpusRead, recordScreenshot, recordRead, recordValidDesignSystem } from "../src/policy/design/transitions"; +import { initDesignState, type DesignState, type DesignMode } from "../src/policy/design/state"; + +const st = (mode: DesignMode, over: Partial = {}): DesignState => + ({ ...initDesignState("a", mode, false), currentPhase: 1, ...over }); + +test("recordCorpusRead: distinct reads only — re-reading the same file does not inflate", () => { + let s = recordCorpusRead(st("component"), "README.md", true); + s = recordCorpusRead(s, "README.md", true); + expect(s.corpusReads).toEqual(["README.md"]); +}); + +test("component: one corpus read alone is not enough — one screenshot is always required", () => { + let s = recordCorpusRead(st("component"), "umbrel-recode/tokens-umbrel.md", true); + expect(s.currentPhase).toBe(1); + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(2); +}); + +test("page: two distinct corpus reads plus one screenshot open phase 2", () => { + let s = recordCorpusRead(st("page"), "README.md", true); + s = recordCorpusRead(s, "fora-recode/tokens-fora.md", true); + expect(s.currentPhase).toBe(1); // reads alone never open phase 2 + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(2); +}); + +test("page: one corpus read plus one screenshot is not enough", () => { + let s = recordCorpusRead(st("page"), "README.md", true); + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(1); +}); + +test("full: corpus alone is not enough — the 2-screenshot quota still applies", () => { + let s = st("full"); + for (const r of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]) s = recordCorpusRead(s, r, true); + expect(s.currentPhase).toBe(1); + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(1); + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(2); +}); + +test("monotonic: a corpus Read after phase 3 never downgrades currentPhase", () => { + let s = recordValidDesignSystem(st("page", { currentPhase: 2 })); + expect(s.currentPhase).toBe(3); + s = recordCorpusRead(s, "README.md", true); + expect(s.currentPhase).toBe(3); +}); + +test("waived vs required is DISCRIMINANT: the same state opens phase 2 only when the corpus requirement is lifted", () => { + const s = st("page", { screenshotsCount: 1 }); + expect(recordScreenshot(s, false).currentPhase).toBe(2); // fallback quota (2) met, corpus waived + expect(recordScreenshot(s, true).currentPhase).toBe(1); // corpus reads missing + const read = recordCorpusRead(recordCorpusRead(s, "README.md", true), "a-recode/tokens-a.md", true); + expect(recordScreenshot(read, true).currentPhase).toBe(2); // corpus + screenshot +}); + +test("waived (corpus absent): screenshots alone decide, exactly like today", () => { + expect(recordScreenshot(st("page", { screenshotsCount: 1 }), false).currentPhase).toBe(2); + expect(recordScreenshot(st("component"), false).currentPhase).toBe(2); + expect(recordScreenshot(st("full", { screenshotsCount: 3 }), false).currentPhase).toBe(2); + expect(recordRead(st("page"), "/x/design-inspiration.md", false).currentPhase).toBe(1); // no free pass +}); diff --git a/test/design-corpus.test.ts b/test/design-corpus.test.ts new file mode 100644 index 0000000..42eb67a --- /dev/null +++ b/test/design-corpus.test.ts @@ -0,0 +1,70 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + classifyCorpusRead, corpusReady, resolveCorpusRoot, resolvePluginsRoot, pluginsWriteGuard, +} from "../src/policy/design/corpus"; + +const ROOT = "/plugins/design-expert/skills/design-web/references/refs-design"; + +test("classifyCorpusRead: index vs tokens vs outside the delivered corpus", () => { + expect(classifyCorpusRead(`${ROOT}/README.md`, ROOT)).toBe("index"); + expect(classifyCorpusRead(`${ROOT}/umbrel-recode/tokens-umbrel.md`, ROOT)).toBe("tokens"); + expect(classifyCorpusRead(`${ROOT}/elysian/tokens-elysian.md`, ROOT)).toBe("tokens"); + expect(classifyCorpusRead(`${ROOT}/umbrel-recode/index.html`, ROOT)).toBeNull(); + // A refs-design/ dir fabricated outside the delivered plugin never counts. + expect(classifyCorpusRead("/tmp/scratch/refs-design/tokens-fake.md", ROOT)).toBeNull(); + expect(classifyCorpusRead("/proj/docs/refs-design/README.md", ROOT)).toBeNull(); +}); + +test("corpusReady: per-mode thresholds (component >= 1, page >= 2, full = index + 2 tokens)", () => { + const idx = ["README.md"]; + const one = ["README.md", "umbrel-recode/tokens-umbrel.md"]; + const two = [...one, "fora-recode/tokens-fora.md"]; + expect(corpusReady([], "component")).toBe(false); + expect(corpusReady(idx, "component")).toBe(true); + expect(corpusReady(idx, "page")).toBe(false); + expect(corpusReady(one, "page")).toBe(true); + expect(corpusReady(one, "full")).toBe(false); // one tokens file only + expect(corpusReady(two.filter((r) => r !== "README.md"), "full")).toBe(false); // no index + expect(corpusReady(two, "full")).toBe(true); +}); + +test("pluginsWriteGuard: writes under the delivered corpus are denied, anything else passes", () => { + expect(pluginsWriteGuard(`${ROOT}/fake/tokens-fake.md`, ROOT)?.kind).toBe("block"); + expect(pluginsWriteGuard("/proj/refs-design/tokens-fake.md", ROOT)).toBeNull(); + expect(pluginsWriteGuard(`${ROOT}/x.md`, "")).toBeNull(); // no corpus → guard inert +}); + +test("pluginsWriteGuard: RELATIVE paths (the normal form of Codex patches) are resolved against cwd", () => { + const plugins = mkdtempSync(join(tmpdir(), "fh-rel-")); + const rel = "design-expert/skills/design-web/references/refs-design/fake/tokens-fake.md"; + expect(pluginsWriteGuard(rel, join(plugins, "design-expert"), plugins)?.kind).toBe("block"); + expect(pluginsWriteGuard(rel, join(plugins, "design-expert"), "/elsewhere")).toBeNull(); +}); + +test("resolveCorpusRoot: empty when refs-design is absent, real path when present", () => { + const plugins = mkdtempSync(join(tmpdir(), "fh-corpus-")); + const refs = join(plugins, "design-expert", "skills", "design-web", "references", "refs-design"); + expect(resolveCorpusRoot(plugins)).toBe(""); + mkdirSync(refs, { recursive: true }); + expect(resolveCorpusRoot(plugins)).toBe(refs); +}); + +test("resolveCorpusRoot WITHOUT override: a corpus sitting at the cwd is NEVER retained", () => { + // Fake home with NO plugin structure; a valid-looking corpus sits at the cwd. + // There is no cwd fallback anywhere in the resolution: "" must come from the + // refusal, not from chance — the corpus is present and still not retained. + const fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "fh-home-"))); + const proj = join(fakeHome, "project"); + mkdirSync(join(proj, "design-expert", "skills", "design-web", "references", "refs-design"), { recursive: true }); + const prev = process.cwd(); + process.chdir(proj); + try { + expect(resolveCorpusRoot(undefined, fakeHome, "claude-code", {})).toBe(""); + expect(resolvePluginsRoot(undefined, fakeHome, "claude-code", {})).toBe(""); + } finally { + process.chdir(prev); + } +}); diff --git a/test/design-gate-corpus.test.ts b/test/design-gate-corpus.test.ts new file mode 100644 index 0000000..a6ffd43 --- /dev/null +++ b/test/design-gate-corpus.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { designGate } from "../src/runtime/design"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; +import type { NormalizedEvent } from "../src/runtime/normalize"; + +/** Fixture: a delivered corpus (README + two tokens files) and an active design agent. */ +function setup() { + const cache = mkdtempSync(join(tmpdir(), "fh-gc-")); + const root = join(mkdtempSync(join(tmpdir(), "fh-gc-root-")), "refs-design"); + mkdirSync(join(root, "a-recode"), { recursive: true }); + mkdirSync(join(root, "b-recode"), { recursive: true }); + writeFileSync(join(root, "README.md"), "# index"); + writeFileSync(join(root, "a-recode", "tokens-a.md"), "# a"); + writeFileSync(join(root, "b-recode", "tokens-b.md"), "# b"); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 1, inspirationRead: true }); + const ev = (phase: "pre" | "post", tool: string, filePath = "", content = ""): NormalizedEvent => + ({ phase, tool, input: {}, sessionId: "s", filePath, content }); + const gate = (e: NormalizedEvent) => designGate({ agent_id: "ag" }, e, cache, "/proj", root, root); + return { cache, root, ev, gate }; +} + +test("designGate with an injected corpus: reads advance the state, jointure enforced at write", () => { + const { cache, root, ev, gate } = setup(); + // Corpus reads are recorded (distinct, existence-checked); phase waits for the screenshots. + for (const f of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]) { + expect(gate(ev("post", "Read", join(root, f)))).toBeNull(); + } + expect(loadDesignState(cache, "ag")!.corpusReads).toEqual(["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]); + expect(loadDesignState(cache, "ag")!.currentPhase).toBe(1); + // A write of design-system.md is still blocked at phase 1. + expect(gate(ev("pre", "Write", "/proj/design-system.md", "x"))?.kind).toBe("block"); + // Two screenshots complete the conjunction (full mode: corpus + 2 shots). + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + expect(loadDesignState(cache, "ag")!.currentPhase).toBe(2); +}); + +test("designGate on apply_patch add (event.files): add content IS the document — garbage blocked, plugin root protected", () => { + const { cache, root, ev, gate } = setup(); + for (const f of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]) gate(ev("post", "Read", join(root, f))); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + const patch = (filePath: string, content: string, op: "add" | "update" | "delete"): NormalizedEvent => + ({ phase: "pre", tool: "apply_patch", input: {}, sessionId: "s", files: [{ filePath, content, op }] }); + const VALID = "## Design Reference\n- Corpus: a/## 1. T, b/## 2. U\n--a: oklch(0.62 0.19 250);"; + // A generic design-system.md via apply_patch add is blocked, exactly like a Write. + expect(gate(patch("/proj/design-system.md", "TOTAL GARBAGE", "add"))?.kind).toBe("block"); + // A patch into the plugin root is blocked, whatever the op. + expect(gate(patch(join(root, "fake", "tokens-fake.md"), "# fake", "add"))?.kind).toBe("block"); + // A valid ADD passes the PRE, and the POST validates the REAL file on disk. + expect(gate(patch("/proj/design-system.md", VALID, "add"))).toBeNull(); + const proj = mkdtempSync(join(tmpdir(), "fh-ap-proj-")); + writeFileSync(join(proj, "design-system.md"), VALID); + const post: NormalizedEvent = { phase: "post", tool: "apply_patch", input: {}, sessionId: "s", files: [{ filePath: join(proj, "design-system.md"), content: VALID, op: "add" }] }; + gate(post); + const s = loadDesignState(cache, "ag")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("corpusRoot override does NOT decide pluginsRoot — the guard covers the plugin root independently", () => { + const { cache, root, ev } = setup(); + const plugins = mkdtempSync(join(tmpdir(), "fh-pg-")); + const gate = (e: NormalizedEvent) => designGate({ agent_id: "ag" }, e, cache, "/proj", root, plugins); + // A write under the PLUGIN root is blocked even though corpusRoot points elsewhere. + expect(gate(ev("pre", "Write", join(plugins, "design-expert", "skills", "x", "tokens-x.md"), "# x"))?.kind).toBe("block"); + // And the corpus root alone no longer smuggles plugin-root coverage. + expect(gate(ev("pre", "Write", join(root, "fake", "tokens-fake.md"), "# fake"))).toBeNull(); +}); + +test("cwd is WIRED into pluginsWriteGuard: a RELATIVE patch path under the plugin root is blocked", () => { + const cache = mkdtempSync(join(tmpdir(), "fh-cw-")); + const proj = mkdtempSync(join(tmpdir(), "fh-cw-p-")); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 1, inspirationRead: true }); + const ev: NormalizedEvent = { phase: "pre", tool: "apply_patch", input: {}, sessionId: "s", files: [{ filePath: "fake/tokens-fake.md", content: "# fake", op: "add" }] }; + // pluginsRoot === proj here: the relative patch path resolves INTO it. + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "", proj)?.kind).toBe("block"); +}); + +test("designGate: a write citing READ references passes; citing an unread one is blocked and named", () => { + const { cache, root, ev, gate } = setup(); + for (const f of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]) gate(ev("post", "Read", join(root, f))); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + const ds = (refs: string) => `## Design Reference\n- Corpus: ${refs}\n--a: oklch(0.62 0.19 250);`; + expect(gate(ev("pre", "Write", "/proj/design-system.md", ds("a/## 1. T, b/## 2. U")))).toBeNull(); + const forged = gate(ev("pre", "Write", "/proj/design-system.md", ds("a/## 1. T, xai/## 9. Z"))); + expect(forged?.kind).toBe("block"); + expect(forged?.reason).toContain("xai"); + // The corpus itself is write-protected for the design agent. + expect(gate(ev("pre", "Write", join(root, "fake", "tokens-fake.md"), "# fake"))?.kind).toBe("block"); +}); diff --git a/test/design-gates-corpus.test.ts b/test/design-gates-corpus.test.ts new file mode 100644 index 0000000..a0a1696 --- /dev/null +++ b/test/design-gates-corpus.test.ts @@ -0,0 +1,73 @@ +import { test, expect } from "bun:test"; +import { MIN_SCREENSHOTS, MIN_SCREENSHOTS_NO_CORPUS, initDesignState, type DesignState } from "../src/policy/design/state"; +import { browserNavigateGate } from "../src/policy/design/gates-pipeline"; +import { validateDesignSystem } from "../src/policy/design/gates"; + +const nav = (): DesignState => ({ ...initDesignState("a", "full", false), currentPhase: 1, inspirationRead: true }); + +test("screenshot quotas: corpus-present set lowered, corpus-absent set = today's behavior", () => { + expect(MIN_SCREENSHOTS).toEqual({ full: 2, page: 1, component: 1 }); + expect(MIN_SCREENSHOTS_NO_CORPUS).toEqual({ full: 4, page: 2, component: 1 }); +}); + +test("navigate gate: template platform HOSTS and platform template CATALOGS denied in phase 1", () => { + const denied = [ + "https://themeforest.net/item/x", + "https://themes.shopify.com/", + "https://marketplace.atlassian.com/", + "https://templatemonster.com/x", + "https://framer.com/templates", + "https://webflow.com/templates", + "https://wix.com/templates", + "https://vercel.com/Templates", + "HTTPS://VERCEL.COM/TEMPLATES", + ]; + for (const url of denied) expect(browserNavigateGate(nav(), url)?.kind).toBe("block"); +}); + +test("navigate gate: host normalization — port, case, trailing dot and userinfo do not escape", () => { + const escapes = [ + "https://themeforest.net:443/x", + "https://THEMEFOREST.NET/x", + "https://themeforest.net./x", + "https://user@themeforest.net/x", + ]; + for (const url of escapes) expect(browserNavigateGate(nav(), url)?.kind).toBe("block"); +}); + +test("navigate gate: real sector sites pass — published client domains and real marketplaces included", () => { + const allowed = [ + "https://boulangerie-dupont.fr", + "https://awwwards.com/websites/x", + "https://godly.website", + "https://bestwebsite.gallery", + "https://www.sothebys.com/en/marketplace", + "https://stripe.com/marketplace", + "https://framer.com", + "https://acme-wbs.framer.website/x", + "https://startify-template.webflow.io", + "https://vercel.com/marketplace", + ]; + for (const url of allowed) expect(browserNavigateGate(nav(), url)).toBeNull(); +}); + +test("navigate deny: doctrine marker present, old catalog pointer gone", () => { + const reason = browserNavigateGate(nav(), "https://themeforest.net/item/x")?.reason ?? ""; + expect(reason).toMatch(/corpus|refs-design/i); + expect(reason).not.toContain("design-inspiration-urls.md"); +}); + +test("validateDesignSystem: a corpus citation satisfies the source requirement only when the corpus is delivered", () => { + const ds = "## Design Reference\n- Corpus: umbrel/## 4. Colors, fora/## 2. Colors\n--a: oklch(0.62 0.19 250);"; + expect(validateDesignSystem(ds, true)).toEqual([]); +}); + +test("validateDesignSystem: a forged citation without URL is refused when the corpus is absent", () => { + const forged = "## Design Reference\n- Corpus: totally-made-up/whatever\n--a: oklch(0.62 0.19 250);"; + expect(validateDesignSystem(forged, false).join()).toContain("reference"); +}); + +test("validateDesignSystem: random prose without URL or corpus citation still fails", () => { + const ds = "## Design Reference\nsome prose without any source\n--a: oklch(0.62 0.19 250);"; + expect(validateDesignSystem(ds).join()).toContain("reference"); +}); diff --git a/test/design-post-validity.test.ts b/test/design-post-validity.test.ts new file mode 100644 index 0000000..234381d --- /dev/null +++ b/test/design-post-validity.test.ts @@ -0,0 +1,68 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; +import { recordPost } from "../src/runtime/design-helpers"; +import type { NormalizedEvent } from "../src/runtime/normalize"; + +/** + * POST validity transitions (C1/C3): the POST must not revoke what the PRE + * allowed. Degrade only on problems an Edit INTRODUCED; never on a benign + * legacy edit, never on an unreadable file. + */ +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-pv-")); +const URL_DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const LEGACY = "## Design Reference\n--a: #3366ff;\n--font: \"Fraunces\";"; + +function setup(content: string): { cache: string; f: string } { + const cache = tmp(); + const f = join(tmp(), "design-system.md"); + writeFileSync(f, content); + saveDesignState(cache, { ...initDesignState("a", "page", true), currentPhase: 3, designSystemValid: true }); + return { cache, f }; +} +const editEv = (f: string, oldString: string, newString: string): NormalizedEvent => + ({ phase: "post", tool: "Edit", input: { old_string: oldString, new_string: newString }, sessionId: "s", filePath: f, oldString, content: newString }); + +test("C1: a benign Edit on a legacy invalid file does NOT revoke validity (PRE allowed it)", () => { + const { cache, f } = setup(LEGACY.replace("#3366ff", "#445566")); // on-disk = post-edit content + recordPost(editEv(f, "--a: #3366ff;", "--a: #445566;"), cache, loadDesignState(cache, "a")!, "", false); + const s = loadDesignState(cache, "a")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("C1: an Edit that INTRODUCES a defect revokes validity (phase 3 blocked downstream)", () => { + const { cache, f } = setup(URL_DS.replace("oklch(0.62 0.19 250)", "#3366ff")); + recordPost(editEv(f, "--a: oklch(0.62 0.19 250);", "--a: #3366ff;"), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.designSystemValid).toBe(false); +}); + +test("POST nominal: reverse reconstruction treats $ in old_string as DATA (a $& there must not mask the introduced problem)", () => { + // Original: valid, with a harmless "$&" in a comment. The edit swaps the font + // for a forbidden one. Literal reverse-rebuild: original clean → introduced + // [forbidden font] → revoke. String-replacement would expand $& to the match + // (the Inter line), making the ORIGINAL look invalid too → no revoke (mutation). + const { cache, f } = setup(`${URL_DS}\n--font-x: "Inter"; /* $& */`); + recordPost(editEv(f, `--font-x: "Fraunces"; /* $& */`, `--font-x: "Inter"; /* $& */`), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.designSystemValid).toBe(false); +}); + +test("POST replace_all: same literal rule on every occurrence", () => { + const { cache, f } = setup(`${URL_DS}\n--font-x: "Inter"; /* $& */\n--font-x: "Inter"; /* $& */`); + const ev: NormalizedEvent = { phase: "post", tool: "Edit", input: { old_string: `--font-x: "Fraunces"; /* $& */`, new_string: `--font-x: "Inter"; /* $& */`, replace_all: true }, sessionId: "s", filePath: f, oldString: `--font-x: "Fraunces"; /* $& */`, content: `--font-x: "Inter"; /* $& */` }; + recordPost(ev, cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.designSystemValid).toBe(false); +}); + +test("C3: an unreadable design-system.md leaves the state untouched (unreadable ≠ invalid)", () => { + const cache = tmp(); + saveDesignState(cache, { ...initDesignState("a", "page", true), currentPhase: 3, designSystemValid: true }); + const gone = join(tmp(), "design-system.md"); // never written + const ev: NormalizedEvent = { phase: "post", tool: "Write", input: {}, sessionId: "s", filePath: gone, content: "x" }; + recordPost(ev, cache, loadDesignState(cache, "a")!, "", false); + const s = loadDesignState(cache, "a")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); diff --git a/test/design-state-corpus.test.ts b/test/design-state-corpus.test.ts new file mode 100644 index 0000000..6b58509 --- /dev/null +++ b/test/design-state-corpus.test.ts @@ -0,0 +1,79 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; +import { recordPost } from "../src/runtime/design-helpers"; +import { designPassNotice } from "../src/policy/design/gates"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import type { NormalizedEvent } from "../src/runtime/normalize"; + +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-rc-")); + +test("retrocompat: a persisted state without corpusReads loads with an empty list", () => { + const cache = tmp(); + const legacy = { + agentId: "a", mode: "page", currentPhase: 1, phasesCompleted: [], inspirationRead: true, + scrolledSinceNav: false, screenshotsCount: 0, designSystemExists: false, designSystemValid: false, geminiCalls: 0, + }; + writeFileSync(join(cache, ".design-state-a.json"), JSON.stringify(legacy)); + expect(loadDesignState(cache, "a")?.corpusReads).toEqual([]); +}); + +test("initDesignState initializes corpusReads explicitly", () => { + expect(initDesignState("a", "full", false).corpusReads).toEqual([]); +}); + +test("recordPost: a corpus Read counts only a file that exists under the corpus root", () => { + const cache = tmp(); + const plugins = tmp(); + const root = join(plugins, "design-expert", "skills", "design-web", "references", "refs-design"); + mkdirSync(join(root, "umbrel-recode"), { recursive: true }); + writeFileSync(join(root, "umbrel-recode", "tokens-umbrel.md"), "# tokens"); + saveDesignState(cache, { ...initDesignState("a", "component", false), currentPhase: 1 }); + const ev = (tool: string, p: string): NormalizedEvent => ({ phase: "post", tool, input: {}, sessionId: "s", filePath: p }); + recordPost(ev("Read", join(root, "umbrel-recode", "tokens-umbrel.md")), cache, loadDesignState(cache, "a")!, root, true); + recordPost(ev("Read", join(root, "ghost-recode", "tokens-ghost.md")), cache, loadDesignState(cache, "a")!, root, true); + let s = loadDesignState(cache, "a")!; + expect(s.corpusReads).toEqual(["umbrel-recode/tokens-umbrel.md"]); + expect(s.currentPhase).toBe(1); // component: the corpus read alone is not enough — one screenshot is always required + recordPost(ev("mcp__fuse-browser__browser_screenshot", ""), cache, s, root, true); + s = loadDesignState(cache, "a")!; + expect(s.currentPhase).toBe(2); +}); + +test("recordPost: a garbage design-system.md write is NOT validated — phase 3 stays closed", () => { + const cache = tmp(); + const f = join(tmp(), "design-system.md"); + writeFileSync(f, "TOTAL GARBAGE, zero design content"); + saveDesignState(cache, { ...initDesignState("a", "page", true), currentPhase: 2 }); + const ev = (): NormalizedEvent => ({ phase: "post", tool: "Write", input: {}, sessionId: "s", filePath: f }); + recordPost(ev(), cache, loadDesignState(cache, "a")!, "", false); + const s = loadDesignState(cache, "a")!; + expect(s.designSystemValid).toBe(false); + expect(s.currentPhase).toBe(2); +}); + +test("recordPost: a valid design-system.md write IS validated and opens phase 3", () => { + const cache = tmp(); + const f = join(tmp(), "design-system.md"); + writeFileSync(f, "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"); + saveDesignState(cache, { ...initDesignState("a", "page", true), currentPhase: 2 }); + const ev = (): NormalizedEvent => ({ phase: "post", tool: "Write", input: {}, sessionId: "s", filePath: f }); + recordPost(ev(), cache, loadDesignState(cache, "a")!, "", false); + const s = loadDesignState(cache, "a")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("pass notice: a missing corpus surfaces a user-visible warning (fail-open is not silent)", () => { + const cache = tmp(); + setActiveDesignAgent(cache, "a"); + saveDesignState(cache, initDesignState("a", "full", false)); + const p = designPassNotice( + { agentId: "a", tool: "mcp__fuse-browser__browser_navigate", filePath: "", content: "", url: "https://x.fr", phase: "pre", corpusMissing: true }, + cache, + ); + expect(p?.kind).toBe("inform"); + expect(p?.userMessage).toContain("corpus"); +});