Files
gstack/test/review-plan-origin-base.test.ts
3d99c22497 v1.92.76.0 wave(review): #3809 #3813 #3826 #3831 #3838 (#3848)
* fix(retro): keep retro history in the state root, shared across worktrees (#3749)

/retro saved snapshots under .context/retros inside the working tree, so a run
in a linked worktree saw none of the main checkout's history, reported "First
retro recorded", and wrote a snapshot that git worktree remove deletes.

gstack-retro-metrics --history [--next <date>] now resolves the history
directory as <state root>/projects/<slug>/retros (the slug is keyed on the git
common dir, so every worktree shares it), moves any legacy .context/retros from
this worktree and the main checkout into it, lists prior snapshots and prints
the next snapshot path. Step 12/13 and the gbrain prior-retros query use it.

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

* fix(gate-diff): read a bare base as origin/<base> in gate-diff and diff-scope (#3650)

gstack-gate-diff and gstack-diff-scope resolved a bare base such as main to
the local branch whenever it shared history with HEAD. A local main that is
behind origin/main (a linked worktree, or a Capy clone, where local main
never moves) then set the merge base, so /ship's gate scan and specialist
scope also read commits already upstream. Both now prefer origin/<base>
when it resolves, the base /ship fetches and merges against.

Fixes #3650

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

* fix(retro): make history migration safe against symlinks and concurrent runs (#3749 review)

Review pass 1 findings: a .context/retros that resolves outside its worktree
(or to the shared history itself) is left alone, so migration can no longer
delete the history it points at; moves are no-clobber with retry; a copy
identical to any same-date snapshot collapses; RETRO_FILE is reserved as {}
so overlapping runs in two worktrees never share a path; --next must be a
YYYY-MM-DD date; a missing option value no longer loops forever. Step 12
names RETRO_HISTORY_ERROR instead of reading it as a stale helper.

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

* docs(changelog): fragment for the #3749 retro history fix

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

* fix: pre-landing review fixes (diff-scope reads slash-named bases through origin too)

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

* fix(retro): trim Step 12/13 prose back under the parity skeleton cap

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

* fix: Step 11 review fixes (a fork branch, a colliding tag or another remote never replaces the base)

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

* fix(retro): keep today="<today>" in Step 12 so the judge excerpt keeps both date inputs

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

* docs(changelog): describe the fork, tag and remote guards (#3650)

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

* test(retro): compare #3749 history paths with forward slashes so the Windows shard passes

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

* test(review): review-plan and gate-diff plan the same with a stale local main or a shadowing tag (GST-883, failing first)

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

* fix(review): gate-diff and review-plan resolve a branch base to origin/<base> by full ref name; review-plan scans the merge base its own diff used (GST-883)

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

* docs(changelog): GST-883 fragment

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

* test(review-risk): hostile-input time bounds for every risk pattern; the R51 rm and rmSync rules fail (GST-898)

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

* fix(review-risk): the R51 rm and rmSync rules run in linear time (GST-898)

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

* test(review-packets-removed): repin lib/review-risk.ts on the GST-898 verdict-identity replay

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

* fix(review-risk): padding cannot push recursive: true out of the rmSync window; repin on the GST-898 no-loss replay

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

* docs(changelog): GST-898 fragment wording

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

* test(review): planner misreads: SVG chart as runtime, session data as auth, bare base in diff-scope and receipt depth (GST-911, GST-884, failing first)

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

* fix(review): a drawing-only SVG is data and a scripted one is security; session data files are not auth; diff-scope and the receipt depth read origin/<base> by full ref name (GST-911, GST-884)

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

* fix(review): the SVG drawing check refuses CSS escapes and reads zero-padded character references; the security class reads a file's changed SVG lines joined (GST-911 review pass 1)

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

* test(review): a stray quote cannot hide an external href in a drawing SVG (GST-911 review pass 2, failing first)

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

* fix(review): the SVG drawing check reads every name= without consuming its value, so a stray quote cannot hide an external href (GST-911 review pass 2)

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

* test(review): CSS in a drawing SVG holds only declarations and url(#id): image-set(), image(), element() keep it code (GST-911 Step 11, failing first)

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

* fix(review): a drawing SVG's CSS holds declarations only, no @ rule and no function but url(#id), which closes image-set(), image(), element() and every other CSS load (GST-911 Step 11)

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

* test(review): a style element's CSS is read past comments and CDATA, unclosed style tags are refused in linear time, and every drawing verdict holds at 1 MB and 8 MB (GST-911, failing first)

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

* fix(review): walk a style element's CSS with indexOf, dropping comments and reading CDATA, so no hidden </style> ends it early and unclosed style tags refuse in linear time (GST-911)

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

* docs(review-policy): the linear-time claim covers R51's rm and rmSync rules (GST-898)

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

* docs(review-policy): state the rmSync padding rule exactly (GST-898)

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

* fix(review-depth): linear url() refusal in svgDrawing (wave review-2, red-team CRITICAL)

SVG_REFUSED's url() entry backtracked quadratically on url( followed by a
long whitespace run (80k spaces: 820 ms; 1 MB: over 230 s), and
svgDrawing runs in collectLive and in collectCommit, which ship-queue
ready, train build and land call with no timeout. The rewritten entry
refuses exactly what #3838's pattern refused.

Repin lib/review-depth.ts under no-loss replay GST-911-redos
(verdict identity: 60,000 seeded strings, the edges and every SVG blob
in history, zero differences).

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

* fix: pre-landing review fixes (wave review-2, pass 1)

- bin/gstack-diff-scope: _resolve_base takes a related local <base> before
  another remote's twin, the order resolveBase uses, so the two tools pick
  the same base for a bare name when origin/<base> is unrelated.
- lib/review-depth.ts: a drawing's DOCTYPE names no document but W3C's SVG
  DTD; SYSTEM and other PUBLIC identifiers keep the SVG code.

Repin lib/review-depth.ts under no-loss replay GST-911-redos (the DOCTYPE
change only keeps more SVGs code, so it drops no reviewer).

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

* fix: freeze the exported SVG_REFUSED list (wave review-2, Step 11)

Exported only so test/review-risk-redos.test.ts can reach the url() entry;
a frozen readonly copy keeps any importer from changing verdicts while
DEPTH_RULES still reports the original rules.

Repin lib/review-depth.ts under no-loss replay GST-911-redos (no pattern
changed).

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

* Revert "fix: freeze the exported SVG_REFUSED list (wave review-2, Step 11)"

The fix-check (wave review-2) showed Object.freeze is shallow: each RegExp
can still be changed in place, so the freeze did not close
adversarial-native#1, and it moved the tree past the one the Step 11
voices reviewed. The finding ships as an open note (its reviewer rated it
Low, not blocking; no production caller mutates the list).

Restores the lib/review-depth.ts pin under no-loss replay GST-911-redos.

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

* docs(changelog): wave review-2 entry for the integrator fixes

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

* docs(review-policy): a drawing's DOCTYPE names no outside document (wave review-2 docs audit)

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: exGeni <135786749+exGeni@users.noreply.github.com>
2026-11-02 12:16:51 +00:00

96 lines
6.7 KiB
TypeScript

/**
* gstack-review-plan and gstack-gate-diff diff against origin/<base> (GST-883): with a local <base> that lags
* origin/<base>, review-plan handed the raw name to gstack-gate-diff, which merged against the stale local ref and
* scanned upstream commits the branch only inherited, so a code diff planned as gate class at full depth.
*/
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';
const PLAN = path.join(import.meta.dir, '..', 'bin', 'gstack-review-plan');
const GATE_DIFF = path.join(import.meta.dir, '..', 'bin', 'gstack-gate-diff');
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'gate-diff');
const dirs: string[] = [];
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
function fixture() {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rp-origin-')));
dirs.push(root);
const work = path.join(root, 'work');
const env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') };
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
const sh = (cwd: string, c: string) => { const r = spawnSync('bash', ['-c', c], { cwd, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(`${c}: ${r.stderr}`); return r.stdout.trim(); };
sh(root, 'git init -q --bare -b main origin.git && git clone -q origin.git work 2>/dev/null');
sh(work, `git apply '${FIXTURES}/rh1-skip.base.patch' && git add -A && git commit -qm base && git push -q origin main`);
sh(root, `git clone -q origin.git other && cd other && git apply '${FIXTURES}/rh1-skip.patch' && git commit -qam upstream && git push -q origin main`);
sh(work, "git fetch -q origin && git checkout -qb feat origin/main && echo 'export const v = 1;' > src/v.ts && git add -A && git commit -qm feat");
return { work, env, sh };
}
const planOf = (work: string, env: NodeJS.ProcessEnv) => {
const r = spawnSync(PLAN, ['--base', 'main', '--json'], { cwd: work, env, encoding: 'utf8', timeout: 180_000 });
expect(r.status, r.stdout + r.stderr).toBe(0);
const p = JSON.parse(r.stdout) as { class: string; depth: string; gate_scan: unknown };
return { class: p.class, depth: p.depth, gate_scan: p.gate_scan };
};
const gateDiff = (work: string, env: NodeJS.ProcessEnv, base: string) => {
const r = spawnSync(GATE_DIFF, [base], { cwd: work, env, encoding: 'utf8', timeout: 120_000 });
expect(r.status, r.stdout + r.stderr).toBe(0);
return { tagged: /tagged=\{([^}]*)\}/.exec(r.stdout)?.[1], candidate: /candidate=(\S+)/.exec(r.stdout)?.[1], stderr: r.stderr };
};
describe('review-plan and gate-diff read the origin merge base (GST-883)', () => {
// Value: protects=a stale local <base> never turns inherited upstream commits into gate hunks that force full depth;
// fails_when=gstack-gate-diff merges against the local <base> while origin/<base> is ahead, or review-plan hands it a base the plan's own diff did not use;
// why_new=GEVA-117 paid ~15-20M tokens of full-depth readers for a gate hunk already on origin/main; seam=bin/gstack-review-plan, bin/gstack-gate-diff;
// no_claim=which gate rules fire
test('local main behind origin/main plans the same class, depth and gate-tagged count as a fresh main', () => {
const { work, env, sh } = fixture();
expect(sh(work, 'git merge-base main HEAD')).not.toBe(sh(work, 'git merge-base origin/main HEAD'));
const stale = { plan: planOf(work, env), tags: gateDiff(work, env, 'main').tagged };
sh(work, 'git branch -f main origin/main');
const fresh = { plan: planOf(work, env), tags: gateDiff(work, env, 'main').tagged };
expect(fresh).toEqual({ plan: { class: 'code', depth: 'light', gate_scan: expect.anything() }, tags: '' });
expect(stale).toEqual(fresh);
}, 240_000);
// Value: protects=a tag cannot stand in for the base branch, so it cannot move the merge base to HEAD and hide committed edits;
// fails_when=resolveBase or collectLive read `origin/main` or `main` by short name while a tag of that name exists;
// why_new=outside-voice finding on the whole diff (shadow tag reproduced); seam=bin/gstack-review-plan, bin/gstack-gate-diff
for (const [tag, branch] of [['origin/main', 'refs/remotes/origin/main'], ['main', 'refs/heads/main']] as const) {
test(`a tag named ${tag} at HEAD does not shadow ${branch}`, () => {
const { work, env, sh } = fixture();
if (branch === 'refs/heads/main') sh(work, 'git remote remove origin');
const before = { plan: planOf(work, env), candidate: gateDiff(work, env, 'main').candidate };
sh(work, `git tag '${tag}' HEAD`);
const after = { plan: planOf(work, env), candidate: gateDiff(work, env, 'main').candidate };
expect(after).toEqual(before);
expect(after.candidate?.startsWith(sh(work, `git merge-base ${branch} HEAD`).slice(0, 12))).toBe(true);
}, 240_000);
}
// Value: protects=without a remote-tracking twin the local base still works and the fallback is named on stderr;
// fails_when=the origin preference turns a remote-less branch into no_base, or falls back silently; why_new=the fallback is new; seam=bin/gstack-gate-diff
test('no origin/<base>: gate-diff falls back to the local ref and says so', () => {
const { work, env, sh } = fixture();
sh(work, 'git branch trunk origin/main');
const r = gateDiff(work, env, 'trunk');
expect(r.tagged).toBe('');
expect(r.stderr).toContain('gstack-gate-diff: no remote-tracking branch for trunk; base is the local trunk, which may lag its remote');
}, 240_000);
// Value: protects=the fallback note names why the local ref stands, and a remote-less repository stays quiet;
// fails_when=an unrelated origin/<base> is reported as missing, or a repository with no remote prints a lag note; why_new=review finding on the fallback; seam=bin/gstack-gate-diff
test('an unrelated origin/<base> is named in the note; no remote at all uses the local base quietly', () => {
const { work, env, sh } = fixture();
sh(work, 'git branch -f main origin/main && git update-ref refs/remotes/origin/main "$(git commit-tree "HEAD^{tree}" -m orphan)"');
expect(gateDiff(work, env, 'main').stderr).toContain('gstack-gate-diff: origin/main shares no history with HEAD; base is the local main, which may lag its remote');
sh(work, 'git remote remove origin');
const none = gateDiff(work, env, 'main');
expect(none.tagged).toBe('');
expect(none.stderr).not.toContain('base is');
}, 240_000);
});