dotscope 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 (114) hide show
  1. dotscope/.scope +63 -0
  2. dotscope/__init__.py +3 -0
  3. dotscope/absorber.py +390 -0
  4. dotscope/assertions.py +128 -0
  5. dotscope/ast_analyzer.py +2 -0
  6. dotscope/backtest.py +2 -0
  7. dotscope/bench.py +141 -0
  8. dotscope/budget.py +3 -0
  9. dotscope/cache.py +2 -0
  10. dotscope/check/__init__.py +1 -0
  11. dotscope/check/acknowledge.py +2 -0
  12. dotscope/check/checker.py +3 -0
  13. dotscope/check/checks/__init__.py +1 -0
  14. dotscope/check/checks/antipattern.py +2 -0
  15. dotscope/check/checks/boundary.py +2 -0
  16. dotscope/check/checks/contracts.py +3 -0
  17. dotscope/check/checks/direction.py +2 -0
  18. dotscope/check/checks/intent.py +2 -0
  19. dotscope/check/checks/stability.py +2 -0
  20. dotscope/check/constraints.py +2 -0
  21. dotscope/check/models.py +15 -0
  22. dotscope/cli.py +1447 -0
  23. dotscope/composer.py +147 -0
  24. dotscope/constants.py +45 -0
  25. dotscope/context.py +60 -0
  26. dotscope/counterfactual.py +180 -0
  27. dotscope/debug.py +220 -0
  28. dotscope/discovery.py +104 -0
  29. dotscope/formatter.py +157 -0
  30. dotscope/graph.py +3 -0
  31. dotscope/health.py +212 -0
  32. dotscope/help.py +204 -0
  33. dotscope/history.py +6 -0
  34. dotscope/hooks.py +2 -0
  35. dotscope/ingest.py +858 -0
  36. dotscope/intent.py +618 -0
  37. dotscope/lessons.py +223 -0
  38. dotscope/matcher.py +104 -0
  39. dotscope/mcp_server.py +1081 -0
  40. dotscope/models/.scope +45 -0
  41. dotscope/models/__init__.py +7 -0
  42. dotscope/models/core.py +288 -0
  43. dotscope/models/history.py +73 -0
  44. dotscope/models/intent.py +213 -0
  45. dotscope/models/passes.py +58 -0
  46. dotscope/models/state.py +250 -0
  47. dotscope/models.py +9 -0
  48. dotscope/near_miss.py +3 -0
  49. dotscope/onboarding.py +2 -0
  50. dotscope/parser.py +387 -0
  51. dotscope/passes/.scope +105 -0
  52. dotscope/passes/__init__.py +1 -0
  53. dotscope/passes/ast_analyzer.py +508 -0
  54. dotscope/passes/backtest.py +198 -0
  55. dotscope/passes/budget_allocator.py +164 -0
  56. dotscope/passes/convention_compliance.py +40 -0
  57. dotscope/passes/convention_discovery.py +247 -0
  58. dotscope/passes/convention_parser.py +223 -0
  59. dotscope/passes/graph_builder.py +299 -0
  60. dotscope/passes/history_miner.py +336 -0
  61. dotscope/passes/incremental.py +149 -0
  62. dotscope/passes/lang/__init__.py +38 -0
  63. dotscope/passes/lang/_base.py +20 -0
  64. dotscope/passes/lang/_treesitter.py +93 -0
  65. dotscope/passes/lang/go.py +333 -0
  66. dotscope/passes/lang/javascript.py +348 -0
  67. dotscope/passes/lazy.py +152 -0
  68. dotscope/passes/semantic_diff.py +160 -0
  69. dotscope/passes/sentinel/__init__.py +1 -0
  70. dotscope/passes/sentinel/acknowledge.py +222 -0
  71. dotscope/passes/sentinel/checker.py +383 -0
  72. dotscope/passes/sentinel/checks/__init__.py +1 -0
  73. dotscope/passes/sentinel/checks/antipattern.py +84 -0
  74. dotscope/passes/sentinel/checks/boundary.py +46 -0
  75. dotscope/passes/sentinel/checks/contracts.py +148 -0
  76. dotscope/passes/sentinel/checks/convention.py +54 -0
  77. dotscope/passes/sentinel/checks/direction.py +71 -0
  78. dotscope/passes/sentinel/checks/intent.py +207 -0
  79. dotscope/passes/sentinel/checks/stability.py +66 -0
  80. dotscope/passes/sentinel/checks/voice.py +108 -0
  81. dotscope/passes/sentinel/constraints.py +472 -0
  82. dotscope/passes/sentinel/line_filter.py +88 -0
  83. dotscope/passes/sentinel/models.py +15 -0
  84. dotscope/passes/virtual.py +239 -0
  85. dotscope/passes/voice.py +162 -0
  86. dotscope/passes/voice_defaults.py +28 -0
  87. dotscope/passes/voice_discovery.py +245 -0
  88. dotscope/paths.py +32 -0
  89. dotscope/progress.py +44 -0
  90. dotscope/regression.py +147 -0
  91. dotscope/resolver.py +203 -0
  92. dotscope/scanner.py +246 -0
  93. dotscope/sessions.py +2 -0
  94. dotscope/storage/.scope +64 -0
  95. dotscope/storage/__init__.py +1 -0
  96. dotscope/storage/cache.py +114 -0
  97. dotscope/storage/claude_hooks.py +119 -0
  98. dotscope/storage/git_hooks.py +277 -0
  99. dotscope/storage/incremental_state.py +61 -0
  100. dotscope/storage/mcp_config.py +98 -0
  101. dotscope/storage/near_miss.py +183 -0
  102. dotscope/storage/onboarding.py +150 -0
  103. dotscope/storage/session_manager.py +195 -0
  104. dotscope/storage/timing.py +84 -0
  105. dotscope/timing.py +2 -0
  106. dotscope/tokens.py +53 -0
  107. dotscope/utility.py +123 -0
  108. dotscope/virtual.py +3 -0
  109. dotscope/visibility.py +664 -0
  110. dotscope-0.1.0.dist-info/METADATA +50 -0
  111. dotscope-0.1.0.dist-info/RECORD +114 -0
  112. dotscope-0.1.0.dist-info/WHEEL +4 -0
  113. dotscope-0.1.0.dist-info/entry_points.txt +3 -0
  114. dotscope-0.1.0.dist-info/licenses/LICENSE +21 -0
