Productize jyotish app release surface
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit local Jyotish project fragments against real product surfaces.
|
||||
|
||||
The capability registry can say that a technique is covered, but this audit
|
||||
checks whether the claim has a concrete path through CLI, API, frontend source,
|
||||
tests, or a real file. It is intentionally stdlib-only so it can run inside the
|
||||
normal quality gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = ROOT / "references" / "technique_registry.json"
|
||||
SCRIPTS_DIR = ROOT / "scripts"
|
||||
APP_DIR = ROOT / "jyotish-app"
|
||||
TESTS_DIR = ROOT / "tests"
|
||||
OPEN_SOURCE_DIR = ROOT / "references" / "open_source_sources"
|
||||
|
||||
API_COMMAND_MAP = {
|
||||
"chart": "/api/chart",
|
||||
"kp": "/api/kp",
|
||||
"prashna": "/api/prashna",
|
||||
"synastry": "/api/synastry",
|
||||
"ashtakoot": "/api/synastry",
|
||||
"dasha": "/api/dasha",
|
||||
"remedies": "/api/remedies",
|
||||
"sade_sati": "/api/sade_sati",
|
||||
"pancha_mahapurusha": "/api/pancha_mahapurusha",
|
||||
"career": "/api/career",
|
||||
"relationship": "/api/relationship",
|
||||
"full-reading": "/api/chart",
|
||||
"tajika": "/api/annual",
|
||||
"solar-return": "/api/annual",
|
||||
"muhurta": "/api/muhurta",
|
||||
"panchanga-range": "/api/panchanga_range",
|
||||
"bhava-chalit": "/api/bhava_chalit",
|
||||
"sudarshana": "/api/sudarshana",
|
||||
"nakshatra-full": "/api/nakshatra_full",
|
||||
"varga-full": "/api/varga_full",
|
||||
"jaimini": "/api/jaimini",
|
||||
"ashtakavarga": "/api/ashtakavarga",
|
||||
"shadbala": "/api/shadbala",
|
||||
"yoga": "/api/yogas",
|
||||
"aspects": "/api/aspects",
|
||||
"rectification": "/api/rectification_gate",
|
||||
"case-validation": "/api/case_validation",
|
||||
"deep-varga-avastha": "/api/deep_varga_avastha",
|
||||
"divisional-yoga": "/api/divisional_yoga",
|
||||
"kakshya": "/api/kakshya",
|
||||
"bhava-bala": "/api/bhava_bala",
|
||||
"transit-trigger": "/api/transit",
|
||||
"audit-capabilities": "/api/capability_audit",
|
||||
"thematic-report": "/api/thematic_report",
|
||||
"report-artifact": "/api/report_artifact",
|
||||
}
|
||||
|
||||
FRONTEND_MARKERS = {
|
||||
"remedies": ["remedies-section", "remedies.evidence_chain"],
|
||||
"career": ["computeCareer", "事业分析", "career analysis"],
|
||||
"relationship": ["computeRelationship", "感情分析", "relationship analysis"],
|
||||
"kp": ["computeKP", "tab-kp", "kp-section"],
|
||||
"prashna": ["computePrashna", "tab-prashna"],
|
||||
"synastry": ["computeSynastry", "tab-synastry"],
|
||||
"muhurta": ["computeMuhurta", "muhurta"],
|
||||
"panchanga-range": ["computePanchangaRange", "panchanga-range"],
|
||||
"solar-return": ["computeAnnual", "varshaphala", "tajika"],
|
||||
"tajika": ["computeAnnual", "varshaphala", "tajika"],
|
||||
"bhava-bala": ["computeBhavaBala", "bhava bala"],
|
||||
"divisional-yoga": ["computeDivisionalYoga", "divisional yoga"],
|
||||
"rectification": ["computeRectificationGate", "rectification"],
|
||||
"case-validation": ["computeCaseValidation", "mevg", "case validation"],
|
||||
"deep-varga-avastha": ["deepVargaAvastha", "deep_varga_avastha", "Sayanadi/Shayanadi", "D24/D30/D60"],
|
||||
"transit-trigger": ["computeTransitTriggers", "transit trigger"],
|
||||
"kakshya": ["computeKakshya", "kakshya"],
|
||||
}
|
||||
|
||||
SCRIPT_IGNORE = {
|
||||
"__init__.py",
|
||||
"_compute_one_chart.py",
|
||||
"add_d60_to_test_charts.py",
|
||||
"add_high_confidence_yogas_batch1.py",
|
||||
"analyze_yoga_errors.py",
|
||||
"audit_capabilities.py",
|
||||
"audit_fragments.py",
|
||||
"benchmark_yoga_coverage.py",
|
||||
"build_planet_positions_60.py",
|
||||
"build_standard_test_charts.py",
|
||||
"chart_renderer.py",
|
||||
"generate_yoga_rules.py",
|
||||
"hermes_bridge.py",
|
||||
"jyotish_api_server.py",
|
||||
"jyotish_engine.py",
|
||||
"oss_monitor.py",
|
||||
"run_quality_gate.py",
|
||||
"validate.py",
|
||||
"validate_bphs_invariants.py",
|
||||
"validate_logic_v2.py",
|
||||
"validate_yoga_accuracy.py",
|
||||
}
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def load_registry() -> dict[str, Any]:
|
||||
return json.loads(read_text(REGISTRY_PATH))
|
||||
|
||||
|
||||
def scan_engine_commands() -> set[str]:
|
||||
text = read_text(SCRIPTS_DIR / "jyotish_engine.py")
|
||||
return set(re.findall(r"add_parser\(['\"]([^'\"]+)['\"]", text))
|
||||
|
||||
|
||||
def scan_api_endpoints() -> set[str]:
|
||||
text = read_text(SCRIPTS_DIR / "jyotish_api_server.py")
|
||||
return set(re.findall(r"path == ['\"](/api/[^'\"]+)['\"]", text))
|
||||
|
||||
|
||||
def scan_frontend() -> dict[str, Any]:
|
||||
text_parts: list[str] = []
|
||||
files: list[str] = []
|
||||
if APP_DIR.exists():
|
||||
for path in APP_DIR.rglob("*"):
|
||||
if any(part in {"node_modules", "dist", ".vite"} for part in path.parts):
|
||||
continue
|
||||
if path.suffix not in {".js", ".html", ".css"}:
|
||||
continue
|
||||
files.append(str(path.relative_to(ROOT)))
|
||||
text_parts.append(read_text(path))
|
||||
text = "\n".join(text_parts)
|
||||
return {
|
||||
"file_count": len(files),
|
||||
"api_paths": sorted(set(re.findall(r"['\"](/api/[^'\"]+)['\"]", text))),
|
||||
"api_functions": sorted(set(re.findall(r"async function (compute[A-Z][A-Za-z0-9_]*|getCapabilityAudit)", text))),
|
||||
"source_text": text,
|
||||
}
|
||||
|
||||
|
||||
def scan_test_text() -> str:
|
||||
parts: list[str] = []
|
||||
if TESTS_DIR.exists():
|
||||
for path in TESTS_DIR.rglob("*.py"):
|
||||
parts.append(read_text(path))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def git_untracked() -> list[str]:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "ls-files", "--others", "--exclude-standard"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
if completed.returncode != 0:
|
||||
return []
|
||||
return sorted(line for line in completed.stdout.splitlines() if line)
|
||||
|
||||
|
||||
def git_lost_found() -> list[str]:
|
||||
root = ROOT / ".git" / "lost-found"
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(str(path.relative_to(ROOT)) for path in root.rglob("*") if path.is_file())
|
||||
|
||||
|
||||
def first_token_path(value: str) -> str:
|
||||
return value.split()[0] if value else value
|
||||
|
||||
|
||||
def output_path_exists(path: str) -> tuple[bool, str]:
|
||||
if path.startswith("scripts/"):
|
||||
rel = first_token_path(path)
|
||||
return (ROOT / rel).exists(), rel
|
||||
if path.endswith(".py"):
|
||||
return (ROOT / path).exists(), path
|
||||
return True, path
|
||||
|
||||
|
||||
def command_surface(command: str, engine_commands: set[str], api_endpoints: set[str]) -> str | None:
|
||||
if command in engine_commands:
|
||||
return "engine"
|
||||
endpoint = API_COMMAND_MAP.get(command)
|
||||
if endpoint and endpoint in api_endpoints:
|
||||
return "api"
|
||||
if command.startswith("scripts/") and (ROOT / first_token_path(command)).exists():
|
||||
return "script"
|
||||
return None
|
||||
|
||||
|
||||
def frontend_reaches(command: str, frontend: dict[str, Any]) -> bool:
|
||||
endpoint = API_COMMAND_MAP.get(command)
|
||||
if endpoint and endpoint in frontend["api_paths"]:
|
||||
return True
|
||||
source = frontend["source_text"].lower()
|
||||
markers = FRONTEND_MARKERS.get(command, [])
|
||||
return any(marker.lower() in source for marker in markers)
|
||||
|
||||
|
||||
def registry_script_refs(registry: dict[str, Any]) -> set[str]:
|
||||
refs: set[str] = set()
|
||||
for technique in registry.get("techniques", {}).values():
|
||||
for field in ("commands", "output_paths"):
|
||||
for value in technique.get(field, []):
|
||||
if isinstance(value, str) and value.startswith("scripts/"):
|
||||
refs.add(Path(first_token_path(value)).name)
|
||||
return refs
|
||||
|
||||
|
||||
def source_referenced_scripts(*texts: str) -> set[str]:
|
||||
combined = "\n".join(texts)
|
||||
refs: set[str] = set()
|
||||
for path in SCRIPTS_DIR.glob("*.py"):
|
||||
stem = path.stem
|
||||
if re.search(rf"\b(import|from)\s+{re.escape(stem)}\b", combined):
|
||||
refs.add(path.name)
|
||||
if path.name in combined or stem in combined:
|
||||
refs.add(path.name)
|
||||
return refs
|
||||
|
||||
|
||||
def find_script_fragments(registry: dict[str, Any], frontend: dict[str, Any], test_text: str) -> dict[str, Any]:
|
||||
api_text = read_text(SCRIPTS_DIR / "jyotish_api_server.py")
|
||||
engine_text = read_text(SCRIPTS_DIR / "jyotish_engine.py")
|
||||
app_text = frontend["source_text"]
|
||||
referenced = set(SCRIPT_IGNORE)
|
||||
referenced |= registry_script_refs(registry)
|
||||
referenced |= source_referenced_scripts(api_text, engine_text, app_text, test_text)
|
||||
candidates = []
|
||||
for path in sorted(SCRIPTS_DIR.glob("*.py")):
|
||||
if path.name in referenced:
|
||||
continue
|
||||
candidates.append(path.name)
|
||||
return {
|
||||
"ignored_count": len(SCRIPT_IGNORE),
|
||||
"referenced_count": len(referenced),
|
||||
"candidate_count": len(candidates),
|
||||
"candidates": candidates,
|
||||
}
|
||||
|
||||
|
||||
def list_open_source_sources() -> list[dict[str, Any]]:
|
||||
if not OPEN_SOURCE_DIR.exists():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(p for p in OPEN_SOURCE_DIR.iterdir() if p.is_dir()):
|
||||
files = [p for p in path.rglob("*") if p.is_file() and ".git" not in p.parts]
|
||||
result.append({
|
||||
"name": path.name,
|
||||
"path": str(path.relative_to(ROOT)),
|
||||
"file_count": len(files),
|
||||
"has_license": any(p.name.lower() in {"license", "license.md", "licence"} for p in files),
|
||||
"has_readme": any(p.name.lower() == "readme.md" for p in files),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def audit() -> dict[str, Any]:
|
||||
registry = load_registry()
|
||||
techniques = registry.get("techniques", {})
|
||||
engine_commands = scan_engine_commands()
|
||||
api_endpoints = scan_api_endpoints()
|
||||
frontend = scan_frontend()
|
||||
test_text = scan_test_text()
|
||||
|
||||
problems: list[dict[str, Any]] = []
|
||||
warnings: list[dict[str, Any]] = []
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
for tech_id, technique in sorted(techniques.items()):
|
||||
status = technique.get("status")
|
||||
commands = [c for c in technique.get("commands", []) if isinstance(c, str)]
|
||||
output_paths = [p for p in technique.get("output_paths", []) if isinstance(p, str)]
|
||||
command_surfaces = {command: command_surface(command, engine_commands, api_endpoints) for command in commands}
|
||||
frontend_commands = [command for command in commands if frontend_reaches(command, frontend)]
|
||||
|
||||
if status in {"covered", "complete"} and not commands:
|
||||
problems.append({
|
||||
"kind": "covered_without_command",
|
||||
"technique_id": tech_id,
|
||||
"message": "covered/complete techniques must declare at least one real command or API command",
|
||||
})
|
||||
|
||||
for command, surface in command_surfaces.items():
|
||||
if surface is None:
|
||||
problems.append({
|
||||
"kind": "unreachable_command",
|
||||
"technique_id": tech_id,
|
||||
"command": command,
|
||||
"message": "registry command is not found in CLI commands, API endpoints, or script files",
|
||||
})
|
||||
|
||||
for path in output_paths:
|
||||
ok, checked = output_path_exists(path)
|
||||
if not ok:
|
||||
problems.append({
|
||||
"kind": "missing_output_path",
|
||||
"technique_id": tech_id,
|
||||
"output_path": path,
|
||||
"checked_path": checked,
|
||||
})
|
||||
|
||||
api_commands = [cmd for cmd, surface in command_surfaces.items() if surface == "api"]
|
||||
if api_commands and not frontend_commands:
|
||||
warnings.append({
|
||||
"kind": "api_without_frontend_marker",
|
||||
"technique_id": tech_id,
|
||||
"commands": api_commands,
|
||||
"message": "API exists, but no obvious frontend bridge/topic marker was detected",
|
||||
})
|
||||
|
||||
rows.append({
|
||||
"id": tech_id,
|
||||
"status": status,
|
||||
"commands": commands,
|
||||
"command_surfaces": command_surfaces,
|
||||
"frontend_commands": frontend_commands,
|
||||
"output_paths": output_paths,
|
||||
})
|
||||
|
||||
fragments = find_script_fragments(registry, frontend, test_text)
|
||||
open_sources = list_open_source_sources()
|
||||
untracked = git_untracked()
|
||||
lost_found = git_lost_found()
|
||||
|
||||
return {
|
||||
"valid": not problems,
|
||||
"problem_count": len(problems),
|
||||
"warning_count": len(warnings),
|
||||
"registry": {
|
||||
"technique_count": len(techniques),
|
||||
"version": registry.get("version"),
|
||||
},
|
||||
"surfaces": {
|
||||
"engine_command_count": len(engine_commands),
|
||||
"engine_commands": sorted(engine_commands),
|
||||
"api_endpoint_count": len(api_endpoints),
|
||||
"api_endpoints": sorted(api_endpoints),
|
||||
"frontend_file_count": frontend["file_count"],
|
||||
"frontend_api_paths": frontend["api_paths"],
|
||||
"frontend_api_functions": frontend["api_functions"],
|
||||
},
|
||||
"fragments": fragments,
|
||||
"open_source_sources": {
|
||||
"source_count": len(open_sources),
|
||||
"sources": open_sources,
|
||||
},
|
||||
"workspace_residue": {
|
||||
"untracked_count": len(untracked),
|
||||
"untracked_files": untracked,
|
||||
"git_lost_found_count": len(lost_found),
|
||||
"git_lost_found_files": lost_found,
|
||||
},
|
||||
"problems": problems,
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Audit Jyotish registry, product surfaces, and local fragments")
|
||||
parser.add_argument("--strict", action="store_true", help="Return non-zero when hard problems are found")
|
||||
args = parser.parse_args()
|
||||
result = audit()
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 1 if args.strict and not result["valid"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user