Files
2026-07-12 04:18:40 +08:00

165 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Generate INDICATORS.md — the compact entry file for AI agents.
One row per indicator: public struct name + the file's `//!` one-line title
+ a link to the full Rust implementation. Family taxonomy is taken from
`FAMILIES` in `crates/wickra-core/src/indicators/mod.rs` (the single source
of truth, also enforced by an `assert_eq!(total, 514)` test in that file).
Run from project root:
python scripts/gen_indicators_index.py
ponytail: static snapshot. Rerun after adding or renaming indicators.
The `mod.rs::family_tests` total-count assertion keeps `FAMILIES` honest.
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MOD_RS = ROOT / "crates" / "wickra-core" / "src" / "indicators" / "mod.rs"
INDIC_DIR = ROOT / "crates" / "wickra-core" / "src" / "indicators"
OUTPUT = ROOT / "INDICATORS.md"
REL_INDIC_DIR = INDIC_DIR.relative_to(ROOT).as_posix()
def parse_module_decl_map(mod_rs_text: str) -> dict[str, str]:
"""{struct_name: file_stem} built from `mod <snake>;` declarations.
Files often expose both an `<Name>Output` struct and an `<Name>` struct;
we collect every `pub struct` per file so either name resolves to the
same file. `pattern_swing` is intentionally excluded (it is a `pub(crate)`
helper, not a public indicator).
"""
struct_to_snake: dict[str, str] = {}
in_families = False
for line in mod_rs_text.splitlines():
if line.startswith("pub const FAMILIES"):
in_families = True
continue
if in_families:
continue
m = re.match(
r"^(?:pub(?:\(crate\))?\s+)?mod\s+([a-z][a-z0-9_]*)\s*;\s*$",
line.strip(),
)
if not m:
continue
snake = m.group(1)
if snake in {"tests", "pattern_swing"}:
continue
path = INDIC_DIR / f"{snake}.rs"
if not path.exists():
print(f"warn: module {snake} declared but file missing", file=sys.stderr)
continue
for fl in path.read_text(encoding="utf-8").splitlines():
sm = re.match(r"^\s*pub\s+struct\s+([A-Z][A-Za-z0-9]*)\b", fl)
if sm:
struct_to_snake.setdefault(sm.group(1), snake)
return struct_to_snake
def parse_families(mod_rs_text: str) -> list[tuple[str, list[str]]]:
"""[(family_name, [struct_name, ...]), ...] from FAMILIES array."""
start = mod_rs_text.index("pub const FAMILIES")
line_end = mod_rs_text.index("\n", start)
line_text = mod_rs_text[start:line_end]
last_amp = line_text.rindex("&[")
body_start = start + last_amp
depth = 0
i = body_start
while i < len(mod_rs_text):
ch = mod_rs_text[i]
if ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if depth == 0:
break
i += 1
body = mod_rs_text[body_start : i + 1]
families: list[tuple[str, list[str]]] = []
for fam_match in re.finditer(
r'\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*&\[(.*?)\]\s*,?\s*\)',
body,
re.DOTALL,
):
names = [m.group(1) for m in re.finditer(r'"([A-Za-z0-9_]+)"', fam_match.group(2))]
families.append((fam_match.group(1), names))
return families
def first_title(path: Path) -> str:
"""First non-empty `//!` line — the one-line intent."""
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("//!") and line[3:].strip():
return line[3:].strip()
return ""
def slug(s: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def main() -> int:
text = MOD_RS.read_text(encoding="utf-8")
struct_to_snake = parse_module_decl_map(text)
families = parse_families(text)
total = sum(len(n) for _, n in families)
seen: set[str] = set()
for _, n in families:
for x in n:
if x in seen:
print(f"error: duplicate across families: {x}", file=sys.stderr)
return 2
seen.add(x)
unresolved = [n for _, n in families if any(s not in struct_to_snake for s in n)]
if unresolved:
print(
f"error: struct names not in `mod` decls: {unresolved[:5]}",
file=sys.stderr,
)
return 2
out: list[str] = []
out.append("# Wickra Indicators")
out.append("")
out.append(
f"Compact index of all **{total}** indicators in `{REL_INDIC_DIR}/`, "
f"grouped by `FAMILIES` in `{REL_INDIC_DIR}/mod.rs`. Each row links "
f"to the full implementation file."
)
out.append("")
out.append("> AI agents: skim family headers, then `Read` the linked `.rs` "
"file for the canonical formula. For QuantDinger, fetch the "
"live contract with `quantdinger_get_indicator_authoring_contract` "
"before writing code.")
out.append("")
out.append("## Families")
out.append("")
for family, names in families:
out.append(f"- [{family}](#{slug(family)}) ({len(names)})")
out.append("")
for family, names in families:
out.append(f"## {family} ({len(names)})")
out.append("")
for struct_name in sorted(names, key=str.lower):
snake = struct_to_snake[struct_name]
title = first_title(INDIC_DIR / f"{snake}.rs").rstrip(".")
out.append(
f"- `{struct_name}` — {title} · "
f"[`{snake}.rs`]({REL_INDIC_DIR}/{snake}.rs)"
)
out.append("")
OUTPUT.write_text("\n".join(out) + "\n", encoding="utf-8")
print(f"wrote {OUTPUT.relative_to(ROOT)} ({total} indicators, "
f"{len(families)} families)")
return 0
if __name__ == "__main__":
sys.exit(main())