spidersan 0.20.1 → 0.20.2

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 (310) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/bin/command-registry.d.ts +25 -0
  3. package/dist/bin/command-registry.d.ts.map +1 -0
  4. package/dist/bin/command-registry.js +29 -0
  5. package/dist/bin/command-registry.js.map +1 -0
  6. package/dist/bin/spidersan.d.ts +16 -0
  7. package/dist/bin/spidersan.d.ts.map +1 -0
  8. package/dist/bin/spidersan.js +98 -0
  9. package/dist/bin/spidersan.js.map +1 -0
  10. package/dist/commands/abandon.d.ts +8 -0
  11. package/dist/commands/abandon.d.ts.map +1 -0
  12. package/dist/commands/abandon.js +43 -0
  13. package/dist/commands/abandon.js.map +1 -0
  14. package/dist/commands/cleanup.d.ts +8 -0
  15. package/dist/commands/cleanup.d.ts.map +1 -0
  16. package/dist/commands/cleanup.js +50 -0
  17. package/dist/commands/cleanup.js.map +1 -0
  18. package/dist/commands/config.d.ts +15 -0
  19. package/dist/commands/config.d.ts.map +1 -0
  20. package/dist/commands/config.js +309 -0
  21. package/dist/commands/config.js.map +1 -0
  22. package/dist/commands/conflicts.d.ts +34 -0
  23. package/dist/commands/conflicts.d.ts.map +1 -0
  24. package/dist/commands/conflicts.js +773 -0
  25. package/dist/commands/conflicts.js.map +1 -0
  26. package/dist/commands/context.d.ts +8 -0
  27. package/dist/commands/context.d.ts.map +1 -0
  28. package/dist/commands/context.js +104 -0
  29. package/dist/commands/context.js.map +1 -0
  30. package/dist/commands/cross-conflicts.d.ts +17 -0
  31. package/dist/commands/cross-conflicts.d.ts.map +1 -0
  32. package/dist/commands/cross-conflicts.js +273 -0
  33. package/dist/commands/cross-conflicts.js.map +1 -0
  34. package/dist/commands/depends.d.ts +11 -0
  35. package/dist/commands/depends.d.ts.map +1 -0
  36. package/dist/commands/depends.js +63 -0
  37. package/dist/commands/depends.js.map +1 -0
  38. package/dist/commands/doctor.d.ts +30 -0
  39. package/dist/commands/doctor.d.ts.map +1 -0
  40. package/dist/commands/doctor.js +558 -0
  41. package/dist/commands/doctor.js.map +1 -0
  42. package/dist/commands/git-watch.d.ts +24 -0
  43. package/dist/commands/git-watch.d.ts.map +1 -0
  44. package/dist/commands/git-watch.js +84 -0
  45. package/dist/commands/git-watch.js.map +1 -0
  46. package/dist/commands/github-sync.d.ts +42 -0
  47. package/dist/commands/github-sync.d.ts.map +1 -0
  48. package/dist/commands/github-sync.js +191 -0
  49. package/dist/commands/github-sync.js.map +1 -0
  50. package/dist/commands/index.d.ts +25 -0
  51. package/dist/commands/index.d.ts.map +1 -0
  52. package/dist/commands/index.js +29 -0
  53. package/dist/commands/index.js.map +1 -0
  54. package/dist/commands/init.d.ts +8 -0
  55. package/dist/commands/init.d.ts.map +1 -0
  56. package/dist/commands/init.js +23 -0
  57. package/dist/commands/init.js.map +1 -0
  58. package/dist/commands/list.d.ts +3 -0
  59. package/dist/commands/list.d.ts.map +1 -0
  60. package/dist/commands/list.js +45 -0
  61. package/dist/commands/list.js.map +1 -0
  62. package/dist/commands/log.d.ts +7 -0
  63. package/dist/commands/log.d.ts.map +1 -0
  64. package/dist/commands/log.js +110 -0
  65. package/dist/commands/log.js.map +1 -0
  66. package/dist/commands/merge-order.d.ts +9 -0
  67. package/dist/commands/merge-order.d.ts.map +1 -0
  68. package/dist/commands/merge-order.js +100 -0
  69. package/dist/commands/merge-order.js.map +1 -0
  70. package/dist/commands/merged.d.ts +8 -0
  71. package/dist/commands/merged.d.ts.map +1 -0
  72. package/dist/commands/merged.js +37 -0
  73. package/dist/commands/merged.js.map +1 -0
  74. package/dist/commands/pr-check.d.ts +24 -0
  75. package/dist/commands/pr-check.d.ts.map +1 -0
  76. package/dist/commands/pr-check.js +85 -0
  77. package/dist/commands/pr-check.js.map +1 -0
  78. package/dist/commands/ready-check.d.ts +11 -0
  79. package/dist/commands/ready-check.d.ts.map +1 -0
  80. package/dist/commands/ready-check.js +150 -0
  81. package/dist/commands/ready-check.js.map +1 -0
  82. package/dist/commands/rebase-helper.d.ts +3 -0
  83. package/dist/commands/rebase-helper.d.ts.map +1 -0
  84. package/dist/commands/rebase-helper.js +52 -0
  85. package/dist/commands/rebase-helper.js.map +1 -0
  86. package/dist/commands/register.d.ts +10 -0
  87. package/dist/commands/register.d.ts.map +1 -0
  88. package/dist/commands/register.js +176 -0
  89. package/dist/commands/register.js.map +1 -0
  90. package/dist/commands/registry-sync.d.ts +9 -0
  91. package/dist/commands/registry-sync.d.ts.map +1 -0
  92. package/dist/commands/registry-sync.js +129 -0
  93. package/dist/commands/registry-sync.js.map +1 -0
  94. package/dist/commands/stale.d.ts +17 -0
  95. package/dist/commands/stale.d.ts.map +1 -0
  96. package/dist/commands/stale.js +136 -0
  97. package/dist/commands/stale.js.map +1 -0
  98. package/dist/commands/sync.d.ts +21 -0
  99. package/dist/commands/sync.d.ts.map +1 -0
  100. package/dist/commands/sync.js +78 -0
  101. package/dist/commands/sync.js.map +1 -0
  102. package/dist/commands/verify-trunk.d.ts +18 -0
  103. package/dist/commands/verify-trunk.d.ts.map +1 -0
  104. package/dist/commands/verify-trunk.js +77 -0
  105. package/dist/commands/verify-trunk.js.map +1 -0
  106. package/dist/commands/watch.d.ts +8 -0
  107. package/dist/commands/watch.d.ts.map +1 -0
  108. package/dist/commands/watch.js +509 -0
  109. package/dist/commands/watch.js.map +1 -0
  110. package/dist/index.d.ts +8 -0
  111. package/dist/index.d.ts.map +1 -0
  112. package/dist/index.js +8 -0
  113. package/dist/index.js.map +1 -0
  114. package/dist/lib/activity.d.ts +53 -0
  115. package/dist/lib/activity.d.ts.map +1 -0
  116. package/dist/lib/activity.js +271 -0
  117. package/dist/lib/activity.js.map +1 -0
  118. package/dist/lib/agent-errors.d.ts +41 -0
  119. package/dist/lib/agent-errors.d.ts.map +1 -0
  120. package/dist/lib/agent-errors.js +145 -0
  121. package/dist/lib/agent-errors.js.map +1 -0
  122. package/dist/lib/ai/context-builder.d.ts +16 -0
  123. package/dist/lib/ai/context-builder.d.ts.map +1 -0
  124. package/dist/lib/ai/context-builder.js +229 -0
  125. package/dist/lib/ai/context-builder.js.map +1 -0
  126. package/dist/lib/ai/event-handler.d.ts +21 -0
  127. package/dist/lib/ai/event-handler.d.ts.map +1 -0
  128. package/dist/lib/ai/event-handler.js +111 -0
  129. package/dist/lib/ai/event-handler.js.map +1 -0
  130. package/dist/lib/ai/index.d.ts +13 -0
  131. package/dist/lib/ai/index.d.ts.map +1 -0
  132. package/dist/lib/ai/index.js +11 -0
  133. package/dist/lib/ai/index.js.map +1 -0
  134. package/dist/lib/ai/llm-client.d.ts +37 -0
  135. package/dist/lib/ai/llm-client.d.ts.map +1 -0
  136. package/dist/lib/ai/llm-client.js +225 -0
  137. package/dist/lib/ai/llm-client.js.map +1 -0
  138. package/dist/lib/ai/reasoner.d.ts +11 -0
  139. package/dist/lib/ai/reasoner.d.ts.map +1 -0
  140. package/dist/lib/ai/reasoner.js +227 -0
  141. package/dist/lib/ai/reasoner.js.map +1 -0
  142. package/dist/lib/ai/setup.d.ts +40 -0
  143. package/dist/lib/ai/setup.d.ts.map +1 -0
  144. package/dist/lib/ai/setup.js +154 -0
  145. package/dist/lib/ai/setup.js.map +1 -0
  146. package/dist/lib/ai/types.d.ts +135 -0
  147. package/dist/lib/ai/types.d.ts.map +1 -0
  148. package/dist/lib/ai/types.js +42 -0
  149. package/dist/lib/ai/types.js.map +1 -0
  150. package/dist/lib/ast.d.ts +44 -0
  151. package/dist/lib/ast.d.ts.map +1 -0
  152. package/dist/lib/ast.js +160 -0
  153. package/dist/lib/ast.js.map +1 -0
  154. package/dist/lib/carries.d.ts +22 -0
  155. package/dist/lib/carries.d.ts.map +1 -0
  156. package/dist/lib/carries.js +56 -0
  157. package/dist/lib/carries.js.map +1 -0
  158. package/dist/lib/colony-subscriber.d.ts +56 -0
  159. package/dist/lib/colony-subscriber.d.ts.map +1 -0
  160. package/dist/lib/colony-subscriber.js +265 -0
  161. package/dist/lib/colony-subscriber.js.map +1 -0
  162. package/dist/lib/config.d.ts +62 -0
  163. package/dist/lib/config.d.ts.map +1 -0
  164. package/dist/lib/config.js +235 -0
  165. package/dist/lib/config.js.map +1 -0
  166. package/dist/lib/conflict-analyzer.d.ts +33 -0
  167. package/dist/lib/conflict-analyzer.d.ts.map +1 -0
  168. package/dist/lib/conflict-analyzer.js +114 -0
  169. package/dist/lib/conflict-analyzer.js.map +1 -0
  170. package/dist/lib/conflict-renderer.d.ts +7 -0
  171. package/dist/lib/conflict-renderer.d.ts.map +1 -0
  172. package/dist/lib/conflict-renderer.js +145 -0
  173. package/dist/lib/conflict-renderer.js.map +1 -0
  174. package/dist/lib/conflict-tier.d.ts +20 -0
  175. package/dist/lib/conflict-tier.d.ts.map +1 -0
  176. package/dist/lib/conflict-tier.js +49 -0
  177. package/dist/lib/conflict-tier.js.map +1 -0
  178. package/dist/lib/crypto.d.ts +71 -0
  179. package/dist/lib/crypto.d.ts.map +1 -0
  180. package/dist/lib/crypto.js +176 -0
  181. package/dist/lib/crypto.js.map +1 -0
  182. package/dist/lib/ecosystem-env.d.ts +33 -0
  183. package/dist/lib/ecosystem-env.d.ts.map +1 -0
  184. package/dist/lib/ecosystem-env.js +62 -0
  185. package/dist/lib/ecosystem-env.js.map +1 -0
  186. package/dist/lib/errors.d.ts +35 -0
  187. package/dist/lib/errors.d.ts.map +1 -0
  188. package/dist/lib/errors.js +71 -0
  189. package/dist/lib/errors.js.map +1 -0
  190. package/dist/lib/git-events-subscriber.d.ts +86 -0
  191. package/dist/lib/git-events-subscriber.d.ts.map +1 -0
  192. package/dist/lib/git-events-subscriber.js +870 -0
  193. package/dist/lib/git-events-subscriber.js.map +1 -0
  194. package/dist/lib/git-merge-analyzer.d.ts +38 -0
  195. package/dist/lib/git-merge-analyzer.d.ts.map +1 -0
  196. package/dist/lib/git-merge-analyzer.js +23 -0
  197. package/dist/lib/git-merge-analyzer.js.map +1 -0
  198. package/dist/lib/git.d.ts +103 -0
  199. package/dist/lib/git.d.ts.map +1 -0
  200. package/dist/lib/git.js +670 -0
  201. package/dist/lib/git.js.map +1 -0
  202. package/dist/lib/github.d.ts +151 -0
  203. package/dist/lib/github.d.ts.map +1 -0
  204. package/dist/lib/github.js +425 -0
  205. package/dist/lib/github.js.map +1 -0
  206. package/dist/lib/graph.d.ts +38 -0
  207. package/dist/lib/graph.d.ts.map +1 -0
  208. package/dist/lib/graph.js +165 -0
  209. package/dist/lib/graph.js.map +1 -0
  210. package/dist/lib/machine.d.ts +14 -0
  211. package/dist/lib/machine.d.ts.map +1 -0
  212. package/dist/lib/machine.js +38 -0
  213. package/dist/lib/machine.js.map +1 -0
  214. package/dist/lib/notify.d.ts +68 -0
  215. package/dist/lib/notify.d.ts.map +1 -0
  216. package/dist/lib/notify.js +111 -0
  217. package/dist/lib/notify.js.map +1 -0
  218. package/dist/lib/outline.d.ts +6 -0
  219. package/dist/lib/outline.d.ts.map +1 -0
  220. package/dist/lib/outline.js +60 -0
  221. package/dist/lib/outline.js.map +1 -0
  222. package/dist/lib/push-registry.d.ts +22 -0
  223. package/dist/lib/push-registry.d.ts.map +1 -0
  224. package/dist/lib/push-registry.js +21 -0
  225. package/dist/lib/push-registry.js.map +1 -0
  226. package/dist/lib/reconcile.d.ts +100 -0
  227. package/dist/lib/reconcile.d.ts.map +1 -0
  228. package/dist/lib/reconcile.js +121 -0
  229. package/dist/lib/reconcile.js.map +1 -0
  230. package/dist/lib/regex-utils.d.ts +6 -0
  231. package/dist/lib/regex-utils.d.ts.map +1 -0
  232. package/dist/lib/regex-utils.js +24 -0
  233. package/dist/lib/regex-utils.js.map +1 -0
  234. package/dist/lib/register-renderer.d.ts +10 -0
  235. package/dist/lib/register-renderer.d.ts.map +1 -0
  236. package/dist/lib/register-renderer.js +19 -0
  237. package/dist/lib/register-renderer.js.map +1 -0
  238. package/dist/lib/remote-drift.d.ts +61 -0
  239. package/dist/lib/remote-drift.d.ts.map +1 -0
  240. package/dist/lib/remote-drift.js +227 -0
  241. package/dist/lib/remote-drift.js.map +1 -0
  242. package/dist/lib/security.d.ts +72 -0
  243. package/dist/lib/security.d.ts.map +1 -0
  244. package/dist/lib/security.js +180 -0
  245. package/dist/lib/security.js.map +1 -0
  246. package/dist/lib/semantic-analysis.d.ts +31 -0
  247. package/dist/lib/semantic-analysis.d.ts.map +1 -0
  248. package/dist/lib/semantic-analysis.js +120 -0
  249. package/dist/lib/semantic-analysis.js.map +1 -0
  250. package/dist/lib/supabase-credentials.d.ts +36 -0
  251. package/dist/lib/supabase-credentials.d.ts.map +1 -0
  252. package/dist/lib/supabase-credentials.js +43 -0
  253. package/dist/lib/supabase-credentials.js.map +1 -0
  254. package/dist/lib/trunk.d.ts +39 -0
  255. package/dist/lib/trunk.d.ts.map +1 -0
  256. package/dist/lib/trunk.js +116 -0
  257. package/dist/lib/trunk.js.map +1 -0
  258. package/dist/lib/update-check.d.ts +11 -0
  259. package/dist/lib/update-check.d.ts.map +1 -0
  260. package/dist/lib/update-check.js +113 -0
  261. package/dist/lib/update-check.js.map +1 -0
  262. package/dist/lib/watch-renderer.d.ts +13 -0
  263. package/dist/lib/watch-renderer.d.ts.map +1 -0
  264. package/dist/lib/watch-renderer.js +35 -0
  265. package/dist/lib/watch-renderer.js.map +1 -0
  266. package/dist/storage/adapter.d.ts +81 -0
  267. package/dist/storage/adapter.d.ts.map +1 -0
  268. package/dist/storage/adapter.js +8 -0
  269. package/dist/storage/adapter.js.map +1 -0
  270. package/dist/storage/branch-registry-store.d.ts +13 -0
  271. package/dist/storage/branch-registry-store.d.ts.map +1 -0
  272. package/dist/storage/branch-registry-store.js +2 -0
  273. package/dist/storage/branch-registry-store.js.map +1 -0
  274. package/dist/storage/factory.d.ts +6 -0
  275. package/dist/storage/factory.d.ts.map +1 -0
  276. package/dist/storage/factory.js +14 -0
  277. package/dist/storage/factory.js.map +1 -0
  278. package/dist/storage/index.d.ts +10 -0
  279. package/dist/storage/index.d.ts.map +1 -0
  280. package/dist/storage/index.js +11 -0
  281. package/dist/storage/index.js.map +1 -0
  282. package/dist/storage/json-branch-registry-store.d.ts +19 -0
  283. package/dist/storage/json-branch-registry-store.d.ts.map +1 -0
  284. package/dist/storage/json-branch-registry-store.js +138 -0
  285. package/dist/storage/json-branch-registry-store.js.map +1 -0
  286. package/dist/storage/local.d.ts +20 -0
  287. package/dist/storage/local.d.ts.map +1 -0
  288. package/dist/storage/local.js +40 -0
  289. package/dist/storage/local.js.map +1 -0
  290. package/dist/storage/memory-branch-registry-store.d.ts +16 -0
  291. package/dist/storage/memory-branch-registry-store.d.ts.map +1 -0
  292. package/dist/storage/memory-branch-registry-store.js +68 -0
  293. package/dist/storage/memory-branch-registry-store.js.map +1 -0
  294. package/dist/storage/supabase-registry-sync-client-impl.d.ts +40 -0
  295. package/dist/storage/supabase-registry-sync-client-impl.d.ts.map +1 -0
  296. package/dist/storage/supabase-registry-sync-client-impl.js +278 -0
  297. package/dist/storage/supabase-registry-sync-client-impl.js.map +1 -0
  298. package/dist/storage/supabase-registry-sync-client.d.ts +9 -0
  299. package/dist/storage/supabase-registry-sync-client.d.ts.map +1 -0
  300. package/dist/storage/supabase-registry-sync-client.js +2 -0
  301. package/dist/storage/supabase-registry-sync-client.js.map +1 -0
  302. package/dist/storage/supabase.d.ts +28 -0
  303. package/dist/storage/supabase.d.ts.map +1 -0
  304. package/dist/storage/supabase.js +56 -0
  305. package/dist/storage/supabase.js.map +1 -0
  306. package/dist/types/cloud.d.ts +102 -0
  307. package/dist/types/cloud.d.ts.map +1 -0
  308. package/dist/types/cloud.js +13 -0
  309. package/dist/types/cloud.js.map +1 -0
  310. package/package.json +1 -1
