libidentifier 0.2.0rc1__py3-none-any.whl

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 (50) hide show
  1. libidentifier/__init__.py +36 -0
  2. libidentifier/api.py +90 -0
  3. libidentifier/archive.py +133 -0
  4. libidentifier/cli.py +340 -0
  5. libidentifier/cli_resources.py +36 -0
  6. libidentifier/cli_setup.py +177 -0
  7. libidentifier/config.py +54 -0
  8. libidentifier/detect.py +30 -0
  9. libidentifier/errors.py +30 -0
  10. libidentifier/extractors/__init__.py +2 -0
  11. libidentifier/extractors/dex.py +121 -0
  12. libidentifier/extractors/dex_tables.py +137 -0
  13. libidentifier/extractors/elf.py +66 -0
  14. libidentifier/extractors/instructions.py +77 -0
  15. libidentifier/extractors/metadata.py +99 -0
  16. libidentifier/extractors/version_metadata.py +72 -0
  17. libidentifier/fingerprint/__init__.py +2 -0
  18. libidentifier/fingerprint/dex_v2.py +97 -0
  19. libidentifier/fingerprint/elf_v1.py +16 -0
  20. libidentifier/grouping.py +74 -0
  21. libidentifier/inventory.py +260 -0
  22. libidentifier/matcher.py +381 -0
  23. libidentifier/models.py +423 -0
  24. libidentifier/profiles/__init__.py +2 -0
  25. libidentifier/profiles/builder.py +182 -0
  26. libidentifier/profiles/catalog.py +135 -0
  27. libidentifier/profiles/distribution.py +303 -0
  28. libidentifier/profiles/fetcher.py +141 -0
  29. libidentifier/profiles/pom.py +66 -0
  30. libidentifier/profiles/schema.py +137 -0
  31. libidentifier/profiles/store.py +212 -0
  32. libidentifier/purl.py +36 -0
  33. libidentifier/relationships.py +168 -0
  34. libidentifier/sbom.py +62 -0
  35. libidentifier/serialization.py +53 -0
  36. libidentifier/table.py +254 -0
  37. libidentifier/version.py +2 -0
  38. libidentifier/vulnerabilities/__init__.py +2 -0
  39. libidentifier/vulnerabilities/correlate.py +164 -0
  40. libidentifier/vulnerabilities/database.py +213 -0
  41. libidentifier/vulnerabilities/models.py +155 -0
  42. libidentifier/vulnerabilities/runner.py +148 -0
  43. libidentifier/vulnerabilities/setup.py +74 -0
  44. libidentifier-0.2.0rc1.dist-info/METADATA +156 -0
  45. libidentifier-0.2.0rc1.dist-info/RECORD +50 -0
  46. libidentifier-0.2.0rc1.dist-info/WHEEL +5 -0
  47. libidentifier-0.2.0rc1.dist-info/entry_points.txt +2 -0
  48. libidentifier-0.2.0rc1.dist-info/licenses/LICENSE +62 -0
  49. libidentifier-0.2.0rc1.dist-info/licenses/THIRD_PARTY_NOTICES.md +15 -0
  50. libidentifier-0.2.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,77 @@