dotscope/intent.py ADDED
@@ -0,0 +1,618 @@
1
+ """Architectural intent: declare where the codebase is trying to go.
2
+
3
+ intent.yaml lives at repo root alongside .scopes:
4
+
5
+ intents:
6
+ - directive: decouple
7
+ modules: [auth/, payments/]
8
+ reason: "Auth should not depend on payment internals"
9
+ set_by: developer
10
+ set_at: 2026-03-20
11
+ """
12
+
13
+ import hashlib
14
+ import os
15
+ from typing import List, Optional
16
+
17
+ from .check.models import ConventionRule, IntentDirective
18
+
19
+
20
+ def load_intents(repo_root: str) -> List[IntentDirective]:
21
+ """Load intent.yaml from repo root."""
22
+ path = os.path.join(repo_root, "intent.yaml")
23
+ if not os.path.exists(path):
24
+ return []
25
+
26
+ with open(path, "r", encoding="utf-8") as f:
27
+ text = f.read()
28
+
29
+ raw_intents = _parse_intent_list(text)
30
+ results = []
31
+
32
+ _valid_directives = ("decouple", "deprecate", "freeze", "consolidate")
33
+ for item in raw_intents:
34
+ directive = item.get("directive", "")
35
+ if directive not in _valid_directives:
36
+ if directive:
37
+ import sys
38
+ print(
39
+ f"dotscope: unknown directive '{directive}' in intent.yaml, skipping",
40
+ file=sys.stderr,
41
+ )
42
+ continue
43
+
44
+ modules = _to_list(item.get("modules", []))
45
+ files = _to_list(item.get("files", []))
46
+ reason = item.get("reason", "").strip('"').strip("'")
47
+ slug = hashlib.md5(
48
+ f"{directive}:{','.join(modules + files)}".encode()
49
+ ).hexdigest()[:8]
50
+
51
+ results.append(IntentDirective(
52
+ directive=directive,
53
+ modules=modules,
54
+ files=files,
55
+ reason=reason,
56
+ replacement=item.get("replacement"),
57
+ target=item.get("target"),
58
+ set_by=item.get("set_by", "developer"),
59
+ set_at=item.get("set_at", ""),
60
+ id=slug,
61
+ ))
62
+
63
+ return results
64
+
65
+
66
+ def _parse_intent_list(text: str) -> List[dict]:
67
+ """Parse the intents list from intent.yaml.
68
+
69
+ Handles the specific pattern:
70
+ intents:
71
+ - directive: freeze
72
+ modules: [core/]
73
+ reason: "Stable"
74
+ """
75
+ items = []
76
+ current: Optional[dict] = None
77
+ in_intents = False
78
+
79
+ for line in text.splitlines():
80
+ stripped = line.strip()
81
+ if not stripped or stripped.startswith("#"):
82
+ continue
83
+
84
+ if stripped == "intents:" or stripped.startswith("intents:"):
85
+ in_intents = True
86
+ continue
87
+
88
+ if not in_intents:
89
+ continue
90
+
91
+ indent = len(line) - len(line.lstrip())
92
+
93
+ if stripped.startswith("- "):
94
+ # New list item
95
+ if current is not None:
96
+ items.append(current)
97
+ current = {}
98
+ # Parse the key-value on the same line as the dash
99
+ kv = stripped[2:].strip()
100
+ if ":" in kv:
101
+ k, v = kv.split(":", 1)
102
+ current[k.strip()] = _parse_value(v.strip())
103
+ elif current is not None and ":" in stripped and indent >= 4:
104
+ k, v = stripped.split(":", 1)
105
+ current[k.strip()] = _parse_value(v.strip())
106
+ elif indent == 0 and not stripped.startswith("-"):
107
+ # New top-level key — we've left the intents block
108
+ break
109
+
110
+ if current is not None:
111
+ items.append(current)
112
+
113
+ return items
114
+
115
+
116
+ def _parse_value(val: str) -> object:
117
+ """Parse a YAML value: inline list, quoted string, or plain string."""
118
+ if val.startswith("[") and val.endswith("]"):
119
+ inner = val[1:-1]
120
+ return [v.strip().strip('"').strip("'") for v in inner.split(",") if v.strip()]
121
+ if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
122
+ return val[1:-1]
123
+ return val
124
+
125
+
126
+ def save_intents(repo_root: str, intents: List[IntentDirective]) -> str:
127
+ """Write intents to intent.yaml."""
128
+ lines = ["intents:"]
129
+ for intent in intents:
130
+ lines.append(f" - directive: {intent.directive}")
131
+ if intent.modules:
132
+ items = ", ".join(intent.modules)
133
+ lines.append(f" modules: [{items}]")
134
+ if intent.files:
135
+ items = ", ".join(intent.files)
136
+ lines.append(f" files: [{items}]")
137
+ if intent.replacement:
138
+ lines.append(f" replacement: {intent.replacement}")
139
+ if intent.target:
140
+ lines.append(f" target: {intent.target}")
141
+ if intent.reason:
142
+ lines.append(f' reason: "{intent.reason}"')
143
+ lines.append(f" set_by: {intent.set_by}")
144
+ if intent.set_at:
145
+ lines.append(f" set_at: {intent.set_at}")
146
+
147
+ path = os.path.join(repo_root, "intent.yaml")
148
+ content = "\n".join(lines) + "\n"
149
+ with open(path, "w", encoding="utf-8") as f:
150
+ f.write(content)
151
+ return path
152
+
153
+
154
+ def add_intent(
155
+ repo_root: str,
156
+ directive: str,
157
+ targets: List[str],
158
+ reason: str = "",
159
+ replacement: Optional[str] = None,
160
+ target: Optional[str] = None,
161
+ ) -> IntentDirective:
162
+ """Add a new intent and persist to intent.yaml."""
163
+ from datetime import date
164
+
165
+ existing = load_intents(repo_root)
166
+
167
+ # Classify targets as modules (end with /) or files
168
+ modules = [t for t in targets if t.endswith("/")]
169
+ files = [t for t in targets if not t.endswith("/")]
170
+
171
+ slug = hashlib.md5(
172
+ f"{directive}:{','.join(modules + files)}".encode()
173
+ ).hexdigest()[:8]
174
+
175
+ intent = IntentDirective(
176
+ directive=directive,
177
+ modules=modules,
178
+ files=files,
179
+ reason=reason,
180
+ replacement=replacement,
181
+ target=target,
182
+ set_by="developer",
183
+ set_at=str(date.today()),
184
+ id=slug,
185
+ )
186
+
187
+ existing.append(intent)
188
+ save_intents(repo_root, existing)
189
+ return intent
190
+
191
+
192
+ def remove_intent(repo_root: str, intent_id: str) -> bool:
193
+ """Remove an intent by its ID."""
194
+ existing = load_intents(repo_root)
195
+ filtered = [i for i in existing if i.id != intent_id]
196
+ if len(filtered) == len(existing):
197
+ return False
198
+ save_intents(repo_root, filtered)
199
+ return True
200
+
201
+
202
+ def _to_list(val: object) -> List[str]:
203
+ if isinstance(val, list):
204
+ return [str(v) for v in val]
205
+ if isinstance(val, str) and val:
206
+ return [val]
207
+ return []
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Convention loading/saving
212
+ # ---------------------------------------------------------------------------
213
+
214
+ def load_conventions(repo_root: str) -> List[ConventionRule]:
215
+ """Load conventions from intent.yaml."""
216
+ path = os.path.join(repo_root, "intent.yaml")
217
+ if not os.path.exists(path):
218
+ return []
219
+
220
+ with open(path, "r", encoding="utf-8") as f:
221
+ text = f.read()
222
+
223
+ raw = _parse_conventions_list(text)
224
+ results = []
225
+
226
+ for item in raw:
227
+ name = item.get("name", "")
228
+ if not name:
229
+ continue
230
+
231
+ match_criteria = {}
232
+ match_block = item.get("match", {})
233
+ if isinstance(match_block, dict):
234
+ match_criteria = match_block
235
+ # Legacy flat format: treat as all_of
236
+ if not match_criteria.get("any_of") and not match_criteria.get("all_of"):
237
+ if match_criteria:
238
+ match_criteria = {"all_of": [match_criteria]}
239
+
240
+ rules = item.get("rules", {})
241
+ if not isinstance(rules, dict):
242
+ rules = {}
243
+
244
+ results.append(ConventionRule(
245
+ name=name,
246
+ source=item.get("source", "discovered"),
247
+ match_criteria=match_criteria,
248
+ rules=rules,
249
+ description=item.get("description", ""),
250
+ compliance=float(item.get("compliance", 1.0)),
251
+ last_checked=item.get("last_checked"),
252
+ ))
253
+
254
+ return results
255
+
256
+
257
+ def save_conventions(repo_root: str, conventions: List[ConventionRule]) -> str:
258
+ """Write conventions to intent.yaml, preserving existing intents."""
259
+ path = os.path.join(repo_root, "intent.yaml")
260
+
261
+ # Preserve existing content before the conventions block
262
+ existing = ""
263
+ if os.path.exists(path):
264
+ with open(path, "r", encoding="utf-8") as f:
265
+ existing = f.read()
266
+
267
+ # Strip any existing conventions block
268
+ lines_out = []
269
+ in_conventions = False
270
+ for line in existing.splitlines():
271
+ stripped = line.strip()
272
+ if stripped == "conventions:" or stripped.startswith("conventions:"):
273
+ in_conventions = True
274
+ continue
275
+ if in_conventions:
276
+ indent = len(line) - len(line.lstrip())
277
+ if indent == 0 and stripped and not stripped.startswith("-"):
278
+ in_conventions = False
279
+ else:
280
+ continue
281
+ if not in_conventions:
282
+ lines_out.append(line)
283
+
284
+ # Remove trailing blank lines
285
+ while lines_out and not lines_out[-1].strip():
286
+ lines_out.pop()
287
+
288
+ # Append conventions block
289
+ if conventions:
290
+ if lines_out:
291
+ lines_out.append("")
292
+ lines_out.append("conventions:")
293
+ for conv in conventions:
294
+ lines_out.append(f' - name: "{conv.name}"')
295
+ lines_out.append(f" source: {conv.source}")
296
+ if conv.match_criteria:
297
+ lines_out.append(" match:")
298
+ for key in ("any_of", "all_of"):
299
+ criteria_list = conv.match_criteria.get(key)
300
+ if criteria_list:
301
+ lines_out.append(f" {key}:")
302
+ for criterion in criteria_list:
303
+ if isinstance(criterion, dict):
304
+ for ck, cv in criterion.items():
305
+ if isinstance(cv, list):
306
+ items = ", ".join(cv)
307
+ lines_out.append(f" - {ck}: [{items}]")
308
+ else:
309
+ lines_out.append(f' - {ck}: "{cv}"')
310
+ if conv.rules:
311
+ lines_out.append(" rules:")
312
+ for rk, rv in conv.rules.items():
313
+ if isinstance(rv, list):
314
+ items = ", ".join(str(v) for v in rv)
315
+ lines_out.append(f" {rk}: [{items}]")
316
+ else:
317
+ lines_out.append(f" {rk}: {rv}")
318
+ if conv.description:
319
+ lines_out.append(f' description: "{conv.description}"')
320
+ lines_out.append(f" compliance: {conv.compliance}")
321
+ if conv.last_checked:
322
+ lines_out.append(f" last_checked: {conv.last_checked}")
323
+
324
+ content = "\n".join(lines_out) + "\n"
325
+ with open(path, "w", encoding="utf-8") as f:
326
+ f.write(content)
327
+ return path
328
+
329
+
330
+ def _parse_conventions_list(text: str) -> List[dict]:
331
+ """Parse the conventions list from intent.yaml.
332
+
333
+ Handles nested structure:
334
+ conventions:
335
+ - name: "REST Controller"
336
+ source: discovered
337
+ match:
338
+ any_of:
339
+ - has_decorator: "@app.route"
340
+ all_of:
341
+ - not_imports: [sqlalchemy]
342
+ rules:
343
+ prohibited_imports: [sqlalchemy]
344
+ description: "..."
345
+ compliance: 1.0
346
+ """
347
+ items: List[dict] = []
348
+ current: Optional[dict] = None
349
+ in_conventions = False
350
+ current_subkey = None # "match", "rules"
351
+ current_subsubkey = None # "any_of", "all_of"
352
+ current_list: Optional[list] = None
353
+
354
+ for line in text.splitlines():
355
+ stripped = line.strip()
356
+ if not stripped or stripped.startswith("#"):
357
+ continue
358
+
359
+ if stripped == "conventions:" or stripped.startswith("conventions:"):
360
+ in_conventions = True
361
+ continue
362
+
363
+ if not in_conventions:
364
+ continue
365
+
366
+ indent = len(line) - len(line.lstrip())
367
+
368
+ # New top-level key exits conventions block
369
+ if indent == 0 and not stripped.startswith("-"):
370
+ break
371
+
372
+ # New convention item (indent 2, starts with "- ")
373
+ if indent <= 2 and stripped.startswith("- "):
374
+ if current is not None:
375
+ items.append(current)
376
+ current = {}
377
+ current_subkey = None
378
+ current_subsubkey = None
379
+ current_list = None
380
+ kv = stripped[2:].strip()
381
+ if ":" in kv:
382
+ k, v = kv.split(":", 1)
383
+ current[k.strip()] = _parse_value(v.strip())
384
+ continue
385
+
386
+ if current is None:
387
+ continue
388
+
389
+ # Indent 4: top-level keys of current convention
390
+ if indent == 4 and ":" in stripped and not stripped.startswith("-"):
391
+ k, v = stripped.split(":", 1)
392
+ k = k.strip()
393
+ v = v.strip()
394
+ if k in ("match", "rules"):
395
+ current_subkey = k
396
+ current_subsubkey = None
397
+ current_list = None
398
+ if v:
399
+ current[k] = _parse_value(v)
400
+ else:
401
+ current.setdefault(k, {})
402
+ else:
403
+ current_subkey = None
404
+ current_subsubkey = None
405
+ current[k] = _parse_value(v)
406
+ continue
407
+
408
+ # Indent 6: sub-keys of match or rules
409
+ if indent == 6 and current_subkey and ":" in stripped and not stripped.startswith("-"):
410
+ k, v = stripped.split(":", 1)
411
+ k = k.strip()
412
+ v = v.strip()
413
+ block = current.setdefault(current_subkey, {})
414
+ if k in ("any_of", "all_of"):
415
+ current_subsubkey = k
416
+ if v:
417
+ block[k] = _parse_value(v)
418
+ else:
419
+ block.setdefault(k, [])
420
+ current_list = block.get(k)
421
+ else:
422
+ current_subsubkey = None
423
+ current_list = None
424
+ block[k] = _parse_value(v)
425
+ continue
426
+
427
+ # Indent 8: list items within any_of/all_of
428
+ if indent == 8 and stripped.startswith("- ") and current_subsubkey:
429
+ kv = stripped[2:].strip()
430
+ if ":" in kv:
431
+ k, v = kv.split(":", 1)
432
+ criterion = {k.strip(): _parse_value(v.strip())}
433
+ block = current.get(current_subkey, {})
434
+ lst = block.setdefault(current_subsubkey, [])
435
+ lst.append(criterion)
436
+ continue
437
+
438
+ if current is not None:
439
+ items.append(current)
440
+
441
+ return items
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # Voice loading/saving
446
+ # ---------------------------------------------------------------------------
447
+
448
+ def load_voice_config(repo_root: str) -> Optional[dict]:
449
+ """Load voice config from intent.yaml.
450
+
451
+ Returns dict with mode, rules, stats, enforce, or None if no voice block.
452
+ """
453
+ path = os.path.join(repo_root, "intent.yaml")
454
+ if not os.path.exists(path):
455
+ return None
456
+
457
+ with open(path, "r", encoding="utf-8") as f:
458
+ text = f.read()
459
+
460
+ return _parse_voice_block(text)
461
+
462
+
463
+ def save_voice_config(repo_root: str, voice) -> str:
464
+ """Write voice config to intent.yaml, preserving intents and conventions."""
465
+ path = os.path.join(repo_root, "intent.yaml")
466
+
467
+ existing = ""
468
+ if os.path.exists(path):
469
+ with open(path, "r", encoding="utf-8") as f:
470
+ existing = f.read()
471
+
472
+ # Strip any existing voice block
473
+ lines_out = []
474
+ in_voice = False
475
+ for line in existing.splitlines():
476
+ stripped = line.strip()
477
+ if stripped == "voice:" or stripped.startswith("voice:"):
478
+ in_voice = True
479
+ continue
480
+ if in_voice:
481
+ indent_n = len(line) - len(line.lstrip())
482
+ if indent_n == 0 and stripped and not stripped.startswith("-"):
483
+ in_voice = False
484
+ else:
485
+ continue
486
+ if not in_voice:
487
+ lines_out.append(line)
488
+
489
+ # Remove trailing blank lines
490
+ while lines_out and not lines_out[-1].strip():
491
+ lines_out.pop()
492
+
493
+ # Append voice block
494
+ if lines_out:
495
+ lines_out.append("")
496
+ lines_out.append("voice:")
497
+ lines_out.append(f" mode: {voice.mode}")
498
+
499
+ if voice.rules:
500
+ for key, val in voice.rules.items():
501
+ lines_out.append(f" {key}: |")
502
+ for vline in val.strip().splitlines():
503
+ lines_out.append(f" {vline}")
504
+
505
+ if voice.enforce:
506
+ lines_out.append(" enforce:")
507
+ for key, val in voice.enforce.items():
508
+ if val is False:
509
+ lines_out.append(f" {key}: false")
510
+ else:
511
+ lines_out.append(f' {key}: "{val}"')
512
+
513
+ if voice.stats:
514
+ lines_out.append(" stats:")
515
+ for key, val in voice.stats.items():
516
+ if val is None:
517
+ lines_out.append(f" {key}: null")
518
+ elif isinstance(val, str):
519
+ lines_out.append(f' {key}: "{val}"')
520
+ else:
521
+ lines_out.append(f" {key}: {val}")
522
+
523
+ content = "\n".join(lines_out) + "\n"
524
+ os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True)
525
+ with open(path, "w", encoding="utf-8") as f:
526
+ f.write(content)
527
+ return path
528
+
529
+
530
+ def _parse_voice_block(text: str) -> Optional[dict]:
531
+ """Parse the voice block from intent.yaml text."""
532
+ in_voice = False
533
+ result = {"mode": "adaptive", "rules": {}, "stats": {}, "enforce": {}}
534
+ current_key = None
535
+ current_section = None
536
+ multiline_val = []
537
+
538
+ for line in text.splitlines():
539
+ stripped = line.strip()
540
+
541
+ if stripped == "voice:" or stripped.startswith("voice:"):
542
+ in_voice = True
543
+ continue
544
+
545
+ if not in_voice:
546
+ continue
547
+
548
+ indent_n = len(line) - len(line.lstrip())
549
+ if indent_n == 0 and stripped and not stripped.startswith("-"):
550
+ break
551
+
552
+ if not stripped or stripped.startswith("#"):
553
+ continue
554
+
555
+ # Flush multiline value
556
+ if indent_n == 4 and current_key and multiline_val and current_section == "rules":
557
+ result["rules"][current_key] = "\n".join(multiline_val)
558
+ current_key = None
559
+ multiline_val = []
560
+
561
+ # Indent 2: top-level voice keys
562
+ if indent_n == 2 and ":" in stripped:
563
+ key, _, val = stripped.partition(":")
564
+ key = key.strip()
565
+ val = val.strip()
566
+
567
+ if key == "mode":
568
+ result["mode"] = val.strip('"').strip("'")
569
+ elif key == "enforce":
570
+ current_section = "enforce"
571
+ current_key = None
572
+ elif key == "stats":
573
+ current_section = "stats"
574
+ current_key = None
575
+ elif val == "|":
576
+ current_section = "rules"
577
+ current_key = key
578
+ multiline_val = []
579
+ elif val:
580
+ result["rules"][key] = val.strip('"').strip("'")
581
+ current_section = "rules"
582
+ current_key = None
583
+ continue
584
+
585
+ # Indent 4: enforce/stats values or multiline continuation
586
+ if indent_n == 4 and ":" in stripped:
587
+ key, _, val = stripped.partition(":")
588
+ key = key.strip()
589
+ val = val.strip().strip('"').strip("'")
590
+
591
+ if current_section == "enforce":
592
+ if val == "false":
593
+ result["enforce"][key] = False
594
+ else:
595
+ result["enforce"][key] = val
596
+ elif current_section == "stats":
597
+ if val == "null":
598
+ result["stats"][key] = None
599
+ else:
600
+ try:
601
+ result["stats"][key] = float(val)
602
+ except ValueError:
603
+ result["stats"][key] = val
604
+ continue
605
+
606
+ # Multiline rule continuation
607
+ if indent_n >= 4 and current_key and current_section == "rules":
608
+ multiline_val.append(stripped)
609
+ continue
610
+
611
+ # Flush final multiline value
612
+ if current_key and multiline_val and current_section == "rules":
613
+ result["rules"][current_key] = "\n".join(multiline_val)
614
+
615
+ if not in_voice:
616
+ return None
617
+
618
+ return result