fix: resolve TypeScript build errors (#177)

- Add RunMode type and QaGateState interface to shared task.types.ts
- Add runMode and qaGate optional fields to Task and UpdateTaskInput interfaces
- Mirror changes in shared/src/types/task.types.d.ts (used by web bundler)
- Add RunModeGateSection.tsx component (was untracked, causing web build failure)
- Add qa-gate.test.ts and dependency-cycle.test.ts (untracked test files)
This commit is contained in:
Brad Groux 2026-03-19 15:06:23 -05:00
parent 0b23cf83e5
commit c4c21459d4
4 changed files with 450 additions and 0 deletions

View file

@ -0,0 +1,146 @@
/**
* QA Gate & Run Mode Unit tests for the blocking logic
*
* These tests verify that:
* 1. runMode and qaGate fields are accepted in the updateTaskSchema
* 2. The QA gate check correctly identifies when a task is blocked from done
*
* We test the logic layer directly (not via HTTP) to avoid the
* singleton service wiring complexity of the route layer.
*/
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import type { QaGateState } from '@veritas-kanban/shared';
// ─── Re-create the minimal schemas from the route (mirrors tasks.ts) ──────────
const runModeSchema = z
.enum(['strategy', 'eng-review', 'paranoid-review', 'qa'])
.optional()
.nullable();
const qaGateSchema = z
.object({
required: z.boolean(),
passed: z.boolean(),
passedAt: z.string().optional(),
passedBy: z.string().optional(),
})
.optional()
.nullable();
const patchSchema = z.object({
status: z.enum(['todo', 'in-progress', 'blocked', 'done']).optional(),
runMode: runModeSchema,
qaGate: qaGateSchema,
});
// ─── QA gate blocking logic (mirrors tasks.ts) ────────────────────────────────
function wouldBeBlockedByQaGate(
existingGate: QaGateState | null | undefined,
incomingGate: QaGateState | null | undefined,
newStatus: string | undefined
): boolean {
if (newStatus !== 'done') return false;
const merged = incomingGate !== undefined ? incomingGate : existingGate;
return !!(merged?.required && !merged?.passed);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('runMode schema', () => {
it('accepts valid run modes', () => {
for (const mode of ['strategy', 'eng-review', 'paranoid-review', 'qa']) {
const result = runModeSchema.safeParse(mode);
expect(result.success, `mode: ${mode}`).toBe(true);
}
});
it('rejects invalid run modes', () => {
const result = runModeSchema.safeParse('auto-ship');
expect(result.success).toBe(false);
});
it('accepts null to clear', () => {
expect(runModeSchema.safeParse(null).success).toBe(true);
});
it('accepts undefined (no-op)', () => {
expect(runModeSchema.safeParse(undefined).success).toBe(true);
});
});
describe('qaGate schema', () => {
it('accepts required+not-passed gate', () => {
const result = qaGateSchema.safeParse({ required: true, passed: false });
expect(result.success).toBe(true);
});
it('accepts required+passed gate with metadata', () => {
const result = qaGateSchema.safeParse({
required: true,
passed: true,
passedAt: '2026-03-12T21:00:00Z',
passedBy: 'brad',
});
expect(result.success).toBe(true);
});
it('accepts null to clear', () => {
expect(qaGateSchema.safeParse(null).success).toBe(true);
});
it('rejects missing required field', () => {
const result = qaGateSchema.safeParse({ passed: false });
expect(result.success).toBe(false);
});
});
describe('QA gate blocking logic', () => {
it('blocks done when required=true, passed=false on existing gate', () => {
expect(wouldBeBlockedByQaGate({ required: true, passed: false }, undefined, 'done')).toBe(true);
});
it('allows done when required=true, passed=true on existing gate', () => {
expect(wouldBeBlockedByQaGate({ required: true, passed: true }, undefined, 'done')).toBe(false);
});
it('allows done when no gate is set', () => {
expect(wouldBeBlockedByQaGate(undefined, undefined, 'done')).toBe(false);
});
it('allows done when required=false', () => {
expect(wouldBeBlockedByQaGate({ required: false, passed: false }, undefined, 'done')).toBe(
false
);
});
it('allows passing QA and moving to done in same PATCH', () => {
// Existing: required, not passed. Incoming: required, passed.
expect(
wouldBeBlockedByQaGate(
{ required: true, passed: false },
{ required: true, passed: true },
'done'
)
).toBe(false);
});
it('does not block non-done transitions', () => {
expect(
wouldBeBlockedByQaGate({ required: true, passed: false }, undefined, 'in-progress')
).toBe(false);
});
it('uses incoming gate over existing when both present', () => {
// Incoming says passed=false, should block
expect(
wouldBeBlockedByQaGate(
{ required: true, passed: true }, // existing (passed)
{ required: true, passed: false }, // incoming (revoked)
'done'
)
).toBe(true);
});
});

View file

@ -1,6 +1,15 @@
export type TaskType = string;
export type TaskStatus = 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
/** Run mode controls the review/QA strategy for a task. */
export type RunMode = 'strategy' | 'eng-review' | 'paranoid-review' | 'qa';
/** QA gate state — tracks whether a QA review is required and whether it has passed. */
export interface QaGateState {
required: boolean;
passed: boolean;
passedAt?: string;
passedBy?: string;
}
/** Built-in agent types. Custom agents use any string slug. */
export type BuiltInAgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas';
export type AgentType = BuiltInAgentType | (string & {});
@ -140,6 +149,8 @@ export interface Task {
actualCost?: number;
lessonsLearned?: string;
lessonTags?: string[];
runMode?: RunMode | null;
qaGate?: QaGateState | null;
}
export interface ReviewComment {
id: string;
@ -199,6 +210,8 @@ export interface UpdateTaskInput {
position?: number;
lessonsLearned?: string;
lessonTags?: string[];
runMode?: RunMode | null;
qaGate?: QaGateState | null;
}
export interface TaskFilters {
status?: TaskStatus | TaskStatus[];

View file

@ -3,6 +3,17 @@
export type TaskType = string;
export type TaskStatus = 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
/** Run mode controls the review/QA strategy for a task. */
export type RunMode = 'strategy' | 'eng-review' | 'paranoid-review' | 'qa';
/** QA gate state — tracks whether a QA review is required and whether it has passed. */
export interface QaGateState {
required: boolean;
passed: boolean;
passedAt?: string; // ISO timestamp when QA was approved
passedBy?: string; // Who approved (e.g. 'human', agent slug, user name)
}
/** Built-in agent types. Custom agents use any string slug. */
export type BuiltInAgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas';
export type AgentType = BuiltInAgentType | (string & {});
@ -263,6 +274,12 @@ export interface Task {
timestamp: string; // ISO timestamp when checkpoint was saved
resumeCount?: number; // How many times this task has been resumed
};
// Run mode — controls the review/QA strategy for the task
runMode?: RunMode | null;
// QA gate — requires a QA pass before the task can move to Done
qaGate?: QaGateState | null;
}
export interface ReviewComment {
@ -343,6 +360,8 @@ export interface UpdateTaskInput {
timestamp: string;
resumeCount?: number;
};
runMode?: RunMode | null;
qaGate?: QaGateState | null;
}
export interface TaskFilters {

View file

@ -0,0 +1,272 @@
/**
* RunModeGateSection v1
*
* Displays and manages:
* - Run mode (strategy / eng-review / paranoid-review / qa)
* - QA gate state (required + passed/not passed)
*
* Kept intentionally simple: no workflow engine, just a couple of selects
* and a toggle button that PATCHes the task.
*/
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { CheckCircle2, XCircle, ShieldCheck, Loader2 } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useToast } from '@/hooks/useToast';
import type { Task, RunMode, QaGateState } from '@veritas-kanban/shared';
interface RunModeGateSectionProps {
task: Task;
onUpdate: <K extends keyof Task>(field: K, value: Task[K]) => void;
readOnly?: boolean;
}
const RUN_MODE_LABELS: Record<RunMode, string> = {
strategy: '📋 Strategy',
'eng-review': '🔧 Eng Review',
'paranoid-review': '🔍 Paranoid Review',
qa: '✅ QA',
};
const RUN_MODE_DESCRIPTIONS: Record<RunMode, string> = {
strategy: 'High-level strategic review required before completion',
'eng-review': 'Engineering review (PR / code quality) required',
'paranoid-review': 'Extra-thorough review — security-sensitive or critical infra',
qa: 'QA pass required before marking done',
};
export function RunModeGateSection({ task, onUpdate, readOnly = false }: RunModeGateSectionProps) {
const { toast } = useToast();
const [isSaving, setIsSaving] = useState(false);
const runMode = task.runMode ?? null;
const qaGate = task.qaGate ?? null;
const handleRunModeChange = async (value: string) => {
const newMode = value === 'none' ? null : (value as RunMode);
setIsSaving(true);
try {
const res = await fetch(`${API_BASE}/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ runMode: newMode }),
});
if (!res.ok) throw new Error(await res.text());
onUpdate('runMode', newMode ?? undefined);
toast({ title: newMode ? `Run mode set: ${RUN_MODE_LABELS[newMode]}` : 'Run mode cleared' });
} catch (err) {
toast({
title: '❌ Failed to update run mode',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setIsSaving(false);
}
};
const handleQaRequiredToggle = async (checked: boolean) => {
const newGate: QaGateState = {
required: checked,
passed: checked ? (qaGate?.passed ?? false) : false,
passedAt: checked ? qaGate?.passedAt : undefined,
passedBy: checked ? qaGate?.passedBy : undefined,
};
setIsSaving(true);
try {
const res = await fetch(`${API_BASE}/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qaGate: newGate }),
});
if (!res.ok) throw new Error(await res.text());
onUpdate('qaGate', newGate);
toast({ title: checked ? 'QA gate enabled' : 'QA gate disabled' });
} catch (err) {
toast({
title: '❌ Failed to update QA gate',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setIsSaving(false);
}
};
const handleQaPassToggle = async () => {
if (!qaGate?.required) return;
const nowPassed = !qaGate.passed;
const newGate: QaGateState = {
required: true,
passed: nowPassed,
passedAt: nowPassed ? new Date().toISOString() : undefined,
passedBy: nowPassed ? 'human' : undefined,
};
setIsSaving(true);
try {
const res = await fetch(`${API_BASE}/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qaGate: newGate }),
});
if (!res.ok) throw new Error(await res.text());
onUpdate('qaGate', newGate);
toast({ title: nowPassed ? '✅ QA passed' : 'QA pass revoked' });
} catch (err) {
toast({
title: '❌ Failed to update QA gate',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-4">
{/* Run Mode */}
<div className="space-y-2">
<Label className="text-muted-foreground flex items-center gap-1">
<ShieldCheck className="h-3.5 w-3.5" />
Run Mode
</Label>
{readOnly ? (
<div>
{runMode ? (
<Badge variant="outline" className="text-xs">
{RUN_MODE_LABELS[runMode]}
</Badge>
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
</div>
) : (
<Select value={runMode ?? 'none'} onValueChange={handleRunModeChange} disabled={isSaving}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select run mode…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">None</span>
</SelectItem>
{(Object.keys(RUN_MODE_LABELS) as RunMode[]).map((mode) => (
<SelectItem key={mode} value={mode}>
<span className="font-medium">{RUN_MODE_LABELS[mode]}</span>
<span className="ml-2 text-xs text-muted-foreground hidden sm:inline">
{RUN_MODE_DESCRIPTIONS[mode]}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
{runMode && (
<p className="text-xs text-muted-foreground">{RUN_MODE_DESCRIPTIONS[runMode]}</p>
)}
</div>
{/* QA Gate */}
<div className="space-y-2">
<Label className="text-muted-foreground">QA Gate</Label>
{readOnly ? (
<div className="flex items-center gap-2">
{qaGate?.required ? (
qaGate.passed ? (
<Badge className="bg-green-100 text-green-800 border-green-300 text-xs flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
QA Passed
</Badge>
) : (
<Badge variant="destructive" className="text-xs flex items-center gap-1">
<XCircle className="h-3 w-3" />
QA Required Not Passed
</Badge>
)
) : (
<span className="text-xs text-muted-foreground">No QA gate</span>
)}
</div>
) : (
<div className="space-y-3">
{/* Required toggle */}
<div className="flex items-center justify-between">
<span className="text-sm">Require QA before done</span>
<Switch
checked={qaGate?.required ?? false}
onCheckedChange={handleQaRequiredToggle}
disabled={isSaving}
/>
</div>
{/* Pass/fail button — only visible when required */}
{qaGate?.required && (
<div className="flex items-center gap-3 p-3 rounded-md border bg-muted/30">
{qaGate.passed ? (
<>
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-green-700">QA Passed</p>
{qaGate.passedAt && (
<p className="text-xs text-muted-foreground">
{new Date(qaGate.passedAt).toLocaleString()}
{qaGate.passedBy ? ` · ${qaGate.passedBy}` : ''}
</p>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={handleQaPassToggle}
disabled={isSaving}
className="text-xs h-7"
>
{isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Revoke'}
</Button>
</>
) : (
<>
<XCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">QA Not Passed</p>
<p className="text-xs text-muted-foreground">
Task cannot be moved to Done until QA is approved
</p>
</div>
<Button
size="sm"
onClick={handleQaPassToggle}
disabled={isSaving}
className="text-xs h-7 bg-green-600 hover:bg-green-700"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<CheckCircle2 className="h-3 w-3 mr-1" />
Pass QA
</>
)}
</Button>
</>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}