graphyos 0.1.0__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 (84) hide show
  1. graphy/__init__.py +38 -0
  2. graphy/__main__.py +3 -0
  3. graphy/_portable_flock.py +19 -0
  4. graphy/_shared.py +49 -0
  5. graphy/adapters/__init__.py +12 -0
  6. graphy/adapters/outline.py +76 -0
  7. graphy/adapters/python_ast.py +349 -0
  8. graphy/adapters/typescript_ast.py +561 -0
  9. graphy/arms.py +278 -0
  10. graphy/augment_registry.py +266 -0
  11. graphy/bridge.py +264 -0
  12. graphy/cartograph.py +306 -0
  13. graphy/cli.py +1657 -0
  14. graphy/container.py +219 -0
  15. graphy/converge.py +330 -0
  16. graphy/cross_substrate.py +746 -0
  17. graphy/doors.py +289 -0
  18. graphy/draw.py +228 -0
  19. graphy/fanout.py +609 -0
  20. graphy/farm.py +354 -0
  21. graphy/federated_store.py +695 -0
  22. graphy/index.py +333 -0
  23. graphy/index_estate.py +139 -0
  24. graphy/inventory.py +218 -0
  25. graphy/ir.py +367 -0
  26. graphy/journal.py +735 -0
  27. graphy/lightning/__init__.py +20 -0
  28. graphy/lightning/__main__.py +3 -0
  29. graphy/lightning/archive.py +23 -0
  30. graphy/lightning/blitz_hunt.py +258 -0
  31. graphy/lightning/block_blast.py +181 -0
  32. graphy/lightning/bloodhound.py +217 -0
  33. graphy/lightning/bolt_cli.py +190 -0
  34. graphy/lightning/code_hit.py +35 -0
  35. graphy/lightning/context_strike.py +267 -0
  36. graphy/lightning/extras/__init__.py +0 -0
  37. graphy/lightning/extras/stats.py +34 -0
  38. graphy/lightning/extras/storm_cooccurrence.py +387 -0
  39. graphy/lightning/files_from_envelope.py +54 -0
  40. graphy/lightning/formatters.py +74 -0
  41. graphy/lightning/hit_kind.py +70 -0
  42. graphy/lightning/log_blast.py +62 -0
  43. graphy/lightning/models.py +154 -0
  44. graphy/lightning/pattern_splinter.py +70 -0
  45. graphy/lightning/prose_blast.py +41 -0
  46. graphy/lightning/pseudo_ast/__init__.py +12 -0
  47. graphy/lightning/pseudo_ast/blocks.py +392 -0
  48. graphy/lightning/pseudo_ast/brace.py +224 -0
  49. graphy/lightning/pseudo_ast/svelte.py +70 -0
  50. graphy/lightning/reseed_graph.py +400 -0
  51. graphy/lightning/ripgrep.py +222 -0
  52. graphy/lightning/source_kind.py +83 -0
  53. graphy/mcp.py +230 -0
  54. graphy/mesh_federation_gate.py +597 -0
  55. graphy/native_json_graph_ir.py +370 -0
  56. graphy/parity.py +240 -0
  57. graphy/pillars.py +382 -0
  58. graphy/provision.py +75 -0
  59. graphy/query.py +483 -0
  60. graphy/refresh.py +410 -0
  61. graphy/release.py +100 -0
  62. graphy/reseed.py +302 -0
  63. graphy/session_tail.py +302 -0
  64. graphy/shell/README.md +46 -0
  65. graphy/shell/__init__.py +5 -0
  66. graphy/shell/claude/GRAPHY.md +25 -0
  67. graphy/shell/claude/settings.json +18 -0
  68. graphy/shell/gate.py +79 -0
  69. graphy/shell/hooks/before_edit.sh +12 -0
  70. graphy/shell/hooks/session_end.sh +9 -0
  71. graphy/shell/hooks/session_start.sh +8 -0
  72. graphy/shell/install.py +70 -0
  73. graphy/showcase.py +186 -0
  74. graphy/smash.py +490 -0
  75. graphy/sugiyama.py +1243 -0
  76. graphy/tenant.py +121 -0
  77. graphy/traversal.py +327 -0
  78. graphyos-0.1.0.dist-info/METADATA +289 -0
  79. graphyos-0.1.0.dist-info/RECORD +84 -0
  80. graphyos-0.1.0.dist-info/WHEEL +5 -0
  81. graphyos-0.1.0.dist-info/entry_points.txt +2 -0
  82. graphyos-0.1.0.dist-info/licenses/LICENSE +202 -0
  83. graphyos-0.1.0.dist-info/licenses/NOTICE +9 -0
  84. graphyos-0.1.0.dist-info/top_level.txt +1 -0
