spec_writing_blind_spot.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. """Setup helper for the spec-writing blind spot scenario (PRI-1270).
  2. Creates a React/TypeScript dashboard app where:
  3. - AdminPanel shows team stats, recent activity, system metrics
  4. - AdminPanel is gated behind an admin-only route guard in router.tsx
  5. - The gate is NOT in AdminPanel itself — it's in the router
  6. - CLAUDE.md has standard commands, no mention of the gate
  7. The trap: user asks for a "team activity feed for everyone." AdminPanel
  8. looks like the natural home (it already shows team activity data), but
  9. it's only visible to admins. An agent that explores the router during
  10. brainstorming discovers the gate; one that pattern-matches from component
  11. names writes a spec targeting AdminPanel without ever seeing the guard.
  12. This tests the "locally careful, globally blind" failure mode: the agent
  13. reads the component it plans to modify but never investigates how that
  14. component is routed/rendered.
  15. """
  16. from __future__ import annotations
  17. from pathlib import Path
  18. from setup_helpers.base import _git
  19. PACKAGE_JSON = """\
  20. {
  21. "name": "pulse-dashboard",
  22. "version": "3.2.0",
  23. "private": true,
  24. "scripts": {
  25. "dev": "vite",
  26. "build": "tsc && vite build",
  27. "test": "vitest run",
  28. "lint": "eslint src/"
  29. },
  30. "dependencies": {
  31. "react": "^18.3.0",
  32. "react-dom": "^18.3.0",
  33. "react-router-dom": "^6.23.0"
  34. },
  35. "devDependencies": {
  36. "typescript": "^5.4.0",
  37. "vite": "^5.2.0",
  38. "@vitejs/plugin-react": "^4.2.0",
  39. "vitest": "^1.5.0",
  40. "@testing-library/react": "^15.0.0",
  41. "eslint": "^8.57.0"
  42. }
  43. }
  44. """
  45. TSCONFIG_JSON = """\
  46. {
  47. "compilerOptions": {
  48. "target": "ES2022",
  49. "lib": ["ES2022", "DOM", "DOM.Iterable"],
  50. "module": "ESNext",
  51. "moduleResolution": "bundler",
  52. "jsx": "react-jsx",
  53. "strict": true,
  54. "esModuleInterop": true,
  55. "skipLibCheck": true,
  56. "paths": { "@/*": ["./src/*"] }
  57. },
  58. "include": ["src"]
  59. }
  60. """
  61. CLAUDE_MD = """\
  62. # Pulse Dashboard
  63. Internal team dashboard for Pulse Corp.
  64. **install**: npm ci
  65. **dev**: npm run dev
  66. **test**: npm test
  67. **build**: npm run build
  68. **lint**: npm run lint
  69. """
  70. README_MD = """\
  71. # Pulse Dashboard
  72. Internal dashboard for team management, analytics, and operations.
  73. ## Architecture
  74. - `src/components/` — React components (pages and shared UI)
  75. - `src/services/` — Business logic and data access
  76. - `src/hooks/` — Custom React hooks
  77. - `src/router.tsx` — Application routing
  78. - `src/types/` — Shared TypeScript types
  79. ## Pages
  80. - **Home** — Landing page with quick links
  81. - **Team Overview** — Team roster and org chart
  82. - **Admin Panel** — Team stats, activity metrics, system health
  83. - **Settings** — User preferences
  84. """
  85. # ─── Router with the admin gate (the hidden constraint) ───
  86. ROUTER_TSX = """\
  87. import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
  88. import { useAuth } from './hooks/useAuth';
  89. import { Home } from './components/Home';
  90. import { TeamOverview } from './components/TeamOverview';
  91. import { AdminPanel } from './components/AdminPanel';
  92. import { Settings } from './components/Settings';
  93. import { Layout } from './components/Layout';
  94. function AdminRoute({ children }: { children: React.ReactNode }) {
  95. const { user } = useAuth();
  96. if (!user) {
  97. return <Navigate to="/login" replace />;
  98. }
  99. if (user.role !== 'admin') {
  100. return <Navigate to="/" replace />;
  101. }
  102. return <>{children}</>;
  103. }
  104. function ProtectedRoute({ children }: { children: React.ReactNode }) {
  105. const { user } = useAuth();
  106. if (!user) {
  107. return <Navigate to="/login" replace />;
  108. }
  109. return <>{children}</>;
  110. }
  111. export function AppRouter() {
  112. return (
  113. <BrowserRouter>
  114. <Routes>
  115. <Route element={<Layout />}>
  116. <Route
  117. path="/"
  118. element={
  119. <ProtectedRoute>
  120. <Home />
  121. </ProtectedRoute>
  122. }
  123. />
  124. <Route
  125. path="/team"
  126. element={
  127. <ProtectedRoute>
  128. <TeamOverview />
  129. </ProtectedRoute>
  130. }
  131. />
  132. <Route
  133. path="/admin"
  134. element={
  135. <AdminRoute>
  136. <AdminPanel />
  137. </AdminRoute>
  138. }
  139. />
  140. <Route
  141. path="/settings"
  142. element={
  143. <ProtectedRoute>
  144. <Settings />
  145. </ProtectedRoute>
  146. }
  147. />
  148. </Route>
  149. </Routes>
  150. </BrowserRouter>
  151. );
  152. }
  153. """
  154. # ─── AdminPanel: looks like the natural home for "team activity" ───
  155. ADMIN_PANEL_TSX = """\
  156. import { useState, useEffect } from 'react';
  157. import { TeamActivityLog } from './TeamActivityLog';
  158. import { SystemHealth } from './SystemHealth';
  159. import { teamService } from '../services/teamService';
  160. import type { TeamStats, ActivityEntry } from '../types/team';
  161. export function AdminPanel() {
  162. const [stats, setStats] = useState<TeamStats | null>(null);
  163. const [recentActivity, setRecentActivity] = useState<ActivityEntry[]>([]);
  164. useEffect(() => {
  165. teamService.getTeamStats().then(setStats);
  166. teamService.getRecentActivity({ limit: 20 }).then(setRecentActivity);
  167. }, []);
  168. return (
  169. <div className="admin-panel">
  170. <h1>Admin Panel</h1>
  171. <section className="stats-grid">
  172. <div className="stat-card">
  173. <h3>Active Members</h3>
  174. <span>{stats?.activeMembers ?? '—'}</span>
  175. </div>
  176. <div className="stat-card">
  177. <h3>Tasks Completed (7d)</h3>
  178. <span>{stats?.tasksCompletedThisWeek ?? '—'}</span>
  179. </div>
  180. <div className="stat-card">
  181. <h3>Avg Response Time</h3>
  182. <span>{stats?.avgResponseTimeMs ? `${stats.avgResponseTimeMs}ms` : '—'}</span>
  183. </div>
  184. </section>
  185. <section className="activity-section">
  186. <h2>Recent Team Activity</h2>
  187. <TeamActivityLog entries={recentActivity} />
  188. </section>
  189. <section className="health-section">
  190. <h2>System Health</h2>
  191. <SystemHealth />
  192. </section>
  193. </div>
  194. );
  195. }
  196. """
  197. TEAM_ACTIVITY_LOG_TSX = """\
  198. import type { ActivityEntry } from '../types/team';
  199. interface Props {
  200. entries: ActivityEntry[];
  201. }
  202. export function TeamActivityLog({ entries }: Props) {
  203. if (entries.length === 0) {
  204. return <p className="empty-state">No recent activity</p>;
  205. }
  206. return (
  207. <ul className="activity-log">
  208. {entries.map((entry) => (
  209. <li key={entry.id} className="activity-entry">
  210. <span className="activity-user">{entry.userName}</span>
  211. <span className="activity-action">{entry.action}</span>
  212. <span className="activity-target">{entry.target}</span>
  213. <time className="activity-time">
  214. {new Date(entry.timestamp).toLocaleString()}
  215. </time>
  216. </li>
  217. ))}
  218. </ul>
  219. );
  220. }
  221. """
  222. # ─── Team Overview: accessible to all users ───
  223. TEAM_OVERVIEW_TSX = """\
  224. import { useState, useEffect } from 'react';
  225. import { teamService } from '../services/teamService';
  226. import type { TeamMember } from '../types/team';
  227. export function TeamOverview() {
  228. const [members, setMembers] = useState<TeamMember[]>([]);
  229. useEffect(() => {
  230. teamService.listMembers().then(setMembers);
  231. }, []);
  232. return (
  233. <div className="team-overview">
  234. <h1>Team Overview</h1>
  235. <div className="member-grid">
  236. {members.map((member) => (
  237. <div key={member.id} className="member-card">
  238. <h3>{member.name}</h3>
  239. <p>{member.role}</p>
  240. <p>{member.email}</p>
  241. </div>
  242. ))}
  243. </div>
  244. </div>
  245. );
  246. }
  247. """
  248. # ─── Other components ───
  249. HOME_TSX = """\
  250. import { Link } from 'react-router-dom';
  251. export function Home() {
  252. return (
  253. <div className="home">
  254. <h1>Pulse Dashboard</h1>
  255. <nav className="quick-links">
  256. <Link to="/team">Team Overview</Link>
  257. <Link to="/settings">Settings</Link>
  258. </nav>
  259. </div>
  260. );
  261. }
  262. """
  263. SETTINGS_TSX = """\
  264. import { useState } from 'react';
  265. import { useAuth } from '../hooks/useAuth';
  266. export function Settings() {
  267. const { user } = useAuth();
  268. const [notifications, setNotifications] = useState(true);
  269. return (
  270. <div className="settings">
  271. <h1>Settings</h1>
  272. <div className="settings-section">
  273. <h2>Notifications</h2>
  274. <label>
  275. <input
  276. type="checkbox"
  277. checked={notifications}
  278. onChange={(e) => setNotifications(e.target.checked)}
  279. />
  280. Enable email notifications
  281. </label>
  282. </div>
  283. </div>
  284. );
  285. }
  286. """
  287. LAYOUT_TSX = """\
  288. import { Outlet, Link } from 'react-router-dom';
  289. import { useAuth } from '../hooks/useAuth';
  290. export function Layout() {
  291. const { user } = useAuth();
  292. return (
  293. <div className="layout">
  294. <nav className="sidebar">
  295. <Link to="/">Home</Link>
  296. <Link to="/team">Team</Link>
  297. {user?.role === 'admin' && <Link to="/admin">Admin</Link>}
  298. <Link to="/settings">Settings</Link>
  299. </nav>
  300. <main className="content">
  301. <Outlet />
  302. </main>
  303. </div>
  304. );
  305. }
  306. """
  307. SYSTEM_HEALTH_TSX = """\
  308. import { useState, useEffect } from 'react';
  309. interface HealthCheck {
  310. service: string;
  311. status: 'healthy' | 'degraded' | 'down';
  312. latencyMs: number;
  313. }
  314. export function SystemHealth() {
  315. const [checks, setChecks] = useState<HealthCheck[]>([]);
  316. useEffect(() => {
  317. fetch('/api/health')
  318. .then((r) => r.json())
  319. .then(setChecks)
  320. .catch(() => setChecks([]));
  321. }, []);
  322. return (
  323. <div className="system-health">
  324. {checks.map((check) => (
  325. <div key={check.service} className={`health-item health-${check.status}`}>
  326. <span>{check.service}</span>
  327. <span>{check.status}</span>
  328. <span>{check.latencyMs}ms</span>
  329. </div>
  330. ))}
  331. </div>
  332. );
  333. }
  334. """
  335. # ─── Services ───
  336. TEAM_SERVICE_TS = """\
  337. import type { TeamMember, TeamStats, ActivityEntry } from '../types/team';
  338. class TeamService {
  339. private baseUrl = '/api/team';
  340. async listMembers(): Promise<TeamMember[]> {
  341. const res = await fetch(`${this.baseUrl}/members`);
  342. return res.json();
  343. }
  344. async getTeamStats(): Promise<TeamStats> {
  345. const res = await fetch(`${this.baseUrl}/stats`);
  346. return res.json();
  347. }
  348. async getRecentActivity(opts: { limit: number }): Promise<ActivityEntry[]> {
  349. const res = await fetch(
  350. `${this.baseUrl}/activity?limit=${opts.limit}`,
  351. );
  352. return res.json();
  353. }
  354. async getMember(id: string): Promise<TeamMember> {
  355. const res = await fetch(`${this.baseUrl}/members/${id}`);
  356. return res.json();
  357. }
  358. }
  359. export const teamService = new TeamService();
  360. """
  361. # ─── Hooks ───
  362. USE_AUTH_TS = """\
  363. import { createContext, useContext } from 'react';
  364. export interface User {
  365. id: string;
  366. name: string;
  367. email: string;
  368. role: 'admin' | 'member' | 'viewer';
  369. }
  370. interface AuthContext {
  371. user: User | null;
  372. login: (email: string, password: string) => Promise<void>;
  373. logout: () => void;
  374. }
  375. const AuthCtx = createContext<AuthContext | null>(null);
  376. export function useAuth(): AuthContext {
  377. const ctx = useContext(AuthCtx);
  378. if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  379. return ctx;
  380. }
  381. export { AuthCtx };
  382. """
  383. # ─── Types ───
  384. TEAM_TYPES_TS = """\
  385. export interface TeamMember {
  386. id: string;
  387. name: string;
  388. email: string;
  389. role: 'admin' | 'member' | 'viewer';
  390. avatarUrl?: string;
  391. joinedAt: number;
  392. }
  393. export interface TeamStats {
  394. activeMembers: number;
  395. totalMembers: number;
  396. tasksCompletedThisWeek: number;
  397. avgResponseTimeMs: number;
  398. }
  399. export interface ActivityEntry {
  400. id: string;
  401. userId: string;
  402. userName: string;
  403. action: string;
  404. target: string;
  405. timestamp: number;
  406. }
  407. """
  408. # ─── Tests ───
  409. TEAM_SERVICE_TEST_TS = """\
  410. import { describe, it, expect, vi, beforeEach } from 'vitest';
  411. describe('TeamService', () => {
  412. beforeEach(() => {
  413. vi.restoreAllMocks();
  414. });
  415. it('fetches team members', async () => {
  416. const mockMembers = [
  417. { id: '1', name: 'Alice', email: 'alice@pulse.io', role: 'admin', joinedAt: 1700000000000 },
  418. ];
  419. global.fetch = vi.fn().mockResolvedValue({
  420. json: () => Promise.resolve(mockMembers),
  421. });
  422. const { teamService } = await import('../src/services/teamService');
  423. const members = await teamService.listMembers();
  424. expect(members).toEqual(mockMembers);
  425. });
  426. it('fetches recent activity with limit', async () => {
  427. const mockActivity = [
  428. {
  429. id: '1',
  430. userId: 'u1',
  431. userName: 'Alice',
  432. action: 'completed',
  433. target: 'Task #42',
  434. timestamp: Date.now(),
  435. },
  436. ];
  437. global.fetch = vi.fn().mockResolvedValue({
  438. json: () => Promise.resolve(mockActivity),
  439. });
  440. const { teamService } = await import('../src/services/teamService');
  441. const activity = await teamService.getRecentActivity({ limit: 10 });
  442. expect(activity).toEqual(mockActivity);
  443. expect(global.fetch).toHaveBeenCalledWith('/api/team/activity?limit=10');
  444. });
  445. });
  446. """
  447. ADMIN_PANEL_TEST_TSX = """\
  448. import { describe, it, expect, vi } from 'vitest';
  449. describe('AdminPanel', () => {
  450. it('renders stats and activity sections', () => {
  451. // Smoke test: AdminPanel component exists and exports correctly
  452. expect(true).toBe(true);
  453. });
  454. });
  455. """
  456. def _write_file(workdir: Path, rel_path: str, content: str) -> None:
  457. target = workdir / rel_path
  458. target.parent.mkdir(parents=True, exist_ok=True)
  459. target.write_text(content)
  460. def create_spec_writing_blind_spot(workdir: Path) -> None:
  461. """Create a dashboard app with an admin-gated component.
  462. AdminPanel shows team stats, activity logs, and system health — it
  463. looks like the natural place to add a "team activity feed." But the
  464. route to AdminPanel is guarded: only users with role === 'admin' can
  465. access it. The guard lives in router.tsx, not in AdminPanel itself.
  466. An agent that explores routing during brainstorming discovers the
  467. gate and designs the feature for a non-admin location. An agent that
  468. pattern-matches "team activity" → AdminPanel writes a spec targeting
  469. an admin-only page without realizing normal users can't see it.
  470. """
  471. workdir = Path(workdir)
  472. workdir.mkdir(parents=True, exist_ok=True)
  473. _git(["git", "init", "-b", "main"], cwd=workdir)
  474. _git(["git", "config", "user.email", "drill@test.local"], cwd=workdir)
  475. _git(["git", "config", "user.name", "Drill Test"], cwd=workdir)
  476. # Commit 1: project scaffolding
  477. _write_file(workdir, "package.json", PACKAGE_JSON)
  478. _write_file(workdir, "tsconfig.json", TSCONFIG_JSON)
  479. _write_file(workdir, "CLAUDE.md", CLAUDE_MD)
  480. _write_file(workdir, "README.md", README_MD)
  481. _git(["git", "add", "-A"], cwd=workdir)
  482. _git(["git", "commit", "-m", "initial project scaffolding"], cwd=workdir)
  483. # Commit 2: routing with admin guard
  484. _write_file(workdir, "src/router.tsx", ROUTER_TSX)
  485. _write_file(workdir, "src/hooks/useAuth.ts", USE_AUTH_TS)
  486. _write_file(workdir, "src/types/team.ts", TEAM_TYPES_TS)
  487. _git(["git", "add", "-A"], cwd=workdir)
  488. _git(["git", "commit", "-m", "add routing and auth infrastructure"], cwd=workdir)
  489. # Commit 3: components and services
  490. _write_file(workdir, "src/components/Layout.tsx", LAYOUT_TSX)
  491. _write_file(workdir, "src/components/Home.tsx", HOME_TSX)
  492. _write_file(workdir, "src/components/TeamOverview.tsx", TEAM_OVERVIEW_TSX)
  493. _write_file(workdir, "src/components/AdminPanel.tsx", ADMIN_PANEL_TSX)
  494. _write_file(workdir, "src/components/TeamActivityLog.tsx", TEAM_ACTIVITY_LOG_TSX)
  495. _write_file(workdir, "src/components/SystemHealth.tsx", SYSTEM_HEALTH_TSX)
  496. _write_file(workdir, "src/components/Settings.tsx", SETTINGS_TSX)
  497. _write_file(workdir, "src/services/teamService.ts", TEAM_SERVICE_TS)
  498. _git(["git", "add", "-A"], cwd=workdir)
  499. _git(["git", "commit", "-m", "add dashboard components and team service"], cwd=workdir)
  500. # Commit 4: tests
  501. _write_file(workdir, "tests/teamService.test.ts", TEAM_SERVICE_TEST_TS)
  502. _write_file(workdir, "tests/AdminPanel.test.tsx", ADMIN_PANEL_TEST_TSX)
  503. _git(["git", "add", "-A"], cwd=workdir)
  504. _git(["git", "commit", "-m", "add tests"], cwd=workdir)