specspec 0.0.1__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.
specspec/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """specspec's reusable graph and workspace API."""
2
+
3
+ __version__ = "0.0.1"
specspec/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,118 @@
1
+ """Color-free comparison spotlight CLI."""
2
+
3
+ import sys
4
+
5
+ import click
6
+
7
+ from . import annotations
8
+ from .cli_help import HELP
9
+ from .model import ID_RE, Diagnostic, SpecError
10
+
11
+ STATUS = {
12
+ "added": "A",
13
+ "removed": "D",
14
+ "changed": "M",
15
+ "unchanged": "=",
16
+ "absent": "?",
17
+ "unavailable": "!",
18
+ }
19
+
20
+
21
+ def render_shots(result):
22
+ shots = result["shots"]
23
+ lines = [f"{len(shots)} local spotlight(s) · retained until explicit flush"]
24
+ for shot in shots:
25
+ context = shot["comparison"]
26
+ lines.append(f"\nSpotlight {shot['id']} · {shot['created_at']}")
27
+ lines.append(f" {context['base']} → {context['target']}")
28
+ lines.append(" " + shot["message"])
29
+ for warning in shot.get("warnings", []):
30
+ lines.append(" WARNING: " + warning)
31
+ for member in shot.get("members", []):
32
+ kind = member["kind"]
33
+ lines.append(f" {STATUS[kind]} {member['id']} {member['title'] or kind}")
34
+ return "\n".join(lines)
35
+
36
+
37
+ def parse_arguments(parts):
38
+ if any(part.startswith("--") and not ID_RE.fullmatch(part) for part in parts):
39
+ annotations.fail("Unknown spotlight option; use --base and --target for comparisons.")
40
+ ids = []
41
+ for index, part in enumerate(parts):
42
+ if not ID_RE.fullmatch(part):
43
+ return {
44
+ "nodes": annotations.node_ids(ids),
45
+ "message": annotations.message(" ".join(parts[index:]).strip()),
46
+ }
47
+ ids.append(part)
48
+ annotations.fail("Finish with a spotlight message of 1–500 characters.")
49
+
50
+
51
+ def register(main, root_path, emit, guarded, json_option):
52
+ @main.group(
53
+ name="spotlight",
54
+ help=HELP["spotlight"],
55
+ short_help="Attach local messages to nodes and comparison context.",
56
+ )
57
+ def group():
58
+ """Advanced: add local comparison spotlights, inspect or flush them."""
59
+
60
+ @group.command(
61
+ name="add",
62
+ context_settings={"ignore_unknown_options": True},
63
+ help=HELP["spotlight add"],
64
+ short_help="Record one local message about selected node IDs.",
65
+ )
66
+ @click.argument("parts", nargs=-1, required=True, metavar="NODE_ID... MESSAGE")
67
+ @click.option("--base", help="Base commit/ref; explicit refs are pinned. Default: live HEAD.")
68
+ @click.option("--target", help="Target commit/ref or workspace (default).")
69
+ @json_option
70
+ @click.pass_obj
71
+ @guarded
72
+ def add(root, parts, base, target, as_json):
73
+ """Add one message and node IDs in a revision comparison.
74
+
75
+ Select IDs from either side, including deleted and unchanged nodes.
76
+ Finish with the message (normally quoted). No colors or node copies.
77
+ """
78
+ path = root_path(root)
79
+ shot = annotations.submit(path, parse_arguments(parts), base=base, target=target)
80
+ emit(annotations.listing(path, shot["id"]), as_json=as_json, text=render_shots)
81
+
82
+ @group.command(
83
+ name="show",
84
+ help=HELP["spotlight show"],
85
+ short_help="Read messages, membership statuses and drift warnings.",
86
+ )
87
+ @click.argument("shot_id", required=False)
88
+ @json_option
89
+ @click.pass_obj
90
+ @guarded
91
+ def show(root, shot_id, as_json):
92
+ """Show all spotlights (or one ID), their comparison statuses and drift warnings."""
93
+ emit(annotations.listing(root_path(root), shot_id), as_json=as_json, text=render_shots)
94
+
95
+ @group.command(
96
+ name="flush",
97
+ help=HELP["spotlight flush"],
98
+ short_help="Explicitly clear all checkout-local spotlight records.",
99
+ )
100
+ @click.option(
101
+ "--yes", is_flag=True, help="Explicitly consent to clearing all local spotlights."
102
+ )
103
+ @json_option
104
+ @click.pass_obj
105
+ @guarded
106
+ def flush(root, yes, as_json):
107
+ """Clear spotlight metadata and obsolete copies; keep specification/recovery."""
108
+ if not yes and (as_json or not sys.stdin.isatty()):
109
+ raise SpecError(
110
+ [Diagnostic("SPOTLIGHTS", "Review spotlight show, then use flush --yes.")]
111
+ )
112
+ if not yes and not click.confirm("Flush all local spotlights?", default=False):
113
+ return
114
+ emit(
115
+ annotations.flush(root_path(root)),
116
+ as_json=as_json,
117
+ text=lambda r: f"Flushed {r['flushed']} local spotlight(s).",
118
+ )
@@ -0,0 +1,493 @@
1
+ """Metadata-only checkout-local spotlights; Git owns committed node contents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import secrets
10
+ import subprocess
11
+ from copy import deepcopy
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path, PurePosixPath
14
+
15
+ from .git import git_snapshot, repository_paths, run_git, semantic_diff
16
+ from .git_objects import OBJECT_ID
17
+ from .model import ID_RE, MAX_FILE_BYTES, Diagnostic, SpecError
18
+ from .workspace import SPEC_DIR, STATE_DIR, load_snapshot, snapshot_from_files, workspace_lock
19
+
20
+ SHOT_ID = re.compile(r"[0-9a-f]{32}\Z")
21
+ TEMP_FILE = re.compile(r"\.shot-[0-9a-f]{32}\.tmp\Z")
22
+ CHECKSUM = re.compile(r"[0-9a-f]{64}\Z")
23
+ MAX_SHOT_BYTES = 512 * 1024
24
+ MAX_LIST_BYTES = 4 * 1024 * 1024
25
+ MAX_SHOTS = 256
26
+ MAX_MEMBERS = 512
27
+
28
+
29
+ def fail(message):
30
+ raise SpecError([Diagnostic("SPOTLIGHTS", message)])
31
+
32
+
33
+ def parse_json(raw):
34
+ def pairs(items):
35
+ result = {}
36
+ for key, value in items:
37
+ if key in result:
38
+ raise ValueError(f"Duplicate field: {key}")
39
+ result[key] = value
40
+ return result
41
+
42
+ try:
43
+ return json.loads(raw, object_pairs_hook=pairs)
44
+ except (ValueError, UnicodeError, RecursionError) as error:
45
+ fail(f"Invalid spotlight JSON: {error}")
46
+
47
+
48
+ def object_fields(value, fields):
49
+ if not isinstance(value, dict) or set(value) != set(fields):
50
+ fail(f"Expected exactly these fields: {', '.join(fields)}.")
51
+
52
+
53
+ def node_ids(value):
54
+ if (
55
+ not isinstance(value, list)
56
+ or not 1 <= len(value) <= MAX_MEMBERS
57
+ or any(not isinstance(i, str) or not ID_RE.fullmatch(i) for i in value)
58
+ or len(value) != len(set(value))
59
+ ):
60
+ fail(f"Supply 1–{MAX_MEMBERS} unique full node IDs, including '='.")
61
+ return value
62
+
63
+
64
+ def message(value):
65
+ if not isinstance(value, str) or not value.strip() or len(value) > 500:
66
+ fail("Provide a spotlight message of 1–500 characters.")
67
+ return value
68
+
69
+
70
+ def head_snapshot(root):
71
+ try:
72
+ return git_snapshot(root, "HEAD", allow_empty=True)
73
+ except SpecError:
74
+ # Non-Git projects still support local workspace messages. Invalid Git
75
+ # snapshots are not silently turned into an empty baseline.
76
+ try:
77
+ repository_paths(root)
78
+ except SpecError:
79
+ return snapshot_from_files(root, {}, allow_empty=True)
80
+ raise
81
+
82
+
83
+ def workspace_state(snapshot, head=None):
84
+ """Sparse literal-byte drift hints, never stored hashes of clean Git nodes."""
85
+ head = head if head is not None else head_snapshot(snapshot.root)
86
+ # Drift follows actual canonical-path bytes, not successful node parsing.
87
+ # An unreadable existing file is not a null deletion marker.
88
+ old, new = (
89
+ {PurePosixPath(p).stem: p for p in s.files if ID_RE.fullmatch(PurePosixPath(p).stem)}
90
+ for s in (head, snapshot)
91
+ )
92
+ # Git decides which saved files are uncommitted, including staged changes.
93
+ # This avoids checksumming every clean CRLF checkout or clean-filter output.
94
+ dirty_paths = set()
95
+ if head.commit:
96
+ dirty_paths = {
97
+ p.decode("utf-8", errors="surrogateescape")
98
+ for p in run_git(
99
+ snapshot.root,
100
+ "--literal-pathspecs",
101
+ "diff",
102
+ "--relative",
103
+ "--name-only",
104
+ "-z",
105
+ "--no-renames",
106
+ head.commit,
107
+ "--",
108
+ ".specspec",
109
+ ).split(b"\0")
110
+ if p
111
+ }
112
+ changes = {}
113
+ for node_id in sorted(old.keys() | new.keys()):
114
+ before = head.files[old[node_id]] if node_id in old else None
115
+ after = snapshot.files[new[node_id]] if node_id in new else None
116
+ if before == after:
117
+ continue
118
+ if before is not None and after is not None and new[node_id] not in dirty_paths:
119
+ # Trusted native overlays can differ from clean saved bytes too.
120
+ try:
121
+ with (snapshot.root / new[node_id]).open("rb") as stream:
122
+ saved = stream.read(MAX_FILE_BYTES + 1)
123
+ except FileNotFoundError:
124
+ saved = None
125
+ if after == saved:
126
+ continue
127
+ changes[node_id] = hashlib.sha256(after).hexdigest() if after is not None else None
128
+ return {"head": head.commit, "changes": changes}
129
+
130
+
131
+ def workspace_warnings(captured, current):
132
+ if captured is None:
133
+ return []
134
+ if current is None:
135
+ return ["Workspace state is unavailable; highlighting available node IDs."]
136
+ warnings = []
137
+ if captured["head"] != current["head"]:
138
+ warnings.append("Workspace HEAD moved since capture; highlighting available node IDs.")
139
+ before, after = captured["changes"], current["changes"]
140
+ changed = sorted(
141
+ i
142
+ for i in before.keys() | after.keys()
143
+ if (i in before, before.get(i)) != (i in after, after.get(i))
144
+ )
145
+ if changed:
146
+ warnings.append(
147
+ f"Workspace nodes changed since capture ({len(changed)}): {', '.join(changed)}. "
148
+ "Highlighting available node IDs."
149
+ )
150
+ return warnings
151
+
152
+
153
+ def directory(root):
154
+ path = root / STATE_DIR / "annotations"
155
+ for candidate in (root / SPEC_DIR, root / STATE_DIR, path, path / "blobs"):
156
+ if candidate.is_symlink() or (candidate.exists() and not candidate.is_dir()):
157
+ fail(f"Spotlight storage must be real directories: {candidate}")
158
+ return path
159
+
160
+
161
+ def shot_files(path, *, limited=True):
162
+ if not path.exists():
163
+ return []
164
+ result = []
165
+ for entry in path.iterdir():
166
+ if entry.is_symlink():
167
+ fail("Spotlight storage contains a forbidden symlink.")
168
+ if entry.suffix == ".json":
169
+ if not SHOT_ID.fullmatch(entry.stem) or not entry.is_file():
170
+ fail(f"Unexpected shot file: {entry.name}")
171
+ result.append(entry)
172
+ if limited and len(result) > MAX_SHOTS:
173
+ fail(f"Spotlight storage exceeds {MAX_SHOTS} spotlights; explicitly flush it.")
174
+ return result
175
+
176
+
177
+ def commit_id(value):
178
+ return isinstance(value, str) and OBJECT_ID.fullmatch(value)
179
+
180
+
181
+ def validate_shot(shot, shot_id):
182
+ if (
183
+ not isinstance(shot, dict)
184
+ or type(shot.get("spotlight_version")) is not int
185
+ or shot["spotlight_version"] != 2
186
+ ):
187
+ fail(
188
+ "Unsupported local spotlight format. "
189
+ "Review/clear old local data with spotlight flush --yes."
190
+ )
191
+ target = (
192
+ shot.get("comparison", {}).get("target")
193
+ if isinstance(shot.get("comparison"), dict)
194
+ else None
195
+ )
196
+ fields = ["spotlight_version", "id", "created_at", "message", "comparison", "nodes"]
197
+ object_fields(shot, fields + (["workspace"] if target == "workspace" else []))
198
+ if shot["id"] != shot_id:
199
+ fail("Shot filename and identity disagree.")
200
+ try:
201
+ date = datetime.fromisoformat(shot["created_at"])
202
+ if date.tzinfo is None:
203
+ raise ValueError()
204
+ except (TypeError, ValueError):
205
+ fail("Invalid spotlight capture time.")
206
+ message(shot["message"])
207
+ node_ids(shot["nodes"])
208
+ comparison = shot["comparison"]
209
+ object_fields(comparison, ["base", "target"])
210
+ if comparison["base"] != "HEAD" and not commit_id(comparison["base"]):
211
+ fail("Comparison base needs HEAD or an immutable commit ID.")
212
+ if target != "workspace" and not commit_id(target):
213
+ fail("Comparison target needs workspace or an immutable commit ID.")
214
+ if target == "workspace":
215
+ state = shot["workspace"]
216
+ object_fields(state, ["head", "changes"])
217
+ if state["head"] is not None and not commit_id(state["head"]):
218
+ fail("Workspace HEAD needs an immutable commit ID or null.")
219
+ if not isinstance(state["changes"], dict) or any(
220
+ not isinstance(i, str)
221
+ or not ID_RE.fullmatch(i)
222
+ or (
223
+ checksum is not None
224
+ and (not isinstance(checksum, str) or not CHECKSUM.fullmatch(checksum))
225
+ )
226
+ for i, checksum in state["changes"].items()
227
+ ):
228
+ fail("Invalid sparse workspace checksums/deletion markers.")
229
+ return shot
230
+
231
+
232
+ def load_shots(root):
233
+ shots, total = [], 0
234
+ for file in shot_files(directory(root)):
235
+ try:
236
+ size = file.stat().st_size
237
+ total += size
238
+ if size > MAX_SHOT_BYTES or total > MAX_LIST_BYTES:
239
+ fail("Spotlight metadata exceeds its storage budget; explicitly flush it.")
240
+ with file.open("rb") as stream:
241
+ raw = stream.read(MAX_SHOT_BYTES + 1)
242
+ if len(raw) > MAX_SHOT_BYTES:
243
+ fail("Spotlight exceeds 512 KiB; retained for explicit repair or flush.")
244
+ except FileNotFoundError:
245
+ continue
246
+ try:
247
+ shots.append(validate_shot(parse_json(raw), file.stem))
248
+ except SpecError as error:
249
+ fail(f"Protected malformed shot {file.name}: {error.diagnostics[0].message}")
250
+ return sorted(shots, key=lambda shot: (shot["created_at"], shot["id"]), reverse=True)
251
+
252
+
253
+ def ensure_ignored(root):
254
+ """Protect local data without tracked .gitignore changes or installed hooks."""
255
+ try:
256
+ repository, _ = repository_paths(root)
257
+ except SpecError:
258
+ return
259
+ relative = (root.resolve().relative_to(repository) / STATE_DIR).as_posix()
260
+ if run_git(repository, "--literal-pathspecs", "ls-files", "-z", "--", relative):
261
+ fail("Local state contains Git-tracked files; untrack it explicitly before submitting.")
262
+
263
+ def ignored():
264
+ try:
265
+ result = subprocess.run(
266
+ [
267
+ "git",
268
+ "-C",
269
+ str(repository),
270
+ "check-ignore",
271
+ "--quiet",
272
+ "--",
273
+ relative + "/annotations/",
274
+ ],
275
+ capture_output=True,
276
+ check=False,
277
+ timeout=20,
278
+ )
279
+ except subprocess.TimeoutExpired:
280
+ fail("Git exclusion verification timed out; no spotlight published.")
281
+ if result.returncode not in (0, 1):
282
+ fail("Cannot verify the Git exclusion for local spotlights.")
283
+ return result.returncode == 0
284
+
285
+ if ignored():
286
+ return
287
+ exclude = Path(
288
+ run_git(repository, "rev-parse", "--path-format=absolute", "--git-path", "info/exclude")
289
+ .decode()
290
+ .strip()
291
+ )
292
+ if exclude.is_symlink():
293
+ fail("Refusing to follow a substituted Git exclude file.")
294
+ exclude.parent.mkdir(parents=True, exist_ok=True)
295
+ escaped = "".join("\\" + c if c in "\\!#[]*? " else c for c in relative)
296
+ with exclude.open("a", encoding="utf-8") as stream:
297
+ stream.write(f"\n# specspec checkout-local state\n/{escaped}/\n")
298
+ if not ignored():
299
+ fail("Local ignore rules override the spotlight exclusion; fix them before submitting.")
300
+
301
+
302
+ def atomic_file(path, raw):
303
+ temporary = path.with_name(f".shot-{secrets.token_hex(16)}.tmp")
304
+ try:
305
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
306
+ with os.fdopen(descriptor, "wb") as stream:
307
+ stream.write(raw)
308
+ os.replace(temporary, path)
309
+ finally:
310
+ temporary.unlink(missing_ok=True)
311
+
312
+
313
+ def snapshots(root, base, target, documents=None):
314
+ old = head_snapshot(root) if base == "HEAD" else git_snapshot(root, base, allow_empty=True)
315
+ if target == "workspace":
316
+ if documents is None:
317
+ new = load_snapshot(root)
318
+ else:
319
+ from .editor import document_files
320
+
321
+ new = snapshot_from_files(root, document_files(root, documents))
322
+ else:
323
+ if documents:
324
+ fail("Historical spotlight capture does not consume native overlays.")
325
+ new = git_snapshot(root, target)
326
+ return old, new
327
+
328
+
329
+ def submit(root, value, *, base=None, target=None, documents=None):
330
+ object_fields(value, ["message", "nodes"])
331
+ message(value["message"])
332
+ ids = node_ids(value["nodes"])
333
+ for ref in (base, target):
334
+ if ref is not None and (not isinstance(ref, str) or not ref or len(ref) > 1024):
335
+ fail("Invalid spotlight revision.")
336
+ if documents is not None and not isinstance(documents, dict):
337
+ fail("documents must map canonical paths to UTF-8 text.")
338
+ target = target or "workspace"
339
+ old, new = snapshots(root, base or "HEAD", target, documents)
340
+ available = (
341
+ {n.id for n in old.nodes}
342
+ | {n.id for n in new.nodes}
343
+ | {p for n in [*old.nodes, *new.nodes] for p in n.parents}
344
+ | {
345
+ PurePosixPath(path).stem
346
+ for s in (old, new)
347
+ for path in s.files
348
+ if ID_RE.fullmatch(PurePosixPath(path).stem)
349
+ }
350
+ )
351
+ for node_id in ids:
352
+ if node_id not in available:
353
+ fail(f"Node {node_id} does not exist in either side of the comparison.")
354
+ comparison = {
355
+ "base": old.commit
356
+ if (base is not None or target != "workspace") and old.commit
357
+ else "HEAD",
358
+ "target": new.commit if target != "workspace" else "workspace",
359
+ }
360
+ extra = (
361
+ {"workspace": workspace_state(new, old if base in (None, "HEAD") else None)}
362
+ if target == "workspace"
363
+ else {}
364
+ )
365
+ return publish(
366
+ root, {"message": value["message"], "comparison": comparison, "nodes": ids, **extra}
367
+ )
368
+
369
+
370
+ def publish(root, value):
371
+ with workspace_lock(root):
372
+ path = directory(root)
373
+ existing = load_shots(root)
374
+ if len(existing) >= MAX_SHOTS:
375
+ fail(f"Spotlight storage is full ({MAX_SHOTS} spotlights); explicitly flush it.")
376
+ shot_id = secrets.token_hex(16)
377
+ while (path / f"{shot_id}.json").exists():
378
+ shot_id = secrets.token_hex(16)
379
+ shot = {
380
+ "spotlight_version": 2,
381
+ "id": shot_id,
382
+ "created_at": datetime.now(UTC).isoformat(),
383
+ **deepcopy(value),
384
+ }
385
+ validate_shot(shot, shot_id)
386
+ raw = json.dumps(shot, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
387
+ if (
388
+ len(raw) > MAX_SHOT_BYTES
389
+ or len(raw) + sum(p.stat().st_size for p in shot_files(path)) > MAX_LIST_BYTES
390
+ ):
391
+ fail("Spotlight metadata storage is full; explicitly flush it.")
392
+ ensure_ignored(root)
393
+ path.mkdir(parents=True, exist_ok=True)
394
+ atomic_file(path / f"{shot_id}.json", raw)
395
+ return shot
396
+
397
+
398
+ def flush(root, *, expected=None):
399
+ if expected is not None and (
400
+ not isinstance(expected, list)
401
+ or any(not isinstance(i, str) or not SHOT_ID.fullmatch(i) for i in expected)
402
+ or len(expected) != len(set(expected))
403
+ ):
404
+ fail("Invalid reviewed spotlight list.")
405
+ with workspace_lock(root):
406
+ path = directory(root)
407
+ files = shot_files(path, limited=False)
408
+ if expected is not None and set(expected) != {file.stem for file in files}:
409
+ fail("The spotlight list changed; review it and confirm flush again.")
410
+ # Cleanup only: the current mechanism never reads or creates node copies.
411
+ legacy = list((path / "blobs").iterdir()) if (path / "blobs").exists() else []
412
+ if any(
413
+ p.is_symlink()
414
+ or not p.is_file()
415
+ or not (OBJECT_ID.fullmatch(p.name) or TEMP_FILE.fullmatch(p.name))
416
+ for p in legacy
417
+ ):
418
+ fail("Obsolete spotlight blobs contain an unsafe entry; nothing removed.")
419
+ if files or legacy:
420
+ ensure_ignored(root)
421
+ for file in files + legacy:
422
+ file.unlink(missing_ok=True)
423
+ if path.exists():
424
+ for temporary in path.glob(".shot-*.tmp"):
425
+ if (
426
+ TEMP_FILE.fullmatch(temporary.name)
427
+ and not temporary.is_symlink()
428
+ and temporary.is_file()
429
+ ):
430
+ temporary.unlink(missing_ok=True)
431
+ if (path / "blobs").exists():
432
+ (path / "blobs").rmdir()
433
+ return {"flushed": len(files)}
434
+
435
+
436
+ def listing(root, shot_id=None):
437
+ shots = deepcopy(load_shots(root))
438
+ if shot_id is not None:
439
+ shots = [s for s in shots if s["id"] == shot_id]
440
+ if not shots:
441
+ fail("No local spotlight has that ID.")
442
+ cache = {}
443
+ for shot in shots:
444
+ context = shot["comparison"]
445
+ key = context["base"], context["target"]
446
+ if key not in cache:
447
+ try:
448
+ old, new = snapshots(root, *key)
449
+ changes = {c["id"]: c["kind"] for c in semantic_diff(old, new)["changes"]}
450
+ state = (
451
+ workspace_state(new, old if key[0] == "HEAD" else None)
452
+ if key[1] == "workspace"
453
+ else None
454
+ )
455
+ cache[key] = old, new, changes, state, None
456
+ except SpecError as error:
457
+ cache[key] = None, None, {}, None, "; ".join(d.message for d in error.diagnostics)
458
+ old, new, changes, state, error = cache[key]
459
+ shot["warnings"] = workspace_warnings(shot.get("workspace"), state)
460
+ if error:
461
+ shot["warnings"].append("Comparison unavailable: " + error)
462
+ members = []
463
+ for node_id in shot["nodes"]:
464
+ node = (
465
+ (
466
+ (new.graph.nodes.get(node_id) if new.graph else None)
467
+ or (old.graph.nodes.get(node_id) if old.graph else None)
468
+ )
469
+ if new
470
+ else None
471
+ )
472
+ unreadable = not node and any(
473
+ f".specspec/{node_id}.md" in s.files for s in (old, new) if s
474
+ )
475
+ kind = (
476
+ "unavailable"
477
+ if error or unreadable
478
+ else changes.get(node_id, "unchanged")
479
+ if node
480
+ else "absent"
481
+ )
482
+ title = (
483
+ next(
484
+ (line.lstrip("# ") for line in node.body.splitlines() if line.strip()), node_id
485
+ )
486
+ if node
487
+ else None
488
+ )
489
+ members.append(
490
+ {"id": node_id, "kind": kind, "title": title, "path": node.path if node else None}
491
+ )
492
+ shot["members"] = members
493
+ return {"shots": shots}
@@ -0,0 +1,42 @@
1
+ """Bounded API-7 frames; requests retain the ordinary application authority."""
2
+
3
+ import json
4
+
5
+ from .application import request
6
+ from .editor import MAX_REQUEST_BYTES
7
+ from .model import Diagnostic, SpecError
8
+ from .output import envelope, json_text
9
+
10
+
11
+ def decode_request(raw):
12
+ try:
13
+ if len(raw) > MAX_REQUEST_BYTES:
14
+ raise ValueError("Request exceeds the 16 MiB limit.")
15
+ return json.loads(raw)
16
+ except (ValueError, UnicodeError) as error:
17
+ raise SpecError([Diagnostic("API", f"Invalid application JSON: {error}")], 2) from error
18
+
19
+
20
+ def serve(root, reader, writer):
21
+ """One bounded line in, one flushed ordinary envelope out; EOF closes the channel."""
22
+
23
+ def send(result=None, diagnostics=(), ok=False):
24
+ writer.write((json_text(envelope(result, diagnostics, ok)) + "\n").encode("utf-8"))
25
+ writer.flush()
26
+
27
+ while True:
28
+ raw = reader.readline(MAX_REQUEST_BYTES + 2)
29
+ if not raw:
30
+ return 0
31
+ if not raw.endswith(b"\n") or len(raw) > MAX_REQUEST_BYTES + 1:
32
+ send(
33
+ diagnostics=[Diagnostic("API", "Application frame needs a bounded complete line.")]
34
+ )
35
+ return 2 # Framing loss terminates; never guess the next request boundary.
36
+ try:
37
+ result, diagnostics = request(root, decode_request(raw[:-1]))
38
+ send(result, diagnostics, True)
39
+ except SpecError as error:
40
+ send(diagnostics=error.diagnostics)
41
+ except (OSError, UnicodeError) as error:
42
+ send(diagnostics=[Diagnostic("IO", str(error))])