Files
gstack/test/review-collect.test.ts
Garry Tanandcapy-ai[bot] 8966f3620c v1.91.350.0 feat(review): GSTA-167 variant G reviewer stack: blocking floor in both voices, one batched specialist, data-file classifier (#3660)
* feat(review): variant G part 4: data files no code names are docs in classifyDiff, untested state-matrix cells map below the floor per cell (GSTA-167 PR-a)

Part 4a: lib/review-depth.ts owns DATA_PATH (JSON, JSONL, NDJSON, CSV, TSV, raster
images; YAML, TOML and SVG stay code) and applies the data rule only in classifyDiff,
to candidates lib/review-collect.ts finds no non-docs consumer for (git grep of
basenames at head, merge base and, live, the working tree; data-to-data chains four
deep; over 2,000 candidates keeps all code; a grep failure fails closed). classifyPath
and every path-only caller are unchanged; the receipt gate scan decides through
gateScanNeeded (path-only), so it cannot be skipped. gstack-ship-preflight computes
RUNTIME_LINES with classifyDiff over gstack-review-plan --json (now with consumed).

Part 4b: lib/review-scope.ts mapStateMatrixOutput maps an "untested cell" finding to
INFORMATIONAL when its cell has test null in the same output's matrix and it is the
only finding on that cell; gstack-state-matrix findings prints it for Step 9.2 before
the merge. state-machines.md is byte-identical; check still reads the raw output.

Reviewer-brief pins repinned under no-loss replay PR-a (Arm 4: plan identity at the 14
cohort heads; free exact re-score of saved C, D and A' findings).

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

* feat(review): variant G parts 1 and 3: blocking floor in both prose voices, specialists at medium (GSTA-167)

Part 1: the native adversarial and outside-voice prompts end with the blocking
floor ("would fail a test, break production, or lose data or secrets") and ask
each finding for Severity and Blocks: yes|no. Additive rule: a finding is
CRITICAL when its label rule says so or it says Blocks: yes; Blocks: no never
lowers one (proseVoiceSeverity, BLOCKING_FLOOR in lib/review-scope.ts).
Part 3: ROLE_EFFORT puts specialists at medium; Red Team, native adversarial
and fix-check stay full; light's batched specialist (carries the Red Team
prompt) stays full; --no-tiers sends specialists to host.

Repinned reviewer briefs with no-loss replay G1 (live Arm G1, not yet run):
ship/sections/review-army.md, ship/sections/adversarial.md,
review/sections/adversarial.md, lib/review-depth.ts. Merge only on G1's pass.
Review-skill parity ratio 1.3136 -> 1.3180 and the gstack-review size pins
+537 for the approved +0.3k tokens per voice.

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

* feat(review): variant G part 2: lean ensemble, one batched specialist at every depth (GSTA-167)

planReview dispatches one 'specialist batched' at standard and full depth (and
light, which adds the Red Team checklist): Testing and Security always on code
and gate class, plus State-machines, Data-migration, API-contract and Design
when scope asks; a forced checklist always joins. Maintainability, Performance
and Simplification are read only when forced (SKIP names them). LIGHT_FIXED is
[testing, security]; BATCH_SCOPED joins DEPTH_RULES so old receipts re-plan.
The Red Team rule and prompt are unchanged; it reads the batched reader's
merged findings. specialistsFor stays as the legacy selection the dispatch
compare script models.

Repinned reviewer briefs with no-loss replay L (live Arm L, scored L∘G1 vs G1;
not yet run): ship/sections/review-army.md, lib/review-depth.ts. Merge only on
L's pass.

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

* feat(review): variant G on #3648's dispatch: Blocks: yes collects as CRITICAL, batched checklists by name (GSTA-167)

Integrates parts 1 and 2 with gstack-review-dispatch (#3648):
- lib/review-ledger.ts parseFindings: a prose voice's "Blocks: yes" makes its
  own line, or the finding on the line just before it, CRITICAL in
  merged.jsonl (normalizer v2, certified by round 5's G1), so judge refuses a
  lead that keeps it below the floor; with no finding before it the Blocks
  line is its own CRITICAL finding. "Blocks: no" never lowers a label.
- lib/review-brief.ts: the outside-voice brief no longer appends "Give each
  finding a severity"; the section's prompt now asks for Severity and Blocks,
  so the brief is the certified section prompt plus the branch diff.
- bin/gstack-review-dispatch render --checklists takes the plan's
  checklists= names (review/specialists/<name>.md) as well as paths and
  refuses an unknown one, since the batched reader is now every depth's
  specialist dispatch.

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

* chore(changelog): GSTA-167 variant G reviewer stack fragment

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

* fix(review): consumer scan survives grep.lineNumber/grep.column; collect reads separate-line Severity/Blocks

/ship pass 1 (PR #3660): the Red Team CRITICAL at lib/review-collect.ts:120 and the
informational findings in the same batch. proseVoiceSeverity had no caller and is
removed; the normalizer v2 table now runs through parseFindings, the path collect uses.

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

* fix(review): collect keeps every prose blocker and one id per finding

/ship pass 2 (PR #3660): an unlabeled line carrying an inline Blocks: yes collects as
CRITICAL again; Severity/Blocks continuation needs a colon, stops at a list item, heading,
bold paragraph opener or a repeated field, and never swallows a top-level bullet or its
section label; the native voice's FIXABLE collects as CRITICAL under its role with or
without a dispatch name; the consumer scan adds --no-color.

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

* fix(review): a later finding's fields never land on the one before it; explicit Severity wins its line

/ship pass 2 Red Team (PR #3660): prose after a finding's Severity/Blocks field, a
post-blank paragraph or a line in an indented block starts the next finding, and a
line's explicit Severity: label decides it over other label words on the line.

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

* fix(review): FIXABLE outranks a Severity label on its line; a blocker keeps its own id and subject

/ship fix-check (PR #3660): labels are named before the strongest is taken, so a one-line
native FIXABLE ... Severity: Medium collects as CRITICAL; a later finding starts at a
file:line after the open finding's fields, field bullets stay on their finding, a field
line may omit the colon, and continuation prose never replaces a list item's subject.

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

* docs(changelog): GSTA-167 fragment adds round 6 and the collect and consumer-scan fixes

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>
2026-10-31 05:06:03 +00:00

180 lines
13 KiB
TypeScript

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, CONSUMER_SCAN_CAP, 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']);
});
});
describe('GSTA-167 part 4a: which data files code names (scanConsumers)', () => {
const rows = Array.from({ length: 80 }, (_, i) => `{"i":${i}}`).join('\n') + '\n';
// Value: protects=a data file a code file names keeps review, one nothing names drops to docs, and markdown mentions do not
// count; fails_when=the consumer search misses a literal reader or counts prose; seam=git grep at head and merge base
test('a JSON code names stays code; one only markdown names is docs', () => {
const r = repo({ 'src/load.ts': "export const rows = () => require('./data/rows.json');\n", 'docs/wiki.md': 'see wiki/post/data.json\n' });
const head = r.commit({ 'data/rows.json': rows, 'wiki/post/data.json': rows });
const c = collectCommit(r.dir, r.baseSha, head);
expect([c.consumed, c.errors]).toEqual([['data/rows.json'], []]);
expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ class: 'code', runtimeLines: 80 });
});
test('a consumer the change deletes still counts (merge base); a data file named only by another data file follows it', () => {
const r = repo({ 'src/old.ts': "read('rules.json')\n", 'src/manifest.ts': "load('index.json')\n" });
const head = r.commit({ 'src/old.ts': null, 'assets/rules.json': rows, 'assets/index.json': '["post.json"]\n', 'assets/post.json': rows, 'orphan/list.json': '["lone.json"]\n', 'orphan/lone.json': rows });
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.consumed).toEqual(['assets/index.json', 'assets/post.json', 'assets/rules.json']);
});
test('a deleted data file head code still names keeps the deletion guarded (gate class)', () => {
const r = repo({ 'src/a.ts': "read('table.csv')\n", 'data/table.csv': 'a,b\n' });
const head = r.commit({ 'data/table.csv': null, 'src/a.ts': "read('table.csv'); // moved\n" });
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.consumed).toEqual(['data/table.csv']);
expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('gate');
});
// Value: protects=the live plan (gstack-review-plan) and the queue recompute (collectCommit) agree on a committed tree, and
// the live plan sees an untracked consumer; fails_when=a receipt and the queue disagree on depth; seam=collectLive vs collectCommit
test('collectLive and collectCommit compute the same consumed set on a clean tree; live counts an untracked consumer', () => {
const r = repo({ 'src/load.ts': "read('rows.json')\n" });
const head = r.commit({ 'data/rows.json': rows, 'wiki/post/data.json': rows });
expect(collectLive(r.dir, 'main').consumed).toEqual(collectCommit(r.dir, r.baseSha, head).consumed);
r.write({ 'src/new.ts': "read('post/data.json')\n" });
expect(collectLive(r.dir, 'main').consumed).toEqual(['data/rows.json', 'wiki/post/data.json']);
});
// Value: protects=the scan reads git grep the same way under any user grep config, and a failed grep keeps every data file
// code; fails_when=grep.lineNumber/grep.column shift the NUL fields so no consumer matches, or a grep failure drops data to
// docs silently; seam=git grep output and exit status
test('grep.lineNumber, grep.column and color.grep in git config do not change which files count as consumers', () => {
const r = repo({ 'src/load.ts': "export const rows = () => require('./data/rows.json');\n" });
r.git('config', 'grep.lineNumber', 'true'); r.git('config', 'grep.column', 'true'); r.git('config', 'color.grep', 'always');
const head = r.commit({ 'data/rows.json': rows, 'docs/notes.md': 'see rows.json and orphan.json\n', 'data/orphan.json': rows });
expect([collectCommit(r.dir, r.baseSha, head).consumed, collectLive(r.dir, 'main').consumed]).toEqual([['data/rows.json'], ['data/rows.json']]);
});
test('a git grep failure keeps every data file code and records consumer_scan_failed', () => {
const r = repo({ 'src/load.ts': "read('rows.json')\n" });
const head = r.commit({ 'data/rows.json': rows });
r.git('config', 'grep.lineNumber', 'not-a-bool');
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.consumed).toBe('all');
expect(c.errors.map(e => e.code)).toEqual(['consumer_scan_failed']);
expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('code');
});
test('past CONSUMER_SCAN_CAP data paths every one stays code', () => {
const r = repo({});
const head = r.commit(Object.fromEntries(Array.from({ length: CONSUMER_SCAN_CAP + 1 }, (_, i) => [`d/${i}.json`, '{}\n'])));
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.consumed).toBe('all');
expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('code');
}, 60_000);
});