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.
Files changed (122) hide show
  1. package/.claude-plugin/plugin.json +92 -0
  2. package/LICENSE +21 -0
  3. package/README.md +73 -0
  4. package/agents/architect.md +64 -0
  5. package/agents/code-reviewer.md +72 -0
  6. package/agents/expo-config-resolver.md +77 -0
  7. package/agents/native-bridge-builder.md +98 -0
  8. package/agents/performance-profiler.md +89 -0
  9. package/agents/tdd-guide.md +86 -0
  10. package/agents/ui-designer.md +100 -0
  11. package/agents/upgrade-assistant.md +106 -0
  12. package/bin/cli.js +55 -0
  13. package/commands/animate.md +70 -0
  14. package/commands/build-fix.md +57 -0
  15. package/commands/code-review.md +51 -0
  16. package/commands/component.md +93 -0
  17. package/commands/debug.md +74 -0
  18. package/commands/deploy.md +82 -0
  19. package/commands/learn.md +56 -0
  20. package/commands/native-module.md +51 -0
  21. package/commands/navigate.md +69 -0
  22. package/commands/perf.md +68 -0
  23. package/commands/plan.md +49 -0
  24. package/commands/quality-gate.md +80 -0
  25. package/commands/retrospective.md +70 -0
  26. package/commands/setup-device.md +99 -0
  27. package/commands/tdd.md +51 -0
  28. package/commands/upgrade.md +78 -0
  29. package/contexts/dev.md +29 -0
  30. package/contexts/review.md +32 -0
  31. package/contexts/vibe.md +44 -0
  32. package/docs/agents.md +41 -0
  33. package/docs/commands.md +53 -0
  34. package/docs/creating-skills.md +63 -0
  35. package/docs/getting-started.md +60 -0
  36. package/docs/hooks-profiles.md +73 -0
  37. package/docs/superpowers/plans/2026-03-10-erne-plan-1-infrastructure-hooks.md +3973 -0
  38. package/docs/superpowers/plans/2026-03-10-erne-plan-2-content-layer.md +4496 -0
  39. package/docs/superpowers/plans/2026-03-10-erne-plan-3-skills-knowledge-base.md +1952 -0
  40. package/docs/superpowers/plans/2026-03-10-erne-plan-4-install-cli-distribution.md +1624 -0
  41. package/docs/superpowers/specs/2026-03-10-everything-react-native-expo-design.md +581 -0
  42. package/examples/claude-md-bare-rn.md +46 -0
  43. package/examples/claude-md-expo-managed.md +45 -0
  44. package/examples/eas-json-standard.json +41 -0
  45. package/hooks/hooks.json +113 -0
  46. package/hooks/profiles/minimal.json +9 -0
  47. package/hooks/profiles/standard.json +17 -0
  48. package/hooks/profiles/strict.json +22 -0
  49. package/install.sh +50 -0
  50. package/mcp-configs/agent-device.json +10 -0
  51. package/mcp-configs/appstore-connect.json +15 -0
  52. package/mcp-configs/expo-api.json +13 -0
  53. package/mcp-configs/figma.json +13 -0
  54. package/mcp-configs/firebase.json +14 -0
  55. package/mcp-configs/github.json +13 -0
  56. package/mcp-configs/memory.json +13 -0
  57. package/mcp-configs/play-console.json +14 -0
  58. package/mcp-configs/sentry.json +15 -0
  59. package/mcp-configs/supabase.json +14 -0
  60. package/package.json +50 -0
  61. package/rules/bare-rn/coding-style.md +62 -0
  62. package/rules/bare-rn/patterns.md +54 -0
  63. package/rules/bare-rn/security.md +58 -0
  64. package/rules/bare-rn/testing.md +78 -0
  65. package/rules/common/coding-style.md +50 -0
  66. package/rules/common/development-workflow.md +55 -0
  67. package/rules/common/git-workflow.md +40 -0
  68. package/rules/common/navigation.md +56 -0
  69. package/rules/common/patterns.md +59 -0
  70. package/rules/common/performance.md +55 -0
  71. package/rules/common/security.md +64 -0
  72. package/rules/common/state-management.md +86 -0
  73. package/rules/common/testing.md +61 -0
  74. package/rules/expo/coding-style.md +54 -0
  75. package/rules/expo/patterns.md +71 -0
  76. package/rules/expo/security.md +41 -0
  77. package/rules/expo/testing.md +68 -0
  78. package/rules/native-android/coding-style.md +81 -0
  79. package/rules/native-android/patterns.md +77 -0
  80. package/rules/native-android/security.md +80 -0
  81. package/rules/native-android/testing.md +94 -0
  82. package/rules/native-ios/coding-style.md +72 -0
  83. package/rules/native-ios/patterns.md +72 -0
  84. package/rules/native-ios/security.md +59 -0
  85. package/rules/native-ios/testing.md +79 -0
  86. package/schemas/hooks.schema.json +34 -0
  87. package/schemas/plugin.schema.json +55 -0
  88. package/scripts/hooks/accessibility-check.js +117 -0
  89. package/scripts/hooks/bundle-size-check.js +31 -0
  90. package/scripts/hooks/check-console-log.js +37 -0
  91. package/scripts/hooks/check-expo-config.js +40 -0
  92. package/scripts/hooks/check-platform-specific.js +40 -0
  93. package/scripts/hooks/check-reanimated-worklet.js +51 -0
  94. package/scripts/hooks/continuous-learning-observer.js +24 -0
  95. package/scripts/hooks/evaluate-session.js +26 -0
  96. package/scripts/hooks/lib/hook-utils.js +52 -0
  97. package/scripts/hooks/native-compat-check.js +42 -0
  98. package/scripts/hooks/performance-budget.js +57 -0
  99. package/scripts/hooks/post-edit-format.js +38 -0
  100. package/scripts/hooks/post-edit-typecheck.js +31 -0
  101. package/scripts/hooks/pre-commit-lint.js +44 -0
  102. package/scripts/hooks/pre-edit-test-gate.js +68 -0
  103. package/scripts/hooks/run-with-flags.js +93 -0
  104. package/scripts/hooks/security-scan.js +65 -0
  105. package/scripts/hooks/session-start.js +77 -0
  106. package/scripts/lint-content.js +62 -0
  107. package/scripts/validate-all.js +137 -0
  108. package/skills/coding-standards/SKILL.md +88 -0
  109. package/skills/continuous-learning-v2/SKILL.md +61 -0
  110. package/skills/continuous-learning-v2/agent-prompts/pattern-analyzer.md +51 -0
  111. package/skills/continuous-learning-v2/agent-prompts/skill-generator.md +74 -0
  112. package/skills/continuous-learning-v2/config.json +25 -0
  113. package/skills/continuous-learning-v2/hook-templates/evaluate-session.cjs.template +69 -0
  114. package/skills/continuous-learning-v2/hook-templates/observer-hook.cjs.template +54 -0
  115. package/skills/continuous-learning-v2/scripts/analyze-patterns.js +50 -0
  116. package/skills/continuous-learning-v2/scripts/extract-session-patterns.js +54 -0
  117. package/skills/continuous-learning-v2/scripts/validate-content.js +88 -0
  118. package/skills/native-module-scaffold/SKILL.md +118 -0
  119. package/skills/performance-optimization/SKILL.md +103 -0
  120. package/skills/security-review/SKILL.md +99 -0
  121. package/skills/tdd-workflow/SKILL.md +142 -0
  122. 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