File size: 14,279 Bytes
8d20d7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
"""Allowlisted scalar edits with exact snapshot binding and post-edit verification."""

import json
from collections.abc import Sequence
from difflib import unified_diff
from typing import Never, cast

from ruamel.yaml import YAML
from ruamel.yaml.nodes import ScalarNode

from .models import (
    MAX_BATCH_CANDIDATE_BYTES,
    MAX_BATCH_OPERATIONS,
    MAX_BATCH_SCALAR_BYTES,
    BatchEntry,
    BatchPreview,
    BatchProposal,
    BatchRequest,
    Candidate,
    EditPreview,
    EditProposal,
    ErrorCode,
    Inspection,
    Path,
    Scalar,
    Tree,
    YamlEditError,
    is_scalar,
    validate_batch_proposal,
)
from .parsing import Parsed, parse_source
from .validation import validate_value


def inspect(source: str) -> Inspection:
    """Discover editable fields and bind their identities to this exact source."""
    parsed = parse_source(source)
    return Inspection(parsed.source_version, parsed.candidates)


def _candidate(parsed: Parsed, candidate_id: str) -> Candidate:
    for candidate in parsed.candidates:
        if candidate.id == candidate_id:
            return candidate
    raise YamlEditError(
        ErrorCode.UNKNOWN_TARGET, f"Not an editable existing field: {candidate_id!r}."
    )


def _same(left: Tree, right: Tree) -> bool:
    """Compare semantic trees without conflating bool/int or int/float."""
    if type(left) is not type(right):
        return False
    if isinstance(left, dict) and isinstance(right, dict):
        return left.keys() == right.keys() and all(_same(left[key], right[key]) for key in left)
    if isinstance(left, list) and isinstance(right, list):
        return len(left) == len(right) and all(
            _same(a, b) for a, b in zip(left, right, strict=True)
        )
    return left == right


def _with_value(tree: Tree, path: Path, value: Scalar) -> Tree:
    """Copy only the selected ancestor chain, retaining the original semantic tree."""
    if not path:
        return value
    head, *tail = path
    if isinstance(tree, dict) and isinstance(head, str) and head in tree:
        return {**tree, head: _with_value(tree[head], tuple(tail), value)}
    if isinstance(tree, list) and type(head) is int and 0 <= head < len(tree):
        result = tree.copy()
        result[head] = _with_value(tree[head], tuple(tail), value)
        return result
    raise YamlEditError(
        ErrorCode.INVARIANT_VIOLATION, "Discovered path does not match the semantic tree."
    )


def _plain_string(value: str) -> bool:
    # Avoid reinterpreting strings as booleans/numbers in either YAML 1.2 or
    # common YAML 1.1 consumers. Parsing never runs arbitrary constructors here.
    for version in ((1, 2), (1, 1)):
        parser = YAML(typ="safe", pure=True)
        parser.version = version
        node = parser.compose(value)
        if not isinstance(node, ScalarNode) or node.tag != "tag:yaml.org,2002:str":
            return False
        if node.value != value:
            return False
    return True


def _replacement(value: Scalar, original: ScalarNode) -> str:
    if isinstance(value, str):
        if original.style == "'":
            return "'" + value.replace("'", "''") + "'"
        if original.style != '"' and _plain_string(value):
            return value
    return json.dumps(value, ensure_ascii=False, allow_nan=False)


def _unified_diff(before: str, after: str) -> str:
    if before == after:
        return ""
    # Review output uses LF regardless of the source's newline convention.
    # The returned source itself remains byte-for-byte preserved outside the edit.
    lines = unified_diff(
        before.replace("\r\n", "\n").replace("\r", "\n").splitlines(keepends=True),
        after.replace("\r\n", "\n").replace("\r", "\n").splitlines(keepends=True),
        fromfile="before.yaml",
        tofile="after.yaml",
    )
    # difflib doesn't supply the conventional missing-newline marker itself.
    return "".join(
        line if line.endswith("\n") else line + "\n\\ No newline at end of file\n" for line in lines
    )


def _propose_parsed(parsed: Parsed, source: str, candidate_id: str, value: object) -> EditProposal:
    """Create a proposal from a private, request-local parsed source snapshot."""
    candidate = _candidate(parsed, candidate_id)
    proposal = EditProposal(
        source_version=parsed.source_version,
        candidate_id=candidate.id,
        path=candidate.path,
        expected_old_value=candidate.current_value,
        value=validate_value(candidate.kind, value),
    )
    # Includes post-edit structure, request/limit, and invariant checks.
    _preview(parsed, source, proposal)
    return proposal


