404 lines
14 KiB
Python
404 lines
14 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
|
||
|
||
|
||
HELP_EPILOG = """\
|
||
查询顺序:
|
||
1. 先按 class 取类摘要或 Framework Profile
|
||
class_lookup.py --class TStringList
|
||
class_lookup.py --class TSBackTesting --format json
|
||
2. 需要成员正文时,再按返回的 qualified_name 调用 lookup.py --name
|
||
|
||
输出状态:
|
||
status resolved、not_found 或 ambiguous
|
||
profile_status resolved 表示有 Framework Profile;not_profiled 表示普通 class
|
||
scaffold_status 生命周期和成员引用是否完整,仅在有 Profile 时输出
|
||
contract_status 回调字段契约是否完整;非 resolved 时不得生成回调字段
|
||
diagnostics 消歧、配置或契约缺口及下一步动作
|
||
|
||
--check 校验全部 Framework Profile 对 class、成员正文和证据的引用。
|
||
使用自定义 --tsv 时默认不加载 Framework Profile;普通 class 仍可正常查询。若该索引
|
||
另有 curated Profile,必须同时显式传入 --profiles。
|
||
|
||
退出码:
|
||
0 查询或校验完成;not_found、ambiguous 通过输出状态表达
|
||
1 function/framework 索引缺失、格式错误、为空或引用校验失败
|
||
2 参数不合法
|
||
"""
|
||
|
||
|
||
def empty_profile_index():
|
||
return {"schema_version": 1, "frameworks": []}
|
||
|
||
|
||
def profile_path_for(args):
|
||
if args.profiles:
|
||
return Path(args.profiles)
|
||
if args.tsv:
|
||
return None
|
||
return DEFAULT_INDEX
|
||
|
||
|
||
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="查询 TSL class 摘要及其证据化的 Framework Profile。",
|
||
epilog=HELP_EPILOG,
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
allow_abbrev=False,
|
||
)
|
||
action = parser.add_mutually_exclusive_group(required=True)
|
||
action.add_argument("--class", dest="class_name", help="精确 class 名或完全限定名")
|
||
action.add_argument("--list", action="store_true", help="列出索引中的 class")
|
||
action.add_argument("--check", action="store_true", help="校验全部 Framework Profile 引用")
|
||
parser.add_argument("--scope", help="按 class scope 过滤")
|
||
parser.add_argument("--module", help="按 class module 过滤")
|
||
parser.add_argument(
|
||
"--config",
|
||
action="append",
|
||
default=[],
|
||
metavar="KEY=VALUE",
|
||
help="传入原始框架配置,可重复",
|
||
)
|
||
parser.add_argument(
|
||
"--include-members", action="store_true", help="在类摘要中包含紧凑成员清单"
|
||
)
|
||
parser.add_argument(
|
||
"--format", choices=("text", "json"), default="text", help="输出格式"
|
||
)
|
||
parser.add_argument("--profiles", metavar="PATH", help="Framework Profile 索引路径")
|
||
parser.add_argument("--tsv", metavar="PATH", help="function_index.tsv 路径")
|
||
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")
|
||
|
||
tsv_path = Path(args.tsv) if args.tsv else DEFAULT_TSV
|
||
profile_path = profile_path_for(args)
|
||
try:
|
||
profiles = (
|
||
load_index(profile_path) if profile_path else empty_profile_index()
|
||
)
|
||
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())
|