PMFlow/scripts/semantic_lint.py

219 lines
7.4 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
import re
from typing import Iterable, Sequence
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SCAN_ROOTS = (
Path("backend/app/services/ai_pmo"),
Path("backend/app/services/adaptive_rag_service"),
Path("backend/app/services/external_query_service"),
Path("backend/app/services/integrations"),
Path("backend/app/services/signal"),
Path("backend/app/api/ai_pmo_api"),
)
ALLOWLIST_PREFIXES = (
Path("backend/app/config/ai_prompt_registry.py"),
Path("backend/app/config/ai_prompts.py"),
Path("backend/app/config/ai_prompts_catalog"),
Path("backend/app/config/operation_catalog_core"),
Path("backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_project.py"),
Path("backend/app/services/signal/signal_source_orchestration/mixin_planning_locator_roles.py"),
Path("backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py"),
Path("backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py"),
Path("backend/app/services/signal/symbol_index/shared.py"),
Path("backend/app/services/integrations/feishu_integration_service/mixin_message_payload.py"),
Path("backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py"),
)
DEFAULT_BASELINE = Path("scripts/semantic_lint_baseline.txt")
@dataclass(frozen=True)
class Problem:
path: str
line: int
rule_id: str
excerpt: str
def key(self) -> str:
return f"{self.path}:{self.line}:{self.rule_id}"
@dataclass(frozen=True)
class Rule:
rule_id: str
pattern: re.Pattern[str]
message: str
RULES: tuple[Rule, ...] = (
Rule(
rule_id="semantic_hint_constant",
pattern=re.compile(r"\b[A-Z][A-Z0-9_]*(?:HINTS|KEYWORDS)\b\s*="),
message="semantic hint/keyword table should live in workflow_semantic_planner or prompt/catalog config",
),
Rule(
rule_id="semantic_regex_constant",
pattern=re.compile(
r"\b[A-Z][A-Z0-9_]*(?:PROJECT|TASK|SIGNAL|FOLLOWUP|COMMAND|QUERY|SOURCE|INTENT|EXTERNAL|CORRECTION)[A-Z0-9_]*_RE\b\s*=\s*re\.compile"
),
message="business routing regex should live in workflow_semantic_planner",
),
Rule(
rule_id="looks_like_helper",
pattern=re.compile(
r"def\s+_?looks_like_(?:project|task|signal|followup|query|command|external|contextual|write|source|intent)[A-Za-z0-9_]*\s*\("
),
message="behavior-routing looks_like helper should live in workflow_semantic_planner",
),
Rule(
rule_id="question_routing_helper",
pattern=re.compile(r"def\s+_is_(?:query_like_question|obvious_question)\s*\("),
message="question routing helper should live in workflow_semantic_planner",
),
Rule(
rule_id="semantic_route_object_table",
pattern=re.compile(
r"\b(?:_INTERNAL_ROUTE_OBJECTS|_EXTERNAL_ROUTE_OBJECTS|_SEMANTIC_QUERY_OBJECT_TYPES)\b\s*="
),
message="runtime route object tables are forbidden; consume structured upstream semantics instead",
),
Rule(
rule_id="semantic_route_probe_helper",
pattern=re.compile(
r"def\s+_(?:internal_probe_is_strong|external_probe_is_strong|external_probe_is_minimally_answerable|run_internal_route_probe|run_external_route_probe)\s*\("
),
message="runtime route probe strong/weak helpers are forbidden in query routing",
),
Rule(
rule_id="semantic_object_kind_mapping",
pattern=re.compile(
r'\b(?:if|elif)\s+"(?:weather|public_company|public_model|market_data|news_topic)"\s+in\s+referenced_objects\b'
),
message="runtime object-to-query_kind mapping is forbidden; keep external fallback generic",
),
)
def _normalize_relpath(root: Path, path: Path) -> str:
return path.resolve().relative_to(root.resolve()).as_posix()
def _is_allowlisted(relpath: str) -> bool:
relative = Path(relpath)
for prefix in ALLOWLIST_PREFIXES:
if relative == prefix or prefix in relative.parents:
return True
return False
def _iter_files(root: Path, scan_roots: Sequence[Path]) -> Iterable[Path]:
for rel_root in scan_roots:
candidate = (root / rel_root).resolve()
if not candidate.exists():
continue
if candidate.is_file():
yield candidate
continue
for path in candidate.rglob("*"):
if not path.is_file():
continue
if path.suffix not in {".py", ".ts", ".tsx"}:
continue
yield path
def _load_baseline(root: Path, baseline_path: Path) -> set[str]:
candidate = (root / baseline_path).resolve()
if not candidate.exists():
return set()
rows: set[str] = set()
for line in candidate.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
rows.add(stripped)
return rows
def collect_problems(
*,
root: Path,
scan_roots: Sequence[Path],
baseline_path: Path,
) -> list[Problem]:
baseline = _load_baseline(root, baseline_path)
problems: list[Problem] = []
for path in _iter_files(root, scan_roots):
relpath = _normalize_relpath(root, path)
if _is_allowlisted(relpath):
continue
lines = path.read_text(encoding="utf-8").splitlines()
for idx, raw_line in enumerate(lines, start=1):
line = raw_line.rstrip("\n")
if "semantic-lint: ignore" in line:
continue
for rule in RULES:
if not rule.pattern.search(line):
continue
problem = Problem(
path=relpath,
line=idx,
rule_id=rule.rule_id,
excerpt=line.strip()[:240],
)
if problem.key() in baseline:
continue
problems.append(problem)
break
return problems
def _parse_args(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Guard PMFlow business semantics from leaking outside workflow_semantic_planner.")
parser.add_argument(
"--root",
default=str(ROOT),
help="Repository root to scan.",
)
parser.add_argument(
"--baseline",
default=str(DEFAULT_BASELINE),
help="Baseline file with approved compatibility exceptions.",
)
parser.add_argument(
"scan_roots",
nargs="*",
help="Optional relative roots/files to scan. Defaults to backend/app and frontend/src.",
)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
args = _parse_args(argv or sys.argv[1:])
root = Path(args.root).resolve()
scan_roots = tuple(Path(item) for item in (args.scan_roots or DEFAULT_SCAN_ROOTS))
problems = collect_problems(
root=root,
scan_roots=scan_roots,
baseline_path=Path(args.baseline),
)
if not problems:
print("semantic-lint: ok")
return 0
print("semantic-lint: problems found")
for item in problems:
print(f"- {item.path}:{item.line} [{item.rule_id}] {item.excerpt}")
return 1
if __name__ == "__main__":
raise SystemExit(main())