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 agent auto-verify hook.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* docs: add agentic feedback loop implementation plan

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: add parallel verify script for fast quality checks

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: add missing test detection to verify script

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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>`.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: add agent stop hook for automatic verification

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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 <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
katspaugh
2026-03-20 15:12:33 +01:00
committed by GitHub
co-authored by Hanzo Dev
parent 0a9b65fe99
commit 0c925137d1
7 changed files with 654 additions and 1 deletions
+15 -1
View File
@@ -1 +1,15 @@
{}
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node scripts/verify-changed-hook.mjs"
}
]
}
]
}
}
+38
View File
@@ -243,6 +243,23 @@ Every code change must include tests. See [`apps/web/docs/TESTING.md`](apps/web/
## Workflow
### Fast Feedback Loop
The repo provides automated verification:
1. **Automatic**: A Claude Code `Stop` hook runs `verify:changed` once at the end of each agent turn. It early-exits (no-op) when no `.ts/.tsx/.js/.jsx` files have been modified. When it does run, type-check runs on the full project (TSC requires this), while lint, prettier, and tests are scoped to changed files only. The workspace (web/mobile) is auto-detected from the changed file paths. Set `SKIP_VERIFY=1` to disable. Fix any errors before moving on.
2. **Manual**: Run `yarn verify:changed:web` anytime to check your work. Run `yarn verify:web` for a full check before committing.
3. **Test scaffolding**: Run `yarn test:scaffold <file>` to generate a test skeleton with the correct imports, mocks, and structure. See the Test Decision Matrix in the Testing Guidelines section for which files need tests.
**Rules for agents:**
- Fix all `verify:changed` errors before proceeding to the next task
- If `verify:changed` reports a missing test, write one before committing
- Do NOT run type-check, lint, prettier, and test separately — use `verify`
- Do NOT commit without a clean `verify:changed` pass
1. **Install dependencies**: `yarn install` (from the repository root).
- Uses Yarn 4 (managed via `corepack`)
- Automatically runs `yarn after-install` for the web workspace, which generates TypeScript types from contract ABIs
@@ -301,6 +318,8 @@ Every code change must include tests. See [`apps/web/docs/TESTING.md`](apps/web/
> bundle shrinks, tests pass clean.
```
9. **PR description**: Always use the GitHub PR template (`.github/PULL_REQUEST_TEMPLATE.md`). Fill out all sections — "What it solves", "How this PR fixes it", "How to test it", and the checklist.
**Environment Variables** Web apps use `NEXT_PUBLIC_*` prefix, mobile apps use `EXPO_PUBLIC_*` prefix for environment variables. In shared packages, check for both prefixes.
## Testing Guidelines
@@ -350,6 +369,25 @@ Coverage report: `apps/web/cypress/COVERAGE.md`
- Run `yarn workspace @safe-global/web test:coverage` to generate coverage reports
- Coverage reports help identify untested code paths
### Test Decision Matrix
| What you changed | Required tests | Test type | Example |
| ---------------------------- | ------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------ |
| New hook (`use*.ts`) | Unit test with `renderHook` | `hooks/__tests__/useX.test.ts` | Mock dependencies, test return values and state changes |
| New utility/service (`*.ts`) | Unit test | `utils.test.ts` colocated | Pure function tests, edge cases, error paths |
| New component with logic | Unit test + Storybook story | `Component.test.tsx` + `Component.stories.tsx` | Render with providers, test interactions, story for visual states |
| New component (layout only) | Storybook story only | `Component.stories.tsx` | No unit test needed — story covers visual correctness |
| Redux slice | State transition test | `mySlice.test.ts` | Test reducers by dispatching actions and asserting resulting state |
| RTK Query endpoint | MSW integration test | `api.test.ts` | Use MSW to mock API, test cache behavior |
| Bug fix (any file) | Regression test | Add to existing test file | Write a test that fails without the fix, passes with it |
| Feature (new feature dir) | All of the above as applicable | Per-file rules above | Plus: add feature flag test showing disabled state |
### What NOT to test
- Type-only files, barrel re-exports, constants
- Auto-generated files (`AUTO_GENERATED/`, contract types)
- Storybook stories themselves (covered by snapshot workflow)
## Mobile Development (Expo + Tamagui)
- **UI Components** Use Tamagui components for styling and theming. Import from `tamagui` not React Native directly when possible.
+1
View File
@@ -20,6 +20,7 @@
"test": "cross-env TZ=CET LC_ALL=C DEBUG_PRINT_LIMIT=30000 NODE_ENV=test jest",
"test:ci": "cross-env NODE_ENV=test yarn test --ci --silent --coverage --json --watchAll=false --testLocationInResults --outputFile=report.json",
"test:coverage": "cross-env NODE_ENV=test yarn test --coverage --watchAll=false",
"test:scaffold": "node ../../scripts/test-scaffold.mjs",
"cmp": "./scripts/cmp.sh",
"routes": "node scripts/generate-routes.js > src/config/routes.ts && prettier -w src/config/routes.ts && cat src/config/routes.ts",
"css-vars": "npx -y tsx ./scripts/css-vars.ts > ./src/styles/vars.css && prettier -w src/styles/vars.css",
+4
View File
@@ -17,6 +17,10 @@
"eslint": "yarn workspaces foreach --all -pt run eslint",
"prettier": "prettier --check . --config ./.prettierrc --ignore-path ./.prettierignore",
"prettier:fix": "prettier --write . --config ./.prettierrc --ignore-path ./.prettierignore",
"verify": "node scripts/verify.mjs",
"verify:web": "node scripts/verify.mjs --workspace=web",
"verify:changed": "node scripts/verify.mjs --changed",
"verify:changed:web": "node scripts/verify.mjs --changed --workspace=web",
"postinstall": "husky"
},
"resolutions": {
+191
View File
@@ -0,0 +1,191 @@
#!/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}`)
+59
View File
@@ -0,0 +1,59 @@
// scripts/verify-changed-hook.mjs
import { execFileSync, spawn } from 'node:child_process'
// Allow skipping via env var (e.g. SKIP_VERIFY=1 in Claude Code settings)
if (process.env.SKIP_VERIFY) {
process.exit(0)
}
function getChangedSourceFiles() {
try {
const unstaged = execFileSync('git', ['diff', '--name-only'], { encoding: 'utf8' })
const staged = execFileSync('git', ['diff', '--name-only', '--cached'], { encoding: 'utf8' })
return [...unstaged.trim().split('\n'), ...staged.trim().split('\n')]
.filter(Boolean)
.filter((f) => /\.[tj]sx?$/.test(f))
} catch (err) {
console.warn('verify-changed-hook: git diff failed:', err.message)
return []
}
}
function detectWorkspaces(files) {
const hasWeb = files.some((f) => f.startsWith('apps/web/'))
const hasMobile = files.some((f) => f.startsWith('apps/mobile/'))
if (hasWeb && hasMobile) return ['web', 'mobile']
if (hasMobile) return ['mobile']
// Default to web for packages/ or other shared dirs
return ['web']
}
const changedFiles = getChangedSourceFiles()
if (changedFiles.length === 0) {
process.exit(0)
}
const workspaces = detectWorkspaces(changedFiles)
// Run verify for each affected workspace sequentially
let exitCode = 0
function runNext(index) {
if (index >= workspaces.length) {
process.exit(exitCode)
}
const ws = workspaces[index]
const child = spawn('node', ['scripts/verify.mjs', '--changed', `--workspace=${ws}`, '--compact'], {
stdio: 'inherit',
})
child.on('close', (code) => {
if (code !== 0) exitCode = code
runNext(index + 1)
})
}
runNext(0)
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env node
/**
* Parallel verify script for running all quality checks simultaneously.
*
* Usage:
* node scripts/verify.mjs [--workspace=web] [--changed] [--compact]
*
* Flags:
* --workspace=<name> Workspace to check (default: "web")
* --changed Only check files changed since the base branch
* --compact Capture output and print summary instead of streaming
*/
import { spawn, execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
import { createInterface } from 'node:readline'
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
const args = process.argv.slice(2)
function getFlag(name) {
const prefix = `--${name}=`
const entry = args.find((a) => a.startsWith(prefix))
if (entry) return entry.slice(prefix.length)
return undefined
}
const workspace = getFlag('workspace') ?? 'web'
const isCompact = args.includes('--compact')
const isChanged = args.includes('--changed')
// ---------------------------------------------------------------------------
// Changed-file detection (--changed mode)
// ---------------------------------------------------------------------------
function gitDiff(...diffArgs) {
try {
const out = execFileSync('git', ['diff', '--name-only', '--diff-filter=d', ...diffArgs], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
})
return out
.trim()
.split('\n')
.filter((l) => l.length > 0)
} catch (err) {
console.warn(`verify: git diff failed (${diffArgs.join(' ')}):`, err.message)
return []
}
}
function getMergeBase() {
const targets = ['dev', 'origin/dev', 'main']
for (const target of targets) {
try {
return execFileSync('git', ['merge-base', 'HEAD', target], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
} catch {
// target branch not found, try next
}
}
return ''
}
const SOURCE_EXT_RE = /\.(ts|tsx|js|jsx)$/
function getChangedFiles(ws, { quiet = false } = {}) {
const mergeBase = getMergeBase()
const committed = mergeBase ? gitDiff(`${mergeBase}...HEAD`) : []
const unstaged = gitDiff()
const staged = gitDiff('--cached')
const all = [...new Set([...committed, ...unstaged, ...staged])].filter((f) => SOURCE_EXT_RE.test(f))
if (!ws) return all
const prefix = `apps/${ws}/`
const outside = all.filter((f) => !f.startsWith(prefix))
const inside = all.filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length))
if (outside.length > 0 && !quiet) {
console.log(` ${outside.length} changed file(s) outside apps/${ws}/ — skipped`)
}
return inside
}
// ---------------------------------------------------------------------------
// Workspace name mapping
// ---------------------------------------------------------------------------
const WORKSPACE_NAMES = {
web: '@safe-global/web',
mobile: '@safe-global/mobile',
}
const workspacePkg = WORKSPACE_NAMES[workspace] ?? `@safe-global/${workspace}`
// ---------------------------------------------------------------------------
// Missing test detection
// ---------------------------------------------------------------------------
function detectMissingTests(changedFiles, workspace) {
const wsRoot = workspace ? path.join('apps', workspace) : ''
const warnings = []
const testablePatterns = [
/hooks\/use.+\.tsx?$/,
/services\/.+\.ts$/,
/components\/.+\.tsx$/,
/store\/.+Slice\.ts$/,
/utils\/.+\.ts$/,
]
const skipPatterns = [
/\.d\.ts$/,
/index\.ts$/,
/\.stories\.tsx?$/,
/\.test\.tsx?$/,
/constants\.ts$/,
/types\.ts$/,
/AUTO_GENERATED/,
]
for (const file of changedFiles) {
if (skipPatterns.some((p) => p.test(file))) continue
if (!testablePatterns.some((p) => p.test(file))) continue
const dir = path.dirname(file)
const base = path.basename(file, path.extname(file))
const ext = file.endsWith('.tsx') ? '.tsx' : '.ts'
const candidates = [
path.join(wsRoot, dir, `${base}.test${ext}`),
path.join(wsRoot, dir, '__tests__', `${base}.test${ext}`),
]
const testExists = candidates.some((c) => existsSync(c))
if (!testExists) {
warnings.push({ file, message: `expected ${base}.test${ext}` })
} else {
const testModified = changedFiles.some(
(f) => f.endsWith(`${base}.test${ext}`) && (f.includes(dir) || f.includes('__tests__')),
)
if (!testModified) {
warnings.push({ file, message: 'test exists but was not updated' })
}
}
}
return warnings
}
// ---------------------------------------------------------------------------
// Check definitions
// ---------------------------------------------------------------------------
function buildChecks() {
if (isChanged) {
const changedFiles = getChangedFiles(workspace, { quiet: isCompact })
if (changedFiles.length === 0) {
console.log('No changed files detected — nothing to verify.')
process.exit(0)
}
if (changedFiles.length > 50) {
console.log(`Note: 50+ files changed (${changedFiles.length}) — running full verify.`)
return { checks: buildFullChecks(), changedFiles }
}
const lintableFiles = changedFiles.filter((f) => SOURCE_EXT_RE.test(f))
const testableFiles = changedFiles.filter((f) => !f.endsWith('.d.ts'))
const checks = [
{
label: 'types',
cmd: 'yarn',
args: ['workspace', workspacePkg, 'type-check'],
},
]
if (lintableFiles.length > 0) {
checks.push({
label: 'lint',
cmd: 'yarn',
args: ['workspace', workspacePkg, 'eslint', ...lintableFiles],
})
}
if (changedFiles.length > 0) {
checks.push({
label: 'prettier',
cmd: 'yarn',
args: ['workspace', workspacePkg, 'prettier', '--check', ...changedFiles],
})
}
if (testableFiles.length > 0) {
checks.push({
label: 'tests',
cmd: 'yarn',
args: [
'workspace',
workspacePkg,
'test',
'--findRelatedTests',
...testableFiles,
'--watchAll=false',
'--passWithNoTests',
],
})
}
return { checks, changedFiles }
}
return { checks: buildFullChecks(), changedFiles: null }
}
function buildFullChecks() {
return [
{
label: 'types',
cmd: 'yarn',
args: ['workspace', workspacePkg, 'type-check'],
},
{
label: 'lint',
cmd: 'yarn',
args: ['workspace', workspacePkg, 'lint'],
},
{
label: 'prettier',
cmd: 'yarn',
args: ['workspace', workspacePkg, 'prettier'],
},
{
label: 'tests',
cmd: 'yarn',
args: ['workspace', workspacePkg, 'test', '--watchAll=false'],
},
]
}
const { checks, changedFiles } = buildChecks()
// ---------------------------------------------------------------------------
// Runner
// ---------------------------------------------------------------------------
function runCheck(check) {
return new Promise((resolve) => {
const stdio = isCompact ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe']
const child = spawn(check.cmd, check.args, { stdio })
const chunks = []
if (isCompact) {
// Capture all output silently
child.stdout.on('data', (d) => chunks.push(d))
child.stderr.on('data', (d) => chunks.push(d))
} else {
// Stream with prefixed lines
const prefix = `[${check.label}]`
const rlOut = createInterface({ input: child.stdout })
rlOut.on('line', (line) => {
process.stdout.write(`${prefix} ${line}\n`)
})
const rlErr = createInterface({ input: child.stderr })
rlErr.on('line', (line) => {
process.stderr.write(`${prefix} ${line}\n`)
})
}
child.on('close', (code) => {
resolve({
label: check.label,
code: code ?? 1,
output: Buffer.concat(chunks).toString(),
})
})
})
}
async function main() {
const results = await Promise.all(checks.map(runCheck))
const failed = results.filter((r) => r.code !== 0)
const allPassed = failed.length === 0
// Detect missing tests when running in --changed mode
const testWarnings = changedFiles ? detectMissingTests(changedFiles, workspace) : []
if (isCompact) {
console.log('-- verify -----')
// Print failed check output first so errors are visible
for (const r of failed) {
console.log(`\n--- ${r.label} (exit ${r.code}) ---`)
console.log(r.output.trimEnd())
}
// Print missing test warnings (one line each)
for (const w of testWarnings) {
console.log(`WARN Missing test: ${w.file}`)
}
// Summary line
const summary = results.map((r) => (r.code === 0 ? `PASS ${r.label}` : `FAIL ${r.label}`)).join(' ')
console.log(`\n${summary}`)
console.log('--------')
} else {
if (!allPassed) {
// In non-compact mode output was already streamed; just print summary
console.log('')
for (const r of failed) {
console.error(`FAIL: ${r.label} (exit ${r.code})`)
}
}
// Print missing test warnings block
if (testWarnings.length > 0) {
console.log('')
console.log('WARN Missing tests:')
for (const w of testWarnings) {
console.log(` ${w.file} -> ${w.message}`)
}
console.log(` ${testWarnings.length} changed file(s) have no corresponding tests.`)
console.log(' Run: yarn test:scaffold <file> to generate a test skeleton.')
}
}
process.exit(allPassed ? 0 : 1)
}
main()