witan-code 0.2.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.
@@ -0,0 +1,642 @@
1
+ """Cross-repo interface-binding extractors for the Layer-2.5 bridge.
2
+
3
+ A *binding* is a point where a repo PROVIDES or CONSUMES a shared contract that
4
+ crosses repo boundaries: an environment variable, a shared package, a service /
5
+ deploy unit, or an HTTP endpoint. Bindings from every indexed repo accumulate in
6
+ one shared bridge store; cross-repo linkages are found by grouping on
7
+ ``(kind, key_norm)``.
8
+
9
+ Extraction is HEURISTIC and syntactic — regex over source text for a handful of
10
+ well-formed, high-signal patterns, matching the same best-effort ethos as the
11
+ symbol indexer. It will miss dynamic construction and occasionally over-match.
12
+
13
+ Two tiers:
14
+ * Tier A (``extract_file_bindings``) — per source file; rides the per-file walk
15
+ the indexer already does. Env-var + package *consumers*, endpoint consumers.
16
+ * Tier B (``extract_repo_bindings``) — once per repo over well-known files that
17
+ are not otherwise indexed: OpenAPI specs (endpoint providers), Pulumi stack
18
+ configs (env-var providers), service definitions, package.json (package
19
+ providers).
20
+
21
+ Each consumer ``ParsedBinding`` carries a ``confidence`` score (0.0–1.0) computed
22
+ by stacking independent heuristics at extraction time. Higher scores indicate
23
+ more likely genuine cross-repo calls; callers can filter on ``min_confidence`` to
24
+ suppress phantom edges without hardcoding library names.
25
+ """
26
+
27
+ import json
28
+ import re
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ # ── Binding record ────────────────────────────────────────────────
33
+
34
+
35
+ @dataclass
36
+ class ParsedBinding:
37
+ kind: str # env_var | package | service | endpoint
38
+ key: str # raw as written (METHOD path, pkg name, env NAME)
39
+ key_norm: str # normalized join key
40
+ role: str # provider | consumer | shared
41
+ file: str # repo-relative source path
42
+ sub_kind: str | None = None # service anchor variant: repo | image | name
43
+ symbol_id: str | None = None # filled by the indexer (Tier A) via line lookup
44
+ line: int | None = None
45
+ language: str | None = None
46
+ framework: str | None = None
47
+ generic: bool = False
48
+ # Confidence that this binding represents a genuine cross-repo call (0.0–1.0).
49
+ # 1.0 for provider bindings (always genuine); computed for consumer endpoint
50
+ # bindings by _compute_file_confidence. Defaults to 1.0 so non-endpoint
51
+ # consumers and providers pass through unfiltered.
52
+ confidence: float = 1.0
53
+ # Canonical symbol string (docs/SYMBOL_FORMAT.md), filled at bridge-write
54
+ # time by canonical_symbol() once the repo's package identity is known.
55
+ symbol: str | None = None
56
+
57
+
58
+ # Generic env names that appear in unrelated repos — extracted but flagged so
59
+ # cross-repo impact can de-prioritize them (a `DEBUG` edit doesn't "touch" 40 repos).
60
+ GENERIC_ENV = frozenset(
61
+ {
62
+ "DEBUG",
63
+ "PORT",
64
+ "HOST",
65
+ "HOSTNAME",
66
+ "PATH",
67
+ "HOME",
68
+ "USER",
69
+ "ENV",
70
+ "ENVIRONMENT",
71
+ "LOG_LEVEL",
72
+ "LOGLEVEL",
73
+ "TZ",
74
+ "LANG",
75
+ "SECRET_KEY",
76
+ "NODE_ENV",
77
+ "PYTHONPATH",
78
+ "TMPDIR",
79
+ "PWD",
80
+ "SHELL",
81
+ }
82
+ )
83
+
84
+
85
+ # ── Key normalization ─────────────────────────────────────────────
86
+
87
+
88
+ def normalize_endpoint(path: str) -> str:
89
+ """Collapse path params so producer and consumer URLs join.
90
+
91
+ ``/api/v1/articles/{id}/`` · `` `/api/v1/articles/${x}/` `` · ``:id`` all
92
+ normalize to ``/api/v1/articles/{}``. Strips one trailing slash.
93
+ """
94
+ path = path.strip().strip("`\"'")
95
+ # Drop a leading scheme://host so consumer absolute URLs match relative paths.
96
+ path = re.sub(r"^[a-z]+://[^/]+", "", path)
97
+ path = re.sub(r"\$\{[^}]*\}|\{[^}]*\}|:[^/]+", "{}", path)
98
+ path = re.sub(r"/+", "/", path)
99
+ return path.rstrip("/") or "/"
100
+
101
+
102
+ def normalize_key(kind: str, key: str) -> str:
103
+ if kind == "endpoint":
104
+ # key may be "METHOD /path"; normalize only the path part.
105
+ method, _, rest = key.partition(" ")
106
+ if rest:
107
+ return normalize_endpoint(rest)
108
+ return normalize_endpoint(key)
109
+ return key.strip()
110
+
111
+
112
+ # ── Canonical symbol strings (docs/SYMBOL_FORMAT.md) ──────────────
113
+ #
114
+ # {scheme}:{manager}:{package}:{version}:{descriptor} — five colon-separated
115
+ # fields; descriptor is terminal (parse with maxsplit=4). "." = empty/unknown.
116
+ # Providers are qualified with the repo's PackageIdentity; consumers emit
117
+ # unresolved external symbols (".") except package imports, which name their
118
+ # package at the reference site.
119
+
120
+ _SYMBOL_SCHEME = {
121
+ "endpoint": "http",
122
+ "env_var": "env",
123
+ "package": "pkg",
124
+ "service": "svc",
125
+ }
126
+
127
+ _MANAGER_BY_FRAMEWORK = {"npm": "npm", "python": "pypi"}
128
+
129
+
130
+ def _esc(field: str) -> str:
131
+ return field.replace("%", "%25").replace(":", "%3A")
132
+
133
+
134
+ def canonical_symbol(binding: ParsedBinding, identity=None) -> str:
135
+ """Build the canonical symbol string for ``binding``.
136
+
137
+ ``identity`` is the repo's ``package_map.PackageIdentity``; only provider
138
+ bindings use it (consumers stay unresolved by design).
139
+ """
140
+ scheme = _SYMBOL_SCHEME.get(binding.kind, binding.kind)
141
+ provider = binding.role == "provider"
142
+
143
+ if binding.kind == "package":
144
+ manager = _MANAGER_BY_FRAMEWORK.get(binding.framework or "", ".")
145
+ package = binding.key_norm
146
+ version = identity.version if provider and identity else "."
147
+ descriptor = "."
148
+ else:
149
+ manager = identity.manager if provider and identity else "."
150
+ package = identity.name if provider and identity else "."
151
+ version = identity.version if provider and identity else "."
152
+ if binding.kind == "endpoint":
153
+ method, _, rest = binding.key.strip().partition(" ")
154
+ method = method.upper() if rest else "*"
155
+ descriptor = f"{method} {binding.key_norm}"
156
+ elif binding.kind == "service":
157
+ descriptor = f"{binding.sub_kind or '.'}/{binding.key_norm}"
158
+ else:
159
+ descriptor = binding.key_norm
160
+
161
+ prefix = ":".join(_esc(f) for f in (scheme, manager, package, version))
162
+ return f"{prefix}:{descriptor}"
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class ParsedSymbol:
167
+ scheme: str
168
+ manager: str
169
+ package: str
170
+ version: str
171
+ descriptor: str
172
+
173
+
174
+ def _unesc(field: str) -> str:
175
+ # Inverse of _esc: %3A first, %25 last (so an escaped "%253A" round-trips
176
+ # to the literal "%3A" instead of ":").
177
+ return field.replace("%3A", ":").replace("%25", "%")
178
+
179
+
180
+ def parse_symbol(symbol: str) -> ParsedSymbol | None:
181
+ """Split a canonical symbol string into its five fields.
182
+
183
+ The descriptor is terminal and may contain colons, so split with
184
+ ``maxsplit=4``; only the first four fields are percent-decoded
185
+ (descriptors are never encoded). Returns None on a malformed string.
186
+ """
187
+ parts = symbol.split(":", 4)
188
+ if len(parts) != 5:
189
+ return None
190
+ scheme, manager, package, version = (_unesc(p) for p in parts[:4])
191
+ return ParsedSymbol(scheme, manager, package, version, parts[4])
192
+
193
+
194
+ def _binding(kind, key, role, file, *, sub_kind=None, **kw) -> ParsedBinding:
195
+ key_norm = normalize_key(kind, key)
196
+ generic = kind == "env_var" and key in GENERIC_ENV
197
+ return ParsedBinding(
198
+ kind=kind,
199
+ key=key,
200
+ key_norm=key_norm,
201
+ role=role,
202
+ file=file,
203
+ sub_kind=sub_kind,
204
+ generic=generic,
205
+ **kw,
206
+ )
207
+
208
+
209
+ # ── Tier A: per-file consumers (regex over source text) ───────────
210
+
211
+ _PY_ENV = (
212
+ # get_string("NAME", ...) and friends from the settings-helper convention.
213
+ re.compile(
214
+ r"\bget_(?:string|bool|int|float|list_of_str)\s*\(\s*[\"']([A-Z][A-Z0-9_]*[A-Z0-9])[\"']"
215
+ ),
216
+ # os.getenv("NAME") / os.environ.get("NAME")
217
+ re.compile(
218
+ r"\bos\.(?:getenv|environ\.get)\s*\(\s*[\"']([A-Z][A-Z0-9_]*[A-Z0-9])[\"']"
219
+ ),
220
+ # os.environ["NAME"]
221
+ re.compile(r"\bos\.environ\s*\[\s*[\"']([A-Z][A-Z0-9_]*[A-Z0-9])[\"']"),
222
+ )
223
+
224
+ _TS_ENV = (
225
+ # process.env.NAME / process.env["NAME"]
226
+ re.compile(
227
+ r"\bprocess\.env(?:\.([A-Z][A-Z0-9_]*[A-Z0-9])|\[\s*[\"']([A-Z][A-Z0-9_]*[A-Z0-9])[\"'])"
228
+ ),
229
+ # env("NAME") runtime accessor
230
+ re.compile(r"\benv\s*\(\s*[\"']([A-Z][A-Z0-9_]*[A-Z0-9])[\"']"),
231
+ )
232
+
233
+ # Shared internal packages.
234
+ _PY_PKG = re.compile(
235
+ r"\b(?:from|import)\s+(mitol\.[a-z0-9_]+)|include\(\s*[\"'](mitol\.[a-z0-9_]+)"
236
+ )
237
+ _TS_PKG = re.compile(r"(?:from|require\()\s*[\"'](@mitodl/[A-Za-z0-9._-]+)")
238
+
239
+ # Endpoint consumer path literals (string/template literals that look like API
240
+ # paths). Conservative: the value must start with "/" and contain an "api/"
241
+ # segment, to keep noise down. Matches both "/api/v1/x" and "/foo/api/x".
242
+ _TS_ENDPOINT = re.compile(r"""[\"'`](/[\w./${}:()@-]*?api/[\w./${}:()@-]*)[\"'`]""")
243
+
244
+ _PY_FRAMEWORK = "django"
245
+
246
+
247
+ # ── Confidence scoring ────────────────────────────────────────────
248
+ #
249
+ # Score starts at 0.5 (neutral). Independent heuristics add or subtract deltas.
250
+ # Final score is clamped to [0.0, 1.0]. Only endpoint consumer bindings are
251
+ # scored; other kinds default to confidence=1.0.
252
+ #
253
+ # File-level heuristics (evaluated at extraction time, no bridge-store access):
254
+ # generated_file -0.8 @generated / DO NOT EDIT / openapi-generator headers
255
+ # load_test_path -0.6 path segment is load_testing, benchmark, perf, k6, locust
256
+ # load_test_framework -0.6 imports k6, locust, artillery, gatling (structural pattern)
257
+ # relative_url -0.5 extracted URL has no scheme (no http:// or https://)
258
+ # same_origin_helper -0.3 relative import (./...) to csrf/fetch/api-client file
259
+ # explicit_hostname +0.3 extracted URL has scheme + host (absolute URL)
260
+ # client_directory +0.2 path segment is clients, integrations, services, api-client
261
+ #
262
+ # Store-level heuristics (applied later in bridge.py by adjust_confidence).
263
+ # The cross-repo half of each comes from OTHER repos' Stage 1 symbol table
264
+ # (RepoSymbol `exported` rows, docs/SYMBOL_TABLE.md) — the deduplicated
265
+ # artifact those repos' own writes already produced, not a re-derivation
266
+ # from raw bindings:
267
+ # self_provided_key -0.5 consumer repo also provides same key_norm
268
+ # known_provider_pkg +0.3 imported package matches a provider package slug from
269
+ # a different repo's Stage 1 symbol table
270
+
271
+ _GENERATED_HEADERS = re.compile(
272
+ r"@generated|Code generated by|DO NOT EDIT|openapi-generator",
273
+ re.IGNORECASE,
274
+ )
275
+
276
+ _LOAD_TEST_PATH_SEGMENTS = frozenset(
277
+ ["load_testing", "benchmark", "perf", "k6", "locust"]
278
+ )
279
+
280
+ # Load-test framework import patterns (name-agnostic structural detection).
281
+ # Matches: import ... from 'k6', import ... from 'k6/http', require('locust'),
282
+ # import { ... } from 'artillery', etc.
283
+ _LOAD_TEST_FRAMEWORK = re.compile(
284
+ r"""(?:from|require\()\s*[\"'](?:k6(?:/[^\"']*)?|locust|artillery|gatling)[\"']"""
285
+ )
286
+
287
+ _HAS_SCHEME = re.compile(r"^[a-z][a-z0-9+\-.]*://")
288
+
289
+ # Same-origin helper: relative import (starts with ./ or ../) to a file whose
290
+ # basename suggests a CSRF/fetch/api-client wrapper.
291
+ _SAME_ORIGIN_RELATIVE = re.compile(
292
+ r"""(?:from|require\()\s*[\"'](\.\.?/[^\"']*?(?:csrf|fetch|api[-_]?client|http[-_]?client)[^\"']*)[\"']""",
293
+ re.IGNORECASE,
294
+ )
295
+
296
+ _CLIENT_PATH_SEGMENTS = re.compile(
297
+ r"(?:^|/)(?:clients|integrations|services|api[-_]client)(?:/|$)"
298
+ )
299
+
300
+
301
+ def _compute_file_confidence(file: str, content: str, url: str) -> float:
302
+ """Score an endpoint consumer binding using file-level heuristics only.
303
+
304
+ Returns a value in [0.0, 1.0]. Store-dependent adjustments
305
+ (self_provided_key, known_provider_package) are applied later by
306
+ ``adjust_confidence`` in bridge.py after the bridge store is queried.
307
+ """
308
+ score = 0.5
309
+
310
+ # Penalties
311
+ if _GENERATED_HEADERS.search(content):
312
+ score -= 0.8
313
+
314
+ parts = set(file.replace("\\", "/").split("/"))
315
+ if parts & _LOAD_TEST_PATH_SEGMENTS:
316
+ score -= 0.6
317
+
318
+ if _LOAD_TEST_FRAMEWORK.search(content):
319
+ score -= 0.6
320
+
321
+ if not _HAS_SCHEME.match(url):
322
+ score -= 0.5
323
+
324
+ if _SAME_ORIGIN_RELATIVE.search(content):
325
+ score -= 0.3
326
+
327
+ # Boosts
328
+ if _HAS_SCHEME.match(url):
329
+ score += 0.3
330
+
331
+ file_lower = "/" + file.lower().replace("\\", "/")
332
+ if _CLIENT_PATH_SEGMENTS.search(file_lower):
333
+ score += 0.2
334
+
335
+ return max(0.0, min(1.0, score))
336
+
337
+
338
+ def adjust_confidence(
339
+ binding: "ParsedBinding",
340
+ *,
341
+ consumer_repo: str,
342
+ provider_keys: "frozenset[tuple[str, str]]",
343
+ has_known_provider_package: bool = False,
344
+ ) -> "ParsedBinding":
345
+ """Apply store-dependent confidence adjustments in-place and return the binding.
346
+
347
+ Only adjusts endpoint consumer bindings; all others are returned untouched.
348
+
349
+ ``provider_keys`` — set of ``(repo, key_norm)`` pairs for ALL
350
+ provider records, including rows where
351
+ repo==consumer_repo (so the self_provided_key
352
+ penalty fires when the consumer repo also
353
+ provides the same key_norm).
354
+ ``has_known_provider_package``— True when the consumer file is known to import
355
+ a package tracked as a provider in a different
356
+ repo in the bridge store (derived from
357
+ co-located package consumer bindings in the
358
+ same file).
359
+ """
360
+ if binding.kind != "endpoint" or binding.role != "consumer":
361
+ return binding
362
+
363
+ score = binding.confidence
364
+
365
+ if (consumer_repo, binding.key_norm) in provider_keys:
366
+ score -= 0.5
367
+
368
+ if has_known_provider_package:
369
+ score += 0.3
370
+
371
+ binding.confidence = max(0.0, min(1.0, score))
372
+ return binding
373
+
374
+
375
+ def extract_file_bindings(text: str, language: str, file: str) -> list[ParsedBinding]:
376
+ """Tier A — consumer bindings from one source file's text.
377
+
378
+ ``symbol_id`` is left None here; the indexer fills it by line containment.
379
+
380
+ Endpoint consumer bindings receive a ``confidence`` score computed from
381
+ file-level heuristics (``_compute_file_confidence``). Store-dependent
382
+ adjustments (self_provided_key, known_provider_package) are applied later
383
+ by ``adjust_confidence`` in bridge.py.
384
+
385
+ All bindings are emitted regardless of their confidence score; the
386
+ ``min_confidence`` filter in ``cross_repo_edges`` / ``build_graph`` decides
387
+ which ones become graph edges.
388
+ """
389
+ out: list[ParsedBinding] = []
390
+ if language == "python":
391
+ for pat in _PY_ENV:
392
+ for m in pat.finditer(text):
393
+ name = m.group(1)
394
+ out.append(
395
+ _binding(
396
+ "env_var",
397
+ name,
398
+ "consumer",
399
+ file,
400
+ line=_line_of(text, m.start()),
401
+ language=language,
402
+ framework=_PY_FRAMEWORK,
403
+ )
404
+ )
405
+ for m in _PY_PKG.finditer(text):
406
+ pkg = m.group(1) or m.group(2)
407
+ out.append(
408
+ _binding(
409
+ "package",
410
+ pkg,
411
+ "consumer",
412
+ file,
413
+ line=_line_of(text, m.start()),
414
+ language=language,
415
+ framework="python",
416
+ )
417
+ )
418
+ elif language in ("typescript", "javascript"):
419
+ for pat in _TS_ENV:
420
+ for m in pat.finditer(text):
421
+ name = next(g for g in m.groups() if g)
422
+ out.append(
423
+ _binding(
424
+ "env_var",
425
+ name,
426
+ "consumer",
427
+ file,
428
+ line=_line_of(text, m.start()),
429
+ language=language,
430
+ framework="nextjs",
431
+ )
432
+ )
433
+ for m in _TS_PKG.finditer(text):
434
+ out.append(
435
+ _binding(
436
+ "package",
437
+ m.group(1),
438
+ "consumer",
439
+ file,
440
+ line=_line_of(text, m.start()),
441
+ language=language,
442
+ framework="npm",
443
+ )
444
+ )
445
+ for m in _TS_ENDPOINT.finditer(text):
446
+ raw = m.group(1)
447
+ conf = _compute_file_confidence(file, text, raw)
448
+ b = _binding(
449
+ "endpoint",
450
+ raw,
451
+ "consumer",
452
+ file,
453
+ line=_line_of(text, m.start()),
454
+ language=language,
455
+ framework="nextjs",
456
+ )
457
+ b.confidence = conf
458
+ out.append(b)
459
+ return out
460
+
461
+
462
+ def _line_of(text: str, offset: int) -> int:
463
+ return text.count("\n", 0, offset) + 1
464
+
465
+
466
+ # ── Tier B: repo-level providers (well-known files) ───────────────
467
+
468
+ _SKIP_DIRS = {
469
+ ".git",
470
+ "node_modules",
471
+ ".venv",
472
+ "venv",
473
+ "__pycache__",
474
+ "dist",
475
+ "build",
476
+ ".mypy_cache",
477
+ ".pytest_cache",
478
+ ".ruff_cache",
479
+ ".tox",
480
+ ".next",
481
+ }
482
+
483
+
484
+ def extract_repo_bindings(base: Path, repo: str) -> list[ParsedBinding]:
485
+ """Tier B — provider bindings from a repo's well-known files.
486
+
487
+ Each sub-extractor is best-effort; a malformed file yields nothing rather
488
+ than aborting. ``repo`` is the canonical HTTPS slug (used to self-join
489
+ service deploy targets).
490
+ """
491
+ out: list[ParsedBinding] = []
492
+ for path in _walk(base):
493
+ rel = _rel(path, base)
494
+ name = path.name
495
+ try:
496
+ if name.startswith("Pulumi.") and path.suffix in (".yaml", ".yml"):
497
+ out.extend(_pulumi_env_vars(path, rel))
498
+ elif name == "__main__.py":
499
+ out.extend(_service_anchors(path, rel))
500
+ elif name == "package.json":
501
+ out.extend(_package_provider(path, rel))
502
+ elif _looks_like_openapi(path):
503
+ out.extend(_openapi_endpoints(path, rel))
504
+ except Exception: # noqa: BLE001 — one bad file must not abort the repo
505
+ continue
506
+ return out
507
+
508
+
509
+ def _walk(base: Path):
510
+ for path in base.rglob("*"):
511
+ if path.is_dir() or any(part in _SKIP_DIRS for part in path.parts):
512
+ continue
513
+ yield path
514
+
515
+
516
+ def _rel(path: Path, base: Path) -> str:
517
+ try:
518
+ return path.resolve().relative_to(base.resolve()).as_posix()
519
+ except ValueError:
520
+ return path.name
521
+
522
+
523
+ def _looks_like_openapi(path: Path) -> bool:
524
+ if path.suffix not in (".json", ".yaml", ".yml"):
525
+ return False
526
+ if path.name.lower() in (
527
+ "openapi.json",
528
+ "openapi.yaml",
529
+ "openapi.yml",
530
+ "schema.yaml",
531
+ ):
532
+ return True
533
+ return "openapi" in path.parts
534
+
535
+
536
+ def _pulumi_env_vars(path: Path, rel: str) -> list[ParsedBinding]:
537
+ """Env-var providers: keys of any `<project>:env_vars` map in a stack config."""
538
+ import yaml
539
+
540
+ data = yaml.safe_load(path.read_text()) or {}
541
+ config = data.get("config", data) if isinstance(data, dict) else {}
542
+ out: list[ParsedBinding] = []
543
+ for cfg_key, value in (config or {}).items():
544
+ if cfg_key.endswith(":env_vars") and isinstance(value, dict):
545
+ for env_name in value:
546
+ if isinstance(env_name, str):
547
+ out.append(
548
+ _binding(
549
+ "env_var", env_name, "provider", rel, framework="pulumi"
550
+ )
551
+ )
552
+ return out
553
+
554
+
555
+ _SVC_REPO = re.compile(
556
+ r"github\.get_repository\(\s*full_name\s*=\s*[\"']([^\"']+)[\"']"
557
+ )
558
+ _SVC_IMAGE = re.compile(r"application_image_repository\s*=\s*[\"']([^\"']+)[\"']")
559
+ _SVC_NAME = re.compile(r"application_name\s*=\s*[\"']([^\"']+)[\"']")
560
+
561
+
562
+ def _service_anchors(path: Path, rel: str) -> list[ParsedBinding]:
563
+ """Service/deploy anchors from an ol-infrastructure application __main__.py.
564
+
565
+ The repo full_name is normalized to its canonical HTTPS URI so it joins
566
+ against the deployed repo's own slug (kind=service, key_norm=<that URI>).
567
+ sub_kind ("repo" | "image" | "name") distinguishes anchor types without
568
+ packing the variant into the key string — joining on (kind, key_norm) works
569
+ even when the consumer records only the bare URI/name.
570
+ """
571
+ text = path.read_text()
572
+ out: list[ParsedBinding] = []
573
+ for m in _SVC_REPO.finditer(text):
574
+ raw = m.group(1)
575
+ uri = f"https://github.com/{raw}" if "/" in raw and "://" not in raw else raw
576
+ out.append(
577
+ _binding(
578
+ "service",
579
+ uri,
580
+ "provider",
581
+ rel,
582
+ sub_kind="repo",
583
+ line=_line_of(text, m.start()),
584
+ framework="pulumi",
585
+ )
586
+ )
587
+ for m in _SVC_IMAGE.finditer(text):
588
+ out.append(
589
+ _binding(
590
+ "service",
591
+ m.group(1),
592
+ "provider",
593
+ rel,
594
+ sub_kind="image",
595
+ line=_line_of(text, m.start()),
596
+ framework="pulumi",
597
+ )
598
+ )
599
+ for m in _SVC_NAME.finditer(text):
600
+ out.append(
601
+ _binding(
602
+ "service",
603
+ m.group(1),
604
+ "provider",
605
+ rel,
606
+ sub_kind="name",
607
+ line=_line_of(text, m.start()),
608
+ framework="pulumi",
609
+ )
610
+ )
611
+ return out
612
+
613
+
614
+ def _package_provider(path: Path, rel: str) -> list[ParsedBinding]:
615
+ """A repo that publishes an @mitodl/* package provides it."""
616
+ data = json.loads(path.read_text())
617
+ name = data.get("name")
618
+ if isinstance(name, str) and name.startswith("@mitodl/"):
619
+ return [_binding("package", name, "provider", rel, framework="npm")]
620
+ return []
621
+
622
+
623
+ _HTTP_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
624
+
625
+
626
+ def _openapi_endpoints(path: Path, rel: str) -> list[ParsedBinding]:
627
+ """Endpoint providers: every path+method in a drf-spectacular OpenAPI spec."""
628
+ import yaml
629
+
630
+ raw = path.read_text()
631
+ data = json.loads(raw) if path.suffix == ".json" else yaml.safe_load(raw)
632
+ if not isinstance(data, dict) or "paths" not in data:
633
+ return []
634
+ out: list[ParsedBinding] = []
635
+ for url, methods in (data.get("paths") or {}).items():
636
+ if not isinstance(methods, dict):
637
+ continue
638
+ for method in methods:
639
+ if method.lower() in _HTTP_METHODS:
640
+ key = f"{method.upper()} {url}"
641
+ out.append(_binding("endpoint", key, "provider", rel, framework="drf"))
642
+ return out