deployhealth-scan 0.2.0 → 0.3.1

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
@@ -9,9 +9,14 @@ Find env var drift in a repository and report it to
9
9
  - **MISMATCH**: in `.env` but not `.env.example`, or the reverse.
10
10
 
11
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+.
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.
15
20
 
16
21
  A first scan stays quiet:
17
22
 
@@ -23,20 +28,57 @@ A first scan stays quiet:
23
28
  `os.getenv("X", "a")`, `os.environ.get("X", "a")`, `os.getenv("X") or "a"`, `ENV.fetch("X", "a")`,
24
29
  `ENV.fetch("X") { … }`, `ENV["X"] || "a"`. Such variables are listed as optional. (A default of
25
30
  `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
+ - **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.
31
50
  - **A folder with no env file at all gets one line**, "No .env.example …: N variables referenced",
32
51
  instead of a MISSING row per reference; deployhealth's handoff offers them as a starting
33
52
  `.env.example`.
34
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.
76
+
35
77
  ## Try it locally
36
78
 
37
79
  ```sh
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
80
+ npx deployhealth-scan@0.3.1 --dry-run # grouped findings with file:line
81
+ npx deployhealth-scan@0.3.1 --dry-run --json # the same, as JSON
40
82
  ```
41
83
 
42
84
  With `--dry-run` nothing leaves your machine.
@@ -71,14 +113,14 @@ jobs:
71
113
  env:
72
114
  DEPLOYHEALTH_TOKEN: ${{ secrets.DEPLOYHEALTH_TOKEN }}
73
115
  run: |
74
- npx --yes deployhealth-scan@0.2.0 \
116
+ npx --yes deployhealth-scan@0.3.1 \
75
117
  --url https://deployhealth.dev \
76
118
  --token "$DEPLOYHEALTH_TOKEN" \
77
119
  --sha "$GITHUB_SHA" \
78
120
  --branch "$GITHUB_REF_NAME"
79
121
  ```
80
122
 
81
- Pin the version (`@0.2.0`) so an update never runs in your CI unreviewed. For a self-hosted
123
+ Pin the version (`@0.3.1`) so an update never runs in your CI unreviewed. For a self-hosted
82
124
  deployhealth, change `--url` to your instance.
83
125
 
84
126
  ## What it sends
@@ -92,7 +134,8 @@ names they define; values are neither sent nor printed.
92
134
 
93
135
  ```
94
136
  --url <url> deployhealth base URL (required unless --dry-run)
95
- --token <token> the project's ingest token, dh_... (required unless --dry-run)
137
+ --token <token> the project's ingest token, dh_... (required unless --dry-run;
138
+ default: the DEPLOYHEALTH_TOKEN environment variable)
96
139
  --sha <sha> commit being deployed (default: git rev-parse HEAD)
97
140
  --branch <name> branch being deployed (default: current git branch)
98
141
  --dir <path> directory to scan (default: current directory)
@@ -100,14 +143,19 @@ names they define; values are neither sent nor printed.
100
143
  --no-default-ignore also check names the platform or runtime provides (NODE_ENV,
101
144
  CI, GITHUB_SHA, npm_*, VERCEL_*, RAILWAY_*, RENDER_*, FLY_*, ...)
102
145
  --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, ...)
146
+ --include-tests also scan tests, fixtures and test tooling (test/, tests/, e2e/,
147
+ fixtures/, playwright/, testing/, *.test.*, *.spec.*, *.e2e.*,
148
+ *_test.go, vitest.config.*, ...)
105
149
  --dry-run print the findings instead of sending them
106
150
  --json with --dry-run, print JSON
107
151
  -v, --version print the version
108
152
  -h, --help show this help
109
153
  ```
110
154
 
155
+ The token can come from the `DEPLOYHEALTH_TOKEN` environment variable instead of `--token`, which
156
+ keeps it out of process listings. A `--url` that is plain `http:` to anything but this machine
157
+ prints a warning: the token and the report would travel unencrypted.
158
+
111
159
  Exit codes: `0` success, `1` the report failed (network or HTTP error), `2` usage error.
112
160
 
113
161
  ## License
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
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]
2
+ import{execFileSync as Un}from"child_process";import{resolve as Hn}from"path";import{parseArgs as Gn}from"util";import{readFile as Ln,stat as $n}from"fs/promises";import{join as Le,posix as ne}from"path";function j(e){return/^(?:docker-)?compose(?:[.-][A-Za-z0-9_.-]+)?\.ya?ml$/.test(e)}var je=/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)(?=[}:?+-])|([A-Za-z_][A-Za-z0-9_]*))/g;function ie(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(je))n.add(r[1]??r[2])}return n}var oe=["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 Yn}from"fs/promises";var We=/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/,ze=/^\s*#+\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/,Ve=new Set(['"',"'","`"]),ae="-----BEGIN ",Ke="-----END ";function le(e){let n=e.replace(/^\uFEFF/,"").split(/\r?\n/),t=[],s=[],r=[],a=null,o=i=>{for(let l=i+1;l<n.length;l++)if((n[l]??"").trim().startsWith(Ke))return l;return-1};for(let i=0;i<n.length;i++){let l=n[i]??"",c=l.trim();if(c.startsWith("#")){let m=ze.exec(l)?.[1];m&&s.push({key:m,line:i+1});continue}if(c==="")continue;if(c.startsWith(ae)){let m=o(i);if(m===-1){a={line:i+1,kind:"block"};break}i=m;continue}let u=We.exec(l);if(!u){r.push({line:i+1,text:l});continue}let p=u[1],d=(u[2]??"").trimStart(),E=i+1,T=d[0];if(T!==void 0&&Ve.has(T)){let m=Ze(n,i,d,T);if(t.push({key:p,value:m.value,line:E}),m.endIndex===-1){a={line:E,kind:"quote"};break}i=m.endIndex;continue}let _=qe(d).trim();if(t.push({key:p,value:_,line:E}),_.startsWith(ae)){let m=o(i);if(m===-1){a={line:E,kind:"block"};break}i=m}}return{entries:t,commented:s,invalid:r,unterminated:a}}function Ze(e,n,t,s){let r=[],a=t.slice(1);for(let o=n;;){let i=Ye(a,s);if(i!==-1){r.push(a.slice(0,i));let l=r.join(`
3
+ `);return{value:s==='"'?ce(l):l,endIndex:o}}if(r.push(a),o++,o>=e.length){let l=r.join(`
4
+ `);return{value:s==='"'?ce(l):l,endIndex:-1}}a=e[o]??""}}function Ye(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`
5
+ `;case"r":return"\r";case"t":return" ";default:return t}})}function qe(e){let n=/(^|\s)#/.exec(e);return n?e.slice(0,n.index):e}var Xe=[".env.example",".env",".env.local",".env.development",".env.development.local",".env.production",".env.production.local",".env.test",".env.test.local"],ue=[".env.sample",".env.template",".env.dist",".env.defaults","example.env","sample.env","env.example"],fe=/^\.env\.[A-Za-z0-9_-]{1,64}\.(?:example|sample|template)$/;var W=[...Xe,...ue],Je=new Set(W),Qe=new Set([".env.example",...ue]);function $(e){return Je.has(e)||fe.test(e)}function pe(e){return Qe.has(e)||fe.test(e)}function en(e,n){let t=s=>{let r=W.indexOf(s);return r===-1?W.length:r};return t(e)-t(n)||(e<n?-1:e>n?1:0)}function H(e){return[...new Set(e)].sort(en)}var de=/^[0-9a-f]{7,64}$/i;function Ee({references:e,envFiles:n,isIgnored:t,usedOutsideCode:s}){let r=[],a=new Set(n.flatMap(c=>[...c.entries,...c.commented??[]].map(u=>u.key))),o=new Set([...e.map(c=>c.name),...s??[]]);for(let c of n.length===0?[]:e)t(c.name)||c.hasDefault||a.has(c.name)||r.push({kind:"missing",var_name:c.name,file:c.file,line:c.line,env_file:null});for(let c of n)for(let{key:u,line:p}of ge(c.entries))t(u)||o.has(u)||r.push({kind:"unused",var_name:u,file:c.path,line:p,env_file:c.path});let i=n.find(c=>c.name===".env"),l=n.find(c=>c.name===".env.example");if(i&&l)for(let[c,u]of[[i,l],[l,i]]){let p=new Set(u.entries.map(d=>d.key));for(let{key:d,line:E}of ge(c.entries))t(d)||p.has(d)||r.push({kind:"mismatch",var_name:d,file:c.path,line:E,env_file:u.path})}return r}function ge(e){let n=new Set;return e.filter(t=>n.has(t.key)?!1:(n.add(t.key),!0))}var me={missing:0,unused:1,mismatch:2};function _e({scope:e,references:n,envFiles:t,isIgnored:s}){let r=new Map;for(let i of n)r.set(i.name,(r.get(i.name)??!0)&&i.hasDefault===!0);let a=new Map;for(let i of t)for(let l of new Set([...i.entries,...i.commented??[]].map(c=>c.key))){let c=a.get(l);c?c.push(i.name):a.set(l,[i.name])}return[...r.keys()].filter(i=>!s(i)).sort().map(i=>{let l={var_name:i,scope:e,defined_in:H(a.get(i)??[])};return r.get(i)&&(l.optional=!0),l})}function ye(e,n){return me[e.kind]-me[n.kind]||he(e.var_name,n.var_name)||he(e.file??"",n.file??"")||(e.line??0)-(n.line??0)}function Se(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 he(e,n){return e<n?-1:e>n?1:0}function nn(e){let n="";for(let t of e)t==="*"?n+=".*":t==="?"?n+=".":n+=t.replace(/[.+^${}()|[\]\\]/g,"\\$&");return new RegExp(`^${n}$`)}function z(e){let n=e.map(nn);return t=>n.some(s=>s.test(t))}import{extname as fn}from"path";function tn(e){let n=e.split(/\r?\n/),t=[],s=null,r=0,a=null;for(let o=0;o<n.length;o++){let i=n[o];if(!s){if(i.trim()==="")continue;s={line:o+1,indent:i.length-i.trimStart().length,code:""}}let l="";for(let u=0;u<i.length;){let p=i[u];if(a){p==="\\"?(l+=i.slice(u,u+2),u+=2):i.startsWith(a,u)?(l+=a,u+=a.length,a=null):(l+=p,u++);continue}if(p==="#")break;if(p==='"'||p==="'"){a=i.startsWith(p.repeat(3),u)?p.repeat(3):p,l+=a,u+=a.length;continue}"([{".includes(p)?r++:")]}".includes(p)&&(r=Math.max(0,r-1)),l+=p,u++}let c=!a&&l.trimEnd().endsWith("\\");c&&(l=l.trimEnd().slice(0,-1)),a?.length===1&&(a=null),s.code=s.code?`${s.code} ${l.trim()}`:l.trim(),r===0&&!a&&!c&&(s.code&&t.push(s),s=null)}return s?.code&&t.push(s),t}function G(e,n){let t=[],s=0,r=null,a=0;for(let o=0;o<e.length;o++){let i=e[o];if(r){i==="\\"?o++:e.startsWith(r,o)&&(o+=r.length-1,r=null);continue}if(i==='"'||i==="'")r=e.startsWith(i.repeat(3),o)?i.repeat(3):i;else if("([{".includes(i))s++;else if(")]}".includes(i))s--;else if(i===n&&s===0){if(n==="="&&("=!<>:".includes(e[o-1]??"")||e[o+1]==="="))continue;t.push(e.slice(a,o)),a=o+1}}return t.push(e.slice(a)),t.map(o=>o.trim())}function V(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 sn=/^class\s+([A-Za-z_]\w*)\s*(\(?)/;function rn(e){let n=[],t=[];for(let s=0;s<e.length;s++){let r=e[s];for(;t.length>0&&r.indent<=t[t.length-1].indent;)t.pop().cls.end=s;let a=t[t.length-1];a&&(a.bodyIndent??=r.indent,r.indent===a.bodyIndent&&a.cls.direct.push(s));let o=sn.exec(r.code);if(!o)continue;let i=o[2]?V(r.code,o[0].length-1)??"":"",l=G(i,",").filter(u=>u&&!u.includes("=")).map(u=>u.replace(/\[.*$/s,"").split(".").pop().trim()),c={name:o[1],bases:l,header:s,end:e.length,direct:[]};n.push(c),t.push({cls:c,indent:r.indent,bodyIndent:null})}return n}var on=/^([A-Za-z_]\w*)\s*:(?!=)\s*(.+)$/s,an=/^(?:[A-Za-z_]\w*\.)*Field\s*\(/,ve=/^[rRbBuU]?(['"])([A-Za-z_][A-Za-z0-9_]*)\1$/,cn=/^[A-Za-z_][A-Za-z0-9_]*$/;function xe(e,n,t=new Set){if(!e.includes("BaseSettings")&&t.size===0)return[];let s=tn(e),r=rn(s),a=new Map;for(let h of r)for(let y of h.bases){let x=a.get(y);x?x.push(h):a.set(y,[h])}let o=new Set,i=["BaseSettings",...t],l=new Set(i);for(let h=0;h<i.length;h++)for(let y of a.get(i[h])??[])o.add(y),l.has(y.name)||(l.add(y.name),i.push(y.name));let c=new Map;for(let h of r)o.has(h)&&!c.has(h.name)&&c.set(h.name,h);if(c.size===0)return[];let u=new Set([...c.keys(),...r.filter(h=>h.bases.includes("BaseModel")).map(h=>h.name)]),p=h=>{let y=new Int32Array(s.length+1).fill(s.length);for(let x=s.length-1;x>=0;x--)y[x]=h.test(s[x].code)?x:y[x+1];return y},d=p(/\benv_prefix\s*=\s*[rRuU]?(['"])([A-Za-z0-9_]*)\1/),E=p(/\bcase_sensitive\s*=\s*True\b/),T=h=>{let y=d[h.header];return y<h.end?/\benv_prefix\s*=\s*[rRuU]?(['"])([A-Za-z0-9_]*)\1/.exec(s[y].code)[2]:null},_=new Map,m=h=>{let y=[],x=new Set,b="";for(let S=h;S;){let L=_.get(S);if(L!==void 0){b=L;break}y.push(S),x.add(S);let I=T(S);if(I!==null){b=I;break}S=S.bases.map(F=>c.get(F)).find(F=>F&&!x.has(F))}for(let S of y)_.set(S,b);return b},v=[];for(let h of c.values()){if(h.direct.length===0)continue;let y=m(h),x=E[h.header+1]<h.end;for(let b of h.direct){let S=s[b],L=on.exec(S.code);if(!L)continue;let[,I,F]=L;if(I.startsWith("_")||I==="model_config")continue;let[C="",f]=G(F,"=");if(/^(?:typing\.)?ClassVar\b/.test(C)||ln(C,u))continue;let{value:g,aliases:R}=un(f),N=/\bNone\b|\bOptional\s*\[/.test(C),k=g!==null&&(g!=="None"||N),O=S.indent+1;if(R.length>0){let Me=R.length>1;for(let se of R){let re={name:x?se:se.toUpperCase(),file:n,line:S.line,column:O,syntax:"BaseSettings"};(k||Me)&&(re.hasDefault=!0),v.push(re)}continue}let U=x?`${y}${I}`:`${y}${I}`.toUpperCase();if(!cn.test(U))continue;let te={name:U,file:n,line:S.line,column:O,syntax:"BaseSettings"};k&&(te.hasDefault=!0),v.push(te)}}return v}function ln(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 un(e){if(e===void 0||e==="")return{value:null,aliases:[]};let n=an.exec(e);if(!n)return{value:e,aliases:[]};let t=G(V(e,n[0].length-1)??"",",").filter(Boolean),s=null,r=[];for(let[a,o]of t.entries()){let i=/^([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/s.exec(o);if(!i){a===0&&o!=="..."&&o!=="Ellipsis"&&(s=o);continue}let[,l,c]=i;if(l==="default")s=c==="..."||c==="Ellipsis"?null:c;else if(l==="default_factory")s="factory";else if(l==="alias"||l==="validation_alias"||l==="env"){let u=ve.exec(c);u&&r.push(u[2]);let p=/^(?:[A-Za-z_]\w*\.)*AliasChoices\s*\(/.exec(c);if(p)for(let d of G(V(c,p[0].length-1)??"",",")){let E=ve.exec(d);E&&r.push(E[2])}}}return{value:s,aliases:[...new Set(r)]}}var Te={".ts":"javascript",".tsx":"javascript",".mts":"javascript",".cts":"javascript",".js":"javascript",".jsx":"javascript",".mjs":"javascript",".cjs":"javascript",".py":"python",".go":"go",".rb":"ruby"},be=new Set(Object.keys(Te)),Z="(?<name>[A-Za-z_][A-Za-z0-9_]*)",w=e=>`(?<q>[${e}])${Z}\\k<q>`,B=/^\s*(?:\?\?|\|\|)=?\s*(?!(?:undefined|null)\b)\S/,Re=/^\s*(?:,|\)\s*or\b)\s*(?!None\b)[^\s)]/,pn=/^\s*(?:,\s*(?!nil\b)\S|\)\s*(?:\{|do\b))/,dn=/^\s*\|\|\s*(?!nil\b)\S/,gn={javascript:[{syntax:"process.env",regex:new RegExp(`\\bprocess\\.env(?:\\?\\.|\\.)${Z}\\b`,"g"),defaultAfter:B},{syntax:"process.env",regex:new RegExp(`\\bprocess\\.env(?:\\?\\.)?\\[\\s*${w("'\"`")}\\s*\\]`,"g"),defaultAfter:B},{syntax:"import.meta.env",regex:new RegExp(`\\bimport\\.meta\\.env(?:\\?\\.|\\.)${Z}\\b`,"g"),defaultAfter:B},{syntax:"import.meta.env",regex:new RegExp(`\\bimport\\.meta\\.env(?:\\?\\.)?\\[\\s*${w("'\"`")}\\s*\\]`,"g"),defaultAfter:B}],python:[{syntax:"os.environ",regex:new RegExp(`\\bos\\.environ\\s*\\[\\s*${w(`'"`)}\\s*\\]`,"g")},{syntax:"os.environ",regex:new RegExp(`\\bos\\.environ\\.get\\s*\\(\\s*${w(`'"`)}`,"g"),defaultAfter:Re},{syntax:"os.getenv",regex:new RegExp(`\\bos\\.getenv\\s*\\(\\s*${w(`'"`)}`,"g"),defaultAfter:Re}],go:[{syntax:"os.Getenv",regex:new RegExp(`\\bos\\.Getenv\\s*\\(\\s*${w('"`')}\\s*\\)`,"g")},{syntax:"os.LookupEnv",regex:new RegExp(`\\bos\\.LookupEnv\\s*\\(\\s*${w('"`')}\\s*\\)`,"g")}],ruby:[{syntax:"ENV",regex:new RegExp(`\\bENV\\s*\\[\\s*${w(`'"`)}\\s*\\]`,"g"),defaultAfter:dn},{syntax:"ENV",regex:new RegExp(`\\bENV\\.fetch(?:\\s*\\(\\s*|\\s+)${w(`'"`)}`,"g"),defaultAfter:pn}]};function Y(e){return Te[fn(e).toLowerCase()]}function q(e,n,t){let s=[],r=e.split(/\r?\n/);for(let a=0;a<r.length;a++){let o=r[a];for(let{syntax:i,regex:l,defaultAfter:c}of gn[n]){l.lastIndex=0;for(let u=l.exec(o);u;u=l.exec(o)){let p=u.groups?.name;if(!p||yn(o,u.index,u.index+u[0].length))continue;let d={name:p,file:t,line:a+1,column:u.index+1,syntax:i};c?.test(o.slice(u.index+u[0].length))&&(d.hasDefault=!0),s.push(d)}}if(n==="javascript")for(let i of En(o,a+1,t))s.push(i)}if(n==="python")for(let a of xe(e,t))s.push(a);return Sn(s).sort((a,o)=>a.line-o.line||a.column-o.column)}var K=/\{([^{}]*)\}\s*(?::[^=;{}]+)?=\s*(process\.env|import\.meta\.env)\b(?!\s*\.|\s*\[|\s*\?\.)/g,mn=/^\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,hn=/^(?!(?:undefined|null)\b)\S/;function En(e,n,t){let s=[];K.lastIndex=0;for(let r=K.exec(e);r;r=K.exec(e)){let a=r[2]==="process.env"?"process.env":"import.meta.env",o=r.index+1;for(let i of r[1].split(",")){let l=mn.exec(i),c=l?.[2]??l?.[3];if(l&&c){let u={name:c,file:t,line:n,column:o+i.indexOf(c)+1,syntax:a};l[4]&&hn.test(l[5].trim())&&(u.hasDefault=!0),s.push(u)}o+=i.length+1}}return s}var _n=new Set(['"',"'","`"]);function yn(e,n,t){let s=e[n-1];return s!==void 0&&_n.has(s)&&e[t]===s}function Sn(e){let n=new Map;for(let t of e.slice().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 Ie=new Set(["test","tests","__tests__","spec","e2e","fixtures","__fixtures__","testdata","playwright","cypress","mocks","__mocks__","testing"]),vn=[/.\.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 Ne(e){return!$(e)&&vn.some(n=>n.test(e))}var X=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 J(e){return e===".pnp.cjs"||e===".pnp.loader.mjs"||/\.min\.(?:js|mjs|cjs)$/.test(e)}var Q=512*1024;import{readdir as we,readFile as On}from"fs/promises";import{join as ee}from"path";var P=class e{constructor(n){this.rules=n}rules;static empty(){return new e([])}extend(n,t){let s=xn(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||s!==r.negate)continue;let a=Fn(r.base,n);a===null||!r.glob||Nn(r.glob,a)&&(s=!r.negate)}return s}};function xn(e){let n=[];for(let t of e.split(/\r?\n/)){if(n.length>=1e3)break;if(t.length>1024)continue;let s=Rn(t);s&&n.push(s)}return n}function Rn(e){let n=Tn(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}`),{glob:In(n),negate:t,dirOnly:s,source:e}}function Tn(e){let n=e.length;for(;n>0&&e[n-1]===" "&&e[n-2]!=="\\";)n--;return e.slice(0,n)}var ke=e=>e!=="/",Ae=()=>!0,bn=e=>e==="/";function In(e){let n=An(e);if(!n)return null;let t=["accept"],s=0;for(let o=n.length-1;o>=0;o--){let i=n[o],l=t.length;switch(i.kind){case"literal":{let c=i.ch;t.push({test:u=>u===c,next:s});break}case"one":t.push({test:i.test,next:s});break;case"star":case"rest":t.push({a:l+1,b:s},{test:i.kind==="star"?ke:Ae,next:l});break;case"dirs":t.push({a:l+1,b:s},{a:l+2,b:l+3},{test:Ae,next:l+1},{test:bn,next:s});break}s=l}let r="";for(let o of n){if(o.kind!=="literal")break;r+=o.ch}let a="";for(let o=n.length-1;o>=0;o--){let i=n[o];if(i.kind!=="literal")break;a=i.ch+a}return{states:t,start:s,prefix:r,suffix:a}}function Nn(e,n){if(!n.startsWith(e.prefix)||!n.endsWith(e.suffix))return!1;let{states:t}=e,s=new Int32Array(t.length).fill(-1),r=[],a=0,o=(i,l)=>{let c=[l];for(;c.length>0;){let u=c.pop();if(s[u]===a)continue;s[u]=a;let p=t[u];typeof p=="object"&&"a"in p?c.push(p.b,p.a):i.push(u)}};o(r,e.start);for(let i=0;i<n.length&&r.length>0;i++){let l=n[i];a++;let c=[];for(let u of r){let p=t[u];typeof p=="object"&&"test"in p&&p.test(l)&&o(c,p.next)}r=c}return r.some(i=>t[i]==="accept")}function An(e){let n=[],t=0;for(;t<e.length;){let s=e[t];if(s==="*"){if(e[t+1]==="*"){let r=t===0,a=r||e[t-1]==="/",o=e[t+2]==="/",i=t+2===e.length;if(a&&o){n.push({kind:"dirs"}),t+=3;continue}if(a&&i&&!r){n.push({kind:"rest"}),t+=2;continue}n.push({kind:"star"}),t+=2;continue}n.push({kind:"star"}),t++;continue}if(s==="?"){n.push({kind:"one",test:ke}),t++;continue}if(s==="["){let r=wn(e,t);if(!r)return null;n.push({kind:"one",test:r.test}),t=r.end;continue}if(s==="\\"&&t+1<e.length){n.push({kind:"literal",ch:e[t+1]}),t+=2;continue}n.push({kind:"literal",ch:s}),t++}return n}var kn={alnum:e=>/[A-Za-z0-9]/.test(e),alpha:e=>/[A-Za-z]/.test(e),blank:e=>e===" "||e===" ",cntrl:e=>e.charCodeAt(0)<32||e.charCodeAt(0)===127,digit:e=>e>="0"&&e<="9",graph:e=>e.charCodeAt(0)>32&&e.charCodeAt(0)<127,lower:e=>e>="a"&&e<="z",print:e=>e.charCodeAt(0)>=32&&e.charCodeAt(0)<127,punct:e=>/[!-/:-@[-`{-~]/.test(e),space:e=>/[ \t\n\r\f\v]/.test(e),upper:e=>e>="A"&&e<="Z",xdigit:e=>/[0-9A-Fa-f]/.test(e)};function wn(e,n){let t=n+1,s=e[t]==="!"||e[t]==="^";s&&t++;let r=[];for(let o=!0;;o=!1){if(t>=e.length)return null;let i=e[t];if(i==="]"&&!o)break;if(i==="["&&e[t+1]===":"){let c=e.indexOf(":]",t+2);if(c!==-1){let u=kn[e.slice(t+2,c)];if(!u)return null;r.push(u),t=c+2;continue}}if(i==="\\"){if(t++,t>=e.length)return null;i=e[t]}if(e[t+1]==="-"&&t+2<e.length&&e[t+2]!=="]"){let c=t+2;if(e[c]==="\\"&&c++,c>=e.length)return null;let[u,p]=[i,e[c]];r.push(d=>d>=u&&d<=p),t=c+1;continue}let l=i;r.push(c=>c===l),t++}return{test:o=>o!=="/"&&r.some(i=>i(o))!==s,end:t+1}}function Fn(e,n){return e===""?n:n.startsWith(`${e}/`)?n.slice(e.length+1):null}var Fe=new Set([".git",...X]);async function Oe(e,n){let t=n.skipDirs??Fe,s=n.respectGitignore??!0,r=[],a=[],o=[],i=[],l=P.empty().extend("",(n.exclude??[]).join(`
6
+ `));async function c(p,d,E){let T=p===""?e:ee(e,p),_;try{_=await we(T,{withFileTypes:!0})}catch(m){a.push({file:p||".",message:`cannot read directory: ${m.message}`});return}s&&_.some(m=>m.name===".gitignore"&&m.isFile())&&(d=d.extend(p,await On(ee(T,".gitignore"),"utf8")));for(let m of _){if(m.isSymbolicLink())continue;let v=p===""?m.name:`${p}/${m.name}`;if(m.isDirectory()){if(l.ignores(v,!0)||d.ignores(v,!0))continue;if(t.has(m.name)){X.has(m.name)&&!E&&await u(v)&&i.push(`${v}/`);continue}await c(v,d,E||n.skipTests===!0&&Ie.has(m.name))}else if(m.isFile()){if(!n.include(v,m.name)||l.ignores(v,!1)||d.ignores(v,!1)&&!n.keepIgnored?.(m.name))continue;J(m.name)?E||i.push(v):n.skipTests&&(E||Ne(m.name))?o.push(v):r.push(v)}}}async function u(p){let d;try{d=await we(ee(e,p),{withFileTypes:!0})}catch{return!1}for(let E of d){let T=`${p}/${E.name}`;if(E.isFile()&&n.include(T,E.name))return!0}for(let E of d)if(E.isDirectory()&&!E.isSymbolicLink()&&await u(`${p}/${E.name}`))return!0;return!1}return await c("",P.empty(),!1),r.sort(),o.sort(),i.sort(),{files:r,warnings:a,testFiles:o,vendored:i}}async function Ce(e,n={}){let{files:t,warnings:s,testFiles:r,vendored:a}=await Oe(e,{include:Cn,exclude:n.exclude,keepIgnored:$,skipTests:!n.includeTests});return Dn(t,{read:i=>Ln(Le(e,i),"utf8"),size:async i=>(await $n(Le(e,i))).size},s,n,r,a)}function Cn(e,n){return $(n)||j(n)||be.has(ne.extname(e).toLowerCase())}var Pn={quote:"a quote that never closes: the rest of the file was read as this value",block:"a -----BEGIN block with no -----END line: the rest of the file was skipped"};async function Dn(e,{read:n,size:t},s,r,a,o){let i=[],l=[],c=[],u=[];for(let f of e){let g=ne.basename(f);if(!$(g)){await t(f)>Q?u.push(f):(j(g)?c:l).push(f);continue}let{entries:R,commented:N,invalid:k,unterminated:O}=le(await n(f));for(let{line:U}of k)s.push({file:f,line:U,message:"ignored a line that is not KEY=value"});O&&s.push({file:f,line:O.line,message:Pn[O.kind]}),i.push({path:f,name:g,entries:R,commented:pe(g)?N:[]})}let p=new Set(i.map(f=>D(f.path))),d=new Map;for(let f of l){let g=Y(f);if(!g)continue;let R=q(await n(f),g,f);if(R.length===0)continue;let N=$e(D(f),p),k=d.get(N);if(k)for(let O of R)k.push(O);else d.set(N,R)}let E=new Map,T=(f,g)=>{let R=$e(D(f),p),N=E.get(R);N||E.set(R,N=new Set);for(let k of g)N.add(k)};for(let f of a){let g=Y(f);!g||await t(f)>Q||T(f,q(await n(f),g,f).map(R=>R.name))}for(let f of c)T(f,ie(await n(f)));let _=z(r.ignore??[]),m=z(r.defaultIgnore===!1?[]:oe),v=f=>_(f)||m(f),h=new Set;for(let f of d.values())for(let g of f)h.add(g.name);for(let f of i)for(let g of f.entries)h.add(g.key);let y=[...h].filter(f=>m(f)&&!_(f)).sort(),x=new Map;for(let f of i){let g=D(f.path),R=x.get(g);R?R.push(f):x.set(g,[f])}let b=f=>x.get(f)??[],S=[...new Set([...p,...d.keys()])].sort(),L=S.map(f=>{let g=H(b(f).map(R=>R.name));return g.length>64&&s.push({file:f||".",message:`${g.length} env files; only the first ${64} are reported`}),{scope:f,env_files:g.slice(0,64)}}),I=S.flatMap(f=>Ee({references:d.get(f)??[],envFiles:b(f),isIgnored:v,usedOutsideCode:E.get(f)})).sort(ye),F=S.flatMap(f=>_e({scope:f,references:d.get(f)??[],envFiles:b(f),isIgnored:v}).map(g=>({...g,defined_in:g.defined_in.slice(0,64)}))),C=S.flatMap(f=>(d.get(f)??[]).filter(g=>!v(g.name)).map(g=>({...g,scope:f}))).sort((f,g)=>f.file.localeCompare(g.file)||f.line-g.line||f.column-g.column);return{findings:I,references:C,variables:F,counts:Se(I),scopes:S,envScopes:L,defaultIgnored:y,testFilesSkipped:a.length,vendoredSkipped:o,tooLargeSkipped:u,sourceFiles:l.length,envFiles:i.map(f=>f.path),warnings:s}}function D(e){let n=ne.dirname(e);return n==="."?"":n}function $e(e,n){for(let t=e;t!=="";t=D(t))if(n.has(t))return t;return""}var Pe="DEPLOYHEALTH_TOKEN";var De="0.3.1";var A={ok:0,failed:1,usage:2},Ue=`Usage: deployhealth-scan [options]
6
7
 
7
8
  Scan a repository for env var drift and report it to deployhealth.
8
9
 
9
10
  Options:
10
11
  --url <url> deployhealth base URL (required unless --dry-run)
11
- --token <token> the project's ingest token, dh_... (required unless --dry-run)
12
+ --token <token> the project's ingest token, dh_... (required unless --dry-run;
13
+ default: the DEPLOYHEALTH_TOKEN environment variable)
12
14
  --sha <sha> commit being deployed (default: git rev-parse HEAD)
13
15
  --branch <name> branch being deployed (default: current git branch)
14
16
  --dir <path> directory to scan (default: current directory)
@@ -16,23 +18,25 @@ Options:
16
18
  --no-default-ignore also check names the platform or runtime provides (NODE_ENV,
17
19
  CI, GITHUB_SHA, npm_*, VERCEL_*, RAILWAY_*, RENDER_*, FLY_*, ...)
18
20
  --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, ...)
21
+ --include-tests also scan tests, fixtures and test tooling (test/, tests/, e2e/,
22
+ fixtures/, playwright/, testing/, *.test.*, *.spec.*, *.e2e.*,
23
+ *_test.go, vitest.config.*, ...)
21
24
  --dry-run print the findings instead of sending them
22
25
  --json with --dry-run, print JSON
23
26
  -v, --version print the version
24
27
  -h, --help show this help
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}
28
+ `,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 Ge(e,n){let t;try{({values:t}=Gn({args:[...e],options:Bn,strict:!0,allowPositionals:!1}))}catch(_){return n.stderr(`deployhealth-scan: ${_.message}
26
29
 
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)}
30
+ ${Ue}`),A.usage}if(t.help)return n.stdout(Ue),A.ok;if(t.version)return n.stdout(`${De}
31
+ `),A.ok;let s=Hn(n.cwd,t.dir??"."),r=await Ce(s,{ignore:t.ignore,exclude:t.exclude,defaultIgnore:!t["no-default-ignore"],includeTests:t["include-tests"]});for(let _ of r.warnings)n.stderr(`warning ${_.file}${_.line?`:${_.line}`:""}: ${_.message}
32
+ `);if(t["dry-run"])return n.stdout(t.json?`${JSON.stringify(jn(r),null,2)}
33
+ `:zn(r)),A.ok;let a=t.token||n.env.DEPLOYHEALTH_TOKEN||void 0;if(!t.url||!a)return n.stderr(`deployhealth-scan: --url and --token (or ${Pe}) are required (or pass --dry-run)
34
+ `),A.usage;Mn(t.url)&&n.stderr(`deployhealth-scan: warning: --url is plain http; the token and the report are sent unencrypted
35
+ `);let o=t.sha??n.git(["rev-parse","HEAD"],s),i=t.branch??n.git(["rev-parse","--abbrev-ref","HEAD"],s);if(!o||!i||i==="HEAD")return n.stderr(`deployhealth-scan: could not determine the commit and branch from git; pass --sha and --branch
36
+ `),A.usage;if(!de.test(o))return n.stderr(`deployhealth-scan: --sha must be a hex commit id, got "${o}"
37
+ `),A.usage;let l={sha:o,branch:i,timestamp:n.now().toISOString(),findings:r.findings,variables:r.variables,env_scopes:r.envScopes},c=`${t.url.replace(/\/+$/,"")}/api/ingest/scan`,u;try{u=await n.fetch(c,{method:"POST",headers:{authorization:`Bearer ${a}`,"content-type":"application/json"},body:JSON.stringify(l)})}catch(_){return n.stderr(`deployhealth-scan: could not reach ${c}: ${_.message}
38
+ `),A.failed}if(!u.ok){let _=(await u.text()).slice(0,500);return n.stderr(`deployhealth-scan: ingest failed with HTTP ${u.status}: ${_}
39
+ `),A.failed}let p=await u.json(),{missing:d,unused:E,mismatch:T}=p.counts;return n.stdout(`deployhealth: reported ${o.slice(0,7)} on ${i}: ${d} missing, ${E} unused, ${T} mismatch (deploy ${p.deployId})
40
+ `),A.ok}function Mn(e){let n;try{n=new URL(e)}catch{return!1}if(n.protocol!=="http:")return!1;let t=n.hostname.replace(/^\[|\]$/g,"").toLowerCase();return!(t==="localhost"||t.endsWith(".localhost")||/^127(?:\.\d{1,3}){3}$/.test(t)||t==="::1")}function jn(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 Wn={missing:"MISSING",unused:"UNUSED",mismatch:"MISMATCH"};function zn(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 a=e.findings.filter(o=>o.kind===r);t.push(`${Wn[r]} (${e.counts[r]})`),a.length===0&&t.push(" none");for(let o of a){let i=o.file?`${o.file}${o.line?`:${o.line}`:""}`:"",l=r==="mismatch"?` (missing from ${o.env_file})`:"";t.push(` ${o.var_name.padEnd(28)} ${i}${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(a=>a.env_files.length===0)){let a=e.variables.filter(o=>o.scope===r).length;a!==0&&(t.push(`No .env.example in ${r||"the repository root"}: ${a} variable${a===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: ${He(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): ${He(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(`
41
+ `)}`}function He(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 Be=()=>({cwd:process.cwd(),stdout:e=>process.stdout.write(e),stderr:e=>process.stderr.write(e),fetch:globalThis.fetch,git:(e,n)=>{try{return Un("git",e,{cwd:n,encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null}catch{return null}},now:()=>new Date,env:{DEPLOYHEALTH_TOKEN:process.env.DEPLOYHEALTH_TOKEN}});Ge(process.argv.slice(2),Be()).then(e=>{process.exitCode=e},e=>{process.stderr.write(`deployhealth-scan: unexpected error: ${e instanceof Error?e.stack:String(e)}
38
42
  `),process.exitCode=1});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deployhealth-scan",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",