Files
playbook/skills/tsl-api-reference/scripts/class_lookup.py
T

357 lines
12 KiB
Python

#!/usr/bin/env python3
"""Query compact class surfaces and optional framework lifecycle profiles."""
import argparse
import json
import sys
from collections import Counter
from pathlib import Path
from framework_lookup import (
DEFAULT_INDEX,
load_index,
scaffold_packet,
validate_index,
)
from lookup import DEFAULT_TSV, codegen_root_for_tsv, load_rows, normalize
def class_rows(rows):
return [row for row in rows if row.get("kind") == "class"]
def filter_rows(rows, scope=None, module=None):
filtered = rows
if scope:
filtered = [
row for row in filtered if normalize(row.get("scope", "")) == normalize(scope)
]
if module:
filtered = [
row
for row in filtered
if normalize(row.get("module", "")) == normalize(module)
]
return filtered
def find_classes(rows, name, scope=None, module=None):
requested = normalize(name)
matches = [
row
for row in class_rows(rows)
if requested
in {
normalize(row.get("name", "")),
normalize(row.get("qualified_name", "")),
}
]
return filter_rows(matches, scope, module)
def find_profile(data, row):
for framework in data.get("frameworks", []):
if not isinstance(framework, dict):
continue
if (
normalize(framework.get("qualified_name", ""))
== normalize(row.get("qualified_name", ""))
and normalize(framework.get("scope", "")) == normalize(row.get("scope", ""))
and normalize(framework.get("module", ""))
== normalize(row.get("module", ""))
):
return framework
return None
def class_members(rows, row):
owner = normalize(row.get("qualified_name", "") or row.get("name", ""))
members = [
candidate
for candidate in rows
if candidate.get("kind") != "class"
and normalize(candidate.get("scope", "")) == normalize(row.get("scope", ""))
and normalize(candidate.get("module", "")) == normalize(row.get("module", ""))
and normalize(candidate.get("owner", "")) == owner
]
return sorted(
members,
key=lambda item: (
normalize(item.get("kind", "")),
normalize(item.get("qualified_name", "") or item.get("name", "")),
normalize(item.get("signature", "")),
item.get("anchor", ""),
),
)
def source_id(row):
return f"{row.get('page', '')}#{row.get('anchor', '')}"
def compact_class(row, profile_available=False):
return {
"name": row.get("name", ""),
"qualified_name": row.get("qualified_name", "") or row.get("name", ""),
"scope": row.get("scope", ""),
"module": row.get("module", ""),
"summary": row.get("summary", ""),
"profile_available": profile_available,
"evidence": [source_id(row)],
}
def compact_member(row):
return {
"name": row.get("name", ""),
"qualified_name": row.get("qualified_name", "") or row.get("name", ""),
"signature": row.get("signature", ""),
"kind": row.get("kind", ""),
"binding": row.get("binding", ""),
"visibility": row.get("visibility", ""),
"summary": row.get("summary", ""),
"evidence": [source_id(row)],
}
def member_summary(members):
counts = Counter(member.get("kind", "unknown") or "unknown" for member in members)
return dict(sorted(counts.items()))
def class_packet(rows, row, profile, config, include_members):
members = class_members(rows, row)
packet = {
"status": "resolved",
"class": compact_class(row, profile is not None),
"member_summary": member_summary(members),
"profile_status": "resolved" if profile is not None else "not_profiled",
"profile": None,
"diagnostics": [],
}
if include_members:
packet["members"] = [compact_member(member) for member in members]
if profile is not None:
packet["profile"] = scaffold_packet(profile, config)
packet["diagnostics"] = packet["profile"]["diagnostics"]
elif config:
packet["diagnostics"].append(
{
"code": "CLASS_NOT_PROFILED",
"severity": "error",
"message": "Configuration contracts require a curated framework profile.",
"next_action": "Query class members directly or add an evidence-backed profile.",
}
)
return packet
def ambiguous_packet(matches, profiles):
return {
"status": "ambiguous",
"candidates": [
compact_class(row, find_profile(profiles, row) is not None) for row in matches
],
"diagnostics": [
{
"code": "AMBIGUOUS_CLASS",
"severity": "error",
"message": "Multiple classes share this name across scopes or modules.",
"next_action": "Pass --scope and --module, or use a qualified class name.",
}
],
}
def print_class_text(packet):
if packet["status"] != "resolved":
print(f"status: {packet['status']}")
for candidate in packet.get("candidates", []):
print(
f"- {candidate['qualified_name']} "
f"({candidate['scope']}/{candidate['module']})"
)
for diagnostic in packet.get("diagnostics", []):
print(f"- {diagnostic['code']}: {diagnostic['message']}")
return
item = packet["class"]
print(f"class: {item['qualified_name']}")
print(f"scope/module: {item['scope']}/{item['module']}")
print(f"summary: {item['summary']}")
print(
"members: "
+ ", ".join(
f"{kind}={count}" for kind, count in packet["member_summary"].items()
)
)
print(f"profile_status: {packet['profile_status']}")
profile = packet.get("profile")
if profile:
print(f"scaffold_status: {profile['scaffold_status']}")
print(f"contract_status: {profile['contract_status']}")
print(
"lifecycle: "
+ " -> ".join(phase["phase"] for phase in profile["lifecycle"])
)
required_hooks = [
hook["api"] for hook in profile["hooks"] if hook.get("required")
]
print("required_hooks: " + (", ".join(required_hooks) or "none"))
if packet.get("members"):
print("member_list:")
for member in packet["members"]:
print(
f"- {member['qualified_name']}\t{member['kind']}\t"
f"{member['signature']}\t{member['evidence'][0]}"
)
if packet["diagnostics"]:
print("diagnostics:")
for diagnostic in packet["diagnostics"]:
print(f"- {diagnostic['code']}: {diagnostic['message']}")
def print_list_text(packet):
print(f"# {packet['count']} indexed classes")
print("qualified_name\tscope\tmodule\tprofile\tsummary")
for item in packet["classes"]:
print(
"\t".join(
[
item["qualified_name"],
item["scope"],
item["module"],
"framework" if item["profile_available"] else "generic",
item["summary"],
]
)
)
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Query indexed TSL classes and curated framework profiles.",
allow_abbrev=False,
)
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument("--class", dest="class_name", help="exact class name")
action.add_argument("--list", action="store_true", help="list indexed classes")
action.add_argument("--check", action="store_true", help="validate framework profiles")
parser.add_argument("--scope", help="filter class scope")
parser.add_argument("--module", help="filter class module")
parser.add_argument(
"--config", action="append", default=[], metavar="KEY=VALUE", help="raw config"
)
parser.add_argument(
"--include-members", action="store_true", help="include the compact member list"
)
parser.add_argument("--format", choices=("text", "json"), default="text")
parser.add_argument("--profiles", metavar="PATH", help="framework profile index")
parser.add_argument("--tsv", metavar="PATH", help="function index")
args = parser.parse_args(argv)
if args.config and not args.class_name:
parser.error("--config requires --class")
if args.include_members and not args.class_name:
parser.error("--include-members requires --class")
profile_path = Path(args.profiles) if args.profiles else DEFAULT_INDEX
tsv_path = Path(args.tsv) if args.tsv else DEFAULT_TSV
try:
profiles = load_index(profile_path)
rows = load_rows(tsv_path)
except (OSError, UnicodeError, ValueError) as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
if not rows:
print(f"ERROR: {tsv_path} has no entries", file=sys.stderr)
return 1
errors = validate_index(profiles, rows, codegen_root_for_tsv(tsv_path))
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
indexed_classes = filter_rows(class_rows(rows), args.scope, args.module)
if args.check:
print(
f"OK: {len(class_rows(rows))} classes indexed; "
f"{len(profiles['frameworks'])} framework profile(s) validated"
)
return 0
if args.list:
items = [
compact_class(row, find_profile(profiles, row) is not None)
for row in sorted(
indexed_classes,
key=lambda item: (
normalize(item.get("qualified_name", "") or item.get("name", "")),
normalize(item.get("scope", "")),
normalize(item.get("module", "")),
),
)
]
packet = {"status": "resolved", "count": len(items), "classes": items}
if args.format == "json":
print(json.dumps(packet, ensure_ascii=False, indent=2))
else:
print_list_text(packet)
return 0
matches = find_classes(rows, args.class_name, args.scope, args.module)
if not matches:
packet = {
"status": "not_found",
"class": args.class_name,
"diagnostics": [
{
"code": "CLASS_NOT_FOUND",
"severity": "error",
"message": f"No indexed class named {args.class_name!r}.",
"next_action": "Use --list or query an API directly with lookup.py.",
}
],
}
elif len(matches) > 1:
packet = ambiguous_packet(matches, profiles)
else:
config = {}
config_errors = []
for value in args.config:
key, separator, raw_value = value.partition("=")
if not separator or not key.strip() or not raw_value.strip():
config_errors.append(f"invalid configuration {value!r}")
else:
config[key.strip()] = raw_value.strip()
row = matches[0]
packet = class_packet(
rows,
row,
find_profile(profiles, row),
config,
args.include_members,
)
packet["diagnostics"].extend(
{
"code": "INVALID_CONFIGURATION",
"severity": "error",
"message": error,
"next_action": "Use KEY=VALUE syntax with non-empty values.",
}
for error in config_errors
)
if args.format == "json":
print(json.dumps(packet, ensure_ascii=False, indent=2))
else:
print_class_text(packet)
return 0
if __name__ == "__main__":
raise SystemExit(main())