codmap 0.0.3__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 (55) hide show
  1. codemap/__init__.py +10 -0
  2. codemap/apidiff.py +208 -0
  3. codemap/arch.py +190 -0
  4. codemap/cli.py +718 -0
  5. codemap/diagnostics.py +256 -0
  6. codemap/extract/__init__.py +10 -0
  7. codemap/extract/attrflow.py +230 -0
  8. codemap/extract/behavior.py +771 -0
  9. codemap/extract/dataflow.py +97 -0
  10. codemap/extract/dispatch.py +248 -0
  11. codemap/extract/griffe_extractor.py +496 -0
  12. codemap/extract/gsource.py +83 -0
  13. codemap/extract/roots.py +427 -0
  14. codemap/freshness.py +94 -0
  15. codemap/incremental.py +195 -0
  16. codemap/integrations/__init__.py +51 -0
  17. codemap/integrations/base.py +196 -0
  18. codemap/integrations/cocoindex.py +78 -0
  19. codemap/integrations/gate.py +58 -0
  20. codemap/integrations/gitnexus.py +93 -0
  21. codemap/integrations/registry.py +69 -0
  22. codemap/integrations/transport.py +46 -0
  23. codemap/model.py +178 -0
  24. codemap/provenance.py +248 -0
  25. codemap/query.py +1164 -0
  26. codemap/scope.py +212 -0
  27. codemap/serve/__init__.py +26 -0
  28. codemap/serve/_scip_pb2.py +100 -0
  29. codemap/serve/api_surface.py +60 -0
  30. codemap/serve/apidiff.py +83 -0
  31. codemap/serve/architecture.py +101 -0
  32. codemap/serve/audit.py +176 -0
  33. codemap/serve/check.py +80 -0
  34. codemap/serve/ctags.py +203 -0
  35. codemap/serve/impact.py +84 -0
  36. codemap/serve/livingdocs.py +174 -0
  37. codemap/serve/mcp_server.py +278 -0
  38. codemap/serve/mermaid.py +120 -0
  39. codemap/serve/pack.py +93 -0
  40. codemap/serve/rag.py +142 -0
  41. codemap/serve/review.py +197 -0
  42. codemap/serve/scip.py +183 -0
  43. codemap/serve/semantic.py +71 -0
  44. codemap/serve/server.py +43 -0
  45. codemap/serve/session.py +482 -0
  46. codemap/serve/subsystems.py +85 -0
  47. codemap/serve/vault.py +156 -0
  48. codemap/store.py +28 -0
  49. codemap/tomlio.py +59 -0
  50. codmap-0.0.3.dist-info/METADATA +245 -0
  51. codmap-0.0.3.dist-info/RECORD +55 -0
  52. codmap-0.0.3.dist-info/WHEEL +5 -0
  53. codmap-0.0.3.dist-info/entry_points.txt +2 -0
  54. codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
  55. codmap-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,496 @@
