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.
- dotscope/.scope +63 -0
- dotscope/__init__.py +3 -0
- dotscope/absorber.py +390 -0
- dotscope/assertions.py +128 -0
- dotscope/ast_analyzer.py +2 -0
- dotscope/backtest.py +2 -0
- dotscope/bench.py +141 -0
- dotscope/budget.py +3 -0
- dotscope/cache.py +2 -0
- dotscope/check/__init__.py +1 -0
- dotscope/check/acknowledge.py +2 -0
- dotscope/check/checker.py +3 -0
- dotscope/check/checks/__init__.py +1 -0
- dotscope/check/checks/antipattern.py +2 -0
- dotscope/check/checks/boundary.py +2 -0
- dotscope/check/checks/contracts.py +3 -0
- dotscope/check/checks/direction.py +2 -0
- dotscope/check/checks/intent.py +2 -0
- dotscope/check/checks/stability.py +2 -0
- dotscope/check/constraints.py +2 -0
- dotscope/check/models.py +15 -0
- dotscope/cli.py +1447 -0
- dotscope/composer.py +147 -0
- dotscope/constants.py +45 -0
- dotscope/context.py +60 -0
- dotscope/counterfactual.py +180 -0
- dotscope/debug.py +220 -0
- dotscope/discovery.py +104 -0
- dotscope/formatter.py +157 -0
- dotscope/graph.py +3 -0
- dotscope/health.py +212 -0
- dotscope/help.py +204 -0
- dotscope/history.py +6 -0
- dotscope/hooks.py +2 -0
- dotscope/ingest.py +858 -0
- dotscope/intent.py +618 -0
- dotscope/lessons.py +223 -0
- dotscope/matcher.py +104 -0
- dotscope/mcp_server.py +1081 -0
- dotscope/models/.scope +45 -0
- dotscope/models/__init__.py +7 -0
- dotscope/models/core.py +288 -0
- dotscope/models/history.py +73 -0
- dotscope/models/intent.py +213 -0
- dotscope/models/passes.py +58 -0
- dotscope/models/state.py +250 -0
- dotscope/models.py +9 -0
- dotscope/near_miss.py +3 -0
- dotscope/onboarding.py +2 -0
- dotscope/parser.py +387 -0
- dotscope/passes/.scope +105 -0
- dotscope/passes/__init__.py +1 -0
- dotscope/passes/ast_analyzer.py +508 -0
- dotscope/passes/backtest.py +198 -0
- dotscope/passes/budget_allocator.py +164 -0
- dotscope/passes/convention_compliance.py +40 -0
- dotscope/passes/convention_discovery.py +247 -0
- dotscope/passes/convention_parser.py +223 -0
- dotscope/passes/graph_builder.py +299 -0
- dotscope/passes/history_miner.py +336 -0
- dotscope/passes/incremental.py +149 -0
- dotscope/passes/lang/__init__.py +38 -0
- dotscope/passes/lang/_base.py +20 -0
- dotscope/passes/lang/_treesitter.py +93 -0
- dotscope/passes/lang/go.py +333 -0
- dotscope/passes/lang/javascript.py +348 -0
- dotscope/passes/lazy.py +152 -0
- dotscope/passes/semantic_diff.py +160 -0
- dotscope/passes/sentinel/__init__.py +1 -0
- dotscope/passes/sentinel/acknowledge.py +222 -0
- dotscope/passes/sentinel/checker.py +383 -0
- dotscope/passes/sentinel/checks/__init__.py +1 -0
- dotscope/passes/sentinel/checks/antipattern.py +84 -0
- dotscope/passes/sentinel/checks/boundary.py +46 -0
- dotscope/passes/sentinel/checks/contracts.py +148 -0
- dotscope/passes/sentinel/checks/convention.py +54 -0
- dotscope/passes/sentinel/checks/direction.py +71 -0
- dotscope/passes/sentinel/checks/intent.py +207 -0
- dotscope/passes/sentinel/checks/stability.py +66 -0
- dotscope/passes/sentinel/checks/voice.py +108 -0
- dotscope/passes/sentinel/constraints.py +472 -0
- dotscope/passes/sentinel/line_filter.py +88 -0
- dotscope/passes/sentinel/models.py +15 -0
- dotscope/passes/virtual.py +239 -0
- dotscope/passes/voice.py +162 -0
- dotscope/passes/voice_defaults.py +28 -0
- dotscope/passes/voice_discovery.py +245 -0
- dotscope/paths.py +32 -0
- dotscope/progress.py +44 -0
- dotscope/regression.py +147 -0
- dotscope/resolver.py +203 -0
- dotscope/scanner.py +246 -0
- dotscope/sessions.py +2 -0
- dotscope/storage/.scope +64 -0
- dotscope/storage/__init__.py +1 -0
- dotscope/storage/cache.py +114 -0
- dotscope/storage/claude_hooks.py +119 -0
- dotscope/storage/git_hooks.py +277 -0
- dotscope/storage/incremental_state.py +61 -0
- dotscope/storage/mcp_config.py +98 -0
- dotscope/storage/near_miss.py +183 -0
- dotscope/storage/onboarding.py +150 -0
- dotscope/storage/session_manager.py +195 -0
- dotscope/storage/timing.py +84 -0
- dotscope/timing.py +2 -0
- dotscope/tokens.py +53 -0
- dotscope/utility.py +123 -0
- dotscope/virtual.py +3 -0
- dotscope/visibility.py +664 -0
- dotscope-0.1.0.dist-info/METADATA +50 -0
- dotscope-0.1.0.dist-info/RECORD +114 -0
- dotscope-0.1.0.dist-info/WHEEL +4 -0
- dotscope-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|