scientific-method-engine 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/PKG-INFO +1 -1
  2. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/pyproject.toml +1 -1
  3. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/cli.py +1 -1
  4. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/reports.py +196 -0
  5. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/tests/test_x86.py +131 -0
  6. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/.gitignore +0 -0
  7. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/LICENSE +0 -0
  8. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/README.md +0 -0
  9. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/__init__.py +0 -0
  10. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/__main__.py +0 -0
  11. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ClearNoReturnFunctions.java +0 -0
  12. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/CreateFunctions.java +0 -0
  13. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ExportBoundedFlow.java +0 -0
  14. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ExportFunctionFingerprints.java +0 -0
  15. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ExportFunctionInventory.java +0 -0
  16. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/MergeFallThroughFragment.java +0 -0
  17. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/RecoverCitedFunctions.java +0 -0
  18. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/RepairReturningCallers.java +0 -0
  19. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportCallArguments.java +0 -0
  20. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportCallPaths.java +0 -0
  21. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportCallSitesWithScalars.java +0 -0
  22. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportCallsToRange.java +0 -0
  23. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportConstantFirstArgumentCalls.java +0 -0
  24. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportDataBytes.java +0 -0
  25. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportDecompileMatches.java +0 -0
  26. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportDecompileWindow.java +0 -0
  27. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportFilePatternInMemory.java +0 -0
  28. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportFirstArgumentCallSummary.java +0 -0
  29. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportFunctionScalarConstants.java +0 -0
  30. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportFunctionSummary.java +0 -0
  31. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportInstructionContext.java +0 -0
  32. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportInstructionWindow.java +0 -0
  33. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportMemoryBlockForFileOffset.java +0 -0
  34. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportMemoryBlocks.java +0 -0
  35. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportRandomnessCandidates.java +0 -0
  36. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportReferences.java +0 -0
  37. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportScalarConstants.java +0 -0
  38. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportStringReferences.java +0 -0
  39. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/ghidra/ReportSymbolReferences.java +0 -0
  40. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/__init__.py +0 -0
  41. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/dispatch.py +0 -0
  42. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/image.py +0 -0
  43. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/machine.py +0 -0
  44. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/pe.py +0 -0
  45. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/trace.py +0 -0
  46. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/src/scientific_method_engine/x86/values.py +0 -0
  47. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/tests/test_dispatch.py +0 -0
  48. {scientific_method_engine-0.1.0 → scientific_method_engine-0.2.0}/tests/test_pe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scientific-method-engine
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Bounded instruction-derived x86 evidence reports for segmented MZ/FBOV and PE32/i386 code.
5
5
  Project-URL: Source, https://github.com/kibertoad/refurbished-dinosaurs-toolkit/tree/main/packages/scientific-method-engine
6
6
  Author: kibertoad
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "scientific-method-engine"
7
7
  # The release workflow writes the published version from the package's release tag.
8
- version = "0.1.0"
8
+ version = "0.2.0"
9
9
  description = "Bounded instruction-derived x86 evidence reports for segmented MZ/FBOV and PE32/i386 code."
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -8,7 +8,7 @@ from . import PREPARED_PROTOCOL
8
8
  CONFIG_LIMIT = 1024 * 1024
9
9
  PREPARED_CONFIG_LIMIT = 16 * 1024 * 1024
10
10
  USAGE = ("Usage: scientific-method-engine <operand|operand-candidates|target|bounds|owner|callees|trace|uses|arguments|"
11
- "effects|returns|memory|incoming|guards|allocation|dispatch> <config.json|->\n"
11
+ "effects|returns|memory|incoming|call-order|guards|allocation|dispatch> <config.json|->\n"
12
12
  " scientific-method-engine ghidra-scripts")
13
13
 
14
14
 
@@ -994,6 +994,200 @@ def callees(image, config):
994
994
  "that does not. Neither proves runtime recursion."}
995
995
 
996
996
 
997
+ # These branches test CX/ECX (LOOPE/LOOPNE also ZF), so an adjacent CMP/TEST never describes their predicate.
998
+ COUNT_BRANCHES = frozenset(("jcxz", "jecxz", "jrcxz", "loop", "loope", "loopne", "loopz", "loopnz"))
999
+
1000
+
1001
+ def _operand_view(ins, o):
1002
+ if o.type == X86_OP_REG:
1003
+ return {"kind": "register", "name": ins.reg_name(o.reg), "width": o.size}
1004
+ if o.type == X86_OP_IMM:
1005
+ return {"kind": "immediate", "value": o.imm, "width": o.size}
1006
+ if o.type == X86_OP_MEM:
1007
+ return {"kind": "memory", "width": memory_width(ins, o), "segmentRegister": segment_register(ins, o.mem),
1008
+ "baseRegister": ins.reg_name(o.mem.base) or None, "indexRegister": ins.reg_name(o.mem.index) or None,
1009
+ "displacement": o.mem.disp}
1010
+ return {"kind": "unresolved"}
1011
+
1012
+
1013
+ def _stack_cleanup(ins):
1014
+ """The bytes an immediate ADD SP/ESP releases, or None for any other instruction or a negative immediate."""
1015
+ if (ins is None or ins.mnemonic != "add" or len(ins.operands) != 2 or ins.operands[0].type != X86_OP_REG
1016
+ or ins.reg_name(ins.operands[0].reg) not in ("sp", "esp") or ins.operands[1].type != X86_OP_IMM):
1017
+ return None
1018
+ bits = 8 * ins.operands[0].size
1019
+ amount = ins.operands[1].imm & ((1 << bits) - 1)
1020
+ return amount if 0 < amount < 1 << (bits - 1) else None
1021
+
1022
+
1023
+ def call_order(image, config):
1024
+ """Group the confirmed incoming calls of each containing entry by necessary guards and CFG order."""
1025
+ flat = incoming(image, config)
1026
+ entry_limit = integer(config.get("entryLimit", 64), 1, 256, "entry limit")
1027
+ analysis_limit = integer(config.get("analysisLimit", 1000000), 1, 10000000, "call order analysis limit")
1028
+ established = entries(image)
1029
+ bodies = {e: body(image, e, config.get("instructionLimit", 10000)) for e in established[:entry_limit]}
1030
+ conflicts = _cross_entry_overlaps(bodies)
1031
+ unchecked = established[entry_limit:]
1032
+ sites = {r["site"] for r in flat["confirmed"]}
1033
+ owners = {at: [e for e, b in bodies.items() if at in b["instructions"]] for at in sites}
1034
+ reports = []
1035
+ for entry, b in bodies.items():
1036
+ selected = sorted(at for at in sites if entry in owners[at])
1037
+ if not selected:
1038
+ continue
1039
+ usable = b["complete"] and not unchecked and not conflicts.get(entry) and not flat["truncated"] and all(owners[at] == [entry] for at in selected)
1040
+ instructions, successors, branches, exits = b["instructions"], {}, [], set()
1041
+ ending = {}
1042
+ for at, ins in instructions.items():
1043
+ ending.setdefault(at + ins.size, []).append(at)
1044
+ for at, ins in instructions.items():
1045
+ m, following = base_mnemonic(ins), at + ins.size
1046
+ targets = []
1047
+ if m in RETURNS or m == "hlt" or unsupported_transfer(image, ins):
1048
+ pass
1049
+ elif m in ("jmp", "ljmp"):
1050
+ d = image.indirect_jumps.get(at)
1051
+ targets = [r["target"] for r in d["rows"]] if d else [call_target(image, at, ins)[0]]
1052
+ elif m.startswith("j") or m.startswith("loop"):
1053
+ target = call_target(image, at, ins)[0]
1054
+ targets = [target, following]
1055
+ if target != following:
1056
+ producer = ending.get(at, []) if m not in COUNT_BRANCHES and at != entry else []
1057
+ comparison = instructions[producer[0]] if len(producer) == 1 else None
1058
+ context = ({"site": producer[0], "mnemonic": comparison.mnemonic,
1059
+ "operands": [_operand_view(comparison, o) for o in comparison.operands]}
1060
+ if comparison and comparison.mnemonic in ("cmp", "test") else None)
1061
+ for taken, to in ((True, target), (False, following)):
1062
+ if to is not None:
1063
+ branches.append({"site": at, "to": to, "taken": taken, "predicate": m,
1064
+ "comparison": context, "interpretation": "necessary caller CFG edge, not a runtime value or preserved guard"})
1065
+ else:
1066
+ targets = [following]
1067
+ successors[at] = sorted(set(t for t in targets if t in instructions))
1068
+ # Returns, halts, unsupported frames and transfers out of the body leave the caller CFG.
1069
+ if not targets or any(t not in instructions for t in targets):
1070
+ exits.add(at)
1071
+ predecessors = {}
1072
+ for source, targets in successors.items():
1073
+ for to in targets:
1074
+ predecessors.setdefault(to, set()).add(source)
1075
+ for guard in branches:
1076
+ if guard["comparison"] and predecessors.get(guard["site"], set()) != {guard["comparison"]["site"]}:
1077
+ guard["comparison"] = None
1078
+ cache, spent, capped = {}, 0, False
1079
+ def reachable(start, blocked=None, stops=frozenset()):
1080
+ nonlocal spent, capped
1081
+ key = (start, blocked, stops)
1082
+ if key in cache:
1083
+ return cache[key]
1084
+ todo, seen = [start], set()
1085
+ while todo:
1086
+ at = todo.pop()
1087
+ if at in seen or at not in instructions or at in stops:
1088
+ continue
1089
+ if spent >= analysis_limit:
1090
+ capped = True
1091
+ return set()
1092
+ spent += 1
1093
+ seen.add(at)
1094
+ todo.extend(to for to in successors[at] if (at, to) != blocked)
1095
+ cache[key] = seen
1096
+ return seen
1097
+
1098
+ def dominates(first, second):
1099
+ """Whether every route from the entry to second passes first."""
1100
+ return second not in reachable(entry, stops=frozenset((first,)))
1101
+
1102
+ def must_follow(first, second, guards):
1103
+ """Whether every route from first's continuation reaches second before an exit or a guard revisit."""
1104
+ start = first + instructions[first].size
1105
+ if start == second:
1106
+ return True
1107
+ if start in guards:
1108
+ return False
1109
+ seen = reachable(start, stops=guards | {second})
1110
+ return not seen & exits and not any(to in guards for at in seen for to in successors[at])
1111
+ necessary = {at: [] for at in selected}
1112
+ for guard in branches:
1113
+ remaining = reachable(entry, (guard["site"], guard["to"]))
1114
+ for at in selected:
1115
+ if at not in remaining:
1116
+ necessary[at].append({k: v for k, v in guard.items() if k != "to"})
1117
+ later = {at: reachable(at + instructions[at].size) & sites for at in selected}
1118
+ rows = []
1119
+ for at in selected:
1120
+ following = at + instructions[at].size
1121
+ amount = _stack_cleanup(instructions.get(following))
1122
+ rows.append({"site": at, "necessaryGuards": necessary[at] if not capped else [],
1123
+ "cleanup": {"continuation": following, "site": following if amount is not None else None,
1124
+ "argumentBytes": amount, "status": "observed after assumed return" if amount is not None else "unread cleanup",
1125
+ "assumption": "callee returns to the next instruction"},
1126
+ "calleeEffects": {"status": "unresolved", "reason": "caller CFG never proves callee return success or restored/preserved state"}})
1127
+ partitions = {}
1128
+ for row in rows:
1129
+ key = tuple((g["site"], g["taken"]) for g in row["necessaryGuards"])
1130
+ partitions.setdefault(key, []).append(row["site"])
1131
+ groups = []
1132
+ for members in partitions.values():
1133
+ shared_guards = next(r["necessaryGuards"] for r in rows if r["site"] == members[0]) if not capped else []
1134
+ guard_sites = frozenset(g["site"] for g in shared_guards)
1135
+ local_later = {at: reachable(at + instructions[at].size, stops=guard_sites) & sites for at in members}
1136
+ pairs = [(a, z) for index, a in enumerate(members) for z in members[index + 1:]]
1137
+ sequence = all((z in local_later[a]) != (a in local_later[z]) for a, z in pairs) and all(a not in local_later[a] for a in members)
1138
+ alternatives = len(members) > 1 and all(z not in local_later[a] and a not in local_later[z] for a, z in pairs)
1139
+ kind = "unread" if not usable or capped else "sequence" if sequence else "branchAlternatives" if alternatives else "unread"
1140
+ order = sorted(members, key=lambda a: sum(a in local_later[z] for z in members if z != a)) if kind == "sequence" else None
1141
+ # Equal single-edge guards do not make every member run: a later call may be reached around an
1142
+ # earlier one (two branches to it, a jump table) or be skipped after it. Each call must dominate
1143
+ # the next and the next must follow it on every route within the visit.
1144
+ if order and not all(dominates(a, z) and must_follow(a, z, guard_sites) for a, z in zip(order, order[1:])):
1145
+ kind, order = "unread", None
1146
+ groups.append({"kind": kind, "sites": members, "order": order,
1147
+ "sharedGuards": shared_guards,
1148
+ "scope": "one visit past the shared guard edges" if guard_sites else "caller CFG",
1149
+ "mayRepeatAcrossGuardVisits": any(a in later[a] for a in members)})
1150
+ if capped or not usable:
1151
+ # A cycle found in a partial read still may repeat; finding none there proves nothing.
1152
+ for group in groups:
1153
+ group["mayRepeatAcrossGuardVisits"] = group["mayRepeatAcrossGuardVisits"] or None
1154
+ if capped:
1155
+ for group in groups:
1156
+ group.update(kind="unread", order=None, sharedGuards=[])
1157
+ for row in rows:
1158
+ row["necessaryGuards"] = []
1159
+ relations = []
1160
+ for index, first in enumerate(selected):
1161
+ for second in selected[index + 1:]:
1162
+ forward, backward = second in later[first], first in later[second]
1163
+ kind = ("unread" if not usable or capped or forward and backward else
1164
+ "sequence" if forward or backward else "branchAlternatives")
1165
+ relations.append({"sites": [first, second], "kind": kind,
1166
+ "order": [first, second] if kind == "sequence" and forward else [second, first] if kind == "sequence" else None})
1167
+ if relations and all(r["kind"] == "branchAlternatives" for r in relations):
1168
+ common = [g for g in rows[0]["necessaryGuards"] if all(any(g["site"] == h["site"] and g["taken"] == h["taken"] for h in row["necessaryGuards"]) for row in rows)]
1169
+ groups = [{"kind": "branchAlternatives", "sites": selected, "order": None, "sharedGuards": common,
1170
+ "scope": "caller CFG", "mayRepeatAcrossGuardVisits": any(a in later[a] for a in selected)}]
1171
+ reports.append({"entry": entry, "ranges": b["intervals"], "boundaryUsable": usable, "orderingUsable": usable and not capped,
1172
+ "gaps": b["gaps"], "conflicts": conflicts.get(entry, []), "analysisCapped": capped, "analysisSteps": spent,
1173
+ "calls": rows, "groups": groups, "relations": relations, "assumptions": b["assumedContinuations"]})
1174
+ controls = config.get("orderControls", [])
1175
+ if not isinstance(controls, list) or len(controls) > 256:
1176
+ raise ValueError("Invalid call order controls")
1177
+ for control in controls:
1178
+ if not isinstance(control, dict) or control.get("kind") not in ("sequence", "branchAlternatives") or not isinstance(control.get("sites"), list) or not control["sites"] or len(control["sites"]) > 256 or any(type(at) is not int for at in control["sites"]) or type(control.get("entry")) is not int:
1179
+ raise ValueError("Invalid call order control")
1180
+ report = next((r for r in reports if r["entry"] == control.get("entry")), None)
1181
+ # Sequence sites are compared in order; branch alternatives have none.
1182
+ expected = control["sites"] if control["kind"] == "sequence" else sorted(control["sites"])
1183
+ group = next((g for g in report["groups"] if g["kind"] == control["kind"] and (g["order"] if g["kind"] == "sequence" else sorted(g["sites"])) == expected), None) if report else None
1184
+ if group is None:
1185
+ raise ValueError("Call order positive control missed")
1186
+ return {"incoming": flat, "callers": reports, "uncheckedEntries": unchecked, "orderControls": controls,
1187
+ "unownedSites": sorted(at for at in sites if not owners[at]),
1188
+ "interpretation": "Order and guards describe a conditional caller CFG. Cleanup follows an assumed return; callees' effects, successful return and state restoration remain unresolved."}
1189
+
1190
+
997
1191
  def _body_report(b):
998
1192
  return {k: v for k, v in b.items() if k != "instructions"} | {"instructionCount": len(b["instructions"])}
999
1193
 
@@ -1158,6 +1352,8 @@ def _run_report(image, config, command):
1158
1352
  return owner(image, config)
1159
1353
  if command == "callees":
1160
1354
  return callees(image, config)
1355
+ if command == "call-order":
1356
+ return call_order(image, config)
1161
1357
  if command == "incoming":
1162
1358
  return incoming(image, config)
1163
1359
  if command == "operand-candidates":
@@ -349,6 +349,137 @@ class NearPointerSegmentTests(unittest.TestCase):
349
349
  self.assertTrue(all(p["dereferenceSegmentRegister"] == "es" and p["segmentRelationship"] == "sameWithinModel" for p in links))
350
350
 
351
351
 
352
+ class CallOrderTests(unittest.TestCase):
353
+ def run_order(self, code, **extra):
354
+ data = code.bytes()
355
+ cfg = configuration(data, target=code.labels["helper"], **extra)
356
+ cfg["regions"][0]["entries"] = [0, code.labels["helper"]]
357
+ return run_report(data, cfg, "call-order"), cfg
358
+
359
+ def test_guarded_sequence_retains_shared_cmp_cleanup_and_unread_effects(self):
360
+ c = Code().emit("83 7e fa 05").branch("7c", "end")
361
+ c.label("one").branch("e8", "helper").emit("83 c4 08")
362
+ c.label("two").branch("e8", "helper").emit("83 c4 08")
363
+ c.label("end").emit("c3").label("helper").emit("c3")
364
+ r, cfg = self.run_order(c, controls=[c.labels["one"], c.labels["two"]], orderControls=[{"entry": 0, "kind": "sequence", "sites": [c.labels["one"], c.labels["two"]]}])
365
+ caller = r["callers"][0]
366
+ self.assertEqual(caller["groups"][0]["kind"], "sequence")
367
+ self.assertEqual(caller["groups"][0]["sharedGuards"][0]["comparison"]["operands"][0]["segmentRegister"], "ss")
368
+ self.assertTrue(all(row["cleanup"]["argumentBytes"] == 8 and row["calleeEffects"]["status"] == "unresolved" for row in caller["calls"]))
369
+ self.assertEqual(len(r["incoming"]["confirmed"]), 2)
370
+
371
+ def test_adjacent_comparison_is_not_claimed_when_another_edge_enters_the_branch(self):
372
+ c = Code().emit("85 c0").branch("74", "compare").branch("e9", "condition")
373
+ c.label("compare").emit("83 fb 05").label("condition").branch("7c", "end").branch("e8", "helper")
374
+ c.label("end").emit("c3").label("helper").emit("c3")
375
+ r, _ = self.run_order(c)
376
+ guards = r["callers"][0]["groups"][0]["sharedGuards"]
377
+ conditional = next(g for g in guards if g["site"] == c.labels["condition"])
378
+ self.assertIsNone(conditional["comparison"])
379
+
380
+ def test_sequence_order_comes_from_flow_not_ascending_addresses(self):
381
+ c = Code().branch("e9", "first").label("second").branch("e8", "helper").emit("c3")
382
+ c.label("first").branch("e8", "helper").branch("e9", "second").label("helper").emit("c3")
383
+ r, _ = self.run_order(c)
384
+ self.assertEqual(r["callers"][0]["groups"][0]["order"], [c.labels["first"], c.labels["second"]])
385
+
386
+ def test_branch_alternatives_do_not_become_a_sequence(self):
387
+ c = Code().emit("85 c0").branch("74", "other").branch("e8", "helper").emit("c3")
388
+ c.label("other").branch("e8", "helper").emit("c3").label("helper").emit("c3")
389
+ r, cfg = self.run_order(c)
390
+ self.assertEqual(r["callers"][0]["groups"][0]["kind"], "branchAlternatives")
391
+ cfg["orderControls"] = [{"entry": 0, "kind": "sequence", "sites": r["callers"][0]["groups"][0]["sites"]}]
392
+ with self.assertRaisesRegex(ValueError, "positive control"):
393
+ run_report(c.bytes(), cfg, "call-order")
394
+
395
+ def test_outer_loop_keeps_sequence_within_a_guard_visit_and_reports_recurrence(self):
396
+ c = Code().label("guard").emit("83 f8 05").branch("7c", "end")
397
+ c.label("one").branch("e8", "helper").label("two").branch("e8", "helper").branch("eb", "guard")
398
+ c.label("end").emit("c3").label("helper").emit("c3")
399
+ r, _ = self.run_order(c)
400
+ g = r["callers"][0]["groups"][0]
401
+ self.assertEqual(g["kind"], "sequence")
402
+ self.assertEqual(g["order"], [c.labels["one"], c.labels["two"]])
403
+ self.assertTrue(g["mayRepeatAcrossGuardVisits"])
404
+ self.assertEqual(g["scope"], "one visit past the shared guard edges")
405
+
406
+ def test_shared_ownership_never_verifies_order(self):
407
+ c = Code().emit("90").branch("e8", "helper").emit("c3").label("helper").emit("c3")
408
+ cfg = configuration(c.bytes(), target=c.labels["helper"])
409
+ cfg["regions"][0]["entries"] = [0, 1, c.labels["helper"]]
410
+ r = run_report(c.bytes(), cfg, "call-order")
411
+ self.assertTrue(r["callers"])
412
+ self.assertTrue(all(not caller["orderingUsable"] for caller in r["callers"]))
413
+ self.assertTrue(all(g["kind"] == "unread" for caller in r["callers"] for g in caller["groups"]))
414
+
415
+ def test_overlapping_entries_are_rejected_by_the_flat_boundary_inventory(self):
416
+ c = Code().emit("66 90").branch("e8", "helper").emit("c3").label("helper").emit("c3")
417
+ cfg = configuration(c.bytes(), target=c.labels["helper"])
418
+ cfg["regions"][0]["entries"] = [0, 1, c.labels["helper"]]
419
+ r = run_report(c.bytes(), cfg, "call-order")
420
+ self.assertFalse(r["incoming"]["confirmed"])
421
+ self.assertGreater(sum(r["incoming"]["counts"].values()), 0)
422
+ self.assertFalse(r["callers"])
423
+
424
+ def test_caps_and_recurring_calls_leave_order_unread(self):
425
+ c = Code().branch("e8", "helper").branch("e8", "helper").emit("c3").label("helper").emit("c3")
426
+ for options in ({"entryLimit": 1}, {"limit": 1}, {"instructionLimit": 1}, {"analysisLimit": 1}):
427
+ r, _ = self.run_order(c, **options)
428
+ self.assertTrue(all(g["kind"] == "unread" for caller in r["callers"] for g in caller["groups"]))
429
+ loop = Code().label("again").branch("e8", "helper").branch("eb", "again").label("helper").emit("c3")
430
+ r, _ = self.run_order(loop)
431
+ self.assertTrue(all(g["kind"] == "unread" for caller in r["callers"] for g in caller["groups"]))
432
+
433
+ def test_capped_analysis_does_not_deny_recurrence(self):
434
+ c = Code().branch("e8", "helper").emit("90 c3").label("helper").emit("c3")
435
+ r, _ = self.run_order(c, analysisLimit=1)
436
+ self.assertTrue(r["callers"][0]["analysisCapped"])
437
+ self.assertIsNone(r["callers"][0]["groups"][0]["mayRepeatAcrossGuardVisits"])
438
+
439
+ def test_a_call_reached_around_another_is_not_sequenced_after_it(self):
440
+ # Two branches reach the first call, so neither edge alone is necessary, and the JMP skips it.
441
+ c = Code().emit("85 c0").branch("74", "first").branch("75", "first").branch("eb", "second")
442
+ c.label("first").branch("e8", "helper").label("second").branch("e8", "helper").emit("c3").label("helper").emit("c3")
443
+ r, cfg = self.run_order(c)
444
+ self.assertEqual(r["callers"][0]["groups"][0]["kind"], "unread")
445
+ cfg["orderControls"] = [{"entry": 0, "kind": "sequence", "sites": [c.labels["first"], c.labels["second"]]}]
446
+ with self.assertRaisesRegex(ValueError, "positive control"):
447
+ run_report(c.bytes(), cfg, "call-order")
448
+
449
+ def test_a_call_that_can_be_skipped_after_another_is_not_sequenced_after_it(self):
450
+ c = Code().label("first").branch("e8", "helper").emit("85 c0").branch("74", "second").branch("75", "second").emit("c3")
451
+ c.label("second").branch("e8", "helper").emit("c3").label("helper").emit("c3")
452
+ r, _ = self.run_order(c)
453
+ self.assertEqual(r["callers"][0]["groups"][0]["kind"], "unread")
454
+
455
+ def test_alternatives_have_the_full_group_shape_and_unordered_controls(self):
456
+ c = Code().emit("85 c0").branch("74", "other").label("one").branch("e8", "helper").emit("c3")
457
+ c.label("other").branch("e8", "helper").emit("c3").label("helper").emit("c3")
458
+ r, _ = self.run_order(c, orderControls=[{"entry": 0, "kind": "branchAlternatives", "sites": [c.labels["other"], c.labels["one"]]}])
459
+ g = r["callers"][0]["groups"][0]
460
+ self.assertEqual(g["kind"], "branchAlternatives")
461
+ self.assertEqual((g["scope"], g["mayRepeatAcrossGuardVisits"]), ("caller CFG", False))
462
+
463
+ def test_count_branches_and_entry_branches_take_no_adjacent_comparison(self):
464
+ c = Code().emit("83 f8 05").branch("e3", "end").branch("e8", "helper").label("end").emit("c3").label("helper").emit("c3")
465
+ r, _ = self.run_order(c)
466
+ self.assertIsNone(r["callers"][0]["calls"][0]["necessaryGuards"][0]["comparison"])
467
+ # The CMP before the entry is its only CFG predecessor, but the entry is also entered from outside.
468
+ c = Code().label("compare").emit("83 f8 05").label("entry").branch("7c", "end").branch("e8", "helper").branch("eb", "compare")
469
+ c.label("end").emit("c3").label("helper").emit("c3")
470
+ data = c.bytes()
471
+ cfg = configuration(data, target=c.labels["helper"])
472
+ cfg["regions"][0]["entries"] = [c.labels["entry"], c.labels["helper"]]
473
+ r = run_report(data, cfg, "call-order")
474
+ self.assertIsNone(r["callers"][0]["calls"][0]["necessaryGuards"][0]["comparison"])
475
+
476
+ def test_a_negative_stack_adjustment_is_not_cleanup(self):
477
+ c = Code().branch("e8", "helper").emit("81 c4 00 80 c3").label("helper").emit("c3")
478
+ r, _ = self.run_order(c)
479
+ self.assertEqual(r["callers"][0]["calls"][0]["cleanup"]["status"], "unread cleanup")
480
+ self.assertIsNone(r["callers"][0]["calls"][0]["cleanup"]["argumentBytes"])
481
+
482
+
352
483
  class ReporterTests(unittest.TestCase):
353
484
  def test_register_parts_preserve_neighbor(self):
354
485
  r = report("b8 34 12 b0 00 c3")