From 2b1c25117f312087672775517fb80c33a6532891 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 9 Sep 2026 16:24:25 -0400 Subject: [PATCH 01/11] build(scripts): add shared script library and script test harness Introduce scripts/lib (common.sh, colors.sh, pipeline-config.js) so scripts share one set of log helpers, project-root resolution, and a reader for .claude/pipeline.config.json, plus a Vitest harness under scripts/__tests__ and the pnpm test/verify/validate scripts. Adapted from the Aurelius scripts/lib pattern. Co-Authored-By: Claude Fable 5.1 --- package.json | 14 +- pnpm-lock.yaml | 672 +++++++++++++++++++++++++++++ scripts/__tests__/helpers.js | 62 +++ scripts/__tests__/vitest.config.js | 15 + scripts/lib/colors.sh | 39 ++ scripts/lib/common.sh | 159 +++++++ scripts/lib/pipeline-config.js | 110 +++++ 7 files changed, 1069 insertions(+), 2 deletions(-) create mode 100644 scripts/__tests__/helpers.js create mode 100644 scripts/__tests__/vitest.config.js create mode 100644 scripts/lib/colors.sh create mode 100644 scripts/lib/common.sh create mode 100644 scripts/lib/pipeline-config.js diff --git a/package.json b/package.json index 09e3107..67d897c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,15 @@ "release:dry": "commit-and-tag-version --dry-run", "release:first": "commit-and-tag-version --first-release", "setup": "bash scripts/setup-project.sh", - "prepare": "husky" + "prepare": "husky", + "test": "vitest run --config scripts/__tests__/vitest.config.js", + "test:watch": "vitest --config scripts/__tests__/vitest.config.js", + "verify": "bash scripts/verify-all.sh", + "verify:ci": "bash scripts/verify-all.sh --ci", + "validate:config": "node scripts/validate-pipeline-config.js", + "check:prereqs": "bash scripts/check-prerequisites.sh", + "check:doc-counts": "bash scripts/check-doc-counts.sh", + "check:destructive": "node scripts/check-destructive-migrations.js" }, "repository": { "type": "git", @@ -45,7 +53,9 @@ "devDependencies": { "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", + "ajv": "^8.20.0", "commit-and-tag-version": "^13.1.2", - "husky": "^9.1.7" + "husky": "^9.1.7", + "vitest": "^4.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f75bc2..588ff4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,12 +18,18 @@ importers: '@commitlint/config-conventional': specifier: ^21.2.2 version: 21.2.2 + ajv: + specifier: ^8.20.0 + version: 8.20.0 commit-and-tag-version: specifier: ^13.1.2 version: 13.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.1) husky: specifier: ^9.1.7 version: 9.1.7 + vitest: + specifier: ^4.1.2 + version: 4.1.11(@types/node@25.5.2)(vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0)) packages: @@ -120,9 +126,108 @@ packages: resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} engines: {node: '>=22'} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@oxc-project/types@0.149.0': + resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@simple-libs/child-process-utils@2.0.0': resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} engines: {node: '>=22'} @@ -139,9 +244,50 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -171,6 +317,10 @@ packages: resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} engines: {node: '>=22'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -181,6 +331,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -247,6 +401,9 @@ packages: engines: {node: '>=22'} hasBin: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cosmiconfig-typescript-loader@6.3.0: resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} engines: {node: '>=v18'} @@ -268,6 +425,10 @@ packages: resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} engines: {node: '>=12.20'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -289,6 +450,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -300,6 +464,13 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -316,6 +487,15 @@ packages: fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -328,6 +508,11 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -384,6 +569,76 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -395,9 +650,21 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -438,9 +705,20 @@ packages: resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} engines: {node: '>=14.0.0'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -457,11 +735,29 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + rolldown@1.2.8: + resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -481,10 +777,21 @@ packages: strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + typescript@6.0.2: resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} engines: {node: '>=14.17'} @@ -493,10 +800,99 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -668,8 +1064,59 @@ snapshots: '@conventional-changelog/template@1.2.1': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@nodable/entities@3.0.0': {} + '@oxc-project/types@0.149.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@simple-libs/child-process-utils@2.0.0': dependencies: '@simple-libs/stream-utils': 2.0.0 @@ -683,10 +1130,62 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@25.5.2': dependencies: undici-types: 7.18.2 + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -710,6 +1209,8 @@ snapshots: argue-cli@3.1.0: {} + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} brace-expansion@1.1.17: @@ -719,6 +1220,8 @@ snapshots: callsites@3.1.0: {} + chai@6.2.2: {} + chalk@5.6.2: {} cliui@8.0.1: @@ -810,6 +1313,8 @@ snapshots: conventional-commits-filter: 6.0.1 conventional-commits-parser: 7.1.1 + convert-source-map@2.0.0: {} + cosmiconfig-typescript-loader@6.3.0(@types/node@25.5.2)(cosmiconfig@9.0.2(typescript@6.0.2))(typescript@6.0.2): dependencies: '@types/node': 25.5.2 @@ -828,6 +1333,8 @@ snapshots: detect-indent@7.0.2: {} + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} dotgitignore@2.1.0: @@ -845,12 +1352,20 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-module-lexer@2.3.2: {} + es-toolkit@1.49.0: {} escalade@3.2.0: {} escape-string-regexp@1.0.5: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-uri@4.1.0: {} @@ -873,6 +1388,10 @@ snapshots: dependencies: walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -886,6 +1405,9 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + fsevents@2.3.3: + optional: true + get-caller-file@2.0.5: {} get-east-asian-width@1.6.0: {} @@ -923,6 +1445,55 @@ snapshots: json-schema-traverse@1.0.0: {} + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} locate-path@3.0.0: @@ -934,10 +1505,18 @@ snapshots: dependencies: p-locate: 5.0.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.17 + nanoid@3.3.18: {} + + obug@2.2.1: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -973,8 +1552,18 @@ snapshots: path-expression-matcher@1.6.2: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -983,8 +1572,37 @@ snapshots: resolve-from@5.0.0: {} + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + semver@7.8.5: {} + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1009,14 +1627,68 @@ snapshots: dependencies: anynum: 1.0.1 + tinybench@2.9.0: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + typescript@6.0.2: {} undici-types@7.18.2: {} + vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.5.2 + fsevents: 2.3.3 + jiti: 2.6.1 + yaml: 2.9.0 + + vitest@4.1.11(@types/node@25.5.2)(vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.2 + transitivePeerDependencies: + - msw + walk-up-path@4.0.0: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/scripts/__tests__/helpers.js b/scripts/__tests__/helpers.js new file mode 100644 index 0000000..098cb10 --- /dev/null +++ b/scripts/__tests__/helpers.js @@ -0,0 +1,62 @@ +/** + * Shared helpers for scripts/__tests__. + * + * run(cmd, args, opts) shells out with NO_COLOR set and returns + * { stdout, stderr, exitCode } without throwing on non-zero exit. + */ +import { execFileSync } from "child_process"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { join, dirname, resolve } from "path"; +import { tmpdir } from "os"; +import { fileURLToPath } from "url"; + +export const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); +export const SCRIPTS_DIR = resolve(TESTS_DIR, ".."); +export const REPO_ROOT = resolve(SCRIPTS_DIR, ".."); +export const HOOKS_DIR = join(REPO_ROOT, ".claude", "hooks"); + +export function run(cmd, args = [], opts = {}) { + const { cwd = REPO_ROOT, env = {}, input, timeout = 60000 } = opts; + try { + const stdout = execFileSync(cmd, args, { + encoding: "utf-8", + cwd, + timeout, + input, + env: { ...process.env, NO_COLOR: "1", ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + return { stdout, stderr: "", exitCode: 0 }; + } catch (err) { + return { + stdout: err.stdout?.toString() ?? "", + stderr: err.stderr?.toString() ?? "", + exitCode: typeof err.status === "number" ? err.status : 1, + }; + } +} + +export function runBash(scriptPath, args = [], opts = {}) { + return run("bash", [scriptPath, ...args], opts); +} + +export function runNode(scriptPath, args = [], opts = {}) { + return run("node", [scriptPath, ...args], opts); +} + +/** Create a throwaway directory; call the returned cleanup() in afterAll/afterEach. */ +export function tmpProject(prefix = "nerva-test-") { + const dir = mkdtempSync(join(tmpdir(), prefix)); + return { + dir, + write(relPath, content) { + const full = join(dir, relPath); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + return full; + }, + cleanup() { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/scripts/__tests__/vitest.config.js b/scripts/__tests__/vitest.config.js new file mode 100644 index 0000000..db7a4e0 --- /dev/null +++ b/scripts/__tests__/vitest.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["scripts/__tests__/**/*.test.js"], + // Script tests shell out and some share fixture directories on disk, so + // run files sequentially to keep them deterministic. + fileParallelism: false, + testTimeout: 60000, + env: { + // Keep script output free of ANSI codes so assertions can match literals. + NO_COLOR: "1", + }, + }, +}); diff --git a/scripts/lib/colors.sh b/scripts/lib/colors.sh new file mode 100644 index 0000000..018fee8 --- /dev/null +++ b/scripts/lib/colors.sh @@ -0,0 +1,39 @@ +# colors.sh — ANSI terminal color constants +# +# Source this file to get color variables. Colors are disabled when stdout is +# not a terminal, or when NO_COLOR (no-color.org) / COLORS_DISABLE is set, so +# output stays clean when piped, captured by tests, or written to logs. +# +# Usage: +# source "$SCRIPT_DIR/lib/colors.sh" +# echo -e "${RED}error${NC}" + +# Avoid redefining if sourced multiple times. +if [[ -n "${__NERVA_COLORS_SH_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +__NERVA_COLORS_SH_LOADED=1 + +if [[ -n "${NO_COLOR:-}" ]] || [[ -n "${COLORS_DISABLE:-}" ]] || [[ ! -t 1 ]]; then + RED='' + GREEN='' + YELLOW='' + BLUE='' + MAGENTA='' + CYAN='' + BOLD='' + DIM='' + NC='' +else + RED=$'\033[0;31m' + GREEN=$'\033[0;32m' + YELLOW=$'\033[1;33m' + BLUE=$'\033[0;34m' + MAGENTA=$'\033[0;35m' + CYAN=$'\033[0;36m' + BOLD=$'\033[1m' + DIM=$'\033[2m' + NC=$'\033[0m' +fi + +export RED GREEN YELLOW BLUE MAGENTA CYAN BOLD DIM NC diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..91658f2 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,159 @@ +# common.sh — Shared bash helpers for scripts/*.sh and .claude/hooks/*.sh +# +# Source this from any script: +# +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# # shellcheck source=lib/common.sh +# source "$SCRIPT_DIR/lib/common.sh" +# +# Provides: +# - colors (via lib/colors.sh) +# - info/success/warn/error/step — the log prefixes Nerva scripts already use +# - say_banner/say_step/say_pass/say_fail/say_skip/say_warn — plain-text +# prefixes for machine-parsed output (tests grep for these literals) +# - common_project_root / common_api_dir +# - require_cmd / have_cmd +# - common_track_tmpfile (EXIT-trap cleanup) +# - common_now_ms, common_csv_contains +# - common_config_get [default] — reads .claude/pipeline.config.json +# +# Adapted from the Aurelius scripts/lib pattern so both frameworks share one +# convention. + +if [[ -n "${__NERVA_COMMON_SH_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +__NERVA_COMMON_SH_LOADED=1 + +__NERVA_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/colors.sh +source "$__NERVA_LIB_DIR/colors.sh" + +# --- Project root --------------------------------------------------------- + +# Repository root = the parent of scripts/. Independent of the caller's cwd. +common_project_root() { + (cd "$__NERVA_LIB_DIR/../.." && pwd) +} + +# Directory of the generated API project. Defaults to /api; override +# with NERVA_API_DIR for monorepos or tests. +common_api_dir() { + if [[ -n "${NERVA_API_DIR:-}" ]]; then + echo "$NERVA_API_DIR" + else + echo "$(common_project_root)/api" + fi +} + +# --- Log helpers (Nerva style) ------------------------------------------- + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +step() { echo -e "${CYAN}==>${NC} $*"; } + +# --- Plain-text status prefixes (stable; tests assert these) -------------- + +say_banner() { echo "=== $* ==="; } +say_step() { echo "▸ $*"; } +say_pass() { echo " ✓ $*"; } +say_fail() { echo " ✗ $*"; } +say_skip() { echo " ⊘ $*"; } +say_warn() { echo " ⚠ $*"; } +say_err() { echo "Error: $*" >&2; } + +# --- Tool availability ---------------------------------------------------- + +# require_cmd [install hint] — exits 1 if the command is missing. +require_cmd() { + local cmd="$1" + local hint="${2:-}" + if ! command -v "$cmd" >/dev/null 2>&1; then + say_err "$cmd not found on PATH." + if [[ -n "$hint" ]]; then + echo " Install it with: $hint" >&2 + fi + exit 1 + fi +} + +# have_cmd — 0/1 without exiting. +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +# --- Tempfile tracking ---------------------------------------------------- +# +# F=$(mktemp); common_track_tmpfile "$F" +# +# (mktemp cannot be wrapped in a function: the $( ) subshell would run the +# EXIT trap and delete the file before the caller receives the path.) + +__NERVA_TMPFILES=() +__NERVA_TRAP_SET=0 + +common_cleanup_tmpfiles() { + local f + for f in "${__NERVA_TMPFILES[@]+"${__NERVA_TMPFILES[@]}"}"; do + [[ -e "$f" ]] && rm -rf "$f" + done +} + +common_track_tmpfile() { + __NERVA_TMPFILES+=("$1") + if [[ "$__NERVA_TRAP_SET" -eq 0 ]]; then + trap common_cleanup_tmpfiles EXIT + __NERVA_TRAP_SET=1 + fi +} + +# --- Cross-platform timestamp -------------------------------------------- + +# Milliseconds since epoch. GNU date supports %N; macOS does not. +common_now_ms() { + if have_cmd node; then + node -e 'process.stdout.write(String(Date.now()))' + elif have_cmd python3; then + python3 -c 'import time; print(int(time.time()*1000))' + else + echo "$(( $(date +%s) * 1000 ))" + fi +} + +# --- CSV helpers ---------------------------------------------------------- + +# common_csv_contains "a,b,c" "b" → 0/1 +common_csv_contains() { + local csv="$1" + local needle="$2" + [[ -z "$csv" ]] && return 1 + local parts part + IFS=',' read -ra parts <<< "$csv" + for part in "${parts[@]}"; do + [[ "$(echo "$part" | tr -d ' ')" == "$needle" ]] && return 0 + done + return 1 +} + +# --- pipeline.config.json access ----------------------------------------- +# +# common_config_get [default] +# +# THRESHOLD=$(common_config_get 'tdd.coverageThreshold' 80) +# LEVEL=$(common_config_get 'security.audit.level' moderate) +# +# Thin wrapper around scripts/lib/pipeline-config.js. Prints the default when +# the config, the key, or node is missing, so callers never need set +e. + +common_config_get() { + local key="$1" + local default="${2-}" + local helper="$__NERVA_LIB_DIR/pipeline-config.js" + if [[ ! -f "$helper" ]] || ! have_cmd node; then + echo "$default" + return 0 + fi + node "$helper" get "$key" "$default" 2>/dev/null || echo "$default" +} diff --git a/scripts/lib/pipeline-config.js b/scripts/lib/pipeline-config.js new file mode 100644 index 0000000..ef5f217 --- /dev/null +++ b/scripts/lib/pipeline-config.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * pipeline-config.js — Read values from .claude/pipeline.config.json + * + * Library use (ESM): + * + * import { getValue, loadConfig } from "./lib/pipeline-config.js"; + * const threshold = getValue("tdd.coverageThreshold", 80); + * + * CLI use (from common.sh `common_config_get`): + * + * node scripts/lib/pipeline-config.js get [default] + * + * The config file is resolved relative to the repo root (the parent of + * scripts/), or from NERVA_PIPELINE_CONFIG when set (tests use this). Exit + * code is always 0 — the default is printed if anything fails, so callers can + * rely on `VALUE=$(...)` under `set -e`. + */ +import { readFileSync, existsSync, statSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join, resolve } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** Repo root = scripts/lib/../.. */ +export const REPO_ROOT = resolve(__dirname, "..", ".."); +export const DEFAULT_CONFIG_PATH = join(REPO_ROOT, ".claude", "pipeline.config.json"); + +let cachedPath = null; +let cachedConfig = null; +let cachedMtimeMs = null; + +function resolveConfigPath(configPath) { + if (configPath) return resolve(configPath); + if (process.env.NERVA_PIPELINE_CONFIG) return resolve(process.env.NERVA_PIPELINE_CONFIG); + return DEFAULT_CONFIG_PATH; +} + +/** + * Load pipeline.config.json. Returns {} when the file is missing or invalid + * so callers can rely on optional-chaining without crashing. + */ +export function loadConfig(configPath) { + const path = resolveConfigPath(configPath); + if (!existsSync(path)) return {}; + try { + let mtime; + try { + mtime = statSync(path).mtimeMs; + } catch { + mtime = null; + } + if (cachedConfig && cachedPath === path && cachedMtimeMs === mtime) return cachedConfig; + cachedConfig = JSON.parse(readFileSync(path, "utf8")); + cachedPath = path; + cachedMtimeMs = mtime; + return cachedConfig; + } catch { + return {}; + } +} + +/** + * Look up a dotted path. Missing keys → defaultValue. A `false` default is + * honored exactly (returns false when the key is absent). + */ +export function getValue(dottedPath, defaultValue = undefined, configPath) { + const config = loadConfig(configPath); + const parts = String(dottedPath).split(".").filter(Boolean); + let cur = config; + for (const p of parts) { + if (cur === null || typeof cur !== "object" || !(p in cur)) { + return defaultValue; + } + cur = cur[p]; + } + return cur === undefined ? defaultValue : cur; +} + +/** Render a value for shell consumption: booleans/numbers as text, objects as JSON. */ +export function renderForShell(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function main(argv) { + const [cmd, key, def] = argv; + if (cmd !== "get" || !key) { + console.error("Usage: node scripts/lib/pipeline-config.js get [default]"); + process.stdout.write(def ?? ""); + return; + } + // Coerce shell-provided defaults so `false`/`0` survive the round trip. + let defaultValue = def; + if (def === "true") defaultValue = true; + else if (def === "false") defaultValue = false; + const value = getValue(key, defaultValue); + process.stdout.write(renderForShell(value)); +} + +if (process.argv[1] && resolve(process.argv[1]) === __filename) { + try { + main(process.argv.slice(2)); + } catch { + process.stdout.write(process.argv[4] ?? ""); + } +} From 1a24fe92d645542d7f2c788c3c5001b7236e120e Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 9 Sep 2026 16:24:31 -0400 Subject: [PATCH 02/11] refactor(scripts): source the shared lib and read thresholds from pipeline.config.json Drop the duplicated color and log definitions from every script. run-tests.sh now enforces tdd.coverageThreshold via the json-summary reporter, security-scan.sh honors security.audit.level/failOnVulnerability/checkLockfile, load-test.sh defaults VUs, duration and k6 thresholds from testing.*, and check-types.sh reports 'any' usage when qualityGate.noAnyTypes is set. Co-Authored-By: Claude Fable 5.1 --- scripts/check-types.sh | 29 ++++++++++--------- scripts/generate-client.sh | 18 +++--------- scripts/generate-openapi-docs.sh | 19 +++--------- scripts/load-test.sh | 45 ++++++++++++++--------------- scripts/run-tests.sh | 42 +++++++++++++++++---------- scripts/security-scan.sh | 48 +++++++++++++++++++------------ scripts/seed-database.sh | 18 +++--------- templates/shared/vitest.config.ts | 3 +- 8 files changed, 106 insertions(+), 116 deletions(-) diff --git a/scripts/check-types.sh b/scripts/check-types.sh index 554dec0..9da0e08 100644 --- a/scripts/check-types.sh +++ b/scripts/check-types.sh @@ -6,21 +6,11 @@ set -euo pipefail # Usage: ./scripts/check-types.sh [--strict] [--verbose] # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' - -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -API_DIR="$PROJECT_ROOT/api" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +PROJECT_ROOT="$(common_project_root)" +API_DIR="$(common_api_dir)" VERBOSE=false EXTRA_ARGS=() @@ -72,6 +62,17 @@ if "${TSC_CMD[@]}"; then DURATION=$((END_TIME - START_TIME)) echo "" success "Type check passed in ${DURATION}s. No type errors found." + + # Advisory: qualityGate.noAnyTypes in .claude/pipeline.config.json. + if [[ "$(common_config_get 'qualityGate.noAnyTypes' true)" == true && -d src ]]; then + ANY_HITS=$(grep -rnE '(:\s*any\b||as any\b)' src --include='*.ts' 2>/dev/null | grep -v '\.test\.' | grep -v '\.spec\.' || true) + if [[ -n "$ANY_HITS" ]]; then + ANY_COUNT=$(printf '%s\n' "$ANY_HITS" | wc -l | tr -d ' ') + warn "qualityGate.noAnyTypes is on and $ANY_COUNT use(s) of 'any' were found in src/:" + printf '%s\n' "$ANY_HITS" | head -10 | sed 's/^/ /' + [[ "$ANY_COUNT" -gt 10 ]] && echo " ..." + fi + fi else END_TIME=$(date +%s) DURATION=$((END_TIME - START_TIME)) diff --git a/scripts/generate-client.sh b/scripts/generate-client.sh index da1447d..2923ec7 100644 --- a/scripts/generate-client.sh +++ b/scripts/generate-client.sh @@ -6,21 +6,11 @@ set -euo pipefail # Usage: ./scripts/generate-client.sh --spec docs/openapi.yaml --output client/ # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' - -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -API_DIR="$PROJECT_ROOT/api" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +PROJECT_ROOT="$(common_project_root)" +API_DIR="$(common_api_dir)" SPEC_FILE="$PROJECT_ROOT/docs/openapi.yaml" OUTPUT_DIR="$PROJECT_ROOT/client" diff --git a/scripts/generate-openapi-docs.sh b/scripts/generate-openapi-docs.sh index a284867..6d0b930 100644 --- a/scripts/generate-openapi-docs.sh +++ b/scripts/generate-openapi-docs.sh @@ -7,22 +7,11 @@ set -euo pipefail # Usage: ./scripts/generate-openapi-docs.sh [--serve] [--port 8080] # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -# shellcheck disable=SC2034 # CYAN is used in echo -e strings below -CYAN='\033[0;36m' -NC='\033[0m' - -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -API_DIR="$PROJECT_ROOT/api" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +PROJECT_ROOT="$(common_project_root)" +API_DIR="$(common_api_dir)" DOCS_DIR="$PROJECT_ROOT/docs" SERVE=false diff --git a/scripts/load-test.sh b/scripts/load-test.sh index d3b4991..f43e696 100644 --- a/scripts/load-test.sh +++ b/scripts/load-test.sh @@ -4,26 +4,23 @@ set -euo pipefail # ============================================================================ # load-test.sh - Run k6 load tests # Usage: ./scripts/load-test.sh [--vus 50] [--duration 30s] [--script path] +# +# VU count, duration and k6 thresholds default to testing.* in +# .claude/pipeline.config.json. # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' - -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -API_DIR="$PROJECT_ROOT/api" - -VUS=10 -DURATION="30s" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +PROJECT_ROOT="$(common_project_root)" +API_DIR="$(common_api_dir)" + +# Defaults from testing.* in .claude/pipeline.config.json; flags override. +VUS="$(common_config_get 'testing.loadTestVUs' 10)" +DURATION="$(common_config_get 'testing.loadTestDuration' 30s)" +P95_MS="$(common_config_get 'testing.loadTestThresholds.http_req_duration_p95' 500)" +P99_MS="$(common_config_get 'testing.loadTestThresholds.http_req_duration_p99' 1000)" +FAIL_RATE="$(common_config_get 'testing.loadTestThresholds.http_req_failed_rate' 0.01)" K6_SCRIPT="" BASE_URL="http://localhost:3000" OUTPUT_JSON=false @@ -41,7 +38,7 @@ while [[ $# -gt 0 ]]; do esac done -if ! command -v k6 &>/dev/null; then +if ! have_cmd k6; then error "k6 is not installed." echo "" echo " Install k6:" @@ -58,7 +55,7 @@ fi if [[ ! -f "$K6_SCRIPT" ]]; then warn "Load test script not found. Creating default baseline..." mkdir -p "$(dirname "$K6_SCRIPT")" - cat > "$K6_SCRIPT" << 'K6EOF' + cat > "$K6_SCRIPT" << K6EOF import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate, Trend } from 'k6/metrics'; @@ -71,14 +68,14 @@ export const options = { vus: __ENV.VUS ? parseInt(__ENV.VUS) : 10, duration: __ENV.DURATION || '30s', thresholds: { - http_req_duration: ['p(95)<500', 'p(99)<1000'], - errors: ['rate<0.01'], - http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<${P95_MS}', 'p(99)<${P99_MS}'], + errors: ['rate<${FAIL_RATE}'], + http_req_failed: ['rate<${FAIL_RATE}'], }, }; export default function () { - const healthRes = http.get(`${BASE_URL}/health`); + const healthRes = http.get(\`\${BASE_URL}/health\`); check(healthRes, { 'health status 200': (r) => r.status === 200, 'health response < 200ms': (r) => r.timings.duration < 200, @@ -86,7 +83,7 @@ export default function () { errorRate.add(healthRes.status !== 200); responseTime.add(healthRes.timings.duration); - const rootRes = http.get(`${BASE_URL}/`); + const rootRes = http.get(\`\${BASE_URL}/\`); check(rootRes, { 'root status 200': (r) => r.status === 200, 'root response < 300ms': (r) => r.timings.duration < 300, diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 151705c..6eeb8e3 100644 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -4,23 +4,17 @@ set -euo pipefail # ============================================================================ # run-tests.sh - Run Vitest tests with coverage # Usage: ./scripts/run-tests.sh [--unit|--integration|--all|--coverage] +# +# Reads testing.integrationTimeout and tdd.coverageThreshold from +# .claude/pipeline.config.json; --coverage fails when line coverage is below +# the threshold. # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' - -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -API_DIR="$PROJECT_ROOT/api" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +PROJECT_ROOT="$(common_project_root)" +API_DIR="$(common_api_dir)" TEST_MODE="all" WITH_COVERAGE=false @@ -60,7 +54,7 @@ case "$TEST_MODE" in integration) info "Running integration tests..." VITEST_CMD+=("--include" "tests/integration/**/*.{test,spec}.ts") - VITEST_CMD+=("--testTimeout" "30000") + VITEST_CMD+=("--testTimeout" "$(common_config_get 'testing.integrationTimeout' 30000)") ;; all) info "Running all tests..." @@ -98,4 +92,22 @@ if [[ "$WITH_COVERAGE" == true && -f "coverage/coverage-summary.json" ]]; then echo "" info "Coverage report generated at: $API_DIR/coverage/" info "Open coverage/index.html for the full HTML report." + + # Enforce tdd.coverageThreshold from .claude/pipeline.config.json (default 80). + # Uses the json-summary reporter output; templates/shared/vitest.config.ts emits it. + THRESHOLD="$(common_config_get 'tdd.coverageThreshold' 80)" + LINES_PCT="$(node -e ' + const s = JSON.parse(require("fs").readFileSync("coverage/coverage-summary.json", "utf8")); + process.stdout.write(String(s.total?.lines?.pct ?? "")); + ' 2>/dev/null || true)" + if [[ -n "$LINES_PCT" ]]; then + if node -e 'process.exit(Number(process.argv[1]) >= Number(process.argv[2]) ? 0 : 1)' "$LINES_PCT" "$THRESHOLD"; then + success "Line coverage ${LINES_PCT}% meets the ${THRESHOLD}% threshold (tdd.coverageThreshold)." + else + error "Line coverage ${LINES_PCT}% is below the ${THRESHOLD}% threshold (tdd.coverageThreshold)." + exit 1 + fi + else + warn "Could not read total line coverage from coverage-summary.json; threshold not enforced." + fi fi diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index 4f9c8f2..fb47673 100644 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -3,24 +3,17 @@ set -euo pipefail # ============================================================================ # security-scan.sh - Security scanning for Nerva API projects -# Usage: ./scripts/security-scan.sh [--json] [--audit-only] [--patterns-only] +# Usage: ./scripts/security-scan.sh [--json] [--audit-only] [--patterns-only] [--level ] [--no-fail] +# +# Audit level, fail-on-vulnerability and lockfile policy come from +# security.audit.* in .claude/pipeline.config.json; flags override. # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' - -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -API_DIR="$PROJECT_ROOT/api" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +PROJECT_ROOT="$(common_project_root)" +API_DIR="$(common_api_dir)" OUTPUT_JSON=false AUDIT_ONLY=false @@ -28,11 +21,18 @@ PATTERNS_ONLY=false ISSUES_FOUND=0 JSON_RESULTS=() +# Policy from .claude/pipeline.config.json (security.audit.*); flags override. +AUDIT_LEVEL="$(common_config_get 'security.audit.level' moderate)" +FAIL_ON_VULN="$(common_config_get 'security.audit.failOnVulnerability' true)" +CHECK_LOCKFILE="$(common_config_get 'security.audit.checkLockfile' true)" + while [[ $# -gt 0 ]]; do case "$1" in --json) OUTPUT_JSON=true; shift ;; --audit-only) AUDIT_ONLY=true; shift ;; --patterns-only) PATTERNS_ONLY=true; shift ;; + --level) AUDIT_LEVEL="$2"; shift 2 ;; + --no-fail) FAIL_ON_VULN=false; shift ;; *) error "Unknown option: $1"; exit 1 ;; esac done @@ -62,14 +62,18 @@ add_issue() { } run_audit() { - info "Running pnpm audit for known vulnerabilities..." + info "Running pnpm audit for known vulnerabilities (level: $AUDIT_LEVEL)..." echo "" - if ! command -v pnpm &>/dev/null; then + if ! have_cmd pnpm; then error "pnpm is not installed." return 1 fi - AUDIT_OUTPUT=$(pnpm audit 2>&1) || true + if [[ "$CHECK_LOCKFILE" == true && ! -f "pnpm-lock.yaml" ]]; then + add_issue "medium" "lockfile" "pnpm-lock.yaml is missing; dependency versions are not pinned (security.audit.checkLockfile)" + fi + + AUDIT_OUTPUT=$(pnpm audit --audit-level "$AUDIT_LEVEL" 2>&1) || true if echo "$AUDIT_OUTPUT" | grep -q "No known vulnerabilities found"; then success "No known vulnerabilities in dependencies." @@ -176,4 +180,10 @@ else echo -e "${CYAN}============================================${NC}" fi -[[ "$ISSUES_FOUND" -gt 0 ]] && exit 1 || exit 0 +if [[ "$ISSUES_FOUND" -gt 0 ]]; then + if [[ "$FAIL_ON_VULN" == true ]]; then + exit 1 + fi + warn "Issues found but security.audit.failOnVulnerability is false (or --no-fail); exiting 0." +fi +exit 0 diff --git a/scripts/seed-database.sh b/scripts/seed-database.sh index ceda644..76db336 100644 --- a/scripts/seed-database.sh +++ b/scripts/seed-database.sh @@ -6,21 +6,11 @@ set -euo pipefail # Usage: ./scripts/seed-database.sh [--env development|staging|production] # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' - -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -API_DIR="$PROJECT_ROOT/api" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +PROJECT_ROOT="$(common_project_root)" +API_DIR="$(common_api_dir)" TARGET_ENV="development" diff --git a/templates/shared/vitest.config.ts b/templates/shared/vitest.config.ts index ec6e83d..994429f 100644 --- a/templates/shared/vitest.config.ts +++ b/templates/shared/vitest.config.ts @@ -17,7 +17,8 @@ export default defineConfig({ hookTimeout: 30_000, coverage: { provider: 'v8', - reporter: ['text', 'text-summary', 'lcov', 'json'], + // json-summary feeds the coverage gate in scripts/run-tests.sh --coverage. + reporter: ['text', 'text-summary', 'lcov', 'json', 'json-summary'], include: ['src/**/*.ts'], exclude: [ 'src/**/*.test.ts', From 1ad026f34846d57bdb4b7d2a2e86997f754f1735 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 9 Sep 2026 16:24:34 -0400 Subject: [PATCH 03/11] feat(migrations): add destructive-DDL guard and prod schema drift check check-destructive-migrations.js scans migration SQL for DROP/TRUNCATE/unbounded DELETE after stripping comments, with a per-file allowlist marker, and generate-migration.sh runs it before applying when database.blockDestructiveMigrations is on. schema-drift.ts compares the Drizzle schema against a production database in lenient (PR gate) or strict (daily) mode; schema-drift.yml and schema-applied.yml ship as generated-project workflows. Adapted from the Bridleway migration-safety scripts. Co-Authored-By: Claude Fable 5.1 --- docs/api-development/README.md | 2 + docs/api-development/migration-safety.md | 95 ++++ .../check-destructive-migrations.test.js | 296 ++++++++++ scripts/__tests__/generate-migration.test.js | 62 ++ scripts/check-destructive-migrations.js | 537 ++++++++++++++++++ scripts/generate-migration.sh | 97 +++- templates/shared/workflows/schema-applied.yml | 127 +++++ templates/shared/workflows/schema-drift.yml | 83 +++ .../snippets/shared/src/db/schema-drift.ts | 531 +++++++++++++++++ 9 files changed, 1812 insertions(+), 18 deletions(-) create mode 100644 docs/api-development/migration-safety.md create mode 100644 scripts/__tests__/check-destructive-migrations.test.js create mode 100644 scripts/__tests__/generate-migration.test.js create mode 100644 scripts/check-destructive-migrations.js create mode 100644 templates/shared/workflows/schema-applied.yml create mode 100644 templates/shared/workflows/schema-drift.yml create mode 100644 templates/snippets/shared/src/db/schema-drift.ts diff --git a/docs/api-development/README.md b/docs/api-development/README.md index 77910c3..611cb19 100644 --- a/docs/api-development/README.md +++ b/docs/api-development/README.md @@ -208,6 +208,8 @@ const result = await db.transaction(async (tx) => { }); ``` +See the [migration safety guide](./migration-safety.md) for the destructive-migration guard, the `-- nerva:allow-destructive:` allowlist marker, the production schema drift check, and the two GitHub Actions workflows generated projects ship with. + ## Testing Strategy ### Unit Tests (Services) diff --git a/docs/api-development/migration-safety.md b/docs/api-development/migration-safety.md new file mode 100644 index 0000000..6b59311 --- /dev/null +++ b/docs/api-development/migration-safety.md @@ -0,0 +1,95 @@ +# Migration Safety + +Two guards keep a Nerva project's database schema and its Drizzle declarations from silently diverging: + +- **Destructive migration guard** (`check-destructive-migrations`) — a static scan of committed `*.sql` migration files. No database needed. +- **Schema drift check** (`schema-drift.ts`) — compares what `src/db/schema.ts` declares with what the production database actually has. + +Both ship in every generated project (`api/scripts/check-destructive-migrations.mjs`, `api/src/db/schema-drift.ts`) together with two GitHub Actions workflows, and the framework repo runs the destructive guard itself from `scripts/generate-migration.sh`. + +## Destructive migration guard + +`drizzle-kit generate` diffs the schema against its snapshot. If a developer's local database (or the snapshot) has drifted, the generated migration can contain a "reconcile" plan — `DROP TABLE`, `DROP COLUMN`, `ALTER COLUMN ... DROP NOT NULL` — that deletes production data when applied. Nerva migrations are additive by policy, so any destructive DDL in a committed migration is treated as a mistake until a human says otherwise. + +The guard strips `--` and `/* */` comments (string literals and `$$` bodies are preserved) and flags: + +| Pattern | Example | +|---------|---------| +| `DROP TABLE` | `DROP TABLE "sessions";` | +| `DROP COLUMN` | `ALTER TABLE "users" DROP COLUMN "email";` | +| `DROP CONSTRAINT` | `ALTER TABLE "users" DROP CONSTRAINT "users_email_unique";` | +| `DROP NOT NULL` | `ALTER TABLE "users" ALTER COLUMN "name" DROP NOT NULL;` | +| `DROP TYPE` / `DROP SCHEMA` / `DROP INDEX` | `DROP TYPE "role";` | +| `TRUNCATE` | `TRUNCATE "audit_log";` | +| `DELETE FROM` without `WHERE` | `DELETE FROM "sessions";` (also inside `DO $$ ... $$` blocks) | + +Prose mentions in comments are never flagged. + +```bash +# Framework repo (scans ./src/db/migrations, ./api/src/db/migrations, ./drizzle, +# or $NERVA_API_DIR/src/db/migrations — first one that exists) +pnpm check:destructive +node scripts/check-destructive-migrations.js --dir path/to/migrations --json + +# Generated project (from api/) +pnpm db:check-destructive +``` + +Flags: `--dir `, `--json` (machine-readable report), `--warn-only` (report but exit 0), `--help`. + +### Blocking vs. warn-only + +Inside the framework repo the guard reads `database.blockDestructiveMigrations` from `.claude/pipeline.config.json` (default `true`). `scripts/generate-migration.sh` runs the guard right after `drizzle-kit generate`; when blocking is on and findings exist, the migration is **not applied** and the script exits 1. Set the key to `false` to downgrade findings to warnings. The standalone copy in a generated project has no config and always blocks unless `--warn-only` is passed. + +### Allowlisting an intended destructive migration + +A reviewed destructive change opts out with a header comment line in the migration file. The reason is mandatory — a bare marker is itself reported as a finding. + +```sql +-- nerva:allow-destructive: sessions moved to Redis in #42; table has been empty since v1.3 +DROP TABLE "sessions"; +``` + +Allowlisted files are reported as skipped with their reason, so the exception stays visible in CI output and code review. + +## Schema drift check + +`schema-drift.ts` introspects `information_schema` / `pg_catalog` and asserts that every `pgTable`, column, `NOT NULL` constraint and `pgEnum` value declared in `src/db/schema.ts` exists in the target database. The check is asymmetric on purpose: extra tables or columns in the database are fine (in-flight deprecations); anything *missing* is drift, because code that depends on it will fail at runtime. Column types, indexes and foreign keys are not compared. + +The target comes from **`PROD_DATABASE_URL` only**. `DATABASE_URL` is never used as a fallback, so a local database can never be mistaken for production. The connection string is printed with the password masked. A read-only role is sufficient. + +```bash +cd api +PROD_DATABASE_URL=postgres://... pnpm db:check-drift # lenient +PROD_DATABASE_URL=postgres://... pnpm db:check-drift:strict # strict +``` + +### Modes + +| Mode | Trigger | Behavior | +|------|---------|----------| +| **Lenient** (default) | `pnpm db:check-drift` | Drift that a committed file in `src/db/migrations/*.sql` would fix (a `CREATE TABLE`, `ADD COLUMN`, `SET NOT NULL`, `CREATE TYPE` or `ADD VALUE` for the missing object) is reported as *pending* and tolerated. This is the PR gate: the migration ships in the same PR. | +| **Strict** | `--strict` or `SCHEMA_DRIFT_STRICT=1` | A covering migration does not excuse drift. If production is missing it, the migration merged but was never applied — the check fails. This is the post-deploy / scheduled gate. | + +To cover additional schema modules (for example `src/tenancy/schema.ts` in a `--multi-tenant` project), add them to the `SCHEMA_MODULES` list at the top of `schema-drift.ts`. + +## Workflows + +Generated projects get two workflows in `.github/workflows/`: + +- **`schema-drift.yml`** — runs on pull requests that touch `api/src/db/**`. Step 1 runs the destructive guard (no secrets needed). Step 2 runs the lenient drift check against production. +- **`schema-applied.yml`** — runs daily at 13:00 UTC and on `workflow_dispatch`, in strict mode. On failure it opens (or comments on) a single issue labeled `schema-drift`; on success it comments on and closes any open `schema-drift` issue, so the alert self-heals once production is reconciled. It is deliberately not a push gate: production legitimately lags a merged schema PR until the deploy applies the migration. + +### The `PROD_DATABASE_URL` secret + +Add `PROD_DATABASE_URL` under **Settings → Secrets and variables → Actions** with a read-only connection string to the production database. Until it is set, both workflows print a `::notice::` explaining how to configure it and exit green — the destructive guard still runs, only the database-backed steps are skipped. + +## Exit codes + +| Code | `check-destructive-migrations` | `schema-drift.ts` | +|------|-------------------------------|-------------------| +| `0` | No findings, or findings with `--warn-only` / blocking disabled | No unresolved drift (lenient: pending migrations tolerated) | +| `1` | Destructive findings while blocking | Unresolved drift | +| `2` | Usage error or migrations directory missing/unreadable | `PROD_DATABASE_URL` unset, or the database is unreachable | + +`scripts/generate-migration.sh` exits 1 when the guard blocks the freshly generated migration and prints the allowlist instructions. diff --git a/scripts/__tests__/check-destructive-migrations.test.js b/scripts/__tests__/check-destructive-migrations.test.js new file mode 100644 index 0000000..2fc2b1e --- /dev/null +++ b/scripts/__tests__/check-destructive-migrations.test.js @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { realpathSync } from "fs"; +import { runNode, tmpProject, SCRIPTS_DIR } from "./helpers.js"; +import { + stripSqlComments, + scanSql, + parseAllowlistReason, + hasAllowlistMarker, + splitStatements, + DELETE_WITHOUT_WHERE_LABEL, + INVALID_ALLOWLIST_LABEL, +} from "../check-destructive-migrations.js"; + +const SCRIPT = join(SCRIPTS_DIR, "check-destructive-migrations.js"); + +/** Run the CLI with a pipeline config that pins blockDestructiveMigrations. */ +function runCheck(project, args, { block = true } = {}) { + const configPath = project.write( + `pipeline.${block}.json`, + JSON.stringify({ database: { blockDestructiveMigrations: block } }), + ); + return runNode(SCRIPT, args, { + cwd: project.dir, + env: { NERVA_PIPELINE_CONFIG: configPath }, + }); +} + +describe("stripSqlComments", () => { + it("removes line and block comments but keeps string literals", () => { + const sql = [ + "-- DROP TABLE prose", + "INSERT INTO t VALUES ('-- not a comment', '/* nor this */'); /* DROP COLUMN x */", + "SELECT 1;", + ].join("\n"); + const out = stripSqlComments(sql); + expect(out).not.toContain("DROP TABLE"); + expect(out).not.toContain("DROP COLUMN"); + expect(out).toContain("'-- not a comment'"); + expect(out).toContain("'/* nor this */'"); + expect(out).toContain("SELECT 1;"); + }); + + it("preserves line numbers across multi-line block comments", () => { + const sql = "/* a\nb\nc */\nSELECT 1;"; + expect(stripSqlComments(sql).split("\n").length).toBe(sql.split("\n").length); + }); + + it("keeps dollar-quoted bodies intact", () => { + const sql = "DO $$ BEGIN -- keep\nEND $$;"; + expect(stripSqlComments(sql)).toContain("-- keep"); + }); +}); + +describe("splitStatements", () => { + it("splits on semicolons outside of string literals and descends into DO bodies", () => { + const stmts = splitStatements("SELECT 'a;b'; DO $$ BEGIN DELETE FROM c; END $$; SELECT 2"); + const texts = stmts.map((s) => s.text.trim()); + expect(texts).toContain("SELECT 'a;b'"); + expect(texts).toContain("SELECT 2"); + expect(texts.some((t) => /DELETE FROM c/.test(t))).toBe(true); + }); +}); + +describe("scanSql", () => { + const cases = [ + ["DROP TABLE", 'DROP TABLE "users";'], + ["DROP TABLE", 'DROP TABLE IF EXISTS "users" CASCADE;'], + ["DROP COLUMN", 'ALTER TABLE "users" DROP COLUMN "email";'], + ["DROP CONSTRAINT", 'ALTER TABLE "users" DROP CONSTRAINT "users_email_unique";'], + ["DROP NOT NULL", 'ALTER TABLE "users" ALTER COLUMN "name" DROP NOT NULL;'], + ["DROP TYPE", 'DROP TYPE "role";'], + ["DROP SCHEMA", 'DROP SCHEMA "tenant_acme" CASCADE;'], + ["DROP INDEX", 'DROP INDEX "users_email_idx";'], + ["TRUNCATE", 'TRUNCATE TABLE "sessions";'], + [DELETE_WITHOUT_WHERE_LABEL, 'DELETE FROM "sessions";'], + ]; + + it.each(cases)("flags %s", (label, sql) => { + const findings = scanSql("x.sql", sql); + expect(findings.map((f) => f.label)).toContain(label); + expect(findings[0].file).toBe("x.sql"); + expect(findings[0].line).toBe(1); + }); + + it("flags every destructive statement, case-insensitively, with line numbers", () => { + const sql = 'CREATE TABLE "a" ("id" serial);\ndrop table "b";\nALTER TABLE "c" drop column "x";'; + const findings = scanSql("multi.sql", sql); + expect(findings.map((f) => [f.label, f.line])).toEqual([ + ["DROP TABLE", 2], + ["DROP COLUMN", 3], + ]); + }); + + it("does not flag destructive keywords that appear only in comments", () => { + const sql = [ + "-- DROP TABLE legacy", + "/* ALTER TABLE x DROP COLUMN y;", + " TRUNCATE z; DELETE FROM q; */", + 'ALTER TABLE "users" ADD COLUMN "nickname" text;', + ].join("\n"); + expect(scanSql("comments.sql", sql)).toEqual([]); + }); + + it("does not flag DELETE FROM with a WHERE clause", () => { + expect(scanSql("d.sql", "DELETE FROM sessions WHERE expires_at < now();")).toEqual([]); + }); + + it("flags DELETE FROM without WHERE inside a DO block", () => { + const findings = scanSql("do.sql", "DO $$ BEGIN\n DELETE FROM sessions;\nEND $$;"); + expect(findings.map((f) => f.label)).toEqual([DELETE_WITHOUT_WHERE_LABEL]); + expect(findings[0].line).toBe(2); + }); + + it("is clean for additive migrations", () => { + const sql = [ + 'CREATE TYPE "public"."role" AS ENUM(\'admin\', \'user\');', + 'CREATE TABLE "users" ("id" uuid PRIMARY KEY, "role" "role" NOT NULL);', + 'ALTER TABLE "users" ADD COLUMN "nickname" text;', + 'CREATE INDEX "users_role_idx" ON "users" ("role");', + 'ALTER TABLE "users" ALTER COLUMN "nickname" SET NOT NULL;', + ].join("\n"); + expect(scanSql("clean.sql", sql)).toEqual([]); + }); +}); + +describe("parseAllowlistReason", () => { + it("returns the reason from a header marker", () => { + const sql = "-- nerva:allow-destructive: drops the legacy sessions table (moved to Redis)\nDROP TABLE sessions;"; + expect(parseAllowlistReason(sql)).toBe("drops the legacy sessions table (moved to Redis)"); + expect(hasAllowlistMarker(sql)).toBe(true); + }); + + it("tolerates spacing variants", () => { + expect(parseAllowlistReason("--nerva:allow-destructive:reason here\n")).toBe("reason here"); + expect(parseAllowlistReason(" -- nerva:allow-destructive: spaced \n")).toBe("spaced"); + }); + + it("rejects a marker without a reason", () => { + expect(parseAllowlistReason("-- nerva:allow-destructive:\nDROP TABLE x;")).toBeNull(); + expect(parseAllowlistReason("-- nerva:allow-destructive: \nDROP TABLE x;")).toBeNull(); + expect(hasAllowlistMarker("-- nerva:allow-destructive:\n")).toBe(true); + }); + + it("returns null when no marker is present", () => { + expect(parseAllowlistReason("DROP TABLE x;")).toBeNull(); + expect(hasAllowlistMarker("DROP TABLE x;")).toBe(false); + }); + + it("ignores the marker outside a comment line", () => { + expect(parseAllowlistReason("INSERT INTO t VALUES ('nerva:allow-destructive: nope');")).toBeNull(); + }); +}); + +describe("CLI", () => { + let project; + + beforeAll(() => { + project = tmpProject("nerva-destructive-"); + project.write("clean/0000_init.sql", 'CREATE TABLE "users" ("id" serial PRIMARY KEY);\n'); + project.write( + "clean/0001_add.sql", + '-- DROP TABLE mentioned in prose only\nALTER TABLE "users" ADD COLUMN "email" text;\n', + ); + + project.write("bad/0000_init.sql", 'CREATE TABLE "users" ("id" serial PRIMARY KEY);\n'); + project.write("bad/0001_drop.sql", 'ALTER TABLE "users" DROP COLUMN "email";\nTRUNCATE "audit";\n'); + + project.write( + "allowed/0000_drop.sql", + '-- nerva:allow-destructive: legacy table replaced by KV cache in #42\nDROP TABLE "legacy";\n', + ); + + project.write("noreason/0000_drop.sql", '-- nerva:allow-destructive:\nDROP TABLE "legacy";\n'); + + project.write("empty/.keep", ""); + }); + + afterAll(() => project.cleanup()); + + it("exits 0 on a clean directory and reports the scan", () => { + const r = runCheck(project, ["--dir", "clean"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Scanned 2 of 2 migration file(s)"); + expect(r.stdout).toContain("No destructive DDL found"); + }); + + it("exits 0 on a directory with no .sql files", () => { + const r = runCheck(project, ["--dir", "empty"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Scanned 0 of 0"); + }); + + it("exits 1 with findings and allowlist instructions when blocking", () => { + const r = runCheck(project, ["--dir", "bad"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("0001_drop.sql:1 DROP COLUMN"); + expect(r.stderr).toContain("0001_drop.sql:2 TRUNCATE"); + expect(r.stderr).toContain("-- nerva:allow-destructive:"); + }); + + it("exits 0 with findings when --warn-only", () => { + const r = runCheck(project, ["--dir", "bad", "--warn-only"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("DROP COLUMN"); + expect(r.stdout).toContain("Not blocking (warn-only)"); + }); + + it("exits 0 with findings when the pipeline config disables blocking", () => { + const r = runCheck(project, ["--dir", "bad"], { block: false }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("DROP COLUMN"); + }); + + it("skips allowlisted files and reports the reason", () => { + const r = runCheck(project, ["--dir", "allowed"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("1 allowlisted, skipped"); + expect(r.stdout).toContain("0000_drop.sql: allowlisted — legacy table replaced by KV cache in #42"); + }); + + it("rejects an allowlist marker without a reason", () => { + const r = runCheck(project, ["--dir", "noreason"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain(INVALID_ALLOWLIST_LABEL); + expect(r.stderr).toContain("DROP TABLE"); + }); + + it("emits a JSON report with --json", () => { + const r = runCheck(project, ["--dir", "bad", "--json"]); + expect(r.exitCode).toBe(1); + const report = JSON.parse(r.stdout); + expect(report).toMatchObject({ + ok: false, + blocking: true, + scanned: 2, + files: ["0000_init.sql", "0001_drop.sql"], + skipped: [], + }); + // macOS tmpdir is a symlink (/var -> /private/var); the child resolves it. + expect(report.dir).toBe(realpathSync(join(project.dir, "bad"))); + expect(report.findings).toHaveLength(2); + expect(report.findings[0]).toEqual({ + file: "0001_drop.sql", + label: "DROP COLUMN", + line: 1, + excerpt: expect.stringContaining("DROP COLUMN"), + }); + }); + + it("JSON report lists allowlisted files under skipped", () => { + const r = runCheck(project, ["--dir", "allowed", "--json"]); + expect(r.exitCode).toBe(0); + const report = JSON.parse(r.stdout); + expect(report.ok).toBe(true); + expect(report.skipped).toEqual([ + { file: "0000_drop.sql", reason: "legacy table replaced by KV cache in #42" }, + ]); + }); + + it("exits 2 when the migrations directory is missing", () => { + const r = runCheck(project, ["--dir", "does-not-exist"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("migrations directory not found"); + }); + + it("exits 2 when no default directory can be resolved", () => { + const r = runNode(SCRIPT, [], { cwd: join(project.dir, "empty"), env: { NERVA_API_DIR: "" } }); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("no migrations directory found"); + }); + + it("resolves the default directory from NERVA_API_DIR", () => { + project.write("apiroot/src/db/migrations/0000_init.sql", 'CREATE TABLE "t" ("id" serial);\n'); + const r = runNode(SCRIPT, [], { + cwd: join(project.dir, "empty"), + env: { NERVA_API_DIR: join(project.dir, "apiroot") }, + }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Scanned 1 of 1"); + }); + + it("exits 2 on an unknown option and prints usage", () => { + const r = runCheck(project, ["--bogus"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown option: --bogus"); + expect(r.stderr).toContain("Usage:"); + }); + + it("prints help with --help", () => { + const r = runNode(SCRIPT, ["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + expect(r.stdout).toContain("nerva:allow-destructive:"); + }); +}); diff --git a/scripts/__tests__/generate-migration.test.js b/scripts/__tests__/generate-migration.test.js new file mode 100644 index 0000000..8553a09 --- /dev/null +++ b/scripts/__tests__/generate-migration.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { run, runBash, tmpProject, SCRIPTS_DIR } from "./helpers.js"; + +const SCRIPT = join(SCRIPTS_DIR, "generate-migration.sh"); + +describe("generate-migration.sh", () => { + let project; + + beforeAll(() => { + project = tmpProject("nerva-genmig-"); + }); + + afterAll(() => project.cleanup()); + + it("passes bash -n syntax check", () => { + const r = run("bash", ["-n", SCRIPT]); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + }); + + it("prints usage and exits 0 with --help", () => { + const r = runBash(SCRIPT, ["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + expect(r.stdout).toContain("--apply"); + expect(r.stdout).toContain("nerva:allow-destructive:"); + }); + + it("exits 1 with a clear message when the API directory is missing", () => { + const missing = join(project.dir, "no-such-api"); + const r = runBash(SCRIPT, [], { env: { NERVA_API_DIR: missing } }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("[ERROR] API directory not found at:"); + expect(r.stderr).toContain(missing); + }); + + it("exits 1 when drizzle.config.ts is missing from the API directory", () => { + project.write("api-no-drizzle/.keep", ""); + const r = runBash(SCRIPT, ["add_users"], { + env: { NERVA_API_DIR: join(project.dir, "api-no-drizzle") }, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("drizzle.config.ts not found"); + }); + + it("exits 1 when dependencies are not installed", () => { + project.write("api-no-deps/drizzle.config.ts", "export default {};\n"); + const r = runBash(SCRIPT, ["add_users"], { + env: { NERVA_API_DIR: join(project.dir, "api-no-deps") }, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Dependencies not installed"); + }); + + it("rejects unknown options with exit 1 and usage", () => { + const r = runBash(SCRIPT, ["--bogus"], { env: { NERVA_API_DIR: project.dir } }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Unknown option: --bogus"); + expect(r.stderr).toContain("Usage:"); + }); +}); diff --git a/scripts/check-destructive-migrations.js b/scripts/check-destructive-migrations.js new file mode 100644 index 0000000..2faf16c --- /dev/null +++ b/scripts/check-destructive-migrations.js @@ -0,0 +1,537 @@ +#!/usr/bin/env node +/** + * check-destructive-migrations.js — Fail (or warn) when a Drizzle migration + * contains destructive DDL. + * + * Why + * --- + * Nerva migrations are additive by policy. A DROP/TRUNCATE in a committed + * *.sql file usually means `drizzle-kit generate` ran against a database that + * had drifted from the schema and emitted a "reconcile" plan — the kind of + * migration that deletes production data when it is applied. This guard is a + * pure static scan (no database needed) so it can run in CI, in a pre-commit + * hook, and from scripts/generate-migration.sh right after a migration is + * generated. + * + * What is flagged (after stripping -- and /* *\/ comments): + * DROP TABLE, DROP COLUMN, DROP CONSTRAINT, DROP NOT NULL, DROP TYPE, + * DROP SCHEMA, DROP INDEX, TRUNCATE, and DELETE FROM without a WHERE clause. + * + * Opting out + * ---------- + * A reviewed, intentional destructive migration can allowlist itself with a + * header comment (the reason is mandatory and shows up in the report): + * + * -- nerva:allow-destructive: drops the legacy sessions table (moved to Redis in #42) + * + * Usage + * ----- + * node scripts/check-destructive-migrations.js [--dir ] [--json] [--warn-only] + * + * --dir Migrations directory. Default: the first existing of + * ./src/db/migrations, ./api/src/db/migrations, ./drizzle, + * $NERVA_API_DIR/src/db/migrations. + * --json Machine-readable report on stdout. + * --warn-only Report findings but exit 0. + * --help Show this help. + * + * Blocking mode is read from database.blockDestructiveMigrations in + * .claude/pipeline.config.json when this script runs inside the Nerva + * framework repo (default true). When copied standalone into a generated + * project (api/scripts/check-destructive-migrations.mjs) it has no config and + * blocks by default; use --warn-only to soften it. + * + * Exit codes: 0 clean (or warn-only), 1 destructive findings, 2 usage/IO error. + * + * The pure helpers (stripSqlComments, scanSql, parseAllowlistReason, ...) are + * exported for tests; main() only runs when the file is invoked directly. + */ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// Patterns +// --------------------------------------------------------------------------- + +/** Destructive tokens, matched against comment-stripped SQL. */ +export const DESTRUCTIVE_PATTERNS = Object.freeze([ + { label: "DROP TABLE", re: /\bDROP\s+TABLE\b/gi }, + { label: "DROP COLUMN", re: /\bDROP\s+COLUMN\b/gi }, + { label: "DROP CONSTRAINT", re: /\bDROP\s+CONSTRAINT\b/gi }, + { label: "DROP NOT NULL", re: /\bDROP\s+NOT\s+NULL\b/gi }, + { label: "DROP TYPE", re: /\bDROP\s+TYPE\b/gi }, + { label: "DROP SCHEMA", re: /\bDROP\s+SCHEMA\b/gi }, + { label: "DROP INDEX", re: /\bDROP\s+INDEX\b/gi }, + { label: "TRUNCATE", re: /\bTRUNCATE\b/gi }, +]); + +export const DELETE_WITHOUT_WHERE_LABEL = "DELETE FROM without WHERE"; +export const INVALID_ALLOWLIST_LABEL = "allow-destructive marker without a reason"; + +export const ALLOWLIST_MARKER = "nerva:allow-destructive:"; +const ALLOWLIST_LINE_RE = /^[ \t]*--[ \t]*nerva:allow-destructive:(.*)$/im; + +export const DEFAULT_DIR_CANDIDATES = Object.freeze([ + "src/db/migrations", + "api/src/db/migrations", + "drizzle", +]); + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +/** + * Remove SQL comments while preserving string literals and line structure. + * + * - `-- ...` line comments and `/* ... *\/` block comments (nesting allowed) + * are replaced by whitespace; newlines inside block comments are kept so + * line numbers in findings stay accurate. + * - Single-quoted strings ('' escapes a quote) and dollar-quoted strings + * ($$ ... $$ or $tag$ ... $tag$) are copied verbatim, so a `--` inside a + * literal never starts a comment. + */ +export function stripSqlComments(sql) { + let out = ""; + let i = 0; + const n = sql.length; + while (i < n) { + const ch = sql[i]; + const next = sql[i + 1]; + + if (ch === "'") { + out += ch; + i++; + while (i < n) { + out += sql[i]; + if (sql[i] === "'") { + if (sql[i + 1] === "'") { + out += sql[i + 1]; + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + if (ch === "$") { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); + if (m) { + const tag = m[0]; + const end = sql.indexOf(tag, i + tag.length); + const stop = end === -1 ? n : end + tag.length; + out += sql.slice(i, stop); + i = stop; + continue; + } + } + + if (ch === "-" && next === "-") { + let j = i + 2; + while (j < n && sql[j] !== "\n") j++; + out += " "; + i = j; + continue; + } + + if (ch === "/" && next === "*") { + let depth = 1; + let j = i + 2; + let newlines = ""; + while (j < n && depth > 0) { + if (sql[j] === "/" && sql[j + 1] === "*") { + depth++; + j += 2; + } else if (sql[j] === "*" && sql[j + 1] === "/") { + depth--; + j += 2; + } else { + if (sql[j] === "\n") newlines += "\n"; + j++; + } + } + out += " " + newlines; + i = j; + continue; + } + + out += ch; + i++; + } + return out; +} + +/** + * Split (comment-stripped) SQL into top-level statements on `;`, honoring + * single-quoted and dollar-quoted strings. Returns { text, offset } pairs so + * callers can map findings back to line numbers. Dollar-quoted bodies (DO + * blocks, function bodies) are also returned as nested statements so a + * DELETE hidden inside `DO $$ ... $$` is still inspected. + */ +export function splitStatements(sql) { + const statements = []; + const nestedBodies = []; + let start = 0; + let i = 0; + const n = sql.length; + while (i < n) { + const ch = sql[i]; + if (ch === "'") { + i++; + while (i < n) { + if (sql[i] === "'") { + if (sql[i + 1] === "'") { + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + if (ch === "$") { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); + if (m) { + const tag = m[0]; + const end = sql.indexOf(tag, i + tag.length); + const bodyStart = i + tag.length; + const bodyEnd = end === -1 ? n : end; + nestedBodies.push({ text: sql.slice(bodyStart, bodyEnd), offset: bodyStart }); + i = end === -1 ? n : end + tag.length; + continue; + } + } + if (ch === ";") { + statements.push({ text: sql.slice(start, i), offset: start }); + start = i + 1; + } + i++; + } + if (start < n) statements.push({ text: sql.slice(start), offset: start }); + + for (const body of nestedBodies) { + for (const nested of splitStatements(body.text)) { + statements.push({ text: nested.text, offset: body.offset + nested.offset }); + } + } + return statements; +} + +function lineOf(text, index) { + let line = 1; + for (let i = 0; i < index && i < text.length; i++) { + if (text[i] === "\n") line++; + } + return line; +} + +function excerptAround(text, index, before = 30, after = 50) { + const start = Math.max(0, index - before); + return text + .slice(start, index + after) + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Scan one migration's SQL text. Returns findings of the shape + * { file, label, line, excerpt }. Comments are stripped first so prose that + * mentions "DROP TABLE" never trips the guard. + */ +export function scanSql(file, sqlText) { + const stripped = stripSqlComments(sqlText); + const findings = []; + + for (const { label, re } of DESTRUCTIVE_PATTERNS) { + const regex = new RegExp(re.source, re.flags); + let m; + while ((m = regex.exec(stripped)) !== null) { + findings.push({ + file, + label, + line: lineOf(stripped, m.index), + excerpt: excerptAround(stripped, m.index), + }); + } + } + + for (const stmt of splitStatements(stripped)) { + // Statements inside DO/function bodies may start with BEGIN. + const m = /^\s*(?:BEGIN\s+)?(DELETE\s+FROM)\b/i.exec(stmt.text); + if (!m) continue; + if (/\bWHERE\b/i.test(stmt.text)) continue; + const index = stmt.offset + m.index + m[0].length - m[1].length; + findings.push({ + file, + label: DELETE_WITHOUT_WHERE_LABEL, + line: lineOf(stripped, index), + excerpt: stmt.text.replace(/\s+/g, " ").trim().slice(0, 80), + }); + } + + findings.sort((a, b) => a.line - b.line || a.label.localeCompare(b.label)); + return findings; +} + +/** True when the file carries a `-- nerva:allow-destructive:` line at all. */ +export function hasAllowlistMarker(sqlText) { + return ALLOWLIST_LINE_RE.test(sqlText); +} + +/** + * Return the allowlist reason from a `-- nerva:allow-destructive: ` + * comment line, or null when the marker is absent or its reason is empty. + */ +export function parseAllowlistReason(sqlText) { + const m = ALLOWLIST_LINE_RE.exec(sqlText); + if (!m) return null; + const reason = (m[1] ?? "").trim(); + return reason.length > 0 ? reason : null; +} + +export function listMigrationFiles(dir) { + return readdirSync(dir) + .filter((f) => f.toLowerCase().endsWith(".sql")) + .sort(); +} + +/** + * Scan a directory of migrations. Returns a report object: + * { dir, files, scanned, skipped: [{file, reason}], findings: [...] } + */ +export function scanDirectory(dir) { + const files = listMigrationFiles(dir); + const skipped = []; + const findings = []; + + for (const file of files) { + const text = readFileSync(join(dir, file), "utf8"); + const reason = parseAllowlistReason(text); + if (reason !== null) { + skipped.push({ file, reason }); + continue; + } + if (hasAllowlistMarker(text)) { + findings.push({ + file, + label: INVALID_ALLOWLIST_LABEL, + line: lineOf(text, text.search(ALLOWLIST_LINE_RE)), + excerpt: `-- ${ALLOWLIST_MARKER} `, + }); + } + findings.push(...scanSql(file, text)); + } + + return { dir, files, scanned: files.length - skipped.length, skipped, findings }; +} + +// --------------------------------------------------------------------------- +// Directory + config resolution +// --------------------------------------------------------------------------- + +export function resolveMigrationsDir(explicitDir, cwd = process.cwd(), env = process.env) { + if (explicitDir) return resolve(cwd, explicitDir); + const candidates = DEFAULT_DIR_CANDIDATES.map((c) => resolve(cwd, c)); + if (env.NERVA_API_DIR) candidates.push(resolve(env.NERVA_API_DIR, "src/db/migrations")); + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isDirectory()) return candidate; + } + return null; +} + +/** + * Read database.blockDestructiveMigrations from pipeline.config.json via + * scripts/lib/pipeline-config.js when that helper sits next to this script. + * Standalone copies (generated projects) have no helper and default to true. + */ +export async function readBlockingFromConfig(defaultValue = true) { + const helperUrl = new URL("./lib/pipeline-config.js", import.meta.url); + let helperPath; + try { + helperPath = fileURLToPath(helperUrl); + } catch { + return defaultValue; + } + if (!existsSync(helperPath)) return defaultValue; + try { + const mod = await import(helperUrl.href); + if (typeof mod.getValue !== "function") return defaultValue; + const value = mod.getValue("database.blockDestructiveMigrations", defaultValue); + if (typeof value === "boolean") return value; + if (value === "false") return false; + if (value === "true") return true; + return defaultValue; + } catch { + return defaultValue; + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const USAGE = `Usage: node check-destructive-migrations.js [--dir ] [--json] [--warn-only] + +Scans *.sql migration files for destructive DDL (DROP TABLE/COLUMN/CONSTRAINT/ +NOT NULL/TYPE/SCHEMA/INDEX, TRUNCATE, DELETE FROM without WHERE). + +Options: + --dir Migrations directory (default: first existing of + ./src/db/migrations, ./api/src/db/migrations, ./drizzle, + $NERVA_API_DIR/src/db/migrations) + --json Print a JSON report to stdout + --warn-only Report findings but exit 0 + --help, -h Show this help + +Allowlist a reviewed destructive migration by adding a header comment line: + -- ${ALLOWLIST_MARKER} + +Exit codes: 0 clean or warn-only, 1 destructive findings, 2 usage/IO error.`; + +export function parseArgs(argv) { + const opts = { dir: null, json: false, warnOnly: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--dir") { + const value = argv[i + 1]; + if (!value || value.startsWith("--")) throw new Error("--dir requires a path"); + opts.dir = value; + i++; + } else if (arg.startsWith("--dir=")) { + opts.dir = arg.slice("--dir=".length); + if (!opts.dir) throw new Error("--dir requires a path"); + } else if (arg === "--json") { + opts.json = true; + } else if (arg === "--warn-only") { + opts.warnOnly = true; + } else if (arg === "--help" || arg === "-h") { + opts.help = true; + } else { + throw new Error(`Unknown option: ${arg}`); + } + } + return opts; +} + +function printTextReport(report, blocking) { + const { dir, files, skipped, findings } = report; + console.log( + `Scanned ${report.scanned} of ${files.length} migration file(s) in ${dir} (${skipped.length} allowlisted, skipped).`, + ); + for (const s of skipped) { + console.log(` ⊘ ${s.file}: allowlisted — ${s.reason}`); + } + + if (findings.length === 0) { + console.log(" ✓ No destructive DDL found in migrations."); + return; + } + + const stream = blocking ? console.error : console.log; + stream(""); + stream(` ✗ Destructive DDL found in ${new Set(findings.map((f) => f.file)).size} migration file(s):`); + stream(""); + for (const f of findings) { + stream(` ${f.file}:${f.line} ${f.label} …${f.excerpt}…`); + } + stream(""); + stream( + [ + "Migrations are expected to be additive. A DROP/TRUNCATE usually means", + "`drizzle-kit generate` ran against a drifted database and emitted a", + "reconcile plan; do not apply it. If the destructive change is intended and", + "reviewed, allowlist the file by adding a header comment line:", + "", + ` -- ${ALLOWLIST_MARKER} `, + "", + blocking + ? "Blocking (database.blockDestructiveMigrations=true); use --warn-only to report without failing." + : "Not blocking (warn-only); findings reported for review.", + ].join("\n"), + ); +} + +export async function main(argv = process.argv.slice(2)) { + let opts; + try { + opts = parseArgs(argv); + } catch (err) { + console.error(`check-destructive-migrations: ${err.message}`); + console.error(USAGE); + return 2; + } + if (opts.help) { + console.log(USAGE); + return 0; + } + + const dir = resolveMigrationsDir(opts.dir); + if (!dir) { + console.error( + "check-destructive-migrations: no migrations directory found. Pass --dir or run from a project containing " + + DEFAULT_DIR_CANDIDATES.join(", ") + + ".", + ); + return 2; + } + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + console.error(`check-destructive-migrations: migrations directory not found: ${dir}`); + return 2; + } + + let report; + try { + report = scanDirectory(dir); + } catch (err) { + console.error(`check-destructive-migrations: failed to read ${dir}: ${err.message}`); + return 2; + } + + const blocking = opts.warnOnly ? false : await readBlockingFromConfig(true); + const ok = report.findings.length === 0; + + if (opts.json) { + console.log( + JSON.stringify( + { + ok, + blocking, + dir: report.dir, + scanned: report.scanned, + files: report.files, + skipped: report.skipped, + findings: report.findings, + }, + null, + 2, + ), + ); + } else { + printTextReport(report, blocking); + } + + if (ok) return 0; + return blocking ? 1 : 0; +} + +const invokedDirectly = + typeof process.argv[1] === "string" && + (resolve(process.argv[1]) === fileURLToPath(import.meta.url) || + basename(process.argv[1]) === basename(fileURLToPath(import.meta.url))); + +if (invokedDirectly) { + main().then( + (code) => process.exit(code), + (err) => { + console.error(`check-destructive-migrations: unexpected error: ${err?.stack ?? err}`); + process.exit(2); + }, + ); +} diff --git a/scripts/generate-migration.sh b/scripts/generate-migration.sh index a1640de..b12d507 100644 --- a/scripts/generate-migration.sh +++ b/scripts/generate-migration.sh @@ -3,36 +3,54 @@ set -euo pipefail # ============================================================================ # generate-migration.sh - Generate a Drizzle ORM migration -# Usage: ./scripts/generate-migration.sh [migration-name] +# Usage: ./scripts/generate-migration.sh [migration-name] [--apply] +# +# After drizzle-kit generate succeeds, the new migrations are scanned for +# destructive DDL (scripts/check-destructive-migrations.js). When +# database.blockDestructiveMigrations is true in .claude/pipeline.config.json +# (the default), findings block the apply step; allowlist a reviewed +# migration with a header comment: -- nerva:allow-destructive: # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +usage() { + cat < +EOF +} -MIGRATION_NAME="${1:-}" +MIGRATION_NAME="" AUTO_APPLY=false -shift 2>/dev/null || true +if [[ $# -gt 0 && "$1" != --* ]]; then + MIGRATION_NAME="$1" + shift +fi + while [[ $# -gt 0 ]]; do case "$1" in - --apply) AUTO_APPLY=true; shift ;; - *) error "Unknown option: $1"; exit 1 ;; + --apply) AUTO_APPLY=true; shift ;; + --help|-h) usage; exit 0 ;; + *) error "Unknown option: $1"; usage >&2; exit 1 ;; esac done +API_DIR="$(common_api_dir)" + if [[ ! -d "$API_DIR" ]]; then error "API directory not found at: $API_DIR" exit 1 @@ -83,6 +101,49 @@ if [[ -d "$MIGRATIONS_DIR" ]]; then fi fi +# --- Destructive DDL guard --------------------------------------------------- + +DESTRUCTIVE_CHECK="$SCRIPT_DIR/check-destructive-migrations.js" +if [[ -d "$MIGRATIONS_DIR" && -f "$DESTRUCTIVE_CHECK" ]] && have_cmd node; then + step "Checking migrations for destructive DDL..." + BLOCK_DESTRUCTIVE="$(common_config_get 'database.blockDestructiveMigrations' true)" + CHECK_ARGS=("--dir" "$MIGRATIONS_DIR") + if [[ "$BLOCK_DESTRUCTIVE" != "true" ]]; then + CHECK_ARGS+=("--warn-only") + fi + + set +e + node "$DESTRUCTIVE_CHECK" "${CHECK_ARGS[@]}" + CHECK_STATUS=$? + set -e + + case "$CHECK_STATUS" in + 0) + success "Destructive DDL check passed." + ;; + 1) + error "Destructive DDL found in migrations; the migration will NOT be applied." + echo "" >&2 + echo " If this destructive change is intended and has been reviewed, add a header" >&2 + echo " comment line to the migration file and re-run:" >&2 + echo "" >&2 + echo " -- nerva:allow-destructive: " >&2 + echo "" >&2 + echo " To report without blocking, set database.blockDestructiveMigrations to" >&2 + echo " false in .claude/pipeline.config.json." >&2 + exit 1 + ;; + *) + warn "Destructive DDL check could not run (exit $CHECK_STATUS); continuing." + ;; + esac + echo "" +else + warn "Skipping destructive DDL check (node or $DESTRUCTIVE_CHECK not available)." +fi + +# --- Apply -------------------------------------------------------------------- + if [[ "$AUTO_APPLY" == true ]]; then info "Auto-apply enabled. Applying migration..." elif [[ -t 0 ]]; then diff --git a/templates/shared/workflows/schema-applied.yml b/templates/shared/workflows/schema-applied.yml new file mode 100644 index 0000000..6778853 --- /dev/null +++ b/templates/shared/workflows/schema-applied.yml @@ -0,0 +1,127 @@ +name: Schema applied (strict) + +# The post-deploy counterpart to schema-drift.yml. +# +# schema-drift.yml is the PR gate and tolerates drift that a committed +# migration would resolve, because the migration ships in the same PR. That +# tolerance has a blind spot: a migration can merge and then never be applied +# to production, and the lenient check cannot tell that apart from "already +# applied". This workflow runs the same check in --strict mode, where a +# covering migration does NOT excuse drift: if production is missing anything +# declared in api/src/db/schema.ts, it fails. +# +# It runs daily and on demand (workflow_dispatch), not on push: right after a +# schema PR merges, production legitimately lags until the deploy applies the +# migration, so a per-push strict run would be red by design. +# +# On failure it opens (or comments on) a single tracking issue labeled +# `schema-drift`; on success it closes any open tracking issue, so the alert +# self-heals once production is reconciled. +# +# Requires the PROD_DATABASE_URL repository secret (read-only is enough). +# Without it the run prints a notice and exits green rather than failing. + +on: + schedule: + # 13:00 UTC daily, after typical deploy windows. + - cron: "0 13 * * *" + workflow_dispatch: {} + +permissions: + contents: read + issues: write + +jobs: + check: + name: Assert production has every declared schema object + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: api + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check PROD_DATABASE_URL secret + id: prod + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: | + if [ -z "$PROD_DATABASE_URL" ]; then + echo "::notice title=Strict schema check skipped::The PROD_DATABASE_URL secret is not set. Add a read-only production connection string under Settings > Secrets and variables > Actions to enable the daily strict drift gate." + echo "configured=false" >> "$GITHUB_OUTPUT" + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Install pnpm + if: steps.prod.outputs.configured == 'true' + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + if: steps.prod.outputs.configured == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: api/pnpm-lock.yaml + + - name: Install dependencies + if: steps.prod.outputs.configured == 'true' + run: pnpm install --frozen-lockfile + + - name: Run drift check (strict) + if: steps.prod.outputs.configured == 'true' + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: pnpm db:check-drift:strict + + - name: Open or update the drift alert issue + if: failure() && steps.prod.outputs.configured == 'true' + uses: actions/github-script@v7 + with: + script: | + const TITLE = "Production schema drift: strict gate failing"; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + "The scheduled **strict** schema check failed.", + "", + "Production is missing something declared in `api/src/db/schema.ts`. The most common cause is a migration that merged but was never applied to production.", + "", + `- Failing run: ${runUrl}`, + "- Verify locally: `PROD_DATABASE_URL=... pnpm db:check-drift:strict` (from `api/`)", + "- Fix: apply the pending migration to production (`pnpm db:migrate` with `DATABASE_URL` pointed at production), then re-run this workflow.", + "", + "This issue closes automatically when a later run passes.", + ].join("\n"); + const { owner, repo } = context.repo; + const open = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: "open", labels: "schema-drift", per_page: 100, + }); + if (open.length > 0) { + await github.rest.issues.createComment({ owner, repo, issue_number: open[0].number, body }); + } else { + await github.rest.issues.create({ owner, repo, title: TITLE, body, labels: ["schema-drift"] }); + } + + - name: Close the drift alert issue + if: success() && steps.prod.outputs.configured == 'true' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const open = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: "open", labels: "schema-drift", per_page: 100, + }); + for (const issue of open) { + await github.rest.issues.createComment({ + owner, repo, issue_number: issue.number, + body: `Strict schema check is green again; production matches the declared schema. Closing. (run ${runUrl})`, + }); + await github.rest.issues.update({ owner, repo, issue_number: issue.number, state: "closed" }); + } diff --git a/templates/shared/workflows/schema-drift.yml b/templates/shared/workflows/schema-drift.yml new file mode 100644 index 0000000..a4aa08e --- /dev/null +++ b/templates/shared/workflows/schema-drift.yml @@ -0,0 +1,83 @@ +name: Schema drift + +# PR gate for the database schema. +# +# 1. Destructive migration guard (no database needed): fails the PR if a +# committed migration in api/src/db/migrations contains DROP/TRUNCATE/etc. +# unless the file is allowlisted with `-- nerva:allow-destructive: `. +# +# 2. Lenient drift check (needs PROD_DATABASE_URL): asserts every table, +# column, NOT NULL constraint, and enum declared in api/src/db/schema.ts +# exists in production, tolerating drift that a migration in this PR will +# resolve. This stops app code merging ahead of the schema change it needs. +# +# Configure the PROD_DATABASE_URL repository secret (Settings > Secrets and +# variables > Actions) with a READ-ONLY connection string to enable step 2. +# Without the secret the drift step is skipped with a notice, not a failure. +# See docs/api-development/migration-safety.md in the Nerva framework repo. + +on: + pull_request: + paths: + - "api/src/db/**" + - "api/scripts/check-destructive-migrations.mjs" + - ".github/workflows/schema-drift.yml" + +permissions: + contents: read + +concurrency: + group: schema-drift-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Migration guard + prod drift check + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: api + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: api/pnpm-lock.yaml + + # Pure static scan; the script has no dependencies, so it runs before + # (and independently of) pnpm install. + - name: Check migrations for destructive DDL + run: node scripts/check-destructive-migrations.mjs + + - name: Check PROD_DATABASE_URL secret + id: prod + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: | + if [ -z "$PROD_DATABASE_URL" ]; then + echo "::notice title=Schema drift check skipped::The PROD_DATABASE_URL secret is not set. Add a read-only production connection string under Settings > Secrets and variables > Actions to enable the drift gate." + echo "configured=false" >> "$GITHUB_OUTPUT" + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Install dependencies + if: steps.prod.outputs.configured == 'true' + run: pnpm install --frozen-lockfile + + - name: Run drift check (lenient) + if: steps.prod.outputs.configured == 'true' + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: pnpm db:check-drift diff --git a/templates/snippets/shared/src/db/schema-drift.ts b/templates/snippets/shared/src/db/schema-drift.ts new file mode 100644 index 0000000..85ef013 --- /dev/null +++ b/templates/snippets/shared/src/db/schema-drift.ts @@ -0,0 +1,531 @@ +/** + * schema-drift.ts — Assert that the production database has everything the + * Drizzle schema declares. + * + * Run with tsx (dev dependency of every generated project): + * + * PROD_DATABASE_URL=postgres://... pnpm db:check-drift # lenient + * PROD_DATABASE_URL=postgres://... pnpm db:check-drift:strict # strict + * + * The check is deliberately asymmetric: the database having *extra* tables or + * columns is fine (in-flight deprecations); the database *missing* anything + * declared in the schema is drift, because application code that depends on + * the missing object will fail at runtime. + * + * What is checked: + * - every declared pgTable exists + * - every declared column exists on its table + * - every column declared NOT NULL is NOT NULL in the database + * - every declared pgEnum exists with every declared value + * + * Not checked (on purpose): exact column types, indexes, foreign keys. + * + * Modes: + * lenient (default) — the PR gate. Drift that a committed migration file in + * src/db/migrations would resolve is tolerated, since the migration ships + * in the same PR and is applied on deploy. + * strict (--strict or SCHEMA_DRIFT_STRICT=1) — the post-deploy / scheduled + * gate. A committed-but-unapplied migration does not excuse drift: if the + * database is missing it, the check fails. + * + * The target database comes from PROD_DATABASE_URL only. DATABASE_URL is + * never used as a fallback, so a local dev database can never be mistaken for + * production. + * + * Exit codes: + * 0 = no unresolved drift + * 1 = unresolved drift (details printed) + * 2 = configuration problem (PROD_DATABASE_URL unset or unreachable) + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { is } from 'drizzle-orm'; +import { PgTable, getTableConfig, isPgEnum } from 'drizzle-orm/pg-core'; +import postgres from 'postgres'; +import * as schema from './schema.js'; + +// Every module whose pgTable / pgEnum exports should exist in production. +// Add further schema modules here (for example './tenancy/schema.js' in a +// multi-tenant project) so the check covers them too. +const SCHEMA_MODULES: ReadonlyArray]> = [ + ['schema.ts', schema as Record], +]; + +const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'migrations'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DeclaredColumn { + name: string; + notNull: boolean; +} + +interface DeclaredTable { + schema: string; + name: string; + columns: DeclaredColumn[]; + source: string; +} + +interface DeclaredEnum { + schema: string; + name: string; + values: readonly string[]; + source: string; +} + +export interface Declared { + tables: DeclaredTable[]; + enums: DeclaredEnum[]; +} + +interface LiveColumn { + isNullable: boolean; +} + +/** `schema.table` → column name → metadata */ +type LiveTables = Map>; +/** `schema.enum` → values */ +type LiveEnums = Map>; + +export interface Live { + tables: LiveTables; + enums: LiveEnums; +} + +export type Drift = + | { kind: 'missing_table'; schema: string; table: string; source: string } + | { kind: 'missing_column'; schema: string; table: string; column: string; source: string } + | { kind: 'nullability'; schema: string; table: string; column: string; source: string } + | { kind: 'missing_enum'; schema: string; enum: string; source: string } + | { kind: 'missing_enum_value'; schema: string; enum: string; value: string; source: string }; + +export interface MigrationFile { + filename: string; + content: string; +} + +export interface Classified { + drift: Drift; + coveredBy: MigrationFile | null; +} + +function qualified(schemaName: string, name: string): string { + return `${schemaName}.${name}`; +} + +// --------------------------------------------------------------------------- +// Declared schema (from Drizzle) +// --------------------------------------------------------------------------- + +export function collectDeclared( + modules: ReadonlyArray]> = SCHEMA_MODULES, +): Declared { + const tables: DeclaredTable[] = []; + const enums: DeclaredEnum[] = []; + + for (const [sourceName, mod] of modules) { + for (const [exportName, value] of Object.entries(mod)) { + const source = `${sourceName} (export ${exportName})`; + if (is(value, PgTable)) { + const cfg = getTableConfig(value); + tables.push({ + schema: cfg.schema ?? 'public', + name: cfg.name, + columns: cfg.columns.map((c) => ({ name: c.name, notNull: c.notNull })), + source, + }); + } else if (isPgEnum(value)) { + enums.push({ + schema: value.schema ?? 'public', + name: value.enumName, + values: value.enumValues, + source, + }); + } + } + } + + return { tables, enums }; +} + +// --------------------------------------------------------------------------- +// Live schema (from information_schema / pg_catalog) +// --------------------------------------------------------------------------- + +interface ColumnRow { + table_schema: string; + table_name: string; + column_name: string; + is_nullable: 'YES' | 'NO'; +} + +interface EnumRow { + enum_schema: string; + enum_name: string; + value: string; +} + +async function introspect(sql: postgres.Sql): Promise { + const tables: LiveTables = new Map(); + const enums: LiveEnums = new Map(); + + const columnRows = await sql` + SELECT table_schema, table_name, column_name, is_nullable + FROM information_schema.columns + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + `; + for (const row of columnRows) { + const key = qualified(row.table_schema, row.table_name); + let table = tables.get(key); + if (!table) { + table = new Map(); + tables.set(key, table); + } + table.set(row.column_name, { isNullable: row.is_nullable === 'YES' }); + } + + const enumRows = await sql` + SELECT n.nspname AS enum_schema, t.typname AS enum_name, e.enumlabel AS value + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + JOIN pg_namespace n ON n.oid = t.typnamespace + `; + for (const row of enumRows) { + const key = qualified(row.enum_schema, row.enum_name); + let values = enums.get(key); + if (!values) { + values = new Set(); + enums.set(key, values); + } + values.add(row.value); + } + + return { tables, enums }; +} + +// --------------------------------------------------------------------------- +// Diff +// --------------------------------------------------------------------------- + +export function diff(declared: Declared, live: Live): Drift[] { + const drift: Drift[] = []; + + for (const t of declared.tables) { + const liveTable = live.tables.get(qualified(t.schema, t.name)); + if (!liveTable) { + drift.push({ kind: 'missing_table', schema: t.schema, table: t.name, source: t.source }); + continue; + } + for (const c of t.columns) { + const liveColumn = liveTable.get(c.name); + if (!liveColumn) { + drift.push({ + kind: 'missing_column', + schema: t.schema, + table: t.name, + column: c.name, + source: t.source, + }); + continue; + } + // Schema says NOT NULL but the database allows NULL: code relying on + // the guarantee can crash on real NULLs. The reverse (schema nullable, + // database NOT NULL) is tolerated; code is already defensive. + if (c.notNull && liveColumn.isNullable) { + drift.push({ + kind: 'nullability', + schema: t.schema, + table: t.name, + column: c.name, + source: t.source, + }); + } + } + } + + for (const e of declared.enums) { + const liveEnum = live.enums.get(qualified(e.schema, e.name)); + if (!liveEnum) { + drift.push({ kind: 'missing_enum', schema: e.schema, enum: e.name, source: e.source }); + continue; + } + for (const v of e.values) { + if (!liveEnum.has(v)) { + drift.push({ + kind: 'missing_enum_value', + schema: e.schema, + enum: e.name, + value: v, + source: e.source, + }); + } + } + } + + return drift; +} + +// --------------------------------------------------------------------------- +// Pending-migration coverage (lenient mode) +// --------------------------------------------------------------------------- +// +// Every src/db/migrations/*.sql file is scanned, not only unapplied ones: an +// applied migration's objects would not be reported as drift in the first +// place, and this avoids depending on the __drizzle_migrations journal. +// Matching is regex-based (no SQL parser); identifiers may be bare, quoted, +// or schema-qualified ("public"."users"), as drizzle-kit emits them. + +export function loadMigrationFiles(dir: string = MIGRATIONS_DIR): MigrationFile[] { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + return entries + .filter((f) => f.endsWith('.sql')) + .sort() + .map((filename) => ({ + filename: `src/db/migrations/${filename}`, + content: readFileSync(join(dir, filename), 'utf-8'), + })); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Identifier pattern accepting `name`, `"name"`, `schema.name`, `"schema"."name"`. */ +function ident(name: string): string { + const escaped = escapeRegExp(name); + return `(?:"?[A-Za-z_][A-Za-z0-9_]*"?\\.)?"?${escaped}"?`; +} + +function findCreateTableBody(content: string, table: string): string | null { + const re = new RegExp(`CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${ident(table)}\\s*\\(`, 'i'); + const m = re.exec(content); + if (!m) return null; + const openIdx = m.index + m[0].length - 1; + let depth = 0; + for (let i = openIdx; i < content.length; i++) { + const ch = content[i]; + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return content.slice(openIdx + 1, i); + } + } + return null; +} + +function findCreateEnumValues(content: string, enumName: string): string[] | null { + const re = new RegExp(`CREATE\\s+TYPE\\s+${ident(enumName)}\\s+AS\\s+ENUM\\s*\\(([^)]*)\\)`, 'i'); + const m = re.exec(content); + const body = m?.[1]; + if (body === undefined) return null; + const values: string[] = []; + const valueRe = /'((?:[^']|'')*)'/g; + let vm: RegExpExecArray | null; + while ((vm = valueRe.exec(body)) !== null) { + values.push((vm[1] ?? '').replace(/''/g, "'")); + } + return values; +} + +export function coversDrift(d: Drift, content: string): boolean { + switch (d.kind) { + case 'missing_table': { + const re = new RegExp(`CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${ident(d.table)}\\b`, 'i'); + return re.test(content); + } + case 'missing_column': { + const addRe = new RegExp( + `ALTER\\s+TABLE\\s+${ident(d.table)}\\s+ADD\\s+COLUMN\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${ident(d.column)}\\b`, + 'i', + ); + if (addRe.test(content)) return true; + const body = findCreateTableBody(content, d.table); + if (!body) return false; + const colRe = new RegExp(`(^|,|\\s)"?${escapeRegExp(d.column)}"?\\s`, 'i'); + return colRe.test(body); + } + case 'nullability': { + const setRe = new RegExp( + `ALTER\\s+TABLE\\s+${ident(d.table)}\\s+ALTER\\s+COLUMN\\s+${ident(d.column)}\\s+SET\\s+NOT\\s+NULL`, + 'i', + ); + if (setRe.test(content)) return true; + const body = findCreateTableBody(content, d.table); + if (!body) return false; + const colDefRe = new RegExp( + `(^|,|\\s)"?${escapeRegExp(d.column)}"?\\b[^,]*\\bNOT\\s+NULL\\b`, + 'i', + ); + return colDefRe.test(body); + } + case 'missing_enum': { + const re = new RegExp(`CREATE\\s+TYPE\\s+${ident(d.enum)}\\s+AS\\s+ENUM\\b`, 'i'); + return re.test(content); + } + case 'missing_enum_value': { + const addRe = new RegExp( + `ALTER\\s+TYPE\\s+${ident(d.enum)}\\s+ADD\\s+VALUE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?'${escapeRegExp(d.value.replace(/'/g, "''"))}'`, + 'i', + ); + if (addRe.test(content)) return true; + const values = findCreateEnumValues(content, d.enum); + return values !== null && values.includes(d.value); + } + } +} + +export function classify(driftItems: Drift[], files: MigrationFile[]): Classified[] { + return driftItems.map((drift) => ({ + drift, + coveredBy: files.find((f) => coversDrift(drift, f.content)) ?? null, + })); +} + +/** Strict mode fails every drift item; lenient mode only uncovered ones. */ +export function selectUnresolved(classified: Classified[], strict: boolean): Classified[] { + return strict ? classified.slice() : classified.filter((c) => c.coveredBy === null); +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +export function describeDrift(d: Drift): string { + switch (d.kind) { + case 'missing_table': + return `table ${d.table}`; + case 'missing_column': + return `column ${d.table}.${d.column}`; + case 'nullability': + return `${d.table}.${d.column} is NOT NULL in schema but nullable in the database`; + case 'missing_enum': + return `enum ${d.enum}`; + case 'missing_enum_value': + return `enum value ${d.enum}.'${d.value}'`; + } +} + +function formatClassified(items: Classified[], strict: boolean): string { + return items + .map((c) => { + const what = `${describeDrift(c.drift)} [declared in ${c.drift.source}]`; + if (c.coveredBy === null) { + return ` ✗ drift: ${what} — no committed migration covers this`; + } + return strict + ? ` ✗ unapplied: ${what} — ${c.coveredBy.filename} is committed but not applied` + : ` ✓ pending: ${what} — will be added by ${c.coveredBy.filename}`; + }) + .join('\n'); +} + +export function maskDatabaseUrl(raw: string): string { + try { + const url = new URL(raw); + if (url.password) url.password = '***'; + return url.toString(); + } catch { + return ''; + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function main(): Promise { + const strict = process.argv.includes('--strict') || process.env.SCHEMA_DRIFT_STRICT === '1'; + + const url = process.env.PROD_DATABASE_URL; + if (!url) { + console.error('schema-drift: PROD_DATABASE_URL is not set.'); + console.error(' This check targets production only and never falls back to DATABASE_URL.'); + console.error(' In CI, add the PROD_DATABASE_URL repository secret; locally, export it'); + console.error(' (a read-only role is sufficient) before running pnpm db:check-drift.'); + return 2; + } + + console.log(`Mode: ${strict ? 'STRICT (committed-but-unapplied migrations still fail)' : 'lenient (pending migrations tolerated)'}`); + console.log(`Database: ${maskDatabaseUrl(url)}`); + + const declared = collectDeclared(); + console.log(`Declared: ${declared.tables.length} table(s), ${declared.enums.length} enum(s)`); + + const sql = postgres(url, { max: 1, connect_timeout: 15 }); + let live: Live; + try { + live = await introspect(sql); + } catch (err) { + console.error('schema-drift: failed to introspect the database:'); + console.error(` ${err instanceof Error ? err.message : String(err)}`); + await sql.end({ timeout: 5 }).catch(() => undefined); + return 2; + } + await sql.end({ timeout: 5 }).catch(() => undefined); + + console.log(`Live: ${live.tables.size} table(s), ${live.enums.size} enum(s)`); + + const drift = diff(declared, live); + if (drift.length === 0) { + console.log('No drift detected. The database satisfies the declared schema.'); + return 0; + } + + const files = loadMigrationFiles(); + const classified = classify(drift, files); + const unresolved = selectUnresolved(classified, strict); + const pending = classified.filter((c) => c.coveredBy !== null); + + console.log(''); + console.log(`DRIFT CHECK — ${drift.length} item(s) declared in the schema but missing from the database:`); + console.log(''); + console.log(formatClassified(classified, strict)); + console.log(''); + + if (unresolved.length === 0) { + console.log(`Drift check passed (${pending.length} item(s) resolved by pending migrations).`); + return 0; + } + + const unapplied = unresolved.filter((c) => c.coveredBy !== null).length; + console.error(`DRIFT DETECTED${strict ? ' (strict)' : ''} — ${unresolved.length} unresolved item(s).`); + console.error(''); + if (unapplied > 0) { + console.error(`${unapplied} of these are covered by a committed migration that has not been applied.`); + console.error('To resolve: apply pending migrations against the production database'); + console.error(' (pnpm db:migrate with DATABASE_URL pointed at production, or your deploy'); + console.error(' pipeline\'s migrate step), then re-run this check.'); + } + if (unapplied < unresolved.length) { + console.error('Items with no covering migration: run pnpm db:generate, review the SQL,'); + console.error(' commit the migration file, and re-run this check.'); + } + console.error(''); + console.error(`EXIT 1 — ${unresolved.length} unresolved drift item(s).`); + return 1; +} + +// Only run the database-touching CLI when invoked directly. Importing the +// module (for example to unit-test diff/classify) must not open a connection. +const invokedDirectly = + typeof process.argv[1] === 'string' && /schema-drift(\.[cm]?[jt]s)?$/.test(process.argv[1]); + +if (invokedDirectly) { + main().then( + (code) => process.exit(code), + (err: unknown) => { + console.error('schema-drift: unexpected error:'); + console.error(err); + process.exit(2); + }, + ); +} From 1519fc081221c619cab0f59931e808077507eb6e Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 9 Sep 2026 16:24:37 -0400 Subject: [PATCH 04/11] feat(hooks): add Claude Code hooks for secrets, prod databases, and quality reminders PreToolUse: secret-guard blocks staging .env/.dev.vars/key files and forced adds; prod-db-guard blocks drizzle-kit push and DROP/TRUNCATE against production. PostToolUse: coverage-check, migration-safety-reminder and quality-gate-reminder. Hooks read the stdin JSON contract, fail open on malformed input, and are covered by scripts/__tests__/hooks.test.js. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/coverage-check.sh | 49 +++ .claude/hooks/lib/bash-cmd.sh | 257 ++++++++++++ .claude/hooks/lib/hook-input.sh | 172 ++++++++ .claude/hooks/migration-safety-reminder.sh | 55 +++ .claude/hooks/prod-db-guard.sh | 112 +++++ .claude/hooks/quality-gate-reminder.sh | 39 ++ .claude/hooks/secret-guard.sh | 148 +++++++ .claude/settings.json | 43 ++ docs/onboarding/README.md | 1 + docs/onboarding/hooks.md | 242 +++++++++++ scripts/__tests__/hooks.test.js | 465 +++++++++++++++++++++ 11 files changed, 1583 insertions(+) create mode 100755 .claude/hooks/coverage-check.sh create mode 100644 .claude/hooks/lib/bash-cmd.sh create mode 100644 .claude/hooks/lib/hook-input.sh create mode 100755 .claude/hooks/migration-safety-reminder.sh create mode 100755 .claude/hooks/prod-db-guard.sh create mode 100755 .claude/hooks/quality-gate-reminder.sh create mode 100755 .claude/hooks/secret-guard.sh create mode 100644 .claude/settings.json create mode 100644 docs/onboarding/hooks.md create mode 100644 scripts/__tests__/hooks.test.js diff --git a/.claude/hooks/coverage-check.sh b/.claude/hooks/coverage-check.sh new file mode 100755 index 0000000..1f5872d --- /dev/null +++ b/.claude/hooks/coverage-check.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# coverage-check.sh — PostToolUse hook (matcher: Bash) +# +# After a vitest run whose output mentions coverage, prints a one-line +# reminder with the threshold from tdd.coverageThreshold in +# .claude/pipeline.config.json (default 80). When the text reporter's +# "All files" row is present, the statement percentage is compared to the +# threshold so the message says whether coverage is above or below it. +# +# Contract: JSON on stdin (see lib/hook-input.sh). Always exits 0. Stdout is +# surfaced to Claude as a reminder; no output means silent. + +set -u +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" + +hook_skip_requested && exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 +printf '%s' "$CMD" | grep -qE 'vitest|run-tests\.sh' || exit 0 + +OUT="$(hook_tool_output)" +[[ -n "$OUT" ]] || exit 0 +printf '%s' "$OUT" | grep -qi 'coverage' || exit 0 + +NUM_RE='^[0-9]+(\.[0-9]+)?$' +THRESHOLD="$(common_config_get tdd.coverageThreshold 80)" +[[ "$THRESHOLD" =~ $NUM_RE ]] || THRESHOLD=80 + +# Statement % from the text reporter's "All files | 91.2 | ..." row, if present. +STMTS="$(printf '%s\n' "$OUT" | grep -E '^[[:space:]]*All files[[:space:]]*\|' | head -1 | awk -F'|' '{ gsub(/[[:space:]]/, "", $2); print $2 }')" || STMTS="" + +if [[ "$STMTS" =~ $NUM_RE ]]; then + if awk -v s="$STMTS" -v t="$THRESHOLD" 'BEGIN { exit !(s + 0 < t + 0) }'; then + echo "[coverage-check] Statement coverage ${STMTS}% is BELOW the ${THRESHOLD}% threshold (tdd.coverageThreshold in .claude/pipeline.config.json). Add tests before moving on." + else + echo "[coverage-check] Statement coverage ${STMTS}% meets the ${THRESHOLD}% threshold (tdd.coverageThreshold). Check the branches, functions, and lines columns too." + fi +else + echo "[coverage-check] Coverage output detected. Confirm it meets the ${THRESHOLD}% threshold (tdd.coverageThreshold in .claude/pipeline.config.json)." +fi +exit 0 diff --git a/.claude/hooks/lib/bash-cmd.sh b/.claude/hooks/lib/bash-cmd.sh new file mode 100644 index 0000000..b4a4d7c --- /dev/null +++ b/.claude/hooks/lib/bash-cmd.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# bash-cmd.sh — small shell-command parser for the PreToolUse guards +# +# The guards need to know which *simple commands* a Bash tool call contains +# and what their words are, without being fooled by quoted strings, heredoc +# bodies, or comments. This is a pragmatic single-pass parser, not a full +# bash grammar; the known limits are listed at the bottom. +# +# Source after hook-input.sh: +# +# source "$HOOK_DIR/lib/bash-cmd.sh" +# +# Provides: +# +# hook_split_commands +# Fills HOOK_SEGMENTS with one entry per simple command. Splits on +# newline ; | || && & ( ) and backticks (outside quotes), drops +# comments, and skips heredoc bodies (<&1 and &> are kept inside their segment. +# Segments may be empty; callers skip those. +# +# hook_tokenize +# Fills HOOK_TOKENS with the words of one segment. Single and double +# quotes and backslash escapes are honoured and removed. +# +# hook_strip_wrappers +# Reads HOOK_TOKENS and fills HOOK_ARGV with the same words minus leading +# VAR=value assignments and launcher words (sudo, env, command, time, +# nohup, exec, builtin, if/then/else/while/until/do, { } !). After this, +# HOOK_ARGV[0] is the program being run. +# +# Bash 3.2 compatible (macOS /bin/bash). All functions return 0. +# +# Limits (documented in docs/onboarding/hooks.md): +# - `bash -c "..."`, `eval "..."`, and `sh script.sh` bodies are not parsed. +# - $'...' ANSI-C strings and ${VAR} expansions are treated as plain text. +# - Only one heredoc per line is recognised. + +if [[ -n "${__NERVA_BASH_CMD_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +__NERVA_BASH_CMD_LOADED=1 + +HOOK_SEGMENTS=() +HOOK_TOKENS=() +HOOK_ARGV=() + +hook_split_commands() { + local s="${1-}" + local n=${#s} + local i=0 j=0 ln=0 + local c="" nxt="" prev="" seg="" q="" hd="" w="" qq="" line="" t="" + HOOK_SEGMENTS=() + + while (( i < n )); do + c="${s:i:1}" + + # Inside quotes: copy verbatim until the matching quote. + if [[ -n "$q" ]]; then + seg+="$c" + if [[ "$c" == "$q" ]]; then + q="" + elif [[ "$q" == '"' && "$c" == '\' ]]; then + i=$((i + 1)) + seg+="${s:i:1}" + fi + i=$((i + 1)) + continue + fi + + nxt="${s:i+1:1}" + case "$c" in + \'|\") + q="$c" + seg+="$c" + ;; + \\) + # Backslash escape (including backslash-newline continuation). + seg+="$c$nxt" + i=$((i + 1)) + ;; + '#') + # A comment only starts at a word boundary. + if [[ -z "$seg" || "$seg" == *[[:space:]] ]]; then + while (( i < n )) && [[ "${s:i:1}" != $'\n' ]]; do + i=$((i + 1)) + done + continue + fi + seg+="$c" + ;; + '<') + if [[ "$nxt" == '<' && "${s:i+2:1}" != '<' ]]; then + # Heredoc operator: read the delimiter word, remember it, and skip + # the body once the current line ends. + j=$((i + 2)) + w="" + if [[ "${s:j:1}" == '-' ]]; then + j=$((j + 1)) + fi + while [[ "${s:j:1}" == ' ' || "${s:j:1}" == $'\t' ]]; do + j=$((j + 1)) + done + qq="${s:j:1}" + if [[ "$qq" == "'" || "$qq" == '"' ]]; then + j=$((j + 1)) + while (( j < n )) && [[ "${s:j:1}" != "$qq" ]]; do + w+="${s:j:1}" + j=$((j + 1)) + done + j=$((j + 1)) + else + while (( j < n )) && [[ "${s:j:1}" == [A-Za-z0-9_] ]]; do + w+="${s:j:1}" + j=$((j + 1)) + done + fi + hd="$w" + seg+="${s:i:j-i}" + i=$j + continue + fi + seg+="$c" + ;; + $'\n') + HOOK_SEGMENTS+=("$seg") + seg="" + if [[ -n "$hd" ]]; then + # Skip heredoc body lines up to and including the terminator. + i=$((i + 1)) + while (( i < n )); do + line="${s:i}" + line="${line%%$'\n'*}" + ln=${#line} + i=$((i + ln + 1)) + t="$line" + while [[ "$t" == $'\t'* ]]; do + t="${t:1}" + done + if [[ "$t" == "$hd" ]]; then + break + fi + done + hd="" + continue + fi + ;; + ';'|'('|')'|'`') + HOOK_SEGMENTS+=("$seg") + seg="" + ;; + '|') + HOOK_SEGMENTS+=("$seg") + seg="" + if [[ "$nxt" == '|' ]]; then + i=$((i + 1)) + fi + ;; + '&') + if [[ "$nxt" == '&' ]]; then + HOOK_SEGMENTS+=("$seg") + seg="" + i=$((i + 1)) + elif [[ "$nxt" == '>' ]]; then + seg+="$c" # &> redirect + else + prev="" + if (( i > 0 )); then + prev="${s:i-1:1}" + fi + if [[ "$prev" == '>' || "$prev" == '<' ]]; then + seg+="$c" # 2>&1, <&0 + else + HOOK_SEGMENTS+=("$seg") # background job separator + seg="" + fi + fi + ;; + *) + seg+="$c" + ;; + esac + i=$((i + 1)) + done + + if [[ -n "$seg" ]]; then + HOOK_SEGMENTS+=("$seg") + fi + return 0 +} + +hook_tokenize() { + local s="${1-}" + local n=${#s} + local i=0 intok=0 + local c="" tok="" q="" + HOOK_TOKENS=() + + while (( i < n )); do + c="${s:i:1}" + if [[ -n "$q" ]]; then + if [[ "$c" == "$q" ]]; then + q="" + elif [[ "$q" == '"' && "$c" == '\' ]]; then + i=$((i + 1)) + tok+="${s:i:1}" + else + tok+="$c" + fi + i=$((i + 1)) + continue + fi + case "$c" in + \'|\") + q="$c" + intok=1 + ;; + \\) + i=$((i + 1)) + tok+="${s:i:1}" + intok=1 + ;; + ' '|$'\t'|$'\n'|$'\r') + if (( intok )); then + HOOK_TOKENS+=("$tok") + tok="" + intok=0 + fi + ;; + *) + tok+="$c" + intok=1 + ;; + esac + i=$((i + 1)) + done + + if (( intok )); then + HOOK_TOKENS+=("$tok") + fi + return 0 +} + +hook_strip_wrappers() { + local -a rest + rest=(${HOOK_TOKENS[@]+"${HOOK_TOKENS[@]}"}) + while (( ${#rest[@]} > 0 )); do + case "${rest[0]}" in + sudo|command|env|time|nohup|exec|builtin|if|then|else|elif|while|until|do|\{|\}|\!) ;; + [A-Za-z_]*=*) ;; + *) break ;; + esac + rest=(${rest[@]+"${rest[@]:1}"}) + done + HOOK_ARGV=(${rest[@]+"${rest[@]}"}) + return 0 +} diff --git a/.claude/hooks/lib/hook-input.sh b/.claude/hooks/lib/hook-input.sh new file mode 100644 index 0000000..590b408 --- /dev/null +++ b/.claude/hooks/lib/hook-input.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# hook-input.sh — stdin helpers for .claude/hooks/*.sh +# +# Claude Code passes ONE JSON object on stdin to every hook, for example: +# +# { +# "hook_event_name": "PreToolUse", +# "tool_name": "Bash", +# "tool_input": { "command": "git add .env" }, +# "tool_response": { "stdout": "...", "stderr": "..." }, // PostToolUse only +# "cwd": "/path/to/project" +# } +# +# Source this once near the top of a hook (after scripts/lib/common.sh when +# that file is available): +# +# HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$HOOK_DIR/lib/hook-input.sh" +# +# It reads stdin exactly once into HOOK_INPUT and provides: +# +# hook_get jq-style path such as `.tool_input.command`. Prints "" +# (and returns 0) when the key is missing, the value is +# null/false, or stdin was not JSON. Uses jq when it is +# on PATH, otherwise node (always present in Nerva). +# Object/array values are printed as JSON text. +# hook_tool_name `hook_get .tool_name` +# hook_tool_command `hook_get .tool_input.command` +# hook_tool_output tool_response as plain text. The Bash tool returns an +# object ({stdout, stderr, ...}); other tools may return +# a string or an {output}/{content} object. All shapes +# come back as text (stdout then stderr for Bash). +# (set NERVA_HOOK_JSON_TOOL=node to force the node path; tests use this) +# hook_skip_requested returns 0 when NERVA_SKIP_HOOKS=1 (or "true") is set +# in the environment Claude Code was launched from. This +# is the user-level bypass: Claude's own Bash commands +# cannot influence the hook process environment. +# +# Fallbacks: when scripts/lib/common.sh was not sourced (for instance because +# the hooks were copied into a generated project), minimal `have_cmd` and +# `common_config_get` are defined here so hooks keep working. +# +# Nothing in this file exits the caller and every helper returns 0. + +if [[ -n "${__NERVA_HOOK_INPUT_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +__NERVA_HOOK_INPUT_LOADED=1 + +if ! declare -F have_cmd >/dev/null 2>&1; then + have_cmd() { command -v "$1" >/dev/null 2>&1; } +fi + +if ! declare -F common_config_get >/dev/null 2>&1; then + # Fallback reader for .claude/pipeline.config.json (cwd-relative, or the file + # named by NERVA_PIPELINE_CONFIG). Mirrors scripts/lib/common.sh's contract: + # prints the default when anything is missing. + common_config_get() { + local key="${1:-}" + local default="${2-}" + local file="${NERVA_PIPELINE_CONFIG:-.claude/pipeline.config.json}" + local out="" + if [[ -z "$key" || ! -f "$file" ]] || ! have_cmd node; then + echo "$default" + return 0 + fi + out="$(HOOK_CFG_FILE="$file" HOOK_CFG_KEY="$key" node -e ' + const fs = require("fs"); + let v; + try { v = JSON.parse(fs.readFileSync(process.env.HOOK_CFG_FILE, "utf8")); } catch { process.exit(1); } + for (const p of process.env.HOOK_CFG_KEY.split(".").filter(Boolean)) { + if (v === null || typeof v !== "object" || !(p in v)) process.exit(1); + v = v[p]; + } + if (v === undefined || v === null) process.exit(1); + process.stdout.write(typeof v === "object" ? JSON.stringify(v) : String(v)); + ' 2>/dev/null)" || out="" + if [[ -n "$out" ]]; then + echo "$out" + else + echo "$default" + fi + return 0 + } +fi + +# --- Read stdin once ------------------------------------------------------- + +HOOK_INPUT="" +# Skip the read when stdin is a terminal (someone ran the hook by hand with +# no pipe) so the script does not hang waiting for input. +if [[ ! -t 0 ]]; then + HOOK_INPUT="$(cat 2>/dev/null || true)" +fi + +# --- Accessors ------------------------------------------------------------- + +hook_get() { + local path="${1:-}" + if [[ -z "$HOOK_INPUT" || -z "$path" ]]; then + return 0 + fi + [[ "$path" == .* ]] || path=".$path" + + # NERVA_HOOK_JSON_TOOL=node forces the node path (used by the test-suite). + if [[ "${NERVA_HOOK_JSON_TOOL:-}" != "node" ]] && have_cmd jq; then + printf '%s' "$HOOK_INPUT" | jq -r "$path // empty" 2>/dev/null || true + elif have_cmd node; then + # Dotted object keys only (".a.b.c"); that is all the hooks need. + printf '%s' "$HOOK_INPUT" | HOOK_PATH="$path" node -e ' + const fs = require("fs"); + let v; + try { v = JSON.parse(fs.readFileSync(0, "utf8")); } catch { process.exit(0); } + for (const p of process.env.HOOK_PATH.split(".").filter(Boolean)) { + if (v === null || typeof v !== "object" || !(p in v)) process.exit(0); + v = v[p]; + } + if (v === undefined || v === null || v === false) process.exit(0); + process.stdout.write(typeof v === "object" ? JSON.stringify(v) : String(v)); + ' 2>/dev/null || true + fi + return 0 +} + +hook_tool_name() { + hook_get .tool_name +} + +hook_tool_command() { + hook_get .tool_input.command +} + +hook_tool_output() { + local raw out err + raw="$(hook_get .tool_response)" + if [[ -z "$raw" ]]; then + return 0 + fi + case "$raw" in + \{*) + out="$(hook_get .tool_response.stdout)" + err="$(hook_get .tool_response.stderr)" + if [[ -n "$out" || -n "$err" ]]; then + printf '%s' "$out" + if [[ -n "$out" && -n "$err" ]]; then + printf '\n' + fi + printf '%s' "$err" + return 0 + fi + out="$(hook_get .tool_response.output)" + if [[ -n "$out" ]]; then + printf '%s' "$out" + return 0 + fi + out="$(hook_get .tool_response.content)" + if [[ -n "$out" ]]; then + printf '%s' "$out" + return 0 + fi + printf '%s' "$raw" + ;; + *) + printf '%s' "$raw" + ;; + esac + return 0 +} + +hook_skip_requested() { + [[ "${NERVA_SKIP_HOOKS:-}" == "1" || "${NERVA_SKIP_HOOKS:-}" == "true" ]] +} diff --git a/.claude/hooks/migration-safety-reminder.sh b/.claude/hooks/migration-safety-reminder.sh new file mode 100755 index 0000000..13ca2fb --- /dev/null +++ b/.claude/hooks/migration-safety-reminder.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# migration-safety-reminder.sh — PostToolUse hook (matcher: Bash) +# +# - After `drizzle-kit generate` (or `pnpm db:generate`, +# scripts/generate-migration.sh) succeeds: remind to run the +# destructive-DDL check and to read the generated SQL. The check command +# depends on the cwd: `node scripts/check-destructive-migrations.js` in the +# framework repo, `pnpm db:check-destructive` in a generated api/ project. +# - After any `drizzle-kit push` (or `pnpm db:push`): remind that push is +# dev-only and production uses migrations. +# +# Contract: JSON on stdin (see lib/hook-input.sh). Always exits 0. Stdout is +# surfaced to Claude as a reminder; no output means silent. + +set -u +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" + +hook_skip_requested && exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 + +PUSH_RE='drizzle-kit[[:space:]]+push|db:push' +GENERATE_RE='drizzle-kit[[:space:]]+generate|db:generate|generate-migration\.sh' + +if printf '%s' "$CMD" | grep -qE -- "$PUSH_RE"; then + echo "[migration-safety] drizzle-kit push applied schema changes straight to the database. That is dev-only: staging and production take generated migrations (pnpm drizzle-kit generate, review the SQL, pnpm drizzle-kit migrate)." +fi + +if printf '%s' "$CMD" | grep -qE -- "$GENERATE_RE"; then + OUT="$(hook_tool_output)" + if [[ -n "$OUT" ]] \ + && ! printf '%s' "$OUT" | grep -qi 'no schema changes' \ + && ! printf '%s' "$OUT" | grep -qiE '(^|[^[:alnum:]])(error|failed)([^[:alnum:]]|$)|✗|ERR_' \ + && printf '%s' "$OUT" | grep -qiE 'migration|[[:alnum:]_-]+\.sql'; then + if [[ -f scripts/check-destructive-migrations.js ]]; then + CHECK="node scripts/check-destructive-migrations.js" + elif [[ -f package.json ]] && grep -q '"db:check-destructive"' package.json 2>/dev/null; then + CHECK="pnpm db:check-destructive" + elif [[ -f api/package.json ]] && grep -q '"db:check-destructive"' api/package.json 2>/dev/null; then + CHECK="cd api && pnpm db:check-destructive" + else + CHECK="the destructive-DDL check (node scripts/check-destructive-migrations.js in the framework repo, pnpm db:check-destructive in a generated api/ project)" + fi + echo "[migration-safety] Migration generated. Before applying it: run ${CHECK} to flag destructive DDL (DROP, ALTER COLUMN ... TYPE, SET NOT NULL), read the new SQL file, and apply it with drizzle-kit migrate (never push) outside dev." + fi +fi +exit 0 diff --git a/.claude/hooks/prod-db-guard.sh b/.claude/hooks/prod-db-guard.sh new file mode 100755 index 0000000..d697b8c --- /dev/null +++ b/.claude/hooks/prod-db-guard.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# prod-db-guard.sh — PreToolUse hook (matcher: Bash) +# +# Blocks Bash commands that would change a production database outside the +# migration workflow: +# - `drizzle-kit push` (or `pnpm db:push`) when the same command references +# production: --env production, PROD_DATABASE_URL, NODE_ENV=production, +# or a database URL whose host/path contains "prod" +# - psql / `pnpm db:*` / drizzle-kit invocations that reference production +# AND contain DROP DATABASE, DROP SCHEMA, or TRUNCATE +# - `drizzle-kit drop` (rewrites migration history) — always +# +# Everything else is allowed. A plain `drizzle-kit push` in dev passes; the +# PostToolUse migration-safety-reminder.sh nudges instead of blocking. +# +# Contract: JSON on stdin (see lib/hook-input.sh). Exit 2 blocks the tool call +# and the one-line stderr message is shown to Claude. Exit 0 allows it. +# Fails open on empty/malformed stdin or unexpected errors. + +set -u +set -f +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" +# shellcheck source=lib/bash-cmd.sh +source "$HOOK_DIR/lib/bash-cmd.sh" + +hook_skip_requested && exit 0 +[[ "$(hook_tool_name)" == "Bash" ]] || exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 + +# --- Does the command reference a production database? ----------------------- + +PROD_RE='--env[= ]+prod(uction)?|(PROD|PRODUCTION)_DATABASE_URL|DATABASE_URL_PROD(UCTION)?|NODE_ENV=production|(postgres(ql)?|pg|mysql)://[^[:space:]"]*prod[^[:space:]"]*' +PROD_REF="$(printf '%s' "$CMD" | grep -oE -- "$PROD_RE" 2>/dev/null | head -1)" || PROD_REF="" + +DESTRUCTIVE_RE='DROP[[:space:]]+(DATABASE|SCHEMA)|(^|[^[:alnum:]_])TRUNCATE([^[:alnum:]_]|$)' + +# --- Walk every simple command in the call ----------------------------------- + +BLOCK="" +hook_split_commands "$CMD" +for seg in ${HOOK_SEGMENTS[@]+"${HOOK_SEGMENTS[@]}"}; do + [[ -n "${seg//[[:space:]]/}" ]] || continue + hook_tokenize "$seg" + hook_strip_wrappers + argv=(${HOOK_ARGV[@]+"${HOOK_ARGV[@]}"}) + (( ${#argv[@]} >= 1 )) || continue + + # Identify the database tool in this segment and its subcommand. + tool="" + sub="" + i=0 + while (( i < ${#argv[@]} )); do + t="${argv[i]##*/}" + case "$t" in + drizzle-kit) + tool="drizzle-kit" + sub="${argv[i+1]:-}" + break + ;; + psql|pgcli) + tool="$t" + break + ;; + db:*) + tool="pnpm $t" + sub="${t#db:}" + break + ;; + esac + i=$((i + 1)) + done + [[ -n "$tool" ]] || continue + + case "$sub" in + drop|drop:*) + BLOCK="$tool $sub rewrites migration history (deletes a migration and edits the journal). Remove the migration file and its journal entry by hand, and never for a migration that has been applied" + break + ;; + push|push:*) + if [[ -n "$PROD_REF" ]]; then + BLOCK="$tool push against a production target ($PROD_REF). push is dev-only; production changes go through drizzle-kit generate, review, then drizzle-kit migrate" + break + fi + ;; + esac + + if [[ -n "$PROD_REF" ]]; then + hit="$(printf '%s' "$seg" | grep -oiE -- "$DESTRUCTIVE_RE" 2>/dev/null | head -1 | tr -s '[:space:]' ' ')" || hit="" + if [[ -n "$hit" ]]; then + hit="${hit#"${hit%%[![:space:]]*}"}" + hit="${hit%"${hit##*[![:space:]]}"}" + hit="$(printf '%s' "$hit" | tr -d '"();,')" + BLOCK="destructive SQL ($hit) in a $tool command that references production ($PROD_REF). Put it in a reviewed migration instead" + break + fi + fi +done + +if [[ -n "$BLOCK" ]]; then + echo "[prod-db-guard] Blocked: $BLOCK (bypass: run it yourself outside Claude Code, or launch Claude with NERVA_SKIP_HOOKS=1)." >&2 + exit 2 +fi +exit 0 diff --git a/.claude/hooks/quality-gate-reminder.sh b/.claude/hooks/quality-gate-reminder.sh new file mode 100755 index 0000000..5e86438 --- /dev/null +++ b/.claude/hooks/quality-gate-reminder.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# quality-gate-reminder.sh — PostToolUse hook (matcher: Bash) +# +# When a vitest run reports that every test passed ("Tests N passed" with no +# "failed"), remind to run the rest of the quality gate: type check, security +# scan, and the coverage threshold from tdd.coverageThreshold. +# +# Contract: JSON on stdin (see lib/hook-input.sh). Always exits 0. Stdout is +# surfaced to Claude as a reminder; no output means silent. + +set -u +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" + +hook_skip_requested && exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 +printf '%s' "$CMD" | grep -qE 'vitest|run-tests\.sh|(pnpm|npm|yarn|bun)([[:space:]]+run)?[[:space:]]+test' || exit 0 + +OUT="$(hook_tool_output)" +[[ -n "$OUT" ]] || exit 0 +printf '%s' "$OUT" | grep -qE 'Tests[[:space:]]+[0-9]+ passed' || exit 0 +if printf '%s' "$OUT" | grep -qi 'failed'; then + exit 0 +fi + +NUM_RE='^[0-9]+(\.[0-9]+)?$' +THRESHOLD="$(common_config_get tdd.coverageThreshold 80)" +[[ "$THRESHOLD" =~ $NUM_RE ]] || THRESHOLD=80 + +echo "[quality-gate] All tests passed. Run the quality gate before calling this done: ./scripts/check-types.sh && ./scripts/security-scan.sh, then confirm coverage is at least ${THRESHOLD}% (pnpm vitest run --coverage; tdd.coverageThreshold in .claude/pipeline.config.json)." +exit 0 diff --git a/.claude/hooks/secret-guard.sh b/.claude/hooks/secret-guard.sh new file mode 100755 index 0000000..72711a1 --- /dev/null +++ b/.claude/hooks/secret-guard.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# secret-guard.sh — PreToolUse hook (matcher: Bash) +# +# Blocks Bash commands that would put secrets into git: +# - `git add` / `git stage` of .env, .env.* (except *.example), .dev.vars, +# .dev.vars.*, *.pem, *.key, *.p12, credentials.json +# - any `git add -f` / `git add --force` (it bypasses .gitignore) +# - `git commit -a` / `-am` / `--all` while such a file is already tracked +# +# Contract: JSON on stdin (see lib/hook-input.sh). Exit 2 blocks the tool call +# and the one-line stderr message is shown to Claude. Exit 0 allows it. +# +# Fails open: empty or malformed stdin, a non-Bash tool, or any unexpected +# runtime error exits 0. The only blocking exit is the explicit `exit 2`. +# +# Known limits (.gitignore is the real backstop): +# - `bash -c "git add .env"` and `eval` bodies are not inspected. +# - `git add .` / `git add -A` are allowed; .gitignore must cover secrets. +# - Only tracked files visible from the project root are checked for -a. + +set -u +set -f +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" +# shellcheck source=lib/bash-cmd.sh +source "$HOOK_DIR/lib/bash-cmd.sh" + +hook_skip_requested && exit 0 +[[ "$(hook_tool_name)" == "Bash" ]] || exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 + +# --- Secret file patterns ---------------------------------------------------- + +# is_secret_path → 0 when the basename looks like a secret file. +is_secret_path() { + local base="${1##*/}" + case "$base" in + .env.example|.env.*.example|.dev.vars.example|.dev.vars.*.example) return 1 ;; + .env|.env.*|.dev.vars|.dev.vars.*|*.pem|*.key|*.p12|credentials.json) return 0 ;; + esac + return 1 +} + +# tracked_secret → prints the first tracked secret file (if any). +tracked_secret() { + local f + while IFS= read -r f; do + [[ -n "$f" ]] || continue + if is_secret_path "$f"; then + printf '%s' "$f" + return 0 + fi + done < <(git ls-files -- '.env' '.env.*' '*/.env' '*/.env.*' '.dev.vars*' '*/.dev.vars*' \ + '*.pem' '*.key' '*.p12' 'credentials.json' '*/credentials.json' 2>/dev/null || true) + return 0 +} + +# --- Per-subcommand checks (read ARGS, set BLOCK) ---------------------------- + +BLOCK="" +ARGS=() + +check_add() { + local tok dd=0 + for tok in ${ARGS[@]+"${ARGS[@]}"}; do + if (( dd == 0 )); then + case "$tok" in + --) dd=1; continue ;; + -f|--force) BLOCK="a forced add ($tok), which bypasses .gitignore"; return 0 ;; + --*) continue ;; + -*f*) BLOCK="a forced add ($tok), which bypasses .gitignore"; return 0 ;; + -*) continue ;; + esac + fi + if is_secret_path "$tok"; then + BLOCK="a secret file ($tok)" + return 0 + fi + done + return 0 +} + +check_commit() { + local tok all=0 tracked="" + local cluster_re='^-[A-Za-z]+$' + for tok in ${ARGS[@]+"${ARGS[@]}"}; do + case "$tok" in + --) break ;; + -a|--all) all=1; break ;; + --*) continue ;; + esac + if [[ "$tok" =~ $cluster_re && "$tok" == *a* ]]; then + all=1 + break + fi + done + (( all )) || return 0 + tracked="$(tracked_secret)" + if [[ -n "$tracked" ]]; then + BLOCK="every tracked change via commit -a while a secret file is tracked ($tracked); untrack it first with: git rm --cached $tracked" + fi + return 0 +} + +# --- Walk every simple command in the call ----------------------------------- + +hook_split_commands "$CMD" +for seg in ${HOOK_SEGMENTS[@]+"${HOOK_SEGMENTS[@]}"}; do + [[ -n "${seg//[[:space:]]/}" ]] || continue + hook_tokenize "$seg" + hook_strip_wrappers + argv=(${HOOK_ARGV[@]+"${HOOK_ARGV[@]}"}) + (( ${#argv[@]} >= 2 )) || continue + [[ "${argv[0]##*/}" == "git" ]] || continue + + # Skip git's global options to find the subcommand (git -C api add .env). + idx=1 + while (( idx < ${#argv[@]} )); do + case "${argv[idx]}" in + -C|-c|--git-dir|--work-tree|--namespace|--exec-path) idx=$((idx + 2)) ;; + -*) idx=$((idx + 1)) ;; + *) break ;; + esac + done + (( idx < ${#argv[@]} )) || continue + + sub="${argv[idx]}" + ARGS=(${argv[@]+"${argv[@]:idx+1}"}) + case "$sub" in + add|stage) check_add ;; + commit) check_commit ;; + esac + [[ -z "$BLOCK" ]] || break +done + +if [[ -n "$BLOCK" ]]; then + echo "[secret-guard] Blocked: this command would stage $BLOCK. Secrets stay out of git: keep them in .gitignore and commit a .env.example instead (bypass: run it yourself outside Claude Code, or launch Claude with NERVA_SKIP_HOOKS=1)." >&2 + exit 2 +fi +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c358805 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,43 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/secret-guard.sh", + "description": "Block git add of .env/.dev.vars/keys/credentials, forced adds, and commit -a while a secret is tracked" + }, + { + "type": "command", + "command": "bash .claude/hooks/prod-db-guard.sh", + "description": "Block drizzle-kit push and destructive SQL against production databases, and drizzle-kit drop" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/coverage-check.sh", + "description": "Compare vitest coverage output with tdd.coverageThreshold from pipeline.config.json" + }, + { + "type": "command", + "command": "bash .claude/hooks/migration-safety-reminder.sh", + "description": "After drizzle-kit generate, remind to run the destructive-DDL check; after drizzle-kit push, remind that push is dev-only" + }, + { + "type": "command", + "command": "bash .claude/hooks/quality-gate-reminder.sh", + "description": "After all vitest tests pass, remind to run check-types, security-scan, and the coverage threshold" + } + ] + } + ] + } +} diff --git a/docs/onboarding/README.md b/docs/onboarding/README.md index 98c839f..7ffe72a 100644 --- a/docs/onboarding/README.md +++ b/docs/onboarding/README.md @@ -21,6 +21,7 @@ Welcome to the Nerva documentation. Nerva is a Claude Code-integrated API & back | [Agent Catalog](../../.claude/CUSTOM-AGENTS-GUIDE.md) | All 24 Claude Code agents | | [Pipeline Configuration](../../.claude/pipeline.config.json) | Pipeline thresholds and settings | | [Patreon Voting](../community/patreon-voting.md) | Community roadmap voting process | +| [Hooks](hooks.md) | Claude Code hook guards (secrets, production DB) and post-run reminders | ## Technology Stack diff --git a/docs/onboarding/hooks.md b/docs/onboarding/hooks.md new file mode 100644 index 0000000..276fa4f --- /dev/null +++ b/docs/onboarding/hooks.md @@ -0,0 +1,242 @@ +# Claude Code Hooks + +Hooks are small bash scripts that Claude Code runs automatically around its own tool calls. Nerva ships two kinds: + +- **PreToolUse guards** that block a dangerous Bash command before it runs (staging secrets into git, pushing schema changes at a production database). +- **PostToolUse reminders** that surface a one-line nudge after a Bash command finishes (coverage threshold, migration review, quality gate). + +Every hook is a file under `.claude/hooks/`, wired in `.claude/settings.json`, and covered by `scripts/__tests__/hooks.test.js`. + +``` +.claude/ +├── settings.json # wiring (committed; settings.local.json stays personal) +└── hooks/ + ├── lib/ + │ ├── hook-input.sh # stdin JSON helpers (hook_get, hook_tool_output, ...) + │ └── bash-cmd.sh # splits a Bash command into simple commands + words + ├── secret-guard.sh # PreToolUse + ├── prod-db-guard.sh # PreToolUse + ├── coverage-check.sh # PostToolUse + ├── migration-safety-reminder.sh # PostToolUse + └── quality-gate-reminder.sh # PostToolUse +``` + +## The stdin contract + +Claude Code passes **one JSON object on stdin** to each hook. Hooks read it once and never take positional arguments. + +```json +{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { "command": "git add .env" }, + "tool_response": { "stdout": "...", "stderr": "...", "interrupted": false }, + "cwd": "/path/to/project" +} +``` + +| Field | Present on | Notes | +|-------|-----------|-------| +| `tool_name` | both | Hooks in this repo only act on `Bash` | +| `tool_input.command` | both | The exact command Claude asked to run | +| `tool_response` | PostToolUse | For Bash an object with `stdout`/`stderr`; other tools may return a string | + +What the exit code means: + +| Event | Exit 0 | Exit 2 | Other | +|-------|--------|--------|-------| +| PreToolUse | allow the call | **block** the call; stderr is shown to Claude | non-blocking error, call proceeds | +| PostToolUse | done; stdout is surfaced to Claude as a reminder (no output = silent) | n/a | treated as a hook error | + +Hooks run with the **project root as cwd**, which is why `settings.json` references them as `bash .claude/hooks/.sh`. + +Two rules every hook here follows: + +1. **Never crash the tool call.** `set -u` plus `trap 'exit 0' ERR`, and every helper returns 0. Empty, truncated, or non-JSON stdin exits 0 with no output. A PreToolUse guard therefore *fails open*: the only way it blocks is its explicit `exit 2`. +2. **Read stdin through the shared helper**, never with ad-hoc `jq` calls, so the jq/node fallback and the `tool_response` shape are handled in one place. + +## Shared libraries + +`.claude/hooks/lib/hook-input.sh` (sourced by every hook): + +| Helper | Purpose | +|--------|---------| +| `HOOK_INPUT` | Raw stdin, read once | +| `hook_get .tool_input.command` | jq-style lookup; `""` when missing or not JSON. Uses `jq` when installed, otherwise `node` (always present in Nerva). `NERVA_HOOK_JSON_TOOL=node` forces the node path (tests use it) | +| `hook_tool_name`, `hook_tool_command` | Shorthands | +| `hook_tool_output` | `tool_response` as text, whatever its shape (`{stdout,stderr}`, `{output}`, or a string) | +| `hook_skip_requested` | True when `NERVA_SKIP_HOOKS=1` is in the hook's environment | +| `common_config_get` fallback | Defined only if `scripts/lib/common.sh` was not sourced, so copied hooks still read `pipeline.config.json` | + +`.claude/hooks/lib/bash-cmd.sh` (PreToolUse guards): + +| Helper | Purpose | +|--------|---------| +| `hook_split_commands "$CMD"` | Fills `HOOK_SEGMENTS`: one simple command per entry. Splits on newline, `;`, `|`, `||`, `&&`, `&`, `(`, `)` and backticks outside quotes, drops comments, skips heredoc bodies (`<&1`-style redirects intact | +| `hook_tokenize "$seg"` | Fills `HOOK_TOKENS`: words with quotes and backslash escapes removed | +| `hook_strip_wrappers` | Fills `HOOK_ARGV`: `HOOK_TOKENS` minus leading `VAR=value`, `sudo`, `env`, `command`, `time`, `if`, `{`, ... so `HOOK_ARGV[0]` is the program | + +Both libraries are Bash 3.2 compatible (macOS `/bin/bash`). + +`scripts/lib/common.sh` is sourced via an absolute path derived from the hook's own location (`$HOOK_DIR/../../scripts/lib/common.sh`). It supplies `have_cmd` and `common_config_get [default]`, which reads `.claude/pipeline.config.json` (or the file named by `NERVA_PIPELINE_CONFIG`). + +## Built-in hooks + +### secret-guard.sh (PreToolUse, Bash) + +| | | +|---|---| +| Triggers on | `git add` / `git stage` of `.env`, `.env.*`, `.dev.vars`, `.dev.vars.*`, `*.pem`, `*.key`, `*.p12`, `credentials.json`; any `git add -f` / `--force` (including clusters such as `-Af`); `git commit -a` / `-am` / `--all` while one of those files is already tracked (`git ls-files`) | +| Not blocked | `.env.example`, `.env.*.example`, `.dev.vars.example`; `git add .` / `-A` (rely on `.gitignore`); a mention inside a quoted string, comment, or heredoc body; `git commit -m "add .env support"` | +| Result | exit 2, stderr `[secret-guard] Blocked: this command would stage a secret file (.env). ...` | +| Compound commands | Every simple command is inspected, so `cd api && git add .env`, `git -C api add .env`, `(cd api && git add .env)` and `FOO=1 git add .env` are all caught | +| Bypass | Commit `.env.example` instead; untrack a secret with `git rm --cached `; or run the command yourself in a terminal (see below) | + +### prod-db-guard.sh (PreToolUse, Bash) + +| | | +|---|---| +| Triggers on | (1) `drizzle-kit push` / `pnpm db:push` when the same command references production: `--env production`, `PROD_DATABASE_URL`, `NODE_ENV=production`, or a `postgres://...` URL containing `prod`. (2) `psql`, `pnpm db:*`, or `drizzle-kit` invocations that reference production **and** contain `DROP DATABASE`, `DROP SCHEMA`, or `TRUNCATE`. (3) `drizzle-kit drop`, always | +| Not blocked | Plain `drizzle-kit push` / `pnpm db:push` in dev (the PostToolUse reminder fires instead); `drizzle-kit generate` / `migrate`; read-only `psql` against production; `TRUNCATE` against a non-production URL | +| Result | exit 2, stderr `[prod-db-guard] Blocked: drizzle-kit push against a production target (--env production). ...` naming the matched token | +| Bypass | Production schema changes go through `drizzle-kit generate`, review, `drizzle-kit migrate`. For a genuine one-off, run it yourself in a terminal | + +### coverage-check.sh (PostToolUse, Bash) + +| | | +|---|---| +| Triggers on | Command contains `vitest` (or `run-tests.sh`) **and** the output mentions coverage | +| Prints | `[coverage-check] Statement coverage 91.2% meets the 80% threshold ...` when the text reporter's `All files` row is present, otherwise a generic reminder. Threshold comes from `tdd.coverageThreshold` (default 80) | +| Silent when | No coverage in the output, or the command is not a test run | + +### migration-safety-reminder.sh (PostToolUse, Bash) + +| | | +|---|---| +| Triggers on | `drizzle-kit generate` / `pnpm db:generate` / `scripts/generate-migration.sh` whose output reports a generated migration; any `drizzle-kit push` / `pnpm db:push` | +| Prints | After generate: run the destructive-DDL check and read the SQL. The command is chosen from the cwd: `node scripts/check-destructive-migrations.js` when that file exists (framework repo), `pnpm db:check-destructive` when `package.json` defines it (generated `api/` project). After push: push is dev-only; staging and production take migrations | +| Silent when | Generate reported `No schema changes` or an error | + +### quality-gate-reminder.sh (PostToolUse, Bash) + +| | | +|---|---| +| Triggers on | A test command (`vitest`, `pnpm test`, `run-tests.sh`) whose output contains `Tests N passed` and no `failed` | +| Prints | `[quality-gate] All tests passed. Run the quality gate ...: ./scripts/check-types.sh && ./scripts/security-scan.sh, then confirm coverage is at least 80% ...` | +| Silent when | Any test failed, or there is no summary line | + +## Bypassing a guard legitimately + +The guards govern **Claude's Bash tool only**. Options, from most to least targeted: + +1. **Do the safe thing instead.** Stage `.env.example`; generate a migration instead of pushing; put the `TRUNCATE` in a reviewed migration. +2. **Run the command yourself** in your own terminal. Hooks do not run there. +3. **Launch Claude with `NERVA_SKIP_HOOKS=1`** (`NERVA_SKIP_HOOKS=1 claude`). Every hook exits 0 immediately. This has to be set in the environment Claude Code starts from; a `NERVA_SKIP_HOOKS=1 git add .env` typed by Claude does *not* work, because the prefix is stripped by the parser and the hook's own environment is untouched. +4. **Remove the entry from `.claude/settings.json`** for the session and restore it afterwards. Prefer the options above; `settings.local.json` cannot disable a hook defined in `settings.json`. + +## Adding a hook + +### 1. Write the script + +`.claude/hooks/my-hook.sh`: + +```bash +#!/usr/bin/env bash +# my-hook.sh — PostToolUse hook (matcher: Bash): one line on what it does. +set -u +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +source "$HOOK_DIR/lib/hook-input.sh" + +hook_skip_requested && exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 +printf '%s' "$CMD" | grep -qE 'git tag' || exit 0 + +OUT="$(hook_tool_output)" +THRESHOLD="$(common_config_get tdd.coverageThreshold 80)" +echo "[my-hook] New tag created. Update CHANGELOG.md if you have not." +exit 0 +``` + +For a PreToolUse guard, add `set -f`, source `lib/bash-cmd.sh`, walk `HOOK_SEGMENTS`, and finish with: + +```bash +if [[ -n "$BLOCK" ]]; then + echo "[my-guard] Blocked: $BLOCK" >&2 + exit 2 +fi +exit 0 +``` + +Keep it fast (well under a second: it runs on every Bash call), prefix output with `[hook-name]`, and never `set -e`. + +### 2. Register it + +Append to the matching group in `.claude/settings.json`: + +```json +{ + "type": "command", + "command": "bash .claude/hooks/my-hook.sh", + "description": "Remind to update the changelog after creating a git tag" +} +``` + +Entries in a group run in order; each is independent. + +### 3. Test it + +Try it by hand with a crafted payload: + +```bash +echo '{"tool_name":"Bash","tool_input":{"command":"git tag v1.2.0"},"tool_response":{"stdout":"","stderr":""}}' \ + | bash .claude/hooks/my-hook.sh; echo "exit=$?" + +echo 'garbage' | bash .claude/hooks/my-hook.sh; echo "exit=$?" # must be 0 and silent +bash -n .claude/hooks/my-hook.sh +``` + +Then add a `describe` block to `scripts/__tests__/hooks.test.js`. The file already has `runHook(name, payload, { cwd, env })`, `pre(command)`, `post(command, stdout, stderr)`, `configEnv({...})` for a throwaway `pipeline.config.json` (via `NERVA_PIPELINE_CONFIG`), and `gitRepo({...})` for a throwaway repository: + +```js +describe("my-hook.sh (PostToolUse)", () => { + it("fires after git tag", () => { + const r = runHook("my-hook.sh", post("git tag v1.2.0", "")); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("[my-hook]"); + }); + + it("stays silent otherwise", () => { + expect(runHook("my-hook.sh", post("git status", "")).stdout).toBe(""); + }); +}); +``` + +Add the file name to `PRE_HOOKS` or `POST_HOOKS` at the top of the test file: that enrols it in the `bash -n`, settings.json wiring, and bad-input robustness suites automatically. Run: + +```bash +pnpm test -- scripts/__tests__/hooks.test.js +``` + +## Known limits + +- The command parser does not descend into `bash -c "..."`, `eval`, or scripts invoked by path. `.gitignore` and database credentials scoping remain the real backstops. +- `git add .` / `git add -A` are allowed; they are far too common to block and `.gitignore` covers them. +- `git commit -a` checks tracked files from the project root; a nested repository under `api/` is not inspected. +- SQL inside a file (`psql -f drop.sql`) is invisible to prod-db-guard. +- Only one heredoc per line is recognised. + +## Troubleshooting + +**A guard blocked something legitimate.** The stderr line names the token that matched. Rename to `*.example`, untrack the file, or use one of the bypasses above. + +**A hook does nothing.** Run it by hand with the payload shown in "Test it" and check `echo $?`. Then confirm the entry exists in `.claude/settings.json` (hooks are read at session start; restart Claude Code after editing). + +**"jq: command not found" style noise.** Hooks should never print that; `hook_get` falls back to node. If you see it, the hook is calling jq directly instead of using `hook_get`. + +**Hook output not visible.** PostToolUse reminders go to stdout; PreToolUse block reasons go to stderr. Swapping them makes the message disappear. diff --git a/scripts/__tests__/hooks.test.js b/scripts/__tests__/hooks.test.js new file mode 100644 index 0000000..39aa6ca --- /dev/null +++ b/scripts/__tests__/hooks.test.js @@ -0,0 +1,465 @@ +/** + * Tests for the Claude Code hooks in .claude/hooks/. + * + * Every hook reads one JSON object on stdin (the Claude Code hook contract): + * { tool_name, tool_input: { command }, tool_response? } + * + * PreToolUse hooks exit 2 to block (message on stderr) and 0 to allow. + * PostToolUse hooks always exit 0; a reminder on stdout, or nothing. + * All hooks must exit 0 (and stay quiet) on empty or malformed stdin. + */ +import { describe, it, expect, afterAll } from "vitest"; +import { join } from "path"; +import { existsSync, readFileSync, readdirSync } from "fs"; +import { run, tmpProject, HOOKS_DIR, REPO_ROOT } from "./helpers.js"; + +const PRE_HOOKS = ["secret-guard.sh", "prod-db-guard.sh"]; +const POST_HOOKS = ["coverage-check.sh", "migration-safety-reminder.sh", "quality-gate-reminder.sh"]; +const ALL_HOOKS = [...PRE_HOOKS, ...POST_HOOKS]; + +const projects = []; +afterAll(() => { + for (const p of projects) p.cleanup(); +}); + +function scratch(prefix = "nerva-hooks-") { + const p = tmpProject(prefix); + projects.push(p); + return p; +} + +function runHook(name, payload, opts = {}) { + const input = typeof payload === "string" ? payload : JSON.stringify(payload); + const { env = {}, ...rest } = opts; + return run("bash", [join(HOOKS_DIR, name)], { + input, + // Neutralise any user-level bypass so assertions are deterministic. + env: { NERVA_SKIP_HOOKS: "", ...env }, + ...rest, + }); +} + +const pre = (command) => ({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + cwd: REPO_ROOT, +}); + +const post = (command, stdout = "", stderr = "") => ({ + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command }, + tool_response: { stdout, stderr, interrupted: false }, + cwd: REPO_ROOT, +}); + +/** A pipeline.config.json in a scratch dir, returned as env for NERVA_PIPELINE_CONFIG. */ +function configEnv(config) { + const p = scratch("nerva-hooks-cfg-"); + const file = p.write(".claude/pipeline.config.json", JSON.stringify(config)); + return { NERVA_PIPELINE_CONFIG: file }; +} + +/** A throwaway git repo with the given files staged (tracked). */ +function gitRepo(files = {}) { + const p = scratch("nerva-hooks-git-"); + const env = { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }; + expect(run("git", ["init", "-q"], { cwd: p.dir, env }).exitCode).toBe(0); + const names = Object.keys(files); + for (const name of names) p.write(name, files[name]); + if (names.length > 0) { + // -f so a global excludes file that ignores .env cannot break the fixture. + expect(run("git", ["add", "-f", "--", ...names], { cwd: p.dir, env }).exitCode).toBe(0); + } + return p; +} + +// --------------------------------------------------------------------------- + +describe("hook files", () => { + const libs = readdirSync(join(HOOKS_DIR, "lib")).filter((f) => f.endsWith(".sh")); + + for (const file of [...ALL_HOOKS, ...libs.map((l) => join("lib", l))]) { + it(`${file} passes bash -n`, () => { + const r = run("bash", ["-n", join(HOOKS_DIR, file)]); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); + }); + } + + it("lib/hook-input.sh and lib/bash-cmd.sh exist", () => { + expect(existsSync(join(HOOKS_DIR, "lib", "hook-input.sh"))).toBe(true); + expect(existsSync(join(HOOKS_DIR, "lib", "bash-cmd.sh"))).toBe(true); + }); +}); + +describe(".claude/settings.json", () => { + const settings = JSON.parse(readFileSync(join(REPO_ROOT, ".claude", "settings.json"), "utf8")); + + function entries(event) { + const groups = settings.hooks?.[event] ?? []; + return groups.flatMap((g) => { + expect(g.matcher).toBe("Bash"); + return g.hooks; + }); + } + + it("wires every PreToolUse hook with a description and an existing script", () => { + const wired = entries("PreToolUse"); + for (const h of wired) { + expect(h.type).toBe("command"); + expect(h.description?.length ?? 0).toBeGreaterThan(10); + const m = h.command.match(/^bash \.claude\/hooks\/([\w-]+\.sh)$/); + expect(m, `unexpected command: ${h.command}`).not.toBeNull(); + expect(existsSync(join(HOOKS_DIR, m[1]))).toBe(true); + } + expect(wired.map((h) => h.command.split("/").pop()).sort()).toEqual([...PRE_HOOKS].sort()); + }); + + it("wires every PostToolUse hook with a description and an existing script", () => { + const wired = entries("PostToolUse"); + for (const h of wired) { + expect(h.type).toBe("command"); + expect(h.description?.length ?? 0).toBeGreaterThan(10); + const m = h.command.match(/^bash \.claude\/hooks\/([\w-]+\.sh)$/); + expect(m, `unexpected command: ${h.command}`).not.toBeNull(); + expect(existsSync(join(HOOKS_DIR, m[1]))).toBe(true); + } + expect(wired.map((h) => h.command.split("/").pop()).sort()).toEqual([...POST_HOOKS].sort()); + }); +}); + +// --------------------------------------------------------------------------- + +describe("secret-guard.sh (PreToolUse)", () => { + const blocks = [ + "git add .env", + "git add -f secret.pem", + "git add --force src/", + "git add -Af src/", + "git add api/.env.local", + "git add config/credentials.json", + "git add certs/server.key", + "git add certs/client.p12", + "git add .dev.vars", + "git stage ./.env", + 'git add "api/.env"', + "cd api && git add .env", + "git add src/ ; git add .env", + "git add src/ || git add .env", + "(cd api && git add .env)", + "git -C api add .env", + "FOO=1 git add .env", + "echo `git add .env`", + "cat < x\nfoo\nEOF\ngit add .env", + ]; + for (const command of blocks) { + it(`blocks: ${JSON.stringify(command)}`, () => { + const r = runHook("secret-guard.sh", pre(command)); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("[secret-guard]"); + expect(r.stdout).toBe(""); + }); + } + + it("names the offending token", () => { + expect(runHook("secret-guard.sh", pre("git add .env")).stderr).toContain("(.env)"); + expect(runHook("secret-guard.sh", pre("git add -f secret.pem")).stderr).toContain("(-f)"); + expect(runHook("secret-guard.sh", pre("git add api/.env.local")).stderr).toContain("api/.env.local"); + }); + + const allows = [ + "git add .env.example", + "git add .env.local.example", + "git add src/", + "git add .", + "git add -- src", + "git add src/env.ts", + 'git commit -m "add .env support"', + 'git commit -m "chore: git add .env to ignore"', + 'echo "git add .env"', + "# git add .env", + "cat <<'EOF' > notes.md\ngit add .env\nEOF\ngit status", + "cat <<-EOF > notes.md\n\tgit add .env\n\tEOF\ngit status 2>&1", + "git log --oneline | head", + "git status && git diff", + "ls -la", + ]; + for (const command of allows) { + it(`allows: ${JSON.stringify(command)}`, () => { + const r = runHook("secret-guard.sh", pre(command)); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + }); + } + + it("blocks commit -a / -am / --all when a secret file is tracked", () => { + const repo = gitRepo({ ".env": "SECRET=1\n", "src/index.ts": "export {};\n" }); + for (const command of ['git commit -am "wip"', 'git commit -a -m "wip"', 'git commit --all -m "wip"']) { + const r = runHook("secret-guard.sh", pre(command), { cwd: repo.dir }); + expect(r.exitCode, command).toBe(2); + expect(r.stderr).toContain("[secret-guard]"); + expect(r.stderr).toContain(".env"); + } + }); + + it("allows commit -a when only .env.example is tracked, and plain commit anywhere", () => { + const clean = gitRepo({ ".env.example": "SECRET=\n", "src/index.ts": "export {};\n" }); + expect(runHook("secret-guard.sh", pre('git commit -am "wip"'), { cwd: clean.dir }).exitCode).toBe(0); + + const dirty = gitRepo({ "certs/server.pem": "---\n" }); + expect(runHook("secret-guard.sh", pre('git commit -m "wip"'), { cwd: dirty.dir }).exitCode).toBe(0); + expect(runHook("secret-guard.sh", pre('git commit -am "wip"'), { cwd: dirty.dir }).exitCode).toBe(2); + }); + + it("ignores non-Bash tools", () => { + const r = runHook("secret-guard.sh", { tool_name: "Write", tool_input: { file_path: ".env", content: "x" } }); + expect(r.exitCode).toBe(0); + }); + + it("honours NERVA_SKIP_HOOKS=1 from the hook environment", () => { + const r = runHook("secret-guard.sh", pre("git add .env"), { env: { NERVA_SKIP_HOOKS: "1" } }); + expect(r.exitCode).toBe(0); + }); + + it("works through the node JSON fallback", () => { + const r = runHook("secret-guard.sh", pre("git add .env"), { env: { NERVA_HOOK_JSON_TOOL: "node" } }); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("(.env)"); + }); +}); + +// --------------------------------------------------------------------------- + +describe("prod-db-guard.sh (PreToolUse)", () => { + const blocks = [ + ["wrangler secret put X && drizzle-kit push --env production", "production"], + ["npx drizzle-kit push --env=production", "production"], + ["DATABASE_URL=postgres://user@db.prod.internal/app pnpm drizzle-kit push", "prod"], + ["NODE_ENV=production pnpm db:push", "NODE_ENV=production"], + ['PROD_DATABASE_URL=postgres://x psql -c "TRUNCATE users"', "TRUNCATE"], + ['psql "$PROD_DATABASE_URL" -c "DROP SCHEMA public CASCADE"', "DROP SCHEMA"], + ['PROD_DATABASE_URL=postgres://x pnpm db:seed -- --sql "drop database app"', /drop database/i], + ["drizzle-kit drop", "drop"], + ["pnpm drizzle-kit drop", "drop"], + ["cd api && npx drizzle-kit drop", "drop"], + ]; + for (const [command, token] of blocks) { + it(`blocks: ${JSON.stringify(command)}`, () => { + const r = runHook("prod-db-guard.sh", pre(command)); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("[prod-db-guard]"); + if (token instanceof RegExp) expect(r.stderr).toMatch(token); + else expect(r.stderr).toContain(token); + expect(r.stdout).toBe(""); + }); + } + + const allows = [ + "drizzle-kit push", + "pnpm drizzle-kit push", + "pnpm db:push", + "drizzle-kit generate", + "pnpm drizzle-kit generate && pnpm drizzle-kit migrate", + 'psql $PROD_DATABASE_URL -c "SELECT count(*) FROM users"', + "PROD_DATABASE_URL=postgres://x pnpm db:migrate", + 'psql dev -c "TRUNCATE users"', + 'git commit -m "docs: drop database section"', + 'echo "drizzle-kit drop"', + 'grep -r "drizzle-kit push" docs/', + "pnpm test", + ]; + for (const command of allows) { + it(`allows: ${JSON.stringify(command)}`, () => { + const r = runHook("prod-db-guard.sh", pre(command)); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + }); + } + + it("honours NERVA_SKIP_HOOKS=1 from the hook environment", () => { + const r = runHook("prod-db-guard.sh", pre("drizzle-kit drop"), { env: { NERVA_SKIP_HOOKS: "1" } }); + expect(r.exitCode).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- + +describe("coverage-check.sh (PostToolUse)", () => { + const coverageTable = [ + "% Coverage report from v8", + "-----------|---------|----------|---------|---------|", + "File | % Stmts | % Branch | % Funcs | % Lines |", + "All files | 91.2 | 80.5 | 88.9 | 91.2 |", + ].join("\n"); + + it("fires for vitest + coverage using tdd.coverageThreshold from NERVA_PIPELINE_CONFIG", () => { + const env = configEnv({ tdd: { coverageThreshold: 85 } }); + const r = runHook("coverage-check.sh", post("pnpm vitest run --coverage", coverageTable), { env }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("[coverage-check]"); + expect(r.stdout).toContain("85%"); + expect(r.stdout).toContain("91.2%"); + expect(r.stdout).toMatch(/meets/); + }); + + it("flags coverage below the threshold", () => { + const env = configEnv({ tdd: { coverageThreshold: 85 } }); + const r = runHook("coverage-check.sh", post("vitest --coverage", "% Coverage report from v8\nAll files | 62.5 | 50 |"), { env }); + expect(r.stdout).toContain("62.5%"); + expect(r.stdout).toContain("BELOW"); + expect(r.stdout).toContain("85%"); + }); + + it("falls back to 80% when the config file is missing", () => { + const p = scratch(); + const env = { NERVA_PIPELINE_CONFIG: join(p.dir, "does-not-exist.json") }; + const r = runHook("coverage-check.sh", post("pnpm vitest run --coverage", "Coverage enabled with v8"), { env }); + expect(r.stdout).toContain("[coverage-check]"); + expect(r.stdout).toContain("80%"); + }); + + it("accepts a string tool_response", () => { + const env = configEnv({ tdd: { coverageThreshold: 90 } }); + const payload = { tool_name: "Bash", tool_input: { command: "vitest run --coverage" }, tool_response: "Coverage summary" }; + const r = runHook("coverage-check.sh", payload, { env }); + expect(r.stdout).toContain("90%"); + }); + + it("reads coverage from stderr too", () => { + const r = runHook("coverage-check.sh", post("pnpm vitest run --coverage", "", "Coverage enabled")); + expect(r.stdout).toContain("[coverage-check]"); + }); + + it("works through the node JSON fallback", () => { + const env = { ...configEnv({ tdd: { coverageThreshold: 77 } }), NERVA_HOOK_JSON_TOOL: "node" }; + const r = runHook("coverage-check.sh", post("pnpm vitest run --coverage", coverageTable), { env }); + expect(r.stdout).toContain("77%"); + }); + + it("stays silent for vitest without coverage output", () => { + const r = runHook("coverage-check.sh", post("pnpm vitest run", "Tests 12 passed (12)")); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe(""); + }); + + it("stays silent for non-vitest commands even when the output mentions coverage", () => { + const r = runHook("coverage-check.sh", post("pnpm build", "Coverage: full")); + expect(r.stdout).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- + +describe("migration-safety-reminder.sh (PostToolUse)", () => { + const generated = "[✓] Your SQL migration file ➜ drizzle/0001_bold_hulk.sql 🚀"; + + it("after drizzle-kit generate in the framework repo, points at check-destructive-migrations.js", () => { + const p = scratch(); + p.write("scripts/check-destructive-migrations.js", "// stub\n"); + const r = runHook("migration-safety-reminder.sh", post("pnpm drizzle-kit generate", generated), { cwd: p.dir }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("[migration-safety]"); + expect(r.stdout).toContain("node scripts/check-destructive-migrations.js"); + expect(r.stdout).toMatch(/read the new SQL/i); + }); + + it("after drizzle-kit generate in a generated api/ project, points at pnpm db:check-destructive", () => { + const p = scratch(); + p.write("package.json", JSON.stringify({ scripts: { "db:check-destructive": "node scripts/check-destructive.js" } })); + const r = runHook("migration-safety-reminder.sh", post("pnpm db:generate", generated), { cwd: p.dir }); + expect(r.stdout).toContain("pnpm db:check-destructive"); + expect(r.stdout).not.toContain("node scripts/check-destructive-migrations.js"); + }); + + it("stays silent when generate produced nothing or failed", () => { + const p = scratch(); + p.write("scripts/check-destructive-migrations.js", "// stub\n"); + const none = runHook("migration-safety-reminder.sh", post("drizzle-kit generate", "No schema changes, nothing to migrate 😴"), { cwd: p.dir }); + expect(none.stdout).toBe(""); + const failed = runHook("migration-safety-reminder.sh", post("drizzle-kit generate", "", "Error: cannot find drizzle.config.ts"), { cwd: p.dir }); + expect(failed.stdout).toBe(""); + const empty = runHook("migration-safety-reminder.sh", post("drizzle-kit generate", ""), { cwd: p.dir }); + expect(empty.stdout).toBe(""); + }); + + it("reminds that push is dev-only after any drizzle-kit push", () => { + for (const command of ["drizzle-kit push", "pnpm drizzle-kit push --force", "pnpm db:push"]) { + const r = runHook("migration-safety-reminder.sh", post(command, "[✓] Changes applied")); + expect(r.exitCode, command).toBe(0); + expect(r.stdout).toContain("[migration-safety]"); + expect(r.stdout).toMatch(/dev-only/); + expect(r.stdout).toContain("migrate"); + } + }); + + it("stays silent for unrelated commands", () => { + const r = runHook("migration-safety-reminder.sh", post("pnpm drizzle-kit studio", "Drizzle Studio is up")); + expect(r.stdout).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- + +describe("quality-gate-reminder.sh (PostToolUse)", () => { + const passed = "Test Files 3 passed (3)\n Tests 42 passed (42)\n Duration 1.20s"; + + it("fires when every vitest test passed, with the configured threshold", () => { + const env = configEnv({ tdd: { coverageThreshold: 85 } }); + const r = runHook("quality-gate-reminder.sh", post("pnpm vitest run", passed), { env }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("[quality-gate]"); + expect(r.stdout).toContain("./scripts/check-types.sh"); + expect(r.stdout).toContain("./scripts/security-scan.sh"); + expect(r.stdout).toContain("85%"); + }); + + it("also fires for pnpm test and run-tests.sh", () => { + expect(runHook("quality-gate-reminder.sh", post("pnpm test", passed)).stdout).toContain("[quality-gate]"); + expect(runHook("quality-gate-reminder.sh", post("./scripts/run-tests.sh", passed)).stdout).toContain("[quality-gate]"); + }); + + it("stays silent when any test failed", () => { + const r = runHook("quality-gate-reminder.sh", post("pnpm vitest run", "Tests 1 failed | 41 passed (42)")); + expect(r.stdout).toBe(""); + }); + + it("stays silent without a passing summary or for non-test commands", () => { + expect(runHook("quality-gate-reminder.sh", post("pnpm vitest run", "compiling...")).stdout).toBe(""); + expect(runHook("quality-gate-reminder.sh", post("pnpm build", passed)).stdout).toBe(""); + expect(runHook("quality-gate-reminder.sh", post("pnpm vitest run", "")).stdout).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- + +describe("robustness: every hook fails open on bad input", () => { + const badInputs = [ + ["empty stdin", ""], + ["plain text", "not json at all"], + ["empty object", "{}"], + ["missing tool_input", JSON.stringify({ tool_name: "Bash" })], + ["tool_input is a string", JSON.stringify({ tool_name: "Bash", tool_input: "git add .env" })], + ["array", "[1,2,3]"], + ["truncated JSON", '{"tool_name":"Bash","tool_input":{"command":"git add .env"'], + ]; + + for (const hook of ALL_HOOKS) { + for (const [label, input] of badInputs) { + it(`${hook} exits 0 silently on ${label}`, () => { + const r = runHook(hook, input); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(""); + }); + } + } + + for (const hook of POST_HOOKS) { + it(`${hook} exits 0 when tool_response is missing`, () => { + const r = runHook(hook, { tool_name: "Bash", tool_input: { command: "pnpm vitest run --coverage && drizzle-kit generate" } }); + expect(r.exitCode).toBe(0); + }); + } +}); From 8c16c9d33570d35a5fff4853f3b2cbff6ec56519 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 9 Sep 2026 16:24:40 -0400 Subject: [PATCH 05/11] feat(config): add pipeline.config.json schema and validator Strict draft 2020-12 schema for every section of pipeline.config.json, and validate-pipeline-config.js (ajv) with structural checks the schema cannot express: phase dependency targets, cycles, reachability, defaultTarget in targets, and coverage-threshold consistency. The config now points $schema at the project schema. Co-Authored-By: Claude Fable 5.1 --- .claude/pipeline.config.json | 2 +- .claude/pipeline.config.schema.json | 510 ++++++++++++++++++ scripts/__tests__/pipeline-config-lib.test.js | 150 ++++++ .../validate-pipeline-config.test.js | 258 +++++++++ scripts/validate-pipeline-config.js | 345 ++++++++++++ 5 files changed, 1264 insertions(+), 1 deletion(-) create mode 100644 .claude/pipeline.config.schema.json create mode 100644 scripts/__tests__/pipeline-config-lib.test.js create mode 100644 scripts/__tests__/validate-pipeline-config.test.js create mode 100644 scripts/validate-pipeline-config.js diff --git a/.claude/pipeline.config.json b/.claude/pipeline.config.json index 1b7b84f..67c23f6 100644 --- a/.claude/pipeline.config.json +++ b/.claude/pipeline.config.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "./pipeline.config.schema.json", "description": "Configuration for the OpenAPI-to-Working-API autonomous build pipeline", "version": "1.0.0", "tdd": { diff --git a/.claude/pipeline.config.schema.json b/.claude/pipeline.config.schema.json new file mode 100644 index 0000000..cecb6be --- /dev/null +++ b/.claude/pipeline.config.schema.json @@ -0,0 +1,510 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/PMDevSolutions/Nerva/blob/main/.claude/pipeline.config.schema.json", + "title": "Nerva Pipeline Configuration", + "description": "Schema for .claude/pipeline.config.json — controls TDD enforcement, database defaults, auth, testing, quality gates, deployment targets, security, API conventions, reporting, and orchestration for the OpenAPI-to-Working-API pipeline.", + "type": "object", + "required": ["version"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for IDE validation and autocompletion. Should point at ./pipeline.config.schema.json." + }, + "description": { + "type": "string", + "description": "Human-readable summary of what this config controls." + }, + "version": { + "$ref": "#/$defs/semver", + "description": "Semantic version of the config document." + }, + + "tdd": { + "type": "object", + "additionalProperties": false, + "description": "Test-driven development enforcement for the build pipeline (Phase 2 hard gate).", + "properties": { + "enforced": { "type": "boolean", "description": "Hard-gate Phase 3 (route generation) on Phase 2 (failing tests) completion." }, + "redPhaseRequired": { "type": "boolean", "description": "Tests must be confirmed failing (RED) before handlers are written." }, + "greenPhaseRequired": { "type": "boolean", "description": "Tests must pass (GREEN) before the phase is considered complete." }, + "refactorPhaseOptional": { "type": "boolean", "description": "Whether the refactor step may be skipped after GREEN." }, + "coverageThreshold": { "$ref": "#/$defs/percentage", "description": "Minimum line coverage percentage the TDD phase targets." }, + "integrationTestRequired": { "type": "boolean", "description": "Every endpoint must have an integration test before its handler exists." }, + "contractTestRequired": { "type": "boolean", "description": "Responses must be validated against the OpenAPI spec." }, + "testExistenceGate": { "type": "boolean", "description": "Block route generation until a test file exists for every endpoint." } + } + }, + + "database": { + "type": "object", + "additionalProperties": false, + "description": "Database provider, ORM, connection pooling, and schema defaults.", + "properties": { + "defaultProvider": { "type": "string", "enum": ["postgresql"], "description": "Database engine the generated API targets." }, + "orm": { "type": "string", "enum": ["drizzle"], "description": "ORM used for schema, queries, and migrations." }, + "connectionPoolSize": { "$ref": "#/$defs/positiveInt", "description": "Maximum connections in the pool." }, + "connectionPoolMin": { "type": "integer", "minimum": 0, "description": "Minimum idle connections kept open in the pool." }, + "idleTimeoutMs": { "$ref": "#/$defs/positiveInt", "description": "Milliseconds an idle connection stays open before being closed." }, + "migrationSafety": { "type": "boolean", "description": "Review generated migrations for destructive operations before applying." }, + "blockDestructiveMigrations": { "type": "boolean", "description": "Refuse to apply migrations that drop tables or columns." }, + "softDeleteDefault": { "type": "boolean", "description": "Generate a deletedAt column and soft-delete semantics by default." }, + "timestampsDefault": { "type": "boolean", "description": "Add createdAt/updatedAt columns to every table by default." }, + "uuidPrimaryKeys": { "type": "boolean", "description": "Use UUID primary keys for generated tables." }, + "serialPrimaryKeys": { "type": "boolean", "description": "Use serial (auto-increment integer) primary keys for generated tables." } + } + }, + + "auth": { + "type": "object", + "additionalProperties": false, + "description": "Authentication and authorization defaults applied by the auth-middleware phase.", + "properties": { + "defaultStrategy": { "type": "string", "enum": ["jwt", "oauth2", "api-key", "session"], "description": "Authentication scheme generated when the OpenAPI spec does not specify one." }, + "jwtAlgorithm": { "type": "string", "enum": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "EdDSA"], "description": "JOSE algorithm used to sign JWTs." }, + "tokenExpiry": { "$ref": "#/$defs/duration", "description": "Access token lifetime (e.g. \"15m\")." }, + "refreshTokenExpiry": { "$ref": "#/$defs/duration", "description": "Refresh token lifetime (e.g. \"7d\")." }, + "bcryptRounds": { "type": "integer", "minimum": 4, "maximum": 31, "description": "bcrypt cost factor for password hashing." }, + "apiKeyLength": { "type": "integer", "minimum": 16, "description": "Byte length of generated API keys." }, + "rateLimitPerUser": { "$ref": "#/$defs/positiveInt", "description": "Requests per window allowed per authenticated user." }, + "sessionStrategy": { "type": "string", "enum": ["stateless", "stateful"], "description": "Whether sessions are stored server-side (stateful) or encoded in the token (stateless)." }, + "rbacEnabled": { "type": "boolean", "description": "Generate role-based access control middleware." } + } + }, + + "testing": { + "type": "object", + "additionalProperties": false, + "description": "Test framework, timeouts, load testing, and test database strategy.", + "properties": { + "framework": { "type": "string", "enum": ["vitest"], "description": "Unit and integration test runner." }, + "httpClient": { "type": "string", "enum": ["supertest", "hono-testing"], "description": "HTTP client used by integration tests." }, + "integrationTimeout": { "$ref": "#/$defs/positiveInt", "description": "Per-test timeout in milliseconds for integration tests." }, + "unitTimeout": { "$ref": "#/$defs/positiveInt", "description": "Per-test timeout in milliseconds for unit tests." }, + "contractTestEnabled": { "type": "boolean", "description": "Run contract tests that validate responses against the OpenAPI spec." }, + "loadTestEnabled": { "type": "boolean", "description": "Generate and run k6 load tests." }, + "loadTestVUs": { "$ref": "#/$defs/positiveInt", "description": "Number of k6 virtual users." }, + "loadTestDuration": { "$ref": "#/$defs/duration", "description": "k6 steady-state duration (e.g. \"30s\")." }, + "loadTestRampUp": { "$ref": "#/$defs/duration", "description": "k6 ramp-up duration (e.g. \"10s\")." }, + "loadTestThresholds": { + "type": "object", + "additionalProperties": false, + "description": "k6 pass/fail thresholds.", + "properties": { + "http_req_duration_p95": { "$ref": "#/$defs/positiveInt", "description": "Maximum p95 request duration in milliseconds." }, + "http_req_duration_p99": { "$ref": "#/$defs/positiveInt", "description": "Maximum p99 request duration in milliseconds." }, + "http_req_failed_rate": { "$ref": "#/$defs/ratio", "description": "Maximum fraction of failed requests (0-1)." } + } + }, + "testDatabase": { + "type": "object", + "additionalProperties": false, + "description": "How integration tests obtain an isolated database.", + "properties": { + "strategy": { "$ref": "#/$defs/testDatabaseStrategy", "description": "Primary isolation strategy." }, + "alternativeStrategy": { "$ref": "#/$defs/testDatabaseStrategy", "description": "Fallback isolation strategy when the primary is unavailable." }, + "seedBeforeTests": { "type": "boolean", "description": "Run the seed script before the integration suite." } + } + } + } + }, + + "qualityGate": { + "type": "object", + "additionalProperties": false, + "description": "Criteria that must pass in Phase 7 before deployment config and the report are produced.", + "properties": { + "testsRequired": { "type": "boolean", "description": "All tests must pass." }, + "typescriptRequired": { "type": "boolean", "description": "tsc --noEmit must succeed under strict mode." }, + "buildRequired": { "type": "boolean", "description": "The production build must succeed." }, + "securityAuditRequired": { "type": "boolean", "description": "security-scan.sh must report no vulnerabilities at or above the configured level." }, + "coverageThreshold": { "$ref": "#/$defs/percentage", "description": "Minimum line coverage percentage enforced at the gate." }, + "maxFixAttempts": { "type": "integer", "minimum": 0, "description": "How many automatic fix-and-retry loops run before the gate fails." }, + "eslintRequired": { "type": "boolean", "description": "ESLint must report zero errors." }, + "noAnyTypes": { "type": "boolean", "description": "Fail the gate if any `any` types are present in generated code." } + } + }, + + "deployment": { + "type": "object", + "additionalProperties": false, + "description": "Supported deployment targets and their per-target defaults.", + "properties": { + "targets": { + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { "$ref": "#/$defs/deploymentTarget" }, + "description": "Deployment targets the pipeline can generate configuration for." + }, + "defaultTarget": { "$ref": "#/$defs/deploymentTarget", "description": "Target used when build-spec.json does not name one. Must appear in targets." }, + "cloudflareWorkers": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the cloudflare-workers target (wrangler.toml).", + "properties": { + "compatibilityDate": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "Workers runtime compatibility date (YYYY-MM-DD)." }, + "nodeCompat": { "type": "boolean", "description": "Enable the nodejs_compat compatibility flag." }, + "hyperdrive": { "type": "boolean", "description": "Bind a Hyperdrive connection pool for PostgreSQL." }, + "kv": { "type": "boolean", "description": "Bind a Workers KV namespace." }, + "d1": { "type": "boolean", "description": "Bind a D1 database." }, + "r2": { "type": "boolean", "description": "Bind an R2 bucket." }, + "environments": { "$ref": "#/$defs/environmentList", "description": "Wrangler environments to generate." } + } + }, + "nodeDocker": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the node-docker target (Dockerfile, docker-compose).", + "properties": { + "nodeVersion": { "type": "string", "minLength": 1, "description": "Docker image tag for the node base image (e.g. \"22-alpine\")." }, + "port": { "$ref": "#/$defs/port", "description": "Container port the server listens on." }, + "healthCheckPath": { "$ref": "#/$defs/urlPath", "description": "HTTP path polled by the container health check." }, + "processManager": { "type": "string", "enum": ["node", "pm2"], "description": "Process supervisor used inside the container." }, + "multiStage": { "type": "boolean", "description": "Generate a multi-stage Dockerfile (build stage + runtime stage)." } + } + }, + "awsLambda": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the aws-lambda target (SAM template).", + "properties": { + "runtime": { "type": "string", "pattern": "^nodejs\\d+\\.x$", "description": "Lambda runtime identifier (e.g. \"nodejs22.x\")." }, + "architecture": { "type": "string", "enum": ["arm64", "x86_64"], "description": "Lambda CPU architecture." }, + "memorySizeMb": { "type": "integer", "minimum": 128, "maximum": 10240, "description": "Function memory in MB." }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 900, "description": "Function timeout in seconds (API Gateway caps at 29-30s)." }, + "apiGatewayType": { "type": "string", "enum": ["http-api", "rest-api"], "description": "API Gateway flavour fronting the function." }, + "bundler": { "type": "string", "enum": ["esbuild"], "description": "Bundler used to package the handler." }, + "framework": { "type": "string", "enum": ["sam"], "description": "Infrastructure-as-code tool used to deploy." }, + "provisionedConcurrency": { "type": "integer", "minimum": 0, "description": "Provisioned concurrency units (0 disables)." } + } + }, + "railway": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the railway target (railway.toml, nixpacks.toml).", + "properties": { + "builder": { "$ref": "#/$defs/paasBuilder", "description": "Build strategy Railway uses." }, + "healthcheckPath": { "$ref": "#/$defs/urlPath", "description": "HTTP path Railway polls before routing traffic." }, + "healthcheckTimeoutSeconds": { "$ref": "#/$defs/positiveInt", "description": "Seconds to wait for the health check to pass." }, + "restartPolicy": { "type": "string", "enum": ["on-failure", "always", "never"], "description": "Restart policy for crashed containers." }, + "restartPolicyMaxRetries": { "type": "integer", "minimum": 0, "description": "Maximum restart attempts under the on-failure policy." }, + "managedPostgres": { "type": "boolean", "description": "Provision a Railway-managed PostgreSQL service." }, + "autoDeployFromGitHub": { "type": "boolean", "description": "Deploy automatically on push to the connected GitHub branch." } + } + }, + "fly": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the fly target (fly.toml).", + "properties": { + "builder": { "$ref": "#/$defs/paasBuilder", "description": "Build strategy Fly uses." }, + "internalPort": { "$ref": "#/$defs/port", "description": "Port the app listens on inside the Machine." }, + "healthcheckPath": { "$ref": "#/$defs/urlPath", "description": "HTTP path Fly polls for Machine health." }, + "autoStopMachines": { "type": "string", "enum": ["off", "stop", "suspend"], "description": "What Fly does with idle Machines." }, + "autoStartMachines": { "type": "boolean", "description": "Start stopped Machines on incoming traffic." }, + "minMachinesRunning": { "type": "integer", "minimum": 0, "description": "Machines kept running regardless of traffic." }, + "maxMachines": { "$ref": "#/$defs/positiveInt", "description": "Upper bound on Machines under autoscaling." }, + "managedPostgres": { "type": "boolean", "description": "Attach a Fly Managed Postgres cluster." }, + "autoDeployFromGitHub": { "type": "boolean", "description": "Deploy automatically from GitHub Actions on push." } + } + }, + "cicd": { + "type": "object", + "additionalProperties": false, + "description": "Continuous integration and delivery workflow generation.", + "properties": { + "provider": { "type": "string", "enum": ["github-actions"], "description": "CI/CD platform the workflow is generated for." }, + "runTests": { "type": "boolean", "description": "Run the test suite in CI." }, + "runTypeCheck": { "type": "boolean", "description": "Run tsc --noEmit in CI." }, + "runSecurityAudit": { "type": "boolean", "description": "Run the dependency security audit in CI." }, + "runBuild": { "type": "boolean", "description": "Run the production build in CI." }, + "autoDeployStaging": { "type": "boolean", "description": "Deploy to staging automatically when CI passes on the main branch." }, + "autoDeployProduction": { "type": "boolean", "description": "Deploy to production automatically (usually false; use a manual approval)." } + } + } + } + }, + + "security": { + "type": "object", + "additionalProperties": false, + "description": "Security middleware defaults and audit policy.", + "properties": { + "rateLimiting": { + "type": "object", + "additionalProperties": false, + "description": "Global rate limiting middleware.", + "properties": { + "enabled": { "type": "boolean", "description": "Apply rate limiting to every route." }, + "requests": { "$ref": "#/$defs/positiveInt", "description": "Requests allowed per window." }, + "window": { "$ref": "#/$defs/duration", "description": "Window length (e.g. \"1m\")." }, + "strategy": { "type": "string", "enum": ["sliding-window", "fixed-window", "token-bucket"], "description": "Rate limiting algorithm." } + } + }, + "cors": { + "type": "object", + "additionalProperties": false, + "description": "Cross-origin resource sharing middleware.", + "properties": { + "enabled": { "type": "boolean", "description": "Emit CORS headers." }, + "origins": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Allowed origins. \"*\" is acceptable in development only." }, + "methods": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] }, "description": "Allowed HTTP methods." }, + "allowHeaders": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Allowed request headers." }, + "maxAge": { "type": "integer", "minimum": 0, "description": "Seconds a preflight response may be cached." } + } + }, + "headers": { + "type": "object", + "additionalProperties": false, + "description": "Helmet-equivalent security response headers.", + "properties": { + "hsts": { "type": "boolean", "description": "Send Strict-Transport-Security." }, + "noSniff": { "type": "boolean", "description": "Send X-Content-Type-Options: nosniff." }, + "xssProtection": { "type": "boolean", "description": "Send X-XSS-Protection." }, + "frameOptions": { "type": "string", "enum": ["DENY", "SAMEORIGIN"], "description": "Value for X-Frame-Options." } + } + }, + "audit": { + "type": "object", + "additionalProperties": false, + "description": "Dependency vulnerability audit policy (security-scan.sh).", + "properties": { + "level": { "type": "string", "enum": ["low", "moderate", "high", "critical"], "description": "Minimum severity that fails the audit." }, + "failOnVulnerability": { "type": "boolean", "description": "Fail the quality gate when a vulnerability at or above level is found." }, + "checkLockfile": { "type": "boolean", "description": "Verify the lockfile is present and in sync." } + } + }, + "inputValidation": { + "type": "object", + "additionalProperties": false, + "description": "Request size and sanitization limits.", + "properties": { + "maxBodySizeKb": { "$ref": "#/$defs/positiveInt", "description": "Maximum request body size in kilobytes." }, + "maxUrlLengthChars": { "$ref": "#/$defs/positiveInt", "description": "Maximum URL length in characters." }, + "sanitizeStrings": { "type": "boolean", "description": "Strip control characters and trim string inputs." } + } + } + } + }, + + "api": { + "type": "object", + "additionalProperties": false, + "description": "API response conventions shared by generated routes.", + "properties": { + "pagination": { + "type": "object", + "additionalProperties": false, + "description": "List endpoint pagination defaults.", + "properties": { + "defaultLimit": { "$ref": "#/$defs/positiveInt", "description": "Page size when the client omits limit." }, + "maxLimit": { "$ref": "#/$defs/positiveInt", "description": "Largest page size a client may request." }, + "strategy": { "type": "string", "enum": ["offset", "cursor", "keyset"], "description": "Default pagination style." }, + "cursorPaginationAvailable": { "type": "boolean", "description": "Also expose cursor pagination on list endpoints." } + } + }, + "responseFormat": { "type": "string", "enum": ["json"], "description": "Wire format for responses." }, + "envelope": { + "type": "object", + "additionalProperties": false, + "description": "Field names of the standard response envelope.", + "properties": { + "dataField": { "type": "string", "minLength": 1, "description": "Key holding the payload." }, + "metaField": { "type": "string", "minLength": 1, "description": "Key holding pagination and request metadata." }, + "errorField": { "type": "string", "minLength": 1, "description": "Key holding structured error details." } + } + }, + "versioning": { + "type": "object", + "additionalProperties": false, + "description": "API versioning strategy.", + "properties": { + "strategy": { "type": "string", "enum": ["url-prefix", "header", "query", "none"], "description": "Where the API version is carried." }, + "currentVersion": { "type": "string", "pattern": "^v\\d+$", "description": "Current version label (e.g. \"v1\")." }, + "deprecationHeaderEnabled": { "type": "boolean", "description": "Send Deprecation/Sunset headers on deprecated versions." } + } + }, + "filtering": { + "type": "object", + "additionalProperties": false, + "description": "Query-string filtering on list endpoints.", + "properties": { + "enabled": { "type": "boolean", "description": "Generate filter parsing for list endpoints." }, + "operators": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "nin", "between", "isnull"] }, "description": "Filter operators clients may use." } + } + }, + "sorting": { + "type": "object", + "additionalProperties": false, + "description": "Sorting on list endpoints.", + "properties": { + "enabled": { "type": "boolean", "description": "Generate sort parsing for list endpoints." }, + "defaultField": { "type": "string", "minLength": 1, "description": "Field used when the client omits a sort." }, + "defaultOrder": { "type": "string", "enum": ["asc", "desc"], "description": "Direction used when the client omits one." }, + "maxFields": { "$ref": "#/$defs/positiveInt", "description": "Maximum number of sort fields per request." } + } + }, + "healthCheck": { + "type": "object", + "additionalProperties": false, + "description": "Health endpoint configuration.", + "properties": { + "path": { "$ref": "#/$defs/urlPath", "description": "Route path of the health endpoint." }, + "includeDbStatus": { "type": "boolean", "description": "Report database connectivity in the health response." }, + "includeVersion": { "type": "boolean", "description": "Report the application version in the health response." } + } + } + } + }, + + "reporting": { + "type": "object", + "additionalProperties": false, + "description": "Where and what the Phase 9 build report writes.", + "properties": { + "outputDir": { "type": "string", "minLength": 1, "description": "Directory (relative to the project root) that receives report files." }, + "buildReportFile": { "type": "string", "minLength": 1, "description": "Filename of the main build report." }, + "testReportFile": { "type": "string", "minLength": 1, "description": "Filename of the test results report." }, + "loadTestReportFile": { "type": "string", "minLength": 1, "description": "Filename of the k6 load test report." }, + "securityReportFile": { "type": "string", "minLength": 1, "description": "Filename of the security audit report." }, + "includeEndpointInventory": { "type": "boolean", "description": "List every generated endpoint in the build report." }, + "includeSchemaDiagram": { "type": "boolean", "description": "Embed an entity-relationship diagram in the build report." } + } + }, + + "orchestration": { + "type": "object", + "additionalProperties": false, + "description": "Phase dependency graph and concurrency for the autonomous pipeline.", + "properties": { + "enabled": { "type": "boolean", "description": "Run independent phases concurrently according to the dependency graph." }, + "maxConcurrent": { "$ref": "#/$defs/positiveInt", "description": "Maximum phases running at once." }, + "phases": { + "type": "object", + "description": "Pipeline phases keyed by name. Names must be kebab-case.", + "minProperties": 1, + "propertyNames": { "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, + "additionalProperties": { "$ref": "#/$defs/phase" } + }, + "qualityGateSubtasks": { + "type": "object", + "description": "Independent checks run in parallel inside the quality-gate phase, keyed by name.", + "propertyNames": { "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, + "additionalProperties": { "$ref": "#/$defs/subtask" } + }, + "reporting": { + "type": "object", + "additionalProperties": false, + "description": "Progress output while phases run.", + "properties": { + "showStreamingResults": { "type": "boolean", "description": "Print each phase result as it completes." }, + "showBatchSummary": { "type": "boolean", "description": "Print a summary after each concurrent batch." }, + "showSpeedupEstimate": { "type": "boolean", "description": "Estimate wall-clock savings versus sequential execution." }, + "includeTimeline": { "type": "boolean", "description": "Include a phase timeline in the final report." } + } + } + } + } + }, + + "$defs": { + "semver": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$", + "description": "Semantic version string (MAJOR.MINOR.PATCH with optional pre-release and build metadata)." + }, + "percentage": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Percentage between 0 and 100." + }, + "ratio": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fraction between 0 and 1 (e.g. 0.01 = 1%)." + }, + "positiveInt": { + "type": "integer", + "minimum": 1, + "description": "Whole number greater than zero." + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "TCP port number." + }, + "duration": { + "type": "string", + "pattern": "^\\d+(ms|s|m|h|d)$", + "description": "Duration string: integer followed by ms, s, m, h, or d (e.g. \"30s\", \"15m\", \"7d\")." + }, + "urlPath": { + "type": "string", + "pattern": "^/", + "description": "Absolute URL path beginning with a slash." + }, + "environmentList": { + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { "type": "string", "enum": ["development", "staging", "production"] }, + "description": "Named deployment environments." + }, + "deploymentTarget": { + "type": "string", + "enum": ["cloudflare-workers", "node-docker", "aws-lambda", "railway", "fly"], + "description": "A deployment platform the pipeline can generate configuration for." + }, + "paasBuilder": { + "type": "string", + "enum": ["dockerfile", "nixpacks"], + "description": "Build strategy for PaaS targets." + }, + "testDatabaseStrategy": { + "type": "string", + "enum": ["transaction-rollback", "testcontainers", "pglite", "schema-per-worker"], + "description": "How each test run gets an isolated database." + }, + "resourceList": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$", + "description": "Resource identifier in category:name form (e.g. \"filesystem:schema\"). Two phases declaring the same resource cannot run concurrently." + }, + "description": "Exclusive resources a phase or subtask holds while running." + }, + "phase": { + "type": "object", + "additionalProperties": false, + "required": ["depends", "description"], + "description": "One pipeline phase in the orchestration graph.", + "properties": { + "depends": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, + "description": "Phase names that must complete before this phase starts." + }, + "resources": { "$ref": "#/$defs/resourceList" }, + "blocking": { "type": "boolean", "description": "When true, failure of this phase fails the whole pipeline; when false it is reported and the pipeline continues." }, + "description": { "type": "string", "minLength": 1, "description": "What the phase does." } + } + }, + "subtask": { + "type": "object", + "additionalProperties": false, + "required": ["description"], + "description": "One independent check inside the quality gate.", + "properties": { + "resources": { "$ref": "#/$defs/resourceList" }, + "description": { "type": "string", "minLength": 1, "description": "What the subtask runs." } + } + } + } +} diff --git a/scripts/__tests__/pipeline-config-lib.test.js b/scripts/__tests__/pipeline-config-lib.test.js new file mode 100644 index 0000000..9040ab3 --- /dev/null +++ b/scripts/__tests__/pipeline-config-lib.test.js @@ -0,0 +1,150 @@ +/** + * Tests for scripts/lib/pipeline-config.js — the shared config reader used by + * scripts and hooks (getValue/loadConfig as ESM, and the `get` CLI that + * common.sh's common_config_get wraps). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { runNode, run, tmpProject, SCRIPTS_DIR, REPO_ROOT } from "./helpers.js"; +import { getValue, loadConfig, renderForShell, DEFAULT_CONFIG_PATH } from "../lib/pipeline-config.js"; + +const LIB = join(SCRIPTS_DIR, "lib", "pipeline-config.js"); +const COMMON = join(SCRIPTS_DIR, "lib", "common.sh"); + +let tmp; +let customConfig; + +beforeAll(() => { + tmp = tmpProject("nerva-config-lib-"); + customConfig = tmp.write( + "custom.json", + JSON.stringify({ + version: "9.9.9", + tdd: { coverageThreshold: 42, enforced: false }, + nested: { list: [1, 2], obj: { a: "b" }, nul: null }, + }), + ); +}); +afterAll(() => tmp.cleanup()); + +describe("pipeline-config.js (library)", () => { + it("DEFAULT_CONFIG_PATH points at .claude/pipeline.config.json in the repo", () => { + expect(DEFAULT_CONFIG_PATH).toBe(join(REPO_ROOT, ".claude", "pipeline.config.json")); + }); + + it("loadConfig reads the real config", () => { + const cfg = loadConfig(); + expect(cfg.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(cfg.tdd.enforced).toBe(true); + }); + + it("getValue reads dotted paths from the real config", () => { + expect(getValue("tdd.coverageThreshold")).toBe(80); + expect(getValue("deployment.defaultTarget")).toBe("cloudflare-workers"); + expect(getValue("orchestration.phases.report.depends")).toEqual([ + "quality-gate", + "documentation", + ]); + }); + + it("getValue returns the default for a missing key", () => { + expect(getValue("does.not.exist", "fallback")).toBe("fallback"); + expect(getValue("does.not.exist")).toBeUndefined(); + expect(getValue("tdd.coverageThreshold.deeper", 7)).toBe(7); + }); + + it("getValue honors a boolean false default", () => { + expect(getValue("nope.nothing", false)).toBe(false); + expect(getValue("nope.nothing", 0)).toBe(0); + expect(getValue("nope.nothing", "")).toBe(""); + }); + + it("getValue returns a stored false rather than the default", () => { + expect(getValue("database.uuidPrimaryKeys", true)).toBe(false); + }); + + it("getValue accepts an explicit config path", () => { + expect(getValue("tdd.coverageThreshold", 80, customConfig)).toBe(42); + expect(getValue("tdd.enforced", true, customConfig)).toBe(false); + expect(getValue("nested.nul", "dflt", customConfig)).toBeNull(); + }); + + it("loadConfig returns {} for a missing or malformed file", () => { + expect(loadConfig(join(tmp.dir, "missing.json"))).toEqual({}); + const broken = tmp.write("broken.json", "{ nope"); + expect(loadConfig(broken)).toEqual({}); + }); + + it("renderForShell formats values for bash consumption", () => { + expect(renderForShell(true)).toBe("true"); + expect(renderForShell(false)).toBe("false"); + expect(renderForShell(80)).toBe("80"); + expect(renderForShell("x")).toBe("x"); + expect(renderForShell(null)).toBe(""); + expect(renderForShell(undefined)).toBe(""); + expect(renderForShell([1, 2])).toBe("[1,2]"); + expect(renderForShell({ a: "b" })).toBe('{"a":"b"}'); + }); +}); + +describe("pipeline-config.js (CLI)", () => { + it("get prints a value from the real config", () => { + const r = runNode(LIB, ["get", "tdd.coverageThreshold"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("80"); + }); + + it("get prints the default for a missing key", () => { + const r = runNode(LIB, ["get", "no.such.key", "dflt"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("dflt"); + }); + + it("get prints an empty string for a missing key with no default", () => { + const r = runNode(LIB, ["get", "no.such.key"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe(""); + }); + + it("get round-trips a false default", () => { + const r = runNode(LIB, ["get", "no.such.key", "false"]); + expect(r.stdout).toBe("false"); + }); + + it("get renders booleans and objects for shell", () => { + expect(runNode(LIB, ["get", "tdd.enforced"]).stdout).toBe("true"); + expect(runNode(LIB, ["get", "database.uuidPrimaryKeys", "true"]).stdout).toBe("false"); + expect(JSON.parse(runNode(LIB, ["get", "deployment.targets"]).stdout)).toContain("fly"); + }); + + it("NERVA_PIPELINE_CONFIG overrides the config path", () => { + const env = { NERVA_PIPELINE_CONFIG: customConfig }; + expect(runNode(LIB, ["get", "tdd.coverageThreshold", "80"], { env }).stdout).toBe("42"); + expect(runNode(LIB, ["get", "version"], { env }).stdout).toBe("9.9.9"); + expect(runNode(LIB, ["get", "tdd.enforced", "true"], { env }).stdout).toBe("false"); + }); + + it("NERVA_PIPELINE_CONFIG pointing at a missing file yields the default, exit 0", () => { + const env = { NERVA_PIPELINE_CONFIG: join(tmp.dir, "absent.json") }; + const r = runNode(LIB, ["get", "tdd.coverageThreshold", "55"], { env }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("55"); + }); + + it("bad usage still exits 0 and prints the default", () => { + const r = runNode(LIB, ["frobnicate", "x", "d"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("d"); + // helpers.run() drops stderr on a zero exit, so the usage line printed to + // stderr is not observable here; the contract under test is exit 0 + default. + }); + + it("common_config_get in common.sh wraps the CLI", () => { + const script = `source "${COMMON}"; common_config_get tdd.coverageThreshold 0; echo; common_config_get missing.key dflt`; + const r = run("bash", ["-c", script]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("80\ndflt"); + const env = { NERVA_PIPELINE_CONFIG: customConfig }; + expect(run("bash", ["-c", `source "${COMMON}"; common_config_get tdd.coverageThreshold 0`], { env }).stdout).toBe("42"); + }); +}); diff --git a/scripts/__tests__/validate-pipeline-config.test.js b/scripts/__tests__/validate-pipeline-config.test.js new file mode 100644 index 0000000..df674d3 --- /dev/null +++ b/scripts/__tests__/validate-pipeline-config.test.js @@ -0,0 +1,258 @@ +/** + * Tests for scripts/validate-pipeline-config.js + * + * The real .claude/pipeline.config.json must validate cleanly against + * .claude/pipeline.config.schema.json. Mutated copies exercise the schema + * (unknown keys) and the structural checks (phase graph, deployment targets). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { run, runNode, tmpProject, REPO_ROOT, SCRIPTS_DIR } from "./helpers.js"; + +const SCRIPT = join(SCRIPTS_DIR, "validate-pipeline-config.js"); +const CONFIG = join(REPO_ROOT, ".claude", "pipeline.config.json"); +const SCHEMA = join(REPO_ROOT, ".claude", "pipeline.config.schema.json"); + +let tmp; +let baseConfig; + +beforeAll(() => { + tmp = tmpProject("nerva-validate-"); + baseConfig = JSON.parse(readFileSync(CONFIG, "utf8")); +}); +afterAll(() => tmp.cleanup()); + +/** Write a mutated copy of the real config and validate it with --json. */ +function validateMutated(name, mutate) { + const config = structuredClone(baseConfig); + mutate(config); + const path = tmp.write(`${name}.json`, JSON.stringify(config, null, 2)); + const r = runNode(SCRIPT, ["--config", path, "--json"]); + return { ...r, result: r.stdout ? JSON.parse(r.stdout) : null }; +} + +describe("validate-pipeline-config.js", () => { + it("passes node --check", () => { + expect(run("node", ["--check", SCRIPT]).exitCode).toBe(0); + }); + + it("the real config validates against the real schema (exit 0)", () => { + const r = runNode(SCRIPT, []); + expect(r.stderr).toBe(""); + expect(r.stdout).toContain("VALID: pipeline config passes"); + expect(r.exitCode).toBe(0); + }); + + it("the config's $schema points at the local schema file", () => { + expect(baseConfig.$schema).toBe("./pipeline.config.schema.json"); + }); + + it("--json emits {valid, errors, warnings} for the real config", () => { + const r = runNode(SCRIPT, ["--json"]); + expect(r.exitCode).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toEqual({ valid: true, errors: [], warnings: [] }); + }); + + it("--help exits 0 and prints usage", () => { + const r = runNode(SCRIPT, ["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + expect(r.stdout).toContain("--config"); + expect(r.stdout).toContain("--json"); + }); + + it("an unknown flag exits 2", () => { + const r = runNode(SCRIPT, ["--bogus"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown argument"); + }); + + it("a missing config file exits 2", () => { + const r = runNode(SCRIPT, ["--config", join(tmp.dir, "does-not-exist.json")]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("not found"); + }); + + it("a missing config file with --json exits 2 and reports valid:false", () => { + const r = runNode(SCRIPT, ["--config", join(tmp.dir, "nope.json"), "--json"]); + expect(r.exitCode).toBe(2); + const out = JSON.parse(r.stdout); + expect(out.valid).toBe(false); + expect(out.errors[0].message).toContain("not found"); + }); + + it("malformed JSON exits 2", () => { + const path = tmp.write("broken.json", "{ not json"); + const r = runNode(SCRIPT, ["--config", path]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Failed to parse"); + }); + + it("honors NERVA_PIPELINE_CONFIG when --config is omitted", () => { + const path = tmp.write("env.json", JSON.stringify({ version: "1.0.0" })); + const r = runNode(SCRIPT, ["--json"], { env: { NERVA_PIPELINE_CONFIG: path } }); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).valid).toBe(true); + }); + + it("an unknown top-level key fails with a JSON-pointer path", () => { + const { exitCode, result } = validateMutated("unknown-top", (c) => { + c.bogusSection = { a: 1 }; + }); + expect(exitCode).toBe(1); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual({ + path: "/", + message: 'unexpected property "bogusSection"', + }); + }); + + it("an unknown nested key fails at every object level", () => { + const { exitCode, result } = validateMutated("unknown-nested", (c) => { + c.deployment.awsLambda.coldStartMagic = true; + }); + expect(exitCode).toBe(1); + expect(result.errors.map((e) => e.path)).toContain("/deployment/awsLambda"); + expect(result.errors[0].message).toContain("coldStartMagic"); + }); + + it("an enum violation lists the allowed values", () => { + const { exitCode, result } = validateMutated("bad-enum", (c) => { + c.auth.defaultStrategy = "magic-link"; + }); + expect(exitCode).toBe(1); + const err = result.errors.find((e) => e.path === "/auth/defaultStrategy"); + expect(err).toBeDefined(); + expect(err.message).toContain('"jwt"'); + }); + + it("a percentage above 100 fails", () => { + const { exitCode, result } = validateMutated("bad-pct", (c) => { + c.qualityGate.coverageThreshold = 150; + }); + expect(exitCode).toBe(1); + expect(result.errors.some((e) => e.path === "/qualityGate/coverageThreshold")).toBe(true); + }); + + it("a malformed duration fails", () => { + const { exitCode, result } = validateMutated("bad-duration", (c) => { + c.auth.tokenExpiry = "fifteen minutes"; + }); + expect(exitCode).toBe(1); + expect(result.errors.some((e) => e.path === "/auth/tokenExpiry")).toBe(true); + }); + + it("a non-semver version fails", () => { + const { exitCode, result } = validateMutated("bad-version", (c) => { + c.version = "1.0"; + }); + expect(exitCode).toBe(1); + const versionErrors = result.errors.filter((e) => e.path === "/version"); + expect(versionErrors).toHaveLength(1); + }); + + it("a phase dependency cycle fails", () => { + const { exitCode, result } = validateMutated("cycle", (c) => { + c.orchestration.phases["schema-intake"].depends = ["report"]; + }); + expect(exitCode).toBe(1); + const cycle = result.errors.find((e) => e.path === "/orchestration/phases"); + expect(cycle).toBeDefined(); + expect(cycle.message).toContain("dependency cycle"); + expect(cycle.message).toContain("schema-intake"); + expect(cycle.message).toContain("report"); + }); + + it("a depends entry naming a missing phase fails", () => { + const { exitCode, result } = validateMutated("missing-dep", (c) => { + c.orchestration.phases["report"].depends.push("nonexistent-phase"); + }); + expect(exitCode).toBe(1); + expect(result.errors).toContainEqual({ + path: "/orchestration/phases/report/depends", + message: 'references unknown phase "nonexistent-phase"', + }); + // A missing dependency must not also be reported as a cycle. + expect(result.errors.some((e) => e.message.includes("dependency cycle"))).toBe(false); + // report is blocking, so it is also flagged as unreachable. + expect(result.errors.some((e) => e.path === "/orchestration/phases/report")).toBe(true); + }); + + it("a non-blocking phase with a missing dependency is not flagged as unreachable", () => { + const { result } = validateMutated("missing-dep-nonblocking", (c) => { + c.orchestration.phases["documentation"].depends = ["ghost"]; + }); + expect(result.errors.some((e) => e.path === "/orchestration/phases/documentation")).toBe( + false, + ); + expect( + result.errors.some((e) => e.path === "/orchestration/phases/documentation/depends"), + ).toBe(true); + }); + + it("empty qualityGateSubtasks fails when orchestration is enabled", () => { + const { exitCode, result } = validateMutated("no-subtasks", (c) => { + c.orchestration.qualityGateSubtasks = {}; + }); + expect(exitCode).toBe(1); + expect(result.errors.some((e) => e.path === "/orchestration/qualityGateSubtasks")).toBe(true); + }); + + it("empty qualityGateSubtasks is allowed when orchestration is disabled", () => { + const { exitCode } = validateMutated("no-subtasks-disabled", (c) => { + c.orchestration.enabled = false; + c.orchestration.qualityGateSubtasks = {}; + }); + expect(exitCode).toBe(0); + }); + + it("defaultTarget not in targets fails", () => { + const { exitCode, result } = validateMutated("bad-target", (c) => { + c.deployment.targets = ["node-docker", "fly"]; + c.deployment.defaultTarget = "cloudflare-workers"; + }); + expect(exitCode).toBe(1); + const err = result.errors.find((e) => e.path === "/deployment/defaultTarget"); + expect(err).toBeDefined(); + expect(err.message).toContain("cloudflare-workers"); + }); + + it("coverage threshold drift is a warning, not an error", () => { + const { exitCode, result } = validateMutated("coverage-drift", (c) => { + c.tdd.coverageThreshold = 70; + }); + expect(exitCode).toBe(0); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].path).toBe("/tdd/coverageThreshold"); + expect(result.warnings[0].message).toContain("70 vs 80"); + }); + + it("human output lists errors with paths", () => { + const config = structuredClone(baseConfig); + config.deployment.defaultTarget = "railway"; + config.deployment.targets = ["fly"]; + const path = tmp.write("human.json", JSON.stringify(config)); + const r = runNode(SCRIPT, ["--config", path]); + expect(r.exitCode).toBe(1); + expect(r.stdout).toContain("Errors (1):"); + expect(r.stdout).toContain("/deployment/defaultTarget"); + expect(r.stdout).toContain("INVALID"); + }); + + it("accepts an alternate --schema", () => { + const schema = JSON.parse(readFileSync(SCHEMA, "utf8")); + schema.required = ["version", "database"]; + const schemaPath = tmp.write("alt-schema.json", JSON.stringify(schema)); + const configPath = tmp.write("alt-config.json", JSON.stringify({ version: "1.0.0" })); + const r = runNode(SCRIPT, ["--config", configPath, "--schema", schemaPath, "--json"]); + expect(r.exitCode).toBe(1); + expect(JSON.parse(r.stdout).errors).toContainEqual({ + path: "/", + message: 'missing required property "database"', + }); + }); +}); diff --git a/scripts/validate-pipeline-config.js b/scripts/validate-pipeline-config.js new file mode 100644 index 0000000..0ad508e --- /dev/null +++ b/scripts/validate-pipeline-config.js @@ -0,0 +1,345 @@ +#!/usr/bin/env node +/** + * validate-pipeline-config.js — Validate .claude/pipeline.config.json against + * .claude/pipeline.config.schema.json (JSON Schema draft 2020-12), then run the + * structural checks the schema cannot express: + * + * - every orchestration.phases[*].depends entry names an existing phase + * - the phase graph has no dependency cycles (topological sort) + * - every phase with blocking: true is reachable (all of its transitive + * dependencies exist and are acyclic) + * - orchestration.qualityGateSubtasks is non-empty when orchestration.enabled + * - deployment.defaultTarget appears in deployment.targets + * - version is a semver string + * - WARN (never fail) when tdd.coverageThreshold differs from + * qualityGate.coverageThreshold + * + * Usage: + * node scripts/validate-pipeline-config.js + * node scripts/validate-pipeline-config.js --config --schema + * node scripts/validate-pipeline-config.js --json + * + * The config path falls back to NERVA_PIPELINE_CONFIG (the same override + * scripts/lib/pipeline-config.js honors) before the repo default. + * + * Exit codes: + * 0 — config is valid (warnings allowed) + * 1 — config has schema or structural errors + * 2 — usage or IO error (bad flag, file missing, JSON parse failure, ajv missing) + */ + +import { readFileSync, existsSync } from "fs"; +import { join, dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const repoRoot = resolve(__dirname, ".."); + +export const DEFAULT_CONFIG = join(repoRoot, ".claude", "pipeline.config.json"); +export const DEFAULT_SCHEMA = join(repoRoot, ".claude", "pipeline.config.schema.json"); + +const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/; + +class UsageError extends Error {} + +function printHelp() { + console.log(`Usage: node scripts/validate-pipeline-config.js [options] + +Validate .claude/pipeline.config.json against its JSON Schema and run +structural checks (phase graph, deployment targets, version). + +Options: + --config Config to validate (default: NERVA_PIPELINE_CONFIG or + .claude/pipeline.config.json) + --schema Schema to validate against + (default: .claude/pipeline.config.schema.json) + --json Emit {valid, errors:[{path,message}], warnings:[...]} as JSON + -h, --help Show this message + +Exit codes: 0 valid, 1 invalid, 2 usage/IO error`); +} + +export function parseArgs(argv) { + const out = { + config: process.env.NERVA_PIPELINE_CONFIG + ? resolve(process.env.NERVA_PIPELINE_CONFIG) + : DEFAULT_CONFIG, + schema: DEFAULT_SCHEMA, + json: false, + help: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--config" || a === "--schema") { + const value = argv[++i]; + if (value === undefined || value.startsWith("--")) { + throw new UsageError(`${a} requires a path argument`); + } + out[a.slice(2)] = resolve(value); + } else if (a === "--json") { + out.json = true; + } else if (a === "-h" || a === "--help") { + out.help = true; + } else { + throw new UsageError(`Unknown argument: ${a}`); + } + } + return out; +} + +function loadJson(path, label) { + if (!existsSync(path)) { + throw new Error(`${label} not found at ${path}`); + } + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch (e) { + throw new Error(`Failed to parse ${label} (${path}): ${e.message}`, { cause: e }); + } +} + +/** Convert an ajv error into {path, message} with a JSON-pointer path. */ +export function formatSchemaError(err) { + const path = err.instancePath || "/"; + let message = err.message ?? "validation failed"; + const p = err.params ?? {}; + if (p.additionalProperty !== undefined) { + message = `unexpected property "${p.additionalProperty}"`; + } else if (p.missingProperty !== undefined) { + message = `missing required property "${p.missingProperty}"`; + } else if (p.allowedValues !== undefined) { + message = `${message}: ${p.allowedValues.map((v) => JSON.stringify(v)).join(", ")}`; + } else if (err.keyword === "propertyName" && p.propertyName !== undefined) { + message = `property name "${p.propertyName}" is invalid`; + } + return { path, message }; +} + +/** + * Structural checks. Returns { errors: [{path, message}], warnings: [{path, message}] }. + * `skipVersion` suppresses the semver check when the schema already reported /version. + */ +export function structuralChecks(config, { skipVersion = false } = {}) { + const errors = []; + const warnings = []; + const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); + + // 1. version is semver + if (!skipVersion) { + if (typeof config.version !== "string" || !SEMVER_RE.test(config.version)) { + errors.push({ + path: "/version", + message: `must be a semver string (MAJOR.MINOR.PATCH), got ${JSON.stringify(config.version)}`, + }); + } + } + + // 2. Orchestration phase graph + const orchestration = isObject(config.orchestration) ? config.orchestration : null; + const phases = orchestration && isObject(orchestration.phases) ? orchestration.phases : null; + if (phases) { + const names = new Set(Object.keys(phases)); + const depsOf = (name) => { + const d = phases[name]?.depends; + return Array.isArray(d) ? d.filter((x) => typeof x === "string") : []; + }; + + for (const name of names) { + for (const dep of depsOf(name)) { + if (!names.has(dep)) { + errors.push({ + path: `/orchestration/phases/${name}/depends`, + message: `references unknown phase "${dep}"`, + }); + } + if (dep === name) { + errors.push({ + path: `/orchestration/phases/${name}/depends`, + message: `phase depends on itself`, + }); + } + } + } + + // Kahn's algorithm over edges to existing phases only, so a missing + // dependency is reported once (above) rather than also as a cycle. + const indegree = new Map([...names].map((n) => [n, 0])); + const dependents = new Map([...names].map((n) => [n, []])); + for (const name of names) { + for (const dep of depsOf(name)) { + if (names.has(dep)) { + indegree.set(name, indegree.get(name) + 1); + dependents.get(dep).push(name); + } + } + } + const queue = [...names].filter((n) => indegree.get(n) === 0); + const ordered = []; + while (queue.length) { + const n = queue.shift(); + ordered.push(n); + for (const m of dependents.get(n)) { + indegree.set(m, indegree.get(m) - 1); + if (indegree.get(m) === 0) queue.push(m); + } + } + const inCycle = [...names].filter((n) => !ordered.includes(n)); + if (inCycle.length) { + errors.push({ + path: "/orchestration/phases", + message: `dependency cycle detected involving: ${inCycle.join(", ")}`, + }); + } + + // Reachability: a phase is reachable when it is not in a cycle and every + // transitive dependency exists. Blocking phases must be reachable. + const reachable = new Map(); + const isReachable = (name, seen = new Set()) => { + if (reachable.has(name)) return reachable.get(name); + if (!names.has(name) || inCycle.includes(name) || seen.has(name)) return false; + seen.add(name); + const ok = depsOf(name).every((d) => isReachable(d, seen)); + reachable.set(name, ok); + return ok; + }; + for (const name of names) { + if (phases[name]?.blocking === true && !isReachable(name)) { + errors.push({ + path: `/orchestration/phases/${name}`, + message: `blocking phase is unreachable (a dependency is missing or cyclic)`, + }); + } + } + } + + // 3. qualityGateSubtasks non-empty when orchestration is enabled + if (orchestration && orchestration.enabled === true) { + const subtasks = orchestration.qualityGateSubtasks; + if (!isObject(subtasks) || Object.keys(subtasks).length === 0) { + errors.push({ + path: "/orchestration/qualityGateSubtasks", + message: "must define at least one subtask when orchestration.enabled is true", + }); + } + } + + // 4. deployment.defaultTarget ∈ deployment.targets + const deployment = isObject(config.deployment) ? config.deployment : null; + if (deployment && deployment.defaultTarget !== undefined && Array.isArray(deployment.targets)) { + if (!deployment.targets.includes(deployment.defaultTarget)) { + errors.push({ + path: "/deployment/defaultTarget", + message: `"${deployment.defaultTarget}" is not listed in /deployment/targets (${deployment.targets.join(", ")})`, + }); + } + } + + // 5. Coverage threshold drift (warning only) + const tddCoverage = config.tdd?.coverageThreshold; + const gateCoverage = config.qualityGate?.coverageThreshold; + if (tddCoverage !== undefined && gateCoverage !== undefined && tddCoverage !== gateCoverage) { + warnings.push({ + path: "/tdd/coverageThreshold", + message: `differs from /qualityGate/coverageThreshold (${tddCoverage} vs ${gateCoverage}); the TDD phase and the quality gate will disagree`, + }); + } + + return { errors, warnings }; +} + +/** + * Validate a config object against a schema object. Returns + * { valid, errors, warnings }. Ajv2020 is passed in so callers control loading. + */ +export function validateConfig(config, schema, Ajv2020) { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + const validate = ajv.compile(schema); + const schemaOk = validate(config); + const schemaErrors = (validate.errors ?? []).map(formatSchemaError); + const versionFlagged = schemaErrors.some((e) => e.path === "/version"); + const structural = structuralChecks(config, { skipVersion: versionFlagged }); + const errors = [...schemaErrors, ...structural.errors]; + return { + valid: schemaOk && structural.errors.length === 0, + errors, + warnings: structural.warnings, + }; +} + +function emitFailure(args, message, exitCode) { + if (args?.json) { + console.log(JSON.stringify({ valid: false, errors: [{ path: "/", message }], warnings: [] })); + } else { + console.error(`Error: ${message}`); + } + process.exit(exitCode); +} + +async function main() { + let args; + try { + args = parseArgs(process.argv.slice(2)); + } catch (e) { + console.error(`Error: ${e.message}\n`); + printHelp(); + process.exit(2); + } + if (args.help) { + printHelp(); + process.exit(0); + } + + let config; + let schema; + try { + config = loadJson(args.config, "Config"); + schema = loadJson(args.schema, "Schema"); + } catch (e) { + emitFailure(args, e.message, 2); + } + if (config === null || typeof config !== "object" || Array.isArray(config)) { + emitFailure(args, `Config root must be a JSON object (${args.config})`, 2); + } + + let Ajv2020; + try { + ({ default: Ajv2020 } = await import("ajv/dist/2020.js")); + } catch { + emitFailure(args, "ajv is not installed. Run `pnpm install` and retry.", 2); + } + + const result = validateConfig(config, schema, Ajv2020); + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Validating ${args.config}`); + console.log(`Against ${args.schema}`); + console.log(""); + if (result.errors.length) { + console.log(`Errors (${result.errors.length}):`); + for (const e of result.errors) console.log(` ${e.path}: ${e.message}`); + } + if (result.warnings.length) { + console.log(`Warnings (${result.warnings.length}):`); + for (const w of result.warnings) console.log(` ${w.path}: ${w.message}`); + } + if (result.valid) { + const sections = Object.keys(config).filter((k) => !k.startsWith("$")).length; + const suffix = result.warnings.length ? ` with ${result.warnings.length} warning(s)` : ""; + console.log(`VALID: pipeline config passes (${sections} top-level sections)${suffix}`); + } else { + console.log(`INVALID: fix ${result.errors.length} error(s) and re-run.`); + } + } + + process.exit(result.valid ? 0 : 1); +} + +if (process.argv[1] && resolve(process.argv[1]) === __filename) { + main().catch((e) => { + console.error(`Error: ${e.stack ?? e.message}`); + process.exit(2); + }); +} From bcfc8f357a6500ecba69576846cbd911389a38e2 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 9 Sep 2026 16:24:43 -0400 Subject: [PATCH 06/11] feat(scripts): add prerequisites checker with a stable output contract Reports required software, optional tools, Claude Code plugins and project state as [PASS]/[FAIL]/[SKIP]/[INFO] lines with a summary block and --json. The claude CLI and plugins never block readiness. Co-Authored-By: Claude Fable 5.1 --- scripts/__tests__/check-prerequisites.test.js | 204 +++++++++ scripts/check-prerequisites.sh | 400 ++++++++++++++++++ 2 files changed, 604 insertions(+) create mode 100644 scripts/__tests__/check-prerequisites.test.js create mode 100644 scripts/check-prerequisites.sh diff --git a/scripts/__tests__/check-prerequisites.test.js b/scripts/__tests__/check-prerequisites.test.js new file mode 100644 index 0000000..4e2a336 --- /dev/null +++ b/scripts/__tests__/check-prerequisites.test.js @@ -0,0 +1,204 @@ +/** + * Tests for scripts/check-prerequisites.sh + * + * The script's output is a stable, machine-parseable contract (section + * headers, [PASS]/[FAIL]/[SKIP]/[INFO] lines, the summary block, exit codes). + * Tool detection depends on the machine, so assertions on this machine only + * pin the contract; NERVA_PROJECT_ROOT and NERVA_PLUGINS_FILE drive the + * deterministic PROJECT and CLAUDE CODE cases. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { mkdirSync, writeFileSync } from "fs"; +import { run, runBash, tmpProject, SCRIPTS_DIR } from "./helpers.js"; + +const SCRIPT = join(SCRIPTS_DIR, "check-prerequisites.sh"); +const SECTIONS = ["REQUIRED SOFTWARE", "OPTIONAL SOFTWARE", "CLAUDE CODE", "PROJECT"]; + +let tmp; +beforeAll(() => { + tmp = tmpProject("nerva-prereq-"); +}); +afterAll(() => tmp.cleanup()); + +describe("check-prerequisites.sh", () => { + it("passes bash -n", () => { + expect(run("bash", ["-n", SCRIPT]).exitCode).toBe(0); + }); + + it("emits every section header with a dashed underline, in order", () => { + const { stdout } = runBash(SCRIPT); + let last = -1; + for (const h of SECTIONS) { + const idx = stdout.indexOf(`\n${h}\n${"-".repeat(h.length)}\n`); + expect(idx, `section ${h}`).toBeGreaterThan(last); + last = idx; + } + }); + + it("every result line uses a known status tag and hints are indented", () => { + const { stdout } = runBash(SCRIPT); + const body = stdout.split("=== Summary ===")[0]; + const lines = body.split("\n").filter((l) => l.trim() !== ""); + const resultLines = lines.filter((l) => l.startsWith("[")); + expect(resultLines.length).toBeGreaterThan(10); + for (const l of resultLines) { + expect(l).toMatch(/^\[(PASS|FAIL|SKIP|INFO)\] \S/); + } + const hintLines = lines.filter((l) => l.startsWith(" ")); + for (const l of hintLines) expect(l).toMatch(/^ {7}\S/); + }); + + it("prints the summary block and a YES|NO verdict with a matching exit code", () => { + const { stdout, exitCode } = runBash(SCRIPT); + expect(stdout).toContain("=== Summary ==="); + expect(stdout).toMatch(/^Required: \d+\/4 passed$/m); + expect(stdout).toMatch(/^Optional: \d+\/10 installed$/m); + expect(stdout).toMatch(/^Claude Code: \d+\/4 ready$/m); + expect(stdout).toMatch(/^Project: \d+\/5 ready$/m); + const verdict = stdout.match(/^Ready to use Nerva: (YES|NO)$/m); + expect(verdict).not.toBeNull(); + expect([0, 1]).toContain(exitCode); + expect(exitCode).toBe(verdict[1] === "YES" ? 0 : 1); + }); + + it("reports required tool minimums and the Node 22 recommendation", () => { + const { stdout } = runBash(SCRIPT); + expect(stdout).toMatch(/\[(PASS|FAIL)\] Git .*minimum: 2\.30\.0/); + expect(stdout).toMatch(/\[(PASS|FAIL)\] Node\.js .*minimum: 20\.0\.0, recommended: 22/); + expect(stdout).toMatch(/\[(PASS|FAIL)\] pnpm .*minimum: 9\.0\.0/); + expect(stdout).toMatch(/\[PASS\] Bash \d/); + }); + + it("the framework checkout reports a ready PROJECT section with api/ as INFO", () => { + const { stdout } = runBash(SCRIPT); + expect(stdout).toContain("[PASS] node_modules present"); + expect(stdout).toContain("[PASS] .claude/pipeline.config.json parses"); + expect(stdout).toContain("[PASS] .husky/pre-commit hook present"); + expect(stdout).toMatch(/\[PASS\] \.claude\/agents contains \d+ agent\(s\)/); + expect(stdout).toMatch(/\[PASS\] \.claude\/skills contains \d+ skill\(s\)/); + expect(stdout).toContain("[INFO] api/ not present"); + expect(stdout).toContain("Project: 5/5 ready"); + }); + + it("NERVA_PROJECT_ROOT pointing at an empty dir makes the PROJECT section fail", () => { + const empty = join(tmp.dir, "empty"); + mkdirSync(empty, { recursive: true }); + const { stdout, exitCode } = runBash(SCRIPT, [], { env: { NERVA_PROJECT_ROOT: empty } }); + expect(stdout).toContain("[FAIL] node_modules missing at repo root"); + expect(stdout).toContain(" Run: pnpm install"); + expect(stdout).toContain("[FAIL] .claude/pipeline.config.json not found"); + expect(stdout).toContain("[FAIL] .husky/pre-commit hook missing"); + expect(stdout).toContain("[FAIL] .claude/agents is missing or empty"); + expect(stdout).toContain("[FAIL] .claude/skills is missing or empty"); + expect(stdout).toContain("Project: 0/5 ready"); + expect(stdout).toContain("Ready to use Nerva: NO"); + expect(exitCode).toBe(1); + }); + + it("a malformed pipeline.config.json is a PROJECT failure", () => { + const root = join(tmp.dir, "broken-config"); + mkdirSync(join(root, ".claude"), { recursive: true }); + writeFileSync(join(root, ".claude", "pipeline.config.json"), "{ nope"); + const { stdout, exitCode } = runBash(SCRIPT, [], { env: { NERVA_PROJECT_ROOT: root } }); + expect(stdout).toContain("[FAIL] .claude/pipeline.config.json is not valid JSON"); + expect(exitCode).toBe(1); + }); + + it("a non-existent NERVA_PROJECT_ROOT is a script error (exit 2)", () => { + const r = runBash(SCRIPT, [], { env: { NERVA_PROJECT_ROOT: join(tmp.dir, "nowhere") } }); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Project root does not exist"); + }); + + it("NERVA_PLUGINS_FILE pointing at a plugin registry lists installed plugins", () => { + const pluginsFile = tmp.write( + "installed_plugins.json", + JSON.stringify({ + version: 2, + plugins: { + "superpowers@claude-plugins-official": [{ version: "6.3.0" }], + "episodic-memory@claude-plugins-official": [{ version: "1.2.0" }], + }, + }), + ); + const { stdout } = runBash(SCRIPT, [], { env: { NERVA_PLUGINS_FILE: pluginsFile } }); + expect(stdout).toContain("[PASS] superpowers plugin 6.3.0"); + expect(stdout).toContain("[PASS] episodic-memory plugin 1.2.0"); + expect(stdout).toContain("[SKIP] commit-commands plugin not installed"); + expect(stdout).toContain(" Install in Claude Code: /plugin install commit-commands"); + expect(stdout).toContain("[INFO] ai-taskmaster is a local plugin"); + expect(stdout).toMatch(/^Claude Code: [23]\/4 ready$/m); + }); + + it("a missing plugin registry SKIPs every plugin instead of failing", () => { + const missing = join(tmp.dir, "no-such-plugins.json"); + const { stdout } = runBash(SCRIPT, [], { env: { NERVA_PLUGINS_FILE: missing } }); + for (const p of ["episodic-memory", "commit-commands", "superpowers"]) { + expect(stdout).toContain(`[SKIP] ${p} plugin: cannot verify`); + } + expect(stdout).toContain(` Plugin registry not found at ${missing}`); + }); + + it("--json emits the same data as JSON", () => { + const text = runBash(SCRIPT); + const r = runBash(SCRIPT, ["--json"]); + expect(r.exitCode).toBe(text.exitCode); + const out = JSON.parse(r.stdout); + expect(out.ready).toBe(text.exitCode === 0); + expect(out.exitCode).toBe(text.exitCode); + expect(out.summary.required.total).toBe(4); + expect(out.summary.optional.total).toBe(10); + expect(out.summary.claudeCode.total).toBe(4); + expect(out.summary.project.total).toBe(5); + expect(Array.isArray(out.checks)).toBe(true); + // Same results as the text report, in the same order. + const textResults = text.stdout + .split("=== Summary ===")[0] + .split("\n") + .filter((l) => /^\[(PASS|FAIL|SKIP|INFO)\] /.test(l)) + .map((l) => ({ status: l.slice(1, 5), detail: l.slice(7) })); + expect(out.checks.map((c) => ({ status: c.status, detail: c.detail }))).toEqual(textResults); + for (const c of out.checks) { + expect(SECTIONS).toContain(c.section); + expect(["PASS", "FAIL", "SKIP", "INFO"]).toContain(c.status); + expect(Array.isArray(c.hints)).toBe(true); + } + const nm = out.checks.find((c) => c.detail === "node_modules present"); + expect(nm.section).toBe("PROJECT"); + // Summary numbers agree with the per-check statuses. + const passed = out.checks.filter((c) => c.section === "PROJECT" && c.status === "PASS"); + expect(passed).toHaveLength(out.summary.project.ready); + }); + + it("--json carries hints for failing checks", () => { + const empty = join(tmp.dir, "empty-json"); + mkdirSync(empty, { recursive: true }); + const r = runBash(SCRIPT, ["--json"], { env: { NERVA_PROJECT_ROOT: empty } }); + expect(r.exitCode).toBe(1); + const out = JSON.parse(r.stdout); + expect(out.ready).toBe(false); + const nm = out.checks.find((c) => c.detail === "node_modules missing at repo root"); + expect(nm.status).toBe("FAIL"); + expect(nm.hints).toEqual(["Run: pnpm install"]); + }); + + it("--help exits 0 with the output contract", () => { + const r = runBash(SCRIPT, ["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Output contract"); + expect(r.stdout).toContain("NERVA_PROJECT_ROOT"); + }); + + it("an unknown flag exits 2", () => { + const r = runBash(SCRIPT, ["--wat"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown argument"); + }); + + it("disables colors under NO_COLOR", () => { + const { stdout } = runBash(SCRIPT); + // eslint-disable-next-line no-control-regex + expect(stdout).not.toMatch(/\x1b\[/); + }); +}); diff --git a/scripts/check-prerequisites.sh b/scripts/check-prerequisites.sh new file mode 100644 index 0000000..bfd6258 --- /dev/null +++ b/scripts/check-prerequisites.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# +# check-prerequisites.sh — Verify the tools Nerva needs and report them in a +# stable, machine-parseable layout. +# +# Usage: +# ./scripts/check-prerequisites.sh # human-readable report +# ./scripts/check-prerequisites.sh --json # same data as JSON +# ./scripts/check-prerequisites.sh --help +# +# Output contract (do not change casually — scripts/__tests__ pins it): +# - Section headers on their own line, each followed by a dashed underline: +# REQUIRED SOFTWARE, OPTIONAL SOFTWARE, CLAUDE CODE, PROJECT +# - One result per line: [PASS] | [FAIL] | [SKIP] | [INFO] +# - Indented continuation lines under a result are hints for that result +# - A "=== Summary ===" block with +# "Required: x/y passed", "Optional: x/y installed", +# "Claude Code: x/y ready", "Project: x/y ready", +# then "Ready to use Nerva: YES|NO" +# +# JSON mode (--json) emits one object: +# { "ready": bool, "exitCode": n, +# "summary": { "required": {passed,total}, "optional": {installed,total}, +# "claudeCode": {passed,total}, "project": {ready,total} }, +# "checks": [ { "section", "status", "detail", "hints": [] }, ... ] } +# +# What blocks readiness: any [FAIL] in REQUIRED SOFTWARE or any [FAIL] in +# PROJECT. The claude CLI, plugins, optional software, and [INFO]/[SKIP] +# lines never block (the IDE extensions run Claude Code without a CLI on PATH). +# +# Exit codes: 0 = ready, 1 = a required item is missing or the project is +# broken, 2 = script/usage error. +# +# Env overrides (tests): NERVA_PROJECT_ROOT, NERVA_PLUGINS_FILE. +# Colors come from scripts/lib/colors.sh and are disabled under NO_COLOR. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + +MIN_GIT_VERSION="2.30.0" +MIN_NODE_VERSION="20.0.0" +RECOMMENDED_NODE_MAJOR=22 +MIN_PNPM_VERSION="9.0.0" +# Marketplace plugins CLAUDE.md lists as installed (ai-taskmaster is local). +EXPECTED_PLUGINS="episodic-memory commit-commands superpowers" + +JSON_MODE=0 +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=1 ;; + -h|--help) + sed -n '2,/^set -u/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + error "Unknown argument: $arg (expected --json or --help)" + exit 2 + ;; + esac +done + +ROOT="${NERVA_PROJECT_ROOT:-$(common_project_root)}" +PLUGINS_FILE="${NERVA_PLUGINS_FILE:-$HOME/.claude/plugins/installed_plugins.json}" + +if [[ ! -d "$ROOT" ]]; then + error "Project root does not exist: $ROOT" + exit 2 +fi + +# ── Counters ──────────────────────────────────────────────────────────────── +REQUIRED_PASS=0; REQUIRED_TOTAL=0 +OPTIONAL_PASS=0; OPTIONAL_TOTAL=0 +CLAUDE_PASS=0; CLAUDE_TOTAL=0 +PROJECT_PASS=0; PROJECT_TOTAL=0 +BLOCKING=0 + +# ── Result recording (text or JSON) ───────────────────────────────────────── +CURRENT_SECTION="" +ITEM_SECTION=(); ITEM_STATUS=(); ITEM_DETAIL=(); ITEM_HINTS=() +HINT_SEP=$'\x1f' + +header() { + CURRENT_SECTION="$1" + [[ "$JSON_MODE" -eq 1 ]] && return 0 + echo "" + printf '%s%s%s\n' "$CYAN" "$1" "$NC" + printf '%s\n' "$(printf '%s' "$1" | sed 's/./-/g')" +} + +record() { + local status="$1" detail="$2" color="" + ITEM_SECTION+=("$CURRENT_SECTION"); ITEM_STATUS+=("$status") + ITEM_DETAIL+=("$detail"); ITEM_HINTS+=("") + [[ "$JSON_MODE" -eq 1 ]] && return 0 + case "$status" in + PASS) color="$GREEN" ;; + FAIL) color="$RED" ;; + SKIP) color="$YELLOW" ;; + INFO) color="$BLUE" ;; + esac + printf '%s[%s]%s %s\n' "$color" "$status" "$NC" "$detail" +} +pass() { record PASS "$1"; } +fail() { record FAIL "$1"; } +skip() { record SKIP "$1"; } +note() { record INFO "$1"; } +hint() { + local last=$(( ${#ITEM_HINTS[@]} - 1 )) + if [[ "$last" -ge 0 ]]; then + if [[ -n "${ITEM_HINTS[$last]}" ]]; then + ITEM_HINTS[$last]="${ITEM_HINTS[$last]}${HINT_SEP}$1" + else + ITEM_HINTS[$last]="$1" + fi + fi + [[ "$JSON_MODE" -eq 1 ]] && return 0 + printf ' %s\n' "$1" +} + +# ── Version helpers ───────────────────────────────────────────────────────── +first_version() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1; } + +# version_gte A B → 0 when A >= B (dotted numeric versions; pure bash, no sort -V) +version_gte() { + local a b i ai bi + IFS='.' read -r -a a <<< "$(printf '%s' "$1" | sed 's/[^0-9.]//g')" + IFS='.' read -r -a b <<< "$(printf '%s' "$2" | sed 's/[^0-9.]//g')" + for i in 0 1 2; do + ai="${a[$i]:-0}"; bi="${b[$i]:-0}" + if (( 10#$ai > 10#$bi )); then return 0; fi + if (( 10#$ai < 10#$bi )); then return 1; fi + done + return 0 +} + +# check_optional