whykit 0.3.1__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.
Files changed (84) hide show
  1. whykit/__init__.py +21 -0
  2. whykit/adopt.py +776 -0
  3. whykit/backlinks.py +106 -0
  4. whykit/check.py +320 -0
  5. whykit/ci_formats.py +283 -0
  6. whykit/cli.py +1719 -0
  7. whykit/completion.py +193 -0
  8. whykit/config.py +233 -0
  9. whykit/console.py +217 -0
  10. whykit/context.py +204 -0
  11. whykit/contract.py +210 -0
  12. whykit/contract_schemas/backlinks-report.schema.json +61 -0
  13. whykit/contract_schemas/context-bundle.schema.json +27 -0
  14. whykit/contract_schemas/context-pack.schema.json +29 -0
  15. whykit/contract_schemas/impact-report.schema.json +122 -0
  16. whykit/contract_schemas/query-result.schema.json +105 -0
  17. whykit/contract_schemas/status-report.schema.json +176 -0
  18. whykit/contract_schemas/trace-report.schema.json +211 -0
  19. whykit/diff.py +823 -0
  20. whykit/evidence.py +178 -0
  21. whykit/explorer_index.py +554 -0
  22. whykit/graph.py +318 -0
  23. whykit/immutability.py +624 -0
  24. whykit/impact.py +272 -0
  25. whykit/init_layout.py +205 -0
  26. whykit/io.py +563 -0
  27. whykit/lint.py +2190 -0
  28. whykit/lsp.py +1241 -0
  29. whykit/markdown_code.py +107 -0
  30. whykit/mcp_server.py +2289 -0
  31. whykit/messages.py +46 -0
  32. whykit/pack.py +294 -0
  33. whykit/parse_cache.py +752 -0
  34. whykit/placeholders.py +84 -0
  35. whykit/promote.py +651 -0
  36. whykit/py.typed +0 -0
  37. whykit/query.py +174 -0
  38. whykit/review.py +527 -0
  39. whykit/rule_policy.py +1063 -0
  40. whykit/rules.py +274 -0
  41. whykit/scaffold.py +784 -0
  42. whykit/sensitivity.py +95 -0
  43. whykit/snapshot.py +319 -0
  44. whykit/status.py +167 -0
  45. whykit/tables.py +120 -0
  46. whykit/template/00-context/README.md +17 -0
  47. whykit/template/00-context/company.md +52 -0
  48. whykit/template/00-context/evidence-register.md +34 -0
  49. whykit/template/00-context/goals.md +38 -0
  50. whykit/template/00-context/review-log.md +22 -0
  51. whykit/template/00-context/terminology.md +34 -0
  52. whykit/template/01-strategy/README.md +7 -0
  53. whykit/template/02-discoverability/README.md +10 -0
  54. whykit/template/03-website/README.md +7 -0
  55. whykit/template/04-automation/README.md +10 -0
  56. whykit/template/05-operations/README.md +19 -0
  57. whykit/template/06-decisions/README.md +11 -0
  58. whykit/template/06-decisions/decision-log.md +38 -0
  59. whykit/template/07-research/README.md +10 -0
  60. whykit/template/AGENTS.md +163 -0
  61. whykit/template/Home.md +57 -0
  62. whykit/template/INTEROP.md +117 -0
  63. whykit/template/OBSIDIAN.md +92 -0
  64. whykit/template/README.md +51 -0
  65. whykit/template/assets/README.md +6 -0
  66. whykit/template/reports/README.md +7 -0
  67. whykit/template/templates/README.md +7 -0
  68. whykit/template/templates/automation-spec-template.md +70 -0
  69. whykit/template/templates/decision-record-template.md +65 -0
  70. whykit/template/templates/document-template.md +55 -0
  71. whykit/template/templates/experiment-template.md +62 -0
  72. whykit/template/templates/meeting-note-template.md +47 -0
  73. whykit/template/templates/page-brief-template.md +63 -0
  74. whykit/template/templates/research-note-template.md +53 -0
  75. whykit/template/templates/source-ingestion-template.md +43 -0
  76. whykit/template/whykit.toml +39 -0
  77. whykit/trace.py +272 -0
  78. whykit/vault_index.py +190 -0
  79. whykit-0.3.1.dist-info/METADATA +162 -0
  80. whykit-0.3.1.dist-info/RECORD +84 -0
  81. whykit-0.3.1.dist-info/WHEEL +4 -0
  82. whykit-0.3.1.dist-info/entry_points.txt +3 -0
  83. whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
  84. whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/completion.py ADDED
