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
@@ -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
@@ -0,0 +1,3 @@
1
+ """Backward-compatibility stub. Moved to dotscope.storage.near_miss."""
2
+ from .storage.near_miss import * # noqa: F401,F403
3
+ from .models.intent import NearMiss, WarningPair # noqa: F401
dotscope/onboarding.py ADDED
@@ -0,0 +1,2 @@
1
+ """Backward-compatibility stub. Moved to dotscope.storage.onboarding."""
2
+ from .storage.onboarding import * # noqa: F401,F403
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 []