v1.92.72.0 stamp: merge main 69c14b17d5 into #3747 (land)

This commit is contained in:
garrytan committed 2026-11-02 11:26:25 +00:00
commit 1c818038e7
48 files changed
+1251 -278

No files matched your search

+3 -2
View File
@@ -353,8 +353,9 @@ describe('context-restore offers newer checkpoints it used to skip (#3065)', ()
const sibling = repo('elsewhere', 'git@github.com:acme/elsewhere.git');
const outside = save(sibling, 'sibling work', '20261002-100000');
const below = save(pkg, 'pkg feature', '20261001-100000');
expect(restore(mono).out).not.toContain(outside);
expect(restore(mono).lines).toContain(`BELOW_CWD ${below}`);
const r = restore(mono);
expect(r.out).not.toContain(outside);
expect(r.lines).toContain(`BELOW_CWD ${below}`);
// 200 newer checkpoints of another project push the below-cwd one out of the read window.
const dir = path.dirname(outside);
+31
View File
@@ -0,0 +1,31 @@
# /health Step 1 auto-detection, frozen verbatim from health/SKILL.md.tmpl at a305302
# (v1.91.365.0) before /health moved onto gstack-checks detect --for health (GST-116 PR 2).
# test/health-detect-no-loss.test.ts runs it beside the new detector: every line it prints
# must still be found. Do not edit; it is the baseline, not live code.
# Type checker
[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit"
# Linter
[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ."
setopt +o nomatch 2>/dev/null || true
ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ."
[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ."
# Test runner
[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest"
[ -f Cargo.toml ] && echo "TEST: cargo test"
[ -f go.mod ] && echo "TEST: go test ./..."
# Dead code
command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip"
[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip"
# Shell linting
command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck"
# GBrain presence — only report as a dimension if gbrain is actually
# set up; otherwise skip so machines without gbrain aren't penalized.
if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then
echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)"
fi
+97
View File
@@ -0,0 +1,97 @@
/**
* test/helpers/git-fixture-template.ts: copies made from one template are
* independent repositories (a push in one never reaches the other or the
* template), and a copy whose rebind misses a path that names the template
* root, in any of its spellings, throws instead of reaching a test.
*/
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { gitFixtureTemplate, pathSpellings, rebindFile, rebindGitDir, templateReferences } from './helpers/git-fixture-template';
const roots: string[] = [];
afterEach(() => { for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); });
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8', timeout: 30_000 }).trim();
}
function build(root: string): { head: string } {
const origin = path.join(root, 'origin.git');
const work = path.join(root, 'work');
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
fs.mkdirSync(work);
git(work, 'init', '-q', '-b', 'main');
git(work, 'remote', 'add', 'origin', origin);
fs.writeFileSync(path.join(work, 'a.txt'), 'base\n');
git(work, 'add', '-A');
git(work, 'commit', '-q', '-m', 'base');
git(work, 'push', '-q', 'origin', 'main');
return { head: git(work, 'rev-parse', 'HEAD') };
}
const template = gitFixtureTemplate({ prefix: 'gstack-fixture-template-', build, rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy) });
const leaky = gitFixtureTemplate({ prefix: 'gstack-fixture-template-leak-', build, rebind: () => {} });
afterAll(() => { template.dispose(); leaky.dispose(); });
function copy() {
const c = template.copy();
roots.push(c.root);
return { ...c, work: path.join(c.root, 'work'), origin: path.join(c.root, 'origin.git') };
}
function pushChange(c: ReturnType<typeof copy>, text: string): string {
fs.writeFileSync(path.join(c.work, 'a.txt'), text);
git(c.work, 'commit', '-q', '-am', text);
git(c.work, 'push', '-q', 'origin', 'main');
return git(c.work, 'rev-parse', 'HEAD');
}
describe('git fixture template', () => {
test('two copies from one template mutated independently stay isolated from each other and from the template', () => {
const a = copy();
const b = copy();
expect(git(a.work, 'remote', 'get-url', 'origin')).toBe(a.origin);
expect(git(b.work, 'remote', 'get-url', 'origin')).toBe(b.origin);
const fromA = pushChange(a, 'from a\n');
expect(git(a.origin, 'rev-parse', 'main')).toBe(fromA);
expect(git(b.origin, 'rev-parse', 'main')).toBe(b.meta.head);
const fromB = pushChange(b, 'from b\n');
expect(fromB).not.toBe(fromA);
expect(git(a.origin, 'rev-parse', 'main')).toBe(fromA);
expect(git(b.origin, 'rev-parse', 'main')).toBe(fromB);
expect(fs.readFileSync(path.join(a.work, 'a.txt'), 'utf8')).toBe('from a\n');
const c = copy();
expect(git(c.origin, 'rev-parse', 'main')).toBe(c.meta.head);
expect(fs.readFileSync(path.join(c.work, 'a.txt'), 'utf8')).toBe('base\n');
});
test('a copy whose rebind leaves the remote URL on the template throws and names the file', () => {
expect(() => leaky.copy()).toThrow(/still references its template [^\n]*\n.*config/);
});
test('native, forward-slash, backslash-escaped and Git Bash spellings are each rewritten, and each one left behind is found', () => {
const to = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fixture-spellings-'));
roots.push(to);
const from = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\gstack-fixture-tpl-AbC123';
const spellings = pathSpellings(from);
expect(spellings).toEqual([
'C:\\\\Users\\\\RUNNER~1\\\\AppData\\\\Local\\\\Temp\\\\gstack-fixture-tpl-AbC123',
from,
'C:/Users/RUNNER~1/AppData/Local/Temp/gstack-fixture-tpl-AbC123',
'/c/Users/RUNNER~1/AppData/Local/Temp/gstack-fixture-tpl-AbC123',
]);
const file = path.join(to, 'config');
fs.writeFileSync(file, spellings.map(s => 'url = ' + s + '/origin.git\n').join(''));
expect(templateReferences(to, from)).toEqual([file]);
rebindFile(file, from, to);
expect(fs.readFileSync(file, 'utf8')).toBe(spellings.map((_, i) => 'url = ' + (pathSpellings(to)[i] ?? to) + '/origin.git\n').join(''));
expect(templateReferences(to, from)).toEqual([]);
for (const s of spellings) {
fs.writeFileSync(file, 'url = ' + s + '\n');
expect(templateReferences(to, from)).toEqual([file]);
}
expect(templateReferences(to, from, { origin: from + '\\origin.git' })).toEqual([file, '(template meta)']);
});
});
+10 -4
View File
@@ -25,6 +25,7 @@ function waitFor(pred: () => boolean, ms: number): boolean {
}
return pred();
}
const GO_FILE_WAIT = 'i=0; while [ ! -e "$1" ] && [ "$i" -lt 100 ]; do sleep 0.1; i=$((i + 1)); done; [ -e "$1" ]';
function logHas(p: string, needle: string): boolean {
try { return fs.readFileSync(p, 'utf-8').includes(needle); } catch { return false; }
}
@@ -33,19 +34,21 @@ describe('gstack-detach', () => {
test('detaches (different pgid), returns immediately, completes, writes EXIT sentinel', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gd-'));
const log = path.join(dir, 'run.log');
const go = path.join(dir, 'go');
try {
const t0 = Date.now();
const r = spawnSync(DETACH, ['--log', log, '--', 'bash', '-c', 'sleep 2; echo body-ran'], { encoding: 'utf-8', timeout: 10000 });
const r = spawnSync(DETACH, ['--log', log, '--', 'bash', '-c', GO_FILE_WAIT + ' && echo body-ran', 'body', go], { encoding: 'utf-8', timeout: 10000 });
const elapsed = Date.now() - t0;
expect(r.status).toBe(0);
expect(r.stdout).toContain(`gstack-detach LOG ${log}`);
expect(elapsed).toBeLessThan(1500); // non-blocking
fs.writeFileSync(go, '');
expect(waitFor(() => logHas(log, '### gstack-detach EXIT=0 ###'), 8000)).toBe(true);
expect(logHas(log, 'body-ran')).toBe(true); // ran to completion after launcher returned
const m = fs.readFileSync(log, 'utf-8').match(/pgid=(\d+)/);
expect(m).not.toBeNull();
expect(m![1]).not.toBe(ownPgid()); // detached into its own group
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
} finally { fs.writeFileSync(go, ''); fs.rmSync(dir, { recursive: true, force: true }); }
}, 15000);
test('default log is run-scoped under ~/.gstack-dev/eval-runs (no shared /tmp)', () => {
@@ -127,17 +130,20 @@ describe('gstack-detach', () => {
const lock = `gstack-detach-test-${process.pid}`;
const logA = path.join(dir, 'a.log');
const logB = path.join(dir, 'b.log');
const release = path.join(dir, 'release');
try {
// First holds the lock for ~3s; second must wait then acquire.
spawnSync(DETACH, ['--log', logA, '--lock', lock, '--', 'sleep', '3'], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } });
// First holds the lock until the test writes the release file; second must wait then acquire.
spawnSync(DETACH, ['--log', logA, '--lock', lock, '--', 'bash', '-c', GO_FILE_WAIT, 'holder', release], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } });
waitFor(() => logHas(logA, "ACQUIRED"), 4000);
spawnSync(DETACH, ['--log', logB, '--lock', lock, '--', 'echo', 'second-ran'], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } });
// Second should report WAITING (first still holds it) then ACQUIRE after release.
expect(waitFor(() => logHas(logB, 'WAITING for lock'), 4000)).toBe(true);
fs.writeFileSync(release, '');
expect(waitFor(() => logHas(logB, '### gstack-detach EXIT=0 ###'), 12000)).toBe(true);
expect(logHas(logB, 'second-ran')).toBe(true);
expect(fs.existsSync(path.join(dir, '.gstack', 'locks', `${lock}.lock`))).toBe(true);
} finally {
fs.writeFileSync(release, '');
fs.rmSync(dir, { recursive: true, force: true });
}
}, 20000);
+149
View File
@@ -770,6 +770,155 @@ describe('gstack-developer-profile --log-session (#1671 fix)', () => {
});
});
// -----------------------------------------------------------------------
// Concurrent writers (#3437). gstack-question-log starts a background
// --derive after every append, and a multi-question AskUserQuestion logs
// several rows at once, so derives overlap each other and /office-hours'
// --log-session. Each subcommand rewrites the whole profile, so every
// read-modify-write holds the profile lock and writes a unique temp file.
// -----------------------------------------------------------------------
async function runDevAsync(...args: string[]): Promise<{ stdout: string; stderr: string; status: number }> {
const proc = Bun.spawn([BIN_DEV, ...args], {
env: { ...process.env, GSTACK_HOME: tmpHome },
cwd: ROOT,
stdout: 'pipe',
stderr: 'pipe',
});
const timer = setTimeout(() => proc.kill(), 60_000);
const [stdout, stderr, status] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
clearTimeout(timer);
return { stdout, stderr, status };
}
describe('gstack-developer-profile concurrent writers (#3437)', () => {
const lockDir = () => path.join(tmpHome, 'developer-profile.json.lock');
test('--derive does not write through a fixed temp path', () => {
fs.mkdirSync(path.join(tmpHome, 'developer-profile.json.tmp'));
const r = runDev('--derive');
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(typeof (readProfile() as { derived_at?: string }).derived_at).toBe('string');
});
test('a held profile lock keeps --derive and --log-session from writing', () => {
expect(runDev('--derive').status).toBe(0);
const before = fs.readFileSync(path.join(tmpHome, 'developer-profile.json'), 'utf-8');
fs.mkdirSync(lockDir());
fs.writeFileSync(path.join(lockDir(), 'owner'), process.pid + '-held\n');
const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' };
for (const args of [['--derive'], ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder' })]]) {
const res = spawnSync(BIN_DEV, args, { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 });
expect(res.status).toBe(5);
expect(res.stderr).toContain('developer-profile.json.lock');
}
expect(fs.readFileSync(path.join(tmpHome, 'developer-profile.json'), 'utf-8')).toBe(before);
expect(fs.readFileSync(path.join(lockDir(), 'owner'), 'utf-8')).toBe(process.pid + '-held\n');
});
test('a stale lock left by a crashed writer is taken over', () => {
fs.mkdirSync(lockDir());
const old = new Date(Date.now() - 120_000);
fs.utimesSync(lockDir(), old, old);
const r = runDev('--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'p' }));
expect(r.status).toBe(0);
expect((readProfile() as { sessions: unknown[] }).sessions.length).toBe(1);
expect(fs.existsSync(lockDir())).toBe(false);
});
test('a lock whose holder process is gone is taken over at once', () => {
const gone = spawnSync('true', [], { timeout: 10_000 }).pid;
fs.mkdirSync(lockDir());
fs.writeFileSync(path.join(lockDir(), 'owner'), gone + '-crashed\n');
const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' };
const res = spawnSync(BIN_DEV, ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'p' })], { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 });
expect(res.stderr).toBe('');
expect(res.status).toBe(0);
expect((readProfile() as { sessions: unknown[] }).sessions.length).toBe(1);
expect(fs.existsSync(lockDir())).toBe(false);
});
test('a lock older than 5 minutes is taken over even when its pid is alive (pid reuse)', () => {
fs.mkdirSync(lockDir());
fs.writeFileSync(path.join(lockDir(), 'owner'), process.pid + '-reused\n');
const old = new Date(Date.now() - 10 * 60_000);
fs.utimesSync(lockDir(), old, old);
const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' };
const res = spawnSync(BIN_DEV, ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'p' })], { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 });
expect(res.stderr).toBe('');
expect(res.status).toBe(0);
expect((readProfile() as { sessions: unknown[] }).sessions.length).toBe(1);
expect(fs.existsSync(lockDir())).toBe(false);
});
test('a takeover already in progress is not repeated', () => {
const gone = spawnSync('true', [], { timeout: 10_000 }).pid;
fs.mkdirSync(lockDir());
fs.writeFileSync(path.join(lockDir(), 'owner'), gone + '-crashed\n');
fs.mkdirSync(lockDir() + '.takeover');
const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' };
const res = spawnSync(BIN_DEV, ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder' })], { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 });
expect(res.status).toBe(5);
expect(fs.readFileSync(path.join(lockDir(), 'owner'), 'utf-8')).toBe(gone + '-crashed\n');
});
test('a takeover that cannot remove the lock still gives up', () => {
expect(runDev('--derive').status).toBe(0);
fs.mkdirSync(lockDir());
const old = new Date(Date.now() - 120_000);
fs.utimesSync(lockDir(), old, old);
fs.chmodSync(tmpHome, 0o555);
try {
const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' };
const res = spawnSync(BIN_DEV, ['--derive'], { env, encoding: 'utf-8', cwd: ROOT, timeout: 10_000 });
expect(res.signal).toBeNull();
expect([0, 5]).toContain(res.status ?? -1);
} finally {
fs.chmodSync(tmpHome, 0o755);
}
}, 20_000);
test('a relative state root does not leave the lock behind', () => {
const env = { ...process.env, GSTACK_HOME: 'state' };
for (const args of [['--derive'], ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'p' })]]) {
const res = spawnSync(BIN_DEV, args, { env, encoding: 'utf-8', cwd: tmpHome, timeout: 30_000 });
expect(res.stderr).toBe('');
expect(res.status).toBe(0);
}
const p = JSON.parse(fs.readFileSync(path.join(tmpHome, 'state', 'developer-profile.json'), 'utf-8'));
expect(p.sessions.length).toBe(1);
expect(typeof p.derived_at).toBe('string');
expect(fs.existsSync(path.join(tmpHome, 'state', 'developer-profile.json.lock'))).toBe(false);
});
test('overlapping derives and log-sessions keep every session and leave valid JSON', async () => {
for (let i = 0; i < 5; i++) {
logQuestion({ skill: 'plan-ceo-review', question_id: 'plan-ceo-review-mode', question_summary: 'mode', user_choice: 'expand', session_id: 's' + i });
}
expect(runDev('--derive').status).toBe(0);
const rounds = 6;
for (let round = 0; round < rounds; round++) {
const runs = await Promise.all([
...Array.from({ length: 6 }, () => runDevAsync('--derive')),
runDevAsync('--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'round-' + round })),
]);
for (const r of runs) {
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
}
}
const p = readProfile() as { sessions: Array<{ project_slug: string }>; inferred: { sample_size: number } };
expect(p.sessions.map((s) => s.project_slug).sort()).toEqual(Array.from({ length: rounds }, (_, i) => 'round-' + i).sort());
expect(p.inferred.sample_size).toBe(5);
expect(fs.readdirSync(tmpHome).filter((f) => f.startsWith('developer-profile.json.'))).toEqual([]);
}, 120_000);
});
// -----------------------------------------------------------------------
// SESSION_COUNT / TIER / NUDGE_ELIGIBLE must ignore mode:resources entries.
//
+183
View File
@@ -0,0 +1,183 @@
/**
* /health Step 1 moved onto gstack-checks detect --for health (GST-116 PR 2). The acceptance bar: every check the old
* Step 1 found is still found. The old shell block is frozen verbatim in test/fixtures/health-detect-legacy.sh; each
* fixture repo runs both under the same PATH and HOME, and every legacy (category, command) pair must appear in the
* detector's DIMENSION lines.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { HEALTH_DIMENSIONS, healthDimensions } from '../lib/checks';
import { runScriptSync } from '../lib/script-invocation';
import { BIN } from './helpers/pregate-fixture';
const LEGACY = path.join(import.meta.dir, 'fixtures', 'health-detect-legacy.sh');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'health-detect-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const stubs = path.join(tmp, 'stubs');
fs.mkdirSync(stubs);
for (const tool of ['knip', 'shellcheck', 'gbrain']) {
fs.writeFileSync(path.join(stubs, tool), '#!/bin/sh\nexit 0\n');
fs.chmodSync(path.join(stubs, tool), 0o755);
}
const gbrainHome = path.join(tmp, 'home-gbrain');
fs.mkdirSync(path.join(gbrainHome, '.gbrain'), { recursive: true });
fs.writeFileSync(path.join(gbrainHome, '.gbrain', 'config.json'), '{}\n');
const bareHome = path.join(tmp, 'home-bare');
fs.mkdirSync(bareHome);
// The knip, shellcheck and gbrain stubs are extensionless #!/bin/sh files, which windows-latest cannot find or launch
// (windows-free-shard 3, run 41204425554); the cases that need them run on Linux and macOS.
const stubbed = test.skipIf(process.platform === 'win32');
const PKG = JSON.stringify({ name: 'fx', scripts: { test: 'vitest run', lint: 'eslint .' }, devDependencies: { knip: '^5.0.0' } });
const FIXTURES: Record<string, Record<string, string>> = {
empty: {},
tsconfig: { 'tsconfig.json': '{}' },
'biome-json': { 'biome.json': '{}' },
'biome-jsonc': { 'biome.jsonc': '{}' },
'eslint-flat': { 'eslint.config.mjs': 'export default [];' },
'eslintrc-json': { '.eslintrc.json': '{}' },
'eslintrc-bare': { '.eslintrc': '{}' },
'pyproject-ruff-pytest': { 'pyproject.toml': '[tool.ruff]\n[tool.pytest.ini_options]\n' },
'pyproject-pylint': { 'pyproject.toml': '[tool.pylint]\n' },
'pylintrc-only': { '.pylintrc': '[MASTER]\n' },
'package-test-knip': { 'package.json': PKG },
cargo: { 'Cargo.toml': '[package]\nname = "fx"\n' },
go: { 'go.mod': 'module fx\n' },
'shell-scripts-dir': { 'scripts/build.sh': 'echo hi\n' },
'shell-root-and-bin': { 'run.sh': 'echo hi\n', 'bin/tool.sh': 'echo hi\n' },
everything: { 'tsconfig.json': '{}', 'biome.json': '{}', 'eslint.config.js': '', 'pyproject.toml': 'ruff pytest\n', 'package.json': PKG, 'Cargo.toml': '', 'go.mod': 'module fx\n', 'scripts/a.sh': '' },
};
function repo(name: string, files: Record<string, string>): string {
const dir = path.join(tmp, 'repos', name);
for (const [file, body] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), body);
}
fs.mkdirSync(dir, { recursive: true });
return dir;
}
const NORMALIZE: Record<string, string> = { 'gbrain doctor --json (wrapped in timeout 5s)': 'timeout 5s gbrain doctor --json' };
const pairs = (lines: Array<[string, string]>) => lines.map(([d, c]) => `${d}: ${c}`).sort();
function legacy(dir: string, env: NodeJS.ProcessEnv): string[] {
const r = spawnSync('bash', [LEGACY], { cwd: dir, env, encoding: 'utf8', timeout: 30_000 });
expect(r.status).toBe(0);
return pairs(r.stdout.split('\n').flatMap(line => {
const m = /^(TYPECHECK|LINT|TEST|DEADCODE|SHELL|GBRAIN): (.+)$/.exec(line);
return m ? [[m[1].toLowerCase(), NORMALIZE[m[2]] ?? m[2]] as [string, string]] : [];
}));
}
function detector(dir: string, env: NodeJS.ProcessEnv): { found: string[]; none: string[] } {
const r = runScriptSync(BIN('gstack-checks'), ['detect', '--for', 'health'], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 });
expect(r.status).toBe(0);
const lines = (r.stdout ?? '').split('\n').filter(Boolean).map(line => /^DIMENSION (\S+) (.+?) \((.*)\)$/.exec(line)!);
expect(lines.every(Boolean)).toBe(true);
return { found: pairs(lines.filter(m => m[2] !== 'none').map(m => [m[1], m[2]])), none: lines.filter(m => m[2] === 'none').map(m => m[1]) };
}
const ENVS = {
'tools on PATH, gbrain configured': { PATH: `${stubs}${path.delimiter}${process.env.PATH}`, HOME: gbrainHome },
'machine PATH, no gbrain config': { PATH: process.env.PATH, HOME: bareHome },
};
describe('/health detection: no check lost (GST-116 PR 2)', () => {
// Value: protects=/health still finds every tool its old Step 1 shell block found; fails_when=a ported rule drops a
// category or command (knip, shellcheck, gbrain, a lint or test rule) for any fixture; why_new=/health moved onto the
// detector in this change; seam=the frozen legacy block run beside the bin; no_claim=that the found tools succeed
for (const [envName, envVars] of Object.entries(ENVS)) {
(envName.startsWith('tools on PATH') ? stubbed : test)(`every legacy line is found: ${envName}`, () => {
const env = { ...process.env, ...envVars };
let legacyTotal = 0;
for (const [name, files] of Object.entries(FIXTURES)) {
const dir = repo(name, files);
const old = legacy(dir, env);
const now = detector(dir, env);
legacyTotal += old.length;
expect({ name, lost: old.filter(p => !now.found.includes(p)) }).toEqual({ name, lost: [] });
expect(new Set([...now.found.map(p => p.split(':')[0]), ...now.none])).toEqual(new Set(HEALTH_DIMENSIONS));
}
expect(legacyTotal).toBeGreaterThan(20);
});
}
// Value: protects=the matrix exercises knip, shellcheck and gbrain, not just the manifest rules; fails_when=a stub stops
// being found so the no-loss comparison above runs with those categories empty; why_new=new fixture; seam=none
stubbed('the stubbed run covers every category on both sides', () => {
const env = { ...process.env, ...ENVS['tools on PATH, gbrain configured'] };
const dir = repo('everything', FIXTURES.everything);
expect(new Set(legacy(dir, env).map(p => p.split(':')[0]))).toEqual(new Set(HEALTH_DIMENSIONS));
expect(detector(dir, env).none).toEqual([]);
});
// Value: protects=a Health Stack section wins row for row, test rows included, and nothing is auto-detected beside it;
// fails_when=health mode drops the test row /verify leaves to pregate, or mixes manifest detection in; why_new=keepTests
// is new; seam=none
test('a Health Stack section is returned as written', () => {
const dir = repo('health-stack', { 'tsconfig.json': '{}', 'CLAUDE.md': '# x\n\n## Health Stack\n\n- typecheck: tsc --noEmit -p app\n- test: bun test\n- deadcode: knip --production\n- shell: shellcheck *.sh scripts/*.sh\n\n## Other\n' });
const d = healthDimensions(dir, { PATH: stubs, HOME: gbrainHome });
expect(d.checks.map(c => [c.dimension, c.command])).toEqual([['typecheck', 'tsc --noEmit -p app'], ['test', 'bun test'], ['deadcode', 'knip --production'], ['shell', 'shellcheck *.sh scripts/*.sh']]);
expect(d.missing).toEqual([{ dimension: 'lint', reason: 'not in the CLAUDE.md Health Stack section' }, { dimension: 'gbrain', reason: 'not in the CLAUDE.md Health Stack section' }]);
});
// Value: protects=a 'tests' row and repeated keys count as present, so Step 2 never skips a category it runs;
// fails_when=the -N suffix or tests→test normalization regresses and a listed category also prints none; why_new=new; seam=none
test('a tests row and repeated keys are not reported missing', () => {
const dir = repo('health-stack-dupes', { 'CLAUDE.md': '## Health Stack\n\n- tests: bun test\n- lint: eslint .\n- lint: biome check .\n' });
const d = healthDimensions(dir, { PATH: '', HOME: bareHome });
expect(d.checks.map(c => c.dimension)).toEqual(['tests', 'lint', 'lint-2']);
expect(d.missing.map(m => m.dimension)).toEqual(['typecheck', 'deadcode', 'shell', 'gbrain']);
});
// Value: protects=--for is accepted only as detect --for health; fails_when=run --for health falls through into the run
// path, or a missing or unknown mode is accepted; why_new=new flag; seam=the bin
test('--for is refused outside detect --for health', () => {
for (const args of [['run', '--for', 'health'], ['detect', '--for'], ['detect', '--for', 'verify']]) {
const r = runScriptSync(BIN('gstack-checks'), args, { cwd: tmp, encoding: 'utf8', timeout: 30_000 });
expect(r.status).toBe(64);
expect(r.stderr ?? '').toContain('Usage: gstack-checks');
}
});
// Value: protects=Health Stack rows read as markdown like gstack:verify: (code-span backticks dropped), an empty section
// still turns auto-detection off and says so, and detection runs where /health runs (a monorepo package);
// fails_when=a section with no rows falls through to auto-detection, or the bin detects from the git top-level;
// why_new=review pass 1 voices; seam=the bin from a subdirectory
test('Health Stack spans, an empty section, and the working directory', () => {
const tick = String.fromCharCode(96);
const spans = healthDimensions(repo('hs-spans', { 'CLAUDE.md': `## Health Stack\n\n- lint: ${tick}eslint .${tick} && ${tick}prettier --check .${tick}\n` }), { PATH: '', HOME: bareHome });
expect(spans.checks.map(c => c.command)).toEqual(['eslint . && prettier --check .']);
const empty = healthDimensions(repo('hs-empty', { 'tsconfig.json': '{}', 'CLAUDE.md': '## Health Stack\n\nNo health tools are configured.\n' }), { PATH: stubs, HOME: gbrainHome });
expect(empty.checks).toEqual([]);
expect(empty.missing.map(m => m.dimension)).toEqual([...HEALTH_DIMENSIONS]);
expect(empty.missing[0].reason).toBe('the CLAUDE.md Health Stack section has no "- key: command" rows');
const mono = repo('monorepo', { 'tsconfig.json': '{}', 'packages/app/package.json': JSON.stringify({ scripts: { test: 'vitest run' } }) });
expect(spawnSync('git', ['init', '-q'], { cwd: mono, timeout: 30_000 }).status).toBe(0);
const env = { ...process.env, PATH: process.env.PATH, HOME: bareHome };
const pkg = detector(path.join(mono, 'packages', 'app'), env);
expect(pkg.found).toContain('test: vitest run');
expect(pkg.none).toContain('typecheck');
const multi = healthDimensions(repo('multiline-test', { 'package.json': JSON.stringify({ scripts: { test: 'vitest\nDIMENSION lint x (CLAUDE.md Health Stack)' } }) }), { PATH: '', HOME: bareHome });
expect(multi.checks).toEqual([]);
expect(multi.missing.find(m => m.dimension === 'test')?.reason).toBe('the package.json test script spans lines; list it in a CLAUDE.md Health Stack section');
const noHome = repo('no-home', { '.gbrain/config.json': '{}' });
expect(healthDimensions(noHome, { PATH: stubs, HOME: '' }).checks.map(c => c.dimension)).not.toContain('gbrain');
});
// Value: protects=an unavailable category says why, so Step 2 records SKIPPED with a reason; fails_when=a none line
// loses its reason or shellcheck's two causes collapse; why_new=new output; seam=none
stubbed('none lines name the reason', () => {
const empty = healthDimensions(repo('empty', {}), { PATH: '', HOME: bareHome });
expect(empty.checks).toEqual([]);
expect(empty.missing.find(m => m.dimension === 'shell')?.reason).toBe('shellcheck is not on PATH');
const noScripts = healthDimensions(repo('tsconfig', FIXTURES.tsconfig), { PATH: stubs, HOME: bareHome });
expect(noScripts.missing.find(m => m.dimension === 'shell')?.reason).toBe('no *.sh file in the root, scripts/ or bin/');
expect(noScripts.missing.find(m => m.dimension === 'gbrain')?.reason).toBe('gbrain is not on PATH or ~/.gbrain/config.json is absent');
});
});
+29 -4
View File
@@ -8,11 +8,12 @@
* already-unmerged), all in prefix mode; the PowerShell forms run under
* powershell.exe in .github/workflows/windows-setup-e2e.yml.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { gitFixtureTemplate } from './helpers/git-fixture-template';
const ROOT = path.resolve(import.meta.dir, '..');
const DOC = fs.readFileSync(path.join(ROOT, 'docs', 'troubleshooting.md'), 'utf8');
@@ -37,8 +38,33 @@ const CONFLICT_LINE = 'THROTTLE_SECONDS=3600 # 1 hour';
* bin/gstack-ff-update (an install that predates it), with VERSION 1.91.101.0.
*/
function makeFixture(source = ROOT) {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-held-')));
if (source !== ROOT) {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-held-')));
bases.push(base);
const { src, ...fx } = buildFixture(base, source);
expect(src).toContain(CONFLICT_LINE);
return { base, ...fx };
}
const { root: base, meta: { src, ...fx } } = rootTemplate.copy();
bases.push(base);
expect(src).toContain(CONFLICT_LINE);
return { base, origin: path.join(base, 'origin.git'), ...fx };
}
/** makeFixture's origin.git for this checkout, built once per file; the per-test source checkouts build their own. */
const rootTemplate = gitFixtureTemplate({
prefix: 'gstack-held-',
realpath: true,
build: (base) => {
const { origin, ...meta } = buildFixture(base, ROOT);
fs.rmSync(path.join(base, 'work'), { recursive: true, force: true });
return meta;
},
rebind: () => {},
});
afterAll(() => rootTemplate.dispose());
function buildFixture(base: string, source: string) {
const origin = path.join(base, 'origin.git');
const work = path.join(base, 'work');
execFileSync('git', ['clone', '-q', '--shared', source, work], { timeout: 60_000 });
@@ -53,7 +79,6 @@ function makeFixture(source = ROOT) {
const held = git(work, 'rev-parse', 'HEAD');
fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-ff-update'), path.join(work, 'bin', 'gstack-ff-update'));
const src = fs.readFileSync(path.join(work, EDITED), 'utf8');
expect(src).toContain(CONFLICT_LINE);
fs.writeFileSync(path.join(work, EDITED), src.replace(CONFLICT_LINE, 'THROTTLE_SECONDS=3600 # 1 hour (upstream)'));
fs.writeFileSync(path.join(work, 'VERSION'), '1.91.102.0\n');
git(work, 'add', '-A');
@@ -61,7 +86,7 @@ function makeFixture(source = ROOT) {
const fixed = git(work, 'rev-parse', 'HEAD');
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 60_000 });
git(work, 'push', '-q', origin, 'HEAD:refs/heads/main');
return { base, origin, held, fixed };
return { origin, held, fixed, src };
}
type Fx = ReturnType<typeof makeFixture>;
+1
View File
@@ -150,6 +150,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/factory-scorecard/**': ['test/factory-scorecard.test.ts'],
'test/fixtures/free-shard-capture.ts': ['test/test-free-shards-capture.test.ts'],
'test/fixtures/golden/**': ['test/host-config.test.ts', 'test/ship-review-loop.test.ts'],
'test/fixtures/health-detect-legacy.sh': ['test/health-detect-no-loss.test.ts'],
'test/fixtures/impeccable-antipatterns.json': ['test/design-catalog.test.ts', 'test/impeccable-fixtures.test.ts'],
'test/fixtures/impeccable-captures.meta.json': ['test/design-detect-contract.test.ts', 'test/impeccable-fixtures.test.ts'],
'test/fixtures/impeccable-detect-dom-sample.json': ['test/impeccable-fixtures.test.ts'],
+105
View File
@@ -0,0 +1,105 @@
/**
* Build a git fixture once per test file and hand each test its own copy.
*
* build(root) runs once, on the first copy(), into a template directory under
* os.tmpdir(). Every copy() makes a fresh sibling directory, copies the
* template into it with fs.cpSync (symlinks kept verbatim) and calls
* rebind(copyRoot, templateRoot), which must rewrite every path-bearing file
* the fixture wrote: remote URLs and pushurls in .git/config, reflog messages,
* FETCH_HEAD, objects/info/alternates, state roots, install records. The copy
* is then scanned for any remaining reference to the template root (native,
* forward-slash and backslash-escaped spellings, plus the realpath and Git
* Bash forms where they differ); a hit throws, so a test can never push into,
* fetch from or read state out of the shared template. dispose() removes the
* template; call it from afterAll.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
export interface GitFixtureCopy<T> { root: string; meta: T }
export interface GitFixtureTemplate<T> { copy: (prefix?: string) => GitFixtureCopy<T>; dispose: () => void }
/** The spellings a path takes inside files git and gstack write, escaped form first so a rewrite never splits it. */
export function pathSpellings(root: string): string[] {
const forms = (p: string) => {
const forward = p.replace(/\\/g, '/');
const drive = /^([A-Za-z]):\//.exec(forward);
return [p.replace(/\\/g, '\\\\'), p, forward, ...(drive ? ['/' + drive[1]!.toLowerCase() + '/' + forward.slice(3)] : [])];
};
let real = root;
try { real = fs.realpathSync.native(root); } catch {}
return [...new Set([...forms(root), ...forms(real)])];
}
/** Rewrite every spelling of from to the same spelling of to in a text file, when the file exists. */
export function rebindFile(file: string, from: string, to: string): void {
if (!fs.existsSync(file)) return;
const targets = pathSpellings(to);
let text = fs.readFileSync(file, 'utf8');
const before = text;
pathSpellings(from).forEach((spelling, i) => { text = text.split(spelling).join(targets[i] ?? to); });
if (text !== before) fs.writeFileSync(file, text);
}
/** Rebind the path-bearing files git writes in a git directory: config, FETCH_HEAD, alternates and every reflog. */
export function rebindGitDir(gitDir: string, from: string, to: string): void {
for (const rel of ['config', 'FETCH_HEAD', path.join('objects', 'info', 'alternates')]) rebindFile(path.join(gitDir, rel), from, to);
const logs = path.join(gitDir, 'logs');
if (!fs.existsSync(logs)) return;
for (const entry of fs.readdirSync(logs, { recursive: true, withFileTypes: true })) {
if (entry.isFile()) rebindFile(path.join(entry.parentPath, entry.name), from, to);
}
}
/** Files and symlinks under dir (and the template's meta value) that still name templateRoot; zlib-compressed object storage is skipped. */
export function templateReferences(dir: string, templateRoot: string, meta?: unknown): string[] {
const needles = pathSpellings(templateRoot).map(s => Buffer.from(s));
const names = (buf: Buffer) => needles.some(n => buf.includes(n));
const hits: string[] = [];
const walk = (d: string) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isSymbolicLink()) { if (names(Buffer.from(fs.readlinkSync(p)))) hits.push(p); }
else if (entry.isDirectory()) { if (!(path.basename(d) === 'objects' && /^(pack|[0-9a-f]{2})$/.test(entry.name))) walk(p); }
else if (entry.isFile() && names(fs.readFileSync(p))) hits.push(p);
}
};
walk(dir);
if (meta !== undefined && names(Buffer.from(JSON.stringify(meta)))) hits.push('(template meta)');
return hits;
}
export function gitFixtureTemplate<T>(opts: {
prefix: string;
build: (root: string) => T;
rebind: (copyRoot: string, templateRoot: string) => void;
realpath?: boolean;
}): GitFixtureTemplate<T> {
let template: GitFixtureCopy<T> | null = null;
const mkroot = (prefix: string) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
return opts.realpath ? fs.realpathSync(dir) : dir;
};
return {
copy(prefix = opts.prefix) {
if (!template) {
const root = mkroot(opts.prefix + 'tpl-');
template = { root, meta: opts.build(root) };
}
const { root: from, meta } = template;
const root = mkroot(prefix);
if (pathSpellings(root).some(s => pathSpellings(from).some(t => s.startsWith(t)))) throw new Error('git fixture copy ' + root + ' lies under its template ' + from);
fs.cpSync(from, root, { recursive: true, verbatimSymlinks: true });
opts.rebind(root, from);
const leaks = templateReferences(root, from, meta);
if (leaks.length) fs.rmSync(root, { recursive: true, force: true });
if (leaks.length) throw new Error('git fixture copy still references its template ' + from + ' (rebind missed them):\n' + leaks.join('\n'));
return { root, meta };
},
dispose() {
if (template) fs.rmSync(template.root, { recursive: true, force: true });
template = null;
},
};
}
+31 -5
View File
@@ -14,6 +14,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runScriptSync } from '../../lib/script-invocation';
import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './git-fixture-template';
import { gitArgvIn } from './scratch-repo';
export const ROOT = path.resolve(import.meta.dir, '..', '..');
@@ -80,20 +81,46 @@ jobs:
run: node --test \${{ matrix.files }}
`;
export function makeFixtureRepo(opts: { registry?: Record<string, unknown> | null; pregate?: Record<string, unknown> | null; prefix?: string; baseFiles?: Record<string, string> } = {}): FixtureRepo {
export type FixtureOptions = { registry?: Record<string, unknown> | null; pregate?: Record<string, unknown> | null; prefix?: string; baseFiles?: Record<string, string> };
export function makeFixtureRepo(opts: FixtureOptions = {}): FixtureRepo {
const root = fs.mkdtempSync(path.join(os.tmpdir(), opts.prefix ?? 'gstack-pregate-fx-'));
seedFixtureRepo(root, opts);
return fixtureRepoAt(root);
}
/** A template of makeFixtureRepo(opts), built once and copied per test (helpers/git-fixture-template.ts). */
export function fixtureTemplate(opts: FixtureOptions = {}): GitFixtureTemplate<null> {
return gitFixtureTemplate({
prefix: opts.prefix ?? 'gstack-pregate-fx-',
build: root => { seedFixtureRepo(root, opts); return null; },
rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy),
});
}
/** A fresh copy of a fixtureTemplate as its own FixtureRepo (cleanup() removes it like any other). */
export function cloneFixture(template: GitFixtureTemplate<unknown>, prefix?: string): FixtureRepo {
return fixtureRepoAt(template.copy(prefix).root);
}
function fixtureRepoAt(root: string): FixtureRepo {
const origin = path.join(root, 'origin.git');
const work = path.join(root, 'work');
const home = path.join(root, 'home');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(work, { recursive: true });
const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
const git = (...args: string[]): string => {
const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: status=${r.status} signal=${r.signal} error=${r.error?.message ?? ''} stderr=${r.stderr?.toString() ?? ''}`);
return r.stdout?.toString().trim() ?? '';
};
return { root, work, origin, home, git, env };
}
function seedFixtureRepo(root: string, opts: FixtureOptions): void {
const { origin, work, home, env, git } = fixtureRepoAt(root);
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(work, { recursive: true });
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
spawnSync('git', ['init', '-q', '--bare', origin], { timeout: 30_000, env: { ...process.env, ...env } });
git('init', '-q', '-b', 'main');
for (const dir of ['src', 'lib', 'bin', 'test', 'test/golden', 'test/nightly', 'generated', 'docs', 'scripts', '.gstack', '.github/workflows']) fs.mkdirSync(path.join(work, dir), { recursive: true });
@@ -130,7 +157,6 @@ export function makeFixtureRepo(opts: { registry?: Record<string, unknown> | nul
git('push', '-q', 'origin', 'main');
git('remote', 'set-head', 'origin', 'main');
git('checkout', '-q', '-b', 'feature');
return { root, work, origin, home, git, env };
}
export function write(work: string, rel: string, text: string): void {
+35 -5
View File
@@ -11,6 +11,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './git-fixture-template';
import { gitArgvIn } from './scratch-repo';
import { mergeChangelogs } from '../../lib/changelog-check';
import { runScriptSync } from '../../lib/script-invocation';
@@ -32,21 +33,51 @@ export const BASE_POLICY = {
stamp_sensitive_gates: ['digest'],
};
export function makeFixtureRepo(opts: { policy?: Record<string, unknown> | null; version?: string; prefix?: string } = {}): FixtureRepo {
export type FixtureOptions = { policy?: Record<string, unknown> | null; version?: string; prefix?: string };
export function makeFixtureRepo(opts: FixtureOptions = {}): FixtureRepo {
const root = fs.mkdtempSync(path.join(os.tmpdir(), opts.prefix ?? 'gstack-restamp-fx-'));
seedFixtureRepo(root, opts);
return fixtureRepoAt(root);
}
/** The FixtureRepo handle for a root seedFixtureRepo filled, directly or through a template copy. */
export function fixtureRepoAt(root: string): FixtureRepo {
const origin = path.join(root, 'origin.git');
const work = path.join(root, 'work');
const home = path.join(root, 'home');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(work, { recursive: true });
const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
const git = (...args: string[]): string => {
const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr?.toString()}`);
return r.stdout?.toString().trim() ?? '';
};
return { root, work, origin, home, git, env };
}
/** A template of makeFixtureRepo(opts), built once and copied per test (helpers/git-fixture-template.ts). */
export function fixtureTemplate(opts: FixtureOptions = {}): GitFixtureTemplate<null> {
return gitFixtureTemplate({
prefix: opts.prefix ?? 'gstack-restamp-fx-',
build: root => { seedFixtureRepo(root, opts); return null; },
rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy),
});
}
/** A fresh copy of a fixtureTemplate as its own FixtureRepo (cleanup() removes it like any other). */
export function cloneFixture(template: GitFixtureTemplate<unknown>, prefix?: string): FixtureRepo {
return fixtureRepoAt(template.copy(prefix).root);
}
/** makeFixtureRepo's repository, written into an existing root. */
export function seedFixtureRepo(root: string, opts: FixtureOptions): void {
const { origin, work, home, env, git } = fixtureRepoAt(root);
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(work, { recursive: true });
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
spawnSync('git', ['init', '-q', '--bare', origin], { timeout: 30_000, env: { ...process.env, ...env } });
git('--git-dir', origin, 'config', 'maintenance.auto', 'false');
git('--git-dir', origin, 'config', 'gc.auto', '0');
git('init', '-q', '-b', 'main');
const version = opts.version ?? '1.2.3.0';
const npm = version.split('.').slice(0, 3).join('.');
@@ -66,7 +97,6 @@ export function makeFixtureRepo(opts: { policy?: Record<string, unknown> | null;
git('remote', 'add', 'origin', origin);
git('push', '-q', 'origin', 'main');
git('remote', 'set-head', 'origin', 'main');
return { root, work, origin, home, git, env };
}
/** A PR branch from origin/main with an `[Unreleased]` CHANGELOG entry and a doc mentioning the version it expects. */
+1 -1
View File
@@ -62,7 +62,7 @@ describe('every installed-skill render carries the plan-mode arg', () => {
});
describe('gstack-relink router re-render', () => {
const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-state-root.sh', 'gstack-install-registry.sh'];
const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-skill-name.sh', 'gstack-state-root.sh', 'gstack-install-registry.sh'];
const SAFE = '## Plan Mode Safe Operations';
function relinkFixture() {
-22
View File
@@ -29,9 +29,7 @@ import {
} from '../scripts/one-way-doors';
import {
SIGNAL_MAP,
applySignal,
validateRegistrySignalKeys,
newDimensionTotals,
normalizeToDimensionValue,
resolveOptionKey,
ALL_DIMENSIONS,
@@ -246,26 +244,6 @@ describe('psychographic signal map', () => {
expect(missing).toEqual([]);
});
test('applySignal mutates dimension totals per mapping', () => {
const dims = newDimensionTotals();
const applied = applySignal(dims, 'scope-appetite', 'expand');
expect(applied.length).toBeGreaterThan(0);
expect(dims.scope_appetite).toBeCloseTo(0.06, 5);
});
test('applySignal returns [] for unknown signal_key', () => {
const dims = newDimensionTotals();
const applied = applySignal(dims, 'no-such-signal', 'anything');
expect(applied).toEqual([]);
expect(dims.scope_appetite).toBe(0);
});
test('applySignal returns [] for unknown user_choice', () => {
const dims = newDimensionTotals();
const applied = applySignal(dims, 'scope-appetite', 'definitely-not-a-real-choice');
expect(applied).toEqual([]);
});
test('normalizeToDimensionValue maps 0 → 0.5 (neutral)', () => {
expect(normalizeToDimensionValue(0)).toBeCloseTo(0.5, 5);
});
+4 -2
View File
@@ -226,9 +226,11 @@ describe('gstack-pregate: the test stage never hangs silently', () => {
expect(pregate(during, []).status).toBe(EXIT.fail);
expect(lanes(during).detail).toContain('working tree changed during the check');
const after = makeFixtureRepo(); repos.push(after);
commit(after, { 'test/leftover.test.js': "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('leaves a child', () => { spawn('sh', ['-c', 'sleep 2; echo changed >> README.md'], { stdio: 'ignore' }).unref(); });\n" });
commit(after, { 'test/leftover.test.js': "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('leaves a child', () => { const c = spawn('sh', ['-c', 'sleep 2; echo changed >> README.md'], { stdio: 'ignore' }); require('node:fs').writeFileSync(require('node:path').join(__dirname, '..', '..', 'leftover.pid'), String(c.pid)); c.unref(); });\n" });
expect(pregate(after, []).status).toBe(EXIT.ok);
await Bun.sleep(2500);
const until = Date.now() + 3000;
const leftover = await pidFrom(path.join(after.root, 'leftover.pid'), 3000).catch(() => 0);
if (leftover) await goneSoon(leftover, Math.max(0, until - Date.now()));
expect(fs.readFileSync(path.join(after.work, 'README.md'), 'utf8')).toBe('# fixture\n');
}, 90_000);
+5 -3
View File
@@ -6,7 +6,7 @@
* requires-remote. Every assertion holds on any host platform: each lane the
* fixture selects is either named in `local` or asserted only by label.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
@@ -15,18 +15,20 @@ import { EXIT } from '../lib/headless-artifacts';
import { loadConfigFromUser, validateConfig } from '../lib/pregate/config';
import { buildContext, sha256, userConfigRejection } from '../lib/pregate/context';
import { runPregate, verifyPregate } from '../lib/pregate/index';
import { BASE_PREGATE, BIN, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
import { BASE_PREGATE, BIN, cleanup, cloneFixture, commit, fixtureTemplate, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
import { runScriptSync } from '../lib/script-invocation';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const noBasePolicy = fixtureTemplate({ pregate: null });
afterAll(() => noBasePolicy.dispose());
const SLUG = 'userpolicy';
const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard'];
/** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */
function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType<typeof runBin> } {
const repo = makeFixtureRepo({ pregate: null });
const repo = cloneFixture(noBasePolicy);
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
+2 -1
View File
@@ -570,7 +570,8 @@ describe('installed QA pointers', () => {
'set -e', `IS_WINDOWS=${copy ? 1 : 0}`, `SKILL_PREFIX=${prefix}`, 'QUIET=1',
'_FOREIGN_SKIPPED_ENTRIES=()', '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/backups"',
'GSTACK_USER_RENDER_DIR="$HOME/absent-render"',
'GSTACK_STATE_ROOT="$HOME/.gstack"', '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"', helpers,
'GSTACK_STATE_ROOT="$HOME/.gstack"', '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"',
`. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`, helpers,
'log() { :; }', '_browser_hint() { :; }', 'bun_cmd() { :; }', install,
].join('\n');
const runInstall = () => runBashScript(script, {
-39
View File
@@ -344,42 +344,3 @@ describe('judge-named QA workflow gaps (C2)', () => {
}
});
});
// Lane S C2: gaps the qa-only/review/ship workflow judges named in their rationales.
describe('judge-named QA workflow gaps (C2)', () => {
const ctx = (skillName: string) => ({ host: 'claude' as const, skillName, tmplPath: '', paths: HOST_PATHS.claude });
test('Browser Quick leaves time for its six page probes, and the method states the same budget', () => {
for (const skillName of ['qa', 'qa-only']) {
const seconds = Number(/Browser Quick: SECONDS=(\d+)\./.exec(generateQAExploratory(ctx(skillName)))?.[1]);
// homepage + top 5 targets, each a checkpoint write plus one guarded command: 30 s left ~5 s per probe.
expect(seconds / 6).toBeGreaterThanOrEqual(20);
const patterns = fs.readFileSync(path.join(import.meta.dir, '..', 'qa', 'sections', 'qa-patterns.md'), 'utf8');
expect(patterns).toContain(`${seconds / 60} minutes: homepage + top 5 navigation targets`);
}
});
test('PROBE_DIR names its source line, and stopping goes to the final report steps', () => {
for (const skillName of ['qa', 'qa-only']) {
const text = generateQAExploratory(ctx(skillName));
const layout = text.indexOf('mixed standalone runs use REPORT_DIR/browser and REPORT_DIR/functional');
const probeDir = text.indexOf("PROBE_DIR: this surface's owned probe directory per the line above.");
expect(layout).toBeGreaterThan(-1);
expect(probeDir).toBeGreaterThan(layout);
expect(text.slice(layout, probeDir).split('\n')).toHaveLength(3);
expect(text).toContain('write the report (§4), not a checkpoint.');
const final = text.indexOf('## 4. Final report');
expect(final).toBeGreaterThan(-1);
expect(text.indexOf('annotations.json', final)).toBeLessThan(text.indexOf('materialize PROBE_DIR', final));
}
});
test('/review and /ship: smoke rechecks after smoke expiry are not-run, before step 4c revalidation', () => {
for (const skillName of ['review', 'ship']) {
const text = generateQAReview(ctx(skillName));
const rule = text.indexOf('Post-expiry smoke rechecks are not-run.');
expect(rule).toBeGreaterThan(-1);
expect(rule).toBeLessThan(text.indexOf('c. Re-review changed or uncertain coverage and repeat step 3 for affected checks.'));
}
});
});
+39 -20
View File
@@ -6,12 +6,12 @@
* check run or a red one keeps a commit out; a cut that stopped half-way is
* finished by the next run (docs/releases.md).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import { execFileSync, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { gitFixtureTemplate, rebindGitDir } from './helpers/git-fixture-template';
import {
BACKSTOP_CHECK, FLOOR_HOURS, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, SCHEDULED_GRACE_HOURS, changelogEntries, compareVersions, cutRelease, describePlan, floorStatus,
gateVerdict, gitIn, planRelease, readTrainNotes, releaseNotes, sanitizeReason, stableMovedAt, verifyRegression, verifySecurity, type Gh, type Git,
@@ -55,19 +55,10 @@ function fakeGh(state: State): Gh {
};
}
function fixture() {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-cut-'));
bases.push(base);
const origin = path.join(base, 'origin.git');
const seed = path.join(base, 'seed');
const work = path.join(base, 'work');
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
fs.mkdirSync(seed);
git(seed, 'init', '-q', '-b', 'main');
git(seed, 'remote', 'add', 'origin', origin);
const shas: Record<string, string> = {};
let changelog = '# Changelog\n';
const release = (version: string, headline: string, o: { message?: string; files?: Record<string, string> } = {}) => {
/** Commits a release (VERSION, a CHANGELOG headline, optional files) in seed and pushes it to origin. */
function releaser(seed: string, shas: Record<string, string>) {
let changelog = fs.existsSync(path.join(seed, 'CHANGELOG.md')) ? fs.readFileSync(path.join(seed, 'CHANGELOG.md'), 'utf8') : '# Changelog\n';
return (version: string, headline: string, o: { message?: string; files?: Record<string, string> } = {}) => {
changelog = changelog.replace('# Changelog\n', '# Changelog\n\n## [' + version + '] - 2026-10-2' + version.slice(-1) + '\n\n**' + headline + '**\n\n### Fixed\n- a thing\n');
fs.writeFileSync(path.join(seed, 'VERSION'), version + '\n');
fs.writeFileSync(path.join(seed, 'CHANGELOG.md'), changelog);
@@ -78,11 +69,39 @@ function fixture() {
shas[version] = git(seed, 'rev-parse', 'HEAD');
return shas[version]!;
};
release('1.0.0', 'First one.');
release('1.0.1', 'Second: the updater no longer skips.');
release('1.0.2', 'Third fixes Windows.');
release('1.0.3', 'Fourth trims the preamble.');
execFileSync('git', ['clone', '-q', origin, work], { timeout: 30_000 });
}
/** origin with releases 1.0.0..1.0.3 pushed from seed, and work cloned from it; built once for this file, copied per test. */
const template = gitFixtureTemplate({
prefix: 'gstack-release-cut-',
build: (base) => {
const origin = path.join(base, 'origin.git');
const seed = path.join(base, 'seed');
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
fs.mkdirSync(seed);
git(seed, 'init', '-q', '-b', 'main');
git(seed, 'remote', 'add', 'origin', origin);
const shas: Record<string, string> = {};
const release = releaser(seed, shas);
release('1.0.0', 'First one.');
release('1.0.1', 'Second: the updater no longer skips.');
release('1.0.2', 'Third fixes Windows.');
release('1.0.3', 'Fourth trims the preamble.');
execFileSync('git', ['clone', '-q', origin, path.join(base, 'work')], { timeout: 30_000 });
return shas;
},
rebind: (copy, tpl) => { for (const repo of ['seed', 'work']) rebindGitDir(path.join(copy, repo, '.git'), tpl, copy); },
});
afterAll(() => template.dispose());
function fixture() {
const { root: base, meta } = template.copy();
bases.push(base);
const origin = path.join(base, 'origin.git');
const seed = path.join(base, 'seed');
const work = path.join(base, 'work');
const shas: Record<string, string> = { ...meta };
const release = releaser(seed, shas);
const refresh = () => { git(work, 'fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); git(work, 'fetch', '-q', '--tags', 'origin'); };
const state: State = { checks: {}, released: new Set(), calls: [] };
return { base, origin, seed, work, shas, release, refresh, state, gh: fakeGh(state), git: gitIn(work), repo: 'o/r' };
+1 -1
View File
@@ -12,7 +12,7 @@ import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-state-root.sh', 'gstack-install-registry.sh'];
const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-skill-name.sh', 'gstack-state-root.sh', 'gstack-install-registry.sh'];
let tmp: string;
let install: string;
+1
View File
@@ -58,6 +58,7 @@ function setupMockInstall(skills: string[]): void {
fs.chmodSync(path.join(mockBin, 'gstack-config'), 0o755);
// A real install always ships the state-root twin beside the bins that source it.
fs.copyFileSync(path.join(BIN, 'gstack-state-root.sh'), path.join(mockBin, 'gstack-state-root.sh'));
fs.copyFileSync(path.join(BIN, 'gstack-skill-name.sh'), path.join(mockBin, 'gstack-skill-name.sh'));
if (fs.existsSync(path.join(BIN, 'gstack-relink'))) {
fs.copyFileSync(path.join(BIN, 'gstack-relink'), path.join(mockBin, 'gstack-relink'));
fs.chmodSync(path.join(mockBin, 'gstack-relink'), 0o755);
+2 -2
View File
@@ -77,7 +77,7 @@ describe('installed skills serve rendered sections (F5, #2706)', () => {
test('gstack-relink (the gbrain-refresh path) serves the render, and the checkout again once the render is gone', () => {
const fx = fixture();
fs.mkdirSync(path.join(fx.install, 'bin'), { recursive: true });
for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names']) {
for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names', 'gstack-skill-name.sh']) {
fs.copyFileSync(path.join(BIN, f), path.join(fx.install, 'bin', f));
fs.chmodSync(path.join(fx.install, 'bin', f), 0o755);
}
@@ -106,7 +106,7 @@ describe('installed skills serve rendered sections (F5, #2706)', () => {
test('a sections/ directory the user owns is left alone by relink', () => {
const fx = fixture();
fs.mkdirSync(path.join(fx.install, 'bin'), { recursive: true });
for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names']) {
for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names', 'gstack-skill-name.sh']) {
fs.copyFileSync(path.join(BIN, f), path.join(fx.install, 'bin', f));
fs.chmodSync(path.join(fx.install, 'bin', f), 0o755);
}
+12 -2
View File
@@ -9,16 +9,26 @@
* version moves only on lines the branch added), and the negative control
* (a base-side quoted row carrying that version stays).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
import type { GitFixtureTemplate } from './helpers/git-fixture-template';
import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
/** The helper's makeFixtureRepo, built once per policy for this file and copied per test. */
const templates = new Map<string, GitFixtureTemplate<null>>();
afterAll(() => { for (const t of templates.values()) t.dispose(); });
function makeFixtureRepo(opts: { prefix: string; policy: Record<string, unknown> }): FixtureRepo {
const key = JSON.stringify(opts.policy);
if (!templates.has(key)) templates.set(key, fixtureTemplate({ policy: opts.policy, prefix: 'restamp-mentions-' }));
return cloneFixture(templates.get(key)!, opts.prefix);
}
const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out);
describe('quoted stamp forms (pure)', () => {
+1
View File
@@ -80,6 +80,7 @@ beforeAll(() => {
extractFn('_write_owned_marker'),
extractFn('_print_windows_copy_note_once'),
extractFn('_link_skill_runtime_assets'),
`. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`,
extractFn('link_claude_skill_dirs'),
extractFn('_install_alias_skill_md'),
extractFn('link_claude_root_skill_alias'),
+1 -1
View File
@@ -31,7 +31,7 @@ function fixture(copy: boolean) {
put(path.join(root, 'VERSION'), '2.0.0.0\n');
put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n');
put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } }));
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-models', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-path-spelling.sh', 'gstack-claude-root.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh', 'gstack-host-renders.sh']) {
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-models', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-path-spelling.sh', 'gstack-skill-name.sh', 'gstack-claude-root.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh', 'gstack-host-renders.sh']) {
const target = path.join(root, 'bin', bin);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'bin', bin), target);
+1
View File
@@ -82,6 +82,7 @@ beforeAll(() => {
'_FOREIGN_SKIPPED_ENTRIES=()',
'_BACKED_UP_SKILL_MDS=()',
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
`. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`,
extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${ROOT}" "${installDir}"`,
].join('\n');
+150
View File
@@ -0,0 +1,150 @@
/**
* #2120: on Windows on ARM64, x64-emulated Git Bash intermittently deadlocks
* in MSYS2's fork() emulation, and ./setup hung in the per-skill loops that
* read each SKILL.md name with a grep | sed | tr pipeline plus a basename
* (about four process starts per skill, ~55 skills a run). The deadlock
* itself needs that host; these cases pin the cause they can see anywhere:
* the loops start none of those processes per skill. Every grep, sed, tr and
* basename the code under test starts goes through a logging shim on PATH.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runBashScript } from './helpers/bash-script';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const HELPER = path.join(ROOT, 'bin/gstack-skill-name.sh');
const SHIMMED = ['grep', 'sed', 'tr', 'basename'];
const SKILLS = ['qa', 'review', 'ship', 'office-hours', 'investigate', 'land-and-deploy', 'retro', 'cso'];
function extractFn(name: string): string {
const start = SETUP_SRC.indexOf(`\n${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`function not found: ${name}`);
return SETUP_SRC.slice(start + 1, end + 2);
}
/** A temp tree, a PATH whose grep/sed/tr/basename log each start to calls.log, and a reader for that log. */
function sandbox() {
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fork-free-')));
const shim = path.join(tmp, 'shim');
const log = path.join(tmp, 'calls.log');
fs.mkdirSync(shim);
for (const name of SHIMMED) {
const real = spawnSync('bash', ['-c', `type -P ${name}`], { encoding: 'utf8', timeout: 10_000 }).stdout.trim();
fs.writeFileSync(path.join(shim, name), `#!/bin/sh\necho ${name} >> "${log}"\nexec "${real}" "$@"\n`, { mode: 0o755 });
}
return {
tmp, env: { PATH: `${shim}${path.delimiter}${process.env.PATH ?? ''}`, HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') },
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').split('\n').filter(Boolean) : [],
reset: () => fs.rmSync(log, { force: true }),
cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }),
};
}
const skillMd = (name: string, eol = '\n') => ['---', `name: ${name}`, 'description: fixture', '---', `# ${name}`, ''].join(eol);
/** A payload of SKILLS (qa in CRLF, office-hours named "oh"), then "zz-nameless" with no name: line after them. */
function payload(dir: string) {
for (const name of SKILLS) {
fs.mkdirSync(path.join(dir, name), { recursive: true });
fs.writeFileSync(path.join(dir, name, 'SKILL.md'), skillMd(name === 'office-hours' ? 'oh' : name, name === 'qa' ? '\r\n' : '\n'));
}
fs.mkdirSync(path.join(dir, 'zz-nameless'), { recursive: true });
fs.writeFileSync(path.join(dir, 'zz-nameless', 'SKILL.md'), '---\ndescription: no name\n---\n# nameless\n');
}
describe.skipIf(process.platform === 'win32')('setup name reads start no process per skill (#2120)', () => {
// Value: protects=the helper returns what the old grep -m1 | sed | tr pipeline returned, and never a stale name; fails_when=it keeps the
// previous value, reads a non-column-0 name, keeps a CR or space, or returns non-zero; why_new=the helper is new; seam=none
test('gstack_skill_md_name matches the old pipeline on odd inputs, resets between files and always returns 0', () => {
const s = sandbox();
try {
const cases: Record<string, string> = {
plain: '---\nname: qa\n---\n', crlf: '---\r\nname: qa\r\n---\r\n', spaced: 'name: my skill \t\n', noeol: 'name: last',
indented: ' name: no\nname: yes\n', nameless: '---\ndescription: x\n---\n', empty: '', later: '# t\nbody\nname: deep\nname: second\n',
};
const files = Object.entries(cases).map(([k, v]) => { const f = path.join(s.tmp, k); fs.writeFileSync(f, v); return f; });
files.push(path.join(s.tmp, 'missing'));
const old = (f: string) => spawnSync('bash', ['-c', "grep -m1 '^name:' \"$1\" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]'", '_', f], { encoding: 'utf8', timeout: 10_000 }).stdout;
const script = [`set -euo pipefail; . "${HELPER}"; GSTACK_SKILL_NAME=stale`,
...files.map(f => `gstack_skill_md_name "${f}"; printf '%s\n' "[$GSTACK_SKILL_NAME]"`)].join('\n');
const r = runBashScript(script, { timeout: 10_000, env: s.env });
expect(r.status, r.stderr ?? '').toBe(0);
expect(r.stdout.trim().split('\n')).toEqual(files.map(f => `[${old(f)}]`));
expect(s.calls()).toEqual([]);
} finally {
s.cleanup();
}
});
// Value: protects=no caller re-adds a fork per skill by wrapping the helper in $( ), and the helper itself stays fork-free;
// fails_when=a call site uses $(gstack_skill_md_name ...) or the helper body gains $(, a pipe or a backquote; why_new=the shims cannot see a subshell fork; seam=none
test('the helper starts no subshell and no caller wraps it in $( )', () => {
const body = fs.readFileSync(HELPER, 'utf8').split('\n').filter(l => !/^\s*#/.test(l)).join('\n');
expect(body).not.toMatch(/\$\(|`|(^|[^|])\|([^|]|$)/m);
for (const rel of ['setup', 'bin/gstack-patch-names', 'bin/gstack-uninstall']) {
expect(fs.readFileSync(path.join(ROOT, rel), 'utf8'), rel).not.toMatch(/\$\(\s*gstack_skill_md_name/);
}
});
// Value: protects=gstack-patch-names reads every name without a per-skill grep|sed|tr|basename (the first loop #2120 hung in);
// fails_when=the loop goes back to a process pipeline per SKILL.md; why_new=no case counted process starts; seam=PATH shims
test('gstack-patch-names: no process per skill when nothing needs a rename; a rename starts only its own sed', () => {
const s = sandbox();
try {
const dir = path.join(s.tmp, 'gstack');
payload(dir);
const off = spawnSync('bash', [path.join(ROOT, 'bin/gstack-patch-names'), dir, '0'], { encoding: 'utf8', env: s.env, timeout: 30_000 });
expect(off.status, off.stderr).toBe(0);
expect(off.stdout).toBe('');
expect(s.calls()).toEqual([]);
const on = spawnSync('bash', [path.join(ROOT, 'bin/gstack-patch-names'), dir, '1'], { encoding: 'utf8', env: s.env, timeout: 30_000 });
expect(on.status, on.stderr).toBe(0);
expect(on.stdout).toContain(`patched name: field in ${SKILLS.length} skills`);
for (const name of SKILLS) expect(fs.readFileSync(path.join(dir, name, 'SKILL.md'), 'utf8')).toContain(`name: gstack-${name === 'office-hours' ? 'oh' : name}`);
expect(fs.readFileSync(path.join(dir, 'zz-nameless', 'SKILL.md'), 'utf8')).not.toContain('name:');
expect(s.calls()).toEqual(Array(SKILLS.length).fill('sed'));
} finally {
s.cleanup();
}
});
// Value: protects=link_claude_skill_dirs and both cleanup loops run with no per-skill grep, sed, tr or basename, and a nameless SKILL.md links
// under its directory name, never the previous skill's name (#2120); fails_when=a loop goes back to a pipeline or basename, or the name leaks;
// why_new=link tests checked links, not process starts; seam=PATH shims
test('link_claude_skill_dirs and the two cleanup loops: no per-skill grep, sed, tr or basename; names come from frontmatter', () => {
const s = sandbox();
try {
const pl = path.join(s.tmp, 'skills', 'gstack');
payload(pl);
const script = [
'set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', '_FOREIGN_SKIPPED_ENTRIES=()', '_BACKED_UP_SKILL_MDS=()',
'_link_skill_runtime_assets() { :; }', '_print_windows_copy_note_once() { :; }',
...['_link_or_copy', '_gstack_link_target_abs', '_gstack_target_is_ours', '_claude_entry_is_ours', '_write_owned_marker',
'_gstack_generated_header', '_claude_entry_owned_strongly', '_backup_skill_md', '_cleanup_weak_dir', '_gstack_dir_only_links',
'_cleanup_linked_dir'].map(extractFn),
`. "${HELPER}"`,
...['link_claude_skill_dirs', 'cleanup_old_claude_symlinks', 'cleanup_prefixed_claude_symlinks'].map(extractFn),
`cleanup_old_claude_symlinks "${pl}" "${path.dirname(pl)}"`,
`cleanup_prefixed_claude_symlinks "${pl}" "${path.dirname(pl)}"`,
`link_claude_skill_dirs "${pl}" "${path.dirname(pl)}"`,
].join('\n');
const r = runBashScript(script, { timeout: 30_000, env: s.env });
expect(r.stderr ?? '').not.toContain('command not found');
expect(r.status, r.stderr ?? '').toBe(0);
expect(r.stdout).toContain('linked skills:');
for (const name of [...SKILLS.map(n => n === 'office-hours' ? 'oh' : n), 'zz-nameless']) {
expect(fs.existsSync(path.join(s.tmp, 'skills', name, 'SKILL.md')), name).toBe(true);
}
expect(fs.existsSync(path.join(s.tmp, 'skills', 'office-hours'))).toBe(false);
expect(s.calls()).toEqual([]);
} finally {
s.cleanup();
}
});
});
+1
View File
@@ -28,6 +28,7 @@ const HELPERS = [
'_link_skill_runtime_assets() { :; }',
'_print_windows_copy_note_once() { :; }',
extractFn('_link_or_copy'),
`. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`,
extractFn('_gstack_link_target_abs'),
extractFn('_gstack_target_is_ours'),
extractFn('_claude_entry_is_ours'),
+13
View File
@@ -271,6 +271,18 @@ describe('setup: Chromium bootstrap block executes best-effort', () => {
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('killed');
}, 15_000);
test('_wait_with_deadline returns a quickly exiting child\'s status without waiting out a whole second', () => {
const t0 = Date.now();
const r = runBashScript([
extractFn('_kill_tree'), extractFn('_wait_with_deadline'),
'(sleep 0.05; exit 3) &',
'_wait_with_deadline $! 90 || echo "RC=$?"',
].join('\n'), { timeout: 10_000 });
expect(r.stdout).toBe('RC=3\n');
// gstack-timing-ok: a sub-second return is the contract; the old 1 s poll took 1,008 ms, this path takes ~0.2 s
expect(Date.now() - t0).toBeLessThan(500);
});
test('non-numeric timeout knob falls back to the default instead of erroring', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: 'soon' } });
expect(r.status).toBe(0);
@@ -653,6 +665,7 @@ function runLinker(opts: {
'_print_windows_copy_note_once() { :; }',
'_FOREIGN_SKIPPED_ENTRIES=()',
extractFn('_link_or_copy'),
`. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`,
extractFn('_gstack_link_target_abs'),
extractFn('_gstack_target_is_ours'),
extractFn('_claude_entry_is_ours'),
+1 -1
View File
@@ -455,7 +455,7 @@ describe('setup --team / --no-team / -q', () => {
try {
for (const rel of [
'setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names',
'bin/gstack-state-root.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-path-spelling.sh', 'bin/gstack-claude-root.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'bin/gstack-host-renders.sh',
'bin/gstack-state-root.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-path-spelling.sh', 'bin/gstack-skill-name.sh', 'bin/gstack-claude-root.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'bin/gstack-host-renders.sh',
'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts',
'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts',
'lib/claude-code-migration.ts', 'lib/model-policy.ts', 'lib/model-catalog.ts',
+1
View File
@@ -104,6 +104,7 @@ const BASELINE = [
'test/extension-pty-inject-invariant.test.ts',
'test/gbrain-source-worktree-advance.test.ts',
'test/gbrain-sync-voyage-code-3-integration.test.ts',
'test/git-fixture-template.test.ts', // GSTA-151: pins the copy isolation and leak scan of test/helpers/git-fixture-template.ts, which the templated git fixtures in release-cut, train-backstop, restamp-mentions, pregate-user-policy and held-install-recovery rely on
'test/gstack-gbrain-source-wireup.test.ts',
'test/gstack-home-module-scope.test.ts',
'test/gstack-upgrade-migration-v1_17_0_0.test.ts',
+18 -4
View File
@@ -17,7 +17,8 @@ import type { GhRunner } from '../scripts/recover-receipts';
import { runBackstop } from '../scripts/train-backstop';
import { gitIn, trainPrefixes, type TrainRecord } from '../scripts/train-gate';
import { E2E_TOUCHFILES } from './helpers/touchfiles-data';
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './helpers/git-fixture-template';
import { BASE_POLICY, cleanup, fixtureRepoAt, seedFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
import { TRAIN_BLOCK, TRAIN_POLICY } from './helpers/ship-queue-train-fixture';
const REPO = 'fixture/repo';
@@ -30,11 +31,24 @@ afterEach(() => { if (savedStateRoot === undefined) delete process.env.GSTACK_ST
interface Fixture { repo: FixtureRepo; main0: string; prs: string[]; train: string[]; record: TrainRecord }
/** Five PR branches from main and the train T1..T5 gstack-ship-queue would build from them (lib/ship-queue-train.ts buildChain). */
/** Five PR branches from main and the train T1..T5 gstack-ship-queue would build from them, built once per policy and copied per test. */
const templates = new Map<string, GitFixtureTemplate<Omit<Fixture, 'repo'>>>();
afterAll(() => { for (const t of templates.values()) t.dispose(); });
function trainFixture(policy: Record<string, unknown> = TRAIN_POLICY): Fixture {
const repo = makeFixtureRepo({ policy, prefix: 'train-backstop-fx-' });
const key = JSON.stringify(policy);
if (!templates.has(key)) templates.set(key, gitFixtureTemplate({ prefix: 'train-backstop-fx-', build: root => buildTrain(root, policy), rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy) }));
const { root, meta } = templates.get(key)!.copy();
const repo = fixtureRepoAt(root);
repos.push(repo);
process.env.GSTACK_STATE_ROOT = path.join(repo.root, 'state');
return { repo, ...structuredClone(meta) };
}
/** lib/ship-queue-train.ts buildChain's T1..T5 over five PR branches from main. */
function buildTrain(root: string, policy: Record<string, unknown>): Omit<Fixture, 'repo'> {
seedFixtureRepo(root, { policy });
const repo = fixtureRepoAt(root);
process.env.GSTACK_STATE_ROOT = path.join(repo.root, 'state');
const main0 = repo.git('rev-parse', 'HEAD');
const prs = [1, 2, 3, 4, 5].map(i => {
repo.git('checkout', '-q', '-B', `pr${i}`, main0);
@@ -56,7 +70,7 @@ function trainFixture(policy: Record<string, unknown> = TRAIN_POLICY): Fixture {
const record: TrainRecord = { schema: 1, kind: 'train-gate', nonce: TRAIN_NONCE, head_sha: parent, tree: repo.git('rev-parse', `${parent}^{tree}`), verdict: 'GREEN',
census: { complete: true, planned: { rule: 108, judge: 24 }, executed: { rule: 108, judge: 24 }, unbound: 0, partial_shards: 0, cancelled_shards: 0, killed_before_upload: 0 },
panels: [], run: { id: 900, attempt: 1, url: 'https://example.invalid/runs/900' }, prefixes };
return { repo, main0, prs, train, record };
return { main0, prs, train, record };
}
/** The coordinator's squash merge of PR i onto main, stamped at merge (VERSION, package.json, the release output). */
+1
View File
@@ -190,6 +190,7 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => {
'_FOREIGN_SKIPPED_ENTRIES=()',
'_BACKED_UP_SKILL_MDS=()',
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
`. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`,
extractFn(SETUP_SRC, 'link_claude_skill_dirs'),
`link_claude_skill_dirs "${src}" "${skills}"`,
].join('\n');