diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1fc30..2495e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.86] - 2026-07-29 + +### Fixed + +- **Design pipeline dropped screenshot and corpus-read counts it should have credited** (`src/runtime/design-helpers.ts`, `src/policy/design/screenshot-tools.ts`, `corpus-resolve.ts`, `corpus-fs.ts`, `corpus-resolve-cache.ts`, `gates.ts`, `allowed-write.ts`, `corpus.ts`, `src/policy/detect-framework.ts`) — a real `design-expert` run had captured 3 screenshots and read 5 corpus files, yet the pipeline reported `screenshotsCount:0, corpusReads:[], currentPhase:1`, so the phase-2 gate never opened. Root cause: corpus resolution only recognized the `marketplaces//plugins/design-expert` tree, not the `cache////` tree Claude Code also serves (where the plugin is named `fuse-design`) — now detected structurally by corpus suffix, never a hardcoded name, with marketplace priority and `maxSemver` selection. Alongside: `browser_shots_batch`/`browser_site_shots` now credit the screenshot quota (previously only `browser_screenshot` did); the write gate now allows `motion*.js` by basename (all eleven corpus references ship one); reference `design-system.md` sheets now credit as a new `"sheet"` `CorpusKind` (without counting toward the `tokens-*` threshold that the `full` quota requires); `.css` files are classified `tailwind` only when their content matches known Tailwind markers, not by extension alone. + +### Changed + +- **`full` corpus quota raised to README + 3 tokens** (was 2) (`src/policy/design/corpus.ts`) — owner-arbitrated; `page` and `component` quotas unchanged. + ## [0.1.85] - 2026-07-29 ### Fixed diff --git a/package.json b/package.json index 49bfd09..2ee2f09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.85", + "version": "0.1.86", "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", diff --git a/src/policy/design/allowed-write.ts b/src/policy/design/allowed-write.ts new file mode 100644 index 0000000..dc827a3 --- /dev/null +++ b/src/policy/design/allowed-write.ts @@ -0,0 +1,28 @@ +/** + * @module allowed-write + * The design-agent write allowlist, split out of `gates.ts` to keep that file + * within the SOLID size budget (SRP). + * @packageDocumentation + */ +import { basename } from "node:path"; + +/** Extensions the design agent may always write: .html/.css/.md/.json. */ +export const ALLOWED_WRITE: RegExp = /\.(html|css|md|json)$/; + +/** + * C4: `motion*.js` ONLY, matched on the BASENAME — every one of the 11 + * corpus references ships its animation as a separate `motion.js` / + * `motion-nav.js` / `motion-scroll.js` file, so banning every `.js` forced + * the agent to inline scripts the corpus explicitly teaches out-of-band. + * Deliberately NOT `\.js$` in general (that would open arbitrary app code, + * the exact thing this gate exists to block) — after the `motion` prefix, + * either nothing (`motion.js`) or `.`/`-` then more (`motion-nav.js`) must + * lead into `.js`; `app.js` and `motionless-app.js` — a bare `^motion` + * prefix would wrongly pass this — do not match. + */ +const MOTION_JS: RegExp = /^motion([.-].*)?\.js$/; + +/** True when `filePath` is a write the design agent may make: .html/.css/.md/.json, or a `motion*.js` file. */ +export function isAllowedWrite(filePath: string): boolean { + return ALLOWED_WRITE.test(filePath) || MOTION_JS.test(basename(filePath)); +} diff --git a/src/policy/design/corpus-fs.ts b/src/policy/design/corpus-fs.ts new file mode 100644 index 0000000..c8cad6b --- /dev/null +++ b/src/policy/design/corpus-fs.ts @@ -0,0 +1,21 @@ +/** + * @module corpus-fs + * Shared fs primitives for corpus/plugins root resolution, split out of + * `corpus-resolve.ts` so `corpus-resolve-cache.ts` can reuse them without a + * circular import (both modules feed `corpus-resolve.ts`'s `probeClaude`). + * @packageDocumentation + */ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** The refs-design/ path suffix common to every design-corpus-bearing plugin tree. */ +export const CORPUS_SUFFIX: string = join("skills", "design-web", "references", "refs-design"); + +/** Immediate child dir names of `dir`, alphabetically sorted, or [] when unreadable. */ +export function children(dir: string): string[] { + try { + return readdirSync(dir).sort(); + } catch { + return []; + } +} diff --git a/src/policy/design/corpus-resolve-cache.ts b/src/policy/design/corpus-resolve-cache.ts new file mode 100644 index 0000000..625e36c --- /dev/null +++ b/src/policy/design/corpus-resolve-cache.ts @@ -0,0 +1,49 @@ +/** + * @module corpus-resolve-cache + * Claude Code's per-plugin VERSIONED CACHE tree, split out of + * `corpus-resolve.ts` to keep that file within the SOLID size budget (SRP). + * + * Claude Code caches every installed marketplace plugin at + * `~/.claude/plugins/cache////` — a tree SEPARATE from + * `~/.claude/plugins/marketplaces//plugins//` (the marketplace's + * own checkout), one version dir per `plugin.json` version bump, orphaned + * versions pruned ~14 days after being superseded (docs: "Plugin caching and + * file resolution"). The design corpus can ship under EITHER tree, and under + * a DIFFERENT plugin id in each: a machine observed 2026-07-29 has the corpus + * at `cache/fusengine-plugins/fuse-design/2.2.3/...` while the marketplace + * checkout is `marketplaces/fusengine-plugins/plugins/design-expert/...`. + * Identification here is therefore STRUCTURAL, never by plugin name: a + * candidate `///` dir qualifies iff it itself contains + * `CORPUS_SUFFIX` — matching a hardcoded name would silently miss a + * renamed/relocated corpus (the exact defect this module fixes). + * @packageDocumentation + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { maxSemver } from "../../util/semver"; +import { CORPUS_SUFFIX, children } from "./corpus-fs"; + +/** Real (non-prerelease) semver dirs, same filter `probeCodex` uses. */ +const SEMVER_RE = /^\d+\.\d+(\.\d+)?$/; + +/** + * Claude: `/.claude/plugins/cache///` ("" when no plugin under any marketplace cache + * ships the corpus). Marketplaces and plugin ids are both walked — neither is + * assumed — so a corpus shipped under any plugin name is found. + */ +export function probeClaudeCache(home: string): string { + const cacheRoot = join(home, ".claude", "plugins", "cache"); + for (const mkt of children(cacheRoot)) { + const mktDir = join(cacheRoot, mkt); + for (const plugin of children(mktDir)) { + const pluginDir = join(mktDir, plugin); + const versions = children(pluginDir).filter( + (v) => SEMVER_RE.test(v) && existsSync(join(pluginDir, v, CORPUS_SUFFIX)), + ); + const latest = maxSemver(versions); + if (latest) return join(pluginDir, latest); + } + } + return ""; +} diff --git a/src/policy/design/corpus-resolve.ts b/src/policy/design/corpus-resolve.ts index f56ac5c..6b5fb7c 100644 --- a/src/policy/design/corpus-resolve.ts +++ b/src/policy/design/corpus-resolve.ts @@ -6,26 +6,17 @@ * 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 { existsSync } 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"; +import { CORPUS_SUFFIX, children } from "./corpus-fs"; +import { probeClaudeCache } from "./corpus-resolve-cache"; -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 { +/** Claude marketplace checkout: `/.claude/plugins/marketplaces//plugins/design-expert` ("" when absent). */ +function probeClaudeMarketplace(home: string): string { const markets = join(home, ".claude", "plugins", "marketplaces"); for (const m of children(markets)) { const de = join(markets, m, "plugins", "design-expert"); @@ -34,6 +25,15 @@ function probeClaude(home: string): string { return ""; } +/** + * Claude: the marketplace checkout WINS when present (a single unambiguous + * live git checkout); the versioned plugin CACHE tree is the fallback, since + * Claude Code keeps orphaned prior-version cache dirs ~14 days after a bump. + */ +function probeClaude(home: string): string { + return probeClaudeMarketplace(home) || probeClaudeCache(home); +} + /** 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"); diff --git a/src/policy/design/corpus.ts b/src/policy/design/corpus.ts index f8e9b50..eee6c35 100644 --- a/src/policy/design/corpus.ts +++ b/src/policy/design/corpus.ts @@ -11,8 +11,16 @@ 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"; +/** + * What a read under the corpus root is: the index, a tokens-* procedure + * file, or a per-reference `design-system.md` DIRECTION sheet (C6) — the + * fiche a reference ships alongside its `tokens-*.md`: register, tone, + * macrostructure, signature element (the PROCEDURES live in tokens-*; + * design-system.md is what you read to CHOOSE a reference in the first + * place). "sheet" is a new variant, not folded into "index" — the two are + * read for opposite reasons (corpus-wide index vs one reference's direction). + */ +export type CorpusKind = "index" | "tokens" | "sheet"; const TOKENS_RE = /^tokens-.+\.md$/; const CORPUS_LINE_RE = /^[-*]\s*Corpus:\s*(.+)$/gim; @@ -21,15 +29,23 @@ const CORPUS_LINE_RE = /^[-*]\s*Corpus:\s*(.+)$/gim; 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"; + if (!filePath.startsWith(`${corpusRoot}/`)) return null; + if (TOKENS_RE.test(basename(filePath))) return "tokens"; + if (basename(filePath) === "design-system.md") return "sheet"; return null; } -/** Per-mode corpus-read threshold (component >= 1 file, page >= 2 files, full = index + 2 tokens). */ +/** + * Per-mode corpus-read threshold (component >= 1 file, page >= 2 files, + * full = index + 3 tokens — C5: raised from 2, since 2 references still let + * an agent pattern-match by tonal affinity without ever opening the one + * reference that actually answered the brief). `"sheet"` reads (C6, + * `design-system.md`) never count toward this — only `TOKENS_RE` matches do. + */ 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; + return reads.includes("README.md") && reads.filter((r) => TOKENS_RE.test(basename(r))).length >= 3; } /** True when the content carries a `- Corpus: ref/section` citation line (form only). */ diff --git a/src/policy/design/gates-pipeline.ts b/src/policy/design/gates-pipeline.ts index bcc387d..5bc43c5 100644 --- a/src/policy/design/gates-pipeline.ts +++ b/src/policy/design/gates-pipeline.ts @@ -15,7 +15,7 @@ export function designSystemWriteGate(filePath: string, state: DesignState, corp "RECOVERY: 1) Read identity templates from skills/design-system/ " + "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) " + + "(browser_shots_batch/browser_site_shots also count, 1 credit per call) " + "5) Then write design-system.md", ); } @@ -25,7 +25,7 @@ export function designSystemWriteGate(filePath: string, state: DesignState, corp `BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. ` + `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) " + + "mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots also count, 1 credit per call) " + "3) Use browser_open + browser_navigate + browser_screenshot fullPage:true 4) Then write design-system.md", ); } diff --git a/src/policy/design/gates.ts b/src/policy/design/gates.ts index 34a568d..892eb5a 100644 --- a/src/policy/design/gates.ts +++ b/src/policy/design/gates.ts @@ -4,10 +4,11 @@ import { loadDesignState, type DesignState } from "./state"; import { activeDesignAgent } from "./flag"; import { isUiWrite } from "./skill-gate"; import { SKILL_TRIGGERS } from "./skill-triggers"; +import { ALLOWED_WRITE, isAllowedWrite } from "./allowed-write"; export { validateDesignSystem } from "./design-system-rules"; +export { ALLOWED_WRITE, isAllowedWrite } from "./allowed-write"; -export const ALLOWED_WRITE: RegExp = /\.(html|css|md|json)$/; export const EXEMPT_DIRS: readonly string[] = ["node_modules/", "dist/", "build/", ".claude/"]; const NAV = "mcp__fuse-browser__browser_navigate"; const SHOT = "mcp__fuse-browser__browser_screenshot"; @@ -18,11 +19,11 @@ export const deny = (reason: string): Prompt => ({ actions: ["Follow the design pipeline phases (0→identity, 1→inspiration, 2→screenshots, 3→design-system, 4→generate) in order"], }); -/** Block the design agent from writing anything but .html/.css/.md/.json. */ +/** Block the design agent from writing anything but .html/.css/.md/.json (+ motion*.js). */ export function htmlCssOnlyGate(filePath: string): Prompt | null { - if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null; + if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || isAllowedWrite(filePath)) return null; return deny( - "BLOCKED: design-expert can only write .html, .css, .md, and .json files. " + + "BLOCKED: design-expert can only write .html, .css, .json, .md, and motion*.js files. " + "Framework files (.tsx, .astro, .vue, .swift, .php) must be written by the domain expert " + "(astro-expert, react-expert, etc.) AFTER design validation.", ); diff --git a/src/policy/design/screenshot-tools.ts b/src/policy/design/screenshot-tools.ts new file mode 100644 index 0000000..1bc5b4c --- /dev/null +++ b/src/policy/design/screenshot-tools.ts @@ -0,0 +1,24 @@ +/** + * @module screenshot-tools + * Screenshot-credit tool set (C1), split out of `runtime/design-helpers.ts` to + * keep that file within the SOLID size budget (SRP). + * @packageDocumentation + */ + +/** + * Screenshot-credit tools: the single-shot capture PLUS the two fuse-browser + * batch capture primitives (`browser_shots_batch`, `browser_site_shots`) — a + * batch call that captures N urls/viewports must credit the quota too, or + * capturing efficiently would make the quota infranchissable. LIMITATION + * (deliberate, documented): this repo's cross-harness `NormalizedEvent` (see + * `runtime/normalize.ts`) never surfaces `tool_response` for any tool — by + * design, since its shape is undocumented for MCP tools and harness-specific + * for built-ins — so no per-call item count reaches this gate. A batch call + * therefore credits EXACTLY 1, same as a unit screenshot, never the N it may + * have captured. + */ +export const SHOT_TOOLS: ReadonlySet = new Set([ + "mcp__fuse-browser__browser_screenshot", + "mcp__fuse-browser__browser_shots_batch", + "mcp__fuse-browser__browser_site_shots", +]); diff --git a/src/policy/detect-framework.ts b/src/policy/detect-framework.ts index 0043d5c..1815f4e 100644 --- a/src/policy/detect-framework.ts +++ b/src/policy/detect-framework.ts @@ -18,6 +18,17 @@ const NEXT_CONTENT = /use client|use server|NextRequest|NextResponse|from ['"]ne const NEXT_ROUTE = /(page|layout|loading|error|route|middleware)\.(ts|tsx|js|jsx)$/; /** TanStack Start markers (file routes, server fns, start/router imports). */ const TANSTACK_CONTENT = /createFileRoute|createServerFn|from ['"]@tanstack\/react-(start|router)/; +/** + * C7: Tailwind CSS-file signals, verified against official docs (v3 + v4) — + * `@tailwind` (v3 layer directive, removed in v4), `@apply` (both versions), + * `@config` (v3 native / v4 compat, JS config path), `@source` (v4 content + * globbing), `@theme` (v4 design-token block), `theme(` (the function, both + * versions' syntax). Deliberately NOT a bare utility-class-in-selector scan + * (e.g. `.flex{}`) — an ordinary hand-written class named `.flex` is exactly + * the false-positive shape this fix removes; only genuine Tailwind-specific + * at-rules/functions count. + */ +const TAILWIND_CSS_CONTENT = /@tailwind\b|@apply\b|@config\b|@source\b|@theme\b|\btheme\(/; /** Derive the raw signal an extension + content carry (no filesystem access). */ function fileSignal(filePath: string, content: string): FileSignal { @@ -27,7 +38,15 @@ function fileSignal(filePath: string, content: string): FileSignal { if (/\.go$/.test(filePath)) return { definitive: "go" }; if (/\.rb$/.test(filePath)) return { definitive: "ruby" }; if (/\.rs$/.test(filePath)) return { definitive: "rust" }; - if (/\.css$/.test(filePath) || /@tailwind|@apply/.test(content)) return { definitive: "tailwind" }; + // C7: a .css file is "tailwind" only when its CONTENT shows it — bare + // extension used to classify EVERY .css as tailwind, forcing 6 unrelated + // skill reads on hand-written CSS. Empty content (file-size-scope.ts's + // caller) can never prove Tailwind usage, so it falls through to the + // JS-family checks below (all false for .css) and returns "generic" — the + // function's existing fail-open contract, not a special case. That caller + // never reaches here in practice: .css is already outside its + // FILE_SIZE_CODE_EXT scope, so this is inert there either way. + if (/\.css$/.test(filePath) && TAILWIND_CSS_CONTENT.test(content)) return { definitive: "tailwind" }; // JS branch: content signals ride MASKED content (a `// migrated from // createServerFn pattern` comment must not flip nextjs → tanstack-start). const masked = maskCommentsAndStrings(content, "c"); diff --git a/src/runtime/design-helpers.ts b/src/runtime/design-helpers.ts index a42162b..28048e0 100644 --- a/src/runtime/design-helpers.ts +++ b/src/runtime/design-helpers.ts @@ -11,13 +11,13 @@ import type { NormalizedEvent } from "./normalize"; 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 { SHOT_TOOLS } from "../policy/design/screenshot-tools"; 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"; const SCROLL = "mcp__fuse-browser__browser_scroll"; const GEMINI = "mcp__gemini-design__create_frontend"; @@ -42,7 +42,7 @@ 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, corpusRoot = "", corpusRequired = false, cwd = ""): void { - if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, corpusRequired)); + if (SHOT_TOOLS.has(event.tool)) 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 }); diff --git a/test/design-corpus-cache.test.ts b/test/design-corpus-cache.test.ts new file mode 100644 index 0000000..6cf091d --- /dev/null +++ b/test/design-corpus-cache.test.ts @@ -0,0 +1,64 @@ +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"; + +/** + * C2/C3: Claude Code also caches installed plugins at a SEPARATE versioned + * tree, `~/.claude/plugins/cache////`, under a plugin + * id that need not match the marketplace checkout's ("fuse-design" observed + * on a real machine vs "design-expert" in marketplaces/). Identification + * must be STRUCTURAL (the refs-design corpus suffix present under a version + * dir), never a hardcoded plugin name. + */ +const home = (): string => realpathSync(mkdtempSync(join(tmpdir(), "fh-cache-"))); +const REFS = join("skills", "design-web", "references", "refs-design"); + +test("claude-code cache tree: a corpus under ANY plugin id (not 'design-expert') is resolved", () => { + const h = home(); + const de = join(h, ".claude", "plugins", "cache", "fusengine-plugins", "fuse-design", "2.2.3"); + mkdirSync(join(de, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "claude-code", {})).toBe(join(de, REFS)); + expect(resolvePluginsRoot(undefined, h, "claude-code", {})).toBe(de); +}); + +test("claude-code cache tree: highest semver wins among several installed versions", () => { + const h = home(); + const plugin = join(h, ".claude", "plugins", "cache", "fusengine-plugins", "fuse-design"); + for (const v of ["2.1.30", "2.1.31", "2.2.0", "2.2.3"]) mkdirSync(join(plugin, v, REFS), { recursive: true }); + // An older version present WITHOUT the corpus suffix must not beat a newer one that has it. + mkdirSync(join(plugin, "2.2.9", "skills"), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "claude-code", {})).toBe(join(plugin, "2.2.3", REFS)); +}); + +test("claude-code cache tree: a plugin dir WITHOUT the corpus suffix is ignored (structural, not name-based)", () => { + const h = home(); + // A sibling plugin cache (e.g. fuse-astro) must never be picked as the corpus root. + mkdirSync(join(h, ".claude", "plugins", "cache", "fusengine-plugins", "fuse-astro", "1.0.0", "skills"), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "claude-code", {})).toBe(""); + expect(resolvePluginsRoot(undefined, h, "claude-code", {})).toBe(""); +}); + +test("claude-code: marketplace tree still resolves when the cache tree is absent (non-regression)", () => { + 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)); +}); + +test("claude-code: marketplace tree wins over the cache tree when both exist", () => { + const h = home(); + const marketplacePlugin = join(h, ".claude", "plugins", "marketplaces", "fusengine-plugins", "plugins", "design-expert"); + mkdirSync(join(marketplacePlugin, REFS), { recursive: true }); + const cachePlugin = join(h, ".claude", "plugins", "cache", "fusengine-plugins", "fuse-design", "2.2.3"); + mkdirSync(join(cachePlugin, REFS), { recursive: true }); + expect(resolvePluginsRoot(undefined, h, "claude-code", {})).toBe(marketplacePlugin); +}); + +test("claude-code: the cache tree is used when the marketplace tree is absent", () => { + const h = home(); + const cachePlugin = join(h, ".claude", "plugins", "cache", "fusengine-plugins", "fuse-design", "2.2.3"); + mkdirSync(join(cachePlugin, REFS), { recursive: true }); + expect(resolvePluginsRoot(undefined, h, "claude-code", {})).toBe(cachePlugin); +}); diff --git a/test/design-corpus-sheet.test.ts b/test/design-corpus-sheet.test.ts new file mode 100644 index 0000000..f6d8269 --- /dev/null +++ b/test/design-corpus-sheet.test.ts @@ -0,0 +1,24 @@ +import { test, expect } from "bun:test"; +import { classifyCorpusRead } from "../src/policy/design/corpus"; + +/** + * C6: a reference's `design-system.md` DIRECTION sheet (register, tone, + * macrostructure, signature element) must credit a corpus read — it is the + * fiche read to CHOOSE a reference, distinct from the tokens-*.md procedures. + */ +const ROOT = "/plugins/design-expert/skills/design-web/references/refs-design"; + +test("C6: /design-system.md under the corpus root credits as 'sheet'", () => { + expect(classifyCorpusRead(`${ROOT}/elysian/design-system.md`, ROOT)).toBe("sheet"); + expect(classifyCorpusRead(`${ROOT}/umbrel-recode/design-system.md`, ROOT)).toBe("sheet"); +}); + +test("C6: a design-system.md OUTSIDE the corpus root does not credit", () => { + expect(classifyCorpusRead("/proj/design-system.md", ROOT)).toBeNull(); + expect(classifyCorpusRead("/tmp/scratch/refs-design/elysian/design-system.md", ROOT)).toBeNull(); +}); + +test("C6 non-regression: README.md and tokens-*.md still credit as before", () => { + expect(classifyCorpusRead(`${ROOT}/README.md`, ROOT)).toBe("index"); + expect(classifyCorpusRead(`${ROOT}/elysian/tokens-elysian.md`, ROOT)).toBe("tokens"); +}); diff --git a/test/design-corpus-transitions.test.ts b/test/design-corpus-transitions.test.ts index b258e8d..d734535 100644 --- a/test/design-corpus-transitions.test.ts +++ b/test/design-corpus-transitions.test.ts @@ -34,7 +34,10 @@ test("page: one corpus read plus one screenshot is not enough", () => { 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); + // C5: full mode now needs README + 3 tokens; the 3rd file (c-recode) only + // satisfies the corpus side of the conjunction — this test's own assertions + // stay on the screenshot quota, unchanged. + for (const r of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md", "c-recode/tokens-c.md"]) s = recordCorpusRead(s, r, true); expect(s.currentPhase).toBe(1); s = recordScreenshot(s, true); expect(s.currentPhase).toBe(1); diff --git a/test/design-corpus.test.ts b/test/design-corpus.test.ts index 42eb67a..1bc81da 100644 --- a/test/design-corpus.test.ts +++ b/test/design-corpus.test.ts @@ -18,17 +18,21 @@ test("classifyCorpusRead: index vs tokens vs outside the delivered corpus", () = expect(classifyCorpusRead("/proj/docs/refs-design/README.md", ROOT)).toBeNull(); }); -test("corpusReady: per-mode thresholds (component >= 1, page >= 2, full = index + 2 tokens)", () => { +test("corpusReady: per-mode thresholds (component >= 1, page >= 2, full = index + 3 tokens)", () => { const idx = ["README.md"]; const one = ["README.md", "umbrel-recode/tokens-umbrel.md"]; const two = [...one, "fora-recode/tokens-fora.md"]; + const three = [...two, "linear-recode/tokens-linear.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); + // C5: README + 2 tokens is NO LONGER enough for full (raised 2 -> 3) — this + // is the assertion that bites: without it, the 2->3 change would be invisible. + expect(corpusReady(two, "full")).toBe(false); + expect(corpusReady(three, "full")).toBe(true); }); test("pluginsWriteGuard: writes under the delivered corpus are denied, anything else passes", () => { diff --git a/test/design-gate-corpus.test.ts b/test/design-gate-corpus.test.ts index a6ffd43..9265357 100644 --- a/test/design-gate-corpus.test.ts +++ b/test/design-gate-corpus.test.ts @@ -7,15 +7,22 @@ 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. */ +/** + * Fixture: a delivered corpus (README + THREE tokens files — C5 raised the + * `full`-mode threshold to 3) and an active design agent. `setup()` only + * provisions the 3rd file (c-recode) ON DISK — it does NOT read it, so each + * test body decides WHEN that read happens relative to its own assertions. + */ 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 }); + mkdirSync(join(root, "c-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"); + writeFileSync(join(root, "c-recode", "tokens-c.md"), "# c"); setActiveDesignAgent(cache, "ag"); saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 1, inspirationRead: true }); const ev = (phase: "pre" | "post", tool: string, filePath = "", content = ""): NormalizedEvent => @@ -34,6 +41,8 @@ test("designGate with an injected corpus: reads advance the state, jointure enfo 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"); + // C5: the corpus completes with a 3rd tokens read (full mode needs 3). + gate(ev("post", "Read", join(root, "c-recode", "tokens-c.md"))); // 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")); @@ -42,7 +51,7 @@ test("designGate with an injected corpus: reads advance the state, jointure enfo 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))); + for (const f of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md", "c-recode/tokens-c.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 => @@ -85,7 +94,7 @@ test("cwd is WIRED into pluginsWriteGuard: a RELATIVE patch path under the plugi 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))); + for (const f of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md", "c-recode/tokens-c.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);`; diff --git a/test/design-motion-js.test.ts b/test/design-motion-js.test.ts new file mode 100644 index 0000000..84980e4 --- /dev/null +++ b/test/design-motion-js.test.ts @@ -0,0 +1,31 @@ +import { test, expect } from "bun:test"; +import { htmlCssOnlyGate } from "../src/policy/design/gates"; + +/** + * C4: the design write-allowlist bans every `.js` file, but all 11 corpus + * references ship their animation as a SEPARATE `motion*.js` file — the gate + * must allow that pattern specifically, never `.js` in general. + */ +test("C4: motion.js is allowed (bare motion file)", () => { + expect(htmlCssOnlyGate("motion.js")).toBeNull(); +}); + +test("C4: motion-nav.js is allowed (hyphenated motion file)", () => { + expect(htmlCssOnlyGate("src/site/motion-nav.js")).toBeNull(); +}); + +test("C4: motion-scroll.js under a nested path is allowed", () => { + expect(htmlCssOnlyGate("output/umbrel-recode/motion-scroll.js")).toBeNull(); +}); + +test("C4 anti-over-width: app.js (generic JS, no motion prefix) is still blocked", () => { + expect(htmlCssOnlyGate("src/site/app.js")?.kind).toBe("block"); +}); + +test("C4 anti-over-width: motionless-app.js (motion PREFIX only, not the motion*.js shape) is still blocked", () => { + expect(htmlCssOnlyGate("src/site/motionless-app.js")?.kind).toBe("block"); +}); + +test("C4 non-regression: .tsx is still blocked (framework files stay off-limits)", () => { + expect(htmlCssOnlyGate("src/components/x.tsx")?.kind).toBe("block"); +}); diff --git a/test/design-screenshot-tools.test.ts b/test/design-screenshot-tools.test.ts new file mode 100644 index 0000000..1d97f8b --- /dev/null +++ b/test/design-screenshot-tools.test.ts @@ -0,0 +1,63 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync } 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"; + +/** + * C1: screenshotsCount must credit the fuse-browser batch capture primitives + * (browser_shots_batch, browser_site_shots), not just the unit screenshot — + * else capturing efficiently makes the phase-2 quota infranchissable. + */ +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-shot-")); + +function freshState(cache: string): void { + saveDesignState(cache, initDesignState("a", "component", true)); +} + +function post(tool: string, input: Record = {}): NormalizedEvent { + return { phase: "post", tool, input, sessionId: "s" }; +} + +test("C1: browser_shots_batch credits the screenshot quota", () => { + const cache = tmp(); + freshState(cache); + recordPost(post("mcp__fuse-browser__browser_shots_batch"), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.screenshotsCount).toBe(1); +}); + +test("C1: browser_site_shots credits the screenshot quota", () => { + const cache = tmp(); + freshState(cache); + recordPost(post("mcp__fuse-browser__browser_site_shots"), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.screenshotsCount).toBe(1); +}); + +test("C1 non-regression: browser_screenshot (unit capture) still credits", () => { + const cache = tmp(); + freshState(cache); + recordPost(post("mcp__fuse-browser__browser_screenshot"), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.screenshotsCount).toBe(1); +}); + +test("C1 anti-over-width: a non-capture tool does NOT credit the quota", () => { + const cache = tmp(); + freshState(cache); + recordPost(post("mcp__fuse-browser__browser_navigate"), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.screenshotsCount).toBe(0); + recordPost(post("Read", { file_path: "/tmp/whatever.md" }), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.screenshotsCount).toBe(0); +}); + +test("C1: a batch call credits exactly 1, never the N urls it may have shot", () => { + const cache = tmp(); + freshState(cache); + let state = loadDesignState(cache, "a")!; + recordPost(post("mcp__fuse-browser__browser_site_shots", { urls: ["a", "b", "c"] }), cache, state, "", false); + state = loadDesignState(cache, "a")!; + expect(state.screenshotsCount).toBe(1); + recordPost(post("mcp__fuse-browser__browser_site_shots", { urls: ["d", "e"] }), cache, state, "", false); + expect(loadDesignState(cache, "a")!.screenshotsCount).toBe(2); +}); diff --git a/test/detect-framework-css-tailwind.test.ts b/test/detect-framework-css-tailwind.test.ts new file mode 100644 index 0000000..edf8fb9 --- /dev/null +++ b/test/detect-framework-css-tailwind.test.ts @@ -0,0 +1,37 @@ +import { test, expect } from "bun:test"; +import { detectFramework } from "../src/policy/detect-framework"; + +/** + * C7: a `.css` file is only classified "tailwind" when its CONTENT shows it + * — bare `.css` extension used to force EVERY hand-written stylesheet into + * the tailwind skill-consultation gate, no matter its content. + */ +test("C7: a .css file with no Tailwind marker is NOT classified tailwind", () => { + const css = "body { margin: 0; padding: 0; } .header { display: flex; }"; + expect(detectFramework("src/styles/site.css", css)).not.toBe("tailwind"); +}); + +test("C7: a .css file with @apply is classified tailwind", () => { + const css = ".btn-primary {\n @apply bg-blue-500 text-white px-4 py-2 rounded-lg;\n}"; + expect(detectFramework("src/styles/buttons.css", css)).toBe("tailwind"); +}); + +test("C7: a .css file with @tailwind (v3 directive) is classified tailwind", () => { + const css = "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"; + expect(detectFramework("src/styles/globals.css", css)).toBe("tailwind"); +}); + +test("C7 non-regression: a .tsx file with utility classes keeps its existing (non-css) classification", () => { + const tsx = "export const Card = () =>
hi
;"; + // No project caps injected here (no package.json), so the JS-family signal + // reconciles to "generic" — the point is that .tsx utility-class usage is + // routed through the EXISTING react/jsHint path, never through the new + // CSS-content branch this fix touches. + expect(detectFramework("src/components/Card.tsx", tsx)).not.toBe("tailwind"); +}); + +test("C7 empty-content decision: detectFramework(cssPath, \"\") resolves to generic, not tailwind", () => { + // Documented decision: empty content can never prove Tailwind usage, so it + // falls through to the fail-open \"generic\" contract instead of guessing. + expect(detectFramework("src/styles/unknown.css", "")).toBe("generic"); +});