awkno 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. awkno/__init__.py +25 -0
  2. awkno/_doctor.py +139 -0
  3. awkno/cli.py +355 -0
  4. awkno/corpus.py +216 -0
  5. awkno/generate.py +549 -0
  6. awkno/pages/agent-senses.json +16 -0
  7. awkno/pages/agent-vm.json +14 -0
  8. awkno/pages/aitherconnect.json +11 -0
  9. awkno/pages/aitherkvcache.json +11 -0
  10. awkno/pages/aitherzero.json +11 -0
  11. awkno/pages/awask.json +15 -0
  12. awkno/pages/awbac.json +11 -0
  13. awkno/pages/awbeads.json +13 -0
  14. awkno/pages/awbonsai.json +13 -0
  15. awkno/pages/awbrowse.json +13 -0
  16. awkno/pages/awdit.json +12 -0
  17. awkno/pages/awdk.json +17 -0
  18. awkno/pages/awevolve.json +15 -0
  19. awkno/pages/awfind.json +13 -0
  20. awkno/pages/awgit.json +12 -0
  21. awkno/pages/awgraph.json +13 -0
  22. awkno/pages/awiam.json +12 -0
  23. awkno/pages/awkit.json +13 -0
  24. awkno/pages/awkno.json +13 -0
  25. awkno/pages/awknowledge.json +12 -0
  26. awkno/pages/awm.json +13 -0
  27. awkno/pages/awmail.json +18 -0
  28. awkno/pages/awnboard.json +20 -0
  29. awkno/pages/awnest.json +19 -0
  30. awkno/pages/awnet.json +14 -0
  31. awkno/pages/awnix.json +20 -0
  32. awkno/pages/awnode.json +12 -0
  33. awkno/pages/awpack.json +14 -0
  34. awkno/pages/awpredict.json +11 -0
  35. awkno/pages/awprism.json +14 -0
  36. awkno/pages/awreason.json +14 -0
  37. awkno/pages/awrecover.json +13 -0
  38. awkno/pages/awrecurse.json +13 -0
  39. awkno/pages/awrelay.json +12 -0
  40. awkno/pages/awrena.json +14 -0
  41. awkno/pages/awrepl.json +13 -0
  42. awkno/pages/awresearch.json +14 -0
  43. awkno/pages/awrise.json +15 -0
  44. awkno/pages/awrun.json +14 -0
  45. awkno/pages/awscreen.json +13 -0
  46. awkno/pages/awseal.json +13 -0
  47. awkno/pages/awsh.json +13 -0
  48. awkno/pages/awshare.json +12 -0
  49. awkno/pages/awskills.json +12 -0
  50. awkno/pages/awsprite.json +14 -0
  51. awkno/pages/awsync.json +16 -0
  52. awkno/pages/awtoll.json +13 -0
  53. awkno/pages/awtunnel.json +12 -0
  54. awkno/pages/awvision.json +13 -0
  55. awkno/pages/awvoice.json +13 -0
  56. awkno/pages/awwall.json +19 -0
  57. awkno/pages/bead-space.json +12 -0
  58. awkno/pages/browser-companion-graduation.json +15 -0
  59. awkno/pages/cited-research.json +14 -0
  60. awkno/pages/gobbonet-agentic.json +12 -0
  61. awkno/pages/gobbonet.json +13 -0
  62. awkno/pages/guide-00.json +16 -0
  63. awkno/pages/guide-01.json +14 -0
  64. awkno/pages/guide-02.json +14 -0
  65. awkno/pages/guide-03.json +14 -0
  66. awkno/pages/guide-04.json +15 -0
  67. awkno/pages/guide-05.json +16 -0
  68. awkno/pages/guide-06.json +15 -0
  69. awkno/pages/guide-07.json +15 -0
  70. awkno/pages/guide-08.json +17 -0
  71. awkno/pages/guide-09.json +13 -0
  72. awkno/pages/guide.json +22 -0
  73. awkno/pages/law-01.json +11 -0
  74. awkno/pages/law-02.json +11 -0
  75. awkno/pages/law-03.json +11 -0
  76. awkno/pages/law-04.json +11 -0
  77. awkno/pages/law-05.json +11 -0
  78. awkno/pages/law-06.json +11 -0
  79. awkno/pages/law-07.json +11 -0
  80. awkno/pages/law-08.json +11 -0
  81. awkno/pages/law-09.json +11 -0
  82. awkno/pages/law-10.json +11 -0
  83. awkno/pages/law-11.json +11 -0
  84. awkno/pages/law-12.json +11 -0
  85. awkno/pages/law-13.json +11 -0
  86. awkno/pages/law-14.json +11 -0
  87. awkno/pages/law-15.json +11 -0
  88. awkno/pages/law-16.json +11 -0
  89. awkno/pages/law-17.json +11 -0
  90. awkno/pages/law-18.json +11 -0
  91. awkno/pages/law-19.json +11 -0
  92. awkno/pages/law-20.json +11 -0
  93. awkno/pages/one-surface.json +15 -0
  94. awkno/pages/persona.json +12 -0
  95. awkno/pages/personal-mesh-pool.json +13 -0
  96. awkno/pages/provenance.json +14 -0
  97. awkno/pages/shared-worktree.json +14 -0
  98. awkno/pages/the-front-door.json +15 -0
  99. awkno/pages/the-reasoning-loop.json +14 -0
  100. awkno/pages/who-what-did.json +13 -0
  101. awkno-0.1.0.dist-info/METADATA +336 -0
  102. awkno-0.1.0.dist-info/RECORD +106 -0
  103. awkno-0.1.0.dist-info/WHEEL +5 -0
  104. awkno-0.1.0.dist-info/entry_points.txt +2 -0
  105. awkno-0.1.0.dist-info/licenses/LICENSE +118 -0
  106. awkno-0.1.0.dist-info/top_level.txt +1 -0
