|
| 1 | +/** |
| 2 | + * CI lint guard — validates route-ordering invariants in `worker/hono-app.ts`. |
| 3 | + * |
| 4 | + * Checks: |
| 5 | + * 1. `timing()` appears before all other `app.use()` calls. |
| 6 | + * 2. `app.on(['POST', 'GET'], '/api/auth/*', ...)` appears before |
| 7 | + * `app.route('/', agentRouter)` (Better Auth before agent routing). |
| 8 | + * 3. `app.route('/api', routes)` is NOT preceded by `app.route('/', routes)` |
| 9 | + * (double-mount guard — bare `/` mount was removed in Phase 4). |
| 10 | + * 4. The compress middleware in the `routes` sub-app uses the `NO_COMPRESS_PATHS` |
| 11 | + * exclusion pattern (not a bare `compress()` wildcard). |
| 12 | + * |
| 13 | + * Usage: |
| 14 | + * deno run --allow-read scripts/lint-route-order.ts |
| 15 | + * |
| 16 | + * Exit codes: |
| 17 | + * 0 — all checks pass |
| 18 | + * 1 — one or more checks failed (descriptive messages printed to stderr) |
| 19 | + */ |
| 20 | + |
| 21 | +import { join } from 'jsr:@std/path@^1.1.4'; |
| 22 | + |
| 23 | +const HONO_APP_PATH = join(import.meta.dirname ?? '.', '..', 'worker', 'hono-app.ts'); |
| 24 | + |
| 25 | +// Read the file |
| 26 | +let src: string; |
| 27 | +try { |
| 28 | + src = await Deno.readTextFile(HONO_APP_PATH); |
| 29 | +} catch (e) { |
| 30 | + console.error(`[lint-route-order] ERROR: Cannot read ${HONO_APP_PATH}:`, e); |
| 31 | + Deno.exit(1); |
| 32 | +} |
| 33 | + |
| 34 | +// Strip single-line and multi-line comments so we don't accidentally match |
| 35 | +// commented-out code. This is intentionally lightweight — it does not handle |
| 36 | +// every edge case, but is sufficient for the ordered-invariant checks below. |
| 37 | +const srcNoComments = src |
| 38 | + .replace(/\/\*[\s\S]*?\*\//g, '') // block comments |
| 39 | + .replace(/\/\/.*/g, ''); // line comments |
| 40 | + |
| 41 | +let passed = true; |
| 42 | + |
| 43 | +// ── Check 1: timing() is the FIRST app.use() call ──────────────────────────── |
| 44 | +// We look for the position of the first `app.use(` call and the position of |
| 45 | +// `timing()` inside it. |
| 46 | + |
| 47 | +const firstAppUseIdx = srcNoComments.indexOf('app.use('); |
| 48 | +const timingCallIdx = srcNoComments.indexOf('timing()'); |
| 49 | + |
| 50 | +if (timingCallIdx === -1) { |
| 51 | + console.error('[lint-route-order] FAIL #1: `timing()` call not found in hono-app.ts'); |
| 52 | + passed = false; |
| 53 | +} else if (firstAppUseIdx === -1) { |
| 54 | + console.error('[lint-route-order] FAIL #1: No `app.use(` calls found in hono-app.ts'); |
| 55 | + passed = false; |
| 56 | +} else { |
| 57 | + // The timing() middleware is registered as `app.use('*', timing())`. |
| 58 | + // The firstAppUseIdx should be <= timingCallIdx (timing is first or included in the first call). |
| 59 | + const timingAppUsePattern = /app\.use\s*\([^)]*timing\s*\(\)/; |
| 60 | + const timingAppUseMatch = timingAppUsePattern.exec(srcNoComments); |
| 61 | + if (!timingAppUseMatch) { |
| 62 | + console.error('[lint-route-order] FAIL #1: `app.use(... timing() ...)` pattern not found'); |
| 63 | + passed = false; |
| 64 | + } else { |
| 65 | + const timingAppUseIdx = timingAppUseMatch.index; |
| 66 | + if (timingAppUseIdx > firstAppUseIdx) { |
| 67 | + console.error( |
| 68 | + `[lint-route-order] FAIL #1: timing() is not the first app.use() call.\n` + |
| 69 | + ` First app.use() at char ${firstAppUseIdx}, timing() app.use() at char ${timingAppUseIdx}`, |
| 70 | + ); |
| 71 | + passed = false; |
| 72 | + } else { |
| 73 | + console.log('[lint-route-order] PASS #1: timing() is first app.use()'); |
| 74 | + } |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +// ── Check 2: Better Auth handler before agent router ───────────────────────── |
| 79 | +// `app.on(['POST', 'GET'], '/api/auth/*', ...)` must appear before |
| 80 | +// `app.route('/', agentRouter)`. |
| 81 | + |
| 82 | +const betterAuthIdx = srcNoComments.indexOf("app.on(['POST', 'GET'], '/api/auth/*'"); |
| 83 | +const agentRouterIdx = srcNoComments.indexOf("app.route('/', agentRouter)"); |
| 84 | + |
| 85 | +if (betterAuthIdx === -1) { |
| 86 | + console.error("[lint-route-order] FAIL #2: `app.on(['POST', 'GET'], '/api/auth/*')` not found"); |
| 87 | + passed = false; |
| 88 | +} else if (agentRouterIdx === -1) { |
| 89 | + console.error("[lint-route-order] FAIL #2: `app.route('/', agentRouter)` not found"); |
| 90 | + passed = false; |
| 91 | +} else if (betterAuthIdx > agentRouterIdx) { |
| 92 | + console.error( |
| 93 | + `[lint-route-order] FAIL #2: Better Auth /api/auth/* handler appears AFTER agentRouter mount.\n` + |
| 94 | + ` Better Auth at char ${betterAuthIdx}, agentRouter at char ${agentRouterIdx}`, |
| 95 | + ); |
| 96 | + passed = false; |
| 97 | +} else { |
| 98 | + console.log('[lint-route-order] PASS #2: Better Auth registered before agentRouter'); |
| 99 | +} |
| 100 | + |
| 101 | +// ── Check 3: No bare-path double-mount guard ────────────────────────────────── |
| 102 | +// `app.route('/', routes)` must NOT appear anywhere in the file (the bare-path |
| 103 | +// double-mount was intentionally removed in Phase 4). |
| 104 | + |
| 105 | +// We specifically look for `app.route('/', routes)` (where `routes` is the local |
| 106 | +// business-routes sub-app, not `agentRouter`). Be precise to avoid false positives. |
| 107 | +const doubleMount = /app\.route\s*\(\s*'\/'\s*,\s*routes\s*\)/.exec(srcNoComments); |
| 108 | +if (doubleMount) { |
| 109 | + console.error( |
| 110 | + `[lint-route-order] FAIL #3: Bare-path double-mount detected: app.route('/', routes)\n` + |
| 111 | + ` This was removed in Phase 4. Only app.route('/api', routes) is allowed.\n` + |
| 112 | + ` Found at char ${doubleMount.index}`, |
| 113 | + ); |
| 114 | + passed = false; |
| 115 | +} else { |
| 116 | + console.log("[lint-route-order] PASS #3: No bare-path double-mount (app.route('/', routes) not present)"); |
| 117 | +} |
| 118 | + |
| 119 | +// ── Check 4: Compress middleware uses NO_COMPRESS_PATHS exclusion ───────────── |
| 120 | +// The `routes` sub-app compress middleware must reference `NO_COMPRESS_PATHS` |
| 121 | +// (not a bare `routes.use('*', compress())` call). |
| 122 | + |
| 123 | +const bareCompressPattern = /routes\.use\s*\(\s*'\*'\s*,\s*compress\s*\(\s*\)\s*\)/; |
| 124 | +if (bareCompressPattern.test(srcNoComments)) { |
| 125 | + console.error( |
| 126 | + "[lint-route-order] FAIL #4: Bare compress() wildcard detected: routes.use('*', compress()).\n" + |
| 127 | + ' Use the NO_COMPRESS_PATHS exclusion pattern instead to skip compression on health/metrics endpoints.', |
| 128 | + ); |
| 129 | + passed = false; |
| 130 | +} else if (!srcNoComments.includes('NO_COMPRESS_PATHS')) { |
| 131 | + console.error( |
| 132 | + '[lint-route-order] FAIL #4: NO_COMPRESS_PATHS constant not found in hono-app.ts.\n' + |
| 133 | + ' The compress middleware must use this exclusion set to skip /health and /metrics routes.', |
| 134 | + ); |
| 135 | + passed = false; |
| 136 | +} else { |
| 137 | + console.log('[lint-route-order] PASS #4: Compress middleware uses NO_COMPRESS_PATHS exclusion'); |
| 138 | +} |
| 139 | + |
| 140 | +// ── Summary ─────────────────────────────────────────────────────────────────── |
| 141 | + |
| 142 | +if (passed) { |
| 143 | + console.log('\n✅ All route-order checks passed.'); |
| 144 | + Deno.exit(0); |
| 145 | +} else { |
| 146 | + console.error('\n❌ One or more route-order checks failed. See errors above.'); |
| 147 | + Deno.exit(1); |
| 148 | +} |
0 commit comments