#!/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 ;` declarations. Files often expose both an `Output` struct and an `` 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())