Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 124 additions & 1 deletion __tests__/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
applySelfReviewDowngrade,
parseQualityGrades,
compareGrades,
parseCitations,
verifyCitations,
} from '../src/gate.js';
import type { GateVerdict, Grade } from '../src/gate.js';
import type { GateVerdict, Grade, Citation, FileReader } from '../src/gate.js';
import type { TeamRole } from '../src/types.js';

// Helper: wrap a verdict body with the TRANSCRIPTS+CITATIONS+QUALITY_GRADES
Expand Down Expand Up @@ -308,3 +310,124 @@ describe('compareGrades', () => {
expect(d.maxDrift).toBe(4);
});
});

describe('parseCitations', () => {
it('parses a block-scalar citation and stops at the next header', () => {
const out = [
'GATE_VERDICT: APPROVE',
'',
'CITATIONS:',
' - claim: auth resolves identity via Okta',
' file: src/auth/middleware.ts',
' line_range: 42-57',
' quoted_fragment: |',
' const { userId } = await oktaClient.users.getByEmail(claim.email);',
' if (!userId) throw new AuthError("unknown identity");',
'',
'QUALITY_GRADES:',
' security: A-',
].join('\n');
const cits = parseCitations(out);
expect(cits).toHaveLength(1);
expect(cits[0].file).toBe('src/auth/middleware.ts');
expect(cits[0].lineRange).toBe('42-57');
expect(cits[0].claim).toContain('Okta');
expect(cits[0].quotedFragment).toContain('oktaClient.users.getByEmail');
expect(cits[0].quotedFragment).toContain('throw new AuthError');
expect(cits[0].quotedFragment).not.toContain('QUALITY_GRADES');
});

it('parses multiple citations in one block', () => {
const out = [
'CITATIONS:',
' - claim: first',
' file: a.ts',
' line_range: 1-2',
' quoted_fragment: |',
' const a = 1;',
' - claim: second',
' file: b.ts',
' line_range: 3-4',
' quoted_fragment: |',
' const b = 2;',
].join('\n');
const cits = parseCitations(out);
expect(cits.map((c) => c.file)).toEqual(['a.ts', 'b.ts']);
expect(cits[1].quotedFragment).toBe('const b = 2;');
});

it('returns [] when no CITATIONS header is present', () => {
expect(parseCitations('GATE_VERDICT: APPROVE')).toEqual([]);
});
});

describe('verifyCitations', () => {
const cit: Citation = { claim: 'x', file: 'a.ts', lineRange: '1-2', quotedFragment: 'const a = 1;' };

it('passes when the fragment appears in the cited file', () => {
const reader: FileReader = (f) => (f === 'a.ts' ? 'line0\nconst a = 1;\nline2' : null);
expect(verifyCitations([cit], reader)[0].ok).toBe(true);
});

it('fails as fragment-not-found when the fragment is absent (fabricated)', () => {
const reader: FileReader = () => 'totally unrelated content';
const check = verifyCitations([cit], reader)[0];
expect(check.ok).toBe(false);
expect(check.status).toBe('fragment-not-found');
expect(check.reason).toContain('not found verbatim');
});

it('reports file-unreadable (not fabrication) when the cited file does not exist', () => {
const reader: FileReader = () => null;
const check = verifyCitations([cit], reader)[0];
expect(check.ok).toBe(false);
expect(check.status).toBe('file-unreadable');
});

it('is whitespace/indentation tolerant (block-scalar dedent vs real indent)', () => {
const multi: Citation = { ...cit, quotedFragment: 'const a = 1;\nconst b = 2;' };
const reader: FileReader = () => ' const a = 1;\n const b = 2;'; // indented in source
expect(verifyCitations([multi], reader)[0].ok).toBe(true);
});

it('requires the fragment lines to be contiguous and in order', () => {
const multi: Citation = { ...cit, quotedFragment: 'const a = 1;\nconst b = 2;' };
const reader: FileReader = () => 'const a = 1;\nsomething else;\nconst b = 2;';
expect(verifyCitations([multi], reader)[0].ok).toBe(false);
});
});

