refactor(cleanup): simplify pass on the R-a diff
engine-compare imports the coordinator's title limit and trim instead of a copy whose reason went with Arm B; the autonomy gate exports one parser and its test runs in-process with two CLI runs; one import scanner in lib-imports; the fenced-section walker shared from tests/helpers/md-section.mjs instead of six copies; plan-gate hash in-process; living-paths scans once; routing drops the orchestrator filter whose files are gone; small leftovers removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
029e83117c
commit
efe981f7bc
18 changed files with 161 additions and 358 deletions
|
|
@ -444,7 +444,7 @@ function effectiveSeverity(f) {
|
||||||
return getRule(f.rule_key)?.severity ?? f.severity;
|
return getRule(f.rule_key)?.severity ?? f.severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimText(text, max) {
|
export function trimText(text, max) {
|
||||||
return text.slice(0, max - TRIM_MARKER.length) + TRIM_MARKER;
|
return text.slice(0, max - TRIM_MARKER.length) + TRIM_MARKER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
// (lib/review/coordinator-contract.mjs runContract, `engine:out-of-scope`).
|
// (lib/review/coordinator-contract.mjs runContract, `engine:out-of-scope`).
|
||||||
|
|
||||||
import { ENGINES } from './engine-findings.mjs';
|
import { ENGINES } from './engine-findings.mjs';
|
||||||
|
import { JUDGE_TITLE_MAX, trimText } from './coordinator-contract.mjs';
|
||||||
|
|
||||||
// The (file,line,rule_key) triplet a finding is deduped and matched on.
|
// The (file,line,rule_key) triplet a finding is deduped and matched on.
|
||||||
const tripletOf = (f) => `${f.file}\u0000${f.line}\u0000${f.rule_key}`;
|
const tripletOf = (f) => `${f.file}\u0000${f.line}\u0000${f.rule_key}`;
|
||||||
|
|
@ -26,14 +27,10 @@ const normTitle = (f) => String(f.title).trim().toLowerCase();
|
||||||
// The row a finding is matched to after the coordinator: its triplet plus its
|
// The row a finding is matched to after the coordinator: its triplet plus its
|
||||||
// `normTitle`. An untitled finding gets a key no titled row has.
|
// `normTitle`. An untitled finding gets a key no titled row has.
|
||||||
const rowOf = (f) => `${tripletOf(f)}\u0000${f.title === undefined ? '\u0001' : normTitle(f)}`;
|
const rowOf = (f) => `${tripletOf(f)}\u0000${f.title === undefined ? '\u0001' : normTitle(f)}`;
|
||||||
// The coordinator trims an over-long MINOR/SUGGESTION title as
|
// A title as lib/review/coordinator-contract.mjs judgeFilter trims an
|
||||||
// lib/review/coordinator-contract.mjs judgeFilter does; its marker and limit
|
// over-long MINOR/SUGGESTION title: over JUDGE_TITLE_MAX characters it is
|
||||||
// are inlined.
|
// trimText's cut; any other title unchanged.
|
||||||
const TRIM_MARKER = ' […trimmed]';
|
const trimmedTitle = (t) => (t.length > JUDGE_TITLE_MAX ? trimText(t, JUDGE_TITLE_MAX) : t);
|
||||||
const JUDGE_TITLE_MAX = 100;
|
|
||||||
// A title as that trim writes it: over JUDGE_TITLE_MAX characters, cut to
|
|
||||||
// leave room for the marker, then the marker; any other title unchanged.
|
|
||||||
const trimmedTitle = (t) => (t.length > JUDGE_TITLE_MAX ? t.slice(0, JUDGE_TITLE_MAX - TRIM_MARKER.length) + TRIM_MARKER : t);
|
|
||||||
|
|
||||||
export { tripletOf, normTitle, rowOf, trimmedTitle };
|
export { tripletOf, normTitle, rowOf, trimmedTitle };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,10 +150,7 @@ export function collisions(corpus, t = THRESHOLDS) {
|
||||||
|
|
||||||
// ---- loading ----
|
// ---- loading ----
|
||||||
|
|
||||||
/** Orchestrator reference docs are not spawnable capabilities (CLAUDE.md, Inventory). */
|
/** Skills, commands and agents of a plugin root. */
|
||||||
const isReferenceDoc = (name, description) => /-orchestrator$/.test(name) || /^Reference document, not a spawnable/.test(description.trim());
|
|
||||||
|
|
||||||
/** Skills, commands and spawnable agents of a plugin root. */
|
|
||||||
export function loadEntries(root) {
|
export function loadEntries(root) {
|
||||||
const entries = [];
|
const entries = [];
|
||||||
const skillsDir = join(root, 'skills');
|
const skillsDir = join(root, 'skills');
|
||||||
|
|
@ -174,9 +171,7 @@ export function loadEntries(root) {
|
||||||
const text = readFileSync(join(d, file), 'utf8');
|
const text = readFileSync(join(d, file), 'utf8');
|
||||||
const fm = readFrontmatterFields(text).fields;
|
const fm = readFrontmatterFields(text).fields;
|
||||||
const name = fm.name ? String(fm.name.value) : file.replace(/\.md$/, '');
|
const name = fm.name ? String(fm.name.value) : file.replace(/\.md$/, '');
|
||||||
const description = descriptionOf(text);
|
entries.push({ id: `${kind}:${name}`, kind, name, description: descriptionOf(text) });
|
||||||
if (kind === 'agent' && isReferenceDoc(name, description)) continue;
|
|
||||||
entries.push({ id: `${kind}:${name}`, kind, name, description });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,8 @@
|
||||||
|
|
||||||
import { isMain } from './is-main.mjs';
|
import { isMain } from './is-main.mjs';
|
||||||
|
|
||||||
/**
|
/** A --gates value: true|closed = on, false|open = off, else undefined. */
|
||||||
* A --gates value: true|closed = on, false|open = off, else undefined.
|
function gatesValue(v) {
|
||||||
*
|
|
||||||
* @param {string|undefined} v
|
|
||||||
* @returns {boolean|undefined}
|
|
||||||
*/
|
|
||||||
export function gatesValue(v) {
|
|
||||||
if (v === 'true' || v === 'closed') return true;
|
if (v === 'true' || v === 'closed') return true;
|
||||||
if (v === 'false' || v === 'open') return false;
|
if (v === 'false' || v === 'open') return false;
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { runHook } from '../helpers/hook-helper.mjs';
|
import { runHook } from '../helpers/hook-helper.mjs';
|
||||||
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
import { sectionText } from '../helpers/md-section.mjs';
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = join(HERE, '..', '..');
|
const ROOT = join(HERE, '..', '..');
|
||||||
|
|
@ -300,24 +300,6 @@ test('trekcontinue Bug 4 (f-2) confirm deletes and (f-3) idempotent re-run handl
|
||||||
/** Whitespace (line breaks too) collapsed to one space. */
|
/** Whitespace (line breaks too) collapsed to one space. */
|
||||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
/**
|
|
||||||
* The text of a heading's section, up to the next heading of the same or a
|
|
||||||
* higher level outside a fence. The heading must exist.
|
|
||||||
*/
|
|
||||||
function sectionText(text, heading) {
|
|
||||||
const level = heading.match(/^#+/)[0].length;
|
|
||||||
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
|
||||||
assert.ok(s, `missing ${heading}`);
|
|
||||||
const lines = s.text.split('\n');
|
|
||||||
let fenced = false;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
|
||||||
const m = !fenced && lines[i].match(/^(#+) /);
|
|
||||||
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
|
||||||
}
|
|
||||||
return s.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
||||||
function libLine(text, mod, verb = '') {
|
function libLine(text, mod, verb = '') {
|
||||||
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { dirname, join } from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { childEnv } from '../helpers/child-env.mjs';
|
import { childEnv } from '../helpers/child-env.mjs';
|
||||||
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
import { sectionText } from '../helpers/md-section.mjs';
|
||||||
import { readFrontmatterFields } from '../../lib/skills/skill-frontmatter.mjs';
|
import { readFrontmatterFields } from '../../lib/skills/skill-frontmatter.mjs';
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -244,24 +244,6 @@ test("[DJ5] a label with an apostrophe is kept", () => {
|
||||||
/** Whitespace (line breaks too) collapsed to one space. */
|
/** Whitespace (line breaks too) collapsed to one space. */
|
||||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
/**
|
|
||||||
* The text of a heading's section, up to the next heading of the same or a
|
|
||||||
* higher level outside a fence. The heading must exist.
|
|
||||||
*/
|
|
||||||
function sectionText(text, heading) {
|
|
||||||
const level = heading.match(/^#+/)[0].length;
|
|
||||||
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
|
||||||
assert.ok(s, `missing ${heading}`);
|
|
||||||
const lines = s.text.split('\n');
|
|
||||||
let fenced = false;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
|
||||||
const m = !fenced && lines[i].match(/^(#+) /);
|
|
||||||
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
|
||||||
}
|
|
||||||
return s.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
||||||
function libLine(text, mod, verb = '') {
|
function libLine(text, mod, verb = '') {
|
||||||
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { spawnSync } from 'node:child_process';
|
||||||
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
|
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
|
||||||
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
|
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
|
||||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||||
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
import { sectionText } from '../helpers/md-section.mjs';
|
||||||
import { childEnv } from '../helpers/child-env.mjs';
|
import { childEnv } from '../helpers/child-env.mjs';
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -165,24 +165,6 @@ test('trekexecute — iterations_remaining decrement + backfill + gate cross-che
|
||||||
/** Whitespace (line breaks too) collapsed to one space. */
|
/** Whitespace (line breaks too) collapsed to one space. */
|
||||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
/**
|
|
||||||
* The text of a heading's section, up to the next heading of the same or a
|
|
||||||
* higher level outside a fence. The heading must exist.
|
|
||||||
*/
|
|
||||||
function sectionText(text, heading) {
|
|
||||||
const level = heading.match(/^#+/)[0].length;
|
|
||||||
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
|
||||||
assert.ok(s, `missing ${heading}`);
|
|
||||||
const lines = s.text.split('\n');
|
|
||||||
let fenced = false;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
|
||||||
const m = !fenced && lines[i].match(/^(#+) /);
|
|
||||||
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
|
||||||
}
|
|
||||||
return s.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
||||||
function libLine(text, mod, verb = '') {
|
function libLine(text, mod, verb = '') {
|
||||||
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
||||||
|
|
@ -340,11 +322,11 @@ function staleFixture() {
|
||||||
return sharedStale;
|
return sharedStale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The lock line with its review path filled in, run in `repo` through bash(). */
|
/** The lock line with its review path filled in for review.md in `dir`. */
|
||||||
function runLock(cmd, dir, repo) {
|
const fillLock = (cmd, dir) => cmd.split('{review_path}').join(join(dir, 'review.md')).split('{project_dir}').join(dir);
|
||||||
const filled = cmd.split('{review_path}').join(join(dir, 'review.md')).split('{project_dir}').join(dir);
|
|
||||||
return bash(filled, repo);
|
/** The lock line, filled for `dir`, run in `repo` through bash(). */
|
||||||
}
|
const runLock = (cmd, dir, repo) => bash(fillLock(cmd, dir), repo);
|
||||||
|
|
||||||
test('trekexecute merge lock: the Main-merge gate and Phase 7.2 step 4 each hold exactly one verdict-gate.mjs line, with --repo .', () => {
|
test('trekexecute merge lock: the Main-merge gate and Phase 7.2 step 4 each hold exactly one verdict-gate.mjs line, with --repo .', () => {
|
||||||
const text = read();
|
const text = read();
|
||||||
|
|
@ -358,10 +340,14 @@ test('trekexecute merge lock: the extracted lock lines, run on an ALLOW review o
|
||||||
const text = read();
|
const text = read();
|
||||||
const lines = [libLine(sectionText(text, MERGE_GATE), 'review/verdict-gate.mjs'), libLine(sectionText(text, PHASE_72), 'review/verdict-gate.mjs')];
|
const lines = [libLine(sectionText(text, MERGE_GATE), 'review/verdict-gate.mjs'), libLine(sectionText(text, PHASE_72), 'review/verdict-gate.mjs')];
|
||||||
const fx = staleFixture();
|
const fx = staleFixture();
|
||||||
for (const cmd of lines) {
|
const filled = lines.map((cmd) => fillLock(cmd, fx.review('ALLOW', fx.head)));
|
||||||
const ok = runLock(cmd, fx.review('ALLOW', fx.head), fx.repo);
|
assert.equal(filled[0], filled[1], 'both lock lines fill to the same command');
|
||||||
|
for (const cmd of new Set(filled)) {
|
||||||
|
fx.review('ALLOW', fx.head);
|
||||||
|
const ok = bash(cmd, fx.repo);
|
||||||
assert.equal(ok.status, 0, `control: an ALLOW review of HEAD opens the lock: ${ok.stdout}${ok.stderr}`);
|
assert.equal(ok.status, 0, `control: an ALLOW review of HEAD opens the lock: ${ok.stdout}${ok.stderr}`);
|
||||||
const r = runLock(cmd, fx.review('ALLOW', fx.reviewed), fx.repo);
|
fx.review('ALLOW', fx.reviewed);
|
||||||
|
const r = bash(cmd, fx.repo);
|
||||||
assert.equal(r.status, 2, `${cmd}: an ALLOW review of an older commit must exit 2: ${r.stdout}${r.stderr}`);
|
assert.equal(r.status, 2, `${cmd}: an ALLOW review of an older commit must exit 2: ${r.stdout}${r.stderr}`);
|
||||||
const reason = JSON.parse(r.stdout).reason;
|
const reason = JSON.parse(r.stdout).reason;
|
||||||
assert.ok(reason.startsWith('review-stale'), `the reason must start review-stale: ${reason}`);
|
assert.ok(reason.startsWith('review-stale'), `the reason must start review-stale: ${reason}`);
|
||||||
|
|
|
||||||
26
tests/helpers/md-section.mjs
Normal file
26
tests/helpers/md-section.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// tests/helpers/md-section.mjs
|
||||||
|
// The text of one markdown section, for the prose-pin tests.
|
||||||
|
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text of a heading's section (level from its #-count), up to the next
|
||||||
|
* heading of the same or a higher level outside a fence. The heading must exist.
|
||||||
|
* @param {string} text
|
||||||
|
* @param {string} heading - e.g. `## Phase 8 — Final report`
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function sectionText(text, heading) {
|
||||||
|
const level = heading.match(/^#+/)[0].length;
|
||||||
|
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
||||||
|
assert.ok(s, `missing ${heading}`);
|
||||||
|
const lines = s.text.split('\n');
|
||||||
|
let fenced = false;
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
||||||
|
const m = !fenced && lines[i].match(/^(#+) /);
|
||||||
|
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
||||||
|
}
|
||||||
|
return s.text;
|
||||||
|
}
|
||||||
|
|
@ -5,110 +5,58 @@
|
||||||
|
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import { execFileSync, spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
import { parseGatesArgs } from '../../lib/util/autonomy-gate.mjs';
|
||||||
|
import { sectionText } from '../helpers/md-section.mjs';
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = join(HERE, '..', '..');
|
const ROOT = join(HERE, '..', '..');
|
||||||
const SHIM = join(ROOT, 'lib', 'util', 'autonomy-gate.mjs');
|
const SHIM = join(ROOT, 'lib', 'util', 'autonomy-gate.mjs');
|
||||||
const GATES_USAGE = 'usage: --gates takes true, false, closed or open';
|
const GATES_USAGE = 'usage: --gates takes true, false, closed or open';
|
||||||
|
const USAGE = 'usage: autonomy-gate.mjs [--gates [true|false|closed|open]]';
|
||||||
|
|
||||||
function runShim(args) {
|
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||||
try {
|
|
||||||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
return { code: 0, out };
|
|
||||||
} catch (e) {
|
|
||||||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The exit code is asserted before any JSON.parse, so a crash or a usage
|
|
||||||
// error never reads as a wrong value.
|
|
||||||
function shimGates(args) {
|
|
||||||
const r = runShim(args);
|
|
||||||
assert.equal(r.code, 0, `expected exit 0 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`);
|
|
||||||
const parsed = JSON.parse(r.out.trim());
|
|
||||||
assert.deepEqual(parsed, { ok: true, gates: parsed.gates });
|
|
||||||
assert.equal(typeof parsed.gates, 'boolean', `gates must be a boolean: ${r.out}`);
|
|
||||||
return parsed.gates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shimUsage(args) {
|
|
||||||
const r = runShim(args);
|
|
||||||
assert.equal(r.code, 1, `expected exit 1 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`);
|
|
||||||
const parsed = JSON.parse(r.out.trim());
|
|
||||||
assert.equal(parsed.ok, false);
|
|
||||||
assert.equal(Object.keys(parsed).sort().join(','), 'error,ok');
|
|
||||||
return parsed.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the exported parser ----------------------------------------------------
|
// ---- the exported parser ----------------------------------------------------
|
||||||
|
|
||||||
test('gatesValue maps true|closed to on, false|open to off, anything else to undefined', async () => {
|
test('parseGatesArgs: bare --gates and true|closed are on, false|open and no flag are off, in both forms', () => {
|
||||||
const mod = await import(pathToFileURL(SHIM).href);
|
const on = (argv) => assert.deepEqual(parseGatesArgs(argv), { ok: true, gates: true }, JSON.stringify(argv));
|
||||||
assert.equal(typeof mod.gatesValue, 'function', 'autonomy-gate.mjs must export gatesValue');
|
const off = (argv) => assert.deepEqual(parseGatesArgs(argv), { ok: true, gates: false }, JSON.stringify(argv));
|
||||||
for (const v of ['true', 'closed']) assert.equal(mod.gatesValue(v), true, v);
|
on(['--gates']);
|
||||||
for (const v of ['false', 'open']) assert.equal(mod.gatesValue(v), false, v);
|
off([]);
|
||||||
for (const v of ['TRUE', 'yes', 'adaptive', '', undefined]) assert.equal(mod.gatesValue(v), undefined, String(v));
|
for (const v of ['true', 'closed']) { on(['--gates', v]); on([`--gates=${v}`]); }
|
||||||
|
for (const v of ['false', 'open']) { off(['--gates', v]); off([`--gates=${v}`]); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the module is the flag parser only: no state machine is exported', async () => {
|
test('parseGatesArgs: any other value is the exact --gates usage error', () => {
|
||||||
const mod = await import(pathToFileURL(SHIM).href);
|
for (const bad of [['--gates', 'TRUE'], ['--gates', 'yes'], ['--gates', 'adaptive'], ['--gates=maybe'], ['--gates=']]) {
|
||||||
for (const gone of ['STATES', 'EVENTS', 'transition', 'isTerminal']) {
|
assert.deepEqual(parseGatesArgs(bad), { ok: false, error: GATES_USAGE }, bad.join(' '));
|
||||||
assert.equal(mod[gone], undefined, `autonomy-gate.mjs still exports ${gone}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseGatesArgs: any other argument (the old --state/--event too) is the exact usage error', () => {
|
||||||
|
assert.deepEqual(parseGatesArgs(['--gates', '--state', 'idle']), { ok: false, error: USAGE });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- the CLI ----------------------------------------------------------------
|
// ---- the CLI ----------------------------------------------------------------
|
||||||
|
|
||||||
test('CLI: bare --gates → {"ok":true,"gates":true}, exit 0', () => {
|
const runShim = (args) => spawnSync(process.execPath, [SHIM, ...args], { encoding: 'utf8' });
|
||||||
assert.equal(shimGates(['--gates']), true);
|
|
||||||
|
test('CLI: --gates closed prints {"ok":true,"gates":true}, exit 0', () => {
|
||||||
|
const r = runShim(['--gates', 'closed']);
|
||||||
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||||
|
assert.equal(r.stdout, '{"ok":true,"gates":true}\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CLI: no flag → {"ok":true,"gates":false}, exit 0', () => {
|
test('CLI: a bad value prints the usage error as JSON, exit 1', () => {
|
||||||
assert.equal(shimGates([]), false);
|
const r = runShim(['--gates', 'maybe']);
|
||||||
|
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||||
|
assert.equal(r.stdout, `${JSON.stringify({ ok: false, error: GATES_USAGE })}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CLI: --gates true and --gates closed → gates true', () => {
|
|
||||||
assert.equal(shimGates(['--gates', 'true']), true);
|
|
||||||
assert.equal(shimGates(['--gates', 'closed']), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CLI: --gates false and --gates open → gates false', () => {
|
|
||||||
assert.equal(shimGates(['--gates', 'false']), false);
|
|
||||||
assert.equal(shimGates(['--gates', 'open']), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CLI: the one-token --gates=<value> form is read as --gates <value>', () => {
|
|
||||||
assert.equal(shimGates(['--gates=true']), true);
|
|
||||||
assert.equal(shimGates(['--gates=closed']), true);
|
|
||||||
assert.equal(shimGates(['--gates=false']), false);
|
|
||||||
assert.equal(shimGates(['--gates=open']), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const bad of [['--gates', 'TRUE'], ['--gates', 'yes'], ['--gates', 'adaptive'], ['--gates=maybe'], ['--gates=']]) {
|
|
||||||
test(`CLI: ${bad.join(' ')} → the exact usage error, exit 1`, () => {
|
|
||||||
assert.equal(shimUsage(bad), GATES_USAGE);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const old of [
|
|
||||||
['--state', 'idle', '--event', 'start', '--gates', 'true'],
|
|
||||||
['--state', 'idle', '--event', 'start'],
|
|
||||||
['--gates', '--state', 'idle'],
|
|
||||||
['--event', 'start'],
|
|
||||||
]) {
|
|
||||||
test(`CLI: the old state-machine arguments are a usage error (${old.join(' ')}), exit 1`, () => {
|
|
||||||
assert.match(shimUsage(old), /^usage:/);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the four commands' --gates rule and CLI line ----------------------------
|
// ---- the four commands' --gates rule and CLI line ----------------------------
|
||||||
// Each of the four commands holds one rule: the next token is the flag's value
|
// Each of the four commands holds one rule: the next token is the flag's value
|
||||||
// only when it is true, false, closed or open; the CLI maps it, run once; a
|
// only when it is true, false, closed or open; the CLI maps it, run once; a
|
||||||
|
|
@ -124,14 +72,12 @@ const GATES_RULE = [
|
||||||
'On exit 1 or no JSON (a broken shim; the four values cannot cause it), print the error in one line and map in prose instead: bare, `true`, `closed` → true; `false`, `open` → false. The run goes on either way.',
|
'On exit 1 or no JSON (a broken shim; the four values cannot cause it), print the error in one line and map in prose instead: bare, `true`, `closed` → true; `false`, `open` → false. The run goes on either way.',
|
||||||
];
|
];
|
||||||
|
|
||||||
const commandText = (cmd) => readFileSync(join(ROOT, 'commands', `${cmd}.md`), 'utf8');
|
|
||||||
|
|
||||||
/** Whitespace (line breaks too) collapsed to one space. */
|
/** Whitespace (line breaks too) collapsed to one space. */
|
||||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
/** The command's one CLI line, `[value]` filled in with `value` (left out when ''). */
|
/** The command's one CLI line, `[value]` filled in with `value` (left out when ''). */
|
||||||
function gatesLine(cmd, value) {
|
function gatesLine(cmd, value) {
|
||||||
const hits = commandText(cmd).split('\n')
|
const hits = read(`commands/${cmd}.md`).split('\n')
|
||||||
.filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/autonomy-gate\.mjs\b/.test(l));
|
.filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/autonomy-gate\.mjs\b/.test(l));
|
||||||
assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs line; found ${hits.length}`);
|
assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs line; found ${hits.length}`);
|
||||||
const l = hits[0].trim();
|
const l = hits[0].trim();
|
||||||
|
|
@ -155,7 +101,7 @@ function lineGates(cmdLine) {
|
||||||
|
|
||||||
for (const cmd of GATES_COMMANDS) {
|
for (const cmd of GATES_COMMANDS) {
|
||||||
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a CLI line that maps closed to gates true`, () => {
|
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a CLI line that maps closed to gates true`, () => {
|
||||||
const text = commandText(cmd);
|
const text = read(`commands/${cmd}.md`);
|
||||||
const f = flat(text);
|
const f = flat(text);
|
||||||
for (const s of GATES_RULE) assert.ok(f.includes(s), `${cmd}: missing the shared --gates sentence: ${s}`);
|
for (const s of GATES_RULE) assert.ok(f.includes(s), `${cmd}: missing the shared --gates sentence: ${s}`);
|
||||||
assert.doesNotMatch(text, /--state \w+ --event \w+/, `${cmd}: the state-machine line is still there`);
|
assert.doesNotMatch(text, /--state \w+ --event \w+/, `${cmd}: the state-machine line is still there`);
|
||||||
|
|
@ -178,27 +124,8 @@ test('every command\'s CLI line maps a bare --gates to true and open to false',
|
||||||
// flag and pauses on nothing; the docs give the four values and no strictness
|
// flag and pauses on nothing; the docs give the four values and no strictness
|
||||||
// policy (fix round 4-5a, #1).
|
// policy (fix round 4-5a, #1).
|
||||||
|
|
||||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
|
||||||
const EXEC = 'commands/trekexecute.md';
|
const EXEC = 'commands/trekexecute.md';
|
||||||
|
|
||||||
/**
|
|
||||||
* The text of a heading's section, up to the next heading of the same or a
|
|
||||||
* higher level outside a fence. The heading must exist.
|
|
||||||
*/
|
|
||||||
function section(text, heading) {
|
|
||||||
const level = heading.match(/^#+/)[0].length;
|
|
||||||
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
|
||||||
assert.ok(s, `missing ${heading}`);
|
|
||||||
const lines = s.text.split('\n');
|
|
||||||
let fenced = false;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
|
||||||
const m = !fenced && lines[i].match(/^(#+) /);
|
|
||||||
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
|
||||||
}
|
|
||||||
return s.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GATES_ONE_PAUSE = '`gates_mode = true` adds one pause, before the auto chain starts (Phase 5); the chain does not pass '
|
const GATES_ONE_PAUSE = '`gates_mode = true` adds one pause, before the auto chain starts (Phase 5); the chain does not pass '
|
||||||
+ '`--gates` on to `/trekresearch` or `/trekplan`. Default `gates_mode = false` skips that pause.';
|
+ '`--gates` on to `/trekresearch` or `/trekplan`. Default `gates_mode = false` skips that pause.';
|
||||||
|
|
||||||
|
|
@ -210,14 +137,14 @@ test('[#1] sibling: commands/trekbrief.md promises no execute pause and no pause
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[#1] sibling: /trekbrief Phase 1 says --gates adds one pause, before the auto chain', () => {
|
test('[#1] sibling: /trekbrief Phase 1 says --gates adds one pause, before the auto chain', () => {
|
||||||
const p1 = flat(section(read('commands/trekbrief.md'), '## Phase 1 — Parse mode and validate input'));
|
const p1 = flat(sectionText(read('commands/trekbrief.md'), '## Phase 1 — Parse mode and validate input'));
|
||||||
assert.ok(p1.includes(GATES_ONE_PAUSE), 'Phase 1 does not say --gates adds one pause before the auto chain');
|
assert.ok(p1.includes(GATES_ONE_PAUSE), 'Phase 1 does not say --gates adds one pause before the auto chain');
|
||||||
assert.ok(!p1.includes('every phase boundary in the downstream pipeline'), 'Phase 1 still promises a pause at every downstream phase boundary');
|
assert.ok(!p1.includes('every phase boundary in the downstream pipeline'), 'Phase 1 still promises a pause at every downstream phase boundary');
|
||||||
assert.ok(!p1.includes('runs continuously until the main-merge gate'), 'Phase 1 still says the auto chain runs until the main-merge gate');
|
assert.ok(!p1.includes('runs continuously until the main-merge gate'), 'Phase 1 still says the auto chain runs until the main-merge gate');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[#1] sibling: /trekbrief Phase 5 says the auto chain ends at the plan, never at the main-merge gate', () => {
|
test('[#1] sibling: /trekbrief Phase 5 says the auto chain ends at the plan, never at the main-merge gate', () => {
|
||||||
const p5 = flat(section(read('commands/trekbrief.md'), '## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)'));
|
const p5 = flat(sectionText(read('commands/trekbrief.md'), '## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)'));
|
||||||
assert.ok(p5.includes('The chain ends when `/trekplan` has written the plan (Step 6e); it never starts `/trekexecute`.'),
|
assert.ok(p5.includes('The chain ends when `/trekplan` has written the plan (Step 6e); it never starts `/trekexecute`.'),
|
||||||
'Phase 5 does not say the chain ends when /trekplan has written the plan');
|
'Phase 5 does not say the chain ends when /trekplan has written the plan');
|
||||||
assert.ok(!p5.includes('The chain stops only at the main-merge gate'), 'Phase 5 still says the chain stops only at the main-merge gate');
|
assert.ok(!p5.includes('The chain stops only at the main-merge gate'), 'Phase 5 still says the chain stops only at the main-merge gate');
|
||||||
|
|
@ -263,7 +190,7 @@ test('[#1] sibling: docs/command-modes.md gives every --gates row the four value
|
||||||
|
|
||||||
test('[#1] sibling: README.md gives the four values, and no strictness table', () => {
|
test('[#1] sibling: README.md gives the four values, and no strictness table', () => {
|
||||||
const text = read('README.md');
|
const text = read('README.md');
|
||||||
const sec = flat(section(text, '### Autonomy mode (`--gates`, v3.4.0)'));
|
const sec = flat(sectionText(text, '### Autonomy mode (`--gates`, v3.4.0)'));
|
||||||
assert.doesNotMatch(text, /\{open\|closed\|adaptive\}/);
|
assert.doesNotMatch(text, /\{open\|closed\|adaptive\}/);
|
||||||
assert.doesNotMatch(sec, /\| Value \| Behavior \|/, 'the strictness table is still there');
|
assert.doesNotMatch(sec, /\| Value \| Behavior \|/, 'the strictness table is still there');
|
||||||
assert.doesNotMatch(sec, /`adaptive` \(default\)/);
|
assert.doesNotMatch(sec, /`adaptive` \(default\)/);
|
||||||
|
|
|
||||||
|
|
@ -288,22 +288,20 @@ test('Phase 9 prose mandates parallel single-message dispatch + inline dedup', (
|
||||||
// The read-only reviewers (plan-critic, scope-guardian: Read/Glob/Grep, no Write)
|
// The read-only reviewers (plan-critic, scope-guardian: Read/Glob/Grep, no Write)
|
||||||
// cannot write the temp files the old prose told them to. Pin the A′ fix so the
|
// cannot write the temp files the old prose told them to. Pin the A′ fix so the
|
||||||
// broken /tmp-write instruction cannot creep back:
|
// broken /tmp-write instruction cannot creep back:
|
||||||
// 1. Phase-9 prose in BOTH docs must use --stdin, not the dead /tmp/*-out.json
|
// 1. The Phase-9 prose of commands/trekplan.md must use --stdin, not the dead
|
||||||
// file path the reviewers can't produce.
|
// /tmp/*-out.json file path the reviewers can't produce.
|
||||||
// 2. BOTH reviewer agents must declare a machine-readable json findings block
|
// 2. BOTH reviewer agents must declare a machine-readable json findings block
|
||||||
// with the keys the dedup helper consumes (agent + findings + rule_key).
|
// with the keys the dedup helper consumes (agent + findings + rule_key).
|
||||||
test('S23: Phase-9 dedup hand-off uses --stdin and the reviewers emit a json findings block', () => {
|
test('S23: Phase-9 dedup hand-off uses --stdin and the reviewers emit a json findings block', () => {
|
||||||
const cmd = read('commands/trekplan.md');
|
const cmd = read('commands/trekplan.md');
|
||||||
for (const [name, doc] of [['commands/trekplan.md', cmd]]) {
|
assert.ok(
|
||||||
assert.ok(
|
cmd.includes('--stdin'),
|
||||||
doc.includes('--stdin'),
|
'commands/trekplan.md Phase 9 must pipe reviewer findings into plan-review-dedup.mjs via --stdin (reviewers are read-only)',
|
||||||
`${name} Phase 9 must pipe reviewer findings into plan-review-dedup.mjs via --stdin (reviewers are read-only)`,
|
);
|
||||||
);
|
assert.ok(
|
||||||
assert.ok(
|
!cmd.includes('/tmp/plan-critic-out.json') && !cmd.includes('/tmp/scope-guardian-out.json'),
|
||||||
!doc.includes('/tmp/plan-critic-out.json') && !doc.includes('/tmp/scope-guardian-out.json'),
|
'commands/trekplan.md must NOT instruct the read-only reviewers to Write /tmp/*-out.json — they have no Write tool (defect #1)',
|
||||||
`${name} must NOT instruct the read-only reviewers to Write /tmp/*-out.json — they have no Write tool (defect #1)`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const agentFile of ['agents/plan-critic.md', 'agents/scope-guardian.md']) {
|
for (const agentFile of ['agents/plan-critic.md', 'agents/scope-guardian.md']) {
|
||||||
const a = read(agentFile);
|
const a = read(agentFile);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
||||||
|
|
@ -291,9 +291,7 @@ test('[ENG] a finding returned under a trimmed title is not put back twice; an e
|
||||||
|
|
||||||
// --- the lib row rule ------------------------------------------------
|
// --- the lib row rule ------------------------------------------------
|
||||||
|
|
||||||
test('[ENG] lib rowOf and trimmedTitle are exported; an untitled finding gets a key no titled row has', () => {
|
test('[ENG] lib rowOf: an untitled finding gets a key no titled row has', () => {
|
||||||
assert.equal(typeof compareMod.rowOf, 'function', 'engine-compare.mjs exports rowOf');
|
|
||||||
assert.equal(typeof compareMod.trimmedTitle, 'function', 'engine-compare.mjs exports trimmedTitle');
|
|
||||||
const untitled = { file: 'src/a.mjs', line: 1, rule_key: 'CODE_REVIEW_DEFECT' };
|
const untitled = { file: 'src/a.mjs', line: 1, rule_key: 'CODE_REVIEW_DEFECT' };
|
||||||
assert.notEqual(compareMod.rowOf(untitled), compareMod.rowOf({ ...untitled, title: '' }), 'an untitled finding gets a key no titled row has');
|
assert.notEqual(compareMod.rowOf(untitled), compareMod.rowOf({ ...untitled, title: '' }), 'an untitled finding gets a key no titled row has');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
|
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
|
@ -14,30 +13,36 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
|
|
||||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||||
|
|
||||||
// ---- lib/ never imports from scripts/ ----------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The import specifiers in `text` (a file at `file`) that resolve into the
|
* The import specifiers in `text`: static and side-effect imports, re-exports
|
||||||
* repo's scripts/ directory: static and side-effect imports, re-exports and
|
* and dynamic imports with a string literal.
|
||||||
* dynamic imports with a string literal.
|
|
||||||
*/
|
*/
|
||||||
function importsIntoScripts(file, text) {
|
function specifiers(text) {
|
||||||
const re = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)|\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
const re = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)|\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||||
const scripts = join(ROOT, 'scripts') + sep;
|
return [...text.matchAll(re)].map((m) => m[1] ?? m[2]);
|
||||||
const hits = [];
|
|
||||||
for (const m of text.matchAll(re)) {
|
|
||||||
const spec = m[1] ?? m[2];
|
|
||||||
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
|
||||||
if (resolve(dirname(file), spec).startsWith(scripts)) hits.push(spec);
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every .mjs file under `dir`, recursively. */
|
/**
|
||||||
|
* `file: specifier` for each import of `files` (repo-relative) that resolves
|
||||||
|
* into one of `dirs` of the repo. `textOf` reads a file's text.
|
||||||
|
*/
|
||||||
|
function forbiddenImports(files, dirs, textOf = read) {
|
||||||
|
const out = [];
|
||||||
|
for (const rel of files) {
|
||||||
|
for (const spec of specifiers(textOf(rel))) {
|
||||||
|
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
||||||
|
const target = relative(ROOT, resolve(ROOT, dirname(rel), spec)).split(sep).join('/');
|
||||||
|
if (dirs.some((d) => target === d || target.startsWith(`${d}/`))) out.push(`${rel}: ${spec}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every .mjs file under the repo-relative `dir`, recursively, repo-relative. */
|
||||||
function mjsFiles(dir) {
|
function mjsFiles(dir) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
for (const e of readdirSync(join(ROOT, dir), { withFileTypes: true })) {
|
||||||
const p = join(dir, e.name);
|
const p = `${dir}/${e.name}`;
|
||||||
if (e.isDirectory()) out.push(...mjsFiles(p));
|
if (e.isDirectory()) out.push(...mjsFiles(p));
|
||||||
else if (e.name.endsWith('.mjs')) out.push(p);
|
else if (e.name.endsWith('.mjs')) out.push(p);
|
||||||
}
|
}
|
||||||
|
|
@ -45,74 +50,23 @@ function mjsFiles(dir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
test('arch: lib/ never imports from scripts/, and the scan reports a planted import (control)', () => {
|
test('arch: lib/ never imports from scripts/, and the scan reports a planted import (control)', () => {
|
||||||
const planted = importsIntoScripts(join(ROOT, 'lib', 'review', 'x.mjs'), "import x from '../../scripts/release-check.mjs';\n");
|
const planted = "import x from '../../scripts/release-check.mjs';\n";
|
||||||
assert.deepEqual(planted, ['../../scripts/release-check.mjs'], 'control: the scan does not report a planted import from scripts/');
|
assert.deepEqual(forbiddenImports(['lib/review/x.mjs'], ['scripts'], () => planted),
|
||||||
const files = mjsFiles(join(ROOT, 'lib'));
|
['lib/review/x.mjs: ../../scripts/release-check.mjs'], 'control: the scan does not report a planted import from scripts/');
|
||||||
|
const files = mjsFiles('lib');
|
||||||
assert.ok(files.length > 10, `the scan found only ${files.length} files under lib/`);
|
assert.ok(files.length > 10, `the scan found only ${files.length} files under lib/`);
|
||||||
const bad = files.flatMap((f) => importsIntoScripts(f, readFileSync(f, 'utf8')).map((s) => `${f.slice(ROOT.length + 1)}: ${s}`));
|
assert.deepEqual(forbiddenImports(files, ['scripts']), [], 'lib/ imports from scripts/');
|
||||||
assert.deepEqual(bad, [], 'lib/ imports from scripts/');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- the review modules import no scripts/ or hooks/ code ---------------------
|
|
||||||
|
|
||||||
const REVIEW_LIB = [
|
const REVIEW_LIB = [
|
||||||
'lib/review/review-answers.mjs', 'lib/review/pre-existing.mjs', 'lib/review/review-rounds.mjs',
|
'lib/review/review-answers.mjs', 'lib/review/pre-existing.mjs', 'lib/review/review-rounds.mjs',
|
||||||
'lib/review/review-policy.mjs', 'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs',
|
'lib/review/review-policy.mjs', 'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs',
|
||||||
'lib/review/review-finalize.mjs', 'lib/review/merge-evidence.mjs', 'lib/util/doors.mjs',
|
'lib/review/review-finalize.mjs', 'lib/review/merge-evidence.mjs', 'lib/util/doors.mjs',
|
||||||
];
|
];
|
||||||
const FORBIDDEN_DIRS = ['scripts', 'hooks'];
|
|
||||||
const PLANTED = "import { checkReview } from '../../scripts/release-check.mjs';\n";
|
|
||||||
|
|
||||||
/** The specifiers of a module's import lines: `import … from`, bare `import '…'`, `export … from`, `import('…')`. */
|
test('arch: the review modules import no hooks/ code, and the scan reports a planted import (control)', () => {
|
||||||
function importSpecifiers(src) {
|
const planted = (rel) => (rel === REVIEW_LIB[0] ? "await import('../../hooks/scripts/pre-bash-executor.mjs');\n" : '') + read(rel);
|
||||||
const out = [];
|
assert.deepEqual(forbiddenImports(REVIEW_LIB, ['hooks'], planted),
|
||||||
for (const m of src.matchAll(/^[ \t]*import\b(?!\s*\()[^;]*?(['"])([^'"\n]+)\1/gm)) out.push(m[2]);
|
[`${REVIEW_LIB[0]}: ../../hooks/scripts/pre-bash-executor.mjs`], 'control: the scan does not report a planted import from hooks/');
|
||||||
for (const m of src.matchAll(/^[ \t]*export\s*(?:\*|\{)[^;]*?\bfrom\s*(['"])([^'"\n]+)\1/gm)) out.push(m[2]);
|
assert.deepEqual(forbiddenImports(REVIEW_LIB, ['hooks']), [], 'a review module imports hooks/ code');
|
||||||
for (const m of src.matchAll(/\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g)) out.push(m[2]);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `file: specifier` for each import of `files` (paths under `root`) that resolves into scripts/ or hooks/ of `root`. */
|
|
||||||
function forbiddenImports(root, files) {
|
|
||||||
const out = [];
|
|
||||||
for (const rel of files) {
|
|
||||||
const abs = join(root, rel);
|
|
||||||
for (const spec of importSpecifiers(readFileSync(abs, 'utf8'))) {
|
|
||||||
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
|
||||||
const target = relative(root, resolve(dirname(abs), spec)).split(sep).join('/');
|
|
||||||
if (FORBIDDEN_DIRS.some((d) => target === d || target.startsWith(`${d}/`))) out.push(`${rel}: ${spec}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('arch: the review modules import no scripts/ or hooks/ code', () => {
|
|
||||||
assert.deepEqual([...REVIEW_LIB].sort(), [
|
|
||||||
'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs', 'lib/review/merge-evidence.mjs',
|
|
||||||
'lib/review/pre-existing.mjs', 'lib/review/review-answers.mjs', 'lib/review/review-finalize.mjs',
|
|
||||||
'lib/review/review-policy.mjs', 'lib/review/review-rounds.mjs', 'lib/util/doors.mjs',
|
|
||||||
], 'the scanner reads exactly these nine files');
|
|
||||||
for (const rel of REVIEW_LIB) assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
|
||||||
|
|
||||||
// The real files: nothing.
|
|
||||||
assert.deepEqual(forbiddenImports(ROOT, REVIEW_LIB), [], 'a review module imports scripts/ or hooks/ code');
|
|
||||||
|
|
||||||
const tmp = mkdtempSync(join(tmpdir(), 'lib-imports-'));
|
|
||||||
try {
|
|
||||||
for (const rel of REVIEW_LIB) {
|
|
||||||
mkdirSync(dirname(join(tmp, rel)), { recursive: true });
|
|
||||||
writeFileSync(join(tmp, rel), read(rel));
|
|
||||||
}
|
|
||||||
// Control: the copy reads like the real files.
|
|
||||||
assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [], 'Control: the clean copy reports nothing');
|
|
||||||
// A planted import of a scripts/ module is reported.
|
|
||||||
writeFileSync(join(tmp, REVIEW_LIB[0]), PLANTED + read(REVIEW_LIB[0]));
|
|
||||||
assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [`${REVIEW_LIB[0]}: ../../scripts/release-check.mjs`],
|
|
||||||
'the scanner reports the planted import of ../../scripts/release-check.mjs');
|
|
||||||
// The clean copy again: nothing.
|
|
||||||
writeFileSync(join(tmp, REVIEW_LIB[0]), read(REVIEW_LIB[0]));
|
|
||||||
assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [], 'the clean copy again reports nothing');
|
|
||||||
} finally {
|
|
||||||
rmSync(tmp, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ const SCOPE_DIRS = ['agents/', 'commands/', 'skills/', 'templates/', 'docs/'];
|
||||||
const SCOPE_ROOT = new Set(['README.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'SECURITY.md']);
|
const SCOPE_ROOT = new Set(['README.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'SECURITY.md']);
|
||||||
|
|
||||||
// Living tracked text in scope (see the header).
|
// Living tracked text in scope (see the header).
|
||||||
export function inScope(rel) {
|
function inScope(rel) {
|
||||||
if (!rel.endsWith('.md') || /\.local\./.test(rel)) return false;
|
if (!rel.endsWith('.md') || /\.local\./.test(rel)) return false;
|
||||||
if (rel === 'CHANGELOG.md') return false;
|
if (rel === 'CHANGELOG.md') return false;
|
||||||
return SCOPE_ROOT.has(rel) || SCOPE_DIRS.some(d => rel.startsWith(d));
|
return SCOPE_ROOT.has(rel) || SCOPE_DIRS.some(d => rel.startsWith(d));
|
||||||
|
|
@ -49,7 +49,7 @@ export function inScope(rel) {
|
||||||
|
|
||||||
// Every plugin path a text names, as { line, path } with path relative to the
|
// Every plugin path a text names, as { line, path } with path relative to the
|
||||||
// repository root. `rel` is the text's own path, for relative links.
|
// repository root. `rel` is the text's own path, for relative links.
|
||||||
export function extractPaths(text, rel) {
|
function extractPaths(text, rel) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
|
|
@ -78,7 +78,7 @@ export function extractPaths(text, rel) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// "file:line path" for every named path that does not exist under root.
|
// "file:line path" for every named path that does not exist under root.
|
||||||
export function deadPaths(root, files) {
|
function deadPaths(root, files) {
|
||||||
const dead = [];
|
const dead = [];
|
||||||
for (const rel of files) {
|
for (const rel of files) {
|
||||||
const text = readFileSync(join(root, rel), 'utf-8');
|
const text = readFileSync(join(root, rel), 'utf-8');
|
||||||
|
|
@ -89,9 +89,15 @@ export function deadPaths(root, files) {
|
||||||
return dead;
|
return dead;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackedFiles(root) {
|
let scan = null;
|
||||||
const out = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf-8' });
|
|
||||||
return out.split('\0').filter(Boolean);
|
/** The tracked files and every dead hit in the in-scope ones (EXEMPT not applied), computed once. */
|
||||||
|
function repoScan() {
|
||||||
|
if (!scan) {
|
||||||
|
const files = execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf-8' }).split('\0').filter(Boolean);
|
||||||
|
scan = { files, dead: deadPaths(ROOT, files.filter(inScope)) };
|
||||||
|
}
|
||||||
|
return scan;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Named exceptions: "file path" -> reason. Each must still be dead (a stale
|
// Named exceptions: "file path" -> reason. Each must still be dead (a stale
|
||||||
|
|
@ -156,19 +162,19 @@ test('scope keeps living docs and drops history and target-repo text', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('living tracked text names only paths that exist', () => {
|
test('living tracked text names only paths that exist', () => {
|
||||||
const files = trackedFiles(ROOT);
|
const { files, dead } = repoScan();
|
||||||
assert.ok(files.length > 100, `git ls-files returned ${files.length} files; refusing to pass on an empty scan`);
|
assert.ok(files.length > 100, `git ls-files returned ${files.length} files; refusing to pass on an empty scan`);
|
||||||
const hits = deadPaths(ROOT, files.filter(inScope)).filter(h => !EXEMPT.has(exemptKey(h)));
|
const hits = dead.filter(h => !EXEMPT.has(exemptKey(h)));
|
||||||
assert.deepEqual(hits, [], `${hits.length} dead path reference(s):\n${hits.join('\n')}`);
|
assert.deepEqual(hits, [], `${hits.length} dead path reference(s):\n${hits.join('\n')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every EXEMPT entry is still a dead reference in a tracked file', () => {
|
test('every EXEMPT entry is still a dead reference in a tracked file', () => {
|
||||||
const tracked = new Set(trackedFiles(ROOT));
|
const { files, dead } = repoScan();
|
||||||
|
const tracked = new Set(files);
|
||||||
|
const deadKeys = new Set(dead.map(exemptKey));
|
||||||
for (const key of EXEMPT.keys()) {
|
for (const key of EXEMPT.keys()) {
|
||||||
const [rel, path] = key.split(' ');
|
const rel = key.split(' ')[0];
|
||||||
assert.ok(tracked.has(rel), `EXEMPT names untracked ${rel}`);
|
assert.ok(tracked.has(rel), `EXEMPT names untracked ${rel}`);
|
||||||
const dead = deadPaths(ROOT, [rel]).map(exemptKey);
|
assert.ok(deadKeys.has(key), `EXEMPT entry "${key}" is no longer dead; remove it`);
|
||||||
assert.ok(dead.includes(key), `EXEMPT entry "${key}" is no longer dead; remove it`);
|
|
||||||
assert.ok(!existsSync(join(ROOT, path)), `EXEMPT path ${path} exists`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import { fileURLToPath } from 'node:url';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { MAX_ROUNDS } from '../../lib/validators/plan-gate.mjs';
|
import { MAX_ROUNDS } from '../../lib/validators/plan-gate.mjs';
|
||||||
import { DESIGN_SUBSECTIONS, OPTIONAL_STEP_FIELDS } from '../../lib/validators/plan-validator.mjs';
|
import { DESIGN_SUBSECTIONS, OPTIONAL_STEP_FIELDS } from '../../lib/validators/plan-validator.mjs';
|
||||||
import { findSection, findSteps, PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
|
import { findSteps, PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
|
||||||
|
import { sectionText } from '../helpers/md-section.mjs';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
|
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
|
||||||
|
|
@ -134,24 +135,6 @@ test('the verification CLIs credit what they borrow and say they install nothing
|
||||||
/** Whitespace (line breaks too) collapsed to one space. */
|
/** Whitespace (line breaks too) collapsed to one space. */
|
||||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
/**
|
|
||||||
* The text of a heading's section (level from its #-count), up to the next
|
|
||||||
* heading of the same or a higher level outside a fence. The heading must exist.
|
|
||||||
*/
|
|
||||||
function sectionText(text, heading) {
|
|
||||||
const level = heading.match(/^#+/)[0].length;
|
|
||||||
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
|
||||||
assert.ok(s, `missing ${heading}`);
|
|
||||||
const lines = s.text.split('\n');
|
|
||||||
let fenced = false;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
|
||||||
const m = !fenced && lines[i].match(/^(#+) /);
|
|
||||||
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
|
||||||
}
|
|
||||||
return s.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* From the one line of `rel` holding `marker` through the end of the next
|
* From the one line of `rel` holding `marker` through the end of the next
|
||||||
* blank-line-separated paragraph after the marker's own paragraph, flattened.
|
* blank-line-separated paragraph after the marker's own paragraph, flattened.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { validateFindings } from '../../lib/review/findings-schema.mjs';
|
||||||
import { lintSkill } from '../../lib/skills/skill-lint.mjs';
|
import { lintSkill } from '../../lib/skills/skill-lint.mjs';
|
||||||
import * as routing from '../../lib/skills/routing.mjs';
|
import * as routing from '../../lib/skills/routing.mjs';
|
||||||
import { childEnv } from '../helpers/child-env.mjs';
|
import { childEnv } from '../helpers/child-env.mjs';
|
||||||
import * as timing from '../helpers/timing.mjs';
|
import { CONTROL_RUN } from '../helpers/timing.mjs';
|
||||||
|
|
||||||
const awaitImportSync = () => routing;
|
const awaitImportSync = () => routing;
|
||||||
|
|
||||||
|
|
@ -1148,12 +1148,10 @@ test('review 630 r9 / 7b3750e9 plan-schema.mjs:372: a plain path that ends with
|
||||||
// control run. Its numbers live in tests/helpers/timing.mjs (CONTROL_RUN): the
|
// control run. Its numbers live in tests/helpers/timing.mjs (CONTROL_RUN): the
|
||||||
// timeout leaves headroom over a slow start, the hang outlasts it. Every run
|
// timeout leaves headroom over a slow start, the hang outlasts it. Every run
|
||||||
// but the control starts slowly on purpose (slowStartMs), so a timeout without
|
// but the control starts slowly on purpose (slowStartMs), so a timeout without
|
||||||
// headroom fails here on demand, not only under load. BEFORE_703D87E1 is the
|
// headroom fails here on demand, not only under load.
|
||||||
// record of the numbers before the fix, taken only when the export is absent.
|
|
||||||
const BEFORE_703D87E1 = Object.freeze({ timeoutMs: 2000, hangMs: 6000, slowStartMs: 2500 });
|
|
||||||
|
|
||||||
test('review 630 r9 / brief note 3 + 703d87e1: red proof reports a control run it stopped as not-run, in the control run, when every other run starts slowly', async () => {
|
test('review 630 r9 / brief note 3 + 703d87e1: red proof reports a control run it stopped as not-run, in the control run, when every other run starts slowly', async () => {
|
||||||
const c = timing.CONTROL_RUN ?? BEFORE_703D87E1;
|
const c = CONTROL_RUN;
|
||||||
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
|
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
|
||||||
const repo = gitRepo('r630-r9-control-');
|
const repo = gitRepo('r630-r9-control-');
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { strict as assert } from 'node:assert';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
import { sectionText } from '../helpers/md-section.mjs';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||||
|
|
@ -63,24 +63,6 @@ test('run coupling: the writing-criteria quiet case is owned by run, with no sta
|
||||||
// also one with no app to launch (the "no — {reason}" form).
|
// also one with no app to launch (the "no — {reason}" form).
|
||||||
const EXEC = 'commands/trekexecute.md';
|
const EXEC = 'commands/trekexecute.md';
|
||||||
|
|
||||||
/**
|
|
||||||
* The text of a heading's section (level from its #-count), up to the next
|
|
||||||
* heading of the same or a higher level outside a fence. The heading must exist.
|
|
||||||
*/
|
|
||||||
function sectionText(text, heading) {
|
|
||||||
const level = heading.match(/^#+/)[0].length;
|
|
||||||
const s = findSection(text, heading.replace(/^#+ /, ''), level);
|
|
||||||
assert.ok(s, `missing ${heading}`);
|
|
||||||
const lines = s.text.split('\n');
|
|
||||||
let fenced = false;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
|
||||||
const m = !fenced && lines[i].match(/^(#+) /);
|
|
||||||
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
|
|
||||||
}
|
|
||||||
return s.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Phase 8 of /trekexecute, read up to Phase 9 (its report fence holds its own `## ` heading). */
|
/** Phase 8 of /trekexecute, read up to Phase 9 (its report fence holds its own `## ` heading). */
|
||||||
function phase8(text) {
|
function phase8(text) {
|
||||||
const start = text.indexOf('## Phase 8 — Final report');
|
const start = text.indexOf('## Phase 8 — Final report');
|
||||||
|
|
|
||||||
|
|
@ -107,13 +107,11 @@ test('routing: rank puts the matching description first, and a no-overlap prompt
|
||||||
assert.ok(rank('zebra quantum', corpus).every((r) => r.score === 0));
|
assert.ok(rank('zebra quantum', corpus).every((r) => r.score === 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('routing: the reader takes block-scalar agent descriptions and skips orchestrator reference docs', () => {
|
test('routing: the reader takes block-scalar agent descriptions', () => {
|
||||||
withPlugin({
|
withPlugin({
|
||||||
agents: { 'plan-critic': 'Use this agent when a plan needs adversarial review.' },
|
agents: { 'plan-critic': 'Use this agent when a plan needs adversarial review.' },
|
||||||
commands: { trekplan: 'Deep implementation planning from a task brief.' },
|
commands: { trekplan: 'Deep implementation planning from a task brief.' },
|
||||||
}, (root) => {
|
}, (root) => {
|
||||||
writeFileSync(join(root, 'agents', 'planning-orchestrator.md'),
|
|
||||||
'---\nname: planning-orchestrator\ndescription: Reference document, not a spawnable capability.\n---\n');
|
|
||||||
const ids = loadEntries(root).map((e) => e.id).sort();
|
const ids = loadEntries(root).map((e) => e.id).sort();
|
||||||
assert.deepEqual(ids, ['agent:plan-critic', 'command:trekplan']);
|
assert.deepEqual(ids, ['agent:plan-critic', 'command:trekplan']);
|
||||||
assert.equal(loadEntries(root).find((e) => e.id === 'agent:plan-critic').description.trim(),
|
assert.equal(loadEntries(root).find((e) => e.id === 'agent:plan-critic').description.trim(),
|
||||||
|
|
|
||||||
|
|
@ -721,16 +721,12 @@ const snapDir = dir => join(dir, '.git', 'voyage', 'plan-snapshots');
|
||||||
const snaps = dir => (existsSync(snapDir(dir)) ? readdirSync(snapDir(dir)).sort() : []);
|
const snaps = dir => (existsSync(snapDir(dir)) ? readdirSync(snapDir(dir)).sort() : []);
|
||||||
const sha256 = s => createHash('sha256').update(s).digest('hex');
|
const sha256 = s => createHash('sha256').update(s).digest('hex');
|
||||||
|
|
||||||
function hashOf(dir, p) {
|
// What the CLI's --hash prints for the plan at `p`, computed in-process.
|
||||||
const r = gate(dir, ['--hash', p]);
|
const hashOf = p => planHash(readFileSync(p, 'utf-8'));
|
||||||
assert.equal(r.code, 0, r.out + r.err);
|
|
||||||
assert.match(r.out.trim(), /^[0-9a-f]{64}$/);
|
|
||||||
return r.out.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One round as Phase 9 runs it: --hash, then --record with the payloads through the real dedup. */
|
/** One round as Phase 9 runs it: the --hash value, then --record with the payloads through the real dedup. */
|
||||||
function cliRound(dir, p, payload = {}, extra = []) {
|
function cliRound(dir, p, payload = {}, extra = []) {
|
||||||
const reviewed = hashOf(dir, p);
|
const reviewed = hashOf(p);
|
||||||
const r = gate(dir, ['--record', p, '--reviewed', reviewed, ...extra], JSON.stringify(dd(payload)));
|
const r = gate(dir, ['--record', p, '--reviewed', reviewed, ...extra], JSON.stringify(dd(payload)));
|
||||||
const lines = r.out.split('\n').filter(l => l !== '');
|
const lines = r.out.split('\n').filter(l => l !== '');
|
||||||
return { ...r, reviewed, lines, j: lines.length ? JSON.parse(lines[lines.length - 1]) : null };
|
return { ...r, reviewed, lines, j: lines.length ? JSON.parse(lines[lines.length - 1]) : null };
|
||||||
|
|
@ -805,7 +801,7 @@ test('20 (CLI): a refused round writes nothing: exit 2 on a stale --reviewed, ex
|
||||||
assert.deepEqual(snaps(dir), [`${r1.reviewed}.md`]);
|
assert.deepEqual(snaps(dir), [`${r1.reviewed}.md`]);
|
||||||
|
|
||||||
// exit 2: the plan changed after --hash
|
// exit 2: the plan changed after --hash
|
||||||
const stale = hashOf(dir, p);
|
const stale = hashOf(p);
|
||||||
const t2 = editA(readPlan(p));
|
const t2 = editA(readPlan(p));
|
||||||
writeFileSync(p, t2);
|
writeFileSync(p, t2);
|
||||||
const s = gate(dir, ['--record', p, '--reviewed', stale], JSON.stringify(dd()));
|
const s = gate(dir, ['--record', p, '--reviewed', stale], JSON.stringify(dd()));
|
||||||
|
|
@ -938,9 +934,9 @@ test('23 (CLI): --record on a plan outside any git repo keeps no snapshot anywhe
|
||||||
// CLI's --stdin, its stdout into the gate's --record (no in-process dedup).
|
// CLI's --stdin, its stdout into the gate's --record (no in-process dedup).
|
||||||
const DEDUP = join(ROOT, 'lib', 'review', 'plan-review-dedup.mjs');
|
const DEDUP = join(ROOT, 'lib', 'review', 'plan-review-dedup.mjs');
|
||||||
|
|
||||||
/** One round through the two CLIs: --hash, dedup --stdin on `payload`, then --record on its output. */
|
/** One round through the two CLIs: the --hash value, dedup --stdin on `payload`, then --record on its output. */
|
||||||
function pipeRound(dir, p, payload) {
|
function pipeRound(dir, p, payload) {
|
||||||
const reviewed = hashOf(dir, p);
|
const reviewed = hashOf(p);
|
||||||
const d = spawnSync(process.execPath, [DEDUP, '--stdin'], { cwd: dir, input: JSON.stringify(payload), encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
const d = spawnSync(process.execPath, [DEDUP, '--stdin'], { cwd: dir, input: JSON.stringify(payload), encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||||
assert.equal(d.status, 0, `plan-review-dedup --stdin: ${d.stdout}${d.stderr}`);
|
assert.equal(d.status, 0, `plan-review-dedup --stdin: ${d.stdout}${d.stderr}`);
|
||||||
const r = gate(dir, ['--record', p, '--reviewed', reviewed], d.stdout);
|
const r = gate(dir, ['--record', p, '--reviewed', reviewed], d.stdout);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue