* test(#1959): add failing-first SBFL contract + Ochiai correctness tests Epic #1957 Phase 1B. Source-text-is-the-product contract tests (Ochiai formula documented, Tarantula fallback, top-N seeding, no-coverage skip logged, ranking->Evidence, Bohrbug gating) plus a behavioral Ochiai formula-correctness section: bound [0,1], max-score invariant, a known-fault fixture proving the fault ranks #1 (criterion 2), clean degradation on zero failing tests, and two fast-check properties. Failing-first: reference file and agent routing do not yet exist. * feat(#1959): add spectrum-based fault localization (Ochiai) pre-filter to gsd-debugger Epic #1957 Phase 1B. When a runnable test suite with per-test coverage exists (>=1 failing AND >=1 passing test), the debugger computes an Ochiai suspiciousness ranking over the coverage spectrum and seeds the top-N suspicious locations into Evidence as first-class hypothesis candidates, narrowing the search space deterministically before LLM reasoning. Tarantula documented as fallback. Degrades cleanly (logged, never silent) when there is no test suite, no failing tests, or no per-test coverage, and is explicitly not trusted on flaky/Heisenbug spectra (pairs with Phase 2B bug-taxonomy). Full rules extracted to gsd-core/references/debugger-sbfl.md (slim Phase 1.25 routing kept in the agent to respect the size cap). No new coverage framework — reuses the project's existing test/coverage runner. INVENTORY + manifest + agent-size baseline + install-parity goldens + AGENTS.md updated. * test(#1959): bound property generators to valid coverage counts The [0,1] property generated failedExec independently of totalFailed, but Ochiai's score is only bounded by 1 under the coverage invariant failedExec <= totalFailed (a failing test that executed s is one of the totalFailed failing tests). Out-of-domain inputs (failedExec=100, totalFailed=5) make the formula correctly return >1. Bound failedExec by totalFailed via fc.chain so the property tests the real domain. Also cleaned up the ranking property (removed dead code). * fix(#1959): address orthogonal review (monotonicity property, degradation row, coverage bounding) - Replace vacuous ranking property (true-by-sort-construction) with a non-trivial monotonicity property: holding totalFailed + passedExec fixed, ochiai is non-decreasing in failedExec. An inverted formula would fail it. - Add the missing 'no passing tests' degradation row (preconditions require >=1 passing test; Tarantula would divide by totalPassed=0). - Bound the coverage subprocess (CLAUDE.md gauntlet): cap the coverage run, degrade-to-skip on timeout, never hang the debug session. - Reword 'discard the ranking' -> 'mark the Evidence entry as revoked (do not delete)' per Kernighan auditability. * test(#1959): bound monotonicity-property generator to valid coverage (failedExecA <= totalFailed) * chore(#1959): backfill changeset pr number (PR #2403)
199 lines
8.9 KiB
JavaScript
199 lines
8.9 KiB
JavaScript
// allow-test-rule: source-text-is-the-product (see #1959)
|
|
// Agent .md + reference .md files — their text IS what the runtime loads.
|
|
// Testing text content tests the deployed SBFL contract. The pure-JS Ochiai
|
|
// tests below validate the formula the reference documents (criterion 2:
|
|
// known-faulty location in top-N). Per CONTRIBUTING.md exception matrix.
|
|
// Covers epic #1957 Phase 1B (#1959).
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
let fc;
|
|
try {
|
|
fc = require('fast-check');
|
|
} catch {
|
|
fc = null;
|
|
}
|
|
|
|
const ROOT = process.cwd();
|
|
const AGENT = path.join(ROOT, 'agents/gsd-debugger.md');
|
|
const REFERENCE = path.join(ROOT, 'gsd-core/references/debugger-sbfl.md');
|
|
|
|
// In-test canonical Ochiai implementation — validates the formula the reference
|
|
// documents. If the reference's formula drifts from real Ochiai, the contract
|
|
// tests (Ochiai named + formula shape) flag the drift, and these correctness
|
|
// tests pin what "correct Ochiai" means.
|
|
function ochiai(failedExec, passedExec, totalFailed) {
|
|
if (totalFailed <= 0) return 0;
|
|
const denom = Math.sqrt(totalFailed * (failedExec + passedExec));
|
|
return denom === 0 ? 0 : failedExec / denom;
|
|
}
|
|
|
|
function rankByOchiai(elements, totalFailed) {
|
|
return elements
|
|
.map((e) => ({ element: e.element, score: ochiai(e.failedExec, e.passedExec, totalFailed) }))
|
|
.sort((a, b) => b.score - a.score);
|
|
}
|
|
|
|
describe('spectrum-based fault localization (#1959, epic #1957 Phase 1B)', () => {
|
|
describe('reference extract exists and is wired into the agent', () => {
|
|
test('gsd-core/references/debugger-sbfl.md exists', () => {
|
|
assert.ok(fs.existsSync(REFERENCE), 'debugger-sbfl.md reference must exist');
|
|
});
|
|
|
|
test('gsd-debugger.md @-includes the SBFL reference', () => {
|
|
const content = fs.readFileSync(AGENT, 'utf8');
|
|
assert.ok(
|
|
content.includes('@~/.claude/gsd-core/references/debugger-sbfl.md'),
|
|
'gsd-debugger.md must @-include the SBFL reference'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Ochiai contract documented (criterion 1)', () => {
|
|
test('reference names Ochiai and documents the formula', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/ochiai/i.test(content), 'reference must name the Ochiai formula');
|
|
assert.ok(/sqrt/i.test(content), 'reference must document the sqrt-based formula');
|
|
assert.ok(/failed/i.test(content) && /passed/i.test(content),
|
|
'reference must reference failed(s) and passed(s) terms');
|
|
});
|
|
|
|
test('reference documents Tarantula as a fallback', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/tarantula/i.test(content), 'reference must mention Tarantula as a documented fallback');
|
|
});
|
|
|
|
test('reference documents top-N seeding into the hypothesis space', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/top-?n/i.test(content), 'reference must document top-N shortlist seeding');
|
|
});
|
|
});
|
|
|
|
describe('graceful degradation (criterion 3)', () => {
|
|
test('reference documents skip when no coverage / no test suite', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/no coverage|no test suite|coverage (?:is )?(?:absent|unavailable)/i.test(content),
|
|
'must document the no-coverage skip path');
|
|
assert.ok(/skip.*log|log.*skip|skip.*note|note.*skip/i.test(content),
|
|
'skip must be logged (not a silent pass) — Kernighan auditability');
|
|
});
|
|
});
|
|
|
|
describe('ranking written to the debug file as seed evidence (criterion 4)', () => {
|
|
test('reference documents recording the ranking under Evidence', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/evidence/i.test(content),
|
|
'reference must document that the ranking lands in the Evidence section of the debug file');
|
|
});
|
|
});
|
|
|
|
describe('pairs with Phase 2B bug-taxonomy routing', () => {
|
|
test('reference notes SBFL is gated for deterministic (Bohrbug) failures', () => {
|
|
const content = fs.readFileSync(REFERENCE, 'utf8');
|
|
assert.ok(/bohrbug|deterministic|flaky|heisenbug|mandelbug/i.test(content),
|
|
'reference must note SBFL suits deterministic bugs and is not trusted on flaky spectra (pairs with Phase 2B)');
|
|
});
|
|
});
|
|
|
|
describe('Ochiai formula correctness — behavioral (criterion 2: known-faulty location in top-N)', () => {
|
|
test('score is always in [0, 1] for any non-negative inputs (totalFailed > 0)', () => {
|
|
const fixture = [
|
|
{ failedExec: 0, passedExec: 10, totalFailed: 5 },
|
|
{ failedExec: 5, passedExec: 0, totalFailed: 5 },
|
|
{ failedExec: 3, passedExec: 7, totalFailed: 5 },
|
|
{ failedExec: 5, passedExec: 100, totalFailed: 5 },
|
|
];
|
|
for (const f of fixture) {
|
|
const s = ochiai(f.failedExec, f.passedExec, f.totalFailed);
|
|
assert.ok(s >= 0 && s <= 1, `score ${s} out of [0,1] for failedExec=${f.failedExec} passedExec=${f.passedExec} totalFailed=${f.totalFailed}`);
|
|
}
|
|
});
|
|
|
|
test('an element hit by all failing tests and no passing tests scores the maximum (1.0)', () => {
|
|
assert.strictEqual(ochiai(5, 0, 5), 1);
|
|
assert.strictEqual(ochiai(1, 0, 1), 1);
|
|
});
|
|
|
|
test('a known-faulty element ranks in the top-N of a representative fixture', () => {
|
|
// Representative fixture: 5 tests (3 failing, 2 passing), 4 code elements.
|
|
// Element "bug" is executed by all 3 failing tests and 0 passing → Ochiai 1.0 → rank #1.
|
|
const totalFailed = 3;
|
|
const elements = [
|
|
{ element: 'unrelated-a', failedExec: 1, passedExec: 2 },
|
|
{ element: 'bug', failedExec: 3, passedExec: 0 }, // the known fault
|
|
{ element: 'unrelated-b', failedExec: 2, passedExec: 2 },
|
|
{ element: 'unrelated-c', failedExec: 0, passedExec: 2 },
|
|
];
|
|
const ranked = rankByOchiai(elements, totalFailed);
|
|
const topN = ranked.slice(0, Math.min(3, ranked.length));
|
|
assert.ok(topN.length > 0, 'ranking must be non-empty');
|
|
assert.strictEqual(ranked[0].element, 'bug', 'the known-faulty element must rank #1');
|
|
assert.ok(topN.some((r) => r.element === 'bug'),
|
|
'the known-faulty element must appear in the top-N (criterion 2)');
|
|
// ranking is non-increasing
|
|
for (let i = 1; i < ranked.length; i++) {
|
|
assert.ok(ranked[i - 1].score >= ranked[i].score, 'ranking must be non-increasing');
|
|
}
|
|
});
|
|
|
|
test('degrades cleanly when there are no failing tests (totalFailed = 0 → all scores 0)', () => {
|
|
const elements = [
|
|
{ element: 'a', failedExec: 0, passedExec: 5 },
|
|
{ element: 'b', failedExec: 0, passedExec: 3 },
|
|
];
|
|
const ranked = rankByOchiai(elements, 0);
|
|
for (const r of ranked) {
|
|
assert.strictEqual(r.score, 0, 'no failing tests → every suspiciousness score is 0 (clean degradation)');
|
|
}
|
|
});
|
|
|
|
if (fc) {
|
|
test('property: ochiai ∈ [0, 1] for valid coverage counts (failedExec ≤ totalFailed)', () => {
|
|
// Ochiai is defined over coverage counts where failedExec is bounded by
|
|
// totalFailed (a failing test that executed s is one of the totalFailed
|
|
// failing tests) and passedExec is bounded by totalPassed. Generate
|
|
// in-domain inputs via chain.
|
|
const validCoverage = fc.integer({ min: 1, max: 30 }).chain((totalFailed) =>
|
|
fc.record({
|
|
totalFailed: fc.constant(totalFailed),
|
|
failedExec: fc.integer({ min: 0, max: totalFailed }),
|
|
passedExec: fc.nat(30),
|
|
})
|
|
);
|
|
fc.assert(
|
|
fc.property(validCoverage, ({ totalFailed, failedExec, passedExec }) => {
|
|
const s = ochiai(failedExec, passedExec, totalFailed);
|
|
return s >= 0 && s <= 1 + 1e-9;
|
|
}),
|
|
{ numRuns: 200 }
|
|
);
|
|
});
|
|
|
|
test('property: ochiai is non-decreasing in failedExec (holding totalFailed + passedExec fixed)', () => {
|
|
// Non-trivial: a correct Ochiai must not penalize an element for being
|
|
// hit by MORE failing tests. An inverted numerator/denominator would fail this.
|
|
// failedExecA is bounded by totalFailed (coverage invariant) via chain.
|
|
const validStart = fc.integer({ min: 1, max: 30 }).chain((totalFailed) =>
|
|
fc.record({
|
|
totalFailed: fc.constant(totalFailed),
|
|
passedExec: fc.nat(30),
|
|
failedExecA: fc.integer({ min: 0, max: totalFailed }),
|
|
})
|
|
);
|
|
fc.assert(
|
|
fc.property(validStart, ({ totalFailed, passedExec, failedExecA }) => {
|
|
const failedExecB = Math.min(totalFailed, failedExecA + 1);
|
|
if (failedExecB === failedExecA) return true; // A already at totalFailed
|
|
return ochiai(failedExecB, passedExec, totalFailed) >= ochiai(failedExecA, passedExec, totalFailed) - 1e-9;
|
|
}),
|
|
{ numRuns: 300 }
|
|
);
|
|
});
|
|
}
|
|
});
|
|
});
|