Skip to content

docs(env): document LOG_LEVEL, and guard against the next undocumented variable - #583

Merged
cevheri merged 2 commits into
libredb:mainfrom
dchaudhari7177:docs/document-log-level
Sep 6, 2026
Merged

docs(env): document LOG_LEVEL, and guard against the next undocumented variable#583
cevheri merged 2 commits into
libredb:mainfrom
dchaudhari7177:docs/document-log-level

Conversation

@dchaudhari7177

Copy link
Copy Markdown
Contributor

Closes #566.

The failing test first, as the repo rules ask. Verified in both directions on this branch:

# with .env.example reverted to main
(fail) every variable read under src/ is documented or allowlisted
(fail) LOG_LEVEL is documented with its accepted values and both defaults
 3 pass, 2 fail

# with the .env.example change
 5 pass, 0 fail

.env.example. A ─── Logging ─── block in the file's existing style, above Seed Connections. It names the four accepted values, says an unrecognised value is ignored rather than rejected (which is what logger.ts does, and not what a reader would assume), and gives both NODE_ENV-dependent defaults — debug outside production, info in production. That last part is the bit an operator cannot guess, and it is why the example line is left commented: unset is the right setting for most deployments, and the reason to set it is getting debug output out of a production container without rebuilding. Cross-referenced to docs/HELM_CHART.md since the chart writes it from config.logLevel.

The guard. tests/unit/env-documentation.test.ts, modelled on agent-documentation.test.ts. It extracts every literal process.env.NAME under src/ — including the optional-chained process.env?.NAME form logger.ts actually uses, which a naive pattern would miss — and requires each to be documented or allowlisted.

The five allowlisted names are the ones the issue identified as platform or build-time (NODE_ENV, NEXT_RUNTIME, PORT, NEXT_PUBLIC_APP_VERSION, NEXT_PUBLIC_MANAGED_POLL_MS), each carrying its reason as a string rather than a comment.

Three assertions exist to stop this guard rotting the way the documentation did:

  • the extractor found something — >20 names, including LOG_LEVEL and JWT_SECRET. Without it, a broken pattern would make the main check pass on an empty list, which is the failure mode a guard like this actually has;
  • every allowlisted name is still read — an allowlist that outlives the code it excuses is how the next variable slips through;
  • no allowlisted name is also documented — if it is, the reason is wrong and it is operator-facing after all.

