first commit
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user