def propose(source: str, candidate_id: str, value: object) -> EditProposal:
    """Create a validated proposal. No files are read or written."""
    return _propose_parsed(parse_source(source), source, candidate_id, value)


def _preview(parsed: Parsed, source: str, proposal: EditProposal) -> EditPreview:
    if parsed.source_version != proposal.source_version:
        raise YamlEditError(
            ErrorCode.STALE_PROPOSAL, "Source changed; generate and review a new proposal."
        )
    candidate = _candidate(parsed, proposal.candidate_id)
    if candidate.path != proposal.path or not _same(
        candidate.current_value, proposal.expected_old_value
    ):
        raise YamlEditError(
            ErrorCode.INVALID_PROPOSAL, "Proposal does not match its allowlisted target."
        )
    value = validate_value(candidate.kind, proposal.value)
    if _same(value, candidate.current_value):
        return EditPreview(source=source, diff="", changed=False, candidate=candidate)
    original = parsed.nodes[candidate.path]
    if not isinstance(original, ScalarNode):
        raise YamlEditError(ErrorCode.INVARIANT_VIOLATION, "Discovered target is not a scalar.")
    start, end = candidate.location.start, candidate.location.end
    result = source[:start] + _replacement(value, original) + source[end:]
    reparsed = parse_source(result)
    expected = _with_value(parsed.data, candidate.path, value)
    if not _same(expected, reparsed.data):
        raise YamlEditError(
            ErrorCode.INVARIANT_VIOLATION, "Edit changed more than the intended semantic value."
        )
    return EditPreview(
        source=result, diff=_unified_diff(source, result), changed=True, candidate=candidate
    )


def preview(source: str, proposal: object) -> EditPreview:
    """Revalidate an untrusted proposal and return the exact source and review diff."""
    checked = EditProposal.from_dict(
        proposal.to_dict() if isinstance(proposal, EditProposal) else proposal
    )
    parsed = parse_source(source)
    return _preview(parsed, source, checked)


def apply(source: str, proposal: object) -> str:
    """Apply to an immutable string snapshot; callers handle persistence/confirmation."""
    return preview(source, proposal).source


def _batch_invalid() -> Never:
    raise YamlEditError(ErrorCode.INVALID_PROPOSAL, "Malformed or unsupported batch request.")


def _bounded_request_text(value: object, maximum: int, *, pointer: bool = False) -> str:
    if type(value) is not str or len(value) > maximum or (pointer and not value.startswith("/")):
        _batch_invalid()
    text = value
    try:
        raw = text.encode("utf-8")
    except UnicodeEncodeError as error:
        raise YamlEditError(ErrorCode.INVALID_PROPOSAL, "Malformed batch request text.") from error
    if len(raw) > maximum:
        _batch_invalid()
    return text


def _batch_requests(value: object) -> tuple[BatchRequest, ...]:
    if type(value) not in (list, tuple):
        _batch_invalid()
    requests = cast("list[object] | tuple[object, ...]", value)
    if not 1 <= len(requests) <= MAX_BATCH_OPERATIONS:
        _batch_invalid()
    checked: list[BatchRequest] = []
    for item in requests:
        if type(item) is not BatchRequest:
            _batch_invalid()
        candidate_value = cast("object", getattr(item, "candidate_id", None))
        requested_value = cast("object", getattr(item, "value", None))
        candidate_id = _bounded_request_text(
            candidate_value, MAX_BATCH_CANDIDATE_BYTES, pointer=True
        )
        if not is_scalar(requested_value):
            _batch_invalid()
        if type(requested_value) is str:
            _bounded_request_text(requested_value, MAX_BATCH_SCALAR_BYTES)
        checked.append(BatchRequest(candidate_id, requested_value))
    return tuple(checked)


def _resolve_requests(
    parsed: Parsed, requests: tuple[BatchRequest, ...]
) -> tuple[tuple[Candidate, Scalar], ...]:
    seen: set[str] = set()
    resolved: list[tuple[Candidate, Scalar]] = []
    for request in requests:
        if request.candidate_id in seen:
            _batch_invalid()
        seen.add(request.candidate_id)
        candidate = _candidate(parsed, request.candidate_id)
        resolved.append((candidate, validate_value(candidate.kind, request.value)))
    return tuple(sorted(resolved, key=lambda item: (item[0].location.start, item[0].id)))


def _proposal_entries(
    resolved: tuple[tuple[Candidate, Scalar], ...],
) -> tuple[BatchEntry, ...]:
    return tuple(
        BatchEntry(candidate.id, candidate.path, candidate.current_value, value)
        for candidate, value in resolved
    )


