#!/usr/bin/env python3 """ Path-scoped spec matching for on-demand spec injection. Spec files under `.trellis/spec/**/*.md` MAY start with a YAML-like frontmatter block declaring which repo paths they govern: --- name: commands-workflow description: workflow command conventions paths: - packages/cli/src/commands/workflow.ts - packages/cli/src/utils/workflow-resolver.ts --- The parser is hand-rolled (house pattern, modeled on ``trellis_config.parse_simple_yaml`` — no YAML dependency) and reads only a bounded head of each file (16 KiB / 200 lines, whichever ends first). Only files whose first line is exactly ``---`` are considered. ``name:`` / ``description:`` single-line strings are recognized (description is reused in index lines). ``paths:`` accepts both a block list (``- `` items) and a flow sequence (``paths: [a, b]``). The parser is deliberately tolerant — a spec is prose that happens to carry a routing hint, not a config file. Unknown keys, unrecognized line shapes and stray ``- item`` lines are ignored; block scalars (``key: >`` / ``key: |``) consume their more-indented continuation lines, so a SKILL.md-style ``description: >`` paragraph does not disqualify the file. An opening ``---`` with no recognized key before the closing marker is not frontmatter at all (a Markdown horizontal rule opening the prose) and is ignored silently. Two things are errors, and both warn + skip the whole file rather than route on a half-read block: a malformed ``paths:`` (a scalar where a list belongs — that key is the one thing the rest of the pipeline depends on), and a frontmatter block that is still open when the head bound is reached. Glob grammar (repo-relative, POSIX separators): - ``*`` matches within a single path segment (never crosses ``/``) - ``?`` matches exactly one character within a segment - ``**`` as a whole segment matches zero or more segments - a trailing ``/`` is sugar for ``/**`` - ``**`` embedded in a segment with other characters degrades to ``*`` Validation rejects only what is unsafe or meaningless: empty globs, a leading ``/`` (globs are repo-relative), ``..`` segments, backslashes (POSIX separators only) and control characters. Everything else is legal — real repositories carry ``@scope`` packages, ``[slug]`` routes, ``(marketing)`` groups and non-ASCII directories, and the translation escapes literals character by character. An invalid glob is skipped with a stderr warning; the rest of the file's globs still apply. Translation examples (glob → matches / non-matches): packages/cli/src/commands/update.ts matches only that exact file packages/cli/src/commands/*.ts matches packages/cli/src/commands/update.ts not packages/cli/src/commands/channel/spawn.ts packages/cli/src/templates/** matches packages/cli/src/templates/trellis/index.ts (any depth) not packages/cli/src/templates (the directory itself) packages/**/index.ts matches packages/index.ts and packages/cli/src/index.ts src/util?.py matches src/utils.py, not src/util.py or src/utilXY.py packages/cli/ same as packages/cli/** Provides: SpecMatch - frozen match record (spec_path, rel_path, description) match_specs_for_file - map an edited file to the specs that govern it normalize_repo_relative - the canonical repo-relative path normalization parse_spec_frontmatter - parse the optional frontmatter head block glob_to_regex - deterministic glob → compiled regex translation """ from __future__ import annotations import re import sys import unicodedata from dataclasses import dataclass from pathlib import Path from .paths import DIR_SPEC, DIR_WORKFLOW from .trellis_config import _strip_inline_comment, _unquote # Bounded head-read limits for frontmatter scanning (design contract). HEAD_MAX_BYTES = 16384 HEAD_MAX_LINES = 200 # Recognized frontmatter keys. An opening `---` block that declares none of # them is prose under a horizontal rule, not frontmatter. _KNOWN_KEYS = ("paths", "name", "description") _GLOB_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") _KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(.*)$") # YAML block-scalar introducers; the value lives in the indented lines below. _BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+") # macOS and Windows filesystems are case-insensitive: the very same file can # be handed to us in a case the glob author never wrote. Match case-insensitively # there — over-injecting a spec is the safe side of the asymmetry. _CASE_INSENSITIVE_FS = sys.platform == "darwin" or sys.platform.startswith("win") _GLOB_FLAGS = re.IGNORECASE if _CASE_INSENSITIVE_FS else 0 @dataclass(frozen=True) class SpecFrontmatter: """Parsed frontmatter head. ``paths`` is None when the key is absent.""" paths: tuple[str, ...] | None name: str | None description: str | None @dataclass(frozen=True) class SpecMatch: spec_path: Path """Absolute path to the spec file.""" rel_path: str """Repo-relative POSIX path, for display.""" description: str | None """Frontmatter ``description:`` value, if declared.""" def _warn(message: str) -> None: print(f"[WARN] spec_match: {message}", file=sys.stderr) def _parse_flow_sequence(value: str) -> list[str]: """Split a YAML flow sequence body (``[a, b]``) into unquoted items. Commas separate; each item is trimmed and unquoted. Empty items (a trailing comma, ``[]``) collapse away. """ inner = value[1:-1] items = (_unquote(part.strip()).strip() for part in inner.split(",")) return [item for item in items if item] def _read_head(path: Path) -> str: """Read at most HEAD_MAX_BYTES from the file, decoded as UTF-8.""" with open(path, "rb") as f: data = f.read(HEAD_MAX_BYTES) return data.decode("utf-8", errors="replace") def parse_spec_frontmatter(head_text: str) -> SpecFrontmatter | None: """Parse the optional frontmatter block from a spec file's head. Returns None when the file has no frontmatter: either the first line is not ``---``, or the block declares no recognized key before its closing marker (a horizontal rule opening a prose file — silent, not an error). Raises ValueError on a malformed ``paths:`` key (a scalar where a list belongs) and on a block that is still open when the head bound (HEAD_MAX_LINES / HEAD_MAX_BYTES) is reached — routing on a half-read frontmatter would be worse than skipping the file loudly. Every other line shape is tolerated and ignored. """ lines = head_text.splitlines()[:HEAD_MAX_LINES] if not lines: return None first = lines[0].lstrip("\ufeff") # tolerate a UTF-8 BOM if first != "---": return None paths: list[str] | None = None name: str | None = None description: str | None = None pending_key: str | None = None block_indent: int | None = None saw_known_key = False closed = False for line in lines[1:]: stripped = line.strip() indent = len(line) - len(line.lstrip()) if block_indent is not None: # Inside a block scalar: everything more indented (and blank lines) # is its value. A dedent ends the block; that line still counts. if not stripped or indent > block_indent: continue block_indent = None if stripped == "---": closed = True break if not stripped or stripped.startswith("#"): continue if stripped == "-" or stripped.startswith("- "): if pending_key == "paths" and paths is not None: item = _unquote(_strip_inline_comment(stripped[1:].strip()).strip()) paths.append(item) # List items outside `paths:` are tolerated and ignored. continue key_match = _KEY_RE.match(stripped) if key_match is None: continue # Unrecognized line shape — tolerated and ignored. key = key_match.group(1) saw_known_key = saw_known_key or key in _KNOWN_KEYS raw_value = key_match.group(2).strip() if raw_value in _BLOCK_SCALARS: if key == "paths": raise ValueError("'paths' must be a list of globs") pending_key = None block_indent = indent continue value = _unquote(_strip_inline_comment(raw_value).strip()) if value: pending_key = None if key == "paths": if not (value.startswith("[") and value.endswith("]")): raise ValueError("'paths' must be a list of globs") paths = _parse_flow_sequence(value) elif key == "name": name = value elif key == "description": description = value # Unknown scalar keys are tolerated and ignored. else: pending_key = key if key == "paths": paths = [] if not saw_known_key: # An opening `---` with no recognized key is a horizontal rule, not a # frontmatter block. Silent by design: prose files are not malformed. return None if not closed: raise ValueError( f"frontmatter block never closed within the head bound " f"({HEAD_MAX_BYTES} bytes / {HEAD_MAX_LINES} lines)" ) return SpecFrontmatter( paths=tuple(paths) if paths is not None else None, name=name, description=description, ) def validate_glob(glob: str) -> str | None: """Return an error message for an invalid glob, or None when valid. Deny-list, not allow-list: only what is unsafe or meaningless is rejected (see module docstring). Everything else — ``@scope``, ``[slug]``, ``(marketing)``, non-ASCII directory names — is a legal path in a real repository and translates fine. """ if not glob: return "empty glob" if glob.startswith("/"): return "absolute paths are not allowed (globs are repo-relative)" if ".." in glob.split("/"): return "'..' segments are not allowed" if "\\" in glob: return "backslashes are not allowed (globs use POSIX '/' separators)" if _GLOB_CONTROL_RE.search(glob): return "contains control characters" return None def glob_to_regex(glob: str) -> re.Pattern[str]: """Translate a validated glob to a compiled full-match regex. Deterministic, segment-based translation (see module docstring for the grammar and examples): ``**`` as a whole segment spans zero or more segments; ``*`` becomes ``[^/]*``; ``?`` becomes ``[^/]``; everything else is escaped literally. A trailing ``/`` is expanded to ``/**`` first. On case-insensitive filesystems (macOS, Windows) the pattern compiles with ``re.IGNORECASE`` — see ``_CASE_INSENSITIVE_FS``. """ if glob.endswith("/"): glob += "**" segments = glob.split("/") parts: list[str] = [] for i, seg in enumerate(segments): is_last = i == len(segments) - 1 if seg == "**": # Last: consume the rest of the path (at least the separator # boundary is already emitted by the previous segment). Not last: # zero or more whole segments including their separators. parts.append(".*" if is_last else r"(?:[^/]+/)*") continue piece = "".join( "[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch) for ch in seg ) parts.append(piece if is_last else piece + "/") return re.compile("^" + "".join(parts) + "$", _GLOB_FLAGS) def normalize_repo_relative(repo_root: Path, file_path: str | Path) -> str | None: """Canonical repo-relative POSIX path — the one normalization in the pipeline, used both for matching and for display. Root and file are fully resolved (``strict=False``, so a file that no longer exists still normalizes): symlinked repo roots, macOS's ``/tmp`` → ``/private/tmp`` and ``..`` segments cannot make one file look like two different paths. The result is NFC-normalized (macOS hands out NFD filenames). Relative inputs are taken as repo-relative. Returns None when the file resolves outside the repo. """ try: root = Path(repo_root).resolve(strict=False) candidate = Path(file_path) if not candidate.is_absolute(): text = str(file_path).replace("\\", "/") while text.startswith("./"): text = text[2:] if not text: return None candidate = root / text rel = candidate.resolve(strict=False).relative_to(root).as_posix() except (OSError, ValueError): return None return unicodedata.normalize("NFC", rel) def match_specs_for_file(repo_root: Path, file_path: str | Path) -> list[SpecMatch]: """Return specs whose frontmatter ``paths:`` globs match file_path. ``file_path`` may be absolute or repo-relative. More specific matching globs are returned first; ``rel_path`` is the deterministic tie-break. Scans ``.trellis/spec/**/*.md`` with bounded head-reads only. Never raises; unreadable or malformed spec files are skipped with a stderr warning. """ try: repo_root = Path(repo_root).resolve() spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC if not spec_dir.is_dir(): return [] rel = normalize_repo_relative(repo_root, file_path) if rel is None: return [] matches: list[SpecMatch] = [] specificity: dict[str, tuple[int, int, int, int]] = {} for spec_file in spec_dir.rglob("*.md"): spec_rel = spec_file.relative_to(repo_root).as_posix() try: head = _read_head(spec_file) except OSError as exc: _warn(f"cannot read {spec_rel}: {exc}") continue try: frontmatter = parse_spec_frontmatter(head) except ValueError as exc: _warn(f"malformed frontmatter in {spec_rel}: {exc}") continue if frontmatter is None or not frontmatter.paths: continue for glob in frontmatter.paths: error = validate_glob(glob) if error is not None: _warn(f"invalid glob {glob!r} in {spec_rel}: {error}") continue if glob_to_regex(glob).match(rel): scored_glob = glob + "**" if glob.endswith("/") else glob wildcard_count = scored_glob.count("*") + scored_glob.count("?") segments = scored_glob.split("/") literal_segments = sum( "*" not in segment and "?" not in segment for segment in segments ) specificity[spec_rel] = ( 0 if wildcard_count == 0 else 1, -literal_segments, wildcard_count, -(len(scored_glob) - wildcard_count), ) matches.append( SpecMatch( spec_path=spec_file, rel_path=spec_rel, description=frontmatter.description, ) ) break # Payload assembly spends its budget in this order. Exact and narrowly # scoped matches must therefore outrank broad tree globs; alphabetic # order is only a deterministic tie-break. matches.sort(key=lambda m: (*specificity[m.rel_path], m.rel_path)) return matches except Exception as exc: # Never raise — callers are hooks/context tools. _warn(f"spec scan failed: {exc}") return []