Files
CM95/cm93_importer_normalization_source_audit_standalone_v1.py
2026-09-02 09:50:12 +08:00

570 lines
19 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CM93 Importer Normalization 独立源码审计
Version: standalone-v1.0
完全独立:
- 不读取 V10/V11/V12 历史结果
- 不连接 MySQL
- 不修改源码
- 不生成 patch
- 不开始 PBF
用途:
从当前 importer 项目源码零开始定位:
1. Area / Coverage geometry 构造
2. chart_area_geometry / chart_coverage / chart_feature 写库
3. geometry_issue / geometry missing
4. raw_geotype / geometry_type / META / AREA
5. geometry_invalid_count / geometry_repaired_count
6. bbox 计算与写入
7. Shapely / GEOS / Polygon / MultiPolygon / WKB
8. raw_feature_blob / chart_raw_record / source_record_index
9. import job / summary / counters
10. 可能的 normalization 接入点
运行:
python .\\cm93_importer_normalization_source_audit_standalone_v1.py `
--project-root "."
输出自动生成 ZIP。
"""
from __future__ import annotations
import argparse
import ast
import csv
import json
import re
import sys
import zipfile
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
TEXT_EXTENSIONS = {
".py", ".sql", ".toml", ".ini", ".cfg", ".json",
".yaml", ".yml", ".md", ".txt", ".env", ".example",
}
SKIP_DIRS = {
".git", ".hg", ".svn", "__pycache__", ".pytest_cache",
".mypy_cache", ".ruff_cache", ".venv", "venv", "env",
"node_modules", "dist", "build", ".idea", ".vscode",
}
SEARCH_GROUPS = {
"GEOMETRY_BUILD": [
r"\bPolygon\s*\(", r"\bMultiPolygon\s*\(", r"\bLineString\s*\(",
r"\bPoint\s*\(", r"\bshapely\b", r"\bGEOS\b", r"\bwkb\b", r"\bgeom\b",
],
"NORMALIZATION": [
r"\bmake_valid\b", r"\bpolygonize_full\b", r"\bpolygonize\b",
r"\bunary_union\b", r"\bnormalize\b", r"\bis_valid\b",
r"\bexplain_validity\b", r"\bbuffer\s*\(\s*0",
],
"GEOMETRY_ISSUE": [
r"geometry_issue", r"geometry missing", r"geometry_missing",
r"invalid geometry", r"geometry_invalid", r"geometry_repaired",
],
"GEOTYPE": [
r"raw_geotype", r"\bgeotype\b", r"geometry_type",
r"\bMETA\b", r"\bAREA\b", r"\bSOUNDING\b", r"\bPOINT\b", r"\bLINE\b",
],
"BBOX": [
r"bbox_min_lon", r"bbox_min_lat", r"bbox_max_lon", r"bbox_max_lat",
r"\bbbox\b", r"\bbounds\b", r"min_lon", r"max_lon", r"min_lat", r"max_lat",
],
"COUNTERS": [
r"geometry_invalid_count", r"geometry_repaired_count",
r"invalid_count", r"repaired_count", r"summary_json",
r"chart_import_job", r"import_job",
],
"DB_AREA": [r"chart_area_geometry", r"area_geometry"],
"DB_COVERAGE": [r"chart_coverage", r"coverage.*geom"],
"DB_FEATURE": [r"chart_feature", r"feature_attribute"],
"DB_WRITE": [
r"\bINSERT\s+INTO\b", r"\bUPDATE\b", r"\bexecutemany\s*\(",
r"\bexecute\s*\(", r"\bcommit\s*\(", r"\bflush\s*\(", r"\badd\s*\(",
],
"RAW_SOURCE": [
r"raw_feature_blob", r"chart_raw_record", r"source_record_index",
r"raw_blob", r"raw_metadata_json", r"record_kind",
],
"TOPOLOGY": [
r"edge_index", r"segment_usage", r"vector_record", r"vector_table",
r"n_elements", r"\bring\b", r"\bcontour\b", r"\bedge\b", r"\bnode\b",
],
"SRID": [r"normalized_srid", r"\bSRID\b", r"\bsrid\b", r"4326"],
}
WEIGHTS = {
"GEOMETRY_BUILD": 5, "NORMALIZATION": 8, "GEOMETRY_ISSUE": 10,
"GEOTYPE": 7, "BBOX": 6, "COUNTERS": 9, "DB_AREA": 10,
"DB_COVERAGE": 10, "DB_FEATURE": 7, "DB_WRITE": 6,
"RAW_SOURCE": 5, "TOPOLOGY": 5, "SRID": 3,
}
def write_csv(path: Path, rows: Iterable[Dict[str, Any]]) -> None:
rows = list(rows)
keys: List[str] = []
seen = set()
for row in rows:
for key in row:
if key not in seen:
seen.add(key)
keys.append(key)
with path.open("w", encoding="utf-8-sig", newline="") as f:
if not keys:
return
w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
w.writeheader()
for row in rows:
out = {}
for key in keys:
val = row.get(key)
if isinstance(val, (dict, list, tuple)):
val = json.dumps(val, ensure_ascii=False, default=str)
out[key] = "" if val is None else val
w.writerow(out)
def safe_text(path: Path) -> Optional[str]:
for enc in ("utf-8", "utf-8-sig", "cp932", "latin-1"):
try:
return path.read_text(encoding=enc)
except Exception:
pass
return None
def should_skip(path: Path) -> bool:
return any(part in SKIP_DIRS for part in path.parts)
def rel(path: Path, root: Path) -> str:
try:
return str(path.resolve().relative_to(root.resolve()))
except Exception:
return str(path)
def list_files(root: Path, outdir: Path) -> List[Path]:
result = []
for p in root.rglob("*"):
if not p.is_file() or should_skip(p):
continue
try:
p.resolve().relative_to(outdir.resolve())
continue
except Exception:
pass
if p.suffix.lower() in TEXT_EXTENSIONS:
result.append(p)
return sorted(result)
def context(lines: List[str], line_no: int, radius: int = 3) -> str:
start = max(1, line_no - radius)
end = min(len(lines), line_no + radius)
return "\n".join(f"{i:05d}: {lines[i-1]}" for i in range(start, end + 1))
def scan_hits(path: Path, text: str, root: Path) -> List[Dict[str, Any]]:
rows = []
lines = text.splitlines()
compiled = {
group: [re.compile(p, re.I) for p in pats]
for group, pats in SEARCH_GROUPS.items()
}
for no, line in enumerate(lines, 1):
for group, pats in compiled.items():
matched = [p.pattern for p in pats if p.search(line)]
if matched:
rows.append({
"file": rel(path, root),
"line": no,
"group": group,
"matched_patterns": matched,
"code": line.strip(),
"context": context(lines, no),
})
return rows
def ast_inventory(path: Path, text: str, root: Path):
funcs, classes, deps, sqls = [], [], [], []
if path.suffix.lower() != ".py":
return funcs, classes, deps, sqls
try:
tree = ast.parse(text)
except Exception:
return funcs, classes, deps, sqls
class V(ast.NodeVisitor):
def __init__(self):
self.cls: List[str] = []
self.fn: List[str] = []
def visit_ClassDef(self, node):
classes.append({
"file": rel(path, root),
"class_name": node.name,
"line": node.lineno,
"end_line": getattr(node, "end_lineno", None),
})
self.cls.append(node.name)
self.generic_visit(node)
self.cls.pop()
def visit_FunctionDef(self, node):
funcs.append({
"file": rel(path, root),
"class_name": ".".join(self.cls),
"function_name": node.name,
"line": node.lineno,
"end_line": getattr(node, "end_lineno", None),
"async": 0,
"args": [a.arg for a in node.args.args],
})
self.fn.append(node.name)
self.generic_visit(node)
self.fn.pop()
def visit_AsyncFunctionDef(self, node):
funcs.append({
"file": rel(path, root),
"class_name": ".".join(self.cls),
"function_name": node.name,
"line": node.lineno,
"end_line": getattr(node, "end_lineno", None),
"async": 1,
"args": [a.arg for a in node.args.args],
})
self.fn.append(node.name)
self.generic_visit(node)
self.fn.pop()
def visit_Import(self, node):
for a in node.names:
deps.append({
"file": rel(path, root), "line": node.lineno,
"type": "import", "module": a.name, "names": "",
})
def visit_ImportFrom(self, node):
deps.append({
"file": rel(path, root), "line": node.lineno,
"type": "from", "module": node.module or "",
"names": [x.name for x in node.names],
})
def visit_Constant(self, node):
if isinstance(node.value, str) and re.search(
r"\b(SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM|CREATE\s+TABLE|ALTER\s+TABLE)\b",
node.value, re.I
):
sqls.append({
"file": rel(path, root),
"line": getattr(node, "lineno", None),
"class_name": ".".join(self.cls),
"function_name": ".".join(self.fn),
"sql": node.value.strip(),
})
V().visit(tree)
return funcs, classes, deps, sqls
def score_file(path: Path, hits: List[Dict[str, Any]], root: Path) -> Dict[str, Any]:
c = Counter(x["group"] for x in hits)
score = sum(WEIGHTS.get(g, 1) * min(n, 15) for g, n in c.items())
name = rel(path, root).lower()
for token, bonus in (
("import", 10), ("cm93", 10), ("geom", 7), ("coverage", 6),
("feature", 5), ("parser", 5), ("decoder", 5), ("mysql", 4),
("database", 4), ("repository", 3), ("model", 2),
):
if token in name:
score += bonus
row = {"file": rel(path, root), "score": score, "hit_count": len(hits)}
for g in SEARCH_GROUPS:
row[f"hits_{g.lower()}"] = c.get(g, 0)
return row
def derive_points(hits: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
buckets = {
"area_geometry_build": [], "coverage_geometry_build": [],
"area_geometry_write": [], "coverage_geometry_write": [],
"feature_write": [], "geometry_issue_logic": [],
"geotype_logic": [], "invalid_counter_logic": [],
"repaired_counter_logic": [], "bbox_logic": [],
"raw_source_preservation": [], "srid_logic": [],
"normalization_existing": [],
}
for r in hits:
code = r["code"].lower()
grp = r["group"]
if grp == "GEOMETRY_BUILD":
if "coverage" in code or "coverage" in r["file"].lower():
buckets["coverage_geometry_build"].append(r)
else:
buckets["area_geometry_build"].append(r)
if "chart_area_geometry" in code or "area_geometry" in code:
buckets["area_geometry_write"].append(r)
if "chart_coverage" in code:
buckets["coverage_geometry_write"].append(r)
if "chart_feature" in code and grp in {"DB_FEATURE", "DB_WRITE"}:
buckets["feature_write"].append(r)
if grp == "GEOMETRY_ISSUE":
buckets["geometry_issue_logic"].append(r)
if grp == "GEOTYPE" or "geometry missing" in code:
buckets["geotype_logic"].append(r)
if "geometry_invalid_count" in code:
buckets["invalid_counter_logic"].append(r)
if "geometry_repaired_count" in code:
buckets["repaired_counter_logic"].append(r)
if grp == "BBOX":
buckets["bbox_logic"].append(r)
if grp == "RAW_SOURCE":
buckets["raw_source_preservation"].append(r)
if grp == "SRID":
buckets["srid_logic"].append(r)
if grp == "NORMALIZATION":
buckets["normalization_existing"].append(r)
for key, rows in buckets.items():
uniq = {(r["file"], int(r["line"])): r for r in rows}
buckets[key] = sorted(
uniq.values(),
key=lambda x: (x["file"], int(x["line"]))
)[:60]
return buckets
def build_md(candidates, points) -> str:
titles = {
"area_geometry_build": "Area geometry 构造",
"coverage_geometry_build": "Coverage geometry 构造",
"area_geometry_write": "Area geometry 写库",
"coverage_geometry_write": "Coverage geometry 写库",
"feature_write": "Feature 写库",
"geometry_issue_logic": "geometry_issue 判定",
"geotype_logic": "raw_geotype / META / geometry missing",
"invalid_counter_logic": "geometry_invalid_count",
"repaired_counter_logic": "geometry_repaired_count",
"bbox_logic": "bbox 计算与写入",
"raw_source_preservation": "raw source 保留",
"srid_logic": "SRID / normalized_srid",
"normalization_existing": "现有 validity / normalization",
}
lines = [
"# CM93 Importer Normalization 独立源码审计 v1",
"",
"本报告仅依据当前项目源码,不依赖任何历史测试结果。",
"",
"## 高优先级候选文件",
"",
]
for r in candidates[:20]:
lines.append(f"- `{r['file']}` — score={r['score']}, hits={r['hit_count']}")
for key, title in titles.items():
lines += ["", f"## {title}", ""]
rows = points.get(key, [])
if not rows:
lines.append("- 未自动定位。")
else:
for r in rows[:30]:
lines.append(f"- `{r['file']}:{r['line']}` — `{r['code']}`")
lines += [
"",
"## 下一阶段人工确认重点",
"",
"1. 确认 Area geometry 的唯一最终构造入口。",
"2. 确认 Coverage geometry 的唯一最终构造入口。",
"3. 确认 validity 检查发生在构造后、写库前还是写库后。",
"4. 确认 raw_geotype / META 为什么会进入 geometry missing。",
"5. 确认 geometry_invalid_count 的实际累计条件。",
"6. 确认 bbox 来自 raw topology 还是最终 geometry。",
"7. 确认数据库写入点对 Polygon/MultiPolygon 的处理方式。",
"8. 确认 raw_feature_blob / chart_raw_record 始终保留。",
"",
"本轮不修改源码,不修改数据库,不进入 PBF。",
]
return "\n".join(lines)
def parse_args():
p = argparse.ArgumentParser(description="CM93 importer 独立源码审计")
p.add_argument("--project-root", default=".")
p.add_argument("--output", default=None)
return p.parse_args()
def main():
args = parse_args()
root = Path(args.project_root).expanduser().resolve()
if not root.is_dir():
print(f"项目目录不存在:{root}")
sys.exit(2)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
outdir = Path(
args.output or root / f"cm93_importer_source_audit_standalone_v1_{stamp}"
).expanduser().resolve()
outdir.mkdir(parents=True, exist_ok=True)
print("[1/6] 枚举源码文件 ...")
files = list_files(root, outdir)
print(f" files={len(files)}")
file_rows, hits = [], []
funcs, classes, deps, ast_sql = [], [], [], []
candidates = []
print("[2/6] 关键字 + AST 扫描 ...")
for i, path in enumerate(files, 1):
text = safe_text(path)
file_rows.append({
"file": rel(path, root),
"suffix": path.suffix.lower(),
"size_bytes": path.stat().st_size,
"readable_text": int(text is not None),
})
if text is None:
continue
h = scan_hits(path, text, root)
hits.extend(h)
f, c, d, s = ast_inventory(path, text, root)
funcs.extend(f)
classes.extend(c)
deps.extend(d)
ast_sql.extend(s)
if h:
candidates.append(score_file(path, h, root))
if i % 100 == 0:
print(f" scanned={i}/{len(files)}")
candidates.sort(key=lambda r: (-int(r["score"]), r["file"]))
print("[3/6] 归类 SQL 与接入点 ...")
sql_hits = [
r for r in hits
if r["group"] in {"DB_AREA", "DB_COVERAGE", "DB_FEATURE", "DB_WRITE"}
or re.search(r"\b(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER)\b", r["code"], re.I)
]
for r in ast_sql:
sql_hits.append({
"file": r["file"], "line": r["line"],
"group": "AST_SQL_LITERAL", "code": r["sql"],
"context": r["sql"], "class_name": r.get("class_name"),
"function_name": r.get("function_name"),
})
points = derive_points(hits)
print("[4/6] 写结构化输出 ...")
write_csv(outdir / "项目文件清单_v1.csv", file_rows)
write_csv(outdir / "候选源码文件_v1.csv", candidates)
write_csv(outdir / "关键代码命中_v1.csv", hits)
write_csv(outdir / "SQL命中_v1.csv", sql_hits)
write_csv(outdir / "Python函数清单_v1.csv", funcs)
write_csv(outdir / "Python类清单_v1.csv", classes)
write_csv(outdir / "Python依赖清单_v1.csv", deps)
(outdir / "normalization接入点_v1.md").write_text(
build_md(candidates, points), encoding="utf-8"
)
print("[5/6] 写摘要 ...")
group_counts = Counter(r["group"] for r in hits)
report = {
"version": "standalone-v1.0",
"generated_at": datetime.now().isoformat(timespec="seconds"),
"project_root": str(root),
"history_dependency": False,
"database_dependency": False,
"read_only": True,
"pbf": "OUT_OF_SCOPE",
"source_file_count": len(files),
"candidate_file_count": len(candidates),
"code_hit_count": len(hits),
"sql_hit_count": len(sql_hits),
"function_count": len(funcs),
"class_count": len(classes),
"dependency_count": len(deps),
"hit_group_counts": dict(group_counts),
"integration_point_counts": {k: len(v) for k, v in points.items()},
"top_candidate_files": candidates[:25],
}
(outdir / "独立源码审计报告_v1.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str),
encoding="utf-8"
)
lines = [
"CM93 Importer Normalization 独立源码审计 v1",
"=" * 88,
f"Generated: {report['generated_at']}",
f"Project: {root}",
"",
"DEPENDENCY",
" 历史测试依赖: NO",
" 数据库依赖: NO",
" 源码修改: NO",
" PBF: OUT OF SCOPE",
"",
"SCAN",
f" source files: {len(files)}",
f" candidate files: {len(candidates)}",
f" code hits: {len(hits)}",
f" SQL hits: {len(sql_hits)}",
f" Python functions: {len(funcs)}",
f" Python classes: {len(classes)}",
"",
"TOP CANDIDATE FILES",
]
for r in candidates[:15]:
lines.append(f" {int(r['score']):4d} {r['file']}")
lines += [
"",
"SOURCE AUDIT COMPLETE: YES",
"NO SOURCE WRITE / NO DATABASE WRITE / NO PBF",
]
(outdir / "独立源码审计摘要_v1.txt").write_text(
"\n".join(lines), encoding="utf-8"
)
print("[6/6] 压缩 ...")
zpath = outdir.with_suffix(".zip")
with zipfile.ZipFile(zpath, "w", compression=zipfile.ZIP_DEFLATED) as z:
for p in sorted(outdir.rglob("*")):
if p.is_file():
z.write(p, p.relative_to(outdir.parent))
print("")
print("SOURCE AUDIT COMPLETE: YES")
print(f"Output: {outdir}")
print(f"ZIP: {zpath}")
print("把 ZIP 上传给 ChatGPT。")
if __name__ == "__main__":
main()