1
+ """Python extractor backed by griffe (DESIGN §10.8).
2
+
3
+ Static analysis only — griffe parses source without importing the target, and
4
+ resolves the hard parts for us (signatures, docstrings, __all__ visibility,
5
+ base-class / relative-import / re-export resolution — DESIGN §3.1); we consume
6
+ that, we don't reinvent it.
7
+
8
+ Emits (M0 + M1 + M1.5):
9
+ - definition nodes (module/class/function/attribute) + `contains` structure;
10
+ - `export` edges for re-exports/aliases (module re-exposes a symbol — §2.1);
11
+ - `imports` edges between modules (dependency graph — §1, §3.1);
12
+ - `inherits` edges (class → base class; external bases flagged — §2);
13
+ - `decorated_by` edges (symbol → decorator callable — §2);
14
+ - node `extras`: attribute `annotation`, class `is_dataclass`, and dynamic
15
+ `registry` binding (decorator + literal key) for factory/registry wiring (§7).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ import os
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ import griffe
26
+
27
+ from codemap.extract.attrflow import add_attrflow
28
+ from codemap.extract.behavior import add_behavior
29
+ from codemap.extract.dataflow import add_dataflow
30
+ from codemap.extract.dispatch import add_dispatch, add_family_links
31
+ from codemap.extract.gsource import module_file, module_identity
32
+ from codemap.provenance import build_provenance
33
+ from codemap.model import Edge, Graph, Node
34
+
35
+ # griffe object kinds we turn into definition nodes (aliases handled separately).
36
+ _NODE_KINDS = {"module", "class", "function", "attribute"}
37
+
38
+ #: Why an input file produced no module (R1-C23 / design D2).
39
+ SKIP_ENCODING, SKIP_SYNTAX, SKIP_IO, SKIP_UNREAD = "encoding", "syntax", "io", "unread"
40
+
41
+
42
+ @dataclass
43
+ class _Walk:
44
+ """What one structural walk accumulates besides nodes and edges."""
45
+
46
+ aliases: list = field(default_factory=list) # (parent_module, name, target, public)
47
+ imports: list = field(default_factory=list) # (module_id, target_symbol_path)
48
+ #: canonical real path → the module id that claimed it (R1-C23/D1, symlink cycles)
49
+ claimed: dict = field(default_factory=dict)
50
+ #: module ids skipped because their file was already read under another name
51
+ aliased: list = field(default_factory=list) # (skipped_id, owner_id)
52
+
53
+
54
+ def build_structural(package_path: str | Path):
55
+ """The cheap, deterministic base: griffe load + definition nodes + structural
56
+ edges (contains / imports / inherits / decorated_by / export). No behavioral
57
+ layer. Shared by :func:`extract` and the incremental path (R1-C9), which both
58
+ add the (expensive, tier-sensitive) behavioral passes on top.
59
+
60
+ Returns ``(graph, root, module_name, search_path)``.
61
+ """
62
+ pkg_dir = Path(package_path).resolve()
63
+ if not pkg_dir.is_dir():
64
+ raise NotADirectoryError(f"Not a package directory: {pkg_dir}")
65
+
66
+ module_name = pkg_dir.name
67
+ search_path = pkg_dir.parent
68
+ root = griffe.load(module_name, search_paths=[str(search_path)])
69
+
70
+ graph = Graph(target=module_name)
71
+ walk = _Walk()
72
+
73
+ _collect(graph, root, search_path, module_name, walk)
74
+ _resolve_edges(graph, module_name, walk.aliases, walk.imports)
75
+ # R1-C23/D2: an input the extractor could not read used to vanish without a word,
76
+ # and the graph then reported on a tree it had not fully seen. Record what was
77
+ # missed *in the artifact*, so a consumer holding only the graph is told.
78
+ graph.provenance = {"inputs": _input_report(graph, pkg_dir, search_path, walk)}
79
+ return graph, root, module_name, search_path
80
+
81
+
82
+ def add_behavioral_layer(graph, root, module_name, search_path, *, deep: bool,
83
+ behavior_only=None, attr_only=None) -> None:
84
+ """Add every behavioral pass on top of a structural base (in the fixed order).
85
+
86
+ ``behavior_only`` / ``attr_only`` (module-path sets) restrict the two expensive
87
+ jedi-sensitive passes to those modules — the incremental hook (R1-C9). The cheap,
88
+ tier-independent passes (dispatch / family / dataflow) always run whole.
89
+ """
90
+ # M4/M5: call-graph + control skeleton (deep=jedi type inference).
91
+ add_behavior(graph, root, module_name, deep=deep, search_path=search_path,
92
+ only=behavior_only)
93
+ # M7: bridge factory/registry dispatch seams using the M1.5 registry table.
94
+ add_dispatch(graph, root, module_name)
95
+ # M9 (F4): link registry-family members to the Protocol they satisfy.
96
+ add_family_links(graph)
97
+ # M12 (F6): column/string-key dataflow (reads/writes on `df['col']`).
98
+ add_dataflow(graph, root, module_name)
99
+ # R1-C20 (issue #1): attribute-access edges (accesses: function → attribute).
100
+ add_attrflow(graph, root, module_name, deep=deep, search_path=search_path,
101
+ only=attr_only)
102
+
103
+
104
+ def extract(package_path: str | Path, *, deep: bool = False) -> Graph:
105
+ """Build a code graph from a Python package directory.
106
+
107
+ ``deep=True`` runs the jedi-backed call resolver (M5) — richer call-graph
108
+ (local-variable type inference) at ~1 min build cost; default is the fast
109
+ ast tier (sub-second). See ``extract/behavior.py``.
110
+ """
111
+ graph, root, module_name, search_path = build_structural(package_path)
112
+ add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
113
+ # R1-C25: even a library-built graph says which tool and which tier made it. The
114
+ # input identity (scope_id, source commit) is added by whoever resolved the scope —
115
+ # `extract` deliberately does not hash the tree a second time.
116
+ graph.provenance = build_provenance(tier="deep" if deep else "fast",
117
+ inputs=graph.provenance.get("inputs"))
118
+ return graph
119
+
120
+
121
+ # -- pass 1: definition nodes + contains/inherits/decorated_by, collect aliases/imports --
122
+
123
+ def _enumerate_sources(pkg_dir: Path) -> list[Path]:
124
+ """Every ``.py`` under the package, each real file once (R1-C23 / design D1+D2).
125
+
126
+ Symlinks *are* followed — a symlinked source directory is a legitimate layout — but a
127
+ directory whose real path was already walked is not re-entered, which is what keeps a
128
+ ``loop -> .`` link from generating an unbounded tree. Deterministic order.
129
+ """
130
+ out: list[Path] = []
131
+ seen: set[str] = set()
132
+ for dirpath, dirnames, filenames in os.walk(pkg_dir, followlinks=True):
133
+ real = os.path.realpath(dirpath)
134
+ if real in seen:
135
+ dirnames[:] = []
136
+ continue
137
+ seen.add(real)
138
+ dirnames[:] = sorted(d for d in dirnames if d != "__pycache__")
139
+ out.extend(Path(dirpath) / n for n in sorted(filenames) if n.endswith(".py"))
140
+ return out
141
+
142
+
143
+ def _skip_reason(path: Path) -> str:
144
+ """Why this file produced no module — asked only of files that produced none."""
145
+ try:
146
+ src = path.read_text(encoding="utf-8")
147
+ except UnicodeDecodeError:
148
+ return SKIP_ENCODING
149
+ except OSError:
150
+ return SKIP_IO
151
+ try:
152
+ ast.parse(src)
153
+ except (SyntaxError, ValueError):
154
+ return SKIP_SYNTAX
155
+ return SKIP_UNREAD # parses here but griffe produced nothing — say so plainly
156
+
157
+
158
+ def _input_report(graph, pkg_dir: Path, root: Path, walk) -> dict:
159
+ """What the walk read, and what it could not (R1-C23 / design D2).
160
+
161
+ Lives in the graph's ``provenance`` rather than in a sidecar because a consumer
162
+ holding only ``graph.json`` is exactly the one who must be told that the tree was
163
+ read incompletely. Paths are relative to the search root — the artifact travels.
164
+ """
165
+ files = _enumerate_sources(pkg_dir)
166
+ have = {n.file for n in graph.nodes.values() if n.kind == "module" and n.file}
167
+ py = sorted(_rel(f, root) for f in files)
168
+ skipped = [{"path": rel, "reason": _skip_reason(root / rel)}
169
+ for rel in py if rel not in have]
170
+ report: dict = {"python_files": len(py)}
171
+ if skipped:
172
+ report["skipped"] = skipped
173
+ if walk.aliased:
174
+ report["aliased_modules"] = [
175
+ {"id": mid, "same_as": owner} for mid, owner in sorted(walk.aliased)
176
+ ]
177
+ return report
178
+
179
+
180
+ def _star_import_targets(module) -> list[str]:
181
+ """Modules pulled in by ``from X import *`` (R1-C23 / design D3).
182
+
183
+ Fed into the same ``imports`` list griffe fills, so the target resolves — and gets
184
+ the flat-layout retry — through exactly one code path.
185
+
186
+ Cost is why this is a substring gate before a parse: ``module.source`` is already in
187
+ griffe's cache, and scanning every module of the dogfood target for ``import *`` takes
188
+ 0.065s and yields zero candidates. Only a file that contains the text is parsed, so
189
+ the answer is exact rather than a regex guess about what is inside a string literal.
190
+ """
191
+ try:
192
+ src = module.source
193
+ except Exception: # no source (namespace dir, synthetic)
194
+ return []
195
+ if "import *" not in src:
196
+ return []
197
+ try:
198
+ tree = ast.parse(src)
199
+ except (SyntaxError, ValueError):
200
+ return [] # unreadable: D2's report owns this file
201
+ modpath = module.canonical_path
202
+ f = module_file(module)
203
+ is_pkg = f is not None and f.name == "__init__.py"
204
+ base = modpath.split(".") if is_pkg else modpath.split(".")[:-1]
205
+ targets = []
206
+ for node in ast.walk(tree):
207
+ if not isinstance(node, ast.ImportFrom):
208
+ continue
209
+ if not any(a.name == "*" for a in node.names):
210
+ continue
211
+ if node.level:
212
+ anchor = base[:len(base) - (node.level - 1)]
213
+ target = ".".join(anchor + ([node.module] if node.module else []))
214
+ else:
215
+ target = node.module or ""
216
+ if target:
217
+ targets.append(target)
218
+ return targets
219
+
220
+
221
+ def _collect(graph, obj, root, target_pkg, walk) -> None:
222
+ if obj.kind.value == "module":
223
+ _claim(obj, walk) # the root claims its own path before any member is walked
224
+ _add_node(graph, obj, root)
225
+ for name, tgt in (obj.imports or {}).items():
226
+ walk.imports.append((obj.canonical_path, tgt))
227
+ # R1-C23/D3: griffe expands `from .m import *` into member aliases but records
228
+ # no import, so the dependency itself was invisible — a star-import is the least
229
+ # explicit dependency in the language and the one most worth surfacing.
230
+ for tgt in _star_import_targets(obj):
231
+ walk.imports.append((obj.canonical_path, tgt))
232
+ for name, member in obj.members.items():
233
+ if member.is_alias:
234
+ # capture ALL re-exports (public flag kept) — a symbol can be importable
235
+ # via a module without being in its __all__ (e.g. bquant.analysis.zones
236
+ # re-exports analyze_zones but its __all__ lists only the legacy API).
237
+ walk.aliases.append(
238
+ (obj.canonical_path, name, member.target_path, member.is_public)
239
+ )
240
+ continue
241
+ if member.kind.value not in _NODE_KINDS:
242
+ continue
243
+ # R1-C23/D1: a directory symlink into its own ancestry makes the same file
244
+ # reachable under unboundedly many names. Refuse the second name — before the
245
+ # `contains` edge, or the graph keeps an edge to a node that is never added.
246
+ if member.kind.value == "module" and not _claim(member, walk):
247
+ continue
248
+ if member.kind.value != "module": # modules add themselves in the branch above
249
+ _add_node(graph, member, root)
250
+ graph.add_edge(Edge("contains", obj.canonical_path, member.canonical_path))
251
+ _emit_decorated_by(graph, member)
252
+ if member.kind.value == "class":
253
+ _emit_inherits(graph, member, target_pkg)
254
+ if member.kind.value in {"module", "class"}:
255
+ _collect(graph, member, root, target_pkg, walk)
256
+
257
+
258
+ def _claim(module, walk) -> bool:
259
+ """Claim a module's real path for it; False when another module already holds it.
260
+
261
+ The survivor is whichever module the walk reached first — the walk descends from the
262
+ package root, so that is always the shallower, real name (``hardpkg.api`` over
263
+ ``hardpkg.loop.api``). A module with no resolvable path cannot be proven a duplicate
264
+ and is always kept: omitting real code is the worse error.
265
+ """
266
+ key = module_identity(module)
267
+ if key is None:
268
+ return True
269
+ owner = walk.claimed.get(key)
270
+ if owner is None:
271
+ walk.claimed[key] = module.canonical_path
272
+ return True
273
+ if owner == module.canonical_path:
274
+ return True
275
+ walk.aliased.append((module.canonical_path, owner))
276
+ return False
277
+
278
+
279
+ # -- semantic edges resolvable inline (griffe gives absolute targets) --------
280
+
281
+ def _emit_inherits(graph, cls, target_pkg) -> None:
282
+ """One `inherits` edge per base; griffe resolves the base to a canonical path."""
283
+ for base in getattr(cls, "bases", None) or []:
284
+ target = getattr(base, "canonical_path", None) or str(base)
285
+ internal = target == target_pkg or target.startswith(target_pkg + ".")
286
+ graph.add_edge(
287
+ Edge(
288
+ "inherits",
289
+ cls.canonical_path,
290
+ target,
291
+ extras={} if internal else {"external": True},
292
+ )
293
+ )
294
+
295
+
296
+ def _emit_decorated_by(graph, obj) -> None:
297
+ """One `decorated_by` edge per decorator (target = its callable path)."""
298
+ for name in _decorator_names(obj):
299
+ graph.add_edge(Edge("decorated_by", obj.canonical_path, name))
300
+
301
+
302
+ # -- pass 2: resolve export + import edges against known nodes ----------------
303
+
304
+ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
305
+ module_ids = sorted(
306
+ (n.id for n in graph.nodes.values() if n.kind == "module"), key=len, reverse=True
307
+ )
308
+
309
+ for parent_module, name, target_path, is_public in aliases:
310
+ if not (target_path == target_pkg or target_path.startswith(target_pkg + ".")):
311
+ continue # external re-export (e.g. `import numpy as np`) — out of scope
312
+ graph.add_edge(
313
+ Edge(
314
+ "export",
315
+ parent_module,
316
+ target_path,
317
+ extras={"as": name, "public": is_public},
318
+ )
319
+ )
320
+
321
+ seen: set[tuple[str, str]] = set()
322
+ unresolved: list[tuple[str, str]] = []
323
+
324
+ # pass A — package-qualified targets. Exact, and run first so that a pair reachable
325
+ # both ways is recorded as exact rather than inferred.
326
+ for src_module, target_path in imports:
327
+ if not (target_path == target_pkg or target_path.startswith(target_pkg + ".")):
328
+ unresolved.append((src_module, target_path)) # external, or flat (pass B)
329
+ continue
330
+ tgt_module = _containing_module(target_path, module_ids)
331
+ if tgt_module is None or tgt_module == src_module:
332
+ continue
333
+ key = (src_module, tgt_module)
334
+ if key in seen:
335
+ continue
336
+ seen.add(key)
337
+ graph.add_edge(Edge("imports", src_module, tgt_module))
338
+
339
+ # pass B — flat layout (R1-C21): sibling modules importing each other by bare name
340
+ # (`from alpha import X`), which works at runtime because the directory itself is on
341
+ # sys.path. griffe records the source-literal target, so pass A cannot tell it from
342
+ # `pandas.DataFrame`. Retry it against the importer's own package — and label it, since
343
+ # this is an inference about sys.path, not something the source states.
344
+ known_modules = set(module_ids)
345
+ for src_module, target_path in unresolved:
346
+ tgt_module = _flat_sibling(src_module, target_path, known_modules)
347
+ if tgt_module is None or tgt_module == src_module:
348
+ continue
349
+ key = (src_module, tgt_module)
350
+ if key in seen:
351
+ continue
352
+ seen.add(key)
353
+ graph.add_edge(
354
+ Edge("imports", src_module, tgt_module, extras={"resolution": "flat"})
355
+ )
356
+
357
+
358
+ def _flat_sibling(src_module: str, target_path: str, known_modules: set[str]) -> str | None:
359
+ """The flat-layout sibling of ``src_module`` named by ``target_path``, or None.
360
+
361
+ Deliberately narrow (design D2): it only ever sees targets that resolved to nothing
362
+ as package-qualified, and it only fires when the target's head names a module sitting
363
+ **beside the importer**. Measured on two real packages (codemap, bquant): fires zero
364
+ times, so a correctly-laid-out package cannot be disturbed by it.
365
+ """
366
+ if "." not in src_module:
367
+ return None # a top-level module has no package for siblings to live in
368
+ parent = src_module.rsplit(".", 1)[0]
369
+ candidate = f"{parent}.{target_path.split('.', 1)[0]}"
370
+ return candidate if candidate in known_modules else None
371
+
372
+
373
+ def _containing_module(symbol_path: str, module_ids: list[str]) -> str | None:
374
+ """Longest module-id that is a prefix of (or equals) the symbol path."""
375
+ for mid in module_ids: # already sorted longest-first
376
+ if symbol_path == mid or symbol_path.startswith(mid + "."):
377
+ return mid
378
+ return None
379
+
380
+
381
+ # -- node building (M0 + M1.5 extras) ----------------------------------------
382
+
383
+ def _add_node(graph, obj, root) -> None:
384
+ decorators = _decorator_names(obj)
385
+ graph.add_node(
386
+ Node(
387
+ id=obj.canonical_path,
388
+ kind=obj.kind.value,
389
+ file=_rel(module_file(obj), root), # None for a namespace dir (R1-C21)
390
+ lineno=getattr(obj, "lineno", None),
391
+ endlineno=getattr(obj, "endlineno", None),
392
+ signature=_signature(obj),
393
+ docstring=obj.docstring.value if obj.docstring else None,
394
+ visibility="public" if obj.is_public else "private",
395
+ decorators=decorators,
396
+ is_deprecated=any(d.split(".")[-1] == "deprecated" for d in decorators),
397
+ extras=_stub_marked(_extras(obj, decorators), obj),
398
+ )
399
+ )
400
+
401
+
402
+ def _stub_marked(extras: dict, obj) -> dict:
403
+ """Label a symbol that exists only in a ``.pyi`` stub (R1-C23 / design D5).
404
+
405
+ A stub-only module has no runtime counterpart, so its symbols are declarations, not
406
+ code. Labelling beats both alternatives: dropping them loses the declared surface of
407
+ a stubs distribution, and leaving them unmarked presents a function that does not
408
+ exist as if it did. Consumers that reason about execution (dead-code) exclude them.
409
+ """
410
+ f = module_file(obj)
411
+ if f is not None and f.suffix == ".pyi":
412
+ extras = {**extras, "stub": True}
413
+ return extras
414
+
415
+
416
+ def _extras(obj, decorators) -> dict:
417
+ """Language-specific facts kept off the neutral core (DESIGN §2)."""
418
+ extras: dict = {}
419
+ kind = obj.kind.value
420
+ if kind == "attribute" and getattr(obj, "annotation", None) is not None:
421
+ extras["annotation"] = str(obj.annotation) # e.g. "List[ZoneInfo]" (CM-01)
422
+ if kind == "function":
423
+ # structured param/return types — basis for type-flow (M4) and CM-03.
424
+ params = [
425
+ {"name": p.name, "type": str(p.annotation)}
426
+ for p in obj.parameters
427
+ if p.annotation is not None
428
+ ]
429
+ if params:
430
+ extras["params"] = params
431
+ if obj.returns is not None:
432
+ extras["returns"] = str(obj.returns)
433
+ if kind == "class":
434
+ if any(d.split(".")[-1] == "dataclass" for d in decorators):
435
+ extras["is_dataclass"] = True # CM-02
436
+ binding = _registry_binding(obj) # CM-07: @Registry.register('key')
437
+ if binding is not None:
438
+ extras["registry"] = binding
439
+ return extras
440
+
441
+
442
+ def _registry_binding(obj) -> dict | None:
443
+ """Dynamic registration via a decorator call with a literal string key.
444
+
445
+ Turns ``@ZoneDetectionRegistry.register('zero_crossing', ...)`` into
446
+ ``{"decorator": "...register", "key": "zero_crossing"}`` so factory/registry
447
+ wiring is queryable instead of hidden in a decorator string (DESIGN §7).
448
+ """
449
+ for d in getattr(obj, "decorators", []) or []:
450
+ path = getattr(d, "callable_path", None)
451
+ value = getattr(d, "value", None)
452
+ if path is None or "register" not in str(path).lower():
453
+ continue
454
+ args = getattr(value, "arguments", None)
455
+ if not args:
456
+ continue
457
+ first = args[0] # griffe gives a string-literal arg as quoted source text
458
+ if isinstance(first, str) and len(first) >= 2 and first[0] in "'\"":
459
+ key = first.strip("'\"")
460
+ if key:
461
+ return {"decorator": str(path), "key": key}
462
+ return None
463
+
464
+
465
+ def _signature(obj) -> str | None:
466
+ if obj.kind.value != "function":
467
+ return None
468
+ parts = []
469
+ for p in obj.parameters:
470
+ s = p.name
471
+ if p.annotation is not None:
472
+ s += f": {p.annotation}"
473
+ if p.default is not None:
474
+ s += f" = {p.default}"
475
+ parts.append(s)
476
+ sig = f"{obj.name}({', '.join(parts)})"
477
+ if obj.returns is not None:
478
+ sig += f" -> {obj.returns}"
479
+ return sig
480
+
481
+
482
+ def _decorator_names(obj) -> list[str]:
483
+ names = []
484
+ for d in getattr(obj, "decorators", []) or []:
485
+ path = getattr(d, "callable_path", None)
486
+ names.append(str(path) if path else str(getattr(d, "value", d)))
487
+ return names
488
+
489
+
490
+ def _rel(filepath, root: Path) -> str | None:
491
+ if filepath is None:
492
+ return None
493
+ try:
494
+ return str(Path(filepath).resolve().relative_to(root.resolve()))
495
+ except ValueError:
496
+ return str(filepath)
@@ -0,0 +1,83 @@
1
+ """Griffe object → its source file, normalized (R1-C21 / design D4).
2
+
3
+ ``filepath`` on a griffe object has **three** shapes, not two:
4
+
5
+ - a ``Path`` — the ordinary module/class/function case;
6
+ - ``None`` — no file (synthetic objects);
7
+ - a ``list[Path]`` — a **namespace package** (a directory with no ``__init__.py``),
8
+ which has search locations rather than one source file.
9
+
10
+ Every consumer used to take the first two into account and crash on the third
11
+ (``TypeError: ... not 'list'``, issue #4). The list shape is normalized here, once,
12
+ so no pass has to know about it: a namespace package has no single file, and ``None``
13
+ is the value every one of those call sites already handles.
14
+
15
+ Note the shape that made the old guards fail: ``if not fp`` *passes* a non-empty
16
+ list, so each site sailed past its own check straight into ``Path(list)``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+
24
+ def module_file(obj) -> Path | None:
25
+ """The single source file behind a griffe object, or ``None`` if it has none."""
26
+ fp = getattr(obj, "filepath", None)
27
+ if fp is None or isinstance(fp, list):
28
+ return None
29
+ return Path(fp)
30
+
31
+
32
+ def is_namespace_dir(obj) -> bool:
33
+ """True for a namespace package (a directory without ``__init__.py``)."""
34
+ return isinstance(getattr(obj, "filepath", None), list)
35
+
36
+
37
+ def module_identity(obj) -> str | None:
38
+ """The **real** path behind a module — symlinks resolved (R1-C23 / design D1).
39
+
40
+ A directory symlink that points into its own ancestry makes the same file reachable
41
+ under unboundedly many module names: a single ``loop -> .`` link turned a 17-file
42
+ package into **615 modules** nested 40 deep, silently. Resolving to a canonical path
43
+ lets the walk notice it has read this file already.
44
+
45
+ A namespace package has search locations rather than a file; its first location
46
+ identifies it well enough for that purpose.
47
+ """
48
+ fp = getattr(obj, "filepath", None)
49
+ if isinstance(fp, list):
50
+ fp = fp[0] if fp else None
51
+ if fp is None:
52
+ return None
53
+ try:
54
+ return str(Path(fp).resolve())
55
+ except OSError:
56
+ return None
57
+
58
+
59
+ def module_imports(mod, modpath: str, known_modules) -> dict[str, str]:
60
+ """``mod.imports``, with flat-layout sibling targets package-qualified (R1-C21).
61
+
62
+ griffe records an import target exactly as the source writes it, so in a flat layout
63
+ (`from alpha import X`, the directory itself on ``sys.path``) the target is
64
+ ``alpha.X`` — unprefixed, and therefore indistinguishable from ``pandas.X`` to every
65
+ consumer that tests ``target.startswith(pkg + ".")``. Qualifying the map here means
66
+ the inference lives in **one** place and each pass (calls, attribute access) simply
67
+ stops being blind to the layout, rather than each re-deriving it.
68
+
69
+ Same guard as the ``imports``-edge resolution (design D2): only a target that is not
70
+ already package-internal, and only when its head names a module sitting **beside**
71
+ the importer. The module-level inference stays visible on the corresponding
72
+ ``imports`` edge, which carries ``extras.resolution="flat"``.
73
+ """
74
+ imports = dict(getattr(mod, "imports", None) or {})
75
+ if "." not in modpath:
76
+ return imports
77
+ parent, pkg = modpath.rsplit(".", 1)[0], modpath.split(".", 1)[0] + "."
78
+ out = {}
79
+ for name, target in imports.items():
80
+ if not target.startswith(pkg) and f"{parent}.{target.split('.', 1)[0]}" in known_modules:
81
+ target = f"{parent}.{target}"
82
+ out[name] = target
83
+ return out