mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 10:40:32 +01:00
Merge remote-tracking branch 'origin/main' into capy/fixwave-C1-windows-setup-degrade
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md # VERSION # agents-digest/gstack-AGENTS.md # package.json
This commit is contained in:
commit
866d3f583e
10 files changed
+233
-34
No files matched your search
@@ -1,10 +1,10 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { prepareMethodology } from '../bin/gstack-autoplan-snapshot';
|
||||
import { canonicalDestination, prepareMethodology } from '../bin/gstack-autoplan-snapshot';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const TOOL = join(ROOT, 'bin/gstack-autoplan-snapshot.ts');
|
||||
@@ -41,7 +41,7 @@ test('actual raw R input initializes before scope and reaches a complete CEO dis
|
||||
expect(missing.status).toBe(1);
|
||||
expect(missing.stderr).toContain('Expected one Implementation plan section');
|
||||
const initialized = invoke('init', f.source, f.source, f.restore);
|
||||
expect(initialized.activePlan).toBe(f.source);
|
||||
expect(initialized.activePlan).toBe(canonicalDestination(f.source));
|
||||
expect(initialized.originalBytes).toBe(4607);
|
||||
expect(initialized.originalSha256).toBe(hash(original));
|
||||
expect(readFileSync(f.restore)).toEqual(original);
|
||||
@@ -64,8 +64,8 @@ test('assigned active path preserves the separate original source and exact rest
|
||||
if (emptyAssigned) writeFileSync(f.active, '');
|
||||
const sourceMtime = statSync(f.source).mtimeMs;
|
||||
const initialized = invoke('init', f.source, f.active, f.restore);
|
||||
expect(initialized.activePlan).toBe(f.active);
|
||||
expect(initialized.restorePath).toBe(f.restore);
|
||||
expect(initialized.activePlan).toBe(canonicalDestination(f.active));
|
||||
expect(initialized.restorePath).toBe(canonicalDestination(f.restore));
|
||||
expect(readFileSync(f.source)).toEqual(original);
|
||||
expect(statSync(f.source).mtimeMs).toBe(sourceMtime);
|
||||
expect(readFileSync(f.restore)).toEqual(original);
|
||||
@@ -79,8 +79,8 @@ test('the observed missing harness plans directory is initialized without a sepa
|
||||
const active = join(f.dir, 'harness', 'plans', 'assigned.md');
|
||||
const restore = join(f.dir, 'state', 'project', 'restore.md');
|
||||
const initialized = invoke('init', f.source, active, restore);
|
||||
expect(initialized.activePlan).toBe(active);
|
||||
expect(initialized.restorePath).toBe(restore);
|
||||
expect(initialized.activePlan).toBe(canonicalDestination(active));
|
||||
expect(initialized.restorePath).toBe(canonicalDestination(restore));
|
||||
expect(initialized.scope.dxRequired).toBe(true);
|
||||
expect(readFileSync(f.source)).toEqual(original);
|
||||
expect(readFileSync(restore)).toEqual(original);
|
||||
@@ -241,3 +241,29 @@ catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
expect(existsSync(active)).toBe(false);
|
||||
expect(existsSync(restore)).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('PAX-3638 Fix A: init through a symlinked plan directory reports the canonical destinations', () => {
|
||||
const f = fixture();
|
||||
const real = join(f.dir, 'real'), alias = join(f.dir, 'alias');
|
||||
mkdirSync(real); symlinkSync(real, alias, 'dir');
|
||||
const active = join(alias, 'nested', 'active.md'), restore = join(alias, 'restore.md');
|
||||
const canonicalActive = join(realpathSync(real), 'nested', 'active.md');
|
||||
expect(canonicalDestination(active)).toBe(canonicalActive);
|
||||
expect(canonicalDestination(restore)).toBe(join(realpathSync(real), 'restore.md'));
|
||||
const initialized = invoke('init', f.source, active, restore);
|
||||
expect(initialized.activePlan).toBe(canonicalActive);
|
||||
expect(initialized.restorePath).toBe(join(realpathSync(real), 'restore.md'));
|
||||
expect(initialized.activePlan).not.toBe(active);
|
||||
expect(readFileSync(canonicalActive, 'utf8')).toContain('## Implementation plan\n');
|
||||
});
|
||||
|
||||
test('PAX-3638 Fix A: canonicalDestination is pure path math and leaves a leaf link or directory to initializePlan', () => {
|
||||
const f = fixture();
|
||||
const dirLeaf = join(f.dir, 'a-directory'); mkdirSync(dirLeaf);
|
||||
expect(canonicalDestination(dirLeaf)).toBe(join(realpathSync(f.dir), 'a-directory'));
|
||||
expect(existsSync(join(f.dir, 'missing', 'deeper'))).toBe(false);
|
||||
expect(canonicalDestination(join(f.dir, 'missing', 'deeper', 'x.md'))).toBe(join(realpathSync(f.dir), 'missing', 'deeper', 'x.md'));
|
||||
expect(existsSync(join(f.dir, 'missing'))).toBe(false);
|
||||
// initializePlan still refuses a directory destination.
|
||||
expect(cli('init', f.source, dirLeaf, f.restore).status).not.toBe(0);
|
||||
});
|
||||
@@ -1510,3 +1510,91 @@ describe('Autoplan ownership in a linked git worktree session', () => {
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('code foreign_cwd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PAX-3638: symlinked plan directory and superseded failed inits', () => {
|
||||
const skipWin = process.platform === 'win32';
|
||||
/** The init Bash command spells active/restore through a symlinked directory; the journaled JSON stays canonical. */
|
||||
function aliasFixture() {
|
||||
const f = fixture();
|
||||
const aliasDir = `${f.cwd}-alias`; dirs.push(aliasDir);
|
||||
fs.symlinkSync(f.cwd, aliasDir, 'dir');
|
||||
const aliasActive = path.join(aliasDir, 'active.md'), aliasRestore = path.join(aliasDir, 'restore.md');
|
||||
const command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${aliasActive}" "${aliasRestore}"`;
|
||||
(f.events[0] as any).input.command = command;
|
||||
expect(f.init.activePlan).toBe(f.active); // canonical, unlike the argv above
|
||||
expect(aliasActive).not.toBe(f.active);
|
||||
const erroredInit = (id: string) => [
|
||||
{ ...(structuredClone(f.events[0]!) as any), toolUseId: id },
|
||||
{ ...(structuredClone(f.events[1]!) as any), toolUseId: id, isError: true, content: 'Error: Initialization source must be nonempty UTF-8 text' },
|
||||
];
|
||||
const ready = () => { f.message(); f.current(); f.reorder(); };
|
||||
return { ...f, aliasDir, aliasActive, aliasRestore, command, erroredInit, ready };
|
||||
}
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix A: alias argv binds to init canonical paths', () => {
|
||||
const f = aliasFixture(); f.ready();
|
||||
expect(f.evaluate()).toEqual({ allow: true });
|
||||
});
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix B: errored first init followed by a correct init binds', () => {
|
||||
const f = aliasFixture(); f.events.unshift(...f.erroredInit('bad-init') as any); f.ready();
|
||||
expect(f.evaluate()).toEqual({ allow: true });
|
||||
});
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix C: errored init with no later success is init_failed and says re-run', () => {
|
||||
const f = aliasFixture(); (f.events[1] as any).isError = true; f.ready();
|
||||
const decision = f.evaluate() as any, reason: string = decision.reason;
|
||||
expect(decision.allow).toBe(false); expect(reason).toContain('(code init_failed,');
|
||||
expect(reason).toContain('re-run');
|
||||
expect(reason).toContain('The most recent snapshot init call failed');
|
||||
});
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix B: a trailing errored init after a good bind is init_failed', () => {
|
||||
const f = aliasFixture(); f.events.splice(2, 0, ...f.erroredInit('late-bad-init') as any); f.ready();
|
||||
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_failed,') });
|
||||
});
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix B: a later good init supersedes a trailing errored one', () => {
|
||||
const f = aliasFixture(); f.events.splice(2, 0, ...f.erroredInit('late-bad-init') as any);
|
||||
f.events.splice(4, 0, { ...(structuredClone(f.events[0]!) as any), toolUseId: 'again' },
|
||||
{ ...(structuredClone(f.events[1]!) as any), toolUseId: 'again', content: JSON.stringify({ ...f.init, reused: true }) });
|
||||
f.ready();
|
||||
expect(f.evaluate()).toEqual({ allow: true });
|
||||
});
|
||||
|
||||
for (const field of ['activePlan', 'restorePath'] as const)
|
||||
test.skipIf(skipWin)(`PAX-3638 Fix C: retargeted ${field} is init_mismatch without a re-run hint`, () => {
|
||||
const f = aliasFixture(); (f.events[1] as any).content = JSON.stringify({ ...f.init, [field]: f.source }); f.ready();
|
||||
const decision = f.evaluate() as any, reason: string = decision.reason;
|
||||
expect(decision.allow).toBe(false); expect(reason).toContain('(code init_mismatch,');
|
||||
expect(reason).toContain('do not match the canonical form of its own command arguments');
|
||||
expect(reason).not.toContain('re-run');
|
||||
});
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix B: zero results for an init use still denies', () => {
|
||||
const f = aliasFixture(); f.events.splice(1, 1); f.ready();
|
||||
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_mismatch,') });
|
||||
});
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix A: an argv that cannot be canonicalized is init_mismatch, not a hook crash', () => {
|
||||
const f = aliasFixture();
|
||||
(f.events[0] as any).input.command = `bun "${ROOT}/bin/gstack-autoplan-snapshot.ts" init "${f.source}" "${f.source}/not-a-dir/active.md" "${f.aliasRestore}"`;
|
||||
f.ready();
|
||||
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code init_mismatch,') });
|
||||
});
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix A: a mistyped tool path is unbindable, and a later correct init still binds', () => {
|
||||
const f = aliasFixture();
|
||||
const bad = { ...(structuredClone(f.events[0]!) as any), toolUseId: 'typo' };
|
||||
bad.input.command = bad.input.command.replace('gstack-autoplan-snapshot.ts', 'gstack-autoplan-snapshto.ts');
|
||||
f.events.unshift(bad); f.ready();
|
||||
expect(f.evaluate()).toEqual({ allow: true });
|
||||
});
|
||||
|
||||
test.skipIf(skipWin)('PAX-3638 Fix A2: a pending Edit through the alias spelling is the same file', () => {
|
||||
const f = aliasFixture(); f.message();
|
||||
f.use('alias-edit', 'Edit', { file_path: f.aliasActive, old_string: 'Keep documented behavior.', new_string: 'Implement new behavior.' });
|
||||
f.current(); f.reorder();
|
||||
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code mutation_pending,') });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user