mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 16:38:56 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/gbrain-code-smell-audit
# Conflicts: # CHANGELOG.md # browse/test/dual-listener.test.ts # browse/test/fixtures/security-bench-haiku-responses.json # browse/test/sidebar-tabs.test.ts # browse/test/sidebar-ux.test.ts # browse/test/terminal-agent.test.ts # claude/SKILL.md.tmpl # scripts/gen-skill-docs.ts # scripts/proactive-suggestions.json # spec/SKILL.md # test/gen-skill-docs.test.ts # test/host-config.test.ts
This commit is contained in:
+134
-12
@@ -4,6 +4,7 @@ import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
|
||||
@@ -105,7 +106,14 @@ const ALL_SKILLS = (() => {
|
||||
|
||||
// hosts/claude.ts generation.skipSkills entries would filter here; the set is
|
||||
// currently empty (the /claude outside-voice template was removed).
|
||||
const CLAUDE_GENERATED_SKILLS = ALL_SKILLS;
|
||||
// The claude host deliberately skips some skills (skipSkills — e.g. the
|
||||
// /claude outside-voice skill exists only for non-Claude hosts), so those
|
||||
// dirs have a SKILL.md.tmpl but no generated claude-host SKILL.md on a fresh
|
||||
// checkout. Every generated-file assertion must exclude them or it is red on
|
||||
// every clean clone (it was, invisibly, until the free suite ran in CI).
|
||||
import { getHostConfig as __getHostConfig } from '../hosts/index';
|
||||
const CLAUDE_SKIPPED = new Set(__getHostConfig('claude').generation.skipSkills ?? []);
|
||||
const CLAUDE_GENERATED_SKILLS = ALL_SKILLS.filter(s => !CLAUDE_SKIPPED.has(s.dir));
|
||||
|
||||
describe('gen-skill-docs', () => {
|
||||
test('generated SKILL.md contains all command categories', () => {
|
||||
@@ -321,6 +329,31 @@ describe('gen-skill-docs', () => {
|
||||
expect(content).toContain('git branch --show-current');
|
||||
});
|
||||
|
||||
// #2001: update_check: false silences the binary but the upgrade-handling
|
||||
// instruction prose used to ship unconditionally. Every skill that carries
|
||||
// the runtime config-echo cluster must (a) echo UPDATE_CHECK so the
|
||||
// instruction layer can read it, and (b) gate the UPGRADE_AVAILABLE /
|
||||
// JUST_UPGRADED prose on it — the same echo-then-gate convention every other
|
||||
// flag (PROACTIVE, SKILL_PREFIX, EXPLAIN_LEVEL, QUESTION_TUNING) follows.
|
||||
test('update_check opt-out gates preamble echo and upgrade-handling prose (issue #2001)', () => {
|
||||
let checked = 0;
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
// Scope: only skills that render the runtime config-echo cluster.
|
||||
if (!content.includes('echo "QUESTION_TUNING: $_QUESTION_TUNING"')) continue;
|
||||
checked++;
|
||||
expect(content, `${skill.dir} must echo UPDATE_CHECK`).toContain('echo "UPDATE_CHECK: $_UPDATE_CHECK"');
|
||||
expect(content, `${skill.dir} must read update_check config`).toContain('_UPDATE_CHECK=$(');
|
||||
// Whenever the upgrade-handling prose ships, it must gate on the flag.
|
||||
if (content.includes('UPGRADE_AVAILABLE <old> <new>')) {
|
||||
expect(content, `${skill.dir} upgrade prose must gate on UPDATE_CHECK`)
|
||||
.toContain('If `UPDATE_CHECK` is `"false"`');
|
||||
}
|
||||
}
|
||||
// Guard against the scope filter silently matching nothing.
|
||||
expect(checked).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('tier 2+ skills contain ELI10 simplification rules (AskUserQuestion format)', () => {
|
||||
// Root SKILL.md is tier 1 (no AskUserQuestion format). Check a tier 2+ skill instead.
|
||||
// v1.7.0.0 Pros/Cons format uses "ELI10 (ALWAYS)" rather than "Simplify (ELI10".
|
||||
@@ -1769,6 +1802,43 @@ describe('Codex generation (--host codex)', () => {
|
||||
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-codex'))).toBe(false);
|
||||
});
|
||||
|
||||
test('Codex output includes Claude outside-voice skill with read-only boundary', () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-claude', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('claude -p');
|
||||
expect(content).toContain('mktemp /tmp/gstack-claude-prompt-');
|
||||
expect(content).toContain('mktemp /tmp/gstack-claude-response-XXXXXX');
|
||||
expect(content).toContain('mktemp /tmp/gstack-claude-error-XXXXXX');
|
||||
expect(content).toContain('mktemp /tmp/gstack-claude-diff-');
|
||||
expect(content).not.toMatch(/gstack-claude-(?:prompt|response|error|diff)-X{6,}\.\w+/);
|
||||
expect(content).not.toContain('/tmp/gstack-claude-diff-$$');
|
||||
expect(content).toContain('cat "$PROMPT_FILE" | "$CLAUDE_BIN" -p');
|
||||
expect(content).toContain('Resolve the binary and invoke it in the same host execution context');
|
||||
expect(content).toContain('--disable-slash-commands');
|
||||
expect(content).toContain('--tools ""');
|
||||
expect(content).toContain('--allowedTools Read,Grep,Glob');
|
||||
expect(content).toContain('--disallowedTools Bash,Edit,Write');
|
||||
expect(content).toContain('Do not infer authentication state from credential files');
|
||||
expect(content).toContain('run the actual `claude -p`');
|
||||
expect(content).not.toContain('AUTH_MISSING');
|
||||
expect(content).not.toContain('$HOME/.claude/.credentials.json');
|
||||
expect(content).toContain('is_error');
|
||||
});
|
||||
|
||||
test('Claude temp file templates are accepted by host mktemp', () => {
|
||||
for (const template of [
|
||||
'/tmp/gstack-claude-prompt-XXXXXX',
|
||||
'/tmp/gstack-claude-response-XXXXXX',
|
||||
'/tmp/gstack-claude-error-XXXXXX',
|
||||
'/tmp/gstack-claude-diff-XXXXXX',
|
||||
]) {
|
||||
const result = spawnSync('mktemp', [template], { encoding: 'utf-8' });
|
||||
expect(result.status).toBe(0);
|
||||
const created = result.stdout.trim();
|
||||
expect(created.startsWith(template.replace('XXXXXX', ''))).toBe(true);
|
||||
fs.unlinkSync(created);
|
||||
}
|
||||
});
|
||||
|
||||
test('Codex review step stripped from Codex-host ship and review', () => {
|
||||
const shipContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||||
expect(shipContent).not.toContain('codex review --base');
|
||||
@@ -1926,16 +1996,21 @@ describe('Codex generation (--host codex)', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('.claude/skills/review/checklist.md');
|
||||
expect(content).toContain('~/.claude/skills/gstack');
|
||||
// Must NOT contain Codex paths
|
||||
// Must NOT contain Codex HOST paths. `~/.codex/sessions/` is exempt: the
|
||||
// timeout-wrapper guidance documents the Codex CLI's own rollout-log
|
||||
// location (a user-facing CLI path, same class as ~/.codex/logs/ in the
|
||||
// codex skill), not the gstack Codex host install path.
|
||||
expect(content).not.toContain('.agents/skills');
|
||||
expect(content).not.toContain('~/.codex/');
|
||||
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
|
||||
});
|
||||
|
||||
test('Claude output unchanged: ship skill still uses .claude/skills/ paths', () => {
|
||||
const content = readShipUnion();
|
||||
expect(content).toContain('~/.claude/skills/gstack');
|
||||
expect(content).not.toContain('.agents/skills');
|
||||
expect(content).not.toContain('~/.codex/');
|
||||
// ~/.codex/sessions/ is the Codex CLI's rollout-log path (user-facing),
|
||||
// documented by the adversarial-pass timeout guidance — see review test above.
|
||||
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
|
||||
});
|
||||
|
||||
test('Claude output unchanged: all Claude skills have zero Codex paths', () => {
|
||||
@@ -1944,9 +2019,11 @@ describe('Codex generation (--host codex)', () => {
|
||||
// pair-agent legitimately documents how Codex agents store credentials.
|
||||
// codex + autoplan document the Codex CLI auth file (~/.codex/auth.json)
|
||||
// and log path (~/.codex/logs/) — those are user-facing Codex CLI paths,
|
||||
// not the gstack Codex host install path.
|
||||
// not the gstack Codex host install path. ~/.codex/sessions/ (rollout
|
||||
// logs, referenced by the review/ship timeout guidance) is the same
|
||||
// user-facing class, so it is scrubbed before the ban.
|
||||
if (skill.dir !== 'pair-agent' && skill.dir !== 'codex' && skill.dir !== 'autoplan') {
|
||||
expect(content).not.toContain('~/.codex/');
|
||||
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
|
||||
}
|
||||
// gstack-upgrade legitimately references .agents/skills for cross-platform detection
|
||||
if (skill.dir !== 'gstack-upgrade') {
|
||||
@@ -2378,6 +2455,7 @@ describe('setup script validation', () => {
|
||||
expect(setupContent).toContain('kiro-cli');
|
||||
expect(setupContent).toContain('KIRO_SKILLS=');
|
||||
expect(setupContent).toContain('~/.kiro/skills/gstack');
|
||||
expect(setupContent).toContain('$KIRO_GSTACK/lib');
|
||||
});
|
||||
|
||||
test('setup supports --host opencode with install section and OpenCode skill path vars', () => {
|
||||
@@ -2393,14 +2471,16 @@ describe('setup script validation', () => {
|
||||
expect(setupContent).toContain('qa/templates');
|
||||
expect(setupContent).toContain('qa/references');
|
||||
expect(setupContent).toContain('dx-hall-of-fame.md');
|
||||
expect(setupContent).toContain('$opencode_gstack/lib');
|
||||
});
|
||||
|
||||
test('create_agents_sidecar links runtime assets', () => {
|
||||
// Sidecar must link bin, browse, review, qa
|
||||
// Sidecar must link bin with its shared lib modules, plus browse, review, qa
|
||||
const fnStart = setupContent.indexOf('create_agents_sidecar()');
|
||||
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart));
|
||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||
expect(fnBody).toContain('bin');
|
||||
expect(fnBody).toContain('lib');
|
||||
expect(fnBody).toContain('browse');
|
||||
expect(fnBody).toContain('review');
|
||||
expect(fnBody).toContain('qa');
|
||||
@@ -2411,6 +2491,7 @@ describe('setup script validation', () => {
|
||||
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', setupContent.indexOf('review/', fnStart)));
|
||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||
expect(fnBody).toContain('gstack/SKILL.md');
|
||||
expect(fnBody).toContain('$codex_gstack/lib');
|
||||
expect(fnBody).toContain('browse/dist');
|
||||
expect(fnBody).toContain('browse/bin');
|
||||
expect(fnBody).toContain('gstack-upgrade/SKILL.md');
|
||||
@@ -2422,6 +2503,14 @@ describe('setup script validation', () => {
|
||||
expect(fnBody).not.toContain('_link_or_copy "$gstack_dir" "$codex_gstack"');
|
||||
});
|
||||
|
||||
test('create_factory_runtime_root links shared lib modules beside bin', () => {
|
||||
const fnStart = setupContent.indexOf('create_factory_runtime_root()');
|
||||
const fnEnd = setupContent.indexOf('create_opencode_runtime_root()', fnStart);
|
||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||
expect(fnBody).toContain('$factory_gstack/bin');
|
||||
expect(fnBody).toContain('$factory_gstack/lib');
|
||||
});
|
||||
|
||||
test('direct Codex installs are migrated out of ~/.codex/skills/gstack', () => {
|
||||
expect(setupContent).toContain('migrate_direct_codex_install');
|
||||
expect(setupContent).toContain('$HOME/.gstack/repos/gstack');
|
||||
@@ -2776,21 +2865,54 @@ describe('codex commands must not use inline $(git rev-parse --show-toplevel) fo
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('codex review commands pass diff scope through prompt, not --base', () => {
|
||||
test('codex review commands take their scope from a flag, never from prompt text', () => {
|
||||
// `codex review` scope comes ONLY from --base/--commit/--uncommitted. The
|
||||
// positional [PROMPT] is mutually exclusive with all three (#1428, #1479),
|
||||
// and a prompt-only `codex review` silently falls back to the *uncommitted
|
||||
// working-tree* scope (`git status --short; git diff`) — so describing the
|
||||
// diff range in prompt text produces a confident review of the wrong
|
||||
// changes, with no error. Both halves are pinned here:
|
||||
// (a) every `codex review` invocation carries a scope flag, and
|
||||
// (b) no invocation puts a positional prompt in front of that flag.
|
||||
//
|
||||
// This does NOT apply to `codex exec`, which is agentic and really does run
|
||||
// the git command it's told to — the adversarial pass legitimately scopes
|
||||
// itself in prompt text.
|
||||
const checkedFiles = [
|
||||
'codex/SKILL.md.tmpl',
|
||||
'codex/SKILL.md',
|
||||
'scripts/resolvers/review.ts',
|
||||
'review/SKILL.md',
|
||||
'ship/SKILL.md',
|
||||
'codex/SKILL.md.tmpl',
|
||||
'codex/SKILL.md',
|
||||
];
|
||||
|
||||
const violations: string[] = [];
|
||||
for (const rel of checkedFiles) {
|
||||
// ship's codex/adversarial command moved into sections/adversarial.md (T9 carve).
|
||||
const content = rel === 'ship/SKILL.md' ? readShipUnion() : fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
expect(content).not.toContain('--base <base> -c \'model_reasoning_effort="high"\'');
|
||||
expect(content).toContain('Run git diff origin/<base>...HEAD 2>/dev/null || git diff <base>...HEAD');
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Only inspect real shell invocations, not prose mentioning the command.
|
||||
if (line.includes('`codex review`')) continue;
|
||||
const match = line.match(/(?:^|[;&|]\s*|\s)codex\s+review\b(.*)$/);
|
||||
if (!match) continue;
|
||||
const rest = match[1];
|
||||
const scopeFlag = /--base\b|--commit\b|--uncommitted\b/;
|
||||
if (!scopeFlag.test(rest)) {
|
||||
// A quoted prompt with no scope flag is the silent-wrong-scope bug.
|
||||
if (/^\s*["'$]/.test(rest)) {
|
||||
violations.push(`${rel}:${i + 1} — prompt-only codex review (falls back to working-tree scope)`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const beforeFlag = rest.split(scopeFlag)[0].trim();
|
||||
if (/^["'$]|^--\s*["']/.test(beforeFlag)) {
|
||||
violations.push(`${rel}:${i + 1} — positional prompt passed alongside a scope flag`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user