1
+ """Instruction-boundary traversal for standard DEX explicit invocations.
2
+
3
+ Only declared references are returned. All supported instruction widths are
4
+ accounted for, including switch/array payloads. Invalid widths/targets stop the
5
+ method and retain preceding observations with a diagnostic.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+
11
+ WIDTHS = {}
12
+ for start, end, width in [(0x00,0x01,1),(0x02,0x02,2),(0x03,0x03,3),
13
+ (0x04,0x04,1),(0x05,0x05,2),(0x06,0x06,3),
14
+ (0x07,0x07,1),(0x08,0x08,2),(0x09,0x09,3),
15
+ (0x0a,0x12,1),(0x13,0x13,2),(0x14,0x14,3),
16
+ (0x15,0x16,2),(0x17,0x17,3),(0x18,0x18,5),
17
+ (0x19,0x1a,2),(0x1b,0x1b,3),(0x1c,0x1c,2),
18
+ (0x1d,0x1e,1),(0x1f,0x20,2),(0x21,0x21,1),
19
+ (0x22,0x23,2),(0x24,0x26,3),(0x27,0x28,1),
20
+ (0x29,0x29,2),(0x2a,0x2c,3),(0x2d,0x3d,2),
21
+ (0x44,0x6d,2),(0x6e,0x72,3),(0x74,0x78,3),
22
+ (0x7b,0x8f,1),(0x90,0xaf,2),(0xb0,0xcf,1),
23
+ (0xd0,0xe2,2),(0xfa,0xfb,4),(0xfc,0xfd,3),(0xfe,0xff,2)]:
24
+ WIDTHS.update({op: width for op in range(start, end + 1)})
25
+ INVOKES = dict(zip(range(0x6e,0x73), ('virtual','super','direct','static','interface')))
26
+ INVOKES.update({op+6: kind+'/range' for op,kind in list(INVOKES.items())})
27
+
28
+
29
+ def decode_invocations(code: bytes, *, method_count: int, dex_version: int = 35):
30
+ calls, issues = [], []
31
+ units = len(code) // 2
32
+ def u16(i):
33
+ if i >= units:
34
+ raise ValueError('Truncated instruction or payload')
35
+ return struct.unpack_from('<H', code, i * 2)[0]
36
+ pc = 0
37
+ if len(code) % 2:
38
+ return [], ['Odd byte length in instruction stream']
39
+ try:
40
+ while pc < units:
41
+ first = u16(pc)
42
+ op = first & 255
43
+ width = WIDTHS.get(op)
44
+ if width is None:
45
+ raise ValueError(f'Unsupported opcode 0x{op:02x} at code unit {pc}')
46
+ if op >= 0xfa and dex_version < (39 if op >= 0xfe else 38):
47
+ raise ValueError('Opcode is unsupported for this DEX version')
48
+ if op == 0 and first:
49
+ if pc % 2:
50
+ raise ValueError('Misaligned instruction payload')
51
+ count = u16(pc + 1)
52
+ if first == 0x0100:
53
+ width = 4 + 2 * count
54
+ elif first == 0x0200:
55
+ width = 2 + 4 * count
56
+ elif first == 0x0300:
57
+ size = u16(pc + 2) | (u16(pc + 3) << 16)
58
+ if count not in (1,2,4,8):
59
+ raise ValueError('Invalid array payload element width')
60
+ width = 4 + (count * size + 1) // 2
61
+ else:
62
+ raise ValueError('Unknown instruction payload')
63
+ if pc + width > units:
64
+ raise ValueError(f'Truncated instruction/payload at code unit {pc}')
65
+ if op in INVOKES:
66
+ if op <= 0x72 and first >> 12 > 5:
67
+ raise ValueError('Invalid invoke register count')
68
+ index = u16(pc + 1)
69
+ if index >= method_count:
70
+ raise ValueError('Invocation method index out of bounds')
71
+ calls.append((pc * 2, INVOKES[op], index))
72
+ elif op in (0xfa,0xfb,0xfc,0xfd):
73
+ issues.append(f'Special invocation at code unit {pc}; dynamic target resolution unsupported')
74
+ pc += width
75
+ except ValueError as exc:
76
+ issues.append(str(exc))
77
+ return calls, issues
@@ -0,0 +1,99 @@
1
+ """Conservative static framework indicators; no framework payload execution."""
2
+ from __future__ import annotations
3
+ from pathlib import PurePosixPath
4
+ from ..models import CodeLocator, FrameworkEvidence, FrameworkHint
5
+
6
+ # Exact runtime entry points, not generic AndroidX/Kotlin namespaces.
7
+ RULES = {
8
+ "flutter": ("Flutter", {
9
+ "Lio/flutter/embedding/engine/FlutterEngine;", "Lio/flutter/embedding/android/FlutterActivity;",
10
+ "Lio/flutter/view/FlutterNativeView;",
11
+ }, {"libflutter.so"}, "Dart application logic and package dependencies are not analyzed"),
12
+ "react_native": ("React Native", {
13
+ "Lcom/facebook/react/ReactInstanceManager;", "Lcom/facebook/react/ReactNativeHost;",
14
+ "Lcom/facebook/react/bridge/CatalystInstanceImpl;",
15
+ }, {"libreactnative.so", "libreactnativejni.so"}, "JavaScript and Hermes application logic and dependencies are not analyzed"),
16
+ "jetpack_compose": ("Jetpack Compose", {
17
+ "Landroidx/compose/runtime/Composer;", "Landroidx/compose/runtime/ComposerImpl;",
18
+ "Landroidx/compose/ui/platform/AndroidComposeView;",
19
+ }, set(), "DEX structure is analyzed; Compose usage and UI behavior are not established"),
20
+ "cordova": ("Cordova", {
21
+ "Lorg/apache/cordova/CordovaActivity;", "Lorg/apache/cordova/CordovaWebViewImpl;",
22
+ "Lorg/apache/cordova/PluginManager;",
23
+ }, set(), "JavaScript application logic and dependencies are not analyzed"),
24
+ "unity": ("Unity", {
25
+ "Lcom/unity3d/player/UnityPlayer;", "Lcom/unity3d/player/UnityPlayerActivity;",
26
+ }, {"libunity.so"}, "Unity managed code and IL2CPP application logic are not analyzed"),
27
+ "dotnet_android": ("Xamarin / .NET MAUI", {
28
+ "Lmono/android/Runtime;", "Lmono/MonoPackageManager;", "Lmono/MonoPackageManager_Resources;",
29
+ }, {"libmonodroid.so", "libmonosgen-2.0.so"}, ".NET assemblies and MAUI application logic are not analyzed; Xamarin versus MAUI is not always distinguishable"),
30
+ }
31
+ ASSETS = {
32
+ "flutter": {"assets/flutter_assets/" + x for x in (
33
+ "AssetManifest.json", "AssetManifest.bin", "NOTICES.Z", "kernel_blob.bin",
34
+ "isolate_snapshot_data", "vm_snapshot_data")},
35
+ "react_native": {"assets/index.android.bundle"},
36
+ "cordova": {"assets/www/cordova.js"},
37
+ "unity": {"assets/bin/Data/globalgamemanagers", "assets/bin/Data/Managed/Metadata/global-metadata.dat"},
38
+ "dotnet_android": {"assemblies/" + x for x in (
39
+ "assemblies.blob", "assemblies.manifest", "mscorlib.dll", "Mono.Android.dll",
40
+ "Microsoft.Maui.dll", "Microsoft.Maui.Controls.dll")},
41
+ }
42
+
43
+
44
+ def detect_frameworks(members, classes, elf_features, *, container: str) -> list[FrameworkHint]:
45
+ """Combine independent evidence kinds; multiple ABIs do not increase confidence.
46
+
47
+ A single exact class definition is a low-confidence presence hint. Filenames
48
+ alone never suffice; parsed native artifacts need corroboration.
49
+ """
50
+ results = []
51
+ for framework_id, (name, descriptors, native_names, limitation) in RULES.items():
52
+ evidence, kinds, found_classes = [], set(), set()
53
+
54
+ def add(kind, indicator, location):
55
+ kinds.add(kind)
56
+ evidence.append(FrameworkEvidence(indicator, location))
57
+
58
+ for cls in classes:
59
+ if cls.descriptor in descriptors:
60
+ found_classes.add(cls.descriptor)
61
+ add("dex", "defined framework runtime class", cls.locator)
62
+ for elf, artifact in elf_features:
63
+ basename = PurePosixPath(artifact.member or artifact.path or "").name
64
+ loc = CodeLocator(container, artifact.member, sha256=elf.sha256)
65
+ if basename in native_names or elf.soname in native_names:
66
+ add("native", "parsed ELF runtime: " + (elf.soname or basename), loc)
67
+ if framework_id == "flutter":
68
+ for symbol in elf.exported_symbols:
69
+ if symbol in {"FlutterEngineRun", "FlutterEngineInitialize"}:
70
+ add("symbol", "ELF exported symbol: " + symbol, loc)
71
+ for m in members:
72
+ if m.name not in ASSETS.get(framework_id, set()):
73
+ continue
74
+ if framework_id == "cordova" and not any(marker in m.data for marker in (
75
+ b"cordova.define", b'define("cordova', b"define('cordova")):
76
+ continue
77
+ add("asset", name + " asset layout" + (" with bootstrap marker" if framework_id == "cordova" else " (content not analyzed)"),
78
+ CodeLocator(container, m.name, sha256=m.sha256))
79
+ if not found_classes and not ({"native", "asset"} <= kinds or "symbol" in kinds):
80
+ continue
81
+ confidence = "low"
82
+ if len(found_classes) >= 2 or len(kinds) >= 2:
83
+ confidence = "medium"
84
+ if len(kinds) >= 3 or ({"dex", "native"} <= kinds and len(found_classes) >= 2):
85
+ confidence = "high"
86
+ evidence.sort(key=lambda e: (e.location.member or "", e.location.class_descriptor or "", e.indicator))
87
+ results.append(FrameworkHint(
88
+ name=name, evidence="; ".join(sorted({e.indicator for e in evidence})),
89
+ member=evidence[0].location.member, confidence=confidence,
90
+ limitations=[limitation, "Presence does not prove active use; no framework version is inferred"],
91
+ framework_id=framework_id, evidence_records=evidence,
92
+ application_logic="dex_structure_only" if framework_id == "jetpack_compose" else "not_supported",
93
+ ))
94
+ return sorted(results, key=lambda h: h.name)
95
+
96
+
97
+ def framework_hints_from_names(member_names, classes):
98
+ """Legacy internal helper: filenames alone no longer establish presence."""
99
+ return detect_frameworks([], classes, [], container="")
@@ -0,0 +1,72 @@
1
+ """A deliberately small pom.properties subset, never a global version-string search."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import replace
6
+ from ..models import CodeLocator, EvidenceRecord, Producer, AnalysisRun
7
+ from ..grouping import stable_id
8
+ from ..purl import reference_coordinates
9
+
10
+
11
+ def read_properties(data, member):
12
+ match = re.fullmatch(r'META-INF/maven/([^/]+)/([^/]+)/pom\.properties', member)
13
+ if not match or len(data) > 65536:
14
+ return None
15
+ values = {}
16
+ try:
17
+ for line in data.decode('utf-8-sig').splitlines():
18
+ line = line.strip()
19
+ if not line or line.startswith(('#','!')):
20
+ continue
21
+ key, value = re.split(r'\s*[=:]\s*', line, maxsplit=1)
22
+ if key in values or '\\' in line:
23
+ return None # Unsupported Java-properties escapes/continuations.
24
+ values[key] = value.strip()
25
+ except (ValueError, UnicodeError):
26
+ return None
27
+ if (values.get('groupId'), values.get('artifactId')) != match.groups() or not values.get('version'):
28
+ return None
29
+ return values
30
+
31
+
32
+ def apply_version_metadata(result, inv):
33
+ components, evidence = [], list(result.evidence_records)
34
+ conflicts = 0
35
+ for component in result.components:
36
+ records = []
37
+ containers = {loc.container for loc in component.matched_code}
38
+ if component.family_purl:
39
+ try:
40
+ base = re.split(r'[?#]', component.family_purl, maxsplit=1)[0]
41
+ ecosystem, group, name, _, _ = reference_coordinates(base + '@__unknown__')
42
+ except ValueError:
43
+ ecosystem, group, name = None, None, None
44
+ for loc, props in inv.version_metadata:
45
+ if loc.container in containers and ecosystem == 'maven' and (group, name) == (props['groupId'], props['artifactId']):
46
+ eid = stable_id('evidence', ['pom.properties', loc.to_dict(), props])
47
+ evidence.append(EvidenceRecord(eid, 'version_metadata', Producer('maven-properties','1'), loc,
48
+ {'component_id':component.component_id, 'declared':props},
49
+ limitations=['Embedded metadata is a declaration and can be modified; structural corroboration is required']))
50
+ records.append(props)
51
+ versions = {r['version'] for r in records}
52
+ if len(versions) > 1 or (component.version and versions and versions != {component.version}):
53
+ conflicts += 1
54
+ component = replace(component, version=None, purl=None, version_confidence='unknown',
55
+ limitations=component.limitations + ['Conflicting embedded version metadata'])
56
+ elif (len(versions) == 1 and not component.version and len(containers) == 1 and len(component.matched_code) >= 3
57
+ and component.score_details.get('exact_descriptor_matches',0) >= 3):
58
+ version = next(iter(versions))
59
+ # Full supported candidates remain conservative; never use mere best release.
60
+ supported = {v for v,details in component.score_details.get('version_candidate_scores',{}).items() if details.get('supported')}
61
+ if version in supported or version in component.version_candidates:
62
+ from urllib.parse import quote
63
+ base = re.split(r'[?#]', component.family_purl, maxsplit=1)[0]
64
+ suffix = component.family_purl[len(base):]
65
+ component = replace(component, version=version, purl=base+'@'+quote(version,safe='')+suffix,
66
+ version_confidence='high', identification_methods=component.identification_methods+['maven_properties'],
67
+ limitations=component.limitations+['Version uses attributable embedded Maven declaration plus matching code; not cryptographic provenance'])
68
+ components.append(component)
69
+ return replace(result, components=components, evidence_records=evidence,
70
+ analysis_runs=result.analysis_runs+[AnalysisRun('version_metadata', 'partial' if conflicts or inv.metadata_issues or inv.coverage.dex=='partial' else 'complete',
71
+ Producer('maven-properties','1'), {'records':len(inv.version_metadata), 'conflicting_components':conflicts},
72
+ ['Only simple canonical pom.properties associated with identified code is supported'], inv.metadata_issues)])
@@ -0,0 +1,2 @@
1
+ """Fingerprint algorithms."""
2
+
@@ -0,0 +1,97 @@
1
+ """dex-shape-v2 canonical class fingerprints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections import Counter
8
+
9
+ from ..extractors.dex import ClassFeature
10
+
11
+ PLATFORM_PREFIXES = (
12
+ "Ljava/",
13
+ "Ljavax/",
14
+ "Landroid/",
15
+ "Landroidx/",
16
+ "Ldalvik/",
17
+ "Lkotlin/",
18
+ )
19
+
20
+
21
+ def normalize_type(descriptor: str | None) -> str:
22
+ if not descriptor:
23
+ return "UNKNOWN"
24
+ primitive_names = {
25
+ "void": "V",
26
+ "boolean": "Z",
27
+ "byte": "B",
28
+ "short": "S",
29
+ "char": "C",
30
+ "int": "I",
31
+ "long": "J",
32
+ "float": "F",
33
+ "double": "D",
34
+ }
35
+ descriptor = primitive_names.get(descriptor, descriptor)
36
+ depth = 0
37
+ while descriptor.startswith("["):
38
+ depth += 1
39
+ descriptor = descriptor[1:]
40
+ if descriptor in {"V", "Z", "B", "S", "C", "I", "J", "F", "D"}:
41
+ base = descriptor
42
+ elif descriptor.startswith(PLATFORM_PREFIXES):
43
+ base = descriptor
44
+ elif descriptor.startswith("L"):
45
+ base = "OBJECT"
46
+ else:
47
+ base = "UNKNOWN"
48
+ return ("ARRAY:" * depth) + base
49
+
50
+
51
+ def _method_kind(name: str) -> str:
52
+ if name == "<init>":
53
+ return "constructor"
54
+ if name == "<clinit>":
55
+ return "class_initializer"
56
+ return "method"
57
+
58
+
59
+ def class_shape_map(feature: ClassFeature) -> dict:
60
+ fields = [
61
+ {"type": normalize_type(f.type_descriptor), "flags": sorted(f.access_flags)} for f in feature.fields
62
+ ]
63
+ methods = [
64
+ {
65
+ "kind": _method_kind(m.name),
66
+ "return": normalize_type(m.return_type),
67
+ "params": [normalize_type(p) for p in m.parameter_types],
68
+ "flags": sorted(m.access_flags),
69
+ }
70
+ for m in feature.methods
71
+ ]
72
+ return {
73
+ "class_flags": sorted(feature.access_flags),
74
+ "field_count": len(feature.fields),
75
+ "method_count": len(feature.methods),
76
+ "interface_count": len(feature.interfaces),
77
+ "super": normalize_type(feature.superclass),
78
+ "interfaces": sorted(normalize_type(i) for i in feature.interfaces),
79
+ "fields": sorted(fields, key=lambda x: json.dumps(x, sort_keys=True, separators=(",", ":"))),
80
+ "methods": sorted(methods, key=lambda x: json.dumps(x, sort_keys=True, separators=(",", ":"))),
81
+ }
82
+
83
+
84
+ def digest_class(feature: ClassFeature) -> str:
85
+ payload = json.dumps(class_shape_map(feature), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
86
+ "utf-8"
87
+ )
88
+ return hashlib.sha256(b"dex-shape-v2\0" + payload).hexdigest()
89
+
90
+
91
+ def digest_counts(features: list[ClassFeature]) -> Counter[str]:
92
+ return Counter(digest_class(f) for f in features)
93
+
94
+
95
+ def is_trivial_shape(feature: ClassFeature) -> bool:
96
+ return len(feature.fields) == 0 and len(feature.methods) <= 1 and len(feature.interfaces) == 0
97
+
@@ -0,0 +1,16 @@
1
+ """elf-identity-v1 helper functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..extractors.elf import ElfFeature
6
+
7
+
8
+ def identity_record(feature: ElfFeature) -> dict[str, str | None]:
9
+ return {
10
+ "abi": feature.abi,
11
+ "sha256": feature.sha256,
12
+ "build_id": feature.build_id,
13
+ "soname": feature.soname,
14
+ "symbol_digest": feature.symbol_digest,
15
+ }
16
+
@@ -0,0 +1,74 @@
1
+ """Deterministic inexpensive clustering; clusters make no library-origin claim."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from collections import defaultdict
7
+ from .models import UnattributedCluster
8
+
9
+
10
+ def locator_key(loc):
11
+ return (loc.container, loc.member or '', loc.class_descriptor or '')
12
+
13
+
14
+ def stable_id(prefix, value):
15
+ return prefix + '-' + hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=True).encode()).hexdigest()[:24]
16
+
17
+
18
+ def group_unknown(classes, unknown, relationships):
19
+ locators = {locator_key(loc): loc for loc in unknown}
20
+ parent = {key: key for key in locators}
21
+ def root(key):
22
+ while parent[key] != key:
23
+ parent[key] = parent[parent[key]]
24
+ key = parent[key]
25
+ return key
26
+ def join(a, b):
27
+ a, b = root(a), root(b)
28
+ parent[max(a,b)] = min(a,b)
29
+ namespaces = defaultdict(list)
30
+ for key in locators:
31
+ desc = key[2]
32
+ package = desc[1:].rsplit('/', 1)[0] if '/' in desc else None
33
+ # Root/default and broad namespace prefixes are not useful identities.
34
+ if package and '/' in package and package not in {'com/google', 'com/android', 'org/apache'}:
35
+ namespaces[package].append(key)
36
+ for members in namespaces.values():
37
+ for key in members[1:]:
38
+ join(members[0], key)
39
+ structural_pairs = []
40
+ for rel in relationships:
41
+ if rel.source.location is None or rel.target.location is None:
42
+ continue
43
+ a, b = locator_key(rel.source.location), locator_key(rel.target.location)
44
+ if a in parent and b in parent and rel.type in {'inheritance', 'interface_implementation'}:
45
+ # Type-signature/call connectivity alone easily bridges separate code.
46
+ join(a, b)
47
+ structural_pairs.append((a,b,rel.type))
48
+ groups = defaultdict(list)
49
+ for key in sorted(locators):
50
+ groups[root(key)].append(key)
51
+ supports = defaultdict(list)
52
+ used_methods = defaultdict(set)
53
+ for members in namespaces.values():
54
+ if len(members) > 1:
55
+ used_methods[root(members[0])].add('namespace')
56
+ for a,b,kind in structural_pairs:
57
+ used_methods[root(a)].add(kind)
58
+ for rel in relationships:
59
+ if rel.type not in {'inheritance','interface_implementation'} or not rel.source.location or not rel.target.location:
60
+ continue
61
+ a,b=locator_key(rel.source.location),locator_key(rel.target.location)
62
+ if a in parent and b in parent and root(a)==root(b):
63
+ supports[root(a)].append(rel.id)
64
+ results = []
65
+ for group,members in groups.items():
66
+ methods = used_methods[group] or {'singleton'}
67
+ packages = {k[2][1:].rsplit('/',1)[0] for k in members if '/' in k[2]}
68
+ hint = next(iter(packages)).replace('/', '.') if len(packages) == 1 else None
69
+ results.append(UnattributedCluster(stable_id('cluster', members), len(members),
70
+ [locators[k] for k in members], hint, sorted(methods),
71
+ quality={'namespace_count': len(packages), 'singleton': len(members) == 1},
72
+ limitations=['Grouping does not establish common library origin; namespace reuse and obfuscation may mix code'],
73
+ supporting_relationship_ids=sorted(supports[group])))
74
+ return sorted(results, key=lambda c:c.id)