fix: 修复6个bug并接入CodeStable工作流

Bug修复:
- GetWorkList 使用了错误的 RecordType (RecordStudy→RecordWork)
- AllRecord handler 返回错误的分页信息 (page硬编码1, pageSize用RecordsCount)
- CourseParse creditNode nil panic (加nil检查)
- WebSocket CheckOrigin 安全漏洞 (release模式限制为同源)
- math/rand 可预测 (替换为 crypto/rand)
- GetDiscussList 未实现 (补全实现, 移除重复路由)

其他:
- 接入 CodeStable 工作流体系 (codestable/ 骨架 + AGENTS.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 19:55:57 +08:00
parent 83ee4bb5ea
commit 2a6732ffe7
23 changed files with 1561 additions and 23 deletions

View File

@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
search-yaml.py — Generic YAML-frontmatter search tool for markdown document directories.
Works on any directory of .md files that use YAML frontmatter (--- ... ---).
Designed for AI agent use: fast, structured output, no required external dependencies.
Filter syntax (--filter flag, repeatable, AND logic):
key=value Exact match on a scalar field (case-insensitive)
key~=value Substring match on a string field, or element-in for list fields
Usage examples:
# Search codestable/compound (learning / trick / decision / explore docs share this dir)
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning --filter track=pitfall
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter tags~=prisma
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter status=active --full
# Full-text search in body + frontmatter values
python codestable/tools/search-yaml.py --dir codestable/compound --query "shadow database"
# JSON output for AI agent consumption
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning --filter track=knowledge --json
# Sort by a frontmatter date field (works on any ISO-8601 date string, YAML date, or sortable value)
python codestable/tools/search-yaml.py --dir codestable/library-docs --sort-by last_reviewed --order asc # oldest first (stalest)
python codestable/tools/search-yaml.py --dir codestable/compound --sort-by date --order desc # newest first
# Works on any yaml-frontmatter markdown directory
python codestable/tools/search-yaml.py --dir docs/decisions --filter status=accepted
python codestable/tools/search-yaml.py --dir content/posts --filter tags~=python --query "asyncio"
"""
import argparse
import json
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Frontmatter parsing (PyYAML used when available, builtin fallback otherwise)
# ---------------------------------------------------------------------------
def _parse_yaml_scalar(val: str):
val = val.strip()
if val.startswith("[") and val.endswith("]"):
inner = val[1:-1]
return [item.strip().strip("'\"") for item in inner.split(",") if item.strip()]
lower = val.lower()
if lower in ("true", "yes"):
return True
if lower in ("false", "no"):
return False
if lower in ("null", "~", ""):
return None
return val
def parse_frontmatter(text: str) -> tuple[dict, str]:
"""
Split a markdown document into (frontmatter_dict, body_text).
Returns ({}, full_text) when no frontmatter is present.
"""
if not text.startswith("---"):
return {}, text
end = text.find("\n---", 3)
if end == -1:
return {}, text
fm_text = text[3:end].strip()
body = text[end + 4:].strip()
try:
import yaml # type: ignore
meta = yaml.safe_load(fm_text)
return (meta or {}), body
except Exception:
pass
# Minimal fallback: handles scalar values and inline lists
meta: dict = {}
for line in fm_text.splitlines():
if not line.strip() or line.startswith("#") or ":" not in line:
continue
key, _, raw = line.partition(":")
meta[key.strip()] = _parse_yaml_scalar(raw)
return meta, body
# ---------------------------------------------------------------------------
# Document loading
# ---------------------------------------------------------------------------
def load_documents(directory: Path) -> list[dict]:
docs = []
for md_file in sorted(directory.rglob("*.md")):
try:
text = md_file.read_text(encoding="utf-8")
except OSError as exc:
print(f"[warn] Cannot read {md_file.name}: {exc}", file=sys.stderr)
continue
meta, body = parse_frontmatter(text)
docs.append({
"file": str(md_file.relative_to(directory)),
"path": str(md_file),
"meta": meta,
"body": body,
})
return docs
# ---------------------------------------------------------------------------
# Filter parsing and evaluation
# ---------------------------------------------------------------------------
class Filter:
"""Parsed representation of a single --filter expression."""
def __init__(self, raw: str):
if "~=" in raw:
key, _, value = raw.partition("~=")
self.key = key.strip()
self.value = value.strip()
self.operator = "contains"
elif "=" in raw:
key, _, value = raw.partition("=")
self.key = key.strip()
self.value = value.strip()
self.operator = "exact"
else:
raise argparse.ArgumentTypeError(
f"Invalid filter expression {raw!r}. "
"Use 'key=value' for exact match or 'key~=value' for substring/list-contains match."
)
def matches(self, meta: dict) -> bool:
field_val = meta.get(self.key)
if field_val is None:
return False
if self.operator == "exact":
return str(field_val).lower() == self.value.lower()
# contains: substring for strings, element-in for lists
if isinstance(field_val, list):
return any(self.value.lower() == str(item).lower() for item in field_val)
return self.value.lower() in str(field_val).lower()
def __repr__(self):
op = "~=" if self.operator == "contains" else "="
return f"Filter({self.key}{op}{self.value})"
def parse_filter(raw: str) -> Filter:
"""argparse type converter for --filter."""
return Filter(raw)
_MISSING = object()
def _sort_key(doc: dict, field: str):
"""
Sort key for --sort-by. Docs missing the field sort to the end regardless
of --order. Dates (datetime.date / datetime.datetime) and strings are both
normalized to their string form — ISO 8601 date strings sort the same
lexicographically as YAML-parsed date objects' isoformat().
"""
val = doc["meta"].get(field, _MISSING)
if val is _MISSING or val is None:
return (1, "")
try:
return (0, val.isoformat()) # datetime.date / datetime.datetime
except AttributeError:
return (0, str(val))
def doc_matches(doc: dict, filters: list[Filter], query: str | None) -> bool:
meta = doc["meta"]
for f in filters:
if not f.matches(meta):
return False
if query:
needle = query.lower()
haystack = doc["body"].lower() + " " + " ".join(str(v) for v in meta.values()).lower()
if needle not in haystack:
return False
return True
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def _meta_summary(meta: dict) -> str:
"""One-line summary of frontmatter fields, skipping slug/date for brevity."""
skip = {"slug"}
parts = []
for k, v in meta.items():
if k in skip:
continue
if isinstance(v, list):
parts.append(f"{k}=[{', '.join(str(i) for i in v)}]")
else:
parts.append(f"{k}={v}")
return " ".join(parts)
def format_summary(doc: dict) -> str:
return f"### {doc['file']}\n{_meta_summary(doc['meta'])}"
def format_full(doc: dict) -> str:
return format_summary(doc) + "\n\n" + doc["body"]
def print_text(results: list[dict], full: bool) -> None:
print(f"Found {len(results)} document(s).\n")
sep = "\n" + "" * 60 + "\n"
chunks = [format_full(d) if full else format_summary(d) for d in results]
print(sep.join(chunks))
def print_json(results: list[dict], full: bool) -> None:
output = []
for doc in results:
body = doc["body"]
if not full and len(body) > 400:
body = body[:400] + ""
output.append({"file": doc["file"], "meta": doc["meta"], "body": body})
print(json.dumps(output, ensure_ascii=False, indent=2))
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generic YAML-frontmatter search across a directory of markdown files.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--dir", metavar="DIR", required=True,
help="Directory of .md files to search.")
parser.add_argument("--filter", "-f", metavar="EXPR", dest="filters",
type=parse_filter, action="append", default=[],
help="Frontmatter filter expression. Repeatable (AND logic). "
"key=value for exact match; key~=value for substring (strings) or element-in (lists).")
parser.add_argument("--query", "-q", metavar="TEXT",
help="Full-text search in document body and frontmatter values.")
parser.add_argument("--full", action="store_true",
help="Print full document body instead of just the frontmatter summary.")
parser.add_argument("--json", dest="as_json", action="store_true",
help="Output results as a JSON array.")
parser.add_argument("--sort-by", metavar="FIELD", dest="sort_by",
help="Sort results by a frontmatter field (e.g. last_reviewed, date, updated_at). "
"ISO-8601 date strings and YAML-parsed dates both sort correctly. "
"Docs missing the field are pushed to the end.")
parser.add_argument("--order", choices=("asc", "desc"), default="desc",
help="Sort order when --sort-by is set. Default: desc (newest first).")
return parser
def _resolve_directory(dir_arg: str) -> Path:
directory = Path(dir_arg)
if not directory.exists():
print(f"[error] Directory not found: {directory}", file=sys.stderr)
sys.exit(1)
if not directory.is_dir():
print(f"[error] Not a directory: {directory}", file=sys.stderr)
sys.exit(1)
return directory
def _sort_results(results: list[dict], sort_by: str, order: str) -> list[dict]:
def has_field(d: dict) -> bool:
return sort_by in d["meta"] and d["meta"][sort_by] is not None
present = [d for d in results if has_field(d)]
missing = [d for d in results if not has_field(d)]
present.sort(key=lambda d: _sort_key(d, sort_by), reverse=(order == "desc"))
return present + missing
def main() -> None:
args = _build_parser().parse_args()
directory = _resolve_directory(args.dir)
docs = load_documents(directory)
if not docs:
print(f"No .md files found in {directory}")
return
results = [d for d in docs if doc_matches(d, args.filters, args.query)]
if not results:
print("No matching documents found.")
return
if args.sort_by:
results = _sort_results(results, args.sort_by, args.order)
if args.as_json:
print_json(results, full=args.full)
else:
print_text(results, full=args.full)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
validate-yaml.py — Validate YAML frontmatter syntax in markdown files.
Scans markdown files for YAML frontmatter (--- ... ---) and checks:
1. Frontmatter block is properly delimited (opening and closing ---)
2. YAML syntax is valid (parseable without errors)
3. (Optional) Required fields are present (--require flag)
Designed for AI agent use: structured output, exit code reflects pass/fail,
no required external dependencies (falls back to builtin parser if PyYAML unavailable).
Usage examples:
# Validate all .md files under codestable/features
python codestable/tools/validate-yaml.py --dir codestable/features
# Validate a single file
python codestable/tools/validate-yaml.py --file codestable/features/2026-04-11-auth/auth-design.md
# Check that required fields exist in frontmatter
python codestable/tools/validate-yaml.py --dir codestable/features --require doc_type --require status
# JSON output for programmatic consumption
python codestable/tools/validate-yaml.py --dir docs/api --json
# Validate the libdoc manifest
python codestable/tools/validate-yaml.py --file docs/api/manifest.yaml --yaml-only
"""
import argparse
import json
import sys
from pathlib import Path
# Force UTF-8 stdout/stderr on Windows where default codepage (e.g. GBK / cp936)
# can't encode the ✓ / ✗ icons used in text output. Safe no-op on POSIX.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8")
except Exception:
pass
# ---------------------------------------------------------------------------
# YAML parsing
# ---------------------------------------------------------------------------
_HAS_PYYAML = False
try:
import yaml # type: ignore
_HAS_PYYAML = True
except ImportError:
pass
def _builtin_parse_yaml(text: str) -> dict:
"""Minimal YAML parser for flat key-value frontmatter (no nested structures)."""
result: dict = {}
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or ":" not in stripped:
continue
key, _, raw = stripped.partition(":")
val = raw.strip()
# Inline list
if val.startswith("[") and val.endswith("]"):
inner = val[1:-1]
result[key.strip()] = [
item.strip().strip("'\"") for item in inner.split(",") if item.strip()
]
else:
result[key.strip()] = val.strip("'\"") if val else ""
return result
def parse_yaml_text(text: str) -> tuple[dict | None, str | None]:
"""
Parse a YAML string. Returns (parsed_dict, None) on success,
or (None, error_message) on failure.
"""
if _HAS_PYYAML:
try:
result = yaml.safe_load(text)
if result is None:
return {}, None
if not isinstance(result, dict):
return None, f"Expected a mapping, got {type(result).__name__}"
return result, None
except yaml.YAMLError as exc:
return None, str(exc)
else:
# Builtin fallback — can only detect gross syntax issues
try:
result = _builtin_parse_yaml(text)
return result, None
except Exception as exc:
return None, str(exc)
# ---------------------------------------------------------------------------
# Frontmatter extraction
# ---------------------------------------------------------------------------
def extract_frontmatter(text: str) -> tuple[str | None, str | None]:
"""
Extract YAML frontmatter from a markdown file.
Returns (frontmatter_text, None) on success,
or (None, error_message) if frontmatter is missing or malformed.
"""
if not text.startswith("---"):
return None, "No opening '---' delimiter found"
end = text.find("\n---", 3)
if end == -1:
return None, "No closing '---' delimiter found (frontmatter block not terminated)"
fm_text = text[3:end].strip()
if not fm_text:
return None, "Frontmatter block is empty"
return fm_text, None
# ---------------------------------------------------------------------------
# Validation logic
# ---------------------------------------------------------------------------
class ValidationResult:
def __init__(self, file_path: str):
self.file = file_path
self.errors: list[str] = []
self.warnings: list[str] = []
self.fields: list[str] = [] # fields found in frontmatter
@property
def ok(self) -> bool:
return len(self.errors) == 0
def to_dict(self) -> dict:
d: dict = {"file": self.file, "status": "pass" if self.ok else "fail"}
if self.errors:
d["errors"] = self.errors
if self.warnings:
d["warnings"] = self.warnings
if self.fields:
d["fields"] = self.fields
return d
def _check_required(parsed: dict | None, required_fields: list[str] | None, result: ValidationResult) -> None:
if not required_fields:
return
for field in required_fields:
if field not in (parsed or {}):
result.errors.append(f"Missing required field: '{field}'")
def _warn_if_builtin(result: ValidationResult) -> None:
if not _HAS_PYYAML:
result.warnings.append(
"PyYAML not installed — using builtin fallback parser "
"(may miss some syntax errors). Install with: pip install pyyaml"
)
def _validate_file(
file_path: Path,
required_fields: list[str] | None,
base_dir: Path | None,
mode: str, # "markdown" | "yaml"
) -> ValidationResult:
display_path = str(file_path.relative_to(base_dir)) if base_dir else str(file_path)
result = ValidationResult(display_path)
try:
text = file_path.read_text(encoding="utf-8")
except OSError as exc:
result.errors.append(f"Cannot read file: {exc}")
return result
if mode == "markdown":
yaml_text, extract_err = extract_frontmatter(text)
if extract_err:
result.errors.append(extract_err)
return result
else:
yaml_text = text
parsed, parse_err = parse_yaml_text(yaml_text)
if parse_err:
result.errors.append(f"YAML syntax error: {parse_err}")
return result
result.fields = list(parsed.keys()) if parsed else []
_check_required(parsed, required_fields, result)
_warn_if_builtin(result)
return result
def validate_markdown_file(file_path, required_fields=None, base_dir=None):
"""Validate YAML frontmatter in a single markdown file."""
return _validate_file(file_path, required_fields, base_dir, "markdown")
def validate_yaml_file(file_path, required_fields=None, base_dir=None):
"""Validate a pure YAML file (not markdown with frontmatter)."""
return _validate_file(file_path, required_fields, base_dir, "yaml")
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def print_text_results(results: list[ValidationResult]) -> None:
passed = sum(1 for r in results if r.ok)
failed = len(results) - passed
print(f"Validated {len(results)} file(s): {passed} passed, {failed} failed.\n")
for r in results:
icon = "" if r.ok else ""
print(f" {icon} {r.file}")
for err in r.errors:
print(f" ERROR: {err}")
for warn in r.warnings:
print(f" WARN: {warn}")
if failed > 0:
print(f"\n{failed} file(s) have YAML errors.")
else:
print("\nAll files valid.")
def print_json_results(results: list[ValidationResult]) -> None:
output = {
"total": len(results),
"passed": sum(1 for r in results if r.ok),
"failed": sum(1 for r in results if not r.ok),
"results": [r.to_dict() for r in results],
}
print(json.dumps(output, indent=2, ensure_ascii=False))
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Validate YAML frontmatter in markdown files or pure YAML files.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--dir", type=str, help="Directory to scan recursively for .md files")
source.add_argument("--file", type=str, help="Single file to validate")
parser.add_argument("--require", action="append", default=[], metavar="FIELD",
help="Require this field in frontmatter (repeatable)")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
parser.add_argument("--yaml-only", action="store_true",
help="Treat input as pure YAML (not markdown with frontmatter). "
"Use for .yaml/.yml files like manifest.yaml.")
return parser
def _validate_single(path_str: str, require: list[str], yaml_only: bool) -> list[ValidationResult]:
fp = Path(path_str)
if not fp.exists():
print(f"Error: File not found: {fp}", file=sys.stderr)
sys.exit(2)
if yaml_only or fp.suffix in (".yaml", ".yml"):
return [validate_yaml_file(fp, require)]
return [validate_markdown_file(fp, require)]
def _validate_directory(dir_str: str, require: list[str]) -> list[ValidationResult]:
dp = Path(dir_str)
if not dp.is_dir():
print(f"Error: Directory not found: {dp}", file=sys.stderr)
sys.exit(2)
md_files = sorted(dp.rglob("*.md"))
yaml_files = sorted(dp.rglob("*.yaml")) + sorted(dp.rglob("*.yml"))
if not md_files and not yaml_files:
print(f"No .md or .yaml files found under {dp}", file=sys.stderr)
sys.exit(2)
results = [validate_markdown_file(md, require, dp) for md in md_files]
results += [validate_yaml_file(yf, require, dp) for yf in yaml_files]
return results
def main() -> None:
args = _build_parser().parse_args()
if args.file:
results = _validate_single(args.file, args.require, args.yaml_only)
else:
results = _validate_directory(args.dir, args.require)
if args.json_output:
print_json_results(results)
else:
print_text_results(results)
sys.exit(0 if all(r.ok for r in results) else 1)
if __name__ == "__main__":
main()