bingo-light 2.1.2 → 2.2.0

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,268 @@
1
+ """
2
+ bingo_core.dep_fork — Fork-as-dependency tracking for npm projects.
3
+
4
+ Scans package.json for git-based dependencies (github:user/repo, git+https://,
5
+ etc.), detects drift from upstream releases, and updates fork refs.
6
+
7
+ Python 3.8+ stdlib only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import tempfile
16
+ import urllib.request
17
+ import urllib.error
18
+ from typing import Any, Dict, List, Optional
19
+
20
+
21
+ # Git dependency patterns in package.json
22
+ _GIT_DEP_PATTERNS = [
23
+ re.compile(r'^github:(.+)$'), # github:user/repo#ref
24
+ re.compile(r'^git\+https?://github\.com/(.+?)(?:\.git)?(?:#(.+))?$'), # git+https://
25
+ re.compile(r'^git\+ssh://git@github\.com[:/](.+?)(?:\.git)?(?:#(.+))?$'), # git+ssh://
26
+ re.compile(r'^([a-zA-Z0-9_-]+/[a-zA-Z0-9._-]+)(?:#(.+))?$'), # user/repo shorthand
27
+ ]
28
+
29
+
30
+ class ForkTracker:
31
+ """Track git-based dependencies in npm projects."""
32
+
33
+ def __init__(self, cwd: str = "."):
34
+ self.cwd = os.path.abspath(cwd)
35
+
36
+ def _read_package_json(self) -> Optional[dict]:
37
+ pj = os.path.join(self.cwd, "package.json")
38
+ if not os.path.isfile(pj):
39
+ return None
40
+ try:
41
+ with open(pj) as f:
42
+ return json.load(f)
43
+ except (json.JSONDecodeError, IOError):
44
+ return None
45
+
46
+ def _write_package_json(self, data: dict) -> None:
47
+ pj = os.path.join(self.cwd, "package.json")
48
+ fd, tmp = tempfile.mkstemp(suffix=".tmp", dir=self.cwd)
49
+ try:
50
+ with os.fdopen(fd, "w") as f:
51
+ json.dump(data, f, indent=2)
52
+ f.write("\n")
53
+ os.replace(tmp, pj)
54
+ except Exception:
55
+ try:
56
+ os.unlink(tmp)
57
+ except FileNotFoundError:
58
+ pass
59
+ raise
60
+
61
+ def _parse_git_dep(self, value: str) -> Optional[dict]:
62
+ """Parse a git-based dependency value. Returns {repo, ref, protocol} or None."""
63
+ for pat in _GIT_DEP_PATTERNS:
64
+ m = pat.match(value)
65
+ if m:
66
+ groups = m.groups()
67
+ repo_part = groups[0]
68
+ ref = groups[1] if len(groups) > 1 and groups[1] else ""
69
+
70
+ # Handle github:user/repo#ref
71
+ if "#" in repo_part:
72
+ repo_part, ref = repo_part.split("#", 1)
73
+
74
+ # Normalize repo
75
+ repo_part = repo_part.rstrip("/")
76
+
77
+ return {"repo": repo_part, "ref": ref, "raw": value}
78
+ return None
79
+
80
+ def _fetch_json(self, url: str) -> Optional[dict]:
81
+ """Fetch JSON from URL with timeout and error handling."""
82
+ headers = {"Accept": "application/json", "User-Agent": "bingo-light"}
83
+ # Use GITHUB_TOKEN if available
84
+ token = os.environ.get("GITHUB_TOKEN", "")
85
+ if token and "github" in url:
86
+ headers["Authorization"] = f"token {token}"
87
+
88
+ req = urllib.request.Request(url, headers=headers)
89
+ try:
90
+ with urllib.request.urlopen(req, timeout=15) as resp:
91
+ # Check GitHub rate limit
92
+ remaining = resp.headers.get("X-RateLimit-Remaining", "")
93
+ if remaining and int(remaining) <= 1:
94
+ import sys
95
+ print(
96
+ "warning: GitHub API rate limit nearly exhausted. "
97
+ "Set GITHUB_TOKEN env var for higher limits.",
98
+ file=sys.stderr,
99
+ )
100
+ return json.loads(resp.read().decode())
101
+ except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError,
102
+ OSError, ValueError):
103
+ return None
104
+
105
+ @staticmethod
106
+ def _is_sha_like(ref: str) -> bool:
107
+ """Check if a ref looks like a commit SHA (hex string, 7+ chars)."""
108
+ return len(ref) >= 7 and all(c in "0123456789abcdef" for c in ref.lower())
109
+
110
+ def fork_list(self) -> dict:
111
+ """List all git-based dependencies in package.json.
112
+
113
+ Returns {"ok": True, "forks": [...], "count": N}
114
+ """
115
+ pj = self._read_package_json()
116
+ if pj is None:
117
+ return {"ok": True, "forks": [], "count": 0, "note": "No package.json"}
118
+
119
+ forks: List[dict] = []
120
+ for dep_type in ("dependencies", "devDependencies"):
121
+ deps = pj.get(dep_type, {})
122
+ for name, value in deps.items():
123
+ if not isinstance(value, str):
124
+ continue
125
+ parsed = self._parse_git_dep(value)
126
+ if parsed:
127
+ forks.append({
128
+ "package": name,
129
+ "repo": parsed["repo"],
130
+ "ref": parsed["ref"],
131
+ "dep_type": dep_type,
132
+ "raw": parsed["raw"],
133
+ })
134
+
135
+ return {"ok": True, "forks": forks, "count": len(forks)}
136
+
137
+ def fork_check(self) -> dict:
138
+ """Check fork drift against upstream npm releases and GitHub commits.
139
+
140
+ Returns {"ok": True, "forks": [...], "drifted": N}
141
+ """
142
+ list_result = self.fork_list()
143
+ forks = list_result.get("forks", [])
144
+ if not forks:
145
+ return {"ok": True, "forks": [], "drifted": 0}
146
+
147
+ results: List[dict] = []
148
+ drifted = 0
149
+
150
+ for fork in forks:
151
+ entry: Dict[str, Any] = {
152
+ "package": fork["package"],
153
+ "repo": fork["repo"],
154
+ "ref": fork["ref"],
155
+ }
156
+
157
+ # Check npm registry for latest published version
158
+ npm_url = f"https://registry.npmjs.org/{fork['package']}/latest"
159
+ npm_data = self._fetch_json(npm_url)
160
+ if npm_data:
161
+ entry["npm_latest"] = npm_data.get("version", "")
162
+ else:
163
+ entry["npm_latest"] = ""
164
+
165
+ # Check GitHub for latest commit on default branch
166
+ gh_url = f"https://api.github.com/repos/{fork['repo']}/commits?per_page=1"
167
+ gh_data = self._fetch_json(gh_url)
168
+ if gh_data and isinstance(gh_data, list) and len(gh_data) > 0:
169
+ latest_sha = gh_data[0].get("sha", "")[:12]
170
+ entry["latest_commit"] = latest_sha
171
+ entry["commit_date"] = gh_data[0].get("commit", {}).get(
172
+ "committer", {}
173
+ ).get("date", "")
174
+
175
+ # Compare ref — handle SHA, tag, and branch refs
176
+ ref = fork["ref"]
177
+ if not ref:
178
+ entry["status"] = "no_ref_pinned"
179
+ elif self._is_sha_like(ref):
180
+ # ref looks like a commit SHA — compare directly
181
+ if latest_sha.startswith(ref[:8]) or ref.startswith(latest_sha[:8]):
182
+ entry["status"] = "up_to_date"
183
+ else:
184
+ entry["status"] = "drifted"
185
+ drifted += 1
186
+ else:
187
+ # ref is a tag or branch name — resolve via GitHub API
188
+ ref_url = f"https://api.github.com/repos/{fork['repo']}/git/ref/tags/{ref}"
189
+ ref_data = self._fetch_json(ref_url)
190
+ if not ref_data:
191
+ # Try as branch
192
+ ref_url = f"https://api.github.com/repos/{fork['repo']}/git/ref/heads/{ref}"
193
+ ref_data = self._fetch_json(ref_url)
194
+ if ref_data and isinstance(ref_data, dict):
195
+ ref_sha = ref_data.get("object", {}).get("sha", "")[:12]
196
+ if ref_sha and (latest_sha.startswith(ref_sha[:8]) or ref_sha.startswith(latest_sha[:8])):
197
+ entry["status"] = "up_to_date"
198
+ else:
199
+ entry["status"] = "drifted"
200
+ entry["ref_resolved"] = ref_sha
201
+ drifted += 1
202
+ else:
203
+ # Can't resolve ref — report as unknown
204
+ entry["status"] = "unknown"
205
+ entry["note"] = f"Cannot resolve ref '{ref}'"
206
+ else:
207
+ entry["latest_commit"] = ""
208
+ entry["status"] = "unknown"
209
+
210
+ results.append(entry)
211
+
212
+ return {"ok": True, "forks": results, "drifted": drifted}
213
+
214
+ def fork_sync(self, package: str) -> dict:
215
+ """Update a fork dependency ref to the latest commit.
216
+
217
+ Returns {"ok": True, "package": ..., "old_ref": ..., "new_ref": ...}
218
+ """
219
+ pj = self._read_package_json()
220
+ if pj is None:
221
+ return {"ok": False, "error": "No package.json found"}
222
+
223
+ # Find the package
224
+ found_type = None
225
+ old_value = None
226
+ for dep_type in ("dependencies", "devDependencies"):
227
+ deps = pj.get(dep_type, {})
228
+ if package in deps:
229
+ found_type = dep_type
230
+ old_value = deps[package]
231
+ break
232
+
233
+ if not found_type or not isinstance(old_value, str):
234
+ return {"ok": False, "error": f"Package '{package}' not found in dependencies"}
235
+
236
+ parsed = self._parse_git_dep(old_value)
237
+ if not parsed:
238
+ return {"ok": False, "error": f"'{package}' is not a git-based dependency"}
239
+
240
+ old_ref = parsed["ref"]
241
+
242
+ # Fetch latest commit from GitHub
243
+ gh_url = f"https://api.github.com/repos/{parsed['repo']}/commits?per_page=1"
244
+ gh_data = self._fetch_json(gh_url)
245
+ if not gh_data or not isinstance(gh_data, list) or len(gh_data) == 0:
246
+ return {"ok": False, "error": f"Cannot fetch latest commit for {parsed['repo']}"}
247
+
248
+ new_ref = gh_data[0].get("sha", "")[:12]
249
+ if not new_ref:
250
+ return {"ok": False, "error": "Empty commit SHA from GitHub"}
251
+
252
+ # Update the dependency value
253
+ if "#" in old_value:
254
+ new_value = old_value.rsplit("#", 1)[0] + "#" + new_ref
255
+ else:
256
+ new_value = old_value + "#" + new_ref
257
+
258
+ pj[found_type][package] = new_value
259
+ self._write_package_json(pj)
260
+
261
+ return {
262
+ "ok": True,
263
+ "package": package,
264
+ "old_ref": old_ref,
265
+ "new_ref": new_ref,
266
+ "old_value": old_value,
267
+ "new_value": new_value,
268
+ }
@@ -32,6 +32,7 @@ class ConflictInfo:
32
32
  theirs: str = ""
33
33
  conflict_count: int = 0
34
34
  merge_hint: str = ""
35
+ semantic_class: str = "logic"
35
36
 
36
37
  def to_dict(self) -> dict:
37
38
  return asdict(self)