Files
gstack/test/branch-slug-hygiene.test.ts
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

223 lines
11 KiB
TypeScript

/**
* Branch-name slug hygiene in file-path positions (#2550, #1851/#1127).
*
* gstack-review-log WRITES `<canonical-branch>-reviews.jsonl` where the
* canonical form comes from bin/gstack-slug (tr '/' '-' then
* tr -cd 'a-zA-Z0-9._-'). Context Recovery used to PROBE the same file with
* raw $_BRANCH (`git branch --show-current`) — so for any branch containing
* a `/` (most feature branches) the REVIEWS line never fired. Same class:
* review.ts's plan content-search sanitized with tr '/' '-' only, missing
* the tr -cd half of the canonical pipeline.
*
* Discipline pinned here:
* - FILE-PATH positions interpolate the slug-canonical $BRANCH (set by the
* gstack-slug eval that opens Context Recovery).
* - Raw $_BRANCH stays for display (BRANCH: echo) and for timeline.jsonl
* content greps — the timeline writer stores the RAW branch, so slugging
* the reader would break that pairing.
*
* Reader-side fix folded from community PR #1851 by @harjothkhara.
*/
import { describe, test, expect } from 'bun:test';
import { execFileSync, execSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { HOST_PATHS } from '../scripts/resolvers/types';
import type { TemplateContext } from '../scripts/resolvers/types';
import { generateContextRecovery } from '../scripts/resolvers/preamble/generate-context-recovery';
import { ALL_HOST_CONFIGS } from '../hosts';
import { discoverSkillFiles } from '../scripts/discover-skills';
const ROOT = path.join(import.meta.dir, '..');
// Raw $_BRANCH (either spelling) immediately before/after a path separator.
const PATH_ADJACENT = /\/\$\{?_BRANCH|\$\{_BRANCH\}\/|\$_BRANCH\//;
// Raw $_BRANCH as a filename prefix (…-reviews.jsonl and friends).
const FILENAME_PREFIX = /\$\{?_BRANCH\}?[A-Za-z0-9._-]*\.(?:jsonl|json|md|txt|log)/;
function renderedSkillFiles(root = ROOT): string[] {
// Repository files, including new outputs, exclude ignored workspaces/archives.
const files = execFileSync('git', [
'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--',
'SKILL.md', '**/SKILL.md', '**/sections/*.md',
], { cwd: root, encoding: 'utf-8', timeout: 30_000 })
.split('\0').filter(Boolean).map(file => path.join(root, file));
// Host outputs are deliberately gitignored; inspect only their registered roots.
for (const host of ALL_HOST_CONFIGS.filter(host => host.name !== 'claude')) {
const hostRoot = path.join(root, host.hostSubdir);
const skillsRoot = path.join(hostRoot, 'skills');
if (!fs.existsSync(skillsRoot) || fs.lstatSync(hostRoot).isSymbolicLink()
|| fs.lstatSync(skillsRoot).isSymbolicLink()) continue;
files.push(...discoverSkillFiles(skillsRoot).map(file => path.join(skillsRoot, file)));
}
return [...new Set(files)];
}
describe('branch slug hygiene (#2550, #1851)', () => {
test('inventory covers repository outputs and host caches without ignored candidate trees', () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill inventory-'));
try {
execFileSync('git', ['init', '-q'], { cwd: repo, timeout: 30_000 });
const write = (file: string) => {
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
fs.writeFileSync(path.join(repo, file), '# Skill\n');
};
const source = ['SKILL.md', 'health/SKILL.md', 'health/sections/checks.md'];
const hosts = ALL_HOST_CONFIGS.filter(host => host.name !== 'claude');
fs.writeFileSync(path.join(repo, '.gitignore'),
['.context/', 'node_modules/', ...hosts.map(host => `${host.hostSubdir}/`)].join('\n'));
source.forEach(write);
execFileSync('git', ['add', '--', ...source], { cwd: repo, timeout: 30_000 });
source.push('new skill/SKILL.md');
write(source.at(-1)!); // New, untracked output must still be checked.
write('.context/candidate/SKILL.md');
write('.context/candidate/health/sections/checks.md');
write('node_modules/other/SKILL.md');
expect(renderedSkillFiles(repo).sort())
.toEqual(source.map(file => path.join(repo, file)).sort());
const caches = hosts.map(host => `${host.hostSubdir}/skills/gstack-health/SKILL.md`);
caches.forEach(write);
expect(renderedSkillFiles(repo).sort())
.toEqual([...source, ...caches].map(file => path.join(repo, file)).sort());
// Old find never followed host or skills directory symlinks into another tree.
write('.context/outside/skills/gstack-foreign/SKILL.md');
for (const [index, host] of hosts.slice(0, 2).entries()) {
const link = path.join(repo, host.hostSubdir, index ? 'skills' : '');
fs.rmSync(link, { recursive: true, force: true });
fs.symlinkSync(path.join(repo, '.context/outside', index ? 'skills' : ''), link, 'junction');
}
expect(renderedSkillFiles(repo).sort())
.toEqual([...source, ...caches.slice(2)].map(file => path.join(repo, file)).sort());
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
test('no generated SKILL.md or section interpolates raw $_BRANCH in a path position', () => {
const offenders: string[] = [];
for (const file of renderedSkillFiles()) {
const content = fs.readFileSync(file, 'utf-8');
if (PATH_ADJACENT.test(content) || FILENAME_PREFIX.test(content)) {
offenders.push(path.relative(ROOT, file));
}
}
expect(offenders).toEqual([]);
});
test('Context Recovery probes reviews.jsonl with the slug-canonical $BRANCH', () => {
const ctx: TemplateContext = {
skillName: 'test-skill',
tmplPath: 'test.tmpl',
host: 'claude',
paths: HOST_PATHS.claude,
preambleTier: 2,
};
// The probe moved into bin/gstack-context-recovery (#2763); the render is
// one literal command, so the branch-form discipline is pinned there.
expect(generateContextRecovery(ctx)).toContain('```bash\n~/.claude/skills/gstack/bin/gstack-context-recovery\n```');
const out = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-context-recovery'), 'utf-8');
expect(out).toContain('${BRANCH:-unknown}-reviews.jsonl');
expect(out).not.toContain('${_BRANCH}-reviews.jsonl');
// The gstack-slug eval that defines $BRANCH must run BEFORE the probe.
const evalIdx = out.indexOf('gstack-slug');
const probeIdx = out.indexOf('${BRANCH:-unknown}-reviews.jsonl');
expect(evalIdx).toBeGreaterThan(-1);
expect(evalIdx).toBeLessThan(probeIdx);
// Raw $_BRANCH stays for the timeline.jsonl content greps (writer stores raw).
expect(out).toContain('"branch\\":\\"${_BRANCH}');
});
test('plan content-search BRANCH uses the full gstack-slug canonical pipeline', () => {
const rendered = fs.readFileSync(
path.join(ROOT, 'ship', 'sections', 'plan-completion.md'),
'utf-8',
);
expect(rendered).toContain(
`BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')`,
);
});
test('live round-trip: Context Recovery finds slugged reviews and raw timeline branches in a fresh shell', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-repo-'));
try {
const env = { ...process.env, GSTACK_HOME: home };
execSync(
'git init -q && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init && git checkout -q -b feat/slug-hygiene',
{ cwd: repo, encoding: 'utf-8', timeout: 30_000 },
);
// Writer: the real gstack-review-log (canonicalizes via gstack-slug).
execSync(
`"${path.join(ROOT, 'bin', 'gstack-review-log')}" '{"skill":"ship","status":"ok"}'`,
{ cwd: repo, env, encoding: 'utf-8', timeout: 30_000 },
);
// The slug-canonical filename must exist; the raw form must not.
const slugVars = execSync(`"${path.join(ROOT, 'bin', 'gstack-slug')}"`, {
cwd: repo, env, encoding: 'utf-8', timeout: 30_000,
});
const slug = slugVars.match(/^SLUG=(.*)$/m)![1];
const branch = slugVars.match(/^BRANCH=(.*)$/m)![1];
expect(branch).toBe('feat-slug-hygiene');
const proj = path.join(home, 'projects', slug);
expect(fs.existsSync(path.join(proj, 'feat-slug-hygiene-reviews.jsonl'))).toBe(true);
// Reader: execute the complete rendered block without inheriting the
// external skill-start process's private shell variables.
const ctx: TemplateContext = {
skillName: 'test-skill', tmplPath: 'test.tmpl', host: 'claude',
paths: { ...HOST_PATHS.claude, binDir: '"$TEST_BIN"' }, preambleTier: 2,
};
const script = generateContextRecovery(ctx).match(/```bash\n([\s\S]*?)\n```/)![1];
fs.writeFileSync(path.join(proj, 'timeline.jsonl'), [
{ branch: 'feat/slug-hygiene', event: 'completed', skill: 'review' },
{ branch: 'feat/slug-hygiene', event: 'started', skill: 'unfinished' },
{ branch, event: 'completed', skill: 'slugged-decoy' },
{ branch: 'stale/parent-branch', event: 'completed', skill: 'inherited-decoy' },
{ branch: 'unknown', event: 'completed', skill: 'fallback' },
{ branch: 'feat/slug-hygiene', event: 'completed', skill: 'ship' },
].map(entry => JSON.stringify(entry)).join('\n') + '\n');
const recover = (cwd: string, inheritedBranch?: string) => {
const result = spawnSync('bash', ['-c', script], {
cwd, encoding: 'utf8', timeout: 30_000,
env: { ...env, GSTACK_PROJECT_SLUG: slug, TEST_BIN: path.join(ROOT, 'bin'), _BRANCH: inheritedBranch },
});
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).not.toContain('not a git repository');
return result.stdout;
};
for (const inheritedBranch of [undefined, 'stale/parent-branch']) {
const out = recover(repo, inheritedBranch);
expect(out).toContain('REVIEWS: 1 entries');
expect(out.split('\n').filter(line => line.startsWith('LAST_SESSION:'))).toEqual([
'LAST_SESSION: {"branch":"feat/slug-hygiene","event":"completed","skill":"ship"}',
]);
expect(out.split('\n').filter(line => line.startsWith('RECENT_PATTERN:'))).toEqual([
'RECENT_PATTERN: review,ship,',
]);
}
// Negative control: the raw-branch probe (the pre-fix shape) misses.
expect(fs.existsSync(path.join(proj, 'feat/slug-hygiene-reviews.jsonl'))).toBe(false);
// Both an unnamed checkout and a non-repository use the same unknown
// timeline identity as skill-start/end, never an inherited branch.
execSync('git checkout -q --detach', { cwd: repo, timeout: 30_000 });
for (const cwd of [repo, home]) {
const out = recover(cwd, 'stale/parent-branch');
expect(out.split('\n').filter(line => line.startsWith('LAST_SESSION:'))).toEqual([
'LAST_SESSION: {"branch":"unknown","event":"completed","skill":"fallback"}',
]);
expect(out.split('\n').filter(line => line.startsWith('RECENT_PATTERN:'))).toEqual([
'RECENT_PATTERN: fallback,',
]);
}
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
}
});
});