mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* wip(GSTA-131): review depth, fix-check transitions, dispatch counters, carry pregate, autoplan round policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wip(GSTA-131): reviewers stay at full effort; audits light Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): Step 9 dispatches what gstack-review-plan plans; fix-check after below-floor fixes; cap two full passes plus one fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-dispatch-compare has a free lane; review-depth fixtures registered free; resumed max_rounds case off the old default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-1 repairs: fix-check binds under its own skill, Step 11.5 accepts a fix-check or a planned adversarial skip, delta carry with a pregate, carry --pregate pinned to the checkout, quiet-after-cap revise regates, skill-dir markdown and rule docs classed code, gate signals from tests class up, plan fails closed on git errors, per-class compare ratios Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-2 repairs: Step 11 follows the last full-pass plan, Important Rules restored, ship goldens regenerated, true per-class medians, review-policy transitions and once-per-invocation voices, docs/** assets stay docs-class, CLI and fix-check binding tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GSTA-131 review depth, fix-check, delta /ship and autoplan rounds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy cap and counters, review-policy /review cycle cap, unattended passes (docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): 400-word findings cap fits the /review size pins (five host pins lowered by 2); CHANGELOG max_rounds wording Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): gate integrity names the size-ledger entry and the lowered /review pins Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the round-budget case asserts the default through ROUNDS output only (pregate literals heuristic) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: curate ship-receipt-carry-pregate out of the Windows lane like its --pregate siblings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): end the Windows curation sentence instead of a mid-sentence colon Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): one decision each for the review-loop exits, the planned adversarial skip and Step 16's TODOS rerun (screen judge repair) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): fix-check repairs: pin the renamed Step 11 heading, keep the below-floor exit at the cap, name the plan run a repeat reuses, print the native skip on outside-only plans Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
348 lines
21 KiB
TypeScript
348 lines
21 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { deriveReviewStatus, readJsonl } from './headless-artifacts';
|
|
import { resultLine, type ResultCodeName } from './result-codes';
|
|
|
|
const DIFF_REVIEWS = new Set(['review', 'review-fix-check', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']);
|
|
const SHARED_LIBS_COVERAGE_VERSION = 1;
|
|
const HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
|
|
function record(value: unknown): value is Record<string, any> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function relativeSourcePath(value: unknown): value is string {
|
|
return typeof value === 'string' && value.trim().length > 0 &&
|
|
!/^[A-Za-z]:|[\\\x00-\x1f\x7f]/.test(value) &&
|
|
value.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part !== '.git');
|
|
}
|
|
|
|
function sha256(value: string): string {
|
|
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
}
|
|
|
|
/** Structural identity only; the reviewer must establish authored-source provenance. */
|
|
export function sharedLibsFingerprint(input: unknown): string | undefined {
|
|
if (!record(input) || !Array.isArray(input.evidence_paths) || input.evidence_paths.length === 0 ||
|
|
!Array.from(input.evidence_paths).every(relativeSourcePath) || !record(input.helper_target)) return;
|
|
const target = input.helper_target;
|
|
if (!relativeSourcePath(target.path) || typeof target.symbol !== 'string' ||
|
|
target.symbol.trim().length === 0 || /[\x00-\x1f\x7f]/.test(target.symbol)) return;
|
|
// Default Array.sort compares UTF-16 code units; localeCompare would change the identity by locale.
|
|
const paths = [...new Set(input.evidence_paths)].sort();
|
|
return `shared-libs:${sha256(JSON.stringify(['shared-libs', 1, paths, target.path, target.symbol]))}`;
|
|
}
|
|
|
|
/**
|
|
* Both prior snapshot_covered_paths and current covered_paths must be verified
|
|
* ordinary source files whose raw bytes equal their blobs in the bound snapshot.
|
|
* Exclude symlinks, submodules, ignored/outside files, index flags/sparse paths,
|
|
* and Git filter/encoding transformations. This pure check does not inspect a repo.
|
|
*/
|
|
export function canReuseSharedLibsAdvisory(
|
|
priorFinding: unknown, currentFinding: unknown, priorReview: unknown, currentSnapshot: unknown,
|
|
): boolean {
|
|
if (!record(priorFinding) || !record(currentFinding) || !record(priorReview) || !record(currentSnapshot) ||
|
|
priorFinding.advisory !== true || currentFinding.advisory !== true ||
|
|
priorFinding.severity !== 'INFORMATIONAL' || currentFinding.severity !== 'INFORMATIONAL' ||
|
|
priorFinding.action !== 'skipped') return false;
|
|
const identity = sharedLibsFingerprint(currentFinding);
|
|
if (!identity || sharedLibsFingerprint(priorFinding) !== identity || priorFinding.fingerprint !== identity ||
|
|
(currentFinding.fingerprint !== undefined && currentFinding.fingerprint !== identity)) return false;
|
|
|
|
const binding = priorReview.review_binding;
|
|
if (priorReview.skill !== 'review' || priorReview.completed !== true || priorReview.converged !== true ||
|
|
!record(binding) || binding.state !== 'verified' || typeof currentSnapshot.wtree !== 'string' ||
|
|
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(currentSnapshot.wtree) ||
|
|
priorReview.wtree !== currentSnapshot.wtree || binding.start_wtree !== currentSnapshot.wtree ||
|
|
binding.end_wtree !== currentSnapshot.wtree || typeof currentSnapshot.branch_id !== 'string' ||
|
|
!/^[0-9a-f]{64}$/.test(currentSnapshot.branch_id) || binding.branch_id !== currentSnapshot.branch_id ||
|
|
!Array.isArray(priorFinding.snapshot_covered_paths) ||
|
|
!Array.from(priorFinding.snapshot_covered_paths).every(relativeSourcePath) ||
|
|
!Array.isArray(currentSnapshot.covered_paths) || !Array.from(currentSnapshot.covered_paths).every(relativeSourcePath)) return false;
|
|
const priorCovered = new Set(priorFinding.snapshot_covered_paths);
|
|
const covered = new Set(currentSnapshot.covered_paths);
|
|
return currentFinding.evidence_paths.every((path: string) => priorCovered.has(path) && covered.has(path));
|
|
}
|
|
|
|
export function sharedLibsSnapshotCoverage(repo: string, wtree: string, paths: unknown, env = process.env): string[] {
|
|
if (!repo || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(wtree) || !Array.isArray(paths) ||
|
|
!Array.from(paths).every(relativeSourcePath)) return [];
|
|
const git = (...args: string[]) => {
|
|
const result = spawnSync('git', ['--no-replace-objects', '-c', 'core.fsmonitor=false',
|
|
'-c', 'core.untrackedCache=false', ...args], {
|
|
cwd: repo, env: { ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1', GIT_NO_LAZY_FETCH: '1' },
|
|
timeout: 10_000, maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
if (result.status !== 0 || result.error) throw new Error('Git snapshot inspection failed');
|
|
return result.stdout;
|
|
};
|
|
try {
|
|
const config = new Map(git('config', '--list', '-z').toString().split('\0').filter(Boolean).map(item => {
|
|
const split = item.indexOf('\n');
|
|
return split < 0 ? [item.toLowerCase(), 'true'] as const
|
|
: [item.slice(0, split).toLowerCase(), item.slice(split + 1)] as const;
|
|
}));
|
|
if (config.has('core.autocrlf') && config.get('core.autocrlf')?.toLowerCase() !== 'false') return [];
|
|
if ([...config.keys()].some(key => key === 'extensions.partialclone' || /^remote\..*\.promisor$/.test(key))) return [];
|
|
if (git('cat-file', '-t', wtree).toString().trim() !== 'tree') return [];
|
|
} catch { return []; }
|
|
|
|
return [...new Set(paths as string[])].filter(path => {
|
|
let fd: number | undefined;
|
|
try {
|
|
let absolute = repo;
|
|
for (const component of path.split('/')) {
|
|
absolute = join(absolute, component);
|
|
const stat = lstatSync(absolute);
|
|
if (stat.isSymbolicLink() || (!stat.isDirectory() && absolute !== join(repo, path))) return false;
|
|
}
|
|
const before = lstatSync(absolute);
|
|
if (!before.isFile()) return false;
|
|
const ignored = spawnSync('git', ['-c', 'core.fsmonitor=false', 'check-ignore', '--no-index', '-q', '--', path], {
|
|
cwd: repo, env: { ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '0' }, timeout: 10_000,
|
|
});
|
|
if (ignored.status !== 1 || ignored.error) return false;
|
|
const tracked = git('ls-files', '-v', '-z', '--', path).toString();
|
|
if (tracked ? tracked !== `H ${path}\0`
|
|
: git('ls-files', '--others', '--exclude-standard', '-z', '--', path).toString() !== `${path}\0`) return false;
|
|
if (tracked) {
|
|
const stage = git('ls-files', '--stage', '--sparse', '-z', '--', path).toString();
|
|
if (!/^(?:100644|100755) [0-9a-f]+ 0\t/.test(stage) || stage.split('\0').filter(Boolean).length !== 1) return false;
|
|
}
|
|
const names = ['filter', 'working-tree-encoding', 'ident', 'text', 'eol', 'crlf'];
|
|
const attrs = git('check-attr', '-z', ...names, '--', path).toString().split('\0');
|
|
if (attrs.length !== names.length * 3 + 1) return false;
|
|
for (let i = 0; i < names.length; i++) {
|
|
if (attrs[i * 3] !== path || attrs[i * 3 + 1] !== names[i] ||
|
|
!['unspecified', 'unset'].includes(attrs[i * 3 + 2])) return false;
|
|
}
|
|
const entry = git('ls-tree', '-z', wtree, '--', path).toString();
|
|
const match = /^(100644|100755) blob ([0-9a-f]+)\t([^\0]+)\0$/.exec(entry);
|
|
if (!match || match[3] !== path) return false;
|
|
if (process.platform !== 'win32' && (Boolean(before.mode & 0o111) !== (match[1] === '100755'))) return false;
|
|
fd = openSync(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
const bytes = readFileSync(fd);
|
|
const after = fstatSync(fd);
|
|
if ((['dev', 'ino', 'mode', 'size', 'mtimeMs', 'ctimeMs'] as const).some(key => before[key] !== after[key])) return false;
|
|
return bytes.equals(git('cat-file', 'blob', match[2]));
|
|
} catch { return false; }
|
|
finally { if (fd !== undefined) closeSync(fd); }
|
|
});
|
|
}
|
|
|
|
export function checkSharedLibsReuse(finding: unknown, token: string, env = process.env): Record<string, any> {
|
|
const fingerprint = sharedLibsFingerprint(finding);
|
|
const result: Record<string, any> = { reusable: false, fingerprint };
|
|
if (!fingerprint || !record(finding) || !/^[0-9a-f-]{36}$/.test(token) ||
|
|
!env.GSTACK_REVIEW_REPO || !env.GSTACK_REVIEW_BRANCH || !env.GSTACK_STAMP_WTREE ||
|
|
!env.GSTACK_REVIEW_DIR || !env.GSTACK_REVIEW_LOG) return result;
|
|
try {
|
|
const start = JSON.parse(readFileSync(join(env.GSTACK_REVIEW_DIR, '.review-starts', `${token}.json`), 'utf8'));
|
|
result.review_start = start;
|
|
if (start.skill !== 'review' || start.repo !== env.GSTACK_REVIEW_REPO ||
|
|
start.branch !== env.GSTACK_REVIEW_BRANCH || start.wtree !== env.GSTACK_STAMP_WTREE) return result;
|
|
const snapshot = {
|
|
wtree: start.wtree, branch_id: sha256(start.branch),
|
|
covered_paths: sharedLibsSnapshotCoverage(start.repo, start.wtree, finding.evidence_paths, env),
|
|
};
|
|
result.snapshot = snapshot;
|
|
const rows = readFileSync(env.GSTACK_REVIEW_LOG, 'utf8').split('\n').filter(Boolean);
|
|
for (const row of rows.reverse()) {
|
|
let prior;
|
|
try { prior = JSON.parse(row); } catch { continue; }
|
|
if (!record(prior) || prior.shared_libs_coverage_version !== SHARED_LIBS_COVERAGE_VERSION ||
|
|
!Array.isArray(prior.findings)) continue;
|
|
if (prior.findings.some(value => canReuseSharedLibsAdvisory(value, finding, prior, snapshot))) {
|
|
result.reusable = true;
|
|
return result;
|
|
}
|
|
}
|
|
} catch { return result; }
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* #3339: a fix-range repeat reviews only what changed since the latest pass of
|
|
* the same skill, and only when that pass completed and made fixes on this
|
|
* branch, no Step 11 review followed it, and the content it started from is
|
|
* exactly its commit's tree. That commit, held in the review log rather than
|
|
* the working tree, is the base; it must still be an ancestor of HEAD, so
|
|
* `git diff <base>` holds every fix. Anything else needs a full pass.
|
|
*/
|
|
function fixRangeBase(skill: string, env: NodeJS.ProcessEnv): string {
|
|
const refuse = (reason: string): never => {
|
|
throw new Error(`gstack-review-log: fix-range refused: ${reason}; run a full pass (gstack-review-log --start ${skill})`);
|
|
};
|
|
let rows: Record<string, any>[] = [];
|
|
try {
|
|
rows = readFileSync(env.GSTACK_REVIEW_LOG!, 'utf8').split('\n').filter(Boolean).flatMap(line => {
|
|
try { const row = JSON.parse(line); return record(row) ? [row] : []; } catch { return []; }
|
|
});
|
|
} catch {}
|
|
const at = rows.findLastIndex(row => row.skill === skill);
|
|
if (at < 0) refuse('no earlier pass on this branch');
|
|
if (rows.slice(at + 1).some(row => row.skill === 'adversarial-review' || row.skill === 'codex-review')) refuse('a Step 11 review followed the latest pass');
|
|
const prior = rows[at]!, binding = prior.review_binding;
|
|
if (prior.completed !== true) refuse('the latest pass did not complete');
|
|
if (prior.converged !== false) refuse('the latest pass made no fixes');
|
|
if (!record(binding) || (binding.state !== 'changed' && binding.state !== 'incomplete') || typeof binding.start_wtree !== 'string') refuse('the latest pass has no captured start');
|
|
if (binding.branch_id !== sha256(env.GSTACK_REVIEW_BRANCH ?? '')) refuse('the latest pass is from another branch');
|
|
const base = prior.commit_full;
|
|
const git = (...args: string[]) => spawnSync('git', args, { cwd: env.GSTACK_REVIEW_REPO, encoding: 'utf8', timeout: 10_000 });
|
|
if (typeof base !== 'string' || !HASH.test(base) || git('cat-file', '-e', `${base}^{commit}`).status !== 0) refuse('the latest pass has no resolvable commit');
|
|
if (git('rev-parse', `${base}^{tree}`).stdout.trim() !== binding.start_wtree) refuse('the latest pass started from uncommitted content, so no commit marks what it read');
|
|
if (git('merge-base', '--is-ancestor', base, 'HEAD').status !== 0) refuse(`${base} is not an ancestor of HEAD`);
|
|
return base;
|
|
}
|
|
|
|
export function captureReviewStart(skill: string, env = process.env, fixRange = false): string {
|
|
if (!DIFF_REVIEWS.has(skill) || !env.GSTACK_STAMP_WTREE || !env.GSTACK_REVIEW_REPO) {
|
|
throw new Error('cannot capture a diff review without a working-tree fingerprint');
|
|
}
|
|
const fixBase = fixRange ? fixRangeBase(skill, env) : undefined;
|
|
const dir = join(env.GSTACK_REVIEW_DIR!, '.review-starts');
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
const token = crypto.randomUUID();
|
|
writeFileSync(join(dir, `${token}.json`), JSON.stringify({
|
|
skill, repo: env.GSTACK_REVIEW_REPO, branch: env.GSTACK_REVIEW_BRANCH,
|
|
wtree: env.GSTACK_STAMP_WTREE, started_at: new Date().toISOString(),
|
|
...(fixBase ? { scope: 'fix-range', fix_base: fixBase } : {}),
|
|
}), { mode: 0o600, flag: 'wx' });
|
|
return fixBase ? `${token}\nFIX_BASE=${fixBase}` : token;
|
|
}
|
|
|
|
const GATE_ACTIONS = new Set(['kept', 'restored', 'open']);
|
|
|
|
/**
|
|
* Gate findings (any finding carrying a `gate` tag) are stamped by the logger,
|
|
* never by the caller: `actor` comes from gstack-session-kind through
|
|
* GSTACK_STAMP_SESSION_KIND, and a session no human is watching cannot record
|
|
* a disposition, so its gate findings are forced to `action: "open"`. Only
|
|
* `interactive` keeps the caller's `kept`/`restored`/`open`; an action outside
|
|
* that set is a malformed disposition and also becomes `open`.
|
|
*/
|
|
export function stampGateFindings(findings: unknown, sessionKind: string | undefined): void {
|
|
if (!Array.isArray(findings)) return;
|
|
const actor = sessionKind === 'spawned' || sessionKind === 'headless' || sessionKind === 'unattended' ? sessionKind : 'interactive';
|
|
for (const finding of findings) {
|
|
if (!record(finding) || typeof finding.gate !== 'string' || finding.gate.length === 0) continue;
|
|
finding.actor = actor;
|
|
if (actor !== 'interactive' || !GATE_ACTIONS.has(finding.action)) finding.action = 'open';
|
|
if (finding.action === 'open') delete finding.reason;
|
|
}
|
|
}
|
|
|
|
/** A rejected row: `code` is the result code the wrapper prints; nothing was written. */
|
|
export class ReviewLogError extends Error {
|
|
constructor(public code: ResultCodeName, message: string) { super(message); }
|
|
}
|
|
|
|
const DERIVED_KEYS = ['status', 'unresolved', 'issues_found', 'critical_gaps'] as const;
|
|
|
|
/**
|
|
* B7: derive status/unresolved/issues_found/critical_gaps from a findings file
|
|
* (lib/headless-artifacts.ts `findings` rows or review findings with
|
|
* severity + disposition/action). A claimed value that disagrees rejects the
|
|
* row with REVIEW_STATUS_MISMATCH; the file is the record, never the claim.
|
|
*/
|
|
export function deriveFromFindings(rec: Record<string, any>, findingsPath: string): Record<string, any> {
|
|
let read;
|
|
try { read = readJsonl(findingsPath); }
|
|
catch (e: any) { throw new ReviewLogError('ARTIFACT_MISSING', resultLine('review-log', 'ARTIFACT_MISSING', `${findingsPath}: ${e.message}`)); }
|
|
if (read.errors.length) throw new ReviewLogError('ARTIFACT_MALFORMED_JSONL', resultLine('review-log', 'ARTIFACT_MALFORMED_JSONL', `${read.errors[0]!.path}: ${read.errors[0]!.message}`));
|
|
const derived = deriveReviewStatus(read.rows);
|
|
const mismatches = DERIVED_KEYS.filter(key => rec[key] !== undefined && rec[key] !== derived[key])
|
|
.map(key => `claimed ${key} ${JSON.stringify(rec[key])}, findings file has ${JSON.stringify(derived[key])}`);
|
|
if (mismatches.length) {
|
|
throw new ReviewLogError('REVIEW_STATUS_MISMATCH', `review-log: status mismatch: ${mismatches.join('; ')} (${derived.findings_open} open of ${derived.findings_total}) (REVIEW_STATUS_MISMATCH)`);
|
|
}
|
|
return { ...rec, ...derived, status_source: 'derived', findings_file: findingsPath };
|
|
}
|
|
|
|
export function bindReview(rec: Record<string, any>, token: string, env = process.env, findingsPath?: string): Record<string, any> {
|
|
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key];
|
|
// The findings file is validated BEFORE the start token is consumed below:
|
|
// a rejected row must leave the token for the retry.
|
|
rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' };
|
|
if (env.GSTACK_STAMP_COMMIT_FULL) rec.commit_full = env.GSTACK_STAMP_COMMIT_FULL;
|
|
if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE;
|
|
if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true';
|
|
stampGateFindings(rec.findings, env.GSTACK_STAMP_SESSION_KIND);
|
|
if (!DIFF_REVIEWS.has(rec.skill)) {
|
|
if (env.GSTACK_STAMP_WTREE) rec.wtree = env.GSTACK_STAMP_WTREE;
|
|
return rec;
|
|
}
|
|
|
|
let start;
|
|
if (/^[0-9a-f-]{36}$/.test(token)) {
|
|
const file = join(env.GSTACK_REVIEW_DIR!, '.review-starts', `${token}.json`);
|
|
try {
|
|
const saved = readFileSync(file, 'utf8');
|
|
unlinkSync(file);
|
|
const parsed = JSON.parse(saved);
|
|
if (parsed.skill === rec.skill && parsed.repo === env.GSTACK_REVIEW_REPO &&
|
|
parsed.branch === env.GSTACK_REVIEW_BRANCH && parsed.wtree) start = parsed;
|
|
} catch (error: any) {
|
|
if (error.code !== 'ENOENT') console.error(`gstack-review-log: cannot consume review start: ${error.message}`);
|
|
}
|
|
}
|
|
const end = env.GSTACK_STAMP_WTREE;
|
|
const state = !start || !end ? 'uncaptured'
|
|
: start.wtree !== end ? 'changed'
|
|
: rec.completed !== true || rec.converged !== true ? 'incomplete' : 'verified';
|
|
const branchId = typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {};
|
|
const narrowed = start?.scope === 'fix-range' || start?.fix_base !== undefined;
|
|
if (narrowed) {
|
|
rec.scope = 'fix-range';
|
|
if (typeof start.fix_base === 'string' && HASH.test(start.fix_base)) rec.fix_base = start.fix_base;
|
|
}
|
|
// A narrowed pass on unchanged content never carries start_wtree/end_wtree,
|
|
// so Step 11.5 cannot bind it and it never becomes CURRENT; only a later full pass can.
|
|
rec.review_binding = narrowed && (state === 'incomplete' || state === 'verified')
|
|
? { state: 'fix-range', fix_base: rec.fix_base, range_start_wtree: start.wtree, range_end_wtree: end, started_at: start.started_at, ...branchId }
|
|
: { state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at, ...branchId };
|
|
const verified = rec.review_binding.state === 'verified';
|
|
if (verified) rec.wtree = end;
|
|
if (rec.skill === 'review' && Array.isArray(rec.findings)) {
|
|
rec.shared_libs_coverage_version = SHARED_LIBS_COVERAGE_VERSION;
|
|
for (const finding of rec.findings) {
|
|
if (!record(finding)) continue;
|
|
delete finding.snapshot_covered_paths;
|
|
if (finding.advisory !== true || finding.severity !== 'INFORMATIONAL') continue;
|
|
const fingerprint = sharedLibsFingerprint(finding);
|
|
if (!fingerprint) continue;
|
|
finding.fingerprint = fingerprint;
|
|
finding.snapshot_covered_paths = verified && finding.action === 'skipped'
|
|
? sharedLibsSnapshotCoverage(env.GSTACK_REVIEW_REPO!, end!, finding.evidence_paths, env) : [];
|
|
}
|
|
}
|
|
return rec;
|
|
}
|
|
|
|
export function reviewFreshness(rec: Record<string, any>, currentWtree: string): { status: string; reason: string } | undefined {
|
|
if (!DIFF_REVIEWS.has(rec.skill)) return;
|
|
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
|
|
const binding = rec.review_binding;
|
|
if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' };
|
|
if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
|
|
if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true ||
|
|
!rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) {
|
|
return { status: 'UNVERIFIED', reason: 'missing start capture or incomplete/nonconverged pass' };
|
|
}
|
|
if (!currentWtree || currentWtree === 'unknown' || rec.wtree !== currentWtree) {
|
|
return { status: 'STALE', reason: 'working-tree content differs from reviewed content' };
|
|
}
|
|
// B7: a derived row carries findings_open; a resolved critical finding no
|
|
// longer counts against it. Claimed rows keep the conservative reading of
|
|
// issues_found, and the legacy `critical` and Codex counters stay as they were.
|
|
const open = typeof rec.findings_open === 'number' ? rec.findings_open : rec.issues_found;
|
|
if (rec.status !== 'clean' || open > 0 || rec.critical > 0 ||
|
|
(rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0))) {
|
|
return { status: 'UNVERIFIED', reason: 'review has unresolved findings or did not finish clean' };
|
|
}
|
|
return { status: 'CURRENT', reason: 'completed clean pass on unchanged content' };
|
|
}
|