pyflashkit 1.1.0__tar.gz → 1.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 (88) hide show
  1. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/PKG-INFO +1 -1
  2. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/__init__.py +5 -1
  3. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/abc/__init__.py +3 -1
  4. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/abc/disasm.py +124 -0
  5. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/analysis/__init__.py +22 -0
  6. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/analysis/call_graph.py +5 -1
  7. pyflashkit-1.2.0/flashkit/analysis/class_graph.py +270 -0
  8. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/analysis/field_access.py +6 -3
  9. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/analysis/inheritance.py +18 -0
  10. pyflashkit-1.2.0/flashkit/analysis/method_fingerprint.py +378 -0
  11. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/analysis/references.py +6 -3
  12. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/analysis/strings.py +6 -3
  13. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/analysis/unified.py +7 -3
  14. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/info/class_info.py +73 -4
  15. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/info/member_info.py +32 -0
  16. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/workspace/workspace.py +82 -5
  17. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/pyflashkit.egg-info/PKG-INFO +1 -1
  18. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/pyflashkit.egg-info/SOURCES.txt +7 -1
  19. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/pyproject.toml +1 -1
  20. pyflashkit-1.2.0/tests/analysis/test_class_graph.py +10 -0
  21. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/analysis/test_inheritance.py +9 -0
  22. pyflashkit-1.2.0/tests/analysis/test_type_hints.py +38 -0
  23. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/cli/test_cli.py +1 -1
  24. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/conftest.py +9 -0
  25. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/info/test_class_info.py +61 -0
  26. pyflashkit-1.2.0/tests/test_public_api.py +41 -0
  27. pyflashkit-1.2.0/tests/workspace/test_workspace_properties.py +44 -0
  28. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/.github/workflows/ci.yml +0 -0
  29. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/.github/workflows/release.yml +0 -0
  30. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/.gitignore +0 -0
  31. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/CONTRIBUTING.md +0 -0
  32. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/LICENSE +0 -0
  33. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/README.md +0 -0
  34. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/abc/builder.py +0 -0
  35. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/abc/constants.py +0 -0
  36. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/abc/parser.py +0 -0
  37. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/abc/types.py +0 -0
  38. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/abc/writer.py +0 -0
  39. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/__init__.py +0 -0
  40. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/_util.py +0 -0
  41. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/build.py +0 -0
  42. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/callees.py +0 -0
  43. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/callers.py +0 -0
  44. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/class_cmd.py +0 -0
  45. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/classes.py +0 -0
  46. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/disasm.py +0 -0
  47. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/extract.py +0 -0
  48. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/field_access.py +0 -0
  49. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/info.py +0 -0
  50. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/packages.py +0 -0
  51. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/refs.py +0 -0
  52. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/strings.py +0 -0
  53. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/tags.py +0 -0
  54. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/cli/tree.py +0 -0
  55. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/errors.py +0 -0
  56. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/info/__init__.py +0 -0
  57. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/info/package_info.py +0 -0
  58. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/swf/__init__.py +0 -0
  59. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/swf/builder.py +0 -0
  60. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/swf/parser.py +0 -0
  61. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/swf/tags.py +0 -0
  62. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/workspace/__init__.py +0 -0
  63. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/flashkit/workspace/resource.py +0 -0
  64. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/pyflashkit.egg-info/dependency_links.txt +0 -0
  65. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/pyflashkit.egg-info/entry_points.txt +0 -0
  66. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/pyflashkit.egg-info/requires.txt +0 -0
  67. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/pyflashkit.egg-info/top_level.txt +0 -0
  68. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/setup.cfg +0 -0
  69. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/__init__.py +0 -0
  70. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/abc/__init__.py +0 -0
  71. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/abc/test_builder.py +0 -0
  72. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/abc/test_disasm.py +0 -0
  73. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/abc/test_parser.py +0 -0
  74. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/abc/test_writer.py +0 -0
  75. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/analysis/__init__.py +0 -0
  76. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/analysis/test_call_graph.py +0 -0
  77. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/analysis/test_field_access.py +0 -0
  78. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/analysis/test_references.py +0 -0
  79. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/analysis/test_strings.py +0 -0
  80. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/cli/__init__.py +0 -0
  81. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/info/__init__.py +0 -0
  82. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/info/test_member_info.py +0 -0
  83. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/swf/__init__.py +0 -0
  84. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/swf/test_builder.py +0 -0
  85. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/swf/test_parser.py +0 -0
  86. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/test_integration.py +0 -0
  87. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/workspace/__init__.py +0 -0
  88. {pyflashkit-1.1.0 → pyflashkit-1.2.0}/tests/workspace/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyflashkit
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: SWF/ABC toolkit for parsing, analyzing, and manipulating Flash files and AVM2 bytecode
5
5
  License: MIT
