mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.307.0 ship: proportional review depth (light, standard, full) with depth-checked receipts (#3567)
* feat(ship-receipt): carry across regenerated outputs and onto a reviewed tree after a rebase
A base merge whose only product-side change is a registered output the
pregate regen check proved fresh now carries the receipt. A rebased head
carries when its tree is one the receipt covers and its product patch-id
against the current base matches (RECEIPT_CARRY_TREE_MISMATCH otherwise).
Docs: merge main into a reviewed branch, never rebase it.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: regenerate doctor anchors; type the head_tree assert
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(review): proportional /ship review depth (light, standard, full)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: carry sets aside only outputs the regen check proved it recreates
The regen check deletes each touched output in its scratch worktree before
the command runs and records the ones recreated byte-identically as proven.
gstack-ship-receipt carry exempts only those paths, and only when the check
ran origin/<base>'s registry at the commit the carry reads. A file under an
output glob that the generator never writes stays product (pre-landing
review, #3564 pass 1).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ship-receipt): record review depth, check it in the queue, structured review-loop exception (#3559)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: prove generated outputs in a second regen run; literal names only
The regen verdict comes from the unmodified run again, so a generator that
reads its own registered output (gen-skill-docs and
qa/templates/functional-report-template.md) passes as before; the proof is a
second run with the touched outputs deleted, and its failure only means not
proven. The carry ignores a proven path with glob syntax, since it uses the
paths as pathspec globs (pre-landing review, #3564 pass 2).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: receipt evidence keys and carry record fields; carry refusal fix line
Documentation audit for #3564 (/document-release, audit ship3564-docs-1).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (depth scan binding, queue scope, carry checks)
- carry refuses a --head other than the checked-out HEAD, a receipt with
gstack_version but no review_depth, and an owner exception that no longer
stands; a dirty or foreign checkout makes the write-time gate scan
unavailable (full)
- the queue binds a gate scan only to the full bound commit and recomputes
the scope signals with gstack-diff-scope <base> --head <sha>, the same
range the writer uses
- the queue checks the review-loop exception's fix-check ancestry
- a package.json version line counts as stamp-only only when it is a bare
version key
- docs and help name what review_depth is and which registry the queue reads
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: CHANGELOG for the review fixes; defer two plan items to TODOS
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: queue and carry refusal lists name the depth and exception codes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: approval-classes summary-line regexes allow the receipt's depth field
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-receipt): summary line prints depth before approval
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Revert "test: approval-classes summary-line regexes allow the receipt's depth field"
This reverts commit e9ec264358.
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
de4c694d91
commit
edf4c2e581
43 files changed
+1464
-193
No files matched your search
@@ -367,6 +367,27 @@ describe('uncommitted work is visible (#2299)', () => {
|
||||
expect(vars.SCOPE_BACKEND).toBe('true');
|
||||
});
|
||||
|
||||
// Value: protects=--head scopes one committed range from any checkout, the input the receipt writer and the merge queue
|
||||
// share; fails_when=--head still reads the working tree or untracked files, or an unknown head reads as all-false; seam=none
|
||||
test('--head <sha> scopes only the committed range: working-tree and untracked files are ignored, an unknown head is an error', () => {
|
||||
const dir = createRepo(['lib/auth.ts']);
|
||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, stdio: 'pipe' }).stdout.toString().trim();
|
||||
mkdirSync(join(dir, 'db', 'migrate'), { recursive: true });
|
||||
writeFileSync(join(dir, 'db', 'migrate', '001.rb'), '# untracked\n');
|
||||
writeFileSync(join(dir, 'styles.css'), 'a{}\n');
|
||||
const scope = (args: string[]) => spawnSync('bash', [SCRIPT, 'main', ...args], { cwd: dir, stdio: 'pipe', timeout: 5000 });
|
||||
const ranged = scope(['--head', head]);
|
||||
expect(ranged.status).toBe(0);
|
||||
const out = ranged.stdout.toString();
|
||||
expect(out).toContain('SCOPE_AUTH=true');
|
||||
expect(out).toContain('SCOPE_MIGRATIONS=false');
|
||||
expect(out).toContain('SCOPE_FRONTEND=false');
|
||||
expect(runScopeFull(dir).vars.SCOPE_MIGRATIONS).toBe('true');
|
||||
const unknown = scope(['--head', 'f'.repeat(40)]);
|
||||
expect(unknown.status).toBe(2);
|
||||
expect(unknown.stdout.toString()).toContain('SCOPE_ERROR=no_head');
|
||||
});
|
||||
|
||||
test('non-ASCII path still matches extension globs (NUL-safe file listing, #2526)', () => {
|
||||
const dir = createRepo(['docs/M2 — Notes.md']);
|
||||
const { vars, status } = runScopeFull(dir);
|
||||
|
||||
+5
-5
@@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
|
||||
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
|
||||
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
|
||||
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
**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)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+5
-5
@@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
|
||||
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
|
||||
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
|
||||
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
**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)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+5
-5
@@ -200,11 +200,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
|
||||
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
|
||||
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
|
||||
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
**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)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
+5
-5
@@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
|
||||
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
|
||||
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
|
||||
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
**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)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
@@ -44,11 +44,11 @@ describe('gstack-ship-receipt write', () => {
|
||||
'--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge',
|
||||
'--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--body', body, '--out', path.join(repo.root, 'receipt.md')]);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3`);
|
||||
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3 depth=light`);
|
||||
const parsed = extractReceipt(ok.out);
|
||||
expect(parsed.found).toBe(true);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id']);
|
||||
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth']);
|
||||
expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } });
|
||||
expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}'));
|
||||
expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { collectCommit, collectLive, loadRegistryAt } from '../lib/review-collect';
|
||||
import { depthOfCommitInput } from '../lib/review-depth';
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
|
||||
const lines = (n: number, tag = 'v') => Array.from({ length: n }, (_, i) => `export const ${tag}${i} = ${i};`).join('\n') + '\n';
|
||||
const REGISTRY = JSON.stringify({ entries: [{ id: 'docs', command: 'bun run gen', outputs: ['*/SKILL.md'] }, { id: 'manual', command: null, outputs: ['src/hand.ts'] }] });
|
||||
|
||||
function repo(baseFiles: Record<string, string | Buffer>) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-collect-'));
|
||||
dirs.push(dir);
|
||||
const git = (...a: string[]) => { const r = spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 }); if (r.status !== 0) throw new Error(`git ${a.join(' ')}: ${r.stderr}`); return r.stdout.trim(); };
|
||||
const write = (files: Record<string, string | Buffer | null>) => { for (const [p, text] of Object.entries(files)) { const f = path.join(dir, p); if (text === null) { fs.rmSync(f); continue; } fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); } };
|
||||
git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false');
|
||||
write({ 'README.md': '# x\n', ...baseFiles }); git('add', '-A'); git('commit', '-qm', 'base');
|
||||
const baseSha = git('rev-parse', 'HEAD');
|
||||
git('checkout', '-qb', 'feat');
|
||||
const commit = (files: Record<string, string | Buffer | null>) => { write(files); git('add', '-A'); git('commit', '-qm', 'feat'); return git('rev-parse', 'HEAD'); };
|
||||
return { dir, git, write, commit, baseSha };
|
||||
}
|
||||
|
||||
describe('collectCommit: git objects only', () => {
|
||||
// Value: protects=only a bare version bump in package.json is release metadata; fails_when=a version line that also
|
||||
// carries scripts counts as stamp-only (docs class, light); seam=none
|
||||
test('a package.json version line that also carries scripts is not stamp-only; a bare version bump is', () => {
|
||||
const r = repo({ 'package.json': '{\n "name": "x",\n "version": "1.0.0"\n}\n' });
|
||||
const bare = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.1"\n}\n' });
|
||||
expect(collectCommit(r.dir, r.baseSha, bare).stampOnly).toEqual(['package.json']);
|
||||
const riding = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.2", "scripts": { "postinstall": "node x.js" }\n}\n' });
|
||||
expect(collectCommit(r.dir, r.baseSha, riding).stampOnly).toEqual([]);
|
||||
});
|
||||
test('a registered glob output is excluded, a command:null entry is not, and a .tmpl edit counts once', () => {
|
||||
const r = repo({ '.gstack/generated.json': REGISTRY, 'ship/SKILL.md.tmpl': 'a\n', 'ship/SKILL.md': 'a\n' });
|
||||
const head = r.commit({ 'ship/SKILL.md.tmpl': 'a\nb\nc\n', 'ship/SKILL.md': lines(120), 'src/hand.ts': lines(5) });
|
||||
const c = collectCommit(r.dir, r.baseSha, head);
|
||||
expect(c.errors).toEqual([]);
|
||||
expect(c.registry).toEqual({ sha: r.baseSha, outputs: ['*/SKILL.md'] });
|
||||
const d = depthOfCommitInput({ ...c, gateTagged: 0 });
|
||||
expect([d.depth, d.runtimeLines]).toEqual(['light', 2 + 5]);
|
||||
});
|
||||
test('a head-only registry entry never excludes: the registry is the base commit\'s', () => {
|
||||
const r = repo({ 'src/keep.ts': lines(1) });
|
||||
const head = r.commit({ '.gstack/generated.json': JSON.stringify({ entries: [{ id: 'gen', command: 'gen', outputs: ['src/gen.ts'] }] }), 'src/gen.ts': lines(60) });
|
||||
const c = collectCommit(r.dir, r.baseSha, head);
|
||||
expect(c.registry).toEqual({ sha: r.baseSha, outputs: [] });
|
||||
const d = depthOfCommitInput({ ...c, gateTagged: 0 });
|
||||
expect(d.runtimeLines).toBeGreaterThanOrEqual(60);
|
||||
expect(d.depth).toBe('full');
|
||||
});
|
||||
test('a rename into src/ counts the new path as runtime', () => {
|
||||
const r = repo({ 'notes/a.txt': lines(30) });
|
||||
fs.mkdirSync(path.join(r.dir, 'src'));
|
||||
r.git('mv', 'notes/a.txt', 'src/a.ts');
|
||||
r.git('commit', '-qm', 'mv');
|
||||
const c = collectCommit(r.dir, r.baseSha, r.git('rev-parse', 'HEAD'));
|
||||
expect(c.deleted).toEqual(['notes/a.txt']);
|
||||
expect(c.perPath.find(e => e.path === 'src/a.ts')).toEqual({ path: 'src/a.ts', lines: 30, binary: false });
|
||||
expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 30 });
|
||||
});
|
||||
test('a binary code-class path is an error (full), never 0 lines; a deleted test is full', () => {
|
||||
const r = repo({ 'test/old.test.ts': 'x\n' });
|
||||
const head = r.commit({ 'lib/x.wasm': Buffer.from([0, 97, 115, 109, 0, 1, 2]) });
|
||||
const d = depthOfCommitInput({ ...collectCommit(r.dir, r.baseSha, head), gateTagged: 0 });
|
||||
expect(d.depth).toBe('full');
|
||||
expect(d.errors).toEqual([expect.objectContaining({ code: 'binary_code_path', input: 'lib/x.wasm' })]);
|
||||
const t = repo({ 'test/old.test.ts': 'x\n', 'test/keep.test.ts': 'x\n' });
|
||||
const del = t.commit({ 'test/old.test.ts': null });
|
||||
const deleted = depthOfCommitInput({ ...collectCommit(t.dir, t.baseSha, del), gateTagged: 0 });
|
||||
expect([deleted.depth, deleted.runtimeLines, deleted.errors]).toEqual(['full', 0, []]);
|
||||
});
|
||||
test('an invalid base registry is an error with its result code; an unresolvable sha is unavailable', () => {
|
||||
const r = repo({ '.gstack/generated.json': '{"entries": [{"id": "x"}]}' });
|
||||
const head = r.commit({ 'src/a.ts': lines(3) });
|
||||
const d = depthOfCommitInput({ ...collectCommit(r.dir, r.baseSha, head), gateTagged: 0 });
|
||||
expect(d.depth).toBe('full');
|
||||
expect(d.codes).toEqual(['REGEN_REGISTRY_INVALID']);
|
||||
expect(d.errors[0]).toMatchObject({ code: 'registry_invalid', result: 'REGEN_REGISTRY_INVALID' });
|
||||
expect(d.errors[0]!.repair).toContain('gstack-regen validate');
|
||||
expect(loadRegistryAt(r.dir, 'f'.repeat(40)).error?.code).toBe('registry_unavailable');
|
||||
const missing = collectCommit(r.dir, 'f'.repeat(40), head);
|
||||
expect(missing.errors.map(e => e.code)).toContain('numstat_failed');
|
||||
expect(depthOfCommitInput({ ...missing, gateTagged: 0 }).depth).toBe('full');
|
||||
});
|
||||
test('from a checkout at another HEAD with a dirty tree it equals the clean result at head', () => {
|
||||
const r = repo({ '.gstack/generated.json': REGISTRY, 'src/a.ts': lines(2), 'ship/SKILL.md': 'a\n', 'autoplan/SKILL.md.tmpl': 'x\n' });
|
||||
const head = r.commit({ 'src/a.ts': lines(40), 'ship/SKILL.md': lines(9), 'docs/b.md': '# b\n12% faster\n', 'autoplan/notes.md': 'n\n', 'package.json': '{"version":"1.0.1"}\n' });
|
||||
const clean = collectCommit(r.dir, r.baseSha, head);
|
||||
r.git('checkout', '-q', r.baseSha);
|
||||
r.write({ 'src/a.ts': lines(500, 'dirty'), 'src/untracked.ts': lines(80), 'autoplan/SKILL.md.tmpl': null });
|
||||
const dirty = collectCommit(r.dir, r.baseSha, head);
|
||||
expect(dirty).toEqual(clean);
|
||||
expect(depthOfCommitInput({ ...dirty, gateTagged: 0 })).toEqual(depthOfCommitInput({ ...clean, gateTagged: 0 }));
|
||||
expect(clean.root).toEqual(['autoplan', 'ship']);
|
||||
expect(clean.added['docs/b.md']).toEqual(['# b', '12% faster']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLive: the working tree', () => {
|
||||
test('tracked uncommitted edits and untracked files count per path; scratch under .gstack/tmp/ does not', () => {
|
||||
const r = repo({ 'src/a.ts': lines(1) });
|
||||
r.commit({ 'docs/a.md': '# a\n' });
|
||||
r.write({ 'src/a.ts': lines(11), 'src/new.ts': lines(20), '.gstack/tmp/x.ts': lines(500) });
|
||||
const c = collectLive(r.dir, 'main');
|
||||
expect(c.errors).toEqual([]);
|
||||
expect(c.untracked).toEqual(['src/new.ts']);
|
||||
expect(c.perPath.find(e => e.path === 'src/new.ts')).toEqual({ path: 'src/new.ts', lines: 20, binary: false });
|
||||
expect(c.paths).toEqual(['docs/a.md', 'src/a.ts', 'src/new.ts']);
|
||||
expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 20 + 10 });
|
||||
});
|
||||
test('no merge base is a collector error the CLI refuses on', () => {
|
||||
const r = repo({});
|
||||
expect(collectLive(r.dir, 'no-such-branch').errors.map(e => e.code)).toEqual(['numstat_failed']);
|
||||
});
|
||||
});
|
||||
+126
-4
@@ -1,6 +1,9 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { classifyDiff, classifyPath, nextStep, planReview, scanNumbers, FULL_PASS_CAP, type PlanInput } from '../lib/review-depth';
|
||||
import { classifyDiff, classifyPath, depthFor, depthOfCommitInput, diffCommand, nextStep, planReview, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, type DepthError, type PlanInput } from '../lib/review-depth';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const none = scanNumbers({});
|
||||
@@ -73,9 +76,10 @@ describe('planReview', () => {
|
||||
const p = planReview(base({ paths: ['docs/bench.md'], scope: {}, numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) }));
|
||||
expect(roles(p)).toEqual(['outside-voice:outside-voice:host']);
|
||||
});
|
||||
test('tests class: testing specialist only', () => {
|
||||
test('tests class: runtime 0 is light, core only (Behavior changes: no Testing specialist)', () => {
|
||||
const p = planReview(base({ paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' } }));
|
||||
expect(roles(p)).toEqual(['specialist:testing:full']);
|
||||
expect(p.depth).toBe('light');
|
||||
expect(roles(p)).toEqual([]);
|
||||
});
|
||||
test('code class pass 1: every reviewer at full effort, the outside voice at its own', () => {
|
||||
const p = planReview(base({}));
|
||||
@@ -88,7 +92,7 @@ describe('planReview', () => {
|
||||
});
|
||||
test('code class honours hit-rate gating and small-diff skip', () => {
|
||||
expect(roles(planReview(base({ gated: ['simplification'] })))).not.toContain('specialist:simplification:full');
|
||||
expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist')).toEqual([]);
|
||||
expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist').map(d => d.name)).toEqual(['batched']);
|
||||
});
|
||||
test('pass 2 reruns the army on the range but not the adversarial or outside voice once Step 11 ran', () => {
|
||||
const p = planReview(base({ adversarialRan: true, history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
|
||||
@@ -167,3 +171,121 @@ describe('GSTA-131 coverage: Red Team and specialist arms', () => {
|
||||
expect(roles(planReview(base({ paths: ['docs/a.md'], force: ['security'] })))).toEqual(['specialist:security']);
|
||||
});
|
||||
});
|
||||
|
||||
const per = (o: Record<string, number>) => Object.entries(o).map(([p, lines]) => ({ path: p, lines, binary: false }));
|
||||
const mixed = (o: Partial<PlanInput> = {}) => base({ paths: Object.keys(o.perPath ? Object.fromEntries(o.perPath.map(e => [e.path, 1])) : {}), ...o });
|
||||
const err = (code: DepthError['code'], result?: DepthError['result']): DepthError => ({ code, detail: 'd', input: 'i', repair: 'r', ...(result ? { result } : {}) });
|
||||
|
||||
describe('review depth: the matrix', () => {
|
||||
test('runtime 0 (tests and docs only) is light: core only, nothing dispatched', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'test/a.test.ts': 300, 'docs/a.md': 40 }), diffLines: 340 }));
|
||||
expect([p.depth, p.runtimeLines, p.dispatches]).toEqual(['light', 0, []]);
|
||||
});
|
||||
test('runtime 1-50 is light plus one batched specialist: the fixed set, scope picks and the Red Team in one context', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 48, 'test/a.test.ts': 190, 'docs/a.md': 9 }), diffLines: 247 }));
|
||||
expect([p.depth, p.runtimeLines]).toEqual(['light', 48]);
|
||||
expect(p.dispatches).toEqual([
|
||||
{ role: 'specialist', name: 'batched', effort: 'full', exclude: [':(exclude)docs/a.md'], checklists: ['testing', 'maintainability', 'security', 'performance', 'red-team'] },
|
||||
{ role: 'adversarial-native', name: 'adversarial', effort: 'full', exclude: [], reason: 'backtest inconclusive (GSTA-147 D5)' },
|
||||
]);
|
||||
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', () => {
|
||||
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]).toEqual(['standard', 51]);
|
||||
expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||
const ex = Object.fromEntries(p.dispatches.map(d => [d.name, d.exclude]));
|
||||
expect(ex.testing).toEqual([':(exclude)README.md']);
|
||||
expect(ex.maintainability).toEqual([':(exclude)README.md', ':(exclude)test/a.test.ts']);
|
||||
expect([ex.adversarial, ex['outside-voice']]).toEqual([[], []]);
|
||||
});
|
||||
test.each([
|
||||
['scope error', { scope: { SCOPE_ERROR: 'boom' } }, 'scope_error', []],
|
||||
['gate scan unavailable', { gateTagged: null }, 'gate_scan_unavailable', []],
|
||||
['base registry invalid', { registry: { sha: 'abc', outputs: [], error: err('registry_invalid', 'REGEN_REGISTRY_INVALID') } }, 'registry_invalid', ['REGEN_REGISTRY_INVALID']],
|
||||
['base registry unavailable', { registry: { sha: null, outputs: [], error: err('registry_unavailable') } }, 'registry_unavailable', []],
|
||||
['numstat failure', { errors: [err('numstat_failed')] }, 'numstat_failed', []],
|
||||
['binary code path', { perPath: [...per({ 'src/app.ts': 5 }), { path: 'lib/x.wasm', lines: 0, binary: true }], paths: ['src/app.ts', 'lib/x.wasm'] }, 'binary_code_path', []],
|
||||
] as const)('%s fails closed to full with its code', (_n, o, code, codes) => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 5 }), ...(o as Partial<PlanInput>) }));
|
||||
expect(p.depth).toBe('full');
|
||||
expect(p.errors.map(e => e.code)).toContain(code);
|
||||
expect(p.codes).toEqual([...codes]);
|
||||
expect(roles(p)).toContain('red-team:red-team:full');
|
||||
});
|
||||
test.each([
|
||||
['a gate path', { paths: ['src/app.ts', '.github/workflows/ci.yml'] }],
|
||||
['a gate tag', { gateTagged: 1 }],
|
||||
['a deleted test', { deleted: ['test/old.test.ts'] }],
|
||||
['an edit to the registry', { paths: ['.gstack/generated.json'] }],
|
||||
['an edit to SMALL_DIFF_LINES', { paths: ['lib/review-depth.ts'] }],
|
||||
['the collector', { paths: ['lib/review-collect.ts'] }],
|
||||
['the plan CLI', { paths: ['bin/gstack-review-plan'] }],
|
||||
['the queue', { paths: ['lib/ship-queue-train.ts'] }],
|
||||
['the Step 9 template', { paths: ['ship/sections/review-army.md.tmpl'] }],
|
||||
] as const)('%s is full', (_n, o) => {
|
||||
const paths = (o as any).paths ?? ['src/app.ts'];
|
||||
expect(planReview(mixed({ perPath: per(Object.fromEntries(paths.map((p: string) => [p, 2]))), ...(o as Partial<PlanInput>) })).depth).toBe('full');
|
||||
});
|
||||
test('a registered glob output is excluded from runtime; a .tmpl edit plus its generated SKILL.md counts the tmpl only', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'ship/SKILL.md.tmpl': 30, 'ship/SKILL.md': 200 }), registry: { sha: 'abc', outputs: ['*/SKILL.md'] } }));
|
||||
expect([p.depth, p.runtimeLines]).toEqual(['light', 30]);
|
||||
expect(p.dispatches[0]!.exclude).toEqual([':(exclude)ship/SKILL.md']);
|
||||
});
|
||||
test('a path no base-registry entry names stays runtime', () => {
|
||||
expect(planReview(mixed({ perPath: per({ 'src/gen.ts': 60 }), registry: { sha: 'abc', outputs: ['lib/dom.js'] } })).runtimeLines).toBe(60);
|
||||
});
|
||||
test('numbers in light dispatch the outside voice with an empty exclude', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'docs/bench.md': 4 }), numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) }));
|
||||
expect(p.depth).toBe('light');
|
||||
expect(p.dispatches).toEqual([{ role: 'outside-voice', name: 'outside-voice', effort: 'host', exclude: [] }]);
|
||||
});
|
||||
test('a fixed blocker in light raises the next full step to the standard set, Red Team included', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 30 }), history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
|
||||
expect([p.depth, p.stepDepth]).toEqual(['light', 'standard']);
|
||||
expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'red-team:red-team:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||
});
|
||||
test('--depth-floor raises and never lowers', () => {
|
||||
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');
|
||||
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, gateTagged: null }).depth).toBe('full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review depth: diff commands and the rules hash', () => {
|
||||
test('DIFF_CMD shell-quotes each pathspec; a glob character makes the pathspec literal', () => {
|
||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10, "docs/my notes*.md": 3, "docs/it's.md": 1 }) }));
|
||||
const ex = p.dispatches[0]!.exclude;
|
||||
expect(ex).toEqual([':(exclude,literal)docs/my notes*.md', ":(exclude)docs/it's.md"]);
|
||||
expect(diffCommand(p.dispatches[0]!)).toBe("git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)docs/it'\\''s.md'");
|
||||
expect(diffCommand({ role: 'red-team', name: 'red-team', effort: 'full', exclude: [] })).toBe('git diff <diff-base>');
|
||||
});
|
||||
test('DEPTH_RULES hashes the deciding constants: a comment-only edit keeps it, a threshold edit changes it', () => {
|
||||
expect(DEPTH_RULES).toMatch(/^[0-9a-f]{16}$/);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'depth-rules-'));
|
||||
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'));
|
||||
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");
|
||||
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
expect(hashOf('// a comment-only edit\n' + src)).toBe(DEPTH_RULES);
|
||||
expect(hashOf(src.replace('SMALL_DIFF_LINES = 50', 'SMALL_DIFF_LINES = 60'))).not.toBe(DEPTH_RULES);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('scripts/review-dispatch-compare.ts', () => {
|
||||
const table = run(['--tokens', tok]);
|
||||
expect(table).toContain(`TOKENS: measured (${tok})`);
|
||||
expect(table).toMatch(/^SHIP_TOKEN_RATIO: median=0\.\d+ min=0\.\d+ max=\d\.\d+ \(n=10\)$/m);
|
||||
expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); code median=0\.\d+ \(n=5\)$/m);
|
||||
expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); gate median=0\.\d+ \(n=5\)$/m);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -109,3 +109,51 @@ describe('gstack-review-plan', () => {
|
||||
expect(ran).toContain('SKIP: adversarial-native and outside-voice (run once per invocation)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-review-plan: review depth', () => {
|
||||
const code = (n: number) => Array.from({ length: n }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n';
|
||||
test('prints REVIEW_DEPTH with runtime lines and the rules hash, and GATE_SCAN bound to the head', () => {
|
||||
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).toContain(`GATE_SCAN: tagged=0 head=${head}`);
|
||||
expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,maintainability,security,performance,state-machines,red-team');
|
||||
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)');
|
||||
});
|
||||
test('DIFF_CMD is shell-quoted per dispatch and excludes what the reviewer does not read; DIFF_EXCLUDE: none when empty', () => {
|
||||
const r = repo({ 'src/a.ts': code(80), 'docs/my notes*.md': '# n\n', 'test/a.test.ts': code(3) });
|
||||
const out = run(r, []).stdout;
|
||||
expect(out).toContain("DIFF_CMD: testing git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md'");
|
||||
expect(out).toContain("DIFF_CMD: maintainability git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)test/a.test.ts'");
|
||||
expect(out).toContain('DIFF_CMD: adversarial git diff <diff-base>\nDIFF_EXCLUDE: adversarial none');
|
||||
const cmd = /^DIFF_CMD: maintainability (.*)$/m.exec(out)![1]!.replace('<diff-base>', 'main');
|
||||
const diff = spawnSync('bash', ['-c', cmd.replace('git diff main', 'git diff --name-only main')], { cwd: r.dir, encoding: 'utf8' });
|
||||
expect(diff.stdout.trim().split('\n')).toEqual(['src/a.ts']);
|
||||
});
|
||||
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);
|
||||
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 === 'maintainability').exclude).toEqual([':(exclude)README.md']);
|
||||
expect(plan.dispatches.find((d: any) => d.role === 'outside-voice').exclude).toEqual([]);
|
||||
});
|
||||
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);
|
||||
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);
|
||||
});
|
||||
test('a conservative full plan exits 0 with its errors; a plan that cannot be built exits 1', () => {
|
||||
const r = repo({ 'lib/x.wasm': '\u0000asm\u0000' });
|
||||
const res = run(r, ['--json']);
|
||||
expect(res.status).toBe(0);
|
||||
expect(JSON.parse(res.stdout)).toMatchObject({ depth: 'full', errors: expect.arrayContaining([expect.objectContaining({ code: 'binary_code_path', input: 'lib/x.wasm' })]) });
|
||||
expect(run(r, []).stdout).toContain('DEPTH_ERROR: binary_code_path input=lib/x.wasm');
|
||||
const none = spawnSync(BIN, ['--base', 'no-such-branch', '--gate-tagged', '0'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
|
||||
expect(none.status).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* The /ship receipt's review depth and review-loop exception (GSTA-147 PR 2,
|
||||
* docs/review-policy.md#review-depth, #3559): write records the depth gstack
|
||||
* computes at the checked-out head, the queue recomputes the depth the head
|
||||
* needs and refuses a receipt below it, a carry rebinds the gate scan or
|
||||
* refuses when the head needs more, and the review-loop exception is checked
|
||||
* like the documentation exception.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { bindShippedReceipt, receiptDepthVerdict, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
|
||||
const writeReceipt = (repo: FixtureRepo, body: string, extra: string[] = []) =>
|
||||
runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body, ...extra]);
|
||||
const lines = (n: number) => Array.from({ length: n }, (_, i) => `exports.f${i} = () => ${i};`).join('\n') + '\n';
|
||||
const rewrite = (body: string, edit: (r: Record<string, any>) => void) => {
|
||||
const text = fs.readFileSync(body, 'utf8');
|
||||
const r = extractReceipt(text).receipt as Record<string, any>;
|
||||
edit(r);
|
||||
fs.writeFileSync(body, text.replace(/```gstack-ship-receipt[\s\S]*?\n```\n/, renderReceiptBlock(r as any)));
|
||||
return fs.readFileSync(body, 'utf8');
|
||||
};
|
||||
|
||||
describe('write records the review depth at the checked-out head', () => {
|
||||
// Value: protects=the receipt names the depth and a gate scan bound to the head it is written for, and never binds a
|
||||
// tree it did not review; fails_when=write records no depth, or --head accepts a commit other than HEAD; seam=none
|
||||
test('a light code diff records depth, runtime lines, the base and the gate scan at HEAD; --head of another commit writes nothing', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const older = commit(repo, { 'lib/util.js': lines(3) });
|
||||
const head = commit(repo, { 'lib/more.js': lines(2) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
const ok = writeReceipt(repo, body);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.err).toContain('depth=light');
|
||||
const r = receiptIn(body);
|
||||
expect(r.gstack_version).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
const base = repo.git('rev-parse', 'origin/main');
|
||||
expect(r.review_depth).toMatchObject({ depth: 'light', class: 'code', base, registry: base, gate_scan: { tagged: 0, head } });
|
||||
expect(r.review_depth!.runtime_lines).toBeGreaterThanOrEqual(5);
|
||||
expect(r.review_depth!.runtime_lines).toBeLessThanOrEqual(50);
|
||||
const other = path.join(repo.root, 'other.md');
|
||||
const bad = writeReceipt(repo, other, ['--head', older]);
|
||||
expect(bad.status).toBe(EXIT.fail);
|
||||
expect(bad.err).toContain(`--head ${older.slice(0, 12)} is not the checked-out HEAD ${head.slice(0, 12)}`);
|
||||
expect(bad.err).toContain('(RECEIPT_INVALID)');
|
||||
expect(fs.existsSync(other)).toBe(false);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const head = commit(repo, { 'lib/big.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 });
|
||||
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');
|
||||
|
||||
const low = bind(rewrite(body, r => { r.review_depth.depth = 'light'; }));
|
||||
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(`inputs=base@${base},registry@${base},gate_scan@${head.slice(0, 12)}`);
|
||||
expect(low.message).toContain('runtime=80>50');
|
||||
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); }));
|
||||
expect(unbound.ok).toBe(false);
|
||||
expect(!unbound.ok && unbound.depthLine).toContain('required=full');
|
||||
expect(!unbound.ok && unbound.message).toContain('gate_scan_unavailable');
|
||||
|
||||
const none = bind(rewrite(body, r => { delete r.review_depth; }));
|
||||
expect(!none.ok && none.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
||||
expect(!none.ok && none.message).toContain('records no review_depth though its writer records one');
|
||||
|
||||
const legacy = rewrite(body, r => { delete r.gstack_version; });
|
||||
const verdict = receiptDepthVerdict(repo.work, 'origin/main', extractReceipt(legacy).receipt!, head);
|
||||
expect(verdict).toEqual({ ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' });
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the queue's required depth keeps the scope signals the writer used, and a gate scan binds only the full
|
||||
// bound commit; fails_when=the queue recomputes without diff-scope (auth reads light) or binds an empty or short scan head
|
||||
// by prefix; seam=bindShippedReceipt
|
||||
test('an auth path needs full at the queue though the receipt says light; an empty or short gate scan head binds nothing', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const head = commit(repo, { 'lib/auth.js': lines(3) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', class: 'gate' });
|
||||
const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]);
|
||||
const light = bind(rewrite(body, r => { r.review_depth.depth = 'light'; }));
|
||||
expect(!light.ok && light.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
||||
expect(!light.ok && light.depthLine).toContain('receipt=light required=full');
|
||||
expect(!light.ok && light.depthLine).toContain('class=gate');
|
||||
|
||||
const plain = makeFixtureRepo(); repos.push(plain);
|
||||
const plainHead = commit(plain, { 'lib/util.js': lines(3) });
|
||||
const plainBody = path.join(plain.root, 'body.md');
|
||||
expect(writeReceipt(plain, plainBody).status).toBe(EXIT.ok);
|
||||
for (const scanHead of ['', plainHead.slice(0, 12)]) {
|
||||
const r = bindShippedReceipt(plain.work, 'origin/main', rewrite(plainBody, x => { x.review_depth.gate_scan.head = scanHead; }), '7', [plainHead]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(!r.ok && r.depthLine).toContain('required=full');
|
||||
expect(!r.ok && r.depthLine).toContain('gate_scan@missing');
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('carry and the review depth', () => {
|
||||
// Value: protects=a carried receipt keeps a gate scan bound to the head it now names, and a carry whose new head needs a
|
||||
// deeper review (a skipped test) is refused; fails_when=carry keeps the old scan, or carries a gate-tagged test change; seam=none
|
||||
test('a plain test change carries and rebinds the gate scan; a skipped test raises the depth and refuses the carry', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('adds', () => { expect(1).toBe(1); });\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth!.depth).toBe('light');
|
||||
const plain = commit(repo, { 'test/util.test.js': "test('adds', () => { expect(1).toBe(1); });\ntest('more', () => { expect(2).toBe(2); });\n" });
|
||||
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(carried.status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'light', gate_scan: { tagged: 0, head: plain } });
|
||||
const skipped = commit(repo, { 'test/util.test.js': "test.skip('adds', () => { expect(1).toBe(1); });\ntest('more', () => { expect(2).toBe(2); });\n" });
|
||||
const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(refused.status).toBe(EXIT.refused);
|
||||
expect(refused.out).toContain('CARRY: refused (RECEIPT_CARRY_REFUSED)');
|
||||
expect(refused.err).toContain(`HEAD ${skipped.slice(0, 12)} needs full review depth`);
|
||||
expect(refused.err).toContain("above the receipt's light");
|
||||
expect(receiptIn(body).head).toBe(plain);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=a carry never binds a scan of one checkout to another commit, and never rebinds a receipt the queue would
|
||||
// refuse; fails_when=carry --head from another checkout carries a skipped test at light, or a current receipt without
|
||||
// review_depth carries; seam=none
|
||||
test('carry --head from another checkout refuses; a receipt with gstack_version but no review_depth refuses', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('a', () => {});\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
const skipped = commit(repo, { 'test/util.test.js': "test.skip('a', () => {});\n" });
|
||||
repo.git('checkout', '-q', reviewed);
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--head', skipped]);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain(`--head ${skipped.slice(0, 12)} is not the checked-out HEAD ${reviewed.slice(0, 12)}`);
|
||||
expect(receiptIn(body).head).toBe(reviewed);
|
||||
repo.git('checkout', '-q', skipped);
|
||||
rewrite(body, x => { delete x.review_depth; });
|
||||
const noDepth = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(noDepth.status).toBe(EXIT.refused);
|
||||
expect(noDepth.err).toContain('records gstack_version but no review_depth');
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('the review-loop exception (#3559)', () => {
|
||||
// Value: protects=an accepted stop after a fix-check is a structured record the queue checks, not prose under REVIEW_LOOP;
|
||||
// fails_when=write accepts it without a fix-check, with a foreign head or without the body line, or the queue ignores a
|
||||
// body that dropped the line; seam=bindShippedReceipt
|
||||
test('write refuses an exception with no fix-check, a foreign head or no body line, records it otherwise, and the queue checks the line', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const head = commit(repo, { 'lib/util.js': lines(3) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
const exception = { accepted_by: 'Garry', date: '2026-10-28', via: 'GSTA-147 relay', reason: 'fix-check edit is a comment; scope frozen', fix_check: { head, findings: 1 } };
|
||||
const arg = (e: unknown) => ['--review-loop-exception', JSON.stringify(e)];
|
||||
const loop = (n: number) => ['--review-loop', JSON.stringify({ passes: 2, blocking_found: 1, deferred: 0, fix_checks: n })];
|
||||
fs.writeFileSync(body, '## What changed\n');
|
||||
for (const [extra, why] of [
|
||||
[[...arg(exception)], 'needs --review-loop with fix_checks >= 1'],
|
||||
[[...arg(exception), ...loop(0)], 'needs --review-loop with fix_checks >= 1'],
|
||||
[[...arg({ ...exception, fix_check: { head: 'a'.repeat(40), findings: 1 } }), ...loop(1)], 'is not ' + head.slice(0, 12) + ' or an ancestor of it'],
|
||||
[[...arg({ ...exception, extra: 1 }), ...loop(1)], 'unknown key "extra"'],
|
||||
[[...arg(exception), ...loop(1)], 'does not carry the exception line verbatim'],
|
||||
] as Array<[string[], string]>) {
|
||||
const out = writeReceipt(repo, body, extra);
|
||||
expect(out.status).toBe(EXIT.fail);
|
||||
expect(out.err).toContain('(REVIEW_LOOP_EXCEPTION_INVALID;');
|
||||
expect(out.err).toContain(why);
|
||||
}
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).found).toBe(false);
|
||||
const line = reviewLoopExceptionLine(exception);
|
||||
expect(line).toBe(`Review-loop exception: accepted by Garry 2026-10-28 via GSTA-147 relay: fix-check edit is a comment; scope frozen (fix-check at ${head.slice(0, 12)}, 1 finding)`);
|
||||
fs.writeFileSync(body, `## What changed\n\n${line}\n`);
|
||||
expect(writeReceipt(repo, body, [...arg(exception), ...loop(1)]).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_loop_exception).toEqual(exception);
|
||||
expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]).ok).toBe(true);
|
||||
const dropped = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8').replace(line, ''), '7', [head]);
|
||||
expect(!dropped.ok && dropped.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID');
|
||||
expect(!dropped.ok && dropped.message).toContain('does not carry the review-loop exception line verbatim');
|
||||
const foreign = { ...exception, fix_check: { head: 'f'.repeat(40), findings: 1 } };
|
||||
const forged = rewrite(body, x => { x.review_loop_exception = foreign; }).replace(line, reviewLoopExceptionLine(foreign));
|
||||
const offBranch = bindShippedReceipt(repo.work, 'origin/main', forged, '7', [head]);
|
||||
expect(!offBranch.ok && offBranch.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID');
|
||||
expect(!offBranch.ok && offBranch.message).toContain('is not the receipt head or an ancestor of it');
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 1008,
|
||||
"why": "GSTA-147 review depth: Step 9 saves REVIEW_DEPTH/GATE_SCAN, passes --depth-floor, records depth fields and reads each reviewer's DIFF_CMD; the batched light specialist; Step 11's light note; the skeleton's Review policy paragraph names the depth and --depth-floor (trimmed to hold the skeleton cap); the pr-body section's --review-loop-exception instruction (#3559)"
|
||||
}
|
||||
Reference in new issue
Block a user