2026-07-21 12:40:21 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Canonical YAML normalization used by every HA import path.
|
|
|
|
|
|
|
|
|
|
One function, one style, used everywhere data lands in the repo (see
|
fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.
15 markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
rozwiazywala sie z katalogu, w ktorym lezala)
200 odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
-> nowa sciezka repo-root-relative, zgodnie z konwencja repo
5 linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
tylko w starym katalogu; przeliczone recznie
Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.
Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.
Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.
Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:12:24 +02:00
|
|
|
kb/decisions/ha-configs-as-code.md, "Canonical format"): block collections,
|
2026-07-21 12:40:21 +02:00
|
|
|
indent width 2, unlimited line width, sorted keys, UTF-8.
|
2026-07-21 17:41:12 +02:00
|
|
|
|
|
|
|
|
HA configs use custom `!`-prefixed tags (`!include`, `!secret`, `!env_var`,
|
|
|
|
|
`!input`, the `!include_dir_*` family, and any future addition) that
|
|
|
|
|
`yaml.safe_load` has no constructor for and rejects with a ConstructorError.
|
|
|
|
|
These tags are opaque to us — we never need to resolve `!include`, we just
|
|
|
|
|
need to carry it through a parse/dump round-trip unchanged. `TaggedValue`
|
|
|
|
|
plus the loader/dumper hooks below handle any `!xxx` tag generically
|
|
|
|
|
(scalar, sequence, or mapping payload) without enumerating known tag names.
|
2026-07-21 12:40:21 +02:00
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 17:41:12 +02:00
|
|
|
class TaggedValue:
|
|
|
|
|
"""Wraps a value parsed from a custom `!tag` node (see module docstring).
|
|
|
|
|
|
|
|
|
|
`tag` is the full tag text (e.g. `"!include"`); `value` is the plain
|
|
|
|
|
Python object (str/list/dict) constructed from the node's payload.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, tag, value):
|
|
|
|
|
self.tag = tag
|
|
|
|
|
self.value = value
|
|
|
|
|
|
|
|
|
|
def __eq__(self, other):
|
|
|
|
|
return (
|
|
|
|
|
isinstance(other, TaggedValue)
|
|
|
|
|
and self.tag == other.tag
|
|
|
|
|
and self.value == other.value
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
return f"TaggedValue({self.tag!r}, {self.value!r})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _CanonicalLoader(yaml.SafeLoader):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _construct_tagged_value(loader, tag_suffix, node):
|
|
|
|
|
tag = "!" + tag_suffix
|
|
|
|
|
if isinstance(node, yaml.ScalarNode):
|
|
|
|
|
value = loader.construct_scalar(node)
|
|
|
|
|
elif isinstance(node, yaml.SequenceNode):
|
|
|
|
|
value = loader.construct_sequence(node, deep=True)
|
|
|
|
|
elif isinstance(node, yaml.MappingNode):
|
|
|
|
|
value = loader.construct_mapping(node, deep=True)
|
|
|
|
|
else:
|
|
|
|
|
raise yaml.constructor.ConstructorError(
|
|
|
|
|
None, None, f"unsupported node type for tag {tag!r}", node.start_mark
|
|
|
|
|
)
|
|
|
|
|
return TaggedValue(tag, value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_CanonicalLoader.add_multi_constructor("!", _construct_tagged_value)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 12:40:21 +02:00
|
|
|
class _CanonicalDumper(yaml.SafeDumper):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _represent_none(dumper, _value):
|
|
|
|
|
return dumper.represent_scalar("tag:yaml.org,2002:null", "")
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 17:41:12 +02:00
|
|
|
def _represent_tagged_value(dumper, data):
|
|
|
|
|
if isinstance(data.value, dict):
|
|
|
|
|
return dumper.represent_mapping(data.tag, data.value)
|
|
|
|
|
if isinstance(data.value, list):
|
|
|
|
|
return dumper.represent_sequence(data.tag, data.value)
|
|
|
|
|
return dumper.represent_scalar(data.tag, data.value)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 12:40:21 +02:00
|
|
|
_CanonicalDumper.add_representer(type(None), _represent_none)
|
2026-07-21 17:41:12 +02:00
|
|
|
_CanonicalDumper.add_representer(TaggedValue, _represent_tagged_value)
|
2026-07-21 12:40:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def dump_canonical_yaml(data):
|
|
|
|
|
"""Render `data` as canonical YAML text."""
|
|
|
|
|
return yaml.dump(
|
|
|
|
|
data,
|
|
|
|
|
Dumper=_CanonicalDumper,
|
|
|
|
|
default_flow_style=False,
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
allow_unicode=True,
|
|
|
|
|
width=1 << 31,
|
|
|
|
|
indent=2,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 17:41:12 +02:00
|
|
|
def parse_yaml_text(text):
|
|
|
|
|
"""Parse YAML text, resolving HA's custom `!include` / `!secret` / etc.
|
|
|
|
|
|
|
|
|
|
tags to `TaggedValue` instances instead of raising ConstructorError
|
|
|
|
|
(what plain `yaml.safe_load` does).
|
|
|
|
|
"""
|
|
|
|
|
return yaml.load(text, Loader=_CanonicalLoader)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 12:40:21 +02:00
|
|
|
def normalize_yaml_text(text):
|
|
|
|
|
"""Parse YAML text then re-dump it through the canonical formatter."""
|
2026-07-21 17:41:12 +02:00
|
|
|
data = parse_yaml_text(text)
|
2026-07-21 12:40:21 +02:00
|
|
|
return dump_canonical_yaml(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_json_text(text):
|
|
|
|
|
"""Parse JSON text (e.g. a `.storage/*` file) and dump as canonical YAML."""
|
|
|
|
|
data = json.loads(text)
|
|
|
|
|
return dump_canonical_yaml(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv):
|
|
|
|
|
if len(argv) != 3 or argv[1] not in ("yaml", "json"):
|
|
|
|
|
print("usage: normalize.py <yaml|json> <input-path>", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
mode, path = argv[1], argv[2]
|
|
|
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
|
|
|
text = f.read()
|
|
|
|
|
out = normalize_yaml_text(text) if mode == "yaml" else normalize_json_text(text)
|
|
|
|
|
sys.stdout.write(out)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main(sys.argv))
|