6
6
  Classifier: Development Status :: 5 - Production/Stable
@@ -21,7 +21,7 @@ Quick start::
21
21
  output = serialize_abc(abc)
22
22
  """
23
23
 
24
- __version__ = "1.1.0"
24
+ __version__ = "1.2.0"
25
25
 
26
26
  from .errors import (
27
27
  FlashkitError, ParseError, SWFParseError,
@@ -32,6 +32,8 @@ from .swf.builder import rebuild_swf, make_doabc2_tag
32
32
  from .abc.parser import parse_abc
33
33
  from .abc.writer import serialize_abc
34
34
  from .abc.types import AbcFile
35
+ from .workspace.workspace import Workspace
36
+ from .info.class_info import ClassInfo
35
37
 
36
38
  __all__ = [
37
39
  "__version__",
@@ -49,4 +51,6 @@ __all__ = [
49
51
  "parse_abc",
50
52
  "serialize_abc",
51
53
  "AbcFile",
54
+ "Workspace",
55
+ "ClassInfo",
52
56
  ]
@@ -41,7 +41,7 @@ from .parser import (
41
41
  read_d64,
42
42
  )
43
43
  from .writer import serialize_abc
44
- from .disasm import Instruction, decode_instructions, scan_relevant_opcodes
44
+ from .disasm import Instruction, ResolvedInstruction, decode_instructions, resolve_instructions, scan_relevant_opcodes
45
45
  from .builder import AbcBuilder
46
46
 
47
47
  __all__ = [
@@ -73,7 +73,9 @@ __all__ = [
73
73
  "serialize_abc",
74
74
  # Disassembler
75
75
  "Instruction",
76
+ "ResolvedInstruction",
76
77
  "decode_instructions",
78
+ "resolve_instructions",
77
79
  "scan_relevant_opcodes",
78
80
  # Builder
79
81
  "AbcBuilder",
@@ -43,6 +43,24 @@ class Instruction:
43
43
  size: int = 1
44
44
 
45
45
 
46
+ @dataclass(slots=True)
47
+ class ResolvedInstruction:
48
+ """An AVM2 instruction with operands resolved to readable names.
49
+
50
+ Created by ``resolve_instructions()`` from raw ``Instruction`` objects.
51
+ Multiname indices become class/field/method names, string indices become
52
+ quoted literals, int/uint/double indices become numeric values.
53
+
54
+ Attributes:
55
+ offset: Byte offset in the method body.
56
+ mnemonic: Opcode name (e.g. ``"getproperty"``).
57
+ operands: Human-readable operand strings.
58
+ """
59
+ offset: int
60
+ mnemonic: str
61
+ operands: list[str] = field(default_factory=list)
62
+
63
+
46
64
  # ── Opcode table ────────────────────────────────────────────────────────────
47
65
  # Maps opcode → (mnemonic, operand_format)
48
66
  # Operand formats:
@@ -472,3 +490,109 @@ def decode_instructions(code: bytes,
472
490
  operands=operands, size=off - start))
473
491
 
474
492
  return instructions
493
+
494
+
495
+ # ── Opcodes grouped by operand resolution type ─────────────────────────────
496
+ # First operand is a multiname pool index
497
+ _MULTINAME_FIRST = frozenset({
498
+ OP_getproperty, OP_setproperty, OP_initproperty,
499
+ OP_getlex, OP_findpropstrict,
500
+ OP_callproperty, OP_callpropvoid, OP_constructprop,
501
+ OP_coerce,
502
+ # Extra opcodes (from _EXTRA_OPCODES)
503
+ 0x04, # getsuper
504
+ 0x05, # setsuper
505
+ 0x5E, # findproperty
506
+ 0x45, # callsuper
507
+ 0x4C, # callproplex
508
+ 0x4E, # callsupervoid
509
+ 0x59, # getdescendants
510
+ 0x6A, # deleteproperty
511
+ 0x80, # coerce
512
+ 0x86, # astype
513
+ 0xB2, # istype
514
+ })
515
+
516
+ # First operand is a string pool index
517
+ _STRING_FIRST = frozenset({OP_pushstring})
518
+
519
+ # First operand is an int pool index
520
+ _INT_FIRST = frozenset({OP_pushint})
521
+
522
+ # First operand is a uint pool index
523
+ _UINT_FIRST = frozenset({OP_pushuint})
524
+
525
+ # First operand is a double pool index
526
+ _DOUBLE_FIRST = frozenset({OP_pushdouble})
527
+
528
+
529
+ def resolve_instructions(
530
+ abc: "AbcFile",
531
+ instructions: list[Instruction],
532
+ ) -> list[ResolvedInstruction]:
533
+ """Resolve raw instruction operands to human-readable strings.
534
+
535
+ Multiname indices become names, string indices become quoted strings,
536
+ int/uint/double indices become literal values. Everything else stays
537
+ as raw numbers.
538
+
539
+ Args:
540
+ abc: The AbcFile for constant pool lookups.
541
+ instructions: Raw decoded instructions.
542
+
543
+ Returns:
544
+ List of ResolvedInstruction with string operands.
545
+ """
546
+ from .types import AbcFile as _AbcFile # noqa: F811
547
+ from ..info.member_info import resolve_multiname
548
+
549
+ resolved = []
550
+ for instr in instructions:
551
+ ops: list[str] = []
552
+ op = instr.opcode
553
+
554
+ for i, val in enumerate(instr.operands):
555
+ if i == 0 and op in _MULTINAME_FIRST:
556
+ try:
557
+ ops.append(resolve_multiname(abc, val))
558
+ except (IndexError, KeyError):
559
+ ops.append(f"multiname[{val}]")
560
+ elif i == 0 and op in _STRING_FIRST:
561
+ if 0 < val < len(abc.string_pool):
562
+ ops.append(f'"{abc.string_pool[val]}"')
563
+ else:
564
+ ops.append(f"string[{val}]")
565
+ elif i == 0 and op in _INT_FIRST:
566
+ if 0 < val < len(abc.int_pool):
567
+ ops.append(str(abc.int_pool[val]))
568
+ else:
569
+ ops.append(f"int[{val}]")
570
+ elif i == 0 and op in _UINT_FIRST:
571
+ if 0 < val < len(abc.uint_pool):
572
+ ops.append(str(abc.uint_pool[val]))
573
+ else:
574
+ ops.append(f"uint[{val}]")
575
+ elif i == 0 and op in _DOUBLE_FIRST:
576
+ if 0 < val < len(abc.double_pool):
577
+ ops.append(str(abc.double_pool[val]))
578
+ else:
579
+ ops.append(f"double[{val}]")
580
+ elif i == 0 and op == OP_newclass:
581
+ # val = class index
582
+ if 0 <= val < len(abc.instances):
583
+ try:
584
+ ops.append(resolve_multiname(abc, abc.instances[val].name))
585
+ except (IndexError, KeyError):
586
+ ops.append(f"class[{val}]")
587
+ else:
588
+ ops.append(f"class[{val}]")
589
+ else:
590
+ ops.append(str(val))
591
+
592
+ resolved.append(ResolvedInstruction(
593
+ offset=instr.offset,
594
+ mnemonic=instr.mnemonic,
595
+ operands=ops,
596
+ ))
597
+
598
+ return resolved
@@ -11,6 +11,8 @@ Modules:
11
11
  references: ReferenceIndex — cross-references (field types, instantiations, imports).
12
12
  strings: StringIndex — string constant search and classification.
13
13
  field_access: FieldAccessIndex — field read/write tracking from bytecode.
14
+ method_fingerprint: MethodFingerprint — structural features of method bodies.
15
+ class_graph: ClassGraph — class-to-class reference graph with typed edges.
14
16
  """