@@ -0,0 +1,773 @@
1
+ /**
2
+ * spidersan conflicts
3
+ *
4
+ * Detect file conflicts between branches.
5
+ * Supports tiered conflict blocking:
6
+ * TIER 1: Warning only (informational)
7
+ * TIER 2: Pause (notify agent, recommend action)
8
+ * TIER 3: Block (exit with error, prevent merge)
9
+ */
10
+ import { Command } from 'commander';
11
+ import { compilePatterns } from '../lib/regex-utils.js';
12
+ import { execFileSync, spawnSync } from 'child_process';
13
+ import { basename } from 'path';
14
+ import { getStorage } from '../storage/index.js';
15
+ import { SupabaseStorage } from '../storage/supabase.js';
16
+ import { resolveSupabaseCredentials } from '../lib/supabase-credentials.js';
17
+ import { loadMachineIdentity } from '../lib/machine.js';
18
+ import { getRepoName, registryRepoNames, resolveBranchRef } from '../lib/git.js';
19
+ import { analyzeSemantic, resolveBranchTip, resolveCrossMachineRef } from '../lib/semantic-analysis.js';
20
+ import { getCLIPath } from '../lib/security.js';
21
+ import { isExcludedPath } from './register.js';
22
+ import { loadConfig } from '../lib/config.js';
23
+ import { ECOSYSTEM_UNCONFIGURED_HINT, resolveEcosystemRepos } from '../lib/ecosystem-env.js';
24
+ import { logActivity } from '../lib/activity.js';
25
+ import { isGhAvailable, getPRDetails, getPRLabels, listOpenPRs, getPRChangedFiles } from '../lib/github.js';
26
+ import { analyzeCarries } from '../lib/carries.js';
27
+ import { classifyWithLabel } from '../lib/conflict-tier.js';
28
+ import { createNotifier, deliver } from '../lib/notify.js';
29
+ import { analyzeRealConflicts } from '../lib/git-merge-analyzer.js';
30
+ import { getTrunkBranch } from '../lib/trunk.js';
31
+ import { activeBranches } from '../lib/reconcile.js';
32
+ // Lazy so the transport is resolved at first use, not at import.
33
+ let _notifier = null;
34
+ function notifier() {
35
+ return (_notifier ??= createNotifier());
36
+ }
37
+ /**
38
+ * Pull other machines' registered branches as synthetic conflict targets.
39
+ *
40
+ * Why this exists: the registry is per-machine local state, and the pre-push /
41
+ * pre-merge hook calls `conflicts`. Without this, a teammate on another machine
42
+ * editing the same file was NEVER flagged — the hook advisory silently only ever
43
+ * saw local branches, giving false "you're good to merge" confidence. The data
44
+ * was already in Supabase (`registry-sync --push` writes it, `cross-conflicts`
45
+ * reads it); `conflicts` just never asked (tb-ly0b).
46
+ *
47
+ * Fail-open by design: unconfigured / offline / auth failure returns [] so the
48
+ * check degrades to local-only rather than blocking a push. Callers that need to
49
+ * distinguish "no conflicts" from "couldn't check" should use `cross-conflicts`.
50
+ *
51
+ * ponytail: one Supabase round-trip, only on the branch-overlap path (--real and
52
+ * --ecosystem return before this). Sub-second in practice and the hook is fail-open.
53
+ */
54
+ export function crossMachineBranches(views) {
55
+ const branches = [];
56
+ for (const view of views) {
57
+ for (const b of view.branches) {
58
+ // Non-active entries are another machine's finished/abandoned work,
59
+ // and a file-less entry can never overlap — both would be phantom
60
+ // conflicts, which this codebase treats as worse than a miss.
61
+ if (b.status !== 'active' || !b.files?.length)
62
+ continue;
63
+ branches.push({
64
+ ...b,
65
+ // Machine-qualified so the report distinguishes a remote branch
66
+ // from a local one of the same name (e.g. two mains).
67
+ name: `${view.machine_name}/${b.name}`,
68
+ });
69
+ }
70
+ }
71
+ return branches;
72
+ }
73
+ async function fetchCrossMachineBranches(ownBranches = []) {
74
+ try {
75
+ const creds = await resolveSupabaseCredentials();
76
+ if (!creds)
77
+ return { branches: [], degraded: false, status: 'no-credentials' };
78
+ const machine = await loadMachineIdentity();
79
+ const supabase = new SupabaseStorage(creds);
80
+ // Excluding our own machine_id matters: without it this machine's own
81
+ // pushed rows come back and conflict against themselves.
82
+ // ownBranches: a branch pushed from here can reach the registry as a pushed-branch
83
+ // row before this machine's own registry-sync lands; it must not conflict with itself.
84
+ const views = await supabase.pullRegistries(registryRepoNames(), machine.id, ownBranches);
85
+ return { branches: crossMachineBranches(views), degraded: false, status: 'checked' };
86
+ }
87
+ catch {
88
+ return { branches: [], degraded: true, status: 'degraded' };
89
+ }
90
+ }
91
+ function getCurrentBranch() {
92
+ try {
93
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8' }).trim();
94
+ }
95
+ catch {
96
+ throw new Error('Not in a git repository');
97
+ }
98
+ }
99
+ async function notifyConflicts(branch, conflicts) {
100
+ let hasTier3 = false;
101
+ let hasTier2 = false;
102
+ for (const c of conflicts) {
103
+ if (c.tier === 3)
104
+ hasTier3 = true;
105
+ else if (c.tier === 2)
106
+ hasTier2 = true;
107
+ if (hasTier3 && hasTier2)
108
+ break;
109
+ }
110
+ if (!hasTier3 && !hasTier2)
111
+ return;
112
+ const severity = hasTier3 ? '🔴 TIER 3 BLOCK' : '🟠 TIER 2 PAUSE';
113
+ const message = `🕷️⚠️ **Conflict Alert** on \`${branch}\`\n\n${severity}\n\nConflicting files require coordination.`;
114
+ const contested = [...new Set(conflicts.flatMap((c) => c.files))].sort();
115
+ // `deliver` inspects the result and logs the real outcome. The previous
116
+ // implementation POSTed to a decommissioned host inside a bare `catch {}`,
117
+ // so `--notify` reported nothing either way. (sp-hnjf)
118
+ await deliver(notifier(), {
119
+ repo: getRepoName(),
120
+ branch,
121
+ tier: hasTier3 ? 3 : 2,
122
+ message,
123
+ dedupe: `conflicts:${branch}:${contested.join(',')}`,
124
+ files: contested,
125
+ // stderr, not stdout: this runs before the --json blob is printed, and a
126
+ // delivery diagnostic must never end up inside machine-readable output.
127
+ }, (m) => console.error(m));
128
+ }
129
+ function runEcosystemScan(repos, asJson) {
130
+ const results = [];
131
+ for (const repo of repos) {
132
+ const name = basename(repo);
133
+ const result = spawnSync(process.execPath, [getCLIPath(), 'conflicts', '--json'], { cwd: repo, encoding: 'utf-8', timeout: 15000 });
134
+ if (result.status === null || result.error) {
135
+ results.push({ repo, name, tier3: 0, tier2: 0, tier1: 0, error: 'spawn failed' });
136
+ continue;
137
+ }
138
+ if (result.status !== 0) {
139
+ // Exit 1 can be --strict mode; try to parse stdout anyway
140
+ // If it says "not initialized", mark as skipped
141
+ const combinedOut = (result.stderr ?? '') + (result.stdout ?? '');
142
+ if (combinedOut.includes('not initialized') || combinedOut.includes('not registered')) {
143
+ results.push({ repo, name, tier3: 0, tier2: 0, tier1: 0, skipped: true });
144
+ continue;
145
+ }
146
+ }
147
+ try {
148
+ const data = JSON.parse(result.stdout);
149
+ results.push({
150
+ repo,
151
+ name,
152
+ tier3: data.summary.tier3,
153
+ tier2: data.summary.tier2,
154
+ tier1: data.summary.tier1,
155
+ });
156
+ }
157
+ catch {
158
+ results.push({ repo, name, tier3: 0, tier2: 0, tier1: 0, error: 'parse failed' });
159
+ }
160
+ }
161
+ const totals = results.reduce((acc, r) => ({ tier3: acc.tier3 + r.tier3, tier2: acc.tier2 + r.tier2, tier1: acc.tier1 + r.tier1 }), { tier3: 0, tier2: 0, tier1: 0 });
162
+ const reposWithConflicts = results.filter(r => r.tier3 + r.tier2 + r.tier1 > 0).length;
163
+ if (asJson) {
164
+ console.log(JSON.stringify({
165
+ ecosystem: true,
166
+ repos: results,
167
+ summary: { ...totals, repos_scanned: results.length, repos_with_conflicts: reposWithConflicts },
168
+ }, null, 2));
169
+ return;
170
+ }
171
+ console.log(`
172
+ 🕷️ ECOSYSTEM CONFLICT SCAN
173
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
174
+ `);
175
+ for (const r of results) {
176
+ if (r.skipped) {
177
+ console.log(` ⬜ ${r.name.padEnd(22)} (not initialized)`);
178
+ continue;
179
+ }
180
+ if (r.error) {
181
+ console.log(` ❓ ${r.name.padEnd(22)} (${r.error})`);
182
+ continue;
183
+ }
184
+ const icon = r.tier3 > 0 ? '🔴' : r.tier2 > 0 ? '🟠' : r.tier1 > 0 ? '🟡' : '✅';
185
+ const counts = r.tier3 + r.tier2 + r.tier1 > 0
186
+ ? ` T3:${r.tier3} T2:${r.tier2} T1:${r.tier1}`
187
+ : ' clean';
188
+ console.log(` ${icon} ${r.name.padEnd(22)}${counts}`);
189
+ }
190
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
191
+ console.log(`📊 Total: 🔴 ${totals.tier3} BLOCK | 🟠 ${totals.tier2} PAUSE | 🟡 ${totals.tier1} WARN`);
192
+ console.log(` Repos scanned: ${results.length} | With conflicts: ${reposWithConflicts}`);
193
+ }
194
+ // ── Real (git merge-tree) conflict scan ─────────────────────────────────────────
195
+ /**
196
+ * Render a single real-conflict report (git merge-tree backed) to the console.
197
+ * `✅ <branch>: clean vs <base>` or `⚠️ <branch>: N real conflict(s) vs <base>`
198
+ * with the conflicting file list + kind, plus a dim `method` line. On an
199
+ * environmental `error`, prints it and returns (caller continues to the next target).
200
+ */
201
+ function renderRealReport(report) {
202
+ if (report.error) {
203
+ console.log(`❌ ${report.branch}: ${report.error}`);
204
+ return;
205
+ }
206
+ if (report.clean) {
207
+ console.log(`✅ ${report.branch}: clean vs ${report.base}`);
208
+ }
209
+ else {
210
+ const n = report.conflicts.length;
211
+ console.log(`⚠️ ${report.branch}: ${n} real conflict${n === 1 ? '' : 's'} vs ${report.base}`);
212
+ for (const c of report.conflicts) {
213
+ console.log(` • ${c.file} (${c.kind})`);
214
+ }
215
+ }
216
+ // Dim trailing line showing which git path produced the result.
217
+ console.log(`\x1b[2m method: ${report.method}\x1b[0m`);
218
+ }
219
+ /**
220
+ * `conflicts --real`: compute TRUE merge conflicts via git merge-tree against the
221
+ * trunk (or `--base`), for the current branch / `--branch` / (with `--all`) every
222
+ * active registered branch.
223
+ *
224
+ * Pure git for the single-target path — works with NO `.spidersan/registry.json`.
225
+ * Only `--all` (which enumerates registered branches) requires an initialized
226
+ * registry, and that path is gated here.
227
+ *
228
+ * @returns the process exit code (0 always, unless `--exit-code` + conflicts → 1).
229
+ */
230
+ /**
231
+ * The ref `--real` merges INTO: the remote-tracking trunk when it exists,
232
+ * otherwise the local branch name.
233
+ *
234
+ * A local trunk in a long-lived checkout can lag origin badly — a leftover
235
+ * worktree pinning the branch ref held one 78 commits back — and merge-tree
236
+ * against a stale base resolves cleanly where the real merge does not. That is
237
+ * a green earned by comparing against the wrong thing (sp-vifo). The `--pr` arm
238
+ * already reasoned this way (tb-57fr); this generalises it to `--branch`,
239
+ * `--all` and the current branch.
240
+ *
241
+ * Does NOT fetch: it reads refs already on disk, because this runs from a
242
+ * pre-merge hook and a check that hits the network every invocation is a
243
+ * different problem. `origin/<trunk>` can therefore be stale too — the report
244
+ * always names the base it used, so the reader can see what was compared.
245
+ */
246
+ function resolveRealBase(trunk) {
247
+ const ref = resolveBranchRef(trunk);
248
+ return ref?.startsWith('refs/remotes/') ? `origin/${trunk}` : trunk;
249
+ }
250
+ /** precondition(pr): `needs-<kind>:<resource>` labels gate a PR until they're cleared. */
251
+ const NEEDS_LABEL = /^needs-[a-z0-9]+:/i;
252
+ function preconditionLabels(labels) {
253
+ return labels.filter((l) => NEEDS_LABEL.test(l));
254
+ }
255
+ function renderGate(gate) {
256
+ if (gate.unknown) {
257
+ console.log('\n🛑 precondition UNKNOWN: could not read PR labels (gh missing/unauthenticated) — failing closed.');
258
+ }
259
+ else if (gate.unmet) {
260
+ console.log(`\n🛑 precondition UNMET: ${gate.labels.join(', ')} — resource not satisfied; clear the label to merge.`);
261
+ }
262
+ else {
263
+ console.log('\n✅ precondition: no unmet needs-* gate.');
264
+ }
265
+ }
266
+ function renderCarries(report) {
267
+ const total = report.own.length + report.inherited.length;
268
+ console.log(`\n🧬 carries: ${total} commit(s) — ${report.own.length} own, ${report.inherited.length} inherited`);
269
+ for (const c of report.own)
270
+ console.log(` • ${c.sha.slice(0, 7)} ${c.subject} (own)`);
271
+ for (const c of report.inherited) {
272
+ console.log(` 🚩 ${c.sha.slice(0, 7)} ${c.subject} — also on ${c.branches.join(', ')}`);
273
+ }
274
+ if (report.flagged) {
275
+ console.log(' ⚠️ provenance: this PR carries commits from another open branch — merging lands them too.');
276
+ }
277
+ }
278
+ async function runRealConflicts(options) {
279
+ // `|| resolveRealBase(...)` short-circuits: an explicit --base skips trunk
280
+ // detection entirely, as it always has.
281
+ let base = options.base || resolveRealBase(getTrunkBranch());
282
+ // Determine target branches.
283
+ let targets;
284
+ let prNumber;
285
+ if (options.pr) {
286
+ // --pr in real mode: the PR head isn't a local ref, so fetch it. Without
287
+ // this, --real fell through to getCurrentBranch() and silently checked
288
+ // trunk-vs-trunk → a false "clean" (tb-57fr).
289
+ prNumber = parseInt(options.pr, 10);
290
+ if (isNaN(prNumber) || prNumber <= 0) {
291
+ console.error('❌ Invalid PR number. Provide a positive integer, e.g. --pr 42');
292
+ return 1;
293
+ }
294
+ const ref = `refs/spidersan/pr-${prNumber}`;
295
+ try {
296
+ // pull/<n>/head exists on the base repo even for fork PRs.
297
+ // Leading '+' forces the update — after a PR force-push the stashed
298
+ // ref is non-fast-forward and a plain refspec would fail forever.
299
+ execFileSync('git', ['fetch', '--quiet', 'origin', `+pull/${prNumber}/head:${ref}`], { stdio: 'pipe' });
300
+ // Check against the REMOTE trunk, not a possibly-stale local one — a PR
301
+ // merges into origin's HEAD. Falling back to local `main` (which can lag)
302
+ // is the same false-confidence class as tb-57fr.
303
+ if (!options.base) {
304
+ const trunk = getTrunkBranch();
305
+ // Unlike resolveRealBase(), this arm DOES fetch — it is already
306
+ // fetching the PR head, and a PR merges into origin's tip.
307
+ execFileSync('git', ['fetch', '--quiet', 'origin', trunk], { stdio: 'pipe' });
308
+ base = `origin/${trunk}`;
309
+ }
310
+ }
311
+ catch (err) {
312
+ const msg = err instanceof Error ? err.message : String(err);
313
+ console.error(`❌ Could not fetch PR #${prNumber} head: ${msg}`);
314
+ return 1;
315
+ }
316
+ targets = [ref];
317
+ }
318
+ else if (options.all) {
319
+ // --all enumerates registered branches → requires an initialized registry.
320
+ const storage = await getStorage();
321
+ if (!(await storage.isInitialized())) {
322
+ console.error('❌ Spidersan not initialized. Run: spidersan init');
323
+ console.error(' (--real --all needs the registry; --real alone does not)');
324
+ return 1;
325
+ }
326
+ // Reconcile-on-read: fold git truth over stale `active` status so a
327
+ // branch already merged into trunk isn't checked for conflicts (tb-8sa.1).
328
+ targets = activeBranches(await storage.list()).map((b) => b.name);
329
+ }
330
+ else {
331
+ targets = [options.branch || getCurrentBranch()];
332
+ }
333
+ const results = [];
334
+ for (const branch of targets) {
335
+ results.push(await analyzeRealConflicts(base, branch));
336
+ }
337
+ const anyConflicts = results.some((r) => !r.error && !r.clean);
338
+ // --pr extras: precondition() gate + carries() provenance (opt-in, compose).
339
+ let gate;
340
+ let carries;
341
+ if (prNumber !== undefined) {
342
+ const ref = `refs/spidersan/pr-${prNumber}`;
343
+ if (options.gate) {
344
+ // Fail closed: if gh is missing or the label fetch errors we cannot
345
+ // prove the gate is clear, so it stays unmet.
346
+ const labels = isGhAvailable() ? await getPRLabels(prNumber) : null;
347
+ if (labels === null) {
348
+ gate = { labels: [], unmet: true, unknown: true };
349
+ }
350
+ else {
351
+ const needs = preconditionLabels(labels);
352
+ gate = { labels: needs, unmet: needs.length > 0 };
353
+ }
354
+ }
355
+ if (options.carries) {
356
+ const self = [base]; // exclude base + the PR's own remote head from "other branch"
357
+ try {
358
+ self.push(`origin/${(await getPRDetails(prNumber)).headBranch}`);
359
+ }
360
+ catch { /* best-effort self-exclusion */ }
361
+ carries = analyzeCarries(base, ref, self);
362
+ }
363
+ }
364
+ if (options.json) {
365
+ console.log(JSON.stringify({ base, results, carries, gate }, null, 2));
366
+ }
367
+ else {
368
+ if (results.length === 0) {
369
+ console.log(`🕷️ No active branches to check vs ${base}.`);
370
+ }
371
+ for (const report of results) {
372
+ renderRealReport(report);
373
+ }
374
+ if (gate)
375
+ renderGate(gate);
376
+ if (carries)
377
+ renderCarries(carries);
378
+ }
379
+ // --strict is the advertised CI flag on the legacy path; honour it here too
380
+ // instead of silently ignoring it (real conflicts are always tier-blocking).
381
+ return (options.exitCode || options.strict) && (anyConflicts || gate?.unmet === true) ? 1 : 0;
382
+ }
383
+ export const conflictsCommand = new Command('conflicts')
384
+ .description('Detect file conflicts between branches (with tiered blocking)')
385
+ .option('--branch <name>', 'Check conflicts for specific branch')
386
+ .option('--pr <number>', 'Check conflicts for a GitHub pull request by number')
387
+ .option('--vs-prs', 'Also detect conflicts against other open PRs (not just registered branches); composes with --real')
388
+ .option('--json', 'Output as JSON')
389
+ .option('--tier <level>', 'Filter by minimum tier (1, 2, or 3)', '1')
390
+ .option('--strict', 'Strict mode: exit with error if TIER 2+ conflicts found')
391
+ .option('--notify', 'Deliver TIER 2+ conflicts to the configured alert room (SPIDERSAN_ROOM_TOKEN)')
392
+ .option('--semantic', 'Use semantic (AST) analysis for symbol-level conflict detection')
393
+ .option('--ecosystem', 'Scan all ecosystem repos and aggregate conflict tiers')
394
+ .option('--repos <paths>', 'Comma-separated repo paths for --ecosystem scan')
395
+ .option('--real', 'Compute TRUE merge conflicts via git merge-tree (vs registry overlap)')
396
+ .option('--base <ref>', 'Base/trunk ref to merge into for --real (default: detected trunk)')
397
+ .option('--all', 'With --real: check every active registered branch')
398
+ .option('--exit-code', 'With --real: exit 1 if any real conflicts found (like git diff)')
399
+ .option('--carries', 'With --pr: show carries() provenance (own vs inherited commits)')
400
+ .option('--gate', 'With --pr: check precondition() needs-* labels; --exit-code blocks if unmet')
401
+ .action(async (options) => {
402
+ // ── Real (git merge-tree) conflicts ──────────────────────────────────
403
+ // Runs BEFORE the storage-init guard: a single-target real check is pure
404
+ // git and must work with no .spidersan/registry.json. Only --real --all
405
+ // touches the registry, and runRealConflicts gates that path itself.
406
+ //
407
+ // --pr defaults to the real merge-tree path (tb-8hgg): "will this PR merge
408
+ // into trunk" is the common question and file-overlap false-positives it
409
+ // (see #80/identity.ts). Opt into the legacy file-overlap check with --vs-prs.
410
+ //
411
+ // --real --vs-prs COMPOSES: it runs the merge-tree check vs trunk AND then
412
+ // falls through to the cross-PR/overlap pass, because the two answer
413
+ // different questions ("does this merge?" vs "is anyone else touching these
414
+ // files?") and /spidersan documents `conflicts --pr <N> --real --vs-prs` as
415
+ // THE authoritative pre-merge check. Before sp-a1l3 the `options.real` arm
416
+ // returned here, so that documented command silently dropped the cross-PR
417
+ // half and exited 0 looking healthy — a check reporting success while doing
418
+ // nothing. Exit codes are composed at the end via finish(): real conflicts
419
+ // are tier-blocking, overlap is advisory (only --strict makes it block), so
420
+ // the process code is max(real, overlap).
421
+ const composeRealAndOverlap = !!(options.real && options.vsPrs);
422
+ let realExitCode = 0;
423
+ if (options.real || (options.pr && !options.vsPrs)) {
424
+ realExitCode = await runRealConflicts(options);
425
+ if (!composeRealAndOverlap) {
426
+ if (realExitCode !== 0)
427
+ process.exit(realExitCode);
428
+ return;
429
+ }
430
+ console.log(''); // separate the two reports
431
+ }
432
+ /**
433
+ * Terminate the overlap pass, folding in the real-conflict verdict.
434
+ * On the composed path a non-zero code from EITHER half must survive;
435
+ * on the plain overlap path realExitCode is 0, so behaviour is unchanged.
436
+ */
437
+ const finish = (overlapCode) => {
438
+ const code = Math.max(realExitCode, overlapCode);
439
+ if (code !== 0)
440
+ process.exit(code);
441
+ };
442
+ // ── Ecosystem shortcut ───────────────────────────────────────────────
443
+ if (options.ecosystem) {
444
+ // No built-in repo list ships with the package: the list comes from
445
+ // --repos, SPIDERSAN_ECOSYSTEM (path list) or conflicts.ecosystemRepos.
446
+ const { repos } = resolveEcosystemRepos(await loadConfig(), {
447
+ reposFlag: options.repos,
448
+ });
449
+ if (repos.length === 0) {
450
+ console.error(`ℹ️ ${ECOSYSTEM_UNCONFIGURED_HINT}`);
451
+ }
452
+ runEcosystemScan(repos, !!options.json);
453
+ finish(0);
454
+ return;
455
+ }
456
+ const storage = await getStorage();
457
+ const config = await loadConfig();
458
+ if (!await storage.isInitialized()) {
459
+ // --real is deliberately registry-independent; adding --vs-prs must not
460
+ // turn a working real check into a hard failure. Degrade loudly instead
461
+ // of dying — but never silently (sp-a1l3).
462
+ if (composeRealAndOverlap) {
463
+ console.error('⚠️ Overlap/cross-PR check skipped: spidersan not initialized here (run: spidersan init).');
464
+ console.error(' The --real merge-tree result above stands on its own; the --vs-prs half did NOT run.');
465
+ finish(0);
466
+ return;
467
+ }
468
+ console.error('❌ Spidersan not initialized. Run: spidersan init');
469
+ process.exit(1);
470
+ }
471
+ const minTier = parseInt(options.tier, 10);
472
+ // Load custom patterns from config
473
+ const highSeverity = (config.conflicts?.highSeverityPatterns || []).map(p => new RegExp(p));
474
+ const mediumSeverity = (config.conflicts?.mediumSeverityPatterns || []).map(p => new RegExp(p));
475
+ const compiledHigh = compilePatterns(highSeverity);
476
+ const compiledMedium = compilePatterns(mediumSeverity);
477
+ let targetBranch;
478
+ let targetFiles;
479
+ let targetPrNumber;
480
+ const prNumberByLabel = new Map(); // display label -> PR number (labels are NOT git refs)
481
+ if (options.pr) {
482
+ // --pr mode: fetch PR head branch + changed files from GitHub
483
+ const prNumber = parseInt(options.pr, 10);
484
+ if (isNaN(prNumber) || prNumber <= 0) {
485
+ console.error('❌ Invalid PR number. Provide a positive integer, e.g. --pr 42');
486
+ process.exit(1);
487
+ }
488
+ if (!isGhAvailable()) {
489
+ console.error('❌ GitHub CLI (gh) is not available or not authenticated.');
490
+ console.error(' Install: https://cli.github.com → gh auth login');
491
+ process.exit(1);
492
+ }
493
+ let prDetails;
494
+ try {
495
+ prDetails = await getPRDetails(prNumber);
496
+ }
497
+ catch (err) {
498
+ const msg = err instanceof Error ? err.message : String(err);
499
+ console.error(`❌ Failed to fetch PR #${prNumber}: ${msg}`);
500
+ process.exit(1);
501
+ }
502
+ targetBranch = prDetails.headBranch;
503
+ targetFiles = prDetails.files;
504
+ targetPrNumber = prNumber;
505
+ console.log(`🕷️ Checking conflicts for PR #${prNumber}: "${prDetails.title}"`);
506
+ console.log(` Branch: ${targetBranch} (${targetFiles.length} file(s) changed)`);
507
+ }
508
+ else {
509
+ // Normal --branch or current branch mode
510
+ targetBranch = options.branch || getCurrentBranch();
511
+ const target = await storage.get(targetBranch);
512
+ if (!target) {
513
+ // Same reasoning as the isInitialized() guard above: on the composed
514
+ // --real --vs-prs path the real check already produced a valid answer,
515
+ // so an unregistered branch degrades the overlap half loudly rather
516
+ // than failing the whole command (sp-a1l3).
517
+ if (composeRealAndOverlap) {
518
+ console.error(`⚠️ Overlap/cross-PR check skipped: branch "${targetBranch}" is not registered (run: spidersan register --files "...").`);
519
+ console.error(' The --real merge-tree result above stands on its own; the --vs-prs half did NOT run.');
520
+ finish(0);
521
+ return;
522
+ }
523
+ console.error(`❌ Branch "${targetBranch}" is not registered.`);
524
+ console.error(' Run: spidersan register --files "..."');
525
+ process.exit(1);
526
+ }
527
+ targetFiles = target.files;
528
+ }
529
+ // Reconcile-on-read: branches already merged into trunk drop out before
530
+ // overlap detection, so a stale `active` entry can't phantom-conflict (tb-8sa.1).
531
+ const allBranches = activeBranches(await storage.list());
532
+ // --vs-prs: pull other open PRs as conflict targets too, so two simultaneous
533
+ // PRs touching the same files flag each other even when neither branch is in
534
+ // the local registry. PRs become synthetic active "branches" (no agent → wake
535
+ // skips them). Self (same headBranch) is excluded.
536
+ // ponytail: 1 + N gh calls (list + per-PR diff); fine for typical open-PR counts,
537
+ // parallelise if a repo ever carries hundreds of open PRs.
538
+ const prHeadBranches = [];
539
+ if (options.vsPrs) {
540
+ if (!isGhAvailable()) {
541
+ console.error('⚠️ --vs-prs needs gh (unavailable/unauthenticated); checking registered branches only.');
542
+ }
543
+ else {
544
+ const openPRs = (await listOpenPRs()).filter(pr => pr.headBranch !== targetBranch);
545
+ prHeadBranches.push(...openPRs.map(pr => pr.headBranch));
546
+ openPRs.forEach(pr => prNumberByLabel.set(`PR #${pr.number} (${pr.headBranch})`, pr.number));
547
+ const prBranches = await Promise.all(openPRs.map(async (pr) => ({
548
+ name: `PR #${pr.number} (${pr.headBranch})`,
549
+ files: await getPRChangedFiles(pr.number),
550
+ registeredAt: new Date(),
551
+ status: 'active',
552
+ description: pr.title,
553
+ })));
554
+ allBranches.push(...prBranches.filter(b => b.files.length > 0));
555
+ }
556
+ }
557
+ // Cross-machine: other machines' registered branches are conflict targets
558
+ // too. Automatic (not opt-in) so the pre-push/pre-merge hook — which calls
559
+ // plain `conflicts` — actually catches a teammate on another machine
560
+ // touching the same file. Fail-open: degraded => local-only (tb-ly0b).
561
+ const cross = await fetchCrossMachineBranches([targetBranch, ...allBranches.map(b => b.name), ...prHeadBranches]);
562
+ allBranches.push(...cross.branches);
563
+ const crossMachineLabels = new Set(cross.branches.map(b => b.name)); // origin-only refs for these
564
+ const conflicts = [];
565
+ // Performance Optimization: Convert target files to Set for O(1) lookup
566
+ // Reduces complexity from O(N*M*K) to O(N*M) where N=branches, M=files/branch, K=target_files
567
+ // Filter excluded paths (node_modules/, dist/, etc.) at query time to handle stale registrations
568
+ const targetFilesSet = new Set(targetFiles.filter(f => !isExcludedPath(f)));
569
+ for (const branch of allBranches) {
570
+ if (branch.name === targetBranch || branch.status !== 'active')
571
+ continue;
572
+ // Performance Optimization: Replace .filter() with a standard loop to avoid array allocation.
573
+ // Short-circuit using O(1) Set lookup (targetFilesSet.has) before calling the expensive isExcludedPath.
574
+ const overlappingFiles = [];
575
+ for (let i = 0; i < branch.files.length; i++) {
576
+ const f = branch.files[i];
577
+ if (targetFilesSet.has(f) && !isExcludedPath(f)) {
578
+ overlappingFiles.push(f);
579
+ }
580
+ }
581
+ if (overlappingFiles.length > 0) {
582
+ // Get highest tier for this conflict
583
+ let maxTier = { tier: 1, label: 'WARN', icon: '🟡', action: '' };
584
+ for (const file of overlappingFiles) {
585
+ const fileTier = classifyWithLabel(file, {
586
+ extraTier3: compiledHigh,
587
+ extraTier2: compiledMedium,
588
+ });
589
+ if (fileTier.tier > maxTier.tier) {
590
+ maxTier = fileTier;
591
+ }
592
+ }
593
+ if (maxTier.tier >= minTier) {
594
+ conflicts.push({
595
+ branch: branch.name,
596
+ files: overlappingFiles,
597
+ tier: maxTier.tier,
598
+ tierInfo: maxTier,
599
+ ...(branch.sessionId ? { sessionId: branch.sessionId } : {}),
600
+ });
601
+ }
602
+ }
603
+ }
604
+ // SEMANTIC ANALYSIS with AST parser
605
+ let semanticConflicts = [];
606
+ let semanticSkipped = []; // files we could not analyze — "no conflicts" is unproven if non-empty
607
+ let semanticUnsupported = []; // overlapping files of a type --semantic doesn't cover
608
+ if (options.semantic && conflicts.length > 0) {
609
+ console.log('\n🔬 Running semantic (AST) analysis...');
610
+ const fetchPrRef = (n) => {
611
+ const ref = `refs/spidersan/pr-${n}`;
612
+ try {
613
+ // '+' forces the update after a PR force-push (same as the --real arm)
614
+ execFileSync('git', ['fetch', '--quiet', 'origin', `+pull/${n}/head:${ref}`], { stdio: 'pipe', timeout: 30_000 }); // bounded: a stalled origin must not hang the command
615
+ return ref;
616
+ }
617
+ catch {
618
+ return null;
619
+ }
620
+ };
621
+ // Labels ("PR #N (branch)", "machine/branch") are display text, not refs:
622
+ // resolve each to a real ref, or null (→ reported as skipped, never swallowed).
623
+ const refForLabel = (label) => {
624
+ const n = prNumberByLabel.get(label);
625
+ if (n !== undefined)
626
+ return fetchPrRef(n);
627
+ // another machine's branch resolves only against origin; a registered local branch is local-first
628
+ return crossMachineLabels.has(label) ? resolveCrossMachineRef(label) : resolveBranchTip(label);
629
+ };
630
+ // Current side: the PR's own head under --pr (HEAD is just whatever is checked out),
631
+ // else the target branch (HEAD when it is the checked-out one).
632
+ const currentRef = targetPrNumber !== undefined
633
+ ? fetchPrRef(targetPrNumber)
634
+ : targetBranch === getCurrentBranch() ? 'HEAD' : resolveBranchTip(targetBranch);
635
+ const res = analyzeSemantic(targetBranch, currentRef, conflicts.map(c => ({ label: c.branch, files: c.files, ref: refForLabel(c.branch) })));
636
+ semanticConflicts = res.conflicts;
637
+ semanticSkipped = res.skipped;
638
+ semanticUnsupported = res.unsupported;
639
+ for (const line of semanticSkipped)
640
+ console.log(` ⚠️ ${line}`);
641
+ if (semanticUnsupported.length > 0) {
642
+ const shown = semanticUnsupported.slice(0, 5).join(', ') + (semanticUnsupported.length > 5 ? `, +${semanticUnsupported.length - 5} more` : '');
643
+ console.log(` ℹ️ not analyzable by --semantic (unsupported file type): ${shown}`);
644
+ }
645
+ }
646
+ // Sort by tier (highest first)
647
+ conflicts.sort((a, b) => b.tier - a.tier);
648
+ let tier3Count = 0;
649
+ let tier2Count = 0;
650
+ let tier1Count = 0;
651
+ for (const c of conflicts) {
652
+ if (c.tier === 3)
653
+ tier3Count++;
654
+ else if (c.tier === 2)
655
+ tier2Count++;
656
+ else if (c.tier === 1)
657
+ tier1Count++;
658
+ }
659
+ // Log conflict detection to activity log
660
+ if (conflicts.length > 0) {
661
+ logActivity({
662
+ event: 'conflict_detected',
663
+ branch: targetBranch,
664
+ details: {
665
+ tier3: tier3Count,
666
+ tier2: tier2Count,
667
+ tier1: tier1Count,
668
+ conflicting_branches: conflicts.map(c => c.branch),
669
+ }
670
+ });
671
+ }
672
+ // Check for blocking conditions
673
+ const shouldBlock = options.strict && (tier3Count > 0 || tier2Count > 0);
674
+ // Deliver the alert if requested
675
+ if (options.notify && (tier3Count > 0 || tier2Count > 0)) {
676
+ await notifyConflicts(targetBranch, conflicts);
677
+ }
678
+ if (options.json) {
679
+ console.log(JSON.stringify({
680
+ branch: targetBranch,
681
+ conflicts,
682
+ crossMachine: cross.status,
683
+ summary: {
684
+ tier3: tier3Count,
685
+ tier2: tier2Count,
686
+ tier1: tier1Count,
687
+ blocked: shouldBlock
688
+ }
689
+ }, null, 2));
690
+ finish(shouldBlock ? 1 : 0);
691
+ return;
692
+ }
693
+ if (conflicts.length === 0) {
694
+ // Say what was CHECKED, not what we wish it proved. This path compares
695
+ // registered file lists; it never runs a merge. It is structurally blind
696
+ // to add/add and content conflicts against the base — so "no overlap"
697
+ // is not "merges cleanly", and claiming the latter is the lie this
698
+ // wording removes (PATTERN_silent_success, instance #5).
699
+ const compared = allBranches.filter((b) => b.name !== targetBranch && b.status === 'active').length;
700
+ console.log(`🕷️ No registry overlap for "${targetBranch}" (vs ${compared} registered branch${compared === 1 ? '' : 'es'})`);
701
+ console.log(' ℹ️ Registry-only — NOT a merge simulation. For merge readiness: spidersan conflicts --real');
702
+ // Never let a failed cross-machine check read as an all-clear — that
703
+ // silent false confidence is the whole bug this path fixes (tb-ly0b).
704
+ if (cross.degraded) {
705
+ console.error(' ⚠️ Cross-machine check unavailable — LOCAL branches only.');
706
+ }
707
+ else if (cross.status === 'no-credentials') {
708
+ // Same lie, quieter: no creds in this shell reads exactly like a clean cloud check.
709
+ console.log(' ℹ️ Cloud registry not checked (no Supabase credentials) — LOCAL branches only.');
710
+ }
711
+ finish(0);
712
+ return;
713
+ }
714
+ console.log(`
715
+ 🕷️ CONFLICT ANALYSIS: "${targetBranch}"
716
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
717
+ `);
718
+ for (const conflict of conflicts) {
719
+ console.log(`${conflict.tierInfo.icon} TIER ${conflict.tier} (${conflict.tierInfo.label}): ${conflict.branch}${conflict.sessionId ? ` (session ${conflict.sessionId})` : ''}`);
720
+ for (const file of conflict.files) {
721
+ const fileTier = classifyWithLabel(file, {
722
+ extraTier3: compiledHigh,
723
+ extraTier2: compiledMedium,
724
+ });
725
+ console.log(` ${fileTier.icon} ${file}`);
726
+ }
727
+ console.log(` → ${conflict.tierInfo.action}`);
728
+ console.log('');
729
+ }
730
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
731
+ console.log(`📊 Summary: 🔴 ${tier3Count} BLOCK | 🟠 ${tier2Count} PAUSE | 🟡 ${tier1Count} WARN`);
732
+ // Show semantic analysis results
733
+ if (options.semantic && semanticConflicts.length > 0) {
734
+ console.log(`\n🔬 SEMANTIC CONFLICTS DETECTED (${semanticConflicts.length} symbols):`);
735
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
736
+ for (const sc of semanticConflicts) {
737
+ console.log(` ⚡ ${sc.symbolType} '${sc.symbolName}'`);
738
+ console.log(` Modified in BOTH branches (different content)`);
739
+ }
740
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
741
+ console.log('\n💡 TIP: Coordinate on these specific functions/classes,');
742
+ console.log(' not just the files. One of you should rebase.');
743
+ if (semanticSkipped.length + semanticUnsupported.length > 0) {
744
+ console.log(` ⚠️ incomplete: ${semanticSkipped.length + semanticUnsupported.length} file(s) could not be analyzed, so more may exist.`);
745
+ }
746
+ }
747
+ else if (options.semantic && semanticSkipped.length + semanticUnsupported.length > 0) {
748
+ console.log(`\n🔬 SEMANTIC ANALYSIS: incomplete — ${semanticSkipped.length + semanticUnsupported.length} file(s) could not be analyzed; no verdict.`);
749
+ }
750
+ else if (options.semantic && semanticConflicts.length === 0) {
751
+ console.log('\n🔬 SEMANTIC ANALYSIS: No symbol-level conflicts!');
752
+ console.log(' Files overlap, but different functions were modified.');
753
+ console.log(' ✅ Likely safe to merge (git will auto-merge).');
754
+ }
755
+ if (tier3Count > 0) {
756
+ console.log(`
757
+ 🔴 TIER 3 CONFLICTS FOUND
758
+ These files are security-critical and MUST be resolved.
759
+ Merge is BLOCKED until conflicts are cleared.
760
+ `);
761
+ }
762
+ else if (tier2Count > 0) {
763
+ console.log(`
764
+ 🟠 TIER 2 CONFLICTS FOUND
765
+ Coordinate with the other agent before proceeding.
766
+ `);
767
+ }
768
+ if (shouldBlock) {
769
+ console.log('❌ Strict mode: Exiting with error due to TIER 2+ conflicts.');
770
+ }
771
+ finish(shouldBlock ? 1 : 0);
772
+ });
773
+ //# sourceMappingURL=conflicts.js.map