mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 11:40:51 +01:00
Merge remote-tracking branch 'origin/main' into gsta208/ship-receipt-session-kind
This commit is contained in:
commit
0b997e046d
60 files changed
+1188
-226
No files matched your search
+150
-26
@@ -843,7 +843,7 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
}
|
||||
function setup() {
|
||||
init();
|
||||
write(SHARED, '# shared v1\n');
|
||||
write(SHARED, '{"note":"shared v1"}\n');
|
||||
write(OTHER, '# other v1\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
@@ -856,15 +856,15 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
|
||||
test('a dirty tracked file the remote did not touch rides through the merge', () => {
|
||||
setup();
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
peerCommit(SHARED, '{"note":"shared from the peer"}\n');
|
||||
write(OTHER, '# other, local edit not queued\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
expect(remoteFiles()).toContain(CLEAN);
|
||||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||||
expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n');
|
||||
});
|
||||
|
||||
/** The restore command the status names, run as printed. */
|
||||
@@ -878,17 +878,17 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
|
||||
test('a dirty tracked file the remote also changed no longer leaves the push failed', () => {
|
||||
setup();
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
write(SHARED, '# shared, local edit not queued\n', false);
|
||||
peerCommit(SHARED, '{"note":"shared from the peer"}\n');
|
||||
write(SHARED, '{"note":"shared, local edit not queued"}\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('ok');
|
||||
expect(remoteFiles()).toContain(CLEAN);
|
||||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||||
expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n');
|
||||
// The merged version stays in the working tree: no conflict markers,
|
||||
// nothing staged, and nothing stale for a later sync to publish.
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n');
|
||||
expect(unmerged()).toBe('');
|
||||
expect(staged()).toBe('');
|
||||
expect(s.message).toContain(`local edits to ${SHARED} conflicted`);
|
||||
@@ -900,23 +900,23 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
write(OTHER, '# other v2\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||||
expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('# shared, local edit not queued\n');
|
||||
expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n');
|
||||
expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('{"note":"shared, local edit not queued"}\n');
|
||||
// The printed command brings the local edit back.
|
||||
expect(recover(s.message)).toBe(sha!);
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n');
|
||||
});
|
||||
|
||||
test('a local deletion of a file the remote changed keeps the remote file and stashes the deletion', () => {
|
||||
setup();
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
peerCommit(SHARED, '{"note":"shared from the peer"}\n');
|
||||
fs.rmSync(path.join(tmpHome, SHARED));
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('ok');
|
||||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||||
expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n');
|
||||
expect(unmerged()).toBe('');
|
||||
expect(s.message).toContain(`local edits to ${SHARED} conflicted`);
|
||||
recover(s.message);
|
||||
@@ -934,7 +934,7 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
} finally {
|
||||
fs.rmSync(peer, { recursive: true, force: true });
|
||||
}
|
||||
write(SHARED, '# shared, local edit not queued\n', false);
|
||||
write(SHARED, '{"note":"shared, local edit not queued"}\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
@@ -943,7 +943,7 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false);
|
||||
expect(unmerged()).toBe('');
|
||||
recover(s.message);
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n');
|
||||
});
|
||||
|
||||
test('a conflicting file whose name looks like a pattern resets only itself', () => {
|
||||
@@ -954,16 +954,16 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
expect(git(['merge', '-q', '--ff-only', 'origin/main']).status).toBe(0);
|
||||
peerCommit(GLOB, '# glob from the peer\n');
|
||||
write(GLOB, '# glob, local edit not queued\n', false);
|
||||
write(SHARED, '# shared, local edit not queued\n', false);
|
||||
write(SHARED, '{"note":"shared, local edit not queued"}\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('ok');
|
||||
expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob from the peer\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n');
|
||||
recover(s.message);
|
||||
expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n');
|
||||
});
|
||||
|
||||
test('a file that never checks out clean does not strand the other local edits', () => {
|
||||
@@ -973,7 +973,7 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
expect(drain().status).toBe(0);
|
||||
fs.mkdirSync(path.join(tmpHome, '.git', 'info'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, '.git', 'info', 'attributes'), '*.md text eol=lf\n');
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
peerCommit(SHARED, '{"note":"shared from the peer"}\n');
|
||||
write(OTHER, '# other, local edit not queued\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
@@ -987,28 +987,28 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
setup();
|
||||
write(OTHER, '# other, older stashed work\n', false);
|
||||
expect(git(['stash', 'push', '-q', '-m', 'older work']).status).toBe(0);
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
write(SHARED, '# shared, local edit not queued\n', false);
|
||||
peerCommit(SHARED, '{"note":"shared from the peer"}\n');
|
||||
write(SHARED, '{"note":"shared, local edit not queued"}\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
const shim = gitShim('case " $* " in *" stash push "*) exit 0;; esac');
|
||||
expect(drain(shim.env).status).toBe(0);
|
||||
expect(statusJson().status).toBe('push_failed');
|
||||
expect(git(['stash', 'list']).stdout).toContain('older work');
|
||||
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other v1\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n');
|
||||
});
|
||||
|
||||
test('a conflicting merge is aborted and git\'s reason reaches the status', () => {
|
||||
setup();
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
write(SHARED, '# shared, local commit\n');
|
||||
peerCommit(SHARED, '{"note":"shared from the peer"}\n');
|
||||
write(SHARED, '{"note":"shared, local commit"}\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('push_failed');
|
||||
expect(s.message).toContain(`Merge conflict in ${SHARED}`);
|
||||
expect(mergeHead()).toBe(false);
|
||||
expect(unmerged()).toBe('');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local commit\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local commit"}\n');
|
||||
// The next drain is not blocked by a half-finished merge.
|
||||
write(OTHER, '# other v2\n');
|
||||
expect(drain().status).toBe(0);
|
||||
@@ -1270,6 +1270,130 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
expect(remoteShow(SNAP).stdout).toContain('"ccc"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a synced JSON file that does not parse is never committed (#3763)', () => {
|
||||
const PROFILE = 'developer-profile.json';
|
||||
const MARKED = '{\n "sessions": [],\n<<<<<<< HEAD\n "v": "local"\n=======\n "v": "peer"\n>>>>>>> origin/main\n}\n';
|
||||
const parses = (text: string) => { try { JSON.parse(text); return true; } catch { return false; } };
|
||||
|
||||
test('a queued profile holding conflict markers is held back; the rest syncs; a repaired profile syncs next', () => {
|
||||
init();
|
||||
write(PROFILE, MARKED);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(remoteFiles()).toContain(CLEAN);
|
||||
expect(remoteFiles()).not.toContain(PROFILE);
|
||||
expect(staged()).toBe('');
|
||||
expect(spoolText()).toContain(PROFILE);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('held');
|
||||
expect(s.held.map((h: { path: string; rule: string }) => [h.path, h.rule])).toEqual([[PROFILE, 'invalid-json']]);
|
||||
expect(s.held[0].fixes[0]).toContain(`${PROFILE} is not valid JSON`);
|
||||
expect(s.held[0].fixes).toHaveLength(1);
|
||||
expect(s.message).toContain('not valid JSON');
|
||||
expect(s.message).not.toContain('secret scan');
|
||||
|
||||
write(PROFILE, '{"sessions":[],"v":"repaired"}\n', false);
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"repaired"}\n');
|
||||
});
|
||||
|
||||
test('a marker-free syntax error is held too', () => {
|
||||
init();
|
||||
write(PROFILE, '{"sessions": [],}\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(remoteFiles()).not.toContain(PROFILE);
|
||||
expect(statusJson().held.map((h: { rule: string }) => h.rule)).toEqual(['invalid-json']);
|
||||
expect(spoolText()).toContain(PROFILE);
|
||||
});
|
||||
|
||||
test('an unparseable decisions.active.json holds its coupled decisions.jsonl until repaired', () => {
|
||||
init();
|
||||
writeDecisions(['a'], ['a']);
|
||||
expect(drain().status).toBe(0);
|
||||
write(LOG, [decide('a'), decide('bb')].join('\n') + '\n', false);
|
||||
write(SNAP, '[{"id":"a"},\n<<<<<<< HEAD\n{"id":"bb"}\n=======\n{"id":"c"}\n>>>>>>> origin/main\n]\n', false);
|
||||
run(['gstack-brain-enqueue', LOG]);
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('held');
|
||||
expect(s.held.map((h: { path: string; rule: string; dependents: string[] }) => [h.path, h.rule, h.dependents])).toEqual([[SNAP, 'invalid-json', [LOG]]]);
|
||||
expect(remoteShow(LOG).stdout).toBe(decide('a') + '\n');
|
||||
expect(spoolText()).toContain(LOG);
|
||||
|
||||
writeDecisions(['a', 'bb'], ['a', 'bb']);
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
expect(remoteShow(LOG).stdout).toBe([decide('a'), decide('bb')].join('\n') + '\n');
|
||||
});
|
||||
|
||||
test('two machines editing the profile never put conflict markers on the remote', () => {
|
||||
init();
|
||||
write(PROFILE, '{"sessions":[],"v":"base"}\n');
|
||||
expect(drain().status).toBe(0);
|
||||
// The retry merge commits with the ambient identity; a CI runner has none.
|
||||
expect(git(['config', 'user.name', 'fixture']).status).toBe(0);
|
||||
expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0);
|
||||
const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-'));
|
||||
try {
|
||||
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
|
||||
fs.writeFileSync(path.join(peer, PROFILE), '{"sessions":[],"v":"peer"}\n');
|
||||
expect(git(['add', '-f', '--', PROFILE], peer).status).toBe(0);
|
||||
expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer profile'], peer).status).toBe(0);
|
||||
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(peer, { recursive: true, force: true });
|
||||
}
|
||||
write(PROFILE, '{"sessions":[],"v":"local"}\n');
|
||||
expect(drain().status).toBe(0);
|
||||
// Every later profile write queues the file again.
|
||||
run(['gstack-brain-enqueue', PROFILE]);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(parses(remoteShow(PROFILE).stdout)).toBe(true);
|
||||
expect(remoteShow(PROFILE).stdout).not.toContain('<<<<<<<');
|
||||
expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"peer"}\n');
|
||||
});
|
||||
|
||||
test('an unfinished merge with a held profile commits nothing until the profile is repaired', () => {
|
||||
init();
|
||||
write(PROFILE, '{"sessions":[],"v":"base"}\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(git(['config', 'user.name', 'fixture']).status).toBe(0);
|
||||
expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0);
|
||||
const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-'));
|
||||
try {
|
||||
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
|
||||
fs.writeFileSync(path.join(peer, PROFILE), '{"sessions":[],"v":"peer"}\n');
|
||||
expect(git(['add', '-f', '--', PROFILE], peer).status).toBe(0);
|
||||
expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer profile'], peer).status).toBe(0);
|
||||
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(peer, { recursive: true, force: true });
|
||||
}
|
||||
fs.writeFileSync(path.join(tmpHome, PROFILE), '{"sessions":[],"v":"local"}\n');
|
||||
expect(git(['commit', '-qam', 'local profile']).status).toBe(0);
|
||||
expect(git(['fetch', '-q', 'origin']).status).toBe(0);
|
||||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
|
||||
expect(git(['merge', '--no-edit', `origin/${branch}`]).status).not.toBe(0);
|
||||
run(['gstack-brain-enqueue', PROFILE]);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('blocked');
|
||||
expect(statusJson().message).toContain('unfinished');
|
||||
expect(statusJson().message).not.toContain('--skip-file <path>');
|
||||
expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"peer"}\n');
|
||||
expect(remoteFiles()).not.toContain(CLEAN);
|
||||
expect(git(['rev-parse', '-q', '--verify', 'MERGE_HEAD']).status).toBe(0);
|
||||
|
||||
write(PROFILE, '{"sessions":[],"v":"merged"}\n', false);
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"merged"}\n');
|
||||
expect(remoteFiles()).toContain(CLEAN);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+2
-2
@@ -210,8 +210,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+2
-2
@@ -210,8 +210,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+2
-2
@@ -208,8 +208,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+2
-2
@@ -210,8 +210,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
@@ -628,6 +628,24 @@ describe('gstack-developer-profile --check-mismatch', () => {
|
||||
// Error handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('gstack-developer-profile --profile on an unparseable profile (#3763)', () => {
|
||||
test('exits non-zero, prints nothing to stdout, and names the file and the conflict markers', () => {
|
||||
fs.writeFileSync(path.join(tmpHome, 'developer-profile.json'), '{\n<<<<<<< HEAD\n "sessions": []\n=======\n "sessions": [1]\n>>>>>>> origin/main\n}\n');
|
||||
const r = runDev('--profile');
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(r.stderr).toContain('developer-profile.json is not valid JSON');
|
||||
expect(r.stderr).toContain('git conflict markers');
|
||||
});
|
||||
|
||||
test('a valid profile still prints as before', () => {
|
||||
fs.writeFileSync(path.join(tmpHome, 'developer-profile.json'), '{"sessions":[]}\n');
|
||||
const r = runDev('--profile');
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('{"sessions":[]}\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-developer-profile errors', () => {
|
||||
test('unknown subcommand exits non-zero', () => {
|
||||
const r = runDev('--not-a-real-subcommand');
|
||||
|
||||
@@ -136,6 +136,25 @@ describe('gstack-doctor', () => {
|
||||
expect(r.out).not.toContain('.brain-sync-status.json');
|
||||
});
|
||||
|
||||
test('artifacts sync: a synced JSON file with conflict markers warns by name (#3763)', () => {
|
||||
const f = makeFixture();
|
||||
syncOn(f, { status: 'ok', message: 'pushed 3 file(s)', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) });
|
||||
write(path.join(f.state, 'developer-profile.json'), '{\n<<<<<<< HEAD\n "v": 1\n=======\n "v": 2\n>>>>>>> origin/main\n}\n');
|
||||
const r = doctor(f);
|
||||
expect(r.row('artifacts sync').state).toBe('warn');
|
||||
expect(r.row('artifacts sync').detail).toBe('developer-profile.json holds git conflict markers and does not parse');
|
||||
write(path.join(f.state, 'developer-profile.json'), '{"v": 2}\n');
|
||||
expect(doctor(f).row('artifacts sync').state).toBe('ok');
|
||||
});
|
||||
|
||||
test('artifacts sync: a JSON hold is not attributed to the secret scanner (#3763)', () => {
|
||||
const f = makeFixture();
|
||||
syncOn(f, { status: 'held', message: 'held', held_count: 1, held: [{ path: 'developer-profile.json', rule: 'invalid-json', dependents: [], fixes: ['repair', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) });
|
||||
const r = doctor(f);
|
||||
expect(r.row('artifacts sync').state).toBe('warn');
|
||||
expect(r.row('artifacts sync').detail).toBe('status held (1 synced file(s) are not valid JSON; 1 held in all)');
|
||||
});
|
||||
|
||||
test('artifacts sync: held, then ok', () => {
|
||||
const f = makeFixture();
|
||||
syncOn(f, { status: 'held', message: 'held 2 file(s): sk-ant-secret', held_count: 2, held: [{ path: 'projects/p/notes.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }, { path: 'projects/p/b.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) });
|
||||
|
||||
@@ -816,7 +816,7 @@ describe('ARTIFACTS_SYNC attention lines (sync status table)', () => {
|
||||
const held = runStart([], env);
|
||||
expect(held).toMatch(/^ARTIFACTS_SYNC: mode=full \| last_push=\S+ \| queue=\d+$/m);
|
||||
expect(attention(held)).toEqual([
|
||||
`ARTIFACTS_SYNC: attention: status=held held=1. The secret scan is holding back 1 file(s); everything else still syncs. See which files and how to fix them: ${path.join(ROOT, 'bin', 'gstack-brain-sync')} --status`,
|
||||
`ARTIFACTS_SYNC: attention: status=held held=1. Sync is holding back 1 file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: ${path.join(ROOT, 'bin', 'gstack-brain-sync')} --status`,
|
||||
]);
|
||||
expect(held).not.toContain('ghp_');
|
||||
expect(remoteFiles()).toContain('projects/p/ceo-plans/clean.md');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* #3659: gstack-jsonl-merge read its inputs as UTF-8 but wrote them with
|
||||
* print(), so a redirected stdout on a legacy code page (cp1250 on the
|
||||
* reporter's Windows) raised UnicodeEncodeError on a Cyrillic line, and any
|
||||
* Windows Python rewrote LF as CRLF. The driver must write UTF-8 with LF
|
||||
* whatever the platform and code page. PYTHONIOENCODING=cp1252 reproduces the
|
||||
* legacy code page on every OS.
|
||||
*
|
||||
* Windows-safe by construction: the driver launches through
|
||||
* lib/script-invocation.ts (Git Bash on win32), files live under os.tmpdir().
|
||||
*/
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
let tmp = '';
|
||||
|
||||
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-jsonl-enc-'))); });
|
||||
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
test('a line the stdout code page cannot encode merges as UTF-8 with LF endings (#3659)', () => {
|
||||
const base = '{"ts":"2026-01-01T00:00:00Z","note":"base"}\n';
|
||||
const ours = base + '{"ts":"2026-01-02T00:00:00Z","note":"ours"}\n';
|
||||
const theirsLine = '{"ts":"2026-01-03T00:00:00Z","note":"привет"}';
|
||||
const p = (n: string) => path.join(tmp, n);
|
||||
fs.writeFileSync(p('base'), base);
|
||||
fs.writeFileSync(p('ours'), ours);
|
||||
fs.writeFileSync(p('theirs'), base + theirsLine + '\n');
|
||||
const posix = (f: string) => f.replace(/\\/g, '/');
|
||||
const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-jsonl-merge'), [posix(p('base')), posix(p('ours')), posix(p('theirs'))], {
|
||||
env: { ...process.env, PYTHONIOENCODING: 'cp1252', PYTHONUTF8: '0' }, encoding: 'utf8', timeout: 60_000,
|
||||
});
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const bytes = fs.readFileSync(p('ours'));
|
||||
expect(bytes.includes(0x0d)).toBe(false);
|
||||
expect(bytes.toString('utf8')).toBe(ours + theirsLine + '\n');
|
||||
});
|
||||
@@ -201,6 +201,41 @@ describe('literals (pure)', () => {
|
||||
{ name: 'MAX', old: '3', new: '4', file: 'f.ts' }, { name: 'timeout', old: '5000', new: '9000', file: 'f.ts' },
|
||||
]);
|
||||
});
|
||||
// Value: protects=a sibling file that shares the module's stem (`postgres-unit-arms.txt` beside `postgres-unit-arms.ts`) is not the
|
||||
// module, so a test that only names it is not a consumer, while an extensionless import, a JS/TS import extension, the module's own
|
||||
// file name and a path ending at the stem (then a quote, space, bracket, sentence end or `.d.ts`) still are, and neither `<file>.snap` nor a JS twin of a
|
||||
// shell module is; fails_when=`/<stem>` again accepts any following `.<word>`, the file name accepts a further extension,
|
||||
// a non-JS module takes JS/TS extensions, or stops accepting a following space, bracket or sentence end
|
||||
// (GST-820: 17 false files in gbrain #8372 on ARM_SHARDS 12 -> 18); why_new=no test had a same-stem sibling; seam=none;
|
||||
// no_claim=name-gated hits (a file naming ARM_SHARDS still counts any 12)
|
||||
test('consumersOf: a sibling file sharing the module stem is not the module; real imports of it still are (GST-820)', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-stem-'));
|
||||
const nl = String.fromCharCode(10);
|
||||
const files: Record<string, string> = {
|
||||
'test/a.test.ts': ['// listed in test/postgres-unit-arms.txt', 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/json.test.ts': ["const arms = read('fixtures/postgres-unit-arms.json');", 'expect(arms.length).toBe(12);', ''].join(nl),
|
||||
'test/longer.test.ts': ["run('scripts/postgres-unit-arms-v2.ts');", 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/import.test.ts': ["import { shards } from '../scripts/postgres-unit-arms';", 'expect(shards()).toBe(12);', ''].join(nl),
|
||||
'test/import-js.test.ts': ["import { shards } from '../scripts/postgres-unit-arms.js';", 'expect(shards()).toBe(12);', ''].join(nl),
|
||||
'test/own.test.ts': ["const out = run('scripts/postgres-unit-arms.ts');", 'expect(out).toBe(12);', ''].join(nl),
|
||||
'test/line-end.test.ts': ['// covers scripts/postgres-unit-arms', 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/prose.test.ts': ['// covers scripts/postgres-unit-arms and more', 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/paren.test.ts': ['// (scripts/postgres-unit-arms)', 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/sentence.test.ts': ['// see scripts/postgres-unit-arms.', 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/dts.test.ts': ["import type { Arms } from '../scripts/postgres-unit-arms.d.ts';", 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/sibling-test.test.ts': ["run('scripts/postgres-unit-arms.test.ts');", 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/snap.test.ts': ["read('scripts/__snapshots__/postgres-unit-arms.ts.snap');", 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/sh-twin.test.ts': ["import { arms } from '../lib/arms-tool.ts';", 'expect(n).toBe(12);', ''].join(nl),
|
||||
'test/sh-own.test.ts': ["run('scripts/arms-tool.sh');", 'expect(n).toBe(12);', ''].join(nl),
|
||||
};
|
||||
for (const [rel, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); }
|
||||
expect(consumersOf(root, Object.keys(files), { name: 'ARM_SHARDS', old: '12', new: '18', file: 'scripts/postgres-unit-arms.ts' })).toEqual([
|
||||
'test/import.test.ts:2', 'test/import-js.test.ts:2', 'test/own.test.ts:2', 'test/line-end.test.ts:2',
|
||||
'test/prose.test.ts:2', 'test/paren.test.ts:2', 'test/sentence.test.ts:2', 'test/dts.test.ts:2',
|
||||
]);
|
||||
expect(consumersOf(root, ['test/sh-twin.test.ts', 'test/sh-own.test.ts'], { name: 'ARMS', old: '12', new: '18', file: 'scripts/arms-tool.sh' })).toEqual(['test/sh-own.test.ts:2']);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
test('consumersOf: a module stem is a path fragment, never a bare word, and a one-character old value needs the constant name (GSTA-114 flood: install.sh 2 -> 3 flagged 575 consumers)', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-'));
|
||||
const nl = String.fromCharCode(10);
|
||||
|
||||
+46
-19
@@ -44,8 +44,23 @@ describe('nextStep: the transition table', () => {
|
||||
test('a fixed blocker earns full pass 2', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBlocking: true }).step).toBe('full'));
|
||||
test('below-floor fixes earn one fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true }).step).toBe('fix-check'));
|
||||
test('a fix touching gate code earns a full pass instead of a fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, touchedGate: true }).step).toBe('full'));
|
||||
test('at the full-pass cap a fixed blocker gets a fix-check', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true }).step).toBe('fix-check'));
|
||||
test('at the cap a gate-touching fix still gets a fix-check, never a third full pass', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true, touchedGate: true }).step).toBe('fix-check'));
|
||||
// R51: two rounds is the cap, a fix-check included. A blocker open or fixed in round 2 stops for the owner (a grant may add
|
||||
// one scoped fix-check); below-floor fixes in round 2 finish with notes; a light pass whose fixes change no runtime lines is done.
|
||||
test('a blocker fixed in round 2 stops for the owner, never a third round', () => {
|
||||
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('scoped fix-check') });
|
||||
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true, touchedGate: true }).step).toBe('stop');
|
||||
});
|
||||
test('below-floor fixes in round 2 finish with notes, a fix-check included', () => {
|
||||
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBelow: true })).toMatchObject({ step: 'done', reason: expect.stringContaining('notes') });
|
||||
expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBelow: true })).toMatchObject({ step: 'done', reason: expect.stringContaining('notes') });
|
||||
expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBelow: true, fixedBlocking: true }).step).toBe('stop');
|
||||
});
|
||||
test('a light pass 1 whose below-floor fixes change no runtime lines is done; a runtime fix or a deeper diff keeps the fix-check', () => {
|
||||
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 0 }, 'light').step).toBe('done');
|
||||
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 3 }, 'light').step).toBe('fix-check');
|
||||
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 0 }, 'full').step).toBe('fix-check');
|
||||
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true }, 'light').step).toBe('fix-check');
|
||||
});
|
||||
test('a blocker open at the cap stops', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true }).step).toBe('stop'));
|
||||
test('a blocker open with no fix stops', () => expect(nextStep({ fullPasses: 1, last: 'full', blockingOpen: true }).step).toBe('stop'));
|
||||
test('a fix-check that finds a blocker stops, never a third full pass', () => {
|
||||
@@ -83,8 +98,12 @@ describe('planReview', () => {
|
||||
expect(p.depth).toBe('light');
|
||||
expect(roles(p)).toEqual([]);
|
||||
});
|
||||
test('code class pass 1: specialists at medium, the native adversarial at full, the outside voice at its own', () => {
|
||||
const p = planReview(base({}));
|
||||
test('non-risky code pass 1 is light at any size (R51): one batched reader carrying the Red Team prompt and the native adversarial', () => {
|
||||
const p = planReview(base({ diffLines: 900 }));
|
||||
expect([p.depth, roles(p)]).toEqual(['light', ['specialist:batched:full', 'adversarial-native:adversarial:full']]);
|
||||
});
|
||||
test('code class pass 1 at standard: specialists at medium, the native adversarial at full, the outside voice at its own', () => {
|
||||
const p = planReview(base({ floor: 'standard' }));
|
||||
expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||
expect(batch(p)).toEqual(['testing', 'security']);
|
||||
expect(p.skipped).toContain('maintainability, performance, simplification (lean ensemble: read only when forced)');
|
||||
@@ -162,10 +181,10 @@ describe('GSTA-131 pass-1 review repairs', () => {
|
||||
expect(scanNumbers({ 'test/fixtures/bench.json': ['{ "ratio": 0.74 }'] }).publishes).toBe(true);
|
||||
});
|
||||
test('--all-specialists forces every specialist; a code diff past 200 lines or with a CRITICAL adds the Red Team', () => {
|
||||
const all = planReview(base({ force: 'all' }));
|
||||
const all = planReview(base({ floor: 'standard', force: 'all' }));
|
||||
expect(all.dispatches.filter(d => d.role === 'specialist').length).toBe(1);
|
||||
expect([...batch(all)!].sort()).toEqual(['api-contract', 'data-migration', 'design', 'maintainability', 'performance', 'security', 'simplification', 'state-machines', 'testing']);
|
||||
const has = (o: Partial<PlanInput>) => roles(planReview(base(o))).some(r => r.startsWith('red-team'));
|
||||
const has = (o: Partial<PlanInput>) => roles(planReview(base({ floor: 'standard', ...o }))).some(r => r.startsWith('red-team'));
|
||||
expect(has({ diffLines: 120 })).toBe(false);
|
||||
expect(has({ diffLines: 201 })).toBe(true);
|
||||
expect(has({ diffLines: 120, criticalFound: true })).toBe(true);
|
||||
@@ -175,20 +194,20 @@ describe('GSTA-131 pass-1 review repairs', () => {
|
||||
describe('GSTA-131 coverage: Red Team and specialist arms', () => {
|
||||
const roles = (p: ReturnType<typeof planReview>) => p.dispatches.map(d => `${d.role}:${d.name}`);
|
||||
test('Red Team runs on code past 200 lines or after a CRITICAL, and on every gate diff', () => {
|
||||
expect(roles(planReview(base({ diffLines: 120 })))).not.toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 201 })))).toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 120, criticalFound: true })))).toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 120, floor: 'standard' })))).not.toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 201, floor: 'standard' })))).toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 120, floor: 'standard', criticalFound: true })))).toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 20, gateTagged: 1 })))).toContain('red-team:red-team');
|
||||
});
|
||||
test('scope arms select their specialist, state machines default on when scope is unknown, and --force all reaches docs class', () => {
|
||||
const picked = (scope: Record<string, string>) => batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false', ...scope } })));
|
||||
const picked = (scope: Record<string, string>) => batch(planReview(base({ floor: 'standard', scope: { SCOPE_STATE_MACHINE: 'false', ...scope } })));
|
||||
expect(picked({ SCOPE_MIGRATIONS: 'true' })).toContain('data-migration');
|
||||
expect(picked({ SCOPE_API: 'true' })).toContain('api-contract');
|
||||
expect(picked({ SCOPE_FRONTEND: 'true' })).toEqual(['testing', 'security', 'design']);
|
||||
expect(batch(planReview(base({ scope: {} })))).toContain('state-machines');
|
||||
expect(batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false' } })))).not.toContain('state-machines');
|
||||
expect(batch(planReview(base({ force: ['maintainability'] })))).toEqual(['testing', 'security', 'maintainability']);
|
||||
expect(batch(planReview(base({ gated: ['design'], scope: { SCOPE_FRONTEND: 'true', SCOPE_STATE_MACHINE: 'false' } })))).toEqual(['testing', 'security']);
|
||||
expect(batch(planReview(base({ floor: 'standard', force: ['maintainability'] })))).toEqual(['testing', 'security', 'maintainability']);
|
||||
expect(batch(planReview(base({ floor: 'standard', gated: ['design'], scope: { SCOPE_FRONTEND: 'true', SCOPE_STATE_MACHINE: 'false' } })))).toEqual(['testing', 'security']);
|
||||
const docsAll = planReview(base({ paths: ['docs/a.md'], force: 'all' }));
|
||||
expect(docsAll.class).toBe('docs');
|
||||
expect(docsAll.dispatches.filter(d => d.role === 'specialist').length).toBeGreaterThan(5);
|
||||
@@ -215,8 +234,12 @@ describe('review depth: the matrix', () => {
|
||||
expect(p.skipped.join('; ')).toContain('red-team (light depth');
|
||||
expect(planReview(mixed({ perPath: per({ 'src/app.ts': 48 }), adversarialRan: true, history: { fullPasses: 1, last: 'full', touchedGate: true, fixedBelow: true } })).dispatches.some(d => d.role === 'adversarial-native')).toBe(false);
|
||||
});
|
||||
test('runtime 51 is standard: thresholds read runtime lines, reviewers read their own hunks', () => {
|
||||
test('runtime 51 non-risky code stays light (R51: size never raises the depth)', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454 }));
|
||||
expect([p.depth, p.runtimeLines, roles(p)]).toEqual(['light', 51, ['specialist:batched:full', 'adversarial-native:adversarial:full']]);
|
||||
});
|
||||
test('standard (floor or raise): thresholds read runtime lines, reviewers read their own hunks', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454, floor: 'standard' }));
|
||||
expect([p.depth, p.runtimeLines]).toEqual(['standard', 51]);
|
||||
expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||
expect(batch(p)).toEqual(['testing', 'security']);
|
||||
@@ -274,14 +297,16 @@ describe('review depth: the matrix', () => {
|
||||
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'full' })).toBe('full');
|
||||
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('standard');
|
||||
expect(depthFor({ cls: 'gate', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('full');
|
||||
expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('standard');
|
||||
expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('light');
|
||||
expect(depthFor({ cls: 'code', runtimeLines: 3, errors: [], risk: ['data'] })).toBe('full');
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10 }), floor: 'full' }));
|
||||
expect(p.depth).toBe('full');
|
||||
expect(p.depthReasons).toContain('floor:full');
|
||||
});
|
||||
test('depthOfCommitInput gives the same depth as the plan for the same input', () => {
|
||||
const i = { paths: ['src/app.ts', 'test/a.test.ts'], perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 9 }), gateTagged: 0 };
|
||||
expect(depthOfCommitInput(i)).toMatchObject({ depth: 'standard', runtimeLines: 51, rules: DEPTH_RULES, errors: [] });
|
||||
expect(depthOfCommitInput(i)).toMatchObject({ depth: 'light', runtimeLines: 51, rules: DEPTH_RULES, errors: [], risk: [] });
|
||||
expect(depthOfCommitInput({ ...i, added: { 'src/app.ts': ['const spend_cap_usd = 5;'] } })).toMatchObject({ depth: 'full', risk: ['money'] });
|
||||
expect(depthOfCommitInput({ ...i, gateTagged: null }).depth).toBe('full');
|
||||
});
|
||||
});
|
||||
@@ -300,6 +325,7 @@ describe('review depth: diff commands and the rules hash', () => {
|
||||
try {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts'));
|
||||
const hashOf = (text: string) => {
|
||||
fs.writeFileSync(path.join(dir, 'review-depth.ts'), text);
|
||||
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
||||
@@ -369,7 +395,7 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
|
||||
expect(r(p)).toEqual(['fix-check:fix-check', 'red-team:red-team', 'adversarial-native:adversarial']);
|
||||
});
|
||||
test('the scoped pass counts toward the cap, and a blocker it leaves stops for the owner', () => {
|
||||
expect(nextStep(fixed(10, { fullPasses: FULL_PASS_CAP }), 'standard').step).toBe('fix-check');
|
||||
expect(nextStep(fixed(10, { fullPasses: FULL_PASS_CAP }), 'standard').step).toBe('stop');
|
||||
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true, rangeRuntimeLines: 10 }, 'light').step).toBe('stop');
|
||||
const at = (m: number) => new Date(Date.UTC(2026, 9, 28, 10, m)).toISOString();
|
||||
const rows = [
|
||||
@@ -397,6 +423,7 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
|
||||
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
|
||||
expect(src).toContain('SCOPED_RANGE_LINES = 150');
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts'));
|
||||
fs.writeFileSync(path.join(dir, 'review-depth.ts'), src.replace('SCOPED_RANGE_LINES = 150', 'SCOPED_RANGE_LINES = 151'));
|
||||
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
||||
const res = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
|
||||
@@ -491,11 +518,11 @@ describe('lean ensemble: one batched specialist at standard and full depth (GSTA
|
||||
expect(specialists(full).map(d => [d.name, d.effort, d.checklists])).toEqual([['batched', 'medium', ['testing', 'security', 'state-machines']]]);
|
||||
});
|
||||
test('a standard code plan reads one batched reader on its own hunks, and a forced checklist joins it', () => {
|
||||
const p = planReview(base({ diffLines: 400, scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' } }));
|
||||
const p = planReview(base({ diffLines: 400, floor: 'standard', scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' } }));
|
||||
expect(specialists(p).map(d => [d.name, d.checklists])).toEqual([['batched', ['testing', 'security']]]);
|
||||
expect(roles(p)).toContain('red-team:red-team:full');
|
||||
expect(batch(planReview(base({ force: ['performance', 'simplification'] })))).toEqual(['testing', 'security', 'performance', 'simplification']);
|
||||
expect(specialists(planReview(base({ tiers: false })))[0]!.effort).toBe('host');
|
||||
expect(batch(planReview(base({ floor: 'standard', force: ['performance', 'simplification'] })))).toEqual(['testing', 'security', 'performance', 'simplification']);
|
||||
expect(specialists(planReview(base({ floor: 'standard', tiers: false })))[0]!.effort).toBe('host');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ const NO_LOSS_REPLAYS = {
|
||||
'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)",
|
||||
'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)",
|
||||
'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged",
|
||||
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
|
||||
} as const;
|
||||
type Replay = keyof typeof NO_LOSS_REPLAYS;
|
||||
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
||||
@@ -77,7 +78,8 @@ const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
||||
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
|
||||
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
|
||||
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
|
||||
'lib/review-depth.ts': { sha: '4542785a1e217d8dd054985978b42c055b9fd79082579cb7664f187f7066e48e', replay: 'GST-257' },
|
||||
'lib/review-depth.ts': { sha: 'd649569a163c03476c6e0c5ea295f5d02088f96aab85fbc7d558f2ac2b27006a', replay: 'R51' },
|
||||
'lib/review-risk.ts': { sha: 'dbef7475d467e17340b21a2bacb52a16a7ccb1ee66291900ed2d36dfb5cfb7f2', replay: 'R51' },
|
||||
};
|
||||
|
||||
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
|
||||
|
||||
@@ -522,7 +522,7 @@ describe('Capy pass program (fake host, real bins)', () => {
|
||||
fs.mkdirSync(files, { recursive: true });
|
||||
const fc: Record<string, string> = {};
|
||||
for (const [k, v] of [['fix_list', '1. src/a.ts: guard v80\n'], ['hunks', 'diff --git a/src/a.ts b/src/a.ts\n+export const v81 = 81;\n'], ['tests', 'bun test: 3 pass, 0 fail\n'], ['callers', 'none\n']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); }
|
||||
const planFlags = ['--pass', '2', '--last', 'full', '--fixed-blocking'];
|
||||
const planFlags = ['--pass', '1', '--last', 'full', '--fixed-below'];
|
||||
const missing = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/fc0'), pass: 'x19fc0', planFlags });
|
||||
expect(missing).toContain('STOP: gstack-review-dispatch pass exited');
|
||||
expect(missing).toContain('the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests');
|
||||
|
||||
@@ -35,18 +35,18 @@ describe('gstack-review-plan', () => {
|
||||
});
|
||||
test('--json carries the class, the next step, every dispatch with its effort and the audit effort; --no-tiers renders specialists and audits at host effort', () => {
|
||||
const r = repo({ 'src/a.ts': Array.from({ length: 80 }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n' });
|
||||
const plan = JSON.parse(run(r, ['--json']).stdout);
|
||||
const plan = JSON.parse(run(r, ['--json', '--depth-floor', 'standard']).stdout);
|
||||
expect(plan).toMatchObject({ class: 'code', next: { step: 'full' }, pass: 0 });
|
||||
expect(plan.dispatches.filter((d: any) => d.role === 'specialist').map((d: any) => d.effort)).toEqual(expect.arrayContaining(['medium']));
|
||||
expect(plan.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'medium')).toBe(true);
|
||||
expect(plan.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full');
|
||||
expect(plan.audit_effort).toBe('light');
|
||||
expect(run(r, []).stdout).toContain('DISPATCH: specialist batched effort=medium checklists=testing,security,state-machines');
|
||||
const host = JSON.parse(run(r, ['--json', '--no-tiers']).stdout);
|
||||
expect(run(r, ['--depth-floor', 'standard']).stdout).toContain('DISPATCH: specialist batched effort=medium checklists=testing,security,state-machines');
|
||||
const host = JSON.parse(run(r, ['--json', '--no-tiers', '--depth-floor', 'standard']).stdout);
|
||||
expect(host.audit_effort).toBe('host');
|
||||
expect(host.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'host')).toBe(true);
|
||||
expect(host.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full');
|
||||
expect(run(r, ['--no-tiers']).stdout).toContain('DISPATCH: specialist batched effort=host checklists=testing,security,state-machines');
|
||||
expect(run(r, ['--no-tiers', '--depth-floor', 'standard']).stdout).toContain('DISPATCH: specialist batched effort=host checklists=testing,security,state-machines');
|
||||
});
|
||||
test('a below-floor fix after pass 1 plans one fix-check', () => {
|
||||
const r = run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'full', '--fixed-below']);
|
||||
@@ -132,7 +132,7 @@ describe('gstack-review-plan: review depth', () => {
|
||||
const r = repo({ 'src/a.ts': code(10), 'test/a.test.ts': code(200) });
|
||||
const out = run(r, []).stdout;
|
||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||
expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10<=50 codes=none$/m);
|
||||
expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10 codes=none$/m);
|
||||
expect(out).toContain(`GATE_SCAN: tagged=0 head=${head}`);
|
||||
expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,security,state-machines,red-team');
|
||||
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)');
|
||||
@@ -149,7 +149,7 @@ describe('gstack-review-plan: review depth', () => {
|
||||
});
|
||||
test('--json carries depth, runtime_lines, rules, errors, gate_scan and every dispatch\'s exclude array', () => {
|
||||
const r = repo({ 'src/a.ts': code(80), 'README.md': '# y\n' });
|
||||
const plan = JSON.parse(run(r, ['--json']).stdout);
|
||||
const plan = JSON.parse(run(r, ['--json', '--depth-floor', 'standard']).stdout);
|
||||
expect(plan).toMatchObject({ depth: 'standard', step_depth: 'standard', runtime_lines: 80, errors: [], codes: [], gate_scan: { tagged: 0 } });
|
||||
expect(plan.rules).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(plan.dispatches.find((d: any) => d.name === 'batched').exclude).toEqual([':(exclude)README.md']);
|
||||
@@ -157,7 +157,7 @@ describe('gstack-review-plan: review depth', () => {
|
||||
});
|
||||
test('--depth-floor full raises a light diff, never lowers a full one, and an invalid floor is a usage error', () => {
|
||||
const r = repo({ 'src/a.ts': code(5) });
|
||||
expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5<=50,floor:full /m);
|
||||
expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5,floor:full /m);
|
||||
const gate = repo({ 'src/a.ts': code(5), '.github/workflows/ci.yml': 'on: push\n' });
|
||||
expect(run(gate, ['--depth-floor', 'standard']).stdout).toMatch(/^REVIEW_DEPTH: full /m);
|
||||
expect(run(r, ['--depth-floor', 'light']).status).toBe(2);
|
||||
@@ -190,7 +190,7 @@ describe('gstack-review-plan: the scoped second pass (GSTA-152 D6)', () => {
|
||||
const out = run(r, [...pass2, '--range-base', rangeBase]).stdout;
|
||||
expect(out).toMatch(/^REVIEW_PLAN: class=code pass=2 next=scoped dispatches=3 /m);
|
||||
expect(out).toContain(`FIX_RANGE: runtime_lines=24 base=${rangeBase}`);
|
||||
expect(out).toMatch(/^NEXT: scoped \(a blocking finding was fixed on a standard diff with 24 runtime lines in the fix range/m);
|
||||
expect(out).toMatch(/^NEXT: scoped \(a blocking finding was fixed on a light diff with 24 runtime lines in the fix range/m);
|
||||
expect(out).toContain('DISPATCH: fix-check fix-check effort=full');
|
||||
expect(out).toContain('DISPATCH: red-team red-team effort=full');
|
||||
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full');
|
||||
@@ -221,7 +221,7 @@ describe('gstack-review-plan: the scoped second pass (GSTA-152 D6)', () => {
|
||||
fs.writeFileSync(path.join(r.dir, 'src', 'b.ts'), code(2, 'b'));
|
||||
git('add', '.'); git('commit', '-qm', 'fix: revert the workflow edit');
|
||||
const out = run(r, [...pass2, '--range-base', rangeBase]).stdout;
|
||||
expect(out).toMatch(/^REVIEW_DEPTH: standard /m);
|
||||
expect(out).toMatch(/^REVIEW_DEPTH: light .* step=standard$/m);
|
||||
expect(out).toMatch(/^NEXT: full \(a blocking finding was fixed and a fix touched a gate-class path/m);
|
||||
expect(JSON.parse(run(r, [...pass2, '--range-base', rangeBase, '--json']).stdout)).toMatchObject({ next: { step: 'full' }, fix_range: { touched_gate: true } });
|
||||
}, 30_000);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* R51: review scales to risk, not size (docs/review-policy.md#review-depth, #risk-classes). The risk classes
|
||||
* (lib/review-risk.ts), the round cap, and the routing table: every gate,
|
||||
* security, user-data and money fixture still plans the full panel, and non-risky code is light at any size.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyDiff, nextStep, planReview, scanNumbers, type PlanInput } from '../lib/review-depth';
|
||||
import { matchRisk, RISK_RULES } from '../lib/review-risk';
|
||||
import { targetedClasses } from '../lib/review-targeted';
|
||||
import { parseReviewLoop, receiptSummaryLine } from '../lib/ship-receipt';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const risk = (codePaths: string[], added: Record<string, string[]> = {}, scopeAuth = false) => [...matchRisk({ codePaths, added, removed: {}, scopeAuth }).keys()];
|
||||
|
||||
describe('risk classes', () => {
|
||||
// Value: protects=security and user-data code reaches the full panel; fails_when=a trust-boundary or data-loss signal is missed,
|
||||
// or an unrelated path or line raises a class; seam=matchRisk
|
||||
test('security and data: paths and lines, each with a negative', () => {
|
||||
expect(risk(['src/core/trust/owner-actions.ts'])).toEqual(['security']);
|
||||
expect(risk(['lib/redact-patterns.ts'])).toEqual(['security']);
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': ['const child = spawn(cmd, { shell: true });'] })).toEqual(['security']);
|
||||
expect(risk(['src/view.tsx'], { 'src/view.tsx': ['<div dangerouslySetInnerHTML={{ __html: body }} />'] })).toEqual(['security']);
|
||||
expect(risk(['bin/x.sh'], { 'bin/x.sh': ['eval "$CMD"'] })).toEqual(['security']);
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': ['const m = re.exec(line);', 'const s = trustee.name;'] })).toEqual([]);
|
||||
expect(risk(['src/core/persistence/git-door.ts'])).toEqual(['data']);
|
||||
expect(risk(['src/db/users.ts'], { 'src/db/users.ts': ['await sql`DELETE FROM users WHERE id = ${id}`;'] })).toEqual(['data']);
|
||||
expect(risk(['app/models/user.rb'], { 'app/models/user.rb': ['User.where(stale: true).destroy_all'] })).toEqual(['data']);
|
||||
expect(risk(['src/list.ts'], { 'src/list.ts': ['// delete from the list when done', 'arr.splice(i, 1);'] })).toEqual([]);
|
||||
for (const line of ['db.exec("delete from users");', 'await sql`delete from sessions where id = 1`;', 'DELETE FROM users;', 'drop database app;', 'DROP SCHEMA s;',
|
||||
'await sql`DELETE FROM ${table} WHERE id = ?`;', 'DELETE FROM users u WHERE u.stale', 'DELETE FROM sessions USING users WHERE x', 'DELETE FROM users RETURNING id', 'const q = "DELETE FROM " + table;'])
|
||||
expect(risk(['src/db.ts'], { 'src/db.ts': [line] })).toEqual(['data']);
|
||||
for (const line of ["const r = spawn('sh', ['-c', cmd], { timeout: 1 });", "Bun.spawn(['bash', '-lc', x]);", "fs.rmSync(t, { recursive: true, force: true });", 'rm -rf "$DIR"'])
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual(['security']);
|
||||
expect(risk(['scripts/test-pr-profile.ts', 'lib/storyboard.ts'])).toEqual([]);
|
||||
expect(RISK_RULES).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
// Value: protects=the targeted pass keeps its exact classes now that its matchers live in lib/review-risk.ts; fails_when=the move
|
||||
// changes a money, auth or secret verdict; seam=targetedClasses over matchRisk
|
||||
test('money, auth and secret come from the same matchers the targeted pass reads', () => {
|
||||
const lines = { 'src/run.ts': ['const spend_cap_usd = 5;', 'const k = process.env.OPENAI_API_KEY;'] };
|
||||
expect(risk(['src/run.ts', 'src/auth/session.ts'], lines, true)).toEqual(['money', 'auth', 'secret']);
|
||||
expect(targetedClasses({ paths: ['src/run.ts', 'src/auth/session.ts'], added: lines, removed: {}, scope: { SCOPE_AUTH: 'true' }, gateTagged: 0 }).classes).toEqual(['money', 'auth', 'secret']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI text and SVGs stay code (R51: the docs paths are deferred to an allowlist classifier)', () => {
|
||||
// Value: protects=a copy edit, a Vue binding or a namespaced SVG script never plans as docs; fails_when=any of them leaves the code
|
||||
// class or its lines skip the risk scan; seam=classifyDiff
|
||||
test.each([
|
||||
['a JSX copy edit', 'src/App.tsx', ['<h1>Old copy</h1>'], ['<h1>New copy</h1>']],
|
||||
['a Vue binding', 'src/App.vue', ['<div :title="user.name">Profile</div>'], ['<div :title="leak(document.cookie)">Profile</div>']],
|
||||
['a locale value', 'locales/en.yml', ['save: "Save"'], ['save: "<img src=x onerror=alert(1)>"']],
|
||||
['a namespaced SVG script', 'docs/chart.svg', [], ['<svg xmlns:s="http://www.w3.org/2000/svg"><s:script>window.x=42</s:script></svg>']],
|
||||
])('%s is code', (_n, p, removed, added) => {
|
||||
expect(classifyDiff({ paths: [p], gateTagged: 0, added: { [p]: added }, removed: { [p]: removed }, perPath: [{ path: p, lines: removed.length + added.length, binary: false }] }).class).toBe('code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round 2 never ships an unread gate edit', () => {
|
||||
// Value: protects=a gate-class edit after round 1 always gets the owner's eyes; fails_when=a round-2 or fix-check gate edit
|
||||
// returns done; seam=nextStep
|
||||
test('a gate edit in round 2 or in the fix-check stops for the owner; a plain below-floor fix there finishes', () => {
|
||||
expect(nextStep({ fullPasses: 2, last: 'full', fixedBelow: true, touchedGate: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('gate-class') });
|
||||
expect(nextStep({ fullPasses: 1, last: 'fix-check', touchedGate: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('gate-class') });
|
||||
expect(nextStep({ fullPasses: 2, last: 'full', fixedBelow: true }).step).toBe('done');
|
||||
});
|
||||
});
|
||||
|
||||
const none = scanNumbers({});
|
||||
const plan = (o: Partial<PlanInput>) => planReview({ paths: ['src/app.ts'], scope: { SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 10, numbers: none, history: { fullPasses: 0, last: 'none' }, ...o });
|
||||
const roles = (p: ReturnType<typeof planReview>) => [...new Set(p.dispatches.map(d => d.role))];
|
||||
const FULL_PANEL = ['specialist', 'red-team', 'adversarial-native', 'outside-voice'];
|
||||
const captured = (name: string): Partial<PlanInput> => {
|
||||
const x = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-depth', name + '.json'), 'utf8'));
|
||||
return { paths: x.paths, deleted: x.deleted, stampOnly: x.stamp_only ?? [], scope: x.scope, gateTagged: x.gate_tagged, diffLines: x.diff_lines, numbers: x.numbers, perPath: x.per_path };
|
||||
};
|
||||
const one = (p: string, lines: string[] = ['export const a = 1;']) => ({ paths: [p], added: { [p]: lines }, removed: {}, perPath: [{ path: p, lines: lines.length, binary: false }] });
|
||||
|
||||
describe('routing: every gate, security, user-data and money fixture plans the full panel', () => {
|
||||
// Value: protects=R51 never lowers review on risky code; fails_when=a gate or risk fixture plans less than outside voice, Red Team,
|
||||
// batched specialist and the native adversarial pass, or loses the targeted reader it had; seam=planReview
|
||||
test.each([
|
||||
['captured gate fixture', captured('code'), []],
|
||||
['gate path', one('.github/workflows/ci.yml', ['on: push']), []],
|
||||
['gate tag', { ...one('src/a.ts'), gateTagged: 2 }, ['gate']],
|
||||
['deleted test', { ...one('src/a.ts'), deleted: ['test/a.test.ts'] }, []],
|
||||
['auth scope', { ...one('src/auth/session.ts'), scope: { SCOPE_AUTH: 'true' } }, ['auth']],
|
||||
['migration scope', { ...one('db/migrate/001.sql', ['ALTER TABLE users ADD COLUMN email text;']), scope: { SCOPE_MIGRATIONS: 'true' } }, []],
|
||||
['secret path', one('lib/credentials.ts'), ['secret']],
|
||||
['secret line', one('src/client.ts', ['const k = process.env.OPENAI_API_KEY;']), ['secret']],
|
||||
['money path', one('src/billing/invoice.ts'), ['money']],
|
||||
['money line', one('src/charge.ts', ['export const spend_cap_usd = 5;']), ['money']],
|
||||
['security path', one('lib/redact-patterns.ts'), []],
|
||||
['security line', one('src/run.ts', ['const run = new Function(body);']), []],
|
||||
['user-data path', one('src/core/persistence/store.ts'), []],
|
||||
['user-data line', one('src/db/purge.ts', ['await db.users.deleteMany({ where: { stale: true } });']), []],
|
||||
] as const)('%s', (_n, o, targeted) => {
|
||||
const p = plan({ ...(o as Partial<PlanInput>), targeted: { classes: [...targeted] } });
|
||||
expect(p.depth).toBe('full');
|
||||
expect(FULL_PANEL.filter(r => !roles(p).includes(r as never))).toEqual([]);
|
||||
expect(roles(p).includes('targeted')).toBe(targeted.length > 0);
|
||||
});
|
||||
test('non-risky code is light at any size: one batched reader with the Red Team prompt and the native adversarial', () => {
|
||||
const p = plan({ ...one('src/util.ts', Array.from({ length: 900 }, (_, n) => `export const v${n} = ${n};`)), diffLines: 900 });
|
||||
expect([p.depth, roles(p)]).toEqual(['light', ['specialist', 'adversarial-native']]);
|
||||
const docs = plan({ paths: ['docs/evals/run.md'], perPath: [{ path: 'docs/evals/run.md', lines: 400, binary: false }], numbers: scanNumbers({ 'docs/evals/run.md': ['recall 33/41'] }) });
|
||||
expect([docs.depth, roles(docs)]).toEqual(['light', ['outside-voice']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the receipt records the tier and the rounds', () => {
|
||||
// Value: protects=a receipt carries review_loop.rounds and the summary line prints depth and rounds; fails_when=the parser drops
|
||||
// rounds or a legacy loop without it gains one; seam=parseReviewLoop and receiptSummaryLine
|
||||
test('rounds parse and print; a legacy loop prints no rounds', () => {
|
||||
expect(parseReviewLoop({ passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, rounds: 2 })).toMatchObject({ rounds: 2 });
|
||||
const r = { pr: 7, head: 'a'.repeat(40), base: 'main', review_depth: { depth: 'light' }, review_loop: { passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, rounds: 2 } } as never;
|
||||
expect(receiptSummaryLine(r)).toContain('depth=light passes=1 blocking_found=0 deferred=0 fix_checks=1 rounds=2');
|
||||
const legacy = { pr: 7, head: 'a'.repeat(40), base: 'main', review_loop: { passes: 2, blocking_found: 1, deferred: 0 } } as never;
|
||||
expect(receiptSummaryLine(legacy)).not.toContain('rounds=');
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe('late defers from Step 11', () => {
|
||||
{ skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:25:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true, action: 'deferred' }] },
|
||||
];
|
||||
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
|
||||
expect(s).toEqual({ passes: 2, blocking_found: 3, deferred: 2, noted: 0, fixed: 1, scope: 'frozen' });
|
||||
expect(s).toEqual({ passes: 2, blocking_found: 3, deferred: 2, noted: 0, fixed: 1, scope: 'frozen', rounds: 2 });
|
||||
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=2 noted=0 fixed=1 scope=frozen');
|
||||
expect(LATE_DEFER_SKILL).toBe('review-late-defer');
|
||||
// A `review` row logged without a start token would be the newest review row and read UNVERIFIED; the late-defer row is not a diff review at all.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* #3541: `gstack-review-scope summary` reads the review log through
|
||||
* gstack-review-read, a bash script. Spawned by path it never starts on
|
||||
* Windows (no shebangs), so summary always refused with an empty
|
||||
* "gstack-review-read failed:" and /ship could not write its review-loop file.
|
||||
*
|
||||
* Windows-safe by construction: both bins launch through
|
||||
* lib/script-invocation.ts (Git Bash on win32), state lives under os.tmpdir().
|
||||
*/
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SLUG = 'review-scope-launch';
|
||||
|
||||
let tmp = '', repo = '', state = '', env: NodeJS.ProcessEnv = {};
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rs-launch-')));
|
||||
repo = path.join(tmp, 'repo');
|
||||
state = path.join(tmp, 'state');
|
||||
fs.mkdirSync(repo);
|
||||
fs.mkdirSync(path.join(state, 'projects', SLUG), { recursive: true });
|
||||
const gitconfig = path.join(tmp, 'gitconfig');
|
||||
fs.writeFileSync(gitconfig, '');
|
||||
env = {
|
||||
...process.env, HOME: tmp, USERPROFILE: tmp, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, GSTACK_PROJECT_SLUG: SLUG,
|
||||
GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined,
|
||||
};
|
||||
const git = (...args: string[]) => {
|
||||
const r = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
};
|
||||
git('init', '-q', '-b', 'feat');
|
||||
fs.writeFileSync(path.join(repo, 'a.txt'), 'a\n');
|
||||
git('add', 'a.txt');
|
||||
git('-c', 'user.email=fixture@example.test', '-c', 'user.name=Fixture', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture');
|
||||
});
|
||||
|
||||
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
test('summary reads the review log through gstack-review-read (#3541)', () => {
|
||||
const rows = [
|
||||
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', review_scope: 'frozen', findings: [{ fingerprint: 'a:1:x', blocking: true, action: 'fixed' }] },
|
||||
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:30:00Z', review_scope: 'frozen', findings: [] },
|
||||
];
|
||||
fs.writeFileSync(path.join(state, 'projects', SLUG, 'feat-reviews.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
const out = path.join(tmp, 'review-loop.json');
|
||||
const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-review-scope'), ['summary', '--since', '2026-10-26T09:00:00Z', '--out', out, '--json'], {
|
||||
cwd: repo, env, encoding: 'utf8', timeout: 120_000,
|
||||
});
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const summary = JSON.parse(fs.readFileSync(out, 'utf8'));
|
||||
expect(summary).toMatchObject({ passes: 2, blocking_found: 1, fixed: 1, scope: 'frozen' });
|
||||
}, 150_000);
|
||||
@@ -208,7 +208,7 @@ describe('loop summary', () => {
|
||||
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true }] },
|
||||
];
|
||||
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
|
||||
expect(s).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' });
|
||||
expect(s).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 });
|
||||
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
|
||||
});
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('loop summary', () => {
|
||||
{ skill: 'review', via: 'ship', review_binding: { state: 'changed', started_at: '2026-10-26T09:00:00.000Z' }, findings: [{ fingerprint: 'old:1:q', action: 'noted' }] },
|
||||
{ skill: 'review', via: 'ship', findings: [{ fingerprint: 'undated:1:q', action: 'noted' }] },
|
||||
];
|
||||
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen' });
|
||||
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen', rounds: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -421,7 +421,7 @@ describe('gstack-review-scope (bin)', () => {
|
||||
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true, action: 'fixed' }] },
|
||||
];
|
||||
fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n');
|
||||
const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' };
|
||||
const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 };
|
||||
const out = path.join(repo.root, 'review-loop.json');
|
||||
const r = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
|
||||
@@ -110,8 +110,8 @@ describe('planReview dispatches the targeted reader', () => {
|
||||
test('at light, standard and full depth when a class hit, never when none did', () => {
|
||||
expect(plan({ targeted: { classes: ['money'] } })).toMatchObject({ depth: 'light' });
|
||||
expect(roles(plan({ targeted: { classes: ['money'] } }))).toContain('targeted full');
|
||||
expect(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }).depth).toBe('standard');
|
||||
expect(roles(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }))).toContain('targeted full');
|
||||
expect(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] }, floor: 'standard' }).depth).toBe('standard');
|
||||
expect(roles(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] }, floor: 'standard' }))).toContain('targeted full');
|
||||
expect(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }).depth).toBe('full');
|
||||
expect(roles(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }))).toContain('targeted full');
|
||||
expect(roles(plan({ targeted: { classes: [] } }))).not.toContain('targeted full');
|
||||
@@ -183,6 +183,18 @@ function planRepo(f: Fixture) {
|
||||
const run = (args: string[]) => spawnSync(PLAN, ['--base', 'main', ...(args.includes('--gate-tagged') ? [] : ['--gate-tagged', '0']), ...args], { cwd: dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: state } });
|
||||
return { dir, run, write };
|
||||
}
|
||||
/**
|
||||
* R51 (risk, not size): the depth each fixture plans now. Every risk or gate fixture plans full and keeps every role main
|
||||
* dispatched, plus the full panel; a non-risky fixture keeps main's reviewers except the roles listed (size no longer raises it).
|
||||
*/
|
||||
const FULL_PANEL = ['specialist', 'red-team', 'adversarial-native', 'outside-voice'];
|
||||
const R51: Record<string, { depth: string; panel: boolean; dropped?: string[] }> = {
|
||||
'none-light': { depth: 'light', panel: false }, 'none-standard': { depth: 'light', panel: false, dropped: ['outside-voice'] }, 'none-docs': { depth: 'light', panel: false },
|
||||
'none-tests': { depth: 'light', panel: false }, 'none-costume': { depth: 'light', panel: false }, 'none-gate-path': { depth: 'full', panel: true },
|
||||
'secret-light': { depth: 'full', panel: true }, 'secret-path': { depth: 'full', panel: true }, 'secret-removed': { depth: 'full', panel: true }, 'gate-tagged': { depth: 'full', panel: true },
|
||||
'money-light': { depth: 'full', panel: true }, 'money-path': { depth: 'full', panel: true }, 'money-removed': { depth: 'full', panel: true }, 'money-standard': { depth: 'full', panel: true },
|
||||
'auth-path': { depth: 'full', panel: true }, 'money-fix-check': { depth: 'full', panel: false }, 'money-pass2': { depth: 'full', panel: false }, 'gate-repeat': { depth: 'full', panel: false },
|
||||
};
|
||||
const field = (out: string, prefix: string, key: string) => new RegExp(`^${prefix}.* ${key}=(\\S+)`, 'm').exec(out)?.[1];
|
||||
|
||||
describe('gstack-review-plan prints TARGETED and dispatches the targeted reader', () => {
|
||||
@@ -190,17 +202,23 @@ describe('gstack-review-plan prints TARGETED and dispatches the targeted reader'
|
||||
// standard and full depth and on fix-check, scoped and repeat steps, is still dispatched unchanged, and the only addition is the
|
||||
// targeted reader on a class diff; fails_when=the change drops, renames or re-efforts any reviewer, or adds the reader on a diff
|
||||
// with no class; seam=bin/gstack-review-plan end to end; no_claim=that the reader finds the defect (the paid replay's question)
|
||||
test('the dispatch-superset no-loss check: on every fixture the branch dispatches main\'s reviewers plus only the targeted reader', () => {
|
||||
// R51 changes the table: risk and gate fixtures plan full (every role main dispatched, plus the full panel), and the one
|
||||
// non-risky standard fixture drops only the outside voice; fails_when=a risk fixture loses a role main dispatched or misses
|
||||
// the full panel, or a non-risky fixture loses a role the R51 table does not list
|
||||
test('the dispatch-superset no-loss check: risk and gate fixtures keep every role main dispatched plus the full panel; non-risky ones drop only what R51 lists', () => {
|
||||
expect(Object.keys(MAIN).sort()).toEqual(Object.keys(FIXTURES).sort());
|
||||
expect(new Set(Object.values(MAIN).map(m => m.depth))).toEqual(new Set(['light', 'standard', 'full']));
|
||||
expect(Object.keys(R51).sort()).toEqual(Object.keys(FIXTURES).sort());
|
||||
const role = (d: string) => d.split(' ')[0]!;
|
||||
for (const [name, f] of Object.entries(FIXTURES)) {
|
||||
const r = planRepo(f).run(f.args ?? []);
|
||||
expect(r.status, `${name}: ${r.stderr}`).toBe(0);
|
||||
const got = r.stdout.split('\n').filter(l => l.startsWith('DISPATCH: ')).map(l => l.slice('DISPATCH: '.length));
|
||||
const want = MAIN[name]!;
|
||||
expect(r.stdout, name).toContain(`REVIEW_DEPTH: ${want.depth} `);
|
||||
expect(got.filter(d => !want.main.includes(d)), name).toEqual(want.classes === 'none' ? [] : ['targeted targeted effort=full']);
|
||||
expect(want.main.filter(d => !got.includes(d)), name).toEqual([]);
|
||||
const want = MAIN[name]!, now = R51[name]!;
|
||||
expect(r.stdout, name).toContain(`REVIEW_DEPTH: ${now.depth} `);
|
||||
const roles = new Set(got.map(role));
|
||||
expect([...new Set(want.main.map(role))].filter(x => !roles.has(x)), name).toEqual(now.dropped ?? []);
|
||||
if (now.panel) expect(FULL_PANEL.filter(x => !roles.has(x)), name).toEqual([]);
|
||||
expect(roles.has('targeted'), name).toBe(want.classes !== 'none');
|
||||
expect(field(r.stdout, 'TARGETED:', 'classes'), name).toBe(want.classes);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
@@ -18,7 +18,7 @@ import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
const SPAWN_BIN_BY_PATH = /\b(?:spawn|spawnSync|execFile|execFileSync)\(\s*(?:path\.)?join\(\s*(?:BIN_DIR|binDir|BIN|bin|binRoot|BIN_ROOT)\b/;
|
||||
const SPAWN_BIN_BY_PATH = /\b(?:spawn|spawnSync|execFile|execFileSync)\(\s*(?:path\.)?join\(\s*(?:(?:BIN_DIR|binDir|BIN|bin|binRoot|BIN_ROOT)\b|(?:path\.)?dirname\(|import\.meta\.dir\b)/;
|
||||
const SPAWN_BARE_BASH = /\b(?:spawn|spawnSync|execFile|execFileSync|execSync)\(\s*['"]bash['"]|Bun\.spawn(?:Sync)?\(\s*\[\s*['"]bash['"]|\[\s*['"]bash['"]\s*,\s*['"]-l?c['"]/;
|
||||
const COMMENT = /^\s*(?:\/\/|\*|\/\*)/;
|
||||
/** `const X = path.resolve(…, 'bin', 'gstack-…')`: a bin path held in a constant, then spawned by name (#3190's gstack-config). */
|
||||
@@ -34,6 +34,8 @@ const KNOWN_SITES: Record<string, string> = {
|
||||
'bin/gstack-issue-links': 'outside this wave: the gstack-issue-guard spawn moves to runScriptSync',
|
||||
// Found when the bin-constant rule was added (fix wave PR 5, item 10b).
|
||||
'bin/gstack-brain-cache': 'outside this wave: the gstack-config salience read moves to runScriptSync',
|
||||
// Found when the scanner learned the import.meta.url bin path (#3541).
|
||||
'bin/gstack-ship-receipt': 'the gstack-session-kind spawn moves to runScriptSync in the #3541 ship-receipt follow-up',
|
||||
'lib/autoplan-admission.ts': 'returns before the spawn on win32; the bin/gstack-lease touch moves to runScriptSync with that bin\'s owner',
|
||||
};
|
||||
|
||||
@@ -88,6 +90,8 @@ describe('#3197: shebang bins and bash go through lib/script-invocation.ts', ()
|
||||
test('the scanner catches both shapes', () => {
|
||||
expect(SPAWN_BIN_BY_PATH.test("spawnSync(path.join(BIN_DIR, 'gstack-wtree'), [], { timeout: 1 })")).toBe(true);
|
||||
expect(SPAWN_BIN_BY_PATH.test('spawnSync(join(BIN_DIR, "gstack-slug"), { timeout: 1 })')).toBe(true);
|
||||
expect(SPAWN_BIN_BY_PATH.test("spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { timeout: 1 })")).toBe(true);
|
||||
expect(SPAWN_BIN_BY_PATH.test("spawnSync(join(import.meta.dir, 'gstack-slug'), [], { timeout: 1 })")).toBe(true);
|
||||
expect(SPAWN_BARE_BASH.test("spawnSync('bash', ['-c', cmd], { timeout: 1 })")).toBe(true);
|
||||
expect(SPAWN_BARE_BASH.test('const argv = ["bash", "-c", command];')).toBe(true);
|
||||
expect(SPAWN_BARE_BASH.test("spawnSync(process.execPath, [script], { timeout: 1 })")).toBe(false);
|
||||
|
||||
@@ -86,7 +86,7 @@ posixTest('a scripted actor that fixes the diff, defers the pre-existing defect
|
||||
await act(f);
|
||||
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
|
||||
expect(verdict.failures).toEqual([]);
|
||||
expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, scope: 'frozen' });
|
||||
expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, rounds: 2, scope: 'frozen' });
|
||||
const rows = verdict.evidence.rows as Array<Record<string, any>>;
|
||||
expect(rows.map(r => r.pass)).toEqual([1, 2]);
|
||||
expect(rows[0]!.findings.map((x: any) => [x.fingerprint, x.action])).toEqual([[FROZEN_SCOPE_FIX_IDENTITY, 'auto-fixed'], [FROZEN_SCOPE_DEFER_IDENTITY, 'deferred']]);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { laneConcurrency } from '../lib/ship-bin-common';
|
||||
import { lineOf, PREFLIGHT, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(60_000);
|
||||
@@ -107,6 +108,47 @@ describe('gstack-ship-preflight', () => {
|
||||
expect(lineOf(zero.stdout, 'LANE')).toMatch(/^LANE: tests ZERO-RUN exit=0 tests_ran=0/);
|
||||
});
|
||||
|
||||
// Value: protects=Step 5 can run its lanes one at a time, and each lane keeps its own tests_ran (GST-851: GSTA-2 saw six lanes start within
|
||||
// 60 ms on a 4-core machine on gstack-evals#115); fails_when=--lanes serial lanes overlap, a lane loses its count, or a bad --lanes value runs;
|
||||
// why_new=runLanes started every lane at once with no serial mode; seam=runLanes; no_claim=how long a lane takes
|
||||
test('--lanes serial runs one lane at a time and keeps each lane\'s count; a bad --lanes value is usage', () => {
|
||||
const f = fx();
|
||||
const log = path.join(f.root, 'lane-order.log');
|
||||
const lane = (n: string) => `${n}=echo start ${n} >> '${log}'; sleep 1; echo end ${n} >> '${log}'; printf " 2 pass\\n 0 fail\\nRan 2 tests across 1 file. [5.00ms]\\n"`;
|
||||
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lanes', 'serial', '--lane', lane('a'), '--lane', lane('b')]);
|
||||
expect(r.status).toBe(0);
|
||||
const order = fs.readFileSync(log, 'utf8').trim().split('\n');
|
||||
expect(order.length).toBe(4);
|
||||
expect(order.map(l => l.split(' ')[0])).toEqual(['start', 'end', 'start', 'end']);
|
||||
expect(order[1]!.split(' ')[1]).toBe(order[0]!.split(' ')[1]);
|
||||
expect(r.stdout.split('\n').filter(l => l.startsWith('LANE: ')).map(l => l.replace(/ log=\S+$/, ''))).toEqual(['LANE: a pass exit=0 tests_ran=2', 'LANE: b pass exit=0 tests_ran=2']);
|
||||
expect(lineOf(r.stdout, 'LANES')).toBe('LANES: serial (--lanes)');
|
||||
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lanes', 'fast', '--lane', LANE]).status).toBe(2);
|
||||
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE, '--lanes']).status).toBe(2);
|
||||
});
|
||||
|
||||
// Value: protects=parallel lanes report in the order given even when a later lane finishes first, and an unflagged run names the core count
|
||||
// behind its default (GST-851); fails_when=results come back in finish order or the LANES line drops its reason; why_new=runLanes became a
|
||||
// worker pool; seam=runLanes, LANES line; no_claim=which default any given machine picks
|
||||
test('--lanes parallel keeps lane order when a later lane finishes first; unflagged LANES names the core count', () => {
|
||||
const f = fx();
|
||||
const lane = (n: string, wait: number) => `${n}=sleep ${wait}; printf " 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [5.00ms]\\n"`;
|
||||
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lanes', 'parallel', '--lane', lane('slow', 1), '--lane', lane('fast', 0)]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(lineOf(r.stdout, 'LANES')).toBe('LANES: parallel (--lanes)');
|
||||
expect(r.stdout.split('\n').filter(l => l.startsWith('LANE: ')).map(l => l.split(' ')[1])).toEqual(['slow', 'fast']);
|
||||
expect(lineOf(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE]).stdout, 'LANES')).toMatch(/^LANES: (serial|parallel) \(\d+ cores\)$/);
|
||||
});
|
||||
|
||||
// Value: protects=a small machine runs lanes one at a time without a flag, a large one keeps running them together, and the flag overrides
|
||||
// either way (GST-851); fails_when=a 4-core default runs lanes at once or the flag is ignored; why_new=no default existed; seam=laneConcurrency;
|
||||
// no_claim=the core count of any CI runner
|
||||
test('lane concurrency: serial on 4 cores or fewer, parallel above, --lanes overrides', () => {
|
||||
expect([laneConcurrency(undefined, 2), laneConcurrency(undefined, 4), laneConcurrency(undefined, 8)]).toEqual([1, 1, Infinity]);
|
||||
expect([laneConcurrency('serial', 64), laneConcurrency('parallel', 2)]).toEqual([1, Infinity]);
|
||||
expect(laneConcurrency('fast', 8)).toBe('--lanes fast is not serial or parallel');
|
||||
});
|
||||
|
||||
// Value: protects=Step 2's candidates and Step 1.4's carry check are reported for the agent to judge, and an open PR's carry verdict is printed; fails_when=a new bin/ artifact goes unmentioned or the carry check is skipped when a PR is open;
|
||||
// why_new=moved from Steps 1.4 and 2; seam=gh, gstack-ship-receipt carry; no_claim=whether a candidate is a distributable
|
||||
test('a new artifact path is a Step 2 candidate; an open PR runs the carry dry-run and prints its verdict', () => {
|
||||
|
||||
@@ -57,25 +57,26 @@ describe('write records the review depth at the checked-out head', () => {
|
||||
describe('the queue refuses a receipt below the depth its head needs', () => {
|
||||
// Value: protects=a receipt reviewed lighter than its head needs never READYs or seats, and the refusal says what to do;
|
||||
// fails_when=the queue trusts the recorded depth, or prints no required depth, reasons and inputs; seam=bindShippedReceipt
|
||||
test('light on a standard head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => {
|
||||
// R51: size never raises the depth, so the head that needs more than light is a risk-class one (a money path needs full).
|
||||
test('standard on a full (money) head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const head = commit(repo, { 'lib/big.js': lines(80) });
|
||||
const head = commit(repo, { 'lib/billing.js': lines(80) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'standard', runtime_lines: 80 });
|
||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', runtime_lines: 80, risk: ['money'] });
|
||||
const base = repo.git('rev-parse', 'origin/main').slice(0, 12);
|
||||
const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]);
|
||||
const good = bind(fs.readFileSync(body, 'utf8'));
|
||||
expect(good.ok).toBe(true);
|
||||
expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard runtime_lines=80 class=code');
|
||||
expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=full required=full runtime_lines=80 class=code');
|
||||
|
||||
const low = bind(rewrite(body, r => { r.review_depth.depth = 'light'; }));
|
||||
const low = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; }));
|
||||
expect(low.ok).toBe(false);
|
||||
if (low.ok) return;
|
||||
expect(low.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
||||
expect(low.depthLine).toContain('receipt=light required=standard runtime_lines=80');
|
||||
expect(low.depthLine).toContain('receipt=standard required=full runtime_lines=80');
|
||||
expect(low.depthLine).toContain(`inputs=base@${base},registry@${base},gate_scan@${head.slice(0, 12)}`);
|
||||
expect(low.message).toContain('runtime=80>50');
|
||||
expect(low.message).toContain('risk:money');
|
||||
expect(low.message).toContain(`the receipt is unchanged; run /ship on ${head.slice(0, 12)}`);
|
||||
|
||||
const unbound = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; r.review_depth.gate_scan.head = 'a'.repeat(40); }));
|
||||
@@ -218,7 +219,8 @@ describe('the queue and a scoped second pass (GSTA-152 D6)', () => {
|
||||
expect(receiptIn(body).review_loop).toMatchObject({ passes: 2, dispatches: loop.dispatches });
|
||||
const bound = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]);
|
||||
expect(bound.ok).toBe(true);
|
||||
expect(bound.ok && bound.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard');
|
||||
expect(bound.ok && bound.depthLine).toContain('REVIEW_DEPTH: receipt=light required=light');
|
||||
expect(bound.ok && bound.depthLine).toMatch(/ rounds=2$/);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 66,
|
||||
"why": "GST-851: Step 5 prose says lanes run one at a time on 4 or fewer cores, names --lanes serial|parallel and the LANES line"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* #3628: a host that keeps the Stop hook's stdin open after writing its payload
|
||||
* held the hook for as long as the pipe stayed open (fs.readFileSync(0) waits
|
||||
* for EOF), well past the hook's 2 s budget and toward the host's 15 s RPC
|
||||
* window. The hook must return within its budget and still repair the timeline.
|
||||
*
|
||||
* Windows-safe by construction: the hook runs through Bun argv
|
||||
* (process.execPath), state lives under os.tmpdir().
|
||||
*/
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const HOOK_TS = path.join(ROOT, 'hosts', 'claude', 'hooks', 'timeline-stop-hook.ts');
|
||||
const SLUG = 'stop-hook-stdin';
|
||||
const BUDGET_MS = 2_000;
|
||||
const STARTUP_ALLOWANCE_MS = process.platform === 'win32' ? 3_000 : 1_500;
|
||||
|
||||
let home = '', project = '', timeline = '';
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-stdin-home-')));
|
||||
project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-stdin-proj-')));
|
||||
fs.mkdirSync(path.join(home, 'projects', SLUG), { recursive: true });
|
||||
timeline = path.join(home, 'projects', SLUG, 'timeline.jsonl');
|
||||
fs.writeFileSync(timeline, JSON.stringify({ skill: 'review', event: 'started', session: '1-1' }) + '\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
fs.rmSync(project, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runHookHoldingStdin(payload: string | null): Promise<{ code: number | null; ms: number }> {
|
||||
const started = performance.now();
|
||||
const child = Bun.spawn([process.execPath, ...bunIsolationArgs(), HOOK_TS], {
|
||||
stdin: 'pipe', stdout: 'ignore', stderr: 'ignore',
|
||||
env: { ...process.env, GSTACK_HOME: home, GSTACK_STATE_ROOT: home, GSTACK_PROJECT_SLUG: SLUG },
|
||||
});
|
||||
if (payload !== null) { child.stdin.write(payload); child.stdin.flush(); }
|
||||
const kill = setTimeout(() => child.kill(), 10_000);
|
||||
const code = await child.exited;
|
||||
clearTimeout(kill);
|
||||
const ms = performance.now() - started;
|
||||
try { child.stdin.end(); } catch { /* already closed */ }
|
||||
return { code, ms };
|
||||
}
|
||||
|
||||
const repairs = () => fs.readFileSync(timeline, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(e => e.source === 'stop-hook');
|
||||
|
||||
test('a complete payload on a pipe that never closes: returns within budget and repairs (#3628)', async () => {
|
||||
const r = await runHookHoldingStdin(JSON.stringify({ hook_event_name: 'Stop', cwd: project }));
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.ms).toBeLessThan(BUDGET_MS + STARTUP_ALLOWANCE_MS);
|
||||
expect(repairs()).toHaveLength(1);
|
||||
}, 20_000);
|
||||
|
||||
test('an open pipe with no data: returns within budget (#3628)', async () => {
|
||||
const r = await runHookHoldingStdin(null);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.ms).toBeLessThan(BUDGET_MS + STARTUP_ALLOWANCE_MS);
|
||||
}, 20_000);
|
||||
Reference in new issue
Block a user