15
17
 
16
18
  from .inheritance import InheritanceGraph
@@ -18,6 +20,18 @@ from .call_graph import CallGraph, CallEdge
18
20
  from .references import ReferenceIndex, Reference
19
21
  from .strings import StringIndex, StringUsage
20
22
  from .field_access import FieldAccessIndex, FieldAccess
23
+ from .method_fingerprint import (
24
+ MethodFingerprint,
25
+ extract_fingerprint,
26
+ extract_constructor_fingerprint,
27
+ extract_all_fingerprints,
28
+ )
29
+ from .class_graph import (
30
+ ClassGraph,
31
+ ClassNode,
32
+ FRAMEWORK_TYPES,
33
+ CLASS_EDGE_KINDS,
34
+ )
21
35
  from .unified import build_all_indexes
22
36
 
23
37
  __all__ = [
@@ -30,5 +44,13 @@ __all__ = [
30
44
  "StringUsage",
31
45
  "FieldAccessIndex",
32
46
  "FieldAccess",
47
+ "MethodFingerprint",
48
+ "extract_fingerprint",
49
+ "extract_constructor_fingerprint",
50
+ "extract_all_fingerprints",
51
+ "ClassGraph",
52
+ "ClassNode",
53
+ "FRAMEWORK_TYPES",
54
+ "CLASS_EDGE_KINDS",
33
55
  "build_all_indexes",
34
56
  ]
@@ -22,6 +22,10 @@ from __future__ import annotations
22
22
 
23
23
  from dataclasses import dataclass, field
24
24
  from collections import defaultdict
25
+ from typing import TYPE_CHECKING
26
+
27
+ if TYPE_CHECKING:
28
+ from ..workspace.workspace import Workspace
25
29
 
26
30
  from ..abc.types import AbcFile
27
31
  from ..abc.disasm import scan_relevant_opcodes
@@ -115,7 +119,7 @@ class CallGraph:
115
119
  default_factory=lambda: defaultdict(list))
