audit-test 0.2.2__tar.gz → 0.2.4__tar.gz

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.
Files changed (109) hide show
  1. {audit_test-0.2.2/src/audit_test.egg-info → audit_test-0.2.4}/PKG-INFO +17 -17
  2. {audit_test-0.2.2 → audit_test-0.2.4}/README.md +16 -16
  3. {audit_test-0.2.2 → audit_test-0.2.4}/pyproject.toml +1 -1
  4. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/__init__.py +1 -1
  5. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_gate.py +2 -5
  6. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_quality.py +2 -5
  7. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_shared.py +77 -8
  8. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_suite.py +2 -4
  9. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/cli.py +70 -13
  10. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/deps.py +2 -0
  11. audit_test-0.2.4/src/audit_code/encoding_check.py +86 -0
  12. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/gate.py +3 -0
  13. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/phd.py +2 -0
  14. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/quality.py +4 -8
  15. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/run_all_audits.py +3 -0
  16. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/runner.py +4 -8
  17. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/runtime.py +2 -0
  18. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/suite.py +2 -4
  19. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/wiring.py +2 -0
  20. {audit_test-0.2.2 → audit_test-0.2.4/src/audit_test.egg-info}/PKG-INFO +17 -17
  21. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_test.egg-info/SOURCES.txt +4 -0
  22. audit_test-0.2.4/tests/test_cli_focus_ignore.py +217 -0
  23. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_focus.py +23 -0
  24. audit_test-0.2.4/tests/test_quality_helpers.py +44 -0
  25. audit_test-0.2.4/tests/test_utf8_output.py +69 -0
  26. {audit_test-0.2.2 → audit_test-0.2.4}/LICENSE +0 -0
  27. {audit_test-0.2.2 → audit_test-0.2.4}/LICENSES.md +0 -0
  28. {audit_test-0.2.2 → audit_test-0.2.4}/setup.cfg +0 -0
  29. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/__main__.py +0 -0
  30. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/__init__.py +0 -0
  31. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/base.py +0 -0
  32. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/cpp/__init__.py +0 -0
  33. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/cpp/adapter.py +0 -0
  34. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/csharp/__init__.py +0 -0
  35. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/csharp/adapter.py +0 -0
  36. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/go/__init__.py +0 -0
  37. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/go/adapter.py +0 -0
  38. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/html/__init__.py +0 -0
  39. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/html/adapter.py +0 -0
  40. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/java/__init__.py +0 -0
  41. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/java/adapter.py +0 -0
  42. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/javascript/__init__.py +0 -0
  43. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/javascript/adapter.py +0 -0
  44. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/python/__init__.py +0 -0
  45. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/python/adapter.py +0 -0
  46. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/rust/__init__.py +0 -0
  47. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/rust/adapter.py +0 -0
  48. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/sql/__init__.py +0 -0
  49. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/adapters/sql/adapter.py +0 -0
  50. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_config.py +0 -0
  51. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_deps.py +0 -0
  52. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_phd.py +0 -0
  53. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_runtime.py +0 -0
  54. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/audit_wiring.py +0 -0
  55. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/config.py +0 -0
  56. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/__init__.py +0 -0
  57. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/_tool_runner.py +0 -0
  58. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/bandit.py +0 -0
  59. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/checkstyle.py +0 -0
  60. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/clang_tidy.py +0 -0
  61. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/clippy.py +0 -0
  62. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/codeql.py +0 -0
  63. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/cppcheck.py +0 -0
  64. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/dependency_scan.py +0 -0
  65. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/dotnet_format.py +0 -0
  66. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/eslint.py +0 -0
  67. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/go_vet.py +0 -0
  68. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/golangci_lint.py +0 -0
  69. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/htmlhint.py +0 -0
  70. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/megalinter.py +0 -0
  71. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/pmd.py +0 -0
  72. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/prettier.py +0 -0
  73. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/rustfmt.py +0 -0
  74. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/secret_scan.py +0 -0
  75. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/semgrep.py +0 -0
  76. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/integrations/stylelint.py +0 -0
  77. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/models.py +0 -0
  78. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/profiles/__init__.py +0 -0
  79. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/profiles/agent_engine/__init__.py +0 -0
  80. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/profiles/agent_engine/config_checks.py +0 -0
  81. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/profiles/agent_engine/profile.py +0 -0
  82. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/profiles/agent_engine/prompt_checks.py +0 -0
  83. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/profiles/agent_engine/stdout_checks.py +0 -0
  84. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/profiles/agent_engine/tool_registry_checks.py +0 -0
  85. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/project.py +0 -0
  86. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/reporting/__init__.py +0 -0
  87. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/reporting/json_report.py +0 -0
  88. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/reporting/junit.py +0 -0
  89. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_code/reporting/sarif.py +0 -0
  90. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_test.egg-info/dependency_links.txt +0 -0
  91. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_test.egg-info/entry_points.txt +0 -0
  92. {audit_test-0.2.2 → audit_test-0.2.4}/src/audit_test.egg-info/top_level.txt +0 -0
  93. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_adapters.py +0 -0
  94. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_audit_misc.py +0 -0
  95. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_audit_phd.py +0 -0
  96. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_audit_quality.py +0 -0
  97. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_audit_runtime.py +0 -0
  98. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_audit_wiring.py +0 -0
  99. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_base.py +0 -0
  100. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_cli.py +0 -0
  101. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_cli_combinatorics.py +0 -0
  102. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_config.py +0 -0
  103. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_coverage.py +0 -0
  104. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_gate.py +0 -0
  105. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_integrations.py +0 -0
  106. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_reporting.py +0 -0
  107. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_runner.py +0 -0
  108. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_smoke.py +0 -0
  109. {audit_test-0.2.2 → audit_test-0.2.4}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audit-test
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Code and test verification orchestrator — interrogates code quality AND whether tests genuinely prove correctness
5
5
  License: CC BY-NC-ND 4.0
