mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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:
1 parent
21277291c8
commit
33d6029273
18 files changed
+436
-54
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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).**');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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)"
|
||||
}
|
||||
Reference in new issue
Block a user