116
120
 
117
121
  @classmethod
118
- def from_workspace(cls, workspace: object) -> CallGraph:
122
+ def from_workspace(cls, workspace: Workspace) -> CallGraph:
119
123
  """Build a CallGraph from a Workspace.
120
124
 
121
125
  Iterates all ABC blocks and their method bodies, decodes
@@ -0,0 +1,270 @@
1
+ """Class-reference graph built from a Workspace.
2
+
3
+ Wraps ``ReferenceIndex`` into a per-class adjacency structure with typed
4
+ edges and per-node intrinsic features. Each user-defined class becomes a
5
+ node; each cross-reference between two user-defined classes becomes a
6
+ directed, typed edge.
7
+
8
+ Edges to framework/builtin types (Sprite, Event, Array, etc.) are
9
+ filtered out — they add noise without structural information. Strings
10
+ are collected per class in ``string_pool`` and indexed globally in
11
+ ``ClassGraph.string_to_classes`` for quick reverse lookups.
12
+
13
+ Each node also carries method fingerprints
14
+ (see :mod:`flashkit.analysis.method_fingerprint`) so downstream code can
15
+ reason about method shapes without re-walking the ABC.
16
+
17
+ Typical usage::
18
+
19
+ from flashkit.workspace import Workspace
20
+ from flashkit.analysis import ClassGraph
21
+
22
+ ws = Workspace()
23
+ ws.load_swf("game.swf")
24
+ g = ClassGraph.from_workspace(ws)
25
+
26
+ node = g.nodes["PlayerController"]
27
+ print(node.out_degree_by_kind) # {'call': 12, 'field_type': 3, ...}
28
+ print(len(node.method_fps), "methods fingerprinted")
29
+ print(g.string_to_classes["Hello"]) # which classes reference "Hello"
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections import defaultdict
35
+ from dataclasses import dataclass, field
36
+ from typing import TYPE_CHECKING
37
+
38
+ if TYPE_CHECKING:
39
+ from ..workspace.workspace import Workspace
40
+
41
+ from .references import ReferenceIndex
42
+ from .method_fingerprint import (
43
+ BUILTIN_TYPES,
44
+ MethodFingerprint,
45
+ extract_all_fingerprints,
46
+ )
47
+
48
+
49
+ __all__ = [
50
+ "FRAMEWORK_TYPES",
51
+ "CLASS_EDGE_KINDS",
52
+ "ClassNode",
53
+ "ClassGraph",
54
+ ]
55
+
56
+
57
+ # Framework types filtered out of class-to-class edges. These are Flash
58
+ # Player runtime / AS3 builtins; references to them aren't meaningful
59
+ # when comparing user-defined class structure.
60
+ FRAMEWORK_TYPES: frozenset[str] = frozenset({
61
+ "int", "uint", "Number", "String", "Boolean", "void", "Object",
62
+ "Array", "Class", "Function", "*", "Namespace", "QName",
63
+ "ByteArray", "Dictionary", "Date", "RegExp", "Error", "XML",
64
+ "XMLList",
65
+ # Display list
66
+ "Sprite", "MovieClip", "DisplayObject", "DisplayObjectContainer",
67
+ "BitmapData", "Bitmap", "Shape", "TextField", "TextFormat",
68
+ # Events
69
+ "Event", "EventDispatcher", "MouseEvent", "KeyboardEvent",
70
+ "TimerEvent", "IOErrorEvent", "SecurityErrorEvent", "ProgressEvent",
71
+ # Geometry
72
+ "Point", "Rectangle", "Matrix", "ColorTransform",
73
+ # Audio
74
+ "Sound", "SoundChannel", "SoundTransform",
75
+ # Network / IO
76
+ "URLRequest", "URLLoader", "SharedObject", "Socket",
77
+ # Misc
78
+ "Timer", "Stage", "Loader", "LoaderInfo", "NetConnection",
79
+ "Vector", "BlendMode",
80
+ })
81
+
82
+ # Edge kinds that represent class-to-class relationships.
83
+ CLASS_EDGE_KINDS: frozenset[str] = frozenset({
84
+ "extends", "implements",
85
+ "field_type", "param_type", "return_type",
86
+ "call", "instantiation", "class_ref", "coerce",
87
+ })
88
+
89
+
90
+ def _normalize_super(super_name: str) -> str:
91
+ """Keep the super name if it's a known builtin, else collapse to '?'."""
92
+ if super_name in BUILTIN_TYPES:
93
+ return super_name
94
+ return "?"
95
+
96
+
97
+ @dataclass
98
+ class ClassNode:
99
+ """One node per class in the graph.
100
+
101
+ Stores intrinsic features (counts, flags) plus typed directed edges
102
+ to/from other ``ClassNode``s in the same graph.
103
+
104
+ Attributes:
105
+ name: Simple class name.
106
+ package: Package / namespace string (empty for default package).
107
+ method_count: Number of instance methods, getters, setters.
108
+ static_method_count: Number of static methods.
109
+ field_count: Number of instance fields.
110
+ static_field_count: Number of static fields.
111
+ super_name: Superclass name, normalized — builtins preserved,
112
+ user classes collapsed to ``"?"``.
113
+ is_interface: Whether the class is an interface.
114
+ is_sealed: Whether the class is sealed (no dynamic properties).
115
+ out_edges: ``(target_name, edge_kind)`` pairs for outgoing refs.
116
+ in_edges: ``(source_name, edge_kind)`` pairs for incoming refs.
117
+ out_degree_by_kind: Per-kind outgoing edge counts.
118
+ in_degree_by_kind: Per-kind incoming edge counts.
119
+ string_pool: String constants referenced by this class.
120
+ method_fps: Method fingerprints for all methods + constructor.
121
+ total_code_size: Sum of bytecode sizes across all fingerprints.
122
+ """
123
+
124
+ name: str = ""
125
+ package: str = ""
126
+
127
+ method_count: int = 0
128
+ static_method_count: int = 0
129
+ field_count: int = 0
130
+ static_field_count: int = 0
131
+ super_name: str = "?"
132
+ is_interface: bool = False
133
+ is_sealed: bool = False
134
+
135
+ out_edges: list[tuple[str, str]] = field(default_factory=list)
136
+ in_edges: list[tuple[str, str]] = field(default_factory=list)
137
+ out_degree_by_kind: dict[str, int] = field(
138
+ default_factory=lambda: defaultdict(int))
139
+ in_degree_by_kind: dict[str, int] = field(
140
+ default_factory=lambda: defaultdict(int))
141
+
142
+ string_pool: frozenset[str] = field(default_factory=frozenset)
143
+
144
+ method_fps: list[MethodFingerprint] = field(default_factory=list)
145
+ total_code_size: int = 0
146
+
147
+
148
+ @dataclass
149
+ class ClassGraph:
150
+ """Directed graph of class-to-class references with typed edges.
151
+
152
+ Nodes are keyed by simple class name (not qualified).
153
+
154
+ Attributes:
155
+ nodes: Simple class name → :class:`ClassNode`.
156
+ string_to_classes: Reverse index — string literal → set of
157
+ class names that reference that string.
158
+ """
159
+
160
+ nodes: dict[str, ClassNode] = field(default_factory=dict)
161
+ string_to_classes: dict[str, set[str]] = field(
162
+ default_factory=lambda: defaultdict(set))
163
+
164
+ def total_degree(self, name: str) -> int:
165
+ """Total degree (in + out) for a node, or 0 if the node is absent."""
166
+ node = self.nodes.get(name)
167
+ if node is None:
168
+ return 0
169
+ return len(node.out_edges) + len(node.in_edges)
170
+
171
+ @classmethod
172
+ def from_workspace(cls, workspace: Workspace) -> ClassGraph:
173
+ """Build a :class:`ClassGraph` from a loaded :class:`Workspace`.
174
+
175
+ Walks every class in the workspace, creates a node with intrinsic
176
+ features, then follows its references to populate typed edges to
177
+ other user-defined classes. Framework/builtin targets and
178
+ self-references are filtered out. Finally, method fingerprints are
179
+ extracted for every class.
180
+
181
+ Args:
182
+ workspace: A :class:`flashkit.workspace.Workspace` with at least
183
+ one loaded SWF.
184
+
185
+ Returns:
186
+ A fully populated :class:`ClassGraph`.
187
+ """
188
+ ref_index = ReferenceIndex.from_workspace(workspace)
189
+
190
+ # Map qualified + simple names → simple name for edge resolution.
191
+ all_class_names: set[str] = set()
192
+ qname_to_name: dict[str, str] = {}
193
+ for info in workspace.classes:
194
+ all_class_names.add(info.name)
195
+ qname_to_name[info.qualified_name] = info.name
196
+ qname_to_name[info.name] = info.name
197
+
198
+ graph = cls()
199
+
200
+ # Step 1: create nodes with intrinsic features.
201
+ for info in workspace.classes:
202
+ node = ClassNode(
203
+ name=info.name,
204
+ package=info.package,
205
+ method_count=len(info.methods),
206
+ static_method_count=len(info.static_methods),
207
+ field_count=len(info.fields),
208
+ static_field_count=len(info.static_fields),
209
+ super_name=_normalize_super(info.super_name),
210
+ is_interface=info.is_interface,
211
+ is_sealed=info.is_sealed,
212
+ )
213
+ graph.nodes[info.name] = node
214
+
215
+ # Step 2: walk references to build edges + string pools.
216
+ class_strings: dict[str, set[str]] = defaultdict(set)
217
+
218
+ for info in workspace.classes:
219
+ refs = ref_index.references_from(info.qualified_name)
220
+ node = graph.nodes[info.name]
221
+
222
+ for ref in refs:
223
+ if ref.ref_kind == "string_use":
224
+ class_strings[info.name].add(ref.target)
225
+ graph.string_to_classes[ref.target].add(info.name)
226
+ continue
227
+
228
+ if ref.ref_kind not in CLASS_EDGE_KINDS:
229
+ continue
230
+
231
+ target_name = qname_to_name.get(ref.target)
232
+ if target_name is None:
233
+ if ref.target in all_class_names:
234
+ target_name = ref.target
235
+ else:
236
+ continue
237
+
238
+ if target_name in FRAMEWORK_TYPES:
239
+ continue
240
+ if target_name == info.name:
241
+ continue
242
+
243
+ edge = (target_name, ref.ref_kind)
244
+ node.out_edges.append(edge)
245
+ node.out_degree_by_kind[ref.ref_kind] += 1
246
+
247
+ target_node = graph.nodes.get(target_name)
248
+ if target_node is not None:
249
+ target_node.in_edges.append((info.name, ref.ref_kind))
250
+ target_node.in_degree_by_kind[ref.ref_kind] += 1
251
+
252
+ for cls_name, strings in class_strings.items():
253
+ node = graph.nodes.get(cls_name)
254
+ if node is not None:
255
+ node.string_pool = frozenset(strings)
256
+
257
+ # Step 3: extract method fingerprints for each class.
258
+ for info in workspace.classes:
259
+ node = graph.nodes.get(info.name)
260
+ if node is None:
261
+ continue
262
+ try:
263
+ abc = info.abc
264
+ except RuntimeError:
265
+ continue
266
+ fps = extract_all_fingerprints(info, abc)
267
+ node.method_fps = fps
268
+ node.total_code_size = sum(fp.code_size for fp in fps)
269
+
270
+ return graph
@@ -23,6 +23,10 @@ from __future__ import annotations
23
23
 