awkno/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """awkno — The man page for the Aither World.
2
+
3
+ from awkno import AwknoRegistry
4
+
5
+ registry = AwknoRegistry()
6
+ page = registry.get("awdk")
7
+ print(page.render())
8
+
9
+ Every brick, stack and law in your terminal, offline. No external dependencies,
10
+ no network required. Pages are generated from ecosystem.yaml and the laws corpus,
11
+ committed as data files.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from awkno.corpus import AwknoPage, AwknoRegistry, NotFoundError
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "AwknoRegistry",
22
+ "AwknoPage",
23
+ "NotFoundError",
24
+ "__version__",
25
+ ]
awkno/_doctor.py ADDED
@@ -0,0 +1,139 @@
1
+ """Stack-aware `doctor` for awkno.
2
+
3
+ GENERATED BY gen_aw_doctor.py -- DO NOT EDIT.
4
+ Regenerate it with the generator named above; a hand-edit here is reverted by
5
+ the next run and fails the parity gate.
6
+
7
+ Why a doctor exists at all: the aw* bricks are designed to COMPOSE, so the
8
+ interesting failures live BETWEEN them. "awkno is installed" is not the useful
9
+ fact -- "awkno is installed and the thing it pairs with is not" is. This reports
10
+ the whole stack, not just itself.
11
+
12
+ stdlib only, on purpose: a diagnostic that cannot run because a dependency is
13
+ missing is worthless precisely when you need it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import os
19
+ import shutil
20
+ import sys
21
+
22
+ #: Frozen from AitherOS/config/ecosystem.yaml at generation time. A shipped
23
+ #: package cannot read the registry, and a doctor that guessed at the family
24
+ #: would go stale in silence. Regenerate to update.
25
+ SELF = 'awkno'
26
+ FAMILY = ['awask', 'awask', 'awbac', 'awbrowse', 'awdit', 'awevolve', 'awevolve', 'awfind', 'awgit', 'awgraph', 'awiam', 'awm', 'awmail', 'awnboard', 'awnest', 'awnet', 'awnode', 'awpredict', 'awprism', 'awreason', 'awrecover', 'awrecurse', 'awrelay', 'awrepl', 'awresearch', 'awrun', 'awseal', 'awshare', 'awtunnel']
27
+ PAIRS_WITH = ['awfind', 'awm']
28
+
29
+ #: This brick's OWN config, read out of its source at generation time.
30
+ #: ENV_REQUIRED is `os.environ["X"]` -- absent, that is a KeyError the moment
31
+ #: the line runs. ENV_OPTIONAL is `os.getenv("X")`, which returns None and lets
32
+ #: the caller cope. Only this brick's namespace is listed: reporting the
33
+ #: platform-wide vars it also touches would be noise, and a doctor that floods
34
+ #: gets ignored.
35
+ ENV_REQUIRED = []
36
+ ENV_OPTIONAL = []
37
+
38
+
39
+ def _installed(mod: str) -> "str | None":
40
+ """Version if importable, else None. Never raises -- a broken sibling must
41
+ not take the diagnostic down with it."""
42
+ try:
43
+ if importlib.util.find_spec(mod) is None:
44
+ return None
45
+ except (ImportError, ValueError):
46
+ return None
47
+ try:
48
+ from importlib.metadata import PackageNotFoundError, version
49
+ try:
50
+ return version(mod)
51
+ except PackageNotFoundError:
52
+ return "installed"
53
+ except Exception:
54
+ return "installed"
55
+
56
+
57
+ def report(out=None) -> int:
58
+ """Print the stack picture. 0 = this brick and its pairs are present."""
59
+ out = out or sys.stdout
60
+ print(f"{SELF} doctor", file=out)
61
+
62
+ mine = _installed(SELF)
63
+ print(f" self {SELF} {mine or 'NOT IMPORTABLE'}", file=out)
64
+ shim = shutil.which(SELF)
65
+ print(f" command {shim or 'not on PATH'}", file=out)
66
+
67
+ # The stack. Siblings this brick pairs with are called out separately,
68
+ # because a missing pair is a REASON, while a missing unrelated brick is
69
+ # just a fact about your machine.
70
+ missing_pairs, present = [], []
71
+ for name in FAMILY:
72
+ v = _installed(name)
73
+ if v:
74
+ present.append(name)
75
+ elif name in PAIRS_WITH:
76
+ missing_pairs.append(name)
77
+ print(f" stack {len(present)}/{len(FAMILY)} aw* packages installed",
78
+ file=out)
79
+ if present:
80
+ print(f" {' '.join(sorted(present))}", file=out)
81
+
82
+ missing_req = [v for v in ENV_REQUIRED if not os.environ.get(v)]
83
+ if ENV_REQUIRED or ENV_OPTIONAL:
84
+ have = sum(1 for v in ENV_REQUIRED + ENV_OPTIONAL if os.environ.get(v))
85
+ total = len(ENV_REQUIRED) + len(ENV_OPTIONAL)
86
+ print(f" config {have}/{total} of this brick's own vars set", file=out)
87
+ if missing_req:
88
+ # Not a preference. os.environ[...] raises the moment it runs.
89
+ print(f" MISSING REQUIRED: {' '.join(missing_req)}", file=out)
90
+
91
+ local = _local_checks()
92
+ for line in local:
93
+ print(f" {line}", file=out)
94
+
95
+ if mine is None:
96
+ print(f"\nverdict: {SELF} itself is not importable. Reinstall it before "
97
+ f"anything else here means much.", file=out)
98
+ return 1
99
+ if missing_req:
100
+ print(f"\nverdict: {SELF} is missing required config "
101
+ f"({', '.join(missing_req)}). Those are read with os.environ[...], "
102
+ f"so the code path that needs them raises rather than degrades.",
103
+ file=out)
104
+ return 1
105
+ if missing_pairs:
106
+ print(f"\nverdict: {SELF} works, but pairs with "
107
+ f"{', '.join(sorted(missing_pairs))} which "
108
+ f"{'is' if len(missing_pairs) == 1 else 'are'} not installed. "
109
+ f"That is a capability you are missing, not an error.", file=out)
110
+ return 0
111
+ print(f"\nverdict: {SELF} and everything it pairs with are present.", file=out)
112
+ return 0
113
+
114
+
115
+ def _local_checks() -> "list[str]":
116
+ """Per-brick checks, if this package defines them.
117
+
118
+ Kept as a HOOK rather than generated guesses: the generator knows the family
119
+ from the registry, but it does not know what awkno needs at runtime, and a
120
+ doctor that invented config requirements would be confidently wrong. A
121
+ package supplies `_doctor_local()` returning display lines; absent, the
122
+ stack picture above still stands on its own.
123
+ """
124
+ try:
125
+ mod = importlib.import_module(f"{SELF}.doctor_local")
126
+ except Exception:
127
+ return []
128
+ try:
129
+ return list(mod._doctor_local())
130
+ except Exception as exc: # noqa: BLE001
131
+ return [f"local checks raised {type(exc).__name__}: {exc}"]
132
+
133
+
134
+ def main(argv: "list[str] | None" = None) -> int:
135
+ return report()
136
+
137
+
138
+ if __name__ == "__main__":
139
+ raise SystemExit(main())
awkno/cli.py ADDED
@@ -0,0 +1,355 @@
1
+ """awkno CLI.
2
+
3
+ awkno awdk
4
+ awkno awsh
5
+ awkno law 5
6
+ awkno list
7
+ awkno search design-for-the-silence
8
+ awkno -k silence
9
+ awkno --plain
10
+
11
+ Type awkno with no args to show an overview.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+
22
+ from awkno.corpus import AwknoRegistry, NotFoundError
23
+
24
+
25
+ def pager_render(text: str) -> None:
26
+ """Render text through $PAGER if available, else stdout."""
27
+ pager = os.environ.get("PAGER", "less" if sys.platform != "win32" else "more")
28
+
29
+ if not sys.stdout.isatty():
30
+ print(text)
31
+ return
32
+
33
+ try:
34
+ import subprocess
35
+
36
+ proc = subprocess.Popen(
37
+ pager, stdin=subprocess.PIPE, text=True, bufsize=1024
38
+ )
39
+ try:
40
+ proc.stdin.write(text)
41
+ proc.stdin.close()
42
+ except BrokenPipeError:
43
+ pass
44
+ proc.wait()
45
+ except (FileNotFoundError, OSError):
46
+ print(text)
47
+
48
+
49
+ def show_overview() -> None:
50
+ """Show the overview page."""
51
+ text = """NAME
52
+ awkno — The man page for the Aither World
53
+
54
+ SYNOPSIS
55
+ awkno [TOPIC]
56
+ awkno list
57
+ awkno law <N|SLUG>
58
+ awkno search TERM
59
+ awkno -k TERM
60
+ awkno --plain
61
+ awkno --json
62
+
63
+ DESCRIPTION
64
+ awkno is an offline reference for the Aither World ecosystem. Every brick
65
+ (standalone tool), stack (curated set), and law (learned principle) lives
66
+ here — no browser, no internet connection needed.
67
+
68
+ The registry is built from ecosystem.yaml and the laws corpus at build time
69
+ and committed as data files. After install, the pages are always there.
70
+
71
+ QUICK START
72
+ awkno awdk Show the awdk brick
73
+ awkno awsh Show the awsh brick
74
+ awkno law 5 Show law 5
75
+ awkno list List all topics
76
+ awkno search kubernetes Search for "kubernetes"
77
+
78
+ OPTIONS
79
+ --plain No ANSI formatting
80
+ --json JSON output (for piping)
81
+ -k, --apropos TERM Search for TERM (like man -k)
82
+
83
+ SEE ALSO
84
+ For the full ecosystem, visit https://github.com/Aitherium/awkno
85
+ """
86
+ pager_render(text)
87
+
88
+
89
+ def slugify(text: str) -> str:
90
+ """Reduce a synopsis to a comparable slug."""
91
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
92
+
93
+
94
+ def resolve_law_key(registry: "AwknoRegistry", law_id: str) -> str | None:
95
+ """Map `5` or `design-for-the-silence` onto a law page key.
96
+
97
+ A slug matches when it prefixes the slugified synopsis of exactly ONE law.
98
+ Ambiguous prefixes return None rather than the first hit: a prefix matcher
99
+ that guesses is how `get("nemotron")` came to answer for a model nobody
100
+ registered.
101
+ """
102
+ if law_id.isdigit():
103
+ return f"law-{int(law_id):02d}"
104
+
105
+ want = slugify(law_id)
106
+ if not want:
107
+ return None
108
+
109
+ laws = [t for t in registry.list_topics() if t.startswith("law-")]
110
+
111
+ # The law's OWN slug first. Exact, and it survives a retitle -- the
112
+ # synopsis fallback below does not.
113
+ for topic in laws:
114
+ if slugify(registry.pages[topic].slug or "") == want:
115
+ return topic
116
+
117
+ # Fall back to a unique prefix of the synopsis, for a corpus generated
118
+ # before slugs were carried. Unique or nothing: a prefix matcher that
119
+ # answers with its first hit is fail-open, and returns a confident wrong
120
+ # law for a slug nobody registered.
121
+ hits = [
122
+ topic
123
+ for topic in laws
124
+ if slugify(registry.pages[topic].synopsis).startswith(want)
125
+ ]
126
+ return hits[0] if len(hits) == 1 else None
127
+
128
+
129
+ def _build_parser() -> argparse.ArgumentParser:
130
+ """Build the CLI parser.
131
+
132
+ Separate from main() so the self-test can assert that every form printed
133
+ in the SYNOPSIS actually parses.
134
+ """
135
+ parser = argparse.ArgumentParser(
136
+ description="The man page for the Aither World",
137
+ add_help=False,
138
+ usage="awkno [TOPIC] or awkno COMMAND [OPTIONS]",
139
+ )
140
+
141
+ # nargs="*", not "?": the SYNOPSIS advertises `awkno law 5` and
142
+ # `awkno search TERM`, both of which argparse rejected as
143
+ # "unrecognized arguments" under nargs="?" -- only the quoted
144
+ # `awkno "law 5"` worked, which nobody would type.
145
+ parser.add_argument(
146
+ "topic", nargs="*", help="Topic, brick, stack, law, or command"
147
+ )
148
+ parser.add_argument(
149
+ "--plain", action="store_true", help="Plain text, no ANSI formatting"
150
+ )
151
+ parser.add_argument(
152
+ "--json", action="store_true", help="JSON output for piping"
153
+ )
154
+ parser.add_argument(
155
+ "-k", "--apropos", dest="search", help="Search for term (like man -k)"
156
+ )
157
+ parser.add_argument(
158
+ "--self-test", action="store_true", help="Run self-test"
159
+ )
160
+
161
+ return parser
162
+
163
+
164
+ def main() -> None:
165
+ """Main CLI entry point."""
166
+ # GENERATED doctor intercept (gen_aw_doctor.py) -- do not edit
167
+ _dv = locals().get("argv")
168
+ if (_dv if _dv is not None else __import__("sys").argv[1:])[:1] == ["doctor"]:
169
+ from ._doctor import report
170
+ return report()
171
+ parser = _build_parser()
172
+ args = parser.parse_args()
173
+ topic = " ".join(args.topic).strip()
174
+
175
+ if args.self_test:
176
+ _self_test()
177
+ return
178
+
179
+ registry = AwknoRegistry()
180
+
181
+ if not topic and not args.search:
182
+ show_overview()
183
+ return
184
+
185
+ if args.search:
186
+ results = registry.search(args.search)
187
+ if not results:
188
+ print(f"No matches for '{args.search}'")
189
+ return
190
+ if args.json:
191
+ output = [
192
+ {"topic": page.topic, "score": score}
193
+ for page, score in results[:10]
194
+ ]
195
+ print(json.dumps(output))
196
+ else:
197
+ print(f"\n[{args.search}]\n")
198
+ for page, score in results[:10]:
199
+ print(f" {page.topic:<20} {page.synopsis}")
200
+ return
201
+
202
+ topic_lower = topic.lower()
203
+
204
+ if topic_lower == "list":
205
+ topics = registry.list_topics()
206
+ categories = {
207
+ "brick": [],
208
+ "stack": [],
209
+ "law": [],
210
+ "topic": [],
211
+ }
212
+ for t in topics:
213
+ page = registry.pages[t]
214
+ if page.category not in categories:
215
+ categories[page.category] = []
216
+ categories[page.category].append(t)
217
+
218
+ print()
219
+ for cat in ["brick", "stack", "law", "topic"]:
220
+ if categories[cat]:
221
+ print(f"{cat.upper()}S ({len(categories[cat])})")
222
+ for name in sorted(categories[cat]):
223
+ page = registry.pages[name]
224
+ print(f" {name:<20} {page.synopsis}")
225
+ print()
226
+ return
227
+
228
+ if topic_lower.startswith("law"):
229
+ parts = topic.split()
230
+ if len(parts) < 2:
231
+ print("Usage: awkno law <N|SLUG>")
232
+ return
233
+ law_id = parts[1]
234
+ law_key = resolve_law_key(registry, law_id)
235
+ if law_key is None:
236
+ print(
237
+ f"Law '{law_id}' not found or ambiguous. "
238
+ "Try: awkno law 5, awkno law design-for-the-silence"
239
+ )
240
+ return
241
+ try:
242
+ page = registry.get(law_key)
243
+ except NotFoundError:
244
+ print(f"Law '{law_id}' not found. Try: awkno law 1, awkno law 5, etc.")
245
+ return
246
+ else:
247
+ try:
248
+ page = registry.get(topic)
249
+ except NotFoundError:
250
+ print(f"Topic '{topic}' not found")
251
+ print(f"Try: awkno list, awkno -k '{topic}'")
252
+ return
253
+
254
+ if args.json:
255
+ print(json.dumps(page.to_dict()))
256
+ else:
257
+ text = page.render(plain=args.plain)
258
+ pager_render(text)
259
+
260
+
261
+ def _self_test() -> None:
262
+ """Run self-test (pure, no external service needed)."""
263
+ from awkno.corpus import AwknoPage, AwknoRegistry
264
+
265
+ page = AwknoPage(
266
+ topic="test",
267
+ category="topic",
268
+ synopsis="A test page",
269
+ description="This is a test",
270
+ adopt="Adopt this test",
271
+ status="test",
272
+ )
273
+
274
+ assert page.topic == "test"
275
+ assert page.render()
276
+ assert page.to_dict()
277
+
278
+ # --- the SYNOPSIS is a promise; assert it ------------------------------
279
+ #
280
+ # This block exists because every two-word form printed in this tool's own
281
+ # SYNOPSIS -- `awkno law 5`, `awkno search TERM` -- was rejected by argparse
282
+ # as "unrecognized arguments" in a SHIPPED release on PyPI, and `<N|SLUG>`
283
+ # advertised a slug lookup the generator computed and discarded, so no slug
284
+ # ever resolved. The self-test above could not see any of it: it asserted
285
+ # that a dataclass renders. A tool that documents an invocation it cannot
286
+ # perform is a broken tool that reads as an authoritative one.
287
+ parser = _build_parser()
288
+
289
+ for argv in (["law", "5"], ["search", "silence"], ["list"], ["awdk"]):
290
+ try:
291
+ parsed = parser.parse_args(argv)
292
+ except SystemExit: # argparse exits rather than raising
293
+ raise AssertionError(
294
+ f"SYNOPSIS form `awkno {' '.join(argv)}` was REJECTED by the parser"
295
+ ) from None
296
+ joined = " ".join(parsed.topic).strip()
297
+ assert joined == " ".join(argv), f"SYNOPSIS form {argv} did not parse"
298
+
299
+ # the quoted form callers may already be using must keep working
300
+ assert " ".join(parser.parse_args(["law 5"]).topic).strip() == "law 5"
301
+
302
+ registry = AwknoRegistry()
303
+
304
+ # numeric and slug lookups both resolve, and to the SAME law
305
+ by_num = resolve_law_key(registry, "5")
306
+ by_slug = resolve_law_key(registry, "design-for-the-silence")
307
+ assert by_num == "law-05", f"numeric law lookup gave {by_num}"
308
+ assert by_slug == "law-05", f"slug law lookup gave {by_slug}"
309
+ assert registry.get(by_num).synopsis
310
+
311
+ # every law is reachable by BOTH spellings -- a resolver that answers for
312
+ # law 5 and nothing else passes a single-case test while being useless
313
+ law_topics = [t for t in registry.list_topics() if t.startswith("law-")]
314
+ assert len(law_topics) >= 19, f"only {len(law_topics)} laws in the corpus"
315
+ for topic in law_topics:
316
+ number = topic.split("-", 1)[1].lstrip("0") or "0"
317
+ assert resolve_law_key(registry, number) == topic, f"{topic} not reachable by number"
318
+ slug = slugify(registry.pages[topic].synopsis)
319
+ assert resolve_law_key(registry, slug) == topic, f"{topic} not reachable by slug"
320
+ own = registry.pages[topic].slug
321
+ assert own, f"{topic} carries no slug -- regenerate the corpus"
322
+ assert resolve_law_key(registry, own) == topic, f"{topic} not reachable by its own slug"
323
+
324
+ # A RETITLED law: slug and synopsis diverge, which is the only case the
325
+ # exact-slug branch exists for. Without this the branch is dead weight --
326
+ # every current law's filename slug happens to prefix its own synopsis, so
327
+ # deleting the branch leaves the suite green and the lookup silently
328
+ # dependent on a coincidence that a single retitle ends.
329
+ registry.pages["law-99"] = AwknoPage(
330
+ topic="law-99",
331
+ category="law",
332
+ synopsis="Completely different words after a retitle",
333
+ description="Law #99",
334
+ slug="the-original-filename-slug",
335
+ )
336
+ try:
337
+ assert resolve_law_key(registry, "the-original-filename-slug") == "law-99", (
338
+ "a retitled law is unreachable by its own slug"
339
+ )
340
+ assert resolve_law_key(registry, "completely-different-words") == "law-99"
341
+ finally:
342
+ del registry.pages["law-99"]
343
+
344
+ # fail closed: nonsense and ambiguity resolve to nothing, never to law 1.
345
+ # An ambiguous prefix returning its first hit is the trap that made a
346
+ # licence lookup answer for models nobody had registered.
347
+ assert resolve_law_key(registry, "no-such-law-anywhere") is None
348
+ assert resolve_law_key(registry, "") is None
349
+ assert resolve_law_key(registry, "a") is None, "bare prefix must be ambiguous, not law-01"
350
+
351
+ print(f"[OK] awkno self-test passed ({len(law_topics)} laws, both spellings)")
352
+
353
+
354
+ if __name__ == "__main__":
355
+ main()