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.
- package/CHANGELOG.md +8 -0
- package/dist/bin/command-registry.d.ts +25 -0
- package/dist/bin/command-registry.d.ts.map +1 -0
- package/dist/bin/command-registry.js +29 -0
- package/dist/bin/command-registry.js.map +1 -0
- package/dist/bin/spidersan.d.ts +16 -0
- package/dist/bin/spidersan.d.ts.map +1 -0
- package/dist/bin/spidersan.js +98 -0
- package/dist/bin/spidersan.js.map +1 -0
- package/dist/commands/abandon.d.ts +8 -0
- package/dist/commands/abandon.d.ts.map +1 -0
- package/dist/commands/abandon.js +43 -0
- package/dist/commands/abandon.js.map +1 -0
- package/dist/commands/cleanup.d.ts +8 -0
- package/dist/commands/cleanup.d.ts.map +1 -0
- package/dist/commands/cleanup.js +50 -0
- package/dist/commands/cleanup.js.map +1 -0
- package/dist/commands/config.d.ts +15 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +309 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/conflicts.d.ts +34 -0
- package/dist/commands/conflicts.d.ts.map +1 -0
- package/dist/commands/conflicts.js +773 -0
- package/dist/commands/conflicts.js.map +1 -0
- package/dist/commands/context.d.ts +8 -0
- package/dist/commands/context.d.ts.map +1 -0
- package/dist/commands/context.js +104 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/cross-conflicts.d.ts +17 -0
- package/dist/commands/cross-conflicts.d.ts.map +1 -0
- package/dist/commands/cross-conflicts.js +273 -0
- package/dist/commands/cross-conflicts.js.map +1 -0
- package/dist/commands/depends.d.ts +11 -0
- package/dist/commands/depends.d.ts.map +1 -0
- package/dist/commands/depends.js +63 -0
- package/dist/commands/depends.js.map +1 -0
- package/dist/commands/doctor.d.ts +30 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +558 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/git-watch.d.ts +24 -0
- package/dist/commands/git-watch.d.ts.map +1 -0
- package/dist/commands/git-watch.js +84 -0
- package/dist/commands/git-watch.js.map +1 -0
- package/dist/commands/github-sync.d.ts +42 -0
- package/dist/commands/github-sync.d.ts.map +1 -0
- package/dist/commands/github-sync.js +191 -0
- package/dist/commands/github-sync.js.map +1 -0
- package/dist/commands/index.d.ts +25 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +29 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +23 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +45 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/log.d.ts +7 -0
- package/dist/commands/log.d.ts.map +1 -0
- package/dist/commands/log.js +110 -0
- package/dist/commands/log.js.map +1 -0
- package/dist/commands/merge-order.d.ts +9 -0
- package/dist/commands/merge-order.d.ts.map +1 -0
- package/dist/commands/merge-order.js +100 -0
- package/dist/commands/merge-order.js.map +1 -0
- package/dist/commands/merged.d.ts +8 -0
- package/dist/commands/merged.d.ts.map +1 -0
- package/dist/commands/merged.js +37 -0
- package/dist/commands/merged.js.map +1 -0
- package/dist/commands/pr-check.d.ts +24 -0
- package/dist/commands/pr-check.d.ts.map +1 -0
- package/dist/commands/pr-check.js +85 -0
- package/dist/commands/pr-check.js.map +1 -0
- package/dist/commands/ready-check.d.ts +11 -0
- package/dist/commands/ready-check.d.ts.map +1 -0
- package/dist/commands/ready-check.js +150 -0
- package/dist/commands/ready-check.js.map +1 -0
- package/dist/commands/rebase-helper.d.ts +3 -0
- package/dist/commands/rebase-helper.d.ts.map +1 -0
- package/dist/commands/rebase-helper.js +52 -0
- package/dist/commands/rebase-helper.js.map +1 -0
- package/dist/commands/register.d.ts +10 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +176 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/registry-sync.d.ts +9 -0
- package/dist/commands/registry-sync.d.ts.map +1 -0
- package/dist/commands/registry-sync.js +129 -0
- package/dist/commands/registry-sync.js.map +1 -0
- package/dist/commands/stale.d.ts +17 -0
- package/dist/commands/stale.d.ts.map +1 -0
- package/dist/commands/stale.js +136 -0
- package/dist/commands/stale.js.map +1 -0
- package/dist/commands/sync.d.ts +21 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +78 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/verify-trunk.d.ts +18 -0
- package/dist/commands/verify-trunk.d.ts.map +1 -0
- package/dist/commands/verify-trunk.js +77 -0
- package/dist/commands/verify-trunk.js.map +1 -0
- package/dist/commands/watch.d.ts +8 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/commands/watch.js +509 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/activity.d.ts +53 -0
- package/dist/lib/activity.d.ts.map +1 -0
- package/dist/lib/activity.js +271 -0
- package/dist/lib/activity.js.map +1 -0
- package/dist/lib/agent-errors.d.ts +41 -0
- package/dist/lib/agent-errors.d.ts.map +1 -0
- package/dist/lib/agent-errors.js +145 -0
- package/dist/lib/agent-errors.js.map +1 -0
- package/dist/lib/ai/context-builder.d.ts +16 -0
- package/dist/lib/ai/context-builder.d.ts.map +1 -0
- package/dist/lib/ai/context-builder.js +229 -0
- package/dist/lib/ai/context-builder.js.map +1 -0
- package/dist/lib/ai/event-handler.d.ts +21 -0
- package/dist/lib/ai/event-handler.d.ts.map +1 -0
- package/dist/lib/ai/event-handler.js +111 -0
- package/dist/lib/ai/event-handler.js.map +1 -0
- package/dist/lib/ai/index.d.ts +13 -0
- package/dist/lib/ai/index.d.ts.map +1 -0
- package/dist/lib/ai/index.js +11 -0
- package/dist/lib/ai/index.js.map +1 -0
- package/dist/lib/ai/llm-client.d.ts +37 -0
- package/dist/lib/ai/llm-client.d.ts.map +1 -0
- package/dist/lib/ai/llm-client.js +225 -0
- package/dist/lib/ai/llm-client.js.map +1 -0
- package/dist/lib/ai/reasoner.d.ts +11 -0
- package/dist/lib/ai/reasoner.d.ts.map +1 -0
- package/dist/lib/ai/reasoner.js +227 -0
- package/dist/lib/ai/reasoner.js.map +1 -0
- package/dist/lib/ai/setup.d.ts +40 -0
- package/dist/lib/ai/setup.d.ts.map +1 -0
- package/dist/lib/ai/setup.js +154 -0
- package/dist/lib/ai/setup.js.map +1 -0
- package/dist/lib/ai/types.d.ts +135 -0
- package/dist/lib/ai/types.d.ts.map +1 -0
- package/dist/lib/ai/types.js +42 -0
- package/dist/lib/ai/types.js.map +1 -0
- package/dist/lib/ast.d.ts +44 -0
- package/dist/lib/ast.d.ts.map +1 -0
- package/dist/lib/ast.js +160 -0
- package/dist/lib/ast.js.map +1 -0
- package/dist/lib/carries.d.ts +22 -0
- package/dist/lib/carries.d.ts.map +1 -0
- package/dist/lib/carries.js +56 -0
- package/dist/lib/carries.js.map +1 -0
- package/dist/lib/colony-subscriber.d.ts +56 -0
- package/dist/lib/colony-subscriber.d.ts.map +1 -0
- package/dist/lib/colony-subscriber.js +265 -0
- package/dist/lib/colony-subscriber.js.map +1 -0
- package/dist/lib/config.d.ts +62 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +235 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/conflict-analyzer.d.ts +33 -0
- package/dist/lib/conflict-analyzer.d.ts.map +1 -0
- package/dist/lib/conflict-analyzer.js +114 -0
- package/dist/lib/conflict-analyzer.js.map +1 -0
- package/dist/lib/conflict-renderer.d.ts +7 -0
- package/dist/lib/conflict-renderer.d.ts.map +1 -0
- package/dist/lib/conflict-renderer.js +145 -0
- package/dist/lib/conflict-renderer.js.map +1 -0
- package/dist/lib/conflict-tier.d.ts +20 -0
- package/dist/lib/conflict-tier.d.ts.map +1 -0
- package/dist/lib/conflict-tier.js +49 -0
- package/dist/lib/conflict-tier.js.map +1 -0
- package/dist/lib/crypto.d.ts +71 -0
- package/dist/lib/crypto.d.ts.map +1 -0
- package/dist/lib/crypto.js +176 -0
- package/dist/lib/crypto.js.map +1 -0
- package/dist/lib/ecosystem-env.d.ts +33 -0
- package/dist/lib/ecosystem-env.d.ts.map +1 -0
- package/dist/lib/ecosystem-env.js +62 -0
- package/dist/lib/ecosystem-env.js.map +1 -0
- package/dist/lib/errors.d.ts +35 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +71 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/git-events-subscriber.d.ts +86 -0
- package/dist/lib/git-events-subscriber.d.ts.map +1 -0
- package/dist/lib/git-events-subscriber.js +870 -0
- package/dist/lib/git-events-subscriber.js.map +1 -0
- package/dist/lib/git-merge-analyzer.d.ts +38 -0
- package/dist/lib/git-merge-analyzer.d.ts.map +1 -0
- package/dist/lib/git-merge-analyzer.js +23 -0
- package/dist/lib/git-merge-analyzer.js.map +1 -0
- package/dist/lib/git.d.ts +103 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +670 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/github.d.ts +151 -0
- package/dist/lib/github.d.ts.map +1 -0
- package/dist/lib/github.js +425 -0
- package/dist/lib/github.js.map +1 -0
- package/dist/lib/graph.d.ts +38 -0
- package/dist/lib/graph.d.ts.map +1 -0
- package/dist/lib/graph.js +165 -0
- package/dist/lib/graph.js.map +1 -0
- package/dist/lib/machine.d.ts +14 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/machine.js +38 -0
- package/dist/lib/machine.js.map +1 -0
- package/dist/lib/notify.d.ts +68 -0
- package/dist/lib/notify.d.ts.map +1 -0
- package/dist/lib/notify.js +111 -0
- package/dist/lib/notify.js.map +1 -0
- package/dist/lib/outline.d.ts +6 -0
- package/dist/lib/outline.d.ts.map +1 -0
- package/dist/lib/outline.js +60 -0
- package/dist/lib/outline.js.map +1 -0
- package/dist/lib/push-registry.d.ts +22 -0
- package/dist/lib/push-registry.d.ts.map +1 -0
- package/dist/lib/push-registry.js +21 -0
- package/dist/lib/push-registry.js.map +1 -0
- package/dist/lib/reconcile.d.ts +100 -0
- package/dist/lib/reconcile.d.ts.map +1 -0
- package/dist/lib/reconcile.js +121 -0
- package/dist/lib/reconcile.js.map +1 -0
- package/dist/lib/regex-utils.d.ts +6 -0
- package/dist/lib/regex-utils.d.ts.map +1 -0
- package/dist/lib/regex-utils.js +24 -0
- package/dist/lib/regex-utils.js.map +1 -0
- package/dist/lib/register-renderer.d.ts +10 -0
- package/dist/lib/register-renderer.d.ts.map +1 -0
- package/dist/lib/register-renderer.js +19 -0
- package/dist/lib/register-renderer.js.map +1 -0
- package/dist/lib/remote-drift.d.ts +61 -0
- package/dist/lib/remote-drift.d.ts.map +1 -0
- package/dist/lib/remote-drift.js +227 -0
- package/dist/lib/remote-drift.js.map +1 -0
- package/dist/lib/security.d.ts +72 -0
- package/dist/lib/security.d.ts.map +1 -0
- package/dist/lib/security.js +180 -0
- package/dist/lib/security.js.map +1 -0
- package/dist/lib/semantic-analysis.d.ts +31 -0
- package/dist/lib/semantic-analysis.d.ts.map +1 -0
- package/dist/lib/semantic-analysis.js +120 -0
- package/dist/lib/semantic-analysis.js.map +1 -0
- package/dist/lib/supabase-credentials.d.ts +36 -0
- package/dist/lib/supabase-credentials.d.ts.map +1 -0
- package/dist/lib/supabase-credentials.js +43 -0
- package/dist/lib/supabase-credentials.js.map +1 -0
- package/dist/lib/trunk.d.ts +39 -0
- package/dist/lib/trunk.d.ts.map +1 -0
- package/dist/lib/trunk.js +116 -0
- package/dist/lib/trunk.js.map +1 -0
- package/dist/lib/update-check.d.ts +11 -0
- package/dist/lib/update-check.d.ts.map +1 -0
- package/dist/lib/update-check.js +113 -0
- package/dist/lib/update-check.js.map +1 -0
- package/dist/lib/watch-renderer.d.ts +13 -0
- package/dist/lib/watch-renderer.d.ts.map +1 -0
- package/dist/lib/watch-renderer.js +35 -0
- package/dist/lib/watch-renderer.js.map +1 -0
- package/dist/storage/adapter.d.ts +81 -0
- package/dist/storage/adapter.d.ts.map +1 -0
- package/dist/storage/adapter.js +8 -0
- package/dist/storage/adapter.js.map +1 -0
- package/dist/storage/branch-registry-store.d.ts +13 -0
- package/dist/storage/branch-registry-store.d.ts.map +1 -0
- package/dist/storage/branch-registry-store.js +2 -0
- package/dist/storage/branch-registry-store.js.map +1 -0
- package/dist/storage/factory.d.ts +6 -0
- package/dist/storage/factory.d.ts.map +1 -0
- package/dist/storage/factory.js +14 -0
- package/dist/storage/factory.js.map +1 -0
- package/dist/storage/index.d.ts +10 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +11 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/json-branch-registry-store.d.ts +19 -0
- package/dist/storage/json-branch-registry-store.d.ts.map +1 -0
- package/dist/storage/json-branch-registry-store.js +138 -0
- package/dist/storage/json-branch-registry-store.js.map +1 -0
- package/dist/storage/local.d.ts +20 -0
- package/dist/storage/local.d.ts.map +1 -0
- package/dist/storage/local.js +40 -0
- package/dist/storage/local.js.map +1 -0
- package/dist/storage/memory-branch-registry-store.d.ts +16 -0
- package/dist/storage/memory-branch-registry-store.d.ts.map +1 -0
- package/dist/storage/memory-branch-registry-store.js +68 -0
- package/dist/storage/memory-branch-registry-store.js.map +1 -0
- package/dist/storage/supabase-registry-sync-client-impl.d.ts +40 -0
- package/dist/storage/supabase-registry-sync-client-impl.d.ts.map +1 -0
- package/dist/storage/supabase-registry-sync-client-impl.js +278 -0
- package/dist/storage/supabase-registry-sync-client-impl.js.map +1 -0
- package/dist/storage/supabase-registry-sync-client.d.ts +9 -0
- package/dist/storage/supabase-registry-sync-client.d.ts.map +1 -0
- package/dist/storage/supabase-registry-sync-client.js +2 -0
- package/dist/storage/supabase-registry-sync-client.js.map +1 -0
- package/dist/storage/supabase.d.ts +28 -0
- package/dist/storage/supabase.d.ts.map +1 -0
- package/dist/storage/supabase.js +56 -0
- package/dist/storage/supabase.js.map +1 -0
- package/dist/types/cloud.d.ts +102 -0
- package/dist/types/cloud.d.ts.map +1 -0
- package/dist/types/cloud.js +13 -0
- package/dist/types/cloud.js.map +1 -0
- 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
|