SLOPSHOPPER

test-writing-reminder

Refuses the first write to each test file with the kinds of test this repo does not want

newguard
A shopper browsing a rack in a slop shop
README

All About Olaf

Download on the App Store

About

The St. Olaf community, now in pocket size… rewritten in React Native.

Join the Beta

Getting Started

  • Install Xcode
  • Install Mise
  • Clone the repository
  • cd into your clone
  • Run mise install to install the required tools
  • Run pnpm install --frozen-lockfile to install the required dependencies
  • For iOS, run mise run ios to launch the app in a simulator, or mise run device "Phone" to build and launch the app on a connected iPhone named "Phone"

Contributing

Welcome!

Thank you for your interest in contributing to AAO-React-Native. First, whenever interacting with the team or in general, please adhere to our Code of Conduct. Next, see CONTRIBUTING.md.

<h6>Apple, the Apple logo, iPhone, and iPad are trademarks of Apple Inc., registered in the U.S. and other countries.<br>App Store is a service mark of Apple Inc., registered in the U.S. and other countries.</h6>

Source 2 files
hooks/register.ts 127 lines
1import {atom, read, update} from 'claude-code'
2import type {EngineInterface, Register} from 'claude-code'
3
4import type {ReminderKey} from '../types'
5
6/** What the model reads when a write is refused; AGENTS.md → Testing gives the reasons. */
7export const REMINDER = `test-writing-reminder: check this test against the kinds this repo does not want (AGENTS.md → Testing):
8
91. Jest tests of look or layout: color, size, spacing, truncation, tap targets
102. Tests that check mocked behavior
113. Tests that need heavy mocking to run
124. Tests of platform behavior: what iOS, React Native, Hermes or a library does, rather than what this app decides
135. Tests that metrics or Sentry calls are sent
146. Tests that restate config
157. Data-flow UI tests
16
17If the test is none of these, send the same write again.`
18
19/** Paths that mark a file as a test in this repo. */
20const TEST_FILE_PATTERNS = [
21	/(^|\/)__tests__\//,
22	/\.test\.(ts|tsx|mjs)$/,
23	/(^|\/)uitests\/.+\.swift$/,
24	/Tests?\.swift$/,
25]
26
27/** Whether a path names a test file in this repo. */
28export function isTestFile(path: string): boolean {
29	return TEST_FILE_PATTERNS.some((pattern) => pattern.test(path))
30}
31
32/** A word of a shell command, as far as the mod needs: a path, a flag or a command name. */
33const WORD = /[^\s'"`<>|;&()=,]+/g
34
35/**
36 * Signs that a command edits files in place, where any test file it names may
37 * be the one written. Loose on purpose: a false alarm costs one resend, a miss
38 * costs the reminder.
39 */
40const IN_PLACE_EDITS = [
41	/open\([^)]*['"][wa]\+?['"]/,
42	/\bwrite_text\b/,
43	/\bwriteFile(Sync)?\(/,
44	/\bsed\s+(-\w+\s+)*-i\b/,
45	/\bperl\s+(-\w+\s+)*-\w*i/,
46]
47
48/** Whether a shell command looks like it edits files in place. */
49export function editsInPlace(command: string): boolean {
50	return IN_PLACE_EDITS.some((edit) => edit.test(command))
51}
52
53/** Where a redirect sends output: `>`, `>>` or `1>`, but not `2>&1`, `>&2`, `=>` or `->`. */
54const REDIRECT_TARGET = /(?:^|[^>=-])>>?(?!&)\s*['"]?([^\s'"`<>|;&()]+)/g
55
56/**
57 * The files a shell command sends output to: redirect targets, `tee`'s files,
58 * and the last argument of `cp` or `mv`. Only these count for such commands,
59 * so a test run piped through `tee` or a copy out of a test file is no write.
60 */
61export function destinationsIn(command: string): string[] {
62	const redirected = [...command.matchAll(REDIRECT_TARGET)].flatMap((match) => match[1] ?? [])
63	const written = command.split(/[|;&\n]/).flatMap((segment) => {
64		const words = segment.match(WORD) ?? []
65		const [name, ...args] = words[0] === 'git' ? words.slice(1) : words
66		if (name === 'tee') {
67			return args.filter((arg) => !arg.startsWith('-'))
68		}
69		if (name === 'cp' || name === 'mv') {
70			return args.slice(-1)
71		}
72		return []
73	})
74	return [...redirected, ...written]
75}
76
77/** The test files a shell command names, each once. */
78export function testFilesIn(command: string): string[] {
79	const words = command.match(WORD) ?? []
80	return [...new Set(words.filter(isTestFile))]
81}
82
83/** The test files each agent has already been reminded about this session. */
84const reminded = atom(
85	{plugin: 'test-writing-reminder', key: 'reminded'} as const,
86	[] as readonly ReminderKey[],
87)
88
89/**
90 * Marks the test files as reminded for this agent, and says whether any had
91 * not been yet. A subagent never sees the main loop's refusal, so each agent
92 * is reminded on its own.
93 */
94async function shouldRemind(
95	$: EngineInterface,
96	agentId: string | undefined,
97	paths: readonly string[],
98): Promise<boolean> {
99	const keys = paths.map((path) => `${agentId ?? 'main'}:${path.replace(/^\.\//, '')}`)
100	const seen = await read($, reminded)
101	if (keys.every((key) => seen.includes(key))) {
102		return false
103	}
104	await update($, reminded, (list) => [...new Set([...list, ...keys])])
105	return true
106}
107
108export const register: Register = (on) => {
109	on('tool.call', {tool: 'Write'}, async ($, e, next) =>
110		(await shouldRemind($, e.agentId, [e.file_path].filter(isTestFile)))
111			? {deny: REMINDER}
112			: next(e),
113	)
114
115	on('tool.call', {tool: 'Edit'}, async ($, e, next) =>
116		(await shouldRemind($, e.agentId, [e.file_path].filter(isTestFile)))
117			? {deny: REMINDER}
118			: next(e),
119	)
120
121	on('tool.call', {tool: 'Bash'}, async ($, e, next) => {
122		const edited = editsInPlace(e.command) ? testFilesIn(e.command) : []
123		const paths = [...edited, ...destinationsIn(e.command).filter(isTestFile)]
124		return (await shouldRemind($, e.agentId, paths)) ? {deny: REMINDER} : next(e)
125	})
126}
127
types/index.d.ts 9 lines
1/** `<agentId or "main">:<path as written>`: one test file one agent has been reminded about. */
2export type ReminderKey = string
3
4declare module 'claude-code' {
5	interface PluginState {
6		'test-writing-reminder': {reminded: readonly ReminderKey[]}
7	}
8}
9