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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 01:12:16 +00:00
1 parent 21277291c8
commit 33d6029273
18 files changed
+436 -54

No files matched your search

+1 -1
View File
@@ -137,7 +137,7 @@ function main(): number {
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n');
process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n');
return EXIT.ok;
}
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`];
+17 -1
View File
@@ -5,6 +5,7 @@
* gstack-state-matrix detect [--base <ref>] [--diff-base <sha>]
* gstack-state-matrix check <specialist-output|-> [--repo <dir>] [--expect <expectation.json>] [--json]
* gstack-state-matrix outfile
* gstack-state-matrix findings <reviewer-output|->
*
* `detect` prints `SCOPE_STATE_MACHINE=true|false` (shell-safe for
* `source <(...)`, with `# state-machine:` comment lines naming the hits) from
@@ -14,7 +15,10 @@
* last; exit 0 ok, 1 incomplete (STATE_MATRIX_INCOMPLETE). `outfile` creates and
* prints a new empty 0600 file in the repo's private `.gstack/tmp/` (0700, kept
* out of git through info/exclude) for the specialist's output, so a later
* review never checks or commits an earlier run's file. Logic: lib/state-matrix.ts.
* review never checks or commits an earlier run's file. `findings` prints the
* output for Step 9.3's merge with each untested cell mapped below the floor
* (lib/review-scope.ts mapStateMatrixOutput) and `STATE_MATRIX_FINDINGS:` on
* stderr. Logic: lib/state-matrix.ts.
*/
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
@@ -24,6 +28,7 @@ import { addedLines } from '../lib/banned-terms';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { resolveBaseBranch } from '../lib/ship-policy';
import { mapStateMatrixOutput } from '../lib/review-scope';
import { checkStateMatrix, detectStateMachine, renderStateMatrixCheck, type Expectation } from '../lib/state-matrix';
const HELP = `gstack-state-matrix — kind × owner action × resulting row for proposal, hold and approval state machines
@@ -32,6 +37,7 @@ const HELP = `gstack-state-matrix — kind × owner action × resulting row for
check <file|-> [--repo <dir>] [--expect <json>] [--json]
gaps in the state-machines specialist's output (review/specialists/state-machines.md)
outfile print a new empty private file for that output
findings <file|-> that output for the Step 9.3 merge, each untested cell mapped below the floor
${exitTableHelp()}`;
@@ -71,6 +77,15 @@ function check(rest: string[]): number {
return EXIT.ok;
}
function findings(rest: string[]): number {
const input = rest.find(a => !a.startsWith('--'));
if (!input) { process.stderr.write(`gstack-state-matrix findings: a reviewer output file (or -) is required\n${HELP}\n`); return EXIT.usage; }
const r = mapStateMatrixOutput(input === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(input, 'utf8'));
process.stdout.write(r.text.endsWith('\n') ? r.text : `${r.text}\n`);
process.stderr.write(`STATE_MATRIX_FINDINGS: mapped=${r.mapped} (untested cells below the floor; docs/review-policy.md test gap)\n`);
return EXIT.ok;
}
function outfile(): number {
const root = git(['rev-parse', '--show-toplevel']);
const exclude = git(['rev-parse', '--git-path', 'info/exclude']);
@@ -94,6 +109,7 @@ function main(argv: string[]): number {
if (cmd === 'detect') return detect(rest);
if (cmd === 'check') return check(rest);
if (cmd === 'outfile') return outfile();
if (cmd === 'findings') return findings(rest);
process.stderr.write(`gstack-state-matrix: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
}
+20 -1
View File
@@ -58,7 +58,7 @@ whatever class it resembles.
| **bounded repeat work** | A re-dispatch on every call, a redundant screen after main moves, an orphaned scratch branch, a refusal that could come one step earlier in the sequence, a settle that could wait one more check. | The operation completes, nothing is lost, and the cost is one more bounded run; uncapped spend is the sideways sweep's CRITICAL, not this class. |
| **conservative refusal** | Stale state scoped wider than its cause: a RED verdict that keeps refusing a head after main moved, a pending result that keeps refusing after it settled. | A refusal costs a rerun and never passes something wrong; a wrong pass would be the floor. |
| **artifact presentation** | A string from an artifact printed verbatim into a parsed line (whitespace, control characters); a classifier that reads a non-zero exit code beside a non-RED verdict as RED. | Display and classification of the repository's own output; it forges no parsed field. |
| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. |
| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. Precedence for state machines: the specialist still reports each untested cell CRITICAL so `gstack-state-matrix check` proves the matrix complete, and `gstack-state-matrix findings` maps it to INFORMATIONAL per matrix cell before the merge; a wrong row or collapsed kinds stays CRITICAL, and a cell with a second finding keeps both. |
A reviewer that wants a class promoted argues it against the floor with a
reaching input, in the finding, and the owner rules; the table records the
@@ -168,6 +168,22 @@ or test path, CI workflows, credential, state-root and the paths below).
commit's `.gstack/generated.json` registers. Docs, tests and release metadata are never
runtime; a `.tmpl` edit plus its regenerated `SKILL.md` counts the template once.
**Data files no code names are docs.** A changed or deleted JSON, JSONL, NDJSON, CSV,
TSV or raster image (PNG, JPEG, GIF, WebP, BMP, ICO, AVIF) counts as docs when no
non-docs file names its basename at the head, at the merge base or, for live /ship, in
the working tree with untracked files; a data file named only by another data file
follows that file, four files deep. Precedence: agent prose (`.gstack/**`), a test or
fixture path, a gate path, a config file (`.github/**`, anything under a `config/`
directory, dotfiles, `*.config.*`, `package.json`, lockfiles, `tsconfig*.json`,
`manifest.json` and the other tool manifests `lib/review-depth.ts` lists) and a named
consumer each keep the path code, and YAML, TOML and SVG are always code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same
function); every path-only caller (the receipt's gate-scan decision, the Step 16
post-review route, /autoplan's docs-only scope) keeps data files code. A numeric claim
in a data file still schedules the outside voice.
| Depth | When | Pass 1 dispatches |
|---|---|---|
| **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers |
@@ -248,6 +264,9 @@ the audits and watchers at the host default too
prompt. At runtime 0 light runs no adversarial pass either.
- Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy
diff with few runtime lines dispatches fewer specialists.
- A JSON, CSV, TSV or raster data file no code names is docs, not runtime (GSTA-167
part 4a). A binary image no code names is no longer a `binary_code_path` error
that forces full depth; one that code names still is.
## What the receipt records
+62 -8
View File
@@ -12,7 +12,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { GENERATED_PATH, parseRegistry } from './regen';
import { RESULT_CODES } from './result-codes';
import type { ClassInput, DepthError, PathLines, RegistryInfo } from './review-depth';
import { classifyPath, dataCandidate, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth';
export interface Collected extends ClassInput {
perPath: PathLines[];
@@ -25,11 +25,12 @@ export interface Collected extends ClassInput {
diffLines: number;
mergeBase: string | null;
untracked: string[];
consumed: string[] | 'all';
}
function git(root: string, args: string[]): { ok: boolean; out: string; err: string } {
function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } {
const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
return { ok: r.status === 0, status: r.status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
const scratch = (p: string) => p.startsWith('.gstack/tmp/');
const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200);
@@ -88,7 +89,58 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
return { perPath, deleted, stampOnly, added };
}
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked'>): Collected {
/** More data candidates than this keep every data file code: the consumer scan is not run. */
export const CONSUMER_SCAN_CAP = 2000;
/** How many data files deep a chain of data naming data is followed (a manifest naming a data file) before the rest count as consumed. */
export const CONSUMER_SCAN_DEPTH = 4;
/**
* The data candidates (lib/review-depth.ts dataCandidate) among the changed and deleted paths that a non-docs file names
* by basename, at each rev and, with `live`, in the working tree with untracked files. The merge base is one of the revs,
* so a consumer the change removes still counts. A data file named only by another data file is consumed when that one
* is, followed CONSUMER_SCAN_DEPTH files deep; anything still open after that counts as consumed. A git grep failure is
* a collector error, which fails the depth closed.
*/
export function scanConsumers(root: string, paths: readonly string[], revs: readonly string[], live: boolean, classRoot: string | readonly string[], errors: DepthError[]): string[] | 'all' {
const candidates = [...new Set(paths)].filter(p => dataCandidate(p, classRoot)).sort();
if (candidates.length > CONSUMER_SCAN_CAP) return 'all';
const base = (p: string) => p.slice(p.lastIndexOf('/') + 1);
const named = new Map<string, Set<string>>();
const direct = new Set<string>();
const probed = new Set<string>();
let probe = candidates;
for (let depth = 0; probe.length && depth < CONSUMER_SCAN_DEPTH; depth++) {
const patterns = [...new Set(probe.map(base))].flatMap(b => ['-e', b]);
const runs = [...revs.map(rev => ({ at: rev, args: [rev, '--'] })), ...(live ? [{ at: '', args: ['--untracked', '--'] }] : [])];
const next = new Set<string>();
for (const run of runs) {
const r = git(root, ['grep', '-I', '-F', '-o', '--null', ...patterns, ...run.args]);
if (r.status !== 0 && r.status !== 1) { errors.push({ code: 'consumer_scan_failed', detail: oneLine(r.err || `git grep exited ${r.status}`), input: `git grep -F <data basenames> ${run.at || '--untracked'}`, repair: 'run the git grep by hand and fix what git reports' }); return 'all'; }
for (const line of r.out.split('\n')) {
const [where, match] = line.split('\0');
if (!where || !match) continue;
const file = run.at ? where.slice(run.at.length + 1) : where;
if (scratch(file) || classifyPath(file, classRoot) === 'docs') continue;
for (const p of probe) {
if (file === p || !match.includes(base(p))) continue;
if (!dataCandidate(file, classRoot)) { direct.add(p); continue; }
(named.get(file) ?? named.set(file, new Set()).get(file)!).add(p);
if (!probed.has(file) && !probe.includes(file)) next.add(file);
}
}
}
for (const p of probe) probed.add(p);
probe = [...next];
}
const consumed = new Set([...direct, ...probe]);
for (let grew = true; grew;) {
grew = false;
for (const [file, targets] of named) if (consumed.has(file)) for (const p of targets) if (!consumed.has(p)) { consumed.add(p); grew = true; }
}
return candidates.filter(p => consumed.has(p));
}
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked' | 'consumed'>): Collected {
const paths = diff.perPath.map(e => e.path).sort();
return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) };
}
@@ -100,13 +152,14 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C
const mb = git(root, ['merge-base', baseSha, headSha]);
if (!mb.ok) {
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' });
return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [] });
return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
}
const mergeBase = mb.out.trim();
const diff = readDiff(root, [mergeBase, headSha], errors);
const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]);
const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort();
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [] });
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [headSha, mergeBase], false, skillDirs, errors);
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed });
}
/** The working tree against the merge base with origin/<base> (or <base>), untracked files counted per path; the registry is the base ref's commit. */
@@ -117,7 +170,7 @@ export function collectLive(root: string, base: string): Collected {
const mb = git(root, ['merge-base', ref, 'HEAD']);
if (!mb.ok) {
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` });
return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [] });
return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
}
const mergeBase = mb.out.trim();
const diff = readDiff(root, [mergeBase], errors);
@@ -133,5 +186,6 @@ export function collectLive(root: string, base: string): Collected {
diff.perPath.push({ path: p, lines, binary });
if (!binary) diff.added[p] = text.split('\n');
}
return result(diff, { root, registry, errors, mergeBase, untracked });
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors);
return result(diff, { root, registry, errors, mergeBase, untracked, consumed });
}
+33 -3
View File
@@ -48,10 +48,18 @@ const GATE_PATH = [
/^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
];
/** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */
export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i;
/** Data-shaped files tools read by convention, with no code naming them: always code. */
const DATA_CONFIG = [
/(^|\/)\.github\//, /(^|\/)config\//, /(^|\/)\.[^/]+$/, /(^|\/)[^/]+\.config\.[^/]+$/,
/(^|\/)(package|package-lock|npm-shrinkwrap|composer|deno|jsr|tsconfig[^/]*|jsconfig[^/]*|manifest|app|vercel|netlify|firebase|renovate|turbo|nx|lerna|biome|angular|nest-cli|lint-staged|components|pyrightconfig|cdk|serverless|appsettings[^/]*)\.json$/i,
];
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, SCOPED_RANGE_LINES,
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
})).digest('hex').slice(0, 16);
/**
@@ -70,7 +78,21 @@ export function classifyPath(p: string, root?: string | readonly string[]): Path
return root.includes(top) ? 'code' : 'docs';
}
export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path';
/**
* A code-class path the data rule may move to docs: a data extension, and not agent prose, a gate path or a config file.
* Only classifyDiff applies the rule, and only to a candidate its collector found no consumer for (ClassInput.consumed);
* classifyPath and every path-only caller keep data files code.
*/
export function dataCandidate(p: string, root?: string | readonly string[]): boolean {
return DATA_PATH.test(p) && classifyPath(p, root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p));
}
/** Whether a range needs the gate scan: any path that is not docs by the path-only rule. The data rule never skips it. */
export function gateScanNeeded(paths: readonly string[], root?: string | readonly string[]): boolean {
return paths.some(p => classifyPath(p, root) !== 'docs');
}
export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path' | 'consumer_scan_failed';
/** Why a depth failed closed to full: the input it could not read, a sanitized cause and the repair; `result` is the result code behind it when one exists. */
export interface DepthError { code: DepthErrorCode; detail: string; input: string; repair: string; result?: ResultCodeName }
export interface PathLines { path: string; lines: number; binary: boolean }
@@ -94,12 +116,18 @@ export interface ClassInput {
registry?: RegistryInfo;
/** Collector failures (a diff git could not list): each fails the depth closed. */
errors?: readonly DepthError[];
/**
* The data candidates (dataCandidate) a non-docs file names at the head, the merge base or in the working tree
* (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code.
*/
consumed?: readonly string[] | 'all';
}
export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null }
const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root));
const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && dataCandidate(p, i.root);
const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root));
/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */
export function classifyDiff(i: ClassInput): ClassResult {
@@ -109,6 +137,8 @@ export function classifyDiff(i: ClassInput): ClassResult {
const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); };
if (!i.paths.length) raise('docs', 'no changed paths');
for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); }
const data = i.paths.filter(p => unconsumedData(i, p));
if (data.length) reasons.push(`docs: ${data.length} data path(s) no code names`);
const s = i.scope ?? {};
if (s.SCOPE_ERROR) {
raise('code', `scope error: ${s.SCOPE_ERROR}`);
+36 -1
View File
@@ -14,6 +14,7 @@
* Pure functions here; bin/gstack-review-scope runs git and gh.
*/
import { SPECIALISTS } from './review-depth';
import { parseSpecialistOutput } from './state-matrix';
export type Locus = 'diff' | 'fix-range' | 'outside';
export type Kind = 'defect' | 'test-gap' | 'advisory';
@@ -133,10 +134,41 @@ function fingerprintLine(fp: unknown): number | undefined {
/** The categories a reviewer uses for a test finding; `testing` is what the Testing specialist's schema emits. */
export const TEST_GAP_CATEGORIES: readonly string[] = ['testing', 'tests', 'test-gap', 'test-coverage', 'coverage', 'missing-test', 'untested'];
/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test), or a product defect. */
/** What mapStateMatrixOutput stamps on an untested state-matrix cell: a test gap, below the floor (docs/review-policy.md). */
export const UNTESTED_CELL = 'untested-cell';
const cellKey = (f: Record<string, any>): string | null => (f.cell && typeof f.cell === 'object' ? `${String(f.cell.kind ?? '').trim().toLowerCase()}\u0000${String(f.cell.action ?? '').trim().toLowerCase()}` : null);
/**
* A reviewer output (JSON lines) with each untested state-matrix cell mapped below the floor, per matrix cell and before
* Step 9.3's fingerprint merge: a `state-matrix` finding the checklist's schema writes as "untested cell: …", whose `cell` is a
* cell with `"test": null` in a matrix record of the same output, and the only finding on that cell, becomes INFORMATIONAL with `state_matrix: "untested-cell"` and its
* `raw_severity`. A second finding on the cell (a wrong row, collapsed kinds) keeps both as they are, so a merge can never
* fold a defect into a demoted record; a cell no matrix lists as untested changes nothing. Every other line, matrix records
* included, is returned byte for byte; `gstack-state-matrix check` reads the raw output, never this one.
*/
export function mapStateMatrixOutput(text: string): { text: string; mapped: number } {
const { matrices, findings } = parseSpecialistOutput(text);
const untested = new Set(matrices.flatMap(m => m.cells.filter(c => c.test === null).map(c => cellKey({ cell: c })!)));
const perCell = new Map<string, number>();
for (const f of findings) { const k = cellKey(f); if (k) perCell.set(k, (perCell.get(k) ?? 0) + 1); }
let mapped = 0;
const lines = text.split('\n').map(line => {
let f: Record<string, any>;
try { f = JSON.parse(line.trim()); } catch { return line; }
const k = f && typeof f === 'object' && !f.matrix && f.category === 'state-matrix' && /^untested cell\b/i.test(String(f.summary ?? '')) ? cellKey(f) : null;
if (!k || !untested.has(k) || perCell.get(k) !== 1 || f.state_matrix === UNTESTED_CELL) return line;
mapped += 1;
return JSON.stringify({ ...f, severity: 'INFORMATIONAL', raw_severity: f.severity ?? null, state_matrix: UNTESTED_CELL });
});
return { text: lines.join('\n'), mapped };
}
/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test, an untested state-matrix cell still below CRITICAL), or a product defect. */
export function kindOf(finding: ScopeFinding): Kind {
if (finding.advisory === true) return 'advisory';
if (TEST_GAP_CATEGORIES.includes(String(finding.category ?? '').toLowerCase())) return 'test-gap';
if (finding.state_matrix === UNTESTED_CELL && String(finding.severity ?? '').toUpperCase() !== 'CRITICAL') return 'test-gap';
return 'defect';
}
@@ -153,6 +185,8 @@ export interface ScopeInput { policy: ReviewScopePolicy; pass: number; diffHunks
/**
* The scope decision per finding. `all`: fix everything (today's loop).
* An untested state-matrix cell mapped below the floor is a note from pass 1 on
* (docs/review-policy.md, the test-gap ruling) under the other policies.
* `frozen` pass 1: fix the diff, defer blocking findings in unchanged code,
* note the rest. `frozen` pass 2+: fix blocking findings in the range since
* the previous pass or in the diff; defer blocking findings outside; note
@@ -170,6 +204,7 @@ export function scopeFindings(findings: readonly ScopeFinding[], input: ScopeInp
const placed = typeof finding.path === 'string' || fingerprintPath(finding.fingerprint) !== undefined;
let scope: ScopeAction;
if (input.policy === 'all') scope = 'fix';
else if (kind === 'test-gap' && finding.state_matrix === UNTESTED_CELL) scope = 'note';
else if (locus === 'outside') scope = !blocking ? 'note' : input.policy === 'diff-plus-blocking' || !placed ? 'fix' : 'defer';
else if (input.pass <= 1 && !AFTER_PASS_RAISERS.includes(raised)) scope = 'fix';
else scope = blocking ? 'fix' : 'note';
+3 -8
View File
@@ -7,14 +7,11 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyPath } from './review-depth';
import { classifyDiff } from './review-depth';
import { audit } from './ship-audit';
import { matchesAny } from './ship-policy';
import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
/** Data files: code-class paths with no runtime behavior, left out of RUNTIME_LINES. */
const DATA_PATH = /\.(json|jsonl|ndjson|ya?ml|toml|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf)$/i;
export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md)
gstack-ship-preflight --base <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
@@ -97,10 +94,8 @@ function merge(cwd: string, base: string, r: Report): boolean {
let runtime = 'unknown';
try {
const j = JSON.parse(plan.stdout);
const outputs: string[] = j.registry?.outputs ?? [];
const counted = (j.per_path as { path: string; lines: number; binary: boolean }[]).filter(e => !e.binary && !(j.stamp_only ?? []).includes(e.path)
&& !(outputs.length && matchesAny(outputs, e.path)) && classifyPath(e.path, cwd) === 'code' && !DATA_PATH.test(e.path));
runtime = String(counted.reduce((n, e) => n + e.lines, 0));
const c = classifyDiff({ paths: j.paths, perPath: j.per_path, stampOnly: j.stamp_only, registry: j.registry, consumed: j.consumed, root: cwd, gateTagged: 0 });
runtime = String(c.runtimeLines ?? 'unknown');
} catch { /* unknown */ }
r.add(`RUNTIME_LINES: ${runtime} (gstack-ship-budget check --runtime-lines at the Step 9 and 12 boundaries)`);
return true;
+2 -2
View File
@@ -18,7 +18,7 @@ import { spawnSync } from 'node:child_process';
import * as path from 'node:path';
import type { ShipReceipt } from './headless-artifacts';
import { collectCommit } from './review-collect';
import { classifyPath, depthOfCommitInput, type Depth } from './review-depth';
import { depthOfCommitInput, gateScanNeeded, type Depth } from './review-depth';
import { runScriptSync } from './script-invocation';
import { bindReceipt, type ReceiptBindingCode } from './ship-receipt';
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
@@ -59,7 +59,7 @@ export function receiptDepthAtHead(repoRoot: string, base: string, binDir: strin
const c = collectCommit(repoRoot, baseRev, head);
const scope = scopeOfRange(repoRoot, binDir, baseRev, head);
let tagged: number | null = 0;
if (c.paths.some(p => classifyPath(p, c.root) !== 'docs')) {
if (gateScanNeeded(c.paths, c.root)) {
const atHead = git(repoRoot, ['rev-parse', 'HEAD']).out === head && git(repoRoot, ['diff', '--quiet', 'HEAD', '--']).ok;
const g = atHead ? runScriptSync(path.join(binDir, 'gstack-gate-diff'), [baseRev], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }) : null;
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g?.stdout ?? '');
+6 -1
View File
@@ -186,7 +186,12 @@ their source labels: specialist scoring is not the final review's defect count.
After specialist attempts settle, collect their outputs, tagged by actual source.
\`NO FINDINGS\` is a completed, clean result, never a failed reviewer. Otherwise parse
each JSON line and skip invalid lines. Missing or unusable output is incomplete
coverage, not an empty success. Keep each specialist's returned findings for activity stats.
coverage, not an empty success. Keep each specialist's returned findings for activity stats.${isShip ? `
An output that carries a state-machine matrix record (State Machines, or a batched
reader that read its checklist) is parsed from \`${ctx.paths.binDir}/gstack-state-matrix findings <file>\`,
after \`check\` ran on the raw file: an untested cell that is the only finding on its
cell comes back INFORMATIONAL (\`"state_matrix":"untested-cell"\`), the test-gap ruling
in \`docs/review-policy.md\`; a wrong row or collapsed kinds stays CRITICAL.` : ''}
#### 2. Validate severity
+5
View File
@@ -442,6 +442,11 @@ After specialist attempts settle, collect their outputs, tagged by actual source
`NO FINDINGS` is a completed, clean result, never a failed reviewer. Otherwise parse
each JSON line and skip invalid lines. Missing or unusable output is incomplete
coverage, not an empty success. Keep each specialist's returned findings for activity stats.
An output that carries a state-machine matrix record (State Machines, or a batched
reader that read its checklist) is parsed from `~/.claude/skills/gstack/bin/gstack-state-matrix findings <file>`,
after `check` ran on the raw file: an untested cell that is the only finding on its
cell comes back INFORMATIONAL (`"state_matrix":"untested-cell"`), the test-gap ruling
in `docs/review-policy.md`; a wrong row or collapsed kinds stays CRITICAL.
#### 2. Validate severity
+43 -1
View File
@@ -3,7 +3,7 @@ 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 { collectCommit, collectLive, CONSUMER_SCAN_CAP, loadRegistryAt } from '../lib/review-collect';
import { depthOfCommitInput } from '../lib/review-depth';
const dirs: string[] = [];
@@ -117,3 +117,45 @@ describe('collectLive: the working tree', () => {
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']);
});
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);
});
+55 -1
View File
@@ -3,7 +3,7 @@ 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, depthFor, depthOfCommitInput, diffCommand, nextStep, planReview, reviewPlanLine, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, SCOPED_RANGE_LINES, type DepthError, type PlanInput } from '../lib/review-depth';
import { classifyDiff, classifyPath, dataCandidate, depthFor, gateScanNeeded, depthOfCommitInput, diffCommand, nextStep, planReview, reviewPlanLine, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, SCOPED_RANGE_LINES, type DepthError, type PlanInput } from '../lib/review-depth';
import { summarizeReviewLoop } from '../lib/review-scope';
const ROOT = path.join(import.meta.dir, '..');
@@ -391,3 +391,57 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
expect(policy).toContain('| a full pass that fixed a blocking finding, below the cap, on a diff below full depth with a fix range of at most 150 runtime lines | scoped pass on the range |');
});
});
describe('GSTA-167 part 4a: data files no code names are docs (classifyDiff only)', () => {
const json = (n: number) => ({ path: 'wiki/post/data.json', lines: n, binary: false });
// Value: protects=a data-only docs PR (gstack-evals #34: 6,687 lines of wiki JSON) plans light with no specialists; fails_when=an
// unconsumed JSON counts as runtime lines; seam=ClassInput.consumed from lib/review-collect.ts
test('wiki JSON no code names is docs and a 6,687-line diff plans light; a numbers-bearing JSON keeps the outside voice', () => {
const input = { paths: ['wiki/post/data.json'], perPath: [json(6687)], consumed: [] as string[], gateTagged: 0 };
const c = classifyDiff(input);
expect([c.class, c.runtimeLines]).toEqual(['docs', 0]);
expect(c.reasons).toContain('docs: 1 data path(s) no code names');
const plan = planReview(base({ ...input, scope: {}, diffLines: 6687, numbers: scanNumbers({ 'wiki/post/data.json': ['{"recall": "19/29"}'] }) }));
expect([plan.depth, roles(plan)]).toEqual(['light', ['outside-voice:outside-voice:host']]);
expect(planReview(base({ ...input, scope: {}, diffLines: 6687 })).dispatches).toEqual([]);
});
test('a consumed data file, an unscanned diff and an overflowing scan all keep the file code', () => {
for (const consumed of [['wiki/post/data.json'], undefined, 'all' as const]) {
const c = classifyDiff({ paths: ['wiki/post/data.json'], perPath: [json(80)], consumed, gateTagged: 0 });
expect([c.class, c.runtimeLines]).toEqual(['code', 80]);
}
});
test.each([
'config/runtime.json', 'package.json', 'package-lock.json', 'tsconfig.build.json', 'app/.eslintrc.json', 'vite.config.json', 'public/manifest.json',
'.gstack/ship-policy.json', '.gstack/generated.json', '.github/workflows/ci.yml', 'fly.toml', 'wrangler.toml', 'docker-compose.yml', 'k8s/deploy.yaml',
'openapi.yaml', 'public/icon.svg', 'lib/gate-thresholds.json',
])('%s with no consumer stays code (config, agent prose, gate, YAML, TOML, SVG)', p => {
expect(dataCandidate(p)).toBe(false);
expect(classifyDiff({ paths: [p], perPath: [{ path: p, lines: 3, binary: false }], consumed: [], gateTagged: 0 }).class).not.toBe('docs');
});
test.each(['data/rows.csv', 'data/rows.tsv', 'logs/events.jsonl', 'assets/hero.png', 'assets/photo.JPEG', 'wiki/index.json'])('%s is a data candidate', p => expect(dataCandidate(p)).toBe(true));
test('test fixtures stay tests and markdown stays docs: the rule never touches a non-code class', () => {
expect(classifyDiff({ paths: ['test/fixtures/a.json'], consumed: [], gateTagged: 0 }).class).toBe('tests');
expect(dataCandidate('test/fixtures/a.json')).toBe(false);
});
// Value: protects=F17, the behavior change is named: an image no code names is docs, not a binary_code_path error; one code names
// still fails closed; fails_when=either direction flips; seam=none
test('a binary PNG no code names is docs with no error; one code names is still binary_code_path (full)', () => {
const png = { path: 'assets/hero.png', lines: 0, binary: true };
const free = classifyDiff({ paths: ['assets/hero.png', 'src/app.ts'], perPath: [png, { path: 'src/app.ts', lines: 4, binary: false }], consumed: [], gateTagged: 0 });
expect([free.errors, free.runtimeLines]).toEqual([[], 4]);
const named = classifyDiff({ paths: ['assets/hero.png'], perPath: [png], consumed: ['assets/hero.png'], gateTagged: 0 });
expect(named.errors.map(e => e.code)).toEqual(['binary_code_path']);
});
// Value: protects=F1, the receipt's gate scan cannot be skipped through the data rule; fails_when=a data-only range reads as
// needing no scan, so tagged=0 is recorded unscanned; seam=lib/ship-receipt-depth.ts receiptDepthAtHead
test('gateScanNeeded is path-only: a data-only range still needs the scan, a markdown-only range does not', () => {
expect(gateScanNeeded(['wiki/post/data.json'])).toBe(true);
expect(gateScanNeeded(['assets/hero.png', 'README.md'])).toBe(true);
expect(gateScanNeeded(['README.md', 'docs/a.md'])).toBe(false);
expect(classifyPath('wiki/post/data.json')).toBe('code');
});
test('a deleted data file stays guarded: a code diff that deletes one is gate class', () => {
expect(classifyDiff({ paths: ['src/app.ts', 'wiki/old.json'], deleted: ['wiki/old.json'], consumed: [], gateTagged: 0 }).class).toBe('gate');
});
});
+37 -23
View File
@@ -45,38 +45,52 @@ describe('reviewer packets stay reverted (GSTA-167)', () => {
* specialist, Red Team, adversarial and outside-voice prompts, the checklists they read, the
* fix-check prompt and the depth rules that pick reviewers. A change to any of them changes what a
* reviewer sees or how its findings rate, so it merges only after its preregistered no-loss replay
* passes (GStack Minder crew rule, 2026-10-30). Repin with that replay's id in the commit message.
* passes (GStack Minder crew rule, 2026-10-30); a cost replay alone is not enough (#3621 passed its
* cost replay, then its no-loss replay found 11 of 30 blockers). Every pin names the replay that
* admits its bytes: repin with that replay's id here and in the commit message, and merge only on
* its passing result.
*/
const REVIEWER_BRIEFS: Record<string, string> = {
'ship/sections/review-army.md': '3d89708c2776b0d97fb2420c0461d37179b9f25ca0677ba038a6cff53dbf7465',
'ship/sections/adversarial.md': '3f361c1f42605c8329048c81162216e7bf9902bdbb2327811c39ee4e201bb95d',
'review/sections/review-army.md': '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967',
'review/sections/adversarial.md': 'a402018055670f50b75322ab92a0b03d740c481cc5ef2f2ac72b281dae29ec7b',
'review/checklist.md': '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699',
'review/design-checklist.md': 'b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83',
'review/gate-integrity.md': 'f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332',
'review/specialists/api-contract.md': '263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95',
'review/specialists/data-migration.md': 'b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca',
'review/specialists/maintainability.md': 'f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b',
'review/specialists/performance.md': '545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc',
'review/specialists/red-team.md': '9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427',
'review/specialists/security.md': '17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f',
'review/specialists/simplification.md': 'f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f',
'review/specialists/state-machines.md': '597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2',
'review/specialists/testing.md': 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4',
'scripts/resolvers/fix-check.ts': '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08',
'lib/review-depth.ts': '66f4ff6b03c869d6892f6efdfb237d578c301cf4ea6f6ecb4b366cd286a0bcb5',
const NO_LOSS_REPLAYS = {
'#3638': 'no-loss round 3: main restored the pre-#3621 reviewers (v1.91.331.0)',
'PR-a': "GSTA-167 variant G Arm 4: structural plan identity at the 14 cohort heads and the free exact re-score of saved C, D and A' findings (parts 4a and 4b)",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
'ship/sections/review-army.md': { sha: '8cb848bf22f4decd1b88f18b0a3359af81fc4e45ff81abf626ebf0a1557f8668', replay: 'PR-a' },
'ship/sections/adversarial.md': { sha: '3f361c1f42605c8329048c81162216e7bf9902bdbb2327811c39ee4e201bb95d', replay: '#3638' },
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
'review/sections/adversarial.md': { sha: 'a402018055670f50b75322ab92a0b03d740c481cc5ef2f2ac72b281dae29ec7b', replay: '#3638' },
'review/checklist.md': { sha: '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699', replay: '#3638' },
'review/design-checklist.md': { sha: 'b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83', replay: '#3638' },
'review/gate-integrity.md': { sha: 'f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332', replay: '#3638' },
'review/specialists/api-contract.md': { sha: '263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95', replay: '#3638' },
'review/specialists/data-migration.md': { sha: 'b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca', replay: '#3638' },
'review/specialists/maintainability.md': { sha: 'f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b', replay: '#3638' },
'review/specialists/performance.md': { sha: '545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc', replay: '#3638' },
'review/specialists/red-team.md': { sha: '9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427', replay: '#3638' },
'review/specialists/security.md': { sha: '17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f', replay: '#3638' },
'review/specialists/simplification.md': { sha: 'f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f', replay: '#3638' },
'review/specialists/state-machines.md': { sha: '597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2', replay: '#3638' },
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
'lib/review-depth.ts': { sha: '49a32aa4e94df58d9aebc34ed46bfff7153cc2202ad43692d07390560e617372', replay: 'PR-a' },
};
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
test('every pinned reviewer brief, checklist and depth rule hashes to its pin', () => {
const drift = Object.entries(REVIEWER_BRIEFS).filter(([file, sha]) => {
const drift = Object.entries(REVIEWER_BRIEFS).filter(([file, pin]) => {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8').replace(/\r\n/g, '\n');
return createHash('sha256').update(text).digest('hex') !== sha;
}).map(([file]) => file);
return createHash('sha256').update(text).digest('hex') !== pin.sha;
}).map(([file]) => `${file} (a repin needs a passing no-loss replay; the current pin names ${REVIEWER_BRIEFS[file]!.replay})`);
expect(drift).toEqual([]);
});
test('every pin names the preregistered no-loss replay that admits its bytes', () => {
const unnamed = Object.entries(REVIEWER_BRIEFS).filter(([, pin]) => !(pin.replay in NO_LOSS_REPLAYS)).map(([file]) => file);
expect(unnamed).toEqual([]);
for (const what of Object.values(NO_LOSS_REPLAYS)) expect(what).toMatch(/no-loss|re-score/);
});
test('the pins cover every specialist checklist', () => {
const specialists = fs.readdirSync(path.join(ROOT, 'review/specialists')).filter(f => f.endsWith('.md')).map(f => `review/specialists/${f}`);
expect(specialists.filter(f => !(f in REVIEWER_BRIEFS))).toEqual([]);
+62 -2
View File
@@ -7,12 +7,13 @@
* in the range since the previous pass (or the diff) and notes the rest.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import {
isBlocking, kindOf, locusOf, parseHunks, raisedBy, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
type ScopedFinding,
isBlocking, kindOf, locusOf, mapStateMatrixOutput, parseHunks, raisedBy, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
UNTESTED_CELL, type ScopedFinding, type ScopeFinding,
} from '../lib/review-scope';
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
@@ -452,3 +453,62 @@ describe('raised_by: which reviewer raised a classified finding (GSTA-152 D6)',
expect(summarizeReviewLoop([old], '2026-10-28T09:00:00Z')).toMatchObject({ passes: 1, blocking_found: 1, fixed: 1 });
});
});
describe('GSTA-167 part 4b: untested state-matrix cells map below the floor per matrix cell, before the merge', () => {
const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'state-matrix-6792', 'captured-output.jsonl');
const parse = (text: string) => text.split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(o => !o.matrix);
const scope = (findings: ScopeFinding[], policy: 'frozen' | 'all' = 'frozen') => scopeFindings(findings, { policy, pass: 1, diffHunks: { 'src/takes/proposals.ts': 'whole', 'src/m.ts': 'whole' }, rangeHunks: null });
const matrix = (test: string | null) => JSON.stringify({ specialist: 'state-machines', matrix: { machine: 'm (src/m.ts)', kinds: ['hold'], actions: ['release'], cells: [{ kind: 'hold', action: 'release', row: 'hold released', test }] } });
const untested = { severity: 'CRITICAL', confidence: 7, path: 'src/m.ts', line: 9, category: 'state-matrix', summary: 'untested cell: hold × release → hold released', fingerprint: 'src/m.ts:state-matrix:hold:release', specialist: 'state-machines', cell: { kind: 'hold', action: 'release' } };
// Value: protects=the captured #6792 review keeps its wrong-row and collapsed-kinds defects blocking while each untested cell
// alone on its cell becomes a pass-1 note; matrix records pass through byte for byte; fails_when=a defect is demoted or a
// pure test gap blocks; seam=mapStateMatrixOutput before Step 9.3's merge
test('#6792: four lone untested cells become notes; wrong rows, collapsed kinds and cells they share stay blocking', () => {
const raw = fs.readFileSync(FIXTURE, 'utf8');
const out = mapStateMatrixOutput(raw);
expect(out.mapped).toBe(4);
expect(out.text.split('\n')[0]).toBe(raw.split('\n')[0]);
const scoped = scope(parse(out.text));
const blocking = scoped.filter(f => f.blocking).map(f => f.summary!.split(':')[0]);
expect(blocking.sort()).toEqual(['collapsed kinds', 'untested cell', 'untested cell', 'wrong row', 'wrong row']);
const notes = scoped.filter(f => f.state_matrix === UNTESTED_CELL);
expect(notes.map(f => [f.kind, f.blocking, f.scope, f.severity, f.raw_severity])).toEqual(Array(4).fill(['test-gap', false, 'note', 'INFORMATIONAL', 'CRITICAL']));
});
// Value: protects=F3, a wrong-row finding that reuses the untested cell's fingerprint can never be folded into a demoted record;
// fails_when=the merge keeps the demoted summary and the data-loss row drops to Review notes; seam=the merge keeps CRITICAL if either
test('a wrong row sharing the untested cell and its fingerprint keeps both CRITICAL, before and after a merge', () => {
const wrong = { ...untested, summary: 'wrong row: hold × release deletes the hold', line: 12 };
const out = mapStateMatrixOutput([matrix(null), JSON.stringify(untested), JSON.stringify(wrong)].join('\n'));
expect(out.mapped).toBe(0);
const merged = { ...parse(out.text)[0], severity: 'CRITICAL' };
expect(scope([merged])[0]).toMatchObject({ kind: 'defect', blocking: true, scope: 'fix' });
const demoted = parse(mapStateMatrixOutput([matrix(null), JSON.stringify(untested)].join('\n')).text)[0];
expect(scope([{ ...demoted, severity: 'CRITICAL' }])[0]).toMatchObject({ kind: 'defect', blocking: true });
});
test('no matrix record, a cell the matrix maps to a test, or a lone wrong row on an untested cell: every one stays CRITICAL', () => {
const wrongAlone = { ...untested, summary: 'wrong row: hold × release deletes the hold' };
for (const text of [JSON.stringify(untested), [matrix('test/m.test.ts:4'), JSON.stringify(untested)].join('\n'), [matrix(null), JSON.stringify(wrongAlone)].join('\n')]) {
const out = mapStateMatrixOutput(text);
expect([out.mapped, out.text]).toEqual([0, text]);
expect(scope(parse(text))[0]!.blocking).toBe(true);
}
});
test('policy all fixes a mapped cell; other policies note it from pass 1 on, in or out of the diff', () => {
const [f] = parse(mapStateMatrixOutput([matrix(null), JSON.stringify(untested)].join('\n')).text);
expect(scope([f], 'all')[0]!.scope).toBe('fix');
expect(scope([f])[0]!.scope).toBe('note');
expect(scopeFindings([f], { policy: 'diff-plus-blocking', pass: 2, diffHunks: {}, rangeHunks: {} })[0]!.scope).toBe('note');
});
test('gstack-state-matrix findings prints the mapped output and the count on stderr; check still reads the raw file', () => {
const r = spawnSync('bun', ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-state-matrix'), 'findings', FIXTURE], { encoding: 'utf8', timeout: 60_000 });
expect(r.status).toBe(0);
expect(r.stderr).toContain('STATE_MATRIX_FINDINGS: mapped=4');
expect(r.stdout).toBe(mapStateMatrixOutput(fs.readFileSync(FIXTURE, 'utf8')).text);
expect(fs.readFileSync(path.join(ROOT, 'review', 'specialists', 'state-machines.md'), 'utf8')).toContain('- **Untested cell (CRITICAL; this rule\'s High tier).**');
});
});
+16
View File
@@ -192,3 +192,19 @@ describe('gstack-ship-preflight --audit (GSTA-167 variant G)', () => {
expect(f.run(PREFLIGHT, ['--audit', '--base', 'main']).status).toBe(2);
});
});
describe('GSTA-167 part 4a: RUNTIME_LINES and REVIEW_DEPTH share one owner', () => {
// Value: protects=preflight's RUNTIME_LINES equals gstack-review-plan's runtime_lines on a mixed diff (code, a data file code
// names, one nothing names, config JSON, YAML); fails_when=the two counts disagree, as preflight's own DATA_PATH did; seam=classifyDiff
test('a mixed diff counts the same runtime lines in preflight and the review plan', () => {
const f = fx({}, {
'src/b.ts': 'export const b = 2;\n', 'src/load.ts': "export const u = 'used.json';\n", 'data/used.json': '[1,\n2,\n3]\n',
'data/rows.json': Array.from({ length: 100 }, (_, i) => `{"i":${i}}`).join('\n') + '\n', 'config/app.json': '{"a":1}\n', 'deploy.yaml': 'a: 1\nb: 2\n',
});
const preflight = lineOf(f.run(PREFLIGHT, ['--base', 'main']).stdout, 'RUNTIME_LINES');
const plan = JSON.parse(f.run(path.join(path.dirname(PREFLIGHT), 'gstack-review-plan'), ['--base', 'main', '--json', '--gate-tagged', '0']).stdout);
expect(plan.runtime_lines).toBe(1 + 1 + 3 + 1 + 2);
expect(preflight).toMatch(new RegExp(`^RUNTIME_LINES: ${plan.runtime_lines} `));
expect(plan.consumed).toEqual(['data/used.json']);
});
});
+21 -1
View File
@@ -11,7 +11,7 @@ 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 { bindShippedReceipt, receiptDepthAtHead, receiptDepthVerdict, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
@@ -221,3 +221,23 @@ describe('the queue and a scoped second pass (GSTA-152 D6)', () => {
expect(bound.ok && bound.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard');
}, 60_000);
});
describe('GSTA-167 part 4a: the data rule never skips the receipt gate scan', () => {
// Value: protects=F1, receiptDepthAtHead decides the gate scan path-only, so a range whose only change is a data file is
// scanned and a failed scan records null, never an unscanned 0; fails_when=the scan is skipped through the data rule;
// seam=stub gstack-gate-diff that fails
test('a data-only range no code names is scanned (a failing scan records null); a markdown-only range is not', () => {
const run = (files: Record<string, string>) => {
const repo = makeFixtureRepo(); repos.push(repo);
const bin = path.join(repo.root, 'stub-bin');
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, 'gstack-diff-scope'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'gstack-gate-diff'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
return receiptDepthAtHead(repo.work, 'main', bin, commit(repo, files));
};
const data = run({ 'wiki/post/data.json': '{"a": 1}\n' });
expect([data.class, data.runtime_lines, data.gate_scan.tagged]).toEqual(['docs', 0, null]);
const prose = run({ 'notes/a.md': '# a\n' });
expect([prose.class, prose.gate_scan.tagged]).toEqual(['docs', 0]);
}, 60_000);
});
+13
View File
@@ -208,3 +208,16 @@ describe('gstack-ship-release push: branch names', () => {
expect(fs.existsSync(path.join(f.work, 'PWNED'))).toBe(false);
});
});
describe('GSTA-167 part 4a: the post-review route stays path-only', () => {
// Value: protects=F1/F2, a data file edited after review sends the ship back through review even when no code names it,
// because the route never applies the data rule; fails_when=a JSON edit after review takes the docs-rechecked route; seam=classifyPath
test('a JSON edited after review is a behavior change, not docs', () => {
const f = fx();
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'wiki/post/data.json': '{"a": 1}\n' }); f.commit('late data');
const r = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect(r.stdout).toContain('STOP: behavior, tests or build inputs changed since review (wiki/post/data.json)');
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"bytes": 442,
"why": "GSTA-167 variant G part 4b: Step 9.2 parses state-machine output through gstack-state-matrix findings (untested cells below the floor per matrix cell)"
}