Documentation is matched per line (NAME=, with or without a leading #) rather than by substring, so a variable merely mentioned in prose does not count.

Dynamic process.env[name] reads are out of scope, per the issue — they cannot be resolved statically, and reporting the expression as a name would be worse than not reporting it.

Docs plus one test file; no product code.

…d variable

CLAUDE.md says every environment variable is documented in .env.example with an
example. Nothing enforced that, and LOG_LEVEL drifted: src/lib/logger.ts reads
it, the Helm chart writes it from config.logLevel, docs/HELM_CHART.md lists it,
and the file operators are told to copy never mentioned it.

The new block names the four accepted values, says an unrecognised value is
ignored rather than rejected, and gives both NODE_ENV-dependent defaults, since
'the default depends on NODE_ENV' is the part an operator cannot guess.

tests/unit/env-documentation.test.ts extracts every literal process.env.NAME
read under src/ and requires each to be documented or allowlisted. The five
allowlisted names are platform or build-time, each with its reason, and two
further tests keep the allowlist from becoming a dumping ground: every entry
must still be read somewhere, and no entry may also be documented. A third
asserts the extractor found something, so the guard cannot pass vacuously.

Documentation is matched per line rather than by substring, so a variable
mentioned in prose does not count as documented.

Verified the guard fails before the .env.example change (2 failures) and passes
after (5 pass). Dynamic process.env[name] reads are out of scope, per the issue.

Closes libredb#566
@cevheri

cevheri commented Sep 6, 2026

Copy link
Copy Markdown
Member

Thanks, this is a strong first PR. The failing test first with both directions shown, the non-vacuity assertion on the extractor, and allowlist entries carrying reasons as data rather than comments are all things I usually have to ask for.

One blocker and one thing worth knowing.

Blocker: Biome. Lint, Typecheck and Build fails on formatting only, in the new test file. bun run format:fix fixes it. The full local gate set is in CONTRIBUTING.md; running it before pushing catches this class of failure without a CI round trip.

Worth knowing: the guard covers less than its name suggests. The test reads "every variable read under src/", but the extractor only sees literal process.env.NAME. In this repo that is the minority pattern. Measured on your branch: 56 names documented in .env.example, 40 matched by the extractor, 22 documented names invisible to it. They are invisible because the name usually lives in a constant or a table field:

src/lib/agent/config.ts:54 export const AGENT_ENABLED_ENV = "LIBREDB_AGENT_ENABLED";
src/lib/api/rate-limit.ts:137 maxVar: "RATE_LIMIT_QUERY_MAX",

Both are string literals, so both are statically resolvable. The consequence is worth sitting with: had logger.ts read LOG_LEVEL through a constant, this guard would not have caught the drift it was written for.

I am not asking you to extend the extractor in this PR. I am asking the doc comment to say what the guard does not cover, so the next person looking at a green check knows what that green means. Your dynamic-reads note is correct, it just reads like an edge case when it is most of the repo.

Extending it later (resolve same-file const X = "NAME", then follow process.env[X]) would make a good follow-up.

`bun run format` (biome format) is a required check and it wanted both
multi-line call expressions collapsed onto one line. Formatting only; the
five assertions are unchanged and still pass.
@cevheri

cevheri commented Sep 6, 2026

Copy link
Copy Markdown
Member

main moved this afternoon: 0.13.7 became 0.14.0 and the chart 0.1.58 became 0.1.59, tagged at 14:05 +03. This branch is 17 commits behind and still carries Chart.yaml version: 0.1.58 / appVersion: "0.13.7".

Separately, nothing has actually run on this PR yet. All four workflow runs are sitting at action_required, so the green you see is four third-party apps and not the required checks. Push the update and I will approve the runs so you get a real measurement.

Please take current main before the next round. From a fork:

git remote add upstream https://github.com/libredb/libredb-studio.git   # once
git fetch upstream
git merge upstream/main          # or: git rebase upstream/main
git push                         # after a rebase: git push --force-with-lease

Why now rather than at merge time: the chart version sync guard is the first step of Lint, Typecheck and Build, and CI fetches main with --depth=1, so it compares your Chart.yaml against main's tip rather than against your merge base. libredb-studio-0.1.58 is a released tag, so the guard refuses it. When that step fails the job stops there and the eleven gates behind it (the three other drift guards, format, lint, typecheck, knip, build, build:lib, attw and the Go launcher tests) come back unmeasured rather than green. Measured on #582 earlier today; merging main cleared it.

@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cevheri

cevheri commented Sep 6, 2026

Copy link
Copy Markdown
Member

Merging. The formatting fix cleared the blocker, and every required check ran for real this time rather than sitting at action_required.

The scope note I asked for did not make it in, and I am not holding the PR for it — the guard is a clear improvement as it stands, and you turned the blocker around quickly.

But the gap is bigger than "an edge case", and I measured it again on today's main: of 56 documented variables the extractor sees 40. Of the 22 it misses, 16 are statically resolvable — 6 through a same-file const X = "NAME", 10 through a table field like maxVar: "RATE_LIMIT_QUERY_MAX". Only 6 are genuinely out of reach.

So the follow-up is worth more than it looked when I first raised it. Filed as #609 with the measurements and the two shapes to add, and it is yours first if you want it.

@cevheri
cevheri merged commit 7c3ded6 into libredb:main Sep 6, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LOG_LEVEL is read by the logger and set by the Helm chart, but .env.example does not document it

2 participants