扩展指标

This commit is contained in:
2026-07-09 05:08:16 +08:00
commit 308c46ab9a
537 changed files with 152299 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""
Build a cross-surface API manifest for ferro-ta.
The generated manifest summarizes:
- Python indicator/method exposure (from ferro_ta.tools.api_info)
- Core Rust crate public functions (ferro_ta_core)
- WASM/Node exported functions (from wasm pkg d.ts)
Output is written to `docs/api_manifest.json`.
"""
from __future__ import annotations
import argparse
import ast
import datetime as _dt
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _load_api_info_module(root: Path, module_path: Path):
python_root = str(root / "python")
if python_root not in sys.path:
sys.path.insert(0, python_root)
spec = importlib.util.spec_from_file_location(
"ferro_ta_tools_api_info", module_path
)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load module spec from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[assignment]
return module
def _module_file(root: Path, module_name: str) -> Path | None:
module_rel = module_name.replace(".", "/")
file_path = root / "python" / f"{module_rel}.py"
if file_path.exists():
return file_path
init_path = root / "python" / module_rel / "__init__.py"
if init_path.exists():
return init_path
return None
def _extract_dunder_all(file_path: Path) -> list[str]:
try:
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(file_path))
except Exception:
return []
exports: list[str] = []
for node in tree.body:
value_node = None
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "__all__":
value_node = node.value
break
elif isinstance(node, ast.AnnAssign):
target = node.target
if isinstance(target, ast.Name) and target.id == "__all__":
value_node = node.value
if value_node is None:
continue
try:
value = ast.literal_eval(value_node)
except Exception:
continue
if isinstance(value, str):
exports = [value]
elif isinstance(value, (list, tuple)):
exports = [item for item in value if isinstance(item, str)]
return exports
def _module_exports(root: Path, module_name: str) -> list[str]:
file_path = _module_file(root, module_name)
if file_path is None:
return []
return _extract_dunder_all(file_path)
def _extract_python_api(root: Path) -> dict[str, Any]:
module_path = root / "python" / "ferro_ta" / "tools" / "api_info.py"
api_info_module = _load_api_info_module(root, module_path)
category_modules = dict(getattr(api_info_module, "_CATEGORY_MODULES", {}))
method_modules = dict(getattr(api_info_module, "_METHOD_MODULES", {}))
indicators: list[dict[str, Any]] = []
seen_indicators: set[str] = set()
for category, module_name in category_modules.items():
for name in _module_exports(root, module_name):
if name in seen_indicators:
continue
seen_indicators.add(name)
indicators.append(
{
"name": name,
"category": category,
"module": module_name,
"doc": "",
"params": [],
}
)
methods: list[dict[str, Any]] = []
seen_methods: set[tuple[str, str]] = set()
for category, module_name in method_modules.items():
for name in _module_exports(root, module_name):
key = (module_name, name)
if key in seen_methods:
continue
seen_methods.add(key)
methods.append(
{
"name": name,
"category": category,
"module": module_name,
"doc": "",
"params": [],
}
)
indicators.sort(key=lambda entry: entry["name"])
methods.sort(key=lambda entry: (entry["category"], entry["name"]))
categories = sorted({entry["category"] for entry in indicators})
if not indicators:
raise RuntimeError(
"No Python indicators discovered from source exports. "
"Check `python/ferro_ta/tools/api_info.py` mappings and module __all__ declarations."
)
return {
"indicator_count": len(indicators),
"method_count": len(methods),
"categories": categories,
"indicators": indicators,
"methods": methods,
}
def _extract_core_exports(root: Path) -> list[dict[str, str]]:
core_src = root / "crates" / "ferro_ta_core" / "src"
entries: list[dict[str, str]] = []
for rs_file in sorted(core_src.rglob("*.rs")):
rel = rs_file.relative_to(core_src).as_posix()
module = rel[:-3].replace("/", ".")
text = rs_file.read_text(encoding="utf-8")
for match in re.finditer(r"(?m)^\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(", text):
entries.append(
{
"module": module,
"function": match.group(1),
"file": rel,
}
)
entries.sort(key=lambda item: (item["module"], item["function"]))
return entries
def _extract_wasm_exports(root: Path) -> list[str]:
exports: set[str] = set()
# Source exports are the canonical declaration of the WASM/Node API and
# avoid drift when a stale wasm/pkg folder is present locally.
wasm_lib = root / "wasm" / "src" / "lib.rs"
if wasm_lib.exists():
text = wasm_lib.read_text(encoding="utf-8")
for match in re.finditer(
r"(?ms)#\s*\[wasm_bindgen(?:\([^\)]*\))?\]\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(",
text,
):
exports.add(match.group(1))
if exports:
return sorted(exports)
# Fallback to generated declarations if source parsing did not find exports.
dts_path = root / "wasm" / "node" / "ferro_ta_wasm.d.ts"
if dts_path.exists():
for line in dts_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("export function "):
name = line[len("export function ") :].split("(")[0].strip()
if name:
exports.add(name)
return sorted(exports)
def _safe_git_head(root: Path) -> str | None:
try:
completed = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=root,
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
return None
value = completed.stdout.strip()
return value or None
def build_manifest(
root: Path, include_runtime_metadata: bool = False
) -> dict[str, Any]:
python_api = _extract_python_api(root)
rust_core = _extract_core_exports(root)
wasm_exports = _extract_wasm_exports(root)
python_indicator_names = {entry["name"] for entry in python_api["indicators"]}
python_indicator_names_lc = {name.lower() for name in python_indicator_names}
wasm_set = set(wasm_exports)
wasm_set_lc = {name.lower() for name in wasm_set}
common_with_wasm = sorted(python_indicator_names_lc.intersection(wasm_set_lc))
manifest: dict[str, Any] = {
"surfaces": {
"python": python_api,
"rust_core": {
"public_function_count": len(rust_core),
"functions": rust_core,
},
"wasm_node": {
"export_count": len(wasm_exports),
"exports": wasm_exports,
},
},
"parity_summary": {
"python_indicator_count": len(python_indicator_names_lc),
"wasm_export_count": len(wasm_set),
"common_python_wasm_count": len(common_with_wasm),
"common_python_wasm": common_with_wasm,
"python_only_vs_wasm": sorted(python_indicator_names_lc - wasm_set_lc),
"wasm_only_vs_python": sorted(wasm_set_lc - python_indicator_names_lc),
},
}
if include_runtime_metadata:
manifest["generated_at_utc"] = _dt.datetime.now(tz=_dt.UTC).isoformat()
manifest["git_head"] = _safe_git_head(root)
return manifest
def main() -> None:
parser = argparse.ArgumentParser(description="Build cross-surface API manifest")
parser.add_argument(
"--output",
type=Path,
default=Path("docs/api_manifest.json"),
help="Output JSON path relative to repo root (default: docs/api_manifest.json)",
)
parser.add_argument(
"--include-runtime-metadata",
action="store_true",
help=(
"Include non-deterministic metadata fields (timestamp, git head). "
"Disabled by default to keep manifest reproducible for CI checks."
),
)
args = parser.parse_args()
root = _repo_root()
output_path = (root / args.output).resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
manifest = build_manifest(
root, include_runtime_metadata=args.include_runtime_metadata
)
output_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(f"Wrote API manifest to {output_path}")
if __name__ == "__main__":
main()
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Update or verify ferro-ta version strings across release files.
Usage
-----
python3 scripts/bump_version.py 1.0.3
python3 scripts/bump_version.py --check
python3 scripts/bump_version.py --show
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
@dataclass(frozen=True)
class VersionCarrier:
label: str
path: Path
pattern: str
replacement: str
def read(self) -> str:
text = self.path.read_text(encoding="utf-8")
match = re.search(self.pattern, text, flags=re.MULTILINE)
if not match:
raise ValueError(f"Could not find version for {self.label} in {self.path}")
return match.group(2)
def write(self, version: str) -> bool:
text = self.path.read_text(encoding="utf-8")
updated, count = re.subn(
self.pattern,
rf"\g<1>{version}\g<3>",
text,
count=1,
flags=re.MULTILINE,
)
if count != 1:
raise ValueError(f"Could not update {self.label} in {self.path}")
changed = updated != text
if changed:
self.path.write_text(updated, encoding="utf-8")
return changed
CARRIERS = [
VersionCarrier(
"cargo_root",
ROOT / "Cargo.toml",
r'(?m)^(version = ")([^"]+)(")$',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"cargo_core_dep",
ROOT / "Cargo.toml",
r'(ferro_ta_core = \{ path = "crates/ferro_ta_core", version = ")([^"]+)("[^}]*\})',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"cargo_core_crate",
ROOT / "crates" / "ferro_ta_core" / "Cargo.toml",
r'(?m)^(version = ")([^"]+)(")$',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"cargo_core_readme",
ROOT / "crates" / "ferro_ta_core" / "README.md",
r'(ferro_ta_core = ")([^"]+)(")',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"pyproject",
ROOT / "pyproject.toml",
r'(?m)^(version = ")([^"]+)(")$',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"wasm_cargo",
ROOT / "wasm" / "Cargo.toml",
r'(?m)^(version = ")([^"]+)(")$',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"wasm_package",
ROOT / "wasm" / "package.json",
r'("version": ")([^"]+)(")',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"conda",
ROOT / "conda" / "meta.yaml",
r'({% set version = ")([^"]+)(" %})',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"docs_changelog",
ROOT / "docs" / "changelog.rst",
r"(These docs track package version ``)([^`]+)(``\.)",
r"\g<1>{version}\g<3>",
),
VersionCarrier(
"docs_support_matrix",
ROOT / "docs" / "support_matrix.rst",
r"(These docs track package version ``)([^`]+)(``\.)",
r"\g<1>{version}\g<3>",
),
]
def _read_versions() -> dict[str, str]:
return {carrier.label: carrier.read() for carrier in CARRIERS}
def _print_versions(versions: dict[str, str]) -> None:
for label, version in versions.items():
print(f"{label:20} {version}")
def _check_versions() -> int:
versions = _read_versions()
unique = sorted(set(versions.values()))
_print_versions(versions)
if len(unique) != 1:
print()
print(f"ERROR: version mismatch detected: {', '.join(unique)}")
return 1
print()
print(f"OK: all tracked versions match {unique[0]}")
return 0
def _set_version(version: str) -> int:
if not SEMVER_RE.match(version):
print(f"ERROR: expected MAJOR.MINOR.PATCH, got {version!r}")
return 1
changed_paths: list[Path] = []
for carrier in CARRIERS:
if carrier.write(version):
changed_paths.append(carrier.path)
if changed_paths:
print(f"Updated version to {version}:")
for path in sorted(set(changed_paths)):
print(f" - {path.relative_to(ROOT)}")
else:
print(f"No changes needed. All tracked files already use {version}.")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version", nargs="?", help="New version to write")
parser.add_argument(
"--check",
action="store_true",
help="Fail if tracked version strings do not match",
)
parser.add_argument(
"--show",
action="store_true",
help="Print tracked version strings without modifying files",
)
args = parser.parse_args()
if args.check:
return _check_versions()
if args.show:
_print_versions(_read_versions())
return 0
if args.version:
return _set_version(args.version)
parser.print_help()
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Check that docs/api_manifest.json is up-to-date.
This script regenerates the deterministic manifest in-memory and compares it to
the committed file. It exits non-zero if drift is detected.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
def main() -> int:
root = Path(__file__).resolve().parents[1]
python_root = str(root / "python")
if python_root not in sys.path:
sys.path.insert(0, python_root)
scripts_root = str(root / "scripts")
if scripts_root not in sys.path:
sys.path.insert(0, scripts_root)
from build_api_manifest import build_manifest
manifest_path = root / "docs" / "api_manifest.json"
if not manifest_path.exists():
print(
"docs/api_manifest.json is missing. Run:\n"
" python scripts/build_api_manifest.py --output docs/api_manifest.json"
)
return 1
expected = build_manifest(root, include_runtime_metadata=False)
actual = json.loads(manifest_path.read_text(encoding="utf-8"))
if actual != expected:
print(
"docs/api_manifest.json is out of date.\n"
"Run:\n"
" python scripts/build_api_manifest.py --output docs/api_manifest.json\n"
"and commit the updated file."
)
return 1
print("docs/api_manifest.json is up to date.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Validate that CHANGELOG.md keeps a single top-level [Unreleased] section."""
from __future__ import annotations
import re
from pathlib import Path
def main() -> int:
changelog = Path("CHANGELOG.md")
if not changelog.exists():
print("ERROR: CHANGELOG.md not found.")
return 1
text = changelog.read_text(encoding="utf-8")
headings = list(re.finditer(r"^## \[(.+?)\]\s*$", text, flags=re.MULTILINE))
unreleased = [m for m in headings if m.group(1) == "Unreleased"]
if not unreleased:
print("ERROR: CHANGELOG.md is missing a '## [Unreleased]' heading.")
return 1
if len(unreleased) > 1:
print("ERROR: CHANGELOG.md contains multiple '## [Unreleased]' headings.")
return 1
if headings and headings[0].group(1) != "Unreleased":
print("ERROR: '## [Unreleased]' must be the first top-level changelog section.")
return 1
print("OK: CHANGELOG.md contains a single top-level [Unreleased] section.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# Pre-push CI gate — runs checks in parallel to minimise wall-clock time.
#
# Usage:
# scripts/pre_push_checks.sh # all checks
# scripts/pre_push_checks.sh rust_clippy wasm # selected checks
# scripts/pre_push_checks.sh --list
# FERRO_FAST=1 scripts/pre_push_checks.sh # skip docs + wasm bench
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
AVAILABLE_CHECKS=(
version changelog manifest
rust_fmt rust_clippy rust_core rust_bench
python_lint python_typecheck python_test
docs wasm
)
DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2; exit 1
fi
}
run_cmd() {
printf ' +'
printf ' %q' "$@"
printf '\n'
"$@"
}
usage() {
cat <<'EOF'
Usage:
scripts/pre_push_checks.sh
scripts/pre_push_checks.sh <check> [<check> ...]
scripts/pre_push_checks.sh --list
Environment:
FERRO_FAST=1 Skip docs and wasm (fastest local feedback loop)
EOF
}
# ---------------------------------------------------------------------------
# Individual check functions
# ---------------------------------------------------------------------------
run_version() { need_cmd python3; run_cmd python3 scripts/bump_version.py --check; }
run_changelog() { need_cmd python3; run_cmd python3 scripts/check_changelog.py; }
run_manifest() { need_cmd python3; run_cmd python3 scripts/check_api_manifest.py; }
run_rust_fmt() { need_cmd cargo; run_cmd cargo fmt --all -- --check; }
run_python_lint() {
need_cmd uv
run_cmd uv run --with ruff ruff check python/ tests/
run_cmd uv run --with ruff ruff format --check python/ tests/
}
run_rust_clippy() { need_cmd cargo; run_cmd cargo clippy --release -- -D warnings; }
run_rust_core() { need_cmd cargo; run_cmd cargo build -p ferro_ta_core && run_cmd cargo test -p ferro_ta_core; }
run_rust_bench() { need_cmd cargo; run_cmd cargo bench -p ferro_ta_core --no-run; }
run_python_typecheck() {
need_cmd uv
run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta \
--ignore-missing-imports --no-error-summary
run_cmd uv run --with pyright python -m pyright python/ferro_ta
}
# python_test and docs both need a compiled extension.
# Use a flag file so only the first concurrent caller runs maturin develop;
# subsequent callers (in parallel background jobs) wait and reuse it.
_MATURIN_LOCK="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.lock"
_MATURIN_FLAG="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.done"
ensure_python_env() {
[[ -f "$_MATURIN_FLAG" ]] && return
(
flock 9
if [[ ! -f "$_MATURIN_FLAG" ]]; then
need_cmd uv
run_cmd uv sync --extra dev --extra docs --extra mcp
run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
touch "$_MATURIN_FLAG"
fi
) 9>"$_MATURIN_LOCK"
}
run_python_test() {
ensure_python_env
run_cmd uv run --extra dev --extra mcp --with pytest-cov \
pytest tests/unit/ tests/integration/ \
-v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
}
run_docs() {
ensure_python_env
run_cmd uv run --extra docs python -m sphinx -b html docs docs/_build -W --keep-going
}
run_wasm() {
need_cmd node; need_cmd wasm-pack
(
cd wasm
run_cmd wasm-pack test --node
run_cmd npm run build
if [[ "${FERRO_FAST:-0}" != "1" ]]; then
local bj="../.wasm_benchmark.prepush.json"
run_cmd node bench.js --json "$bj"
rm -f "$bj"
fi
)
}
run_check() {
case "$1" in
version) run_version ;;
changelog) run_changelog ;;
manifest) run_manifest ;;
rust_fmt) run_rust_fmt ;;
rust_clippy) run_rust_clippy ;;
rust_core) run_rust_core ;;
rust_bench) run_rust_bench ;;
python_lint) run_python_lint ;;
python_typecheck) run_python_typecheck ;;
python_test) run_python_test ;;
docs) run_docs ;;
wasm) run_wasm ;;
*) echo "Unknown check: $1 — use --list" >&2; exit 1 ;;
esac
}
# ---------------------------------------------------------------------------
# Parallel runner — starts all checks concurrently, collects results
# ---------------------------------------------------------------------------
run_parallel() {
local -a checks=("$@")
[[ "${#checks[@]}" -eq 0 ]] && return 0
local -a pids logs names
local start
start=$(date +%s)
printf '\nStarting %d checks in parallel: %s\n' "${#checks[@]}" "${checks[*]}"
for check in "${checks[@]}"; do
local log
log=$(mktemp /tmp/ferro_prepush_XXXXXX)
logs+=("$log")
names+=("$check")
run_check "$check" >"$log" 2>&1 &
pids+=($!)
done
local failed=0
local -a failed_names
printf '\n'
for i in "${!pids[@]}"; do
if wait "${pids[$i]}" 2>/dev/null; then
printf ' ✓ %s\n' "${names[$i]}"
else
printf ' ✗ %s\n' "${names[$i]}"
failed_names+=("${names[$i]}")
failed=1
fi
done
# Print logs for failed checks only
if [[ "$failed" -eq 1 ]]; then
for i in "${!names[@]}"; do
local name="${names[$i]}"
if [[ " ${failed_names[*]:-} " == *" $name "* ]]; then
printf '\n'; printf '━%.0s' {1..60}; printf '\nFAILED: %s\n' "$name"; printf '━%.0s' {1..60}; printf '\n'
cat "${logs[$i]}"
fi
done
fi
for log in "${logs[@]}"; do rm -f "$log"; done
rm -f "$_MATURIN_LOCK" "$_MATURIN_FLAG"
printf '\nElapsed: %ds\n' "$(( $(date +%s) - start ))"
return "$failed"
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
[[ "${1:-}" == "--list" ]] && { printf '%s\n' "${AVAILABLE_CHECKS[@]}"; exit 0; }
selected_checks=()
if [[ "$#" -gt 0 ]]; then
selected_checks=("$@")
else
selected_checks=("${DEFAULT_CHECKS[@]}")
if [[ "${FERRO_FAST:-0}" == "1" ]]; then
selected_checks=()
for c in "${DEFAULT_CHECKS[@]}"; do
[[ "$c" == "docs" || "$c" == "wasm" ]] && continue
selected_checks+=("$c")
done
printf 'FERRO_FAST=1: skipping docs + wasm\n'
fi
fi
# ---------------------------------------------------------------------------
# Execution strategy:
# Phase 1 — instant gate (sequential, fail-fast):
# version, changelog, manifest, python_lint, rust_fmt
# These are trivial to run and catch the most common mistakes early.
# If any fail here we abort immediately without waiting for slow checks.
#
# Phase 2 — everything else in parallel:
# rust_clippy, rust_core, rust_bench, python_typecheck,
# python_test, docs, wasm
# ---------------------------------------------------------------------------
FAST_CHECKS=(version changelog manifest python_lint rust_fmt)
phase1=()
phase2=()
for c in "${selected_checks[@]}"; do
is_fast=0
for f in "${FAST_CHECKS[@]}"; do [[ "$c" == "$f" ]] && is_fast=1 && break; done
if [[ "$is_fast" -eq 1 ]]; then phase1+=("$c"); else phase2+=("$c"); fi
done
# Phase 1: fast gate
if [[ "${#phase1[@]}" -gt 0 ]]; then
printf 'Phase 1 — fast gate (%d checks)\n' "${#phase1[@]}"
start1=$(date +%s)
for c in "${phase1[@]}"; do
printf ' [%s] ... ' "$c"
log=$(mktemp /tmp/ferro_prepush_XXXXXX)
if run_check "$c" >"$log" 2>&1; then
printf 'ok\n'
else
printf 'FAILED\n'
cat "$log"
rm -f "$log"
echo "" >&2
echo "Fast gate failed on '$c' — aborting before slow checks." >&2
exit 1
fi
rm -f "$log"
done
printf 'Phase 1 passed (%ds)\n' "$(( $(date +%s) - start1 ))"
fi
# Phase 2: parallel slow checks
if [[ "${#phase2[@]}" -gt 0 ]]; then
printf '\nPhase 2 — parallel slow checks\n'
run_parallel "${phase2[@]}" || exit 1
fi
printf '\nAll pre-push checks passed.\n'