Design notes
Parsing dotenv
The parser reads line by line, tracking the 1-based line where each entry starts. For KEY=value:
- Optional
exportprefix is dropped; the key must match[A-Za-z_][A-Za-z0-9_.]*. - If the value starts with
'or", the parser scans for the closing quote, appending following lines when none is found (multiline values). In double quotes a backslash skips the next character, so\"does not close the value. Anything after the closing quote except a#comment is an error. - Unquoted values end at the first whitespace followed by
#.A=x#ykeeps the wholex#y. - Single-quoted values are literal. Double-quoted and unquoted values go through interpolation; only double-quoted values process escapes (
\n \t \r \" \\ \$).
An error is recorded against the line where the entry began and parsing continues, so one run reports every problem. Example:
OK=1 -> ok
broken line -> line 2: expected KEY=VALUE
W="a" junk -> line 4: unexpected text after closing quote
Z=${oops -> line 5: unterminated '${' interpolation
Interpolation
${NAME}, $NAME, ${NAME:-default} (default if unset or empty) and ${NAME-default} (default only if unset). Braces are matched with depth counting so defaults can nest: ${A:-${B}}. Names resolve against earlier entries in the same file, as shell and docker compose do. An undefined name expands to an empty string and records a warning that check and lint surface. \$ in double quotes yields a literal $ that is not re-expanded because escapes are consumed in the same left-to-right pass.
Drift model
Every source is reduced to a flat name -> value mapping (LoadedEnv) plus where each value came from (file and line, when known). Everything else is a comparison of these mappings:
checkevaluates each schema variable per environment: missing/empty and required (and no default), then deprecated, then type validation. Variables in the files but not the schema areunknownwarnings (errors with--strict).diffis set arithmetic on keys plus value inequality. Values are displayed through one function that masks secrets and redactsuser:password@in URLs.matrixclassifies each (variable, environment) cell intook,-,MISSING,INVALID,deprecatedorextra.
Worked example: SENTRY_DSN has required = ["staging", "prod"]. staging does not set it, so its cell is MISSING and check reports an error; dev and ci do not require it, so their cells are -.
Secret masking
A name is secret when the schema says so; for names outside the schema, when it matches SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|PRIVATE_KEY|CREDENTIAL|_DSN, or it came from a Kubernetes Secret. Type errors for secret variables omit the offending value.
Real-secret detection in example files
A value is flagged when it has a known provider prefix (AKIA..., ghp_, sk_live_, xox*-, AIza) or when it is at least 16 characters, contains no spaces, is not placeholder-like (changeme, your-..., xxx, <...>), has Shannon entropy of at least 3.5 bits per character, and its key looks secret-ish. For example k3J9xQ2mVb8TzL5wR7yNc4HfD6sA1pUe scores 5.0 and is flagged, while changeme and aaaaaaaaaaaaaaaa are not.
sync-example idempotency
The output depends only on the schema, with no timestamps and a trailing newline, so sync-example is a pure function and --check is a byte comparison. Values are quoted only when they contain whitespace, quotes, #, $, backslash or newlines, using the same escapes the parser reads, so the generated file always round-trips.
Codegen
The generated loader embeds small converters mirroring the validators, so it has no envsync dependency. Each field is read with its default, converted, and any failure is appended to an error list; one SettingsError is raised at the end listing every problem. A field is non-optional in the type when it is required = true or has a default.