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,85 @@
1
+ """
2
+ bingo_core.semantic — semantic classification of conflict regions.
3
+
4
+ Given ours/theirs text for a single conflict region, return one of:
5
+ "whitespace" — regions differ only in whitespace
6
+ "import_reorder" — both regions are only import statements,
7
+ same set of imports, just reordered
8
+ "signature_change" — function/method signature changed (params
9
+ added, removed, or renamed) but name same
10
+ "logic" — default; real logic change requiring human
11
+ or AI reasoning
12
+
13
+ The classifier is intentionally conservative: when unsure, return
14
+ "logic" so callers treat it as a real conflict.
15
+
16
+ Python 3.8+ stdlib only.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ # Matches Python "import X" or "from X import Y" lines.
24
+ _IMPORT_RE_PY = re.compile(r"^\s*(?:import|from)\s+\S")
25
+ # Matches JS/TS imports and CommonJS require.
26
+ _IMPORT_RE_JS = re.compile(r"^\s*(?:import\s|const\s+\w+\s*=\s*require\()")
27
+
28
+ # Function signature captures: (name, params) for Python def and JS function.
29
+ _FN_SIG_PY = re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)")
30
+ _FN_SIG_JS = re.compile(r"^\s*(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)")
31
+
32
+
33
+ def classify_conflict(ours: str, theirs: str, file: str = "") -> str:
34
+ """Classify a conflict region as whitespace / import_reorder /
35
+ signature_change / logic.
36
+
37
+ `file` is accepted for future extension (language-specific rules)
38
+ but not currently used.
39
+ """
40
+ if _is_whitespace_only(ours, theirs):
41
+ return "whitespace"
42
+ if _is_import_reorder(ours, theirs):
43
+ return "import_reorder"
44
+ if _is_signature_change(ours, theirs):
45
+ return "signature_change"
46
+ return "logic"
47
+
48
+
49
+ def _is_whitespace_only(a: str, b: str) -> bool:
50
+ """True if the only difference is whitespace (tabs, spaces, newlines).
51
+
52
+ All whitespace is removed for comparison — matching git's
53
+ `diff --ignore-all-space` semantics.
54
+ """
55
+ na = "".join(a.split())
56
+ nb = "".join(b.split())
57
+ return na == nb and na != ""
58
+
59
+
60
+ def _is_import_reorder(a: str, b: str) -> bool:
61
+ """True if both sides contain only import statements, with the same
62
+ set of imports (just reordered)."""
63
+ a_lines = [ln for ln in a.splitlines() if ln.strip()]
64
+ b_lines = [ln for ln in b.splitlines() if ln.strip()]
65
+ if not a_lines or not b_lines:
66
+ return False
67
+ for line in a_lines + b_lines:
68
+ if not (_IMPORT_RE_PY.match(line) or _IMPORT_RE_JS.match(line)):
69
+ return False
70
+ # Compare as sorted sets: order-insensitive match.
71
+ return sorted(a_lines) == sorted(b_lines)
72
+
73
+
74
+ def _is_signature_change(a: str, b: str) -> bool:
75
+ """True if both sides contain a function definition for the same
76
+ name but with different parameter lists."""
77
+ for pattern in (_FN_SIG_PY, _FN_SIG_JS):
78
+ a_match = pattern.search(a)
79
+ b_match = pattern.search(b)
80
+ if a_match and b_match:
81
+ same_name = a_match.group(1) == b_match.group(1)
82
+ different_params = a_match.group(2).strip() != b_match.group(2).strip()
83
+ if same_name and different_params:
84
+ return True
85
+ return False
@@ -184,7 +184,7 @@ class State:
184
184
  for t in (v.strip() for v in value.split(",")):
185
185
  if t and t not in tags_list:
186
186
  tags_list.append(t)
187
- elif key in ("reason", "expires", "upstream_pr", "status"):
187
+ elif key in ("reason", "expires", "upstream_pr", "status", "owner"):
188
188
  p[key] = value
189
189
  self._save_metadata(data)
190
190
 
@@ -0,0 +1,170 @@
1
+ """
2
+ bingo_core.team — Team collaboration state (.bingo/team.json).
3
+
4
+ Manages patch locks and team membership for multi-person fork maintenance.
5
+ Advisory locking: prevents accidental concurrent edits, not a security boundary.
6
+
7
+ Storage:
8
+ .bingo/team.json
9
+ {
10
+ "locks": {
11
+ "<patch_name>": {
12
+ "owner": "<user>",
13
+ "locked_at": "ISO8601",
14
+ "reason": ""
15
+ }
16
+ }
17
+ }
18
+
19
+ Python 3.8+ stdlib only.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import tempfile
27
+ from datetime import datetime, timezone
28
+ from typing import List, Optional
29
+
30
+ from bingo_core import BINGO_DIR
31
+ from bingo_core.exceptions import BingoError
32
+
33
+
34
+ class TeamState:
35
+ """Manages .bingo/team.json for patch locking and team coordination."""
36
+
37
+ def __init__(self, repo_dir: str, git=None):
38
+ self.repo_dir = repo_dir
39
+ self._git = git # optional Git instance for get_user()
40
+ self.bingo_dir = os.path.join(repo_dir, BINGO_DIR)
41
+ self.team_file = os.path.join(self.bingo_dir, "team.json")
42
+
43
+ def _load(self) -> dict:
44
+ """Load team.json, returning empty structure if missing."""
45
+ if not os.path.isfile(self.team_file):
46
+ return {"locks": {}}
47
+ try:
48
+ with open(self.team_file) as f:
49
+ data = json.load(f)
50
+ if "locks" not in data:
51
+ data["locks"] = {}
52
+ return data
53
+ except (json.JSONDecodeError, IOError):
54
+ return {"locks": {}}
55
+
56
+ def _save(self, data: dict) -> None:
57
+ """Atomically write team.json."""
58
+ os.makedirs(self.bingo_dir, exist_ok=True)
59
+ dir_name = os.path.dirname(self.team_file)
60
+ fd, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=dir_name)
61
+ try:
62
+ with os.fdopen(fd, "w") as f:
63
+ json.dump(data, f, indent=2)
64
+ os.replace(tmp_path, self.team_file)
65
+ except Exception:
66
+ try:
67
+ os.unlink(tmp_path)
68
+ except FileNotFoundError:
69
+ pass
70
+ raise
71
+
72
+ def get_user(self) -> str:
73
+ """Detect current user from git config or environment."""
74
+ if self._git:
75
+ for key in ("user.name", "user.email"):
76
+ try:
77
+ val = self._git.run("config", key, check=False)
78
+ if val and val.strip():
79
+ return val.strip()
80
+ except Exception:
81
+ pass
82
+ return os.environ.get("USER", "unknown")
83
+
84
+ def lock(self, patch_name: str, owner: str = "", reason: str = "") -> dict:
85
+ """Lock a patch for exclusive editing.
86
+
87
+ Returns {"ok": True, "patch": ..., "owner": ..., "locked_at": ...}
88
+ Raises BingoError if already locked by another user.
89
+ """
90
+ if not owner:
91
+ owner = self.get_user()
92
+ data = self._load()
93
+ existing = data["locks"].get(patch_name)
94
+ if existing and existing["owner"] != owner:
95
+ raise BingoError(
96
+ f"Patch '{patch_name}' is locked by {existing['owner']} "
97
+ f"(since {existing['locked_at']}). "
98
+ f"They must unlock it first, or use --force."
99
+ )
100
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
101
+ data["locks"][patch_name] = {
102
+ "owner": owner,
103
+ "locked_at": now,
104
+ "reason": reason,
105
+ }
106
+ self._save(data)
107
+ return {
108
+ "ok": True,
109
+ "patch": patch_name,
110
+ "owner": owner,
111
+ "locked_at": now,
112
+ "reason": reason,
113
+ }
114
+
115
+ def unlock(self, patch_name: str, owner: str = "", force: bool = False) -> dict:
116
+ """Unlock a patch.
117
+
118
+ Returns {"ok": True, "patch": ..., "owner": ...}
119
+ Raises BingoError if locked by someone else (unless force=True).
120
+ """
121
+ if not owner:
122
+ owner = self.get_user()
123
+ data = self._load()
124
+ existing = data["locks"].get(patch_name)
125
+ if not existing:
126
+ return {"ok": True, "patch": patch_name, "owner": owner, "was_locked": False}
127
+ if existing["owner"] != owner and not force:
128
+ raise BingoError(
129
+ f"Patch '{patch_name}' is locked by {existing['owner']}. "
130
+ "Use --force to override."
131
+ )
132
+ del data["locks"][patch_name]
133
+ self._save(data)
134
+ return {
135
+ "ok": True,
136
+ "patch": patch_name,
137
+ "owner": owner,
138
+ "was_locked": True,
139
+ "previous_owner": existing["owner"],
140
+ }
141
+
142
+ def get_lock(self, patch_name: str) -> Optional[dict]:
143
+ """Get lock info for a patch, or None if unlocked."""
144
+ data = self._load()
145
+ return data["locks"].get(patch_name)
146
+
147
+ def is_locked_by_other(self, patch_name: str, current_user: str = "") -> bool:
148
+ """Check if a patch is locked by someone other than current_user."""
149
+ if not current_user:
150
+ current_user = self.get_user()
151
+ lock = self.get_lock(patch_name)
152
+ if not lock:
153
+ return False
154
+ return lock["owner"] != current_user
155
+
156
+ def list_locks(self) -> List[dict]:
157
+ """List all active locks.
158
+
159
+ Returns list of {"patch": ..., "owner": ..., "locked_at": ..., "reason": ...}
160
+ """
161
+ data = self._load()
162
+ result = []
163
+ for patch_name, info in data["locks"].items():
164
+ result.append({
165
+ "patch": patch_name,
166
+ "owner": info.get("owner", ""),
167
+ "locked_at": info.get("locked_at", ""),
168
+ "reason": info.get("reason", ""),
169
+ })
170
+ return result
@@ -10,11 +10,11 @@ _bingo_light() {
10
10
  local cur prev words cword
11
11
  _init_completion || return
12
12
 
13
- local -r toplevel_commands="init setup patch dep sync status doctor auto-sync log undo diff version help conflict-analyze conflict-resolve config history test workspace smart-sync session"
13
+ local -r toplevel_commands="init setup patch dep sync status doctor auto-sync log undo diff version help conflict-analyze conflict-resolve config history test workspace smart-sync session report"
14
14
  local -r toplevel_aliases="p s st d ws"
15
15
  local -r all_toplevel="${toplevel_commands} ${toplevel_aliases}"
16
16
 
17
- local -r patch_subcommands="new list show edit drop export import reorder squash meta"
17
+ local -r patch_subcommands="new list show edit drop export import reorder squash meta lock unlock check upstream expire stats"
18
18
  local -r patch_aliases="ls add create rm remove"
19
19
  local -r all_patch="${patch_subcommands} ${patch_aliases}"
20
20
 
@@ -39,7 +39,7 @@ _bingo_light() {
39
39
  dep)
40
40
  cmd="dep"
41
41
  ;;
42
- init|setup|doctor|auto-sync|log|undo|version|help|conflict-analyze|conflict-resolve|config|history|test|workspace|ws|smart-sync|session)
42
+ init|setup|doctor|auto-sync|log|undo|version|help|conflict-analyze|conflict-resolve|config|history|test|workspace|ws|smart-sync|session|report)
43
43
  cmd="${words[i]}"