6
6
  Requires-Python: >=3.10
@@ -31,14 +31,6 @@ All in one shot. Zero config needed.
31
31
  pip install audit-test
32
32
  ```
33
33
 
34
- Three commands — all identical:
35
-
36
- ```powershell
37
- audit-test high
38
- audit-code high
39
- audit-tests high
40
- ```
41
-
42
34
  Or from source:
43
35
 
44
36
  ```powershell
@@ -49,10 +41,17 @@ pip install -e .
49
41
 
50
42
  ## Usage
51
43
 
44
+ Three commands — all identical:
45
+
46
+ ```powershell
47
+ audit-test
48
+ audit-code
49
+ audit-tests
50
+ ```
52
51
  Bare words or flags — both work:
53
52
 
54
53
  ```powershell
55
- audit-test # full audit (HIGH only)
54
+ audit-test # full audit
56
55
  audit-test v # full audit, verbose
57
56
  audit-test f # fix: auto-format (lint+black)
58
57
  audit-test min # min: fast wiring + phd + quality
@@ -189,13 +188,14 @@ python run_all_audits.py # orchestrate all five into one report
189
188
 
190
189
  ## The stack
191
190
 
192
- | Audit | Question it answers |
193
- |---|---|
194
- | wiring | **Is it connected?** Dead symbols, test-only code, config key drift |
195
- | phd | **Does it meet the bar?** Exception discipline, security patterns, state bugs |
196
- | runtime | **Will it hang or crash?** Unbounded loops, missing timeouts, secrets in logs |
197
- | suite | **Is the test suite healthy?** Runs pytest, classifies real vs pollution failures |
198
- | quality | **External gates + execution truth.** Black, ruff, mypy, CVE scan, coverage |
191
+ | Audit | Question it answers | Full rules |
192
+ |---|---|---|
193
+ | wiring | **Is it connected?** Dead symbols, test-only code, config key drift | [docs/wiring.md](docs/wiring.md) |
194
+ | phd | **Does it meet the bar?** Exception discipline, security patterns, state bugs | [docs/phd.md](docs/phd.md) — 37 rules |
195
+ | runtime | **Will it hang or crash?** Unbounded loops, missing timeouts, secrets in logs | [docs/runtime.md](docs/runtime.md) — 13 checks |
196
+ | suite | **Is the test suite healthy?** Runs pytest, classifies real vs pollution failures | [docs/suite.md](docs/suite.md) |
197
+ | quality | **External gates + execution truth.** Black, ruff, mypy, CVE scan, coverage | [docs/quality.md](docs/quality.md) — Q0-Q8 |
198
+ | integrations | **External tools.** semgrep, bandit, +14 native linters across 9 languages | [docs/integrations.md](docs/integrations.md) |
199
199
 
200
200
  ## Languages
201
201
 
@@ -20,14 +20,6 @@ All in one shot. Zero config needed.
20
20
  pip install audit-test
21
21
  ```