graphy/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ from graphy.ir import (
5
+ SCHEMA_VERSION,
6
+ Edge,
7
+ Evidence,
8
+ IRError,
9
+ Node,
10
+ Provenance,
11
+ Vocabulary,
12
+ PYTHON_AST_VOCABULARY,
13
+ validate_graph,
14
+ )
15
+ from graphy.parity import Golden, Harness, ParityError, load_golden
16
+ from graphy.tenant import REQUIRED_FIELDS, Tenant, TenantError
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "Golden",
22
+ "Harness",
23
+ "ParityError",
24
+ "load_golden",
25
+ "SCHEMA_VERSION",
26
+ "IRError",
27
+ "Node",
28
+ "Edge",
29
+ "Provenance",
30
+ "Evidence",
31
+ "validate_graph",
32
+ "Vocabulary",
33
+ "PYTHON_AST_VOCABULARY",
34
+ "Tenant",
35
+ "TenantError",
36
+ "REQUIRED_FIELDS",
37
+ "__version__",
38
+ ]
graphy/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from graphy.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ try:
4
+ import fcntl # type: ignore # noqa: F401 — re-exported on POSIX
5
+ HAVE_FCNTL = True
6
+ except ImportError:
7
+ HAVE_FCNTL = False
8
+
9
+ class _NoFcntl:
10
+ LOCK_EX = 2
11
+ LOCK_SH = 1
12
+ LOCK_UN = 8
13
+ LOCK_NB = 4
14
+
15
+ @staticmethod
16
+ def flock(*_args, **_kwargs) -> None:
17
+ return None
18
+
19
+ fcntl = _NoFcntl() # type: ignore
graphy/_shared.py ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import math
5
+
6
+ SUPPORT_PRIOR = 5
7
+
8
+ DEFAULT_EXCLUDE = ("tests/", "test/", "docs_src/", "docs/", "scripts/", "examples/")
9
+
10
+
11
+ def nonneg_int(v) -> int:
12
+ import argparse
13
+ i = int(v)
14
+ if i < 0:
15
+ raise argparse.ArgumentTypeError("--support-prior must be >= 0")
16
+ return i
17
+
18
+
19
+ def credibility(support: int, prior: int = SUPPORT_PRIOR) -> float:
20
+ if support <= 0:
21
+ return 0.0
22
+ prior = max(0, prior)
23
+ return support / (support + prior)
24
+
25
+
26
+ def salience(lift: float, support: int, recency: float, prior: int = SUPPORT_PRIOR) -> float:
27
+ return lift * credibility(support, prior) * recency
28
+
29
+
30
+ def lift(support: int, n_a: int, n_b: int, n_commits: int) -> float:
31
+ return (support * n_commits) / (n_a * n_b) if (n_a and n_b and n_commits) else 0.0
32
+
33
+
34
+ def resolve_excludes(user_exclude: str, use_defaults: bool = True) -> tuple[str, ...]:
35
+ user = tuple(x.strip() for x in user_exclude.split(",") if x.strip())
36
+ return (DEFAULT_EXCLUDE + user) if use_defaults else user
37
+
38
+
39
+ AST_STRUCT_SALIENCE = 1.0
40
+ AST_WIRE_SALIENCE = 5.0
41
+ AST_WIRE_RELATIONS = {"serves", "fetches", "fetches_static", "navigates"}
42
+
43
+
44
+ def _ast_edge_salience(relation: str) -> float:
45
+ return AST_WIRE_SALIENCE if relation in AST_WIRE_RELATIONS else AST_STRUCT_SALIENCE
46
+
47
+
48
+ def _shim_adj_for_activate(unified_adj: dict) -> dict:
49
+ return {k: [(n, s, w) for (n, s, w, _r) in vs] for k, vs in unified_adj.items()}
@@ -0,0 +1,12 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ from graphy.adapters.outline import OUTLINE_VOCABULARY, build_ir as build_outline
5
+ from graphy.adapters.python_ast import PYTHON_AST_VOCABULARY, build_ir as build_python_ast
6
+
7
+ __all__ = [
8
+ "PYTHON_AST_VOCABULARY",
9
+ "OUTLINE_VOCABULARY",
10
+ "build_python_ast",
11
+ "build_outline",
12
+ ]
@@ -0,0 +1,76 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from graphy.ir import Vocabulary
9
+
10
+ __all__ = ["build_ir", "OUTLINE_VOCABULARY"]
11
+
12
+ OUTLINE_VOCABULARY = Vocabulary(
13
+ node_types=("outline_line",),
14
+ edge_types=("contains",),
15
+ producer="outline",
16
+ )
17
+
18
+ _BULLET = re.compile(r"^[-*+]\s+(.*)$")
19
+ _SLUG = re.compile(r"[^a-z0-9]+")
20
+
21
+
22
+ class _NodeRecords(dict):
23
+
24
+ def __iter__(self): # type: ignore[override]
25
+ return iter(self.values())
26
+
27
+
28
+ def _slugify(label: str) -> str:
29
+ return (_SLUG.sub("_", label.lower()).strip("_")[:60]) or "line"
30
+
31
+
32
+ def parse_outline(text: str, doc: str) -> list[dict]:
33
+ records: list[dict] = []
34
+ stack: list[tuple[int, str]] = []
35
+ seen: dict[str, int] = {}
36
+ for raw in text.splitlines():
37
+ if not raw.strip():
38
+ continue
39
+ body = raw.lstrip(" \t")
40
+ indent = len(raw[: len(raw) - len(body)].replace("\t", " "))
41
+ label = body.rstrip()
42
+ m = _BULLET.match(label)
43
+ if m:
44
+ label = m.group(1)
45
+ slug = _slugify(label)
46
+ n = seen.get(slug, 0) + 1
47
+ seen[slug] = n
48
+ if n > 1:
49
+ slug = f"{slug}~{n}"
50
+ nid = f"outline://{doc}/{slug}"
51
+ while stack and stack[-1][0] >= indent:
52
+ stack.pop()
53
+ parent = stack[-1][1] if stack else None
54
+ stack.append((indent, nid))
55
+ records.append({
56
+ "kind": "node", "node_type": "outline_line", "id": nid,
57
+ "label": label, "doc": doc,
58
+ "dotted": f"outline.{doc}.{slug}",
59
+ })
60
+ if parent:
61
+ records.append({"kind": "edge", "edge_type": "contains", "src": parent, "dst": nid})
62
+ return records
63
+
64
+
65
+ def build_ir(outline_file: str | Path) -> tuple[_NodeRecords, list[dict]]:
66
+ path = Path(outline_file)
67
+ text = path.read_text(encoding="utf-8")
68
+ doc = _slugify(path.stem)
69
+ nodes: _NodeRecords = _NodeRecords()
70
+ edges: list[dict] = []
71
+ for rec in parse_outline(text, doc):
72
+ if rec.get("kind") == "node":
73
+ nodes[rec["id"]] = rec
74
+ else:
75
+ edges.append(rec)
76
+ return nodes, edges
@@ -0,0 +1,349 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Iterator
8
+
9
+ from graphy.ir import PYTHON_AST_VOCABULARY
10
+
11
+ __all__ = ["build_ir", "walk_files", "is_package_dir", "PYTHON_AST_VOCABULARY"]
12
+
13
+ _DEFAULT_EXCLUDES = (
14
+ "__pycache__", ".git", ".venv", "venv", "node_modules",
15
+ "build", "dist", ".pytest_cache", ".mypy_cache", ".ruff_cache",
16
+ )
17
+ _PACKAGE_EXCLUDES = ("__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache")
18
+
19
+
20
+ class _NodeRecords(dict):
21
+
22
+ def __iter__(self): # type: ignore[override]
23
+ return iter(self.values())
24
+
25
+
26
+
27
+
28
+ def _dotted_for(file: Path, root: Path, package: str) -> str:
29
+ rel = file.relative_to(root)
30
+ parts = list(rel.parts)
31
+ if parts[-1] == "__init__.py":
32
+ parts = parts[:-1]
33
+ else:
34
+ parts[-1] = parts[-1].removesuffix(".pyi").removesuffix(".py")
35
+ if parts == [package]:
36
+ return package
37
+ return ".".join([package] + parts) if parts else package
38
+
39
+
40
+ def _scheme_of(dotted: str) -> str:
41
+ return dotted.split(".", 1)[0] if dotted else "unknown"
42
+
43
+
44
+ def _node_id(kind: str, dotted: str) -> str:
45
+ return f"{_scheme_of(dotted)}://{kind}/{dotted}"
46
+
47
+
48
+
49
+
50
+ def _expr_repr(node: ast.AST) -> str:
51
+ if isinstance(node, ast.Name):
52
+ return node.id
53
+ if isinstance(node, ast.Attribute):
54
+ return f"{_expr_repr(node.value)}.{node.attr}"
55
+ if isinstance(node, ast.Call):
56
+ return _expr_repr(node.func) + "(...)"
57
+ try:
58
+ return ast.unparse(node)[:120]
59
+ except Exception:
60
+ return f"<{type(node).__name__}>"
61
+
62
+
63
+ def _calls_in(func: ast.AST) -> Iterator[tuple[str, int]]:
64
+ for node in ast.walk(func):
65
+ if isinstance(node, ast.Call):
66
+ yield _expr_repr(node.func), getattr(node, "lineno", 0)
67
+
68
+
69
+
70
+
71
+ def _resolve_module_dst(raw: str, package: str, local_packages: frozenset[str]) -> str:
72
+ first = raw.split(".", 1)[0]
73
+ if first == package:
74
+ return raw
75
+ if first in local_packages:
76
+ return f"{package}.{raw}"
77
+ return raw
78
+
79
+
80
+ def _emit_import_edges(
81
+ tree: ast.AST,
82
+ module_id: str,
83
+ module_dotted: str,
84
+ package: str,
85
+ local_packages: frozenset[str],
86
+ is_package: bool = False,
87
+ ) -> Iterator[dict]:
88
+ for node in ast.walk(tree):
89
+ if isinstance(node, ast.Import):
90
+ for alias in node.names:
91
+ yield {
92
+ "kind": "edge",
93
+ "edge_type": "imports",
94
+ "src": module_id,
95
+ "dst": _node_id(
96
+ "module",
97
+ _resolve_module_dst(alias.name, package, local_packages),
98
+ ),
99
+ "alias": alias.asname,
100
+ "line": node.lineno,
101
+ }
102
+ elif isinstance(node, ast.ImportFrom):
103
+ mod = node.module or ""
104
+ if node.level:
105
+ drop = node.level - 1 if is_package else node.level
106
+ base = module_dotted.split(".")
107
+ base = base[: max(0, len(base) - drop)] if drop else base
108
+ pieces = [p for p in base + ([mod] if mod else []) if p]
109
+ mod = ".".join(pieces)
110
+ elif mod:
111
+ mod = _resolve_module_dst(mod, package, local_packages)
112
+ for alias in node.names:
113
+ dst = _node_id("module", mod) if mod else _node_id("module", alias.name)
114
+ yield {
115
+ "kind": "edge",
116
+ "edge_type": "imports",
117
+ "src": module_id,
118
+ "dst": dst,
119
+ "name": alias.name,
120
+ "alias": alias.asname,
121
+ "line": node.lineno,
122
+ }
123
+
124
+
125
+ _COMPOUND = (ast.If, ast.Try, ast.With, ast.AsyncWith, ast.For, ast.AsyncFor, ast.While)
126
+ if hasattr(ast, "TryStar"):
127
+ _COMPOUND = _COMPOUND + (ast.TryStar,)
128
+
129
+
130
+ def _defs_in(body: list) -> Iterator[ast.AST]:
131
+ """The def and class statements a body defines, descending through the compound statements a
132
+ module or class guards them with — ``if``/``else``, ``try``/``except``/``finally``, ``with``,
133
+ ``for``, ``while`` — and never into a function body. A version-gated backport
134
+ (``if hasattr(typing, X): ... else: class X``) defines X on the module; the module is its parent."""
135
+ for stmt in body:
136
+ if isinstance(stmt, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
137
+ yield stmt
138
+ elif isinstance(stmt, _COMPOUND):
139
+ yield from _defs_in(stmt.body)
140
+ yield from _defs_in(getattr(stmt, "orelse", []) or [])
141
+ for handler in getattr(stmt, "handlers", []) or []:
142
+ yield from _defs_in(handler.body)
143
+ yield from _defs_in(getattr(stmt, "finalbody", []) or [])
144
+
145
+
146
+ def _walk_stmt(
147
+ stmt: ast.AST,
148
+ parent_id: str,
149
+ parent_dotted: str,
150
+ file_rel: str,
151
+ container_class: str | None = None,
152
+ package: str = "",
153
+ local_packages: frozenset[str] = frozenset(),
154
+ ) -> Iterator[dict]:
155
+ if isinstance(stmt, ast.ClassDef):
156
+ class_dotted = f"{parent_dotted}.{stmt.name}"
157
+ class_id = _node_id("class", class_dotted)
158
+ yield {
159
+ "kind": "node",
160
+ "node_type": "class",
161
+ "id": class_id,
162
+ "name": stmt.name,
163
+ "dotted": class_dotted,
164
+ "file": file_rel,
165
+ "line": stmt.lineno,
166
+ "docstring": (ast.get_docstring(stmt) or "")[:200],
167
+ }
168
+ yield {"kind": "edge", "edge_type": "contains", "src": parent_id, "dst": class_id}
169
+ for base in stmt.bases:
170
+ yield {
171
+ "kind": "edge",
172
+ "edge_type": "inherits",
173
+ "src": class_id,
174
+ "dst_repr": _expr_repr(base),
175
+ "line": stmt.lineno,
176
+ }
177
+ for dec in stmt.decorator_list:
178
+ yield {
179
+ "kind": "edge",
180
+ "edge_type": "decorates",
181
+ "src_repr": _expr_repr(dec),
182
+ "dst": class_id,
183
+ "line": stmt.lineno,
184
+ }
185
+ for sub in _defs_in(stmt.body):
186
+ yield from _walk_stmt(sub, class_id, class_dotted, file_rel,
187
+ container_class=stmt.name, package=package,
188
+ local_packages=local_packages)
189
+ elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
190
+ kind = "method" if container_class else "func"
191
+ func_dotted = f"{parent_dotted}.{stmt.name}"
192
+ func_id = _node_id(kind, func_dotted)
193
+ yield {
194
+ "kind": "node",
195
+ "node_type": kind,
196
+ "id": func_id,
197
+ "name": stmt.name,
198
+ "dotted": func_dotted,
199
+ "file": file_rel,
200
+ "line": stmt.lineno,
201
+ "is_async": isinstance(stmt, ast.AsyncFunctionDef),
202
+ "args": [a.arg for a in stmt.args.args],
203
+ "returns": _expr_repr(stmt.returns) if stmt.returns else None,
204
+ "docstring": (ast.get_docstring(stmt) or "")[:200],
205
+ "container_class": container_class,
206
+ }
207
+ yield {"kind": "edge", "edge_type": "contains", "src": parent_id, "dst": func_id}
208
+ for dec in stmt.decorator_list:
209
+ yield {
210
+ "kind": "edge",
211
+ "edge_type": "decorates",
212
+ "src_repr": _expr_repr(dec),
213
+ "dst": func_id,
214
+ "line": stmt.lineno,
215
+ }
216
+ for call_repr, line in _calls_in(stmt):
217
+ yield {
218
+ "kind": "edge",
219
+ "edge_type": "calls",
220
+ "src": func_id,
221
+ "dst_repr": call_repr,
222
+ "line": line,
223
+ }
224
+
225
+
226
+ def _is_excluded(file: Path, exclude_patterns: tuple[str, ...], root: Path) -> bool:
227
+ try:
228
+ parts = set(file.relative_to(root).parts)
229
+ except ValueError:
230
+ parts = set(file.parts)
231
+ return any(p in parts for p in exclude_patterns)
232
+
233
+
234
+ def _local_package_names(root: Path) -> frozenset[str]:
235
+ names: set[str] = set()
236
+ for entry in root.iterdir():
237
+ if entry.name.startswith(".") or entry.name in _DEFAULT_EXCLUDES:
238
+ continue
239
+ if entry.is_dir() and next(entry.rglob("*.py"), None) is not None:
240
+ names.add(entry.name)
241
+ elif entry.is_file() and entry.suffix == ".py":
242
+ names.add(entry.stem)
243
+ return frozenset(names)
244
+
245
+
246
+ def _emit_raw_records_for_file(
247
+ file: Path,
248
+ root: Path,
249
+ package: str,
250
+ local_packages: frozenset[str] = frozenset(),
251
+ rel_base: Path | None = None,
252
+ ) -> Iterator[dict]:
253
+ try:
254
+ src = file.read_text(encoding="utf-8")
255
+ except (UnicodeDecodeError, OSError):
256
+ return
257
+ try:
258
+ tree = ast.parse(src)
259
+ except SyntaxError:
260
+ return
261
+
262
+ module_dotted = _dotted_for(file, root, package)
263
+ module_id = _node_id("module", module_dotted)
264
+ file_rel = str(file.relative_to(rel_base if rel_base is not None else root.parent))
265
+
266
+ yield {
267
+ "kind": "node",
268
+ "node_type": "module",
269
+ "id": module_id,
270
+ "dotted": module_dotted,
271
+ "file": file_rel,
272
+ "loc": len(src.splitlines()),
273
+ "docstring": (ast.get_docstring(tree) or "")[:200],
274
+ }
275
+
276
+ yield from _emit_import_edges(tree, module_id, module_dotted, package,
277
+ local_packages,
278
+ is_package=file.name == "__init__.py")
279
+
280
+ for stmt in _defs_in(tree.body):
281
+ yield from _walk_stmt(stmt, module_id, module_dotted, file_rel,
282
+ package=package, local_packages=local_packages)
283
+
284
+
285
+
286
+
287
+
288
+ TEST_FILE = re.compile(r"(^|/)(tests?|testing)/|(^|/)test_[^/]*\.py$|_tests?\.py$|(^|/)conftest\.py$")
289
+
290
+
291
+ def _emit_records_for_file(
292
+ file: Path,
293
+ root: Path,
294
+ package: str,
295
+ local_packages: frozenset[str] = frozenset(),
296
+ rel_base: Path | None = None,
297
+ ) -> Iterator[dict]:
298
+ """Every node carries ``module`` (the dotted module that holds it) and, when the producer's
299
+ own rule says the file is a test, ``role: test``. The consumers read those fields; no consumer
300
+ derives a module from a ``.py`` path or decides what a test is."""
301
+ module_dotted: str | None = None
302
+ for rec in _emit_raw_records_for_file(file, root, package, local_packages, rel_base):
303
+ if rec.get("kind") == "node":
304
+ if rec["node_type"] == "module":
305
+ module_dotted = rec["dotted"]
306
+ rec["module"] = module_dotted if module_dotted is not None else rec["dotted"]
307
+ if TEST_FILE.search(str(rec.get("file") or "")):
308
+ rec["role"] = "test"
309
+ yield rec
310
+
311
+ def is_package_dir(corpus_dir: str | Path) -> bool:
312
+ """A corpus that carries its own ``__init__.py`` is one importable package: its name is the
313
+ scheme, its siblings are other packages. Anything else is a repo root whose top-level
314
+ directories are local packages that resolve under the root's name."""
315
+ return (Path(corpus_dir) / "__init__.py").is_file()
316
+
317
+
318
+ def walk_files(corpus_dir: str | Path) -> list[Path]:
319
+ """The exact files a corpus is minted from, in mint order. A package directory skips only
320
+ caches; a repo root also skips build and vendored trees; a ``.py`` file is itself."""
321
+ root = Path(corpus_dir).resolve()
322
+ if root.is_file():
323
+ return [root] if root.suffix == ".py" else []
324
+ if not root.is_dir():
325
+ raise RuntimeError(f"path is not a directory or a .py file: {root}")
326
+ excludes = _PACKAGE_EXCLUDES if is_package_dir(root) else _DEFAULT_EXCLUDES
327
+ return [f for f in sorted(root.rglob("*.py")) if not _is_excluded(f, excludes, root)]
328
+
329
+
330
+ def build_ir(corpus_dir: str | Path) -> tuple[_NodeRecords, list[dict]]:
331
+ """Mint the IR of one corpus. A directory is a package (``__init__.py`` present) or a repo
332
+ root; a single ``.py`` file is a one-module distribution (``typing_extensions.py``) whose
333
+ scheme is the file's stem. Files are named relative to the corpus's parent."""
334
+ root = Path(corpus_dir).resolve()
335
+ nodes: _NodeRecords = _NodeRecords()
336
+ edges: list[dict] = []
337
+ if root.is_file() and root.suffix == ".py":
338
+ package, base, local_packages = root.stem, root.parent, frozenset()
339
+ else:
340
+ package, base = root.name, root
341
+ local_packages = frozenset() if is_package_dir(root) else _local_package_names(root)
342
+ for file in walk_files(root):
343
+ for rec in _emit_records_for_file(file, base, package, local_packages=local_packages,
344
+ rel_base=base.parent if root.is_dir() else base):
345
+ if rec.get("kind") == "node":
346
+ nodes[rec["id"]] = rec
347
+ else:
348
+ edges.append(rec)
349
+ return nodes, edges