195 lines
6.2 KiB
Python
195 lines
6.2 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/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"),
|
|
)
|
|
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",
|
|
),
|
|
)
|
|
|
|
|
|
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())
|