* test(#1960): add failing-first RCA-branching contract + schema-invariant tests Epic #1957 Phase 2A. Source-text-is-the-product contract tests (fishbone >=2 categories, AND-gate, multi-cause root_cause, backward compat, reasoning checkpoint candidate_causes+and_gate fields, debugger-philosophy single-cause note, DEBUG template) plus behavioral schema-invariant checks on two fixtures: two contributing causes (AND-gate yes) -> both recorded; single-cause (AND-gate no) -> one root_cause, identical to today. Failing-first: reference, agent edits, and template note do not yet exist. * feat(#1960): add RCA branching (fishbone + AND-gate) to gsd-debugger Epic #1957 Phase 2A. Guards against 5-Whys single-cause bias: before committing root_cause, the debugger enumerates candidate causes across >=2 Ishikawa categories (code/config/environment/data) and explicitly answers an AND-gate question. When the AND-gate fires, every contributing cause is recorded, so a multi-cause fix no longer recurs via the unaddressed second cause. Resolution.root_cause may hold one OR a small set (additive; single-cause sessions are byte-identical to today). The Structured Reasoning Checkpoint gains candidate_causes + and_gate fields; debugger-philosophy.md adds the single-cause-bias trap. Full rules extracted to gsd-core/references/debugger-rca-branching.md (slim Phase 2 routing + 2 checkpoint fields kept in the agent). INVENTORY + manifest + agent-size baseline + install-parity goldens + AGENTS.md + DEBUG template updated. * fix(#1960): address orthogonal review (AND-gate self-consistency, parity guard, narrowed claim, ripples) - Reference: the collapse rule now enforces AND-gate self-consistency — and_gate=yes with a single confirmed cause is flagged as incomplete (return to Phase 3); a race/timing note clarifies such bugs bridge categories; the 'byte-identical' backward-compat claim narrowed to 'root_cause shape unchanged; reasoning_checkpoint gains 2 fields in every session'. - DEBUG.md: stale 'five-field' mirror prose -> seven-field (parallel-surface drift the reviewer flagged); new debug-session-management parity test pins the field-count claim to the gsd-debugger.md YAML keys (CRLF-safe). - Scalar-assuming consumers of set-valued root_cause updated: session-manager compact summaries (319/332), diagnose-only return (1062), archive entry (1216), ROOT CAUSE FOUND return (1322). - Test: added the AND-gate-yes/single-cause invariant + fixture; rephrased the fixture describe block honestly as a schema-invariant specification. - Phase 2 bullet phrasing clarified ('at hypothesis formation, before the Phase 4 commit'). * test(#1960): parity regex accepts word-form count ('seven-field' or '7-field') * test(#1960): parity regex counts array-valued YAML keys (no inline value) * chore(#1960): backfill changeset pr number (PR #2405)
183 lines
9.3 KiB
JavaScript
183 lines
9.3 KiB
JavaScript
// allow-test-rule: source-text-is-the-product (see #1960)
|
|
// Agent .md + reference .md + template .md files — their text IS what the
|
|
// runtime loads. Testing text content tests the deployed RCA-branching
|
|
// contract. The pure-JS schema-invariant checks below validate the data model
|
|
// the agent records (root_cause may hold a set when the AND-gate fires).
|
|
// Per CONTRIBUTING.md exception matrix. Covers epic #1957 Phase 2A (#1960).
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = process.cwd();
|
|
const AGENT = path.join(ROOT, 'agents/gsd-debugger.md');
|
|
const REFERENCE = path.join(ROOT, 'gsd-core/references/debugger-rca-branching.md');
|
|
const PHILOSOPHY = path.join(ROOT, 'gsd-core/references/debugger-philosophy.md');
|
|
const DEBUG_TEMPLATE = path.join(ROOT, 'gsd-core/templates/DEBUG.md');
|
|
|
|
// Validated data-model invariant: the RCA-branching output the agent records.
|
|
// root_causes holds every CONFIRMED candidate (one OR a small set — the AND-gate
|
|
// decides whether >1 is kept); eliminated holds the rest. The two never overlap
|
|
// and every candidate lands in exactly one bucket.
|
|
function assertRcaSchemaInvariants(output) {
|
|
assert.ok(Array.isArray(output.root_causes), 'root_causes must be a list (set-valued)');
|
|
assert.ok(Array.isArray(output.eliminated), 'eliminated must be a list');
|
|
assert.ok(output.root_causes.length >= 1, 'at least one confirmed root cause is required');
|
|
const rootIds = new Set(output.root_causes.map((c) => c.id));
|
|
const elimIds = new Set(output.eliminated.map((c) => c.id));
|
|
for (const id of rootIds) {
|
|
assert.ok(!elimIds.has(id), `cause ${id} appears in BOTH root_causes and eliminated (must be disjoint)`);
|
|
}
|
|
// AND-gate self-consistency: if the gate fired (yes — failure requires >=2
|
|
// simultaneous conditions), a single confirmed cause cannot fully account for
|
|
// the symptom; investigation is incomplete.
|
|
const andGateYes = /^yes\b/i.test(String(output.and_gate || '').trim());
|
|
if (andGateYes) {
|
|
assert.ok(
|
|
output.root_causes.length >= 2,
|
|
`and_gate=yes requires >=2 confirmed causes (got ${output.root_causes.length}) — single-cause with AND-gate=yes means investigation is incomplete`
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('RCA branching — anti-single-cause bias (#1960, epic #1957 Phase 2A)', () => {
|
|
describe('reference extract exists and is wired into Phase 2', () => {
|
|
test('gsd-core/references/debugger-rca-branching.md exists', () => {
|
|
assert.ok(fs.existsSync(REFERENCE), 'debugger-rca-branching.md reference must exist');
|
|
});
|
|
|
|
test('gsd-debugger.md Phase 2 @-includes the RCA-branching reference', () => {
|
|
const content = fs.readFileSync(AGENT, 'utf8');
|
|
assert.ok(
|
|
content.includes('@~/.claude/gsd-core/references/debugger-rca-branching.md'),
|
|
'gsd-debugger.md must @-include the RCA-branching reference from Phase 2 (or the reasoning checkpoint)'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('branch-don\'t-chain: fishbone across >=2 categories', () => {
|
|
test('reference documents the four Ishikawa categories', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/code/i.test(content), 'category: code');
|
|
assert.ok(/config/i.test(content), 'category: config');
|
|
assert.ok(/environment/i.test(content), 'category: environment');
|
|
assert.ok(/data/i.test(content), 'category: data');
|
|
assert.ok(/fishbone|ishikawa|\b>=?\s*2\s+categor/i.test(content),
|
|
'must require branching across >=2 categories (Ishikawa/fishbone)');
|
|
});
|
|
|
|
test('reference contrasts branching with a single linear 5-Whys chain', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/5-?\s*whys|five whys|linear chain|single[\s-]?cause/i.test(content),
|
|
'must name the 5-Whys / single-cause-bias failure mode being guarded against');
|
|
});
|
|
});
|
|
|
|
describe('AND-gate check (Fault Tree Analysis)', () => {
|
|
test('reference documents the AND-gate question', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/and[\s-]?gate/i.test(content), 'must name the AND-gate check');
|
|
assert.ok(/more than one|multiple|simultaneous/i.test(content),
|
|
'must ask whether the failure requires multiple contributing conditions simultaneously');
|
|
});
|
|
});
|
|
|
|
describe('root_cause may hold a set; single-cause sessions unaffected (backward compat)', () => {
|
|
test('reference documents that root_cause can record multiple contributing causes', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/root_cause|root cause/i.test(content));
|
|
assert.ok(/multiple|set|more than one|all contributing/i.test(content),
|
|
'must document that root_cause can hold multiple contributing causes when the AND-gate fires');
|
|
});
|
|
|
|
test('reference documents single-cause backward compatibility', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/single[\s-]?cause|backward|identical|unchanged/i.test(content),
|
|
'must document that single-cause sessions are unaffected (backward compatible)');
|
|
});
|
|
|
|
test('DEBUG.md template root_cause field notes it may hold a set', () => {
|
|
const content = fs.readFileSync(DEBUG_TEMPLATE, 'utf8');
|
|
assert.ok(/root_cause/.test(content), 'template must still have the root_cause field');
|
|
assert.ok(/set|multiple|contributing|one or more/i.test(content),
|
|
'DEBUG.md root_cause must note it may hold one or more contributing causes (per RCA branching)');
|
|
});
|
|
});
|
|
|
|
describe('Structured Reasoning Checkpoint gains the RCA fields', () => {
|
|
test('reasoning_checkpoint YAML includes candidate_causes + and_gate', () => {
|
|
const content = fs.readFileSync(AGENT, 'utf8');
|
|
assert.ok(/candidate_causes/.test(content),
|
|
'reasoning_checkpoint must include a candidate_causes field (the >=2-category branches)');
|
|
assert.ok(/and_gate/.test(content),
|
|
'reasoning_checkpoint must include an and_gate field (the AND-gate answer)');
|
|
});
|
|
});
|
|
|
|
describe('debugger-philosophy.md notes the single-cause-bias trap', () => {
|
|
test('philosophy reference calls out single-cause bias', () => {
|
|
const content = fs.readFileSync(PHILOSOPHY, 'utf8');
|
|
assert.ok(/single[\s-]?cause|5-?\s*whys|root[\s-]?cause bias/i.test(content),
|
|
'debugger-philosophy.md must note the single-cause-bias / 5-Whys trap alongside the existing cognitive-bias guidance');
|
|
});
|
|
});
|
|
|
|
describe('RCA output schema — invariant specification (agent-output conformance enforced by the source-text contract tests above)', () => {
|
|
// The fixtures below exercise the documented data model. They are a
|
|
// *specification* of the schema invariants (not a test of production code,
|
|
// which is prompt-level); the Tier-1 source-text contract tests carry the
|
|
// real enforcement that the agent records conformant output.
|
|
|
|
test('fixture A — two contributing causes (AND-gate yes): both recorded in root_causes', () => {
|
|
const output = {
|
|
// The agent confirmed BOTH causes (AND-gate fired — both required simultaneously).
|
|
root_causes: [
|
|
{ id: 'race-condition', category: 'code', evidence: 'two async writers, no lock' },
|
|
{ id: 'missing-index', category: 'config', evidence: 'full-table scan under load amplifies the race window' },
|
|
],
|
|
eliminated: [{ id: 'timezone', category: 'environment', evidence: 'reproduced in UTC too' }],
|
|
and_gate: 'yes — both the race AND the missing index are required to produce the observed corruption',
|
|
};
|
|
assertRcaSchemaInvariants(output);
|
|
assert.strictEqual(output.root_causes.length, 2, 'both contributing causes must be recorded (criterion 1)');
|
|
const cats = new Set(output.root_causes.map((c) => c.category));
|
|
assert.ok(cats.size >= 2, 'the confirmed causes span >=2 categories (branching is real)');
|
|
});
|
|
|
|
test('fixture B — single-cause (AND-gate no): one root_cause, identical to today (criterion 2)', () => {
|
|
const output = {
|
|
root_causes: [
|
|
{ id: 'off-by-one', category: 'code', evidence: 'loop bound < instead of <=' },
|
|
],
|
|
eliminated: [
|
|
{ id: 'config-default', category: 'config', evidence: 'unchanged in repro' },
|
|
{ id: 'env-var', category: 'environment', evidence: 'unset in repro' },
|
|
],
|
|
and_gate: 'no — the off-by-one alone fully accounts for the symptom',
|
|
};
|
|
assertRcaSchemaInvariants(output);
|
|
assert.strictEqual(output.root_causes.length, 1, 'single-cause session records exactly one root cause (criterion 2: unaffected)');
|
|
});
|
|
|
|
test('invariant: a confirmed cause can never also appear in eliminated', () => {
|
|
const bad = {
|
|
root_causes: [{ id: 'x', category: 'code', evidence: 'e' }],
|
|
eliminated: [{ id: 'x', category: 'code', evidence: 'e' }],
|
|
and_gate: 'no',
|
|
};
|
|
assert.throws(() => assertRcaSchemaInvariants(bad), /disjoint/);
|
|
});
|
|
|
|
test('invariant: AND-gate=yes with a single confirmed cause is flagged as incomplete', () => {
|
|
const incomplete = {
|
|
root_causes: [{ id: 'only-one', category: 'code', evidence: 'e' }],
|
|
eliminated: [],
|
|
and_gate: 'yes — two conditions required simultaneously',
|
|
};
|
|
assert.throws(() => assertRcaSchemaInvariants(incomplete), />=2/);
|
|
});
|
|
});
|
|
});
|