deployhealth-scan 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,14 +8,35 @@ Find env var drift in a repository and report it to
8
8
  - **UNUSED**: defined in an env file but never read;
9
9
  - **MISMATCH**: in `.env` but not `.env.example`, or the reverse.
10
10
 
11
- It scans JavaScript/TypeScript, Python, Go and Ruby, respects `.gitignore`, and treats each folder
12
- with its own `.env.example` as a separate scope (monorepos). One file, no dependencies, Node 20+.
11
+ It scans JavaScript/TypeScript (`.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, `.cts`),
12
+ Python, Go and Ruby, respects `.gitignore`, and treats each folder with its own env files as a
13
+ separate scope (monorepos). It reads `.env.example`, `.env`, `.env.local`, `.env.development`,
14
+ `.env.production`, `.env.test` and their `.local` variants. One file, no dependencies, Node 20+.
15
+
16
+ A first scan stays quiet:
17
+
18
+ - **Names the platform or runtime provides are skipped**: `NODE_ENV`, `CI`, `npm_*`, GitHub
19
+ Actions' default variables (`GITHUB_SHA`, `GITHUB_REF_NAME`, …, not your own `GITHUB_` names),
20
+ `RUNNER_*`, `VERCEL_*`, `RAILWAY_*`, `RENDER_*`, `FLY_*` and a few more. `--no-default-ignore`
21
+ checks them too; `--dry-run` lists what was skipped.
22
+ - **A read with a default on the same line isn't MISSING**: `process.env.X ?? "a"` or `|| "a"`,
23
+ `os.getenv("X", "a")`, `os.environ.get("X", "a")`, `os.getenv("X") or "a"`, `ENV.fetch("X", "a")`,
24
+ `ENV.fetch("X") { … }`, `ENV["X"] || "a"`. Such variables are listed as optional. (A default of
25
+ `undefined`, `null`, `None` or `nil` is no default.)
26
+ - **Tests and fixtures are skipped**: `test/`, `tests/`, `__tests__/`, `spec/`, `e2e/`,
27
+ `fixtures/`, `__fixtures__/` and `testdata/` directories (their env files too, so a fixture's
28
+ `.env` never becomes a scope), and `*.test.*`, `*.spec.*`, `*_test.go`, `test_*.py`, `*_test.py`,
29
+ `conftest.py` and `*_spec.rb` files. They're only read to see which variables they use, so a
30
+ variable only tests read isn't UNUSED. `--include-tests` scans them like any other file.
31
+ - **A folder with no env file at all gets one line**, "No .env.example …: N variables referenced",
32
+ instead of a MISSING row per reference; deployhealth's handoff offers them as a starting
33
+ `.env.example`.
13
34
 
14
35
  ## Try it locally
15
36
 
16
37
  ```sh
17
- npx deployhealth-scan@0.1.0 --dry-run # grouped findings with file:line
18
- npx deployhealth-scan@0.1.0 --dry-run --json # the same, as JSON
38
+ npx deployhealth-scan@0.2.0 --dry-run # grouped findings with file:line
39
+ npx deployhealth-scan@0.2.0 --dry-run --json # the same, as JSON
19
40
  ```
20
41
 
21
42
  With `--dry-run` nothing leaves your machine.
@@ -28,7 +49,7 @@ Create a project in deployhealth, add its ingest token as the repository secret
28
49
  ```yaml
29
50
  name: deployhealth
30
51
 
31
- # Push events only. The token is a secret, so never run this on pull_request events from forks.
52
+ # Must not run on pull_request events from forks: the job reads a repository secret.
32
53
  on:
33
54
  push:
34
55
  branches: [main]
@@ -40,28 +61,32 @@ jobs:
40
61
  contents: read
41
62
  steps:
42
63
  - uses: actions/checkout@v5
64
+ with:
65
+ persist-credentials: false
43
66
  - uses: actions/setup-node@v5
44
67
  with:
45
68
  node-version: 22
69
+ package-manager-cache: false
46
70
  - name: Scan env vars and report to deployhealth
47
71
  env:
48
72
  DEPLOYHEALTH_TOKEN: ${{ secrets.DEPLOYHEALTH_TOKEN }}
49
73
  run: |
50
- npx --yes deployhealth-scan@0.1.0 \
51
- --url https://deployhealth-production.up.railway.app \
74
+ npx --yes deployhealth-scan@0.2.0 \
75
+ --url https://deployhealth.dev \
52
76
  --token "$DEPLOYHEALTH_TOKEN" \
53
- --sha "${{ github.sha }}" \
54
- --branch "${{ github.ref_name }}"
77
+ --sha "$GITHUB_SHA" \
78
+ --branch "$GITHUB_REF_NAME"
55
79
  ```
56
80
 
57
- Pin the version (`@0.1.0`) so an update never runs in your CI unreviewed. For a self-hosted
81
+ Pin the version (`@0.2.0`) so an update never runs in your CI unreviewed. For a self-hosted
58
82
  deployhealth, change `--url` to your instance.
59
83
 
60
84
  ## What it sends
61
85
 
62
86
  The commit sha and branch, and for each finding the variable name, `file:line` and env file name,
63
- plus the list of variable names each env file defines. **Never values:** the scanner reads env
64
- files for the names they define; values are neither sent nor printed.
87
+ plus every referenced variable name with the env files that define it (and whether the code has a
88
+ default), and each scope's env file names. **Never values:** the scanner reads env files for the
89
+ names they define; values are neither sent nor printed.
65
90
 
66
91
  ## Options
67
92
 
@@ -71,8 +96,12 @@ files for the names they define; values are neither sent nor printed.
71
96
  --sha <sha> commit being deployed (default: git rev-parse HEAD)
72
97
  --branch <name> branch being deployed (default: current git branch)
73
98
  --dir <path> directory to scan (default: current directory)
74
- --ignore <glob> skip variables matching a glob, e.g. NODE_ENV (repeatable)
99
+ --ignore <glob> skip variables matching a glob, e.g. NEXT_PUBLIC_* (repeatable)
100
+ --no-default-ignore also check names the platform or runtime provides (NODE_ENV,
101
+ CI, GITHUB_SHA, npm_*, VERCEL_*, RAILWAY_*, RENDER_*, FLY_*, ...)
75
102
  --exclude <pattern> skip paths matching a gitignore-style pattern (repeatable)
103
+ --include-tests also scan tests and fixtures (test/, tests/, __tests__/, spec/,
104
+ e2e/, fixtures/, testdata/, *.test.*, *.spec.*, *_test.go, ...)
76
105
  --dry-run print the findings instead of sending them
77
106
  --json with --dry-run, print JSON
78
107
  -v, --version print the version
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import{execFileSync as de}from"child_process";import{resolve as fe}from"path";import{parseArgs as me}from"util";import{readFile as W}from"fs/promises";import{join as G,posix as b}from"path";import{readFile as Se}from"fs/promises";var Z=/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/,J=new Set(['"',"'","`"]);function R(n){let e=n.replace(/^\uFEFF/,"").split(/\r?\n/),t=[],s=[];for(let i=0;i<e.length;i++){let r=e[i]??"",l=r.trim();if(l===""||l.startsWith("#"))continue;let u=Z.exec(r);if(!u){s.push({line:i+1,text:r});continue}let a=u[1],c=(u[2]??"").trimStart(),p=i+1,d=c[0];if(d!==void 0&&J.has(d)){let o=X(e,i,c,d);if(o){t.push({key:a,value:o.value,line:p}),i=o.endIndex;continue}}t.push({key:a,value:ee(c).trim(),line:p})}return{entries:t,invalid:s}}function X(n,e,t,s){let i=t.slice(1),r=e;for(;;){let l=Q(i,s);if(l!==-1){let u=i.slice(0,l);return{value:s==='"'?Y(u):u,endIndex:r}}if(r++,r>=n.length)return null;i+=`
3
- `+n[r]}}function Q(n,e){for(let t=0;t<n.length;t++){let s=n[t];if(s==="\\"){t++;continue}if(s===e)return t}return-1}function Y(n){return n.replace(/\\([nrt"\\])/g,(e,t)=>{switch(t){case"n":return`
4
- `;case"r":return"\r";case"t":return" ";default:return t}})}function ee(n){let e=/(^|\s)#/.exec(n);return e?n.slice(0,e.index):n}var E=[".env.example",".env",".env.local"];var $=/^[0-9a-f]{7,64}$/i;function _({references:n,envFiles:e,isIgnored:t}){let s=[],i=new Set(e.flatMap(a=>a.entries.map(c=>c.key))),r=new Set(n.map(a=>a.name));for(let a of n)t(a.name)||i.has(a.name)||s.push({kind:"missing",var_name:a.name,file:a.file,line:a.line,env_file:null});for(let a of e)for(let{key:c,line:p}of F(a.entries))t(c)||r.has(c)||s.push({kind:"unused",var_name:c,file:a.path,line:p,env_file:a.path});let l=e.find(a=>a.name===".env"),u=e.find(a=>a.name===".env.example");if(l&&u)for(let[a,c]of[[l,u],[u,l]]){let p=new Set(c.entries.map(d=>d.key));for(let{key:d,line:o}of F(a.entries))t(d)||p.has(d)||s.push({kind:"mismatch",var_name:d,file:a.path,line:o,env_file:c.path})}return s}function F(n){let e=new Set;return n.filter(t=>e.has(t.key)?!1:(e.add(t.key),!0))}var k={missing:0,unused:1,mismatch:2};function I({scope:n,references:e,envFiles:t,isIgnored:s}){return[...new Set(e.map(r=>r.name))].filter(r=>!s(r)).sort().map(r=>({var_name:r,scope:n,defined_in:E.filter(l=>t.some(u=>u.name===l&&u.entries.some(a=>a.key===r)))}))}function A(n,e){return k[n.kind]-k[e.kind]||N(n.var_name,e.var_name)||N(n.file??"",e.file??"")||(n.line??0)-(e.line??0)}function P(n){let e={missing:new Set,unused:new Set,mismatch:new Set};for(let t of n)e[t.kind].add(t.var_name);return{missing:e.missing.size,unused:e.unused.size,mismatch:e.mismatch.size}}function N(n,e){return n<e?-1:n>e?1:0}function ne(n){let e="";for(let t of n)t==="*"?e+=".*":t==="?"?e+=".":e+=t.replace(/[.+^${}()|[\]\\]/g,"\\$&");return new RegExp(`^${e}$`)}function O(n){let e=n.map(ne);return t=>e.some(s=>s.test(t))}import{extname as te}from"path";var T={".ts":"javascript",".tsx":"javascript",".js":"javascript",".jsx":"javascript",".py":"python",".go":"go",".rb":"ruby"},L=new Set(Object.keys(T)),S="(?<name>[A-Za-z_][A-Za-z0-9_]*)",h=n=>`(?<q>[${n}])${S}\\k<q>`,re={javascript:[{syntax:"process.env",regex:new RegExp(`\\bprocess\\.env(?:\\?\\.|\\.)${S}\\b`,"g")},{syntax:"process.env",regex:new RegExp(`\\bprocess\\.env(?:\\?\\.)?\\[\\s*${h("'\"`")}\\s*\\]`,"g")},{syntax:"import.meta.env",regex:new RegExp(`\\bimport\\.meta\\.env(?:\\?\\.|\\.)${S}\\b`,"g")},{syntax:"import.meta.env",regex:new RegExp(`\\bimport\\.meta\\.env(?:\\?\\.)?\\[\\s*${h("'\"`")}\\s*\\]`,"g")}],python:[{syntax:"os.environ",regex:new RegExp(`\\bos\\.environ\\s*\\[\\s*${h(`'"`)}\\s*\\]`,"g")},{syntax:"os.environ",regex:new RegExp(`\\bos\\.environ\\.get\\s*\\(\\s*${h(`'"`)}`,"g")},{syntax:"os.getenv",regex:new RegExp(`\\bos\\.getenv\\s*\\(\\s*${h(`'"`)}`,"g")}],go:[{syntax:"os.Getenv",regex:new RegExp(`\\bos\\.Getenv\\s*\\(\\s*${h('"`')}\\s*\\)`,"g")},{syntax:"os.LookupEnv",regex:new RegExp(`\\bos\\.LookupEnv\\s*\\(\\s*${h('"`')}\\s*\\)`,"g")}],ruby:[{syntax:"ENV",regex:new RegExp(`\\bENV\\s*\\[\\s*${h(`'"`)}\\s*\\]`,"g")},{syntax:"ENV",regex:new RegExp(`\\bENV\\.fetch(?:\\s*\\(\\s*|\\s+)${h(`'"`)}`,"g")}]};function j(n){return T[te(n).toLowerCase()]}function V(n,e,t){let s=[],i=n.split(/\r?\n/);for(let r=0;r<i.length;r++){let l=i[r];for(let{syntax:u,regex:a}of re[e]){a.lastIndex=0;for(let c=a.exec(l);c;c=a.exec(l)){let p=c.groups?.name;p&&s.push({name:p,file:t,line:r+1,column:c.index+1,syntax:u})}}}return s.sort((r,l)=>r.line-l.line||r.column-l.column)}import{readdir as le,readFile as ue}from"fs/promises";import{join as C}from"path";var y=class n{constructor(e){this.rules=e}rules;static empty(){return new n([])}extend(e,t){let s=se(t).map(i=>({...i,base:e}));return s.length===0?this:new n([...this.rules,...s])}ignores(e,t){let s=!1;for(let i of this.rules){if(i.dirOnly&&!t)continue;let r=ce(i.base,e);r!==null&&i.regex.test(r)&&(s=!i.negate)}return s}};function se(n){let e=[];for(let t of n.split(/\r?\n/)){let s=ie(t);s&&e.push(s)}return e}function ie(n){let e=oe(n);if(e===""||e.startsWith("#"))return null;let t=!1;e.startsWith("!")?(t=!0,e=e.slice(1)):(e.startsWith("\\!")||e.startsWith("\\#"))&&(e=e.slice(1));let s=!1;if(e.endsWith("/")&&(s=!0,e=e.replace(/\/+$/,"")),e==="")return null;let i=e.includes("/");return e=e.replace(/^\/+/,""),!i&&!e.startsWith("**/")&&(e=`**/${e}`),{regex:new RegExp(`^${ae(e)}$`),negate:t,dirOnly:s,source:n}}function oe(n){let e=n.length;for(;e>0&&n[e-1]===" "&&n[e-2]!=="\\";)e--;return n.slice(0,e)}function ae(n){let e="",t=0;for(;t<n.length;){let s=n[t];if(s==="*"){if(n[t+1]==="*"){let i=t===0,r=i||n[t-1]==="/",l=n[t+2]==="/",u=t+2===n.length;if(r&&l){e+="(?:.*/)?",t+=3;continue}if(r&&u&&!i){e+=".*",t+=2;continue}e+="[^/]*",t+=2;continue}e+="[^/]*",t++;continue}if(s==="?"){e+="[^/]",t++;continue}if(s==="["){let i=n.indexOf("]",t+2);if(i!==-1){let r=n.slice(t+1,i);r.startsWith("!")&&(r=`^${r.slice(1)}`),e+=`[${r.replace(/\\/g,"\\\\")}]`,t=i+1;continue}}if(s==="\\"&&t+1<n.length){e+=D(n[t+1]),t+=2;continue}e+=D(s),t++}return e}function D(n){return n.replace(/[.*+?^${}()|[\]\\/]/g,"\\$&")}function ce(n,e){return n===""?e:e.startsWith(`${n}/`)?e.slice(n.length+1):null}var ge=new Set(["node_modules","dist",".git",".next","venv",".venv"]);async function M(n,e){let t=e.skipDirs??ge,s=e.respectGitignore??!0,i=[],r=[],l=y.empty().extend("",(e.exclude??[]).join(`
5
- `));async function u(a,c){let p=a===""?n:C(n,a),d;try{d=await le(p,{withFileTypes:!0})}catch(o){r.push({file:a||".",message:`cannot read directory: ${o.message}`});return}s&&d.some(o=>o.name===".gitignore"&&o.isFile())&&(c=c.extend(a,await ue(C(p,".gitignore"),"utf8")));for(let o of d){if(o.isSymbolicLink())continue;let g=a===""?o.name:`${a}/${o.name}`;if(o.isDirectory()){if(t.has(o.name)||l.ignores(g,!0)||c.ignores(g,!0))continue;await u(g,c)}else if(o.isFile()){if(!e.include(g,o.name)||l.ignores(g,!1)||c.ignores(g,!1)&&!e.keepIgnored?.(o.name))continue;i.push(g)}}}return await u("",y.empty()),i.sort(),{files:i,warnings:r}}var w=new Set(E);async function q(n,e={}){let{files:t,warnings:s}=await M(n,{include:(o,g)=>w.has(g)||L.has(b.extname(o).toLowerCase()),exclude:e.exclude,keepIgnored:o=>w.has(o)}),i=[],r=[];for(let o of t){let g=b.basename(o);if(!w.has(g)){r.push(o);continue}let{entries:f,invalid:v}=R(await W(G(n,o),"utf8"));for(let{line:U}of v)s.push({file:o,line:U,message:"ignored a line that is not KEY=value"});i.push({path:o,name:g,entries:f})}let l=new Set(i.map(o=>x(o.path))),u=new Map;for(let o of r){let g=j(o);if(!g)continue;let f=V(await W(G(n,o),"utf8"),g,o);if(f.length===0)continue;let v=pe(x(o),l);u.set(v,[...u.get(v)??[],...f])}let a=O(e.ignore??[]),c=[...new Set([...l,...u.keys()])].sort(),p=c.flatMap(o=>_({references:u.get(o)??[],envFiles:i.filter(g=>x(g.path)===o),isIgnored:a})).sort(A),d=c.flatMap(o=>I({scope:o,references:u.get(o)??[],envFiles:i.filter(g=>x(g.path)===o),isIgnored:a}));return{findings:p,variables:d,counts:P(p),scopes:c,sourceFiles:r.length,envFiles:i.map(o=>o.path),warnings:s}}function x(n){let e=b.dirname(n);return e==="."?"":e}function pe(n,e){for(let t=n;t!=="";t=x(t))if(e.has(t))return t;return""}var z="0.1.0";var m={ok:0,failed:1,usage:2},K=`Usage: deployhealth-scan [options]
2
+ import{execFileSync as ke}from"child_process";import{resolve as Ue}from"path";import{parseArgs as Oe}from"util";import{readFile as Fe}from"fs/promises";import{join as Ne,posix as $}from"path";var O=["NODE_ENV","NEXT_RUNTIME","npm_*","HOME","PATH","PWD","USER","SHELL","TMPDIR","HOSTNAME","CI","GITHUB_ACTION","GITHUB_ACTION_PATH","GITHUB_ACTION_REPOSITORY","GITHUB_ACTIONS","GITHUB_ACTOR","GITHUB_ACTOR_ID","GITHUB_API_URL","GITHUB_ARTIFACTS","GITHUB_ARTIFACTS_LIST","GITHUB_BASE_REF","GITHUB_ENV","GITHUB_EVENT_NAME","GITHUB_EVENT_PATH","GITHUB_GRAPHQL_URL","GITHUB_HEAD_REF","GITHUB_JOB","GITHUB_OUTPUT","GITHUB_PATH","GITHUB_REF","GITHUB_REF_NAME","GITHUB_REF_PROTECTED","GITHUB_REF_TYPE","GITHUB_REPOSITORY","GITHUB_REPOSITORY_ID","GITHUB_REPOSITORY_OWNER","GITHUB_REPOSITORY_OWNER_ID","GITHUB_RETENTION_DAYS","GITHUB_RUN_ATTEMPT","GITHUB_RUN_ID","GITHUB_RUN_NUMBER","GITHUB_SERVER_URL","GITHUB_SHA","GITHUB_STEP_SUMMARY","GITHUB_TRIGGERING_ACTOR","GITHUB_WORKFLOW","GITHUB_WORKFLOW_REF","GITHUB_WORKFLOW_SHA","GITHUB_WORKSPACE","RUNNER_*","VERCEL","VERCEL_*","NEXT_PUBLIC_VERCEL_*","RAILWAY_*","RENDER","RENDER_*","FLY_*"];import{readFile as Me}from"fs/promises";var ce=/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/,le=new Set(['"',"'","`"]);function G(n){let e=n.replace(/^\uFEFF/,"").split(/\r?\n/),t=[],i=[];for(let s=0;s<e.length;s++){let r=e[s]??"",c=r.trim();if(c===""||c.startsWith("#"))continue;let l=ce.exec(r);if(!l){i.push({line:s+1,text:r});continue}let p=l[1],a=(l[2]??"").trimStart(),u=s+1,m=a[0];if(m!==void 0&&le.has(m)){let d=ue(e,s,a,m);if(d){t.push({key:p,value:d.value,line:u}),s=d.endIndex;continue}}t.push({key:p,value:fe(a).trim(),line:u})}return{entries:t,invalid:i}}function ue(n,e,t,i){let s=t.slice(1),r=e;for(;;){let c=pe(s,i);if(c!==-1){let l=s.slice(0,c);return{value:i==='"'?ge(l):l,endIndex:r}}if(r++,r>=n.length)return null;s+=`
3
+ `+n[r]}}function pe(n,e){for(let t=0;t<n.length;t++){let i=n[t];if(i==="\\"){t++;continue}if(i===e)return t}return-1}function ge(n){return n.replace(/\\([nrt"\\])/g,(e,t)=>{switch(t){case"n":return`
4
+ `;case"r":return"\r";case"t":return" ";default:return t}})}function fe(n){let e=/(^|\s)#/.exec(n);return e?n.slice(0,e.index):n}var x=[".env.example",".env",".env.local",".env.development",".env.development.local",".env.production",".env.production.local",".env.test",".env.test.local"];var P=/^[0-9a-f]{7,64}$/i;function D({references:n,envFiles:e,isIgnored:t,usedByTests:i}){let s=[],r=new Set(e.flatMap(a=>a.entries.map(u=>u.key))),c=new Set([...n.map(a=>a.name),...i??[]]);for(let a of e.length===0?[]:n)t(a.name)||a.hasDefault||r.has(a.name)||s.push({kind:"missing",var_name:a.name,file:a.file,line:a.line,env_file:null});for(let a of e)for(let{key:u,line:m}of B(a.entries))t(u)||c.has(u)||s.push({kind:"unused",var_name:u,file:a.path,line:m,env_file:a.path});let l=e.find(a=>a.name===".env"),p=e.find(a=>a.name===".env.example");if(l&&p)for(let[a,u]of[[l,p],[p,l]]){let m=new Set(u.entries.map(d=>d.key));for(let{key:d,line:_}of B(a.entries))t(d)||m.has(d)||s.push({kind:"mismatch",var_name:d,file:a.path,line:_,env_file:u.path})}return s}function B(n){let e=new Set;return n.filter(t=>e.has(t.key)?!1:(e.add(t.key),!0))}var H={missing:0,unused:1,mismatch:2};function M({scope:n,references:e,envFiles:t,isIgnored:i}){return[...new Set(e.map(r=>r.name))].filter(r=>!i(r)).sort().map(r=>{let c={var_name:r,scope:n,defined_in:x.filter(l=>t.some(p=>p.name===l&&p.entries.some(a=>a.key===r)))};return e.every(l=>l.name!==r||l.hasDefault)&&(c.optional=!0),c})}function C(n,e){return H[n.kind]-H[e.kind]||L(n.var_name,e.var_name)||L(n.file??"",e.file??"")||(n.line??0)-(e.line??0)}function V(n){let e={missing:new Set,unused:new Set,mismatch:new Set};for(let t of n)e[t.kind].add(t.var_name);return{missing:e.missing.size,unused:e.unused.size,mismatch:e.mismatch.size}}function L(n,e){return n<e?-1:n>e?1:0}function de(n){let e="";for(let t of n)t==="*"?e+=".*":t==="?"?e+=".":e+=t.replace(/[.+^${}()|[\]\\]/g,"\\$&");return new RegExp(`^${e}$`)}function w(n){let e=n.map(de);return t=>e.some(i=>i.test(t))}import{extname as me}from"path";var j={".ts":"javascript",".tsx":"javascript",".mts":"javascript",".cts":"javascript",".js":"javascript",".jsx":"javascript",".mjs":"javascript",".cjs":"javascript",".py":"python",".go":"go",".rb":"ruby"},K=new Set(Object.keys(j)),F="(?<name>[A-Za-z_][A-Za-z0-9_]*)",S=n=>`(?<q>[${n}])${F}\\k<q>`,I=/^\s*(?:\?\?|\|\|)=?\s*(?!(?:undefined|null)\b)\S/,W=/^\s*(?:,|\)\s*or\b)\s*(?!None\b)[^\s)]/,he=/^\s*(?:,\s*(?!nil\b)\S|\)\s*(?:\{|do\b))/,ye=/^\s*\|\|\s*(?!nil\b)\S/,Ee={javascript:[{syntax:"process.env",regex:new RegExp(`\\bprocess\\.env(?:\\?\\.|\\.)${F}\\b`,"g"),defaultAfter:I},{syntax:"process.env",regex:new RegExp(`\\bprocess\\.env(?:\\?\\.)?\\[\\s*${S("'\"`")}\\s*\\]`,"g"),defaultAfter:I},{syntax:"import.meta.env",regex:new RegExp(`\\bimport\\.meta\\.env(?:\\?\\.|\\.)${F}\\b`,"g"),defaultAfter:I},{syntax:"import.meta.env",regex:new RegExp(`\\bimport\\.meta\\.env(?:\\?\\.)?\\[\\s*${S("'\"`")}\\s*\\]`,"g"),defaultAfter:I}],python:[{syntax:"os.environ",regex:new RegExp(`\\bos\\.environ\\s*\\[\\s*${S(`'"`)}\\s*\\]`,"g")},{syntax:"os.environ",regex:new RegExp(`\\bos\\.environ\\.get\\s*\\(\\s*${S(`'"`)}`,"g"),defaultAfter:W},{syntax:"os.getenv",regex:new RegExp(`\\bos\\.getenv\\s*\\(\\s*${S(`'"`)}`,"g"),defaultAfter:W}],go:[{syntax:"os.Getenv",regex:new RegExp(`\\bos\\.Getenv\\s*\\(\\s*${S('"`')}\\s*\\)`,"g")},{syntax:"os.LookupEnv",regex:new RegExp(`\\bos\\.LookupEnv\\s*\\(\\s*${S('"`')}\\s*\\)`,"g")}],ruby:[{syntax:"ENV",regex:new RegExp(`\\bENV\\s*\\[\\s*${S(`'"`)}\\s*\\]`,"g"),defaultAfter:ye},{syntax:"ENV",regex:new RegExp(`\\bENV\\.fetch(?:\\s*\\(\\s*|\\s+)${S(`'"`)}`,"g"),defaultAfter:he}]};function N(n){return j[me(n).toLowerCase()]}function A(n,e,t){let i=[],s=n.split(/\r?\n/);for(let r=0;r<s.length;r++){let c=s[r];for(let{syntax:l,regex:p,defaultAfter:a}of Ee[e]){p.lastIndex=0;for(let u=p.exec(c);u;u=p.exec(c)){let m=u.groups?.name;if(!m)continue;let d={name:m,file:t,line:r+1,column:u.index+1,syntax:l};a?.test(c.slice(u.index+u[0].length))&&(d.hasDefault=!0),i.push(d)}}}return i.sort((r,c)=>r.line-c.line||r.column-c.column)}var T=class n{constructor(e){this.rules=e}rules;static empty(){return new n([])}extend(e,t){let i=_e(t).map(s=>({...s,base:e}));return i.length===0?this:new n([...this.rules,...i])}ignores(e,t){let i=!1;for(let s of this.rules){if(s.dirOnly&&!t)continue;let r=Re(s.base,e);r!==null&&s.regex.test(r)&&(i=!s.negate)}return i}};function _e(n){let e=[];for(let t of n.split(/\r?\n/)){let i=Se(t);i&&e.push(i)}return e}function Se(n){let e=ve(n);if(e===""||e.startsWith("#"))return null;let t=!1;e.startsWith("!")?(t=!0,e=e.slice(1)):(e.startsWith("\\!")||e.startsWith("\\#"))&&(e=e.slice(1));let i=!1;if(e.endsWith("/")&&(i=!0,e=e.replace(/\/+$/,"")),e==="")return null;let s=e.includes("/");return e=e.replace(/^\/+/,""),!s&&!e.startsWith("**/")&&(e=`**/${e}`),{regex:new RegExp(`^${xe(e)}$`),negate:t,dirOnly:i,source:n}}function ve(n){let e=n.length;for(;e>0&&n[e-1]===" "&&n[e-2]!=="\\";)e--;return n.slice(0,e)}function xe(n){let e="",t=0;for(;t<n.length;){let i=n[t];if(i==="*"){if(n[t+1]==="*"){let s=t===0,r=s||n[t-1]==="/",c=n[t+2]==="/",l=t+2===n.length;if(r&&c){e+="(?:.*/)?",t+=3;continue}if(r&&l&&!s){e+=".*",t+=2;continue}e+="[^/]*",t+=2;continue}e+="[^/]*",t++;continue}if(i==="?"){e+="[^/]",t++;continue}if(i==="["){let s=n.indexOf("]",t+2);if(s!==-1){let r=n.slice(t+1,s);r.startsWith("!")&&(r=`^${r.slice(1)}`),e+=`[${r.replace(/\\/g,"\\\\")}]`,t=s+1;continue}}if(i==="\\"&&t+1<n.length){e+=Y(n[t+1]),t+=2;continue}e+=Y(i),t++}return e}function Y(n){return n.replace(/[.*+?^${}()|[\]\\/]/g,"\\$&")}function Re(n,e){return n===""?e:e.startsWith(`${n}/`)?e.slice(n.length+1):null}var q=new Set(["test","tests","__tests__","spec","e2e","fixtures","__fixtures__","testdata"]),Te=[/.\.test\./,/.\.spec\./,/_test\.go$/,/^test_.*\.py$/,/_test\.py$/,/^conftest\.py$/,/_spec\.rb$/],Ie=new Set(x);function z(n){return!Ie.has(n)&&Te.some(e=>e.test(n))}import{readdir as be,readFile as we}from"fs/promises";import{join as X}from"path";var J=new Set(["node_modules","dist",".git",".next","venv",".venv"]);async function Z(n,e){let t=e.skipDirs??J,i=e.respectGitignore??!0,s=[],r=[],c=[],l=T.empty().extend("",(e.exclude??[]).join(`
5
+ `));async function p(a,u,m){let d=a===""?n:X(n,a),_;try{_=await be(d,{withFileTypes:!0})}catch(g){r.push({file:a||".",message:`cannot read directory: ${g.message}`});return}i&&_.some(g=>g.name===".gitignore"&&g.isFile())&&(u=u.extend(a,await we(X(d,".gitignore"),"utf8")));for(let g of _){if(g.isSymbolicLink())continue;let h=a===""?g.name:`${a}/${g.name}`;if(g.isDirectory()){if(t.has(g.name)||l.ignores(h,!0)||u.ignores(h,!0))continue;await p(h,u,m||e.skipTests===!0&&q.has(g.name))}else if(g.isFile()){if(!e.include(h,g.name)||l.ignores(h,!1)||u.ignores(h,!1)&&!e.keepIgnored?.(g.name))continue;e.skipTests&&(m||z(g.name))?c.push(h):s.push(h)}}}return await p("",T.empty(),!1),s.sort(),c.sort(),{files:s,warnings:r,testFiles:c}}var k=new Set(x);async function ee(n,e={}){let{files:t,warnings:i,testFiles:s}=await Z(n,{include:Ae,exclude:e.exclude,keepIgnored:r=>k.has(r),skipTests:!e.includeTests});return $e(t,r=>Fe(Ne(n,r),"utf8"),i,e,s)}function Ae(n,e){return k.has(e)||K.has($.extname(n).toLowerCase())}async function $e(n,e,t,i,s){let r=[],c=[];for(let o of n){let f=$.basename(o);if(!k.has(f)){c.push(o);continue}let{entries:y,invalid:v}=G(await e(o));for(let{line:b}of v)t.push({file:o,line:b,message:"ignored a line that is not KEY=value"});r.push({path:o,name:f,entries:y})}let l=new Set(r.map(o=>R(o.path))),p=new Map;for(let o of c){let f=N(o);if(!f)continue;let y=A(await e(o),f,o);if(y.length===0)continue;let v=Q(R(o),l);p.set(v,[...p.get(v)??[],...y])}let a=new Map;for(let o of s){let f=N(o);if(!f)continue;let y=Q(R(o),l),v=a.get(y)??new Set;for(let b of A(await e(o),f,o))v.add(b.name);a.set(y,v)}let u=w(i.ignore??[]),m=w(i.defaultIgnore===!1?[]:O),d=o=>u(o)||m(o),_=[...p.values()].flat().map(o=>o.name).concat(r.flatMap(o=>o.entries.map(f=>f.key))),g=[...new Set(_.filter(o=>m(o)&&!u(o)))].sort(),h=[...new Set([...l,...p.keys()])].sort(),ie=h.map(o=>({scope:o,env_files:x.filter(f=>r.some(y=>R(y.path)===o&&y.name===f))})),U=h.flatMap(o=>D({references:p.get(o)??[],envFiles:r.filter(f=>R(f.path)===o),isIgnored:d,usedByTests:a.get(o)})).sort(C),oe=h.flatMap(o=>M({scope:o,references:p.get(o)??[],envFiles:r.filter(f=>R(f.path)===o),isIgnored:d})),ae=h.flatMap(o=>(p.get(o)??[]).filter(f=>!d(f.name)).map(f=>({...f,scope:o}))).sort((o,f)=>o.file.localeCompare(f.file)||o.line-f.line||o.column-f.column);return{findings:U,references:ae,variables:oe,counts:V(U),scopes:h,envScopes:ie,defaultIgnored:g,testFilesSkipped:s.length,sourceFiles:c.length,envFiles:r.map(o=>o.path),warnings:t}}function R(n){let e=$.dirname(n);return e==="."?"":e}function Q(n,e){for(let t=n;t!=="";t=R(t))if(e.has(t))return t;return""}var ne="0.2.0";var E={ok:0,failed:1,usage:2},te=`Usage: deployhealth-scan [options]
6
6
 
7
7
  Scan a repository for env var drift and report it to deployhealth.
8
8
 
@@ -12,23 +12,27 @@ Options:
12
12
  --sha <sha> commit being deployed (default: git rev-parse HEAD)
13
13
  --branch <name> branch being deployed (default: current git branch)
14
14
  --dir <path> directory to scan (default: current directory)
15
- --ignore <glob> skip variables matching a glob, e.g. NODE_ENV (repeatable)
15
+ --ignore <glob> skip variables matching a glob, e.g. NEXT_PUBLIC_* (repeatable)
16
+ --no-default-ignore also check names the platform or runtime provides (NODE_ENV,
17
+ CI, GITHUB_SHA, npm_*, VERCEL_*, RAILWAY_*, RENDER_*, FLY_*, ...)
16
18
  --exclude <pattern> skip paths matching a gitignore-style pattern (repeatable)
19
+ --include-tests also scan tests and fixtures (test/, tests/, __tests__/, spec/,
20
+ e2e/, fixtures/, testdata/, *.test.*, *.spec.*, *_test.go, ...)
17
21
  --dry-run print the findings instead of sending them
18
22
  --json with --dry-run, print JSON
19
23
  -v, --version print the version
20
24
  -h, --help show this help
21
- `,he={url:{type:"string"},token:{type:"string"},sha:{type:"string"},branch:{type:"string"},dir:{type:"string"},ignore:{type:"string",multiple:!0},exclude:{type:"string",multiple:!0},"dry-run":{type:"boolean"},json:{type:"boolean"},version:{type:"boolean",short:"v"},help:{type:"boolean",short:"h"}};async function B(n,e){let t;try{({values:t}=me({args:[...n],options:he,strict:!0,allowPositionals:!1}))}catch(f){return e.stderr(`deployhealth-scan: ${f.message}
25
+ `,Ge={url:{type:"string"},token:{type:"string"},sha:{type:"string"},branch:{type:"string"},dir:{type:"string"},ignore:{type:"string",multiple:!0},"no-default-ignore":{type:"boolean"},"include-tests":{type:"boolean"},exclude:{type:"string",multiple:!0},"dry-run":{type:"boolean"},json:{type:"boolean"},version:{type:"boolean",short:"v"},help:{type:"boolean",short:"h"}};async function se(n,e){let t;try{({values:t}=Oe({args:[...n],options:Ge,strict:!0,allowPositionals:!1}))}catch(g){return e.stderr(`deployhealth-scan: ${g.message}
22
26
 
23
- ${K}`),m.usage}if(t.help)return e.stdout(K),m.ok;if(t.version)return e.stdout(`${z}
24
- `),m.ok;let s=fe(e.cwd,t.dir??"."),i=await q(s,{ignore:t.ignore,exclude:t.exclude});for(let f of i.warnings)e.stderr(`warning ${f.file}${f.line?`:${f.line}`:""}: ${f.message}
25
- `);if(t["dry-run"])return e.stdout(t.json?`${JSON.stringify(ye(i),null,2)}
26
- `:ve(i)),m.ok;if(!t.url||!t.token)return e.stderr(`deployhealth-scan: --url and --token are required (or pass --dry-run)
27
- `),m.usage;let r=t.sha??e.git(["rev-parse","HEAD"],s),l=t.branch??e.git(["rev-parse","--abbrev-ref","HEAD"],s);if(!r||!l||l==="HEAD")return e.stderr(`deployhealth-scan: could not determine the commit and branch from git; pass --sha and --branch
28
- `),m.usage;if(!$.test(r))return e.stderr(`deployhealth-scan: --sha must be a hex commit id, got "${r}"
29
- `),m.usage;let u={sha:r,branch:l,timestamp:e.now().toISOString(),findings:i.findings,variables:i.variables},a=`${t.url.replace(/\/+$/,"")}/api/ingest/scan`,c;try{c=await e.fetch(a,{method:"POST",headers:{authorization:`Bearer ${t.token}`,"content-type":"application/json"},body:JSON.stringify(u)})}catch(f){return e.stderr(`deployhealth-scan: could not reach ${a}: ${f.message}
30
- `),m.failed}if(!c.ok){let f=(await c.text()).slice(0,500);return e.stderr(`deployhealth-scan: ingest failed with HTTP ${c.status}: ${f}
31
- `),m.failed}let p=await c.json(),{missing:d,unused:o,mismatch:g}=p.counts;return e.stdout(`deployhealth: reported ${r.slice(0,7)} on ${l}: ${d} missing, ${o} unused, ${g} mismatch (deploy ${p.deployId})
32
- `),m.ok}function ye(n){return{counts:n.counts,scopes:n.scopes,findings:n.findings,variables:n.variables,warnings:n.warnings}}var xe={missing:"MISSING",unused:"UNUSED",mismatch:"MISMATCH"};function ve(n){let e=n.scopes.map(s=>s||"(root)").join(", "),t=[`deployhealth-scan: ${n.sourceFiles} source files, scopes: ${e}`,""];for(let s of["missing","unused","mismatch"]){let i=n.findings.filter(r=>r.kind===s);t.push(`${xe[s]} (${n.counts[s]})`),i.length===0&&t.push(" none");for(let r of i){let l=r.file?`${r.file}${r.line?`:${r.line}`:""}`:"",u=s==="mismatch"?` (missing from ${r.env_file})`:"";t.push(` ${r.var_name.padEnd(28)} ${l}${u}`)}t.push("")}return`${t.join(`
33
- `)}`}var H=()=>({cwd:process.cwd(),stdout:n=>process.stdout.write(n),stderr:n=>process.stderr.write(n),fetch:globalThis.fetch,git:(n,e)=>{try{return de("git",n,{cwd:e,encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null}catch{return null}},now:()=>new Date});B(process.argv.slice(2),H()).then(n=>{process.exitCode=n},n=>{process.stderr.write(`deployhealth-scan: unexpected error: ${n instanceof Error?n.stack:String(n)}
27
+ ${te}`),E.usage}if(t.help)return e.stdout(te),E.ok;if(t.version)return e.stdout(`${ne}
28
+ `),E.ok;let i=Ue(e.cwd,t.dir??"."),s=await ee(i,{ignore:t.ignore,exclude:t.exclude,defaultIgnore:!t["no-default-ignore"],includeTests:t["include-tests"]});for(let g of s.warnings)e.stderr(`warning ${g.file}${g.line?`:${g.line}`:""}: ${g.message}
29
+ `);if(t["dry-run"])return e.stdout(t.json?`${JSON.stringify(Pe(s),null,2)}
30
+ `:He(s)),E.ok;if(!t.url||!t.token)return e.stderr(`deployhealth-scan: --url and --token are required (or pass --dry-run)
31
+ `),E.usage;let r=t.sha??e.git(["rev-parse","HEAD"],i),c=t.branch??e.git(["rev-parse","--abbrev-ref","HEAD"],i);if(!r||!c||c==="HEAD")return e.stderr(`deployhealth-scan: could not determine the commit and branch from git; pass --sha and --branch
32
+ `),E.usage;if(!P.test(r))return e.stderr(`deployhealth-scan: --sha must be a hex commit id, got "${r}"
33
+ `),E.usage;let l={sha:r,branch:c,timestamp:e.now().toISOString(),findings:s.findings,variables:s.variables,env_scopes:s.envScopes},p=`${t.url.replace(/\/+$/,"")}/api/ingest/scan`,a;try{a=await e.fetch(p,{method:"POST",headers:{authorization:`Bearer ${t.token}`,"content-type":"application/json"},body:JSON.stringify(l)})}catch(g){return e.stderr(`deployhealth-scan: could not reach ${p}: ${g.message}
34
+ `),E.failed}if(!a.ok){let g=(await a.text()).slice(0,500);return e.stderr(`deployhealth-scan: ingest failed with HTTP ${a.status}: ${g}
35
+ `),E.failed}let u=await a.json(),{missing:m,unused:d,mismatch:_}=u.counts;return e.stdout(`deployhealth: reported ${r.slice(0,7)} on ${c}: ${m} missing, ${d} unused, ${_} mismatch (deploy ${u.deployId})
36
+ `),E.ok}function Pe(n){return{counts:n.counts,scopes:n.scopes,env_scopes:n.envScopes,findings:n.findings,variables:n.variables,default_ignored:n.defaultIgnored,test_files_skipped:n.testFilesSkipped,warnings:n.warnings}}var Be={missing:"MISSING",unused:"UNUSED",mismatch:"MISMATCH"};function He(n){let e=n.scopes.map(s=>s||"(root)").join(", "),t=[`deployhealth-scan: ${n.sourceFiles} source files, scopes: ${e}`,""];for(let s of["missing","unused","mismatch"]){let r=n.findings.filter(c=>c.kind===s);t.push(`${Be[s]} (${n.counts[s]})`),r.length===0&&t.push(" none");for(let c of r){let l=c.file?`${c.file}${c.line?`:${c.line}`:""}`:"",p=s==="mismatch"?` (missing from ${c.env_file})`:"";t.push(` ${c.var_name.padEnd(28)} ${l}${p}`)}t.push("")}let i=n.variables.filter(s=>s.optional&&s.defined_in.length===0);if(i.length>0){t.push(`OPTIONAL (${i.length}): a default in code, not defined in an env file`);for(let s of i)t.push(` ${s.var_name.padEnd(28)} ${s.scope||"(root)"}`);t.push("")}for(let{scope:s}of n.envScopes.filter(r=>r.env_files.length===0)){let r=n.variables.filter(c=>c.scope===s).length;r!==0&&(t.push(`No .env.example in ${s||"the repository root"}: ${r} variable${r===1?"":"s"} referenced (--json lists them).`),t.push(""))}if(n.testFilesSkipped>0){let s=n.testFilesSkipped===1?"file":"files";t.push(`Skipped ${n.testFilesSkipped} test and fixture ${s} (read only to see which variables they use). --include-tests includes them.`),t.push("")}return n.defaultIgnored.length>0&&(t.push(`Skipped (the platform or runtime provides them): ${n.defaultIgnored.join(", ")}. --no-default-ignore includes them.`),t.push("")),`${t.join(`
37
+ `)}`}var re=()=>({cwd:process.cwd(),stdout:n=>process.stdout.write(n),stderr:n=>process.stderr.write(n),fetch:globalThis.fetch,git:(n,e)=>{try{return ke("git",n,{cwd:e,encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null}catch{return null}},now:()=>new Date});se(process.argv.slice(2),re()).then(n=>{process.exitCode=n},n=>{process.stderr.write(`deployhealth-scan: unexpected error: ${n instanceof Error?n.stack:String(n)}
34
38
  `),process.exitCode=1});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deployhealth-scan",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Find env var drift in a repo (read in code but never defined, defined but never read, out of sync between env files) and report it to deployhealth.",
5
5
  "license": "MIT",
6
6
  "author": "Utsav Mishra",