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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- 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
|
+
|