CoolFace
Apppublic

lankasailendra/BMCTESTMAIN

sourceHugging Faceupdated 1d agoView on Hugging Face
0likes
coverage_audit.py1040 linesDownload Raw Back to scripts
1#!/usr/bin/env python32"""3coverage_audit.py — Phase 1 of the codegen re-architecture (see project memory4`codegen-assembler-handoff.md`).5 6Question this answers, deterministically and with NO LLM in the loop:7 8    What percentage of the ITSM regression catalog's steps can be ASSEMBLED from9    the existing harness-proven conftest helper library, rather than generated10    as fresh code by a model?11 12The number produced here is the go/no-go input for the step->helper assembler.13This script makes NO decision and edits NOTHING; it only measures.14 15Inputs (all read-only), resolved relative to --reference:16    regression_catalog.json     the 407-case catalog17    conftest_manifest.json      the published helper vocabulary18    conftest_files/conftest_*.py  helper definitions (AST-harvested)19    test_*.py                   reference test modules (real call-sites = evidence)20 21Usage:22    python coverage_audit.py --reference <path to reference/itsm> \23                             [--json out.json] [--markdown out.md] [--samples N]24 25Design notes / deliberate conservatism (see MATCHER NOTES in the report):26  * Matching is verb-class x domain-object, both drawn from lexicons below.27    There is no fuzzy string similarity and no learned model: a step matches a28    helper only if it names the same OBJECT the helper name names, and applies a29    VERB in the same class as the helper's leading verb token.30  * A helper only yields the strongest verdict if it is PROVEN -- actually called31    by name in at least one reference test. Helpers that exist but have never run32    are demoted to PARTIAL, because an unexercised helper is not evidence.33  * Where the algorithm was underspecified, the stricter reading was taken and34    recorded. Ambiguity resolves DOWNWARD (ASSEMBLABLE -> PARTIAL -> GAP).35"""36 37from __future__ import annotations38 39import argparse40import ast41import collections42import json43import os44import re45import sys46from typing import Dict, List, Optional, Sequence, Set, Tuple47 48# --------------------------------------------------------------------------49# Verdicts50# --------------------------------------------------------------------------51# Five fine-grained labels. They roll up two ways (see rollup()) because the52# right rollup depends on what the assembler's emitter is taken to include.53HELPER = "HELPER"      # a proven helper performs this operation54FIXTURE = "FIXTURE"    # login / navigation, already carried by a pytest fixture55ASSERT = "ASSERT"      # pure verification; emitter writes an assert from step.expected56FIELD = "FIELD"        # a form-field entry: a PARAMETER of a neighbouring helper57                       # call, not a call of its own. Needs step-grouping.58PARTIAL = "PARTIAL"    # a helper touches this object but verb/params are unclear59GAP = "GAP"            # nothing in the library covers this operation60 61VERDICTS = (HELPER, FIXTURE, ASSERT, FIELD, PARTIAL, GAP)62 63# How many ranked candidate helpers a step verdict carries. Only affects the64# reported candidate lists and the recall measure, never the verdict itself.65MAX_HELPERS = int(os.environ.get("COVERAGE_AUDIT_TOPN", "8"))66 67# Form fields. A step that only puts a value into one of these is not a separate68# operation -- it is an argument to the create/update helper for the record.69FIELD_TRIGGERS = (70    "summary", "description", "detailed description", "notes", "note",71    "impact", "urgency", "priority", "severity", "status", "reason",72    "status reason", "company", "site", "location", "region", "department",73    "organization", "organisation", "first name", "last name", "phone",74    "email", "category", "categorization", "categorisation", "tier",75    "class", "type", "risk", "risk level", "lead time", "start date",76    "end date", "due date", "target date", "scheduled start", "scheduled end",77    "scope", "timing", "service", "product", "manufacturer", "model",78    "serial", "serial number", "tag", "asset tag", "cost", "vendor",79    "supplier", "template", "source", "reported source", "resolution",80    "workaround", "root cause", "title", "keywords", "author", "reviewer",81    "expiry", "expiration", "environment", "version", "customer",82    "requester", "requested for", "assignee", "support group", "owner",83    "coordinator", "manager", "operational", "field",84)85 86# --------------------------------------------------------------------------87# Lexicons88# --------------------------------------------------------------------------89# Step-side verb triggers -> canonical verb class.90# Order matters only in that all triggers are scanned; a step may carry several.91VERB_TRIGGERS: Dict[str, Sequence[str]] = {92    "LOGIN": ("log in", "login", "log into", "sign in", "signin", "sign-in",93              "authenticate", "logged in as", "log on"),94    "NAV": ("open", "navigate", "go to", "browse", "launch", "access",95            "return to", "switch to", "back to", "land on", "visit"),96    "CREATE": ("create", "raise", "submit a new", "new record", "log a",97               "log an", "register", "generate a", "add a new", "make a new",98               "start a new", "initiate", "add", "post", "write", "document",99               "record a", "capture a"),100    "SET": ("set", "enter", "type", "fill", "select", "choose", "specify",101            "populate", "provide", "input", "pick", "assign a value",102            "update the", "modify", "edit", "change the", "supply", "flag",103            "tick", "check the box", "untick", "toggle"),104    "SAVE": ("save", "click save", "submit the form", "commit"),105    "SEARCH": ("search", "find", "look up", "lookup", "filter", "query",106               "locate", "identify", "list all", "retrieve"),107    "RELATE": ("relate", "associate", "link", "related item", "association",108               "attach the", "connect", "tie to"),109    "ASSIGN": ("assign", "reassign", "allocate", "assign to me", "take ownership"),110    "TRANSITION": ("move", "advance", "transition", "progress", "promote",111                   "resolve", "close", "cancel", "complete", "reopen",112                   "approve", "reject", "acknowledge", "escalate", "reroute",113                   "mark as", "set status", "status to", "put the", "revert"),114    "ATTACH": ("attach", "upload", "add an attachment", "add attachment"),115    "CLEAR": ("delete", "remove", "clear", "detach", "unlink", "discard"),116    "SEND": ("send", "issue", "post", "invoke", "call the api", "rest call",117             "api call", "get request", "put request", "patch request"),118    "CLICK": ("click", "press", "tap", "hit"),119    "WAIT": ("wait", "pause", "poll until", "allow time"),120    "ASSERT": ("verify", "confirm", "check", "observe", "note", "read",121               "review", "validate", "inspect", "compare", "ensure that",122               "make sure", "should show", "should be", "record the",123               "capture the", "screenshot"),124}125 126# Helper-name leading-token -> verb class it satisfies.127HELPER_VERB_TOKENS: Dict[str, Sequence[str]] = {128    "CREATE": ("create", "new", "build", "make", "add", "raise", "submit",129               "generate", "provision", "seed"),130    "SET": ("set", "fill", "select", "pick", "patch", "update", "apply",131            "choose", "populate", "assign", "edit", "modify", "put",132            "normalize", "format", "rebuild", "toggle"),133    "SAVE": ("save", "click", "submit", "commit", "finalize"),134    "SEARCH": ("get", "find", "fetch", "search", "lookup", "query", "list",135               "resolve", "read", "extract", "load", "current", "collect",136               "discover", "scan"),137    "RELATE": ("relate", "associate", "link", "add", "attach", "association",138               "associations"),139    "ASSIGN": ("assign", "set", "fill", "pick", "select"),140    "TRANSITION": ("advance", "move", "patch", "close", "resolve", "complete",141                   "reopen", "approve", "reject", "cancel", "transition",142                   "set", "escalate", "ensure", "progress"),143    "ATTACH": ("attach", "upload", "add"),144    "CLEAR": ("clear", "remove", "delete", "detach", "reset", "cleanup"),145    "SEND": ("post", "put", "patch", "send", "call", "request", "api",146             "create", "get", "delete"),147    "CLICK": ("click", "open", "expand", "menu", "press", "select"),148    "WAIT": ("wait", "poll", "ensure"),149    "ASSERT": ("assert", "verify", "is", "has", "check", "get", "expect",150               "match", "compare", "validate", "confirm"),151    "NAV": ("open", "goto", "navigate", "smartit", "console", "page"),152    "LOGIN": ("login", "signin", "persona", "session", "authenticate"),153}154 155# Domain object triggers -> helper-name tokens that denote the same object.156# A step and a helper must agree on OBJECT for the helper to be a candidate.157OBJECT_LEXICON: Dict[str, Tuple[Tuple[str, ...], Tuple[str, ...]]] = {158    # canonical: (step-text triggers, helper-name tokens)159    "incident": (("incident", "hpd"),160                 ("incident", "inc", "hpd")),161    "work_info": (("work info", "work note", "worklog", "work log",162                   "work detail", "public note", "internal note"),163                  ("work", "info", "note", "notes", "worklog")),164    "activity": (("activity", "timeline", "audit trail", "audit history",165                  "history", "feed"),166                 ("activity", "timeline", "audit", "history", "feed")),167    "recordtype": (("class", "type", "kind"),168                   ("type", "class", "kind")),169    "tab": (("tab", "section", "panel", "accordion"),170            ("tab", "section", "panel")),171    "change": (("change request", "change", "crq", "rfc", "chg"),172               ("change", "chg", "crq")),173    "problem": (("problem investigation", "problem", "pbi", "pbm"),174                ("problem", "pbm", "pbi")),175    "known_error": (("known error", "known-error", "ke record", "pke"),176                    ("known", "error", "ke", "pke")),177    "work_order": (("work order", "work-order", "woi", "wo "),178                   ("work", "order", "wo", "woi")),179    "task": (("task", "tms", "sub-task", "subtask"),180             ("task", "tasks", "tms")),181    "knowledge": (("knowledge", "article", "kba", "rkm", "how to", "how-to"),182                  ("knowledge", "article", "kba", "rkm", "km")),183    "release": (("release", "rms", "release manifest"),184                ("release", "rms", "rel")),185    "asset": (("asset", "ast:", "hardware", "inventory"),186              ("asset", "ast")),187    "ci": (("configuration item", "ci", "cis", "cmdb", "bmc.core"),188           ("ci", "cmdb", "config")),189    "approval": (("approval", "approver", "approve", "sfa", "signature",190                  "authoriz"),191                 ("approval", "approve", "approver", "sfa", "signature",192                  "auth")),193    "category": (("categorization", "categorisation", "category", "categories",194                  "operational cat", "product cat", "tier 1", "tier 2"),195                 ("category", "categories", "cat", "cats", "op", "prod",196                  "tier")),197    "status": (("status", "state", "lifecycle", "stage bar", "stage"),198               ("status", "state", "stage", "lifecycle")),199    "assignment": (("assign", "assignee", "support group", "assigned group",200                    "owner group", "coordinator", "manager", "ownership"),201                   ("assign", "assignment", "assignee", "group", "support",202                    "coordinator", "manager", "owner")),203    "attachment": (("attachment", "attach a file", "upload"),204                   ("attachment", "attach", "upload", "file")),205    "console": (("console", "ticket console", "dashboard", "list view",206                 "grid", "search results"),207                ("console", "filter", "filters", "grid", "list")),208    "template": (("template",), ("template", "templates", "tmpl")),209    # Raw REST-transport steps ("Send POST {base}/api/jwt/login ...", "Resend210    # the GET request without the Authorization header"). Added 2026-08-17211    # with send_rest_request / jwt_login_capture_token; deliberately does NOT212    # include the bare word "request" ("change request" would false-positive) —213    # "get/post/put request" phrasings are covered as two-word triggers.214    "rest": (("rest", "api", "endpoint", "jwt", "token", "http", "https",215              "curl", "postman", "payload", "json", "authorization header",216              "get request", "post request", "put request", "patch request",217              "delete request", "rest client", "response code", "status code"),218             ("rest", "api", "jwt", "token", "request", "http", "endpoint")),219    "sla": (("sla", "service target", "svt", "milestone"),220            ("sla", "svt", "target", "milestone")),221    "freeze": (("freeze", "blackout", "calendar"),222               ("freeze", "cal", "calendar", "blackout")),223    "customer": (("customer", "requester", "requested for", "affected user",224                  "contact"),225                 ("customer", "requester", "requested", "contact", "people",226                  "ctm")),227    "notification": (("notification", "email", "notify", "alert"),228                     ("notification", "email", "notify", "alert")),229    "relationship": (("related item", "relationship", "association",230                      "relate"),231                     ("relate", "association", "associations", "item",232                      "items", "link")),233    "date": (("scheduled start", "scheduled end", "target date", "due date",234              "date/time", "schedule"),235             ("date", "dates", "schedule", "sched", "start", "end", "due")),236    "priority": (("priority", "impact", "urgency", "severity", "weight"),237                 ("priority", "impact", "urgency", "severity")),238    "risk": (("risk", "risk level"), ("risk",)),239    "resolution": (("resolution", "resolve", "root cause", "workaround",240                    "fix note", "closure"),241                   ("resolution", "resolve", "cause", "workaround", "close",242                    "closure")),243    "person": (("persona", "user account", "login as", "test account",244                "agent", "requester login"),245               ("persona", "user", "login", "session", "account")),246    "company": (("company", "organization", "organisation", "site",247                 "location", "region"),248                ("company", "org", "site", "location", "region")),249    "service": (("service", "business service", "service ci"),250                ("service", "svc")),251    "cost": (("cost", "price", "financial", "budget", "invoice"),252             ("cost", "price", "financial", "budget")),253    "report": (("report", "metric", "kpi", "chart", "export"),254               ("report", "metric", "kpi", "export")),255    "helixgpt": (("helixgpt", "helix gpt", "ai agent", "chatbot", "copilot",256                  "collaborator", "sparkle", "generative"),257                 ("helixgpt", "gpt", "ai", "chat", "agent")),258}259 260# Steps whose whole content is environment setup already carried by a fixture.261FIXTURE_PATTERNS = (262    r"\blog ?in\b", r"\bsign ?in\b", r"\blogged in\b",263    r"\bopen the smart ?it url\b", r"\bopen smart ?it\b",264    r"\bnavigate to (the )?smart ?it\b",265    r"\bopen (the )?(mid ?tier|browser)\b",266    r"\blaunch the (application|browser|url)\b",267)268 269# Module ownership tokens. If a helper's NAME carries one of these tokens, the270# helper belongs to that module and may only serve a step that actually names271# that module's object (or a step in that module's own cases). This is the272# deterministic cross-module veto -- the recurring leak class in this codebase273# (see project memory `module-inference-catalog-leak`, `cross-module-audit`).274MODULE_TOKENS: Dict[str, Tuple[str, ...]] = {275    "incident": ("incident", "inc", "hpd"),276    "change": ("change", "chg", "crq"),277    "problem": ("problem", "pbm", "pbi"),278    "known_error": ("pke", "ke"),279    "work_order": ("wo", "woi"),280    "task": ("task", "tasks", "tms"),281    "knowledge": ("knowledge", "kba", "rkm", "km"),282    "release": ("release", "rms"),283    "asset": ("asset", "ast"),284    "ci": ("cmdb",),285}286# Catalog module name -> canonical object key, for the veto's "own module" test.287CASE_MODULE_CANON = {288    "Incident": "incident", "Change": "change", "Problem": "problem",289    "Known Error": "known_error", "Work Order": "work_order",290    "Knowledge": "knowledge", "Release": "release", "Asset": "asset",291    "CMDB": "ci", "HelixGPT": "helixgpt",292}293 294# Quoted test data and record ids leak object words ("Create infrastructure295# change request") into steps that are not about that object. Strip them before296# object detection.297QUOTED_RE = re.compile(r"'[^']*'|\"[^\"]*\"|‘[^’]*’|“[^”]*”")298IDLIKE_RE = re.compile(r"\b(?:ITSM|TC|UAT|CRQ|INC|PBI|WO|RLM|TAS|AST|KBA)[-_A-Z0-9]*\d[-_A-Z0-9]*\b",299                       re.IGNORECASE)300 301STOPWORDS = {302    "the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",303    "is", "are", "be", "it", "that", "this", "as", "at", "by", "from", "if",304    "then", "when", "your", "you", "its", "any", "all", "new", "test",305}306 307 308# --------------------------------------------------------------------------309# Vocabulary harvesting310# --------------------------------------------------------------------------311class Helper:312    __slots__ = ("name", "source", "tokens", "lead", "doc", "is_fixture",313                 "is_private", "proven", "call_count")314 315    def __init__(self, name: str, source: str, doc: str = "",316                 is_fixture: bool = False):317        self.name = name318        self.source = source319        self.doc = (doc or "").strip()320        self.is_fixture = is_fixture321        self.is_private = name.startswith("_")322        parts = [p for p in name.strip("_").lower().split("_") if p]323        self.tokens = set(parts)324        self.lead = parts[0] if parts else ""325        self.proven = False326        self.call_count = 0327 328 329def harvest_vocabulary(ref: str) -> Dict[str, Helper]:330    """manifest names UNION AST defs across conftest*.py."""331    helpers: Dict[str, Helper] = {}332 333    manifest_path = os.path.join(ref, "conftest_manifest.json")334    if os.path.exists(manifest_path):335        with open(manifest_path, encoding="utf-8") as fh:336            manifest = json.load(fh)337        for fn in manifest.get("functions", []):338            nm = fn.get("name")339            if not nm:340                continue341            helpers[nm] = Helper(nm, "manifest:" + str(fn.get("module", "")),342                                 fn.get("docstring") or "")343 344    conftests = [os.path.join(ref, "conftest.py")]345    conftests += sorted(346        os.path.join(ref, "conftest_files", f)347        for f in os.listdir(os.path.join(ref, "conftest_files"))348        if f.startswith("conftest_") and f.endswith(".py")349    ) if os.path.isdir(os.path.join(ref, "conftest_files")) else []350 351    for path in conftests:352        if not os.path.exists(path):353            continue354        try:355            tree = ast.parse(_read(path))356        except SyntaxError as exc:  # never fatal; record and continue357            print(f"  ! could not parse {os.path.basename(path)}: {exc}",358                  file=sys.stderr)359            continue360        for node in ast.walk(tree):361            if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):362                continue363            is_fix = any(364                (isinstance(d, ast.Name) and d.id == "fixture")365                or (isinstance(d, ast.Attribute) and d.attr == "fixture")366                or (isinstance(d, ast.Call) and _dec_name(d.func) == "fixture")367                for d in node.decorator_list368            )369            existing = helpers.get(node.name)370            if existing is None:371                helpers[node.name] = Helper(372                    node.name, os.path.basename(path),373                    ast.get_docstring(node) or "", is_fix)374            else:375                existing.source = os.path.basename(path)376                existing.is_fixture = existing.is_fixture or is_fix377                if not existing.doc:378                    existing.doc = ast.get_docstring(node) or ""379    return helpers380 381 382def _dec_name(node) -> str:383    if isinstance(node, ast.Attribute):384        return node.attr385    if isinstance(node, ast.Name):386        return node.id387    return ""388 389 390def _read(path: str) -> str:391    with open(path, encoding="utf-8", errors="replace") as fh:392        return fh.read()393 394 395# --------------------------------------------------------------------------396# Evidence: real call-sites in the reference test modules397# --------------------------------------------------------------------------398TC_RE = re.compile(r"(TC-[A-Z]+-[A-Z]+-\d+)")399STEP_COMMENT_RE = re.compile(r"^\s*#\s*Steps?\s*(\d+)(?:\s*[-–]\s*(\d+))?\s*[:—-]?\s*(.*)$",400                             re.IGNORECASE)401 402 403class Evidence:404    def __init__(self):405        self.by_case: Dict[str, dict] = {}406        self.step_level: List[dict] = []   # only where '# Step N' comments exist407        self.test_count = 0408 409 410def harvest_evidence(ref: str, helpers: Dict[str, Helper]) -> Evidence:411    """For every reference test: which helpers it calls BY NAME, which fixtures412    it takes, and how much raw inline UI/REST it still contains."""413    ev = Evidence()414    test_files = sorted(f for f in os.listdir(ref)415                        if f.startswith("test_") and f.endswith(".py"))416    for fname in test_files:417        path = os.path.join(ref, fname)418        src = _read(path)419        lines = src.splitlines()420        try:421            tree = ast.parse(src)422        except SyntaxError as exc:423            print(f"  ! could not parse {fname}: {exc}", file=sys.stderr)424            continue425        for node in tree.body:426            if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):427                continue428            if not node.name.startswith("test_"):429                continue430            ev.test_count += 1431            doc = ast.get_docstring(node) or ""432            m = TC_RE.search(doc) or TC_RE.search(node.name.upper().replace("_", "-"))433            tc_id = m.group(1) if m else None434 435            called: Set[str] = set()436            raw_rest = 0437            raw_ui = 0438            for sub in ast.walk(node):439                if not isinstance(sub, ast.Call):440                    continue441                fn = sub.func442                # Only bare Name() calls count as helper usage. page.click()443                # and similar attribute calls are Playwright, not our library --444                # counting them would inflate "proven" with false positives.445                if isinstance(fn, ast.Name):446                    if fn.id in helpers:447                        called.add(fn.id)448                        helpers[fn.id].call_count += 1449                        helpers[fn.id].proven = True450                elif isinstance(fn, ast.Attribute):451                    root = _attr_root(fn)452                    if root in ("requests", "session") and fn.attr in (453                            "post", "put", "patch", "delete"):454                        raw_rest += 1455                    elif fn.attr in ("click", "fill", "type", "press",456                                     "select_option", "set_input_files",457                                     "check", "uncheck", "goto"):458                        raw_ui += 1459            for arg in node.args.args:460                if arg.arg in helpers:461                    helpers[arg.arg].proven = True462                    helpers[arg.arg].call_count += 1463                    called.add(arg.arg)464 465            rec = {466                "test": node.name, "file": fname, "tc_id": tc_id,467                "helpers": sorted(called), "raw_rest": raw_rest,468                "raw_ui": raw_ui,469            }470            if tc_id:471                ev.by_case[tc_id] = rec472 473            # step-level evidence where the author left '# Step N' markers474            start = node.lineno - 1475            end = getattr(node, "end_lineno", len(lines))476            markers: List[Tuple[int, int, str]] = []477            for i in range(start, min(end, len(lines))):478                sm = STEP_COMMENT_RE.match(lines[i])479                if sm:480                    lo = int(sm.group(1))481                    hi = int(sm.group(2)) if sm.group(2) else lo482                    markers.append((i, lo, hi))483            for idx, (line_i, lo, hi) in enumerate(markers):484                seg_end = markers[idx + 1][0] if idx + 1 < len(markers) else end485                seg = "\n".join(lines[line_i:seg_end])486                try:487                    seg_tree = ast.parse(_dedent(seg))488                except SyntaxError:489                    continue490                seg_helpers = {491                    n.func.id for n in ast.walk(seg_tree)492                    if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)493                    and n.func.id in helpers494                }495                ev.step_level.append({496                    "tc_id": tc_id, "step_lo": lo, "step_hi": hi,497                    "helpers": sorted(seg_helpers),498                })499    return ev500 501 502def _attr_root(node) -> str:503    cur = node504    while isinstance(cur, ast.Attribute):505        cur = cur.value506    return cur.id if isinstance(cur, ast.Name) else ""507 508 509def _dedent(block: str) -> str:510    lines = [l for l in block.splitlines() if l.strip()]511    if not lines:512        return block513    pad = min(len(l) - len(l.lstrip()) for l in lines)514    return "\n".join(l[pad:] if len(l) >= pad else l515                     for l in block.splitlines())516 517 518# --------------------------------------------------------------------------519# Matcher520# --------------------------------------------------------------------------521def _trigger_re(triggers: Sequence[str]) -> "re.Pattern":522    """Word-boundary alternation. Substring matching produced false positives523    (' ci ' inside 'specific', 'ke' inside 'make'), so every trigger is524    anchored on word boundaries."""525    parts = sorted((re.escape(t) for t in triggers), key=len, reverse=True)526    # trailing (e)s? so plurals match: 'changes', 'assets', 'CIs', 'tasks'527    return re.compile(r"(?<![a-z0-9])(?:" + "|".join(parts) + r")(?:e?s)?(?![a-z0-9])",528                      re.IGNORECASE)529 530 531_VERB_RE = {c: _trigger_re(t) for c, t in VERB_TRIGGERS.items()}532_OBJ_RE = {c: _trigger_re(t[0]) for c, t in OBJECT_LEXICON.items()}533_FIELD_RE = _trigger_re(FIELD_TRIGGERS)534 535# Relative clauses describe a PRECONDITION, not the step's own action:536# "log in with an account that can create changes" is a login step.537SUBORDINATE_RE = re.compile(538    r"\b(that|who|which)\s+(can|may|is|are|has|have)\b.*$|"539    r"\bwith (permission|rights|access|the role)\b.*$|"540    r"\b(able|allowed|authoriz\w+|entitled|permitted) to\b.*$",541    re.IGNORECASE)542 543 544def strip_literals(text: str) -> str:545    """Remove quoted test data and record ids so their words do not register546    as domain objects for the surrounding step."""547    return IDLIKE_RE.sub(" ", QUOTED_RE.sub(" ", text))548 549 550def step_verbs(text: str) -> Set[str]:551    return {c for c, rx in _VERB_RE.items() if rx.search(text)}552 553 554def step_objects(text: str) -> Set[str]:555    clean = strip_literals(text)556    return {c for c, rx in _OBJ_RE.items() if rx.search(clean)}557 558 559def module_veto(h: Helper, objs: Set[str], case_module: str) -> bool:560    """True => reject this helper for this step (cross-module leak)."""561    owned = {mod for mod, toks in MODULE_TOKENS.items() if h.tokens & set(toks)}562    if not owned:563        return False                      # module-neutral helper, always allowed564    allowed = set(objs)565    own = CASE_MODULE_CANON.get(case_module)566    if own:567        allowed.add(own)568    return not (owned & allowed)569 570 571def is_fixture_step(text: str) -> bool:572    low = text.lower()573    return any(re.search(p, low) for p in FIXTURE_PATTERNS)574 575 576def classify_step(action: str, module: str, helpers: Dict[str, Helper],577                  obj_index: Dict[str, List[Helper]]) -> dict:578    verbs = step_verbs(action)579    objs = step_objects(action)580 581    # 1. Fixture-carried environment setup (login / open the app).582    #    Conservative note: this is genuinely covered today by the *_smartit_page583    #    and persona_session fixtures, but it is NOT a helper mapping, so it is584    #    reported in its own bucket rather than folded into HELPER.585    main_clause = SUBORDINATE_RE.sub(" ", action)586    if is_fixture_step(action) and not (587            step_verbs(main_clause) &588            {"CREATE", "SET", "SAVE", "RELATE", "ASSIGN", "TRANSITION",589             "ATTACH", "CLEAR"}):590        return {"verdict": FIXTURE, "helpers": [], "verbs": sorted(verbs),591                "objects": sorted(objs)}592 593    mutating = verbs & {"CREATE", "SET", "SAVE", "RELATE", "ASSIGN",594                        "TRANSITION", "ATTACH", "CLEAR", "SEND"}595 596    # 2. Pure verification -- no mutation verb anywhere in the sentence.597    if not mutating and (verbs & {"ASSERT"}):598        # The emitter can write an assert from step.expected, but only if some599        # proven reader/asserter exists for the object (or it is a plain600        # on-screen text check, which assert_pwa_text covers generically).601        cands = _candidates(objs, obj_index, module)602        proven_readers = [h for h in cands603                          if h.proven and h.lead in HELPER_VERB_TOKENS["ASSERT"]]604        return {"verdict": ASSERT,605                "helpers": [h.name for h in proven_readers[:MAX_HELPERS]],606                "verbs": sorted(verbs), "objects": sorted(objs)}607 608    # 3. Operational steps: need an object to anchor on.609    cands = _candidates(objs, obj_index, module)610    if not cands:611        # Object-less but unambiguous UI commit ("Click Save.")612        if verbs & {"SAVE"}:613            savers = [h for h in helpers.values()614                      if h.proven and "save" in h.tokens]615            if savers:616                return {"verdict": HELPER,617                        "helpers": [h.name for h in savers[:3]],618                        "verbs": sorted(verbs), "objects": []}619        fld = _field_step(action, verbs)620        if fld:621            return {"verdict": FIELD, "helpers": [], "field": fld,622                    "verbs": sorted(verbs), "objects": sorted(objs)}623        return {"verdict": GAP, "helpers": [], "verbs": sorted(verbs),624                "objects": sorted(objs)}625 626    want = set()627    for v in (mutating or verbs):628        want |= set(HELPER_VERB_TOKENS.get(v, ()))629 630    verb_ok_proven = [h for h in cands if h.proven and h.lead in want]631    verb_ok_any = [h for h in cands if h.lead in want]632 633    if verb_ok_proven:634        return {"verdict": HELPER,635                "helpers": [h.name for h in _rank(verb_ok_proven)[:MAX_HELPERS]],636                "verbs": sorted(verbs), "objects": sorted(objs)}637    if verb_ok_any:638        # helper exists for exactly this operation but has never been exercised639        return {"verdict": PARTIAL, "reason": "helper exists but unproven",640                "helpers": [h.name for h in _rank(verb_ok_any)[:MAX_HELPERS]],641                "verbs": sorted(verbs), "objects": sorted(objs)}642    fld = _field_step(action, verbs)643    if fld:644        return {"verdict": FIELD, "helpers": [], "field": fld,645                "verbs": sorted(verbs), "objects": sorted(objs)}646    proven_obj = [h for h in cands if h.proven]647    if proven_obj:648        return {"verdict": PARTIAL, "reason": "object covered, verb unmatched",649                "helpers": [h.name for h in _rank(proven_obj)[:MAX_HELPERS]],650                "verbs": sorted(verbs), "objects": sorted(objs)}651    return {"verdict": GAP, "helpers": [], "verbs": sorted(verbs),652            "objects": sorted(objs)}653 654 655def _field_step(action: str, verbs: Set[str]) -> Optional[str]:656    """A value-entry step: 'Enter Summary X', 'Set Impact = 3-Moderate'.657    These are ARGUMENTS to a create/update helper, not operations of their own,658    so the assembler must group them onto the neighbouring call rather than659    emit one line each. Only fires for pure entry verbs -- a step that also660    transitions, relates or assigns is a real operation."""661    if verbs & {"TRANSITION", "RELATE", "ASSIGN", "ATTACH", "CLEAR", "SEND",662                "CREATE", "SEARCH"}:663        return None664    if not (verbs & {"SET", "CLICK", "SAVE"}):665        return None666    m = _FIELD_RE.search(strip_literals(action))667    return m.group(0).lower() if m else None668 669 670def _candidates(objs: Set[str], obj_index: Dict[str, List[Helper]],671                case_module: str) -> List[Helper]:672    out: Dict[str, Helper] = {}673    for o in objs:674        for h in obj_index.get(o, ()):675            if module_veto(h, objs, case_module):676                continue677            out[h.name] = h678    return list(out.values())679 680 681def _rank(hs: List[Helper]) -> List[Helper]:682    return sorted(hs, key=lambda h: (-h.call_count, h.is_private, len(h.name)))683 684 685def build_object_index(helpers: Dict[str, Helper]) -> Dict[str, List[Helper]]:686    idx: Dict[str, List[Helper]] = collections.defaultdict(list)687    for h in helpers.values():688        for canon, (_, htokens) in OBJECT_LEXICON.items():689            if h.tokens & set(htokens):690                idx[canon].append(h)691    return idx692 693 694# --------------------------------------------------------------------------695# Gap clustering (same style as src/helper_gap_report.py: signature, count, 20)696# --------------------------------------------------------------------------697def gap_signature(action: str, verdict_rec: dict) -> str:698    verbs = verdict_rec.get("verbs") or ["?"]699    objs = verdict_rec.get("objects") or ["?"]700    return f"{'+'.join(sorted(verbs))} :: {'+'.join(sorted(objs))}"701 702 703def cluster_gaps(rows: List[dict], cap: int = 20) -> List[dict]:704    buckets: Dict[str, dict] = {}705    for r in rows:706        if r["verdict"] not in (GAP, PARTIAL):707            continue708        sig = gap_signature(r["action"], r)709        b = buckets.setdefault(sig, {710            "signature": sig, "verdict": r["verdict"], "count": 0,711            "modules": collections.Counter(), "examples": [],712            "gap_count": 0, "partial_count": 0,713        })714        b["count"] += 1715        b["gap_count"] += 1 if r["verdict"] == GAP else 0716        b["partial_count"] += 1 if r["verdict"] == PARTIAL else 0717        b["modules"][r["module"]] += 1718        if len(b["examples"]) < 3:719            b["examples"].append(f'{r["tc_id"]}: {r["action"][:110]}')720    out = sorted(buckets.values(), key=lambda b: -b["gap_count"])721    for b in out:722        b["modules"] = b["modules"].most_common()723    return out[:cap]724 725 726# --------------------------------------------------------------------------727# Rollups728# --------------------------------------------------------------------------729def rollup(counter: collections.Counter) -> dict:730    total = sum(counter.values()) or 1731    strict_ok = counter[HELPER]732    design_ok = counter[HELPER] + counter[FIXTURE] + counter[ASSERT]733    grouped_ok = design_ok + counter[FIELD]734    return {735        "total": sum(counter.values()),736        "by_verdict": {v: counter[v] for v in VERDICTS},737        "pct": {v: round(100.0 * counter[v] / total, 1) for v in VERDICTS},738        # helper calls only -- the narrowest reading739        "strict_assemblable_pct": round(100.0 * strict_ok / total, 1),740        # + fixture-carried setup and expected-text asserts (the emitter's job741        # per the Phase 2 design sketch)742        "design_assemblable_pct": round(100.0 * design_ok / total, 1),743        # + field-entry steps folded into a neighbouring call (needs grouping)744        "grouped_assemblable_pct": round(100.0 * grouped_ok / total, 1),745        "grouped_plus_partial_pct": round(746            100.0 * (grouped_ok + counter[PARTIAL]) / total, 1),747    }748 749 750# --------------------------------------------------------------------------751# Main752# --------------------------------------------------------------------------753def precision_check(ev: Evidence, catalog: List[dict],754                    helpers: Dict[str, Helper],755                    obj_index: Dict[str, List[Helper]]) -> dict:756    """The one OBJECTIVE accuracy measure available.757 758    Where a reference test author left '# Step N' markers, we know which helpers759    really served that catalog step. Run the matcher on the same step text and760    ask whether its predicted helper set intersects the truth. Small n, but it761    is a real held-out check rather than self-assessment."""762    by_tc = {c["tc_id"]: c for c in catalog if c.get("tc_id")}763    hits = misses = no_pred = 0764    detail = []765    for rec in ev.step_level:766        case = by_tc.get(rec["tc_id"])767        if not case or not rec["helpers"]:768            continue                      # no ground truth for this segment769        steps = case.get("steps") or []770        idxs = range(rec["step_lo"] - 1, min(rec["step_hi"], len(steps)))771        actions = [steps[i].get("action", "") for i in idxs if 0 <= i < len(steps)]772        if not actions:773            continue774        pred: Set[str] = set()775        for a in actions:776            pred |= set(classify_step(a, case.get("module", "?"), helpers,777                                      obj_index).get("helpers") or [])778        truth = set(rec["helpers"])779        if not pred:780            no_pred += 1781            outcome = "no-prediction"782        elif pred & truth:783            hits += 1784            outcome = "hit"785        else:786            misses += 1787            outcome = "miss"788        detail.append({789            "tc_id": rec["tc_id"], "steps": f'{rec["step_lo"]}-{rec["step_hi"]}',790            "outcome": outcome, "predicted": sorted(pred)[:4],791            "actual": sorted(truth)[:4],792            "action": (actions[0] or "")[:100],793        })794    n = hits + misses + no_pred795    return {796        "n_segments_with_truth": n,797        "hit": hits, "miss": misses, "no_prediction": no_pred,798        "hit_rate_pct": round(100.0 * hits / n, 1) if n else None,799        "detail": detail,800    }801 802 803def case_level_recall(ev: Evidence, catalog: List[dict],804                      helpers: Dict[str, Helper],805                      obj_index: Dict[str, List[Helper]]) -> dict:806    """Better-powered accuracy measure than the 52 step markers.807 808    For every catalog case that HAS a reference test (n=298), compare the union809    of helpers the matcher proposes across the case's steps against the helpers810    the reference test actually calls. Recall = how much of the real solution811    the matcher found. This is the number to trust when judging the matcher,812    because segment-level '# Step N' truth is misaligned (authors do data setup813    for later steps inside the first segment).814 815    Setup/fixture helpers are excluded from truth: they are carried by fixtures,816    not proposed per step."""817    ignore = {"persona_session", "session", "page"}818    by_tc = {c["tc_id"]: c for c in catalog if c.get("tc_id")}819    recalls, precisions = [], []820    good = 0821    worst = []822    for tc, rec in ev.by_case.items():823        case = by_tc.get(tc)824        if not case:825            continue826        truth = {h for h in rec["helpers"]827                 if h not in ignore and not helpers[h].is_fixture}828        if not truth:829            continue830        pred: Set[str] = set()831        for st in case.get("steps") or []:832            r = classify_step(st.get("action", ""), case.get("module", "?"),833                              helpers, obj_index)834            pred |= set(r.get("helpers") or [])835        inter = pred & truth836        rc = len(inter) / len(truth)837        pr = len(inter) / len(pred) if pred else 0.0838        recalls.append(rc)839        precisions.append(pr)840        if rc >= 0.5:841            good += 1842        else:843            worst.append({"tc_id": tc, "recall": round(rc, 2),844                          "missed": sorted(truth - pred)[:6]})845    n = len(recalls) or 1846    return {847        "cases_evaluated": len(recalls),848        "mean_recall_pct": round(100.0 * sum(recalls) / n, 1),849        "mean_precision_pct": round(100.0 * sum(precisions) / n, 1),850        "cases_recall_ge_50pct": good,851        "cases_recall_ge_50pct_share": round(100.0 * good / n, 1),852        "worst": sorted(worst, key=lambda w: w["recall"])[:12],853    }854 855 856def run(ref: str, samples: int = 6) -> dict:857    print(f"reference: {ref}")858    helpers = harvest_vocabulary(ref)859    print(f"  helper vocabulary: {len(helpers)}")860    ev = harvest_evidence(ref, helpers)861    proven = [h for h in helpers.values() if h.proven]862    print(f"  reference tests parsed: {ev.test_count}"863          f" ({len(ev.by_case)} carry a TC id)")864    print(f"  proven helpers (called by name in >=1 test): {len(proven)}")865    print(f"  step-level '# Step N' evidence records: {len(ev.step_level)}")866 867    obj_index = build_object_index(helpers)868 869    with open(os.path.join(ref, "regression_catalog.json"), encoding="utf-8") as fh:870        catalog = json.load(fh)["test_cases"]871    print(f"  catalog cases: {len(catalog)}")872 873    rows: List[dict] = []874    case_rows: List[dict] = []875    for case in catalog:876        tc = case.get("tc_id")877        mod = case.get("module", "?")878        has_ref = tc in ev.by_case879        verdicts = collections.Counter()880        step_recs = []881        for i, st in enumerate(case.get("steps") or []):882            action = (st.get("action") or "").strip()883            rec = classify_step(action, mod, helpers, obj_index)884            rec.update({"tc_id": tc, "module": mod, "step_no": i + 1,885                        "action": action, "has_ref_test": has_ref})886            rows.append(rec)887            step_recs.append(rec)888            verdicts[rec["verdict"]] += 1889        worst = (GAP if verdicts[GAP] else890                 PARTIAL if verdicts[PARTIAL] else HELPER)891        case_rows.append({892            "tc_id": tc, "module": mod, "has_ref_test": has_ref,893            "steps": len(step_recs), "verdicts": dict(verdicts),894            "case_verdict": worst,895            "raw_ui": ev.by_case.get(tc, {}).get("raw_ui", 0),896            "raw_rest": ev.by_case.get(tc, {}).get("raw_rest", 0),897        })898 899    def cnt(pred) -> collections.Counter:900        c = collections.Counter()901        for r in rows:902            if pred(r):903                c[r["verdict"]] += 1904        return c905 906    overall = rollup(cnt(lambda r: True))907    reuse = rollup(cnt(lambda r: r["has_ref_test"]))908    nonreuse = rollup(cnt(lambda r: not r["has_ref_test"]))909 910    by_module = {}911    for mod in sorted({r["module"] for r in rows}):912        by_module[mod] = rollup(cnt(lambda r, m=mod: r["module"] == m))913 914    # case-level: a case is assemblable only if EVERY step is915    case_total = len(case_rows)916    case_strict = sum(1 for c in case_rows917                      if not c["verdicts"].get(GAP)918                      and not c["verdicts"].get(PARTIAL))919    case_tolerant = sum(1 for c in case_rows if not c["verdicts"].get(GAP))920    case_nonreuse = [c for c in case_rows if not c["has_ref_test"]]921    case_reuse = [c for c in case_rows if c["has_ref_test"]]922 923    result = {924        "inputs": {925            "reference_dir": ref,926            "helper_vocabulary": len(helpers),927            "proven_helpers": len(proven),928            "reference_tests": ev.test_count,929            "reference_tests_with_tc_id": len(ev.by_case),930            "step_level_evidence_records": len(ev.step_level),931            "catalog_cases": len(catalog),932            "catalog_steps": len(rows),933        },934        "steps": {935            "overall": overall,936            "reuse_path": reuse,937            "non_reuse_path": nonreuse,938            "by_module": by_module,939        },940        "cases": {941            "total": case_total,942            "all_steps_helper_or_fixture_or_assert": case_strict,943            "no_gap_steps": case_tolerant,944            "strict_pct": round(100.0 * case_strict / max(case_total, 1), 1),945            "tolerant_pct": round(100.0 * case_tolerant / max(case_total, 1), 1),946            "reuse": {947                "total": len(case_reuse),948                "no_gap": sum(1 for c in case_reuse if not c["verdicts"].get(GAP)),949            },950            "non_reuse": {951                "total": len(case_nonreuse),952                "no_gap": sum(1 for c in case_nonreuse if not c["verdicts"].get(GAP)),953            },954        },955        "precision_check": precision_check(ev, catalog, helpers, obj_index),956        "case_level_recall": case_level_recall(ev, catalog, helpers, obj_index),957        "gap_clusters": cluster_gaps(rows),958        "worst_cases": sorted(959            [c for c in case_rows if c["verdicts"].get(GAP)],960            key=lambda c: -c["verdicts"].get(GAP, 0))[:15],961        "samples": {962            v: [f'[{r["module"]}/{r["tc_id"]}#{r["step_no"]}] {r["action"][:130]}'963                f'  -> {",".join(r["helpers"][:3]) or "-"}'964                for r in rows if r["verdict"] == v][:samples]965            for v in VERDICTS966        },967        "unproven_helper_count": len(helpers) - len(proven),968        "_rows": rows,969        "_cases": case_rows,970    }971    return result972 973 974def main() -> int:975    ap = argparse.ArgumentParser()976    ap.add_argument("--reference", required=True,977                    help="path to reference/itsm")978    ap.add_argument("--json", help="write full results as JSON")979    ap.add_argument("--samples", type=int, default=6)980    args = ap.parse_args()981 982    res = run(args.reference, args.samples)983 984    s = res["steps"]985    print("\n=== STEP COVERAGE ===")986    for label, blk in (("overall", s["overall"]),987                       ("reuse path (has ref test)", s["reuse_path"]),988                       ("NON-REUSE path (the decision)", s["non_reuse_path"])):989        print(f'{label:32s} n={blk["total"]:5d}  '990              f'helper={blk["strict_assemblable_pct"]:5.1f}%  '991              f'+fix/assert={blk["design_assemblable_pct"]:5.1f}%  '992              f'+field={blk["grouped_assemblable_pct"]:5.1f}%  '993              f'+partial={blk["grouped_plus_partial_pct"]:5.1f}%  '994              f'gap={blk["pct"][GAP]:5.1f}%')995    print("\n=== BY MODULE (grouped-assemblable) ===")996    for mod, blk in sorted(s["by_module"].items(),997                           key=lambda kv: -kv[1]["total"]):998        print(f'{mod:14s} n={blk["total"]:5d}  helper={blk["strict_assemblable_pct"]:5.1f}%'999              f'  grouped={blk["grouped_assemblable_pct"]:5.1f}%'1000              f'  partial={blk["pct"][PARTIAL]:5.1f}%  gap={blk["pct"][GAP]:5.1f}%')1001    print("\n=== MATCHER PRECISION (held-out, '# Step N' evidence) ===")1002    pc = res["precision_check"]1003    print(f'  n={pc["n_segments_with_truth"]}  hit={pc["hit"]}  '1004          f'miss={pc["miss"]}  no-prediction={pc["no_prediction"]}  '1005          f'hit-rate={pc["hit_rate_pct"]}%')1006    cl = res["case_level_recall"]1007    print("\n=== MATCHER RECALL (case level, n=%d grounded cases) ==="1008          % cl["cases_evaluated"])1009    print(f'  mean recall={cl["mean_recall_pct"]}%  '1010          f'mean precision={cl["mean_precision_pct"]}%  '1011          f'cases with >=50%% recall: {cl["cases_recall_ge_50pct"]} '1012          f'({cl["cases_recall_ge_50pct_share"]}%)')1013    for w in cl["worst"][:6]:1014        print(f'   worst {w["tc_id"]} recall={w["recall"]} missed={w["missed"][:4]}')1015    for d in pc["detail"][:10]:1016        print(f'   [{d["outcome"]:14s}] {d["tc_id"]} s{d["steps"]}: {d["action"][:70]}')1017        print(f'        pred={d["predicted"]}  actual={d["actual"]}')1018    print("\n=== CASES ===")1019    print(json.dumps(res["cases"], indent=1))1020    print("\n=== TOP GAP CLUSTERS ===")1021    for c in res["gap_clusters"][:12]:1022        print(f'{c["gap_count"]:4d} gap /{c["partial_count"]:4d} partial  {c["signature"]}')1023        print(f'      e.g. {c["examples"][0][:120]}')1024    print("\n=== SAMPLES ===")1025    for v, items in res["samples"].items():1026        print(f'-- {v} --')1027        for it in items:1028            print("   ", it)1029 1030    if args.json:1031        out = {k: v for k, v in res.items() if not k.startswith("_")}1032        with open(args.json, "w", encoding="utf-8") as fh:1033            json.dump(out, fh, indent=1)1034        print(f"\nwrote {args.json}")1035    return 01036 1037 1038if __name__ == "__main__":1039    raise SystemExit(main())1040