diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc3fa792..3082de86b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.1.0] - 2026-09-25 + +### Fixed + +- Find Impeccable installed through the Claude Code plugin marketplace, including a trusted custom `CLAUDE_CONFIG_DIR`. Preserve traditional skill installs and the existing explicit-engine, PATH and standalone-cache priority. +- Select plugin versions deterministically with strict semver ordering and support for hash-named versions. Keep a selected installation's launcher, engine and engine version together instead of borrowing an older plugin's engine. +- Use the same strict ordering for the standalone engine cache, retaining its semver-only policy and precedence. Do not follow cache directory symlinks or repository configuration links into unrelated filesystem trees. +- Preserve repository and symlink execution boundaries, sanitize discovery diagnostics, and quote or suppress launcher hints when a filename cannot be represented safely. Discovery never downloads or runs a launcher; engine compatibility warnings and install consent remain unchanged. +- Compare canonical HOME paths at the trust boundary, so home-directory aliases and dotfiles repositories do not hide user-installed engines or admit private home files as scan targets. +- Add plugin discovery, handoff, malformed-version and adversarial-path regressions, plus Windows-safe discovery cases selected by the native Windows test lane. + +Includes the plugin-cache discovery contribution from @SomSamantray in #2976. + ## [1.90.2.0] - 2026-09-24 **Spend less time waiting for tests.** diff --git a/VERSION b/VERSION index e51a5953e..5b29d5421 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.90.2.0 +1.91.1.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e86234aa2..99839198d 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.90.2.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.1.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-design-detect.ts b/bin/gstack-design-detect.ts index d25e802bf..079ac37a2 100755 --- a/bin/gstack-design-detect.ts +++ b/bin/gstack-design-detect.ts @@ -31,6 +31,8 @@ * ~/{.claude,.agents,.cursor,.gemini,.github,.opencode}/skills/impeccable/scripts/ * ├─ bin/-/impeccable[.exe] (engine installed beside the launcher) ──► READY * └─ impeccable (launcher only) ──► IMPECCABLE_NOT_CACHED: + * ${CLAUDE_CONFIG_DIR:-~/.claude}/plugins/cache////skills/impeccable/ + * └─ newest semver skill per plugin, then opaque names in stable order; same trust checks * //impeccable ──► launcher-present only (IMPECCABLE_NOT_CACHED, no run hint) * │ * nothing ──► IMPECCABLE_NOT_AVAILABLE @@ -71,7 +73,7 @@ * helpers they could outlive the kill (known limit). * * Env trust: Bun auto-loads a cwd `.env`, so every rendered invocation passes - * `--no-env-file`, and independently IMPECCABLE_BIN / IMPECCABLE_HOME values + * `--no-env-file`, and independently IMPECCABLE_BIN / IMPECCABLE_HOME / CLAUDE_CONFIG_DIR values * whose realpath lies inside the repo or cwd are ignored (IMPECCABLE_ENV_IGNORED). * * Observability: one content-free JSON line per probe/scan appended to @@ -99,6 +101,7 @@ import { isFrontendPath } from '../lib/frontend-scope'; const WIN = process.platform === 'win32'; const HOME = os.homedir(); +const REAL_HOME = realpathOrNull(HOME) ?? HOME; const ENV = process.env; /** Where config.yaml lives: the same precedence bin/gstack-config uses. */ @@ -226,7 +229,7 @@ function isEngineName(realFile: string): boolean { * the user's own installs into "repository-controlled" files. */ function isProjectDir(dir: string): boolean { - return !isInside(HOME, dir); + return !isInside(REAL_HOME, dir); } /** Under the project the agent is reviewing: the repository, or cwd, when each is a project directory. */ @@ -239,12 +242,27 @@ function semverKey(v: string): number[] | null { return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; } +function safeReaddir(dir: string): string[] { + try { return fs.readdirSync(dir); } catch { return []; } +} + +function strictSemver(name: string): string | null { + const normalized = name.replace(/^v/, ''); + const match = normalized.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/); + const valid = match && [match[4], match[5]].every(part => part === undefined || part.split('.').every(id => id.length > 0)) + && (!match[4] || match[4].split('.').every(id => !/^0\d+$/.test(id))); + return valid ? normalized : null; +} + +function versionOrder(a: { name: string; version: string | null }, b: { name: string; version: string | null }): number { + const order = a.version && b.version ? Bun.semver.order(b.version, a.version) : Number(!!b.version) - Number(!!a.version); + return order || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); +} + function newestSemverDir(dir: string): string | null { - let entries: string[]; - try { entries = fs.readdirSync(dir); } catch { return null; } - const versions = entries.map(e => ({ e, k: semverKey(e) })).filter(x => x.k) as { e: string; k: number[] }[]; - versions.sort((a, b) => (b.k[0] - a.k[0]) || (b.k[1] - a.k[1]) || (b.k[2] - a.k[2])); - return versions[0]?.e ?? null; + const versions = safeReaddir(dir).map(name => ({ name, version: strictSemver(name) })).filter(x => x.version); + versions.sort(versionOrder); + return versions[0]?.name ?? null; } function readJsonFile(file: string): { ok: true; value: unknown } | { ok: false; missing: boolean } { @@ -285,6 +303,61 @@ function engineSiblings(launcherDir: string): string[] { return [...tags].map(t => path.join(launcherDir, 'bin', t, name)); } +/** + * A Claude Code plugin install of impeccable lands at + * /plugins/cache////skills/impeccable/, + * never at the traditional //skills/impeccable/ the + * ordinary walk below expects. Marketplace/plugin/version names are not + * predictable, so walk the three levels (github.com/garrytan/gstack/issues/2838). + */ +function pluginCacheImpeccableSkillDirs(configDir: string, step: (s: string) => void): string[] { + const directories = (dir: string): string[] => { + try { + return fs.readdirSync(dir, { withFileTypes: true }).filter(entry => { + if (entry.isDirectory()) return true; + if (entry.isSymbolicLink()) step(`plugin skip ${path.join(dir, entry.name)}: cache traversal does not follow directory symlinks`); + return false; + }).map(entry => entry.name).sort(); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') step(`plugin skip ${dir}: ${code}`); + return []; + } + }; + const cacheDir = path.join(configDir, 'plugins', 'cache'); + const dirs: string[] = []; + step(`plugin cache=${cacheDir}`); + const realConfig = realpathOrNull(configDir); + const realCache = realpathOrNull(cacheDir); + if (realConfig && realCache && !isInside(realCache, realConfig)) { + step(`plugin skip ${cacheDir}: cache resolves outside its configuration directory`); + return dirs; + } + for (const marketplace of directories(cacheDir)) { + const marketplaceDir = path.join(cacheDir, marketplace); + for (const plugin of directories(marketplaceDir)) { + const pluginDir = path.join(marketplaceDir, plugin); + const versions = directories(pluginDir).map(name => ({ name, version: strictSemver(name) })); + versions.sort(versionOrder); + for (const { name, version } of versions) { + const skillDir = path.join(pluginDir, name, 'skills', 'impeccable'); + try { + if (!fs.statSync(path.join(skillDir, 'SKILL.md')).isFile()) { + step(`plugin skip ${skillDir}: SKILL.md is not a regular file`); + continue; + } + step(`plugin selected=${skillDir}${version ? '' : ' (opaque version; recency unknown)'}`); + dirs.push(skillDir); + break; + } catch (e) { + step(`plugin skip ${skillDir}: ${(e as NodeJS.ErrnoException).code}`); + } + } + } + } + return dirs; +} + function probe(host: string, verbose = false): Probe { const cwd = realpathOrNull(process.cwd()) ?? process.cwd(); const repoRoot = gitTopLevel(cwd) ?? cwd; @@ -302,33 +375,77 @@ function probe(host: string, verbose = false): Probe { // A launcher inside the repo or cwd counts as "skill present" only: its sibling // engine is repository-controlled and is never a READY candidate, and the hint // never tells anyone to run it. - const roots = [...new Set([repoRoot, cwd, HOME])]; - let siblingEngine: string | null = null; - let siblingVersion: string | null = null; + const home = HOME; + const roots = [...new Map([repoRoot, cwd, home].map(root => [realpathOrNull(root) ?? root, root])).values()]; + let claudeConfig = trustedEnvPath('CLAUDE_CONFIG_DIR', repoRoot, cwd, p.notes, step); + if (claudeConfig) { + try { + if (!fs.statSync(claudeConfig).isDirectory()) { + p.notes.push(`${SENTINEL.ENV_IGNORED}: CLAUDE_CONFIG_DIR is not a directory`); + claudeConfig = null; + } + } catch (e) { + step(`CLAUDE_CONFIG_DIR unavailable: ${(e as NodeJS.ErrnoException).code}`); + claudeConfig = null; + } + } + const installs: { launcher: string; engine?: string; version?: string }[] = []; + const seenSkills = new Set(); let repoLocalLauncher = false; + // Shared by the SKILL_ROOTS walk and the plugin-cache walk below: same + // presence/launcher/repo-local-exclusion/sibling-engine logic either way, + // whichever path convention placed the skill at `skillDir`. + const checkSkillDir = (skillDir: string, rootIsRepo: boolean) => { + const realSkill = realpathOrNull(skillDir); + rootIsRepo ||= !!realSkill && underProject(realSkill, repoRoot, cwd); + const key = `${rootIsRepo ? 'repo:' : ''}${realSkill}`; + if (!realSkill || seenSkills.has(key)) return; + seenSkills.add(key); + if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) p.skillPresent = true; + const launcher = path.join(skillDir, 'scripts', 'impeccable'); + if (!fs.existsSync(launcher)) return; + try { + if (!fs.statSync(launcher).isFile()) { step(`launcher skip ${launcher}: not a regular file`); return; } + } catch (e) { step(`launcher skip ${launcher}: ${(e as NodeJS.ErrnoException).code}`); return; } + if (rootIsRepo) { repoLocalLauncher = true; step(`skill skip ${skillDir}: repository-local install`); return; } + const realLauncher = realpathOrNull(launcher); + if (!realLauncher || underProject(realLauncher, repoRoot, cwd)) { repoLocalLauncher = true; step(`launcher skip ${launcher}: repository-local or missing target`); return; } + const install: typeof installs[number] = { launcher }; + installs.push(install); + for (const cand of engineSiblings(path.dirname(launcher))) { + const real = realpathOrNull(cand); + if (!real) continue; + if (!isExecutableFile(real) || !isEngineName(real) || underProject(real, repoRoot, cwd)) { step(`engine skip ${cand}: not a trusted executable`); continue; } + install.engine = real; + try { + const v = fs.readFileSync(path.join(path.dirname(launcher), 'VERSION'), 'utf-8').trim(); + install.version = semverKey(v) ? v.replace(/^v/, '') : undefined; + } catch { /* no VERSION file */ } + break; + } + }; for (const root of roots) { const rootIsRepo = underProject(root, repoRoot, cwd); for (const sub of SKILL_ROOTS) { - const skillDir = path.join(root, sub, 'skills', 'impeccable'); - if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) p.skillPresent = true; - const launcher = path.join(skillDir, 'scripts', 'impeccable'); - if (!fs.existsSync(launcher)) continue; - if (rootIsRepo) { repoLocalLauncher = true; continue; } - const realLauncher = realpathOrNull(launcher); - if (!realLauncher || underProject(realLauncher, repoRoot, cwd)) { repoLocalLauncher = true; continue; } - p.launcher ??= launcher; - for (const cand of engineSiblings(path.dirname(launcher))) { - const real = realpathOrNull(cand); - if (siblingEngine || !real || !isExecutableFile(real) || !isEngineName(real) || underProject(real, repoRoot, cwd)) continue; - siblingEngine = real; - try { - const v = fs.readFileSync(path.join(path.dirname(launcher), 'VERSION'), 'utf-8').trim(); - siblingVersion = semverKey(v) ? v.replace(/^v/, '') : null; // a non-semver VERSION is not trusted as text - } catch { /* no VERSION file */ } - } + if (sub === '.claude' && claudeConfig && root === home) continue; + checkSkillDir(path.join(root, sub, 'skills', 'impeccable'), rootIsRepo); } } - step(`skill=${p.skillPresent} launcher=${p.launcher ?? 'none'} repoLocalLauncher=${repoLocalLauncher} sibling=${siblingEngine ?? 'none'}`); + if (claudeConfig) checkSkillDir(path.join(claudeConfig, 'skills', 'impeccable'), false); + const configs = roots.map(root => ({ dir: root === home && claudeConfig ? claudeConfig : path.join(root, '.claude'), repoLocal: underProject(root, repoRoot, cwd) })); + const seenConfigs = new Set(); + for (const { dir, repoLocal } of configs) { + const real = realpathOrNull(dir) ?? dir; + if (repoLocal && !underProject(real, repoRoot, cwd)) { step(`plugin skip ${dir}: repository configuration resolves outside the project`); continue; } + if (seenConfigs.has(real)) continue; + seenConfigs.add(real); + for (const skillDir of pluginCacheImpeccableSkillDirs(dir, step)) { + checkSkillDir(skillDir, repoLocal || underProject(real, repoRoot, cwd)); + } + } + const bundled = installs.find(install => install.engine); + p.launcher = (bundled ?? installs[0])?.launcher; + step(`skill=${p.skillPresent} launcher=${p.launcher ?? 'none'} repoLocalLauncher=${repoLocalLauncher} sibling=${bundled?.engine ?? 'none'}`); // Hook manifests, host-aware. const mine = HOSTS_WITH_HOOKS[host] ?? []; @@ -423,10 +540,10 @@ function probe(host: string, verbose = false): Probe { } // engine beside a HOME-rooted launcher - if (!p.engine && siblingEngine) { - p.engine = siblingEngine; - p.engineVersion = siblingVersion ?? undefined; - p.sentinel = `${SENTINEL.READY}: ${siblingEngine}`; + if (!p.engine && bundled?.engine) { + p.engine = bundled.engine; + p.engineVersion = bundled.version; + p.sentinel = `${SENTINEL.READY}: ${bundled.engine}`; } if (p.engine) { @@ -453,8 +570,13 @@ function probe(host: string, verbose = false): Probe { const launcher = p.launcher ?? launcherOnPath ?? (repoLocalLauncher ? 'repository-local install' : null); if (launcher) { p.sentinel = `${SENTINEL.NOT_CACHED}: ${launcher}`; + const hintPath = p.launcher && !/[\x00-\x1f\x7f`]/.test(p.launcher) && stripControl(p.launcher) === p.launcher && !WIN + ? /^[a-zA-Z0-9_./-]+$/.test(p.launcher) ? p.launcher : `'${p.launcher.replaceAll("'", "'\\''")}'` + : null; const how = p.launcher - ? `run \`${p.launcher} detect --help\` once; it fetches the engine version pinned by your install` + ? hintPath + ? `run \`${hintPath} detect --help\` once in a POSIX shell; it fetches the engine version pinned by your install` + : 'use your Impeccable installation to fetch its pinned engine; no shell command is suggested for this path or platform' : repoLocalLauncher && !launcherOnPath ? 'the skill is installed inside this repository, and gstack never runs a repository-local launcher; install it under your home directory (`npx impeccable install --scope global` outside the repo) if you want the engine here' : 'run `npx impeccable install --scope global` yourself (the engine lands beside the skill under your home directory; `npx impeccable detect --help` alone caches it only for npx)'; @@ -630,7 +752,10 @@ function probeLines(p: Probe): string[] { lines.push(`${SENTINEL.IGNORED_VALUES}: ${p.ignoredValues.join(',')}`); lines.push(...p.notes); if (p.steps.length) lines.push(...p.steps.map(s => `${SENTINEL.PROBE_STEP}: ${s}`)); - return lines; + return lines.map(line => { + const colon = line.indexOf(':'); + return colon < 0 ? line : line.slice(0, colon + 1) + stripControl(line.slice(colon + 1)); + }); } // ── Sanitization ───────────────────────────────────────────────────────────── diff --git a/docs/designs/IMPECCABLE_INTEROP.md b/docs/designs/IMPECCABLE_INTEROP.md index 7e1ca2bec..cbb79fb17 100644 --- a/docs/designs/IMPECCABLE_INTEROP.md +++ b/docs/designs/IMPECCABLE_INTEROP.md @@ -13,6 +13,57 @@ - **Generation-time guard.** The design binary's image prompt carries a "Never:" line built from ten catalog ids. - **Attribution.** `NOTICE.md` + `licenses/Apache-2.0.txt`. +## Plugin discovery follow-up (2026-09-25) + +The detector recognizes Claude Code marketplace installs at +`${CLAUDE_CONFIG_DIR:-~/.claude}/plugins/cache////skills/impeccable/` +as well as traditional skill installs. A trusted absolute `CLAUDE_CONFIG_DIR` +replaces the default Claude user profile for both layouts. Relative, missing, +non-directory, or repository-resolving overrides are ignored; `probe --verbose` +explains the fallback. No Claude settings, plugin registry, or hook configuration +is changed. + +Within each marketplace/plugin, discovery selects the newest semver directory +with a regular `SKILL.md`, including prerelease ordering. Equal versions use the +directory name as a stable tie-breaker. Hash and `unknown` directories remain +supported after semver candidates in stable name order; their names do not prove +recency. An incomplete directory is skipped, but a valid newer skill without an +engine does not borrow an older plugin's engine. Launcher, bundled engine and +engine `VERSION` stay associated with the selected installation. Plugin version +numbers never replace engine compatibility evidence. + +Traditional user skill installs precede plugin candidates; explicit +`IMPECCABLE_BIN`, an accepted PATH binary and the standalone engine cache retain +their existing higher priority. The standalone engine cache shares strict +semver ordering so an rc directory cannot win over its stable release; unlike +plugin discovery, it still rejects opaque names. Multiple marketplaces/plugins are ordered by +name. A ready engine suppresses the install offer; a missing engine retains the +existing consent and never-ask controls. `IMPECCABLE_SKILL: present` still means +files were found on disk, not that a cached plugin is enabled in the current +Claude session. `IMPECCABLE_ENGINE_UNTESTED` remains an honest warning for engines +outside the captured test set. + +Discovery is a fixed-depth, read-only filesystem walk. It does not execute +launchers, install plugins, prune caches, or recursively search arbitrary home +directories. Resolved paths inside the reviewed project are not executable +candidates, including symlinked installs. Cache traversal skips directory +symlinks and rejects a cache that resolves outside its configuration directory; +repository configuration links cannot redirect the walk outside the project. +Canonical HOME comparisons preserve +user installs when the home directory is +accessed through an alias, without allowing arbitrary home files as scan targets. +Verbose output records rejected paths and filesystem errors without breaking the +sentinel protocol. Runnable launcher +hints use POSIX-shell quoting; control-character or Markdown-breaking names and +Windows receive non-command guidance instead. The raw path used for engine +execution is separate from its sanitized display. + +Regressions live in `test/gstack-design-detect.test.ts`; the plugin handoff agent +case in `test/skill-e2e-design.test.ts` exercises the actual discovery-to-report +path without an explicit engine override. Native Windows executable behavior and +macOS path alias behavior require their own platform results; Linux fixture +passes are not evidence of those native runs. + ## CEO plan (promoted) ### CEO Plan: impeccable.style interop for gstack's design skills diff --git a/package.json b/package.json index abedee093..8cd5da01e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.90.2", + "version": "1.91.1", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/code-intelligence.test.ts b/test/code-intelligence.test.ts index a0adb51dc..1dbe9be88 100644 --- a/test/code-intelligence.test.ts +++ b/test/code-intelligence.test.ts @@ -37,6 +37,15 @@ import { LARGE_REPO_FILE_THRESHOLD, } from "../lib/code-intelligence"; +function isolateGitRemote(repo: string, url: string): void { + const git = (...args: string[]) => execFileSync("git", args, { + cwd: repo, encoding: "utf8", timeout: 10_000, + }).trim(); + expect(git("config", "--get", "remote.origin.url")).toBe(url); + git("config", "--local", `url.${url}.insteadOf`, url); + expect(git("remote", "get-url", "origin")).toBe(url); +} + describe("capability matrix", () => { test("every provider advertises the four required capabilities", () => { for (const p of [new GbrainProvider(), new SourcebotProvider(), new GraphifyProvider()]) { @@ -402,6 +411,7 @@ describe("consent unification — deny tier wins (R1)", () => { const git = (...a: string[]) => execFileSync("git", a, { cwd: repo, timeout: 30_000 }); git("init", "-q", "."); git("remote", "add", "origin", url); + isolateGitRemote(repo, url); return repo; } const POLICY_BIN = path.join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy"); @@ -500,6 +510,7 @@ describe("read-only repo policy blocks write-class CLI index (R2)", () => { fs.mkdirSync(repo, { recursive: true }); execFileSync("git", ["init", "-q", "."], { cwd: repo }); execFileSync("git", ["remote", "add", "origin", URL], { cwd: repo }); + isolateGitRemote(repo, URL); setProvider("gbrain", env); setConsent(repo, true, env); execFileSync(POLICY_BIN, ["set", URL, "read-only"], { env, encoding: "utf-8" }); @@ -702,6 +713,7 @@ describe("CLI search consent gate (gbrain provider, honest refusal message)", () fs.mkdirSync(repo, { recursive: true }); execFileSync("git", ["init", "-q", "."], { cwd: repo }); execFileSync("git", ["remote", "add", "origin", URL], { cwd: repo }); + isolateGitRemote(repo, URL); env = { ...process.env, GSTACK_HOME: home, PATH: `${shimDir}:${process.env.PATH}` }; setProvider("gbrain", env); setRoot("gbrain", repo, env); diff --git a/test/design-detector-source-fixture.test.ts b/test/design-detector-source-fixture.test.ts index 0d1b1e65c..c6094853c 100644 --- a/test/design-detector-source-fixture.test.ts +++ b/test/design-detector-source-fixture.test.ts @@ -58,7 +58,7 @@ async function exercise(modes: Mode[], retention?: 'directory' | 'run-id' | 'bot }, }; const args = { - ROOT, fs: localFs, os: { tmpdir: () => scratch }, path, expect, CAPTURE_MS, CAPTURE_LONG_MS, + ROOT, fs: localFs, os: { tmpdir: () => scratch }, path, expect, CAPTURE_MS, CAPTURE_LONG_MS, evalsEnabled: true, process: { ...process, env: { ...process.env, ...env } }, resolveEvalModel, getProjectEvalDir: () => artifactRoot, console: { ...console, log: (...args: any[]) => notices.push(args.join(' ')), error: (...args: any[]) => notices.push(args.join(' ')) }, diff --git a/test/dx-manual-handoff-ao.test.ts b/test/dx-manual-handoff-ao.test.ts index f4319bd2d..cba8a75d6 100644 --- a/test/dx-manual-handoff-ao.test.ts +++ b/test/dx-manual-handoff-ao.test.ts @@ -29,7 +29,7 @@ describe('AO completed manual DX handoff preserves report freshness',()=>{ expect(E2E_TOUCHFILES[owner]).toContain('test/fixtures/dx-manual-handoff-ao.json'); } const arrays=[...Object.values(E2E_TOUCHFILES),...Object.values(LLM_JUDGE_TOUCHFILES),GLOBAL_TOUCHFILES]; - expect(arrays).toHaveLength(234); + expect(arrays).toHaveLength(235); for(const values of arrays)for(let i=0;i{ diff --git a/test/fake-impeccable-touchfiles.test.ts b/test/fake-impeccable-touchfiles.test.ts index 629b0a3b8..37d9d25c4 100644 --- a/test/fake-impeccable-touchfiles.test.ts +++ b/test/fake-impeccable-touchfiles.test.ts @@ -8,6 +8,7 @@ describe('fake impeccable engine selection', () => { 'design-html-slop-gate', 'design-review-detector-shim', 'design-review-detector-shim-dom', + 'design-review-plugin-handoff', 'review-design-lite', ]; @@ -28,6 +29,7 @@ describe('fake impeccable engine selection', () => { ['design-html-slop-gate', 'periodic'], ['design-review-detector-shim', 'gate'], ['design-review-detector-shim-dom', 'gate'], + ['design-review-plugin-handoff', 'gate'], ['review-design-lite', 'periodic'], ]); }); diff --git a/test/gbrain-repo-policy.test.ts b/test/gbrain-repo-policy.test.ts index db6b58c2d..b36827a85 100644 --- a/test/gbrain-repo-policy.test.ts +++ b/test/gbrain-repo-policy.test.ts @@ -25,6 +25,17 @@ const BIN = path.join(ROOT, 'bin', 'gstack-gbrain-repo-policy'); let tmpHome: string; +function isolateGitRemote(repo: string, url: string): void { + const git = (...args: string[]) => { + const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 }); + expect(result.status).toBe(0); + return result.stdout.trim(); + }; + expect(git('config', '--get', 'remote.origin.url')).toBe(url); + git('config', '--local', `url.${url}.insteadOf`, url); + expect(git('remote', 'get-url', 'origin')).toBe(url); +} + function run(args: string[], opts: { env?: Record } = {}) { const res = spawnSync(BIN, args, { env: { ...process.env, GSTACK_HOME: tmpHome, ...(opts.env || {}) }, @@ -54,6 +65,25 @@ afterEach(() => { fs.rmSync(tmpHome, { recursive: true, force: true }); }); +test('fixture origin isolation overrides a controlled URL rewrite without changing the stored remote', () => { + const repo = path.join(tmpHome, 'repo'); + fs.mkdirSync(repo); + const git = (...args: string[]) => { + const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 }); + expect(result.status).toBe(0); + return result.stdout.trim(); + }; + const url = 'https://fixture.invalid/org/repo.git'; + git('init', '-q'); + git('remote', 'add', 'origin', url); + git('config', '--local', 'url.https://mirror.invalid/.insteadOf', 'https://fixture.invalid/'); + expect(git('config', '--get', 'remote.origin.url')).toBe(url); + expect(git('remote', 'get-url', 'origin')).toBe('https://mirror.invalid/org/repo.git'); + isolateGitRemote(repo, url); + expect(git('config', '--get', 'remote.origin.url')).toBe(url); + expect(git('remote', 'get-url', 'origin')).toBe(url); +}); + describe('normalize', () => { test('strips https:// and .git', () => { const r = run(['normalize', 'https://github.com/foo/bar.git']); @@ -293,6 +323,7 @@ describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path) spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8', timeout: 30_000 }); git('init', '-q', '.'); git('remote', 'add', 'origin', REPO_URL); + isolateGitRemote(repoDir, REPO_URL); fs.writeFileSync(path.join(repoDir, 'README.md'), 'fixture\n'); git('add', '-A'); git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture'); diff --git a/test/gstack-design-detect-plugins.test.ts b/test/gstack-design-detect-plugins.test.ts new file mode 100644 index 000000000..43f5781e9 --- /dev/null +++ b/test/gstack-design-detect-plugins.test.ts @@ -0,0 +1,61 @@ +import { test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { SENTINEL } from '../lib/design-detect-contract'; + +const detector = fileURLToPath(new URL('../bin/gstack-design-detect.ts', import.meta.url)); + +test.each([ + { versions: ['4.3.1', '4.10.0'], selected: '4.10.0', custom: false }, + { versions: ['4.3.1', '4.10.0'], selected: '4.10.0', custom: true }, + { versions: ['4.3.1-beta.1', '4.3.1'], selected: '4.3.1', custom: false }, + { versions: ['99.0.0-', '4.3.1'], selected: '4.3.1', custom: false }, + { versions: ['a1b2c3d4'], selected: 'a1b2c3d4', custom: true }, + { versions: ['unknown'], selected: 'unknown', custom: false }, +])('native plugin discovery %j', ({ versions, selected, custom }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-plugin-native-')); + const home = path.join(root, 'home'); + const cwd = path.join(root, 'project'); + const config = custom ? path.join(root, 'custom config') : path.join(home, '.claude'); + for (const dir of [home, cwd, config]) fs.mkdirSync(dir, { recursive: true }); + const env: Record = { + HOME: home, USERPROFILE: home, PATH: path.dirname(process.execPath), + GSTACK_HOME: path.join(root, 'gstack'), IMPECCABLE_HOME: path.join(root, 'engine-cache'), + }; + for (const key of ['SystemRoot', 'SYSTEMROOT', 'TEMP', 'TMP']) if (process.env[key]) env[key] = process.env[key]!; + if (custom) env.CLAUDE_CONFIG_DIR = config; + try { + const launchers = new Map(); + for (const version of versions) { + const skill = path.join(config, 'plugins', 'cache', 'market', 'impeccable', version, 'skills', 'impeccable'); + fs.mkdirSync(path.join(skill, 'scripts'), { recursive: true }); + fs.writeFileSync(path.join(skill, 'SKILL.md'), '# fixture\n'); + const launcher = path.join(skill, 'scripts', 'impeccable'); + fs.writeFileSync(launcher, 'This fixture is not an executable.\n'); + launchers.set(version, launcher); + } + if (custom) { + const stale = path.join(home, '.claude', 'skills', 'impeccable'); + fs.mkdirSync(stale, { recursive: true }); + fs.writeFileSync(path.join(stale, 'SKILL.md'), '# stale default profile\n'); + } + const probe = () => spawnSync(process.execPath, ['--no-env-file', 'run', detector, 'probe'], { + cwd, env, encoding: 'utf-8', timeout: 30_000, + }); + const found = probe(); + expect(found.status).toBe(0); + const expectedLauncher = custom + ? path.join(fs.realpathSync(config), path.relative(config, launchers.get(selected)!)) + : launchers.get(selected)!; + expect(found.stdout.split('\n')[0]).toBe(`${SENTINEL.NOT_CACHED}: ${expectedLauncher}`); + expect(found.stdout).toContain(`${SENTINEL.SKILL}: present`); + expect(found.stdout).not.toContain(`${SENTINEL.READY}:`); + fs.rmSync(path.join(config, 'plugins'), { recursive: true }); + const removed = probe(); + expect(removed.status).toBe(0); + expect(removed.stdout).toContain(`${SENTINEL.SKILL}: absent`); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts index 99cc6c9a4..d18358a41 100644 --- a/test/gstack-design-detect.test.ts +++ b/test/gstack-design-detect.test.ts @@ -342,6 +342,420 @@ describe('probe', () => { }); }); +describe('plugin-cache selection and trust', () => { + function fixture(check: (home: string, config: string) => void) { + const home = fs.mkdtempSync(path.join(SANDBOX, 'plugin-home-')); + try { check(home, path.join(home, '.claude')); } + finally { fs.rmSync(home, { recursive: true, force: true }); } + } + + function plugin(config: string, version: string, opts: { engine?: boolean; engineVersion?: string; marketplace?: string; name?: string } = {}) { + const skill = path.join(config, 'plugins', 'cache', opts.marketplace ?? 'impeccable', opts.name ?? 'impeccable', version, 'skills', 'impeccable'); + const scripts = path.join(skill, 'scripts'); + fs.mkdirSync(scripts, { recursive: true }); + fs.writeFileSync(path.join(skill, 'SKILL.md'), '# impeccable\n'); + const marker = path.join(config, 'launcher-ran'); + const launcher = path.join(scripts, 'impeccable'); + fs.writeFileSync(launcher, `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`); + fs.chmodSync(launcher, 0o755); + fs.writeFileSync(path.join(scripts, 'VERSION'), `${opts.engineVersion ?? '0.1.3'}\n`); + const engine = path.join(scripts, 'bin', `${process.platform}-${process.arch}`, POSIX ? 'impeccable' : 'impeccable.exe'); + if (opts.engine) { + fs.mkdirSync(path.dirname(engine), { recursive: true }); + fs.copyFileSync(FAKE, engine); + fs.chmodSync(engine, 0o755); + } + return { skill, scripts, launcher, engine, marker }; + } + + test.each([ + [['4.3.1', '4.10.0'], '4.10.0'], + [['4.10.0', '4.3.1'], '4.10.0'], + [['4.3.1-beta.1', '4.3.1'], '4.3.1'], + [['4.4.0-beta.2', '4.4.0-beta.10'], '4.4.0-beta.10'], + [['unknown', 'a1b2c3d4'], 'a1b2c3d4'], + [['a1b2c3d4'], 'a1b2c3d4'], + [['unknown'], 'unknown'], + [['unknown', '4.3.1'], '4.3.1'], + [['v4.3.1', '4.3.1'], '4.3.1'], + [['99.0.0-', '4.3.1'], '4.3.1'], + [['99.0.0-beta..1', '4.3.1'], '4.3.1'], + [['99.0.0-01', '4.3.1'], '4.3.1'], + [['99.0.0+build..1', '4.3.1'], '4.3.1'], + [['99.0.0-'], '99.0.0-'], + [['4.3.1+build.2', '4.3.1+build.1'], '4.3.1+build.1'], + ] as [string[], string][])('orders plugin versions %j and selects %s', (versions, expected) => { + fixture((home, config) => { + const installs = new Map(versions.map(v => [v, plugin(config, v)])); + const r = run(['probe', '--verbose'], { env: { HOME: home } }); + expect(r.code).toBe(0); + expect(lines(r.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${installs.get(expected)!.launcher}`); + expect(r.out).toContain(`${SENTINEL.SKILL}: present`); + if (!/^[v\d]/.test(expected)) expect(r.out).toContain('opaque version; recency unknown'); + for (const install of installs.values()) expect(fs.existsSync(install.marker)).toBe(false); + }); + }); + + test.skipIf(!POSIX)('selected plugin engine and version match; scans produce handoff data without running launchers', () => { + fixture((home, config) => { + plugin(config, '4.3.1', { engine: true, engineVersion: '0.1.3' }); + const current = plugin(config, '4.10.0', { engine: true, engineVersion: '0.1.5' }); + const env = { HOME: home }; + const p = run(['probe'], { env }); + expect(lines(p.out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(current.engine)}`); + expect(p.out).toContain(`${SENTINEL.ENGINE_UNTESTED}: 0.1.5`); + expect(p.out).not.toContain(SENTINEL.INSTALL_OFFER); + const log = path.join(home, 'engine-args.jsonl'); + const scan = run(['scan', 'src/styles.css'], { env: { ...env, IMPECCABLE_FAKE_LOG: log } }); + expect(scan.code).toBe(2); + expect(scan.err).toContain(`${SENTINEL.SKILL}: present`); + expect(scan.err).toContain('handoff=/impeccable'); + expect(JSON.parse(scan.out).engine).toBe(fs.realpathSync(current.engine)); + expect(JSON.parse(scan.out).engineVersion).toBe('0.1.5'); + expect(fs.readFileSync(log, 'utf-8').trim().split('\n')).toHaveLength(1); + expect(fs.existsSync(current.marker)).toBe(false); + }); + }); + + test('a newer launcher-only plugin never borrows an older bundled engine', () => { + fixture((home, config) => { + plugin(config, '4.3.1', { engine: true }); + const current = plugin(config, '4.10.0'); + const r = run(['probe'], { env: { HOME: home } }); + expect(lines(r.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${current.launcher}`); + expect(r.out).not.toContain(SENTINEL.READY); + }); + }); + + test('custom Claude configuration replaces the default profile for plugins and legacy skills', () => { + fixture((home, config) => { + const ignored = plugin(config, '99.0.0', { engine: true }); + const custom = path.join(home, 'custom claude'); + const selected = plugin(custom, '4.3.1', { engine: true }); + const env = { HOME: home, CLAUDE_CONFIG_DIR: custom }; + const r = run(['probe'], { env }); + expect(lines(r.out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(selected.engine)}`); + expect(r.out).not.toContain(ignored.engine); + fs.rmSync(path.join(custom, 'plugins'), { recursive: true }); + expect(run(['probe'], { env }).out).toContain(`${SENTINEL.SKILL}: absent`); + const legacy = path.join(custom, 'skills', 'impeccable'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.cpSync(ignored.skill, legacy, { recursive: true }); + const legacyEngine = path.join(legacy, 'scripts', 'bin', `${process.platform}-${process.arch}`, path.basename(ignored.engine)); + expect(lines(run(['probe'], { env }).out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(legacyEngine)}`); + }); + }); + + test.each(['relative', 'missing', 'file', 'project'])('invalid %s custom configuration uses safe default discovery', kind => { + fixture((home, config) => { + const good = plugin(config, '4.3.1'); + const file = path.join(home, 'not-directory'); + fs.writeFileSync(file, 'x'); + const override = { relative: '.claude', missing: path.join(home, 'missing'), file, project: REPO }[kind]!; + const r = run(['probe', '--verbose'], { env: { HOME: home, CLAUDE_CONFIG_DIR: override } }); + expect(lines(r.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${good.launcher}`); + expect(r.out).toContain('CLAUDE_CONFIG_DIR'); + }); + }); + + test('ignores incomplete versions, non-directory entries and directory-shaped skill markers', () => { + fixture((home, config) => { + const good = plugin(config, '4.3.1'); + const bad = plugin(config, '99.0.0'); + fs.rmSync(path.join(bad.skill, 'SKILL.md')); + fs.mkdirSync(path.join(bad.skill, 'SKILL.md')); + const pluginRoot = path.resolve(good.skill, '../../..'); + fs.writeFileSync(path.join(pluginRoot, '100.0.0'), 'not a version directory'); + fs.mkdirSync(path.join(pluginRoot, '101.0.0')); + const r = run(['probe', '--verbose'], { env: { HOME: home } }); + expect(lines(r.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${good.launcher}`); + expect(r.out).toContain('SKILL.md is not a regular file'); + expect(r.out).toContain('ENOENT'); + fs.rmSync(path.join(config, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(config, 'plugins')); + fs.writeFileSync(path.join(config, 'plugins', 'cache'), 'not a directory'); + const malformed = run(['probe', '--verbose'], { env: { HOME: home } }); + expect(malformed.code).toBe(0); + expect(malformed.out).toContain('ENOTDIR'); + expect(malformed.out).toContain(`${SENTINEL.SKILL}: absent`); + }); + }); + + test.skipIf(!POSIX || process.getuid?.() === 0)('unreadable cache is diagnosed without crashing', () => { + fixture((home, config) => { + plugin(config, '4.3.1'); + const cache = path.join(config, 'plugins', 'cache'); + fs.chmodSync(cache, 0o000); + try { + const r = run(['probe', '--verbose'], { env: { HOME: home } }); + expect(r.code).toBe(0); + expect(r.out).toContain('EACCES'); + } finally { fs.chmodSync(cache, 0o755); } + }); + }); + + test.skipIf(!POSIX)('project-resolving launcher, engine, cache and config symlinks are never executed', () => { + for (const kind of ['launcher', 'engine', 'cache', 'config']) { + fixture((home, config) => { + const install = plugin(config, '4.3.1', { engine: true }); + const projectDir = fs.mkdtempSync(path.join(REPO, 'plugin-owned-')); + const marker = path.join(home, 'unsafe-ran'); + const env: Record = { HOME: home }; + try { + const evil = path.join(projectDir, 'impeccable'); + fs.writeFileSync(evil, `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\nprintf '[]'\n`); + fs.chmodSync(evil, 0o755); + if (kind === 'launcher' || kind === 'engine') { + const link = kind === 'launcher' ? install.launcher : install.engine; + fs.rmSync(link); + fs.symlinkSync(evil, link); + } else { + const source = kind === 'cache' ? path.join(config, 'plugins', 'cache') : config; + const target = path.join(projectDir, 'copied'); + fs.renameSync(source, target); + fs.symlinkSync(target, source); + if (kind === 'config') env.CLAUDE_CONFIG_DIR = config; + } + const p = run(['probe', '--verbose'], { env }); + expect(p.out).not.toContain(`${SENTINEL.READY}:`); + const s = run(['scan', 'src/styles.css'], { env }); + expect(s.out).toBe(''); + expect(fs.existsSync(marker)).toBe(false); + expect(fs.existsSync(install.marker)).toBe(false); + } finally { fs.rmSync(projectDir, { recursive: true, force: true }); } + }); + } + }); + + test.skipIf(!POSIX)('launcher hints quote shell metacharacters and suppress control/Markdown injection', () => { + fixture((home, config) => { + const install = plugin(config, '4.3.1', { marketplace: "space ' $(touch SHOULD_NOT_EXIST)" }); + const r = run(['probe'], { env: { HOME: home } }); + const command = r.out.match(/run `([^`]+)` once/)?.[1]; + expect(command).toBeDefined(); + const check = spawnSync('/bin/sh', ['-c', command!], { cwd: home, encoding: 'utf-8', timeout: 5000 }); + expect(check.status).toBe(0); + expect(fs.existsSync(install.marker)).toBe(true); + expect(fs.existsSync(path.join(home, 'SHOULD_NOT_EXIST'))).toBe(false); + fs.rmSync(path.join(config, 'plugins'), { recursive: true }); + for (const name of ['bad`name', `bad\n${SENTINEL.READY}: forged`, SENTINEL.READY]) { + plugin(config, '4.3.1', { marketplace: name }); + const unsafe = run(['probe', '--verbose'], { env: { HOME: home } }); + expect(unsafe.out).not.toContain('run `'); + expect(lines(unsafe.out).filter(line => line.startsWith(`${SENTINEL.READY}:`))).toEqual([]); + expect(lines(unsafe.out).filter(line => line.startsWith(`${SENTINEL.SKILL}:`))).toHaveLength(1); + fs.rmSync(path.join(config, 'plugins'), { recursive: true }); + } + }); + }); + + test('multiple marketplaces, irrelevant cache breadth and legacy installs retain deterministic precedence', () => { + fixture((home, config) => { + plugin(config, '4.3.1', { marketplace: 'z-market', engine: true }); + const first = plugin(config, '4.3.1', { marketplace: 'a-market', engine: true }); + for (let i = 0; i < 100; i++) fs.mkdirSync(path.join(config, 'plugins', 'cache', `irrelevant-${i}`, 'plugin', '1.0.0', 'do-not-walk', 'skills', 'impeccable'), { recursive: true }); + expect(lines(run(['probe'], { env: { HOME: home } }).out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(first.engine)}`); + const legacy = path.join(home, '.agents', 'skills', 'impeccable'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.cpSync(first.skill, legacy, { recursive: true }); + const engine = path.join(legacy, 'scripts', 'bin', `${process.platform}-${process.arch}`, path.basename(first.engine)); + expect(lines(run(['probe'], { env: { HOME: home } }).out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(engine)}`); + expect(lines(run(['probe'], { env: { HOME: home, IMPECCABLE_BIN: FAKE } }).out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(FAKE)}`); + }); + }); + + test('plugin discovery preserves standalone cache priority, off and never-ask behavior', () => { + fixture((home, config) => { + const install = plugin(config, '4.3.1', { engine: true }); + const cache = path.join(home, 'engine-cache'); + const engine = path.join(cache, 'bin', '0.1.3', path.basename(install.engine)); + fs.mkdirSync(path.dirname(engine), { recursive: true }); + fs.copyFileSync(FAKE, engine); + fs.chmodSync(engine, 0o755); + const state = path.join(home, 'state'); + fs.mkdirSync(state); + const env = { HOME: home, GSTACK_HOME: state, IMPECCABLE_HOME: cache }; + const p = run(['probe'], { env }); + expect(lines(p.out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(engine)}`); + expect(p.out).not.toContain(SENTINEL.INSTALL_OFFER); + fs.writeFileSync(path.join(state, 'config.yaml'), 'design_detector: off\n'); + const off = run(['probe'], { env }); + expect(lines(off.out)[0]).toBe(SENTINEL.DISABLED); + expect(off.out).toContain(`${SENTINEL.SKILL}: present`); + fs.writeFileSync(path.join(state, 'config.yaml'), 'design_detector_install_prompted: true\n'); + fs.rmSync(cache, { recursive: true }); + fs.rmSync(install.engine); + const nc = run(['probe'], { env }); + expect(lines(nc.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${install.launcher}`); + expect(nc.out).not.toContain(SENTINEL.INSTALL_OFFER); + expect(nc.out).not.toContain(SENTINEL.HINT); + fs.rmSync(path.join(config, 'plugins'), { recursive: true }); + expect(lines(run(['probe'], { env }).out)[0]).toBe(SENTINEL.NOT_AVAILABLE); + expect(fs.existsSync(install.marker)).toBe(false); + }); + }); + + test.skipIf(!POSIX).each([false, true])('symlinked HOME preserves user installs without trusting arbitrary home scan targets (dotfiles=%s)', dotfiles => { + fixture((home, config) => { + const install = plugin(config, '4.3.1', { engine: true }); + const alias = `${home}-alias`; + fs.symlinkSync(home, alias, 'dir'); + try { + if (dotfiles) git(home, 'init', '-q'); + const log = path.join(home, 'engine-calls'); + const opts = { cwd: home, env: { HOME: alias, IMPECCABLE_FAKE_LOG: log } }; + expect(lines(run(['probe'], opts).out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(install.engine)}`); + const target = path.join(home, 'private.css'); + fs.writeFileSync(target, 'body { color: red; }'); + const scan = run(['scan', target], opts); + expect(scan.code).toBe(0); + expect(scan.err).toContain(`${SENTINEL.DETECT_REFUSED}: ${target}`); + expect(scan.err).toContain(SENTINEL.DETECT_NO_TARGETS); + expect(scan.out).toBe(''); + expect(fs.existsSync(log)).toBe(false); + expect(fs.existsSync(install.marker)).toBe(false); + } finally { fs.rmSync(alias); } + }); + }); + + test.skipIf(!POSIX).each(['config', 'cache', 'marketplace', 'version'])('repository %s symlinks cannot redirect plugin traversal to a user install', kind => { + fixture((home) => { + const externalConfig = path.join(home, 'external-config'); + plugin(externalConfig, '4.3.1', { engine: true }); + const config = path.join(REPO, '.claude'); + const tail = { + config: '', cache: 'plugins/cache', marketplace: 'plugins/cache/impeccable', + version: 'plugins/cache/impeccable/impeccable/4.3.1', + }[kind]!; + const link = path.join(config, tail); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(path.join(externalConfig, tail), link, 'dir'); + const log = path.join(home, 'engine-ran'); + try { + const env = { HOME: home, IMPECCABLE_FAKE_LOG: log }; + const p = run(['probe', '--verbose'], { env }); + expect(lines(p.out)[0]).toBe(SENTINEL.NOT_AVAILABLE); + expect(p.out).toContain(`${SENTINEL.SKILL}: absent`); + expect(p.out).not.toContain('plugin selected='); + expect(p.out).toContain('plugin skip'); + expect(run(['scan', 'src/styles.css'], { env }).out).toBe(''); + expect(fs.existsSync(log)).toBe(false); + } finally { fs.rmSync(config, { recursive: true, force: true }); } + }); + }); + + test('standalone cache shares strict version ordering but never treats opaque names as versions', () => { + fixture((home) => { + const cache = path.join(home, 'cache'); + let stable = ''; + for (const version of ['0.1.3-rc.1', 'v0.1.3', '99.0.0-', 'unknown']) { + const engine = path.join(cache, 'bin', version, POSIX ? 'impeccable' : 'impeccable.exe'); + fs.mkdirSync(path.dirname(engine), { recursive: true }); + fs.copyFileSync(FAKE, engine); + fs.chmodSync(engine, 0o755); + if (version === 'v0.1.3') stable = engine; + } + const p = run(['probe'], { env: { HOME: home, IMPECCABLE_HOME: cache } }); + expect(lines(p.out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(stable)}`); + expect(p.out).not.toContain(SENTINEL.ENGINE_UNTESTED); + fs.rmSync(path.join(cache, 'bin', 'v0.1.3'), { recursive: true }); + fs.rmSync(path.join(cache, 'bin', '0.1.3-rc.1'), { recursive: true }); + expect(lines(run(['probe'], { env: { HOME: home, IMPECCABLE_HOME: cache } }).out)[0]).toBe(SENTINEL.NOT_AVAILABLE); + }); + }); +}); + +describe('plugin-cache impeccable install', () => { + // A Claude Code plugin install places impeccable at + // /.claude/plugins/cache////skills/impeccable/, + // never at the traditional /.claude/skills/impeccable/ the SKILL_ROOTS + // walk expects (regression for github.com/garrytan/gstack/issues/2838). + function pluginCacheDir(root: string, marketplace = 'impeccable', plugin = 'impeccable', version = '4.3.1') { + return path.join(root, '.claude', 'plugins', 'cache', marketplace, plugin, version, 'skills', 'impeccable'); + } + + test('SKILL.md present, no launcher → IMPECCABLE_SKILL: present, no crash, no READY', () => { + const home = path.join(SANDBOX, 'fake-home'); + const skillDir = pluginCacheDir(home); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# impeccable\n'); + try { + const r = run(['probe']); + expect(r.out).toContain(`${SENTINEL.SKILL}: present`); + expect(r.out).not.toContain(SENTINEL.READY); + } finally { + fs.rmSync(path.join(home, '.claude'), { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX)('plugin-cache launcher without engine → NOT_CACHED naming the plugin-cache launcher; with sibling engine → READY + VERSION', () => { + const home = path.join(SANDBOX, 'fake-home'); + const skillDir = pluginCacheDir(home); + const scripts = path.join(skillDir, 'scripts'); + fs.mkdirSync(scripts, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# impeccable\n'); + fs.writeFileSync(path.join(scripts, 'impeccable'), '#!/bin/sh\necho "would download"\n'); + fs.chmodSync(path.join(scripts, 'impeccable'), 0o755); + fs.writeFileSync(path.join(scripts, 'VERSION'), '0.1.3\n'); + try { + const r = run(['probe']); + expect(lines(r.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${path.join(scripts, 'impeccable')}`); + expect(r.out).toContain(`${SENTINEL.SKILL}: present`); + expect(r.out).toContain(`run \`${path.join(scripts, 'impeccable')} detect --help\` once`); + expect(r.out).not.toContain('npx impeccable'); + expect(r.out).not.toContain('would download'); + + const sib = path.join(scripts, 'bin', `${process.platform}-${process.arch}`); + fs.mkdirSync(sib, { recursive: true }); + fs.copyFileSync(FAKE, path.join(sib, 'impeccable')); + fs.chmodSync(path.join(sib, 'impeccable'), 0o755); + const r2 = run(['probe']); + expect(lines(r2.out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(path.join(sib, 'impeccable'))}`); + expect(r2.out).not.toContain(SENTINEL.ENGINE_UNTESTED); + expect(r2.out).not.toContain(SENTINEL.HINT); + } finally { + fs.rmSync(path.join(home, '.claude'), { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX)('a plugin-cache install committed INSIDE the repository is never executed: skill-present only, launcher never runs', () => { + const skillDir = pluginCacheDir(REPO, 'acme', 'impeccable', '1.0.0'); + const scripts = path.join(skillDir, 'scripts'); + const sib = path.join(scripts, 'bin', `${process.platform}-${process.arch}`); + fs.mkdirSync(sib, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# impeccable\n'); + fs.writeFileSync(path.join(scripts, 'impeccable'), '#!/bin/sh\necho "would download"\n'); + fs.chmodSync(path.join(scripts, 'impeccable'), 0o755); + fs.writeFileSync(path.join(scripts, 'VERSION'), '0.1.3\n'); + const marker = path.join(SANDBOX, 'repo-plugin-engine-ran.txt'); + fs.writeFileSync(path.join(sib, 'impeccable'), `#!/bin/sh\necho ran > ${JSON.stringify(marker)}\necho "[]"\n`); + fs.chmodSync(path.join(sib, 'impeccable'), 0o755); + try { + const r = run(['probe']); + expect(lines(r.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: repository-local install`); + expect(r.out).toContain(`${SENTINEL.SKILL}: present`); + expect(r.out).toContain('never runs a repository-local launcher'); + expect(fs.existsSync(marker)).toBe(false); + } finally { + fs.rmSync(path.join(REPO, '.claude'), { recursive: true, force: true }); + } + }); + + test('multiple plugin-cache entries (different marketplace/plugin/version) coexist without throwing', () => { + const home = path.join(SANDBOX, 'fake-home'); + fs.mkdirSync(pluginCacheDir(home, 'marketA', 'impeccable', '1.0.0'), { recursive: true }); + fs.writeFileSync(path.join(pluginCacheDir(home, 'marketA', 'impeccable', '1.0.0'), 'SKILL.md'), '# impeccable\n'); + fs.mkdirSync(pluginCacheDir(home, 'marketB', 'impeccable', '2.0.0'), { recursive: true }); + fs.writeFileSync(path.join(pluginCacheDir(home, 'marketB', 'impeccable', '2.0.0'), 'SKILL.md'), '# impeccable\n'); + try { + const r = run(['probe']); + expect(r.out).toContain(`${SENTINEL.SKILL}: present`); + } finally { + fs.rmSync(path.join(home, '.claude'), { recursive: true, force: true }); + } + }); +}); + describe('scan', () => { test('not READY → prints the probe lines, exit 0, engine never needed', () => { const r = run(['scan', 'src/styles.css']); diff --git a/test/gstack-memory-ingest.test.ts b/test/gstack-memory-ingest.test.ts index a0220e279..6af237134 100644 --- a/test/gstack-memory-ingest.test.ts +++ b/test/gstack-memory-ingest.test.ts @@ -28,6 +28,17 @@ function makeTestHome(): string { return mkdtempSync(join(tmpdir(), "gstack-memory-ingest-")); } +function isolateGitRemote(repo: string, url: string): void { + const git = (...args: string[]) => { + const result = spawnSync("git", args, { cwd: repo, encoding: "utf8", timeout: 10_000 }); + expect(result.status).toBe(0); + return result.stdout.trim(); + }; + expect(git("config", "--get", "remote.origin.url")).toBe(url); + git("config", "--local", `url.${url}.insteadOf`, url); + expect(git("remote", "get-url", "origin")).toBe(url); +} + function runScript(args: string[], env: Record = {}): { stdout: string; stderr: string; exitCode: number } { const result = spawnSync("bun", [SCRIPT, ...args], { encoding: "utf-8", @@ -918,6 +929,7 @@ describe("#2394: probe applies the same attribution gate as prepare", () => { mkdirSync(repo, { recursive: true }); spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8", timeout: 30_000 }); spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8", timeout: 30_000 }); + isolateGitRemote(repo, "https://github.com/foo/bar.git"); return repo; } @@ -998,6 +1010,7 @@ describe("#2394: probe applies the same attribution gate as prepare", () => { mkdirSync(attributableCwd, { recursive: true }); spawnSync("git", ["-C", attributableCwd, "init", "-q"], { encoding: "utf-8", timeout: 30_000 }); spawnSync("git", ["-C", attributableCwd, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8", timeout: 30_000 }); + isolateGitRemote(attributableCwd, "https://github.com/foo/bar.git"); const ts = new Date().toISOString(); const cwdLine = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`; @@ -1082,6 +1095,7 @@ describe("#2392: transcript ingest honors per-remote trust policy", () => { mkdirSync(repo, { recursive: true }); spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8", timeout: 30_000 }); spawnSync("git", ["-C", repo, "remote", "add", "origin", remoteUrl], { encoding: "utf-8", timeout: 30_000 }); + isolateGitRemote(repo, remoteUrl); return repo; } diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index a27ed2e40..bd4e9b7f6 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -1135,6 +1135,7 @@ export const E2E_TOUCHFILES: Record = { 'design-review-detector-shim-dom': ['test/session-runner-stream-lifecycle.test.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'browse/src/**', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts', 'scripts/resolvers/testing.ts' ], + 'design-review-plugin-handoff': ['test/session-runner-stream-lifecycle.test.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/testing.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.html', 'test/skill-e2e-design.test.ts'], 'design-html-slop-gate': ['test/session-runner-stream-lifecycle.test.ts', 'test/gstack-paths.test.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts'], // /diagram (diagram-render bundle consumers). Triplet = deterministic @@ -1677,6 +1678,7 @@ export const E2E_TIERS: Record = { 'design-review-fix': 'periodic', 'design-review-detector-shim': 'gate', // deterministic sentinels from the fake engine (source mode on a diff) 'design-review-detector-shim-dom': 'gate', // same shim, DOM mode through the browse binary's dump; self-skips when the binary is absent + 'design-review-plugin-handoff': 'gate', 'design-html-slop-gate': 'periodic', // one-pass gate behavior is a judgment call on a fake engine's fixed output // /diagram — triplet is deterministic functional (gstack-render falls back diff --git a/test/memorable-user-prompt-hook.test.ts b/test/memorable-user-prompt-hook.test.ts index 189298a39..f307b5c1c 100644 --- a/test/memorable-user-prompt-hook.test.ts +++ b/test/memorable-user-prompt-hook.test.ts @@ -49,6 +49,17 @@ const FAKE_AWS_KEY = ['AKIA', '1234567890ABCDEF'].join(''); const CONFIG = path.join(ROOT, 'bin', 'gstack-config'); const POLICY = path.join(ROOT, 'bin', 'gstack-gbrain-repo-policy'); +function isolateGitRemote(repo: string, url: string): void { + const git = (...args: string[]) => { + const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 }); + expect(result.status).toBe(0); + return result.stdout.trim(); + }; + expect(git('config', '--get', 'remote.origin.url')).toBe(url); + git('config', '--local', `url.${url}.insteadOf`, url); + expect(git('remote', 'get-url', 'origin')).toBe(url); +} + const FAKE = `#!/bin/sh MODE=$(cat "$HOME/mode" 2>/dev/null || echo ok) printf '%s\\n' "$*" >> "$HOME/calls.log" @@ -213,6 +224,7 @@ describe('gate on: the mediated hand-off', () => { const git = (args: string[]) => spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 }); git(['init', '-q']); git(['remote', 'add', 'origin', 'https://github.com/example/denied-repo.git']); + isolateGitRemote(repo, 'https://github.com/example/denied-repo.git'); const prompt = JSON.stringify({ prompt: 'hello', cwd: repo }); for (const tier of ['deny', 'read-only']) { const set = spawnSync('bash', [POLICY, 'set', 'https://github.com/example/denied-repo.git', tier], { env, encoding: 'utf8', timeout: 20_000 }); @@ -693,6 +705,7 @@ describe('deadline and policy failure paths (review coverage)', () => { const git = (args: string[]) => spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 }); git(['init', '-q']); git(['remote', 'add', 'origin', 'https://github.com/example/some-repo.git']); + isolateGitRemote(repo, 'https://github.com/example/some-repo.git'); // a directory where the store file should be: hasRepoPolicyStore() is true, every read fails const storeDir = path.join(home, '.gstack', 'gbrain-repo-policy.json'); fs.mkdirSync(storeDir, { recursive: true }); @@ -742,6 +755,7 @@ describe('trust-policy lookup outcomes (review coverage, second pass)', () => { try { spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/example/other.git'], { cwd: repo, timeout: 10_000 }); + isolateGitRemote(repo, 'https://github.com/example/other.git'); expect(runHook(JSON.stringify({ prompt: 'hello', cwd: repo }), {}, repo).stdout).toContain('remembered'); } finally { fs.rmSync(repo, { recursive: true, force: true }); @@ -755,6 +769,7 @@ describe('trust-policy lookup outcomes (review coverage, second pass)', () => { for (const [dir, url] of [[denied, 'https://github.com/example/denied.git'], [allowed, 'https://github.com/example/allowed.git']] as const) { spawnSync('git', ['init', '-q'], { cwd: dir, timeout: 10_000 }); spawnSync('git', ['remote', 'add', 'origin', url], { cwd: dir, timeout: 10_000 }); + isolateGitRemote(dir, url); } expect(spawnSync('bash', [POLICY, 'set', 'https://github.com/example/denied.git', 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0); const r = runHook(JSON.stringify({ prompt: 'hello', cwd: denied }), { GIT_DIR: path.join(allowed, '.git'), GIT_WORK_TREE: allowed }, denied); diff --git a/test/periodic-fixture-selection.test.ts b/test/periodic-fixture-selection.test.ts index cf604fc94..238c523ab 100644 --- a/test/periodic-fixture-selection.test.ts +++ b/test/periodic-fixture-selection.test.ts @@ -828,7 +828,7 @@ test('stderr lifecycle regression selects runtime consumers without a quality-ma 'outside-voice-codex-to-claude-code', 'outside-voice-claude-code-to-codex', 'outside-plan-disabled-no-fallback', 'ship-coverage-audit', 'review-coverage-audit', 'plan-eng-coverage-audit', 'ship-triage', 'ship-docsync', 'docsync-spawned', 'design-consultation-core', 'design-consultation-existing', 'design-consultation-research', 'design-consultation-preview', 'plan-design-review-no-ui-scope', 'design-review-fix', - 'design-review-detector-shim', 'design-review-detector-shim-dom', 'design-html-slop-gate', 'diagram-triplet', 'diagram-authoring-quality', + 'design-review-detector-shim', 'design-review-detector-shim-dom', 'design-review-plugin-handoff', 'design-html-slop-gate', 'diagram-triplet', 'diagram-authoring-quality', 'gstack-upgrade-happy-path', 'land-and-deploy-workflow', 'land-and-deploy-first-run', 'land-and-deploy-review-gate', 'canary-workflow', 'benchmark-workflow', 'setup-deploy-workflow', 'autoplan-dual-voice', 'scrape-match-path', 'scrape-prototype-path', 'skillify-happy-path', 'skillify-provenance-refusal', 'skillify-approval-reject', 'journey-ideation', 'journey-plan-eng', diff --git a/test/skill-e2e-design.test.ts b/test/skill-e2e-design.test.ts index f17bc6a17..c674f1902 100644 --- a/test/skill-e2e-design.test.ts +++ b/test/skill-e2e-design.test.ts @@ -13,6 +13,7 @@ import { } from './helpers/e2e-helpers'; import { asideAvailable } from './helpers/aside-available'; import { installFakeImpeccable, DETECT_SAMPLE } from './helpers/fake-impeccable'; +import { hermeticChildEnv } from './helpers/hermetic-env'; import { sliceBetween, extractDesignResearchContract } from './helpers/skill-fixture'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; @@ -771,6 +772,163 @@ function makeFakeEngine(): string { return installFakeImpeccable('skill-e2e-fake-impeccable-').dir; } +function detectorReportEntries(report: string): string[] { + return report.split(/(?=^[\t ]*(?:#{1,6}\s+|[-*|]\s*|\d+[.)]\s+)?(?:\*\*|`)?FINDING-\d+)/m); +} + +if (!evalsEnabled) test('detector report handoffs stay with their entry across inline cross-references', () => { + const report = `### FINDING-001 \`[low-contrast]\` — impact=high — DEFERRED +handoff=\`/impeccable colorize\` + +### FINDING-002 \`[ai-color-palette]\` — impact=medium — DEFERRED +The colors also appear in FINDING-001. This is unconfirmed static evidence. +handoff=\`/impeccable colorize\` + +### FINDING-003 \`[skipped-heading]\` — impact=medium — DEFERRED +handoff=\`/impeccable typeset\` +`; + const hasPaletteHandoff = (text: string) => detectorReportEntries(text).some(entry => entry.includes('[ai-color-palette]') && entry.includes('/impeccable colorize')); + expect(hasPaletteHandoff(report)).toBe(true); + expect(hasPaletteHandoff(report.replace('handoff=`/impeccable colorize`\n\n### FINDING-003', '### FINDING-003'))).toBe(false); + for (const marker of ['.', ')']) { + const numbered = report.replace(/^### FINDING-(\d+)/gm, (_, number) => `${Number(number)}${marker} **FINDING-${number}**`); + expect(hasPaletteHandoff(numbered)).toBe(true); + expect(hasPaletteHandoff(numbered.replace(`handoff=\`/impeccable colorize\`\n\n3${marker}`, `3${marker}`))).toBe(false); + } +}); + +function pluginDetectorFixture() { + const fixture = installFakeImpeccable('skill-e2e-plugin-'); + const repoDir = path.join(fixture.dir, 'repo'); + const home = path.join(fixture.dir, 'home'); + const configDir = path.join(fixture.dir, 'custom-claude'); + const gstackHome = path.join(fixture.dir, 'gstack'); + const impeccableHome = path.join(fixture.dir, 'engine-cache'); + for (const dir of [repoDir, home, configDir, gstackHome, impeccableHome]) fs.mkdirSync(dir); + const env = hermeticChildEnv({ + HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: configDir, + GSTACK_HOME: gstackHome, IMPECCABLE_HOME: impeccableHome, + CLAUDE_PLUGIN_DATA: '', GSTACK_HEADLESS: '1', + }); + for (const key of Object.keys(env)) if (key.startsWith('IMPECCABLE_') && key !== 'IMPECCABLE_HOME') delete env[key]; + const engines: Record = {}; + for (const version of ['4.3.1', '4.10.0']) { + const skillDir = path.join(configDir, 'plugins/cache/fixture-market/impeccable', version, 'skills/impeccable'); + const scripts = path.join(skillDir, 'scripts'); + const engineDir = path.join(scripts, 'bin', `${process.platform}-${process.arch}`); + fs.mkdirSync(engineDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Impeccable fixture marker\n'); + fs.writeFileSync(path.join(scripts, 'impeccable'), `#!/bin/sh\nprintf launcher > '${fixture.dir}/launcher-ran'\nexit 99\n`, { mode: 0o755 }); + fs.writeFileSync(path.join(scripts, 'VERSION'), '1.6.0\n'); + engines[version] = path.join(engineDir, 'impeccable'); + fs.writeFileSync(engines[version], `#!/bin/sh\nIMPECCABLE_FAKE_LOG='${fixture.dir}/${version}.jsonl' exec '${process.execPath}' '${fixture.bin}' "$@"\n`, { mode: 0o755 }); + engines[version] = fs.realpathSync(engines[version]); + } + const git = (...args: string[]) => { + const result = spawnSync('git', args, { cwd: repoDir, env, encoding: 'utf-8', timeout: 5000 }); + if (result.status !== 0) throw new Error(`Plugin fixture git ${args[0]} failed: ${result.stderr}`); + }; + git('init', '-b', 'main'); + git('config', 'user.email', 'test@test.com'); + git('config', 'user.name', 'Test'); + fs.writeFileSync(path.join(repoDir, 'index.html'), '

Clean

\n'); + git('add', '.'); + git('commit', '-m', 'initial'); + git('checkout', '-b', 'feature/landing'); + fs.copyFileSync(path.join(ROOT, 'test/fixtures/review-eval-design-slop.html'), path.join(repoDir, 'index.html')); + git('add', '.'); + git('commit', '-m', 'landing page'); + fs.writeFileSync(path.join(repoDir, 'design-review-detector.md'), detectorSkillText([ + ['**Design detector (optional, deterministic):**', '**Create output directories:**'], + ['**Phase 0: mechanical scan**', '## Phases 1-6'], + ])); + return { dir: fixture.dir, repoDir, env, engines }; +} + +if (!evalsEnabled) test('plugin detector fixture discovers the selected engine without an override', () => { + const fixture = pluginDetectorFixture(); + try { + expect(fixture.env.IMPECCABLE_BIN).toBeUndefined(); + const probe = spawnSync(process.execPath, ['--no-env-file', 'run', path.join(ROOT, 'bin/gstack-design-detect.ts'), 'probe', '--host', 'claude'], { + cwd: fixture.repoDir, env: fixture.env, encoding: 'utf-8', timeout: 10000, + }); + expect(probe.status).toBe(0); + expect(probe.stdout).toContain(`IMPECCABLE_READY: ${fixture.engines['4.10.0']}`); + expect(probe.stdout).toContain('IMPECCABLE_SKILL: present'); + const scan = spawnSync(process.execPath, ['--no-env-file', 'run', path.join(ROOT, 'bin/gstack-design-detect.ts'), 'scan', '--changed', 'main', '--host', 'claude'], { + cwd: fixture.repoDir, env: fixture.env, encoding: 'utf-8', timeout: 10000, + }); + expect(scan.status).toBe(2); + expect(scan.stderr).toContain('handoff=/impeccable colorize'); + expect(fs.existsSync(path.join(fixture.dir, '4.10.0.jsonl'))).toBe(true); + expect(fs.existsSync(path.join(fixture.dir, '4.3.1.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(fixture.dir, 'launcher-ran'))).toBe(false); + } finally { + fs.rmSync(fixture.dir, { recursive: true, force: true }); + } +}); + +describeIfSelected('Design review plugin discovery E2E', ['design-review-plugin-handoff'], () => { + testConcurrentIfSelected('design-review-plugin-handoff', async () => { + const fixture = pluginDetectorFixture(); + try { + const result = await runSkillTest({ + prompt: `Load gstack's /design-review workflow by reading design-review-detector.md, the actual Setup detector and Phase 0 excerpt. +Supported actor scope: read that excerpt, run its detector probe and one source-mode scan, then write detector-output.md. This isolated repository is on feature/landing; its base is main. There is no URL. +Do not install or download anything, execute a launcher, change environment variables, browse, ask questions, spawn agents, or edit anything except detector-output.md. Do not read Impeccable skill files. If discovery fails, report that failure and stop; do not repair the environment. +Write the probe's first line and skill-presence line, then one FINDING-NNN entry per DETECT_TOP rule with its [rule-id] and impact. All findings are deferred, unconfirmed static evidence because rendered-page confirmation is outside this actor's scope. Apply the excerpt's deferred-finding reporting requirements. Do not fix source files or claim visual verification.`, + workingDirectory: fixture.repoDir, + maxTurns: 10, + timeout: CAPTURE_MS, + testName: 'design-review-plugin-handoff', + runId, + tools: ['Bash', 'Read', 'Write'], + env: fixture.env, + }); + logCost('/design-review plugin handoff', result); + const reportPath = path.join(fixture.repoDir, 'detector-output.md'); + const report = fs.existsSync(reportPath) ? fs.readFileSync(reportPath, 'utf-8') : ''; + const outputs = result.toolCalls.map(call => String(call.output ?? '')).join('\n'); + const commands = result.toolCalls.filter(call => call.tool === 'Bash').map(call => String(call.input?.command ?? '')); + const handoffs = [...outputs.matchAll(/\[([\w-]+)\] impact=(\w+)[^\n]*handoff=(\/impeccable \w+)/g)]; + const entries = detectorReportEntries(report); + const engineLog = path.join(fixture.dir, '4.10.0.jsonl'); + const invocations = fs.existsSync(engineLog) ? fs.readFileSync(engineLog, 'utf-8').trim().split('\n') : []; + const sourceUnchanged = fs.readFileSync(path.join(fixture.repoDir, 'index.html'), 'utf-8') === fs.readFileSync(path.join(ROOT, 'test/fixtures/review-eval-design-slop.html'), 'utf-8'); + if (process.env.GSTACK_EVAL_DIR) { + const evidenceDir = path.join(process.env.GSTACK_EVAL_DIR, 'plugin-handoff', `${Date.now()}`); + fs.mkdirSync(evidenceDir, { recursive: true, mode: 0o700 }); + for (const [name, text] of Object.entries({ + 'report.md': report, + 'skill-excerpt.md': fs.readFileSync(path.join(fixture.repoDir, 'design-review-detector.md'), 'utf-8'), + 'engine-invocations.jsonl': invocations.join('\n'), + })) fs.writeFileSync(path.join(evidenceDir, name), text, { mode: 0o600 }); + } + const checks = { + success: result.exitReason === 'success', + selectedEngine: outputs.includes(`IMPECCABLE_READY: ${fixture.engines['4.10.0']}`), + skillPresent: outputs.includes('IMPECCABLE_SKILL: present'), + probeReported: report.includes(`IMPECCABLE_READY: ${fixture.engines['4.10.0']}`) && report.includes('IMPECCABLE_SKILL: present'), + probeExecuted: commands.some(command => /gstack-design-detect\.ts probe/.test(command)), + scanExecuted: commands.some(command => /gstack-design-detect\.ts scan --changed main/.test(command)), + noInstallOrOverride: !commands.some(command => /\bnpx\b|gstack-design-detect\.ts install|\b(?:curl|wget|npm install|bun add)\b|IMPECCABLE_BIN\s*=/.test(command)), + oneNewEngineInvocation: invocations.length === 1, + oldEngineNotExecuted: !fs.existsSync(path.join(fixture.dir, '4.3.1.jsonl')), + launcherNotExecuted: !fs.existsSync(path.join(fixture.dir, 'launcher-ran')), + sourceUnchanged, + findingsReported: report.includes('FINDING-001') && report.includes('[ai-color-palette]') && report.includes('[low-contrast]'), + deferred: /deferred/i.test(report), + nonemptyHandoffs: handoffs.length > 0, + perFindingHandoff: handoffs.every(([, rule, impact, command]) => entries.some(entry => entry.includes(`[${rule}]`) && entry.toLowerCase().includes(impact) && entry.includes(command))), + }; + recordE2E(evalCollector, '/design-review plugin handoff', 'Design review plugin discovery E2E', result, { passed: Object.values(checks).every(Boolean) }); + expect(Object.entries(checks).filter(([, passed]) => !passed).map(([name]) => name)).toEqual([]); + } finally { + fs.rmSync(fixture.dir, { recursive: true, force: true }); + } + }, CAPTURE_MS); +}); + describeIfSelected('Design review detector shim E2E', ['design-review-detector-shim', 'design-review-detector-shim-dom'], () => { let repoDir: string; let engineDir: string;