mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 11:10:13 +01:00
v1.92.72.0 stamp: merge main 69c14b17d5 into #3747 (land)
This commit is contained in:
commit
1c818038e7
48 files changed
+1251
-278
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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)']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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' };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user