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/models/state.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""State data models: the persistent memory layer."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, List, Optional, Set
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# Observation layer models
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SessionLog:
|
|
13
|
+
"""Records a single scope resolution event (the prediction)."""
|
|
14
|
+
session_id: str
|
|
15
|
+
timestamp: float
|
|
16
|
+
scope_expr: str
|
|
17
|
+
task: Optional[str] = None
|
|
18
|
+
predicted_files: List[str] = field(default_factory=list)
|
|
19
|
+
context_hash: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ObservationLog:
|
|
24
|
+
"""Records what actually happened after a resolution (the outcome)."""
|
|
25
|
+
commit_hash: str
|
|
26
|
+
session_id: str
|
|
27
|
+
actual_files_modified: List[str] = field(default_factory=list)
|
|
28
|
+
predicted_not_touched: List[str] = field(default_factory=list)
|
|
29
|
+
touched_not_predicted: List[str] = field(default_factory=list)
|
|
30
|
+
recall: float = 0.0
|
|
31
|
+
precision: float = 0.0
|
|
32
|
+
timestamp: float = 0.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Health models
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class HealthIssue:
|
|
41
|
+
"""A single health issue found during scope analysis."""
|
|
42
|
+
scope_path: str
|
|
43
|
+
severity: str # "error", "warning", "info"
|
|
44
|
+
category: str # "staleness", "coverage", "drift", "broken_path"
|
|
45
|
+
message: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class HealthReport:
|
|
50
|
+
"""Full health report across all scopes."""
|
|
51
|
+
issues: List[HealthIssue] = field(default_factory=list)
|
|
52
|
+
scopes_checked: int = 0
|
|
53
|
+
directories_total: int = 0
|
|
54
|
+
directories_covered: int = 0
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def coverage_pct(self) -> float:
|
|
58
|
+
if self.directories_total == 0:
|
|
59
|
+
return 100.0
|
|
60
|
+
return (self.directories_covered / self.directories_total) * 100
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def errors(self) -> List[HealthIssue]:
|
|
64
|
+
return [i for i in self.issues if i.severity == "error"]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def warnings(self) -> List[HealthIssue]:
|
|
68
|
+
return [i for i in self.issues if i.severity == "warning"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Backtest models
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MissingSuggestion:
|
|
77
|
+
"""A file that should be added to a scope's includes."""
|
|
78
|
+
path: str
|
|
79
|
+
appearances: int
|
|
80
|
+
would_improve_recall: bool = True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class BacktestResult:
|
|
85
|
+
"""Backtest result for a single scope."""
|
|
86
|
+
scope_path: str
|
|
87
|
+
total_commits: int = 0
|
|
88
|
+
fully_covered: int = 0
|
|
89
|
+
recall: float = 0.0
|
|
90
|
+
missing_includes: List[MissingSuggestion] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class BacktestReport:
|
|
95
|
+
"""Full backtest report across all scopes."""
|
|
96
|
+
results: List[BacktestResult] = field(default_factory=list)
|
|
97
|
+
total_commits: int = 0
|
|
98
|
+
overall_recall: float = 0.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Bench
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class BenchReport:
|
|
107
|
+
# Token efficiency
|
|
108
|
+
avg_tokens_resolved: int = 0
|
|
109
|
+
avg_tokens_used: int = 0
|
|
110
|
+
efficiency_ratio: float = 0.0
|
|
111
|
+
|
|
112
|
+
# Hold rate
|
|
113
|
+
total_commits: int = 0
|
|
114
|
+
commits_with_holds: int = 0
|
|
115
|
+
holds_acknowledged: int = 0
|
|
116
|
+
effective_hold_rate: float = 0.0
|
|
117
|
+
|
|
118
|
+
# Compilation speed
|
|
119
|
+
resolve_median_ms: float = 0.0
|
|
120
|
+
resolve_p95_ms: float = 0.0
|
|
121
|
+
check_median_ms: float = 0.0
|
|
122
|
+
check_p95_ms: float = 0.0
|
|
123
|
+
|
|
124
|
+
# Scope health
|
|
125
|
+
scopes_above_80_recall: int = 0
|
|
126
|
+
total_scopes: int = 0
|
|
127
|
+
stale_scopes: int = 0
|
|
128
|
+
avg_observations: float = 0.0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Regression
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class RegressionCase:
|
|
137
|
+
"""A frozen successful session used as a regression test."""
|
|
138
|
+
id: str
|
|
139
|
+
scope_expr: str
|
|
140
|
+
budget: Optional[int] = None
|
|
141
|
+
task: Optional[str] = None
|
|
142
|
+
expected_files: List[str] = field(default_factory=list)
|
|
143
|
+
expected_context_hash: str = ""
|
|
144
|
+
actual_recall: float = 0.0
|
|
145
|
+
timestamp: str = ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class ReplayResult:
|
|
150
|
+
"""Result of replaying a regression case against current state."""
|
|
151
|
+
case: RegressionCase
|
|
152
|
+
new_files: List[str] = field(default_factory=list)
|
|
153
|
+
new_context_hash: str = ""
|
|
154
|
+
files_added: List[str] = field(default_factory=list)
|
|
155
|
+
files_dropped: List[str] = field(default_factory=list)
|
|
156
|
+
context_changed: bool = False
|
|
157
|
+
is_regression: bool = False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Debug
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class BisectionResult:
|
|
166
|
+
"""Root cause analysis of a bad agent session."""
|
|
167
|
+
session_id: str
|
|
168
|
+
files_that_mattered: List[str] = field(default_factory=list)
|
|
169
|
+
files_that_didnt_help: List[str] = field(default_factory=list)
|
|
170
|
+
context_sections_relevant: List[str] = field(default_factory=list)
|
|
171
|
+
context_sections_irrelevant: List[str] = field(default_factory=list)
|
|
172
|
+
constraints_honored: List[dict] = field(default_factory=list)
|
|
173
|
+
constraints_violated: List[dict] = field(default_factory=list)
|
|
174
|
+
missing_files: List[str] = field(default_factory=list)
|
|
175
|
+
diagnosis: str = "" # "resolution_gap" | "constraint_gap" | "agent_ignored" | "context_conflict"
|
|
176
|
+
recommendations: List[str] = field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Visibility
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class SessionStats:
|
|
185
|
+
"""Raw stats accumulated during an MCP session."""
|
|
186
|
+
scopes_resolved: int = 0
|
|
187
|
+
tokens_served: int = 0
|
|
188
|
+
tokens_available: int = 0
|
|
189
|
+
context_fields_used: int = 0
|
|
190
|
+
attribution_hints_served: int = 0
|
|
191
|
+
health_warnings_surfaced: int = 0
|
|
192
|
+
unique_scopes: Set[str] = field(default_factory=set)
|
|
193
|
+
constraints_served: List[dict] = field(default_factory=list)
|
|
194
|
+
started_at: Optional[str] = None
|
|
195
|
+
last_activity: Optional[str] = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Counterfactual
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class Counterfactual:
|
|
204
|
+
"""A bad thing that didn't happen because dotscope was there."""
|
|
205
|
+
type: str # "anti_pattern_avoided", "contract_honored", "intent_respected"
|
|
206
|
+
description: str
|
|
207
|
+
source: str # Where the knowledge came from
|
|
208
|
+
severity: str = "high" # "high" or "medium"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Utility
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class FileUtilityScore:
|
|
217
|
+
"""Utility score for a single file, derived from observations."""
|
|
218
|
+
path: str
|
|
219
|
+
resolve_count: int = 0 # Sessions that included this file
|
|
220
|
+
touch_count: int = 0 # Observations where this file was modified
|
|
221
|
+
utility_ratio: float = 0.0 # touch_count / resolve_count
|
|
222
|
+
last_touched: float = 0.0
|
|
223
|
+
last_resolved: float = 0.0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Lessons
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class Lesson:
|
|
232
|
+
"""A machine-generated lesson from observation patterns."""
|
|
233
|
+
trigger: str
|
|
234
|
+
observation: str
|
|
235
|
+
lesson_text: str
|
|
236
|
+
confidence: float
|
|
237
|
+
created: float
|
|
238
|
+
source_sessions: List[str] = field(default_factory=list)
|
|
239
|
+
acknowledged: bool = False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class ObservedInvariant:
|
|
244
|
+
"""An evidence-based boundary constraint."""
|
|
245
|
+
boundary: str # e.g., "auth -> payments"
|
|
246
|
+
direction: str # "no_import"
|
|
247
|
+
held_since: str # ISO date
|
|
248
|
+
commit_count: int
|
|
249
|
+
confidence: float
|
|
250
|
+
violations: List[str] = field(default_factory=list)
|
dotscope/models.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Core data models for dotscope.
|
|
2
|
+
|
|
3
|
+
This file is a backward-compatibility facade. All dataclasses are now
|
|
4
|
+
defined in dotscope/models/ sub-modules and re-exported here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Re-export everything so `from dotscope.models import X` still works
|
|
8
|
+
from .models.core import * # noqa: F401,F403
|
|
9
|
+
from .models.state import * # noqa: F401,F403
|
dotscope/near_miss.py
ADDED
dotscope/onboarding.py
ADDED
dotscope/parser.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Minimal YAML subset parser for .scope and .scopes files.
|
|
2
|
+
|
|
3
|
+
Handles the subset needed: scalars, lists, block scalars (|), comments.
|
|
4
|
+
No external dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from .context import parse_context
|
|
13
|
+
from .models import ScopeConfig, ScopeEntry, ScopesIndex
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_scope_file(path: str) -> ScopeConfig:
|
|
17
|
+
"""Parse a .scope file into a ScopeConfig."""
|
|
18
|
+
path = os.path.abspath(path)
|
|
19
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
20
|
+
text = f.read()
|
|
21
|
+
|
|
22
|
+
data = _parse_yaml(text)
|
|
23
|
+
|
|
24
|
+
description = data.get("description", "")
|
|
25
|
+
if not description:
|
|
26
|
+
raise ValueError(f"Missing required 'description' field in {path}")
|
|
27
|
+
|
|
28
|
+
context_raw = data.get("context", "")
|
|
29
|
+
context = parse_context(context_raw) if context_raw else None
|
|
30
|
+
|
|
31
|
+
tokens_est = data.get("tokens_estimate")
|
|
32
|
+
if tokens_est is not None:
|
|
33
|
+
tokens_est = int(tokens_est)
|
|
34
|
+
|
|
35
|
+
return ScopeConfig(
|
|
36
|
+
path=path,
|
|
37
|
+
description=str(description),
|
|
38
|
+
includes=_as_list(data.get("includes", [])),
|
|
39
|
+
excludes=_as_list(data.get("excludes", [])),
|
|
40
|
+
context=context,
|
|
41
|
+
related=_as_list(data.get("related", [])),
|
|
42
|
+
owners=_as_list(data.get("owners", [])),
|
|
43
|
+
tags=_as_list(data.get("tags", [])),
|
|
44
|
+
tokens_estimate=tokens_est,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_scopes_index(path: str) -> ScopesIndex:
|
|
49
|
+
"""Parse a .scopes index file."""
|
|
50
|
+
path = os.path.abspath(path)
|
|
51
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
52
|
+
text = f.read()
|
|
53
|
+
|
|
54
|
+
data = _parse_yaml(text)
|
|
55
|
+
|
|
56
|
+
version = int(data.get("version", 1))
|
|
57
|
+
defaults = data.get("defaults", {})
|
|
58
|
+
if isinstance(defaults, str):
|
|
59
|
+
defaults = {}
|
|
60
|
+
|
|
61
|
+
scopes_raw = data.get("scopes", {})
|
|
62
|
+
scopes = {}
|
|
63
|
+
if isinstance(scopes_raw, dict):
|
|
64
|
+
for name, entry_data in scopes_raw.items():
|
|
65
|
+
if isinstance(entry_data, dict):
|
|
66
|
+
keywords = entry_data.get("keywords", [])
|
|
67
|
+
if isinstance(keywords, str):
|
|
68
|
+
# Handle inline [a, b, c] syntax
|
|
69
|
+
keywords = _parse_inline_list(keywords)
|
|
70
|
+
scopes[name] = ScopeEntry(
|
|
71
|
+
name=name,
|
|
72
|
+
path=str(entry_data.get("path", "")),
|
|
73
|
+
keywords=keywords,
|
|
74
|
+
description=entry_data.get("description"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
total_repo_tokens = int(data.get("total_repo_tokens", 0))
|
|
78
|
+
return ScopesIndex(
|
|
79
|
+
version=version, scopes=scopes, defaults=defaults,
|
|
80
|
+
total_repo_tokens=total_repo_tokens,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def serialize_scope(config: ScopeConfig) -> str:
|
|
85
|
+
"""Serialize a ScopeConfig back to .scope YAML format."""
|
|
86
|
+
lines = []
|
|
87
|
+
|
|
88
|
+
lines.append(f"description: {config.description}")
|
|
89
|
+
|
|
90
|
+
if config.includes:
|
|
91
|
+
lines.append("includes:")
|
|
92
|
+
for inc in config.includes:
|
|
93
|
+
lines.append(f" - {inc}")
|
|
94
|
+
|
|
95
|
+
if config.excludes:
|
|
96
|
+
lines.append("excludes:")
|
|
97
|
+
for exc in config.excludes:
|
|
98
|
+
if any(c in exc for c in "*?["):
|
|
99
|
+
lines.append(f' - "{exc}"')
|
|
100
|
+
else:
|
|
101
|
+
lines.append(f" - {exc}")
|
|
102
|
+
|
|
103
|
+
if config.context:
|
|
104
|
+
lines.append("context: |")
|
|
105
|
+
for line in config.context_str.splitlines():
|
|
106
|
+
lines.append(f" {line}")
|
|
107
|
+
|
|
108
|
+
if config.related:
|
|
109
|
+
lines.append("related:")
|
|
110
|
+
for rel in config.related:
|
|
111
|
+
lines.append(f" - {rel}")
|
|
112
|
+
|
|
113
|
+
if config.owners:
|
|
114
|
+
lines.append("owners:")
|
|
115
|
+
for owner in config.owners:
|
|
116
|
+
lines.append(f' - "{owner}"')
|
|
117
|
+
|
|
118
|
+
if config.tags:
|
|
119
|
+
lines.append("tags:")
|
|
120
|
+
for tag in config.tags:
|
|
121
|
+
lines.append(f" - {tag}")
|
|
122
|
+
|
|
123
|
+
if config.tokens_estimate is not None:
|
|
124
|
+
lines.append(f"tokens_estimate: {config.tokens_estimate}")
|
|
125
|
+
|
|
126
|
+
return "\n".join(lines) + "\n"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Internal YAML subset parser
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _parse_yaml(text: str) -> Dict[str, Any]:
|
|
134
|
+
"""Parse a YAML subset: scalars, lists, block scalars, nested maps (1 level)."""
|
|
135
|
+
result: Dict[str, Any] = {}
|
|
136
|
+
lines = text.splitlines()
|
|
137
|
+
i = 0
|
|
138
|
+
|
|
139
|
+
while i < len(lines):
|
|
140
|
+
line = lines[i]
|
|
141
|
+
stripped = _strip_comment(line)
|
|
142
|
+
|
|
143
|
+
# Skip blank / comment-only lines
|
|
144
|
+
if not stripped.strip():
|
|
145
|
+
i += 1
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
indent = _indent_level(line)
|
|
149
|
+
|
|
150
|
+
# Only process top-level keys (indent 0)
|
|
151
|
+
if indent > 0:
|
|
152
|
+
i += 1
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
key, value, i = _parse_key_value(lines, i)
|
|
156
|
+
if key is not None:
|
|
157
|
+
result[key] = value
|
|
158
|
+
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_key_value(lines: List[str], i: int) -> Tuple[Optional[str], Any, int]:
|
|
163
|
+
"""Parse a key-value pair starting at line i. Returns (key, value, next_i)."""
|
|
164
|
+
line = _strip_comment(lines[i]).strip()
|
|
165
|
+
|
|
166
|
+
m = re.match(r'^(["\']?)([^"\':\s][^"\':]*)(\1)\s*:\s*(.*)', line)
|
|
167
|
+
if not m:
|
|
168
|
+
return None, None, i + 1
|
|
169
|
+
|
|
170
|
+
key = m.group(2).strip()
|
|
171
|
+
rest = m.group(4).strip()
|
|
172
|
+
|
|
173
|
+
i += 1
|
|
174
|
+
|
|
175
|
+
# Block scalar: key: |
|
|
176
|
+
if rest == "|":
|
|
177
|
+
value, i = _parse_block_scalar(lines, i)
|
|
178
|
+
return key, value, i
|
|
179
|
+
|
|
180
|
+
# Check if next lines are list items or nested map
|
|
181
|
+
if not rest and i < len(lines):
|
|
182
|
+
next_stripped = _strip_comment(lines[i]).strip() if i < len(lines) else ""
|
|
183
|
+
if next_stripped.startswith("- "):
|
|
184
|
+
value, i = _parse_list(lines, i)
|
|
185
|
+
return key, value, i
|
|
186
|
+
elif ":" in next_stripped and not next_stripped.startswith("-"):
|
|
187
|
+
value, i = _parse_nested_map(lines, i)
|
|
188
|
+
return key, value, i
|
|
189
|
+
|
|
190
|
+
# Inline value
|
|
191
|
+
if rest:
|
|
192
|
+
# Inline list: [a, b, c]
|
|
193
|
+
if rest.startswith("[") and rest.endswith("]"):
|
|
194
|
+
return key, _parse_inline_list(rest), i
|
|
195
|
+
|
|
196
|
+
# Quoted string
|
|
197
|
+
if (rest.startswith('"') and rest.endswith('"')) or (
|
|
198
|
+
rest.startswith("'") and rest.endswith("'")
|
|
199
|
+
):
|
|
200
|
+
return key, rest[1:-1], i
|
|
201
|
+
|
|
202
|
+
# Try numeric
|
|
203
|
+
try:
|
|
204
|
+
if "." in rest:
|
|
205
|
+
return key, float(rest), i
|
|
206
|
+
return key, int(rest), i
|
|
207
|
+
except ValueError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# Boolean
|
|
211
|
+
if rest.lower() in ("true", "yes"):
|
|
212
|
+
return key, True, i
|
|
213
|
+
if rest.lower() in ("false", "no"):
|
|
214
|
+
return key, False, i
|
|
215
|
+
|
|
216
|
+
return key, rest, i
|
|
217
|
+
|
|
218
|
+
return key, "", i
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_block_scalar(lines: List[str], i: int) -> Tuple[str, int]:
|
|
222
|
+
"""Parse a block scalar (| style) starting at line i."""
|
|
223
|
+
block_lines = []
|
|
224
|
+
if i >= len(lines):
|
|
225
|
+
return "", i
|
|
226
|
+
|
|
227
|
+
# Determine base indent from first content line
|
|
228
|
+
base_indent = _indent_level(lines[i])
|
|
229
|
+
if base_indent == 0:
|
|
230
|
+
return "", i
|
|
231
|
+
|
|
232
|
+
while i < len(lines):
|
|
233
|
+
line = lines[i]
|
|
234
|
+
if not line.strip():
|
|
235
|
+
block_lines.append("")
|
|
236
|
+
i += 1
|
|
237
|
+
continue
|
|
238
|
+
current_indent = _indent_level(line)
|
|
239
|
+
if current_indent < base_indent:
|
|
240
|
+
break
|
|
241
|
+
block_lines.append(line[base_indent:])
|
|
242
|
+
i += 1
|
|
243
|
+
|
|
244
|
+
# Strip trailing empty lines
|
|
245
|
+
while block_lines and not block_lines[-1]:
|
|
246
|
+
block_lines.pop()
|
|
247
|
+
|
|
248
|
+
return "\n".join(block_lines), i
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _parse_list(lines: List[str], i: int) -> Tuple[List[str], int]:
|
|
252
|
+
"""Parse a YAML list starting at line i."""
|
|
253
|
+
items = []
|
|
254
|
+
if i >= len(lines):
|
|
255
|
+
return items, i
|
|
256
|
+
|
|
257
|
+
base_indent = _indent_level(lines[i])
|
|
258
|
+
|
|
259
|
+
while i < len(lines):
|
|
260
|
+
line = lines[i]
|
|
261
|
+
stripped = _strip_comment(line).strip()
|
|
262
|
+
|
|
263
|
+
if not stripped:
|
|
264
|
+
i += 1
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
current_indent = _indent_level(line)
|
|
268
|
+
if current_indent < base_indent:
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
if stripped.startswith("- "):
|
|
272
|
+
item = stripped[2:].strip()
|
|
273
|
+
# Strip quotes
|
|
274
|
+
if (item.startswith('"') and item.endswith('"')) or (
|
|
275
|
+
item.startswith("'") and item.endswith("'")
|
|
276
|
+
):
|
|
277
|
+
item = item[1:-1]
|
|
278
|
+
# Strip inline comments from list items (e.g., "payments/.scope # shares user model")
|
|
279
|
+
comment_match = re.match(r'^([^#]*?)\s+#\s+.*$', item)
|
|
280
|
+
if comment_match:
|
|
281
|
+
item = comment_match.group(1).strip()
|
|
282
|
+
items.append(item)
|
|
283
|
+
elif current_indent > base_indent:
|
|
284
|
+
pass # continuation line, skip
|
|
285
|
+
else:
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
i += 1
|
|
289
|
+
|
|
290
|
+
return items, i
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _parse_nested_map(lines: List[str], i: int) -> Tuple[Dict[str, Any], int]:
|
|
294
|
+
"""Parse a one-level nested map."""
|
|
295
|
+
result: Dict[str, Any] = {}
|
|
296
|
+
if i >= len(lines):
|
|
297
|
+
return result, i
|
|
298
|
+
|
|
299
|
+
base_indent = _indent_level(lines[i])
|
|
300
|
+
|
|
301
|
+
while i < len(lines):
|
|
302
|
+
line = lines[i]
|
|
303
|
+
stripped = _strip_comment(line).strip()
|
|
304
|
+
|
|
305
|
+
if not stripped:
|
|
306
|
+
i += 1
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
current_indent = _indent_level(line)
|
|
310
|
+
if current_indent < base_indent:
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
if current_indent == base_indent:
|
|
314
|
+
# This is a nested key
|
|
315
|
+
key, value, i = _parse_key_value(lines, i)
|
|
316
|
+
if key is not None:
|
|
317
|
+
result[key] = value
|
|
318
|
+
else:
|
|
319
|
+
i += 1
|
|
320
|
+
|
|
321
|
+
return result, i
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_inline_list(text: str) -> List[str]:
|
|
325
|
+
"""Parse [a, b, c] into a list. Handles quoted values containing commas."""
|
|
326
|
+
inner = text.strip()
|
|
327
|
+
if inner.startswith("["):
|
|
328
|
+
inner = inner[1:]
|
|
329
|
+
if inner.endswith("]"):
|
|
330
|
+
inner = inner[:-1]
|
|
331
|
+
|
|
332
|
+
# State-machine split: only split on commas outside quotes
|
|
333
|
+
items = []
|
|
334
|
+
current: List[str] = []
|
|
335
|
+
in_quote = None
|
|
336
|
+
|
|
337
|
+
for ch in inner:
|
|
338
|
+
if ch in ('"', "'"):
|
|
339
|
+
if in_quote == ch:
|
|
340
|
+
in_quote = None
|
|
341
|
+
elif in_quote is None:
|
|
342
|
+
in_quote = ch
|
|
343
|
+
else:
|
|
344
|
+
current.append(ch)
|
|
345
|
+
continue
|
|
346
|
+
if ch == "," and in_quote is None:
|
|
347
|
+
val = "".join(current).strip()
|
|
348
|
+
if val:
|
|
349
|
+
items.append(val)
|
|
350
|
+
current = []
|
|
351
|
+
continue
|
|
352
|
+
current.append(ch)
|
|
353
|
+
|
|
354
|
+
val = "".join(current).strip()
|
|
355
|
+
if val:
|
|
356
|
+
items.append(val)
|
|
357
|
+
return items
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _strip_comment(line: str) -> str:
|
|
361
|
+
"""Strip trailing # comments, respecting quotes."""
|
|
362
|
+
in_quote = None
|
|
363
|
+
for idx, ch in enumerate(line):
|
|
364
|
+
if ch in ('"', "'"):
|
|
365
|
+
if in_quote == ch:
|
|
366
|
+
in_quote = None
|
|
367
|
+
elif in_quote is None:
|
|
368
|
+
in_quote = ch
|
|
369
|
+
elif ch == "#" and in_quote is None:
|
|
370
|
+
# Only strip if preceded by whitespace or at start
|
|
371
|
+
if idx == 0 or line[idx - 1] in (" ", "\t"):
|
|
372
|
+
return line[:idx]
|
|
373
|
+
return line
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _indent_level(line: str) -> int:
|
|
377
|
+
"""Count leading spaces."""
|
|
378
|
+
return len(line) - len(line.lstrip(" "))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _as_list(val: Any) -> List[str]:
|
|
382
|
+
"""Ensure a value is a list of strings."""
|
|
383
|
+
if isinstance(val, list):
|
|
384
|
+
return [str(v) for v in val]
|
|
385
|
+
if isinstance(val, str) and val:
|
|
386
|
+
return [val]
|
|
387
|
+
return []
|