@@ -0,0 +1,193 @@
1
+ """Shell completion scripts generated from the argparse tree.
2
+
3
+ The scripts are derived from ``build_parser()`` at print time, so a new
4
+ subcommand or option completes the moment it exists; there is no second list
5
+ to keep in sync. Bash output stays compatible with the Bash 3.2 that macOS
6
+ still ships (no associative arrays), and zsh reuses it through bashcompinit.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from dataclasses import dataclass, field
12
+
13
+ SHELLS = ("bash", "zsh", "fish")
14
+
15
+
16
+ @dataclass
17
+ class _Option:
18
+ flags: list[str]
19
+ help: str
20
+ takes_value: bool
21
+ choices: list[str]
22
+
23
+
24
+ @dataclass
25
+ class _Command:
26
+ path: tuple[str, ...]
27
+ help: str = ""
28
+ options: list[_Option] = field(default_factory=list)
29
+ arguments: list[str] = field(default_factory=list) # positional choices
30
+ children: dict[str, _Command] = field(default_factory=dict)
31
+
32
+
33
+ def _clean_help(text: str | None) -> str:
34
+ if not text or text == argparse.SUPPRESS:
35
+ return ""
36
+ return " ".join(text.replace("%(default)s", "").replace("(default: )", "").split())
37
+
38
+
39
+ def _walk(parser: argparse.ArgumentParser, path: tuple[str, ...], help_text: str = "") -> _Command:
40
+ node = _Command(path=path, help=help_text)
41
+ for action in parser._actions: # argparse has no public introspection API
42
+ if isinstance(action, argparse._SubParsersAction):
43
+ helps = {choice.dest: choice.help for choice in action._choices_actions}
44
+ for name, child in action.choices.items():
45
+ node.children[name] = _walk(child, (*path, name), _clean_help(helps.get(name)))
46
+ elif not action.option_strings:
47
+ node.arguments.extend(str(c) for c in action.choices or ())
48
+ else:
49
+ node.options.append(_Option(
50
+ flags=list(action.option_strings),
51
+ help=_clean_help(action.help),
52
+ takes_value=action.nargs != 0,
53
+ choices=[str(c) for c in action.choices] if action.choices else [],
54
+ ))
55
+ return node
56
+
57
+
58
+ def _flatten(node: _Command) -> list[_Command]:
59
+ out = [node]
60
+ for child in node.children.values():
61
+ out.extend(_flatten(child))
62
+ return out
63
+
64
+
65
+ def _bash(root: _Command) -> str:
66
+ nodes = _flatten(root)
67
+ words, choices = [], []
68
+ for node in nodes:
69
+ key = " ".join(node.path)
70
+ flags = [flag for opt in node.options for flag in opt.flags]
71
+ words.append(
72
+ f' "{key}") subs="{" ".join(node.children)}"; args="{" ".join(node.arguments)}"; opts="{" ".join(flags)}" ;;'
73
+ )
74
+ for opt in node.options:
75
+ if opt.choices:
76
+ for flag in opt.flags:
77
+ choices.append(f' "{key}|{flag}") echo "{" ".join(opt.choices)}" ;;')
78
+ elif opt.takes_value:
79
+ for flag in opt.flags:
80
+ choices.append(f' "{key}|{flag}") echo "__value__" ;;')
81
+ return f"""# bash completion for whykit (generated by `whykit completion bash`)
82
+ __whykit_words() {{
83
+ case "$1" in
84
+ {chr(10).join(words)}
85
+ *) subs=""; args=""; opts="" ;;
86
+ esac
87
+ }}
88
+
89
+ __whykit_choices() {{
90
+ case "$1|$2" in
91
+ {chr(10).join(choices)}
92
+ esac
93
+ }}
94
+
95
+ _whykit() {{
96
+ local cur="${{COMP_WORDS[COMP_CWORD]}}" prev="" path="" w i subs args opts values skip=0
97
+ [ "$COMP_CWORD" -gt 0 ] && prev="${{COMP_WORDS[COMP_CWORD-1]}}"
98
+ for ((i = 1; i < COMP_CWORD; i++)); do
99
+ w="${{COMP_WORDS[i]}}"
100
+ if [ "$skip" = 1 ]; then skip=0; continue; fi
101
+ case "$w" in
102
+ -*)
103
+ [ -n "$(__whykit_choices "$path" "$w")" ] && skip=1
104
+ continue ;;
105
+ esac
106
+ __whykit_words "$path"
107
+ case " $subs " in *" $w "*) path="${{path:+$path }}$w" ;; esac
108
+ done
109
+ COMPREPLY=()
110
+ values="$(__whykit_choices "$path" "$prev")"
111
+ if [ "$values" = "__value__" ]; then
112
+ return 0
113
+ elif [ -n "$values" ]; then
114
+ COMPREPLY=($(compgen -W "$values" -- "$cur"))
115
+ return 0
116
+ fi
117
+ __whykit_words "$path"
118
+ case "$cur" in
119
+ -*) COMPREPLY=($(compgen -W "$opts" -- "$cur")) ;;
120
+ *) [ -n "$subs$args" ] && COMPREPLY=($(compgen -W "$subs $args" -- "$cur")) ;;
121
+ esac
122
+ return 0
123
+ }}
124
+
125
+ complete -o default -o bashdefault -F _whykit whykit
126
+ """
127
+
128
+
129
+ def _zsh(root: _Command) -> str:
130
+ return (
131
+ "#compdef whykit\n"
132
+ "# zsh completion for whykit (generated by `whykit completion zsh`)\n"
133
+ "(( $+functions[compdef] )) || { autoload -U +X compinit && compinit -i; }\n"
134
+ "autoload -U +X bashcompinit && bashcompinit\n"
135
+ + _bash(root).split("\n", 1)[1]
136
+ )
137
+
138
+
139
+ def _fish_quote(text: str) -> str:
140
+ return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
141
+
142
+
143
+ def _fish(root: _Command) -> str:
144
+ lines = ["# fish completion for whykit (generated by `whykit completion fish`)"]
145
+ nodes = _flatten(root)
146
+ # A nested command name can also be a top-level one (`new evidence` vs
147
+ # `evidence`), so a depth-1 condition excludes the parents that own it.
148
+ owners: dict[str, list[str]] = {}
149
+ for node in nodes:
150
+ if len(node.path) == 2:
151
+ owners.setdefault(node.path[1], []).append(node.path[0])
152
+
153
+ def condition(node: _Command) -> str:
154
+ if not node.path:
155
+ return "__fish_use_subcommand"
156
+ parts = [f"__fish_seen_subcommand_from {name}" for name in node.path]
157
+ if len(node.path) == 1 and node.path[0] in owners:
158
+ parts.append("not __fish_seen_subcommand_from " + " ".join(owners[node.path[0]]))
159
+ if node.children:
160
+ parts.append("not __fish_seen_subcommand_from " + " ".join(node.children))
161
+ return "; and ".join(parts)
162
+
163
+ for node in nodes:
164
+ cond = _fish_quote(condition(node))
165
+ for name, child in node.children.items():
166
+ parent = "__fish_use_subcommand" if not node.path else condition(node)
167
+ lines.append(f"complete -c whykit -n {_fish_quote(parent)} -f -a {name} -d {_fish_quote(child.help)}")
168
+ if node.arguments:
169
+ lines.append(f"complete -c whykit -n {cond} -f -a {_fish_quote(' '.join(node.arguments))}")
170
+ for opt in node.options:
171
+ spec = []
172
+ for flag in opt.flags:
173
+ if flag.startswith("--"):
174
+ spec.append(f"-l {flag[2:]}")
175
+ elif len(flag) == 2:
176
+ spec.append(f"-s {flag[1:]}")
177
+ if opt.choices:
178
+ spec.append(f"-xa {_fish_quote(' '.join(opt.choices))}")
179
+ elif opt.takes_value:
180
+ spec.append("-r -F")
181
+ lines.append(f"complete -c whykit -n {cond} {' '.join(spec)} -d {_fish_quote(opt.help)}")
182
+ return "\n".join(lines) + "\n"
183
+
184
+
185
+ def render_completion(parser: argparse.ArgumentParser, shell: str) -> str:
186
+ root = _walk(parser, ())
187
+ if shell == "bash":
188
+ return _bash(root)
189
+ if shell == "zsh":
190
+ return _zsh(root)
191
+ if shell == "fish":
192
+ return _fish(root)
193
+ raise ValueError(f"unsupported shell: {shell}")
whykit/config.py ADDED
@@ -0,0 +1,233 @@
1
+ """Repository-local WhyKit policy configuration.
2
+
3
+ WhyKit intentionally has no user-global configuration. A vault may optionally
4
+ carry ``whykit.toml`` so the same checkout has the same defaults and CI gates on
5
+ every machine and for every agent.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .rule_policy import PolicyError, build_policy
15
+
16
+ CONFIG_FILE = "whykit.toml"
17
+ FORMAT_VERSION = 1
18
+
19
+ DEFAULT_CONFIG: dict[str, Any] = {
20
+ "format_version": FORMAT_VERSION,
21
+ "evidence_access_age_days": {},
22
+ "defaults": {
23
+ "owner": "TODO",
24
+ "sensitivity": "internal",
25
+ "decision_review_days": 90,
26
+ "status_due_days": 30,
27
+ "require_hub_links": False,
28
+ },
29
+ "profiles": {
30
+ "local": {
31
+ "strict": False,
32
+ "orphans": True,
33
+ "secrets": True,
34
+ "require_git": False,
35
+ "require_clean_tree": False,
36
+ "require_configured": False,
37
+ "require_hub_links": False,
38
+ "history": "optional",
39
+ },
40
+ "ci": {
41
+ "strict": True,
42
+ "orphans": True,
43
+ "secrets": True,
44
+ "require_git": False,
45
+ "require_clean_tree": False,
46
+ "require_configured": False,
47
+ "require_hub_links": False,
48
+ "history": "optional",
49
+ },
50
+ "release": {
51
+ "strict": True,
52
+ "orphans": True,
53
+ "secrets": True,
54
+ "require_git": True,
55
+ "require_clean_tree": True,
56
+ "require_configured": True,
57
+ "require_hub_links": False,
58
+ "history": "required",
59
+ },
60
+ },
61
+ }
62
+
63
+ _ALLOWED_SENSITIVITY = {"public", "internal", "confidential", "restricted"}
64
+ _ALLOWED_HISTORY = {"off", "optional", "required"}
65
+ _TOP_LEVEL_KEYS = {
66
+ "format_version",
67
+ "evidence_access_age_days",
68
+ "defaults",
69
+ "profiles",
70
+ "rules",
71
+ }
72
+ _DEFAULT_KEYS = {
73
+ "owner",
74
+ "sensitivity",
75
+ "decision_review_days",
76
+ "status_due_days",
77
+ "require_hub_links",
78
+ }
79
+ _PROFILE_KEYS = {
80
+ "strict", "orphans", "secrets", "require_git", "require_clean_tree",
81
+ "require_configured", "require_hub_links", "history",
82
+ }
83
+
84
+
85
+ class ConfigError(ValueError):
86
+ pass
87
+
88
+
89
+ def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
90
+ result = copy.deepcopy(base)
91
+ for key, value in override.items():
92
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
93
+ result[key] = _merge(result[key], value)
94
+ else:
95
+ result[key] = value
96
+ return result
97
+
98
+
99
+ def _validate(config: dict[str, Any]) -> None:
100
+ unknown_top = set(config) - _TOP_LEVEL_KEYS
101
+ if unknown_top:
102
+ raise ConfigError("unknown top-level keys: " + ", ".join(sorted(unknown_top)))
103
+
104
+ version = config.get("format_version")
105
+ if version != FORMAT_VERSION:
106
+ raise ConfigError(f"unsupported whykit.toml format_version: {version!r}; expected {FORMAT_VERSION}")
107
+
108
+ access_age = config.get("evidence_access_age_days")
109
+ if not isinstance(access_age, dict):
110
+ raise ConfigError("[evidence_access_age_days] must be a table")
111
+ for source_type, days in access_age.items():
112
+ if not isinstance(source_type, str) or not source_type.strip():
113
+ raise ConfigError("evidence_access_age_days source type must be non-empty")
114
+ if not isinstance(days, int) or isinstance(days, bool) or days < 0:
115
+ raise ConfigError(f"evidence_access_age_days.{source_type} must be an integer >= 0")
116
+
117
+ defaults = config.get("defaults")
118
+ if not isinstance(defaults, dict):
119
+ raise ConfigError("[defaults] must be a table")
120
+ unknown_defaults = set(defaults) - _DEFAULT_KEYS
121
+ if unknown_defaults:
122
+ raise ConfigError("unknown [defaults] keys: " + ", ".join(sorted(unknown_defaults)))
123
+ owner = defaults.get("owner")
124
+ if not isinstance(owner, str) or not owner.strip():
125
+ raise ConfigError("defaults.owner must be a non-empty string")
126
+ sensitivity = defaults.get("sensitivity")
127
+ if sensitivity not in _ALLOWED_SENSITIVITY:
128
+ raise ConfigError("defaults.sensitivity must be public, internal, confidential or restricted")
129
+ for key in ("decision_review_days", "status_due_days"):
130
+ value = defaults.get(key)
131
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
132
+ raise ConfigError(f"defaults.{key} must be an integer >= 0")
133
+ if not isinstance(defaults.get("require_hub_links"), bool):
134
+ raise ConfigError("defaults.require_hub_links must be true or false")
135
+
136
+ profiles = config.get("profiles")
137
+ if not isinstance(profiles, dict) or not profiles:
138
+ raise ConfigError("[profiles] must define at least one profile")
139
+ for name, profile in profiles.items():
140
+ if not isinstance(name, str) or not name.strip() or not isinstance(profile, dict):
141
+ raise ConfigError("each profile must be a named table")
142
+ unknown = set(profile) - _PROFILE_KEYS
143
+ if unknown:
144
+ raise ConfigError(f"profile {name!r} has unknown keys: {', '.join(sorted(unknown))}")
145
+ for key in (
146
+ "strict", "orphans", "secrets", "require_git", "require_clean_tree",
147
+ "require_configured", "require_hub_links",
148
+ ):
149
+ if not isinstance(profile.get(key), bool):
150
+ raise ConfigError(f"profiles.{name}.{key} must be true or false")
151
+ if profile.get("history") not in _ALLOWED_HISTORY:
152
+ raise ConfigError(f"profiles.{name}.history must be off, optional or required")
153
+
154
+ # Team policy: [[rules.custom]] and [rules.overrides."<code>"]. Absent in
155
+ # the built-in defaults, so a vault without it reports exactly as before.
156
+ if "rules" in config:
157
+ try:
158
+ build_policy(config["rules"])
159
+ except PolicyError as exc:
160
+ raise ConfigError(str(exc)) from exc
161
+
162
+
163
+ def load_config(root: Path) -> tuple[dict[str, Any], Path | None]:
164
+ """Load and validate repo-local config, falling back to deterministic defaults."""
165
+ path = root / CONFIG_FILE
166
+ if not path.exists():
167
+ config = copy.deepcopy(DEFAULT_CONFIG)
168
+ _validate(config)
169
+ return config, None
170
+ try:
171
+ text = path.read_text(encoding="utf-8-sig")
172
+ except (OSError, UnicodeDecodeError) as exc:
173
+ raise ConfigError(f"cannot read {CONFIG_FILE}: {exc}") from exc
174
+ return parse_config(text), path
175
+
176
+
177
+
178
+ def parse_config(text: str) -> dict[str, Any]:
179
+ """Validate policy from a file or a pinned Git blob through the same path."""
180
+ try:
181
+ raw = tomllib.loads(text.removeprefix("\ufeff"))
182
+ except tomllib.TOMLDecodeError as exc:
183
+ raise ConfigError(f"cannot read {CONFIG_FILE}: {exc}") from exc
184
+ if not isinstance(raw, dict):
185
+ raise ConfigError(f"{CONFIG_FILE} must contain a TOML table")
186
+ unknown_raw_top = set(raw) - _TOP_LEVEL_KEYS
187
+ if unknown_raw_top:
188
+ raise ConfigError("unknown top-level keys: " + ", ".join(sorted(unknown_raw_top)))
189
+ if "defaults" in raw:
190
+ if not isinstance(raw["defaults"], dict):
191
+ raise ConfigError("[defaults] must be a table")
192
+ unknown_raw_defaults = set(raw["defaults"]) - _DEFAULT_KEYS
193
+ if unknown_raw_defaults:
194
+ raise ConfigError("unknown [defaults] keys: " + ", ".join(sorted(unknown_raw_defaults)))
195
+ config = _merge(DEFAULT_CONFIG, raw)
196
+ _validate(config)
197
+ return config
198
+
199
+
200
+ def configuration_readiness(config: dict[str, Any], config_path: Path | None) -> tuple[bool, list[str]]:
201
+ """Return whether repository policy is concretely configured for release use.
202
+
203
+ This is intentionally stricter than syntactic validation: a generated policy
204
+ containing starter placeholders is valid TOML but is not an operational
205
+ release policy.
206
+ """
207
+ reasons: list[str] = []
208
+ if config_path is None:
209
+ reasons.append(f"{CONFIG_FILE} is missing; built-in defaults are not release configuration")
210
+ owner = str(config.get("defaults", {}).get("owner", "")).strip()
211
+ if owner.casefold() in {"todo", "tbd", "unknown", "unset", "n/a", "none"}:
212
+ reasons.append("defaults.owner is still a placeholder")
213
+ return not reasons, reasons
214
+
215
+
216
+ def get_profile(config: dict[str, Any], name: str) -> dict[str, Any]:
217
+ profiles = config.get("profiles", {})
218
+ if name not in profiles:
219
+ raise ConfigError(f"unknown policy profile {name!r}; available: {', '.join(sorted(profiles))}")
220
+ return copy.deepcopy(profiles[name])
221
+
222
+
223
+ def config_summary(root: Path) -> dict[str, Any]:
224
+ config, path = load_config(root)
225
+ source = "built-in defaults"
226
+ if path is not None:
227
+ source = path.resolve().relative_to(root.resolve()).as_posix()
228
+ return {
229
+ "contract_version": 1,
230
+ "source": source,
231
+ "configured": path is not None,
232
+ "config": config,
233
+ }
whykit/console.py ADDED
@@ -0,0 +1,217 @@
1
+ """Console output that survives terminals which cannot encode Unicode.
2
+
3
+ Human messages use a few typographic glyphs (em dash, ellipsis, arrow) and
4
+ echo vault content, which can be in any script. On an ASCII console
5
+ (``LC_ALL=C``, ``PYTHONIOENCODING=ascii``) or a legacy Windows code page,
6
+ Python's default ``strict`` stdout raises ``UnicodeEncodeError`` mid-report.
7
+
8
+ Two rules keep output safe without changing what UTF-8 consumers receive:
9
+
10
+ * Human text keeps the stream's encoding. Characters it cannot represent are
11
+ replaced: known glyphs by an ASCII spelling, anything else by a
12
+ backslash escape, so a path stays unambiguous rather than becoming ``?``.
13
+ * Machine output (JSON, DOT, Mermaid) is always written as UTF-8, whatever
14
+ the console encoding. On a UTF-8 stream the bytes are unchanged; elsewhere
15
+ this is the only encoding a JSON consumer is required to accept (RFC 8259).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import codecs
21
+ import json
22
+ from contextlib import contextmanager
23
+ from collections.abc import Iterator
24
+ import re
25
+ import sys
26
+ from typing import Any, TextIO
27
+
28
+ ERROR_HANDLER = "whykit-ascii-fallback"
29
+
30
+ # Glyphs WhyKit itself prints, plus common typography that vault titles carry.
31
+ ASCII_FALLBACKS: dict[str, str] = {
32
+ "—": "-", # em dash
33
+ "–": "-", # en dash
34
+ "−": "-", # minus sign
35
+ "…": "...",
36
+ "→": "->",
37
+ "←": "<-",
38
+ "↔": "<->",
39
+ "‘": "'",
40
+ "’": "'",
41
+ "“": '"',
42
+ "”": '"',
43
+ " ": " ",
44
+ "•": "*",
45
+ "·": "-",
46
+ "✓": "OK",
47
+ "✗": "x",
48
+ "×": "x",
49
+ "≤": "<=",
50
+ "≥": ">=",
51
+ "≠": "!=",
52
+ "": "",
53
+ }
54
+
55
+ _DEFAULT_ERROR_MODES = frozenset({"strict", "backslashreplace", "surrogateescape"})
56
+ _UTF_CODECS = frozenset({"utf-8", "utf-8-sig", "utf-16", "utf-16-le", "utf-16-be", "utf-32", "utf-32-le", "utf-32-be"})
57
+
58
+
59
+ def _fallback(exc: UnicodeError) -> tuple[str | bytes, int]:
60
+ if not isinstance(exc, UnicodeEncodeError):
61
+ raise exc
62
+ chunk = exc.object[exc.start:exc.end]
63
+ if all(0xDC80 <= ord(char) <= 0xDCFF for char in chunk):
64
+ # A POSIX name that was not valid in the locale (C locale argv or file
65
+ # name). Usually the bytes are UTF-8: spell them like any other text so
66
+ # an ASCII console stays ASCII; bytes that are not UTF-8 become \xNN.
67
+ raw = bytes(ord(char) - 0xDC00 for char in chunk)
68
+ try:
69
+ chunk = raw.decode("utf-8")
70
+ except UnicodeDecodeError:
71
+ return "".join(f"\\x{byte:02x}" for byte in raw), exc.end
72
+ pieces = []
73
+ for char in chunk:
74
+ if char.isascii():
75
+ pieces.append(char)
76
+ continue
77
+ replacement = ASCII_FALLBACKS.get(char)
78
+ if replacement is None:
79
+ code = ord(char)
80
+ if code <= 0xFF:
81
+ replacement = f"\\x{code:02x}"
82
+ elif code <= 0xFFFF:
83
+ replacement = f"\\u{code:04x}"
84
+ else:
85
+ replacement = f"\\U{code:08x}"
86
+ pieces.append(replacement)
87
+ return "".join(pieces), exc.end
88
+
89
+
90
+ try:
91
+ codecs.lookup_error(ERROR_HANDLER)
92
+ except LookupError:
93
+ codecs.register_error(ERROR_HANDLER, _fallback)
94
+
95
+
96
+ def _codec_name(stream: Any) -> str | None:
97
+ encoding = getattr(stream, "encoding", None)
98
+ if not encoding:
99
+ return None
100
+ try:
101
+ return codecs.lookup(encoding).name
102
+ except LookupError:
103
+ return None
104
+
105
+
106
+ def is_utf(stream: Any) -> bool:
107
+ """True when ``stream`` encodes every code point (or its encoding is unknown)."""
108
+ name = _codec_name(stream)
109
+ return name is None or name in _UTF_CODECS
110
+
111
+
112
+ def as_printed(text: str, stream: Any = None) -> str:
113
+ """*text* as ``stream`` (default stdout) will show it.
114
+
115
+ On a console that cannot encode every character the fallback handler
116
+ spells some of them as escapes, which changes how many columns the text
117
+ takes; layout code measures this form so tables stay aligned.
118
+ """
119
+ stream = sys.stdout if stream is None else stream
120
+ if is_utf(stream):
121
+ return text
122
+ encoding = _codec_name(stream)
123
+ assert encoding is not None
124
+ try:
125
+ return text.encode(encoding, getattr(stream, "errors", None) or "strict").decode(encoding, "replace")
126
+ except (UnicodeError, LookupError):
127
+ return text
128
+
129
+
130
+ def harden_stream(stream: Any) -> None:
131
+ """Replace unencodable characters on a non-UTF stream instead of raising.
132
+
133
+ An explicit user choice such as ``PYTHONIOENCODING=ascii:replace`` is
134
+ respected: only the interpreter defaults (``strict`` on stdout,
135
+ ``backslashreplace`` on stderr, ``surrogateescape`` under the C/POSIX
136
+ locale) are swapped for the fallback handler, which keeps the
137
+ surrogateescape round-trip for undecodable file names.
138
+ """
139
+ reconfigure = getattr(stream, "reconfigure", None)
140
+ if reconfigure is None or is_utf(stream):
141
+ return
142
+ if getattr(stream, "errors", None) not in _DEFAULT_ERROR_MODES:
143
+ return
144
+ try:
145
+ reconfigure(errors=ERROR_HANDLER)
146
+ except (ValueError, OSError): # pragma: no cover - closed or detached stream
147
+ return
148
+
149
+
150
+ def harden_stdio() -> None:
151
+ harden_stream(sys.stdout)
152
+ harden_stream(sys.stderr)
153
+
154
+
155
+ @contextmanager
156
+ def _machine_stream(file: TextIO | None) -> Iterator[TextIO]:
157
+ stream = sys.stdout if file is None else file
158
+ reconfigure = getattr(stream, "reconfigure", None)
159
+ if reconfigure is None or is_utf(stream):
160
+ yield stream
161
+ return
162
+ encoding, errors = stream.encoding, stream.errors
163
+ stream.flush()
164
+ # surrogateescape turns C-locale argv/file-name surrogates back into the
165
+ # original (UTF-8) bytes, so the output matches a UTF-8 console byte for byte.
166
+ reconfigure(encoding="utf-8", errors="surrogateescape")
167
+ try:
168
+ yield stream
169
+ stream.flush()
170
+ finally:
171
+ reconfigure(encoding=encoding, errors=errors)
172
+
173
+
174
+ def emit_machine(text: str, *, file: TextIO | None = None) -> None:
175
+ """Print machine-readable text as UTF-8, keeping newline translation."""
176
+ with _machine_stream(file) as stream:
177
+ print(text, file=stream)
178
+
179
+
180
+ def emit_json(value: object, *, file: TextIO | None = None) -> None:
181
+ """Write the usual pretty JSON without building another complete string."""
182
+ with _machine_stream(file) as stream:
183
+ json.dump(value, stream, ensure_ascii=False, indent=2)
184
+ stream.write("\n")
185
+
186
+
187
+ # Line breaks (including the Unicode ones), other control characters and the
188
+ # bidirectional overrides. Printed raw, vault text containing them could start
189
+ # a new log line that a CI runner reads as a command (``::error::``), move the
190
+ # terminal cursor or recolour it (ESC), or reorder what a reviewer sees.
191
+ # A raw string so the regex engine, not the source file, holds the control
192
+ # characters: C0 except tab, DEL and C1, ALM, LRM/RLM, the Unicode line and
193
+ # paragraph separators, and the bidi embeddings, overrides and isolates.
194
+ _UNSAFE_DISPLAY_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]")
195
+ _DISPLAY_ESCAPES = {"\n": "\\n", "\r": "\\r"}
196
+
197
+
198
+ def one_line(value: object) -> str:
199
+ """*value* as one inert line of human-readable output.
200
+
201
+ Vault text (titles, paths, finding messages) is untrusted. Every
202
+ character that could break the line or control the terminal is shown as a
203
+ visible escape instead; ordinary text, tabs and non-Latin scripts are
204
+ unchanged. Machine formats (JSON, SARIF, GitHub annotations) keep the
205
+ exact value and do their own escaping.
206
+ """
207
+ text = str(value)
208
+ if not _UNSAFE_DISPLAY_RE.search(text):
209
+ return text
210
+
211
+ def escape(match: re.Match[str]) -> str:
212
+ char = match.group(0)
213
+ code = ord(char)
214
+ return _DISPLAY_ESCAPES.get(char) or (f"\\x{code:02x}" if code <= 0xFF else f"\\u{code:04x}")
215
+
216
+ return _UNSAFE_DISPLAY_RE.sub(escape, text)
217
+