Files
gstack/test/review-checklists.test.ts
Garry Tanandcapy-ai[bot] 140003be95 v1.91.72.0 feat(autoplan): unattended runner gstack-autoplan next|submit, pluggable outside voice, review checklists, spec-loop cap (multi-agent wave B-runner) (#3117)
* v1.91.70.0 feat(unattended): artifact contract, unattended session kind, findings-derived review log, gate list

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.72.0 feat(autoplan): unattended runner next|submit, pluggable outside voice, review checklists, spec-loop cap

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(b-runner): atomic lock creation in spend-ledger, judge bundle skips runner-only sections, fixture env spelling

- lib/spend-ledger.ts: create the lock by linking a complete temp file so a
  contender never reads an empty lock and breaks a live owner (CI admitted 20
  of a cap of 8 under contention); unreadable locks break only by mtime age.
- test/helpers/workflow-judge-input.ts: exclude manifest runner_only sections
  (the B4 checklists) from the judge bundle; the judge rates the interactive
  workflow. test/workflow-judge-input.test.ts expects the same set.
- test/helpers/autoplan-run-fixture.ts: spell the runner's session kind
  without the literal the free-suite environment scan rejects.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(spend-ledger): accept Windows' status-only report of the killed child

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-11 00:32:15 +00:00

111 lines
7.3 KiB
TypeScript

/**
* Plan B4: every review skill carries `sections/checklist.md` (at most 200
* lines) whose obligation table maps each required output to the heading in
* `sections/review-sections.md` that produces it, so a condensed profile can
* never drop a required output silently. The manifest's `scope` is optional
* (default `always`), every value is in the enum, the checklist is
* `runner_only` and absent from the rendered Section index (interactive
* renders ignore the field), and the runner's loader keeps the deep section
* whenever scope is uncertain. Negative controls: a row naming a heading that
* does not exist, and a required-output heading missing from the table.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { SECTION_SCOPES, sectionLoadFor, sectionScopeOff, type Scope } from '../lib/autoplan-prompts';
const ROOT = path.resolve(import.meta.dir, '..');
const SKILLS = ['plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review'] as const;
const PHASE: Record<string, 'ceo' | 'eng' | 'design' | 'dx'> = { 'plan-ceo-review': 'ceo', 'plan-eng-review': 'eng', 'plan-design-review': 'design', 'plan-devex-review': 'dx' };
const read = (...p: string[]) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
const headings = (text: string) => text.split('\n').flatMap(l => { const m = /^#{2,3}\s+(.+?)\s*$/.exec(l); return m ? [m[1]!] : []; });
/** Obligation rows: | Output | Produced by | Scope | */
function obligationRows(checklist: string): Array<{ output: string; section: string; scope: string }> {
const start = checklist.indexOf('| Output | Produced by | Scope |');
if (start < 0) throw new Error('checklist lacks the obligation table header');
return checklist.slice(start).split('\n').slice(2).filter(l => l.startsWith('|')).map(l => {
const [, output, section, scope] = l.split('|').map(c => c.trim());
return { output: output!, section: section!, scope: scope! };
});
}
/** Every row must name a heading that exists; every `### ` under Required Outputs must be mapped. */
function checkObligationMap(checklist: string, deep: string): string[] {
const failures: string[] = [];
const all = headings(deep);
const rows = obligationRows(checklist);
for (const row of rows) {
if (!all.some(h => h.startsWith(row.section))) failures.push(`row "${row.output}" names a heading that does not exist: ${row.section}`);
if (!SECTION_SCOPES.includes(row.scope as any)) failures.push(`row "${row.output}" has scope ${row.scope} outside ${SECTION_SCOPES.join('|')}`);
}
const lines = deep.split('\n');
const reqStart = lines.findIndex(l => /^## Required [Oo]utputs/.test(l));
if (reqStart < 0) return [...failures, 'review-sections.md lacks a Required Outputs section'];
let reqEnd = lines.findIndex((l, i) => i > reqStart && /^## /.test(l));
if (reqEnd < 0) reqEnd = lines.length;
const skip = /^(Stage 1|Review facts|Output reference|TODOS\.md updates|Approved Mockups)/;
for (const line of lines.slice(reqStart, reqEnd)) {
const m = /^### (.+?)\s*$/.exec(line);
if (!m || skip.test(m[1]!)) continue;
if (!rows.some(r => m[1]!.startsWith(r.section))) failures.push(`required output not in the checklist table: ${m[1]}`);
}
return failures;
}
describe('review checklists (plan B4)', () => {
for (const skill of SKILLS) {
test(`${skill}: checklist <= 200 lines, generated, and its obligation map covers Required Outputs`, () => {
const checklist = read(skill, 'sections', 'checklist.md');
expect(checklist.split('\n').length).toBeLessThanOrEqual(200);
expect(checklist.slice(0, 200)).toContain('AUTO-GENERATED');
expect(fs.existsSync(path.join(ROOT, skill, 'sections', 'checklist.md.tmpl'))).toBe(true);
expect(checkObligationMap(checklist, read(skill, 'sections', 'review-sections.md'))).toEqual([]);
expect(obligationRows(checklist).length).toBeGreaterThanOrEqual(8);
});
test(`${skill}: manifest scope is optional with always as the default, in the enum, and the checklist is runner_only and out of the Section index`, () => {
const manifest = JSON.parse(read(skill, 'sections', 'manifest.json'));
expect(manifest.note).toContain('scope');
expect(manifest.note).toContain('bin/gstack-autoplan');
const checklist = manifest.sections.find((s: any) => s.id === 'checklist');
expect(checklist).toMatchObject({ file: 'checklist.md', runner_only: true });
for (const s of manifest.sections) expect(SECTION_SCOPES).toContain(s.scope ?? 'always');
const skeleton = read(skill, 'SKILL.md');
const index = skeleton.slice(skeleton.indexOf('## Section index'), skeleton.indexOf('\n## ', skeleton.indexOf('## Section index') + 1));
expect(index).toContain('sections/review-sections.md');
expect(index).not.toContain('checklist');
});
}
test('negative controls: a row naming a missing heading fails, and an unmapped required output fails', () => {
const deep = read('plan-eng-review', 'sections', 'review-sections.md');
const checklist = read('plan-eng-review', 'sections', 'checklist.md');
const bogus = checklist.replace('| Diagrams | Diagrams | always |', '| Diagrams | Diagrams of the moon | always |');
expect(checkObligationMap(bogus, deep)).toEqual(['row "Diagrams" names a heading that does not exist: Diagrams of the moon', 'required output not in the checklist table: Diagrams']);
const dropped = checklist.replace(/\| Failure modes \|.*\n/, '');
expect(checkObligationMap(dropped, deep)).toEqual(['required output not in the checklist table: Failure modes']);
const badScope = checklist.replace('| Diagrams | Diagrams | always |', '| Diagrams | Diagrams | moon |');
expect(checkObligationMap(badScope, deep)[0]).toContain('outside');
});
test('the runner loads the checklist in light mode, keeps always sections, and falls back to the deep section when scope is uncertain', () => {
const detectedNoUi: Scope = { ui: false, dx: true, ui_matches: 0, dx_matches: 4, developer_tool: false, agent_primary: false, source: 'detected' };
const light = sectionLoadFor('ceo', detectedNoUi, 'light');
expect(light.checklist).toContain('## Checklist (runner profile)');
expect(light.loaded).toEqual(['review-sections']);
expect(light.skipped).toEqual([]);
expect(light.line).toBe('Skipped sections: none (scope: ui=no,dx=yes,source=detected)');
const full = sectionLoadFor('ceo', detectedNoUi, 'full');
expect(full.checklist).toBeNull();
expect(full.loaded).toEqual(['review-sections']);
// a scoped section is skipped only when its scope is off by flags or detected with zero matches; weak detection keeps it
expect(sectionScopeOff('ui', detectedNoUi)).toBe(true);
expect(sectionScopeOff('ui', { ...detectedNoUi, ui_matches: 1 })).toBe(false);
expect(sectionScopeOff('ui', { ...detectedNoUi, source: 'flags' })).toBe(true);
expect(sectionScopeOff('ui', { ...detectedNoUi, source: 'flags', ui: true })).toBe(false);
expect(sectionScopeOff('dx', { ...detectedNoUi, dx: false, dx_matches: 0 })).toBe(true);
expect(sectionScopeOff('dx', { ...detectedNoUi, dx: false, dx_matches: 2 })).toBe(false);
for (const s of ['always', 'db', 'perf', 'security', 'incident'] as const) expect(sectionScopeOff(s, { ...detectedNoUi, ui: false, dx: false, dx_matches: 0 })).toBe(false);
});
});