mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 15:11:47 +02:00
v1.91.1.0 fix: harden Impeccable plugin discovery (#2978)
* fix(design-detect): find impeccable installed as a Claude Code plugin The design-detector probe only ever checked <root>/<SKILL_ROOTS>/skills/impeccable/, never the Claude Code plugin-cache layout (<root>/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/impeccable/). A plugin-installed impeccable was therefore invisible: IMPECCABLE_SKILL stayed absent, the launcher was never found (so the NOT_CACHED run hint never fired), and its sibling engine was never considered. Add a plugin-cache walk alongside the existing SKILL_ROOTS walk, sharing the same presence/launcher/repo-local-exclusion/sibling-engine logic via an extracted checkSkillDir() helper so both paths stay behaviorally identical. Fixes #2838 * refactor(design-detect): consolidate newestSemverDir onto safeReaddir Both did the identical try/catch-around-readdirSync; newestSemverDir now reuses the new safeReaddir helper instead of duplicating it. * fix: harden Impeccable plugin discovery and regression fixtures * test: supply eval mode to the integrated detector callback adapter --------- Co-authored-by: Som Samantray <som.samantray@gmail.com>
This commit is contained in:
co-authored by
Som Samantray
parent
7b534d3e90
commit
2a113ae7e6
@@ -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.**
|
||||
|
||||
@@ -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,
|
||||
|
||||
+159
-34
@@ -31,6 +31,8 @@
|
||||
* ~/{.claude,.agents,.cursor,.gemini,.github,.opencode}/skills/impeccable/scripts/
|
||||
* ├─ bin/<os>-<arch>/impeccable[.exe] (engine installed beside the launcher) ──► READY
|
||||
* └─ impeccable (launcher only) ──► IMPECCABLE_NOT_CACHED: <launcher>
|
||||
* ${CLAUDE_CONFIG_DIR:-~/.claude}/plugins/cache/<marketplace>/<plugin>/<version>/skills/impeccable/
|
||||
* └─ newest semver skill per plugin, then opaque names in stable order; same trust checks
|
||||
* <repo|cwd>/<same dirs>/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
|
||||
* <config>/plugins/cache/<marketplace>/<plugin>/<version>/skills/impeccable/,
|
||||
* never at the traditional <root>/<SKILL_ROOTS entry>/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<string>();
|
||||
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<string>();
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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/<marketplace>/<plugin>/<version>/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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(' ')) },
|
||||
|
||||
@@ -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<values.length;i++)expect(typeof values[i]).toBe('string');
|
||||
});
|
||||
test('exact owned report precedes navigation only, with the current Exit gate recognized',()=>{
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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<string, string> } = {}) {
|
||||
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');
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string>();
|
||||
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 }); }
|
||||
});
|
||||
@@ -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<string, string> = { 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
|
||||
// <HOME>/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/impeccable/,
|
||||
// never at the traditional <HOME>/.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']);
|
||||
|
||||
@@ -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<string, string> = {}): { 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1135,6 +1135,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'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<string, 'gate' | 'periodic'> = {
|
||||
'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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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'), '<h1>Clean</h1>\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;
|
||||
|
||||
Reference in New Issue
Block a user