feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
Executable
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone reader for .trellis/config.yaml.
|
||||
|
||||
Owns the minimal YAML parser used across Trellis. ``common.config`` imports
|
||||
``parse_simple_yaml`` from here rather than keeping its own copy: this module
|
||||
imports nothing from the package, so hooks can load it as a single file, and
|
||||
one parser cannot drift from another. Returns an empty dict on
|
||||
missing/malformed files so callers stay simple.
|
||||
|
||||
Supported subset: ``key: value`` scalars (everything is a string), nested
|
||||
mappings by indentation, ``- `` lists of scalars, ``#`` comments (whole-line
|
||||
and inline outside quotes), and one layer of matching surrounding quotes.
|
||||
Constructs outside that subset — block scalars, anchors, aliases, merge keys,
|
||||
flow collections, and mappings nested inside a list — are reported on stderr
|
||||
and skipped rather than parsed into a plausible-looking wrong value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
CONFIG_REL_PATH = ".trellis/config.yaml"
|
||||
|
||||
|
||||
def _unquote(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _strip_inline_comment(value: str) -> str:
|
||||
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
|
||||
|
||||
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
|
||||
is part of the value. Quoted strings are immune.
|
||||
"""
|
||||
in_quote: str | None = None
|
||||
for idx, ch in enumerate(value):
|
||||
if in_quote:
|
||||
if ch == in_quote:
|
||||
in_quote = None
|
||||
continue
|
||||
if ch in ('"', "'"):
|
||||
in_quote = ch
|
||||
continue
|
||||
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
|
||||
return value[:idx]
|
||||
return value
|
||||
|
||||
|
||||
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
||||
i = start
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
return i, lines[i]
|
||||
i += 1
|
||||
return i, ""
|
||||
|
||||
|
||||
def _warn_unsupported(source: str, lineno: int, line: str, reason: str) -> None:
|
||||
"""Report a YAML construct this parser cannot represent, and move on."""
|
||||
print(
|
||||
f"[WARN] {source}:{lineno}: {reason}; ignoring: {line.strip()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def _is_block_scalar(value: str) -> bool:
|
||||
"""True for ``|``, ``>`` and their chomping/indent indicators (``|-``, ``>2``)."""
|
||||
if not value or value[0] not in ("|", ">"):
|
||||
return False
|
||||
return all(ch in "+-0123456789" for ch in value[1:])
|
||||
|
||||
|
||||
def _unsupported_value(key: str, value: str) -> str | None:
|
||||
"""Name the unsupported construct in an unquoted scalar value, else None.
|
||||
|
||||
Only unquoted values are inspected: ``cmd: "[a] | b"`` is a string the user
|
||||
wrote deliberately, while a bare ``notes: |`` or ``base: *anchor`` would
|
||||
otherwise be stored as the literal marker with the real content dropped.
|
||||
"""
|
||||
if key == "<<":
|
||||
return "YAML merge keys are not supported"
|
||||
if _is_block_scalar(value):
|
||||
return "block scalars are not supported"
|
||||
if value.startswith("&"):
|
||||
return "YAML anchors are not supported"
|
||||
if value.startswith("*"):
|
||||
return "YAML aliases are not supported"
|
||||
if value.startswith("["):
|
||||
return "flow sequences are not supported (use `- ` list items)"
|
||||
if value.startswith("{"):
|
||||
return "flow mappings are not supported (use an indented mapping)"
|
||||
return None
|
||||
|
||||
|
||||
def _skip_indented_body(lines: list[str], start: int, indent: int) -> int:
|
||||
"""Skip the continuation lines of a rejected key (block scalar body etc.)."""
|
||||
i = start
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped and len(lines[i]) - len(lines[i].lstrip()) <= indent:
|
||||
break
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _parse_yaml_block(
|
||||
lines: list[str], start: int, min_indent: int, target: dict, source: str
|
||||
) -> int:
|
||||
i = start
|
||||
current_list: list | None = None
|
||||
# Indent of the key that opened current_list, so a deeper `key: value`
|
||||
# can be recognized as a mapping inside that list.
|
||||
list_owner_indent = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped or stripped.startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
if indent < min_indent:
|
||||
break
|
||||
|
||||
if stripped.startswith("- "):
|
||||
if current_list is not None:
|
||||
current_list.append(_unquote(stripped[2:].strip()))
|
||||
i += 1
|
||||
elif ":" in stripped:
|
||||
if current_list is not None and indent > list_owner_indent:
|
||||
# `- name: cli` / ` path: x`: the second key belongs to a
|
||||
# mapping inside the list. Storing it would hoist it into the
|
||||
# parent dict as a sibling of the list — a nested key silently
|
||||
# becoming a root key.
|
||||
_warn_unsupported(
|
||||
source,
|
||||
i + 1,
|
||||
line,
|
||||
"mappings inside a list are not supported",
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = _strip_inline_comment(value).strip()
|
||||
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
|
||||
|
||||
if not was_quoted:
|
||||
reason = _unsupported_value(key, value)
|
||||
if reason is not None:
|
||||
_warn_unsupported(source, i + 1, line, reason)
|
||||
current_list = None
|
||||
i = _skip_indented_body(lines, i + 1, indent)
|
||||
continue
|
||||
|
||||
value = _unquote(value)
|
||||
current_list = None
|
||||
|
||||
if value or was_quoted:
|
||||
target[key] = value
|
||||
i += 1
|
||||
else:
|
||||
next_i, next_line = _next_content_line(lines, i + 1)
|
||||
if next_i >= len(lines):
|
||||
target[key] = {}
|
||||
i = next_i
|
||||
elif next_line.strip().startswith("- "):
|
||||
current_list = []
|
||||
list_owner_indent = indent
|
||||
target[key] = current_list
|
||||
i += 1
|
||||
else:
|
||||
next_indent = len(next_line) - len(next_line.lstrip())
|
||||
if next_indent > indent:
|
||||
nested: dict = {}
|
||||
target[key] = nested
|
||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested, source)
|
||||
else:
|
||||
target[key] = {}
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
|
||||
def parse_simple_yaml(content: str, source: str = "config.yaml") -> dict:
|
||||
"""Parse simple YAML with nested dict support (no dependencies).
|
||||
|
||||
Supports:
|
||||
- key: value (string)
|
||||
- key: (followed by list items)
|
||||
- item1
|
||||
- item2
|
||||
- key: (followed by nested dict)
|
||||
nested_key: value
|
||||
nested_key2:
|
||||
- item
|
||||
|
||||
Uses indentation to detect nesting (2+ spaces deeper = child). Every value
|
||||
is a string; consumers coerce. Unsupported constructs are reported on
|
||||
stderr against ``source`` and skipped — see the module docstring.
|
||||
|
||||
Args:
|
||||
content: YAML content string.
|
||||
source: Label used in warnings, normally the config file path.
|
||||
|
||||
Returns:
|
||||
Parsed dict (values can be str, list[str], or dict).
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
result: dict = {}
|
||||
_parse_yaml_block(lines, 0, 0, result, source)
|
||||
return result
|
||||
|
||||
|
||||
def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
|
||||
"""Read .trellis/config.yaml. Returns {} on missing or malformed file."""
|
||||
root = repo_root or Path.cwd()
|
||||
config_file = root / CONFIG_REL_PATH
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
try:
|
||||
parsed = parse_simple_yaml(content, source=str(config_file))
|
||||
except Exception:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
Reference in new issue
Block a user