165 lines
5.7 KiB
Python
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()) |