refactor(simplify): one source per rule, shared test helpers, a faster isolation test [skip-docs]
/simplify over a97efb6..HEAD (the plan_version 1.9 / trial-4 delivery). - coordinator-contract: engine names come from engine-findings ENGINES; one markedMajorCodeReview predicate under both lowering checks; isEngineFinding everywhere; the unreachable branch (c) of illegalBriefLowering folded into (e). - armB workflow: RULE_KEYS = Object.keys(RULE_TIERS); the engine map is a literal pinned equal to ENGINES by the workflow test. - plan-schema: usesFilesGrammar() is the one 1.9 check (validator, sweep, testFirstFiles, stepTestsOnlyFromFields); opensEntry built on opensChunk. - plan-validator: one issue builder for the Files and Test first grammar. - plan-scope-sweep: the 1.9 and the 6.2.0 reader are one if/else. - artifact-commit: TRAILER_RE/trailerProblem no longer exported (no importer); the order comment says the CLI finds the repo root first. - tests: the lock drivers in session-id-isolation (d) run side by side per mode (~100 s -> ~30 s); a shared artifact-repo helper; severityRank and extractFindingsBlock reused; childEnv(env) without a spread; one copy of the PLAN_VERSION_CURRENT check. Plan readers: validator errors/warnings, plannedPaths, fieldPaths, testFirstFiles and stepTestsOnlyFromFields compared byte for byte against HEAD over 510 plans / 1692 steps plus 210 synthetic 1.7-1.9 cases: same. Suite 2384 (2382/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
acfd2eb9b6
commit
ed6699da58
23 changed files with 201 additions and 204 deletions
|
|
@ -155,6 +155,9 @@ export function versionAtLeast(v, min) {
|
|||
return true;
|
||||
}
|
||||
|
||||
/** True when plan_version `v` reads `Files:` and the `Test first:` `File:` line by the 1.9 grammar. */
|
||||
export const usesFilesGrammar = (v) => versionAtLeast(v, '1.9');
|
||||
|
||||
// ---- ceremony (the brief's size class) -------------------------------------
|
||||
|
||||
export const CEREMONY_VALUES = Object.freeze(['small', 'bounded', 'architectural']);
|
||||
|
|
@ -383,10 +386,6 @@ const isFileWord = (w) => BUILD_FILE.test(w) || (isPathWord(w) && (FILE_OR_DIR.t
|
|||
// plain file with a slash, or a plain file alone on the line or followed by a
|
||||
// colon or a dash ("src/cli.mjs", "package.json", "Makefile — a target"), not
|
||||
// "child_process.spawnSync, path.join" or "Node.js 22 only" (round 10).
|
||||
const opensEntry = (tokens) => {
|
||||
const w = plainWord(tokens[0]);
|
||||
return isFileWord(w) && (w.includes('/') || tokens.length === 1 || tokens[0].endsWith(':') || DESCRIPTION_DASH.test(tokens[1]));
|
||||
};
|
||||
// A comma entry inside a description that opens a new entry: opensEntry's test
|
||||
// without "alone on the line", so "path.join" after a comma stays description
|
||||
// (6.4 / regression A).
|
||||
|
|
@ -394,6 +393,7 @@ const opensChunk = (tokens) => {
|
|||
const w = plainWord(tokens[0]);
|
||||
return isFileWord(w) && (w.includes('/') || tokens[0].endsWith(':') || DESCRIPTION_DASH.test(tokens[1]));
|
||||
};
|
||||
const opensEntry = (tokens) => opensChunk(tokens) || (tokens.length === 1 && isFileWord(plainWord(tokens[0])));
|
||||
|
||||
export function fieldPaths(text) {
|
||||
const spans = [];
|
||||
|
|
@ -420,11 +420,11 @@ export function fieldPaths(text) {
|
|||
if (entry === '') continue;
|
||||
const tokens = entry.split(/\s+/);
|
||||
const first = /^\u0000(\d+)\u0000/.exec(entry);
|
||||
if (first && isTicked[Number(first[1])]) described = false;
|
||||
if (described && opensChunk(tokens)) described = false;
|
||||
// An entry opened by a backticked path: after a word of description a
|
||||
// plain path with a slash counts only where a list starts (6.4 / regression B).
|
||||
const tickedEntry = Boolean(first && isTicked[Number(first[1])]);
|
||||
if (tickedEntry) described = false;
|
||||
if (described && opensChunk(tokens)) described = false;
|
||||
let prose = false;
|
||||
// A backticked capitalised name alone in its entry, before any
|
||||
// description, is a file (rounds 6, 10).
|
||||
|
|
@ -448,8 +448,9 @@ export function fieldPaths(text) {
|
|||
}
|
||||
if (DESCRIPTION_DASH.test(t)) described = true;
|
||||
const w = plainWord(t);
|
||||
if (!isFileWord(w)) prose = true;
|
||||
if (described || !isFileWord(w)) continue;
|
||||
const file = isFileWord(w);
|
||||
if (!file) prose = true;
|
||||
if (described || !file) continue;
|
||||
if ((w.includes('/') && !(tickedEntry && prose)) || i === 0 || LIST_JOINER.test(tokens[i - 1])) out.push(w);
|
||||
if (t.endsWith(':')) described = true;
|
||||
}
|
||||
|
|
@ -622,7 +623,7 @@ export function testFirstFileEntries(fields) {
|
|||
export function testFirstFiles(fields, version) {
|
||||
if (!fields || !fields.has('Test first')) return [];
|
||||
const out = [];
|
||||
if (versionAtLeast(version, '1.9')) {
|
||||
if (usesFilesGrammar(version)) {
|
||||
for (const entry of testFirstFileEntries(fields)) {
|
||||
for (const p of entryPaths(entry).files) if (!out.includes(p)) out.push(p);
|
||||
}
|
||||
|
|
@ -655,7 +656,7 @@ function stepTestFile(f, version) {
|
|||
/** `stepTestsOnly` for a caller that already holds the step's parsed fields. */
|
||||
export function stepTestsOnlyFromFields(f, version) {
|
||||
const text = f.has('Files') ? f.get('Files').text : null;
|
||||
const files = text === null ? [] : versionAtLeast(version, '1.9') ? entryPaths(text).files : fieldPaths(text);
|
||||
const files = text === null ? [] : usesFilesGrammar(version) ? entryPaths(text).files : fieldPaths(text);
|
||||
return { testsOnly: files.length > 0 && files.every(stepTestFile(f, version)), files };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
import { SEVERITY_VALUES, RULE_KEYS, getRule } from './rule-catalogue.mjs';
|
||||
import { computeFindingId } from '../parsers/finding-id.mjs';
|
||||
import { validateFindings } from './findings-schema.mjs';
|
||||
import { ENGINES } from './engine-findings.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const JUDGE_TITLE_MAX = 100;
|
||||
|
|
@ -96,16 +97,12 @@ export const UNVERIFIED_REASONS = Object.freeze([
|
|||
// The verdict gate (verdict-gate.mjs) imports `illegalBriefLowering`; the
|
||||
// opt-in workflow review mirrors it. `original_severity` is never read here.
|
||||
|
||||
const ENGINE_NAMES = Object.freeze(['code-review', 'security-review']);
|
||||
|
||||
// The payload `reviewer` names lib/review/engine-findings.mjs writes, and the
|
||||
// engine each one stands for.
|
||||
const ENGINE_OF_PAYLOAD = Object.freeze({
|
||||
'claude-code-review': 'code-review',
|
||||
'claude-security-review': 'security-review',
|
||||
});
|
||||
const ENGINE_OF_PAYLOAD = Object.freeze(Object.fromEntries(
|
||||
Object.entries(ENGINES).map(([engine, spec]) => [spec.reviewer, engine])));
|
||||
|
||||
const isEngineFinding = (f) => ENGINE_NAMES.includes(f.engine);
|
||||
const isEngineFinding = (f) => Object.hasOwn(ENGINES, f.engine ?? '');
|
||||
|
||||
function carries(f, field) {
|
||||
return f?.[field] !== undefined && f?.[field] !== null;
|
||||
|
|
@ -124,16 +121,17 @@ function isMark(v) {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export function lowerableBriefConflict(f) {
|
||||
return markedMajorCodeReview(f) && f.severity === 'MAJOR';
|
||||
}
|
||||
|
||||
function markedMajorCodeReview(f) {
|
||||
return isMark(f?.contradicts_brief) &&
|
||||
f.engine === 'code-review' &&
|
||||
getRule(f.rule_key)?.severity === 'MAJOR' &&
|
||||
f.severity === 'MAJOR';
|
||||
getRule(f.rule_key)?.severity === 'MAJOR';
|
||||
}
|
||||
|
||||
function isLegalBriefLowering(f) {
|
||||
return isMark(f?.contradicts_brief) &&
|
||||
f.engine === 'code-review' &&
|
||||
getRule(f.rule_key)?.severity === 'MAJOR' &&
|
||||
return markedMajorCodeReview(f) &&
|
||||
f.severity === 'MINOR' &&
|
||||
f.brief_lowered_from === 'MAJOR';
|
||||
}
|
||||
|
|
@ -152,14 +150,13 @@ export function illegalBriefLowering(f) {
|
|||
if (f === null || typeof f !== 'object') return false;
|
||||
if (isLegalBriefLowering(f)) return false;
|
||||
const marked = carries(f, 'contradicts_brief');
|
||||
const engine = ENGINE_NAMES.includes(f.engine);
|
||||
const engine = isEngineFinding(f);
|
||||
const rule = getRule(f.rule_key);
|
||||
const belowTier = rule !== null && severityRank(f.severity) > severityRank(rule.severity);
|
||||
if (carries(f, 'brief_lowered_from')) return true; // (a)
|
||||
if (marked && !engine) return true; // (b)
|
||||
if (marked && belowTier) return true; // (c)
|
||||
if (marked && rule === null) return true; // (d)
|
||||
if (engine && belowTier) return true; // (e)
|
||||
if (engine && belowTier) return true; // (c) and (e): a marked finding is an engine one past (b)
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +406,7 @@ export function reasonablenessFilter(findings) {
|
|||
// `illegalBriefLowering` false.
|
||||
const { brief_lowered_from: _lowered, ...rest } = incoming;
|
||||
let f = rest;
|
||||
if (!ENGINE_NAMES.includes(f.engine) && 'contradicts_brief' in f) {
|
||||
if (!isEngineFinding(f) && 'contradicts_brief' in f) {
|
||||
const { contradicts_brief: _mark, ...unmarked } = f;
|
||||
f = unmarked;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@
|
|||
// trailer, a multi-line one, one that does not match (an unfilled `{model}`
|
||||
// placeholder, no `<email>`, another key) or one holding `Claude-Session:` is
|
||||
// refused with exit 1. Order: the `--store` check first (exit 2), then the
|
||||
// trailer (exit 1), both before any git call, so "nothing to commit" cannot
|
||||
// mask a bad trailer.
|
||||
// trailer (exit 1), both before commitArtifacts makes any git call (the CLI
|
||||
// only finds the repo root first), so "nothing to commit" cannot mask a bad
|
||||
// trailer.
|
||||
//
|
||||
// CLI:
|
||||
// node lib/util/artifact-commit.mjs --store <git|local> [--artifact <path>]...
|
||||
|
|
@ -37,10 +38,10 @@ import { isMain } from './is-main.mjs';
|
|||
export const ARTIFACT_STORES = Object.freeze(['git', 'local']);
|
||||
const DEFAULT_MESSAGE = 'docs(voyage): record approval';
|
||||
// One `Co-Authored-By:` line; `{`/`}` are refused so an unfilled `{model}` fails.
|
||||
export const TRAILER_RE = /^Co-Authored-By: [^<>{}\n]+ <[^\s<>@]+@[^\s<>]+>$/;
|
||||
const TRAILER_RE = /^Co-Authored-By: [^<>{}\n]+ <[^\s<>@]+@[^\s<>]+>$/;
|
||||
|
||||
/** @returns {string|null} why `trailer` is refused, or null when it is valid */
|
||||
export function trailerProblem(trailer) {
|
||||
function trailerProblem(trailer) {
|
||||
if (typeof trailer !== 'string' || trailer === '') {
|
||||
return '--trailer "Co-Authored-By: <name> <<email>>" is required — nothing committed';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|||
import {
|
||||
sliceSteps, validatePlanHeadings, extractPlanVersion, extractPlanCeremony,
|
||||
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
|
||||
versionAtLeast, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
|
||||
versionAtLeast, usesFilesGrammar, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
|
||||
ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnlyFromFields, entryPaths, untickedPaths,
|
||||
testFirstFileEntries, testFirstFiles,
|
||||
} from '../parsers/plan-schema.mjs';
|
||||
|
|
@ -77,7 +77,7 @@ const BACKTICKED = /`[^`\n]+`/;
|
|||
function checkSteps(sections, planVersion) {
|
||||
const out = [];
|
||||
let endToEnd = 0;
|
||||
const grammar = versionAtLeast(planVersion, '1.9');
|
||||
const grammar = usesFilesGrammar(planVersion);
|
||||
for (const s of sections) {
|
||||
const f = parseStepFields(s.body);
|
||||
const tf = f.get('Test first');
|
||||
|
|
@ -217,42 +217,38 @@ function checkRecovery(sections, opts, planVersion) {
|
|||
* names no file. Each `Test first:` `File:` line (plan-schema
|
||||
* `testFirstFileEntries`) is one entry under the same grammar.
|
||||
*/
|
||||
// How checkPathGrammar words an issue in each of the two places the grammar applies.
|
||||
const FILES_WORDING = {
|
||||
subject: 'a `Files:` entry', none: '`Files:` names no file', spans: 'an entry are its files',
|
||||
opens: 'each `Files:` entry opens with its backticked path(s)',
|
||||
example: '`` `src/a.mjs` - the parser ``', tail: ' A step that changes no file names none.',
|
||||
};
|
||||
const TEST_FIRST_WORDING = {
|
||||
subject: 'a `Test first:` `File:` line', none: 'a `Test first:` `File:` line names no file',
|
||||
spans: 'the `File:` line are the test files', opens: 'the `File:` line opens with its backticked test file(s)',
|
||||
example: '`` File: `tests/a.test.mjs` (new) ``', tail: '',
|
||||
};
|
||||
|
||||
function pathGrammarIssue(n, code, shown, w) {
|
||||
if (code === 'PLAN_STEP_PATH_NOT_A_PATH') {
|
||||
return issue(code, `Step ${n}: ${w.subject} opens with a backticked span that is no path: ${shown}`,
|
||||
`plan_version 1.9: the leading backticked spans of ${w.spans}; a call (\`parse()\`), an option ` +
|
||||
'or a URL is description and goes after the path.');
|
||||
}
|
||||
return issue('PLAN_STEP_PATH_NOT_FIRST',
|
||||
shown === '' ? `Step ${n}: ${w.none}` : `Step ${n}: ${w.subject} does not open with a backticked path: ${shown}`,
|
||||
`plan_version 1.9: ${w.opens}, separated by commas or spaces; the description follows (${w.example}).${w.tail}`);
|
||||
}
|
||||
|
||||
function checkPathGrammar(sections) {
|
||||
const out = [];
|
||||
for (const s of sections) {
|
||||
const f = parseStepFields(s.body);
|
||||
const { issues } = entryPaths(f.has('Files') ? f.get('Files').text : '');
|
||||
for (const i of issues) {
|
||||
if (i.code === 'PLAN_STEP_PATH_NOT_A_PATH') {
|
||||
out.push(issue('PLAN_STEP_PATH_NOT_A_PATH',
|
||||
`Step ${s.n}: a \`Files:\` entry opens with a backticked span that is no path: ${i.entry}`,
|
||||
'plan_version 1.9: the leading backticked spans of an entry are its files; a call (`parse()`), an option ' +
|
||||
'or a URL is description and goes after the path.'));
|
||||
} else {
|
||||
out.push(issue('PLAN_STEP_PATH_NOT_FIRST',
|
||||
i.entry === ''
|
||||
? `Step ${s.n}: \`Files:\` names no file`
|
||||
: `Step ${s.n}: a \`Files:\` entry does not open with a backticked path: ${i.entry}`,
|
||||
'plan_version 1.9: each `Files:` entry opens with its backticked path(s), separated by commas or spaces; ' +
|
||||
'the description follows (`` `src/a.mjs` - the parser ``). A step that changes no file names none.'));
|
||||
}
|
||||
for (const i of entryPaths(f.has('Files') ? f.get('Files').text : '').issues) {
|
||||
out.push(pathGrammarIssue(s.n, i.code, i.entry, FILES_WORDING));
|
||||
}
|
||||
for (const entry of testFirstFileEntries(f)) {
|
||||
for (const i of entryPaths(entry).issues) {
|
||||
if (i.code === 'PLAN_STEP_PATH_NOT_A_PATH') {
|
||||
out.push(issue('PLAN_STEP_PATH_NOT_A_PATH',
|
||||
`Step ${s.n}: a \`Test first:\` \`File:\` line opens with a backticked span that is no path: ${entry}`,
|
||||
'plan_version 1.9: the leading backticked spans of the `File:` line are the test files; a call ' +
|
||||
'(`parse()`), an option or a URL is description and goes after the path.'));
|
||||
} else {
|
||||
out.push(issue('PLAN_STEP_PATH_NOT_FIRST',
|
||||
entry === ''
|
||||
? `Step ${s.n}: a \`Test first:\` \`File:\` line names no file`
|
||||
: `Step ${s.n}: a \`Test first:\` \`File:\` line does not open with a backticked path: ${entry}`,
|
||||
'plan_version 1.9: the `File:` line opens with its backticked test file(s), separated by commas or ' +
|
||||
'spaces; the description follows (`` File: `tests/a.test.mjs` (new) ``).'));
|
||||
}
|
||||
}
|
||||
for (const i of entryPaths(entry).issues) out.push(pathGrammarIssue(s.n, i.code, entry, TEST_FIRST_WORDING));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
|
@ -369,7 +365,7 @@ export function validatePlanContent(rawText, opts = {}) {
|
|||
warnings.push(...recovery.warnings);
|
||||
|
||||
// The `Files:` grammar (decision 16): refused from 1.9, a warning below it.
|
||||
if (versionAtLeast(planVersion, '1.9')) {
|
||||
if (usesFilesGrammar(planVersion)) {
|
||||
const grammar = checkPathGrammar(sections);
|
||||
if (strict) errors.push(...grammar);
|
||||
else warnings.push(...grammar);
|
||||
|
|
@ -397,7 +393,7 @@ export function validatePlanContent(rawText, opts = {}) {
|
|||
if (stepsFrom !== null) {
|
||||
const appended = sections.filter((s) => Number(s.n) >= stepsFrom);
|
||||
const stepIssues = versionAtLeast(planVersion, '1.8') ? [] : [
|
||||
...checkSteps(appended, planVersion).filter((i) => i.code !== 'PLAN_NO_END_TO_END'),
|
||||
...checkSteps(appended).filter((i) => i.code !== 'PLAN_NO_END_TO_END'),
|
||||
...checkRunnable(text, appended, { cwd: opts.cwd, stepsOnly: true }),
|
||||
];
|
||||
for (const s of appended) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve, relative, dirname, isAbsolute, sep } from 'node:path';
|
||||
import { sliceSteps, parseStepFields, entryPaths, testFirstFiles, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
|
||||
import { sliceSteps, parseStepFields, entryPaths, testFirstFiles, extractPlanVersion, usesFilesGrammar } from '../parsers/plan-schema.mjs';
|
||||
import { parseManifest } from '../parsers/manifest-yaml.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ const norm = p => p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|||
export function plannedPaths(planText) {
|
||||
const out = new Set();
|
||||
const version = extractPlanVersion(planText);
|
||||
const grammar = versionAtLeast(version, '1.9');
|
||||
const grammar = usesFilesGrammar(version);
|
||||
for (const s of sliceSteps(planText)) {
|
||||
const f = parseStepFields(s.body);
|
||||
const files = f.get('Files');
|
||||
|
|
@ -74,25 +74,26 @@ export function plannedPaths(planText) {
|
|||
// plan_version 1.9: the grammar decides what each entry names.
|
||||
if (files) for (const p of entryPaths(files.text).files) out.add(norm(p));
|
||||
for (const p of testFirstFiles(f, version)) out.add(norm(p));
|
||||
}
|
||||
// v6.2.0's own reading, kept as it was (operator 2026-10-11): every
|
||||
// backticked span that looks like a path, and plain words only when the
|
||||
// field has none. A field that names its paths in backticks plans those,
|
||||
// never a path its description mentions. Release review of 6.3.0, rounds
|
||||
// 3-5: reading Files: through the tests-only check's reader
|
||||
// (plan-schema.mjs fieldPaths) dropped paths v6.2.0 planned and planned
|
||||
// directories a description named, so K6 has its own reader again; the
|
||||
// two answer different questions (what may change; is every named file a test).
|
||||
if (files && !grammar) {
|
||||
const ticked = pathsIn(files.text);
|
||||
// Older plans write bare paths: `- Files: a.ts, b.ts`.
|
||||
const bare = ticked.length ? [] : files.text.split(/[,\s]+/).filter(looksLikePath);
|
||||
for (const p of [...ticked, ...bare]) out.add(norm(p));
|
||||
}
|
||||
const tf = f.get('Test first');
|
||||
if (tf && !grammar) {
|
||||
for (const line of tf.text.split('\n')) {
|
||||
if (/\bFile:/i.test(line)) for (const p of pathsIn(line)) out.add(norm(p));
|
||||
} else {
|
||||
// v6.2.0's own reading, kept as it was (operator 2026-10-11): every
|
||||
// backticked span that looks like a path, and plain words only when the
|
||||
// field has none. A field that names its paths in backticks plans those,
|
||||
// never a path its description mentions. Release review of 6.3.0, rounds
|
||||
// 3-5: reading Files: through the tests-only check's reader
|
||||
// (plan-schema.mjs fieldPaths) dropped paths v6.2.0 planned and planned
|
||||
// directories a description named, so K6 has its own reader again; the
|
||||
// two answer different questions (what may change; is every named file a test).
|
||||
if (files) {
|
||||
const ticked = pathsIn(files.text);
|
||||
// Older plans write bare paths: `- Files: a.ts, b.ts`.
|
||||
const bare = ticked.length ? [] : files.text.split(/[,\s]+/).filter(looksLikePath);
|
||||
for (const p of [...ticked, ...bare]) out.add(norm(p));
|
||||
}
|
||||
const tf = f.get('Test first');
|
||||
if (tf) {
|
||||
for (const line of tf.text.split('\n')) {
|
||||
if (/\bFile:/i.test(line)) for (const p of pathsIn(line)) out.add(norm(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
const man = parseManifest(s.body);
|
||||
|
|
|
|||
|
|
@ -36,12 +36,6 @@ export const meta = {
|
|||
],
|
||||
};
|
||||
|
||||
const RULE_KEYS = [
|
||||
'MISSING_BRIEF_REF', 'UNIMPLEMENTED_CRITERION', 'SCOPE_CREEP_BUILT', 'NON_GOAL_VIOLATED',
|
||||
'MISSING_TEST', 'SECURITY_INJECTION', 'PLACEHOLDER_IN_CODE', 'MISSING_ERROR_HANDLING',
|
||||
'UNDECLARED_DEPENDENCY', 'PLAN_EXECUTE_DRIFT', 'BROKEN_SUCCESS_CRITERION', 'COVERAGE_SILENT_SKIP',
|
||||
'CODE_REVIEW_DEFECT', 'CODE_REVIEW_IMPROVEMENT', 'SECURITY_REVIEW_VULNERABILITY',
|
||||
];
|
||||
const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
|
||||
// Every catalogue rule_key -> its tier (lib/review/rule-catalogue.mjs; scripts
|
||||
// cannot import, so it is inlined and pinned equal by the workflow test).
|
||||
|
|
@ -52,6 +46,7 @@ const RULE_TIERS = {
|
|||
PLAN_EXECUTE_DRIFT: 'MAJOR', BROKEN_SUCCESS_CRITERION: 'BLOCKER', COVERAGE_SILENT_SKIP: 'MAJOR',
|
||||
CODE_REVIEW_DEFECT: 'MAJOR', CODE_REVIEW_IMPROVEMENT: 'MINOR', SECURITY_REVIEW_VULNERABILITY: 'BLOCKER',
|
||||
};
|
||||
const RULE_KEYS = Object.keys(RULE_TIERS);
|
||||
// Lower number = higher severity; an unknown severity ranks last (as
|
||||
// lib/review/coordinator-contract.mjs severityRank does).
|
||||
const sevRank = (s) => (SEVERITIES.includes(s) ? SEVERITIES.indexOf(s) : SEVERITIES.length);
|
||||
|
|
@ -211,8 +206,11 @@ const missingReviewers = (rawResults.length - reviewerResults.length) + missingE
|
|||
// carries; an engine payload's findings get `engine` from its `reviewer`
|
||||
// (the names lib/review/engine-findings.mjs writes); any other payload gives
|
||||
// its findings no `engine`. So an `engine` in `merged` means an engine payload.
|
||||
const engineOf = (reviewer) => (reviewer === 'claude-code-review' ? 'code-review'
|
||||
: reviewer === 'claude-security-review' ? 'security-review' : null);
|
||||
// Inlined (scripts cannot import) and pinned equal to ENGINES by the workflow test.
|
||||
const ENGINE_OF_PAYLOAD = {
|
||||
'claude-code-review': 'code-review', 'claude-security-review': 'security-review',
|
||||
};
|
||||
const engineOf = (reviewer) => (Object.prototype.hasOwnProperty.call(ENGINE_OF_PAYLOAD, reviewer) ? ENGINE_OF_PAYLOAD[reviewer] : null);
|
||||
const withEngine = (f, eng) => {
|
||||
if (!f || typeof f !== 'object') return f;
|
||||
const { engine: _dropped, ...rest } = f;
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ function makeRepo(opts = {}) {
|
|||
}
|
||||
|
||||
function runBlock(script, { cwd, env }) {
|
||||
return spawnSync('bash', ['-c', script], { cwd, env: childEnv({ ...env }), encoding: 'utf8' });
|
||||
return spawnSync('bash', ['-c', script], { cwd, env: childEnv(env), encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function copyBlockEnv(repo, pathPrefixDir) {
|
||||
|
|
|
|||
41
tests/helpers/artifact-repo.mjs
Normal file
41
tests/helpers/artifact-repo.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// tests/helpers/artifact-repo.mjs
|
||||
// A throwaway target repo for the artifact-commit tests
|
||||
// (tests/lib/artifact-commit.test.mjs, tests/lib/artifact-commit-trailer.test.mjs):
|
||||
// one commit, no git hooks, a project folder holding brief.md and, unless
|
||||
// `withLog` is false, an approval-log entry for it.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from './child-env.mjs';
|
||||
|
||||
const LOG = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'lib', 'util', 'approval-log.mjs');
|
||||
const H1 = 'c'.repeat(64);
|
||||
|
||||
export function git(cwd, ...args) {
|
||||
return spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
|
||||
}
|
||||
|
||||
/** @returns {{ d: string, brief: string }} the repo and its brief.md */
|
||||
export function artifactRepo({ prefix = 'artifact-commit-', project = '2026-10-06-toggle', withLog = true } = {}) {
|
||||
const d = mkdtempSync(join(tmpdir(), prefix));
|
||||
git(d, 'init', '-q');
|
||||
git(d, 'config', 'user.name', 'Test');
|
||||
git(d, 'config', 'user.email', 'test@example.invalid');
|
||||
git(d, 'config', 'commit.gpgsign', 'false');
|
||||
mkdirSync(join(d, '.hooks-none'));
|
||||
git(d, 'config', 'core.hooksPath', '.hooks-none');
|
||||
writeFileSync(join(d, 'README.md'), 'x\n');
|
||||
git(d, 'add', 'README.md');
|
||||
git(d, 'commit', '-q', '-m', 'init');
|
||||
const proj = join(d, '.claude', 'projects', project);
|
||||
mkdirSync(proj, { recursive: true });
|
||||
writeFileSync(join(proj, 'brief.md'), '# brief\n');
|
||||
if (withLog) {
|
||||
spawnSync(process.execPath, [LOG, 'append', '--kind', 'intent', '--subject', join(proj, 'brief.md'), '--sha256', H1],
|
||||
{ cwd: d, env: childEnv() });
|
||||
}
|
||||
return { d, brief: join(proj, 'brief.md') };
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export function runHook(scriptPath, input) {
|
|||
*/
|
||||
export function runHookWithEnv(scriptPath, input, envOverrides, opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const env = childEnv({ ...envOverrides });
|
||||
const env = childEnv(envOverrides);
|
||||
const child = execFile(
|
||||
'node',
|
||||
[scriptPath],
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ test('review 5: a guard module that cannot load makes the Bash guard ask, never
|
|||
});
|
||||
|
||||
function lockIn(repo, rel, env = {}) {
|
||||
const r = spawnSync(process.execPath, [LOCK, 'lock', '--files', rel, '--step', '1'], { cwd: repo, encoding: 'utf8', env: childEnv({ ...env }) });
|
||||
const r = spawnSync(process.execPath, [LOCK, 'lock', '--files', rel, '--step', '1'], { cwd: repo, encoding: 'utf8', env: childEnv(env) });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ function jsRepo({ cycle = false, binary = true } = {}) {
|
|||
}
|
||||
|
||||
function run(args, env = {}) {
|
||||
const r = spawnSync('node', [RUNNER, ...args], { encoding: 'utf-8', env: childEnv({ ...env }) });
|
||||
const r = spawnSync('node', [RUNNER, ...args], { encoding: 'utf-8', env: childEnv(env) });
|
||||
let json = null;
|
||||
try { json = JSON.parse(r.stdout); } catch { /* asserted by caller */ }
|
||||
return { code: r.status, json, out: r.stdout, err: r.stderr };
|
||||
|
|
|
|||
|
|
@ -10,46 +10,18 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import { artifactRepo, git } from '../helpers/artifact-repo.mjs';
|
||||
import { commitArtifacts } from '../../lib/util/artifact-commit.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const CLI = join(ROOT, 'lib', 'util', 'artifact-commit.mjs');
|
||||
const LOG = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
||||
const H1 = 'c'.repeat(64);
|
||||
const TRAILER = 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>';
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
|
||||
}
|
||||
|
||||
// Same shape as the repo() factory in tests/lib/artifact-commit.test.mjs.
|
||||
function repo({ withLog = true } = {}) {
|
||||
const d = mkdtempSync(join(tmpdir(), 'artifact-trailer-'));
|
||||
git(d, 'init', '-q');
|
||||
git(d, 'config', 'user.name', 'Test');
|
||||
git(d, 'config', 'user.email', 'test@example.invalid');
|
||||
git(d, 'config', 'commit.gpgsign', 'false');
|
||||
mkdirSync(join(d, '.hooks-none'));
|
||||
git(d, 'config', 'core.hooksPath', '.hooks-none');
|
||||
writeFileSync(join(d, 'README.md'), 'x\n');
|
||||
git(d, 'add', 'README.md');
|
||||
git(d, 'commit', '-q', '-m', 'init');
|
||||
const proj = join(d, '.claude', 'projects', '2026-10-12-trailer');
|
||||
mkdirSync(proj, { recursive: true });
|
||||
writeFileSync(join(proj, 'brief.md'), '# brief\n');
|
||||
if (withLog) {
|
||||
spawnSync(process.execPath, [LOG, 'append', '--kind', 'intent', '--subject', join(proj, 'brief.md'), '--sha256', H1],
|
||||
{ cwd: d, env: childEnv() });
|
||||
}
|
||||
return { d, brief: join(proj, 'brief.md') };
|
||||
}
|
||||
|
||||
function run(args, cwd) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: childEnv() });
|
||||
}
|
||||
|
|
@ -57,7 +29,7 @@ function run(args, cwd) {
|
|||
const head = (d) => git(d, 'rev-parse', 'HEAD').stdout.trim();
|
||||
|
||||
test('a passed trailer ends the commit message and git parses it as a trailer', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
|
||||
try {
|
||||
const r = run(['--store', 'local', '--artifact', brief, '--message', 'docs(voyage): approve intent trailer',
|
||||
'--trailer', TRAILER], d);
|
||||
|
|
@ -81,7 +53,7 @@ const REFUSED = [
|
|||
|
||||
for (const [name, value] of REFUSED) {
|
||||
test(`refused with exit 1, nothing committed: ${name}`, () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
|
||||
try {
|
||||
const before = head(d);
|
||||
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x', '--trailer', value], d);
|
||||
|
|
@ -95,7 +67,7 @@ for (const [name, value] of REFUSED) {
|
|||
}
|
||||
|
||||
test('a call without a trailer exits 1 with HEAD unchanged', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
|
||||
try {
|
||||
const before = head(d);
|
||||
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x'], d);
|
||||
|
|
@ -107,7 +79,7 @@ test('a call without a trailer exits 1 with HEAD unchanged', () => {
|
|||
});
|
||||
|
||||
test('the trailer check comes before any git call: "nothing to commit" does not mask a missing trailer', () => {
|
||||
const { d, brief } = repo({ withLog: false });
|
||||
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer', withLog: false });
|
||||
try {
|
||||
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x'], d);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
|
|
@ -116,7 +88,7 @@ test('the trailer check comes before any git call: "nothing to commit" does not
|
|||
});
|
||||
|
||||
test('commitArtifacts takes the trailer too, and refuses without it', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
|
||||
try {
|
||||
const repoDir = realpathSync(d);
|
||||
const before = head(d);
|
||||
|
|
@ -132,7 +104,7 @@ test('commitArtifacts takes the trailer too, and refuses without it', () => {
|
|||
});
|
||||
|
||||
test('usage before trailer: a missing --store is still exit 2, with or without a trailer', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
|
||||
try {
|
||||
assert.equal(run(['--artifact', brief, '--message', 'x'], d).status, 2);
|
||||
assert.equal(run(['--artifact', brief, '--message', 'x', '--trailer', TRAILER], d).status, 2);
|
||||
|
|
|
|||
|
|
@ -6,41 +6,18 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { artifactRepo, git } from '../helpers/artifact-repo.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const CLI = join(ROOT, 'lib', 'util', 'artifact-commit.mjs');
|
||||
const LOG = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
||||
const H1 = 'c'.repeat(64);
|
||||
// Every call passes a valid trailer (the CLI refuses a call without one).
|
||||
const TRAILER = ['--trailer', 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>'];
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function repo() {
|
||||
const d = mkdtempSync(join(tmpdir(), 'artifact-commit-'));
|
||||
git(d, 'init', '-q');
|
||||
git(d, 'config', 'user.name', 'Test');
|
||||
git(d, 'config', 'user.email', 'test@example.invalid');
|
||||
git(d, 'config', 'commit.gpgsign', 'false');
|
||||
mkdirSync(join(d, '.hooks-none'));
|
||||
git(d, 'config', 'core.hooksPath', '.hooks-none');
|
||||
writeFileSync(join(d, 'README.md'), 'x\n');
|
||||
git(d, 'add', 'README.md');
|
||||
git(d, 'commit', '-q', '-m', 'init');
|
||||
const proj = join(d, '.claude', 'projects', '2026-10-06-toggle');
|
||||
mkdirSync(proj, { recursive: true });
|
||||
writeFileSync(join(proj, 'brief.md'), '# brief\n');
|
||||
spawnSync(process.execPath, [LOG, 'append', '--kind', 'intent', '--subject', join(proj, 'brief.md'), '--sha256', H1], { cwd: d });
|
||||
return { d, brief: join(proj, 'brief.md') };
|
||||
}
|
||||
|
||||
function run(args, cwd) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
|
@ -48,7 +25,7 @@ function run(args, cwd) {
|
|||
const committedFiles = (d) => git(d, 'show', '--name-only', '--format=', 'HEAD').stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
test('store git: the brief and the log land in one commit, and nothing else the operator staged', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo();
|
||||
try {
|
||||
writeFileSync(join(d, 'unrelated.txt'), 'staged by the operator\n');
|
||||
git(d, 'add', 'unrelated.txt');
|
||||
|
|
@ -63,7 +40,7 @@ test('store git: the brief and the log land in one commit, and nothing else the
|
|||
});
|
||||
|
||||
test('store local: only the log is committed, and the project folder is kept out of git locally', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo();
|
||||
try {
|
||||
const r = run(['--store', 'local', '--artifact', brief, '--message', 'docs(voyage): approve intent toggle', ...TRAILER], d);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
|
|
@ -80,7 +57,7 @@ test('store local: only the log is committed, and the project folder is kept out
|
|||
});
|
||||
|
||||
test('nothing new to commit is exit 0 with committed:false (a re-run is not an error)', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo();
|
||||
try {
|
||||
assert.equal(run(['--store', 'git', '--artifact', brief, '--message', 'one', ...TRAILER], d).status, 0);
|
||||
const r = run(['--store', 'git', '--artifact', brief, '--message', 'two', ...TRAILER], d);
|
||||
|
|
@ -90,7 +67,7 @@ test('nothing new to commit is exit 0 with committed:false (a re-run is not an e
|
|||
});
|
||||
|
||||
test('a path the repo ignores is not forced in: exit 1 with the reason', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo();
|
||||
try {
|
||||
writeFileSync(join(d, '.gitignore'), '.claude/\n');
|
||||
const r = run(['--store', 'git', '--artifact', brief, '--message', 'x', ...TRAILER], d);
|
||||
|
|
@ -109,7 +86,7 @@ test('not a git repository: exit 2 (could not run), never a silent pass', () =>
|
|||
});
|
||||
|
||||
test('a missing or unknown --store is a usage error (exit 2)', () => {
|
||||
const { d, brief } = repo();
|
||||
const { d, brief } = artifactRepo();
|
||||
try {
|
||||
assert.equal(run(['--artifact', brief, '--message', 'x', ...TRAILER], d).status, 2);
|
||||
assert.equal(run(['--store', 'cloud', '--artifact', brief, '--message', 'x', ...TRAILER], d).status, 2);
|
||||
|
|
|
|||
|
|
@ -15,12 +15,7 @@ import { stepTestsOnly } from '../../lib/parsers/plan-schema.mjs';
|
|||
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
|
||||
|
||||
// A 1.8 plan whose step 2 carries `files` as its `Files:` field.
|
||||
function planWith(files) {
|
||||
const plan = buildPlan({ steps: [{}, { file: 'src/mod2.mjs', endToEnd: 'bash tests/e2e.sh' }] });
|
||||
const line = '- **Files:** `src/mod2.mjs`';
|
||||
assert.ok(plan.includes(line), 'the builder writes step 2\'s Files: line');
|
||||
return plan.replace(line, `- **Files:** ${files}`);
|
||||
}
|
||||
const planWith = (files) => buildPlan({ steps: [{}, { file: 'src/mod2.mjs', filesRaw: files, endToEnd: 'bash tests/e2e.sh' }] });
|
||||
|
||||
const testsOnlyWarned = (files) => validatePlanContent(planWith(files)).warnings
|
||||
.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY' && /Step 2\b/.test(w.message));
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { strict as assert } from 'node:assert';
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
|
||||
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
|
@ -61,10 +60,6 @@ const claudeRow = () => {
|
|||
return m[0];
|
||||
};
|
||||
|
||||
test('PLAN_VERSION_CURRENT is 1.9', () => {
|
||||
assert.equal(PLAN_VERSION_CURRENT, '1.9');
|
||||
});
|
||||
|
||||
test('Handover 4 names plan_version 1.9 and the "file first on every line" rule', () => {
|
||||
const h4 = handover4();
|
||||
assert.match(h4, /plan_version 1\.9/);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function runShim(args, env) {
|
|||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: childEnv({ ...env }),
|
||||
env: childEnv(env),
|
||||
});
|
||||
return { code: 0, out };
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -179,8 +179,11 @@ test('trekreview — the write-outside-project_dir exception names only the mark
|
|||
|
||||
test('armB workflow — its inlined rule-key enum equals the catalogue (no drift)', () => {
|
||||
const src = read('scripts/trekreview-armB.workflow.mjs');
|
||||
const block = src.slice(src.indexOf('const RULE_KEYS = ['), src.indexOf('];', src.indexOf('const RULE_KEYS = [')));
|
||||
const keys = [...block.matchAll(/'([A-Z_]+)'/g)].map((m) => m[1]).sort();
|
||||
assert.ok(src.includes('const RULE_KEYS = Object.keys(RULE_TIERS);'), 'the enum is the keys of the inlined RULE_TIERS');
|
||||
const start = src.indexOf('const RULE_TIERS = {');
|
||||
assert.ok(start !== -1, 'the script carries a RULE_TIERS literal block');
|
||||
const block = src.slice(start, src.indexOf('};', start));
|
||||
const keys = [...block.matchAll(/([A-Z_]+):\s*'[A-Z]+'/g)].map((m) => m[1]).sort();
|
||||
assert.deepEqual(keys, [...RULE_KEYS].sort());
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
|
||||
import { join, dirname, relative, sep } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -165,7 +165,30 @@ const NOT_DRIVERS = new Map([
|
|||
]);
|
||||
const DRIVER_RE = /test-lock\.mjs|stop-test-lock/;
|
||||
|
||||
test('(d) the lock-driving test files run green and leave no pointer for a made-up id', { timeout: TIMEOUT }, () => {
|
||||
// One nested `node --test` run of `rel`, with its own made-up id and private
|
||||
// TMPDIR. Resolves to the problems it shows.
|
||||
function runDriver(rel, mode, pre) {
|
||||
const id = madeUpId();
|
||||
const tmp = privateTmp();
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [...pre, '--test', '--test-reporter=tap', rel],
|
||||
{ cwd: ROOT, env: isolatedEnv(id, tmp), timeout: TIMEOUT });
|
||||
let out = '';
|
||||
child.stdout.on('data', (d) => { out += d; });
|
||||
child.stderr.on('data', (d) => { out += d; });
|
||||
child.on('close', (status, signal) => {
|
||||
const problems = [];
|
||||
const m = out.match(/^# tests (\d+)$/m);
|
||||
if (status !== 0) problems.push(`${rel} [${mode}]: exit ${status}${signal ? ` (${signal})` : ''}`);
|
||||
if (!m || Number(m[1]) === 0) problems.push(`${rel} [${mode}]: reported no tests`);
|
||||
if (existsSync(pointerPath(tmp, id))) problems.push(`${rel} [${mode}]: left a session pointer for the made-up id`);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
resolve(problems);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('(d) the lock-driving test files run green and leave no pointer for a made-up id', { timeout: TIMEOUT }, async () => {
|
||||
const all = walk(join(ROOT, 'tests'), (n) => n.endsWith('.test.mjs')).filter((f) => f !== SELF);
|
||||
const matching = all.filter((rel) => DRIVER_RE.test(readFileSync(join(ROOT, rel), 'utf8')));
|
||||
for (const [rel, why] of NOT_DRIVERS) {
|
||||
|
|
@ -175,24 +198,12 @@ test('(d) the lock-driving test files run green and leave no pointer for a made-
|
|||
const drivers = matching.filter((rel) => !NOT_DRIVERS.has(rel));
|
||||
assert.ok(drivers.length >= 5, `only ${drivers.length} lock-driving test files found: ${drivers.join(', ')}`);
|
||||
|
||||
const flags = preloadFlags();
|
||||
// The files of one mode run side by side (as `node --test` runs files); the
|
||||
// two modes run one after the other.
|
||||
const problems = [];
|
||||
for (const rel of drivers) {
|
||||
for (const [mode, pre] of [['test command', flags], ['direct', []]]) {
|
||||
const id = madeUpId();
|
||||
const tmp = privateTmp();
|
||||
try {
|
||||
const r = spawnSync(process.execPath, [...pre, '--test', '--test-reporter=tap', rel],
|
||||
{ cwd: ROOT, encoding: 'utf8', env: isolatedEnv(id, tmp), timeout: TIMEOUT });
|
||||
const out = `${r.stdout || ''}\n${r.stderr || ''}`;
|
||||
const m = out.match(/^# tests (\d+)$/m);
|
||||
if (r.status !== 0) problems.push(`${rel} [${mode}]: exit ${r.status}${r.signal ? ` (${r.signal})` : ''}`);
|
||||
if (!m || Number(m[1]) === 0) problems.push(`${rel} [${mode}]: reported no tests`);
|
||||
if (existsSync(pointerPath(tmp, id))) problems.push(`${rel} [${mode}]: left a session pointer for the made-up id`);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
for (const [mode, pre] of [['test command', preloadFlags()], ['direct', []]]) {
|
||||
const runs = await Promise.all(drivers.map((rel) => runDriver(rel, mode, pre)));
|
||||
problems.push(...runs.flat());
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ function fill(script, values) {
|
|||
|
||||
const BASH = existsSync('/bin/bash') ? '/bin/bash' : 'bash';
|
||||
function runBash(script, cwd, env = {}) {
|
||||
return spawnSync(BASH, ['-c', script], { cwd, encoding: 'utf-8', env: childEnv({ ...env }) });
|
||||
return spawnSync(BASH, ['-c', script], { cwd, encoding: 'utf-8', env: childEnv(env) });
|
||||
}
|
||||
|
||||
function gitc(cwd, ...args) {
|
||||
|
|
@ -556,7 +556,7 @@ test('Check 4 and Step 5 pass a first run (no worktrees yet) under zsh as well a
|
|||
const step5 = fill(bashBlock('### Step 5 — Cleanup'), { slug: 'demo' });
|
||||
for (const shell of [BASH, ZSH]) {
|
||||
for (const [name, block] of [['Check 4', check4], ['Step 5', step5]]) {
|
||||
const r = spawnSync(shell, ['-c', block], { cwd: root, encoding: 'utf-8', env: childEnv({ ...env }) });
|
||||
const r = spawnSync(shell, ['-c', block], { cwd: root, encoding: 'utf-8', env: childEnv(env) });
|
||||
assert.equal(r.status, 0, `${name} under ${shell}: ${r.stderr}`);
|
||||
assert.doesNotMatch(r.stderr, /no matches found/, `${name} under ${shell}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|||
const CLI = join(HERE, '..', '..', 'lib', 'util', 'test-lock.mjs');
|
||||
|
||||
function run(cwd, args, env = {}) {
|
||||
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: childEnv({ ...env }) });
|
||||
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: childEnv(env) });
|
||||
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as m from '../../lib/review/coordinator-contract.mjs';
|
||||
import { extractFindingsBlock } from '../../lib/review/findings-schema.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||
|
|
@ -289,7 +290,7 @@ test('verdict gate — exit 0 on the legal brief lowering', () => {
|
|||
test('verdict gate — exit 2 on the illegal brief lowering, the reason naming the finding id', () => {
|
||||
assert.ok(existsSync(ILLEGAL_FIXTURE), 'tests/fixtures/review-brief-conflict-illegal.md exists');
|
||||
const text = readFileSync(ILLEGAL_FIXTURE, 'utf-8');
|
||||
const json = JSON.parse(text.slice(text.lastIndexOf('```json') + 7, text.lastIndexOf('```')));
|
||||
const json = JSON.parse(extractFindingsBlock(text));
|
||||
assert.equal(json.verdict, 'ALLOW', 'the fixture claims ALLOW');
|
||||
const id = json.findings[0].id;
|
||||
assert.match(id, /^[0-9a-f]{40}$/);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { strict as assert } from 'node:assert';
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { illegalBriefLowering } from '../../lib/review/coordinator-contract.mjs';
|
||||
import { illegalBriefLowering, severityRank as rank } from '../../lib/review/coordinator-contract.mjs';
|
||||
import { ENGINES } from '../../lib/review/engine-findings.mjs';
|
||||
import { RULE_CATALOGUE } from '../../lib/review/rule-catalogue.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
|
@ -92,8 +93,6 @@ test('armB — quick mode expects only the /code-review engine', async () => {
|
|||
// checks the floor raised a finding exactly where that value is true — plus
|
||||
// case 7, where a false `engine` label alone raises.
|
||||
|
||||
const RANK = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
|
||||
const rank = (s) => (RANK.indexOf(s) === -1 ? RANK.length : RANK.indexOf(s));
|
||||
const MARK = 'Non-Goal: no new runtime dependency';
|
||||
const FILE = 'src/a.mjs';
|
||||
const LINE = 10;
|
||||
|
|
@ -301,6 +300,15 @@ test('armB brief rule — RULE_TIERS in the script equals the catalogue (same ke
|
|||
assert.deepEqual(tiers, catalogue);
|
||||
});
|
||||
|
||||
test('armB brief rule — ENGINE_OF_PAYLOAD in the script maps each ENGINES reviewer name to its engine', () => {
|
||||
const start = SRC.indexOf('const ENGINE_OF_PAYLOAD = {');
|
||||
assert.ok(start !== -1, 'the script carries an ENGINE_OF_PAYLOAD literal block');
|
||||
const block = SRC.slice(start, SRC.indexOf('};', start));
|
||||
const map = Object.fromEntries([...block.matchAll(/'([a-z-]+)':\s*'([a-z-]+)'/g)].map((m) => [m[1], m[2]]));
|
||||
const expected = Object.fromEntries(Object.entries(ENGINES).map(([engine, spec]) => [spec.reviewer, engine]));
|
||||
assert.deepEqual(map, expected);
|
||||
});
|
||||
|
||||
test('armB brief rule — VERDICT_SCHEMA declares engine, contradicts_brief and brief_lowered_from; the prompt states the rule', async () => {
|
||||
const c = await floorCase({
|
||||
codeFindings: [F('CODE_REVIEW_DEFECT', 'MAJOR')],
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ function commandLine(rel, verb) {
|
|||
function run(line, subs, env = {}) {
|
||||
let cmd = line.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
|
||||
for (const [k, v] of Object.entries(subs)) cmd = cmd.split(k).join(v);
|
||||
return spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: childEnv({ ...env }) });
|
||||
return spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: childEnv(env) });
|
||||
}
|
||||
|
||||
function gate(briefPath) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue