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