describe('parseGateVerdict with citation verification', () => {
const out = withEvidence('GATE_VERDICT: APPROVE');
const fragment = 'const { userId } = await oktaClient.users.getByEmail(claim.email);';
const present: FileReader = (f) => (f === 'src/auth/middleware.ts' ? `foo\n${fragment}\nbar` : null);
const absent: FileReader = () => 'unrelated content with no such line';
const missing: FileReader = () => null;

it('keeps APPROVE when the citation verifies against the file', () => {
expect(parseGateVerdict('pr-reviewer', out, { readFile: present }).verdict).toBe('APPROVE');
});

it('downgrades APPROVE to REJECT when the cited fragment is absent', () => {
const v = parseGateVerdict('pr-reviewer', out, { readFile: absent });
expect(v.verdict).toBe('REJECT');
expect(v.feedback).toContain('verbatim');
expect(v.feedback).toContain('src/auth/middleware.ts');
});

it('does NOT block APPROVE when the cited file is unreadable (path-convention/infra safe)', () => {
// A 404 or transient read failure must not be mistaken for fabrication —
// only a fragment absent from a file we DID read blocks. See gate.ts.
expect(parseGateVerdict('pr-reviewer', out, { readFile: missing }).verdict).toBe('APPROVE');
});

it('is unchanged (no verification) when no readFile is supplied', () => {
// Backward-compat: the default call path keeps presence-only behavior.
expect(parseGateVerdict('pr-reviewer', out).verdict).toBe('APPROVE');
});

it('does not verify citations on a REJECT verdict', () => {
const rej = 'GATE_VERDICT: REJECT\nGATE_FEEDBACK: secrets in plaintext';
expect(parseGateVerdict('qa-security', rej, { readFile: absent }).verdict).toBe('REJECT');
});
});
61 changes: 60 additions & 1 deletion __tests__/git.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { parseGitHubUrl, slugForBranch, createBranchIfMissing } from '../src/git.js';
import { parseGitHubUrl, slugForBranch, createBranchIfMissing, fetchRepoFile } from '../src/git.js';

describe('parseGitHubUrl', () => {
it('parses canonical https URL', () => {
Expand Down Expand Up @@ -129,3 +129,62 @@ describe('createBranchIfMissing', () => {
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer my-token');
});
});

describe('fetchRepoFile', () => {
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
});

afterEach(() => {
vi.restoreAllMocks();
});

it('decodes base64 file content on 200', async () => {
const body = 'const x = 1;\nconst y = 2;\n';
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ type: 'file', encoding: 'base64', content: Buffer.from(body).toString('base64') }),
{ status: 200 },
),
);
expect(await fetchRepoFile('tok', 'nanohype', 'protohype', 'src/x.ts', 'feat/almanac')).toBe(body);
});

it('returns null on 404 (file does not exist — a clean signal, not an error)', async () => {
fetchMock.mockResolvedValueOnce(new Response('not found', { status: 404 }));
expect(await fetchRepoFile('tok', 'o', 'r', 'missing.ts', 'feat/x')).toBeNull();
});

it('throws on a non-404 error (auth / rate-limit)', async () => {
fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
await expect(fetchRepoFile('tok', 'o', 'r', 'a.ts', 'feat/x')).rejects.toThrow(/GET contents a.ts failed \(403\)/);
});

it('throws on unsupported encoding (>1MB file returns encoding "none")', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ type: 'file', encoding: 'none', content: '' }), { status: 200 }),
);
await expect(fetchRepoFile('tok', 'o', 'r', 'big.bin', 'feat/x')).rejects.toThrow(/unsupported encoding/);
});

it('returns null when the path is a directory (array response, no type:"file")', async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify([{ type: 'file', name: 'a.ts' }]), { status: 200 }));
expect(await fetchRepoFile('tok', 'o', 'r', 'src', 'feat/x')).toBeNull();
});

it('url-encodes path segments + ref and sends the auth header', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ type: 'file', encoding: 'base64', content: Buffer.from('x').toString('base64') }), {
status: 200,
}),
);
await fetchRepoFile('my-token', 'nanohype', 'protohype', 'src/a b.ts', 'feat/almanac');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/repos/nanohype/protohype/contents/src/a%20b.ts');
expect(url).toContain('?ref=feat%2Falmanac');
expect(init.headers.Authorization).toBe('Bearer my-token');
});
});
Loading
Loading