One of the ways a .env setup rots is drift. The code starts reading a new variable, nobody adds it to the example file, and the next person to clone the repo finds out the hard way. dottyenv (introduced here) has a scan command for this. It reads your code and compares it with the schema.
The obvious way to build that is a table of languages: a parser or a pattern for process.env, another for os.environ, another for env::var, and so on. I did not want to maintain that table, and it would always be missing the language somebody actually uses. So scan knows no languages at all.
Two questions, two levels of certainty
scan answers two different questions, and they deserve different treatment.
Is this declared variable used anywhere? The schema already has the name, so this is a plain substring search over every file. It works in any language, and also in Dockerfiles, Helm charts and CI config, which no language table would cover. This is also the direction where a false positive hurts most, because the advice that follows is “delete this variable”. So this half is exact.
Is the code reading a variable the schema does not know about? This one has to recognise environment access without being told what to look for. That is the heuristic half.
$ dottyenv scan
used but not declared
SENDGRID_API_KEY src/server.js:3
declared but never mentioned
FEATURE_SEARCH
SESSION_SECRET
STRIPE_PUBLISHABLE_KEY
note: 1 file accesses the environment dynamically, so this list may be
incomplete: src/server.js
Every language spells it env
The trick is that every language I could think of reads the environment with the substring env somewhere in the expression:
process.env.FOO os.environ["FOO"] env::var("FOO")
os.Getenv("FOO") ENV["FOO"] System.getenv("FOO")
$ENV{FOO} System.get_env("FOO") getenv("FOO")
Environment.GetEnvironmentVariable("FOO")
So one pattern covers all of them, including languages nobody listed: env in any case, then a short stretch on the same line, then a delimiter, then a name in SCREAMING_SNAKE_CASE.
Regex::new(r#"(?i:env)[^\n]{0,24}?["'`.\[({,= ](?P<name>[A-Z][A-Z0-9_]{2,})"#)
Each piece of that is there because something went wrong without it.
- The delimiter stops
MY_ENV_VARfrom matching itself. The character afterENVis an underscore, which is not a delimiter, and there is no later delimiter before the name ends. Without it, every constant withENVin its name would report its own tail as a variable. - The 24-character budget is set by the longest real form I found,
Environment.GetEnvironmentVariable(". - The case-insensitive part is scoped to
(?i:env)instead of putting(?i)on the whole pattern. A blanket flag also loosens the name class, soDeno.env.get("FOO")capturedgetas a variable name.
The tests hold it to 23 real forms, from import.meta.env.FOO and Bun.env.FOO through os.LookupEnv, $_ENV and Swift’s ProcessInfo.processInfo.environment["FOO"]. They also check the cases that must not match: const MAX_RETRIES = 5; has no env nearby, and NODE_ENV_SETTING has one only inside an identifier.
Knowing when you cannot know
Some code reads the environment with a variable instead of a literal:
function flag(name) { return process.env[name] === "true"; }
No static scan can tell which variable that reads. A variable read only this way never shows up under “used but not declared”, and if the name is built at runtime, something like process.env["FEATURE_" + name], a variable your code really uses can show up as “never mentioned”. Pretending the lists are complete would be worse than saying nothing.
So scan detects dynamic access and says so. The test is simple: env access where the first character inside the bracket or parenthesis is not a quote. An earlier version also required a closing bracket, which missed dotted expressions like process.env[req.query.name].
Small details that keep it honest
- It walks the tree with the same rules as
.gitignore, sonode_modulesandtargetare skipped for free. A variable read only inside a vendored dependency is not your code reading it. Those rules apply even outside a git repository, so a directory does not scan differently depending on whether it happens to be checked in. - Files that are not valid UTF-8 are skipped, which skips binaries without needing a file-type sniffer.
- The schema itself and every
.env*file are excluded. They name every variable by definition, so counting them would make “never mentioned” always empty. scanalways exits 0. It is advisory.checkis the gate.
That last point is the real design decision. A heuristic is fine as long as you never let it fail a build. Exact where being wrong is expensive, approximate where being wrong is cheap, and honest about which is which.