def propose_batch(source: str, requests: Sequence[BatchRequest]) -> BatchProposal:
    """Create one validated proposal for explicit existing scalar targets."""
    checked_requests = _batch_requests(requests)
    parsed = parse_source(source)
    resolved = _resolve_requests(parsed, checked_requests)
    proposal = BatchProposal(parsed.source_version, _proposal_entries(resolved))
    _preview_batch(parsed, source, proposal)
    proposal.to_dict()
    return proposal


def _entry_matches(candidate: Candidate, entry: BatchEntry) -> bool:
    return candidate.path == entry.path and _same(candidate.current_value, entry.expected_old_value)


def _resolve_entries(
    parsed: Parsed, proposal: BatchProposal, source_length: int
) -> tuple[tuple[Candidate, Scalar], ...]:
    if parsed.source_version != proposal.source_version:
        raise YamlEditError(
            ErrorCode.STALE_PROPOSAL, "Source changed; generate and review a new batch proposal."
        )
    ids: set[str] = set()
    paths: set[Path] = set()
    spans: set[tuple[int, int]] = set()
    resolved: list[tuple[Candidate, Scalar]] = []
    for entry in proposal.entries:
        candidate = _candidate(parsed, entry.candidate_id)
        if not _entry_matches(candidate, entry):
            raise YamlEditError(
                ErrorCode.INVALID_PROPOSAL, "Batch entry does not match its allowlisted target."
            )
        span = (candidate.location.start, candidate.location.end)
        if candidate.id in ids or candidate.path in paths or span in spans:
            raise YamlEditError(ErrorCode.INVALID_PROPOSAL, "Batch targets must be distinct.")
        ids.add(candidate.id)
        paths.add(candidate.path)
        spans.add(span)
        resolved.append((candidate, validate_value(candidate.kind, entry.value)))
    canonical = sorted(resolved, key=lambda item: (item[0].location.start, item[0].id))
    if resolved != canonical:
        raise YamlEditError(ErrorCode.INVALID_PROPOSAL, "Batch entries are not in source order.")
    _check_batch_spans(resolved, source_length)
    return tuple(resolved)


def _check_batch_spans(resolved: list[tuple[Candidate, Scalar]], source_length: int) -> None:
    previous_end = -1
    for candidate, _value in resolved:
        location = candidate.location
        if not 0 <= location.start <= location.end <= source_length:
            raise YamlEditError(
                ErrorCode.INVARIANT_VIOLATION, "Discovered batch target span is invalid."
            )
        if location.start < previous_end:
            raise YamlEditError(
                ErrorCode.INVARIANT_VIOLATION, "Discovered batch target spans overlap."
            )
        previous_end = location.end


def _batch_expected(parsed: Parsed, resolved: tuple[tuple[Candidate, Scalar], ...]) -> Tree:
    expected: Tree = parsed.data
    for candidate, value in resolved:
        expected = _with_value(expected, candidate.path, value)
    return expected


def _batch_source(
    parsed: Parsed, source: str, resolved: tuple[tuple[Candidate, Scalar], ...]
) -> str:
    result = source
    for candidate, value in reversed(resolved):
        if _same(candidate.current_value, value):
            continue
        original = parsed.nodes.get(candidate.path)
        if not isinstance(original, ScalarNode):
            raise YamlEditError(
                ErrorCode.INVARIANT_VIOLATION, "Discovered batch target is not a scalar."
            )
        start, end = candidate.location.start, candidate.location.end
        if not 0 <= start <= end <= len(source):
            raise YamlEditError(
                ErrorCode.INVARIANT_VIOLATION, "Discovered batch target span is invalid."
            )
        result = result[:start] + _replacement(value, original) + result[end:]
    return result


def _preview_batch(parsed: Parsed, source: str, proposal: BatchProposal) -> BatchPreview:
    resolved = _resolve_entries(parsed, proposal, len(source))
    expected = _batch_expected(parsed, resolved)
    result = _batch_source(parsed, source, resolved)
    reparsed = parse_source(result)
    if not _same(expected, reparsed.data):
        raise YamlEditError(
            ErrorCode.INVARIANT_VIOLATION,
            "Batch edit changed more than the intended semantic values.",
        )
    return BatchPreview(
        source=result,
        diff=_unified_diff(source, result),
        changed=result != source,
        candidates=tuple(candidate for candidate, _value in resolved),
    )


def preview_batch(source: str, proposal: object) -> BatchPreview:
    """Revalidate an untrusted batch artifact and return its complete review result."""
    checked = validate_batch_proposal(proposal)
    parsed = parse_source(source)
    return _preview_batch(parsed, source, checked)


def apply_batch(source: str, proposal: object) -> str:
    """Apply an atomic batch to an immutable string snapshot."""
    return preview_batch(source, proposal).source