22
22
 
23
- Three commands — all identical:
24
-
25
- ```powershell
26
- audit-test high
27
- audit-code high
28
- audit-tests high
29
- ```
30
-
31
23
  Or from source:
32
24
 
33
25
  ```powershell
@@ -38,10 +30,17 @@ pip install -e .
38
30
 
39
31
  ## Usage
40
32
 
33
+ Three commands — all identical:
34
+
35
+ ```powershell
36
+ audit-test
37
+ audit-code
38
+ audit-tests
39
+ ```
41
40
  Bare words or flags — both work:
42
41
 
43
42
  ```powershell
44
- audit-test # full audit (HIGH only)
43
+ audit-test # full audit
45
44
  audit-test v # full audit, verbose
46
45
  audit-test f # fix: auto-format (lint+black)
47
46
  audit-test min # min: fast wiring + phd + quality
@@ -178,13 +177,14 @@ python run_all_audits.py # orchestrate all five into one report
178
177
 
179
178
  ## The stack
180
179
 
181
- | Audit | Question it answers |
182
- |---|---|
183
- | wiring | **Is it connected?** Dead symbols, test-only code, config key drift |
184
- | phd | **Does it meet the bar?** Exception discipline, security patterns, state bugs |
185
- | runtime | **Will it hang or crash?** Unbounded loops, missing timeouts, secrets in logs |
186
- | suite | **Is the test suite healthy?** Runs pytest, classifies real vs pollution failures |
187
- | quality | **External gates + execution truth.** Black, ruff, mypy, CVE scan, coverage |
180
+ | Audit | Question it answers | Full rules |
181
+ |---|---|---|
182
+ | wiring | **Is it connected?** Dead symbols, test-only code, config key drift | [docs/wiring.md](docs/wiring.md) |
183
+ | phd | **Does it meet the bar?** Exception discipline, security patterns, state bugs | [docs/phd.md](docs/phd.md) — 37 rules |
184
+ | runtime | **Will it hang or crash?** Unbounded loops, missing timeouts, secrets in logs | [docs/runtime.md](docs/runtime.md) — 13 checks |
185
+ | suite | **Is the test suite healthy?** Runs pytest, classifies real vs pollution failures | [docs/suite.md](docs/suite.md) |
186
+ | quality | **External gates + execution truth.** Black, ruff, mypy, CVE scan, coverage | [docs/quality.md](docs/quality.md) — Q0-Q8 |
187
+ | integrations | **External tools.** semgrep, bandit, +14 native linters across 9 languages | [docs/integrations.md](docs/integrations.md) |
188
188
 
189
189
  ## Languages
190
190
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "audit-test"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Code and test verification orchestrator — interrogates code quality AND whether tests genuinely prove correctness"
9
9
  requires-python = ">=3.10"
10
10
  license = {text = "CC BY-NC-ND 4.0"}
@@ -1,3 +1,3 @@
1
1
  """audit-code: code and test verification orchestrator."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.4"
@@ -54,7 +54,7 @@ from audit_code.audit_config import (
54
54
  SUITE_TIMEOUT,
55
55
  )
56
56
  from audit_code.audit_quality import _def_spans # noqa: E402
57
- from audit_code.audit_shared import EXCLUDE_DIRS
57
+ from audit_code.audit_shared import EXCLUDE_DIRS, force_utf8_streams
58
58
 
59
59
  ROOT = Path(__file__).resolve().parent.parent.parent
60
60
  # Allow --path override for audit-code wrapper
@@ -521,10 +521,7 @@ def _run_gates(
521
521
 
522
522
 
523
523
  def main():
524
- try:
525
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
526
- except (AttributeError, OSError):
527
- pass
524
+ force_utf8_streams()
528
525
  ap = argparse.ArgumentParser()
529
526
  ap.add_argument(
530
527
  "--path", default=None, help="project root (for audit-code wrapper)"
@@ -60,7 +60,7 @@ import tempfile
60
60
  from pathlib import Path
61
61
 
62
62
  from audit_code.audit_config import DOC_THRESHOLD_PCT, MIN_FLAG_BODY_LINES, TOOL_TIMEOUT
63
- from audit_code.audit_shared import EXCLUDE_DIRS
63
+ from audit_code.audit_shared import EXCLUDE_DIRS, force_utf8_streams
64
64
 
65
65
  ROOT = Path(__file__).resolve().parent.parent.parent
66
66
 
@@ -523,10 +523,7 @@ def q_mutation(root: Path, counts: dict, enabled: bool):
523
523
 
524
524
 
525
525
  def main():
526
- try:
527
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
528
- except (AttributeError, OSError):
529
- pass
526
+ force_utf8_streams()
530
527
  ap = argparse.ArgumentParser()
531
528
  ap.add_argument("--path", default=str(ROOT), help="repo root to audit")
532
529
  ap.add_argument("--tests", default="tests", help="tests dir relative to root")
@@ -6,14 +6,85 @@ Reads .audit-test-ignore from the project root. Supports named #only groups:
6
6
  slow=[src/quality.py] /mnt/c/other | full quality sweep
7
7
  #only
8
8
  Format per line: name=[file,...] [/path] [| description]
9
+
10
+ Also supports a project-level source-encoding declaration:
11
+ #encoding utf-8
12
+ used by the `check` command to verify every file decodes under that codec.
9
13
  """
