env-auditor 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,234 @@
1
+ Metadata-Version: 2.4
2
+ Name: env-auditor
3
+ Version: 0.1.0
4
+ Summary: Audit environment variable consistency across a codebase.
5
+ Project-URL: Homepage, https://github.com/SemTiOne/env-check
6
+ Project-URL: Issues, https://github.com/SemTiOne/env-check/issues
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 SemTiOne
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: audit,cli,devtools,dotenv,env
30
+ Classifier: Development Status :: 4 - Beta
31
+ Classifier: Environment :: Console
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Software Development :: Build Tools
39
+ Classifier: Topic :: Utilities
40
+ Requires-Python: >=3.10
41
+ Description-Content-Type: text/markdown
42
+
43
+ # env-check
44
+
45
+ [![CI](https://github.com/SemTiOne/env-check/actions/workflows/ci.yml/badge.svg)](https://github.com/SemTiOne/env-check/actions)
46
+ [![PyPI](https://img.shields.io/pypi/v/envcheck.svg)](https://pypi.org/project/envcheck/)
47
+ [![Python](https://img.shields.io/pypi/pyversions/envcheck.svg)](https://pypi.org/project/envcheck/)
48
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
49
+
50
+ **Audit environment variable consistency across your codebase.** Finds vars used in code but missing from `.env.example`, stale vars nobody references anymore, and required vars with no default value — in any language.
51
+
52
+ ```
53
+ $ envcheck .
54
+
55
+ envcheck — environment variable audit
56
+ ──────────────────────────────────────────
57
+
58
+ ✗ 3 undocumented variables (in code, missing from .env.example)
59
+ DATABASE_URL src/db/connection.py:14
60
+ STRIPE_WEBHOOK_SECRET src/payments/webhook.py:8, src/payments/webhook.py:31
61
+ REDIS_URL src/cache.py:22
62
+
63
+ ⚠ 2 stale variables (in .env.example, not found in code)
64
+ OLD_PAYMENT_KEY
65
+ DEPRECATED_FEATURE_FLAG
66
+
67
+ ○ 2 variables with no default value (empty in .env.example)
68
+ SECRET_KEY
69
+ JWT_SECRET
70
+
71
+ ⚡ 1 dynamic reference (runtime key construction — cannot audit statically)
72
+ src/config/loader.py:45 → process.env[configKey]
73
+
74
+ ──────────────────────────────────────────
75
+ Result: FAIL (exit code 1)
76
+ ```
77
+
78
+ ## Why
79
+
80
+ Your `.env.example` is a contract. It tells new contributors what the app needs to run. Over time that contract drifts: someone adds `process.env.NEW_KEY` to the source and forgets to document it, or removes a feature but leaves the stale key rotting in `.env.example`. `envcheck` catches both automatically, in CI, before it becomes someone else's debugging session.
81
+
82
+ ## Installation
83
+
84
+ ```bash
85
+ pip install env-auditor
86
+ ```
87
+
88
+ Requires Python 3.10+. **Zero runtime dependencies** — pure stdlib.
89
+
90
+ ## Usage
91
+
92
+ ```bash
93
+ # Audit current directory against .env.example (default)
94
+ envcheck
95
+
96
+ # Audit a specific project
97
+ envcheck /path/to/project
98
+
99
+ # Use a different env file
100
+ envcheck --env .env.production
101
+
102
+ # Multiple env files (keys merged — union)
103
+ envcheck --env .env.example --env .env.staging
104
+
105
+ # Strict mode: fail on stale vars too
106
+ envcheck --strict
107
+
108
+ # JSON output for tooling / dashboards
109
+ envcheck --format json | jq .undocumented
110
+
111
+ # Suppress specific sections
112
+ envcheck --ignore-stale --ignore-missing
113
+
114
+ # Exclude extra directories
115
+ envcheck --exclude vendor --exclude third_party
116
+ ```
117
+
118
+ ## Config file
119
+
120
+ Commit a `.envcheckrc` at your project root to persist settings for your whole team:
121
+
122
+ ```toml
123
+ # .envcheckrc
124
+ env_files = [".env.example", ".env.staging"]
125
+ exclude_dirs = ["vendor", "third_party"]
126
+ ignore_stale = false
127
+ strict = true
128
+ ignore_keys = ["CI", "HOME", "USER"]
129
+ required_keys = ["DATABASE_URL", "SECRET_KEY"]
130
+ ```
131
+
132
+ Or add it to `pyproject.toml` under `[tool.envcheck]`:
133
+
134
+ ```toml
135
+ [tool.envcheck]
136
+ env_files = [".env.example"]
137
+ strict = true
138
+ ignore_keys = ["CI"]
139
+ ```
140
+
141
+ CLI flags always override config file values.
142
+
143
+ ## Supported languages
144
+
145
+ | Language | Detected patterns |
146
+ |---|---|
147
+ | JavaScript / TypeScript | `process.env.VAR`, `process.env['VAR']`, `process.env["VAR"]` |
148
+ | Python | `os.environ['VAR']`, `os.environ.get('VAR')`, `os.getenv('VAR')` |
149
+ | Go | `os.Getenv("VAR")`, `os.LookupEnv("VAR")` |
150
+ | Shell | `$VAR`, `${VAR}` (`.sh`, `.bash`, `.zsh` only) |
151
+ | Docker | `ENV VAR`, `ARG VAR` in Dockerfiles |
152
+ | Ruby | `ENV['VAR']`, `ENV["VAR"]`, `ENV.fetch('VAR')` |
153
+
154
+ Dynamic references like `process.env[someVariable]` are flagged separately — they can't be statically audited.
155
+
156
+ ## CLI reference
157
+
158
+ | Flag | Description | Default |
159
+ |---|---|---|
160
+ | `PATH` | Root directory to scan | `.` |
161
+ | `--env FILE` | Env file(s) as source of truth. Repeatable. | `.env.example` |
162
+ | `--config FILE` | Path to config file | auto-discover `.envcheckrc` |
163
+ | `--ignore-stale` | Suppress stale variable report | off |
164
+ | `--ignore-missing` | Suppress empty-value report | off |
165
+ | `--format [text\|json]` | Output format | `text` |
166
+ | `--no-color` | Disable ANSI colors | off |
167
+ | `--exclude DIR` | Extra directories to skip. Repeatable. | — |
168
+ | `--strict` | Exit 1 on stale vars too | off |
169
+ | `--version` | Show version and exit | — |
170
+
171
+ ## Exit codes
172
+
173
+ | Code | Meaning |
174
+ |---|---|
175
+ | `0` | Clean |
176
+ | `1` | Undocumented vars found (or stale with `--strict`) |
177
+ | `2` | Tool error — bad args, missing files, etc. |
178
+
179
+ ## CI integration
180
+
181
+ Block deploys when env vars drift:
182
+
183
+ ```yaml
184
+ # .github/workflows/deploy.yml
185
+ jobs:
186
+ env-audit:
187
+ runs-on: ubuntu-latest
188
+ steps:
189
+ - uses: actions/checkout@v4
190
+ - uses: actions/setup-python@v5
191
+ with: { python-version: "3.12" }
192
+ - run: pip install envcheck
193
+ - run: envcheck --strict
194
+ ```
195
+
196
+ Save the report as a CI artifact:
197
+
198
+ ```yaml
199
+ - run: envcheck --format json > envcheck-report.json || true
200
+ - uses: actions/upload-artifact@v4
201
+ with:
202
+ name: envcheck-report
203
+ path: envcheck-report.json
204
+ ```
205
+
206
+ For monorepos, run per-service:
207
+
208
+ ```yaml
209
+ - run: envcheck services/api --env services/api/.env.example
210
+ - run: envcheck services/worker --env services/worker/.env.example
211
+ ```
212
+
213
+ ## Security
214
+
215
+ - Symlinks are never followed
216
+ - Files over 1 MB are skipped (with a warning)
217
+ - Lines over 2000 characters are skipped (ReDoS protection)
218
+ - `--exclude` paths are validated to be within the scan root — path traversal rejected
219
+ - Actual `.env` values are never stored, logged, or printed — only key names
220
+ - No network calls, no telemetry, entirely local
221
+
222
+ ## Development
223
+
224
+ ```bash
225
+ git clone https://github.com/SemTiOne/env-check
226
+ cd env-check
227
+ pip install -e .
228
+ pip install pytest pytest-cov
229
+ pytest --cov=envcheck --cov-report=term-missing
230
+ ```
231
+
232
+ ## License
233
+
234
+ MIT
@@ -0,0 +1,15 @@
1
+ envcheck/__init__.py,sha256=amU28sKwTeJ9cC71SmZfY9S8mTypA4-RVgl9xEBGHDU,252
2
+ envcheck/__main__.py,sha256=0X3XA8xNObct3tCNDoo47YjUEaFypGLjeNT0tYf3OBM,105
3
+ envcheck/cli.py,sha256=QxTC_lqhLsQE61TTbxMlmT-Q3bfm2W2xe5M8KzmWSfo,12291
4
+ envcheck/colors.py,sha256=82ChshmdCNZG-cZT-DS8IDw9LAwfRXhklHA23uCtne0,1473
5
+ envcheck/config.py,sha256=IMkeMQurZV0cozQcpSmmu5D80z-1TwoXJoAG5CqJzIs,8779
6
+ envcheck/differ.py,sha256=ed7nSvIsDlWkJHTCe7Ty3uDwDNditEq838euXh1bsiU,1516
7
+ envcheck/parser.py,sha256=-aca72m06xI3GkY1iHHaxQdF34x7B_e3lxgLulTnce4,4833
8
+ envcheck/patterns.py,sha256=0kso6w-tNUo3Iu4c2qxh_lUaKCweMwoadebe4awkICM,4542
9
+ envcheck/reporter.py,sha256=CvoSPet_4zVN7LDE6SpvxsvC74qX8cilw2aBSQSm3Uw,4387
10
+ envcheck/scanner.py,sha256=zpL5vPzjuf2UZaHd3bHX3bDLgDg1cjGmOL36j9CPjQ8,7741
11
+ env_auditor-0.1.0.dist-info/METADATA,sha256=wIFwFKv3fMLMTZuVtL1-vv1yOC7Dlscv3Zq9ay68ygY,7911
12
+ env_auditor-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ env_auditor-0.1.0.dist-info/entry_points.txt,sha256=vnagcFF7kFxpDUaFMg41RstibftP_qS-L9ZrKWs7EUc,47
14
+ env_auditor-0.1.0.dist-info/licenses/LICENSE,sha256=eLKOmoG52yqMSkJEKmSSj5jVl2BL_Ovcs-Mouy7v320,1065
15
+ env_auditor-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ envcheck = envcheck.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SemTiOne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
envcheck/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__: str = version("envcheck")
7
+ except PackageNotFoundError: # pragma: no cover
8
+ __version__ = "0.0.0-dev"
9
+
10
+ __all__ = ["__version__"]
envcheck/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from envcheck.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
envcheck/cli.py ADDED
@@ -0,0 +1,350 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import sys
6
+ from dataclasses import replace as dataclass_replace
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from envcheck import __version__
11
+ from envcheck.colors import supports_color
12
+ from envcheck.config import (
13
+ EnvCheckConfig,
14
+ _dict_to_config,
15
+ _parse_toml_file,
16
+ load_config,
17
+ merge_cli_into_config,
18
+ )
19
+ from envcheck.differ import diff_keys
20
+ from envcheck.parser import parse_env_files
21
+ from envcheck.reporter import render_json, render_text
22
+ from envcheck.scanner import scan_directory
23
+
24
+
25
+ # ──────────────────────────────────────────────────────────────────────────────
26
+ # Argument parser
27
+ # ──────────────────────────────────────────────────────────────────────────────
28
+
29
+ def _build_parser() -> argparse.ArgumentParser:
30
+ """Build and return the CLI argument parser."""
31
+ parser = argparse.ArgumentParser(
32
+ prog="envcheck",
33
+ description="Audit environment variable consistency across a codebase.",
34
+ formatter_class=argparse.RawDescriptionHelpFormatter,
35
+ add_help=False,
36
+ )
37
+ parser.add_argument(
38
+ "path",
39
+ metavar="PATH",
40
+ nargs="?",
41
+ default=".",
42
+ help="Root directory to scan. Defaults to current directory.",
43
+ )
44
+ parser.add_argument(
45
+ "--env",
46
+ metavar="FILE",
47
+ action="append",
48
+ dest="env_files",
49
+ default=None,
50
+ help=(
51
+ "Env file(s) to treat as source of truth. "
52
+ "Can be specified multiple times. Default: .env.example"
53
+ ),
54
+ )
55
+ parser.add_argument(
56
+ "--ignore-stale",
57
+ action="store_true",
58
+ default=False,
59
+ help="Do not report stale variables.",
60
+ )
61
+ parser.add_argument(
62
+ "--ignore-missing",
63
+ action="store_true",
64
+ default=False,
65
+ help="Do not report variables with empty values.",
66
+ )
67
+ parser.add_argument(
68
+ "--format",
69
+ choices=["text", "json"],
70
+ default=None,
71
+ help="Output format. Default: text",
72
+ )
73
+ parser.add_argument(
74
+ "--no-color",
75
+ action="store_true",
76
+ default=False,
77
+ help="Disable ANSI color output.",
78
+ )
79
+ parser.add_argument(
80
+ "--exclude",
81
+ metavar="DIR",
82
+ action="append",
83
+ dest="exclude_dirs",
84
+ default=None,
85
+ help=(
86
+ "Additional directories to exclude from scanning. "
87
+ "Can be specified multiple times."
88
+ ),
89
+ )
90
+ parser.add_argument(
91
+ "--strict",
92
+ action="store_true",
93
+ default=False,
94
+ help="Exit 1 on stale variables too, not just undocumented ones.",
95
+ )
96
+ parser.add_argument(
97
+ "--config",
98
+ metavar="FILE",
99
+ default=None,
100
+ help="Path to config file. Default: auto-discover .envcheckrc in scan root.",
101
+ )
102
+ parser.add_argument(
103
+ "--version",
104
+ action="version",
105
+ version=f"envcheck {__version__}",
106
+ )
107
+ parser.add_argument(
108
+ "-h",
109
+ "--help",
110
+ action="help",
111
+ help="Show this message and exit.",
112
+ )
113
+ return parser
114
+
115
+
116
+ # ──────────────────────────────────────────────────────────────────────────────
117
+ # Path validation
118
+ # ──────────────────────────────────────────────────────────────────────────────
119
+
120
+ def _resolve_scan_root(raw_path: str) -> Path:
121
+ """Resolve and validate the scan root directory.
122
+
123
+ Args:
124
+ raw_path: Raw path string from CLI argument.
125
+
126
+ Returns:
127
+ Resolved absolute Path.
128
+
129
+ Raises:
130
+ SystemExit(2): If the path does not exist or is not a directory.
131
+ """
132
+ try:
133
+ resolved = Path(raw_path).resolve()
134
+ except (OSError, ValueError) as exc:
135
+ _die(f"Invalid path '{raw_path}': {exc}")
136
+ if not resolved.exists():
137
+ _die(f"Path does not exist: {resolved}")
138
+ if not resolved.is_dir():
139
+ _die(f"Path is not a directory: {resolved}")
140
+ return resolved
141
+
142
+
143
+ def _resolve_env_files(raw_files: list[str], scan_root: Path) -> list[Path]:
144
+ """Resolve and validate env file paths, warning on missing files.
145
+
146
+ Args:
147
+ raw_files: Raw file path strings from CLI or config.
148
+ scan_root: Resolved scan root used for relative path resolution.
149
+
150
+ Returns:
151
+ List of resolved Paths for files that exist.
152
+ """
153
+ paths: list[Path] = []
154
+ for raw in raw_files:
155
+ p = Path(raw)
156
+ if not p.is_absolute():
157
+ p = scan_root / p
158
+ try:
159
+ resolved = p.resolve()
160
+ except (OSError, ValueError) as exc:
161
+ _die(f"Invalid env file path '{raw}': {exc}")
162
+ if not resolved.exists():
163
+ print(
164
+ f"envcheck: warning: env file not found: {resolved}",
165
+ file=sys.stderr,
166
+ )
167
+ continue
168
+ if not resolved.is_file():
169
+ _die(f"Env path is not a file: {resolved}")
170
+ paths.append(resolved)
171
+ return paths
172
+
173
+
174
+ def _resolve_exclude_dirs(raw_dirs: list[str], scan_root: Path) -> list[Path]:
175
+ """Resolve --exclude paths, rejecting any that escape the scan root.
176
+
177
+ Security: path traversal is rejected — each resolved path must be
178
+ a descendant of scan_root.
179
+
180
+ Args:
181
+ raw_dirs: Raw directory strings from CLI or config.
182
+ scan_root: Resolved absolute scan root.
183
+
184
+ Returns:
185
+ List of resolved absolute Paths within scan_root.
186
+ """
187
+ resolved_list: list[Path] = []
188
+ for raw in raw_dirs:
189
+ # Strip newlines to prevent any injection via crafted input
190
+ raw_safe = re.sub(r"[\r\n]", "", raw)
191
+ p = Path(raw_safe)
192
+ if not p.is_absolute():
193
+ p = scan_root / p
194
+ try:
195
+ resolved = p.resolve()
196
+ except (OSError, ValueError) as exc:
197
+ _die(f"Invalid exclude path '{raw_safe}': {exc}")
198
+ try:
199
+ resolved.relative_to(scan_root)
200
+ except ValueError:
201
+ _die(
202
+ f"Excluded path '{raw_safe}' resolves to '{resolved}', "
203
+ f"which is outside scan root '{scan_root}'. "
204
+ f"Path traversal rejected."
205
+ )
206
+ resolved_list.append(resolved)
207
+ return resolved_list
208
+
209
+
210
+ def _die(msg: str) -> None:
211
+ """Print an error to stderr and exit with code 2 (tool error)."""
212
+ print(f"envcheck: error: {msg}", file=sys.stderr)
213
+ sys.exit(2)
214
+
215
+
216
+ # ──────────────────────────────────────────────────────────────────────────────
217
+ # Config loading
218
+ # ──────────────────────────────────────────────────────────────────────────────
219
+
220
+ def _build_config(args: argparse.Namespace, scan_root: Path) -> EnvCheckConfig:
221
+ """Load config file and apply CLI overrides on top.
222
+
223
+ Config file is auto-discovered in scan_root unless ``--config`` is given.
224
+ CLI flags always win over config file values.
225
+
226
+ Args:
227
+ args: Parsed CLI arguments.
228
+ scan_root: Resolved scan root.
229
+
230
+ Returns:
231
+ Final merged EnvCheckConfig.
232
+ """
233
+ if args.config:
234
+ config_path = Path(args.config).resolve()
235
+ if not config_path.is_file():
236
+ _die(f"Config file not found: {config_path}")
237
+ try:
238
+ raw = _parse_toml_file(config_path, config_path.name == "pyproject.toml")
239
+ cfg = _dict_to_config(raw or {}, config_path)
240
+ except (OSError, ValueError, KeyError) as exc:
241
+ _die(f"Could not parse config file {config_path}: {exc}")
242
+ else:
243
+ cfg = load_config(scan_root)
244
+
245
+ return merge_cli_into_config(
246
+ cfg,
247
+ env_files=args.env_files,
248
+ exclude_dirs=args.exclude_dirs,
249
+ ignore_stale=args.ignore_stale or None,
250
+ ignore_missing=args.ignore_missing or None,
251
+ strict=args.strict or None,
252
+ format=args.format,
253
+ )
254
+
255
+
256
+ # ──────────────────────────────────────────────────────────────────────────────
257
+ # Audit pipeline
258
+ # ──────────────────────────────────────────────────────────────────────────────
259
+
260
+ def _run_audit(
261
+ scan_root: Path,
262
+ cfg: EnvCheckConfig,
263
+ use_color: bool,
264
+ ) -> tuple[int, str]:
265
+ """Execute the full audit pipeline.
266
+
267
+ Args:
268
+ scan_root: Resolved absolute path to scan.
269
+ cfg: Merged configuration.
270
+ use_color: Whether to emit ANSI color codes in text output.
271
+
272
+ Returns:
273
+ ``(exit_code, rendered_output)`` tuple.
274
+ """
275
+ env_paths = _resolve_env_files(cfg.env_files, scan_root)
276
+ if not env_paths:
277
+ print(
278
+ "envcheck: warning: no env files found; "
279
+ "all code references will be reported as undocumented.",
280
+ file=sys.stderr,
281
+ )
282
+
283
+ extra_exclude: list[Path] = []
284
+ if cfg.exclude_dirs:
285
+ extra_exclude = _resolve_exclude_dirs(cfg.exclude_dirs, scan_root)
286
+
287
+ scan_result = scan_directory(scan_root, extra_exclude=extra_exclude or None)
288
+ parsed_env = parse_env_files(env_paths)
289
+ diff = diff_keys(
290
+ code_keys=scan_result.all_keys,
291
+ documented_keys=parsed_env.all_keys,
292
+ empty_keys=parsed_env.empty_keys,
293
+ )
294
+
295
+ ignore_keys: set[str] = set(cfg.ignore_keys) if cfg.ignore_keys else set()
296
+ fmt = cfg.format or "text"
297
+
298
+ if fmt == "json":
299
+ output = render_json(
300
+ diff,
301
+ scan_result,
302
+ ignore_stale=cfg.ignore_stale,
303
+ ignore_missing=cfg.ignore_missing,
304
+ ignore_keys=ignore_keys,
305
+ )
306
+ else:
307
+ output = render_text(
308
+ diff,
309
+ scan_result,
310
+ use_color=use_color,
311
+ ignore_stale=cfg.ignore_stale,
312
+ ignore_missing=cfg.ignore_missing,
313
+ ignore_keys=ignore_keys,
314
+ )
315
+
316
+ effective_undoc = diff.undocumented - ignore_keys
317
+ effective_stale = diff.stale - ignore_keys
318
+
319
+ if effective_undoc:
320
+ return 1, output
321
+ if cfg.strict and effective_stale:
322
+ return 1, output
323
+ return 0, output
324
+
325
+
326
+ # ──────────────────────────────────────────────────────────────────────────────
327
+ # Entry point
328
+ # ──────────────────────────────────────────────────────────────────────────────
329
+
330
+ def main(argv: Optional[list[str]] = None) -> None:
331
+ """Parse arguments, run the audit, and exit with the appropriate code.
332
+
333
+ Exit codes:
334
+ 0 — clean (no undocumented vars; no stale with --strict)
335
+ 1 — undocumented variables found (or stale with --strict)
336
+ 2 — tool error (bad arguments, unreadable config, etc.)
337
+ """
338
+ parser = _build_parser()
339
+ args = parser.parse_args(argv)
340
+
341
+ # Resolve color preference before any output.
342
+ # --no-color disables color without mutating os.environ.
343
+ use_color = (not args.no_color) and supports_color()
344
+
345
+ scan_root = _resolve_scan_root(args.path)
346
+ cfg = _build_config(args, scan_root)
347
+
348
+ exit_code, output = _run_audit(scan_root, cfg, use_color)
349
+ print(output)
350
+ sys.exit(exit_code)