scry-parse 1.0.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.
scry_parse/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """scry-parse: Python parser for scry-spec v1.0 markers.
2
+
3
+ Public API:
4
+ parse_markers(content, language=None, file="") -> ParseResult
5
+ validate_marker(marker) -> ValidationResult
6
+ mint_id(kind, name, content=None) -> str
7
+ BASELINE_KINDS
8
+ BASELINE_STATUSES
9
+
10
+ Dataclasses:
11
+ EntryMarker
12
+ AnchorMarker
13
+ BindingMarker
14
+ ParseResult
15
+ ValidationResult
16
+ """
17
+
18
+ from scry_parse.markers import (
19
+ parse_markers,
20
+ EntryMarker,
21
+ AnchorMarker,
22
+ BindingMarker,
23
+ ParseResult,
24
+ )
25
+ from scry_parse.validate import validate_marker, ValidationResult
26
+ from scry_parse.mint import mint_id
27
+ from scry_parse.consts import BASELINE_KINDS, BASELINE_STATUSES
28
+
29
+ __all__ = [
30
+ "parse_markers",
31
+ "EntryMarker",
32
+ "AnchorMarker",
33
+ "BindingMarker",
34
+ "ParseResult",
35
+ "validate_marker",
36
+ "ValidationResult",
37
+ "mint_id",
38
+ "BASELINE_KINDS",
39
+ "BASELINE_STATUSES",
40
+ ]
41
+
42
+ __version__ = "1.0.0"
scry_parse/consts.py ADDED
@@ -0,0 +1,19 @@
1
+ """Constants for scry-spec v1.0: baseline kinds, statuses, and ID regexes."""
2
+ import re
3
+
4
+ BASELINE_KINDS = (
5
+ "design", "pattern", "spec", "lesson", "internal",
6
+ "task", "milestone",
7
+ "report", "audit", "research",
8
+ "code",
9
+ )
10
+
11
+ BASELINE_STATUSES = ("draft", "active", "deprecated")
12
+
13
+ # ID validation regexes per scry-spec v1.0
14
+ # Entry/doc IDs: kind.name~8hexchars
15
+ ID_REGEX = re.compile(r'^[a-z]+\.[a-z0-9-]+~[a-f0-9]{8}$')
16
+ # Anchor/impl/test IDs: name~8hexchars (no dot)
17
+ ANCHOR_ID_REGEX = re.compile(r'^[a-z0-9-]+~[a-f0-9]{8}$')
18
+ # Loose anchor IDs used in refs: uppercase letters followed by digits (e.g. FR3, UT1)
19
+ LOOSE_ANCHOR_REGEX = re.compile(r'^[A-Z]+[0-9]+$')
scry_parse/markers.py ADDED
@@ -0,0 +1,574 @@
1
+ """Marker parsing for scry-spec v1.0.
2
+
3
+ Implements FR1-FR11. Recognizes:
4
+ - @scry.entry (block marker — declarative knowledge entry)
5
+ - @scry.anchor (block marker — named code location)
6
+ - @scry.bind (line or block marker — binding / cross-reference)
7
+
8
+ Comment prefix detection is inferred from the opening sentinel line.
9
+ Legacy @scry.doc and @scry.file are not recognized per v1.0.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Dataclasses
22
+ # ---------------------------------------------------------------------------
23
+
24
+ @dataclass
25
+ class EntryMarker:
26
+ id: str
27
+ kind: str
28
+ summary: str
29
+ status: str
30
+ weight: float | None # None → default 0.5
31
+ tags: list[str]
32
+ rationale: str | None
33
+ applies: str | None
34
+ seeded_questions: list[str]
35
+ depends_on: list[str]
36
+ implements: str | None
37
+ supersedes: str | None
38
+ file: str
39
+ span: tuple[int, int] # (start_line, end_line) 1-indexed
40
+
41
+
42
+ @dataclass
43
+ class AnchorMarker:
44
+ name: str # the {local-id} from the sentinel
45
+ description: str
46
+ seeded_questions: list[str]
47
+ file: str
48
+ span: tuple[int, int]
49
+
50
+
51
+ @dataclass
52
+ class BindingMarker:
53
+ local_id: str
54
+ ref: str
55
+ comment: str | None
56
+ file: str
57
+ offset: int # line number (1-indexed) of the @scry.bind line
58
+ span: tuple[int, int] | None # (start, end) for block form; None for single-line
59
+
60
+
61
+ @dataclass
62
+ class ParseResult:
63
+ entries: list[EntryMarker] = field(default_factory=list)
64
+ anchors: list[AnchorMarker] = field(default_factory=list)
65
+ bindings: list[BindingMarker] = field(default_factory=list)
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Comment-prefix detection
70
+ # ---------------------------------------------------------------------------
71
+
72
+ # Maps sentinel prefix → (open_prefix, body_strip_prefix, close_suffix)
73
+ # For markdown: body lines are raw (no prefix strip); open/close include <!-- -->
74
+ _COMMENT_STYLES: list[tuple[str, str | None, str | None]] = [
75
+ # (detect_prefix, body_line_prefix_to_strip, close_suffix_hint)
76
+ ("<!-- ", None, " -->"), # HTML/markdown — body is raw, strip nothing
77
+ ("# ", "# ", None), # Python / shell
78
+ ("// ", "// ", None), # TypeScript / JS
79
+ ("-- ", "-- ", None), # SQL
80
+ (";; ", ";; ", None), # Lisp double-semi
81
+ ("; ", "; ", None), # Lisp single-semi
82
+ ]
83
+
84
+
85
+ def _detect_comment_style(sentinel_line: str) -> tuple[str | None, str | None]:
86
+ """Return (body_strip_prefix, close_suffix) from the opening sentinel line.
87
+
88
+ body_strip_prefix: prefix to strip from each body line (None = strip nothing)
89
+ close_suffix: suffix that appears on the closing sentinel line (e.g. ' -->')
90
+ """
91
+ for detect, body_prefix, close_suffix in _COMMENT_STYLES:
92
+ if detect in sentinel_line:
93
+ return body_prefix, close_suffix
94
+ return None, None
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Regexes
99
+ # ---------------------------------------------------------------------------
100
+
101
+ # Matches the open sentinel for block markers (entry/anchor)
102
+ # Captures: (kind, rest_of_line)
103
+ _BLOCK_OPEN_RE = re.compile(r'@scry\.(entry|anchor)(?!\.end)\b(.*)')
104
+ # Matches the close sentinel
105
+ _BLOCK_CLOSE_RE = re.compile(r'@scry\.(entry|anchor)\.end\b')
106
+
107
+ # Bind markers
108
+ _BIND_OPEN_RE = re.compile(r'@scry\.bind(?!\.end)\b(.*)')
109
+ _BIND_CLOSE_RE = re.compile(r'@scry\.bind\.end\b')
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Internal helpers
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _strip_body_prefix(line: str, prefix: str | None) -> str:
117
+ """Strip the comment prefix from a single body line."""
118
+ if prefix is None:
119
+ return line
120
+ if line.startswith(prefix):
121
+ return line[len(prefix):]
122
+ # Handle trailing-whitespace variant (e.g. "#\n" instead of "# \n")
123
+ stripped = prefix.rstrip()
124
+ if stripped and line.startswith(stripped):
125
+ return line[len(stripped):].lstrip(" ")
126
+ return line
127
+
128
+
129
+ def _clean_body(lines: list[str], body_strip_prefix: str | None) -> str:
130
+ """Strip comment prefixes from all body lines and join."""
131
+ cleaned = [_strip_body_prefix(ln, body_strip_prefix) for ln in lines]
132
+ return "".join(cleaned)
133
+
134
+
135
+ # Matches any @scry.* marker token line that might appear inside a block body
136
+ _SCRY_LINE_RE = re.compile(r'^\s*@scry\.\w+.*$', re.MULTILINE)
137
+
138
+
139
+ def _parse_yaml(text: str) -> dict[str, Any] | None:
140
+ """Parse YAML safely; return None on failure.
141
+
142
+ Strips any embedded @scry.* marker lines before parsing (they can appear
143
+ inside block bodies due to FR3 positional exclusion — the parser doesn't
144
+ index them, but they can still break YAML).
145
+ """
146
+ # Remove embedded @scry.* lines to avoid YAML parse errors
147
+ cleaned = _SCRY_LINE_RE.sub("", text)
148
+ try:
149
+ data = yaml.safe_load(cleaned)
150
+ except yaml.YAMLError:
151
+ return None
152
+ if not isinstance(data, dict):
153
+ return None
154
+ return data
155
+
156
+
157
+ def _coerce_list(value: Any) -> list[str]:
158
+ if value is None:
159
+ return []
160
+ if isinstance(value, list):
161
+ return [str(v) for v in value]
162
+ if isinstance(value, str):
163
+ return [value] if value.strip() else []
164
+ return [str(value)]
165
+
166
+
167
+ def _coerce_str(value: Any) -> str | None:
168
+ if value is None:
169
+ return None
170
+ s = str(value).strip()
171
+ return s if s else None
172
+
173
+
174
+ def _coerce_float(value: Any) -> float | None:
175
+ if value is None:
176
+ return None
177
+ try:
178
+ return float(value)
179
+ except (TypeError, ValueError):
180
+ return None
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Block-span finder
185
+ # ---------------------------------------------------------------------------
186
+
187
+ def _find_block_spans(lines: list[str]) -> list[tuple[int, int, str, str]]:
188
+ """Find all declarative block spans (entry, anchor).
189
+
190
+ Returns list of (start_line_1idx, end_line_1idx, kind, sentinel_line).
191
+ Lines are 1-indexed (matching span convention).
192
+ """
193
+ spans: list[tuple[int, int, str, str]] = []
194
+ i = 0
195
+ while i < len(lines):
196
+ line = lines[i]
197
+ m = _BLOCK_OPEN_RE.search(line)
198
+ if not m:
199
+ i += 1
200
+ continue
201
+ kind = m.group(1)
202
+ open_line_idx = i # 0-indexed
203
+ # Scan forward for matching close
204
+ j = i + 1
205
+ close_idx = None
206
+ while j < len(lines):
207
+ cm = _BLOCK_CLOSE_RE.search(lines[j])
208
+ if cm and cm.group(1) == kind:
209
+ close_idx = j
210
+ break
211
+ j += 1
212
+ if close_idx is None:
213
+ # Unterminated block — skip (FR: unterminated → 0 entries)
214
+ i += 1
215
+ continue
216
+ # 1-indexed span
217
+ spans.append((open_line_idx + 1, close_idx + 1, kind, line))
218
+ i = close_idx + 1
219
+ return spans
220
+
221
+
222
+ def _inside_any_span(
223
+ line_1idx: int,
224
+ spans: list[tuple[int, int, str, str]],
225
+ ) -> bool:
226
+ """Return True if line_1idx falls inside any declarative block span."""
227
+ for start, end, _kind, _sentinel in spans:
228
+ if start <= line_1idx <= end:
229
+ return True
230
+ return False
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Binding marker parser
235
+ # ---------------------------------------------------------------------------
236
+
237
+ def _parse_bindings(
238
+ lines: list[str],
239
+ block_spans: list[tuple[int, int, str, str]],
240
+ body_strip_prefix: str | None,
241
+ file: str,
242
+ ) -> list[BindingMarker]:
243
+ """Scan lines for @scry.bind markers.
244
+
245
+ FR3: skip any bind line that falls inside a declarative block span.
246
+ FR2: forward-scan to determine block vs single-line form.
247
+ """
248
+ result: list[BindingMarker] = []
249
+ i = 0
250
+ while i < len(lines):
251
+ line_1idx = i + 1
252
+ line = lines[i]
253
+
254
+ m = _BIND_OPEN_RE.search(line)
255
+ if not m:
256
+ i += 1
257
+ continue
258
+
259
+ # FR3 exclusion
260
+ if _inside_any_span(line_1idx, block_spans):
261
+ i += 1
262
+ continue
263
+
264
+ rest = m.group(1).strip()
265
+
266
+ # Forward-scan disambiguation (FR2):
267
+ # Look for @scry.bind.end before the next @scry.bind
268
+ is_block = False
269
+ close_line_idx = None
270
+ j = i + 1
271
+ while j < len(lines):
272
+ if _BIND_CLOSE_RE.search(lines[j]):
273
+ is_block = True
274
+ close_line_idx = j
275
+ break
276
+ if _BIND_OPEN_RE.search(lines[j]):
277
+ # Next bind found before close → single-line form
278
+ break
279
+ j += 1
280
+
281
+ if is_block and close_line_idx is not None:
282
+ # Block form: extract body lines between open and close
283
+ body_lines = lines[i + 1: close_line_idx]
284
+ body_text = _clean_body(body_lines, body_strip_prefix).strip()
285
+ span: tuple[int, int] | None = (line_1idx, close_line_idx + 1)
286
+ # Parse local_id, ref, comment from rest (open line) + body
287
+ local_id, ref, comment = _parse_bind_content(rest, body_text)
288
+ end_line = close_line_idx + 1
289
+ i = close_line_idx + 1
290
+ else:
291
+ # Single-line form
292
+ body_text = ""
293
+ span = None
294
+ local_id, ref, comment = _parse_bind_content(rest, "")
295
+ end_line = line_1idx
296
+ i += 1
297
+
298
+ if not local_id or not ref:
299
+ continue
300
+
301
+ # Multi-anchor expansion (FR2): ref may contain {id}#{A},{B},{C}
302
+ bindings = _expand_binding(local_id, ref, comment, file, line_1idx, span)
303
+ result.extend(bindings)
304
+
305
+ return result
306
+
307
+
308
+ def _parse_bind_content(
309
+ rest: str, body_text: str
310
+ ) -> tuple[str, str, str | None]:
311
+ """Extract local_id, ref, and optional comment from bind marker content.
312
+
313
+ Single-line form: `@scry.bind <local_id> <ref> [# comment]`
314
+ Block form: open line has local_id + ref; body has comment.
315
+ """
316
+ # Try to parse from the rest (tokens after @scry.bind)
317
+ tokens = rest.split()
318
+ local_id = tokens[0] if len(tokens) >= 1 else ""
319
+ ref = tokens[1] if len(tokens) >= 2 else ""
320
+
321
+ # Comment: anything after the second token on the open line,
322
+ # or the body text for block form
323
+ if len(tokens) >= 3:
324
+ comment: str | None = " ".join(tokens[2:]).lstrip("# ").strip()
325
+ elif body_text:
326
+ comment = body_text
327
+ else:
328
+ comment = None
329
+
330
+ if comment == "":
331
+ comment = None
332
+
333
+ return local_id, ref, comment
334
+
335
+
336
+ def _expand_binding(
337
+ local_id: str,
338
+ ref: str,
339
+ comment: str | None,
340
+ file: str,
341
+ offset: int,
342
+ span: tuple[int, int] | None,
343
+ ) -> list[BindingMarker]:
344
+ """Expand multi-anchor refs (FR2).
345
+
346
+ If ref is `{artifact}#{A},{B},{C}`, expand to N BindingMarker records.
347
+ Range syntax `{A}-{B}` is treated as a single anchor name (no expansion).
348
+ """
349
+ # Check for comma-separated loose anchors in the fragment
350
+ # Pattern: something~hash#ANCHOR1,ANCHOR2,...
351
+ # or just: something#ANCHOR1,ANCHOR2,...
352
+ comma_re = re.compile(r'^(.+)#([^,#]+(?:,[^,#]+)+)$')
353
+ m = comma_re.match(ref)
354
+ if m:
355
+ base = m.group(1)
356
+ anchors_str = m.group(2)
357
+ anchors = [a.strip() for a in anchors_str.split(",")]
358
+ # Only expand if these look like loose anchors (LOOSE_ANCHOR_REGEX)
359
+ from scry_parse.consts import LOOSE_ANCHOR_REGEX
360
+ if all(LOOSE_ANCHOR_REGEX.match(a) for a in anchors):
361
+ return [
362
+ BindingMarker(
363
+ local_id=local_id,
364
+ ref=f"{base}#{anchor}",
365
+ comment=comment,
366
+ file=file,
367
+ offset=offset,
368
+ span=span,
369
+ )
370
+ for anchor in anchors
371
+ ]
372
+
373
+ # Single binding
374
+ return [BindingMarker(
375
+ local_id=local_id,
376
+ ref=ref,
377
+ comment=comment,
378
+ file=file,
379
+ offset=offset,
380
+ span=span,
381
+ )]
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Main parser
386
+ # ---------------------------------------------------------------------------
387
+
388
+ def parse_markers(
389
+ content: str,
390
+ language: str | None = None,
391
+ file: str = "",
392
+ ) -> ParseResult:
393
+ """Parse all scry markers from file content.
394
+
395
+ Args:
396
+ content: The raw file content to parse.
397
+ language: Optional language hint (currently unused; style is
398
+ inferred from the sentinel line).
399
+ file: Optional file path to record in markers.
400
+
401
+ Returns:
402
+ ParseResult with entries, anchors, and bindings.
403
+ """
404
+ result = ParseResult()
405
+ if "@scry." not in content:
406
+ return result
407
+
408
+ lines = content.splitlines(keepends=True)
409
+
410
+ # First pass: find all declarative block spans
411
+ block_spans = _find_block_spans(lines)
412
+
413
+ # Second pass: parse each block
414
+ for start_1idx, end_1idx, kind, sentinel_line in block_spans:
415
+ body_strip_prefix, _close_suffix = _detect_comment_style(sentinel_line)
416
+
417
+ # Body lines (between open and close, exclusive)
418
+ body_lines = lines[start_1idx: end_1idx - 1] # 0-indexed slice
419
+ body_text = _clean_body(body_lines, body_strip_prefix)
420
+
421
+ if kind == "anchor":
422
+ _parse_anchor_block(
423
+ sentinel_line, body_text, file,
424
+ (start_1idx, end_1idx), result
425
+ )
426
+ elif kind == "entry":
427
+ _parse_entry_block(
428
+ body_text, file, (start_1idx, end_1idx), result
429
+ )
430
+
431
+ # Third pass: parse binding markers
432
+ bindings = _parse_bindings(lines, block_spans, None, file)
433
+
434
+ # For bindings, we need to infer the body_strip_prefix per bind line.
435
+ # Re-do with proper per-line prefix detection.
436
+ result.bindings = _parse_bindings_with_prefix(lines, block_spans, file)
437
+
438
+ return result
439
+
440
+
441
+ def _parse_bindings_with_prefix(
442
+ lines: list[str],
443
+ block_spans: list[tuple[int, int, str, str]],
444
+ file: str,
445
+ ) -> list[BindingMarker]:
446
+ """Parse bindings, detecting comment prefix per bind sentinel line."""
447
+ result: list[BindingMarker] = []
448
+ i = 0
449
+ while i < len(lines):
450
+ line_1idx = i + 1
451
+ line = lines[i]
452
+
453
+ m = _BIND_OPEN_RE.search(line)
454
+ if not m:
455
+ i += 1
456
+ continue
457
+
458
+ # FR3 exclusion
459
+ if _inside_any_span(line_1idx, block_spans):
460
+ i += 1
461
+ continue
462
+
463
+ # Detect comment style from this line
464
+ body_strip_prefix, _close_suffix = _detect_comment_style(line)
465
+ rest = m.group(1).strip()
466
+
467
+ # Forward-scan disambiguation (FR2)
468
+ is_block = False
469
+ close_line_idx = None
470
+ j = i + 1
471
+ while j < len(lines):
472
+ if _BIND_CLOSE_RE.search(lines[j]):
473
+ is_block = True
474
+ close_line_idx = j
475
+ break
476
+ if _BIND_OPEN_RE.search(lines[j]):
477
+ break
478
+ j += 1
479
+
480
+ if is_block and close_line_idx is not None:
481
+ body_lines = lines[i + 1: close_line_idx]
482
+ body_text = _clean_body(body_lines, body_strip_prefix).strip()
483
+ span: tuple[int, int] | None = (line_1idx, close_line_idx + 1)
484
+ local_id, ref, comment = _parse_bind_content(rest, body_text)
485
+ i = close_line_idx + 1
486
+ else:
487
+ span = None
488
+ local_id, ref, comment = _parse_bind_content(rest, "")
489
+ i += 1
490
+
491
+ if not local_id or not ref:
492
+ continue
493
+
494
+ bindings = _expand_binding(local_id, ref, comment, file, line_1idx, span)
495
+ result.extend(bindings)
496
+
497
+ return result
498
+
499
+
500
+ def _parse_anchor_block(
501
+ sentinel_line: str,
502
+ body_text: str,
503
+ file: str,
504
+ span: tuple[int, int],
505
+ result: ParseResult,
506
+ ) -> None:
507
+ """Parse an @scry.anchor block and append to result."""
508
+ # Extract name from sentinel line: `@scry.anchor {name}`
509
+ m = _BLOCK_OPEN_RE.search(sentinel_line)
510
+ if not m:
511
+ return
512
+ rest = m.group(2).strip()
513
+ # Name is the first token after the sentinel
514
+ name_part = rest.split()[0] if rest.split() else ""
515
+ if not name_part:
516
+ return
517
+
518
+ data = _parse_yaml(body_text) or {}
519
+ description = _coerce_str(data.get("description")) or ""
520
+ seeded_questions = _coerce_list(data.get("seeded_questions"))
521
+
522
+ result.anchors.append(AnchorMarker(
523
+ name=name_part,
524
+ description=description,
525
+ seeded_questions=seeded_questions,
526
+ file=file,
527
+ span=span,
528
+ ))
529
+
530
+
531
+ def _parse_entry_block(
532
+ body_text: str,
533
+ file: str,
534
+ span: tuple[int, int],
535
+ result: ParseResult,
536
+ ) -> None:
537
+ """Parse a @scry.entry block and append to result."""
538
+ data = _parse_yaml(body_text)
539
+ if data is None:
540
+ # Invalid YAML — fail gracefully, produce 0 entries
541
+ return
542
+
543
+ marker_id = _coerce_str(data.get("id"))
544
+ if not marker_id:
545
+ return
546
+
547
+ kind = _coerce_str(data.get("kind")) or "internal"
548
+ summary = _coerce_str(data.get("summary")) or ""
549
+ status = _coerce_str(data.get("status")) or "draft"
550
+ weight = _coerce_float(data.get("weight"))
551
+ tags = _coerce_list(data.get("tags"))
552
+ rationale = _coerce_str(data.get("rationale"))
553
+ applies = _coerce_str(data.get("applies"))
554
+ seeded_questions = _coerce_list(data.get("seeded_questions"))
555
+ depends_on = _coerce_list(data.get("depends_on"))
556
+ implements = _coerce_str(data.get("implements"))
557
+ supersedes = _coerce_str(data.get("supersedes"))
558
+
559
+ result.entries.append(EntryMarker(
560
+ id=marker_id,
561
+ kind=kind,
562
+ summary=summary,
563
+ status=status,
564
+ weight=weight,
565
+ tags=tags,
566
+ rationale=rationale,
567
+ applies=applies,
568
+ seeded_questions=seeded_questions,
569
+ depends_on=depends_on,
570
+ implements=implements,
571
+ supersedes=supersedes,
572
+ file=file,
573
+ span=span,
574
+ ))
scry_parse/mint.py ADDED
@@ -0,0 +1,34 @@
1
+ """ID generation for scry-spec v1.0 markers."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import secrets
6
+
7
+
8
+ def mint_id(kind: str, name: str, content: str | None = None) -> str:
9
+ """Generate a spec-conformant marker ID: '{kind}.{name}~{hash}'.
10
+
11
+ For entry markers, the full ID is '{kind}.{name}~{8hexchars}'.
12
+ For anchor/impl/test markers, the convention is '{name}~{8hexchars}'
13
+ (no dot prefix) — callers should pass name only without kind in that case,
14
+ or use kind="" and name="name~...".
15
+
16
+ Args:
17
+ kind: The marker kind (e.g. "design", "spec"). Use "" for anchors/impls/tests
18
+ where the ID format is just '{name}~{hash}'.
19
+ name: The human-readable name portion (e.g. "auth-flow").
20
+ content: Optional content for deterministic hash. If None, a random
21
+ 4-byte hex string is generated.
22
+
23
+ Returns:
24
+ A spec-conformant ID string.
25
+ """
26
+ if content is not None:
27
+ hash_hex = hashlib.sha256(content.encode()).hexdigest()[:8]
28
+ else:
29
+ hash_hex = secrets.token_hex(4) # 4 bytes = 8 hex chars
30
+
31
+ if kind:
32
+ return f"{kind}.{name}~{hash_hex}"
33
+ else:
34
+ return f"{name}~{hash_hex}"
scry_parse/validate.py ADDED
@@ -0,0 +1,116 @@
1
+ """Validation for parsed scry markers per scry-spec v1.0."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from scry_parse.consts import (
7
+ ID_REGEX,
8
+ ANCHOR_ID_REGEX,
9
+ BASELINE_KINDS,
10
+ BASELINE_STATUSES,
11
+ )
12
+ from scry_parse.markers import EntryMarker, AnchorMarker, BindingMarker
13
+
14
+
15
+ @dataclass
16
+ class ValidationResult:
17
+ valid: bool
18
+ errors: list[str]
19
+ warnings: list[str]
20
+
21
+
22
+ def validate_marker(marker: EntryMarker | AnchorMarker | BindingMarker) -> ValidationResult:
23
+ """Validate a parsed marker against spec rules.
24
+
25
+ Returns a ValidationResult with errors (spec violations) and warnings
26
+ (non-standard but tolerated values).
27
+ """
28
+ if isinstance(marker, EntryMarker):
29
+ return _validate_entry(marker)
30
+ if isinstance(marker, AnchorMarker):
31
+ return _validate_anchor(marker)
32
+ if isinstance(marker, BindingMarker):
33
+ return _validate_binding(marker)
34
+ return ValidationResult(
35
+ valid=False,
36
+ errors=[f"Unknown marker type: {type(marker).__name__}"],
37
+ warnings=[],
38
+ )
39
+
40
+
41
+ def _validate_entry(marker: EntryMarker) -> ValidationResult:
42
+ errors: list[str] = []
43
+ warnings: list[str] = []
44
+
45
+ # id must match ID_REGEX
46
+ if not marker.id or not ID_REGEX.match(marker.id):
47
+ errors.append(
48
+ f"id {marker.id!r} does not match expected pattern "
49
+ f"'<kind>.<name>~<8hexchars>' (got {marker.id!r})"
50
+ )
51
+
52
+ # kind must be non-empty; warn if not in BASELINE_KINDS
53
+ if not marker.kind or not marker.kind.strip():
54
+ errors.append("kind must be a non-empty string")
55
+ elif marker.kind not in BASELINE_KINDS:
56
+ warnings.append(
57
+ f"kind {marker.kind!r} is not in the baseline kinds list; "
58
+ f"expected one of: {', '.join(BASELINE_KINDS)}"
59
+ )
60
+
61
+ # summary must be non-empty
62
+ if not marker.summary or not marker.summary.strip():
63
+ errors.append("summary must be non-empty")
64
+
65
+ # status must be non-empty; warn if not in BASELINE_STATUSES
66
+ if not marker.status or not marker.status.strip():
67
+ errors.append("status must be a non-empty string")
68
+ elif marker.status not in BASELINE_STATUSES:
69
+ warnings.append(
70
+ f"status {marker.status!r} is not in the baseline statuses list; "
71
+ f"expected one of: {', '.join(BASELINE_STATUSES)}"
72
+ )
73
+
74
+ valid = len(errors) == 0
75
+ return ValidationResult(valid=valid, errors=errors, warnings=warnings)
76
+
77
+
78
+ def _validate_anchor(marker: AnchorMarker) -> ValidationResult:
79
+ errors: list[str] = []
80
+ warnings: list[str] = []
81
+
82
+ # name must match ANCHOR_ID_REGEX
83
+ if not marker.name or not ANCHOR_ID_REGEX.match(marker.name):
84
+ errors.append(
85
+ f"name {marker.name!r} does not match expected pattern "
86
+ f"'<name>~<8hexchars>' (got {marker.name!r})"
87
+ )
88
+
89
+ # description must be non-empty
90
+ if not marker.description or not marker.description.strip():
91
+ errors.append("description must be non-empty")
92
+
93
+ # seeded_questions must be declared (can be empty list)
94
+ # It's a list type so always present; no additional check needed.
95
+
96
+ valid = len(errors) == 0
97
+ return ValidationResult(valid=valid, errors=errors, warnings=warnings)
98
+
99
+
100
+ def _validate_binding(marker: BindingMarker) -> ValidationResult:
101
+ errors: list[str] = []
102
+ warnings: list[str] = []
103
+
104
+ # local_id must match ANCHOR_ID_REGEX
105
+ if not marker.local_id or not ANCHOR_ID_REGEX.match(marker.local_id):
106
+ errors.append(
107
+ f"local_id {marker.local_id!r} does not match expected pattern "
108
+ f"'<name>~<8hexchars>' (got {marker.local_id!r})"
109
+ )
110
+
111
+ # ref must be non-empty
112
+ if not marker.ref or not marker.ref.strip():
113
+ errors.append("ref must be non-empty")
114
+
115
+ valid = len(errors) == 0
116
+ return ValidationResult(valid=valid, errors=errors, warnings=warnings)
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: scry-parse
3
+ Version: 1.0.0
4
+ Summary: Python parser for scry-spec v1.0 markers
5
+ Project-URL: Repository, https://github.com/prmichaelsen/scry-spec
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 reflection
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Requires-Python: >=3.11
29
+ Requires-Dist: pyyaml>=6.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.0; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # scry-parse
35
+
36
+ Python parser for the [scry-spec v1.0](https://github.com/prmichaelsen/scry-spec) marker format.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ uv pip install scry-parse
42
+ # or:
43
+ pip install scry-parse
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from scry_parse import parse_markers, validate_marker, mint_id, BASELINE_KINDS, BASELINE_STATUSES
50
+
51
+ # Parse markers from file content
52
+ with open("my_file.md") as f:
53
+ content = f.read()
54
+
55
+ result = parse_markers(content)
56
+
57
+ for entry in result.entries:
58
+ print(entry.id, entry.kind, entry.summary)
59
+
60
+ for anchor in result.anchors:
61
+ print(anchor.name, anchor.description)
62
+
63
+ for binding in result.bindings:
64
+ print(binding.local_id, binding.ref)
65
+
66
+ # Validate a parsed marker
67
+ vr = validate_marker(result.entries[0])
68
+ if not vr.valid:
69
+ print(vr.errors)
70
+
71
+ # Generate a new marker ID
72
+ entry_id = mint_id("design", "auth-flow") # random hash
73
+ entry_id = mint_id("design", "auth-flow", content) # deterministic hash
74
+ ```
75
+
76
+ ## Supported comment styles
77
+
78
+ - HTML/Markdown: `<!-- @scry.entry ... @scry.entry.end -->`
79
+ - Python/Shell: `# @scry.entry ... # @scry.entry.end`
80
+ - TypeScript/JS: `// @scry.entry ... // @scry.entry.end`
81
+ - SQL: `-- @scry.entry ... -- @scry.entry.end`
82
+ - Lisp: `;; @scry.entry ... ;; @scry.entry.end`
83
+
84
+ ## License
85
+
86
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ scry_parse/__init__.py,sha256=GiNrb0jdZ2aXFwP-mNgRKM6X2_ek5chaGxBhCxq2Mnk,915
2
+ scry_parse/consts.py,sha256=ILXHa9pzv8WFXXtCNTZYLAmDN58zmw8nhW5PCm-qj38,675
3
+ scry_parse/markers.py,sha256=iSkS-jHSxD1If0btrs91-9jIU3iaKYxKWgCLD_AVd3k,18003
4
+ scry_parse/mint.py,sha256=jQ8oI-qUb_HyEljSGGU4CAL6kXMyWgmo7NjglDbf0RE,1197
5
+ scry_parse/validate.py,sha256=BHe9I9t5xf9FEcecOYb3Q41FHGrA6SYGOkPVxpbjluM,3893
6
+ scry_parse-1.0.0.dist-info/METADATA,sha256=VkJZ4nW2MgOx4vhvUdSaOTFdL19Z7177yGBezAnsA94,2857
7
+ scry_parse-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ scry_parse-1.0.0.dist-info/licenses/LICENSE,sha256=9j-1QE48XqQoy_Sh9nueXMhr7z2niD1G9tS9QM9Zb_Q,1067
9
+ scry_parse-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 reflection
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.