mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 20:07:08 +01:00
wip(review): R65 review scales to risk (GSTA-258 build C): lean single reader, platform depth, risk_paths, HARN-1 false triggers
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2e0e65d0e5
commit
f6a7eba552
12 files changed
+466
-52
No files matched your search
@@ -99,7 +99,7 @@ const CERTIFIED_OUTSIDE = { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'med
|
||||
const voiceFile = input.out.replace(/\/+$/, '') + '/voice-models.json';
|
||||
let voices = { laneModel: null, readers: {} };
|
||||
let laneFamily = family(input.laneModel);
|
||||
const modelOf = (r) => r.model || (r.reader in voices.readers ? voices.readers[r.reader] || undefined
|
||||
const modelOf = (r) => r.model || (r.cross_family ? (input.models || {})['outside-voice'] || (laneFamily === 'anthropic' ? CERTIFIED_OUTSIDE : undefined) : undefined) || (r.reader in voices.readers ? voices.readers[r.reader] || undefined
|
||||
: (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role]
|
||||
|| (r.role === 'outside-voice' && laneFamily === 'anthropic' ? CERTIFIED_OUTSIDE : undefined)
|
||||
|| (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined));
|
||||
|
||||
+12
-3
@@ -134,12 +134,21 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
|
||||
['{red-team checklist path}', need(slots, 'checklist')], ['<diff-base>', diffBase]], role);
|
||||
return fixtures(`${t.framing}\n\n${t.fixtures}\n\n${body}`);
|
||||
}
|
||||
case 'adversarial-native':
|
||||
return fixtures(fill(t.native, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role));
|
||||
case 'adversarial-native': {
|
||||
const native = fixtures(fill(t.native, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role));
|
||||
const lists = (slots.checklists ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const merged = lists.length ? `${native}\n\nIn the same read, apply each checklist at ${lists.join(', ')} to the diff, and tag each finding's "specialist" with the name of the checklist it came from (adversarial for your own).` : native;
|
||||
if (!slots.classes) return merged;
|
||||
const classes = parseClassList(slots.classes);
|
||||
if (!classes?.length) throw new BriefError(`slot classes must list money, gate, auth or secret, got ${JSON.stringify(slots.classes)}`);
|
||||
try { return `${targetedBrief(need(slots, 'brief'), classes)}\n\n${merged}\n\nApply the targeted brief above (${need(slots, 'checklist')}: ${classes.join(', ')}) in the same read; your reply is this diff's targeted pass.`; } catch (e) { throw e instanceof BriefError ? e : new BriefError((e as Error).message); }
|
||||
}
|
||||
case 'outside-voice': {
|
||||
const filled = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role);
|
||||
const body = slots.policy_exclude ? filled.split('git diff "$DIFF_BASE".').join(`git diff "$DIFF_BASE" -- . ${slots.policy_exclude}${slots.keep ? ` and git diff "$DIFF_BASE" -- ${slots.keep}` : ''}.`) : filled;
|
||||
return [body, '', ...(slots.disclosure ? [slots.disclosure, ''] : []), `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n');
|
||||
const head = [body, '', ...(slots.disclosure ? [slots.disclosure, ''] : []), `## Branch diff (git diff ${diffBase})`];
|
||||
if (slots.diff_file) return [...head, `The supplied branch diff is the file ${slots.diff_file} (${need(slots, 'diff_bytes')} bytes, sha256 ${need(slots, 'diff_sha256')}). Read it from disk; it is the whole diff under review.`].join('\n');
|
||||
return [...head, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n');
|
||||
}
|
||||
case 'targeted': {
|
||||
const classes = parseClassList(need(slots, 'classes'));
|
||||
|
||||
+104
-13
@@ -13,14 +13,14 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { decodeCharRefs, matchRisk, RISK_RULES, SVG_PATH, svgActive, type RiskClass } from './review-risk';
|
||||
import { decodeCharRefs, DOC_SECRET_LINE, matchRisk, RISK_CLASSES, RISK_RULES, SVG_PATH, svgActive, type RiskClass } from './review-risk';
|
||||
|
||||
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
|
||||
export type PathClass = 'docs' | 'tests' | 'code';
|
||||
export type Depth = 'light' | 'standard' | 'full';
|
||||
export type Effort = 'light' | 'medium' | 'full' | 'host';
|
||||
export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check' | 'targeted';
|
||||
export type Step = 'full' | 'scoped' | 'fix-check' | 'voices' | 'done' | 'stop';
|
||||
export type Step = 'full' | 'scoped' | 'fix-check' | 'voices' | 'platform' | 'done' | 'stop';
|
||||
|
||||
export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const;
|
||||
export type Specialist = (typeof SPECIALISTS)[number];
|
||||
@@ -63,6 +63,8 @@ export const isGatePath = (p: string) => GATE_PATH.some(r => r.test(p));
|
||||
export const isAgentProse = (p: string) => AGENT_PROSE.some(r => r.test(p));
|
||||
export const isDataConfig = (p: string) => DATA_CONFIG.some(r => r.test(p));
|
||||
|
||||
/** Binary data and media (HARN-1: committed sqlite ledgers git cannot count): data by extension wherever they sit, never code, consumed or not. */
|
||||
export const BINARY_DATA_PATH = /\.(sqlite3?|db|duckdb|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|pdf|mp3|mp4|mov|wav|webm)$/i;
|
||||
/** Data files: JSON, CSV, TSV and raster images. YAML and TOML are never data here: deploy configs and API specs stay code. An SVG is data only as a drawing (svgDrawing); executable SVG stays 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. */
|
||||
@@ -153,7 +155,7 @@ export function svgDrawing(text: string): boolean {
|
||||
/** 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({
|
||||
RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, SVG_ELEMENTS: [...SVG_ELEMENTS],
|
||||
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
|
||||
patterns: Object.entries({ BINARY_DATA_PATH: [BINARY_DATA_PATH], GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
/**
|
||||
@@ -229,13 +231,16 @@ export interface ClassInput {
|
||||
removed?: Record<string, readonly string[]>;
|
||||
/** Paths the base policy's review_exclude_paths leave out of every reader's diff (lib/review-exclude.ts): classified as docs. */
|
||||
excluded?: ReadonlySet<string>;
|
||||
/** The base policy's review.risk_paths: a code path they match is risk class `policy`; data and binary files under them never are. */
|
||||
riskPaths?: readonly string[];
|
||||
}
|
||||
export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null; risk: RiskClass[] }
|
||||
|
||||
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 unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(p, i.root)));
|
||||
const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root));
|
||||
const binaryData = (i: ClassInput, p: string) => BINARY_DATA_PATH.test(p) && classifyPath(p, i.root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p));
|
||||
const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || binaryData(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 {
|
||||
@@ -266,8 +271,10 @@ export function classifyDiff(i: ClassInput): ClassResult {
|
||||
for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; }
|
||||
if (i.registry?.error) errors.push(i.registry.error);
|
||||
}
|
||||
const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true' }) : new Map<RiskClass, string>();
|
||||
const risk = [...riskHits.keys()];
|
||||
const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true', ...(i.riskPaths?.length ? { isRiskPath: (p: string) => matchesAny(i.riskPaths!, p) && !DATA_PATH.test(p) } : {}) }) : new Map<RiskClass, string>();
|
||||
const docSecret = i.paths.find(p => pathClass(i, p) === 'docs' && DOC_PATH.test(p) && (i.added?.[p] ?? []).some(l => DOC_SECRET_LINE.some(r => r.test(l))));
|
||||
if (docSecret && !riskHits.has('security')) riskHits.set('security', `docs that can print a credential: ${docSecret}`);
|
||||
const risk = [...riskHits.keys()].sort((a, b) => RISK_CLASSES.indexOf(a) - RISK_CLASSES.indexOf(b));
|
||||
reasons.push(...[...riskHits.values()].map(r => `risk: ${r}`));
|
||||
if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null, risk };
|
||||
let runtimeLines = 0;
|
||||
@@ -351,6 +358,10 @@ export interface History {
|
||||
adversarialRan?: boolean;
|
||||
/** The approval id of the owner's recorded grant of one scoped fix-check after round 2 (review_loop_exception). */
|
||||
granted?: string;
|
||||
/** R65's lean table (planReview sets it from the base policy's review.panel): one reviewer, then at most one fix-check. */
|
||||
lean?: boolean;
|
||||
/** Fix-checks already run in this invocation (lean: the grant adds one only after the first). */
|
||||
fixChecks?: number;
|
||||
}
|
||||
export interface NextStep { step: Step; reason: string }
|
||||
|
||||
@@ -367,13 +378,31 @@ export interface NextStep { step: Step; reason: string }
|
||||
* one fix-check; a fix-check never earns another, so the grant cannot repeat.
|
||||
*/
|
||||
export function nextStep(h: History, depth?: Depth): NextStep {
|
||||
const n = loopStep(h, depth);
|
||||
const n = h.lean ? leanStep(h, depth) : loopStep(h, depth);
|
||||
if (n.step !== 'stop') return n;
|
||||
if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's voices (adversarial, outside, targeted) run before the owner's exit, then ask again with --adversarial-ran (${n.reason})` };
|
||||
if (h.granted && !h.blockingOpen && h.last === 'full' && h.fullPasses === FULL_PASS_CAP) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a fourth round` };
|
||||
if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's ${h.lean ? 'one reader' : 'voices (adversarial, outside, targeted)'} run${h.lean ? 's' : ''} before the owner's exit, then ask again with --adversarial-ran (${n.reason})` };
|
||||
if (!h.lean && h.granted && !h.blockingOpen && h.last === 'full' && h.fullPasses === FULL_PASS_CAP) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a fourth round` };
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* R65's lean table (GSTA-258): pass 1 is the one reviewer; any fix on a risk-class diff, and a fixed blocker or a gate-class
|
||||
* edit on any diff, earns one scoped fix-check (round 2, the cap); below-floor fixes on a light diff ship as notes. After the
|
||||
* fix-check a blocker or gate-class edit stops for the owner, whose recorded grant adds one more fix-check, never two.
|
||||
*/
|
||||
function leanStep(h: History, depth?: Depth): NextStep {
|
||||
if (h.last === 'none') return { step: 'full', reason: 'pass 1: one reviewer reads the whole diff (R65)' };
|
||||
if (h.last === 'fix-check') {
|
||||
if (!h.blockingOpen && !h.fixedBlocking && !h.touchedGate) return { step: 'done', reason: h.fixedBelow ? 'round cap 2 reached: the below-floor fixes ship as notes in the PR body and receipt' : 'the fix-check found nothing blocking' };
|
||||
if (h.granted && !h.blockingOpen && (h.fixChecks ?? 1) < 2) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a second` };
|
||||
return { step: 'stop', reason: `the fix-check ${h.blockingOpen || h.fixedBlocking ? 'found a blocking finding' : 'edited a gate-class path'}: name it for the owner, whose grant may add one scoped fix-check, never a third round` };
|
||||
}
|
||||
if (h.blockingOpen) return { step: 'stop', reason: 'a blocking finding is still open in fix scope: name it for the owner' };
|
||||
if (!h.fixedBlocking && !h.fixedBelow && !h.touchedGate) return { step: 'done', reason: 'the step applied no edits' };
|
||||
if (depth === 'light' && !h.fixedBlocking && !h.touchedGate) return { step: 'done', reason: 'light depth: the below-floor fixes ship as notes in the PR body and receipt without a fix-check (R65)' };
|
||||
return { step: 'fix-check', reason: `${h.fixedBlocking ? 'a blocking finding was fixed' : h.touchedGate ? 'a fix touched a gate-class path' : 'below-floor fixes on a risk-class diff'}: one scoped fix-check reads the touched hunks (round 2, the cap)` };
|
||||
}
|
||||
|
||||
function loopStep(h: History, depth?: Depth): NextStep {
|
||||
if (h.last === 'none') return { step: 'full', reason: 'pass 1 reviews the whole diff' };
|
||||
const fixed = !!(h.fixedBlocking || h.fixedBelow);
|
||||
@@ -421,14 +450,20 @@ export interface PlanInput extends ClassInput {
|
||||
targeted?: { classes: readonly string[]; ran?: boolean };
|
||||
/** The base policy's exclusion: one `:(top,exclude,glob)` pathspec per applied pattern, added to every reader, and the kept files under them as `:(top,literal)` pathspecs. */
|
||||
exclusion?: { specs: readonly string[]; keep: readonly string[] };
|
||||
/** The base policy's review key and the platform evidence at HEAD; absent plans today's panel (review.panel all). */
|
||||
shape?: ReviewShape;
|
||||
}
|
||||
/** How much review a repo runs (lib/ship-policy-review.ts) plus HEAD and the platform review found at it (lib/review-platform.ts). */
|
||||
export interface ReviewShape { panel: 'off' | 'risk' | 'all'; readerModel: string; platform: 'off' | 'on'; head: string; platformReview: { id: string; head: string; reviewer: string } | null }
|
||||
/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what the batched specialist reads. */
|
||||
export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string; keep?: string[] }
|
||||
export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string; keep?: string[]; model?: string; classes?: string[] }
|
||||
/** lean: R65's one reviewer; panel: today's panel (review.panel); platform: the platform review at HEAD stands in for every reader. */
|
||||
export type ReviewMode = 'lean' | 'panel' | 'platform';
|
||||
export interface ReviewPlan extends DepthResult {
|
||||
/** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */
|
||||
stepDepth: Depth;
|
||||
depthReasons: string[];
|
||||
next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[];
|
||||
next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[]; mode: ReviewMode;
|
||||
}
|
||||
|
||||
const scopeWants: Partial<Record<Specialist, (s: Record<string, string>) => boolean>> = {
|
||||
@@ -506,7 +541,10 @@ export function planReview(i: PlanInput): ReviewPlan {
|
||||
const c = classifyDiff(i);
|
||||
const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor);
|
||||
const { class: cls, runtimeLines } = depth;
|
||||
const next = nextStep({ ...i.history, gate: cls === 'gate', adversarialRan: !!i.adversarialRan }, depth.depth);
|
||||
const sh = i.shape;
|
||||
const panel = !sh || sh.panel === 'all' || (sh.panel === 'risk' && riskClass(c, depth));
|
||||
const next = nextStep({ ...i.history, gate: cls === 'gate', adversarialRan: !!i.adversarialRan, lean: !panel }, depth.depth);
|
||||
if (!panel) return leanPlan(i, c, depth, next);
|
||||
const h = i.history;
|
||||
const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound);
|
||||
const stepDepth: Depth = raise ? 'standard' : depth.depth;
|
||||
@@ -576,7 +614,60 @@ export function planReview(i: PlanInput): ReviewPlan {
|
||||
if (i.targeted.ran) skipped.push('targeted (already read this product patch)');
|
||||
else add('targeted', 'targeted', ROLE_EFFORT.targeted);
|
||||
}
|
||||
return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped };
|
||||
return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped, mode: 'panel' };
|
||||
}
|
||||
|
||||
/**
|
||||
* R65's lean dispatch (docs/review-policy.md#review-depth): pass 1 runs one native adversarial reader at the lane's reasoning, on
|
||||
* review.reader_model (the lane's model by default: FOUN-32, ggstack#52). A risk-class diff under review.panel off gets the merged
|
||||
* reader: the gate-class checklists, the Red Team checklist and the targeted brief for its classes; a non-risk code diff gets the
|
||||
* code-class checklists and the Red Team. Docs and tests keep the core read. A docs or tests diff with no numeric, results or prereg
|
||||
* claims, under review.platform on, with the platform review at HEAD, runs no reader at all (mode platform).
|
||||
*/
|
||||
/** Risk class read from the paths and lines (gate class, an R51 or review.risk_paths class, a classifier error), or a floor above light (FOUN-64 item 7: never the recorded review_depth). */
|
||||
const riskClass = (c: ClassResult, depth: DepthResult) => c.class === 'gate' || c.risk.length > 0 || c.errors.length > 0 || depth.depth !== 'light';
|
||||
/** Paths that carry results or preregistration claims: never the platform depth (FOUN-64 item 2). */
|
||||
const CLAIM_PATH = /(^|\/|[-_.])(results?|prereg|preregistration|benchmarks?|evals?|scorers?|checkers?)([-_./]|$)/i;
|
||||
|
||||
function leanPlan(i: PlanInput, c: ClassResult, depth: DepthResult, next: NextStep): ReviewPlan {
|
||||
const sh = i.shape!;
|
||||
const risky = riskClass(c, depth);
|
||||
const skipped: string[] = [];
|
||||
const dispatches: Dispatch[] = [];
|
||||
const x = i.exclusion;
|
||||
const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial<Dispatch> = {}) =>
|
||||
dispatches.push({ role, name, effort, exclude: x ? [...exclude, ...x.specs] : exclude, ...extra, ...(x?.keep.length ? { keep: [...x.keep] } : {}) });
|
||||
const plan = (n: NextStep, mode: ReviewMode): ReviewPlan => ({ ...depth, stepDepth: depth.depth, depthReasons: depth.reasons, reasons: c.reasons, next: n, numbers: i.numbers, dispatches, skipped, mode });
|
||||
const pr = sh.platformReview;
|
||||
const platformFit = !risky && depth.runtimeLines === 0 && !i.numbers.publishes && !i.paths.some(p => CLAIM_PATH.test(p));
|
||||
if (next.step === 'full' && platformFit && sh.platform === 'on' && pr && pr.head === sh.head) {
|
||||
const why = `planned skip: platform review ${pr.id} by ${pr.reviewer} at ${pr.head.slice(0, 12)}`;
|
||||
for (const r of ['core checklist', 'specialists', 'red-team', 'adversarial-native', 'outside-voice', 'targeted']) skipped.push(`${r} (${why})`);
|
||||
return plan({ step: 'platform', reason: `review.platform on: the platform review ${pr.id} read HEAD ${pr.head.slice(0, 12)}, so /ship runs no reader (Step 11.5 binds the planned skip)` }, 'platform');
|
||||
}
|
||||
if (sh.platform === 'on' && next.step === 'full' && !risky) skipped.push(`platform depth (${depth.runtimeLines > 0 ? 'runtime code' : i.numbers.publishes ? 'the diff publishes numbers' : !platformFit ? 'a results, prereg, eval or checker path' : pr ? `the platform review read ${pr.head.slice(0, 12)}, not HEAD` : 'no platform review at HEAD'}: the light read runs)`);
|
||||
const classes = i.targeted?.classes.length && !i.targeted.ran ? { classes: [...i.targeted.classes] } : {};
|
||||
const docsAndOutputs = i.paths.filter(p => !i.excluded?.has(p) && ((pathClass(i, p) === 'docs' && !(i.numbers.publishes && DOC_PATH.test(p))) || isOutput(i, p))).map(excludeSpec);
|
||||
const reader = () => risky
|
||||
? add('adversarial-native', 'merged', 'full', [], { checklists: [...batchChecklists('gate', i, skipped), 'red-team'], model: sh.readerModel, ...classes })
|
||||
: add('adversarial-native', 'single', 'full', docsAndOutputs, { checklists: [...batchChecklists('code', i, skipped), 'red-team'], model: sh.readerModel, ...classes });
|
||||
if (next.step === 'full' && (risky || depth.runtimeLines > 0)) {
|
||||
reader();
|
||||
skipped.push(`specialists, red-team and outside-voice as their own readers (R65: the one ${risky ? 'risk-class' : 'light'} reader carries them)`);
|
||||
} else if (next.step === 'full') {
|
||||
for (const n of SPECIALISTS.filter(forcedBy(i))) add('specialist', n, i.tiers === false ? 'host' : ROLE_EFFORT.specialist);
|
||||
if (i.numbers.publishes) add('outside-voice', 'outside-voice', 'host');
|
||||
else skipped.push(`outside-voice (${depth.class} class, no numeric claims)`);
|
||||
} else if (next.step === 'voices') reader();
|
||||
else if (next.step === 'fix-check') {
|
||||
add('fix-check', 'fix-check', 'full');
|
||||
if (i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint) add('outside-voice', 'outside-voice (numbers changed)', 'host');
|
||||
}
|
||||
if (next.step === 'fix-check' && i.targeted?.classes.length) {
|
||||
if (i.targeted.ran) skipped.push('targeted (already read this product patch)');
|
||||
else add('targeted', 'targeted', ROLE_EFFORT.targeted);
|
||||
}
|
||||
return plan(next, 'lean');
|
||||
}
|
||||
|
||||
export function reviewPlanLine(p: ReviewPlan, pass: number): string {
|
||||
|
||||
+12
-2
@@ -11,6 +11,7 @@
|
||||
* persisted before its subagent is created and its path right after, so a resumed program
|
||||
* reconciles by deterministic title and never doubles a reader.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { BriefError, briefFile, checkoutRoot, checkoutTree, promptHash, renderBrief, withWorkspace, type BriefRole, type SectionTexts, type Slots } from './review-brief';
|
||||
@@ -31,6 +32,8 @@ export interface PassReader {
|
||||
classes?: string[]; diff_base?: string;
|
||||
/** An audit reader: its prompt is a file the parent wrote from its section; its output is returned, never collected. */
|
||||
prompt_file?: string; model?: PassModel;
|
||||
/** The plan's model=cross-family (R65's risk reader or a numbers diff's one reader): the program runs it on the outside voice's model. */
|
||||
cross_family?: boolean;
|
||||
file?: string; sha256?: string; bytes?: number; chunks?: number; findings_sha256?: string;
|
||||
/** With the base policy's review_exclude_paths active: its `:(top,exclude,glob)` items, shell-quoted (the fixture command's slot), and the kept files' `:(top,literal)` items. */
|
||||
policy_exclude?: string; keep?: string[];
|
||||
@@ -103,6 +106,8 @@ export function buildManifest(o: {
|
||||
readers.push({ reader, stage, title: `ship ${o.pass} ${reader}`, state: 'planned', role, name, effort: d.effort, exclude, checklists: d.checklists,
|
||||
...(policy.length ? { policy_exclude: policy.map(shellQuote).join(' ') } : {}), ...(d.keep?.length ? { keep: d.keep } : {}),
|
||||
...(role === 'targeted' ? { classes: [...(o.targetedClasses ?? [])], diff_base: o.mergeBase ?? o.diffBase } : {}),
|
||||
...(role === 'adversarial-native' && d.classes?.length ? { classes: [...d.classes], diff_base: o.mergeBase ?? o.diffBase } : {}),
|
||||
...(d.model === 'cross-family' ? { cross_family: true } : d.model && d.model !== 'lane' ? { model: { model_id: d.model } } : {}),
|
||||
...(role === 'red-team' && o.rangeBase ? { diff_base: o.rangeBase } : {}) });
|
||||
}
|
||||
for (const e of o.extras ?? []) {
|
||||
@@ -176,14 +181,19 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor:
|
||||
const specialists = m.readers.reduce((n, x) => n + (x.role === 'specialist' ? 1 : x.role === 'specialist-batched' ? (x.checklists ?? []).filter(c => c !== 'red-team').length : 0), 0);
|
||||
Object.assign(slots, { checklist: path.join(m.checklist_root, 'review/specialists/red-team.md'), findings, specialists: String(specialists) });
|
||||
}
|
||||
if (role === 'outside-voice') slots.diff = diffFor(m.diff_base, r);
|
||||
if (role === 'outside-voice') {
|
||||
const diff = diffFor(m.diff_base, r);
|
||||
const diffFile = path.join(briefs, 'outside-voice.diff');
|
||||
fs.writeFileSync(diffFile, diff, { mode: 0o600 });
|
||||
Object.assign(slots, { diff_file: diffFile, diff_bytes: String(Buffer.byteLength(diff)), diff_sha256: createHash('sha256').update(diff).digest('hex') });
|
||||
}
|
||||
if (role === 'fix-check') {
|
||||
const read = (f: string | undefined, what: string) => { if (!f) return undefined; try { return fs.readFileSync(f, 'utf8'); } catch { throw new BriefError(`cannot read the fix-check ${what} ${f}`); } };
|
||||
const fc = m.fix_check!;
|
||||
Object.assign(slots, { fix_list: read(fc.fix_list, 'fix list'), hunks: read(fc.hunks, 'hunks'), tests: read(fc.tests, 'tests'), callers: read(fc.callers, 'callers') });
|
||||
}
|
||||
let targeted = {};
|
||||
if (role === 'targeted') {
|
||||
if (role === 'targeted' || (role === 'adversarial-native' && r.classes?.length)) {
|
||||
const checklist = path.join(m.checklist_root, 'review/specialists/targeted.md');
|
||||
let brief: string;
|
||||
try { brief = fs.readFileSync(checklist, 'utf8'); } catch { throw new BriefError(`cannot read the targeted brief ${checklist}`); }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* review-platform — the exact-head evidence that a platform review read a PR (design C6, FOUN-32 on founderstar#496).
|
||||
* Capy's automatic review posts a pull-request review by capy-ai[bot] whose commit_id is the head it read; that review's
|
||||
* id is the run id the plan's planned skip and the receipt's review_depth platform record. Anything else (no PR, no such
|
||||
* review, a review at another commit, gh unavailable) is no evidence, and the single light reader runs.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export const PLATFORM_REVIEWER = 'capy-ai[bot]';
|
||||
export interface PlatformReview { id: string; head: string; reviewer: string }
|
||||
|
||||
function gh(root: string, args: string[]): { ok: boolean; out: string } {
|
||||
const r = spawnSync('gh', args, { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
|
||||
}
|
||||
|
||||
/** The newest submitted platform review of the PR (`pr`, or the current branch's) at `head`; `id`, when given, must be that review. */
|
||||
export function platformReviewAt(root: string, head: string, o: { pr?: string; id?: string } = {}): { ok: true; review: PlatformReview } | { ok: false; reason: string } {
|
||||
const view = gh(root, ['pr', 'view', ...(o.pr ? [o.pr] : []), '--json', 'number']);
|
||||
const pr = view.ok ? (JSON.parse(view.out || '{}') as { number?: number }).number : undefined;
|
||||
if (!pr) return { ok: false, reason: o.pr ? `gh cannot read PR ${o.pr}` : 'no PR for this branch yet' };
|
||||
const list = gh(root, ['api', '--paginate', '--slurp', `repos/{owner}/{repo}/pulls/${pr}/reviews`]);
|
||||
if (!list.ok) return { ok: false, reason: `gh cannot list the reviews of PR #${pr}` };
|
||||
let reviews: Array<{ id?: number; user?: { login?: string }; commit_id?: string; state?: string }>;
|
||||
try { reviews = (JSON.parse(list.out) as unknown[]).flat() as typeof reviews; } catch { return { ok: false, reason: `PR #${pr}'s reviews are not JSON` }; }
|
||||
const at = reviews.filter(r => r.user?.login === PLATFORM_REVIEWER && r.state !== 'PENDING' && r.commit_id?.toLowerCase() === head.toLowerCase() && r.id !== undefined);
|
||||
const hit = o.id ? at.find(r => String(r.id) === o.id) : at.at(-1);
|
||||
if (!hit) return { ok: false, reason: o.id ? `review ${o.id} on PR #${pr} is not a ${PLATFORM_REVIEWER} review at ${head.slice(0, 12)}` : `no ${PLATFORM_REVIEWER} review of PR #${pr} at ${head.slice(0, 12)}` };
|
||||
return { ok: true, review: { id: String(hit.id), head: head.toLowerCase(), reviewer: PLATFORM_REVIEWER } };
|
||||
}
|
||||
+32
-4
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data'] as const;
|
||||
export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] as const;
|
||||
export type RiskClass = (typeof RISK_CLASSES)[number];
|
||||
|
||||
/** Metering and charge words (GST-850: gbrain-evals #289 changed a metering proxy and billing clients with none of the older tokens); they also count on a line. */
|
||||
@@ -68,20 +68,41 @@ export const decodeCharRefs = (s: string) => s.replace(/&#(?:[xX]0*([0-9a-fA-F]+
|
||||
return c <= 0x10ffff ? String.fromCodePoint(c) : '';
|
||||
});
|
||||
export const svgActive = (text: string) => { const d = decodeCharRefs(text); const squeezed = d.replace(/[\s\0-\x1f]+/g, ''); return SVG_ACTIVE.some(r => r.test(d) || r.test(squeezed)); };
|
||||
/** Eval scorers, judges and graders (FOUN-32 §4): code that decides a published result is risk class eval, by path. */
|
||||
const EVAL_PATH_TOKENS = ['scorer', 'scorers', 'scoring', 'judge', 'judges', 'grader', 'graders', 'grade', 'grading', 'rubric', 'rubrics'];
|
||||
const DATA_PATH_TOKENS = ['pii', 'gdpr', 'ccpa', 'privacy', 'personal', 'account', 'accounts', 'customer', 'customers', 'cookie', 'cookies', 'persistence', 'storage', 'database'];
|
||||
export const DATA_LINE = [
|
||||
/\bDELETE\s+FROM\b/, /\b(?:drop\s+(?:table|column|database|schema)|truncate\s+table)\b/i, /\bdelete\s+from\s+[\w."`]+\s*(?:where\b|;|$|["'`)])/i,
|
||||
/\.(?:deleteMany|destroyAll|bulkDelete)\s*\(/, /\b(?:destroy_all|delete_all)\b/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Data and config files (HARN-1, harness-evals#231): judged by their path only, so a lease_usd value in a VM record never names money.
|
||||
* A key in a config file still names a secret.
|
||||
*/
|
||||
export const CONFIG_DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|ya?ml|toml|ini|cfg|conf|env|properties|xml|plist)$/i;
|
||||
/** Analysis scripts that live with their results (docs/benchmarks/.../results/cost-intervals.py): judged by their path, which names no risk class. */
|
||||
export const STATS_SCRIPT = /(^|\/)(docs|results|benchmarks?|analysis|notebooks?)\/(?:[^/]+\/)*[^/]+\.(py|r|jl|ipynb)$/i;
|
||||
|
||||
/**
|
||||
* Docs that can print a credential (gbrain#8417: `mcp get gbrain` shows a stored bearer token): an Authorization header, a
|
||||
* bearer value, a key-shaped literal, an environment key set to a value, or a get/show command for auth config. Read on the
|
||||
* added lines of docs paths, which matchRisk never sees, and counted as security (FOUN-64 item 4).
|
||||
*/
|
||||
export const DOC_SECRET_LINE = [
|
||||
/\bAuthorization\s*:\s*\S/i, /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/, /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}|\bgh[pousr]_[A-Za-z0-9]{20,}|\bxox[abposr]-[A-Za-z0-9-]{10,}|\bAKIA[0-9A-Z]{16}\b/,
|
||||
new RegExp(String.raw`\b${KEY_NAME}\s*[=:]\s*['"]?[^\s'"$<{]{8,}`),
|
||||
/\b(?:mcp|auth|config|credentials?|tokens?|secrets?|keys?|login)\s+(?:get|show|print|view|cat|token)\b/i, /\b(?:get|show|print|cat)\s+(?:\S+[ ./-])?(?:tokens?|auth|credentials?|secrets?|api[_-]?keys?|bearer)\b/i,
|
||||
];
|
||||
|
||||
/** Whether a path or line's tokens name money: a metering or charge word (a cost word too, for a path), or a pair. */
|
||||
const namesMoney = (t: readonly string[], words: readonly string[]) => t.some(x => words.includes(x)) || MONEY_PAIRS.some(([w, beside]) => t.includes(w) && t.some(x => beside.includes(x)));
|
||||
|
||||
/** A hash of the patterns that decide the risk classes, not of this file's bytes; lib/review-depth.ts folds it into DEPTH_RULES. */
|
||||
export const RISK_RULES = createHash('sha256').update(JSON.stringify({
|
||||
RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS,
|
||||
RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS, EVAL_PATH_TOKENS,
|
||||
MONEY_LINE_TOKENS, MONEY_PAIRS,
|
||||
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE, SVG_PATH: [SVG_PATH], SVG_ACTIVE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
|
||||
patterns: Object.entries({ DOC_SECRET_LINE, CONFIG_DATA_PATH: [CONFIG_DATA_PATH], STATS_SCRIPT: [STATS_SCRIPT], MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE, SVG_PATH: [SVG_PATH], SVG_ACTIVE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
export const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
@@ -93,6 +114,8 @@ export interface RiskInput {
|
||||
removed?: Record<string, readonly string[]>;
|
||||
/** gstack-diff-scope's SCOPE_AUTH: the auth class needs it and an auth token in the path. */
|
||||
scopeAuth: boolean;
|
||||
/** The base policy's review.risk_paths matcher: a code path it matches is risk class `policy`. */
|
||||
isRiskPath?: (p: string) => boolean;
|
||||
}
|
||||
|
||||
/** The risk classes the code paths touch, the first reason per class, in RISK_CLASSES order. Nothing fails closed here: input errors are the caller's. */
|
||||
@@ -100,16 +123,21 @@ export function matchRisk(i: RiskInput): Map<RiskClass, string> {
|
||||
const why = new Map<RiskClass, string>();
|
||||
const hit = (c: RiskClass, reason: string) => { if (!why.has(c)) why.set(c, reason); };
|
||||
for (const p of i.codePaths) {
|
||||
if (i.isRiskPath?.(p)) hit('policy', 'risk_paths: ' + p);
|
||||
if (STATS_SCRIPT.test(p)) continue;
|
||||
const t = tokens(p);
|
||||
const config = CONFIG_DATA_PATH.test(p);
|
||||
if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p);
|
||||
if (SECRET_PATH.test(p)) hit('secret', 'secret path: ' + p);
|
||||
if (namesMoney(t, MONEY_PATH_TOKENS)) hit('money', 'money path: ' + p);
|
||||
if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p);
|
||||
if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p);
|
||||
if (!config && t.some(x => EVAL_PATH_TOKENS.includes(x))) hit('eval', 'eval scorer path: ' + p);
|
||||
if (SVG_PATH.test(p) && [i.added?.[p], i.removed?.[p]].some(l => l && svgActive(l.join('\n')))) hit('security', 'active SVG lines: ' + p);
|
||||
for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) {
|
||||
if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p);
|
||||
if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', 'secret line: ' + p);
|
||||
if (config) continue;
|
||||
if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p);
|
||||
if (!why.has('security') && SECURITY_LINE.some(r => r.test(line))) hit('security', 'security line: ' + p);
|
||||
if (!why.has('data') && DATA_LINE.some(r => r.test(line))) hit('data', 'data line: ' + p);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* ship-policy-review — the base-read `review` key of .gstack/ship-policy.json (docs/review-policy.md#review-depth),
|
||||
* kept beside lib/ship-policy.ts so that module stays under the size ratchet. `panel` keeps today's reviewer panel
|
||||
* for a class (risk by default: the printed full-depth plan and its full pass 2 on risk class, one reader elsewhere; R65 as amended by FOUN-64), `reader_model` is the model of that one reader,
|
||||
* `platform` lets a non-risk diff rely on the platform review at its exact head, and `risk_paths` adds code
|
||||
* paths to R51's risk classes.
|
||||
*/
|
||||
export interface ReviewPolicy { panel: 'off' | 'risk' | 'all'; reader_model: string; platform: 'off' | 'on'; risk_paths: string[] }
|
||||
export const REVIEW_POLICY_DEFAULT: ReviewPolicy = { panel: 'risk', reader_model: 'lane', platform: 'off', risk_paths: [] };
|
||||
const MODEL = /^[A-Za-z0-9][\w./:-]{0,127}$/;
|
||||
|
||||
/** The key with every omitted field at its default (a policy file may set any subset). */
|
||||
export function reviewPolicy(p: { review?: Partial<ReviewPolicy> | null } | null | undefined): ReviewPolicy {
|
||||
return { ...REVIEW_POLICY_DEFAULT, ...(p?.review ?? {}) };
|
||||
}
|
||||
|
||||
/** `review`: each field's error at its path; `globs` checks risk_paths as ship-policy checks every glob list. */
|
||||
export function reviewErrors(value: unknown, out: { code: 'POLICY_INVALID'; path: string; message: string }[] | any[], globs: (key: string, v: unknown) => void): void {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'review', message: 'must be { panel?, reader_model?, platform?, risk_paths? }' }); return; }
|
||||
const v = value as Record<string, unknown>;
|
||||
for (const k of Object.keys(v)) if (!(k in REVIEW_POLICY_DEFAULT)) out.push({ code: 'POLICY_INVALID', path: `review.${k}`, message: 'unknown key' });
|
||||
if ('panel' in v && !['off', 'risk', 'all'].includes(v.panel as string)) out.push({ code: 'POLICY_INVALID', path: 'review.panel', message: `must be off | risk | all, got ${JSON.stringify(v.panel)}` });
|
||||
if ('platform' in v && !['off', 'on'].includes(v.platform as string)) out.push({ code: 'POLICY_INVALID', path: 'review.platform', message: `must be off | on, got ${JSON.stringify(v.platform)}` });
|
||||
if ('reader_model' in v && (typeof v.reader_model !== 'string' || !MODEL.test(v.reader_model))) out.push({ code: 'POLICY_INVALID', path: 'review.reader_model', message: 'must be lane, cross-family or a model id (letters, digits and . _ / : -)' });
|
||||
if ('risk_paths' in v) globs('review.risk_paths', v.risk_paths);
|
||||
}
|
||||
+10
-10
@@ -17,6 +17,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { defaultBaseBranch, noDefaultBaseLine } from './default-base';
|
||||
import { RESULT_CODES, type ResultCodeName } from './result-codes';
|
||||
import { reviewErrors, type ReviewPolicy } from './ship-policy-review';
|
||||
import { cmpVersion, parseVersion } from './version-source';
|
||||
|
||||
export const SHIP_POLICY_PATH = '.gstack/ship-policy.json';
|
||||
@@ -56,6 +57,7 @@ export interface ShipPolicy {
|
||||
review_scope: ReviewScope;
|
||||
/** Git glob pathspecs (root-anchored) whose data, docs, binary and log files /ship's reviewers leave out; read from the base only (docs/ship-policy.md#review-exclude-paths). */
|
||||
review_exclude_paths: string[];
|
||||
/** How much review /ship runs (docs/review-policy.md#review-depth): the panel fallback, the risk reader's model, platform review and extra risk paths; read from the base only. */ review: ReviewPolicy;
|
||||
/** Consumed by the plan reviews (D1). */
|
||||
pr_count: { default_pr_count: number; split_requires_owner: boolean };
|
||||
/** Maintainer logins for contributor-PR mode (E3); empty means "use push permission". */
|
||||
@@ -157,6 +159,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
|
||||
stamp_sensitive_gates: [],
|
||||
review_scope: 'frozen',
|
||||
review_exclude_paths: [],
|
||||
review: { panel: 'risk', reader_model: 'lane', platform: 'off', risk_paths: [] },
|
||||
pr_count: { default_pr_count: 1, split_requires_owner: true },
|
||||
maintainers: [],
|
||||
stamp_files: [],
|
||||
@@ -189,6 +192,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
|
||||
stamp_sensitive_gates: 'Evidence labels (gstack-evidence --label) that read VERSION or generated bytes; the tree receipt never marks them reusable.',
|
||||
review_scope: 'frozen | diff-plus-blocking | all. frozen (default): /ship Step 9 fixes findings in the diff on pass 1, files blocking findings in code the PR did not change as issues, and later passes read only the range since the previous pass and fix only blocking findings; diff-plus-blocking: the same, but a blocking finding in code the PR did not change is fixed in this PR instead of filed; all: every finding is fixed now (the pre-1.91.249 loop).',
|
||||
review_exclude_paths: 'Git glob pathspecs, root-anchored and case-sensitive (`receipts/**`, `**/*.jsonl`; at most 64, 256 characters each, no leading `:` or `!`): /ship\'s reviewers leave out the data, docs, binary and .log files they match. Read from the base branch only, so a branch cannot exclude its own files. Code, gate paths, agent prose, config, executables, .gstack/ and .github/ files, gate-tagged or secret-bearing files and files a kept code or test file names by full path stay reviewed (docs/ship-policy.md#review-exclude-paths).',
|
||||
review: '{ panel?, reader_model?, platform?, risk_paths? }, read from the base branch only. panel: risk (default: today\'s panel at full depth on risk class, one reader on the rest) | off (one merged reader on risk class too) | all (today\'s panel and loop everywhere); every value but all runs one fix round. reader_model: the model of the one reader a lean depth runs, `lane` (default: the lane\'s own model, the native adversarial reader), `cross-family` or a model id. platform: off (default) | on: a docs or tests diff with no numeric, results or prereg claims whose PR carries the platform review (capy-ai[bot]) at the exact head skips /ship\'s readers (review_depth platform); never runtime code. risk_paths: globs of code paths that are risk class on top of R51\'s; data, docs and binary files under them stay out (docs/review-policy.md#review-depth).',
|
||||
pr_count: '{ default_pr_count, split_requires_owner }: the PR-count row in plan reviews.',
|
||||
maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.',
|
||||
stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.',
|
||||
@@ -237,6 +241,7 @@ export const BASE_RED_KEY_VERSION = '1.91.374.0';
|
||||
/** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */
|
||||
export const TRAIN_GATES_KEY_VERSION = '1.92.0.0';
|
||||
export const REVIEW_EXCLUDE_KEY_VERSION = '1.94.0.0';
|
||||
export const REVIEW_POLICY_KEY_VERSION = '1.94.0.0';
|
||||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||||
@@ -244,7 +249,7 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
|
||||
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
|
||||
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
|
||||
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION,
|
||||
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION,
|
||||
};
|
||||
|
||||
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
|
||||
@@ -269,18 +274,12 @@ export function minimumReader(raw: Record<string, unknown>): string {
|
||||
|
||||
export interface PolicyError { code: ResultCodeName; path: string; message: string }
|
||||
|
||||
const MAX_LIST = 64;
|
||||
const MAX_PATTERN = 256;
|
||||
const MAX_REGEX = 512;
|
||||
const MAX_COMMAND = 512;
|
||||
const MAX_LIST = 64, MAX_PATTERN = 256, MAX_REGEX = 512, MAX_COMMAND = 512;
|
||||
const SHA_RE = /^[0-9a-f]{7,64}$/;
|
||||
const MAX_TEMPLATES = 16;
|
||||
const MAX_TEMPLATE = 128;
|
||||
const MAX_TEMPLATES = 16, MAX_TEMPLATE = 128;
|
||||
export const VERSION_SLOT = '{version}';
|
||||
|
||||
function hasDotDot(p: string): boolean {
|
||||
return p.split(/[\\/]+/).some(seg => seg === '..');
|
||||
}
|
||||
const hasDotDot = (p: string): boolean => p.split(/[\\/]+/).some(seg => seg === '..');
|
||||
|
||||
/** A repo-relative path or glob: no absolute paths, drive letters, `..` segments, NULs or control bytes. */
|
||||
export function containedRelative(p: string): string | null {
|
||||
@@ -488,6 +487,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
|
||||
oneOf('history', ['merge-only', 'squash-ok']);
|
||||
oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']);
|
||||
if ('review_exclude_paths' in obj) excludeErrors(obj.review_exclude_paths, errors);
|
||||
if ('review' in obj) reviewErrors(obj.review, errors, (k, v) => listErrors(k, v, errors, globError));
|
||||
if ('release_tool' in obj && obj.release_tool !== null) {
|
||||
const t = obj.release_tool;
|
||||
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'release_tool', message: 'must be a non-empty string or null' });
|
||||
|
||||
Reference in new issue
Block a user