44
44
  ;;
45
45
  *)
@@ -94,10 +94,20 @@ _bingo_light() {
94
94
  return
95
95
  fi
96
96
 
97
+ # Inside "conflict-resolve"
98
+ if [[ "$cmd" == "conflict-resolve" ]]; then
99
+ COMPREPLY=( $(compgen -W "--verify --content-stdin --help -h" -- "$cur") )
100
+ return
101
+ fi
102
+
97
103
  # Inside "dep" -- complete subcommands
98
104
  if [[ "$cmd" == "dep" ]]; then
99
105
  if [[ -z "$subcmd" ]]; then
100
- COMPREPLY=( $(compgen -W "patch apply sync status list drop" -- "$cur") )
106
+ COMPREPLY=( $(compgen -W "patch apply sync status list drop override fork" -- "$cur") )
107
+ elif [[ "$subcmd" == "override" ]]; then
108
+ COMPREPLY=( $(compgen -W "list check add drop" -- "$cur") )
109
+ elif [[ "$subcmd" == "fork" ]]; then
110
+ COMPREPLY=( $(compgen -W "list check sync" -- "$cur") )
101
111
  else
102
112
  COMPREPLY=( $(compgen -W "--help -h" -- "$cur") )
103
113
  fi
@@ -86,6 +86,7 @@ complete -c bingo-light -n __bingo_light_needs_command -a test -d 'Run config
86
86
  complete -c bingo-light -n __bingo_light_needs_command -a workspace -d 'Manage multiple forks'
87
87
  complete -c bingo-light -n __bingo_light_needs_command -a smart-sync -d 'Smart sync with circuit breaker and partial state'
88
88
  complete -c bingo-light -n __bingo_light_needs_command -a session -d 'Manage session memory'
89
+ complete -c bingo-light -n __bingo_light_needs_command -a report -d 'Generate fork health report'
89
90
 
90
91
  # Short aliases
91
92
  complete -c bingo-light -n __bingo_light_needs_command -a p -d 'Alias for patch'
@@ -123,9 +124,11 @@ complete -c bingo-light -n '__bingo_light_using_command version' -s h -l help
123
124
 
124
125
  # ---- conflict-resolve flags ----
125
126
  complete -c bingo-light -n '__bingo_light_using_command conflict-resolve' -s h -l help -d 'Show help'
127
+ complete -c bingo-light -n '__bingo_light_using_command conflict-resolve' -l verify -d 'Run test.command after the final rebase continues'
128
+ complete -c bingo-light -n '__bingo_light_using_command conflict-resolve' -l content-stdin -d 'Read resolved content from stdin'
126
129
 
127
130
  # ---- help: complete with command names ----
128
- complete -c bingo-light -n '__bingo_light_using_command help' -a 'init setup patch dep sync status doctor auto-sync log undo diff version conflict-analyze conflict-resolve config history test workspace smart-sync session' -d 'Command'
131
+ complete -c bingo-light -n '__bingo_light_using_command help' -a 'init setup patch dep sync status doctor auto-sync log undo diff version conflict-analyze conflict-resolve config history test workspace smart-sync session report' -d 'Command'
129
132
 
130
133
  # ---- patch subcommands (also alias "p") ----
131
134
  complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a new -d 'Create a new patch'
@@ -138,6 +141,12 @@ complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a import -d 'I
138
141
  complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a reorder -d 'Reorder the patch stack'
139
142
  complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a squash -d 'Squash two patches into one'
140
143
  complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a meta -d 'Get/set patch metadata'
144
+ complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a lock -d 'Lock a patch for exclusive editing'
145
+ complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a unlock -d 'Unlock a patch'
146
+ complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a check -d 'Check if patches are still needed'
147
+ complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a upstream -d 'Export patch as PR-ready diff'
148
+ complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a expire -d 'List expired patches'
149
+ complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a stats -d 'Show patch health metrics'
141
150
 
142
151
  # Patch short aliases
143
152
  complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a ls -d 'Alias for list'
@@ -163,6 +172,14 @@ complete -c bingo-light -n '__bingo_light_patch_using_subcommand import'
163
172
  complete -c bingo-light -n '__bingo_light_patch_using_subcommand reorder' -s h -l help -d 'Show help'
164
173
  complete -c bingo-light -n '__bingo_light_patch_using_subcommand squash' -s h -l help -d 'Show help'
165
174
  complete -c bingo-light -n '__bingo_light_patch_using_subcommand meta' -s h -l help -d 'Show help'
175
+ complete -c bingo-light -n '__bingo_light_patch_using_subcommand lock' -s h -l help -d 'Show help'
176
+ complete -c bingo-light -n '__bingo_light_patch_using_subcommand lock' -l reason -d 'Reason for locking'
177
+ complete -c bingo-light -n '__bingo_light_patch_using_subcommand unlock' -s h -l help -d 'Show help'
178
+ complete -c bingo-light -n '__bingo_light_patch_using_subcommand unlock' -l force -d 'Force unlock even if locked by someone else'
179
+ complete -c bingo-light -n '__bingo_light_patch_using_subcommand check' -s h -l help -d 'Show help'
180
+ complete -c bingo-light -n '__bingo_light_patch_using_subcommand upstream' -s h -l help -d 'Show help'
181
+ complete -c bingo-light -n '__bingo_light_patch_using_subcommand expire' -s h -l help -d 'Show help'
182
+ complete -c bingo-light -n '__bingo_light_patch_using_subcommand stats' -s h -l help -d 'Show help'
166
183
 
167
184
  # ---- dep subcommands ----
168
185
 
@@ -185,7 +202,9 @@ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a apply -d 'Re-a
185
202
  complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a sync -d 'Re-apply after update, detect conflicts'
186
203
  complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a status -d 'Show patch health'
187
204
  complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a list -d 'List all dependency patches'
188
- complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a drop -d 'Remove a dependency patch'
205
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a drop -d 'Remove a dependency patch'
206
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a override -d 'Manage npm overrides/resolutions'
207
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a fork -d 'Track fork-as-dependency drift'
189
208
 
190
209
  # ---- workspace subcommands (also alias "ws") ----
191
210
 
@@ -219,3 +238,5 @@ complete -c bingo-light -n '__bingo_light_using_command test' -s h -
219
238
  complete -c bingo-light -n '__bingo_light_using_command workspace ws' -s h -l help -d 'Show help'
220
239
  complete -c bingo-light -n '__bingo_light_using_command smart-sync' -s h -l help -d 'Show help'
221
240
  complete -c bingo-light -n '__bingo_light_using_command session' -s h -l help -d 'Show help'
241
+ complete -c bingo-light -n '__bingo_light_using_command report' -s h -l help -d 'Show help'
242
+ complete -c bingo-light -n '__bingo_light_using_command doctor' -l report -d 'Include team, expiry, and dep checks'
@@ -30,6 +30,7 @@ _bingo-light() {
30
30
  'workspace:Manage multiple forks'
31
31
  'smart-sync:Smart sync with circuit breaker and partial state'
32
32
  'session:Manage session memory'
33
+ 'report:Generate fork health report'
33
34
  )
34
35
 
35
36
  local -a toplevel_aliases=(
@@ -51,6 +52,12 @@ _bingo-light() {
51
52
  'reorder:Reorder the patch stack'
52
53
  'squash:Squash two patches into one'
53
54
  'meta:Get/set patch metadata'
55
+ 'lock:Lock a patch for exclusive editing'
56
+ 'unlock:Unlock a patch'
57
+ 'check:Check if patches are still needed'
58
+ 'upstream:Export patch as PR-ready diff'
59
+ 'expire:List expired patches'
60
+ 'stats:Show patch health metrics'
54
61
  )
55
62
 
56
63
  local -a patch_aliases=(
@@ -121,7 +128,7 @@ _bingo-light() {
121
128
  list|ls)
122
129
  _arguments $patch_list_flags
123
130
  ;;
124
- new|add|create|show|edit|drop|rm|remove|export|import|reorder|squash|meta)
131
+ new|add|create|show|edit|drop|rm|remove|export|import|reorder|squash|meta|lock|unlock|check|upstream|expire|stats)
125
132
  _arguments $help_flag
126
133
  ;;
127
134
  esac
@@ -145,6 +152,8 @@ _bingo-light() {
145
152
  'status:Show patch health'
146
153
  'list:List all dependency patches'
147
154
  'drop:Remove a dependency patch'
155
+ 'override:Manage npm overrides/resolutions'
156
+ 'fork:Track fork-as-dependency drift'
148
157
  )
149
158
  _arguments -C \
150
159
  '(- *)'{-h,--help}'[Show help]' \
@@ -175,7 +184,14 @@ _bingo-light() {
175
184
  ;;
176
185
  esac
177
186
  ;;
178
- init|setup|doctor|auto-sync|log|undo|version|conflict-analyze|conflict-resolve|config|history|test|smart-sync|session)
187
+ conflict-resolve)
188
+ _arguments \
189
+ '--verify[Run test.command after the final rebase continues]' \
190
+ '--content-stdin[Read resolved content from stdin]' \
191
+ $help_flag \
192
+ '*:file:_files'
193
+ ;;
194
+ init|setup|doctor|auto-sync|log|undo|version|conflict-analyze|config|history|test|smart-sync|session|report)
179
195
  _arguments $help_flag
180
196
  ;;
181
197
  help)