10
14
 
15
+ import codecs
11
16
  import os
17
+ import sys
12
18
  from collections import namedtuple
13
19
  from pathlib import Path
14
20
 
15
21
  Group = namedtuple("Group", "files path description")
16
22
 
23
+ # Encoding a scanned project's source is expected to be in. Overridable per
24
+ # target via a `#encoding <name>` line in that project's .audit-test-ignore.
25
+ DEFAULT_ENCODING = "utf-8"
26
+
27
+
28
+ def normalize_encoding(raw: str) -> str:
29
+ """Validate and canonicalize an encoding name (e.g. 'UTF-16' -> 'utf-16').
30
+
31
+ Accepts spaces so multi-word CLI args like 'GB 18030' resolve to 'gb18030'.
32
+ Raises LookupError if the name is not a known codec.
33
+ """
34
+ candidate = raw.strip()
35
+ try:
36
+ return codecs.lookup(candidate).name
37
+ except LookupError:
38
+ return codecs.lookup(candidate.replace(" ", "")).name
39
+
40
+
41
+ def configured_encoding(root: Path | None = None) -> str:
42
+ """Return the `#encoding` declared in <root>/.audit-test-ignore, else utf-8.
43
+
44
+ Read from the *target* project's ignore file (default cwd) so scanning an
45
+ external repo honours that repo's declared source encoding.
46
+ """
47
+ base = Path.cwd() if root is None else root
48
+ try:
49
+ for line in (base / ".audit-test-ignore").read_text(
50
+ encoding="utf-8"
51
+ ).splitlines():
52
+ s = line.strip()
53
+ if s.lower().startswith("#encoding"):
54
+ parts = s.split(None, 1)
55
+ if len(parts) > 1 and parts[1].strip():
56
+ return parts[1].strip()
57
+ except OSError:
58
+ pass
59
+ return DEFAULT_ENCODING
60
+
61
+
62
+ def force_utf8_streams() -> None:
63
+ """Make stdout AND stderr emit UTF-8 (replace on error), on every platform.
64
+
65
+ Windows consoles and pipes default to cp1252/cp437, which raise
66
+ UnicodeEncodeError on the audit's glyphs (🐇, ═, ✓, ✨). macOS/Linux are
67
+ almost always UTF-8 already, so this is a harmless no-op there. Guarded
68
+ because captured/replaced streams (pytest, StringIO) lack reconfigure().
69
+ """
70
+ for stream in (sys.stdout, sys.stderr):
71
+ try:
72
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
73
+ except (AttributeError, OSError):
74
+ pass
75
+
76
+
77
+ # Environment additions that force a spawned Python worker to start with UTF-8
78
+ # stdio regardless of the host locale — the child sets its pipe encoding at
79
+ # interpreter startup, before any reconfigure() call can run.
80
+ UTF8_ENV = {"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
81
+
82
+
83
+ def utf8_subprocess_env(base: dict | None = None) -> dict:
84
+ """Return a copy of *base* (default os.environ) with UTF-8 stdio forced."""
85
+ return {**(os.environ if base is None else base), **UTF8_ENV}
86
+
87
+
17
88
  # ── Built-in defaults ────────────────────────────────────────────────────────
18
89
 
19
90
  _SKIP_DEFAULTS = {
@@ -53,25 +124,23 @@ def _parse_group(
53
124
  ) -> tuple[str, Group] | None: # audit: ok (parse helper)
54
125
  """Parse 'fast=[main.py,cli.py] /x | desc' into (name, Group)."""
55
126
  line = line.strip()
56
- # description
127
+ # description (strip first so it can't corrupt the file-list terminator)
57
128
  desc = ""
58
129
  if "|" in line:
59
130
  line, desc = line.split("|", 1)
60
131
  desc = desc.strip()
61
- # path override
62
- path = default_path
63
- if " /" in line:
64
- line, path_part = line.rsplit(" /", 1)
65
- path = path_part.strip()
66
132
  # name=[...]
67
133
  if "=[" not in line:
68
134
  return None
69
135
  name, rest = line.split("=[", 1)
70
136
  name = name.strip()
71
- if not rest.endswith("]"):
137
+ # Everything up to the closing ']' is the file list; whatever follows is the
138
+ # optional path, kept verbatim so a leading '/' or Windows 'C:' survives.
139
+ if "]" not in rest:
72
140
  return None
73
- files_str = rest[:-1]
141
+ files_str, after = rest.split("]", 1)
74
142
  files = [f.strip() for f in files_str.split(",") if f.strip()]
143
+ path = after.strip() or default_path
75
144
  return name, Group(tuple(files), path, desc)
76
145
 
77
146
 
@@ -51,6 +51,7 @@ from collections import Counter
51
51
  from pathlib import Path
52
52
 
53
53
  from audit_code.audit_config import FULL_SUITE_TIMEOUT, MAX_SOLO_RERUNS, SOLO_TIMEOUT
54
+ from audit_code.audit_shared import force_utf8_streams
54
55
 
55
56
  ROOT = Path(__file__).resolve().parent.parent.parent
56
57
  TESTS_DIR = "tests"
@@ -221,10 +222,7 @@ def _s3_s5_report(out: str, r: dict, med: int, info: int) -> tuple[int, int]:
221
222
 
222
223
 
223
224
  def main():
224
- try:
225
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
226
- except (AttributeError, OSError):
227
- pass
225
+ force_utf8_streams()
228
226
  strict = "--strict" in sys.argv
229
227
  fast = "--fast" in sys.argv
230
228
  baseline = "--baseline" in sys.argv
@@ -26,6 +26,8 @@ import sys
26
26
  from collections import namedtuple as _nt # audit: ok (focus helpers)
27
27
  from pathlib import Path
28
28
 
29
+ from audit_code import encoding_check
30
+ from audit_code.audit_shared import force_utf8_streams, normalize_encoding
29
31
  from audit_code.config import load_project_config
30
32
  from audit_code.gate import run_gate as gate_main
31
33
  from audit_code.models import EXIT_FAIL, EXIT_PASS
@@ -351,11 +353,7 @@ def run_gate_cmd(args: argparse.Namespace) -> int:
351
353
 
352
354
 
353
355
  def _force_utf8_output() -> None:
354
- for stream in (sys.stdout, sys.stderr):
355
- try:
356
- stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
357
- except (AttributeError, OSError):
358
- pass
356
+ force_utf8_streams()
359
357
 
360
358
 
361
359
  # ── focus / ignore commands ──────────────────────────────────────────────────
@@ -409,12 +407,15 @@ def _parse_groups(lines: list[str]) -> tuple[dict[str, _Grp], str]:
409
407
  if "|" in s:
410
408
  s, desc = s.split("|", 1)
411
409
  desc = desc.strip()
412
- if " /" in s:
413
- s, path = s.rsplit(" /", 1)
414
- path = path.strip()
415
- if "=[" in s and s.endswith("]"):
410
+ if "=[" in s:
416
411
  name, rest = s.split("=[", 1)
417
- files = tuple(f.strip() for f in rest[:-1].split(",") if f.strip())
412
+ # file list ends at the first ']'; the remainder is the path, kept
413
+ # verbatim so a leading '/' or Windows 'C:' survives.
414
+ if "]" not in rest:
415
+ continue
416
+ files_str, after = rest.split("]", 1)
417
+ files = tuple(f.strip() for f in files_str.split(",") if f.strip())
418
+ path = after.strip() or default
418
419
  groups[name.strip()] = _Grp(files, path, desc)
419
420
  return groups, default
420
421
 
@@ -439,10 +440,11 @@ def _rebuild_file(
439
440
  for name, g in sorted(groups.items()):
440
441
  line = f"{name}=[{', '.join(g.files)}]"
441
442
  if g.path and g.path != default_path:
442
- sep = " " if g.path.startswith("/") or g.path.startswith("C:") else " /"
443
- line += f"{sep}{g.path}"
443
+ # plain space separator; the parser takes everything after ']'
444
+ # as the path, so the value round-trips exactly as written.
445
+ line += f" {g.path}"
444
446
  if g.desc:
445
- line += f" | {g.desc}"
447
+ line += f" | {g.desc}"
446
448
  out.append(line)
447
449
  out.append("#only")
448
450
  return out
@@ -714,6 +716,59 @@ def _is_ignore_mode() -> bool:
714
716
  return "ignore" in sys.argv
715
717
 
716
718
 
719
+ def _is_check_mode() -> bool:
720
+ for a in sys.argv[1:]:
721
+ if not a.startswith("-"):
722
+ return a == "check"
723
+ return False
724
+
725
+
726
+ def _handle_check() -> None:
727
+ """`check [encoding...] [--path DIR]` — verify every file decodes as encoding.
728
+
729
+ Encoding may be several tokens ('GB 18030'); if omitted, the target's
730
+ #encoding (or utf-8) is used. Path-like args or --path pick the project.
731
+ """
732
+ idx = sys.argv.index("check")
733
+ args = sys.argv[idx + 1 :]
734
+ if args and args[0] in ("help", "-h", "--help"):
735
+ print(
736
+ "check — verify every text file decodes under an encoding\n\n"
737
+ " check use the project's #encoding (or utf-8)\n"
738
+ " check <encoding> e.g. utf-8 | ascii | UTF-16 | gb18030\n"
739
+ " check <encoding> --path <dir> check another project\n\n"
740
+ "Configure a project's expected encoding with a line in "
741
+ ".audit-test-ignore:\n #encoding utf-8"
742
+ )
743
+ sys.exit(0)
744
+
745
+ root: str | None = None
746
+ enc_tokens: list[str] = []
747
+ i = 0
748
+ while i < len(args):
749
+ a = args[i]
750
+ if a in ("--path", "-p"):
751
+ root = args[i + 1] if i + 1 < len(args) else None
752
+ i += 2
753
+ continue
754
+ if a.startswith("/") or a.startswith("C:") or a.startswith("D:"):
755
+ root = a
756
+ i += 1
757
+ continue
758
+ enc_tokens.append(a)
759
+ i += 1
760
+
761
+ encoding = " ".join(enc_tokens) if enc_tokens else None
762
+ if encoding is not None:
763
+ try:
764
+ encoding = normalize_encoding(encoding)
765
+ except LookupError:
766
+ print(f"unknown encoding: {' '.join(enc_tokens)!r} — try utf-8, ascii, gb18030")
767
+ sys.exit(2)
768
+ target = find_target_root(root)
769
+ sys.exit(encoding_check.run(target, encoding))
770
+
771
+
717
772
  def main():
718
773
  _force_utf8_output()
719
774
  if _is_gate_mode():
@@ -722,6 +777,8 @@ def main():
722
777
  parser = build_gate_parser()
723
778
  args = parser.parse_args()
724
779
  sys.exit(run_gate_cmd(args))
780
+ elif _is_check_mode():
781
+ _handle_check()
725
782
  elif _is_focus_mode():
726
783
  _handle_focus()
727
784
  elif _is_ignore_mode():
@@ -10,6 +10,7 @@ import subprocess
10
10
  import sys
11
11
  from pathlib import Path
12
12
 
13
+ from audit_code.audit_shared import utf8_subprocess_env
13
14
  from audit_code.models import (
14
15
  AuditResult,
15
16
  AuditStatus,
@@ -33,6 +34,7 @@ def run(target_root: Path, print_only: bool = False) -> AuditResult:
33
34
  errors="replace",
34
35
  timeout=60,
35
36
  cwd=str(target_root),
37
+ env=utf8_subprocess_env(),
36
38
  )
37
39
  except subprocess.TimeoutExpired:
38
40
  return AuditResult(
@@ -0,0 +1,86 @@
1
+ """encoding_check.py — universal source-encoding gate.
2
+
3
+ Verifies that every text file under a project decodes cleanly under a chosen
4
+ codec (strict, no error replacement). The codec is either passed explicitly
5
+ (`audit-test check gb18030`) or read from the target project's
6
+ `.audit-test-ignore` (`#encoding <name>`), defaulting to UTF-8.
7
+
8
+ check utf-8 -> every file must be valid UTF-8
9
+ check ascii -> every file must be pure ASCII
10
+ check gb18030 -> every file must be valid GB 18030
11
+
12
+ Binary files (detected by a NUL byte) are skipped so images/archives do not
13
+ false-fail. Skip/exclude and focus-group rules are honoured via should_audit.
14
+ """
15
+
16
+ import os
17
+ from pathlib import Path
18
+
19
+ from audit_code.audit_shared import (
20
+ EXCLUDE_DIRS,
21
+ configured_encoding,
22
+ force_utf8_streams,
23
+ normalize_encoding,
24
+ should_audit,
25
+ )
26
+
27
+ # Only the first slice is sniffed for NUL bytes when classifying binary files.
28
+ _BINARY_SNIFF_BYTES = 4096
29
+
30
+
31
+ def _looks_binary(raw: bytes) -> bool:
32
+ return b"\x00" in raw[:_BINARY_SNIFF_BYTES]
33
+
34
+
35
+ def scan(root: Path, encoding: str) -> tuple[list[tuple[Path, int, str]], int]:
36
+ """Return (failures, files_checked).
37
+
38
+ Each failure is (path, byte_offset, reason) for a file that does NOT decode
39
+ under *encoding*.
40
+ """
41
+ failures: list[tuple[Path, int, str]] = []
42
+ checked = 0
43
+ for dirpath, dirnames, filenames in os.walk(root):
44
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
45
+ for fn in filenames:
46
+ p = Path(dirpath) / fn
47
+ if not should_audit(p):
48
+ continue
49
+ try:
50
+ raw = p.read_bytes()
51
+ except OSError:
52
+ continue
53
+ if _looks_binary(raw):
54
+ continue
55
+ checked += 1
56
+ try:
57
+ raw.decode(encoding)
58
+ except UnicodeDecodeError as e:
59
+ failures.append((p, e.start, e.reason))
60
+ return failures, checked
61
+
62
+
63
+ def run(target_root: Path, encoding: str | None = None) -> int:
64
+ """Run the encoding check and print a report. Return an exit code (0 = clean)."""
65
+ force_utf8_streams()
66
+ root = target_root.resolve()
67
+ enc = normalize_encoding(encoding or configured_encoding(root))
68
+
69
+ failures, checked = scan(root, enc)
70
+
71
+ bar = "=" * 74
72
+ print(bar)
73
+ print(f"ENCODING [{enc}] — {len(failures)} file(s) not decodable / {checked} checked")
74
+ print(bar)
75
+ if not failures:
76
+ print(f" all {checked} text file(s) are valid {enc}")
77
+ return 0
78
+ for path, offset, reason in failures[:50]:
79
+ try:
80
+ rel = path.relative_to(root)
81
+ except ValueError:
82
+ rel = path
83
+ print(f" {rel} byte {offset}: {reason}")
84
+ if len(failures) > 50:
85
+ print(f" ... and {len(failures) - 50} more")
86
+ return 1
@@ -12,6 +12,8 @@ import subprocess
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
+ from audit_code.audit_shared import utf8_subprocess_env
16
+
15
17
  _SCRIPT = Path(__file__).resolve().parent / "audit_gate.py"
16
18
 
17
19
 
@@ -48,5 +50,6 @@ def run_gate(
48
50
  capture_output=False, # stream to terminal so user sees gate progress
49
51
  cwd=str(target_root),
50
52
  timeout=3600,
53
+ env=utf8_subprocess_env(),
51
54
  )
52
55
  return proc.returncode
@@ -9,6 +9,7 @@ import subprocess
9
9
  import sys
10
10
  from pathlib import Path
11
11
 
12
+ from audit_code.audit_shared import utf8_subprocess_env
12
13
  from audit_code.models import (
13
14
  AuditResult,
14
15
  AuditStatus,
@@ -41,6 +42,7 @@ def run(target_root: Path, severity: str | None = "HIGH") -> AuditResult:
41
42
  errors="replace",
42
43
  timeout=300,
43
44
  cwd=str(target_root),
45
+ env=utf8_subprocess_env(),
44
46
  )
45
47
  except subprocess.TimeoutExpired:
46
48
  return AuditResult(
@@ -22,7 +22,7 @@ import sys
22
22
  import tempfile
23
23
  from pathlib import Path
24
24
 
25
- from audit_code.audit_shared import should_audit
25
+ from audit_code.audit_shared import force_utf8_streams, should_audit
26
26
  from audit_code.config import (
27
27
  DOC_THRESHOLD_PCT,
28
28
  MIN_FLAG_BODY_LINES,
@@ -168,10 +168,7 @@ def run( # needs fix (god function 550+ lines — decompose into sub-audits)
168
168
  if fix:
169
169
  fast = True
170
170
 
171
- try:
172
- sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
173
- except (AttributeError, OSError):
174
- pass
171
+ force_utf8_streams()
175
172
 
176
173
  root = target_root.resolve()
177
174
  tests_dir = (root / tests).resolve()
@@ -471,12 +468,11 @@ def run( # needs fix (god function 550+ lines — decompose into sub-audits)
471
468
  else:
472
469
  tmp = Path(tempfile.mkdtemp(prefix="audit_q5_"))
473
470
  json_file = tmp / "cov.json"
474
- reuse = (
471
+ if (
475
472
  shared_cov is not None
476
473
  and shared_cov.exists()
477
474
  and shared_cov.stat().st_size > 0
478
- )
479
- if reuse:
475
+ ):
480
476
  # The suite audit already ran the whole suite under coverage;
481
477
  # reuse its data instead of a second full test run.
482
478
  data_file = shared_cov
@@ -57,6 +57,9 @@ def run_one(script: str):
57
57
  encoding="utf-8",
58
58
  errors="replace",
59
59
  timeout=timeout,
60
+ # force the child interpreter to UTF-8 stdio on every OS (Windows pipes
61
+ # default to cp1252, which crashes on the audit's Unicode glyphs)
62
+ env={**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"},
60
63
  )
61
64
  out = proc.stdout or ""
62
65
  high = med = info = None
@@ -11,7 +11,6 @@ Order:
11
11
 
12
12
  import shutil
13
13
  import subprocess
14
- import sys
15
14
  import tempfile
16
15
  import time
17
16
  from pathlib import Path
@@ -19,6 +18,7 @@ from pathlib import Path
19
18
  from audit_code import phd, quality, runtime, suite, wiring
20
19
  from audit_code.adapters import discover
21
20
  from audit_code.adapters.base import run_tool, which
21
+ from audit_code.audit_shared import force_utf8_streams
22
22
  from audit_code.config import FULL_SUITE_TIMEOUT
23
23
  from audit_code.models import AuditResult, AuditStatus
24
24
  from audit_code.profiles import load as profile_load
@@ -56,11 +56,7 @@ def run_suite(
56
56
  # Status glyphs (✓ ✗ ☠) below crash a cp1252 stdout with UnicodeEncodeError.
57
57
  # The CLI forces UTF-8, but a direct/library caller may not — do it here too
58
58
  # so any entry into the runner is safe. Idempotent; guarded for capture bufs.
59
- for _stream in (sys.stdout, sys.stderr):
60
- try:
61
- _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
62
- except (AttributeError, OSError):
63
- pass
59
+ force_utf8_streams()
64
60
 
65
61
  results: list[AuditResult] = []
66
62
  start = time.monotonic()
@@ -322,7 +318,7 @@ def _run_one_module(
322
318
  stderr=f"Module {module_name} has no run() function",
323
319
  )
324
320
 
325
- kwargs = {}
321
+ kwargs: dict[str, object] = {}
326
322
  if module_name == "quality" and (mode == "min" or fast):
327
323
  kwargs["fast"] = True # skip coverage
328
324
  if module_name == "quality" and fix:
@@ -332,7 +328,7 @@ def _run_one_module(
332
328
  if module_name == "suite" and shared_cov is not None:
333
329
  kwargs["cov_file"] = shared_cov # instrument the one shared run
334
330
  if module_name == "phd":
335
- kwargs["severity"] = severity # type: ignore[assignment]
331
+ kwargs["severity"] = severity
336
332
 
337
333
  return run_fn(target_root, **kwargs)
338
334