24
24
  from dataclasses import dataclass, field
25
25
  from collections import defaultdict
26
+ from typing import TYPE_CHECKING
27
+
28
+ if TYPE_CHECKING:
29
+ from ..workspace.workspace import Workspace
26
30
 
27
31
  from ..abc.types import AbcFile
28
32
  from ..abc.disasm import scan_relevant_opcodes
@@ -91,7 +95,7 @@ class FieldAccessIndex:
91
95
  self.by_class[access.class_name].append(access)
92
96
 
93
97
  @classmethod
94
- def from_workspace(cls, workspace: object) -> FieldAccessIndex:
98
+ def from_workspace(cls, workspace: Workspace) -> FieldAccessIndex:
95
99
  """Build a FieldAccessIndex from a Workspace.
96
100
 
97
101
  Walks all method bodies, decodes instructions, and collects
@@ -103,8 +107,7 @@ class FieldAccessIndex:
103
107
  Returns:
104
108
  Populated FieldAccessIndex.
105
109
  """
106
- from ..workspace.workspace import Workspace
107
- ws: Workspace = workspace # type: ignore[assignment]
110
+ ws = workspace
108
111
 
109
112
  index = cls()
110
113
  for abc in ws.abc_blocks:
@@ -21,11 +21,15 @@ Usage::
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ from typing import TYPE_CHECKING
24
25
  from dataclasses import dataclass, field
25
26
  from collections import defaultdict
26
27
 
27
28
  from ..info.class_info import ClassInfo
28
29
 
30
+ if TYPE_CHECKING:
31
+ from ..workspace.workspace import Workspace
32
+
29
33
 
30
34
  @dataclass(slots=True)
31
35
  class InheritanceGraph:
@@ -76,6 +80,20 @@ class InheritanceGraph:
76
80
 
77
81
  return graph
78
82
 
83
+ @classmethod
84
+ def from_workspace(cls, workspace: Workspace) -> InheritanceGraph:
85
+ """Build an InheritanceGraph from a Workspace's loaded classes.
86
+
87
+ Equivalent to ``InheritanceGraph.from_classes(workspace.classes)``.
88
+
89
+ Args:
90
+ workspace: Workspace instance with loaded classes.
91
+
92
+ Returns:
93
+ Populated InheritanceGraph.
94
+ """
95
+ return cls.from_classes(workspace.classes)
96
+
79
97
  def get_parent(self, name: str) -> str | None:
80
98
  """Get the direct superclass of a class.
81
99