voyage/tests/validators/brief-gate-coverage.test.mjs
Kjell Tore Guttormsen 74f75dcb7d
feat(brief): optional threat model in the brief (decision 164, 218)
A brief may carry an optional `## Threat Model` with one line per bullet,
`- Inside: <text>` or `- Outside: <text>`. Present and malformed (no such
bullet, another label or form, empty text, two sections) is
BRIEF_THREAT_MODEL_MALFORMED; absent is today's rule. No brief_version bump
(the ceremony / artifact_store precedent). Sections are found fence- and
comment-aware through intent-approval's new sectionTexts, so the validator
and the hash count the same sections; brief-validator exports
parseThreatModel for the reader.

The intent hash covers `## Threat Model` when present (two sections -> null
hash, as Intent and Goal), so an edited, added or removed threat model
makes the approval stale; a brief without one hashes exactly as before. The
stale message and the /trekplan halt table name the section.

/trekbrief offers the section in its question round when the task touches
a guard, a gate, untrusted input, secrets or publishing, writes it only
when given, shows it verbatim at the Phase 4h approval, and --approve reads
it out. The template documents it; brief-gate-coverage pairs the new code.

Part 2, step 2.1 of the E plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:47:51 +01:00

197 lines
5.8 KiB
JavaScript

// tests/validators/brief-gate-coverage.test.mjs
// SKAL-1·4a — two-sided (class-balanced) coverage for every error-severity
// BRIEF_* BLOCKER the brief-validator can emit.
//
// brief-validator.test.mjs already tests many codes; several were FAILURE-only
// (only the raising case asserted) and three were untested. This meta-test pins
// a class-balanced pair { fail, pass } for EACH in-scope BLOCKER and a parity
// guard so a code can't sit in IN_SCOPE_CODES without a paired case. It removes
// the one-sided-optimization risk programmatically, not by inspection.
//
// In scope = ERROR-severity BRIEF_* codes the validator pushes to errors[]
// (BLOCKERs). EXCLUDED: warning-severity codes (BRIEF_TLDR_TOO_LONG,
// BRIEF_VERSION_FORMAT, BRIEF_VERSION_BELOW_MINIMUM, BRIEF_PARTIAL_SKIPPED) — a
// "passing" counterpart to a warning is not a BLOCKER concern — and the
// file-level BRIEF_NOT_FOUND / BRIEF_READ_ERROR which live in validateBrief()
// (filesystem), not validateBriefContent().
//
// LIMITATION: brief-validator.mjs does not export its code set (codes are string
// literals inside issue(...)), so IN_SCOPE_CODES is HAND-MAINTAINED. A NEW
// validator BLOCKER must be added here manually; the parity guard catches
// list/case drift, not validator drift. (Operator chose the hand-maintained list
// over exporting BRIEF_ERROR_CODES from the forbidden_paths-guarded validator.)
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
// ---- valid bases (mirror tests/validators/brief-validator.test.mjs) ----------
const GOOD_BRIEF = `---
type: trekbrief
brief_version: "2.0"
created: 2026-04-30
task: "Add JWT auth to API"
slug: jwt-auth
project_dir: .claude/projects/2026-04-30-jwt-auth/
research_topics: 2
research_status: pending
auto_research: false
interview_turns: 5
source: interview
---
# Task: JWT auth
## Intent
Why this matters.
## Goal
What success looks like.
## Success Criteria
- All tests pass.
`;
const GOOD_BRIEF_22 = `---
type: trekbrief
brief_version: "2.2"
created: 2026-06-18
task: "Add JWT auth to API"
slug: jwt-auth
project_dir: .claude/projects/2026-06-18-jwt-auth/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 5
source: interview
framing: new-direction
---
# Task: JWT auth
## TL;DR
Net-new JWT auth; no prior brief to anchor against.
## Intent
Why this matters.
## Goal
What success looks like.
## Success Criteria
- All tests pass.
`;
const REVIEW_AS_BRIEF = `---
type: trekreview
task: "Review delivered trekreview v1.0"
slug: trekreview
project_dir: .claude/projects/2026-05-01-trekreview/
findings:
- 0123456789abcdef0123456789abcdef01234567
- fedcba9876543210fedcba9876543210fedcba98
---
# Review brief
## Intent
Adversarial review of delivered trekreview v1.0.
## Goal
Find what was missed.
## Success Criteria
- All BLOCKER findings get a fix-plan.
`;
// convenience builders
const withLine = (s, line) => s.replace('source: interview\n', `source: interview\n${line}`);
// ---- the canonical in-scope set + a class-balanced pair for each -------------
const IN_SCOPE_CODES = [
'BRIEF_MISSING_FIELD',
'BRIEF_BAD_STATUS',
'BRIEF_STATE_INCOHERENT',
'BRIEF_WRONG_TYPE',
'BRIEF_BAD_FINDINGS_TYPE',
'BRIEF_MISSING_SECTION',
'BRIEF_INVALID_FRAMING',
'BRIEF_MISSING_FRAMING',
'BRIEF_THREAT_MODEL_MALFORMED',
];
const CASES = {
BRIEF_MISSING_FIELD: {
fail: GOOD_BRIEF.replace(/^research_topics: 2\n/m, ''),
pass: GOOD_BRIEF,
},
BRIEF_BAD_STATUS: {
fail: GOOD_BRIEF.replace('research_status: pending', 'research_status: maybe'),
pass: GOOD_BRIEF,
},
BRIEF_STATE_INCOHERENT: {
fail: GOOD_BRIEF.replace('research_status: pending', 'research_status: skipped'),
pass: GOOD_BRIEF,
},
BRIEF_WRONG_TYPE: {
fail: GOOD_BRIEF.replace('type: trekbrief', 'type: notabrief'),
pass: GOOD_BRIEF,
},
BRIEF_BAD_FINDINGS_TYPE: {
fail: REVIEW_AS_BRIEF.replace(/findings:\n - 0123[\s\S]*?- fedcba[0-9a-f]+/, 'findings: not-an-array'),
pass: REVIEW_AS_BRIEF,
},
BRIEF_MISSING_SECTION: {
fail: GOOD_BRIEF.replace(/## Intent\n\nWhy this matters\.\n\n/, ''),
pass: GOOD_BRIEF,
},
BRIEF_INVALID_FRAMING: {
fail: withLine(GOOD_BRIEF, 'framing: sideways\n'),
pass: GOOD_BRIEF_22,
},
BRIEF_MISSING_FRAMING: {
fail: GOOD_BRIEF_22.replace('framing: new-direction\n', ''),
pass: GOOD_BRIEF_22,
},
BRIEF_THREAT_MODEL_MALFORMED: {
fail: GOOD_BRIEF_22.replace('## Success Criteria', '## Threat Model\n\n- Scope: the API\n\n## Success Criteria'),
pass: GOOD_BRIEF_22.replace('## Success Criteria', '## Threat Model\n\n- Inside: a forged token\n- Outside: a stolen signing key\n\n## Success Criteria'),
},
};
test('gate coverage — CASES keys exactly match IN_SCOPE_CODES (no silent gap)', () => {
assert.deepEqual(Object.keys(CASES).sort(), [...IN_SCOPE_CODES].sort());
});
test('gate coverage — every in-scope BRIEF_* BLOCKER raises on its fail case', () => {
for (const code of IN_SCOPE_CODES) {
const r = validateBriefContent(CASES[code].fail, { strict: true });
assert.ok(
r.errors.find((e) => e.code === code),
`${code}: fail case did NOT raise it; errors=${JSON.stringify(r.errors.map((e) => e.code))}`,
);
}
});
test('gate coverage — every in-scope BRIEF_* BLOCKER is absent on its (valid) pass case', () => {
for (const code of IN_SCOPE_CODES) {
const r = validateBriefContent(CASES[code].pass, { strict: true });
assert.ok(
!r.errors.find((e) => e.code === code),
`${code}: pass case wrongly raised it; errors=${JSON.stringify(r.errors.map((e) => e.code))}`,
);
assert.equal(r.valid, true, `${code}: pass case should be a fully valid brief; errors=${JSON.stringify(r.errors)}`);
}
});