deployhealth-scan 0.1.0 → 0.3.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,77 @@ 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 (including pydantic-settings classes), Go and Ruby, respects `.gitignore`, and treats each
13
+ folder with its own env files as a separate scope (monorepos). One file, no dependencies, Node 20+.
14
+
15
+ Env files it reads: `.env`, `.env.local`, `.env.development`, `.env.production`, `.env.test` and
16
+ their `.local` variants, plus **declaration files**: `.env.example`, `.env.sample`,
17
+ `.env.template`, `.env.dist`, `.env.defaults`, `example.env`, `sample.env`, `env.example` and
18
+ `.env.<name>.example` / `.sample` / `.template` (e.g. `.env.appStore.example`). A commented-out
19
+ `# KEY=` line in a declaration file declares `KEY` too.
20
+
21
+ A first scan stays quiet:
22
+
23
+ - **Names the platform or runtime provides are skipped**: `NODE_ENV`, `CI`, `npm_*`, GitHub
24
+ Actions' default variables (`GITHUB_SHA`, `GITHUB_REF_NAME`, …, not your own `GITHUB_` names),
25
+ `RUNNER_*`, `VERCEL_*`, `RAILWAY_*`, `RENDER_*`, `FLY_*` and a few more. `--no-default-ignore`
26
+ checks them too; `--dry-run` lists what was skipped.
27
+ - **A read with a default on the same line isn't MISSING**: `process.env.X ?? "a"` or `|| "a"`,
28
+ `os.getenv("X", "a")`, `os.environ.get("X", "a")`, `os.getenv("X") or "a"`, `ENV.fetch("X", "a")`,
29
+ `ENV.fetch("X") { … }`, `ENV["X"] || "a"`. Such variables are listed as optional. (A default of
30
+ `undefined`, `null`, `None` or `nil` is no default.)
31
+ - **Tests, fixtures and test tooling are skipped**: `test/`, `tests/`, `__tests__/`, `spec/`,
32
+ `e2e/`, `fixtures/`, `__fixtures__/`, `testdata/`, `playwright/`, `cypress/`, `mocks/`,
33
+ `__mocks__/` and `testing/` directories (their env files too, so a fixture's `.env` never
34
+ becomes a scope), and `*.test.*`, `*.spec.*`, `*.e2e.*`, `*.e2e-spec.*`, `*.cy.*`, `*_test.go`,
35
+ `test_*.py`, `*_test.py`, `conftest.py`, `*_spec.rb`, and `playwright`/`vitest`/`jest`/`cypress`
36
+ config files, `vitest.workspace.*` and `vitest`/`jest` setup files. They're only read to see
37
+ which variables they use, so a variable only tests read isn't UNUSED. `--include-tests` scans
38
+ them like any other file.
39
+ - **Vendored and generated code is never read**: `node_modules`, `dist`, `.next`, `venv`,
40
+ `.venv`, `.yarn`, `vendor`, `third_party`, `bower_components`, `out`, `coverage`, `.turbo`,
41
+ `.vercel`, `.output`, `.svelte-kit`, `.nuxt`, `.cache`, `.pnpm-store`, `__pycache__` and
42
+ `site-packages` directories, `.pnp.cjs`, `.pnp.loader.mjs`, `*.min.js` (`.mjs`, `.cjs`), and any
43
+ source file over 512 KB. `--dry-run` says what it skipped.
44
+ - **Docker Compose interpolation counts as a use**: `${VAR}`, `${VAR:-x}`, `${VAR:?x}`, `$VAR`, …
45
+ in `docker-compose*.yml` / `compose*.yaml` keep an env entry Compose consumes from being UNUSED.
46
+ They never make MISSING rows (Compose often gets values from the shell or CI).
47
+ - **One row per variable per line**, even when a line reads it twice, and a quoted
48
+ `'process.env.X'` (a bundler `define` key) isn't a read. Same-line destructuring counts:
49
+ `const { X, Y: y, Z = "a" } = process.env` reads all three, `Z` as optional.
50
+ - **A folder with no env file at all gets one line**, "No .env.example …: N variables referenced",
51
+ instead of a MISSING row per reference; deployhealth's handoff offers them as a starting
52
+ `.env.example`.
53
+
54
+ ### pydantic-settings
55
+
56
+ In a `.py` file, a class whose bases include `BaseSettings` (or a subclass of one in the same file)
57
+ reads one env var per annotated field: `database_url: str` is `DATABASE_URL`, after a literal
58
+ `env_prefix` (`model_config = SettingsConfigDict(env_prefix="APP_")` or `class Config`). A string
59
+ `alias=` / `validation_alias=` replaces the name; each `AliasChoices(...)` string counts and is
60
+ optional. A field with a default is optional; `= None` counts as a default only when the type
61
+ allows `None` (`str | None`, `Optional[str]`). `ClassVar`, `_private` fields, `model_config` and
62
+ fields typed as a model from the same file are skipped.
63
+
64
+ ### Known misses
65
+
66
+ Same-line, regex-based scanning misses some things on purpose:
67
+
68
+ - reads split across lines (including a destructuring pattern spread over several lines), and
69
+ dynamic keys (`process.env[name]`, `os.environ[key]`);
70
+ - pydantic-settings bases defined in another file, prefixes or aliases held in variables,
71
+ `AliasPath`, and `env_nested_delimiter`'s nested names;
72
+ - Compose's `env_file:` (it passes a whole file into a container) and `environment:` keys;
73
+ - env reads in other file types: Rails' ERB in `config/*.yml`, `.rake` files, shell scripts,
74
+ Dockerfiles and CI workflows;
75
+ - `turbo.json`'s `env` lists, which aren't declarations.
13
76
 
