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 +3 -0
- specspec/__main__.py +3 -0
- specspec/annotation_commands.py +118 -0
- specspec/annotations.py +493 -0
- specspec/api_transport.py +42 -0
- specspec/application.py +161 -0
- specspec/batch.py +219 -0
- specspec/body_replacements.py +83 -0
- specspec/cli.py +905 -0
- specspec/cli_help.py +463 -0
- specspec/deletion.py +97 -0
- specspec/edit_cli.py +106 -0
- specspec/editor.py +263 -0
- specspec/format.py +231 -0
- specspec/git.py +285 -0
- specspec/git_objects.py +164 -0
- specspec/graph.py +206 -0
- specspec/inspection.py +180 -0
- specspec/model.py +82 -0
- specspec/node_text.py +32 -0
- specspec/output.py +301 -0
- specspec/query.py +407 -0
- specspec/query_select.py +270 -0
- specspec/query_sources.py +243 -0
- specspec/replacement_matching.py +270 -0
- specspec/source_commands.py +282 -0
- specspec/source_schema.py +65 -0
- specspec/sources.py +448 -0
- specspec/templates/AGENTS.md +205 -0
- specspec/wire.py +51 -0
- specspec/workspace.py +830 -0
- specspec-0.0.1.dist-info/METADATA +253 -0
- specspec-0.0.1.dist-info/RECORD +36 -0
- specspec-0.0.1.dist-info/WHEEL +4 -0
- specspec-0.0.1.dist-info/entry_points.txt +2 -0
- specspec-0.0.1.dist-info/licenses/LICENSE +21 -0
specspec/__init__.py
ADDED
specspec/__main__.py
ADDED
|
@@ -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
|
+
)
|
specspec/annotations.py
ADDED
|
@@ -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))])
|