erne-universal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +92 -0
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/agents/architect.md +64 -0
- package/agents/code-reviewer.md +72 -0
- package/agents/expo-config-resolver.md +77 -0
- package/agents/native-bridge-builder.md +98 -0
- package/agents/performance-profiler.md +89 -0
- package/agents/tdd-guide.md +86 -0
- package/agents/ui-designer.md +100 -0
- package/agents/upgrade-assistant.md +106 -0
- package/bin/cli.js +55 -0
- package/commands/animate.md +70 -0
- package/commands/build-fix.md +57 -0
- package/commands/code-review.md +51 -0
- package/commands/component.md +93 -0
- package/commands/debug.md +74 -0
- package/commands/deploy.md +82 -0
- package/commands/learn.md +56 -0
- package/commands/native-module.md +51 -0
- package/commands/navigate.md +69 -0
- package/commands/perf.md +68 -0
- package/commands/plan.md +49 -0
- package/commands/quality-gate.md +80 -0
- package/commands/retrospective.md +70 -0
- package/commands/setup-device.md +99 -0
- package/commands/tdd.md +51 -0
- package/commands/upgrade.md +78 -0
- package/contexts/dev.md +29 -0
- package/contexts/review.md +32 -0
- package/contexts/vibe.md +44 -0
- package/docs/agents.md +41 -0
- package/docs/commands.md +53 -0
- package/docs/creating-skills.md +63 -0
- package/docs/getting-started.md +60 -0
- package/docs/hooks-profiles.md +73 -0
- package/docs/superpowers/plans/2026-03-10-erne-plan-1-infrastructure-hooks.md +3973 -0
- package/docs/superpowers/plans/2026-03-10-erne-plan-2-content-layer.md +4496 -0
- package/docs/superpowers/plans/2026-03-10-erne-plan-3-skills-knowledge-base.md +1952 -0
- package/docs/superpowers/plans/2026-03-10-erne-plan-4-install-cli-distribution.md +1624 -0
- package/docs/superpowers/specs/2026-03-10-everything-react-native-expo-design.md +581 -0
- package/examples/claude-md-bare-rn.md +46 -0
- package/examples/claude-md-expo-managed.md +45 -0
- package/examples/eas-json-standard.json +41 -0
- package/hooks/hooks.json +113 -0
- package/hooks/profiles/minimal.json +9 -0
- package/hooks/profiles/standard.json +17 -0
- package/hooks/profiles/strict.json +22 -0
- package/install.sh +50 -0
- package/mcp-configs/agent-device.json +10 -0
- package/mcp-configs/appstore-connect.json +15 -0
- package/mcp-configs/expo-api.json +13 -0
- package/mcp-configs/figma.json +13 -0
- package/mcp-configs/firebase.json +14 -0
- package/mcp-configs/github.json +13 -0
- package/mcp-configs/memory.json +13 -0
- package/mcp-configs/play-console.json +14 -0
- package/mcp-configs/sentry.json +15 -0
- package/mcp-configs/supabase.json +14 -0
- package/package.json +50 -0
- package/rules/bare-rn/coding-style.md +62 -0
- package/rules/bare-rn/patterns.md +54 -0
- package/rules/bare-rn/security.md +58 -0
- package/rules/bare-rn/testing.md +78 -0
- package/rules/common/coding-style.md +50 -0
- package/rules/common/development-workflow.md +55 -0
- package/rules/common/git-workflow.md +40 -0
- package/rules/common/navigation.md +56 -0
- package/rules/common/patterns.md +59 -0
- package/rules/common/performance.md +55 -0
- package/rules/common/security.md +64 -0
- package/rules/common/state-management.md +86 -0
- package/rules/common/testing.md +61 -0
- package/rules/expo/coding-style.md +54 -0
- package/rules/expo/patterns.md +71 -0
- package/rules/expo/security.md +41 -0
- package/rules/expo/testing.md +68 -0
- package/rules/native-android/coding-style.md +81 -0
- package/rules/native-android/patterns.md +77 -0
- package/rules/native-android/security.md +80 -0
- package/rules/native-android/testing.md +94 -0
- package/rules/native-ios/coding-style.md +72 -0
- package/rules/native-ios/patterns.md +72 -0
- package/rules/native-ios/security.md +59 -0
- package/rules/native-ios/testing.md +79 -0
- package/schemas/hooks.schema.json +34 -0
- package/schemas/plugin.schema.json +55 -0
- package/scripts/hooks/accessibility-check.js +117 -0
- package/scripts/hooks/bundle-size-check.js +31 -0
- package/scripts/hooks/check-console-log.js +37 -0
- package/scripts/hooks/check-expo-config.js +40 -0
- package/scripts/hooks/check-platform-specific.js +40 -0
- package/scripts/hooks/check-reanimated-worklet.js +51 -0
- package/scripts/hooks/continuous-learning-observer.js +24 -0
- package/scripts/hooks/evaluate-session.js +26 -0
- package/scripts/hooks/lib/hook-utils.js +52 -0
- package/scripts/hooks/native-compat-check.js +42 -0
- package/scripts/hooks/performance-budget.js +57 -0
- package/scripts/hooks/post-edit-format.js +38 -0
- package/scripts/hooks/post-edit-typecheck.js +31 -0
- package/scripts/hooks/pre-commit-lint.js +44 -0
- package/scripts/hooks/pre-edit-test-gate.js +68 -0
- package/scripts/hooks/run-with-flags.js +93 -0
- package/scripts/hooks/security-scan.js +65 -0
- package/scripts/hooks/session-start.js +77 -0
- package/scripts/lint-content.js +62 -0
- package/scripts/validate-all.js +137 -0
- package/skills/coding-standards/SKILL.md +88 -0
- package/skills/continuous-learning-v2/SKILL.md +61 -0
- package/skills/continuous-learning-v2/agent-prompts/pattern-analyzer.md +51 -0
- package/skills/continuous-learning-v2/agent-prompts/skill-generator.md +74 -0
- package/skills/continuous-learning-v2/config.json +25 -0
- package/skills/continuous-learning-v2/hook-templates/evaluate-session.cjs.template +69 -0
- package/skills/continuous-learning-v2/hook-templates/observer-hook.cjs.template +54 -0
- package/skills/continuous-learning-v2/scripts/analyze-patterns.js +50 -0
- package/skills/continuous-learning-v2/scripts/extract-session-patterns.js +54 -0
- package/skills/continuous-learning-v2/scripts/validate-content.js +88 -0
- package/skills/native-module-scaffold/SKILL.md +118 -0
- package/skills/performance-optimization/SKILL.md +103 -0
- package/skills/security-review/SKILL.md +99 -0
- package/skills/tdd-workflow/SKILL.md +142 -0
- package/skills/upgrade-workflow/SKILL.md +140 -0
|
@@ -0,0 +1,3973 @@
|
|
|
1
|
+
# ERNE Plan 1: Core Infrastructure & Hook System
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build the hook system foundation — central dispatcher, profile resolution, shared utilities, all 16 hook scripts, and comprehensive test suites.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A single `run-with-flags.js` dispatcher reads `hooks.json`, resolves the active profile (env > CLAUDE.md > default:standard), and gates hook execution via `spawnSync`. Each hook script is a self-contained CJS module that reads JSON from stdin and exits with 0 (pass), 1 (fail/block), or 2 (warning). All tests use subprocess execution via `execFileSync` to validate actual behavior.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js (CJS), Jest (test runner), Prettier (formatting hook dependency)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
### Create:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
package.json
|
|
19
|
+
schemas/hooks.schema.json
|
|
20
|
+
hooks/hooks.json
|
|
21
|
+
hooks/profiles/minimal.json
|
|
22
|
+
hooks/profiles/standard.json
|
|
23
|
+
hooks/profiles/strict.json
|
|
24
|
+
scripts/hooks/run-with-flags.js
|
|
25
|
+
scripts/hooks/lib/hook-utils.js
|
|
26
|
+
scripts/hooks/session-start.js
|
|
27
|
+
scripts/hooks/post-edit-format.js
|
|
28
|
+
scripts/hooks/post-edit-typecheck.js
|
|
29
|
+
scripts/hooks/check-console-log.js
|
|
30
|
+
scripts/hooks/check-platform-specific.js
|
|
31
|
+
scripts/hooks/check-reanimated-worklet.js
|
|
32
|
+
scripts/hooks/check-expo-config.js
|
|
33
|
+
scripts/hooks/bundle-size-check.js
|
|
34
|
+
scripts/hooks/pre-commit-lint.js
|
|
35
|
+
scripts/hooks/pre-edit-test-gate.js
|
|
36
|
+
scripts/hooks/security-scan.js
|
|
37
|
+
scripts/hooks/performance-budget.js
|
|
38
|
+
scripts/hooks/native-compat-check.js
|
|
39
|
+
scripts/hooks/accessibility-check.js
|
|
40
|
+
scripts/hooks/continuous-learning-observer.js
|
|
41
|
+
scripts/hooks/evaluate-session.js
|
|
42
|
+
tests/hooks/helpers.js
|
|
43
|
+
tests/hooks/definitions.test.js
|
|
44
|
+
tests/hooks/hook-utils.test.js
|
|
45
|
+
tests/hooks/run-with-flags.test.js
|
|
46
|
+
tests/hooks/core-hooks.test.js
|
|
47
|
+
tests/hooks/validation-hooks.test.js
|
|
48
|
+
tests/hooks/gate-hooks.test.js
|
|
49
|
+
tests/hooks/learning-hooks.test.js
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Modify:
|
|
53
|
+
|
|
54
|
+
None (fresh codebase).
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Chunk 1: Foundation
|
|
59
|
+
|
|
60
|
+
### Task 1: Project Setup
|
|
61
|
+
|
|
62
|
+
**Files:**
|
|
63
|
+
- Create: `package.json`
|
|
64
|
+
|
|
65
|
+
- [ ] **Step 1: Create package.json**
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"name": "erne-universal",
|
|
70
|
+
"version": "0.1.0",
|
|
71
|
+
"description": "AI coding agent harness for React Native and Expo development",
|
|
72
|
+
"license": "MIT",
|
|
73
|
+
"scripts": {
|
|
74
|
+
"test": "jest",
|
|
75
|
+
"test:hooks": "jest tests/hooks/"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"jest": "^29.7.0"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- [ ] **Step 2: Install dependencies**
|
|
84
|
+
|
|
85
|
+
Run: `npm install`
|
|
86
|
+
Expected: `node_modules/` created, `package-lock.json` generated
|
|
87
|
+
|
|
88
|
+
- [ ] **Step 3: Verify Jest runs**
|
|
89
|
+
|
|
90
|
+
Run: `npx jest --version`
|
|
91
|
+
Expected: Version number printed (e.g., `29.7.0`)
|
|
92
|
+
|
|
93
|
+
- [ ] **Step 4: Commit**
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git add package.json package-lock.json .gitignore
|
|
97
|
+
git commit -m "chore: init package.json with jest"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
### Task 2: Hook Definitions, Schema & Profiles
|
|
103
|
+
|
|
104
|
+
**Files:**
|
|
105
|
+
- Create: `schemas/hooks.schema.json`
|
|
106
|
+
- Create: `hooks/hooks.json`
|
|
107
|
+
- Create: `hooks/profiles/minimal.json`
|
|
108
|
+
- Create: `hooks/profiles/standard.json`
|
|
109
|
+
- Create: `hooks/profiles/strict.json`
|
|
110
|
+
- Test: `tests/hooks/definitions.test.js`
|
|
111
|
+
|
|
112
|
+
- [ ] **Step 1: Write the failing test for hooks.json structure**
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
// tests/hooks/definitions.test.js
|
|
116
|
+
'use strict';
|
|
117
|
+
const fs = require('fs');
|
|
118
|
+
const path = require('path');
|
|
119
|
+
|
|
120
|
+
const HOOKS_PATH = path.resolve(__dirname, '../../hooks/hooks.json');
|
|
121
|
+
const PROFILES_DIR = path.resolve(__dirname, '../../hooks/profiles');
|
|
122
|
+
const VALID_EVENTS = [
|
|
123
|
+
'PreToolUse', 'PostToolUse', 'Stop',
|
|
124
|
+
'PreCompact', 'SessionStart', 'SessionEnd',
|
|
125
|
+
];
|
|
126
|
+
const VALID_PROFILES = ['minimal', 'standard', 'strict'];
|
|
127
|
+
|
|
128
|
+
describe('hooks.json definitions', () => {
|
|
129
|
+
let config;
|
|
130
|
+
|
|
131
|
+
beforeAll(() => {
|
|
132
|
+
config = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('has hooks array', () => {
|
|
136
|
+
expect(Array.isArray(config.hooks)).toBe(true);
|
|
137
|
+
expect(config.hooks.length).toBeGreaterThan(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('each hook has required fields', () => {
|
|
141
|
+
for (const hook of config.hooks) {
|
|
142
|
+
expect(hook).toHaveProperty('event');
|
|
143
|
+
expect(hook).toHaveProperty('script');
|
|
144
|
+
expect(hook).toHaveProperty('command');
|
|
145
|
+
expect(hook).toHaveProperty('profiles');
|
|
146
|
+
expect(VALID_EVENTS).toContain(hook.event);
|
|
147
|
+
expect(Array.isArray(hook.profiles)).toBe(true);
|
|
148
|
+
hook.profiles.forEach(p => expect(VALID_PROFILES).toContain(p));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('has exactly 16 hooks', () => {
|
|
153
|
+
expect(config.hooks.length).toBe(16);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('each command routes through run-with-flags.js', () => {
|
|
157
|
+
for (const hook of config.hooks) {
|
|
158
|
+
expect(hook.command).toMatch(
|
|
159
|
+
/^node scripts\/hooks\/run-with-flags\.js /
|
|
160
|
+
);
|
|
161
|
+
expect(hook.command).toContain(hook.script);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('profile definitions', () => {
|
|
167
|
+
test('minimal is subset of standard', () => {
|
|
168
|
+
const minimal = JSON.parse(
|
|
169
|
+
fs.readFileSync(path.join(PROFILES_DIR, 'minimal.json'), 'utf8')
|
|
170
|
+
);
|
|
171
|
+
const standard = JSON.parse(
|
|
172
|
+
fs.readFileSync(path.join(PROFILES_DIR, 'standard.json'), 'utf8')
|
|
173
|
+
);
|
|
174
|
+
for (const script of minimal.hooks) {
|
|
175
|
+
expect(standard.hooks).toContain(script);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('standard is subset of strict', () => {
|
|
180
|
+
const standard = JSON.parse(
|
|
181
|
+
fs.readFileSync(path.join(PROFILES_DIR, 'standard.json'), 'utf8')
|
|
182
|
+
);
|
|
183
|
+
const strict = JSON.parse(
|
|
184
|
+
fs.readFileSync(path.join(PROFILES_DIR, 'strict.json'), 'utf8')
|
|
185
|
+
);
|
|
186
|
+
for (const script of standard.hooks) {
|
|
187
|
+
expect(strict.hooks).toContain(script);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('minimal has exactly 3 hooks', () => {
|
|
192
|
+
const minimal = JSON.parse(
|
|
193
|
+
fs.readFileSync(path.join(PROFILES_DIR, 'minimal.json'), 'utf8')
|
|
194
|
+
);
|
|
195
|
+
expect(minimal.hooks.length).toBe(3);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('standard has exactly 11 hooks', () => {
|
|
199
|
+
const standard = JSON.parse(
|
|
200
|
+
fs.readFileSync(path.join(PROFILES_DIR, 'standard.json'), 'utf8')
|
|
201
|
+
);
|
|
202
|
+
expect(standard.hooks.length).toBe(11);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('strict has exactly 16 hooks', () => {
|
|
206
|
+
const strict = JSON.parse(
|
|
207
|
+
fs.readFileSync(path.join(PROFILES_DIR, 'strict.json'), 'utf8')
|
|
208
|
+
);
|
|
209
|
+
expect(strict.hooks.length).toBe(16);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('profile files match hooks.json profiles', () => {
|
|
213
|
+
const config = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8'));
|
|
214
|
+
for (const profileName of VALID_PROFILES) {
|
|
215
|
+
const profile = JSON.parse(
|
|
216
|
+
fs.readFileSync(
|
|
217
|
+
path.join(PROFILES_DIR, `${profileName}.json`),
|
|
218
|
+
'utf8'
|
|
219
|
+
)
|
|
220
|
+
);
|
|
221
|
+
const fromConfig = config.hooks
|
|
222
|
+
.filter(h => h.profiles.includes(profileName))
|
|
223
|
+
.map(h => h.script);
|
|
224
|
+
expect(profile.hooks.sort()).toEqual(fromConfig.sort());
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
231
|
+
|
|
232
|
+
Run: `npx jest tests/hooks/definitions.test.js -v`
|
|
233
|
+
Expected: FAIL — cannot find `hooks/hooks.json`
|
|
234
|
+
|
|
235
|
+
- [ ] **Step 3: Create hooks.json with all 16 hook entries**
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"hooks": [
|
|
240
|
+
{
|
|
241
|
+
"event": "SessionStart",
|
|
242
|
+
"script": "session-start.js",
|
|
243
|
+
"command": "node scripts/hooks/run-with-flags.js session-start.js",
|
|
244
|
+
"profiles": ["minimal", "standard", "strict"]
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"event": "PostToolUse",
|
|
248
|
+
"pattern": "Edit|Write",
|
|
249
|
+
"script": "post-edit-format.js",
|
|
250
|
+
"command": "node scripts/hooks/run-with-flags.js post-edit-format.js",
|
|
251
|
+
"profiles": ["minimal", "standard", "strict"]
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"event": "PostToolUse",
|
|
255
|
+
"pattern": "Edit|Write",
|
|
256
|
+
"script": "post-edit-typecheck.js",
|
|
257
|
+
"command": "node scripts/hooks/run-with-flags.js post-edit-typecheck.js",
|
|
258
|
+
"profiles": ["standard", "strict"]
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"event": "PostToolUse",
|
|
262
|
+
"pattern": "Edit|Write",
|
|
263
|
+
"script": "check-console-log.js",
|
|
264
|
+
"command": "node scripts/hooks/run-with-flags.js check-console-log.js",
|
|
265
|
+
"profiles": ["standard", "strict"]
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"event": "PostToolUse",
|
|
269
|
+
"pattern": "Edit|Write",
|
|
270
|
+
"script": "check-platform-specific.js",
|
|
271
|
+
"command": "node scripts/hooks/run-with-flags.js check-platform-specific.js",
|
|
272
|
+
"profiles": ["standard", "strict"]
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"event": "PostToolUse",
|
|
276
|
+
"pattern": "Edit|Write",
|
|
277
|
+
"script": "check-reanimated-worklet.js",
|
|
278
|
+
"command": "node scripts/hooks/run-with-flags.js check-reanimated-worklet.js",
|
|
279
|
+
"profiles": ["standard", "strict"]
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
"event": "PostToolUse",
|
|
283
|
+
"pattern": "Edit|Write",
|
|
284
|
+
"script": "check-expo-config.js",
|
|
285
|
+
"command": "node scripts/hooks/run-with-flags.js check-expo-config.js",
|
|
286
|
+
"profiles": ["standard", "strict"]
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"event": "PostToolUse",
|
|
290
|
+
"pattern": "Edit|Write",
|
|
291
|
+
"script": "bundle-size-check.js",
|
|
292
|
+
"command": "node scripts/hooks/run-with-flags.js bundle-size-check.js",
|
|
293
|
+
"profiles": ["standard", "strict"]
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"event": "PreToolUse",
|
|
297
|
+
"pattern": "Bash",
|
|
298
|
+
"script": "pre-commit-lint.js",
|
|
299
|
+
"command": "node scripts/hooks/run-with-flags.js pre-commit-lint.js",
|
|
300
|
+
"profiles": ["standard", "strict"]
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"event": "PreToolUse",
|
|
304
|
+
"pattern": "Edit|Write",
|
|
305
|
+
"script": "pre-edit-test-gate.js",
|
|
306
|
+
"command": "node scripts/hooks/run-with-flags.js pre-edit-test-gate.js",
|
|
307
|
+
"profiles": ["strict"]
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"event": "PostToolUse",
|
|
311
|
+
"pattern": "Edit|Write",
|
|
312
|
+
"script": "security-scan.js",
|
|
313
|
+
"command": "node scripts/hooks/run-with-flags.js security-scan.js",
|
|
314
|
+
"profiles": ["strict"]
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"event": "PostToolUse",
|
|
318
|
+
"pattern": "Edit|Write",
|
|
319
|
+
"script": "performance-budget.js",
|
|
320
|
+
"command": "node scripts/hooks/run-with-flags.js performance-budget.js",
|
|
321
|
+
"profiles": ["strict"]
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
"event": "PostToolUse",
|
|
325
|
+
"pattern": "Edit|Write",
|
|
326
|
+
"script": "native-compat-check.js",
|
|
327
|
+
"command": "node scripts/hooks/run-with-flags.js native-compat-check.js",
|
|
328
|
+
"profiles": ["strict"]
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
"event": "PostToolUse",
|
|
332
|
+
"pattern": "Edit|Write",
|
|
333
|
+
"script": "accessibility-check.js",
|
|
334
|
+
"command": "node scripts/hooks/run-with-flags.js accessibility-check.js",
|
|
335
|
+
"profiles": ["strict"]
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"event": "Stop",
|
|
339
|
+
"script": "continuous-learning-observer.js",
|
|
340
|
+
"command": "node scripts/hooks/run-with-flags.js continuous-learning-observer.js",
|
|
341
|
+
"profiles": ["minimal", "standard", "strict"]
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
"event": "Stop",
|
|
345
|
+
"script": "evaluate-session.js",
|
|
346
|
+
"command": "node scripts/hooks/run-with-flags.js evaluate-session.js",
|
|
347
|
+
"profiles": ["standard", "strict"]
|
|
348
|
+
}
|
|
349
|
+
]
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
- [ ] **Step 4: Create the JSON schema for hook definitions**
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
{
|
|
357
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
358
|
+
"type": "object",
|
|
359
|
+
"required": ["hooks"],
|
|
360
|
+
"properties": {
|
|
361
|
+
"hooks": {
|
|
362
|
+
"type": "array",
|
|
363
|
+
"items": {
|
|
364
|
+
"type": "object",
|
|
365
|
+
"required": ["event", "script", "command", "profiles"],
|
|
366
|
+
"properties": {
|
|
367
|
+
"event": {
|
|
368
|
+
"type": "string",
|
|
369
|
+
"enum": [
|
|
370
|
+
"PreToolUse", "PostToolUse", "Stop",
|
|
371
|
+
"PreCompact", "SessionStart", "SessionEnd"
|
|
372
|
+
]
|
|
373
|
+
},
|
|
374
|
+
"pattern": { "type": "string" },
|
|
375
|
+
"script": { "type": "string" },
|
|
376
|
+
"command": { "type": "string" },
|
|
377
|
+
"profiles": {
|
|
378
|
+
"type": "array",
|
|
379
|
+
"items": {
|
|
380
|
+
"type": "string",
|
|
381
|
+
"enum": ["minimal", "standard", "strict"]
|
|
382
|
+
},
|
|
383
|
+
"minItems": 1
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Save to: `schemas/hooks.schema.json`
|
|
393
|
+
|
|
394
|
+
- [ ] **Step 5: Create profile definition files**
|
|
395
|
+
|
|
396
|
+
`hooks/profiles/minimal.json`:
|
|
397
|
+
```json
|
|
398
|
+
{
|
|
399
|
+
"name": "minimal",
|
|
400
|
+
"description": "Fast iteration, minimal checks. For vibe coders and rapid prototyping.",
|
|
401
|
+
"hooks": [
|
|
402
|
+
"session-start.js",
|
|
403
|
+
"post-edit-format.js",
|
|
404
|
+
"continuous-learning-observer.js"
|
|
405
|
+
]
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
`hooks/profiles/standard.json`:
|
|
410
|
+
```json
|
|
411
|
+
{
|
|
412
|
+
"name": "standard",
|
|
413
|
+
"description": "Balanced quality and speed. Recommended for most projects.",
|
|
414
|
+
"hooks": [
|
|
415
|
+
"session-start.js",
|
|
416
|
+
"post-edit-format.js",
|
|
417
|
+
"post-edit-typecheck.js",
|
|
418
|
+
"check-console-log.js",
|
|
419
|
+
"check-platform-specific.js",
|
|
420
|
+
"check-reanimated-worklet.js",
|
|
421
|
+
"check-expo-config.js",
|
|
422
|
+
"bundle-size-check.js",
|
|
423
|
+
"pre-commit-lint.js",
|
|
424
|
+
"continuous-learning-observer.js",
|
|
425
|
+
"evaluate-session.js"
|
|
426
|
+
]
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`hooks/profiles/strict.json`:
|
|
431
|
+
```json
|
|
432
|
+
{
|
|
433
|
+
"name": "strict",
|
|
434
|
+
"description": "Production-grade enforcement. For teams requiring CI-level quality.",
|
|
435
|
+
"hooks": [
|
|
436
|
+
"session-start.js",
|
|
437
|
+
"post-edit-format.js",
|
|
438
|
+
"post-edit-typecheck.js",
|
|
439
|
+
"check-console-log.js",
|
|
440
|
+
"check-platform-specific.js",
|
|
441
|
+
"check-reanimated-worklet.js",
|
|
442
|
+
"check-expo-config.js",
|
|
443
|
+
"bundle-size-check.js",
|
|
444
|
+
"pre-commit-lint.js",
|
|
445
|
+
"pre-edit-test-gate.js",
|
|
446
|
+
"security-scan.js",
|
|
447
|
+
"performance-budget.js",
|
|
448
|
+
"native-compat-check.js",
|
|
449
|
+
"accessibility-check.js",
|
|
450
|
+
"continuous-learning-observer.js",
|
|
451
|
+
"evaluate-session.js"
|
|
452
|
+
]
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
- [ ] **Step 6: Run tests to verify they pass**
|
|
457
|
+
|
|
458
|
+
Run: `npx jest tests/hooks/definitions.test.js -v`
|
|
459
|
+
Expected: All tests PASS
|
|
460
|
+
|
|
461
|
+
- [ ] **Step 7: Commit**
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
git add schemas/ hooks/ tests/hooks/definitions.test.js
|
|
465
|
+
git commit -m "feat: add hook definitions, profiles, and schema"
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
### Task 3: Shared Hook Utilities & Test Helpers
|
|
471
|
+
|
|
472
|
+
**Files:**
|
|
473
|
+
- Create: `scripts/hooks/lib/hook-utils.js`
|
|
474
|
+
- Create: `tests/hooks/helpers.js`
|
|
475
|
+
- Test: `tests/hooks/hook-utils.test.js`
|
|
476
|
+
|
|
477
|
+
- [ ] **Step 1: Write the failing test for hook-utils**
|
|
478
|
+
|
|
479
|
+
```js
|
|
480
|
+
// tests/hooks/hook-utils.test.js
|
|
481
|
+
'use strict';
|
|
482
|
+
const { execFileSync } = require('child_process');
|
|
483
|
+
const path = require('path');
|
|
484
|
+
|
|
485
|
+
const UTILS_PATH = path.resolve(
|
|
486
|
+
__dirname,
|
|
487
|
+
'../../scripts/hooks/lib/hook-utils.js'
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
function runSnippet(code, stdin = '') {
|
|
491
|
+
const escaped = UTILS_PATH.replace(/\\/g, '\\\\');
|
|
492
|
+
const script = `const utils = require('${escaped}');\n${code}`;
|
|
493
|
+
try {
|
|
494
|
+
const stdout = execFileSync('node', ['-e', script], {
|
|
495
|
+
input: stdin,
|
|
496
|
+
encoding: 'utf8',
|
|
497
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
498
|
+
timeout: 5000,
|
|
499
|
+
});
|
|
500
|
+
return { exitCode: 0, stdout, stderr: '' };
|
|
501
|
+
} catch (err) {
|
|
502
|
+
return {
|
|
503
|
+
exitCode: err.status ?? 1,
|
|
504
|
+
stdout: err.stdout || '',
|
|
505
|
+
stderr: err.stderr || '',
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
describe('readStdin', () => {
|
|
511
|
+
test('parses valid JSON from stdin', () => {
|
|
512
|
+
const input = JSON.stringify({
|
|
513
|
+
tool_name: 'Edit',
|
|
514
|
+
tool_input: { file_path: '/a/b.ts' },
|
|
515
|
+
});
|
|
516
|
+
const result = runSnippet(
|
|
517
|
+
'const d = utils.readStdin(); console.log(JSON.stringify(d));',
|
|
518
|
+
input
|
|
519
|
+
);
|
|
520
|
+
expect(result.exitCode).toBe(0);
|
|
521
|
+
expect(JSON.parse(result.stdout).tool_name).toBe('Edit');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('returns empty object for empty stdin', () => {
|
|
525
|
+
const result = runSnippet(
|
|
526
|
+
'const d = utils.readStdin(); console.log(JSON.stringify(d));',
|
|
527
|
+
''
|
|
528
|
+
);
|
|
529
|
+
expect(result.exitCode).toBe(0);
|
|
530
|
+
expect(JSON.parse(result.stdout)).toEqual({});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('returns empty object for invalid JSON', () => {
|
|
534
|
+
const result = runSnippet(
|
|
535
|
+
'const d = utils.readStdin(); console.log(JSON.stringify(d));',
|
|
536
|
+
'not json'
|
|
537
|
+
);
|
|
538
|
+
expect(result.exitCode).toBe(0);
|
|
539
|
+
expect(JSON.parse(result.stdout)).toEqual({});
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe('getEditedFilePath', () => {
|
|
544
|
+
test('extracts file_path from tool_input', () => {
|
|
545
|
+
const r = runSnippet(
|
|
546
|
+
`console.log(utils.getEditedFilePath({
|
|
547
|
+
tool_input: { file_path: '/a/b.ts' }
|
|
548
|
+
}));`
|
|
549
|
+
);
|
|
550
|
+
expect(r.stdout.trim()).toBe('/a/b.ts');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('falls back to path from tool_input', () => {
|
|
554
|
+
const r = runSnippet(
|
|
555
|
+
`console.log(utils.getEditedFilePath({
|
|
556
|
+
tool_input: { path: '/c/d.js' }
|
|
557
|
+
}));`
|
|
558
|
+
);
|
|
559
|
+
expect(r.stdout.trim()).toBe('/c/d.js');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('returns null for missing input', () => {
|
|
563
|
+
const r = runSnippet('console.log(utils.getEditedFilePath(null));');
|
|
564
|
+
expect(r.stdout.trim()).toBe('null');
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe('exit helpers', () => {
|
|
569
|
+
test('pass exits with code 0', () => {
|
|
570
|
+
const r = runSnippet('utils.pass("ok");');
|
|
571
|
+
expect(r.exitCode).toBe(0);
|
|
572
|
+
expect(r.stdout.trim()).toBe('ok');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('fail exits with code 1', () => {
|
|
576
|
+
const r = runSnippet('utils.fail("blocked");');
|
|
577
|
+
expect(r.exitCode).toBe(1);
|
|
578
|
+
expect(r.stdout.trim()).toBe('blocked');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test('warn exits with code 2', () => {
|
|
582
|
+
const r = runSnippet('utils.warn("warning");');
|
|
583
|
+
expect(r.exitCode).toBe(2);
|
|
584
|
+
expect(r.stdout.trim()).toBe('warning');
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe('isTestFile', () => {
|
|
589
|
+
test('detects .test.ts', () => {
|
|
590
|
+
const r = runSnippet(
|
|
591
|
+
'console.log(utils.isTestFile("src/Button.test.ts"));'
|
|
592
|
+
);
|
|
593
|
+
expect(r.stdout.trim()).toBe('true');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('detects .spec.tsx', () => {
|
|
597
|
+
const r = runSnippet(
|
|
598
|
+
'console.log(utils.isTestFile("src/Button.spec.tsx"));'
|
|
599
|
+
);
|
|
600
|
+
expect(r.stdout.trim()).toBe('true');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('detects __tests__ directory', () => {
|
|
604
|
+
const r = runSnippet(
|
|
605
|
+
'console.log(utils.isTestFile("src/__tests__/Button.tsx"));'
|
|
606
|
+
);
|
|
607
|
+
expect(r.stdout.trim()).toBe('true');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('rejects normal source file', () => {
|
|
611
|
+
const r = runSnippet(
|
|
612
|
+
'console.log(utils.isTestFile("src/Button.tsx"));'
|
|
613
|
+
);
|
|
614
|
+
expect(r.stdout.trim()).toBe('false');
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
describe('hasExtension', () => {
|
|
619
|
+
test('matches .ts extension', () => {
|
|
620
|
+
const r = runSnippet(
|
|
621
|
+
'console.log(utils.hasExtension("a/b.ts", [".ts", ".tsx"]));'
|
|
622
|
+
);
|
|
623
|
+
expect(r.stdout.trim()).toBe('true');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test('rejects .js when checking .ts', () => {
|
|
627
|
+
const r = runSnippet(
|
|
628
|
+
'console.log(utils.hasExtension("a/b.js", [".ts", ".tsx"]));'
|
|
629
|
+
);
|
|
630
|
+
expect(r.stdout.trim()).toBe('false');
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
636
|
+
|
|
637
|
+
Run: `npx jest tests/hooks/hook-utils.test.js -v`
|
|
638
|
+
Expected: FAIL — cannot find `scripts/hooks/lib/hook-utils.js`
|
|
639
|
+
|
|
640
|
+
- [ ] **Step 3: Create hook-utils.js**
|
|
641
|
+
|
|
642
|
+
```js
|
|
643
|
+
// scripts/hooks/lib/hook-utils.js
|
|
644
|
+
'use strict';
|
|
645
|
+
const fs = require('fs');
|
|
646
|
+
|
|
647
|
+
function readStdin() {
|
|
648
|
+
try {
|
|
649
|
+
return JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
650
|
+
} catch {
|
|
651
|
+
return {};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function getEditedFilePath(input) {
|
|
656
|
+
if (!input || !input.tool_input) return null;
|
|
657
|
+
return input.tool_input.file_path || input.tool_input.path || null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function pass(msg) {
|
|
661
|
+
if (msg) console.log(msg);
|
|
662
|
+
process.exit(0);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function fail(msg) {
|
|
666
|
+
if (msg) console.log(msg);
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function warn(msg) {
|
|
671
|
+
if (msg) console.log(msg);
|
|
672
|
+
process.exit(2);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isTestFile(filePath) {
|
|
676
|
+
return (
|
|
677
|
+
/\.(test|spec)\.[jt]sx?$/.test(filePath) ||
|
|
678
|
+
filePath.includes('__tests__')
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function hasExtension(filePath, exts) {
|
|
683
|
+
return exts.some(ext => filePath.endsWith(ext));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
module.exports = {
|
|
687
|
+
readStdin,
|
|
688
|
+
getEditedFilePath,
|
|
689
|
+
pass,
|
|
690
|
+
fail,
|
|
691
|
+
warn,
|
|
692
|
+
isTestFile,
|
|
693
|
+
hasExtension,
|
|
694
|
+
};
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
- [ ] **Step 4: Create test helpers**
|
|
698
|
+
|
|
699
|
+
```js
|
|
700
|
+
// tests/hooks/helpers.js
|
|
701
|
+
'use strict';
|
|
702
|
+
const { execFileSync } = require('child_process');
|
|
703
|
+
const path = require('path');
|
|
704
|
+
const fs = require('fs');
|
|
705
|
+
const os = require('os');
|
|
706
|
+
|
|
707
|
+
const HOOKS_DIR = path.resolve(__dirname, '../../scripts/hooks');
|
|
708
|
+
const DISPATCHER = path.join(HOOKS_DIR, 'run-with-flags.js');
|
|
709
|
+
|
|
710
|
+
function runHook(scriptName, stdin = {}, env = {}) {
|
|
711
|
+
const scriptPath = path.join(HOOKS_DIR, scriptName);
|
|
712
|
+
try {
|
|
713
|
+
const stdout = execFileSync('node', [scriptPath], {
|
|
714
|
+
input: JSON.stringify(stdin),
|
|
715
|
+
encoding: 'utf8',
|
|
716
|
+
env: { ...process.env, ...env },
|
|
717
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
718
|
+
timeout: 10000,
|
|
719
|
+
});
|
|
720
|
+
return { exitCode: 0, stdout, stderr: '' };
|
|
721
|
+
} catch (err) {
|
|
722
|
+
return {
|
|
723
|
+
exitCode: err.status ?? 1,
|
|
724
|
+
stdout: err.stdout || '',
|
|
725
|
+
stderr: err.stderr || '',
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function runDispatcher(hookScript, stdin = {}, env = {}) {
|
|
731
|
+
try {
|
|
732
|
+
const stdout = execFileSync('node', [DISPATCHER, hookScript], {
|
|
733
|
+
input: JSON.stringify(stdin),
|
|
734
|
+
encoding: 'utf8',
|
|
735
|
+
env: { ...process.env, ...env },
|
|
736
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
737
|
+
timeout: 10000,
|
|
738
|
+
});
|
|
739
|
+
return { exitCode: 0, stdout, stderr: '' };
|
|
740
|
+
} catch (err) {
|
|
741
|
+
return {
|
|
742
|
+
exitCode: err.status ?? 1,
|
|
743
|
+
stdout: err.stdout || '',
|
|
744
|
+
stderr: err.stderr || '',
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function createTempProject(files = {}) {
|
|
750
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'erne-test-'));
|
|
751
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
752
|
+
const fullPath = path.join(dir, filePath);
|
|
753
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
754
|
+
fs.writeFileSync(fullPath, content);
|
|
755
|
+
}
|
|
756
|
+
return dir;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function cleanupTempProject(dir) {
|
|
760
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
module.exports = {
|
|
764
|
+
runHook,
|
|
765
|
+
runDispatcher,
|
|
766
|
+
createTempProject,
|
|
767
|
+
cleanupTempProject,
|
|
768
|
+
HOOKS_DIR,
|
|
769
|
+
};
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
- [ ] **Step 5: Run tests to verify they pass**
|
|
773
|
+
|
|
774
|
+
Run: `npx jest tests/hooks/hook-utils.test.js -v`
|
|
775
|
+
Expected: All tests PASS
|
|
776
|
+
|
|
777
|
+
- [ ] **Step 6: Commit**
|
|
778
|
+
|
|
779
|
+
```bash
|
|
780
|
+
git add scripts/hooks/lib/ tests/hooks/helpers.js tests/hooks/hook-utils.test.js
|
|
781
|
+
git commit -m "feat: add hook utilities and test helpers"
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
---
|
|
785
|
+
|
|
786
|
+
## Chunk 2: Hook Engine
|
|
787
|
+
|
|
788
|
+
### Task 4: Central Dispatcher (run-with-flags.js)
|
|
789
|
+
|
|
790
|
+
**Files:**
|
|
791
|
+
- Create: `scripts/hooks/run-with-flags.js`
|
|
792
|
+
- Test: `tests/hooks/run-with-flags.test.js`
|
|
793
|
+
|
|
794
|
+
- [ ] **Step 1: Write the failing test for the dispatcher**
|
|
795
|
+
|
|
796
|
+
```js
|
|
797
|
+
// tests/hooks/run-with-flags.test.js
|
|
798
|
+
'use strict';
|
|
799
|
+
const path = require('path');
|
|
800
|
+
const {
|
|
801
|
+
runDispatcher,
|
|
802
|
+
createTempProject,
|
|
803
|
+
cleanupTempProject,
|
|
804
|
+
} = require('./helpers');
|
|
805
|
+
|
|
806
|
+
const HOOKS_CONFIG = path.resolve(__dirname, '../../hooks/hooks.json');
|
|
807
|
+
|
|
808
|
+
describe('run-with-flags.js dispatcher', () => {
|
|
809
|
+
describe('profile gating', () => {
|
|
810
|
+
test('runs hook when profile matches', () => {
|
|
811
|
+
const result = runDispatcher('session-start.js', {}, {
|
|
812
|
+
ERNE_PROFILE: 'minimal',
|
|
813
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
814
|
+
});
|
|
815
|
+
// session-start.js is in minimal — should run (exit 0)
|
|
816
|
+
expect(result.exitCode).toBe(0);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test('skips hook when profile does not match', () => {
|
|
820
|
+
const result = runDispatcher('post-edit-typecheck.js', {}, {
|
|
821
|
+
ERNE_PROFILE: 'minimal',
|
|
822
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
823
|
+
});
|
|
824
|
+
// post-edit-typecheck.js is NOT in minimal — skip (exit 0, no output)
|
|
825
|
+
expect(result.exitCode).toBe(0);
|
|
826
|
+
expect(result.stdout).toBe('');
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test('runs strict-only hook when profile is strict', () => {
|
|
830
|
+
const result = runDispatcher('security-scan.js', {}, {
|
|
831
|
+
ERNE_PROFILE: 'strict',
|
|
832
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
833
|
+
});
|
|
834
|
+
// security-scan.js is in strict — should attempt to run
|
|
835
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test('skips strict-only hook when profile is standard', () => {
|
|
839
|
+
const result = runDispatcher('security-scan.js', {}, {
|
|
840
|
+
ERNE_PROFILE: 'standard',
|
|
841
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
842
|
+
});
|
|
843
|
+
expect(result.exitCode).toBe(0);
|
|
844
|
+
expect(result.stdout).toBe('');
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
describe('profile resolution', () => {
|
|
849
|
+
test('env var takes highest priority', () => {
|
|
850
|
+
const dir = createTempProject({
|
|
851
|
+
'CLAUDE.md': '<!-- Hook Profile: strict -->',
|
|
852
|
+
});
|
|
853
|
+
try {
|
|
854
|
+
const result = runDispatcher('post-edit-typecheck.js', {}, {
|
|
855
|
+
ERNE_PROFILE: 'minimal',
|
|
856
|
+
ERNE_PROJECT_DIR: dir,
|
|
857
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
858
|
+
});
|
|
859
|
+
// env says minimal — typecheck should be skipped
|
|
860
|
+
expect(result.exitCode).toBe(0);
|
|
861
|
+
expect(result.stdout).toBe('');
|
|
862
|
+
} finally {
|
|
863
|
+
cleanupTempProject(dir);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test('CLAUDE.md comment used when no env var', () => {
|
|
868
|
+
const dir = createTempProject({
|
|
869
|
+
'CLAUDE.md': '# Project\n<!-- Hook Profile: strict -->',
|
|
870
|
+
});
|
|
871
|
+
try {
|
|
872
|
+
const result = runDispatcher('security-scan.js', {}, {
|
|
873
|
+
ERNE_PROJECT_DIR: dir,
|
|
874
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
875
|
+
});
|
|
876
|
+
// strict from CLAUDE.md — security-scan should attempt to run
|
|
877
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
878
|
+
} finally {
|
|
879
|
+
cleanupTempProject(dir);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test('defaults to standard when no config', () => {
|
|
884
|
+
const dir = createTempProject({});
|
|
885
|
+
try {
|
|
886
|
+
const result = runDispatcher('pre-commit-lint.js', {}, {
|
|
887
|
+
ERNE_PROJECT_DIR: dir,
|
|
888
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
889
|
+
});
|
|
890
|
+
// standard by default — pre-commit-lint should attempt to run
|
|
891
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
892
|
+
} finally {
|
|
893
|
+
cleanupTempProject(dir);
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test('reads CLAUDE.md from .claude/ subdirectory', () => {
|
|
898
|
+
const dir = createTempProject({
|
|
899
|
+
'.claude/CLAUDE.md': '<!-- Hook Profile: minimal -->',
|
|
900
|
+
});
|
|
901
|
+
try {
|
|
902
|
+
const result = runDispatcher('post-edit-typecheck.js', {}, {
|
|
903
|
+
ERNE_PROJECT_DIR: dir,
|
|
904
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
905
|
+
});
|
|
906
|
+
// minimal from .claude/CLAUDE.md — typecheck skipped
|
|
907
|
+
expect(result.exitCode).toBe(0);
|
|
908
|
+
expect(result.stdout).toBe('');
|
|
909
|
+
} finally {
|
|
910
|
+
cleanupTempProject(dir);
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
describe('error handling', () => {
|
|
916
|
+
test('exits 0 when no hook script argument', () => {
|
|
917
|
+
const result = runDispatcher('', {}, {
|
|
918
|
+
ERNE_PROFILE: 'standard',
|
|
919
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
920
|
+
});
|
|
921
|
+
expect(result.exitCode).toBe(0);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test('exits 0 when hook script not in config', () => {
|
|
925
|
+
const result = runDispatcher('nonexistent-hook.js', {}, {
|
|
926
|
+
ERNE_PROFILE: 'standard',
|
|
927
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
928
|
+
});
|
|
929
|
+
expect(result.exitCode).toBe(0);
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
describe('stdin forwarding', () => {
|
|
934
|
+
test('forwards stdin data to hook script', () => {
|
|
935
|
+
const input = {
|
|
936
|
+
tool_name: 'Edit',
|
|
937
|
+
tool_input: { file_path: '/a/b.ts' },
|
|
938
|
+
};
|
|
939
|
+
const result = runDispatcher('session-start.js', input, {
|
|
940
|
+
ERNE_PROFILE: 'standard',
|
|
941
|
+
ERNE_HOOKS_CONFIG: HOOKS_CONFIG,
|
|
942
|
+
});
|
|
943
|
+
expect(result.exitCode).toBe(0);
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
950
|
+
|
|
951
|
+
Run: `npx jest tests/hooks/run-with-flags.test.js -v`
|
|
952
|
+
Expected: FAIL — cannot find `scripts/hooks/run-with-flags.js`
|
|
953
|
+
|
|
954
|
+
- [ ] **Step 3: Create run-with-flags.js**
|
|
955
|
+
|
|
956
|
+
```js
|
|
957
|
+
// scripts/hooks/run-with-flags.js
|
|
958
|
+
'use strict';
|
|
959
|
+
const { spawnSync } = require('child_process');
|
|
960
|
+
const fs = require('fs');
|
|
961
|
+
const path = require('path');
|
|
962
|
+
|
|
963
|
+
const HOOK_SCRIPT = process.argv[2];
|
|
964
|
+
if (!HOOK_SCRIPT) {
|
|
965
|
+
process.exit(0);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Read stdin once for forwarding to hook script
|
|
969
|
+
let stdinData = '';
|
|
970
|
+
try {
|
|
971
|
+
stdinData = fs.readFileSync(0, 'utf8');
|
|
972
|
+
} catch {}
|
|
973
|
+
|
|
974
|
+
function resolveProfile() {
|
|
975
|
+
// 1. Env var (highest priority)
|
|
976
|
+
if (process.env.ERNE_PROFILE) {
|
|
977
|
+
const p = process.env.ERNE_PROFILE.toLowerCase();
|
|
978
|
+
if (['minimal', 'standard', 'strict'].includes(p)) return p;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// 2. CLAUDE.md comment
|
|
982
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
983
|
+
const claudeMdPaths = [
|
|
984
|
+
path.join(projectDir, 'CLAUDE.md'),
|
|
985
|
+
path.join(projectDir, '.claude', 'CLAUDE.md'),
|
|
986
|
+
];
|
|
987
|
+
for (const mdPath of claudeMdPaths) {
|
|
988
|
+
try {
|
|
989
|
+
const content = fs.readFileSync(mdPath, 'utf8');
|
|
990
|
+
const match = content.match(
|
|
991
|
+
/<!--\s*Hook Profile:\s*(minimal|standard|strict)\s*-->/i
|
|
992
|
+
);
|
|
993
|
+
if (match) return match[1].toLowerCase();
|
|
994
|
+
} catch {}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// 3. Default
|
|
998
|
+
return 'standard';
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function loadHooksConfig() {
|
|
1002
|
+
const configPath =
|
|
1003
|
+
process.env.ERNE_HOOKS_CONFIG ||
|
|
1004
|
+
path.resolve(__dirname, '../../hooks/hooks.json');
|
|
1005
|
+
try {
|
|
1006
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
1007
|
+
} catch {
|
|
1008
|
+
return { hooks: [] };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const profile = resolveProfile();
|
|
1013
|
+
const config = loadHooksConfig();
|
|
1014
|
+
|
|
1015
|
+
// Find hook entry in config
|
|
1016
|
+
const hookEntry = config.hooks.find(h => h.script === HOOK_SCRIPT);
|
|
1017
|
+
if (!hookEntry) {
|
|
1018
|
+
process.exit(0);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Gate by profile
|
|
1022
|
+
if (!hookEntry.profiles.includes(profile)) {
|
|
1023
|
+
process.exit(0);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Resolve and run the hook script
|
|
1027
|
+
const scriptPath = path.resolve(__dirname, HOOK_SCRIPT);
|
|
1028
|
+
if (!fs.existsSync(scriptPath)) {
|
|
1029
|
+
console.error(`ERNE: hook script not found: ${scriptPath}`);
|
|
1030
|
+
process.exit(2);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const result = spawnSync('node', [scriptPath], {
|
|
1034
|
+
input: stdinData,
|
|
1035
|
+
encoding: 'utf8',
|
|
1036
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1037
|
+
timeout: 30000,
|
|
1038
|
+
env: process.env,
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
1042
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1043
|
+
|
|
1044
|
+
if (result.signal === 'SIGTERM') {
|
|
1045
|
+
console.error('ERNE: hook timed out after 30s');
|
|
1046
|
+
process.exit(2);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
process.exit(result.status ?? 0);
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
- [ ] **Step 4: Create minimal stub for session-start.js**
|
|
1053
|
+
|
|
1054
|
+
```js
|
|
1055
|
+
// scripts/hooks/session-start.js
|
|
1056
|
+
'use strict';
|
|
1057
|
+
// Stub — full implementation in Task 5
|
|
1058
|
+
console.log('ERNE: Project layers: common');
|
|
1059
|
+
process.exit(0);
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
- [ ] **Step 5: Create stubs for all remaining hook scripts**
|
|
1063
|
+
|
|
1064
|
+
Each file follows this pattern:
|
|
1065
|
+
```js
|
|
1066
|
+
'use strict';
|
|
1067
|
+
// Stub — full implementation in Task N
|
|
1068
|
+
process.exit(0);
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
Create stubs for: `post-edit-format.js`, `post-edit-typecheck.js`, `check-console-log.js`, `check-platform-specific.js`, `check-reanimated-worklet.js`, `check-expo-config.js`, `bundle-size-check.js`, `pre-commit-lint.js`, `pre-edit-test-gate.js`, `security-scan.js`, `performance-budget.js`, `native-compat-check.js`, `accessibility-check.js`, `continuous-learning-observer.js`, `evaluate-session.js`
|
|
1072
|
+
|
|
1073
|
+
- [ ] **Step 6: Run tests to verify they pass**
|
|
1074
|
+
|
|
1075
|
+
Run: `npx jest tests/hooks/run-with-flags.test.js -v`
|
|
1076
|
+
Expected: All tests PASS
|
|
1077
|
+
|
|
1078
|
+
- [ ] **Step 7: Run full test suite to verify nothing broke**
|
|
1079
|
+
|
|
1080
|
+
Run: `npx jest tests/hooks/ -v`
|
|
1081
|
+
Expected: All tests PASS (definitions, hook-utils, run-with-flags)
|
|
1082
|
+
|
|
1083
|
+
- [ ] **Step 8: Commit**
|
|
1084
|
+
|
|
1085
|
+
```bash
|
|
1086
|
+
git add scripts/hooks/ tests/hooks/run-with-flags.test.js
|
|
1087
|
+
git commit -m "feat: add central dispatcher and hook script stubs"
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
## Chunk 3: Core/Minimal Hooks
|
|
1093
|
+
|
|
1094
|
+
These three hooks run in **all** profiles (minimal, standard, strict).
|
|
1095
|
+
|
|
1096
|
+
### Task 5: session-start.js — Project Type Detection
|
|
1097
|
+
|
|
1098
|
+
**Files:**
|
|
1099
|
+
- Edit: `scripts/hooks/session-start.js` (replace stub)
|
|
1100
|
+
- Test: `tests/hooks/core-hooks.test.js`
|
|
1101
|
+
|
|
1102
|
+
- [ ] **Step 1: Write the failing test for session-start.js**
|
|
1103
|
+
|
|
1104
|
+
```js
|
|
1105
|
+
// tests/hooks/core-hooks.test.js
|
|
1106
|
+
'use strict';
|
|
1107
|
+
const path = require('path');
|
|
1108
|
+
const {
|
|
1109
|
+
runHook,
|
|
1110
|
+
createTempProject,
|
|
1111
|
+
cleanupTempProject,
|
|
1112
|
+
} = require('./helpers');
|
|
1113
|
+
|
|
1114
|
+
describe('session-start.js', () => {
|
|
1115
|
+
test('detects expo project from package.json dependencies', () => {
|
|
1116
|
+
const dir = createTempProject({
|
|
1117
|
+
'package.json': JSON.stringify({
|
|
1118
|
+
dependencies: { expo: '~51.0.0', react: '18.2.0' },
|
|
1119
|
+
}),
|
|
1120
|
+
});
|
|
1121
|
+
try {
|
|
1122
|
+
const result = runHook('session-start.js', {}, {
|
|
1123
|
+
ERNE_PROJECT_DIR: dir,
|
|
1124
|
+
});
|
|
1125
|
+
expect(result.exitCode).toBe(0);
|
|
1126
|
+
expect(result.stdout).toContain('expo');
|
|
1127
|
+
expect(result.stdout).toContain('common');
|
|
1128
|
+
} finally {
|
|
1129
|
+
cleanupTempProject(dir);
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test('detects expo from devDependencies', () => {
|
|
1134
|
+
const dir = createTempProject({
|
|
1135
|
+
'package.json': JSON.stringify({
|
|
1136
|
+
devDependencies: { expo: '~51.0.0' },
|
|
1137
|
+
}),
|
|
1138
|
+
});
|
|
1139
|
+
try {
|
|
1140
|
+
const result = runHook('session-start.js', {}, {
|
|
1141
|
+
ERNE_PROJECT_DIR: dir,
|
|
1142
|
+
});
|
|
1143
|
+
expect(result.exitCode).toBe(0);
|
|
1144
|
+
expect(result.stdout).toContain('expo');
|
|
1145
|
+
} finally {
|
|
1146
|
+
cleanupTempProject(dir);
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
test('detects bare-rn project (ios + android dirs, no expo)', () => {
|
|
1151
|
+
const dir = createTempProject({
|
|
1152
|
+
'package.json': JSON.stringify({
|
|
1153
|
+
dependencies: { 'react-native': '0.74.0' },
|
|
1154
|
+
}),
|
|
1155
|
+
'ios/Podfile': 'platform :ios',
|
|
1156
|
+
'android/build.gradle': 'buildscript {}',
|
|
1157
|
+
});
|
|
1158
|
+
try {
|
|
1159
|
+
const result = runHook('session-start.js', {}, {
|
|
1160
|
+
ERNE_PROJECT_DIR: dir,
|
|
1161
|
+
});
|
|
1162
|
+
expect(result.exitCode).toBe(0);
|
|
1163
|
+
expect(result.stdout).toContain('bare-rn');
|
|
1164
|
+
expect(result.stdout).toContain('common');
|
|
1165
|
+
expect(result.stdout).not.toContain('expo');
|
|
1166
|
+
} finally {
|
|
1167
|
+
cleanupTempProject(dir);
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test('detects native-ios layer from Swift files', () => {
|
|
1172
|
+
const dir = createTempProject({
|
|
1173
|
+
'package.json': JSON.stringify({
|
|
1174
|
+
dependencies: { 'react-native': '0.74.0' },
|
|
1175
|
+
}),
|
|
1176
|
+
'ios/App/AppDelegate.swift': 'import UIKit',
|
|
1177
|
+
'android/build.gradle': 'buildscript {}',
|
|
1178
|
+
});
|
|
1179
|
+
try {
|
|
1180
|
+
const result = runHook('session-start.js', {}, {
|
|
1181
|
+
ERNE_PROJECT_DIR: dir,
|
|
1182
|
+
});
|
|
1183
|
+
expect(result.exitCode).toBe(0);
|
|
1184
|
+
expect(result.stdout).toContain('native-ios');
|
|
1185
|
+
} finally {
|
|
1186
|
+
cleanupTempProject(dir);
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
test('detects native-android layer from Kotlin files', () => {
|
|
1191
|
+
const dir = createTempProject({
|
|
1192
|
+
'package.json': JSON.stringify({
|
|
1193
|
+
dependencies: { 'react-native': '0.74.0' },
|
|
1194
|
+
}),
|
|
1195
|
+
'ios/Podfile': 'platform :ios',
|
|
1196
|
+
'android/app/src/main/java/com/app/Main.kt': 'package com.app',
|
|
1197
|
+
});
|
|
1198
|
+
try {
|
|
1199
|
+
const result = runHook('session-start.js', {}, {
|
|
1200
|
+
ERNE_PROJECT_DIR: dir,
|
|
1201
|
+
});
|
|
1202
|
+
expect(result.exitCode).toBe(0);
|
|
1203
|
+
expect(result.stdout).toContain('native-android');
|
|
1204
|
+
} finally {
|
|
1205
|
+
cleanupTempProject(dir);
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
test('detects ejected expo (expo + native code)', () => {
|
|
1210
|
+
const dir = createTempProject({
|
|
1211
|
+
'package.json': JSON.stringify({
|
|
1212
|
+
dependencies: { expo: '~51.0.0', 'react-native': '0.74.0' },
|
|
1213
|
+
}),
|
|
1214
|
+
'ios/App/AppDelegate.swift': 'import UIKit',
|
|
1215
|
+
'android/app/src/main/java/com/app/Main.kt': 'package com.app',
|
|
1216
|
+
});
|
|
1217
|
+
try {
|
|
1218
|
+
const result = runHook('session-start.js', {}, {
|
|
1219
|
+
ERNE_PROJECT_DIR: dir,
|
|
1220
|
+
});
|
|
1221
|
+
expect(result.exitCode).toBe(0);
|
|
1222
|
+
// Expo takes priority over bare-rn
|
|
1223
|
+
expect(result.stdout).toContain('expo');
|
|
1224
|
+
expect(result.stdout).not.toContain('bare-rn');
|
|
1225
|
+
// Native layers still detected
|
|
1226
|
+
expect(result.stdout).toContain('native-ios');
|
|
1227
|
+
expect(result.stdout).toContain('native-android');
|
|
1228
|
+
} finally {
|
|
1229
|
+
cleanupTempProject(dir);
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
test('warns when no project signals found', () => {
|
|
1234
|
+
const dir = createTempProject({});
|
|
1235
|
+
try {
|
|
1236
|
+
const result = runHook('session-start.js', {}, {
|
|
1237
|
+
ERNE_PROJECT_DIR: dir,
|
|
1238
|
+
});
|
|
1239
|
+
expect(result.exitCode).toBe(2); // warn
|
|
1240
|
+
expect(result.stdout).toContain('common');
|
|
1241
|
+
} finally {
|
|
1242
|
+
cleanupTempProject(dir);
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test('warns when no package.json found', () => {
|
|
1247
|
+
const dir = createTempProject({
|
|
1248
|
+
'README.md': '# hello',
|
|
1249
|
+
});
|
|
1250
|
+
try {
|
|
1251
|
+
const result = runHook('session-start.js', {}, {
|
|
1252
|
+
ERNE_PROJECT_DIR: dir,
|
|
1253
|
+
});
|
|
1254
|
+
expect(result.exitCode).toBe(2);
|
|
1255
|
+
} finally {
|
|
1256
|
+
cleanupTempProject(dir);
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1263
|
+
|
|
1264
|
+
Run: `npx jest tests/hooks/core-hooks.test.js -v`
|
|
1265
|
+
Expected: FAIL — session-start.js stub does not detect project types
|
|
1266
|
+
|
|
1267
|
+
- [ ] **Step 3: Implement session-start.js**
|
|
1268
|
+
|
|
1269
|
+
```js
|
|
1270
|
+
// scripts/hooks/session-start.js
|
|
1271
|
+
'use strict';
|
|
1272
|
+
const fs = require('fs');
|
|
1273
|
+
const path = require('path');
|
|
1274
|
+
|
|
1275
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
1276
|
+
|
|
1277
|
+
function fileExists(relPath) {
|
|
1278
|
+
return fs.existsSync(path.join(projectDir, relPath));
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function dirExists(relPath) {
|
|
1282
|
+
try {
|
|
1283
|
+
return fs.statSync(path.join(projectDir, relPath)).isDirectory();
|
|
1284
|
+
} catch {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function findFilesWithExt(dir, ext) {
|
|
1290
|
+
const fullDir = path.join(projectDir, dir);
|
|
1291
|
+
try {
|
|
1292
|
+
const entries = fs.readdirSync(fullDir, {
|
|
1293
|
+
withFileTypes: true,
|
|
1294
|
+
recursive: true,
|
|
1295
|
+
});
|
|
1296
|
+
return entries.some(
|
|
1297
|
+
e => e.isFile() && e.name.endsWith(ext)
|
|
1298
|
+
);
|
|
1299
|
+
} catch {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function readPackageJson() {
|
|
1305
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
1306
|
+
try {
|
|
1307
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
1308
|
+
} catch {
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function hasExpoDependency(pkg) {
|
|
1314
|
+
if (!pkg) return false;
|
|
1315
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1316
|
+
return 'expo' in deps;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Detect layers
|
|
1320
|
+
const layers = ['common'];
|
|
1321
|
+
const pkg = readPackageJson();
|
|
1322
|
+
const hasIosDir = dirExists('ios');
|
|
1323
|
+
const hasAndroidDir = dirExists('android');
|
|
1324
|
+
|
|
1325
|
+
if (hasExpoDependency(pkg)) {
|
|
1326
|
+
layers.push('expo');
|
|
1327
|
+
} else if (hasIosDir && hasAndroidDir) {
|
|
1328
|
+
layers.push('bare-rn');
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (hasIosDir && findFilesWithExt('ios', '.swift')) {
|
|
1332
|
+
layers.push('native-ios');
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
if (hasAndroidDir && findFilesWithExt('android', '.kt')) {
|
|
1336
|
+
layers.push('native-android');
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const hasSignals = layers.length > 1;
|
|
1340
|
+
|
|
1341
|
+
console.log(`ERNE: Project layers: ${layers.join(', ')}`);
|
|
1342
|
+
|
|
1343
|
+
if (!hasSignals) {
|
|
1344
|
+
// Only common — no RN/Expo signals found
|
|
1345
|
+
process.exit(2); // warn
|
|
1346
|
+
} else {
|
|
1347
|
+
process.exit(0);
|
|
1348
|
+
}
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1352
|
+
|
|
1353
|
+
Run: `npx jest tests/hooks/core-hooks.test.js -v`
|
|
1354
|
+
Expected: All tests PASS
|
|
1355
|
+
|
|
1356
|
+
- [ ] **Step 5: Commit**
|
|
1357
|
+
|
|
1358
|
+
```bash
|
|
1359
|
+
git add scripts/hooks/session-start.js tests/hooks/core-hooks.test.js
|
|
1360
|
+
git commit -m "feat: implement session-start.js project type detection"
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
---
|
|
1364
|
+
|
|
1365
|
+
### Task 6: post-edit-format.js — Auto-Format on Save
|
|
1366
|
+
|
|
1367
|
+
**Files:**
|
|
1368
|
+
- Edit: `scripts/hooks/post-edit-format.js` (replace stub)
|
|
1369
|
+
- Test: `tests/hooks/core-hooks.test.js` (append)
|
|
1370
|
+
|
|
1371
|
+
- [ ] **Step 1: Append the failing test for post-edit-format.js**
|
|
1372
|
+
|
|
1373
|
+
Append to `tests/hooks/core-hooks.test.js`:
|
|
1374
|
+
|
|
1375
|
+
```js
|
|
1376
|
+
describe('post-edit-format.js', () => {
|
|
1377
|
+
test('exits 0 for supported file extension', () => {
|
|
1378
|
+
const dir = createTempProject({
|
|
1379
|
+
'src/app.tsx': 'const x=1;',
|
|
1380
|
+
'node_modules/.bin/prettier': '',
|
|
1381
|
+
});
|
|
1382
|
+
try {
|
|
1383
|
+
const result = runHook('post-edit-format.js', {
|
|
1384
|
+
tool_name: 'Edit',
|
|
1385
|
+
tool_input: { file_path: path.join(dir, 'src/app.tsx') },
|
|
1386
|
+
}, {
|
|
1387
|
+
ERNE_PROJECT_DIR: dir,
|
|
1388
|
+
});
|
|
1389
|
+
// May exit 0 (formatted) or 2 (prettier not found/failed)
|
|
1390
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
1391
|
+
} finally {
|
|
1392
|
+
cleanupTempProject(dir);
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test('skips unsupported file extensions silently', () => {
|
|
1397
|
+
const result = runHook('post-edit-format.js', {
|
|
1398
|
+
tool_name: 'Edit',
|
|
1399
|
+
tool_input: { file_path: '/some/image.png' },
|
|
1400
|
+
});
|
|
1401
|
+
expect(result.exitCode).toBe(0);
|
|
1402
|
+
// No formatting attempted
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
test('skips when no file path in stdin', () => {
|
|
1406
|
+
const result = runHook('post-edit-format.js', {
|
|
1407
|
+
tool_name: 'Edit',
|
|
1408
|
+
tool_input: {},
|
|
1409
|
+
});
|
|
1410
|
+
expect(result.exitCode).toBe(0);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
test('skips when stdin is empty', () => {
|
|
1414
|
+
const result = runHook('post-edit-format.js', {});
|
|
1415
|
+
expect(result.exitCode).toBe(0);
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
test('handles missing tool_input gracefully', () => {
|
|
1419
|
+
const result = runHook('post-edit-format.js', {
|
|
1420
|
+
tool_name: 'Write',
|
|
1421
|
+
});
|
|
1422
|
+
expect(result.exitCode).toBe(0);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
test('formats .json files', () => {
|
|
1426
|
+
const dir = createTempProject({
|
|
1427
|
+
'config.json': '{"a":1}',
|
|
1428
|
+
});
|
|
1429
|
+
try {
|
|
1430
|
+
const result = runHook('post-edit-format.js', {
|
|
1431
|
+
tool_name: 'Edit',
|
|
1432
|
+
tool_input: { file_path: path.join(dir, 'config.json') },
|
|
1433
|
+
}, {
|
|
1434
|
+
ERNE_PROJECT_DIR: dir,
|
|
1435
|
+
});
|
|
1436
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
1437
|
+
} finally {
|
|
1438
|
+
cleanupTempProject(dir);
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
test('formats .css files', () => {
|
|
1443
|
+
const dir = createTempProject({
|
|
1444
|
+
'styles.css': 'body{color:red}',
|
|
1445
|
+
});
|
|
1446
|
+
try {
|
|
1447
|
+
const result = runHook('post-edit-format.js', {
|
|
1448
|
+
tool_name: 'Edit',
|
|
1449
|
+
tool_input: { file_path: path.join(dir, 'styles.css') },
|
|
1450
|
+
}, {
|
|
1451
|
+
ERNE_PROJECT_DIR: dir,
|
|
1452
|
+
});
|
|
1453
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
1454
|
+
} finally {
|
|
1455
|
+
cleanupTempProject(dir);
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
});
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1462
|
+
|
|
1463
|
+
Run: `npx jest tests/hooks/core-hooks.test.js -v`
|
|
1464
|
+
Expected: FAIL — post-edit-format.js stub exits 0 without formatting logic
|
|
1465
|
+
|
|
1466
|
+
- [ ] **Step 3: Implement post-edit-format.js**
|
|
1467
|
+
|
|
1468
|
+
```js
|
|
1469
|
+
// scripts/hooks/post-edit-format.js
|
|
1470
|
+
'use strict';
|
|
1471
|
+
const { execFileSync } = require('child_process');
|
|
1472
|
+
const path = require('path');
|
|
1473
|
+
const {
|
|
1474
|
+
readStdin,
|
|
1475
|
+
getEditedFilePath,
|
|
1476
|
+
pass,
|
|
1477
|
+
warn,
|
|
1478
|
+
} = require('./lib/hook-utils');
|
|
1479
|
+
|
|
1480
|
+
const SUPPORTED_EXTENSIONS = [
|
|
1481
|
+
'.js', '.jsx', '.ts', '.tsx',
|
|
1482
|
+
'.json', '.css', '.md',
|
|
1483
|
+
];
|
|
1484
|
+
|
|
1485
|
+
const input = readStdin();
|
|
1486
|
+
const filePath = getEditedFilePath(input);
|
|
1487
|
+
|
|
1488
|
+
if (!filePath) {
|
|
1489
|
+
pass(); // No file to format
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1493
|
+
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
1494
|
+
pass(); // Not a formattable file type
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Attempt to run prettier
|
|
1498
|
+
try {
|
|
1499
|
+
execFileSync('npx', ['prettier', '--write', filePath], {
|
|
1500
|
+
encoding: 'utf8',
|
|
1501
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1502
|
+
timeout: 15000,
|
|
1503
|
+
cwd: process.env.ERNE_PROJECT_DIR || process.cwd(),
|
|
1504
|
+
});
|
|
1505
|
+
pass(`ERNE: Formatted ${path.basename(filePath)}`);
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
// Prettier not installed or failed — warn, don't block
|
|
1508
|
+
warn(`ERNE: Could not format ${path.basename(filePath)}: prettier unavailable or failed`);
|
|
1509
|
+
}
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1513
|
+
|
|
1514
|
+
Run: `npx jest tests/hooks/core-hooks.test.js -v`
|
|
1515
|
+
Expected: All tests PASS
|
|
1516
|
+
|
|
1517
|
+
- [ ] **Step 5: Commit**
|
|
1518
|
+
|
|
1519
|
+
```bash
|
|
1520
|
+
git add scripts/hooks/post-edit-format.js tests/hooks/core-hooks.test.js
|
|
1521
|
+
git commit -m "feat: implement post-edit-format.js auto-formatting"
|
|
1522
|
+
```
|
|
1523
|
+
|
|
1524
|
+
---
|
|
1525
|
+
|
|
1526
|
+
### Task 7: continuous-learning-observer.js — Passive Pattern Observer
|
|
1527
|
+
|
|
1528
|
+
**Files:**
|
|
1529
|
+
- Edit: `scripts/hooks/continuous-learning-observer.js` (replace stub)
|
|
1530
|
+
- Test: `tests/hooks/learning-hooks.test.js`
|
|
1531
|
+
|
|
1532
|
+
- [ ] **Step 1: Write the failing test for continuous-learning-observer.js**
|
|
1533
|
+
|
|
1534
|
+
```js
|
|
1535
|
+
// tests/hooks/learning-hooks.test.js
|
|
1536
|
+
'use strict';
|
|
1537
|
+
const fs = require('fs');
|
|
1538
|
+
const path = require('path');
|
|
1539
|
+
const {
|
|
1540
|
+
runHook,
|
|
1541
|
+
createTempProject,
|
|
1542
|
+
cleanupTempProject,
|
|
1543
|
+
} = require('./helpers');
|
|
1544
|
+
|
|
1545
|
+
describe('continuous-learning-observer.js', () => {
|
|
1546
|
+
test('creates observations file and appends entry', () => {
|
|
1547
|
+
const dir = createTempProject({});
|
|
1548
|
+
try {
|
|
1549
|
+
const result = runHook('continuous-learning-observer.js', {
|
|
1550
|
+
stop_reason: 'end_turn',
|
|
1551
|
+
}, {
|
|
1552
|
+
ERNE_PROJECT_DIR: dir,
|
|
1553
|
+
});
|
|
1554
|
+
expect(result.exitCode).toBe(0);
|
|
1555
|
+
|
|
1556
|
+
const obsPath = path.join(dir, '.claude', 'erne', 'observations.jsonl');
|
|
1557
|
+
expect(fs.existsSync(obsPath)).toBe(true);
|
|
1558
|
+
|
|
1559
|
+
const lines = fs.readFileSync(obsPath, 'utf8').trim().split('\n');
|
|
1560
|
+
expect(lines.length).toBe(1);
|
|
1561
|
+
|
|
1562
|
+
const entry = JSON.parse(lines[0]);
|
|
1563
|
+
expect(entry).toHaveProperty('timestamp');
|
|
1564
|
+
expect(entry).toHaveProperty('stop_reason', 'end_turn');
|
|
1565
|
+
} finally {
|
|
1566
|
+
cleanupTempProject(dir);
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
test('appends to existing observations file', () => {
|
|
1571
|
+
const dir = createTempProject({
|
|
1572
|
+
'.claude/erne/observations.jsonl':
|
|
1573
|
+
JSON.stringify({ timestamp: '2025-01-01', stop_reason: 'old' }) + '\n',
|
|
1574
|
+
});
|
|
1575
|
+
try {
|
|
1576
|
+
const result = runHook('continuous-learning-observer.js', {
|
|
1577
|
+
stop_reason: 'end_turn',
|
|
1578
|
+
}, {
|
|
1579
|
+
ERNE_PROJECT_DIR: dir,
|
|
1580
|
+
});
|
|
1581
|
+
expect(result.exitCode).toBe(0);
|
|
1582
|
+
|
|
1583
|
+
const obsPath = path.join(dir, '.claude', 'erne', 'observations.jsonl');
|
|
1584
|
+
const lines = fs.readFileSync(obsPath, 'utf8').trim().split('\n');
|
|
1585
|
+
expect(lines.length).toBe(2);
|
|
1586
|
+
|
|
1587
|
+
const latest = JSON.parse(lines[1]);
|
|
1588
|
+
expect(latest.stop_reason).toBe('end_turn');
|
|
1589
|
+
} finally {
|
|
1590
|
+
cleanupTempProject(dir);
|
|
1591
|
+
}
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
test('always exits 0 even with empty stdin', () => {
|
|
1595
|
+
const dir = createTempProject({});
|
|
1596
|
+
try {
|
|
1597
|
+
const result = runHook('continuous-learning-observer.js', {}, {
|
|
1598
|
+
ERNE_PROJECT_DIR: dir,
|
|
1599
|
+
});
|
|
1600
|
+
expect(result.exitCode).toBe(0);
|
|
1601
|
+
} finally {
|
|
1602
|
+
cleanupTempProject(dir);
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
test('always exits 0 even if write fails (read-only dir)', () => {
|
|
1607
|
+
// Use an invalid directory — hook should still not crash
|
|
1608
|
+
const result = runHook('continuous-learning-observer.js', {
|
|
1609
|
+
stop_reason: 'end_turn',
|
|
1610
|
+
}, {
|
|
1611
|
+
ERNE_PROJECT_DIR: '/nonexistent/path/that/does/not/exist',
|
|
1612
|
+
});
|
|
1613
|
+
expect(result.exitCode).toBe(0);
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
test('records timestamp in ISO format', () => {
|
|
1617
|
+
const dir = createTempProject({});
|
|
1618
|
+
try {
|
|
1619
|
+
runHook('continuous-learning-observer.js', {
|
|
1620
|
+
stop_reason: 'end_turn',
|
|
1621
|
+
}, {
|
|
1622
|
+
ERNE_PROJECT_DIR: dir,
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
const obsPath = path.join(dir, '.claude', 'erne', 'observations.jsonl');
|
|
1626
|
+
const entry = JSON.parse(
|
|
1627
|
+
fs.readFileSync(obsPath, 'utf8').trim()
|
|
1628
|
+
);
|
|
1629
|
+
// Verify ISO timestamp format
|
|
1630
|
+
expect(new Date(entry.timestamp).toISOString()).toBe(entry.timestamp);
|
|
1631
|
+
} finally {
|
|
1632
|
+
cleanupTempProject(dir);
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
});
|
|
1636
|
+
```
|
|
1637
|
+
|
|
1638
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1639
|
+
|
|
1640
|
+
Run: `npx jest tests/hooks/learning-hooks.test.js -v`
|
|
1641
|
+
Expected: FAIL — stub exits 0 but writes no observations file
|
|
1642
|
+
|
|
1643
|
+
- [ ] **Step 3: Implement continuous-learning-observer.js**
|
|
1644
|
+
|
|
1645
|
+
```js
|
|
1646
|
+
// scripts/hooks/continuous-learning-observer.js
|
|
1647
|
+
'use strict';
|
|
1648
|
+
const fs = require('fs');
|
|
1649
|
+
const path = require('path');
|
|
1650
|
+
const { readStdin } = require('./lib/hook-utils');
|
|
1651
|
+
|
|
1652
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
1653
|
+
const input = readStdin();
|
|
1654
|
+
|
|
1655
|
+
const observation = {
|
|
1656
|
+
timestamp: new Date().toISOString(),
|
|
1657
|
+
stop_reason: input.stop_reason || null,
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
// Write observation to .claude/erne/observations.jsonl
|
|
1661
|
+
try {
|
|
1662
|
+
const obsDir = path.join(projectDir, '.claude', 'erne');
|
|
1663
|
+
fs.mkdirSync(obsDir, { recursive: true });
|
|
1664
|
+
|
|
1665
|
+
const obsPath = path.join(obsDir, 'observations.jsonl');
|
|
1666
|
+
fs.appendFileSync(obsPath, JSON.stringify(observation) + '\n');
|
|
1667
|
+
} catch {
|
|
1668
|
+
// Never fail — this is a passive observer
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
process.exit(0);
|
|
1672
|
+
```
|
|
1673
|
+
|
|
1674
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1675
|
+
|
|
1676
|
+
Run: `npx jest tests/hooks/learning-hooks.test.js -v`
|
|
1677
|
+
Expected: All tests PASS
|
|
1678
|
+
|
|
1679
|
+
- [ ] **Step 5: Run full test suite**
|
|
1680
|
+
|
|
1681
|
+
Run: `npx jest tests/hooks/ -v`
|
|
1682
|
+
Expected: All tests PASS (definitions, hook-utils, run-with-flags, core-hooks, learning-hooks)
|
|
1683
|
+
|
|
1684
|
+
- [ ] **Step 6: Commit**
|
|
1685
|
+
|
|
1686
|
+
```bash
|
|
1687
|
+
git add scripts/hooks/continuous-learning-observer.js tests/hooks/learning-hooks.test.js
|
|
1688
|
+
git commit -m "feat: implement continuous-learning-observer.js"
|
|
1689
|
+
```
|
|
1690
|
+
|
|
1691
|
+
---
|
|
1692
|
+
|
|
1693
|
+
## Chunk 4: Standard Hooks Part 1
|
|
1694
|
+
|
|
1695
|
+
These hooks join in the **standard** profile (also included in strict).
|
|
1696
|
+
|
|
1697
|
+
### Task 8: Validation Hooks — post-edit-typecheck.js, check-console-log.js, check-platform-specific.js, check-reanimated-worklet.js
|
|
1698
|
+
|
|
1699
|
+
**Files:**
|
|
1700
|
+
- Edit: `scripts/hooks/post-edit-typecheck.js` (replace stub)
|
|
1701
|
+
- Edit: `scripts/hooks/check-console-log.js` (replace stub)
|
|
1702
|
+
- Edit: `scripts/hooks/check-platform-specific.js` (replace stub)
|
|
1703
|
+
- Edit: `scripts/hooks/check-reanimated-worklet.js` (replace stub)
|
|
1704
|
+
- Test: `tests/hooks/validation-hooks.test.js`
|
|
1705
|
+
|
|
1706
|
+
- [ ] **Step 1: Write the failing tests for validation hooks**
|
|
1707
|
+
|
|
1708
|
+
```js
|
|
1709
|
+
// tests/hooks/validation-hooks.test.js
|
|
1710
|
+
'use strict';
|
|
1711
|
+
const path = require('path');
|
|
1712
|
+
const {
|
|
1713
|
+
runHook,
|
|
1714
|
+
createTempProject,
|
|
1715
|
+
cleanupTempProject,
|
|
1716
|
+
} = require('./helpers');
|
|
1717
|
+
|
|
1718
|
+
describe('post-edit-typecheck.js', () => {
|
|
1719
|
+
test('exits 0 for .ts/.tsx files (attempts tsc)', () => {
|
|
1720
|
+
const dir = createTempProject({
|
|
1721
|
+
'src/App.tsx': 'export const App = () => null;',
|
|
1722
|
+
'tsconfig.json': JSON.stringify({
|
|
1723
|
+
compilerOptions: { noEmit: true, jsx: 'react-jsx' },
|
|
1724
|
+
}),
|
|
1725
|
+
});
|
|
1726
|
+
try {
|
|
1727
|
+
const result = runHook('post-edit-typecheck.js', {
|
|
1728
|
+
tool_name: 'Edit',
|
|
1729
|
+
tool_input: { file_path: path.join(dir, 'src/App.tsx') },
|
|
1730
|
+
}, {
|
|
1731
|
+
ERNE_PROJECT_DIR: dir,
|
|
1732
|
+
});
|
|
1733
|
+
// tsc may not be installed in test env — accept 0 or 2
|
|
1734
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
1735
|
+
} finally {
|
|
1736
|
+
cleanupTempProject(dir);
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
test('skips non-TS files', () => {
|
|
1741
|
+
const result = runHook('post-edit-typecheck.js', {
|
|
1742
|
+
tool_name: 'Edit',
|
|
1743
|
+
tool_input: { file_path: '/project/src/utils.js' },
|
|
1744
|
+
});
|
|
1745
|
+
expect(result.exitCode).toBe(0);
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
test('skips when no file path', () => {
|
|
1749
|
+
const result = runHook('post-edit-typecheck.js', {});
|
|
1750
|
+
expect(result.exitCode).toBe(0);
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
test('skips test files', () => {
|
|
1754
|
+
const result = runHook('post-edit-typecheck.js', {
|
|
1755
|
+
tool_name: 'Edit',
|
|
1756
|
+
tool_input: { file_path: '/project/src/App.test.tsx' },
|
|
1757
|
+
});
|
|
1758
|
+
expect(result.exitCode).toBe(0);
|
|
1759
|
+
});
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
describe('check-console-log.js', () => {
|
|
1763
|
+
test('warns on console.log in production code', () => {
|
|
1764
|
+
const dir = createTempProject({
|
|
1765
|
+
'src/app.ts': 'console.log("debug");\nconst x = 1;',
|
|
1766
|
+
});
|
|
1767
|
+
try {
|
|
1768
|
+
const result = runHook('check-console-log.js', {
|
|
1769
|
+
tool_name: 'Edit',
|
|
1770
|
+
tool_input: { file_path: path.join(dir, 'src/app.ts') },
|
|
1771
|
+
}, {
|
|
1772
|
+
ERNE_PROJECT_DIR: dir,
|
|
1773
|
+
});
|
|
1774
|
+
expect(result.exitCode).toBe(2); // warn
|
|
1775
|
+
expect(result.stdout).toContain('console.log');
|
|
1776
|
+
} finally {
|
|
1777
|
+
cleanupTempProject(dir);
|
|
1778
|
+
}
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
test('passes when no console.log found', () => {
|
|
1782
|
+
const dir = createTempProject({
|
|
1783
|
+
'src/app.ts': 'const x = 1;\nexport default x;',
|
|
1784
|
+
});
|
|
1785
|
+
try {
|
|
1786
|
+
const result = runHook('check-console-log.js', {
|
|
1787
|
+
tool_name: 'Edit',
|
|
1788
|
+
tool_input: { file_path: path.join(dir, 'src/app.ts') },
|
|
1789
|
+
}, {
|
|
1790
|
+
ERNE_PROJECT_DIR: dir,
|
|
1791
|
+
});
|
|
1792
|
+
expect(result.exitCode).toBe(0);
|
|
1793
|
+
} finally {
|
|
1794
|
+
cleanupTempProject(dir);
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
test('ignores console.log in test files', () => {
|
|
1799
|
+
const dir = createTempProject({
|
|
1800
|
+
'src/app.test.ts': 'console.log("test output");',
|
|
1801
|
+
});
|
|
1802
|
+
try {
|
|
1803
|
+
const result = runHook('check-console-log.js', {
|
|
1804
|
+
tool_name: 'Edit',
|
|
1805
|
+
tool_input: { file_path: path.join(dir, 'src/app.test.ts') },
|
|
1806
|
+
}, {
|
|
1807
|
+
ERNE_PROJECT_DIR: dir,
|
|
1808
|
+
});
|
|
1809
|
+
expect(result.exitCode).toBe(0);
|
|
1810
|
+
} finally {
|
|
1811
|
+
cleanupTempProject(dir);
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
test('detects console.warn and console.error too', () => {
|
|
1816
|
+
const dir = createTempProject({
|
|
1817
|
+
'src/app.ts': 'console.warn("oops");\nconsole.error("bad");',
|
|
1818
|
+
});
|
|
1819
|
+
try {
|
|
1820
|
+
const result = runHook('check-console-log.js', {
|
|
1821
|
+
tool_name: 'Edit',
|
|
1822
|
+
tool_input: { file_path: path.join(dir, 'src/app.ts') },
|
|
1823
|
+
}, {
|
|
1824
|
+
ERNE_PROJECT_DIR: dir,
|
|
1825
|
+
});
|
|
1826
|
+
expect(result.exitCode).toBe(2);
|
|
1827
|
+
} finally {
|
|
1828
|
+
cleanupTempProject(dir);
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
test('skips non-JS/TS files', () => {
|
|
1833
|
+
const result = runHook('check-console-log.js', {
|
|
1834
|
+
tool_name: 'Edit',
|
|
1835
|
+
tool_input: { file_path: '/project/README.md' },
|
|
1836
|
+
});
|
|
1837
|
+
expect(result.exitCode).toBe(0);
|
|
1838
|
+
});
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
describe('check-platform-specific.js', () => {
|
|
1842
|
+
test('warns when Platform.OS only checks one platform', () => {
|
|
1843
|
+
const dir = createTempProject({
|
|
1844
|
+
'src/app.tsx': [
|
|
1845
|
+
"import { Platform } from 'react-native';",
|
|
1846
|
+
"const style = Platform.OS === 'ios' ? 10 : 10;",
|
|
1847
|
+
].join('\n'),
|
|
1848
|
+
});
|
|
1849
|
+
try {
|
|
1850
|
+
const result = runHook('check-platform-specific.js', {
|
|
1851
|
+
tool_name: 'Edit',
|
|
1852
|
+
tool_input: { file_path: path.join(dir, 'src/app.tsx') },
|
|
1853
|
+
}, {
|
|
1854
|
+
ERNE_PROJECT_DIR: dir,
|
|
1855
|
+
});
|
|
1856
|
+
// File has Platform.OS but both branches are identical — passes
|
|
1857
|
+
// The hook checks for Platform.OS without android or ios branch
|
|
1858
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
1859
|
+
} finally {
|
|
1860
|
+
cleanupTempProject(dir);
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
test('passes when Platform.select has both platforms', () => {
|
|
1865
|
+
const dir = createTempProject({
|
|
1866
|
+
'src/app.tsx': [
|
|
1867
|
+
"import { Platform } from 'react-native';",
|
|
1868
|
+
"const val = Platform.select({ ios: 10, android: 12 });",
|
|
1869
|
+
].join('\n'),
|
|
1870
|
+
});
|
|
1871
|
+
try {
|
|
1872
|
+
const result = runHook('check-platform-specific.js', {
|
|
1873
|
+
tool_name: 'Edit',
|
|
1874
|
+
tool_input: { file_path: path.join(dir, 'src/app.tsx') },
|
|
1875
|
+
}, {
|
|
1876
|
+
ERNE_PROJECT_DIR: dir,
|
|
1877
|
+
});
|
|
1878
|
+
expect(result.exitCode).toBe(0);
|
|
1879
|
+
} finally {
|
|
1880
|
+
cleanupTempProject(dir);
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
test('skips non-RN files', () => {
|
|
1885
|
+
const result = runHook('check-platform-specific.js', {
|
|
1886
|
+
tool_name: 'Edit',
|
|
1887
|
+
tool_input: { file_path: '/project/config.json' },
|
|
1888
|
+
});
|
|
1889
|
+
expect(result.exitCode).toBe(0);
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
test('passes when no Platform usage found', () => {
|
|
1893
|
+
const dir = createTempProject({
|
|
1894
|
+
'src/app.tsx': 'const x = 1;\nexport default x;',
|
|
1895
|
+
});
|
|
1896
|
+
try {
|
|
1897
|
+
const result = runHook('check-platform-specific.js', {
|
|
1898
|
+
tool_name: 'Edit',
|
|
1899
|
+
tool_input: { file_path: path.join(dir, 'src/app.tsx') },
|
|
1900
|
+
}, {
|
|
1901
|
+
ERNE_PROJECT_DIR: dir,
|
|
1902
|
+
});
|
|
1903
|
+
expect(result.exitCode).toBe(0);
|
|
1904
|
+
} finally {
|
|
1905
|
+
cleanupTempProject(dir);
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
describe('check-reanimated-worklet.js', () => {
|
|
1911
|
+
test('warns on non-serializable reference in worklet', () => {
|
|
1912
|
+
const dir = createTempProject({
|
|
1913
|
+
'src/anim.tsx': [
|
|
1914
|
+
"import Animated, { useAnimatedStyle } from 'react-native-reanimated';",
|
|
1915
|
+
"const outsideRef = React.createRef();",
|
|
1916
|
+
"const style = useAnimatedStyle(() => {",
|
|
1917
|
+
" return { opacity: outsideRef.current ? 1 : 0 };",
|
|
1918
|
+
"});",
|
|
1919
|
+
].join('\n'),
|
|
1920
|
+
});
|
|
1921
|
+
try {
|
|
1922
|
+
const result = runHook('check-reanimated-worklet.js', {
|
|
1923
|
+
tool_name: 'Edit',
|
|
1924
|
+
tool_input: { file_path: path.join(dir, 'src/anim.tsx') },
|
|
1925
|
+
}, {
|
|
1926
|
+
ERNE_PROJECT_DIR: dir,
|
|
1927
|
+
});
|
|
1928
|
+
// May detect or miss depending on heuristic depth
|
|
1929
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
1930
|
+
} finally {
|
|
1931
|
+
cleanupTempProject(dir);
|
|
1932
|
+
}
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
test('passes for files without reanimated', () => {
|
|
1936
|
+
const dir = createTempProject({
|
|
1937
|
+
'src/app.tsx': 'const x = 1;\nexport default x;',
|
|
1938
|
+
});
|
|
1939
|
+
try {
|
|
1940
|
+
const result = runHook('check-reanimated-worklet.js', {
|
|
1941
|
+
tool_name: 'Edit',
|
|
1942
|
+
tool_input: { file_path: path.join(dir, 'src/app.tsx') },
|
|
1943
|
+
}, {
|
|
1944
|
+
ERNE_PROJECT_DIR: dir,
|
|
1945
|
+
});
|
|
1946
|
+
expect(result.exitCode).toBe(0);
|
|
1947
|
+
} finally {
|
|
1948
|
+
cleanupTempProject(dir);
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
test('skips non-JS/TS files', () => {
|
|
1953
|
+
const result = runHook('check-reanimated-worklet.js', {
|
|
1954
|
+
tool_name: 'Edit',
|
|
1955
|
+
tool_input: { file_path: '/project/styles.css' },
|
|
1956
|
+
});
|
|
1957
|
+
expect(result.exitCode).toBe(0);
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1960
|
+
```
|
|
1961
|
+
|
|
1962
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
1963
|
+
|
|
1964
|
+
Run: `npx jest tests/hooks/validation-hooks.test.js -v`
|
|
1965
|
+
Expected: FAIL — stubs exit 0 without any validation logic
|
|
1966
|
+
|
|
1967
|
+
- [ ] **Step 3: Implement post-edit-typecheck.js**
|
|
1968
|
+
|
|
1969
|
+
```js
|
|
1970
|
+
// scripts/hooks/post-edit-typecheck.js
|
|
1971
|
+
'use strict';
|
|
1972
|
+
const { execFileSync } = require('child_process');
|
|
1973
|
+
const path = require('path');
|
|
1974
|
+
const {
|
|
1975
|
+
readStdin,
|
|
1976
|
+
getEditedFilePath,
|
|
1977
|
+
pass,
|
|
1978
|
+
warn,
|
|
1979
|
+
isTestFile,
|
|
1980
|
+
hasExtension,
|
|
1981
|
+
} = require('./lib/hook-utils');
|
|
1982
|
+
|
|
1983
|
+
const TS_EXTENSIONS = ['.ts', '.tsx'];
|
|
1984
|
+
|
|
1985
|
+
const input = readStdin();
|
|
1986
|
+
const filePath = getEditedFilePath(input);
|
|
1987
|
+
|
|
1988
|
+
if (!filePath) {
|
|
1989
|
+
pass();
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
if (!hasExtension(filePath, TS_EXTENSIONS)) {
|
|
1993
|
+
pass(); // Not a TypeScript file
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
if (isTestFile(filePath)) {
|
|
1997
|
+
pass(); // Skip test files for speed
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
2001
|
+
|
|
2002
|
+
try {
|
|
2003
|
+
execFileSync('npx', ['tsc', '--noEmit', '--pretty'], {
|
|
2004
|
+
encoding: 'utf8',
|
|
2005
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2006
|
+
timeout: 30000,
|
|
2007
|
+
cwd: projectDir,
|
|
2008
|
+
});
|
|
2009
|
+
pass('ERNE: Type check passed');
|
|
2010
|
+
} catch (err) {
|
|
2011
|
+
const output = err.stdout || err.stderr || '';
|
|
2012
|
+
if (output.includes('error TS')) {
|
|
2013
|
+
warn(`ERNE: Type errors found:\n${output.slice(0, 500)}`);
|
|
2014
|
+
} else {
|
|
2015
|
+
// tsc not available or other issue
|
|
2016
|
+
warn('ERNE: Could not run type check (tsc unavailable)');
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
```
|
|
2020
|
+
|
|
2021
|
+
- [ ] **Step 4: Implement check-console-log.js**
|
|
2022
|
+
|
|
2023
|
+
```js
|
|
2024
|
+
// scripts/hooks/check-console-log.js
|
|
2025
|
+
'use strict';
|
|
2026
|
+
const fs = require('fs');
|
|
2027
|
+
const {
|
|
2028
|
+
readStdin,
|
|
2029
|
+
getEditedFilePath,
|
|
2030
|
+
pass,
|
|
2031
|
+
warn,
|
|
2032
|
+
isTestFile,
|
|
2033
|
+
hasExtension,
|
|
2034
|
+
} = require('./lib/hook-utils');
|
|
2035
|
+
|
|
2036
|
+
const CODE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx'];
|
|
2037
|
+
const CONSOLE_PATTERN = /\bconsole\.(log|warn|error|info|debug)\s*\(/;
|
|
2038
|
+
|
|
2039
|
+
const input = readStdin();
|
|
2040
|
+
const filePath = getEditedFilePath(input);
|
|
2041
|
+
|
|
2042
|
+
if (!filePath) {
|
|
2043
|
+
pass();
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
if (!hasExtension(filePath, CODE_EXTENSIONS)) {
|
|
2047
|
+
pass();
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
if (isTestFile(filePath)) {
|
|
2051
|
+
pass(); // console.log is fine in tests
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
try {
|
|
2055
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
2056
|
+
const lines = content.split('\n');
|
|
2057
|
+
const hits = [];
|
|
2058
|
+
|
|
2059
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2060
|
+
if (CONSOLE_PATTERN.test(lines[i])) {
|
|
2061
|
+
hits.push(` L${i + 1}: ${lines[i].trim()}`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
if (hits.length > 0) {
|
|
2066
|
+
warn(
|
|
2067
|
+
`ERNE: Found ${hits.length} console statement(s) in production code:\n` +
|
|
2068
|
+
hits.slice(0, 5).join('\n') +
|
|
2069
|
+
(hits.length > 5 ? `\n ... and ${hits.length - 5} more` : '')
|
|
2070
|
+
);
|
|
2071
|
+
} else {
|
|
2072
|
+
pass();
|
|
2073
|
+
}
|
|
2074
|
+
} catch {
|
|
2075
|
+
pass(); // Can't read file — don't block
|
|
2076
|
+
}
|
|
2077
|
+
```
|
|
2078
|
+
|
|
2079
|
+
- [ ] **Step 5: Implement check-platform-specific.js**
|
|
2080
|
+
|
|
2081
|
+
```js
|
|
2082
|
+
// scripts/hooks/check-platform-specific.js
|
|
2083
|
+
'use strict';
|
|
2084
|
+
const fs = require('fs');
|
|
2085
|
+
const {
|
|
2086
|
+
readStdin,
|
|
2087
|
+
getEditedFilePath,
|
|
2088
|
+
pass,
|
|
2089
|
+
warn,
|
|
2090
|
+
hasExtension,
|
|
2091
|
+
} = require('./lib/hook-utils');
|
|
2092
|
+
|
|
2093
|
+
const CODE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx'];
|
|
2094
|
+
|
|
2095
|
+
const input = readStdin();
|
|
2096
|
+
const filePath = getEditedFilePath(input);
|
|
2097
|
+
|
|
2098
|
+
if (!filePath) {
|
|
2099
|
+
pass();
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
if (!hasExtension(filePath, CODE_EXTENSIONS)) {
|
|
2103
|
+
pass();
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
try {
|
|
2107
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
2108
|
+
|
|
2109
|
+
// Check for Platform.OS usage
|
|
2110
|
+
const hasPlatformOS = /Platform\.OS\b/.test(content);
|
|
2111
|
+
if (!hasPlatformOS) {
|
|
2112
|
+
pass(); // No Platform.OS usage
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// Check Platform.select for both platforms
|
|
2116
|
+
const selectMatches = content.match(/Platform\.select\s*\(\s*\{([^}]+)\}/g);
|
|
2117
|
+
if (selectMatches) {
|
|
2118
|
+
for (const match of selectMatches) {
|
|
2119
|
+
const hasIos = /ios\s*:/.test(match);
|
|
2120
|
+
const hasAndroid = /android\s*:/.test(match);
|
|
2121
|
+
if (!hasIos || !hasAndroid) {
|
|
2122
|
+
warn(
|
|
2123
|
+
'ERNE: Platform.select missing a platform case. ' +
|
|
2124
|
+
'Ensure both ios and android are handled.'
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// Check Platform.OS conditionals — heuristic: look for 'ios' and 'android' strings near Platform.OS
|
|
2131
|
+
const hasIosRef = /['"]ios['"]/.test(content);
|
|
2132
|
+
const hasAndroidRef = /['"]android['"]/.test(content);
|
|
2133
|
+
|
|
2134
|
+
if (hasPlatformOS && hasIosRef && !hasAndroidRef) {
|
|
2135
|
+
warn('ERNE: Platform.OS checks for iOS but not Android');
|
|
2136
|
+
} else if (hasPlatformOS && hasAndroidRef && !hasIosRef) {
|
|
2137
|
+
warn('ERNE: Platform.OS checks for Android but not iOS');
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
pass();
|
|
2141
|
+
} catch {
|
|
2142
|
+
pass(); // Can't read file — don't block
|
|
2143
|
+
}
|
|
2144
|
+
```
|
|
2145
|
+
|
|
2146
|
+
- [ ] **Step 6: Implement check-reanimated-worklet.js**
|
|
2147
|
+
|
|
2148
|
+
```js
|
|
2149
|
+
// scripts/hooks/check-reanimated-worklet.js
|
|
2150
|
+
'use strict';
|
|
2151
|
+
const fs = require('fs');
|
|
2152
|
+
const {
|
|
2153
|
+
readStdin,
|
|
2154
|
+
getEditedFilePath,
|
|
2155
|
+
pass,
|
|
2156
|
+
warn,
|
|
2157
|
+
hasExtension,
|
|
2158
|
+
} = require('./lib/hook-utils');
|
|
2159
|
+
|
|
2160
|
+
const CODE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx'];
|
|
2161
|
+
|
|
2162
|
+
const input = readStdin();
|
|
2163
|
+
const filePath = getEditedFilePath(input);
|
|
2164
|
+
|
|
2165
|
+
if (!filePath) {
|
|
2166
|
+
pass();
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
if (!hasExtension(filePath, CODE_EXTENSIONS)) {
|
|
2170
|
+
pass();
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
try {
|
|
2174
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
2175
|
+
|
|
2176
|
+
// Only check files that import from react-native-reanimated
|
|
2177
|
+
if (!content.includes('react-native-reanimated')) {
|
|
2178
|
+
pass();
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Heuristic: look for useAnimatedStyle/useAnimatedGestureHandler/etc.
|
|
2182
|
+
// that reference variables defined outside the worklet callback
|
|
2183
|
+
const workletPatterns = [
|
|
2184
|
+
/useAnimatedStyle\s*\(/,
|
|
2185
|
+
/useAnimatedGestureHandler\s*\(/,
|
|
2186
|
+
/useAnimatedScrollHandler\s*\(/,
|
|
2187
|
+
/useDerivedValue\s*\(/,
|
|
2188
|
+
/useAnimatedReaction\s*\(/,
|
|
2189
|
+
];
|
|
2190
|
+
|
|
2191
|
+
const hasWorklet = workletPatterns.some(p => p.test(content));
|
|
2192
|
+
if (!hasWorklet) {
|
|
2193
|
+
pass();
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// Check for common non-serializable patterns inside worklets
|
|
2197
|
+
const dangerousPatterns = [
|
|
2198
|
+
/\.current\b/, // ref.current inside worklet
|
|
2199
|
+
/React\.createRef/, // createRef referenced in worklet scope
|
|
2200
|
+
/useRef\s*\(/, // may indicate ref usage near worklet
|
|
2201
|
+
];
|
|
2202
|
+
|
|
2203
|
+
// Simple heuristic — find worklet callbacks and check for danger
|
|
2204
|
+
const warnings = [];
|
|
2205
|
+
for (const pattern of dangerousPatterns) {
|
|
2206
|
+
if (pattern.test(content)) {
|
|
2207
|
+
warnings.push(pattern.source);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
if (warnings.length > 0) {
|
|
2212
|
+
warn(
|
|
2213
|
+
'ERNE: Possible non-serializable reference in Reanimated worklet. ' +
|
|
2214
|
+
'Refs and non-primitive objects cannot be accessed inside worklet callbacks. ' +
|
|
2215
|
+
'Use shared values instead.'
|
|
2216
|
+
);
|
|
2217
|
+
} else {
|
|
2218
|
+
pass();
|
|
2219
|
+
}
|
|
2220
|
+
} catch {
|
|
2221
|
+
pass();
|
|
2222
|
+
}
|
|
2223
|
+
```
|
|
2224
|
+
|
|
2225
|
+
- [ ] **Step 7: Run tests to verify they pass**
|
|
2226
|
+
|
|
2227
|
+
Run: `npx jest tests/hooks/validation-hooks.test.js -v`
|
|
2228
|
+
Expected: All tests PASS
|
|
2229
|
+
|
|
2230
|
+
- [ ] **Step 8: Run full test suite**
|
|
2231
|
+
|
|
2232
|
+
Run: `npx jest tests/hooks/ -v`
|
|
2233
|
+
Expected: All tests PASS
|
|
2234
|
+
|
|
2235
|
+
- [ ] **Step 9: Commit**
|
|
2236
|
+
|
|
2237
|
+
```bash
|
|
2238
|
+
git add scripts/hooks/post-edit-typecheck.js scripts/hooks/check-console-log.js scripts/hooks/check-platform-specific.js scripts/hooks/check-reanimated-worklet.js tests/hooks/validation-hooks.test.js
|
|
2239
|
+
git commit -m "feat: implement standard validation hooks (typecheck, console-log, platform, reanimated)"
|
|
2240
|
+
```
|
|
2241
|
+
|
|
2242
|
+
---
|
|
2243
|
+
|
|
2244
|
+
### Task 9: Config & Lint Hooks — check-expo-config.js, bundle-size-check.js, pre-commit-lint.js, evaluate-session.js
|
|
2245
|
+
|
|
2246
|
+
**Files:**
|
|
2247
|
+
- Edit: `scripts/hooks/check-expo-config.js` (replace stub)
|
|
2248
|
+
- Edit: `scripts/hooks/bundle-size-check.js` (replace stub)
|
|
2249
|
+
- Edit: `scripts/hooks/pre-commit-lint.js` (replace stub)
|
|
2250
|
+
- Edit: `scripts/hooks/evaluate-session.js` (replace stub)
|
|
2251
|
+
- Test: `tests/hooks/validation-hooks.test.js` (append)
|
|
2252
|
+
- Test: `tests/hooks/learning-hooks.test.js` (append)
|
|
2253
|
+
|
|
2254
|
+
- [ ] **Step 1: Append failing tests to validation-hooks.test.js**
|
|
2255
|
+
|
|
2256
|
+
Append to `tests/hooks/validation-hooks.test.js`:
|
|
2257
|
+
|
|
2258
|
+
```js
|
|
2259
|
+
describe('check-expo-config.js', () => {
|
|
2260
|
+
test('passes for valid app.json', () => {
|
|
2261
|
+
const dir = createTempProject({
|
|
2262
|
+
'app.json': JSON.stringify({
|
|
2263
|
+
expo: {
|
|
2264
|
+
name: 'MyApp',
|
|
2265
|
+
slug: 'myapp',
|
|
2266
|
+
version: '1.0.0',
|
|
2267
|
+
},
|
|
2268
|
+
}),
|
|
2269
|
+
});
|
|
2270
|
+
try {
|
|
2271
|
+
const result = runHook('check-expo-config.js', {
|
|
2272
|
+
tool_name: 'Edit',
|
|
2273
|
+
tool_input: { file_path: path.join(dir, 'app.json') },
|
|
2274
|
+
}, {
|
|
2275
|
+
ERNE_PROJECT_DIR: dir,
|
|
2276
|
+
});
|
|
2277
|
+
expect(result.exitCode).toBe(0);
|
|
2278
|
+
} finally {
|
|
2279
|
+
cleanupTempProject(dir);
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
test('warns on missing expo.name', () => {
|
|
2284
|
+
const dir = createTempProject({
|
|
2285
|
+
'app.json': JSON.stringify({
|
|
2286
|
+
expo: { slug: 'myapp', version: '1.0.0' },
|
|
2287
|
+
}),
|
|
2288
|
+
});
|
|
2289
|
+
try {
|
|
2290
|
+
const result = runHook('check-expo-config.js', {
|
|
2291
|
+
tool_name: 'Edit',
|
|
2292
|
+
tool_input: { file_path: path.join(dir, 'app.json') },
|
|
2293
|
+
}, {
|
|
2294
|
+
ERNE_PROJECT_DIR: dir,
|
|
2295
|
+
});
|
|
2296
|
+
expect(result.exitCode).toBe(2);
|
|
2297
|
+
expect(result.stdout).toContain('name');
|
|
2298
|
+
} finally {
|
|
2299
|
+
cleanupTempProject(dir);
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
test('skips non-config files', () => {
|
|
2304
|
+
const result = runHook('check-expo-config.js', {
|
|
2305
|
+
tool_name: 'Edit',
|
|
2306
|
+
tool_input: { file_path: '/project/src/app.tsx' },
|
|
2307
|
+
});
|
|
2308
|
+
expect(result.exitCode).toBe(0);
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
test('warns on invalid JSON in app.json', () => {
|
|
2312
|
+
const dir = createTempProject({
|
|
2313
|
+
'app.json': '{ invalid json',
|
|
2314
|
+
});
|
|
2315
|
+
try {
|
|
2316
|
+
const result = runHook('check-expo-config.js', {
|
|
2317
|
+
tool_name: 'Edit',
|
|
2318
|
+
tool_input: { file_path: path.join(dir, 'app.json') },
|
|
2319
|
+
}, {
|
|
2320
|
+
ERNE_PROJECT_DIR: dir,
|
|
2321
|
+
});
|
|
2322
|
+
expect(result.exitCode).toBe(2);
|
|
2323
|
+
} finally {
|
|
2324
|
+
cleanupTempProject(dir);
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
});
|
|
2328
|
+
|
|
2329
|
+
describe('bundle-size-check.js', () => {
|
|
2330
|
+
test('warns on large dependency additions', () => {
|
|
2331
|
+
const dir = createTempProject({
|
|
2332
|
+
'package.json': JSON.stringify({
|
|
2333
|
+
dependencies: {
|
|
2334
|
+
'moment': '^2.30.0',
|
|
2335
|
+
'react': '18.2.0',
|
|
2336
|
+
},
|
|
2337
|
+
}),
|
|
2338
|
+
});
|
|
2339
|
+
try {
|
|
2340
|
+
const result = runHook('bundle-size-check.js', {
|
|
2341
|
+
tool_name: 'Edit',
|
|
2342
|
+
tool_input: { file_path: path.join(dir, 'package.json') },
|
|
2343
|
+
}, {
|
|
2344
|
+
ERNE_PROJECT_DIR: dir,
|
|
2345
|
+
});
|
|
2346
|
+
// moment is a known large package
|
|
2347
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
2348
|
+
} finally {
|
|
2349
|
+
cleanupTempProject(dir);
|
|
2350
|
+
}
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2353
|
+
test('passes for non-package.json files', () => {
|
|
2354
|
+
const result = runHook('bundle-size-check.js', {
|
|
2355
|
+
tool_name: 'Edit',
|
|
2356
|
+
tool_input: { file_path: '/project/src/app.tsx' },
|
|
2357
|
+
});
|
|
2358
|
+
expect(result.exitCode).toBe(0);
|
|
2359
|
+
});
|
|
2360
|
+
|
|
2361
|
+
test('skips when no file path', () => {
|
|
2362
|
+
const result = runHook('bundle-size-check.js', {});
|
|
2363
|
+
expect(result.exitCode).toBe(0);
|
|
2364
|
+
});
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
describe('pre-commit-lint.js', () => {
|
|
2368
|
+
test('handles missing eslint gracefully', () => {
|
|
2369
|
+
const dir = createTempProject({
|
|
2370
|
+
'package.json': JSON.stringify({ name: 'test' }),
|
|
2371
|
+
});
|
|
2372
|
+
try {
|
|
2373
|
+
const result = runHook('pre-commit-lint.js', {
|
|
2374
|
+
tool_name: 'Bash',
|
|
2375
|
+
tool_input: { command: 'git commit -m "test"' },
|
|
2376
|
+
}, {
|
|
2377
|
+
ERNE_PROJECT_DIR: dir,
|
|
2378
|
+
});
|
|
2379
|
+
// Should warn (eslint not found) but not crash
|
|
2380
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
2381
|
+
} finally {
|
|
2382
|
+
cleanupTempProject(dir);
|
|
2383
|
+
}
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
test('skips non-commit bash commands', () => {
|
|
2387
|
+
const result = runHook('pre-commit-lint.js', {
|
|
2388
|
+
tool_name: 'Bash',
|
|
2389
|
+
tool_input: { command: 'ls -la' },
|
|
2390
|
+
});
|
|
2391
|
+
expect(result.exitCode).toBe(0);
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
test('skips when no command in stdin', () => {
|
|
2395
|
+
const result = runHook('pre-commit-lint.js', {
|
|
2396
|
+
tool_name: 'Bash',
|
|
2397
|
+
tool_input: {},
|
|
2398
|
+
});
|
|
2399
|
+
expect(result.exitCode).toBe(0);
|
|
2400
|
+
});
|
|
2401
|
+
});
|
|
2402
|
+
```
|
|
2403
|
+
|
|
2404
|
+
- [ ] **Step 2: Append failing test for evaluate-session.js to learning-hooks.test.js**
|
|
2405
|
+
|
|
2406
|
+
Append to `tests/hooks/learning-hooks.test.js`:
|
|
2407
|
+
|
|
2408
|
+
```js
|
|
2409
|
+
describe('evaluate-session.js', () => {
|
|
2410
|
+
test('creates session evaluation file', () => {
|
|
2411
|
+
const dir = createTempProject({});
|
|
2412
|
+
try {
|
|
2413
|
+
const result = runHook('evaluate-session.js', {
|
|
2414
|
+
stop_reason: 'end_turn',
|
|
2415
|
+
}, {
|
|
2416
|
+
ERNE_PROJECT_DIR: dir,
|
|
2417
|
+
});
|
|
2418
|
+
expect(result.exitCode).toBe(0);
|
|
2419
|
+
|
|
2420
|
+
const evalDir = path.join(dir, '.claude', 'erne');
|
|
2421
|
+
const files = fs.readdirSync(evalDir);
|
|
2422
|
+
const evalFiles = files.filter(f => f.startsWith('session-'));
|
|
2423
|
+
expect(evalFiles.length).toBeGreaterThan(0);
|
|
2424
|
+
} finally {
|
|
2425
|
+
cleanupTempProject(dir);
|
|
2426
|
+
}
|
|
2427
|
+
});
|
|
2428
|
+
|
|
2429
|
+
test('always exits 0', () => {
|
|
2430
|
+
const result = runHook('evaluate-session.js', {}, {
|
|
2431
|
+
ERNE_PROJECT_DIR: '/nonexistent/path',
|
|
2432
|
+
});
|
|
2433
|
+
expect(result.exitCode).toBe(0);
|
|
2434
|
+
});
|
|
2435
|
+
});
|
|
2436
|
+
```
|
|
2437
|
+
|
|
2438
|
+
- [ ] **Step 3: Run tests to verify they fail**
|
|
2439
|
+
|
|
2440
|
+
Run: `npx jest tests/hooks/validation-hooks.test.js tests/hooks/learning-hooks.test.js -v`
|
|
2441
|
+
Expected: FAIL — stubs lack implementation
|
|
2442
|
+
|
|
2443
|
+
- [ ] **Step 4: Implement check-expo-config.js**
|
|
2444
|
+
|
|
2445
|
+
```js
|
|
2446
|
+
// scripts/hooks/check-expo-config.js
|
|
2447
|
+
'use strict';
|
|
2448
|
+
const fs = require('fs');
|
|
2449
|
+
const path = require('path');
|
|
2450
|
+
const {
|
|
2451
|
+
readStdin,
|
|
2452
|
+
getEditedFilePath,
|
|
2453
|
+
pass,
|
|
2454
|
+
warn,
|
|
2455
|
+
} = require('./lib/hook-utils');
|
|
2456
|
+
|
|
2457
|
+
const input = readStdin();
|
|
2458
|
+
const filePath = getEditedFilePath(input);
|
|
2459
|
+
|
|
2460
|
+
if (!filePath) {
|
|
2461
|
+
pass();
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
const basename = path.basename(filePath);
|
|
2465
|
+
const isConfig = basename === 'app.json' || basename === 'app.config.ts' || basename === 'app.config.js';
|
|
2466
|
+
|
|
2467
|
+
if (!isConfig) {
|
|
2468
|
+
pass();
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
2472
|
+
const appJsonPath = path.join(projectDir, 'app.json');
|
|
2473
|
+
|
|
2474
|
+
try {
|
|
2475
|
+
const raw = fs.readFileSync(appJsonPath, 'utf8');
|
|
2476
|
+
let config;
|
|
2477
|
+
try {
|
|
2478
|
+
config = JSON.parse(raw);
|
|
2479
|
+
} catch {
|
|
2480
|
+
warn('ERNE: app.json contains invalid JSON');
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
const expo = config.expo || config;
|
|
2484
|
+
const missing = [];
|
|
2485
|
+
|
|
2486
|
+
if (!expo.name) missing.push('name');
|
|
2487
|
+
if (!expo.slug) missing.push('slug');
|
|
2488
|
+
if (!expo.version) missing.push('version');
|
|
2489
|
+
|
|
2490
|
+
if (missing.length > 0) {
|
|
2491
|
+
warn(`ERNE: app.json missing required fields: ${missing.join(', ')}`);
|
|
2492
|
+
} else {
|
|
2493
|
+
pass('ERNE: Expo config valid');
|
|
2494
|
+
}
|
|
2495
|
+
} catch {
|
|
2496
|
+
pass(); // No app.json found — not an Expo project or not relevant
|
|
2497
|
+
}
|
|
2498
|
+
```
|
|
2499
|
+
|
|
2500
|
+
- [ ] **Step 5: Implement bundle-size-check.js**
|
|
2501
|
+
|
|
2502
|
+
```js
|
|
2503
|
+
// scripts/hooks/bundle-size-check.js
|
|
2504
|
+
'use strict';
|
|
2505
|
+
const fs = require('fs');
|
|
2506
|
+
const path = require('path');
|
|
2507
|
+
const {
|
|
2508
|
+
readStdin,
|
|
2509
|
+
getEditedFilePath,
|
|
2510
|
+
pass,
|
|
2511
|
+
warn,
|
|
2512
|
+
} = require('./lib/hook-utils');
|
|
2513
|
+
|
|
2514
|
+
// Known large packages that should trigger a warning
|
|
2515
|
+
const LARGE_PACKAGES = [
|
|
2516
|
+
'moment',
|
|
2517
|
+
'lodash',
|
|
2518
|
+
'firebase',
|
|
2519
|
+
'aws-sdk',
|
|
2520
|
+
'@aws-sdk/client-s3',
|
|
2521
|
+
'native-base',
|
|
2522
|
+
'react-native-paper',
|
|
2523
|
+
];
|
|
2524
|
+
|
|
2525
|
+
const input = readStdin();
|
|
2526
|
+
const filePath = getEditedFilePath(input);
|
|
2527
|
+
|
|
2528
|
+
if (!filePath) {
|
|
2529
|
+
pass();
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
if (path.basename(filePath) !== 'package.json') {
|
|
2533
|
+
pass();
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
try {
|
|
2537
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
2538
|
+
const pkg = JSON.parse(content);
|
|
2539
|
+
const allDeps = {
|
|
2540
|
+
...pkg.dependencies,
|
|
2541
|
+
...pkg.devDependencies,
|
|
2542
|
+
};
|
|
2543
|
+
|
|
2544
|
+
const found = LARGE_PACKAGES.filter(name => name in allDeps);
|
|
2545
|
+
|
|
2546
|
+
if (found.length > 0) {
|
|
2547
|
+
warn(
|
|
2548
|
+
`ERNE: Large dependencies detected: ${found.join(', ')}. ` +
|
|
2549
|
+
'Consider lighter alternatives (e.g., date-fns instead of moment, ' +
|
|
2550
|
+
'lodash-es or individual lodash methods instead of full lodash).'
|
|
2551
|
+
);
|
|
2552
|
+
} else {
|
|
2553
|
+
pass();
|
|
2554
|
+
}
|
|
2555
|
+
} catch {
|
|
2556
|
+
pass();
|
|
2557
|
+
}
|
|
2558
|
+
```
|
|
2559
|
+
|
|
2560
|
+
- [ ] **Step 6: Implement pre-commit-lint.js**
|
|
2561
|
+
|
|
2562
|
+
```js
|
|
2563
|
+
// scripts/hooks/pre-commit-lint.js
|
|
2564
|
+
'use strict';
|
|
2565
|
+
const { execFileSync } = require('child_process');
|
|
2566
|
+
const {
|
|
2567
|
+
readStdin,
|
|
2568
|
+
pass,
|
|
2569
|
+
fail,
|
|
2570
|
+
warn,
|
|
2571
|
+
} = require('./lib/hook-utils');
|
|
2572
|
+
|
|
2573
|
+
const input = readStdin();
|
|
2574
|
+
|
|
2575
|
+
// Only run for git commit commands
|
|
2576
|
+
const command = (input.tool_input && input.tool_input.command) || '';
|
|
2577
|
+
if (!command.includes('git commit')) {
|
|
2578
|
+
pass();
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
2582
|
+
|
|
2583
|
+
// Try running eslint
|
|
2584
|
+
try {
|
|
2585
|
+
execFileSync('npx', ['eslint', '.', '--max-warnings=0'], {
|
|
2586
|
+
encoding: 'utf8',
|
|
2587
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2588
|
+
timeout: 30000,
|
|
2589
|
+
cwd: projectDir,
|
|
2590
|
+
});
|
|
2591
|
+
} catch (err) {
|
|
2592
|
+
const output = err.stdout || err.stderr || '';
|
|
2593
|
+
if (output.includes('error') || output.includes('warning')) {
|
|
2594
|
+
fail(`ERNE: Lint errors found. Fix before committing:\n${output.slice(0, 500)}`);
|
|
2595
|
+
}
|
|
2596
|
+
// eslint not available — continue with warning
|
|
2597
|
+
if (err.status === 127 || output.includes('not found')) {
|
|
2598
|
+
warn('ERNE: ESLint not available, skipping lint check');
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// Try running prettier check
|
|
2603
|
+
try {
|
|
2604
|
+
execFileSync('npx', ['prettier', '--check', '.'], {
|
|
2605
|
+
encoding: 'utf8',
|
|
2606
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2607
|
+
timeout: 15000,
|
|
2608
|
+
cwd: projectDir,
|
|
2609
|
+
});
|
|
2610
|
+
pass('ERNE: Lint and format checks passed');
|
|
2611
|
+
} catch (err) {
|
|
2612
|
+
const output = err.stdout || err.stderr || '';
|
|
2613
|
+
if (output.includes('Code style')) {
|
|
2614
|
+
warn('ERNE: Some files need formatting. Run: npx prettier --write .');
|
|
2615
|
+
} else {
|
|
2616
|
+
pass(); // Prettier not available — already warned about eslint
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
```
|
|
2620
|
+
|
|
2621
|
+
- [ ] **Step 7: Implement evaluate-session.js**
|
|
2622
|
+
|
|
2623
|
+
```js
|
|
2624
|
+
// scripts/hooks/evaluate-session.js
|
|
2625
|
+
'use strict';
|
|
2626
|
+
const fs = require('fs');
|
|
2627
|
+
const path = require('path');
|
|
2628
|
+
const { readStdin } = require('./lib/hook-utils');
|
|
2629
|
+
|
|
2630
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
2631
|
+
const input = readStdin();
|
|
2632
|
+
|
|
2633
|
+
const evaluation = {
|
|
2634
|
+
timestamp: new Date().toISOString(),
|
|
2635
|
+
stop_reason: input.stop_reason || null,
|
|
2636
|
+
session_id: `session-${Date.now()}`,
|
|
2637
|
+
};
|
|
2638
|
+
|
|
2639
|
+
try {
|
|
2640
|
+
const evalDir = path.join(projectDir, '.claude', 'erne');
|
|
2641
|
+
fs.mkdirSync(evalDir, { recursive: true });
|
|
2642
|
+
|
|
2643
|
+
const filename = `session-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
|
2644
|
+
const evalPath = path.join(evalDir, filename);
|
|
2645
|
+
fs.writeFileSync(evalPath, JSON.stringify(evaluation, null, 2) + '\n');
|
|
2646
|
+
} catch {
|
|
2647
|
+
// Never fail — session evaluation is advisory
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
process.exit(0);
|
|
2651
|
+
```
|
|
2652
|
+
|
|
2653
|
+
- [ ] **Step 8: Run tests to verify they pass**
|
|
2654
|
+
|
|
2655
|
+
Run: `npx jest tests/hooks/validation-hooks.test.js tests/hooks/learning-hooks.test.js -v`
|
|
2656
|
+
Expected: All tests PASS
|
|
2657
|
+
|
|
2658
|
+
- [ ] **Step 9: Run full test suite**
|
|
2659
|
+
|
|
2660
|
+
Run: `npx jest tests/hooks/ -v`
|
|
2661
|
+
Expected: All tests PASS
|
|
2662
|
+
|
|
2663
|
+
- [ ] **Step 10: Commit**
|
|
2664
|
+
|
|
2665
|
+
```bash
|
|
2666
|
+
git add scripts/hooks/check-expo-config.js scripts/hooks/bundle-size-check.js scripts/hooks/pre-commit-lint.js scripts/hooks/evaluate-session.js tests/hooks/validation-hooks.test.js tests/hooks/learning-hooks.test.js
|
|
2667
|
+
git commit -m "feat: implement standard config, lint, bundle, and session evaluation hooks"
|
|
2668
|
+
```
|
|
2669
|
+
|
|
2670
|
+
---
|
|
2671
|
+
|
|
2672
|
+
## Chunk 5: Strict Profile Hooks
|
|
2673
|
+
|
|
2674
|
+
### Task 10: Strict Validation Hooks — pre-edit-test-gate.js & security-scan.js
|
|
2675
|
+
|
|
2676
|
+
**Files:**
|
|
2677
|
+
- Create: `scripts/hooks/pre-edit-test-gate.js`
|
|
2678
|
+
- Create: `scripts/hooks/security-scan.js`
|
|
2679
|
+
- Create: `tests/hooks/gate-hooks.test.js`
|
|
2680
|
+
|
|
2681
|
+
**Tests (write first):**
|
|
2682
|
+
|
|
2683
|
+
- [ ] **Step 1: Write tests in gate-hooks.test.js**
|
|
2684
|
+
|
|
2685
|
+
```js
|
|
2686
|
+
// tests/hooks/gate-hooks.test.js
|
|
2687
|
+
'use strict';
|
|
2688
|
+
const fs = require('fs');
|
|
2689
|
+
const path = require('path');
|
|
2690
|
+
const { runHook, createTempProject, cleanupTempProject } = require('./helpers');
|
|
2691
|
+
|
|
2692
|
+
describe('pre-edit-test-gate', () => {
|
|
2693
|
+
let projectDir;
|
|
2694
|
+
|
|
2695
|
+
beforeEach(() => {
|
|
2696
|
+
projectDir = createTempProject();
|
|
2697
|
+
});
|
|
2698
|
+
|
|
2699
|
+
afterEach(() => {
|
|
2700
|
+
cleanupTempProject(projectDir);
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
test('passes when no test file exists for source file', () => {
|
|
2704
|
+
// Create source file without corresponding test
|
|
2705
|
+
const srcDir = path.join(projectDir, 'src');
|
|
2706
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
2707
|
+
fs.writeFileSync(path.join(srcDir, 'utils.ts'), 'export const add = (a, b) => a + b;\n');
|
|
2708
|
+
|
|
2709
|
+
const result = runHook('pre-edit-test-gate.js', {
|
|
2710
|
+
tool_name: 'Edit',
|
|
2711
|
+
tool_input: { file_path: path.join(srcDir, 'utils.ts') },
|
|
2712
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2713
|
+
|
|
2714
|
+
expect(result.exitCode).toBe(0);
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
test('passes when related test file exists and passes', () => {
|
|
2718
|
+
const srcDir = path.join(projectDir, 'src');
|
|
2719
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
2720
|
+
fs.writeFileSync(path.join(srcDir, 'utils.ts'), 'module.exports.add = (a, b) => a + b;\n');
|
|
2721
|
+
|
|
2722
|
+
// Create a passing test
|
|
2723
|
+
const testDir = path.join(projectDir, '__tests__');
|
|
2724
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
2725
|
+
fs.writeFileSync(path.join(testDir, 'utils.test.ts'), `
|
|
2726
|
+
const { add } = require('../src/utils');
|
|
2727
|
+
test('add works', () => { expect(add(1,2)).toBe(3); });
|
|
2728
|
+
`);
|
|
2729
|
+
|
|
2730
|
+
// Also need a jest config or package.json with jest
|
|
2731
|
+
fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({
|
|
2732
|
+
devDependencies: { jest: '29.7.0' },
|
|
2733
|
+
}));
|
|
2734
|
+
|
|
2735
|
+
const result = runHook('pre-edit-test-gate.js', {
|
|
2736
|
+
tool_name: 'Edit',
|
|
2737
|
+
tool_input: { file_path: path.join(srcDir, 'utils.ts') },
|
|
2738
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2739
|
+
|
|
2740
|
+
// Exit 0 (pass) or 2 (warn if jest not available in temp env)
|
|
2741
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2744
|
+
test('skips test files (does not test-gate edits to tests)', () => {
|
|
2745
|
+
const result = runHook('pre-edit-test-gate.js', {
|
|
2746
|
+
tool_name: 'Edit',
|
|
2747
|
+
tool_input: { file_path: '/project/src/__tests__/foo.test.ts' },
|
|
2748
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2749
|
+
|
|
2750
|
+
expect(result.exitCode).toBe(0);
|
|
2751
|
+
});
|
|
2752
|
+
|
|
2753
|
+
test('skips non-JS/TS files', () => {
|
|
2754
|
+
const result = runHook('pre-edit-test-gate.js', {
|
|
2755
|
+
tool_name: 'Edit',
|
|
2756
|
+
tool_input: { file_path: '/project/README.md' },
|
|
2757
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2758
|
+
|
|
2759
|
+
expect(result.exitCode).toBe(0);
|
|
2760
|
+
});
|
|
2761
|
+
|
|
2762
|
+
test('skips when no file path in input', () => {
|
|
2763
|
+
const result = runHook('pre-edit-test-gate.js', {
|
|
2764
|
+
tool_name: 'Edit',
|
|
2765
|
+
tool_input: {},
|
|
2766
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2767
|
+
|
|
2768
|
+
expect(result.exitCode).toBe(0);
|
|
2769
|
+
});
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
describe('security-scan', () => {
|
|
2773
|
+
let projectDir;
|
|
2774
|
+
|
|
2775
|
+
beforeEach(() => {
|
|
2776
|
+
projectDir = createTempProject();
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
afterEach(() => {
|
|
2780
|
+
cleanupTempProject(projectDir);
|
|
2781
|
+
});
|
|
2782
|
+
|
|
2783
|
+
test('warns on hardcoded API keys', () => {
|
|
2784
|
+
const srcDir = path.join(projectDir, 'src');
|
|
2785
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
2786
|
+
fs.writeFileSync(path.join(srcDir, 'config.ts'), `
|
|
2787
|
+
const API_KEY = 'sk-1234567890abcdef1234567890abcdef';
|
|
2788
|
+
export default { API_KEY };
|
|
2789
|
+
`);
|
|
2790
|
+
|
|
2791
|
+
const result = runHook('security-scan.js', {
|
|
2792
|
+
tool_name: 'Write',
|
|
2793
|
+
tool_input: { file_path: path.join(srcDir, 'config.ts') },
|
|
2794
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2795
|
+
|
|
2796
|
+
expect(result.exitCode).toBe(2);
|
|
2797
|
+
expect(result.stdout).toContain('secret');
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
test('warns on unvalidated deep link handling', () => {
|
|
2801
|
+
const srcDir = path.join(projectDir, 'src');
|
|
2802
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
2803
|
+
fs.writeFileSync(path.join(srcDir, 'linking.ts'), `
|
|
2804
|
+
import { Linking } from 'react-native';
|
|
2805
|
+
Linking.openURL(url);
|
|
2806
|
+
`);
|
|
2807
|
+
|
|
2808
|
+
const result = runHook('security-scan.js', {
|
|
2809
|
+
tool_name: 'Edit',
|
|
2810
|
+
tool_input: { file_path: path.join(srcDir, 'linking.ts') },
|
|
2811
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2812
|
+
|
|
2813
|
+
expect(result.exitCode).toBe(2);
|
|
2814
|
+
expect(result.stdout).toContain('deep link');
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2817
|
+
test('passes on clean file', () => {
|
|
2818
|
+
const srcDir = path.join(projectDir, 'src');
|
|
2819
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
2820
|
+
fs.writeFileSync(path.join(srcDir, 'utils.ts'), `
|
|
2821
|
+
export function add(a: number, b: number): number {
|
|
2822
|
+
return a + b;
|
|
2823
|
+
}
|
|
2824
|
+
`);
|
|
2825
|
+
|
|
2826
|
+
const result = runHook('security-scan.js', {
|
|
2827
|
+
tool_name: 'Edit',
|
|
2828
|
+
tool_input: { file_path: path.join(srcDir, 'utils.ts') },
|
|
2829
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2830
|
+
|
|
2831
|
+
expect(result.exitCode).toBe(0);
|
|
2832
|
+
});
|
|
2833
|
+
|
|
2834
|
+
test('warns on eval usage', () => {
|
|
2835
|
+
const srcDir = path.join(projectDir, 'src');
|
|
2836
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
2837
|
+
fs.writeFileSync(path.join(srcDir, 'dynamic.ts'), `
|
|
2838
|
+
const result = eval(userInput);
|
|
2839
|
+
`);
|
|
2840
|
+
|
|
2841
|
+
const result = runHook('security-scan.js', {
|
|
2842
|
+
tool_name: 'Edit',
|
|
2843
|
+
tool_input: { file_path: path.join(srcDir, 'dynamic.ts') },
|
|
2844
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2845
|
+
|
|
2846
|
+
expect(result.exitCode).toBe(2);
|
|
2847
|
+
expect(result.stdout).toContain('unsafe');
|
|
2848
|
+
});
|
|
2849
|
+
|
|
2850
|
+
test('skips non-JS/TS files', () => {
|
|
2851
|
+
const result = runHook('security-scan.js', {
|
|
2852
|
+
tool_name: 'Edit',
|
|
2853
|
+
tool_input: { file_path: '/project/assets/image.png' },
|
|
2854
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2855
|
+
|
|
2856
|
+
expect(result.exitCode).toBe(0);
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
test('skips when no file path', () => {
|
|
2860
|
+
const result = runHook('security-scan.js', {
|
|
2861
|
+
tool_name: 'Edit',
|
|
2862
|
+
tool_input: {},
|
|
2863
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
2864
|
+
|
|
2865
|
+
expect(result.exitCode).toBe(0);
|
|
2866
|
+
});
|
|
2867
|
+
});
|
|
2868
|
+
```
|
|
2869
|
+
|
|
2870
|
+
**Implementation:**
|
|
2871
|
+
|
|
2872
|
+
- [ ] **Step 2: Implement pre-edit-test-gate.js**
|
|
2873
|
+
|
|
2874
|
+
```js
|
|
2875
|
+
// scripts/hooks/pre-edit-test-gate.js
|
|
2876
|
+
'use strict';
|
|
2877
|
+
const fs = require('fs');
|
|
2878
|
+
const path = require('path');
|
|
2879
|
+
const { execFileSync } = require('child_process');
|
|
2880
|
+
const { readStdin, getEditedFilePath, pass, warn, isTestFile, hasExtension } = require('./lib/hook-utils');
|
|
2881
|
+
|
|
2882
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
2883
|
+
const input = readStdin();
|
|
2884
|
+
const filePath = getEditedFilePath(input);
|
|
2885
|
+
|
|
2886
|
+
if (!filePath) {
|
|
2887
|
+
pass();
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// Only gate JS/TS source files
|
|
2891
|
+
const JS_TS_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
|
|
2892
|
+
if (!hasExtension(filePath, JS_TS_EXTS)) {
|
|
2893
|
+
pass();
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// Don't gate test files themselves
|
|
2897
|
+
if (isTestFile(filePath)) {
|
|
2898
|
+
pass();
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
// Find related test file
|
|
2902
|
+
const basename = path.basename(filePath, path.extname(filePath));
|
|
2903
|
+
const dir = path.dirname(filePath);
|
|
2904
|
+
|
|
2905
|
+
const testPatterns = [
|
|
2906
|
+
path.join(dir, '__tests__', `${basename}.test.ts`),
|
|
2907
|
+
path.join(dir, '__tests__', `${basename}.test.tsx`),
|
|
2908
|
+
path.join(dir, '__tests__', `${basename}.test.js`),
|
|
2909
|
+
path.join(dir, '__tests__', `${basename}.test.jsx`),
|
|
2910
|
+
path.join(dir, `${basename}.test.ts`),
|
|
2911
|
+
path.join(dir, `${basename}.test.tsx`),
|
|
2912
|
+
path.join(dir, `${basename}.test.js`),
|
|
2913
|
+
path.join(dir, `${basename}.test.jsx`),
|
|
2914
|
+
path.join(dir, `${basename}.spec.ts`),
|
|
2915
|
+
path.join(dir, `${basename}.spec.tsx`),
|
|
2916
|
+
path.join(dir, `${basename}.spec.js`),
|
|
2917
|
+
path.join(dir, `${basename}.spec.jsx`),
|
|
2918
|
+
];
|
|
2919
|
+
|
|
2920
|
+
// Also check project-root __tests__ and tests/ directories
|
|
2921
|
+
const relPath = path.relative(projectDir, filePath);
|
|
2922
|
+
const relDir = path.dirname(relPath);
|
|
2923
|
+
const rootTestPatterns = [
|
|
2924
|
+
path.join(projectDir, '__tests__', relDir, `${basename}.test.ts`),
|
|
2925
|
+
path.join(projectDir, '__tests__', relDir, `${basename}.test.tsx`),
|
|
2926
|
+
path.join(projectDir, '__tests__', relDir, `${basename}.test.js`),
|
|
2927
|
+
path.join(projectDir, '__tests__', `${basename}.test.ts`),
|
|
2928
|
+
path.join(projectDir, '__tests__', `${basename}.test.js`),
|
|
2929
|
+
path.join(projectDir, 'tests', `${basename}.test.ts`),
|
|
2930
|
+
path.join(projectDir, 'tests', `${basename}.test.js`),
|
|
2931
|
+
];
|
|
2932
|
+
|
|
2933
|
+
const allPatterns = [...testPatterns, ...rootTestPatterns];
|
|
2934
|
+
const testFile = allPatterns.find((p) => fs.existsSync(p));
|
|
2935
|
+
|
|
2936
|
+
if (!testFile) {
|
|
2937
|
+
// No test file found — allow edit but don't block
|
|
2938
|
+
pass();
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
// Run the related test
|
|
2942
|
+
try {
|
|
2943
|
+
execFileSync('npx', ['jest', '--bail', '--no-coverage', testFile], {
|
|
2944
|
+
encoding: 'utf8',
|
|
2945
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2946
|
+
timeout: 30000,
|
|
2947
|
+
cwd: projectDir,
|
|
2948
|
+
});
|
|
2949
|
+
pass('ERNE: Related tests pass');
|
|
2950
|
+
} catch (err) {
|
|
2951
|
+
const output = err.stdout || err.stderr || '';
|
|
2952
|
+
if (output.includes('FAIL')) {
|
|
2953
|
+
warn(`ERNE: Related test failed — ${path.basename(testFile)}. Fix tests before editing.`);
|
|
2954
|
+
} else {
|
|
2955
|
+
// Jest might not be available
|
|
2956
|
+
warn('ERNE: Could not run related tests (jest unavailable or error)');
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
```
|
|
2960
|
+
|
|
2961
|
+
- [ ] **Step 3: Implement security-scan.js**
|
|
2962
|
+
|
|
2963
|
+
```js
|
|
2964
|
+
// scripts/hooks/security-scan.js
|
|
2965
|
+
'use strict';
|
|
2966
|
+
const fs = require('fs');
|
|
2967
|
+
const { readStdin, getEditedFilePath, pass, warn, hasExtension } = require('./lib/hook-utils');
|
|
2968
|
+
|
|
2969
|
+
const input = readStdin();
|
|
2970
|
+
const filePath = getEditedFilePath(input);
|
|
2971
|
+
|
|
2972
|
+
if (!filePath) {
|
|
2973
|
+
pass();
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
const JS_TS_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
|
|
2977
|
+
if (!hasExtension(filePath, JS_TS_EXTS)) {
|
|
2978
|
+
pass();
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
let content;
|
|
2982
|
+
try {
|
|
2983
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
2984
|
+
} catch {
|
|
2985
|
+
pass(); // File might not exist yet (Write tool)
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
const issues = [];
|
|
2989
|
+
|
|
2990
|
+
// Check for hardcoded secrets
|
|
2991
|
+
const SECRET_PATTERNS = [
|
|
2992
|
+
{ pattern: /(['"`])sk-[a-zA-Z0-9]{20,}\1/, label: 'Possible hardcoded API secret key' },
|
|
2993
|
+
{ pattern: /(['"`])AIza[a-zA-Z0-9_-]{35}\1/, label: 'Possible hardcoded Google API key' },
|
|
2994
|
+
{ pattern: /(['"`])(ghp_|gho_|ghu_|ghs_|ghr_)[a-zA-Z0-9]{36,}\1/, label: 'Possible hardcoded GitHub token' },
|
|
2995
|
+
{ pattern: /(['"`])xox[bpras]-[a-zA-Z0-9-]{10,}\1/, label: 'Possible hardcoded Slack token' },
|
|
2996
|
+
{ pattern: /\b(password|secret|apikey|api_key)\s*[:=]\s*(['"`])[^'"]{8,}\2/i, label: 'Possible hardcoded secret or password' },
|
|
2997
|
+
];
|
|
2998
|
+
|
|
2999
|
+
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
3000
|
+
if (pattern.test(content)) {
|
|
3001
|
+
issues.push(label);
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
// Check for unsafe patterns
|
|
3006
|
+
if (/\beval\s*\(/.test(content)) {
|
|
3007
|
+
issues.push('Unsafe `eval()` usage detected');
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
if (/new\s+Function\s*\(/.test(content)) {
|
|
3011
|
+
issues.push('Unsafe `new Function()` usage detected');
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
if (/innerHTML\s*=/.test(content) && !content.includes('dangerouslySetInnerHTML')) {
|
|
3015
|
+
issues.push('Direct innerHTML assignment — potential XSS');
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
// Check for unvalidated deep link handling
|
|
3019
|
+
if (/Linking\.openURL\s*\(/.test(content)) {
|
|
3020
|
+
// Check if there's any URL validation nearby
|
|
3021
|
+
const hasValidation = /url\.startsWith|url\.match|isValidUrl|validateUrl|allowedSchemes/i.test(content);
|
|
3022
|
+
if (!hasValidation) {
|
|
3023
|
+
issues.push('Unvalidated deep link `Linking.openURL()` — validate URL scheme before opening');
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
// Check for WebView with JavaScript enabled without origin whitelist
|
|
3028
|
+
if (/WebView/.test(content) && /javaScriptEnabled/.test(content)) {
|
|
3029
|
+
if (!/originWhitelist/.test(content)) {
|
|
3030
|
+
issues.push('WebView with JS enabled but no `originWhitelist` — restrict allowed origins');
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
if (issues.length > 0) {
|
|
3035
|
+
warn(`ERNE: Security scan found ${issues.length} issue(s):\n${issues.map((i) => ` - ${i}`).join('\n')}`);
|
|
3036
|
+
} else {
|
|
3037
|
+
pass();
|
|
3038
|
+
}
|
|
3039
|
+
```
|
|
3040
|
+
|
|
3041
|
+
- [ ] **Step 4: Run tests**
|
|
3042
|
+
|
|
3043
|
+
Run: `npx jest tests/hooks/gate-hooks.test.js -v`
|
|
3044
|
+
Expected: All tests PASS
|
|
3045
|
+
|
|
3046
|
+
- [ ] **Step 5: Commit**
|
|
3047
|
+
|
|
3048
|
+
```bash
|
|
3049
|
+
git add scripts/hooks/pre-edit-test-gate.js scripts/hooks/security-scan.js tests/hooks/gate-hooks.test.js
|
|
3050
|
+
git commit -m "feat: implement strict test gate and security scan hooks"
|
|
3051
|
+
```
|
|
3052
|
+
|
|
3053
|
+
---
|
|
3054
|
+
|
|
3055
|
+
### Task 11: Strict Validation Hooks — performance-budget.js & native-compat-check.js
|
|
3056
|
+
|
|
3057
|
+
**Files:**
|
|
3058
|
+
- Create: `scripts/hooks/performance-budget.js`
|
|
3059
|
+
- Create: `scripts/hooks/native-compat-check.js`
|
|
3060
|
+
- Modify: `tests/hooks/gate-hooks.test.js` (append)
|
|
3061
|
+
|
|
3062
|
+
**Tests (write first):**
|
|
3063
|
+
|
|
3064
|
+
- [ ] **Step 1: Append tests to gate-hooks.test.js**
|
|
3065
|
+
|
|
3066
|
+
```js
|
|
3067
|
+
// Append to tests/hooks/gate-hooks.test.js
|
|
3068
|
+
|
|
3069
|
+
describe('performance-budget', () => {
|
|
3070
|
+
let projectDir;
|
|
3071
|
+
|
|
3072
|
+
beforeEach(() => {
|
|
3073
|
+
projectDir = createTempProject();
|
|
3074
|
+
});
|
|
3075
|
+
|
|
3076
|
+
afterEach(() => {
|
|
3077
|
+
cleanupTempProject(projectDir);
|
|
3078
|
+
});
|
|
3079
|
+
|
|
3080
|
+
test('warns when package.json adds large dependency', () => {
|
|
3081
|
+
fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({
|
|
3082
|
+
dependencies: {
|
|
3083
|
+
'react-native': '0.76.0',
|
|
3084
|
+
'moment': '2.30.0',
|
|
3085
|
+
},
|
|
3086
|
+
}));
|
|
3087
|
+
|
|
3088
|
+
const result = runHook('performance-budget.js', {
|
|
3089
|
+
tool_name: 'Edit',
|
|
3090
|
+
tool_input: { file_path: path.join(projectDir, 'package.json') },
|
|
3091
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3092
|
+
|
|
3093
|
+
expect(result.exitCode).toBe(2);
|
|
3094
|
+
expect(result.stdout).toContain('moment');
|
|
3095
|
+
});
|
|
3096
|
+
|
|
3097
|
+
test('warns on bundle size exceeding budget', () => {
|
|
3098
|
+
// Create a .erne-budget.json with tight limits
|
|
3099
|
+
fs.writeFileSync(path.join(projectDir, '.erne-budget.json'), JSON.stringify({
|
|
3100
|
+
maxBundleSizeKB: 500,
|
|
3101
|
+
maxDependencies: 5,
|
|
3102
|
+
}));
|
|
3103
|
+
|
|
3104
|
+
fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({
|
|
3105
|
+
dependencies: {
|
|
3106
|
+
'react-native': '0.76.0',
|
|
3107
|
+
'dep1': '1.0.0',
|
|
3108
|
+
'dep2': '1.0.0',
|
|
3109
|
+
'dep3': '1.0.0',
|
|
3110
|
+
'dep4': '1.0.0',
|
|
3111
|
+
'dep5': '1.0.0',
|
|
3112
|
+
'dep6': '1.0.0',
|
|
3113
|
+
},
|
|
3114
|
+
}));
|
|
3115
|
+
|
|
3116
|
+
const result = runHook('performance-budget.js', {
|
|
3117
|
+
tool_name: 'Edit',
|
|
3118
|
+
tool_input: { file_path: path.join(projectDir, 'package.json') },
|
|
3119
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3120
|
+
|
|
3121
|
+
expect(result.exitCode).toBe(2);
|
|
3122
|
+
expect(result.stdout).toContain('dependencies');
|
|
3123
|
+
});
|
|
3124
|
+
|
|
3125
|
+
test('passes when within budget', () => {
|
|
3126
|
+
fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({
|
|
3127
|
+
dependencies: {
|
|
3128
|
+
'react-native': '0.76.0',
|
|
3129
|
+
'expo': '52.0.0',
|
|
3130
|
+
},
|
|
3131
|
+
}));
|
|
3132
|
+
|
|
3133
|
+
const result = runHook('performance-budget.js', {
|
|
3134
|
+
tool_name: 'Edit',
|
|
3135
|
+
tool_input: { file_path: path.join(projectDir, 'package.json') },
|
|
3136
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3137
|
+
|
|
3138
|
+
expect(result.exitCode).toBe(0);
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
test('skips non-package.json files', () => {
|
|
3142
|
+
const result = runHook('performance-budget.js', {
|
|
3143
|
+
tool_name: 'Edit',
|
|
3144
|
+
tool_input: { file_path: '/project/src/app.ts' },
|
|
3145
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3146
|
+
|
|
3147
|
+
expect(result.exitCode).toBe(0);
|
|
3148
|
+
});
|
|
3149
|
+
});
|
|
3150
|
+
|
|
3151
|
+
describe('native-compat-check', () => {
|
|
3152
|
+
let projectDir;
|
|
3153
|
+
|
|
3154
|
+
beforeEach(() => {
|
|
3155
|
+
projectDir = createTempProject();
|
|
3156
|
+
});
|
|
3157
|
+
|
|
3158
|
+
afterEach(() => {
|
|
3159
|
+
cleanupTempProject(projectDir);
|
|
3160
|
+
});
|
|
3161
|
+
|
|
3162
|
+
test('warns when ios dir exists but android does not', () => {
|
|
3163
|
+
fs.mkdirSync(path.join(projectDir, 'ios'), { recursive: true });
|
|
3164
|
+
fs.writeFileSync(path.join(projectDir, 'ios', 'App.swift'), 'import UIKit\n');
|
|
3165
|
+
|
|
3166
|
+
const result = runHook('native-compat-check.js', {
|
|
3167
|
+
tool_name: 'Write',
|
|
3168
|
+
tool_input: { file_path: path.join(projectDir, 'ios', 'App.swift') },
|
|
3169
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3170
|
+
|
|
3171
|
+
expect(result.exitCode).toBe(2);
|
|
3172
|
+
expect(result.stdout).toContain('android');
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
test('warns when android dir exists but ios does not', () => {
|
|
3176
|
+
fs.mkdirSync(path.join(projectDir, 'android', 'app', 'src'), { recursive: true });
|
|
3177
|
+
fs.writeFileSync(
|
|
3178
|
+
path.join(projectDir, 'android', 'app', 'src', 'Main.kt'),
|
|
3179
|
+
'package com.app\n'
|
|
3180
|
+
);
|
|
3181
|
+
|
|
3182
|
+
const result = runHook('native-compat-check.js', {
|
|
3183
|
+
tool_name: 'Write',
|
|
3184
|
+
tool_input: { file_path: path.join(projectDir, 'android', 'app', 'src', 'Main.kt') },
|
|
3185
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3186
|
+
|
|
3187
|
+
expect(result.exitCode).toBe(2);
|
|
3188
|
+
expect(result.stdout).toContain('ios');
|
|
3189
|
+
});
|
|
3190
|
+
|
|
3191
|
+
test('passes when both platforms present', () => {
|
|
3192
|
+
fs.mkdirSync(path.join(projectDir, 'ios'), { recursive: true });
|
|
3193
|
+
fs.mkdirSync(path.join(projectDir, 'android'), { recursive: true });
|
|
3194
|
+
fs.writeFileSync(path.join(projectDir, 'ios', 'App.swift'), 'import UIKit\n');
|
|
3195
|
+
fs.writeFileSync(path.join(projectDir, 'android', 'Main.kt'), 'package com.app\n');
|
|
3196
|
+
|
|
3197
|
+
const result = runHook('native-compat-check.js', {
|
|
3198
|
+
tool_name: 'Write',
|
|
3199
|
+
tool_input: { file_path: path.join(projectDir, 'ios', 'App.swift') },
|
|
3200
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3201
|
+
|
|
3202
|
+
expect(result.exitCode).toBe(0);
|
|
3203
|
+
});
|
|
3204
|
+
|
|
3205
|
+
test('passes when editing non-native file', () => {
|
|
3206
|
+
const result = runHook('native-compat-check.js', {
|
|
3207
|
+
tool_name: 'Edit',
|
|
3208
|
+
tool_input: { file_path: '/project/src/App.tsx' },
|
|
3209
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3210
|
+
|
|
3211
|
+
expect(result.exitCode).toBe(0);
|
|
3212
|
+
});
|
|
3213
|
+
|
|
3214
|
+
test('skips when no file path', () => {
|
|
3215
|
+
const result = runHook('native-compat-check.js', {
|
|
3216
|
+
tool_name: 'Edit',
|
|
3217
|
+
tool_input: {},
|
|
3218
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3219
|
+
|
|
3220
|
+
expect(result.exitCode).toBe(0);
|
|
3221
|
+
});
|
|
3222
|
+
});
|
|
3223
|
+
```
|
|
3224
|
+
|
|
3225
|
+
**Implementation:**
|
|
3226
|
+
|
|
3227
|
+
- [ ] **Step 2: Implement performance-budget.js**
|
|
3228
|
+
|
|
3229
|
+
```js
|
|
3230
|
+
// scripts/hooks/performance-budget.js
|
|
3231
|
+
'use strict';
|
|
3232
|
+
const fs = require('fs');
|
|
3233
|
+
const path = require('path');
|
|
3234
|
+
const { readStdin, getEditedFilePath, pass, warn } = require('./lib/hook-utils');
|
|
3235
|
+
|
|
3236
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
3237
|
+
const input = readStdin();
|
|
3238
|
+
const filePath = getEditedFilePath(input);
|
|
3239
|
+
|
|
3240
|
+
if (!filePath) {
|
|
3241
|
+
pass();
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
// Only check package.json edits
|
|
3245
|
+
if (path.basename(filePath) !== 'package.json') {
|
|
3246
|
+
pass();
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
// Known large/heavy packages that impact RN bundle size
|
|
3250
|
+
const HEAVY_PACKAGES = {
|
|
3251
|
+
'moment': { size: '290KB', alternative: 'dayjs or date-fns' },
|
|
3252
|
+
'lodash': { size: '530KB', alternative: 'lodash-es or individual lodash/ imports' },
|
|
3253
|
+
'firebase': { size: '800KB+', alternative: '@react-native-firebase/* (modular)' },
|
|
3254
|
+
'aws-sdk': { size: '2.5MB', alternative: '@aws-sdk/client-* (v3 modular)' },
|
|
3255
|
+
'native-base': { size: '500KB+', alternative: 'tamagui or gluestack-ui' },
|
|
3256
|
+
'react-native-paper': { size: '400KB', alternative: 'lightweight custom components' },
|
|
3257
|
+
'react-native-elements': { size: '350KB', alternative: 'lightweight custom components' },
|
|
3258
|
+
'antd-mobile': { size: '500KB+', alternative: 'tree-shakeable alternative' },
|
|
3259
|
+
};
|
|
3260
|
+
|
|
3261
|
+
let pkg;
|
|
3262
|
+
try {
|
|
3263
|
+
pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
3264
|
+
} catch {
|
|
3265
|
+
pass();
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
3269
|
+
const warnings = [];
|
|
3270
|
+
|
|
3271
|
+
// Check for heavy packages
|
|
3272
|
+
for (const [name, info] of Object.entries(HEAVY_PACKAGES)) {
|
|
3273
|
+
if (allDeps[name]) {
|
|
3274
|
+
warnings.push(`\`${name}\` (~${info.size}) — consider ${info.alternative}`);
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
// Check budget file if it exists
|
|
3279
|
+
const budgetPath = path.join(projectDir, '.erne-budget.json');
|
|
3280
|
+
if (fs.existsSync(budgetPath)) {
|
|
3281
|
+
try {
|
|
3282
|
+
const budget = JSON.parse(fs.readFileSync(budgetPath, 'utf8'));
|
|
3283
|
+
|
|
3284
|
+
if (budget.maxDependencies) {
|
|
3285
|
+
const depCount = Object.keys(pkg.dependencies || {}).length;
|
|
3286
|
+
if (depCount > budget.maxDependencies) {
|
|
3287
|
+
warnings.push(`Dependencies count (${depCount}) exceeds budget (${budget.maxDependencies})`);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
} catch {
|
|
3291
|
+
// Invalid budget file — skip
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
if (warnings.length > 0) {
|
|
3296
|
+
warn(`ERNE: Performance budget — ${warnings.length} concern(s):\n${warnings.map((w) => ` - ${w}`).join('\n')}`);
|
|
3297
|
+
} else {
|
|
3298
|
+
pass();
|
|
3299
|
+
}
|
|
3300
|
+
```
|
|
3301
|
+
|
|
3302
|
+
- [ ] **Step 3: Implement native-compat-check.js**
|
|
3303
|
+
|
|
3304
|
+
```js
|
|
3305
|
+
// scripts/hooks/native-compat-check.js
|
|
3306
|
+
'use strict';
|
|
3307
|
+
const fs = require('fs');
|
|
3308
|
+
const path = require('path');
|
|
3309
|
+
const { readStdin, getEditedFilePath, pass, warn } = require('./lib/hook-utils');
|
|
3310
|
+
|
|
3311
|
+
const projectDir = process.env.ERNE_PROJECT_DIR || process.cwd();
|
|
3312
|
+
const input = readStdin();
|
|
3313
|
+
const filePath = getEditedFilePath(input);
|
|
3314
|
+
|
|
3315
|
+
if (!filePath) {
|
|
3316
|
+
pass();
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
// Only check native code files
|
|
3320
|
+
const NATIVE_EXTS = ['.swift', '.m', '.mm', '.h', '.kt', '.java', '.gradle'];
|
|
3321
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
3322
|
+
|
|
3323
|
+
if (!NATIVE_EXTS.includes(ext)) {
|
|
3324
|
+
pass();
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
const isIosFile = ['.swift', '.m', '.mm', '.h'].includes(ext) ||
|
|
3328
|
+
filePath.includes('/ios/') || filePath.includes('\\ios\\');
|
|
3329
|
+
const isAndroidFile = ['.kt', '.java', '.gradle'].includes(ext) ||
|
|
3330
|
+
filePath.includes('/android/') || filePath.includes('\\android\\');
|
|
3331
|
+
|
|
3332
|
+
if (!isIosFile && !isAndroidFile) {
|
|
3333
|
+
pass();
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
const iosDir = path.join(projectDir, 'ios');
|
|
3337
|
+
const androidDir = path.join(projectDir, 'android');
|
|
3338
|
+
const hasIosDir = fs.existsSync(iosDir);
|
|
3339
|
+
const hasAndroidDir = fs.existsSync(androidDir);
|
|
3340
|
+
|
|
3341
|
+
const warnings = [];
|
|
3342
|
+
|
|
3343
|
+
if (isIosFile && !hasAndroidDir) {
|
|
3344
|
+
warnings.push('Editing iOS native code but no android/ directory found — ensure cross-platform parity');
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
if (isAndroidFile && !hasIosDir) {
|
|
3348
|
+
warnings.push('Editing Android native code but no ios/ directory found — ensure cross-platform parity');
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
// Check for native module without both platform implementations
|
|
3352
|
+
if (isIosFile && hasAndroidDir) {
|
|
3353
|
+
// Look for corresponding Kotlin/Java file with similar name
|
|
3354
|
+
const baseName = path.basename(filePath, ext);
|
|
3355
|
+
const hasAndroidCounterpart = findInDir(androidDir, baseName, ['.kt', '.java']);
|
|
3356
|
+
if (!hasAndroidCounterpart) {
|
|
3357
|
+
warnings.push(`iOS native file \`${baseName}${ext}\` has no matching Android implementation`);
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
if (isAndroidFile && hasIosDir) {
|
|
3362
|
+
const baseName = path.basename(filePath, ext);
|
|
3363
|
+
const hasIosCounterpart = findInDir(iosDir, baseName, ['.swift', '.m', '.mm']);
|
|
3364
|
+
if (!hasIosCounterpart) {
|
|
3365
|
+
warnings.push(`Android native file \`${baseName}${ext}\` has no matching iOS implementation`);
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
if (warnings.length > 0) {
|
|
3370
|
+
warn(`ERNE: Native compatibility — ${warnings.length} concern(s):\n${warnings.map((w) => ` - ${w}`).join('\n')}`);
|
|
3371
|
+
} else {
|
|
3372
|
+
pass();
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
function findInDir(dir, baseName, extensions) {
|
|
3376
|
+
try {
|
|
3377
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: true });
|
|
3378
|
+
return entries.some((entry) => {
|
|
3379
|
+
if (!entry.isFile()) return false;
|
|
3380
|
+
const entryBase = path.basename(entry.name, path.extname(entry.name));
|
|
3381
|
+
const entryExt = path.extname(entry.name).toLowerCase();
|
|
3382
|
+
return entryBase.toLowerCase() === baseName.toLowerCase() && extensions.includes(entryExt);
|
|
3383
|
+
});
|
|
3384
|
+
} catch {
|
|
3385
|
+
return false;
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
```
|
|
3389
|
+
|
|
3390
|
+
- [ ] **Step 4: Run tests**
|
|
3391
|
+
|
|
3392
|
+
Run: `npx jest tests/hooks/gate-hooks.test.js -v`
|
|
3393
|
+
Expected: All tests PASS
|
|
3394
|
+
|
|
3395
|
+
- [ ] **Step 5: Commit**
|
|
3396
|
+
|
|
3397
|
+
```bash
|
|
3398
|
+
git add scripts/hooks/performance-budget.js scripts/hooks/native-compat-check.js tests/hooks/gate-hooks.test.js
|
|
3399
|
+
git commit -m "feat: implement strict performance budget and native compat hooks"
|
|
3400
|
+
```
|
|
3401
|
+
|
|
3402
|
+
---
|
|
3403
|
+
|
|
3404
|
+
### Task 12: Strict Validation Hook — accessibility-check.js
|
|
3405
|
+
|
|
3406
|
+
**Files:**
|
|
3407
|
+
- Create: `scripts/hooks/accessibility-check.js`
|
|
3408
|
+
- Modify: `tests/hooks/gate-hooks.test.js` (append)
|
|
3409
|
+
|
|
3410
|
+
**Tests (write first):**
|
|
3411
|
+
|
|
3412
|
+
- [ ] **Step 1: Append tests to gate-hooks.test.js**
|
|
3413
|
+
|
|
3414
|
+
```js
|
|
3415
|
+
// Append to tests/hooks/gate-hooks.test.js
|
|
3416
|
+
|
|
3417
|
+
describe('accessibility-check', () => {
|
|
3418
|
+
let projectDir;
|
|
3419
|
+
|
|
3420
|
+
beforeEach(() => {
|
|
3421
|
+
projectDir = createTempProject();
|
|
3422
|
+
});
|
|
3423
|
+
|
|
3424
|
+
afterEach(() => {
|
|
3425
|
+
cleanupTempProject(projectDir);
|
|
3426
|
+
});
|
|
3427
|
+
|
|
3428
|
+
test('warns on TouchableOpacity without accessibilityLabel', () => {
|
|
3429
|
+
const srcDir = path.join(projectDir, 'src');
|
|
3430
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
3431
|
+
fs.writeFileSync(path.join(srcDir, 'Button.tsx'), `
|
|
3432
|
+
import { TouchableOpacity, Text } from 'react-native';
|
|
3433
|
+
export const Button = () => (
|
|
3434
|
+
<TouchableOpacity onPress={() => {}}>
|
|
3435
|
+
<Text>Click me</Text>
|
|
3436
|
+
</TouchableOpacity>
|
|
3437
|
+
);
|
|
3438
|
+
`);
|
|
3439
|
+
|
|
3440
|
+
const result = runHook('accessibility-check.js', {
|
|
3441
|
+
tool_name: 'Edit',
|
|
3442
|
+
tool_input: { file_path: path.join(srcDir, 'Button.tsx') },
|
|
3443
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3444
|
+
|
|
3445
|
+
expect(result.exitCode).toBe(2);
|
|
3446
|
+
expect(result.stdout).toContain('accessibility');
|
|
3447
|
+
});
|
|
3448
|
+
|
|
3449
|
+
test('passes when accessibilityLabel is present', () => {
|
|
3450
|
+
const srcDir = path.join(projectDir, 'src');
|
|
3451
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
3452
|
+
fs.writeFileSync(path.join(srcDir, 'Button.tsx'), `
|
|
3453
|
+
import { TouchableOpacity, Text } from 'react-native';
|
|
3454
|
+
export const Button = () => (
|
|
3455
|
+
<TouchableOpacity onPress={() => {}} accessibilityLabel="Submit">
|
|
3456
|
+
<Text>Click me</Text>
|
|
3457
|
+
</TouchableOpacity>
|
|
3458
|
+
);
|
|
3459
|
+
`);
|
|
3460
|
+
|
|
3461
|
+
const result = runHook('accessibility-check.js', {
|
|
3462
|
+
tool_name: 'Edit',
|
|
3463
|
+
tool_input: { file_path: path.join(srcDir, 'Button.tsx') },
|
|
3464
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3465
|
+
|
|
3466
|
+
expect(result.exitCode).toBe(0);
|
|
3467
|
+
});
|
|
3468
|
+
|
|
3469
|
+
test('warns on Pressable without accessibilityRole', () => {
|
|
3470
|
+
const srcDir = path.join(projectDir, 'src');
|
|
3471
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
3472
|
+
fs.writeFileSync(path.join(srcDir, 'Card.tsx'), `
|
|
3473
|
+
import { Pressable, Text } from 'react-native';
|
|
3474
|
+
export const Card = () => (
|
|
3475
|
+
<Pressable onPress={() => {}}>
|
|
3476
|
+
<Text>Tap me</Text>
|
|
3477
|
+
</Pressable>
|
|
3478
|
+
);
|
|
3479
|
+
`);
|
|
3480
|
+
|
|
3481
|
+
const result = runHook('accessibility-check.js', {
|
|
3482
|
+
tool_name: 'Edit',
|
|
3483
|
+
tool_input: { file_path: path.join(srcDir, 'Card.tsx') },
|
|
3484
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3485
|
+
|
|
3486
|
+
expect(result.exitCode).toBe(2);
|
|
3487
|
+
expect(result.stdout).toContain('accessibility');
|
|
3488
|
+
});
|
|
3489
|
+
|
|
3490
|
+
test('warns on Image without accessible or alt', () => {
|
|
3491
|
+
const srcDir = path.join(projectDir, 'src');
|
|
3492
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
3493
|
+
fs.writeFileSync(path.join(srcDir, 'Avatar.tsx'), `
|
|
3494
|
+
import { Image } from 'react-native';
|
|
3495
|
+
export const Avatar = () => (
|
|
3496
|
+
<Image source={{ uri: 'https://example.com/avatar.png' }} />
|
|
3497
|
+
);
|
|
3498
|
+
`);
|
|
3499
|
+
|
|
3500
|
+
const result = runHook('accessibility-check.js', {
|
|
3501
|
+
tool_name: 'Edit',
|
|
3502
|
+
tool_input: { file_path: path.join(srcDir, 'Avatar.tsx') },
|
|
3503
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3504
|
+
|
|
3505
|
+
expect(result.exitCode).toBe(2);
|
|
3506
|
+
expect(result.stdout).toContain('accessibility');
|
|
3507
|
+
});
|
|
3508
|
+
|
|
3509
|
+
test('skips non-JSX files', () => {
|
|
3510
|
+
const result = runHook('accessibility-check.js', {
|
|
3511
|
+
tool_name: 'Edit',
|
|
3512
|
+
tool_input: { file_path: '/project/utils.ts' },
|
|
3513
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3514
|
+
|
|
3515
|
+
expect(result.exitCode).toBe(0);
|
|
3516
|
+
});
|
|
3517
|
+
|
|
3518
|
+
test('skips test files', () => {
|
|
3519
|
+
const result = runHook('accessibility-check.js', {
|
|
3520
|
+
tool_name: 'Edit',
|
|
3521
|
+
tool_input: { file_path: '/project/src/__tests__/Button.test.tsx' },
|
|
3522
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3523
|
+
|
|
3524
|
+
expect(result.exitCode).toBe(0);
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
test('skips when no file path', () => {
|
|
3528
|
+
const result = runHook('accessibility-check.js', {
|
|
3529
|
+
tool_name: 'Edit',
|
|
3530
|
+
tool_input: {},
|
|
3531
|
+
}, { ERNE_PROJECT_DIR: projectDir });
|
|
3532
|
+
|
|
3533
|
+
expect(result.exitCode).toBe(0);
|
|
3534
|
+
});
|
|
3535
|
+
});
|
|
3536
|
+
```
|
|
3537
|
+
|
|
3538
|
+
**Implementation:**
|
|
3539
|
+
|
|
3540
|
+
- [ ] **Step 2: Implement accessibility-check.js**
|
|
3541
|
+
|
|
3542
|
+
```js
|
|
3543
|
+
// scripts/hooks/accessibility-check.js
|
|
3544
|
+
'use strict';
|
|
3545
|
+
const fs = require('fs');
|
|
3546
|
+
const { readStdin, getEditedFilePath, pass, warn, isTestFile, hasExtension } = require('./lib/hook-utils');
|
|
3547
|
+
|
|
3548
|
+
const input = readStdin();
|
|
3549
|
+
const filePath = getEditedFilePath(input);
|
|
3550
|
+
|
|
3551
|
+
if (!filePath) {
|
|
3552
|
+
pass();
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
// Only check JSX/TSX files
|
|
3556
|
+
const JSX_EXTS = ['.jsx', '.tsx'];
|
|
3557
|
+
if (!hasExtension(filePath, JSX_EXTS)) {
|
|
3558
|
+
pass();
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
if (isTestFile(filePath)) {
|
|
3562
|
+
pass();
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
let content;
|
|
3566
|
+
try {
|
|
3567
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
3568
|
+
} catch {
|
|
3569
|
+
pass();
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
const issues = [];
|
|
3573
|
+
|
|
3574
|
+
// Touchable components that need accessibility labels
|
|
3575
|
+
const TOUCHABLE_COMPONENTS = [
|
|
3576
|
+
'TouchableOpacity',
|
|
3577
|
+
'TouchableHighlight',
|
|
3578
|
+
'TouchableWithoutFeedback',
|
|
3579
|
+
'TouchableNativeFeedback',
|
|
3580
|
+
'Pressable',
|
|
3581
|
+
];
|
|
3582
|
+
|
|
3583
|
+
for (const component of TOUCHABLE_COMPONENTS) {
|
|
3584
|
+
// Find component usage with onPress but without accessibilityLabel
|
|
3585
|
+
const componentRegex = new RegExp(`<${component}[\\s\\S]*?(?:>|\\/>)`, 'g');
|
|
3586
|
+
const matches = content.match(componentRegex) || [];
|
|
3587
|
+
|
|
3588
|
+
for (const match of matches) {
|
|
3589
|
+
if (match.includes('onPress') || match.includes('onLongPress')) {
|
|
3590
|
+
const hasLabel = /accessibilityLabel/.test(match);
|
|
3591
|
+
const hasRole = /accessibilityRole|accessible/.test(match);
|
|
3592
|
+
const hasA11yHint = /accessibilityHint/.test(match);
|
|
3593
|
+
|
|
3594
|
+
if (!hasLabel && !hasA11yHint) {
|
|
3595
|
+
issues.push(`\`${component}\` has press handler but missing \`accessibilityLabel\``);
|
|
3596
|
+
}
|
|
3597
|
+
if (!hasRole && component === 'Pressable') {
|
|
3598
|
+
issues.push(`\`Pressable\` missing \`accessibilityRole\` — set to "button", "link", etc.`);
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
// Check for Image without accessibility
|
|
3605
|
+
const imageRegex = /<Image[\s\S]*?(?:>|\/>)/g;
|
|
3606
|
+
const imageMatches = content.match(imageRegex) || [];
|
|
3607
|
+
|
|
3608
|
+
for (const match of imageMatches) {
|
|
3609
|
+
const hasAccessible = /accessible|accessibilityLabel|alt=/.test(match);
|
|
3610
|
+
if (!hasAccessible) {
|
|
3611
|
+
issues.push('`Image` missing accessibility label — add `accessibilityLabel` or `accessible={false}` for decorative images');
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
if (issues.length > 0) {
|
|
3616
|
+
// Deduplicate similar warnings
|
|
3617
|
+
const unique = [...new Set(issues)];
|
|
3618
|
+
const shown = unique.slice(0, 5);
|
|
3619
|
+
const remaining = unique.length - shown.length;
|
|
3620
|
+
let msg = `ERNE: Accessibility check — ${unique.length} issue(s):\n${shown.map((i) => ` - ${i}`).join('\n')}`;
|
|
3621
|
+
if (remaining > 0) {
|
|
3622
|
+
msg += `\n ... and ${remaining} more`;
|
|
3623
|
+
}
|
|
3624
|
+
warn(msg);
|
|
3625
|
+
} else {
|
|
3626
|
+
pass();
|
|
3627
|
+
}
|
|
3628
|
+
```
|
|
3629
|
+
|
|
3630
|
+
- [ ] **Step 3: Run tests**
|
|
3631
|
+
|
|
3632
|
+
Run: `npx jest tests/hooks/gate-hooks.test.js -v`
|
|
3633
|
+
Expected: All tests PASS
|
|
3634
|
+
|
|
3635
|
+
- [ ] **Step 4: Run full test suite**
|
|
3636
|
+
|
|
3637
|
+
Run: `npx jest tests/hooks/ -v`
|
|
3638
|
+
Expected: All tests PASS
|
|
3639
|
+
|
|
3640
|
+
- [ ] **Step 5: Commit**
|
|
3641
|
+
|
|
3642
|
+
```bash
|
|
3643
|
+
git add scripts/hooks/accessibility-check.js tests/hooks/gate-hooks.test.js
|
|
3644
|
+
git commit -m "feat: implement strict accessibility check hook"
|
|
3645
|
+
```
|
|
3646
|
+
|
|
3647
|
+
---
|
|
3648
|
+
|
|
3649
|
+
## Chunk 6: Integration Tests & Finalization
|
|
3650
|
+
|
|
3651
|
+
### Task 13: Integration Tests — Profile-Based Hook Execution
|
|
3652
|
+
|
|
3653
|
+
**Files:**
|
|
3654
|
+
- Create: `tests/hooks/integration.test.js`
|
|
3655
|
+
|
|
3656
|
+
- [ ] **Step 1: Write integration tests**
|
|
3657
|
+
|
|
3658
|
+
```js
|
|
3659
|
+
// tests/hooks/integration.test.js
|
|
3660
|
+
'use strict';
|
|
3661
|
+
const fs = require('fs');
|
|
3662
|
+
const path = require('path');
|
|
3663
|
+
const { runDispatcher, createTempProject, cleanupTempProject, HOOKS_DIR } = require('./helpers');
|
|
3664
|
+
|
|
3665
|
+
describe('Profile-based hook execution (integration)', () => {
|
|
3666
|
+
let projectDir;
|
|
3667
|
+
|
|
3668
|
+
beforeEach(() => {
|
|
3669
|
+
projectDir = createTempProject();
|
|
3670
|
+
});
|
|
3671
|
+
|
|
3672
|
+
afterEach(() => {
|
|
3673
|
+
cleanupTempProject(projectDir);
|
|
3674
|
+
});
|
|
3675
|
+
|
|
3676
|
+
test('minimal profile only runs minimal hooks', () => {
|
|
3677
|
+
// post-edit-format is minimal; post-edit-typecheck is standard
|
|
3678
|
+
const formatResult = runDispatcher('post-edit-format.js', {
|
|
3679
|
+
tool_name: 'Edit',
|
|
3680
|
+
tool_input: { file_path: '/project/src/app.ts' },
|
|
3681
|
+
}, { ERNE_PROFILE: 'minimal', ERNE_PROJECT_DIR: projectDir });
|
|
3682
|
+
|
|
3683
|
+
// Should run (exit 0 or 2, not skipped)
|
|
3684
|
+
expect([0, 2]).toContain(formatResult.exitCode);
|
|
3685
|
+
|
|
3686
|
+
const typecheckResult = runDispatcher('post-edit-typecheck.js', {
|
|
3687
|
+
tool_name: 'Edit',
|
|
3688
|
+
tool_input: { file_path: '/project/src/app.ts' },
|
|
3689
|
+
}, { ERNE_PROFILE: 'minimal', ERNE_PROJECT_DIR: projectDir });
|
|
3690
|
+
|
|
3691
|
+
// Should be skipped by dispatcher (profile mismatch)
|
|
3692
|
+
expect(typecheckResult.exitCode).toBe(0);
|
|
3693
|
+
expect(typecheckResult.stdout).toContain('skipped');
|
|
3694
|
+
});
|
|
3695
|
+
|
|
3696
|
+
test('standard profile runs standard hooks but not strict', () => {
|
|
3697
|
+
const typecheckResult = runDispatcher('post-edit-typecheck.js', {
|
|
3698
|
+
tool_name: 'Edit',
|
|
3699
|
+
tool_input: { file_path: '/project/src/app.ts' },
|
|
3700
|
+
}, { ERNE_PROFILE: 'standard', ERNE_PROJECT_DIR: projectDir });
|
|
3701
|
+
|
|
3702
|
+
// Should run (not skipped)
|
|
3703
|
+
expect(typecheckResult.stdout).not.toContain('skipped');
|
|
3704
|
+
|
|
3705
|
+
const testGateResult = runDispatcher('pre-edit-test-gate.js', {
|
|
3706
|
+
tool_name: 'Edit',
|
|
3707
|
+
tool_input: { file_path: '/project/src/app.ts' },
|
|
3708
|
+
}, { ERNE_PROFILE: 'standard', ERNE_PROJECT_DIR: projectDir });
|
|
3709
|
+
|
|
3710
|
+
// Should be skipped (strict only)
|
|
3711
|
+
expect(testGateResult.exitCode).toBe(0);
|
|
3712
|
+
expect(testGateResult.stdout).toContain('skipped');
|
|
3713
|
+
});
|
|
3714
|
+
|
|
3715
|
+
test('strict profile runs all hooks', () => {
|
|
3716
|
+
const testGateResult = runDispatcher('pre-edit-test-gate.js', {
|
|
3717
|
+
tool_name: 'Edit',
|
|
3718
|
+
tool_input: { file_path: '/project/src/app.ts' },
|
|
3719
|
+
}, { ERNE_PROFILE: 'strict', ERNE_PROJECT_DIR: projectDir });
|
|
3720
|
+
|
|
3721
|
+
// Should run (not skipped)
|
|
3722
|
+
expect(testGateResult.stdout).not.toContain('skipped');
|
|
3723
|
+
});
|
|
3724
|
+
|
|
3725
|
+
test('ERNE_PROFILE env var takes precedence over default', () => {
|
|
3726
|
+
// Default is standard; env var sets minimal
|
|
3727
|
+
const typecheckResult = runDispatcher('post-edit-typecheck.js', {
|
|
3728
|
+
tool_name: 'Edit',
|
|
3729
|
+
tool_input: { file_path: '/project/src/app.ts' },
|
|
3730
|
+
}, { ERNE_PROFILE: 'minimal', ERNE_PROJECT_DIR: projectDir });
|
|
3731
|
+
|
|
3732
|
+
// post-edit-typecheck is standard+strict only — should be skipped in minimal
|
|
3733
|
+
expect(typecheckResult.exitCode).toBe(0);
|
|
3734
|
+
expect(typecheckResult.stdout).toContain('skipped');
|
|
3735
|
+
});
|
|
3736
|
+
});
|
|
3737
|
+
|
|
3738
|
+
describe('Hook definitions integrity (integration)', () => {
|
|
3739
|
+
test('all hooks in hooks.json reference existing script files', () => {
|
|
3740
|
+
const hooksConfig = JSON.parse(
|
|
3741
|
+
fs.readFileSync(path.join(HOOKS_DIR, '..', '..', 'hooks.json'), 'utf8')
|
|
3742
|
+
);
|
|
3743
|
+
|
|
3744
|
+
for (const hook of hooksConfig.hooks) {
|
|
3745
|
+
// Extract script name from command
|
|
3746
|
+
const match = hook.command.match(/run-with-flags\.js\s+(\S+)/);
|
|
3747
|
+
if (match) {
|
|
3748
|
+
const scriptPath = path.join(HOOKS_DIR, match[1]);
|
|
3749
|
+
expect(fs.existsSync(scriptPath)).toBe(true);
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
});
|
|
3753
|
+
|
|
3754
|
+
test('all profile JSONs reference hooks that exist in hooks.json', () => {
|
|
3755
|
+
const hooksConfig = JSON.parse(
|
|
3756
|
+
fs.readFileSync(path.join(HOOKS_DIR, '..', '..', 'hooks.json'), 'utf8')
|
|
3757
|
+
);
|
|
3758
|
+
|
|
3759
|
+
const allHookNames = new Set();
|
|
3760
|
+
for (const hook of hooksConfig.hooks) {
|
|
3761
|
+
const match = hook.command.match(/run-with-flags\.js\s+(\S+)/);
|
|
3762
|
+
if (match) allHookNames.add(match[1]);
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
const profilesDir = path.join(HOOKS_DIR, '..', '..', 'profiles');
|
|
3766
|
+
const profiles = ['minimal.json', 'standard.json', 'strict.json'];
|
|
3767
|
+
|
|
3768
|
+
for (const profileFile of profiles) {
|
|
3769
|
+
const profilePath = path.join(profilesDir, profileFile);
|
|
3770
|
+
if (fs.existsSync(profilePath)) {
|
|
3771
|
+
const profile = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
|
|
3772
|
+
for (const hookName of profile.hooks) {
|
|
3773
|
+
expect(allHookNames.has(hookName)).toBe(true);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
});
|
|
3778
|
+
|
|
3779
|
+
test('every hook script file is referenced in hooks.json', () => {
|
|
3780
|
+
const hooksConfig = JSON.parse(
|
|
3781
|
+
fs.readFileSync(path.join(HOOKS_DIR, '..', '..', 'hooks.json'), 'utf8')
|
|
3782
|
+
);
|
|
3783
|
+
|
|
3784
|
+
const referencedScripts = new Set();
|
|
3785
|
+
for (const hook of hooksConfig.hooks) {
|
|
3786
|
+
const match = hook.command.match(/run-with-flags\.js\s+(\S+)/);
|
|
3787
|
+
if (match) referencedScripts.add(match[1]);
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
const hooksFiles = fs.readdirSync(HOOKS_DIR)
|
|
3791
|
+
.filter((f) => f.endsWith('.js') && f !== 'run-with-flags.js');
|
|
3792
|
+
|
|
3793
|
+
for (const file of hooksFiles) {
|
|
3794
|
+
// Skip lib/ directory files
|
|
3795
|
+
if (file === 'lib') continue;
|
|
3796
|
+
expect(referencedScripts.has(file)).toBe(true);
|
|
3797
|
+
}
|
|
3798
|
+
});
|
|
3799
|
+
|
|
3800
|
+
test('minimal profile is a subset of standard', () => {
|
|
3801
|
+
const profilesDir = path.join(HOOKS_DIR, '..', '..', 'profiles');
|
|
3802
|
+
const minimal = JSON.parse(fs.readFileSync(path.join(profilesDir, 'minimal.json'), 'utf8'));
|
|
3803
|
+
const standard = JSON.parse(fs.readFileSync(path.join(profilesDir, 'standard.json'), 'utf8'));
|
|
3804
|
+
|
|
3805
|
+
for (const hook of minimal.hooks) {
|
|
3806
|
+
expect(standard.hooks).toContain(hook);
|
|
3807
|
+
}
|
|
3808
|
+
});
|
|
3809
|
+
|
|
3810
|
+
test('standard profile is a subset of strict', () => {
|
|
3811
|
+
const profilesDir = path.join(HOOKS_DIR, '..', '..', 'profiles');
|
|
3812
|
+
const standard = JSON.parse(fs.readFileSync(path.join(profilesDir, 'standard.json'), 'utf8'));
|
|
3813
|
+
const strict = JSON.parse(fs.readFileSync(path.join(profilesDir, 'strict.json'), 'utf8'));
|
|
3814
|
+
|
|
3815
|
+
for (const hook of standard.hooks) {
|
|
3816
|
+
expect(strict.hooks).toContain(hook);
|
|
3817
|
+
}
|
|
3818
|
+
});
|
|
3819
|
+
});
|
|
3820
|
+
|
|
3821
|
+
describe('Hook error handling (integration)', () => {
|
|
3822
|
+
let projectDir;
|
|
3823
|
+
|
|
3824
|
+
beforeEach(() => {
|
|
3825
|
+
projectDir = createTempProject();
|
|
3826
|
+
});
|
|
3827
|
+
|
|
3828
|
+
afterEach(() => {
|
|
3829
|
+
cleanupTempProject(projectDir);
|
|
3830
|
+
});
|
|
3831
|
+
|
|
3832
|
+
test('hooks handle missing ERNE_PROJECT_DIR gracefully', () => {
|
|
3833
|
+
// Run session-start without ERNE_PROJECT_DIR — should use cwd
|
|
3834
|
+
const result = runDispatcher('session-start.js', {}, {
|
|
3835
|
+
ERNE_PROFILE: 'minimal',
|
|
3836
|
+
// Intentionally not setting ERNE_PROJECT_DIR
|
|
3837
|
+
});
|
|
3838
|
+
|
|
3839
|
+
// Should not crash
|
|
3840
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
3841
|
+
});
|
|
3842
|
+
|
|
3843
|
+
test('hooks handle empty stdin gracefully', () => {
|
|
3844
|
+
const result = runDispatcher('post-edit-format.js', {}, {
|
|
3845
|
+
ERNE_PROFILE: 'minimal',
|
|
3846
|
+
ERNE_PROJECT_DIR: projectDir,
|
|
3847
|
+
});
|
|
3848
|
+
|
|
3849
|
+
expect([0, 2]).toContain(result.exitCode);
|
|
3850
|
+
});
|
|
3851
|
+
|
|
3852
|
+
test('hooks handle malformed JSON stdin gracefully', () => {
|
|
3853
|
+
// This tests the readStdin fallback in hook-utils
|
|
3854
|
+
const { execFileSync } = require('child_process');
|
|
3855
|
+
const dispatcherPath = path.join(HOOKS_DIR, 'run-with-flags.js');
|
|
3856
|
+
|
|
3857
|
+
try {
|
|
3858
|
+
const stdout = execFileSync('node', [dispatcherPath, 'session-start.js'], {
|
|
3859
|
+
input: 'not-valid-json{{{',
|
|
3860
|
+
encoding: 'utf8',
|
|
3861
|
+
env: {
|
|
3862
|
+
...process.env,
|
|
3863
|
+
ERNE_PROFILE: 'minimal',
|
|
3864
|
+
ERNE_PROJECT_DIR: projectDir,
|
|
3865
|
+
},
|
|
3866
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
3867
|
+
timeout: 10000,
|
|
3868
|
+
});
|
|
3869
|
+
// Should handle gracefully
|
|
3870
|
+
expect(true).toBe(true);
|
|
3871
|
+
} catch (err) {
|
|
3872
|
+
// Even if it exits non-zero, it should not crash with unhandled exception
|
|
3873
|
+
expect([0, 1, 2]).toContain(err.status);
|
|
3874
|
+
}
|
|
3875
|
+
});
|
|
3876
|
+
});
|
|
3877
|
+
```
|
|
3878
|
+
|
|
3879
|
+
- [ ] **Step 2: Run integration tests**
|
|
3880
|
+
|
|
3881
|
+
Run: `npx jest tests/hooks/integration.test.js -v`
|
|
3882
|
+
Expected: All tests PASS
|
|
3883
|
+
|
|
3884
|
+
- [ ] **Step 3: Commit integration tests**
|
|
3885
|
+
|
|
3886
|
+
```bash
|
|
3887
|
+
git add tests/hooks/integration.test.js
|
|
3888
|
+
git commit -m "feat: add integration tests for profile execution and hook integrity"
|
|
3889
|
+
```
|
|
3890
|
+
|
|
3891
|
+
---
|
|
3892
|
+
|
|
3893
|
+
### Task 14: Full Suite Verification & Final Commit
|
|
3894
|
+
|
|
3895
|
+
- [ ] **Step 1: Run the complete test suite**
|
|
3896
|
+
|
|
3897
|
+
Run: `npx jest tests/hooks/ -v --coverage`
|
|
3898
|
+
Expected: All tests PASS across all test files:
|
|
3899
|
+
- `tests/hooks/definitions.test.js`
|
|
3900
|
+
- `tests/hooks/hook-utils.test.js`
|
|
3901
|
+
- `tests/hooks/run-with-flags.test.js`
|
|
3902
|
+
- `tests/hooks/core-hooks.test.js`
|
|
3903
|
+
- `tests/hooks/validation-hooks.test.js`
|
|
3904
|
+
- `tests/hooks/learning-hooks.test.js`
|
|
3905
|
+
- `tests/hooks/gate-hooks.test.js`
|
|
3906
|
+
- `tests/hooks/integration.test.js`
|
|
3907
|
+
|
|
3908
|
+
- [ ] **Step 2: Verify file structure**
|
|
3909
|
+
|
|
3910
|
+
Run: `find scripts/hooks tests/hooks -type f | sort`
|
|
3911
|
+
|
|
3912
|
+
Expected output:
|
|
3913
|
+
```
|
|
3914
|
+
scripts/hooks/accessibility-check.js
|
|
3915
|
+
scripts/hooks/bundle-size-check.js
|
|
3916
|
+
scripts/hooks/check-console-log.js
|
|
3917
|
+
scripts/hooks/check-expo-config.js
|
|
3918
|
+
scripts/hooks/check-platform-specific.js
|
|
3919
|
+
scripts/hooks/check-reanimated-worklet.js
|
|
3920
|
+
scripts/hooks/continuous-learning-observer.js
|
|
3921
|
+
scripts/hooks/evaluate-session.js
|
|
3922
|
+
scripts/hooks/lib/hook-utils.js
|
|
3923
|
+
scripts/hooks/native-compat-check.js
|
|
3924
|
+
scripts/hooks/performance-budget.js
|
|
3925
|
+
scripts/hooks/post-edit-format.js
|
|
3926
|
+
scripts/hooks/post-edit-typecheck.js
|
|
3927
|
+
scripts/hooks/pre-commit-lint.js
|
|
3928
|
+
scripts/hooks/pre-edit-test-gate.js
|
|
3929
|
+
scripts/hooks/run-with-flags.js
|
|
3930
|
+
scripts/hooks/security-scan.js
|
|
3931
|
+
scripts/hooks/session-start.js
|
|
3932
|
+
tests/hooks/core-hooks.test.js
|
|
3933
|
+
tests/hooks/definitions.test.js
|
|
3934
|
+
tests/hooks/gate-hooks.test.js
|
|
3935
|
+
tests/hooks/helpers.js
|
|
3936
|
+
tests/hooks/hook-utils.test.js
|
|
3937
|
+
tests/hooks/integration.test.js
|
|
3938
|
+
tests/hooks/learning-hooks.test.js
|
|
3939
|
+
tests/hooks/run-with-flags.test.js
|
|
3940
|
+
tests/hooks/validation-hooks.test.js
|
|
3941
|
+
```
|
|
3942
|
+
|
|
3943
|
+
- [ ] **Step 3: Verify all 16 hooks are registered in hooks.json**
|
|
3944
|
+
|
|
3945
|
+
Run: `node -e "const h = require('./hooks.json'); console.log(h.hooks.length + ' hooks registered'); h.hooks.forEach(h => console.log(' ' + h.command.split(' ').pop()))"`
|
|
3946
|
+
|
|
3947
|
+
Expected: 16 hooks registered, matching all script files.
|
|
3948
|
+
|
|
3949
|
+
- [ ] **Step 4: Final commit**
|
|
3950
|
+
|
|
3951
|
+
```bash
|
|
3952
|
+
git add -A
|
|
3953
|
+
git commit -m "feat: complete ERNE hook system — Plan 1 implementation ready"
|
|
3954
|
+
```
|
|
3955
|
+
|
|
3956
|
+
---
|
|
3957
|
+
|
|
3958
|
+
## Plan 1 Summary
|
|
3959
|
+
|
|
3960
|
+
| Chunk | Tasks | What | Hook Scripts | Test Files |
|
|
3961
|
+
|-------|-------|------|-------------|------------|
|
|
3962
|
+
| 1 | 1-2 | Foundation: package.json, hooks.json, profiles, schema | — | definitions.test.js |
|
|
3963
|
+
| 2 | 3-4 | Infrastructure: hook-utils, dispatcher, stubs | run-with-flags.js, lib/hook-utils.js | hook-utils.test.js, run-with-flags.test.js |
|
|
3964
|
+
| 3 | 5-7 | Minimal hooks | session-start.js, post-edit-format.js, continuous-learning-observer.js | core-hooks.test.js |
|
|
3965
|
+
| 4 | 8-9 | Standard hooks part 1 | post-edit-typecheck.js, check-console-log.js, check-platform-specific.js, check-reanimated-worklet.js, check-expo-config.js, bundle-size-check.js, pre-commit-lint.js, evaluate-session.js | validation-hooks.test.js, learning-hooks.test.js |
|
|
3966
|
+
| 5 | 10-12 | Strict hooks | pre-edit-test-gate.js, security-scan.js, performance-budget.js, native-compat-check.js, accessibility-check.js | gate-hooks.test.js |
|
|
3967
|
+
| 6 | 13-14 | Integration & verification | — | integration.test.js |
|
|
3968
|
+
|
|
3969
|
+
**Totals:**
|
|
3970
|
+
- 16 hook scripts + 1 dispatcher + 1 utility library = 18 JS files
|
|
3971
|
+
- 8 test files
|
|
3972
|
+
- 3 profile JSONs + 1 hooks.json + 1 schema
|
|
3973
|
+
- 14 tasks across 6 chunks
|