14
77
  ## Try it locally
15
78
 
16
79
  ```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
80
+ npx deployhealth-scan@0.3.0 --dry-run # grouped findings with file:line
81
+ npx deployhealth-scan@0.3.0 --dry-run --json # the same, as JSON
19
82
  ```
20
83
 
21
84
  With `--dry-run` nothing leaves your machine.
@@ -28,7 +91,7 @@ Create a project in deployhealth, add its ingest token as the repository secret
28
91
  ```yaml
29
92
  name: deployhealth
30
93
 
31
- # Push events only. The token is a secret, so never run this on pull_request events from forks.
94
+ # Must not run on pull_request events from forks: the job reads a repository secret.
32
95
  on:
33
96
  push:
34
97
  branches: [main]
@@ -40,28 +103,32 @@ jobs:
40
103
  contents: read
41
104
  steps:
42
105
  - uses: actions/checkout@v5
106
+ with:
107
+ persist-credentials: false
43
108
  - uses: actions/setup-node@v5
44
109
  with:
45
110
  node-version: 22
111
+ package-manager-cache: false
46
112
  - name: Scan env vars and report to deployhealth
47
113
  env:
48
114
  DEPLOYHEALTH_TOKEN: ${{ secrets.DEPLOYHEALTH_TOKEN }}
49
115
  run: |
50
- npx --yes deployhealth-scan@0.1.0 \
51
- --url https://deployhealth-production.up.railway.app \
116
+ npx --yes deployhealth-scan@0.3.0 \
117
+ --url https://deployhealth.dev \
52
118
  --token "$DEPLOYHEALTH_TOKEN" \
53
- --sha "${{ github.sha }}" \
54
- --branch "${{ github.ref_name }}"
119
+ --sha "$GITHUB_SHA" \
120
+ --branch "$GITHUB_REF_NAME"
55
121
  ```
56
122
 
57
- Pin the version (`@0.1.0`) so an update never runs in your CI unreviewed. For a self-hosted
123
+ Pin the version (`@0.3.0`) so an update never runs in your CI unreviewed. For a self-hosted
58
124
  deployhealth, change `--url` to your instance.
59
125
 
60
126
  ## What it sends
61
127
 
62
128
  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.
129
+ plus every referenced variable name with the env files that define it (and whether the code has a
130
+ default), and each scope's env file names. **Never values:** the scanner reads env files for the
131
+ names they define; values are neither sent nor printed.
65
132
 
66
133
  ## Options
67
134
 
@@ -71,8 +138,13 @@ files for the names they define; values are neither sent nor printed.
71
138
  --sha <sha> commit being deployed (default: git rev-parse HEAD)
72
139
  --branch <name> branch being deployed (default: current git branch)
73
140
  --dir <path> directory to scan (default: current directory)
74
- --ignore <glob> skip variables matching a glob, e.g. NODE_ENV (repeatable)
141
+ --ignore <glob> skip variables matching a glob, e.g. NEXT_PUBLIC_* (repeatable)
142
+ --no-default-ignore also check names the platform or runtime provides (NODE_ENV,
143
+ CI, GITHUB_SHA, npm_*, VERCEL_*, RAILWAY_*, RENDER_*, FLY_*, ...)
75
144
  --exclude <pattern> skip paths matching a gitignore-style pattern (repeatable)
145
+ --include-tests also scan tests, fixtures and test tooling (test/, tests/, e2e/,
146
+ fixtures/, playwright/, testing/, *.test.*, *.spec.*, *.e2e.*,
147
+ *_test.go, vitest.config.*, ...)
76
148
  --dry-run print the findings instead of sending them
77
149
  --json with --dry-run, print JSON
