Files
f3dff9f6e8 chore: add agentic feedback loop for faster quality checks (#7483)
* docs: add agentic feedback loop design spec

Design for tightening the agentic coding feedback loop: parallel
verify script, incremental mode, missing test detection, test
decision matrix, test scaffolding, and Claude Code auto-verify hook.

* docs: add agentic feedback loop implementation plan

7-task plan covering parallel verify script, incremental mode,
missing test detection, test scaffolding, Claude Code hook,
and AGENTS.md updates.

* chore: add parallel verify script for fast quality checks

* chore: add incremental verify mode with --changed flag

* chore: add missing test detection to verify script

* chore: add test scaffolding command

Adds scripts/test-scaffold.mjs that generates test boilerplate based on
file type (hook, component, slice, utility). Registered as
`yarn workspace @safe-global/web test:scaffold <path>`.

* chore: add Claude Code stop hook for automatic verification

* docs: add fast feedback loop and test decision matrix to AGENTS.md

* fix: add --passWithNoTests to incremental test command

When --findRelatedTests finds no matching tests, Jest exits 1 by
default. Adding --passWithNoTests makes this a clean pass instead.

* fix: add --passWithNoTests to incremental test command

When --findRelatedTests finds no matching tests, Jest exits 1 by
default. Adding --passWithNoTests makes this a clean pass instead.

* docs: require "Why?" section in PR descriptions

* docs: address PR review feedback on AGENTS.md and hook docs

- Replace custom "Why?" section requirement with reference to existing
  GitHub PR template
- Clarify that the Stop hook is lightweight and early-exits when no
  source files changed

* fix: auto-detect workspace and clarify type-check scope in hook

- Hook now detects workspace (web/mobile) from changed file paths
  instead of hardcoding --workspace=web
- Updated AGENTS.md to clarify that type-check runs on the full
  project (TSC requires it) while lint/prettier/tests are incremental

* fix: address code review issues in verify scripts

- Add SKIP_VERIFY env var to disable the hook
- Handle mixed web+mobile changes by running both workspaces
- Add warning logs for git failures instead of silent catches
- Remove unreachable code

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-03-20 15:12:33 +01:00

192 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { resolve, basename, dirname, relative, extname, join } from 'path'
const filePath = process.argv[2]
if (!filePath) {
console.error('Usage: test-scaffold <file-path>')
console.error('Example: yarn workspace @safe-global/web test:scaffold src/hooks/useMyHook.ts')
process.exit(1)
}
const cwd = process.cwd()
const absolutePath = resolve(cwd, filePath)
if (!existsSync(absolutePath)) {
console.error(`File not found: ${absolutePath}`)
process.exit(1)
}
const fileName = basename(absolutePath)
const fileDir = dirname(absolutePath)
const ext = extname(fileName)
const nameWithoutExt = fileName.replace(ext, '')
// Determine test type from file name
function getTestType(name, extension) {
if (name.startsWith('use') && extension === '.ts') return 'hook'
if (extension === '.tsx') return 'component'
if (name.endsWith('Slice') && extension === '.ts') return 'slice'
return 'util'
}
const testType = getTestType(nameWithoutExt, ext)
// Determine test file location
const testsDir = join(fileDir, '__tests__')
const hasTestsDir = existsSync(testsDir)
const testExt = testType === 'component' ? '.test.tsx' : '.test.ts'
const testFilePath = hasTestsDir
? join(testsDir, `${nameWithoutExt}${testExt}`)
: join(fileDir, `${nameWithoutExt}${testExt}`)
if (existsSync(testFilePath)) {
console.log('Test file already exists')
process.exit(0)
}
// Read source file
const source = readFileSync(absolutePath, 'utf-8')
// Extract exported names
const exportRegex = /export\s+(?:default\s+)?(?:const|function|class)\s+(\w+)/g
const exports = []
let match
while ((match = exportRegex.exec(source)) !== null) {
exports.push(match[1])
}
const primaryExport = exports[0] || nameWithoutExt
// Detect common mockable imports
const mockableImports = []
const mockPatterns = [
{ pattern: /@\/hooks\/useSafeInfo/, mock: '@/hooks/useSafeInfo' },
{ pattern: /@\/hooks\/useChainId/, mock: '@/hooks/useChainId' },
{ pattern: /@\/hooks\/useWallet/, mock: '@/hooks/useWallet' },
{ pattern: /@\/store/, mock: '@/store' },
]
for (const { pattern, mock } of mockPatterns) {
if (pattern.test(source)) {
mockableImports.push(mock)
}
}
// Calculate relative import path from test file to source file
const testDir = dirname(testFilePath)
let importPath = relative(testDir, absolutePath)
if (!importPath.startsWith('.')) {
importPath = './' + importPath
}
// Remove extension
importPath = importPath.replace(/\.(tsx?|jsx?)$/, '')
// Generate test file content
function generateHookTest() {
const lines = []
lines.push("import { renderHook } from '@/tests/test-utils'")
lines.push(`import { ${primaryExport} } from '${importPath}'`)
lines.push('')
for (const mock of mockableImports) {
lines.push(`jest.mock('${mock}')`)
}
if (mockableImports.length > 0) lines.push('')
lines.push('beforeEach(() => {')
lines.push(' jest.clearAllMocks()')
lines.push('})')
lines.push('')
lines.push(`describe('${primaryExport}', () => {`)
lines.push(` it('should work correctly', () => {`)
lines.push(` const { result } = renderHook(() => ${primaryExport}())`)
lines.push(' // TODO: add assertions')
lines.push(' })')
lines.push('})')
lines.push('')
return lines.join('\n')
}
function generateComponentTest() {
const lines = []
lines.push("import { render, screen } from '@/tests/test-utils'")
lines.push(`import ${primaryExport} from '${importPath}'`)
lines.push('')
for (const mock of mockableImports) {
lines.push(`jest.mock('${mock}')`)
}
if (mockableImports.length > 0) lines.push('')
lines.push(`describe('${primaryExport}', () => {`)
lines.push(` it('should render', () => {`)
lines.push(` render(<${primaryExport} />)`)
lines.push(' // TODO: add assertions')
lines.push(' })')
lines.push('})')
lines.push('')
return lines.join('\n')
}
function generateSliceTest() {
const lines = []
lines.push(`import { ${primaryExport} } from '${importPath}'`)
lines.push('')
lines.push(`describe('${primaryExport}', () => {`)
lines.push(` it('should handle initial state', () => {`)
lines.push(` // TODO: add assertions`)
lines.push(` // const store = configureStore({ reducer: { [${primaryExport}.name]: ${primaryExport}.reducer } })`)
lines.push(` // store.dispatch(someAction())`)
lines.push(` // expect(store.getState()).toEqual(/* expected */)`)
lines.push(' })')
lines.push('})')
lines.push('')
return lines.join('\n')
}
function generateUtilTest() {
const lines = []
if (exports.length === 1) {
lines.push(`import { ${primaryExport} } from '${importPath}'`)
} else if (exports.length > 1) {
lines.push(`import { ${exports.join(', ')} } from '${importPath}'`)
} else {
lines.push(`import { ${nameWithoutExt} } from '${importPath}'`)
}
lines.push('')
lines.push(`describe('${primaryExport}', () => {`)
const testExports = exports.length > 0 ? exports : [nameWithoutExt]
for (const exp of testExports) {
lines.push(` it('${exp} should work correctly', () => {`)
lines.push(' // TODO: add assertions')
lines.push(` // const result = ${exp}(/* args */)`)
lines.push(' // expect(result).toBe(/* expected */)')
lines.push(' })')
if (exp !== testExports[testExports.length - 1]) lines.push('')
}
lines.push('})')
lines.push('')
return lines.join('\n')
}
const generators = {
hook: generateHookTest,
component: generateComponentTest,
slice: generateSliceTest,
util: generateUtilTest,
}
const content = generators[testType]()
writeFileSync(testFilePath, content, 'utf-8')
console.log(`Created ${testType} test: ${testFilePath}`)