mirror of
https://github.com/luxfi/safe-wallet.git
synced 2026-07-27 06:55:00 +00:00
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:
committed by
GitHub
co-authored by
Hanzo Dev
parent
0a9b65fe99
commit
0c925137d1
+15
-1
@@ -1 +1,15 @@
|
||||
{}
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node scripts/verify-changed-hook.mjs"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user