aboutsummaryrefslogtreecommitdiffstats
path: root/fw/ta_metrics.py
blob: 8a6902265ef38ab2f0af3a61d585ce8dd5c91e0e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#!/usr/bin/env python3
"""One definition of what counts as a name, and one of what counts as readable.

Four separate metric-definition errors in this work came from re-writing these predicates at
each use site. Each time the pattern was slightly too strict, the number came out too good and
the work it should have exposed stayed hidden:

  * the field share globbed fw/decomp/*.c, which also matches the .anon.c files holding the
    bodies of functions that have no name, so unnamed bodies were counted against the named
    artifacts (67.5% reported, 75.6% actual);
  * the per-function dereference residue summed each function across all 17 builds, making a
    flat distribution look concentrated (158 reported for the top holder, 14 actual);
  * the named count accepted IDA's address defaults, so 243 sub_401234 entries counted as
    names (67.1% reported, 66.3% actual);
  * the same count, and the repair pass written to fix it, both anchored on $ right after the
    hex digits and so missed IDA's collision suffix, leaving 1,852 j_sub_D07364_12 entries
    counted as names and 713 thunks fixable but untouched (66.6% reported, 60.2% actual).

The last of those was introduced in the commit that described the previous one. So these live
here, imported, with the regex written once.
"""
import json
import re
from pathlib import Path

HERE = Path(__file__).resolve().parent
DECOMP = HERE / "decomp"

# IDA's address-derived defaults, including the collision suffix it appends when the same name
# is assigned twice. sub_401234, j_sub_401234, j_sub_D07364_12 are all equally uninformative.
ADDR_NAME = re.compile(r"^(j_)?sub_[0-9A-Fa-f]+(_\d+)?$")

# A placeholder derived from a body hash: a stable identity, but not a name.
HASH_NAME = re.compile(r"lib_[0-9a-f]{8}_\d+")

FIELD_ACCESS = (re.compile(r"\b\w+->\w+"), re.compile(r"\bv\d+\.[a-z_]\w*"))
RAW_DEREF = re.compile(r"\*\(\s*_?\w+\s*\*\s*\)\s*\(\s*\w+\s*\+")


def is_addr_name(name: str | None) -> bool:
    """True if this is IDA's address default rather than a name."""
    return bool(name) and bool(ADDR_NAME.match(name))


def is_meaningful(name: str | None) -> bool:
    """True if the name tells a reader something. Address defaults do not count."""
    return bool(name) and not ADDR_NAME.match(name)


def measure() -> dict:
    """Corpus metrics, from the artifacts, with every denominator stated."""
    total = meaningful = addr = placeholder = 0
    for p in sorted(DECOMP.glob("*.index.json")):
        for fn in json.loads(p.read_text())["functions"]:
            nm = fn.get("name") or ""
            total += 1
            if not nm:
                continue
            if ADDR_NAME.match(nm):
                addr += 1
            else:
                meaningful += 1
                if HASH_NAME.search(nm):
                    placeholder += 1

    # Named artifacts only. The .anon.c files hold functions with no name; counting their
    # untyped bodies against the typing work measures the corpus, not the readable output.
    fields = raw = 0
    for c in sorted(DECOMP.glob("*.c")):
        if c.name.endswith(".anon.c"):
            continue
        txt = c.read_text()
        for pat in FIELD_ACCESS:
            fields += len(pat.findall(txt))
        raw += len(RAW_DEREF.findall(txt))

    return {
        "functions": total,
        "meaningful_names": meaningful,
        "meaningful_pct": round(100.0 * meaningful / total, 1) if total else 0.0,
        "address_derived": addr,
        "hash_placeholders": placeholder,
        "typed_field_accesses": fields,
        "raw_dereferences": raw,
        "field_share_pct": round(100.0 * fields / (fields + raw), 1) if fields + raw else 0.0,
    }


if __name__ == "__main__":
    m = measure()
    w = max(len(k) for k in m)
    for k, v in m.items():
        print("%-*s %s" % (w, k, v))