78
150
  -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 Sn}from"child_process";import{resolve as Rn}from"path";import{parseArgs as xn}from"util";import{readFile as yn,stat as En}from"fs/promises";import{join as be,posix as J}from"path";function B(e){return/^(?:docker-)?compose(?:[.-][A-Za-z0-9_.-]+)?\.ya?ml$/.test(e)}var ke=/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)(?=[}:?+-])|([A-Za-z_][A-Za-z0-9_]*))/g;function Q(e){let n=new Set;for(let t of e.split(/\r?\n/)){if(t.trimStart().startsWith("#"))continue;let s=t.replace(/\$\$/g,"");for(let r of s.matchAll(ke))n.add(r[1]??r[2])}return n}var ee=["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 $n}from"fs/promises";var Oe=/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/,Le=/^\s*#+\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/,Ue=new Set(['"',"'","`"]);function ne(e){let n=e.replace(/^\uFEFF/,"").split(/\r?\n/),t=[],s=[],r=[];for(let i=0;i<n.length;i++){let o=n[i]??"",a=o.trim();if(a.startsWith("#")){let d=Le.exec(o)?.[1];d&&s.push({key:d,line:i+1});continue}if(a==="")continue;let l=Oe.exec(o);if(!l){r.push({line:i+1,text:o});continue}let p=l[1],c=(l[2]??"").trimStart(),f=i+1,g=c[0];if(g!==void 0&&Ue.has(g)){let d=Pe(n,i,c,g);if(d){t.push({key:p,value:d.value,line:f}),i=d.endIndex;continue}}t.push({key:p,value:Ge(c).trim(),line:f})}return{entries:t,commented:s,invalid:r}}function Pe(e,n,t,s){let r=t.slice(1),i=n;for(;;){let o=De(r,s);if(o!==-1){let a=r.slice(0,o);return{value:s==='"'?Ce(a):a,endIndex:i}}if(i++,i>=e.length)return null;r+=`
3
+ `+e[i]}}function De(e,n){for(let t=0;t<e.length;t++){let s=e[t];if(s==="\\"){t++;continue}if(s===n)return t}return-1}function Ce(e){return e.replace(/\\([nrt"\\])/g,(n,t)=>{switch(t){case"n":return`
4
+ `;case"r":return"\r";case"t":return" ";default:return t}})}function Ge(e){let n=/(^|\s)#/.exec(e);return n?e.slice(0,n.index):e}var Be=[".env.example",".env",".env.local",".env.development",".env.development.local",".env.production",".env.production.local",".env.test",".env.test.local"],te=[".env.sample",".env.template",".env.dist",".env.defaults","example.env","sample.env","env.example"],se=/^\.env\.[A-Za-z0-9_-]{1,64}\.(?:example|sample|template)$/;var H=[...Be,...te],He=new Set(H),Me=new Set([".env.example",...te]);function w(e){return He.has(e)||se.test(e)}function re(e){return Me.has(e)||se.test(e)}function je(e,n){let t=s=>{let r=H.indexOf(s);return r===-1?H.length:r};return t(e)-t(n)||(e<n?-1:e>n?1:0)}function L(e){return[...new Set(e)].sort(je)}var ie=/^[0-9a-f]{7,64}$/i;function le({references:e,envFiles:n,isIgnored:t,usedOutsideCode:s}){let r=[],i=new Set(n.flatMap(p=>[...p.entries,...p.commented??[]].map(c=>c.key))),o=new Set([...e.map(p=>p.name),...s??[]]);for(let p of n.length===0?[]:e)t(p.name)||p.hasDefault||i.has(p.name)||r.push({kind:"missing",var_name:p.name,file:p.file,line:p.line,env_file:null});for(let p of n)for(let{key:c,line:f}of oe(p.entries))t(c)||o.has(c)||r.push({kind:"unused",var_name:c,file:p.path,line:f,env_file:p.path});let a=n.find(p=>p.name===".env"),l=n.find(p=>p.name===".env.example");if(a&&l)for(let[p,c]of[[a,l],[l,a]]){let f=new Set(c.entries.map(g=>g.key));for(let{key:g,line:d}of oe(p.entries))t(g)||f.has(g)||r.push({kind:"mismatch",var_name:g,file:p.path,line:d,env_file:c.path})}return r}function oe(e){let n=new Set;return e.filter(t=>n.has(t.key)?!1:(n.add(t.key),!0))}var ae={missing:0,unused:1,mismatch:2};function ue({scope:e,references:n,envFiles:t,isIgnored:s}){return[...new Set(n.map(i=>i.name))].filter(i=>!s(i)).sort().map(i=>{let o={var_name:i,scope:e,defined_in:L(t.filter(a=>[...a.entries,...a.commented??[]].some(l=>l.key===i)).map(a=>a.name))};return n.every(a=>a.name!==i||a.hasDefault)&&(o.optional=!0),o})}function pe(e,n){return ae[e.kind]-ae[n.kind]||ce(e.var_name,n.var_name)||ce(e.file??"",n.file??"")||(e.line??0)-(n.line??0)}function fe(e){let n={missing:new Set,unused:new Set,mismatch:new Set};for(let t of e)n[t.kind].add(t.var_name);return{missing:n.missing.size,unused:n.unused.size,mismatch:n.mismatch.size}}function ce(e,n){return e<n?-1:e>n?1:0}function ze(e){let n="";for(let t of e)t==="*"?n+=".*":t==="?"?n+=".":n+=t.replace(/[.+^${}()|[\]\\]/g,"\\$&");return new RegExp(`^${n}$`)}function M(e){let n=e.map(ze);return t=>n.some(s=>s.test(t))}import{extname as Qe}from"path";function Ve(e){let n=e.split(/\r?\n/),t=[],s=null,r=0,i=null;for(let o=0;o<n.length;o++){let a=n[o];if(!s){if(a.trim()==="")continue;s={line:o+1,indent:a.length-a.trimStart().length,code:""}}let l="";for(let c=0;c<a.length;){let f=a[c];if(i){f==="\\"?(l+=a.slice(c,c+2),c+=2):a.startsWith(i,c)?(l+=i,c+=i.length,i=null):(l+=f,c++);continue}if(f==="#")break;if(f==='"'||f==="'"){i=a.startsWith(f.repeat(3),c)?f.repeat(3):f,l+=i,c+=i.length;continue}"([{".includes(f)?r++:")]}".includes(f)&&(r=Math.max(0,r-1)),l+=f,c++}let p=!i&&l.trimEnd().endsWith("\\");p&&(l=l.trimEnd().slice(0,-1)),i?.length===1&&(i=null),s.code=s.code?`${s.code} ${l.trim()}`:l.trim(),r===0&&!i&&!p&&(s.code&&t.push(s),s=null)}return s?.code&&t.push(s),t}function U(e,n){let t=[],s=0,r=null,i=0;for(let o=0;o<e.length;o++){let a=e[o];if(r){a==="\\"?o++:e.startsWith(r,o)&&(o+=r.length-1,r=null);continue}if(a==='"'||a==="'")r=e.startsWith(a.repeat(3),o)?a.repeat(3):a;else if("([{".includes(a))s++;else if(")]}".includes(a))s--;else if(a===n&&s===0){if(n==="="&&("=!<>:".includes(e[o-1]??"")||e[o+1]==="="))continue;t.push(e.slice(i,o)),i=o+1}}return t.push(e.slice(i)),t.map(o=>o.trim())}function j(e,n){let t=0;for(let s=n;s<e.length;s++)if(e[s]==="(")t++;else if(e[s]===")"&&--t===0)return e.slice(n+1,s);return null}var We=/^class\s+([A-Za-z_]\w*)\s*(\(?)/;function Ze(e){let n=[];return e.forEach((t,s)=>{let r=We.exec(t.code);if(!r)return;let i=r[2]?j(t.code,r[0].length-1)??"":"",o=U(i,",").filter(l=>l&&!l.includes("=")).map(l=>l.replace(/\[.*$/s,"").split(".").pop().trim()),a=[];for(let l=s+1;l<e.length&&e[l].indent>t.indent;l++)a.push(l);n.push({name:r[1],bases:o,header:s,body:a})}),n}var Ke=/^([A-Za-z_]\w*)\s*:(?!=)\s*(.+)$/s,Ye=/^(?:[A-Za-z_]\w*\.)*Field\s*\(/,ge=/^[rRbBuU]?(['"])([A-Za-z_][A-Za-z0-9_]*)\1$/,qe=/^[A-Za-z_][A-Za-z0-9_]*$/;function de(e,n,t=new Set){if(!e.includes("BaseSettings")&&t.size===0)return[];let s=Ve(e),r=Ze(s),i=new Map,o=c=>c==="BaseSettings"||t.has(c)||i.has(c);for(let c=!0;c;){c=!1;for(let f of r)!i.has(f.name)&&f.bases.some(o)&&(i.set(f.name,f),c=!0)}if(i.size===0)return[];let a=new Set([...i.keys(),...r.filter(c=>c.bases.includes("BaseModel")).map(c=>c.name)]),l=(c,f=new Set)=>{for(let d of[c.header,...c.body]){let h=/\benv_prefix\s*=\s*[rRuU]?(['"])([A-Za-z0-9_]*)\1/.exec(s[d].code);if(h)return h[2]}f.add(c.name);let g=c.bases.map(d=>i.get(d)).find(d=>d&&!f.has(d.name));return g?l(g,f):""},p=[];for(let c of i.values()){if(c.body.length===0)continue;let f=s[c.body[0]].indent,g=l(c),d=c.body.some(h=>/\bcase_sensitive\s*=\s*True\b/.test(s[h].code));for(let h of c.body){let v=s[h];if(v.indent!==f)continue;let y=Ke.exec(v.code);if(!y)continue;let[,E,C]=y;if(E.startsWith("_")||E==="model_config")continue;let[N="",x]=U(C,"=");if(/^(?:typing\.)?ClassVar\b/.test(N)||Xe(N,a))continue;let{value:F,aliases:T}=Je(x),G=/\bNone\b|\bOptional\s*\[/.test(N),$=F!==null&&(F!=="None"||G),u=v.indent+1;if(T.length>0){let I=T.length>1;for(let k of T){let O={name:d?k:k.toUpperCase(),file:n,line:v.line,column:u,syntax:"BaseSettings"};($||I)&&(O.hasDefault=!0),p.push(O)}continue}let m=d?`${g}${E}`:`${g}${E}`.toUpperCase();if(!qe.test(m))continue;let _={name:m,file:n,line:v.line,column:u,syntax:"BaseSettings"};$&&(_.hasDefault=!0),p.push(_)}}return p}function Xe(e,n){let t=e.replace(/^(?:typing\.)?Optional\s*\[(.*)\]$/s,"$1").split("|").map(s=>s.trim()).filter(s=>s!=="None");return t.length===1&&n.has(t[0].replace(/^['"]|['"]$/g,""))}function Je(e){if(e===void 0||e==="")return{value:null,aliases:[]};let n=Ye.exec(e);if(!n)return{value:e,aliases:[]};let t=U(j(e,n[0].length-1)??"",",").filter(Boolean),s=null,r=[];for(let[i,o]of t.entries()){let a=/^([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/s.exec(o);if(!a){i===0&&o!=="..."&&o!=="Ellipsis"&&(s=o);continue}let[,l,p]=a;if(l==="default")s=p==="..."||p==="Ellipsis"?null:p;else if(l==="default_factory")s="factory";else if(l==="alias"||l==="validation_alias"||l==="env"){let c=ge.exec(p);c&&r.push(c[2]);let f=/^(?:[A-Za-z_]\w*\.)*AliasChoices\s*\(/.exec(p);if(f)for(let g of U(j(p,f[0].length-1)??"",",")){let d=ge.exec(g);d&&r.push(d[2])}}}return{value:s,aliases:[...new Set(r)]}}var he={".ts":"javascript",".tsx":"javascript",".mts":"javascript",".cts":"javascript",".js":"javascript",".jsx":"javascript",".mjs":"javascript",".cjs":"javascript",".py":"python",".go":"go",".rb":"ruby"},ye=new Set(Object.keys(he)),V="(?<name>[A-Za-z_][A-Za-z0-9_]*)",R=e=>`(?<q>[${e}])${V}\\k<q>`,P=/^\s*(?:\?\?|\|\|)=?\s*(?!(?:undefined|null)\b)\S/,me=/^\s*(?:,|\)\s*or\b)\s*(?!None\b)[^\s)]/,en=/^\s*(?:,\s*(?!nil\b)\S|\)\s*(?:\{|do\b))/,nn=/^\s*\|\|\s*(?!nil\b)\S/,tn={javascript:[{syntax:"process.env",regex:new RegExp(`\\bprocess\\.env(?:\\?\\.|\\.)${V}\\b`,"g"),defaultAfter:P},{syntax:"process.env",regex:new RegExp(`\\bprocess\\.env(?:\\?\\.)?\\[\\s*${R("'\"`")}\\s*\\]`,"g"),defaultAfter:P},{syntax:"import.meta.env",regex:new RegExp(`\\bimport\\.meta\\.env(?:\\?\\.|\\.)${V}\\b`,"g"),defaultAfter:P},{syntax:"import.meta.env",regex:new RegExp(`\\bimport\\.meta\\.env(?:\\?\\.)?\\[\\s*${R("'\"`")}\\s*\\]`,"g"),defaultAfter:P}],python:[{syntax:"os.environ",regex:new RegExp(`\\bos\\.environ\\s*\\[\\s*${R(`'"`)}\\s*\\]`,"g")},{syntax:"os.environ",regex:new RegExp(`\\bos\\.environ\\.get\\s*\\(\\s*${R(`'"`)}`,"g"),defaultAfter:me},{syntax:"os.getenv",regex:new RegExp(`\\bos\\.getenv\\s*\\(\\s*${R(`'"`)}`,"g"),defaultAfter:me}],go:[{syntax:"os.Getenv",regex:new RegExp(`\\bos\\.Getenv\\s*\\(\\s*${R('"`')}\\s*\\)`,"g")},{syntax:"os.LookupEnv",regex:new RegExp(`\\bos\\.LookupEnv\\s*\\(\\s*${R('"`')}\\s*\\)`,"g")}],ruby:[{syntax:"ENV",regex:new RegExp(`\\bENV\\s*\\[\\s*${R(`'"`)}\\s*\\]`,"g"),defaultAfter:nn},{syntax:"ENV",regex:new RegExp(`\\bENV\\.fetch(?:\\s*\\(\\s*|\\s+)${R(`'"`)}`,"g"),defaultAfter:en}]};function W(e){return he[Qe(e).toLowerCase()]}function Z(e,n,t){let s=[],r=e.split(/\r?\n/);for(let i=0;i<r.length;i++){let o=r[i];for(let{syntax:a,regex:l,defaultAfter:p}of tn[n]){l.lastIndex=0;for(let c=l.exec(o);c;c=l.exec(o)){let f=c.groups?.name;if(!f||cn(o,c.index,c.index+c[0].length))continue;let g={name:f,file:t,line:i+1,column:c.index+1,syntax:a};p?.test(o.slice(c.index+c[0].length))&&(g.hasDefault=!0),s.push(g)}}n==="javascript"&&s.push(...on(o,i+1,t))}return n==="python"&&s.push(...de(e,t)),ln(s).sort((i,o)=>i.line-o.line||i.column-o.column)}var z=/\{([^{}]*)\}\s*(?::[^=;{}]+)?=\s*(process\.env|import\.meta\.env)\b(?!\s*\.|\s*\[|\s*\?\.)/g,sn=/^\s*(?:(['"])([A-Za-z_][A-Za-z0-9_]*)\1|([A-Za-z_][A-Za-z0-9_]*))\s*(?::\s*[A-Za-z_$][\w$]*\s*)?(=\s*(.*))?$/s,rn=/^(?!(?:undefined|null)\b)\S/;function on(e,n,t){let s=[];z.lastIndex=0;for(let r=z.exec(e);r;r=z.exec(e)){let i=r[2]==="process.env"?"process.env":"import.meta.env",o=r.index+1;for(let a of r[1].split(",")){let l=sn.exec(a),p=l?.[2]??l?.[3];if(l&&p){let c={name:p,file:t,line:n,column:o+a.indexOf(p)+1,syntax:i};l[4]&&rn.test(l[5].trim())&&(c.hasDefault=!0),s.push(c)}o+=a.length+1}}return s}var an=new Set(['"',"'","`"]);function cn(e,n,t){let s=e[n-1];return s!==void 0&&an.has(s)&&e[t]===s}function ln(e){let n=new Map;for(let t of[...e].sort((s,r)=>s.line-r.line||s.column-r.column)){let s=`${t.line}\0${t.name}`,r=n.get(s);r?t.hasDefault||delete r.hasDefault:n.set(s,{...t})}return[...n.values()]}var A=class e{constructor(n){this.rules=n}rules;static empty(){return new e([])}extend(n,t){let s=un(t).map(r=>({...r,base:n}));return s.length===0?this:new e([...this.rules,...s])}ignores(n,t){let s=!1;for(let r of this.rules){if(r.dirOnly&&!t)continue;let i=dn(r.base,n);i!==null&&r.regex.test(i)&&(s=!r.negate)}return s}};function un(e){let n=[];for(let t of e.split(/\r?\n/)){let s=pn(t);s&&n.push(s)}return n}function pn(e){let n=fn(e);if(n===""||n.startsWith("#"))return null;let t=!1;n.startsWith("!")?(t=!0,n=n.slice(1)):(n.startsWith("\\!")||n.startsWith("\\#"))&&(n=n.slice(1));let s=!1;if(n.endsWith("/")&&(s=!0,n=n.replace(/\/+$/,"")),n==="")return null;let r=n.includes("/");return n=n.replace(/^\/+/,""),!r&&!n.startsWith("**/")&&(n=`**/${n}`),{regex:new RegExp(`^${gn(n)}$`),negate:t,dirOnly:s,source:e}}function fn(e){let n=e.length;for(;n>0&&e[n-1]===" "&&e[n-2]!=="\\";)n--;return e.slice(0,n)}function gn(e){let n="",t=0;for(;t<e.length;){let s=e[t];if(s==="*"){if(e[t+1]==="*"){let r=t===0,i=r||e[t-1]==="/",o=e[t+2]==="/",a=t+2===e.length;if(i&&o){n+="(?:.*/)?",t+=3;continue}if(i&&a&&!r){n+=".*",t+=2;continue}n+="[^/]*",t+=2;continue}n+="[^/]*",t++;continue}if(s==="?"){n+="[^/]",t++;continue}if(s==="["){let r=e.indexOf("]",t+2);if(r!==-1){let i=e.slice(t+1,r);i.startsWith("!")&&(i=`^${i.slice(1)}`),n+=`[${i.replace(/\\/g,"\\\\")}]`,t=r+1;continue}}if(s==="\\"&&t+1<e.length){n+=Ee(e[t+1]),t+=2;continue}n+=Ee(s),t++}return n}function Ee(e){return e.replace(/[.*+?^${}()|[\]\\/]/g,"\\$&")}function dn(e,n){return e===""?n:n.startsWith(`${e}/`)?n.slice(e.length+1):null}var _e=new Set(["test","tests","__tests__","spec","e2e","fixtures","__fixtures__","testdata","playwright","cypress","mocks","__mocks__","testing"]),mn=[/.\.test\./,/.\.spec\./,/.\.e2e\./,/.\.e2e-spec\./,/.\.cy\./,/_test\.go$/,/^test_.*\.py$/,/_test\.py$/,/^conftest\.py$/,/_spec\.rb$/,/^(?:playwright|vitest|jest|cypress)\.config\./,/^vitest\.workspace\./,/^(?:vitest|jest)\.setup\./];function ve(e){return!w(e)&&mn.some(n=>n.test(e))}var K=new Set(["node_modules","dist",".next","venv",".venv",".yarn","vendor","third_party","bower_components","out","coverage",".turbo",".vercel",".output",".svelte-kit",".nuxt",".cache",".pnpm-store","__pycache__","site-packages"]);function Y(e){return e===".pnp.cjs"||e===".pnp.loader.mjs"||/\.min\.(?:js|mjs|cjs)$/.test(e)}var q=512*1024;import{readdir as Se,readFile as hn}from"fs/promises";import{join as X}from"path";var Re=new Set([".git",...K]);async function xe(e,n){let t=n.skipDirs??Re,s=n.respectGitignore??!0,r=[],i=[],o=[],a=[],l=A.empty().extend("",(n.exclude??[]).join(`
5
+ `));async function p(f,g,d){let h=f===""?e:X(e,f),v;try{v=await Se(h,{withFileTypes:!0})}catch(y){i.push({file:f||".",message:`cannot read directory: ${y.message}`});return}s&&v.some(y=>y.name===".gitignore"&&y.isFile())&&(g=g.extend(f,await hn(X(h,".gitignore"),"utf8")));for(let y of v){if(y.isSymbolicLink())continue;let E=f===""?y.name:`${f}/${y.name}`;if(y.isDirectory()){if(l.ignores(E,!0)||g.ignores(E,!0))continue;if(t.has(y.name)){K.has(y.name)&&!d&&await c(E)&&a.push(`${E}/`);continue}await p(E,g,d||n.skipTests===!0&&_e.has(y.name))}else if(y.isFile()){if(!n.include(E,y.name)||l.ignores(E,!1)||g.ignores(E,!1)&&!n.keepIgnored?.(y.name))continue;Y(y.name)?d||a.push(E):n.skipTests&&(d||ve(y.name))?o.push(E):r.push(E)}}}async function c(f){let g;try{g=await Se(X(e,f),{withFileTypes:!0})}catch{return!1}for(let d of g){let h=`${f}/${d.name}`;if(d.isFile()&&n.include(h,d.name))return!0}for(let d of g)if(d.isDirectory()&&!d.isSymbolicLink()&&await c(`${f}/${d.name}`))return!0;return!1}return await p("",A.empty(),!1),r.sort(),o.sort(),a.sort(),{files:r,warnings:i,testFiles:o,vendored:a}}async function Ie(e,n={}){let{files:t,warnings:s,testFiles:r,vendored:i}=await xe(e,{include:_n,exclude:n.exclude,keepIgnored:w,skipTests:!n.includeTests});return vn(t,{read:a=>yn(be(e,a),"utf8"),size:async a=>(await En(be(e,a))).size},s,n,r,i)}function _n(e,n){return w(n)||B(n)||ye.has(J.extname(e).toLowerCase())}async function vn(e,{read:n,size:t},s,r,i,o){let a=[],l=[],p=[],c=[];for(let u of e){let m=J.basename(u);if(!w(m)){await t(u)>q?c.push(u):(B(m)?p:l).push(u);continue}let{entries:_,commented:I,invalid:k}=ne(await n(u));for(let{line:O}of k)s.push({file:u,line:O,message:"ignored a line that is not KEY=value"});a.push({path:u,name:m,entries:_,commented:re(m)?I:[]})}let f=new Set(a.map(u=>b(u.path))),g=new Map;for(let u of l){let m=W(u);if(!m)continue;let _=Z(await n(u),m,u);if(_.length===0)continue;let I=Te(b(u),f);g.set(I,[...g.get(I)??[],..._])}let d=new Map,h=(u,m)=>{let _=Te(b(u),f);d.set(_,new Set([...d.get(_)??[],...m]))};for(let u of i){let m=W(u);!m||await t(u)>q||h(u,Z(await n(u),m,u).map(_=>_.name))}for(let u of p)h(u,Q(await n(u)));let v=M(r.ignore??[]),y=M(r.defaultIgnore===!1?[]:ee),E=u=>v(u)||y(u),C=[...g.values()].flat().map(u=>u.name).concat(a.flatMap(u=>u.entries.map(m=>m.key))),N=[...new Set(C.filter(u=>y(u)&&!v(u)))].sort(),x=[...new Set([...f,...g.keys()])].sort(),F=x.map(u=>{let m=L(a.filter(_=>b(_.path)===u).map(_=>_.name));return m.length>64&&s.push({file:u||".",message:`${m.length} env files; only the first ${64} are reported`}),{scope:u,env_files:m.slice(0,64)}}),T=x.flatMap(u=>le({references:g.get(u)??[],envFiles:a.filter(m=>b(m.path)===u),isIgnored:E,usedOutsideCode:d.get(u)})).sort(pe),G=x.flatMap(u=>ue({scope:u,references:g.get(u)??[],envFiles:a.filter(m=>b(m.path)===u),isIgnored:E}).map(m=>({...m,defined_in:m.defined_in.slice(0,64)}))),$=x.flatMap(u=>(g.get(u)??[]).filter(m=>!E(m.name)).map(m=>({...m,scope:u}))).sort((u,m)=>u.file.localeCompare(m.file)||u.line-m.line||u.column-m.column);return{findings:T,references:$,variables:G,counts:fe(T),scopes:x,envScopes:F,defaultIgnored:N,testFilesSkipped:i.length,vendoredSkipped:o,tooLargeSkipped:c,sourceFiles:l.length,envFiles:a.map(u=>u.path),warnings:s}}function b(e){let n=J.dirname(e);return n==="."?"":n}function Te(e,n){for(let t=e;t!=="";t=b(t))if(n.has(t))return t;return""}var we="0.3.0";var S={ok:0,failed:1,usage:2},Ae=`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,28 @@ 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, fixtures and test tooling (test/, tests/, e2e/,
20
+ fixtures/, playwright/, testing/, *.test.*, *.spec.*, *.e2e.*,
21
+ *_test.go, vitest.config.*, ...)
17
22
  --dry-run print the findings instead of sending them
18
23
  --json with --dry-run, print JSON
19
24
  -v, --version print the version
20
25
  -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}
26
+ `,bn={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 Fe(e,n){let t;try{({values:t}=xn({args:[...e],options:bn,strict:!0,allowPositionals:!1}))}catch(h){return n.stderr(`deployhealth-scan: ${h.message}
22
27
 
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)}
28
+ ${Ae}`),S.usage}if(t.help)return n.stdout(Ae),S.ok;if(t.version)return n.stdout(`${we}
29
+ `),S.ok;let s=Rn(n.cwd,t.dir??"."),r=await Ie(s,{ignore:t.ignore,exclude:t.exclude,defaultIgnore:!t["no-default-ignore"],includeTests:t["include-tests"]});for(let h of r.warnings)n.stderr(`warning ${h.file}${h.line?`:${h.line}`:""}: ${h.message}
30
+ `);if(t["dry-run"])return n.stdout(t.json?`${JSON.stringify(Tn(r),null,2)}
31
+ `:wn(r)),S.ok;if(!t.url||!t.token)return n.stderr(`deployhealth-scan: --url and --token are required (or pass --dry-run)
32
+ `),S.usage;let i=t.sha??n.git(["rev-parse","HEAD"],s),o=t.branch??n.git(["rev-parse","--abbrev-ref","HEAD"],s);if(!i||!o||o==="HEAD")return n.stderr(`deployhealth-scan: could not determine the commit and branch from git; pass --sha and --branch
33
+ `),S.usage;if(!ie.test(i))return n.stderr(`deployhealth-scan: --sha must be a hex commit id, got "${i}"
34
+ `),S.usage;let a={sha:i,branch:o,timestamp:n.now().toISOString(),findings:r.findings,variables:r.variables,env_scopes:r.envScopes},l=`${t.url.replace(/\/+$/,"")}/api/ingest/scan`,p;try{p=await n.fetch(l,{method:"POST",headers:{authorization:`Bearer ${t.token}`,"content-type":"application/json"},body:JSON.stringify(a)})}catch(h){return n.stderr(`deployhealth-scan: could not reach ${l}: ${h.message}
35
+ `),S.failed}if(!p.ok){let h=(await p.text()).slice(0,500);return n.stderr(`deployhealth-scan: ingest failed with HTTP ${p.status}: ${h}
36
+ `),S.failed}let c=await p.json(),{missing:f,unused:g,mismatch:d}=c.counts;return n.stdout(`deployhealth: reported ${i.slice(0,7)} on ${o}: ${f} missing, ${g} unused, ${d} mismatch (deploy ${c.deployId})
37
+ `),S.ok}function Tn(e){return{counts:e.counts,scopes:e.scopes,env_scopes:e.envScopes,findings:e.findings,variables:e.variables,default_ignored:e.defaultIgnored,test_files_skipped:e.testFilesSkipped,vendored_skipped:e.vendoredSkipped,too_large_skipped:e.tooLargeSkipped,warnings:e.warnings}}var In={missing:"MISSING",unused:"UNUSED",mismatch:"MISMATCH"};function wn(e){let n=e.scopes.map(r=>r||"(root)").join(", "),t=[`deployhealth-scan: ${e.sourceFiles} source files, scopes: ${n}`,""];for(let r of["missing","unused","mismatch"]){let i=e.findings.filter(o=>o.kind===r);t.push(`${In[r]} (${e.counts[r]})`),i.length===0&&t.push(" none");for(let o of i){let a=o.file?`${o.file}${o.line?`:${o.line}`:""}`:"",l=r==="mismatch"?` (missing from ${o.env_file})`:"";t.push(` ${o.var_name.padEnd(28)} ${a}${l}`)}t.push("")}let s=e.variables.filter(r=>r.optional&&r.defined_in.length===0);if(s.length>0){t.push(`OPTIONAL (${s.length}): a default in code, not defined in an env file`);for(let r of s)t.push(` ${r.var_name.padEnd(28)} ${r.scope||"(root)"}`);t.push("")}for(let{scope:r}of e.envScopes.filter(i=>i.env_files.length===0)){let i=e.variables.filter(o=>o.scope===r).length;i!==0&&(t.push(`No .env.example in ${r||"the repository root"}: ${i} variable${i===1?"":"s"} referenced (--json lists them).`),t.push(""))}if(e.testFilesSkipped>0){let r=e.testFilesSkipped===1?"file":"files";t.push(`Skipped ${e.testFilesSkipped} test and fixture ${r} (read only to see which variables they use). --include-tests includes them.`),t.push("")}if(e.vendoredSkipped.length>0&&(t.push(`Skipped vendored and generated code: ${Ne(e.vendoredSkipped)}.`),t.push("")),e.tooLargeSkipped.length>0){let r=e.tooLargeSkipped.length;t.push(`Skipped ${r} file${r===1?"":"s"} over 512 KB (bundles, not code people wrote): ${Ne(e.tooLargeSkipped)}.`),t.push("")}return e.defaultIgnored.length>0&&(t.push(`Skipped (the platform or runtime provides them): ${e.defaultIgnored.join(", ")}. --no-default-ignore includes them.`),t.push("")),`${t.join(`
38
+ `)}`}function Ne(e,n=5){let t=e.length-n;return t>0?`${e.slice(0,n).join(", ")} and ${t} more (--json lists them)`:e.join(", ")}var $e=()=>({cwd:process.cwd(),stdout:e=>process.stdout.write(e),stderr:e=>process.stderr.write(e),fetch:globalThis.fetch,git:(e,n)=>{try{return Sn("git",e,{cwd:n,encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null}catch{return null}},now:()=>new Date});Fe(process.argv.slice(2),$e()).then(e=>{process.exitCode=e},e=>{process.stderr.write(`deployhealth-scan: unexpected error: ${e instanceof Error?e.stack:String(e)}
34
39
  `),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.3.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",