Files
gstack/test/autoplan-init.test.ts
T
4e5ffbb857 v1.91.59.0 fix(autoplan): bind init through symlinked plan directories and superseded failed inits (#3083)
* fix(autoplan): bind init through symlinked plan directories and superseded failed inits

The publication guard compared snapshot init's JSON against the literal argv
spelling, but init reports canonical destinations, so a plan under macOS /tmp
or /var (realpath /private/...) always denied init_mismatch. The guard also
hard-failed on the first errored init, so a corrected re-run never bound.

- bin/gstack-autoplan-snapshot.ts: export canonicalDestination (the inner
  destination() path math); initializePlan output is unchanged.
- autoplan/bin/phase-publication-hook.ts: compare activePlan/restorePath
  against the canonicalized argv; a mistyped tool path is unbindable, not a
  crash; skip errored inits and deny init_failed only when the most recent
  init errored; Write/Edit to the active plan match by canonical path.
- autoplan/bin/guard-reasons.ts: init_mismatch never says re-run; init_failed
  does.
- tests: PAX-3638 Fix A/A2/B/C cases (bun test -t "Fix B" runs one fix); the
  three autoplan-init assertions that failed on macOS now compare canonical
  paths. 9 of the 11 new guard cases fail against the previous hook.
- docs: init_mismatch, init_failed, Symlinked plan directories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(autoplan): canonicalRequested comment matches realpath behavior

realpathSync resolves a leaf link as well as directory links; only a
missing target or dangling link keeps the literal spelling. Note why
resolving a leaf link cannot bind an aliased active plan, and drop Read
from the callers list (only Write/Edit use it).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.59.0 fix(autoplan): bind init through symlinked plan directories and superseded failed inits

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Wyatt Barrett <wyatt@Wyatts-MacBook-Pro.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-08 15:26:56 +00:00

270 lines
14 KiB
TypeScript

import { afterEach, expect, test } from 'bun:test';
import { createHash } from 'node:crypto';
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 { canonicalDestination, prepareMethodology } from '../bin/gstack-autoplan-snapshot';
const ROOT = resolve(import.meta.dir, '..');
const TOOL = join(ROOT, 'bin/gstack-autoplan-snapshot.ts');
const original = readFileSync(join(ROOT, 'test/fixtures/plans/autoplan-dashboard.md'));
const hash = (value: Buffer | string) => createHash('sha256').update(value).digest('hex');
const owned: string[] = [];
function fixture() {
const dir = mkdtempSync(join(tmpdir(), 'gstack-autoplan-init-')); owned.push(dir);
const source = join(dir, 'source plan.md');
const active = join(dir, 'assigned plan.md');
const restore = join(dir, 'restore point.md');
writeFileSync(source, original);
return { dir, source, active, restore };
}
function cli(...args: string[]) {
if (args[0] === 'create' && args.length === 4) args.push(prepareMethodology(args[1]!, join(ROOT, `plan-${args[1] === 'dx' ? 'devex' : args[1]}-review`, 'SKILL.md'), args[3]!).methodologyPath);
return spawnSync(process.execPath, [TOOL, ...args], {
cwd: ROOT, encoding: 'utf8', timeout: 10_000, maxBuffer: 1024 * 1024,
});
}
function invoke(...args: string[]) {
const result = cli(...args);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
return JSON.parse(result.stdout);
}
afterEach(() => { for (const dir of owned.splice(0)) rmSync(dir, { recursive: true, force: true }); });
test('actual raw R input initializes before scope and reaches a complete CEO dispatch payload', () => {
const f = fixture();
expect(original.length).toBe(4607);
expect(hash(original)).toBe('2fdf0ece590925869fe25ae941301894f8f4505da6674302df25d5c4546fddbc');
const missing = cli('scope', f.source);
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(canonicalDestination(f.source));
expect(initialized.originalBytes).toBe(4607);
expect(initialized.originalSha256).toBe(hash(original));
expect(readFileSync(f.restore)).toEqual(original);
expect(initialized.scope.sha256).toBe(hash(original));
expect(initialized.scope.matchCount).toBe(21);
expect(initialized.scope.dxRequired).toBe(true);
expect(invoke('scope', f.source)).toEqual(initialized.scope);
const ceo = invoke('create', 'ceo', f.source, f.restore);
expect(readFileSync(ceo.snapshotPath)).toEqual(original);
expect(ceo.nativePrompt.endsWith(original.toString())).toBe(true);
expect(ceo.nativePrompt).toContain('Mutations already require CSRF tokens');
expect(ceo.sha256).toBe(initialized.scope.sha256);
writeFileSync(f.source, readFileSync(f.source, 'utf8') + '<!-- autoplan-accepted:ceo -->\nNone: Initialization only; no review decisions yet.\n<!-- /autoplan-accepted:ceo -->\n');
expect(invoke('check', 'ceo', f.source, ceo.snapshotPath, 'unchanged').changed).toBe(false);
});
test('assigned active path preserves the separate original source and exact restore', () => {
for (const emptyAssigned of [false, true]) {
const f = fixture();
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(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);
expect(readFileSync(f.active, 'utf8')).toContain('## Implementation plan\n' + original.toString());
expect(readdirSync(f.dir).sort()).toEqual(['assigned plan.md', 'restore point.md', 'source plan.md']);
}
});
test('the observed missing harness plans directory is initialized without a separate mkdir step', () => {
const f = fixture();
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(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);
expect(invoke('init', f.source, active, restore).reused).toBe(true);
});
test('same initialization is idempotent but later amendments never get reset', () => {
for (const separate of [false, true]) {
const f = fixture(); const active = separate ? f.active : f.source;
invoke('init', f.source, active, f.restore);
const bytes = readFileSync(active); const backup = readFileSync(f.restore);
const stamp = statSync(active).mtimeMs; const backupStamp = statSync(f.restore).mtimeMs;
expect(invoke('init', f.source, active, f.restore).reused).toBe(true);
expect(readFileSync(active)).toEqual(bytes);
expect(statSync(active).mtimeMs).toBe(stamp);
expect(statSync(f.restore).mtimeMs).toBe(backupStamp);
writeFileSync(active, bytes.toString() + 'Accepted review decision.\n');
const changed = readFileSync(active);
const retry = cli('init', f.source, active, f.restore);
expect(retry.status).toBe(1);
expect(retry.stderr).toContain('Existing restore does not match');
expect(readFileSync(active)).toEqual(changed);
expect(readFileSync(f.restore)).toEqual(backup);
}
});
test('already structured input keeps the review record separate from blind inputs', () => {
const f = fixture();
const body = 'API and endpoint.\n> ## Review record\n```text\n## Review record\n```\n';
const source = `# Earlier plan\n## Implementation plan\n${body}## Review record\nPrivate earlier review\n`;
writeFileSync(f.source, source);
const initialized = invoke('init', f.source, f.active, f.restore);
expect(readFileSync(f.source, 'utf8')).toBe(source);
expect(readFileSync(f.restore, 'utf8')).toBe(source);
expect(readFileSync(f.active, 'utf8')).toContain('## Review record\nPrivate earlier review');
expect(initialized.scope.sha256).toBe(hash(body));
const ceo = invoke('create', 'ceo', f.active, f.restore);
expect(readFileSync(ceo.snapshotPath, 'utf8')).toBe(body);
expect(ceo.nativePrompt).not.toContain('Private earlier review');
});
test('invalid or ambiguous input fails without publishing any backup or active plan', () => {
for (const source of [Buffer.from(''), Buffer.from([0xff, 0xfe]),
Buffer.from('## Implementation plan\nMissing review boundary\n'),
Buffer.from('## Review record\nPrivate audit\n'),
Buffer.from('```text\nUnclosed fence\n'),
Buffer.from('## Implementation plan\nAPI\n## Review record\nAudit\n## Review record\nAgain\n')]) {
const f = fixture(); writeFileSync(f.source, source);
const result = cli('init', f.source, f.active, f.restore);
expect(result.status).toBe(1);
expect(result.stdout).toBe('');
expect(readFileSync(f.source)).toEqual(source);
expect(readdirSync(f.dir)).toEqual(['source plan.md']);
}
});
test('existing foreign destinations, symlinks and path aliases cannot be overwritten', () => {
for (const kind of ['active-content', 'restore-content', 'active-link', 'restore-link', 'hardlink', 'restore-is-source', 'same-destinations', 'relative']) {
const f = fixture(); let active = f.active; let restore = f.restore;
if (kind === 'active-content') writeFileSync(active, 'Other assigned work');
if (kind === 'restore-content') writeFileSync(restore, 'Existing history');
if (kind === 'active-link') symlinkSync('missing', active);
if (kind === 'restore-link') symlinkSync('missing', restore);
if (kind === 'hardlink') linkSync(f.source, active);
if (kind === 'restore-is-source') restore = f.source;
if (kind === 'same-destinations') restore = active;
if (kind === 'relative') active = 'relative.md';
const entries = readdirSync(f.dir).sort();
const result = cli('init', f.source, active, restore);
expect(result.status, kind).toBe(1);
expect(result.stdout).toBe('');
expect(readdirSync(f.dir).sort()).toEqual(entries);
expect(readFileSync(f.source)).toEqual(original);
if (kind === 'active-content') expect(readFileSync(active, 'utf8')).toBe('Other assigned work');
if (kind === 'restore-content') expect(readFileSync(restore, 'utf8')).toBe('Existing history');
}
});
test('line endings and Unicode survive normalization; only a missing final separator LF is added', () => {
for (const text of ['最後の API 要件 🧪\r\nREST must remain.\r\n', 'API and endpoint without final newline']) {
const f = fixture(); const restore = join(f.dir, process.platform === 'win32' ? "restore -- quoted '名前'.md" : 'restore -- quoted "名前".md');
writeFileSync(f.source, text);
invoke('init', f.source, f.active, restore);
expect(readFileSync(restore, 'utf8')).toBe(text);
const ceo = invoke('create', 'ceo', f.active, restore);
expect(readFileSync(ceo.snapshotPath, 'utf8')).toBe(text + (text.endsWith('\n') ? '' : '\n'));
expect(invoke('init', f.source, f.active, restore).reused).toBe(true);
}
});
test('large file identities remain distinct on reuse while real hardlink aliases are rejected', () => {
const f = fixture();
const worker = join(f.dir, 'large-file-ids.ts');
writeFileSync(worker, `import { mock } from 'bun:test';
const real = { ...await import('node:fs') };
const ids = new Map();
function observed(kind, file, options) {
const exact = real[kind](file, { ...options, bigint: true });
if (!exact) return exact;
const key = exact.dev + ':' + exact.ino;
if (!ids.has(key)) ids.set(key, 2n ** 60n + BigInt(ids.size));
const ino = ids.get(key);
const state = options?.bigint ? exact : real[kind](file, options);
return new Proxy(state, { get(target, key, receiver) {
return key === 'ino' ? (options?.bigint ? ino : Number(ino)) : Reflect.get(target, key, receiver);
} });
}
mock.module('node:fs', () => ({ ...real,
statSync: (file, options) => observed('statSync', file, options),
lstatSync: (file, options) => observed('lstatSync', file, options),
}));
const { initializePlan } = await import(${JSON.stringify(TOOL)});
const [source, active, restore, alias] = process.argv.slice(2);
const initial = initializePlan(source, active, restore);
const reused = initializePlan(source, active, restore);
real.linkSync(source, alias);
let rejected = false;
try { initializePlan(source, alias, restore + '.other'); }
catch (error) { rejected = error.message.includes('ambiguous alias'); }
console.log(JSON.stringify({ initial: initial.reused, reused: reused.reused, rejected,
roundedIds: new Set([...ids.values()].map(Number)).size, exactIds: ids.size }));
`);
const result = spawnSync(process.execPath, [worker, f.source, f.active, f.restore, join(f.dir, 'hardlink.md')], {
encoding: 'utf8', timeout: 10_000,
});
expect(result.status, result.stderr).toBe(0);
const report = JSON.parse(result.stdout);
expect(report).toMatchObject({ initial: false, reused: true, rejected: true, roundedIds: 1 });
expect(report.exactIds).toBeGreaterThan(2);
expect(readFileSync(f.source)).toEqual(original);
expect(readFileSync(f.restore)).toEqual(original);
expect(existsSync(f.restore + '.other')).toBe(false);
});
test('staging failure cleans owned temporary files without changing source or active bytes', () => {
const f = fixture();
const active = join(f.dir, 'harness', 'plans', 'assigned.md');
const restore = join(f.dir, 'state', 'project', 'restore.md');
const worker = join(f.dir, 'fail-stage.ts');
writeFileSync(worker, `import { mock } from 'bun:test';
const real = { ...await import('node:fs') };
mock.module('node:fs', () => ({ ...real, mkdtempSync(prefix, options) {
if (String(prefix).includes('.gstack-autoplan-restore-')) throw new Error('Injected restore staging failure');
return real.mkdtempSync(prefix, options);
} }));
const { initializePlan } = await import(${JSON.stringify(TOOL)});
try { initializePlan(...process.argv.slice(2)); process.exitCode = 5; }
catch (error) { console.error(error.message); process.exitCode = 1; }
`);
const result = spawnSync(process.execPath, [worker, f.source, active, restore], {
encoding: 'utf8', timeout: 10_000,
});
expect(result.status).toBe(1);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('Injected restore staging failure');
expect(readFileSync(f.source)).toEqual(original);
expect(readdirSync(f.dir).sort()).toEqual(['fail-stage.ts', 'source plan.md']);
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);
});