droid-code-checker / forensics.py
legitcoconut
DroidDetect-Large code authorship checker
e84edab
Raw
History Blame Contribute Delete
5.74 kB
"""Static code forensics β€” pure text analysis, no model, no dependencies.
Imports nothing outside the standard library, so it runs and is testable
without torch.
Answers a different question than the stylometry model: not "does this read as
AI-written" but "does this text bear the physical traces of having been pasted
in rather than typed here".
"""
import re
import statistics
COMMENT_PREFIXES = ("#", "//", "/*", "*", "--", "<!--")
IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
CAMEL_RE = re.compile(r"^[a-z]+[A-Z]")
# Curly quotes, en/em dashes, non-breaking space, ellipsis, zero-width space.
# These cannot be typed into a code editor by accident β€” they arrive by paste
# from a rendered web page, PDF, or chat UI.
SMART_PUNCT_RE = re.compile("[β€˜β€™β€œβ€β€“β€”Β β€¦β€‹]")
# Tutorial-style narration: one comment per step, restating what the next line
# does. Characteristic of AI output and of copied walkthrough articles.
AI_COMMENT_RE = re.compile(
r"^\W*(step\s*\d|read\s+(the\s+)?input|initiali[sz]e|iterate|loop\s+through"
r"|print\s+the|check\s+if|return\s+the|calculate\s+the|handle\s+(the\s+)?edge"
r"|create\s+a|define\s+(a|the)|add\s+the|sort\s+the|first,|finally,)",
re.I,
)
KEYWORDS = frozenset(
"""if elif else for while do return def class import from as in is not and or none
true false null nil public private protected static void int long float double char
bool boolean string str var let const function new this self try except catch finally
raise throw with lambda yield break continue pass switch case default struct enum
typedef sizeof namespace using include main print println printf scanf cout cin endl
std len range list dict set tuple map filter input output append push""".split()
)
def static_features(code):
"""Language-agnostic forensic features. Pure text analysis, no model."""
lines = code.splitlines()
stripped = [ln.strip() for ln in lines]
body = [ln for ln in lines if ln.strip()]
n_body = len(body) or 1
comments = [s for s in stripped if s.startswith(COMMENT_PREFIXES)]
lens = [len(ln) for ln in body] or [0]
indented = [ln for ln in body if ln[:1] in (" ", "\t")]
idents = [w for w in IDENT_RE.findall(code) if w.lower() not in KEYWORDS]
uniq = set(idents)
n_uniq = len(uniq) or 1
return {
"chars": len(code),
"lines": len(lines),
"blank_ratio": round(sum(1 for s in stripped if not s) / (len(lines) or 1), 3),
"comment_ratio": round(len(comments) / n_body, 3),
"ai_style_comments": sum(1 for c in comments if AI_COMMENT_RE.match(c)),
"avg_line_len": round(statistics.fmean(lens), 1),
"stdev_line_len": round(statistics.pstdev(lens), 1) if len(lens) > 1 else 0.0,
"max_line_len": max(lens),
"mixed_indent": any(ln.startswith("\t") for ln in indented)
and any(ln.startswith(" ") for ln in indented),
"trailing_ws_lines": sum(1 for ln in lines if ln != ln.rstrip()),
"crlf": "\r\n" in code,
"smart_punct": len(SMART_PUNCT_RE.findall(code)),
"non_ascii": sum(1 for ch in code if ord(ch) > 127),
"unique_identifiers": len(uniq),
"avg_identifier_len": round(
statistics.fmean([len(w) for w in uniq]) if uniq else 0.0, 2
),
"single_char_ratio": round(sum(1 for w in uniq if len(w) == 1) / n_uniq, 3),
"snake_ratio": round(
sum(1 for w in uniq if "_" in w and w.islower()) / n_uniq, 3
),
"camel_ratio": round(sum(1 for w in uniq if CAMEL_RE.match(w)) / n_uniq, 3),
"has_docstring": '"""' in code or "'''" in code,
"has_type_hints": bool(
re.search(r"->\s*[A-Za-z\[]|:\s*(int|str|float|bool|List|Dict)\b", code)
),
"has_main_guard": "__name__" in code and "__main__" in code,
}
# Signal -> (weight, explanation). Weights are the calibration knob: they encode
# how strongly each trace implies "not typed here by this person".
SIGNAL_WEIGHTS = {
"unicode_punctuation": (35, "Curly quotes/dashes β€” pasted from a rendered page or chat UI, not typed"),
"ai_narration_comments": (25, "Step-by-step narrating comments in AI/tutorial style"),
"mixed_indentation": (20, "Tabs and spaces mixed β€” assembled from more than one source"),
"heavy_commenting": (15, "Comment density unusually high for a timed contest answer"),
"production_style": (15, "Docstrings + type hints β€” production habits, rare under time pressure"),
"inconsistent_naming": (15, "snake_case and camelCase both used heavily β€” merged sources"),
"uniform_line_lengths": (10, "Machine-uniform line lengths"),
"crlf_endings": (10, "CRLF line endings β€” came from another environment"),
}
def forensic_signals(f):
"""Fire named signals from a feature dict. Returns (score 0-100, signals)."""
fired = []
if f["smart_punct"]:
fired.append("unicode_punctuation")
if f["ai_style_comments"] >= 3:
fired.append("ai_narration_comments")
if f["mixed_indent"]:
fired.append("mixed_indentation")
if f["comment_ratio"] > 0.30:
fired.append("heavy_commenting")
if f["has_docstring"] and f["has_type_hints"]:
fired.append("production_style")
if f["snake_ratio"] > 0.25 and f["camel_ratio"] > 0.25:
fired.append("inconsistent_naming")
if f["lines"] > 15 and f["stdev_line_len"] < 6:
fired.append("uniform_line_lengths")
if f["crlf"]:
fired.append("crlf_endings")
score = min(100, sum(SIGNAL_WEIGHTS[s][0] for s in fired))
return score, [
{"code": s, "weight": SIGNAL_WEIGHTS[s][0], "detail": SIGNAL_WEIGHTS[s][1]}
for s in fired
]