refactor(review): simplify pass over E (reuse, simplification, efficiency)
Quality only, no behaviour change (four cleanup reviewers, one implementer; npm test 2480/0 on the working tree): - threat-model.mjs splitBacked(): the one known_outside backing check of plan-gate (record and bare gate), verdict-gate and review-finalize, with one why-text; the engines' security keys derived from ENGINES; isSecurity. - coordinator-contract: "not a security engine" read from ENGINES (a string engine only), not the code-review literal. - review-rounds owns trailingBlock, findingsBlockSha, isMajor, isBlocker; review-answers imports them (review-rounds still imports nothing from it). prepare keeps the archive bytes it read instead of reading the previous archive again. - engine-compare exports rowIn and payloadEngine; finalize uses them. - plan-gate: one accepted_open builder; the stop-rule check hoisted; one top-level lookup for --record --brief. - security-engine: notFullShas(), runCli's usage flag, readScopeList shared with engine-findings; findReport and reviewRoundSummary module-private; two dead defaults gone. - tests: shared plan-gate harness and threat-model brief helpers; the [CNV] table cases call convertReport in-process, one CLI case per exit path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b94b060035
commit
f820081bdc
18 changed files with 332 additions and 373 deletions
|
|
@ -136,6 +136,9 @@ const ENGINE_OF_PAYLOAD = Object.freeze(Object.fromEntries(
|
|||
Object.entries(ENGINES).map(([engine, spec]) => [spec.reviewer, engine])));
|
||||
|
||||
const isEngineFinding = (f) => Object.hasOwn(ENGINES, f.engine ?? '');
|
||||
// From an engine that is not a security engine (/code-review). A string only:
|
||||
// a list or object `engine` would coerce to an ENGINES key.
|
||||
const fromNonSecurityEngine = (f) => typeof f?.engine === 'string' && ENGINES[f.engine]?.security === false;
|
||||
|
||||
function carries(f, field) {
|
||||
return f?.[field] !== undefined && f?.[field] !== null;
|
||||
|
|
@ -159,7 +162,7 @@ export function lowerableBriefConflict(f) {
|
|||
|
||||
function markedMajorCodeReview(f) {
|
||||
return isMark(f?.contradicts_brief) &&
|
||||
f.engine === 'code-review' &&
|
||||
fromNonSecurityEngine(f) &&
|
||||
getRule(f.rule_key)?.severity === 'MAJOR';
|
||||
}
|
||||
|
||||
|
|
@ -667,7 +670,7 @@ export function runContract(reviewerPayloads, options = {}) {
|
|||
// unchanged file (independent review, 2026-10-08).
|
||||
const outOfScope = (options.engineOutOfScope ?? [])
|
||||
.map((f) => ({ ...f, suppressed_reason: 'engine:out-of-scope' }));
|
||||
const minorOnly = (f) => f.engine === 'code-review' && (f.severity === 'MINOR' || f.severity === 'SUGGESTION');
|
||||
const minorOnly = (f) => fromNonSecurityEngine(f) && (f.severity === 'MINOR' || f.severity === 'SUGGESTION');
|
||||
const outOfScopeBlocking = outOfScope.filter((f) => !minorOnly(f));
|
||||
const outOfScopeNoted = outOfScope.filter(minorOnly);
|
||||
// Claude Security's findings below confidence `high` (security-engine.mjs
|
||||
|
|
|
|||
|
|
@ -38,11 +38,19 @@ const trimmedTitle = (t) => (t.length > JUDGE_TITLE_MAX ? trimText(t, JUDGE_TITL
|
|||
|
||||
export { tripletOf, normTitle, rowOf, trimmedTitle };
|
||||
|
||||
/** A finding's row is in `rows` (a Set of rowOf keys): as it is, or with its title as the coordinator trims it. */
|
||||
export const rowIn = (rows, f) => rows.has(rowOf(f)) ||
|
||||
(typeof f.title === 'string' && rows.has(rowOf({ ...f, title: trimmedTitle(f.title) })));
|
||||
|
||||
const PUT_BACK_SEVERITIES = ['BLOCKER', 'MAJOR'];
|
||||
const VERDICT_RANK = { ALLOW: 0, WARN: 1, BLOCK: 2 };
|
||||
export const ENGINE_OF_REVIEWER = Object.freeze(Object.fromEntries(Object.entries(ENGINES).map(([engine, s]) => [s.reviewer, engine])));
|
||||
const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
|
||||
/** The engine a payload's top-level `reviewer` maps back to through ENGINES, or null. */
|
||||
export const payloadEngine = (p) => (isPlainObject(p) && typeof p.reviewer === 'string' && Object.hasOwn(ENGINE_OF_REVIEWER, p.reviewer)
|
||||
? ENGINE_OF_REVIEWER[p.reviewer] : null);
|
||||
|
||||
/**
|
||||
* The engine findings at MAJOR or BLOCKER whose row no finding of `payload`
|
||||
* carries, as copies to append, and the verdict raised by them.
|
||||
|
|
@ -53,12 +61,10 @@ const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArr
|
|||
export function putBack(payload, enginePayloads) {
|
||||
const returned = (Array.isArray(payload?.findings) ? payload.findings : []).filter(isPlainObject);
|
||||
const rows = new Set(returned.map(rowOf));
|
||||
const rowReturned = (m) => rows.has(rowOf(m)) ||
|
||||
(typeof m.title === 'string' && rows.has(rowOf({ ...m, title: trimmedTitle(m.title) })));
|
||||
const candidates = [];
|
||||
for (const p of Array.isArray(enginePayloads) ? enginePayloads : []) {
|
||||
if (!isPlainObject(p) || !Array.isArray(p.findings)) continue;
|
||||
const engine = typeof p.reviewer === 'string' && Object.hasOwn(ENGINE_OF_REVIEWER, p.reviewer) ? ENGINE_OF_REVIEWER[p.reviewer] : null;
|
||||
const engine = payloadEngine(p);
|
||||
if (!engine) continue;
|
||||
for (const f of p.findings) {
|
||||
if (!isPlainObject(f) || !PUT_BACK_SEVERITIES.includes(f.severity)) continue;
|
||||
|
|
@ -69,7 +75,7 @@ export function putBack(payload, enginePayloads) {
|
|||
candidates.sort((a, b) => a.rank - b.rank);
|
||||
const added = [];
|
||||
for (const { f, engine } of candidates) {
|
||||
if (rowReturned(f)) continue;
|
||||
if (rowIn(rows, f)) continue;
|
||||
const { id: _id, brief_lowered_from: _lowered, contradicts_brief: _contradicts, pre_existing: _pre, carried_from: _cf, ...rest } = f;
|
||||
const copy = { id: null, ...rest, engine };
|
||||
added.push(copy);
|
||||
|
|
|
|||
|
|
@ -102,6 +102,17 @@ function parseLine(v) {
|
|||
|
||||
const tier = (key) => SEVERITY_VALUES.indexOf(getRule(key).severity);
|
||||
|
||||
/**
|
||||
* A scope list file (one path per line; lines trimmed, blank ones dropped), as
|
||||
* `--scope` gives it here and to lib/review/security-engine.mjs. Throws when
|
||||
* the file cannot be read.
|
||||
* @param {string} path
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function readScopeList(path) {
|
||||
return readFileSync(path, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** /code-review categories that name an improvement, not a defect. */
|
||||
export const IMPROVEMENT_CATEGORIES = Object.freeze(new Set(['simplification', 'efficiency', 'reuse', 'style', 'readability']));
|
||||
|
||||
|
|
@ -188,7 +199,7 @@ if (isMain(import.meta.url)) {
|
|||
let scopeFiles;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
if (scopePath) scopeFiles = readFileSync(scopePath, 'utf-8').split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
if (scopePath) scopeFiles = readScopeList(scopePath);
|
||||
} catch (e) {
|
||||
process.stderr.write(`engine-findings: could not read input: ${e.message}\n`);
|
||||
process.exit(2);
|
||||
|
|
|
|||
|
|
@ -80,10 +80,12 @@ import { createHash } from 'node:crypto';
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, constants as fsConstants, lstatSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
|
||||
import { reviewedSha } from './reviewed-commit.mjs';
|
||||
import { preExistingCheck, scopeShas } from './pre-existing.mjs';
|
||||
import { archiveName, countingMajors, frontmatterField, isSecurityFinding, runId, sameFinding } from './review-rounds.mjs';
|
||||
import {
|
||||
archiveName, countingMajors, findingsBlockSha, frontmatterField, isBlocker, isMajor, isSecurityFinding, runId,
|
||||
sameFinding, trailingBlock,
|
||||
} from './review-rounds.mjs';
|
||||
import {
|
||||
appendApproval, isHollowAnswer, logPath, normalizeSubject, readLog, verifyLog,
|
||||
} from '../util/approval-log.mjs';
|
||||
|
|
@ -105,26 +107,9 @@ const LINK_RE = /^(review-round-([0-9a-f]{12})-(\d+)\.md)#([0-9a-f]{40})$/;
|
|||
const ARCHIVE_NAME_RE = /^review-round-([0-9a-f]{12})-(\d+)\.md$/;
|
||||
|
||||
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
||||
const isMajor = (f) => String(f?.severity ?? '').toUpperCase() === 'MAJOR';
|
||||
const isBlocker = (f) => String(f?.severity ?? '').toUpperCase() === 'BLOCKER';
|
||||
|
||||
/** The trailing fenced block when it is a closed `json` block, else null. */
|
||||
function trailingBlock(text) {
|
||||
const last = fencedBlocks(String(text ?? '')).at(-1);
|
||||
return last && last.closed && isJsonBlock(last) ? last : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* sha256 of the trailing JSON block's body (the one reader the gate uses,
|
||||
* findings-schema `fencedBlocks`), or null when the last block is not a
|
||||
* closed `json` block.
|
||||
* @param {string} text
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function findingsBlockSha(text) {
|
||||
const last = trailingBlock(text);
|
||||
return last ? sha256(last.body) : null;
|
||||
}
|
||||
/** sha256 of the trailing JSON block's body, or null (defined in review-rounds.mjs). */
|
||||
export { findingsBlockSha };
|
||||
|
||||
/**
|
||||
* The sha256 a K2 `review` record carries for one answer: the canonical JSON
|
||||
|
|
|
|||
|
|
@ -130,8 +130,8 @@ import { isSecurityFinding } from './review-answers.mjs';
|
|||
import { carry, frontmatterField, MAX_REVIEW_ROUNDS, runId } from './review-rounds.mjs';
|
||||
import { applyPolicy, readPolicy } from './review-policy.mjs';
|
||||
import { ENGINES } from './engine-findings.mjs';
|
||||
import { ENGINE_OF_REVIEWER, putBack, rowOf, trimmedTitle } from './engine-compare.mjs';
|
||||
import { backedLine, isKnownEntry, reviewThreatModel } from './threat-model.mjs';
|
||||
import { payloadEngine, putBack, rowIn, rowOf } from './engine-compare.mjs';
|
||||
import { isKnownEntry, reviewThreatModel, splitBacked } from './threat-model.mjs';
|
||||
import { getRule } from './rule-catalogue.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
|
|
@ -287,23 +287,16 @@ function outsideSection(kept, back) {
|
|||
*/
|
||||
function passOutside(payload, ctx) {
|
||||
if (!Array.isArray(payload.known_outside) || payload.known_outside.length === 0) return {};
|
||||
const use = reviewThreatModel(ctx.text, ctx.repo);
|
||||
const tm = use.usable ? use.threatModel : null;
|
||||
const kept = [];
|
||||
const back = [];
|
||||
for (const e of payload.known_outside) {
|
||||
if (isKnownEntry(e) && !(tm && backedLine(e, tm) !== null)) back.push(e);
|
||||
else kept.push(e);
|
||||
}
|
||||
const why = tm ? 'no `Outside:` line of the brief equals its threat_model_ref, or it is not a Voyage security finding' : use.why;
|
||||
// Only a well-formed entry goes back; a malformed one stays listed as such.
|
||||
const { unbacked: back, why } = splitBacked(payload.known_outside.filter(isKnownEntry), reviewThreatModel(ctx.text, ctx.repo));
|
||||
const kept = payload.known_outside.filter((e) => !back.includes(e));
|
||||
// A row a finding already carries (the engine pass put the same engine
|
||||
// finding back) is counted once: the entry leaves the list, no second copy.
|
||||
const rows = new Set(payload.findings.filter(isPlainObject).map(rowOf));
|
||||
for (const e of back) {
|
||||
const rule = getRule(e.rule_key.trim().toUpperCase());
|
||||
const f = { ...e, id: null, rule_key: rule ? rule.rule_key : e.rule_key, severity: rule ? rule.severity : 'BLOCKER' };
|
||||
const held = rows.has(rowOf(f)) || (typeof f.title === 'string' && rows.has(rowOf({ ...f, title: trimmedTitle(f.title) })));
|
||||
if (held) continue;
|
||||
if (rowIn(rows, f)) continue;
|
||||
rows.add(rowOf(f));
|
||||
payload.findings.push(f);
|
||||
ctx.added.add(f);
|
||||
|
|
@ -425,10 +418,6 @@ function resolvedSection(n, list) {
|
|||
return `## Resolved since round ${n}\n\nPrevious findings this round no longer raises: ${list.length}.\n\n${items.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
/** The engine a payload's top-level `reviewer` maps back to through ENGINES, or null. */
|
||||
const payloadEngine = (p) => (isPlainObject(p) && typeof p.reviewer === 'string' && Object.hasOwn(ENGINE_OF_REVIEWER, p.reviewer)
|
||||
? ENGINE_OF_REVIEWER[p.reviewer] : null);
|
||||
|
||||
/**
|
||||
* The entries each engine raised this round outside its `findings` (decision 222):
|
||||
* `below_threshold` and `out_of_scope`, plain objects only, each with the
|
||||
|
|
@ -721,7 +710,7 @@ function findingsBefore(sev) {
|
|||
* The body entries: one added for each finding in `added`, and a `### <id>`
|
||||
* entry that sits under another severity's `## Findings` section moved to its own.
|
||||
*/
|
||||
function placeEntries(head, findings, added, putBackSet, outsideBack = new Map()) {
|
||||
function placeEntries(head, findings, added, putBackSet, outsideBack) {
|
||||
let lines = head.split('\n');
|
||||
const fmEnd = frontmatterEnd(lines);
|
||||
for (const f of findings) {
|
||||
|
|
|
|||
|
|
@ -134,20 +134,31 @@ function regularFiles(dir, re) {
|
|||
}).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* sha256 of the trailing fenced block's body when it is a closed `json`
|
||||
* block, else null. A copy of findingsBlockSha (lib/review/review-answers.mjs,
|
||||
* which imports this module, so importing it back would close a cycle).
|
||||
*/
|
||||
function blockSha(text) {
|
||||
// The trailing-block helpers live here and review-answers.mjs imports them:
|
||||
// it imports this module, so this one never imports it back.
|
||||
|
||||
/** The trailing fenced block when it is a closed `json` block, else null. */
|
||||
export function trailingBlock(text) {
|
||||
const last = fencedBlocks(String(text ?? '')).at(-1);
|
||||
return last && last.closed && isJsonBlock(last) ? createHash('sha256').update(last.body).digest('hex') : null;
|
||||
return last && last.closed && isJsonBlock(last) ? last : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* sha256 of the trailing JSON block's body (the one reader the gate uses,
|
||||
* findings-schema `fencedBlocks`), or null when the last block is not a
|
||||
* closed `json` block.
|
||||
* @param {string} text
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function findingsBlockSha(text) {
|
||||
const last = trailingBlock(text);
|
||||
return last ? createHash('sha256').update(last.body).digest('hex') : null;
|
||||
}
|
||||
|
||||
const notMeasured = (reason, detail = '') => ({ exit: 2, reason: `${reason} — not measured${detail ? ` (${detail})` : ''}` });
|
||||
|
||||
const isMajor = (f) => String(f?.severity ?? '').toUpperCase() === 'MAJOR';
|
||||
const isBlocker = (f) => String(f?.severity ?? '').toUpperCase() === 'BLOCKER';
|
||||
export const isMajor = (f) => String(f?.severity ?? '').toUpperCase() === 'MAJOR';
|
||||
export const isBlocker = (f) => String(f?.severity ?? '').toUpperCase() === 'BLOCKER';
|
||||
|
||||
/**
|
||||
* Every MAJOR finding (case-insensitive) that counts toward the lock. A finding
|
||||
|
|
@ -163,10 +174,10 @@ export function countingMajors(payload, { preExisting = () => false } = {}) {
|
|||
&& !(preExisting(f) === true && !isSecurityFinding(f)));
|
||||
}
|
||||
|
||||
/** The parsed trailing JSON block of a review (an object), or null. */
|
||||
/** The parsed trailing JSON block of a review (an object with a `findings` list), or null. */
|
||||
function payloadOf(text) {
|
||||
const last = fencedBlocks(String(text ?? '')).at(-1);
|
||||
if (!last || !last.closed || !isJsonBlock(last)) return null;
|
||||
const last = trailingBlock(text);
|
||||
if (!last) return null;
|
||||
try {
|
||||
const p = JSON.parse(last.body);
|
||||
return p && typeof p === 'object' && !Array.isArray(p) && Array.isArray(p.findings) ? p : null;
|
||||
|
|
@ -180,7 +191,7 @@ function payloadOf(text) {
|
|||
* review has no readable JSON block.
|
||||
* @returns {{blocking: number, newByClass: Object<string, number>}|null}
|
||||
*/
|
||||
export function reviewRoundSummary(text, repo) {
|
||||
function reviewRoundSummary(text, repo) {
|
||||
const payload = payloadOf(text);
|
||||
if (!payload) return null;
|
||||
const pre = preExistingCheck(text, repo);
|
||||
|
|
@ -188,7 +199,7 @@ export function reviewRoundSummary(text, repo) {
|
|||
...payload.findings.filter((f) => f && typeof f === 'object' && isBlocker(f)),
|
||||
...countingMajors(payload, { preExisting: (f) => !f.carried_from && pre(f) }),
|
||||
];
|
||||
return roundSummary(blocking, { fullCount: false });
|
||||
return roundSummary(blocking);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -226,19 +237,19 @@ export function prepare({ projectDir, since, repo, dryRun = false } = {}) {
|
|||
const roundField = frontmatterField(text, 'round');
|
||||
// review.md's own archive (see the header): byte-equal first, then by content.
|
||||
const ownName = archiveName(run, roundField === null ? 1 : roundField);
|
||||
const ownBlock = blockSha(text);
|
||||
const ownBlock = findingsBlockSha(text);
|
||||
const ownEnd = reviewedSha(text);
|
||||
const isOwn = (name, bytes) => {
|
||||
if (bytes.equals(review)) return true;
|
||||
if (name !== ownName || ownBlock === null || ownEnd === '') return false;
|
||||
const t = bytes.toString('utf8');
|
||||
return blockSha(t) === ownBlock && reviewedSha(t) === ownEnd;
|
||||
return findingsBlockSha(t) === ownBlock && reviewedSha(t) === ownEnd;
|
||||
};
|
||||
const others = [];
|
||||
for (const n of archives) {
|
||||
const bytes = readRegular(join(projectDir, n));
|
||||
if (bytes === null) return notMeasured(`archive ${n} is not readable`);
|
||||
if (!isOwn(n, bytes)) others.push(n);
|
||||
for (const name of archives) {
|
||||
const bytes = readRegular(join(projectDir, name));
|
||||
if (bytes === null) return notMeasured(`archive ${name} is not readable`);
|
||||
if (!isOwn(name, bytes)) others.push({ name, bytes });
|
||||
}
|
||||
const P = 1 + others.length;
|
||||
if (roundField !== null && roundField !== String(P)) {
|
||||
|
|
@ -250,11 +261,10 @@ export function prepare({ projectDir, since, repo, dryRun = false } = {}) {
|
|||
}
|
||||
if (P >= 2) {
|
||||
// The stop rule on the pair (P - 1, P): the latest other archive and review.md.
|
||||
const nOf = (name) => Number(archiveRe.exec(name)[1]);
|
||||
const prevName = [...others].sort((a, b) => nOf(b) - nOf(a))[0];
|
||||
const prevBytes = readRegular(join(projectDir, prevName));
|
||||
const nOf = (o) => Number(archiveRe.exec(o.name)[1]);
|
||||
const prev = [...others].sort((x, y) => nOf(y) - nOf(x))[0];
|
||||
const at = repo ?? projectDir;
|
||||
const a = prevBytes === null ? null : reviewRoundSummary(prevBytes.toString('utf8'), at);
|
||||
const a = reviewRoundSummary(prev.bytes.toString('utf8'), at);
|
||||
const b = reviewRoundSummary(text, at);
|
||||
if (!a || !b) return notMeasured('stop rule: a round has no readable JSON block');
|
||||
const d = decideStop([{ round: P - 1, ...a }, { round: P, ...b }]);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, posix } from 'node:path';
|
||||
import { toReviewerPayload, ENGINES } from './engine-findings.mjs';
|
||||
import { toReviewerPayload, ENGINES, readScopeList } from './engine-findings.mjs';
|
||||
import { FULL_SHA_RE } from './reviewed-commit.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
|
|
@ -89,6 +89,14 @@ const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArr
|
|||
const couldNotRun = (reason) => ({ exit: 2, reason });
|
||||
const notMeasured = (why) => ({ exit: 2, reason: `Claude Security report not measured: ${why}` });
|
||||
|
||||
/** The first of --base / --head that is not a full commit id, as a could-not-run; else null. */
|
||||
function notFullShas(base, head) {
|
||||
for (const [name, sha] of [['--base', base], ['--head', head]]) {
|
||||
if (!FULL_SHA_RE.test(String(sha ?? ''))) return couldNotRun(`${name} must be a full 40- or 64-hex commit id`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- choose ------------------------------------------------------------------------
|
||||
|
||||
function git(repo, args) {
|
||||
|
|
@ -111,8 +119,8 @@ function differs(repo, ...revs) {
|
|||
*/
|
||||
export function chooseEngine({ repo, base, head, claudeSecurity } = {}) {
|
||||
if (typeof repo !== 'string' || repo === '') return couldNotRun('--repo is required');
|
||||
if (!FULL_SHA_RE.test(String(base ?? ''))) return couldNotRun('--base must be a full 40- or 64-hex commit id');
|
||||
if (!FULL_SHA_RE.test(String(head ?? ''))) return couldNotRun('--head must be a full 40- or 64-hex commit id');
|
||||
const bad = notFullShas(base, head);
|
||||
if (bad) return bad;
|
||||
if (claudeSecurity !== 'available' && claudeSecurity !== 'missing') {
|
||||
return couldNotRun('--claude-security must be available or missing');
|
||||
}
|
||||
|
|
@ -165,7 +173,7 @@ function stampIn(folder) {
|
|||
const sameSha = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
/** The newest report folder in `dir` whose stamp is a changes scan of exactly base..head, or null. */
|
||||
export function findReport(dir, base, head) {
|
||||
function findReport(dir, base, head) {
|
||||
let names;
|
||||
try { names = readdirSync(dir); } catch { return null; }
|
||||
const folders = names.filter((n) => REPORT_DIR_RE.test(n) && isDir(join(dir, n))).sort().reverse();
|
||||
|
|
@ -253,8 +261,8 @@ function readRecords(folder, prefix) {
|
|||
*/
|
||||
export function convertReport({ reportDir, base, head, scopeFiles } = {}) {
|
||||
if (typeof reportDir !== 'string' || reportDir === '') return couldNotRun('--report-dir is required');
|
||||
if (!FULL_SHA_RE.test(String(base ?? ''))) return couldNotRun('--base must be a full 40- or 64-hex commit id');
|
||||
if (!FULL_SHA_RE.test(String(head ?? ''))) return couldNotRun('--head must be a full 40- or 64-hex commit id');
|
||||
const bad = notFullShas(base, head);
|
||||
if (bad) return bad;
|
||||
const found = findReport(reportDir, base, head);
|
||||
if (!found) {
|
||||
return notMeasured(`no CLAUDE-SECURITY-<ts>/ folder in ${reportDir} holds one stamp of a changes scan of ${base}..${head} (an empty diff writes none)`);
|
||||
|
|
@ -300,28 +308,31 @@ function flags(args, known) {
|
|||
return out;
|
||||
}
|
||||
|
||||
/** A usage error: the CLI prints USAGE after the reason. */
|
||||
const badUsage = (reason) => ({ ...couldNotRun(reason), usage: true });
|
||||
|
||||
function runCli(argv) {
|
||||
const [cmd, ...rest] = argv;
|
||||
if (cmd === 'choose') {
|
||||
const f = flags(rest, ['--repo', '--base', '--head', '--claude-security']);
|
||||
if (!f) return couldNotRun('bad arguments');
|
||||
if (!f) return badUsage('bad arguments');
|
||||
const r = chooseEngine({ repo: f['--repo'], base: f['--base'], head: f['--head'], claudeSecurity: f['--claude-security'] });
|
||||
return r.exit === 0 ? { exit: 0, out: { engine: r.engine, reason: r.reason } } : r;
|
||||
}
|
||||
if (cmd === 'convert') {
|
||||
const f = flags(rest, ['--report-dir', '--base', '--head', '--scope']);
|
||||
if (!f) return couldNotRun('bad arguments');
|
||||
if (!f) return badUsage('bad arguments');
|
||||
let scopeFiles;
|
||||
if (f['--scope'] !== undefined) {
|
||||
if (!existsSync(f['--scope'])) return couldNotRun(`--scope ${f['--scope']} does not exist`);
|
||||
try {
|
||||
scopeFiles = readFileSync(f['--scope'], 'utf8').split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
scopeFiles = readScopeList(f['--scope']);
|
||||
} catch (e) { return couldNotRun(`cannot read --scope: ${e.message}`); }
|
||||
}
|
||||
const r = convertReport({ reportDir: f['--report-dir'], base: f['--base'], head: f['--head'], scopeFiles });
|
||||
return r.exit === 0 ? { exit: 0, out: r.payload } : r;
|
||||
}
|
||||
return couldNotRun('a subcommand is required: choose or convert');
|
||||
return badUsage('a subcommand is required: choose or convert');
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
|
|
@ -331,7 +342,7 @@ if (isMain(import.meta.url)) {
|
|||
process.stdout.write(`${JSON.stringify(r.out, null, 2)}\n`);
|
||||
} else {
|
||||
process.stderr.write(`security-engine: ${r.reason}\n`);
|
||||
if (r.reason === 'bad arguments' || r.reason.startsWith('a subcommand')) process.stderr.write(USAGE);
|
||||
if (r.usage) process.stderr.write(USAGE);
|
||||
}
|
||||
process.exit(r.exit);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
// Otherwise every security finding counts, and `why` says why.
|
||||
// backedLine(finding, tm) -> the `Outside:` text the finding's
|
||||
// `threat_model_ref` equals exactly, else null.
|
||||
// splitBacked(entries, use, asFinding) -> {backed, unbacked, why}: the one
|
||||
// backing check of both gates' `known_outside` (backedLine under a usable
|
||||
// threat model; `why` says why the unbacked ones are not backed).
|
||||
// usableInRepo(briefPath, repo) -> usable() for a brief path relative to
|
||||
// the repo's top level (an absolute one as it is); both gates read a
|
||||
// stored `brief_path` this way, whatever directory they run in.
|
||||
|
|
@ -45,10 +48,11 @@ import { parseDocument } from '../util/frontmatter.mjs';
|
|||
import { verifyLog, normalizeHex, normalizeSubject, resolveRepo } from '../util/approval-log.mjs';
|
||||
import { checkIntentApprovalContent } from '../validators/intent-approval.mjs';
|
||||
import { parseThreatModel } from '../validators/brief-validator.mjs';
|
||||
import { isSecurityKey, keysOf } from './plan-review-dedup.mjs';
|
||||
import { isSecurity, keysOf } from './plan-review-dedup.mjs';
|
||||
import { ENGINES, SECURITY_ENGINES } from './engine-findings.mjs';
|
||||
|
||||
/** The engines' catalogue key (/security-review, Claude Security): never Voyage's own. */
|
||||
export const ENGINES_SECURITY_KEY = 'SECURITY_REVIEW_VULNERABILITY';
|
||||
/** The security engines' catalogue keys (/security-review, Claude Security): never Voyage's own. */
|
||||
const ENGINE_SECURITY_KEYS = new Set(SECURITY_ENGINES.map((e) => ENGINES[e].rule_key));
|
||||
|
||||
const EMPTY = Object.freeze({ present: false, inside: [], outside: [], problems: [] });
|
||||
|
||||
|
|
@ -154,9 +158,8 @@ export function isKnownEntry(e) {
|
|||
export function isOwnSecurityFinding(f) {
|
||||
if (!f || typeof f !== 'object') return false;
|
||||
if (typeof f.engine === 'string' && f.engine.trim() !== '') return false;
|
||||
const keys = keysOf(f);
|
||||
if (keys.some((k) => k.trim().toUpperCase() === ENGINES_SECURITY_KEY)) return false;
|
||||
return keys.some(isSecurityKey);
|
||||
if (keysOf(f).some((k) => ENGINE_SECURITY_KEYS.has(k.trim().toUpperCase()))) return false;
|
||||
return isSecurity(f);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -173,3 +176,23 @@ export function backedLine(finding, tm) {
|
|||
if (typeof ref !== 'string' || ref === '') return null;
|
||||
return tm.outside.find((line) => line === ref) ?? null;
|
||||
}
|
||||
|
||||
const NOT_BACKED = 'no `Outside:` line of the brief equals the text it quotes, or it is not a well-formed Voyage security finding';
|
||||
|
||||
/**
|
||||
* Split entries on the threat model `use` (from usable / usableInRepo /
|
||||
* reviewThreatModel): backed when backedLine finds the `Outside:` line of
|
||||
* `asFinding(entry)`; a threat model that is not usable backs nothing.
|
||||
* @param {Array<*>} entries
|
||||
* @param {{usable: boolean, why: string|null, threatModel: object}|undefined} use
|
||||
* @param {(e: *) => *} [asFinding] - the finding backedLine checks for an entry (default: the entry)
|
||||
* @returns {{backed: Array<*>, unbacked: Array<*>, why: string|null}} why = why the unbacked ones are not backed (null when none is)
|
||||
*/
|
||||
export function splitBacked(entries, use, asFinding = (e) => e) {
|
||||
const tm = use?.usable === true ? use.threatModel : null;
|
||||
const backed = [];
|
||||
const unbacked = [];
|
||||
for (const e of entries) (backedLine(asFinding(e), tm) !== null ? backed : unbacked).push(e);
|
||||
if (unbacked.length === 0) return { backed, unbacked, why: null };
|
||||
return { backed, unbacked, why: tm ? NOT_BACKED : (use?.why ?? 'the threat model is not usable') };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ import { frontmatterField, runId } from './review-rounds.mjs';
|
|||
// No cycle: neither module, nor any module they import, imports this gate.
|
||||
import { roundEvidence } from './review-finalize.mjs';
|
||||
import { putBack, normTitle } from './engine-compare.mjs';
|
||||
import { backedLine, isKnownEntry, reviewThreatModel } from './threat-model.mjs';
|
||||
import { isKnownEntry, reviewThreatModel, splitBacked } from './threat-model.mjs';
|
||||
|
||||
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
|
||||
|
||||
|
|
@ -319,7 +319,7 @@ export function droppedEngineFindings(text, reviewPath) {
|
|||
|
||||
/**
|
||||
* The `known_outside` entries of review.md the brief at its `brief_path` does
|
||||
* not back now (lib/review/threat-model.mjs `reviewThreatModel`, `backedLine`):
|
||||
* not back now (lib/review/threat-model.mjs `reviewThreatModel`, `splitBacked`):
|
||||
* a malformed list or entry, an engine finding, not a security finding, an
|
||||
* `Outside:` line gone, or a threat model that is not usable. An unreadable
|
||||
* trailing block gives none (decideVerdict judges it).
|
||||
|
|
@ -333,11 +333,9 @@ export function notBackedOutside(text, repo) {
|
|||
const list = payload.known_outside;
|
||||
if (!Array.isArray(list)) return { entries: [list], why: 'known_outside is not a list' };
|
||||
if (list.length === 0) return { entries: [], why: null };
|
||||
const use = reviewThreatModel(text, repo);
|
||||
const tm = use.usable ? use.threatModel : null;
|
||||
const entries = list.filter((e) => !isKnownEntry(e) || !(tm && backedLine(e, tm) !== null));
|
||||
const why = tm ? 'no `Outside:` line of the brief equals its threat_model_ref, it is not a Voyage security finding, or it is malformed' : use.why;
|
||||
return { entries, why: entries.length > 0 ? why : null };
|
||||
// A malformed entry is checked as no finding, so it is never backed.
|
||||
const { unbacked, why } = splitBacked(list, reviewThreatModel(text, repo), (e) => (isKnownEntry(e) ? e : null));
|
||||
return { entries: unbacked, why };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -148,8 +148,9 @@ import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from
|
|||
import { normalizeSeverity, isSecurity } from '../review/plan-review-dedup.mjs';
|
||||
import { countRound, lineDiff, oneLine } from '../review/critic-diff.mjs';
|
||||
import { roundSummary, decideStop, describeStop, STOP_REASONS } from '../review/stop-rule.mjs';
|
||||
import { usableInRepo, repoRelativeBrief, backedLine } from '../review/threat-model.mjs';
|
||||
import { usableInRepo, repoRelativeBrief, splitBacked } from '../review/threat-model.mjs';
|
||||
import { resolveRepo } from '../verification/repo-tools.mjs';
|
||||
import { resolveRepo as resolveTopLevel } from '../util/approval-log.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const MAX_ROUNDS = 3;
|
||||
|
|
@ -410,19 +411,15 @@ export function recordVerdict(text, dedup, opts = {}) {
|
|||
// known_outside; a copy rule A carries from an earlier round stays counted
|
||||
// until a reviewer marks it resolved (decision 221).
|
||||
const use = opts.threatModel;
|
||||
const tm = use && use.usable === true ? use.threatModel : null;
|
||||
const backed = [];
|
||||
const counted = [];
|
||||
for (const f of dedup.findings) {
|
||||
const line = tm ? backedLine(f, tm) : null;
|
||||
if (line === null) { counted.push(f); continue; }
|
||||
// rule_keys kept, so the bare gate re-checks the entry with the same keys (keysOf).
|
||||
backed.push({
|
||||
id: f.id ?? null, rule_key: f.rule_key ?? null,
|
||||
...(Array.isArray(f.rule_keys) ? { rule_keys: f.rule_keys.filter(k => typeof k === 'string') } : {}),
|
||||
text: typeof f.text === 'string' ? f.text : '', line,
|
||||
});
|
||||
}
|
||||
const split = splitBacked(dedup.findings, use);
|
||||
const counted = split.unbacked;
|
||||
// rule_keys kept, so the bare gate re-checks the entry with the same keys
|
||||
// (keysOf); line = the `Outside:` text, which a backed threat_model_ref equals.
|
||||
const backed = split.backed.map(f => ({
|
||||
id: f.id ?? null, rule_key: f.rule_key ?? null,
|
||||
...(Array.isArray(f.rule_keys) ? { rule_keys: f.rule_keys.filter(k => typeof k === 'string') } : {}),
|
||||
text: typeof f.text === 'string' ? f.text : '', line: f.threat_model_ref,
|
||||
}));
|
||||
const backedIds = new Set(backed.map(e => e.id).filter(id => id !== null));
|
||||
const c = countRound({
|
||||
round,
|
||||
|
|
@ -506,32 +503,34 @@ export function acceptOpen(rawText, opts = {}) {
|
|||
const ex = extractVerdict(text);
|
||||
if (!ex.found || ex.error) return { code: 2, message: `no readable verdict: ${ex.error ?? 'no ## Critic Verdict'}` };
|
||||
const v = ex.verdict;
|
||||
const fired = v.stop_rule?.fired === true;
|
||||
if (v.plan_sha256 !== planHash(text)) {
|
||||
return {
|
||||
code: 1,
|
||||
message: v.stop_rule?.fired === true
|
||||
message: fired
|
||||
? 'the verdict is stale: the plan changed after the last round, and the stop rule fired, so there is no next round; restore the reviewed text to accept, or stop and take the direction to the operator'
|
||||
: 'the verdict is stale: the plan changed after the last round; run the next round instead',
|
||||
};
|
||||
}
|
||||
if (v.stop_rule?.fired === true) {
|
||||
// The verdict with `accepted_open` naming `list`, bound to this round and plan text.
|
||||
const accept = (list, under) => ({
|
||||
...v,
|
||||
accepted_open: {
|
||||
round: v.round,
|
||||
plan_sha256: v.plan_sha256,
|
||||
accepted_at: opts.now ?? new Date().toISOString(),
|
||||
...(under ? { under } : {}),
|
||||
findings: list.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
|
||||
},
|
||||
});
|
||||
if (fired) {
|
||||
const open = v.findings.filter(f => sevOf(f) !== 'minor');
|
||||
if (open.length === 0) return { code: 1, message: 'no BLOCKER or MAJOR is open; nothing to accept' };
|
||||
const security = open.filter(isSecurity);
|
||||
if (security.length > 0) {
|
||||
return { code: 1, message: `PLAN_GATE_SECURITY_OPEN: ${security.length} open security finding(s) count and are never accepted: ${security.map(s => s.text).join(' | ')}` };
|
||||
}
|
||||
const accepted = {
|
||||
...v,
|
||||
accepted_open: {
|
||||
round: v.round,
|
||||
plan_sha256: v.plan_sha256,
|
||||
accepted_at: opts.now ?? new Date().toISOString(),
|
||||
under: 'stop-rule',
|
||||
findings: open.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
|
||||
},
|
||||
};
|
||||
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${open.length} open finding(s) of round ${v.round} accepted as known issues under the stop rule` };
|
||||
return { code: 0, text: placeSection(text, renderSection(accept(open, 'stop-rule'))), message: `${open.length} open finding(s) of round ${v.round} accepted as known issues under the stop rule` };
|
||||
}
|
||||
if (v.findings.some(f => sevOf(f) !== 'major' && sevOf(f) !== 'minor')) return { code: 1, message: 'a BLOCKER is open; a blocker is never accepted' };
|
||||
const majors = v.findings.filter(f => sevOf(f) === 'major');
|
||||
|
|
@ -540,16 +539,7 @@ export function acceptOpen(rawText, opts = {}) {
|
|||
// (found by the independent review: acceptance at round 1 passed the gate).
|
||||
const left = v.max_rounds + (v.extra_rounds ?? 0) - v.round;
|
||||
if (left > 0) return { code: 1, message: `${left} critic round(s) left: revise the plan and run the next round; acceptance is only for after the last round` };
|
||||
const accepted = {
|
||||
...v,
|
||||
accepted_open: {
|
||||
round: v.round,
|
||||
plan_sha256: v.plan_sha256,
|
||||
accepted_at: opts.now ?? new Date().toISOString(),
|
||||
findings: majors.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
|
||||
},
|
||||
};
|
||||
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` };
|
||||
return { code: 0, text: placeSection(text, renderSection(accept(majors))), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -559,13 +549,8 @@ export function acceptOpen(rawText, opts = {}) {
|
|||
* @param {{usable: boolean, why: string|null, threatModel: object}} use - threat-model.mjs `usable`
|
||||
*/
|
||||
function checkKnownOutside(entries, use, reasons, warnings) {
|
||||
const tm = use?.usable === true ? use.threatModel : null;
|
||||
const backed = [];
|
||||
const unbacked = [];
|
||||
for (const e of entries) {
|
||||
const asFinding = { rule_key: e.rule_key, ...(Array.isArray(e.rule_keys) ? { rule_keys: e.rule_keys } : {}), threat_model_ref: e.line };
|
||||
(tm && backedLine(asFinding, tm) !== null ? backed : unbacked).push(e);
|
||||
}
|
||||
const { backed, unbacked, why } = splitBacked(entries, use,
|
||||
e => ({ rule_key: e.rule_key, ...(Array.isArray(e.rule_keys) ? { rule_keys: e.rule_keys } : {}), threat_model_ref: e.line }));
|
||||
if (backed.length > 0) {
|
||||
warnings.push({
|
||||
code: 'PLAN_GATE_KNOWN_OUTSIDE',
|
||||
|
|
@ -573,7 +558,6 @@ function checkKnownOutside(entries, use, reasons, warnings) {
|
|||
});
|
||||
}
|
||||
if (unbacked.length > 0) {
|
||||
const why = tm ? 'no `Outside:` line in the brief equals it, or it is not a Voyage security finding' : (use?.why ?? 'the threat model is not usable');
|
||||
reasons.push({
|
||||
code: 'PLAN_GATE_OUTSIDE_NOT_BACKED',
|
||||
message: `${unbacked.length} finding(s) recorded as outside the threat model are not backed by the brief now (${why}), so they count in full: ${unbacked.map(e => e.text).join(' | ')}`,
|
||||
|
|
@ -609,10 +593,11 @@ export function gatePlan(rawText, opts = {}) {
|
|||
} else if (ex.error) {
|
||||
reasons.push({ code: 'PLAN_GATE_VERDICT_MALFORMED', message: ex.error });
|
||||
} else {
|
||||
const stop = v.stop_rule?.fired === true ? v.stop_rule : null;
|
||||
if (v.plan_sha256 !== planHash(text)) {
|
||||
reasons.push({
|
||||
code: 'PLAN_GATE_VERDICT_STALE',
|
||||
message: v.stop_rule?.fired === true
|
||||
message: stop
|
||||
? `The plan changed after critic round ${v.round}, and the stop rule fired, so there is no next round; restore the reviewed text, or stop and take the direction to the operator.`
|
||||
: `The plan changed after critic round ${v.round}; run the next round.`,
|
||||
});
|
||||
|
|
@ -620,7 +605,6 @@ export function gatePlan(rawText, opts = {}) {
|
|||
const sev = f => normalizeSeverity(f.severity);
|
||||
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');
|
||||
const majors = v.findings.filter(f => sev(f) === 'major');
|
||||
const stop = v.stop_rule?.fired === true ? v.stop_rule : null;
|
||||
if (stop) {
|
||||
// The stop rule replaces the open-blocker and open-major reasons: no new
|
||||
// round, the operator decides (decision 220). Covered = accepted under
|
||||
|
|
@ -844,9 +828,11 @@ if (isMain(import.meta.url)) {
|
|||
const dir = snapshotDir(planPath);
|
||||
const opts = { extraRound: args.includes('--extra-round'), reviewed, planPaths: planPathsOf(planPath) };
|
||||
if (briefArg !== undefined) {
|
||||
// Stored relative to the repo's top level, never absolute; read from there.
|
||||
opts.briefPath = repoRelativeBrief(briefArg);
|
||||
opts.threatModel = usableInRepo(opts.briefPath);
|
||||
// Stored relative to the repo's top level, never absolute; read from there
|
||||
// (one top-level lookup for both).
|
||||
const top = resolveTopLevel();
|
||||
opts.briefPath = repoRelativeBrief(briefArg, top);
|
||||
opts.threatModel = usableInRepo(opts.briefPath, top);
|
||||
}
|
||||
const lb = loadBase(text, dir);
|
||||
if (lb.verdict) {
|
||||
|
|
|
|||
81
tests/helpers/plan-gate-harness.mjs
Normal file
81
tests/helpers/plan-gate-harness.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// tests/helpers/plan-gate-harness.mjs
|
||||
// The critic-round and CLI harness of the plan-gate tests
|
||||
// (tests/validators/plan-gate-diff-mode, -stop-rule and -threat-model):
|
||||
// rounds through the real recordVerdict and dedup on buildPlan() text, and
|
||||
// plan-gate.mjs run as a child in its own temp `git init` repo.
|
||||
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { recordVerdict, planHash, operativeText } from '../../lib/validators/plan-gate.mjs';
|
||||
import { dedupFindings } from '../../lib/review/plan-review-dedup.mjs';
|
||||
import { childEnv } from './child-env.mjs';
|
||||
|
||||
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
export const PATHS = [resolve('plan.md')];
|
||||
|
||||
/** The 1-based full-file line holding `s` (findIndex over the whole text). */
|
||||
export function at(text, s) {
|
||||
const n = text.split('\n').findIndex(l => l.includes(s)) + 1;
|
||||
assert.ok(n > 0, `the plan holds ${JSON.stringify(s)}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** The base a round diffs against: the operative text the previous round reviewed. */
|
||||
export const baseOf = t => ({ sha256: planHash(t), text: operativeText(t) });
|
||||
|
||||
/** `rec(text, d, opts)`: one round as Phase 9 runs it, recorded at `now`, with the plan path known to the gate. */
|
||||
export function recorder(now) {
|
||||
return (text, d, opts = {}) => {
|
||||
const r = recordVerdict(text, d, { reviewed: planHash(text), planPaths: PATHS, now, ...opts });
|
||||
assert.equal(r.code, 0, r.message);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reviewer payloads through the real dedup. */
|
||||
export function dd({ pc = [], sg = [], pcPrev, sgPrev } = {}) {
|
||||
return dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: pc, ...(pcPrev ? { previous: pcPrev } : {}) } },
|
||||
{ agent: 'scope-guardian', payload: { findings: sg, ...(sgPrev ? { previous: sgPrev } : {}) } },
|
||||
]);
|
||||
}
|
||||
|
||||
/** A reviewer finding on plan.md under `rule_key`. */
|
||||
export const keyedFinding = (severity, line, text, rule_key, extra = {}) => ({ file: 'plan.md', line, rule_key, severity, text, ...extra });
|
||||
|
||||
export const R1_CHANGES = 'Add `run1()`';
|
||||
export const R2_CHANGES = '- **Changes:** Add `run2()` to `src/mod2.mjs`. (new file)';
|
||||
export const R2_EDITED = '- **Changes:** Add `run2()` and its error path to `src/mod2.mjs`. (new file)\n - `run2()` throws on empty input.';
|
||||
|
||||
/** Edit A: Step 2's Changes line becomes two lines (a two-line hunk, nothing above it moves). */
|
||||
export function editA(t) {
|
||||
assert.ok(t.includes(R2_CHANGES));
|
||||
return t.replace(R2_CHANGES, R2_EDITED);
|
||||
}
|
||||
|
||||
// ---- CLI ------------------------------------------------------------------------
|
||||
|
||||
export const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
|
||||
export const GIT_CLEAN = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined };
|
||||
|
||||
/** plan-gate.mjs as a child in `cwd`, with `input` on stdin. */
|
||||
export function runGate(cwd, args, input) {
|
||||
const r = spawnSync(process.execPath, [GATE, ...args], { cwd, input, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
/** The last non-empty output line, parsed. */
|
||||
export const lastJson = out => JSON.parse(out.split('\n').filter(l => l !== '').at(-1));
|
||||
|
||||
/** A fresh temp dir (real path), `git init`-ed; removed again when the init fails. */
|
||||
export function tempRepo(prefix) {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
||||
const g = spawnSync('git', ['init', '-q'], { cwd: dir, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||
if (g.status !== 0) rmSync(dir, { recursive: true, force: true });
|
||||
assert.equal(g.status, 0, g.stderr);
|
||||
return dir;
|
||||
}
|
||||
41
tests/helpers/threat-model-brief.mjs
Normal file
41
tests/helpers/threat-model-brief.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// tests/helpers/threat-model-brief.mjs
|
||||
// The brief fixture of the threat-model tests (tests/lib/threat-model and
|
||||
// tests/validators/brief-threat-model): a brief_version 2.2 brief with an
|
||||
// optional `## Threat Model` section before its Success Criteria.
|
||||
|
||||
export const OUTSIDE = "a local user who can already edit the repo's hooks";
|
||||
export const INSIDE = 'a crafted file name in a pull request reaches the shell';
|
||||
export const TM = `## Threat Model\n\n- Inside: ${INSIDE}\n- Outside: ${OUTSIDE}\n`;
|
||||
|
||||
/** The brief with `threat` (a `## Threat Model` section, or '') before its Success Criteria, of frontmatter `type`. */
|
||||
export const briefWith = (threat, type = 'trekbrief') => `---
|
||||
type: ${type}
|
||||
brief_version: "2.2"
|
||||
created: 2026-11-08
|
||||
task: "Add an upload hook"
|
||||
slug: upload-hook
|
||||
project_dir: .claude/projects/2026-11-08-upload-hook/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
framing: new-direction
|
||||
---
|
||||
|
||||
# Task
|
||||
|
||||
## TL;DR
|
||||
|
||||
An upload hook.
|
||||
|
||||
## Intent
|
||||
|
||||
Uploads arrive from strangers.
|
||||
|
||||
## Goal
|
||||
|
||||
A hook that checks each upload.
|
||||
|
||||
${threat}
|
||||
## Success Criteria
|
||||
|
||||
- It checks.
|
||||
`;
|
||||
|
|
@ -276,23 +276,30 @@ test('[CNV] not measured: exit 2, the reason on stderr, nothing on stdout', () =
|
|||
['a record with an unknown confidence', { records: [record({ confidence: 'certain' })] }],
|
||||
['a record with no title', { records: [record({ title: '' })] }],
|
||||
];
|
||||
// The CLI's channels, once: exit 2, nothing on stdout, the reason on stderr.
|
||||
const cli = run(['convert', '--report-dir', reportDir(), '--base', BASE, '--head', HEAD]);
|
||||
assert.equal(cli.status, 2, `CLI: exit 2, got ${cli.status}: ${cli.stdout}${cli.stderr}`);
|
||||
assert.equal(cli.stdout, '', 'CLI: nothing on stdout');
|
||||
assert.match(cli.stderr, /not measured/, `CLI: the reason on stderr says not measured: ${cli.stderr}`);
|
||||
for (const [name, opts] of cases) {
|
||||
const dir = reportDir();
|
||||
if (opts !== null) writeReport(dir, { head: HEAD, base: BASE, ...opts });
|
||||
const r = run(['convert', '--report-dir', dir, '--base', BASE, '--head', HEAD]);
|
||||
assert.equal(r.status, 2, `${name}: exit 2, got ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(r.stdout, '', `${name}: nothing on stdout`);
|
||||
assert.match(r.stderr, /not measured/, `${name}: the reason says not measured: ${r.stderr}`);
|
||||
const r = convertReport({ reportDir: dir, base: BASE, head: HEAD });
|
||||
assert.equal(r.exit, 2, `${name}: exit 2, got ${r.exit}: ${JSON.stringify(r)}`);
|
||||
assert.equal(r.payload, undefined, `${name}: no payload`);
|
||||
assert.match(r.reason, /not measured/, `${name}: the reason says not measured: ${r.reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** convert's CLI on one fresh report folder per case: [name, exit, nothing on stdout, the reason says not measured]. */
|
||||
/** The convert result of one report folder: [name, exit, no payload, the reason says not measured]. */
|
||||
const convertRow = (name, r) => [name, r.exit, r.payload === undefined, /not measured/.test(r.reason ?? '')];
|
||||
|
||||
/** convertReport on one fresh report folder per case, as convertRow gives it. */
|
||||
function convertEach(cases) {
|
||||
return cases.map(([name, opts]) => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE, ...opts });
|
||||
const r = run(['convert', '--report-dir', dir, '--base', BASE, '--head', HEAD]);
|
||||
return [name, r.status, r.stdout === '', /not measured/.test(r.stderr)];
|
||||
return convertRow(name, convertReport({ reportDir: dir, base: BASE, head: HEAD }));
|
||||
});
|
||||
}
|
||||
const notMeasuredEach = (cases) => cases.map(([name]) => [name, 2, true, true]);
|
||||
|
|
@ -381,10 +388,7 @@ test('[CNV] a symlinked stamp, JSONL or report folder is refused: not measured',
|
|||
symlinkSync(`${what}.real`, join(folder, name));
|
||||
return dir;
|
||||
};
|
||||
const got = kinds.map((what) => {
|
||||
const r = run(['convert', '--report-dir', linked(what), '--base', BASE, '--head', HEAD]);
|
||||
return [what, r.status, r.stdout === '', /not measured/.test(r.stderr)];
|
||||
});
|
||||
const got = kinds.map((what) => convertRow(what, convertReport({ reportDir: linked(what), base: BASE, head: HEAD })));
|
||||
assert.deepEqual(got, kinds.map((what) => [what, 2, true, true]));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { OUTSIDE, INSIDE, TM, briefWith } from '../helpers/threat-model-brief.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
|
@ -19,41 +20,7 @@ const load = () => import(join(ROOT, 'lib', 'review', 'threat-model.mjs'));
|
|||
const approval = () => import(join(ROOT, 'lib', 'validators', 'intent-approval.mjs'));
|
||||
const log = () => import(join(ROOT, 'lib', 'util', 'approval-log.mjs'));
|
||||
|
||||
const OUTSIDE = "a local user who can already edit the repo's hooks";
|
||||
const INSIDE = 'a crafted file name in a pull request reaches the shell';
|
||||
const TM = `## Threat Model\n\n- Inside: ${INSIDE}\n- Outside: ${OUTSIDE}\n`;
|
||||
|
||||
const brief = (threat = TM, type = 'trekbrief') => `---
|
||||
type: ${type}
|
||||
brief_version: "2.2"
|
||||
created: 2026-11-08
|
||||
task: "Add an upload hook"
|
||||
slug: upload-hook
|
||||
project_dir: .claude/projects/2026-11-08-upload-hook/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
framing: new-direction
|
||||
---
|
||||
|
||||
# Task
|
||||
|
||||
## TL;DR
|
||||
|
||||
An upload hook.
|
||||
|
||||
## Intent
|
||||
|
||||
Uploads arrive from strangers.
|
||||
|
||||
## Goal
|
||||
|
||||
A hook that checks each upload.
|
||||
|
||||
${threat}
|
||||
## Success Criteria
|
||||
|
||||
- It checks.
|
||||
`;
|
||||
const brief = (threat = TM, type = 'trekbrief') => briefWith(threat, type);
|
||||
|
||||
const own = (extra = {}) => ({ id: 'f1', rule_key: 'SECURITY_INJECTION', severity: 'blocker', text: 'shell built from a name', threat_model_ref: OUTSIDE, ...extra });
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { strict as assert } from 'node:assert';
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { TM, briefWith } from '../helpers/threat-model-brief.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
|
@ -19,43 +20,7 @@ const validator = () => import(join(ROOT, 'lib', 'validators', 'brief-validator.
|
|||
const approval = () => import(join(ROOT, 'lib', 'validators', 'intent-approval.mjs'));
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
|
||||
const TM = `## Threat Model
|
||||
|
||||
- Inside: a crafted file name in a pull request reaches the shell
|
||||
- Outside: a local user who can already edit the repo's hooks
|
||||
`;
|
||||
|
||||
const brief = (threat = '') => `---
|
||||
type: trekbrief
|
||||
brief_version: "2.2"
|
||||
created: 2026-11-08
|
||||
task: "Add an upload hook"
|
||||
slug: upload-hook
|
||||
project_dir: .claude/projects/2026-11-08-upload-hook/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
framing: new-direction
|
||||
---
|
||||
|
||||
# Task
|
||||
|
||||
## TL;DR
|
||||
|
||||
An upload hook.
|
||||
|
||||
## Intent
|
||||
|
||||
Uploads arrive from strangers.
|
||||
|
||||
## Goal
|
||||
|
||||
A hook that checks each upload.
|
||||
|
||||
${threat}
|
||||
## Success Criteria
|
||||
|
||||
- It checks.
|
||||
`;
|
||||
const brief = (threat = '') => briefWith(threat);
|
||||
|
||||
const codes = (r) => r.errors.map((e) => e.code);
|
||||
const STAMP_AT = new Date('2026-11-08T08:00:00Z');
|
||||
|
|
|
|||
|
|
@ -13,51 +13,28 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { resolve } from 'node:path';
|
||||
import { gatePlan, recordVerdict, planHash, operativeText, acceptOpen } from '../../lib/validators/plan-gate.mjs';
|
||||
import { gatePlan, planHash, operativeText, acceptOpen } from '../../lib/validators/plan-gate.mjs';
|
||||
import * as diffMod from '../../lib/review/critic-diff.mjs';
|
||||
import { dedupFindings, contentOverlap } from '../../lib/review/plan-review-dedup.mjs';
|
||||
import { contentOverlap } from '../../lib/review/plan-review-dedup.mjs';
|
||||
import { computeFindingId } from '../../lib/parsers/finding-id.mjs';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, readdirSync, appendFileSync, realpathSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import {
|
||||
ROOT, at, baseOf, recorder, dd, R2_CHANGES, R2_EDITED, editA, runGate as gate, GIT_CLEAN, tempRepo,
|
||||
} from '../helpers/plan-gate-harness.mjs';
|
||||
|
||||
const NOW = '2026-10-16T00:00:00.000Z';
|
||||
const PLAN = resolve('plan.md');
|
||||
const PATHS = [PLAN];
|
||||
const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }];
|
||||
|
||||
/** The 1-based full-file line holding `s` (findIndex over the whole text). */
|
||||
function at(text, s) {
|
||||
const n = text.split('\n').findIndex(l => l.includes(s)) + 1;
|
||||
assert.ok(n > 0, `the plan holds ${JSON.stringify(s)}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** The base a round diffs against: the operative text the previous round reviewed. */
|
||||
const baseOf = t => ({ sha256: planHash(t), text: operativeText(t) });
|
||||
|
||||
/** One round as Phase 9 runs it, with the plan path known to the gate. */
|
||||
function rec(text, d, opts = {}) {
|
||||
const r = recordVerdict(text, d, { reviewed: planHash(text), planPaths: PATHS, now: NOW, ...opts });
|
||||
assert.equal(r.code, 0, r.message);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Reviewer payloads through the real dedup. */
|
||||
function dd({ pc = [], sg = [], pcPrev, sgPrev } = {}) {
|
||||
return dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: pc, ...(pcPrev ? { previous: pcPrev } : {}) } },
|
||||
{ agent: 'scope-guardian', payload: { findings: sg, ...(sgPrev ? { previous: sgPrev } : {}) } },
|
||||
]);
|
||||
}
|
||||
const rec = recorder(NOW);
|
||||
|
||||
const f = (severity, line, text, extra = {}) => ({ file: 'plan.md', line, rule_key: 'step-quality', severity, text, ...extra });
|
||||
const byText = (v, s) => {
|
||||
|
|
@ -69,15 +46,8 @@ const byId = (v, id) => v.findings.filter(x => x.id === id);
|
|||
|
||||
// ---- the edits ---------------------------------------------------------------
|
||||
|
||||
const R2_CHANGES = '- **Changes:** Add `run2()` to `src/mod2.mjs`. (new file)';
|
||||
const R2_EDITED = '- **Changes:** Add `run2()` and its error path to `src/mod2.mjs`. (new file)\n - `run2()` throws on empty input.';
|
||||
const WHY = 'Why: the brief asks for a parser CLI.';
|
||||
|
||||
/** Edit A: Step 2's Changes line becomes two lines (a two-line hunk, nothing above it moves). */
|
||||
function editA(t) {
|
||||
assert.ok(t.includes(R2_CHANGES));
|
||||
return t.replace(R2_CHANGES, R2_EDITED);
|
||||
}
|
||||
/** Edit B: the Context line is reworded (a one-line hunk high in the plan, nothing moves). */
|
||||
function editB(t) {
|
||||
assert.ok(t.includes(WHY));
|
||||
|
|
@ -702,26 +672,10 @@ test('acceptOpen after round 3 lists only the real majors, never a lowered one',
|
|||
// .git. Expected hashes come from the CLI's own --hash (taken before the
|
||||
// round), expected line numbers from findIndex over the plan text.
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
|
||||
const GIT_CLEAN = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined };
|
||||
|
||||
function gate(cwd, args, input) {
|
||||
const r = spawnSync(process.execPath, [GATE, ...args], { cwd, input, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||
|
||||
/** A fresh temp dir (real path), `git init`-ed when `repo` is true. */
|
||||
function tempDir(prefix, repo) {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
||||
if (repo) {
|
||||
const g = git(dir, ['init', '-q']);
|
||||
assert.equal(g.status, 0, g.stderr);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
const tempDir = (prefix, repo) => (repo ? tempRepo(prefix) : realpathSync(mkdtempSync(join(tmpdir(), prefix))));
|
||||
|
||||
/** The plan in its own temp git repo; the gate runs with that repo as cwd. */
|
||||
function withRepo(text, fn) {
|
||||
|
|
|
|||
|
|
@ -23,45 +23,18 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, realpathSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
gatePlan, recordVerdict, planHash, operativeText, acceptOpen, extractVerdict, REVIEW_LOG_SECTIONS,
|
||||
gatePlan, recordVerdict, planHash, acceptOpen, extractVerdict, REVIEW_LOG_SECTIONS,
|
||||
} from '../../lib/validators/plan-gate.mjs';
|
||||
import { dedupFindings } from '../../lib/review/plan-review-dedup.mjs';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import {
|
||||
PATHS, at, baseOf, recorder, dd, keyedFinding as f, R1_CHANGES, editA, runGate as run, lastJson, tempRepo,
|
||||
} from '../helpers/plan-gate-harness.mjs';
|
||||
|
||||
const NOW = '2026-11-08T00:00:00.000Z';
|
||||
const PLAN = resolve('plan.md');
|
||||
const PATHS = [PLAN];
|
||||
|
||||
function at(text, s) {
|
||||
const n = text.split('\n').findIndex(l => l.includes(s)) + 1;
|
||||
assert.ok(n > 0, `the plan holds ${JSON.stringify(s)}`);
|
||||
return n;
|
||||
}
|
||||
const baseOf = t => ({ sha256: planHash(t), text: operativeText(t) });
|
||||
function rec(text, d, opts = {}) {
|
||||
const r = recordVerdict(text, d, { reviewed: planHash(text), planPaths: PATHS, now: NOW, ...opts });
|
||||
assert.equal(r.code, 0, r.message);
|
||||
return r;
|
||||
}
|
||||
function dd({ pc = [], sg = [], pcPrev, sgPrev } = {}) {
|
||||
return dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: pc, ...(pcPrev ? { previous: pcPrev } : {}) } },
|
||||
{ agent: 'scope-guardian', payload: { findings: sg, ...(sgPrev ? { previous: sgPrev } : {}) } },
|
||||
]);
|
||||
}
|
||||
const f = (severity, line, text, rule_key, extra = {}) => ({ file: 'plan.md', line, rule_key, severity, text, ...extra });
|
||||
|
||||
const R2_CHANGES = '- **Changes:** Add `run2()` to `src/mod2.mjs`. (new file)';
|
||||
const R2_EDITED = '- **Changes:** Add `run2()` and its error path to `src/mod2.mjs`. (new file)\n - `run2()` throws on empty input.';
|
||||
const R1_CHANGES = 'Add `run1()`';
|
||||
const editA = t => { assert.ok(t.includes(R2_CHANGES)); return t.replace(R2_CHANGES, R2_EDITED); };
|
||||
const rec = recorder(NOW);
|
||||
|
||||
const B1 = 'The dependency on the parser module is never declared in the manifest list';
|
||||
const B2 = 'A second import chain reaches the CLI without passing through its argument checker';
|
||||
|
|
@ -327,19 +300,9 @@ test('the "Adversarial Pass 2 (v5.1.1 high-effort)" heading is no longer review
|
|||
|
||||
// ---- CLI ------------------------------------------------------------------------------
|
||||
|
||||
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
|
||||
const GIT_CLEAN = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined };
|
||||
const run = (cwd, args, input) => {
|
||||
const r = spawnSync(process.execPath, [GATE, ...args], { cwd, input, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
};
|
||||
const lastJson = out => JSON.parse(out.split('\n').filter(l => l !== '').at(-1));
|
||||
|
||||
test('CLI: two rounds with a new dep blocker each -> the gate stops with PLAN_GATE_STOP_RULE and a third --record is refused', () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'plan-gate-stop-')));
|
||||
const dir = tempRepo('plan-gate-stop-');
|
||||
try {
|
||||
assert.equal(spawnSync('git', ['init', '-q'], { cwd: dir, env: childEnv(GIT_CLEAN) }).status, 0);
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, buildPlan());
|
||||
const round = (payload, extra = []) => run(dir, ['--record', p, '--reviewed', planHash(readFileSync(p, 'utf-8')), ...extra], JSON.stringify(dd(payload)));
|
||||
|
|
|
|||
|
|
@ -21,22 +21,20 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gatePlan, recordVerdict, planHash, operativeText, extractVerdict } from '../../lib/validators/plan-gate.mjs';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { gatePlan, recordVerdict, planHash, extractVerdict } from '../../lib/validators/plan-gate.mjs';
|
||||
import { dedupFindings } from '../../lib/review/plan-review-dedup.mjs';
|
||||
import { readThreatModel } from '../../lib/review/threat-model.mjs';
|
||||
import { stampIntentApproval } from '../../lib/validators/intent-approval.mjs';
|
||||
import { appendApproval, normalizeHex, normalizeSubject, resolveRepo } from '../../lib/util/approval-log.mjs';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import {
|
||||
ROOT, PATHS, at, baseOf, recorder, dd, keyedFinding as f, R1_CHANGES, editA, runGate as run, lastJson, tempRepo,
|
||||
} from '../helpers/plan-gate-harness.mjs';
|
||||
|
||||
const NOW = '2026-11-08T00:00:00.000Z';
|
||||
const PLAN = resolve('plan.md');
|
||||
const PATHS = [PLAN];
|
||||
const rec = recorder(NOW);
|
||||
|
||||
const OUTSIDE = "a local user who can already edit the repo's hooks";
|
||||
const INSIDE = 'a crafted file name in a pull request reaches the shell';
|
||||
|
|
@ -78,30 +76,6 @@ A parser CLI that checks its input.
|
|||
const TM = { usable: true, why: null, threatModel: readThreatModel(BRIEF) };
|
||||
const NOT_USABLE = { usable: false, why: 'the intent approval is not current (BRIEF_INTENT_APPROVAL_STALE)', threatModel: readThreatModel(BRIEF) };
|
||||
|
||||
function at(text, s) {
|
||||
const n = text.split('\n').findIndex(l => l.includes(s)) + 1;
|
||||
assert.ok(n > 0, `the plan holds ${JSON.stringify(s)}`);
|
||||
return n;
|
||||
}
|
||||
const baseOf = t => ({ sha256: planHash(t), text: operativeText(t) });
|
||||
function rec(text, d, opts = {}) {
|
||||
const r = recordVerdict(text, d, { reviewed: planHash(text), planPaths: PATHS, now: NOW, ...opts });
|
||||
assert.equal(r.code, 0, r.message);
|
||||
return r;
|
||||
}
|
||||
function dd({ pc = [], sg = [], pcPrev, sgPrev } = {}) {
|
||||
return dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: pc, ...(pcPrev ? { previous: pcPrev } : {}) } },
|
||||
{ agent: 'scope-guardian', payload: { findings: sg, ...(sgPrev ? { previous: sgPrev } : {}) } },
|
||||
]);
|
||||
}
|
||||
const f = (severity, line, text, rule_key, extra = {}) => ({ file: 'plan.md', line, rule_key, severity, text, ...extra });
|
||||
|
||||
const R1_CHANGES = 'Add `run1()`';
|
||||
const R2_CHANGES = '- **Changes:** Add `run2()` to `src/mod2.mjs`. (new file)';
|
||||
const R2_EDITED = '- **Changes:** Add `run2()` and its error path to `src/mod2.mjs`. (new file)\n - `run2()` throws on empty input.';
|
||||
const editA = t => { assert.ok(t.includes(R2_CHANGES)); return t.replace(R2_CHANGES, R2_EDITED); };
|
||||
|
||||
const S1 = 'A hook file edited by a local user runs before the parser checks its input';
|
||||
const B1 = 'The dependency on the parser module is never declared in the manifest list';
|
||||
|
||||
|
|
@ -267,19 +241,9 @@ test('gate: without --brief the brief is read from the verdict\'s brief_path; no
|
|||
|
||||
// ---- CLI ------------------------------------------------------------------------------
|
||||
|
||||
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
|
||||
const GIT_CLEAN = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined };
|
||||
const run = (cwd, args, input) => {
|
||||
const r = spawnSync(process.execPath, [GATE, ...args], { cwd, input, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
};
|
||||
const lastJson = out => JSON.parse(out.split('\n').filter(l => l !== '').at(-1));
|
||||
|
||||
test('CLI: --record --brief lists a backed finding as a known issue; the gate warns, and STOPs once the Outside line changes', () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'plan-gate-tm-')));
|
||||
const dir = tempRepo('plan-gate-tm-');
|
||||
try {
|
||||
assert.equal(spawnSync('git', ['init', '-q'], { cwd: dir, env: childEnv(GIT_CLEAN) }).status, 0);
|
||||
const b = join(dir, 'brief.md');
|
||||
const s = stampIntentApproval(BRIEF, new Date(NOW));
|
||||
assert.equal(s.stamped, true);
|
||||
|
|
@ -315,9 +279,8 @@ test('CLI: --record --brief lists a backed finding as a known issue; the gate wa
|
|||
});
|
||||
|
||||
test('fix 6: brief_path is stored relative to the repo top level, and the gate run from a subdirectory reads the same brief', () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'plan-gate-tm-')));
|
||||
const dir = tempRepo('plan-gate-tm-');
|
||||
try {
|
||||
assert.equal(spawnSync('git', ['init', '-q'], { cwd: dir, env: childEnv(GIT_CLEAN) }).status, 0);
|
||||
const b = join(dir, 'brief.md');
|
||||
const s = stampIntentApproval(BRIEF, new Date(NOW));
|
||||
writeFileSync(b, s.text);
|
||||
|
|
@ -344,9 +307,8 @@ test('fix 6: brief_path is stored relative to the repo top level, and the gate r
|
|||
});
|
||||
|
||||
test('CLI: --brief without a value is a usage error; a threat model that is not usable is said in the output', () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'plan-gate-tm-')));
|
||||
const dir = tempRepo('plan-gate-tm-');
|
||||
try {
|
||||
assert.equal(spawnSync('git', ['init', '-q'], { cwd: dir, env: childEnv(GIT_CLEAN) }).status, 0);
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, buildPlan());
|
||||
const t1 = readFileSync(p, 'utf-8');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue