@bridge_gpt/mcp-server 0.2.16 → 0.2.18
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/build/agents.generated.js +2 -2
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +191 -11
- package/build/conductor/claude-hook.js +22 -4
- package/build/conductor/cli.js +11 -13
- package/build/conductor/done-gate.js +5 -0
- package/build/conductor/epic-reconcile.js +62 -13
- package/build/conductor/epic-runtime.js +447 -35
- package/build/conductor/epic-state.js +517 -63
- package/build/conductor/errors.js +41 -0
- package/build/conductor/event-accessors.js +234 -0
- package/build/conductor/file-scope-guard.js +201 -0
- package/build/conductor/github-mergeability.js +85 -0
- package/build/conductor/local-merge.js +47 -1
- package/build/conductor/merge-identity.js +41 -0
- package/build/conductor/merge-ledger.js +13 -68
- package/build/conductor/plan.js +12 -2
- package/build/conductor/pr-discovery.js +11 -1
- package/build/conductor/supervisor-config.js +4 -39
- package/build/conductor/supervisor-escalation.js +10 -26
- package/build/conductor/supervisor-ledger.js +5 -12
- package/build/conductor/supervisor-message-relay.js +2 -5
- package/build/conductor/supervisor-notification.js +1 -1
- package/build/conductor/supervisor-runtime.js +12 -54
- package/build/conductor/supervisor-state.js +4 -18
- package/build/conductor/supervisor-types.js +2 -2
- package/build/conductor/taxonomy.js +4 -0
- package/build/conductor-bin.js +2333 -666
- package/build/conductor-claude-hook-bin.js +4 -2
- package/build/doctor.js +32 -0
- package/build/index.js +10125 -8522
- package/build/install-bridge.js +25 -8
- package/build/install-doctor.js +387 -0
- package/build/pipelines.generated.js +30 -5
- package/build/regression-check.js +53 -1
- package/build/review-tickets.js +175 -21
- package/build/start-tickets-conductor.js +22 -6
- package/build/start-tickets-prereqs.js +33 -3
- package/build/start-tickets.js +122 -22
- package/build/version.generated.js +1 -1
- package/package.json +5 -5
- package/pipelines/review-ticket.json +24 -2
- package/public/css/main.min.css +3272 -1
- package/public/css/main.min.css.map +1 -1
- package/smoke-test/SMOKE-TEST.md +4 -2
|
@@ -10,6 +10,43 @@
|
|
|
10
10
|
* generic message.
|
|
11
11
|
*/
|
|
12
12
|
import { redactSecretString } from "./redaction.js";
|
|
13
|
+
/**
|
|
14
|
+
* Redact a diagnostic string destined for BACKEND LOGS ONLY (never the client
|
|
15
|
+
* envelope): strip secret-shaped material (tokens, keys, bearer creds) via
|
|
16
|
+
* {@link redactSecretString}, then additionally mask user home-dir path segments
|
|
17
|
+
* (`/Users/<name>/…`, `/home/<name>/…`) which leak usernames and credential-bearing
|
|
18
|
+
* locations. The rest of a stack trace is preserved so operators can still diagnose.
|
|
19
|
+
*/
|
|
20
|
+
function redactDiagnostic(value) {
|
|
21
|
+
return redactSecretString(value).replace(/(\/(?:Users|home)\/)[^/\s:]+/g, "$1[REDACTED_USER]");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* FINDING 5 (BAPI-463): surface an unexpected (INTERNAL_ERROR) error with its
|
|
25
|
+
* class + message (redacted so no secret or credential-bearing path leaks) so
|
|
26
|
+
* operators can root-cause the underlying fault that the sanitized client
|
|
27
|
+
* envelope intentionally hides (e.g. the recurring `check_messages` failure).
|
|
28
|
+
*
|
|
29
|
+
* Emitted as a SINGLE LINE with NO stack frames: the conductor's `console.error`
|
|
30
|
+
* IS the CLI's stderr surface, which a committed security invariant requires to
|
|
31
|
+
* stay stack-trace-free (`security-regressions.test.ts`). The error class +
|
|
32
|
+
* message is the diagnostic essence the acceptance criterion calls for; a raw
|
|
33
|
+
* stack would both violate that invariant and risk leaking internal file paths.
|
|
34
|
+
* Best-effort: a logging failure must never mask the original error, and NOTHING
|
|
35
|
+
* here is returned to the MCP/CLI caller.
|
|
36
|
+
*/
|
|
37
|
+
function logInternalErrorDiagnostic(error) {
|
|
38
|
+
try {
|
|
39
|
+
const name = error instanceof Error ? error.name : typeof error;
|
|
40
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
41
|
+
// Collapse any newlines so the single-line diagnostic can never resemble a
|
|
42
|
+
// multi-line stack trace to the CLI-surface guard.
|
|
43
|
+
const message = redactDiagnostic(rawMessage).replace(/\s*\n\s*/g, " ");
|
|
44
|
+
console.error(`[conductor:INTERNAL_ERROR] [${name}] ${message}`);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* diagnostic logging must never throw */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
13
50
|
/** Raised when caller input violates a conductor validation rule. */
|
|
14
51
|
export class ConductorValidationError extends Error {
|
|
15
52
|
constructor(message) {
|
|
@@ -89,6 +126,10 @@ export function toConductorErrorEnvelope(error) {
|
|
|
89
126
|
message: "Conductor ledger is busy; retry shortly.",
|
|
90
127
|
};
|
|
91
128
|
}
|
|
129
|
+
// Unexpected error: log the redacted class + message + stack to backend logs so
|
|
130
|
+
// the real cause is diagnosable, then return the fixed generic client envelope
|
|
131
|
+
// (no trace context, no secret material) to the MCP/CLI caller.
|
|
132
|
+
logInternalErrorDiagnostic(error);
|
|
92
133
|
return {
|
|
93
134
|
error: "INTERNAL_ERROR",
|
|
94
135
|
status: 500,
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical conductor event payload model + typed, fail-closed read accessors
|
|
3
|
+
* (BAPI-493).
|
|
4
|
+
*
|
|
5
|
+
* The stored ledger envelope is deliberately loose (`ConductorEventData.details?:
|
|
6
|
+
* unknown`), so every consumer historically re-parsed `data.details` by hand and
|
|
7
|
+
* producers/consumers silently drifted on field location and nesting. This module
|
|
8
|
+
* makes the read side type-enforced:
|
|
9
|
+
*
|
|
10
|
+
* - {@link EventDetailsByType} maps EVERY {@link SemanticEventType} to an explicit
|
|
11
|
+
* details payload shape (details-less families get a conservative empty payload),
|
|
12
|
+
* with `never`-based exhaustiveness guards that fail the TypeScript build if a
|
|
13
|
+
* new semantic type is added without a payload member.
|
|
14
|
+
* - {@link getEventDetails} is discriminant-constrained (E-27): it validates
|
|
15
|
+
* `event.type === expectedType` at RUNTIME before narrowing and returns `null`
|
|
16
|
+
* on mismatch, so a caller can never obtain a payload for the wrong type.
|
|
17
|
+
* - {@link getHeadSha} / {@link getMergeIdentity} are the behavior-critical
|
|
18
|
+
* accessors that fail closed (a head/identity that cannot be proven is never
|
|
19
|
+
* treated as valid), matching the pre-refactor `extractEventHeadSha` /
|
|
20
|
+
* `extractMergeActionIdentityFromGateEvent` behavior byte-for-byte.
|
|
21
|
+
*
|
|
22
|
+
* This is READ-SIDE typing only: the stored `data_json` envelope shape is unchanged
|
|
23
|
+
* and there is NO ledger migration. The module depends only on lower-level,
|
|
24
|
+
* dependency-light modules (`taxonomy`, `types`, `git-ci-types`, `merge-identity`)
|
|
25
|
+
* so it can be imported by both `epic-state.ts` and `merge-ledger.ts` without a
|
|
26
|
+
* cycle. It is NEVER applied to backend HTTP response envelopes (which use a
|
|
27
|
+
* `detail` wrapper, not `data.details`) — those keep their own normalizers.
|
|
28
|
+
*/
|
|
29
|
+
import { normalizePrNumber, normalizeRepoName, normalizeSha, } from "./git-ci-types.js";
|
|
30
|
+
import { buildGateIdentity, makeMergeActionKey, } from "./merge-identity.js";
|
|
31
|
+
// Re-export the merge identity primitives so downstream consumers can import the
|
|
32
|
+
// whole merge-identity surface from a single typed-accessor entry point.
|
|
33
|
+
export { buildGateIdentity, makeMergeActionKey };
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Small structural helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/** Narrow an unknown value to a plain, indexable object. */
|
|
38
|
+
function isPlainObject(value) {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
/** Runtime exhaustiveness guard for switch/dispatch fall-through. */
|
|
42
|
+
export function assertNever(value) {
|
|
43
|
+
throw new Error(`Unexpected value: ${String(value)}`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Normalize a PR head SHA read from a conductor ledger event's
|
|
47
|
+
* `data.details.head_sha`. Preserves the exact behavior of the pre-refactor
|
|
48
|
+
* `epic-state.extractEventHeadSha`: accept only a trimmed 7–40 character
|
|
49
|
+
* hexadecimal string and return it lowercased; every other shape yields `null`.
|
|
50
|
+
* Deliberately looser than {@link normalizeSha} (which requires a full 40/64-char
|
|
51
|
+
* object id) because head observations may carry an abbreviated SHA.
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeEventHeadSha(value) {
|
|
54
|
+
if (typeof value !== "string")
|
|
55
|
+
return null;
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
if (!/^[0-9a-f]{7,40}$/i.test(trimmed))
|
|
58
|
+
return null;
|
|
59
|
+
return trimmed.toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
/** Read ONLY `event.data.details` as a plain object, or `null`. */
|
|
62
|
+
function getRawEventDetails(event) {
|
|
63
|
+
const details = event.data?.details;
|
|
64
|
+
return isPlainObject(details) ? details : null;
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Per-type parsers
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/** The conservative empty payload shared by all details-less families. */
|
|
70
|
+
const EMPTY_DETAILS = Object.freeze({});
|
|
71
|
+
/** Parse a head-observation payload: read only `details.head_sha`, fail closed. */
|
|
72
|
+
function parseHeadObservation(event) {
|
|
73
|
+
const details = getRawEventDetails(event);
|
|
74
|
+
return { head_sha: details ? normalizeEventHeadSha(details.head_sha) : null };
|
|
75
|
+
}
|
|
76
|
+
/** Parse a `merge.*` lifecycle payload: `action_key` string or null. */
|
|
77
|
+
function parseMergeLifecycle(event) {
|
|
78
|
+
const details = getRawEventDetails(event);
|
|
79
|
+
const actionKey = details && typeof details.action_key === "string" && details.action_key.trim().length > 0
|
|
80
|
+
? details.action_key.trim()
|
|
81
|
+
: null;
|
|
82
|
+
return { action_key: actionKey };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Parse the canonical `gate.met` payload. `head_sha`/`repo`/`pr_number` use the
|
|
86
|
+
* STRICT normalizers (a merge binding requires a full SHA); `required_checks`
|
|
87
|
+
* preserves the top-level → nested `ci_check_status.required_checks` dual path.
|
|
88
|
+
*/
|
|
89
|
+
function parseGateMet(event) {
|
|
90
|
+
const details = getRawEventDetails(event);
|
|
91
|
+
if (!details) {
|
|
92
|
+
return {
|
|
93
|
+
head_sha: null,
|
|
94
|
+
repo: null,
|
|
95
|
+
pr_number: null,
|
|
96
|
+
gate_name: null,
|
|
97
|
+
config_hash: null,
|
|
98
|
+
required_checks: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const gateName = typeof details.gate_name === "string" && details.gate_name.trim().length > 0
|
|
102
|
+
? details.gate_name.trim()
|
|
103
|
+
: null;
|
|
104
|
+
const configHash = typeof details.config_hash === "string" && details.config_hash.trim().length > 0
|
|
105
|
+
? details.config_hash.trim()
|
|
106
|
+
: null;
|
|
107
|
+
const ciCheckStatus = isPlainObject(details.ci_check_status) ? details.ci_check_status : null;
|
|
108
|
+
const rawRequiredChecks = Array.isArray(details.required_checks)
|
|
109
|
+
? details.required_checks
|
|
110
|
+
: ciCheckStatus && Array.isArray(ciCheckStatus.required_checks)
|
|
111
|
+
? ciCheckStatus.required_checks
|
|
112
|
+
: [];
|
|
113
|
+
const requiredChecks = rawRequiredChecks.filter((c) => typeof c === "string" && c.trim().length > 0);
|
|
114
|
+
return {
|
|
115
|
+
head_sha: normalizeSha(details.head_sha),
|
|
116
|
+
repo: normalizeRepoName(details.repo),
|
|
117
|
+
pr_number: normalizePrNumber(details.pr_number),
|
|
118
|
+
gate_name: gateName,
|
|
119
|
+
config_hash: configHash,
|
|
120
|
+
required_checks: requiredChecks,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Parse a spec-review verdict payload (conservative head read). */
|
|
124
|
+
function parseSpecReview(event) {
|
|
125
|
+
const details = getRawEventDetails(event);
|
|
126
|
+
return { head_sha: details ? normalizeEventHeadSha(details.head_sha) : null };
|
|
127
|
+
}
|
|
128
|
+
/** Return the shared conservative empty payload for details-less families. */
|
|
129
|
+
function parseEmpty() {
|
|
130
|
+
return EMPTY_DETAILS;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Exhaustive parser table keyed by every {@link SemanticEventType}. There is no
|
|
134
|
+
* catch-all default branch that returns raw `data.details` for an unrecognized
|
|
135
|
+
* type — a missing key breaks the `satisfies` check and fails the build.
|
|
136
|
+
*/
|
|
137
|
+
const EVENT_PARSERS = {
|
|
138
|
+
"run.started": parseEmpty,
|
|
139
|
+
"run.heartbeat": parseEmpty,
|
|
140
|
+
"run.stopped": parseEmpty,
|
|
141
|
+
"agent.notification": parseEmpty,
|
|
142
|
+
"tool.intent": parseEmpty,
|
|
143
|
+
"worktree.changed": parseEmpty,
|
|
144
|
+
"git.commit_created": parseEmpty,
|
|
145
|
+
"git.pr_opened": parseHeadObservation,
|
|
146
|
+
"ci.passed": parseHeadObservation,
|
|
147
|
+
"ci.failed": parseHeadObservation,
|
|
148
|
+
"gate.met": parseGateMet,
|
|
149
|
+
"supervisor.assessment": parseEmpty,
|
|
150
|
+
"message.sent": parseEmpty,
|
|
151
|
+
"message.delivered": parseEmpty,
|
|
152
|
+
"message.acked": parseEmpty,
|
|
153
|
+
"merge.dry_run": parseMergeLifecycle,
|
|
154
|
+
"merge.attempted": parseMergeLifecycle,
|
|
155
|
+
"merge.succeeded": parseHeadObservation,
|
|
156
|
+
"merge.failed": parseMergeLifecycle,
|
|
157
|
+
"merge.conflict": parseHeadObservation,
|
|
158
|
+
"merge.pending_approval": parseMergeLifecycle,
|
|
159
|
+
"review.passed": parseHeadObservation,
|
|
160
|
+
"review.changes_requested": parseHeadObservation,
|
|
161
|
+
"spec_review.passed": parseSpecReview,
|
|
162
|
+
"spec_review.changes_requested": parseSpecReview,
|
|
163
|
+
"parse.triggered": parseEmpty,
|
|
164
|
+
};
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Public accessors
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
/**
|
|
169
|
+
* Typed, discriminant-constrained details accessor (E-27). Returns the parsed
|
|
170
|
+
* payload for `expectedType` ONLY when `event.type === expectedType` at runtime;
|
|
171
|
+
* returns `null` on any discriminant mismatch so a caller can never obtain a
|
|
172
|
+
* `PayloadFor<T>` by passing the wrong expected type. Details-less families return
|
|
173
|
+
* a conservative empty payload (never the raw `unknown` details).
|
|
174
|
+
*/
|
|
175
|
+
export function getEventDetails(event, expectedType) {
|
|
176
|
+
if (event.type !== expectedType)
|
|
177
|
+
return null;
|
|
178
|
+
const parser = EVENT_PARSERS[expectedType];
|
|
179
|
+
return parser(event);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read and normalize a PR head SHA from the CANONICAL `event.data.details.head_sha`
|
|
183
|
+
* path only. No fallback locations are consulted — a valid-looking head at
|
|
184
|
+
* `data.head_sha` or a nested snapshot is deliberately ignored. Returns a lowercased
|
|
185
|
+
* 7–40 char hex SHA, or `null` for missing/non-object details, non-string,
|
|
186
|
+
* malformed, or out-of-range values. Fail-closed: a head that cannot be proven is
|
|
187
|
+
* never treated as valid.
|
|
188
|
+
*/
|
|
189
|
+
export function getHeadSha(event) {
|
|
190
|
+
const details = getRawEventDetails(event);
|
|
191
|
+
if (!details)
|
|
192
|
+
return null;
|
|
193
|
+
return normalizeEventHeadSha(details.head_sha);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the immutable {@link MergeActionIdentity} from a worker-scoped `gate.met`
|
|
197
|
+
* event. Returns `null` unless the event is a `gate.met` carrying a complete PR
|
|
198
|
+
* binding (repo, pr_number, head_sha, gate_name) and a non-empty `worker_id`.
|
|
199
|
+
* Run-level or incomplete events yield `null`. Preserves the historical
|
|
200
|
+
* `required_checks` dual path (top-level then nested `ci_check_status`) and never
|
|
201
|
+
* throws. Branch-name fields, if present, are ignored.
|
|
202
|
+
*/
|
|
203
|
+
export function getMergeIdentity(event) {
|
|
204
|
+
if (event.type !== "gate.met")
|
|
205
|
+
return null;
|
|
206
|
+
// Worker scope is required — a run-level gate.met never binds a specific worker.
|
|
207
|
+
if (typeof event.worker_id !== "string" || event.worker_id.trim().length === 0) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const details = getEventDetails(event, "gate.met");
|
|
211
|
+
if (details === null)
|
|
212
|
+
return null;
|
|
213
|
+
const { repo, pr_number: prNumber, head_sha: headSha, gate_name: gateName } = details;
|
|
214
|
+
if (repo === null || prNumber === null || headSha === null || gateName === null) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const gateIdentity = buildGateIdentity(gateName, details.config_hash);
|
|
218
|
+
const actionKey = makeMergeActionKey(repo, prNumber, headSha, gateIdentity);
|
|
219
|
+
return {
|
|
220
|
+
repo,
|
|
221
|
+
pr_number: prNumber,
|
|
222
|
+
head_sha: headSha,
|
|
223
|
+
gate_name: gateName,
|
|
224
|
+
config_hash: details.config_hash,
|
|
225
|
+
required_checks: details.required_checks,
|
|
226
|
+
gate_identity: gateIdentity,
|
|
227
|
+
action_key: actionKey,
|
|
228
|
+
gate_event: {
|
|
229
|
+
id: typeof event.id === "string" ? event.id : undefined,
|
|
230
|
+
seq: typeof event.seq === "number" ? event.seq : undefined,
|
|
231
|
+
time: typeof event.time === "string" ? event.time : undefined,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BAPI-507 (N-2): warn-only worker file-scope guard.
|
|
3
|
+
*
|
|
4
|
+
* Compares a branch's changed files against the planner-declared touched-file
|
|
5
|
+
* set (delivered to the worker via `BAPI_CONDUCTOR_DECLARED_TOUCHED_FILES_JSON`)
|
|
6
|
+
* and warns when the diff strays outside that set. It is strictly warn-only /
|
|
7
|
+
* fail-open: it NEVER blocks PR creation, mutates the branch, or exits non-zero.
|
|
8
|
+
* A worker that implements a sibling's files (the N-2 over-reach observed in the
|
|
9
|
+
* BAPI-488 run) surfaces here as a bounded, secret-free warning rather than a
|
|
10
|
+
* silent clobber.
|
|
11
|
+
*
|
|
12
|
+
* Byte-identical false-positive protection: the guard analyzes ONLY the
|
|
13
|
+
* branch-local diff against `origin/main` (`git diff --name-only
|
|
14
|
+
* origin/main...HEAD`), which excludes files that are byte-identical to
|
|
15
|
+
* `origin/main`. So a legitimate `git merge origin/main` that carries
|
|
16
|
+
* sibling-owned files in branch history but does not change them at the tip
|
|
17
|
+
* produces no false positive.
|
|
18
|
+
*
|
|
19
|
+
* All subprocess calls are list-based `spawnSync` with `shell: false` — no shell
|
|
20
|
+
* string is ever constructed.
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
/** Env var carrying the compact JSON array of declared repo-relative paths. */
|
|
24
|
+
export const DECLARED_TOUCHED_FILES_ENV = "BAPI_CONDUCTOR_DECLARED_TOUCHED_FILES_JSON";
|
|
25
|
+
/** Env var carrying the dispatched ticket key (used only to label warnings). */
|
|
26
|
+
export const FILE_SCOPE_GUARD_TICKET_KEY_ENV = "BAPI_CONDUCTOR_TICKET_KEY";
|
|
27
|
+
/** Base ref the branch diff is computed against. */
|
|
28
|
+
export const FILE_SCOPE_GUARD_BASE_REF = "origin/main";
|
|
29
|
+
/**
|
|
30
|
+
* Normalize a single declared/changed path to a repo-relative POSIX path, or
|
|
31
|
+
* `null` when it is unsafe/unusable. Rejects blank paths, POSIX- and
|
|
32
|
+
* Windows-absolute paths, and any parent traversal (`..`). Converts `\` → `/`
|
|
33
|
+
* and strips a single leading `./`. Never consults the filesystem.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeRepoRelativePath(input) {
|
|
36
|
+
if (typeof input !== "string")
|
|
37
|
+
return null;
|
|
38
|
+
const trimmed = input.trim();
|
|
39
|
+
if (trimmed.length === 0)
|
|
40
|
+
return null;
|
|
41
|
+
// Reject absolute paths (POSIX `/…`, `\…`, or Windows drive `C:\…` / `C:/…`)
|
|
42
|
+
// BEFORE separator normalization so both separator styles are caught.
|
|
43
|
+
if (trimmed.startsWith("/") ||
|
|
44
|
+
trimmed.startsWith("\\") ||
|
|
45
|
+
/^[A-Za-z]:[\\/]/.test(trimmed)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
let p = trimmed.replace(/\\/g, "/");
|
|
49
|
+
if (p.startsWith("./"))
|
|
50
|
+
p = p.slice(2);
|
|
51
|
+
const segments = p.split("/");
|
|
52
|
+
if (segments.some((s) => s === ".."))
|
|
53
|
+
return null;
|
|
54
|
+
const cleaned = segments.filter((s) => s !== "" && s !== ".").join("/");
|
|
55
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Normalize a raw list of declared paths: drop non-strings and unsafe paths,
|
|
59
|
+
* dedupe, and sort. Anything that is not an array yields an empty list. This is
|
|
60
|
+
* the single source of truth for the declared-file normalization contract shared
|
|
61
|
+
* by the env producer (`buildEpicIdentityEnv`), the runtime plan mapper, and the
|
|
62
|
+
* guard's env parser.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeDeclaredTouchedFiles(list) {
|
|
65
|
+
if (!Array.isArray(list))
|
|
66
|
+
return [];
|
|
67
|
+
const out = new Set();
|
|
68
|
+
for (const item of list) {
|
|
69
|
+
const norm = normalizeRepoRelativePath(item);
|
|
70
|
+
if (norm)
|
|
71
|
+
out.add(norm);
|
|
72
|
+
}
|
|
73
|
+
return Array.from(out).sort();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse `BAPI_CONDUCTOR_DECLARED_TOUCHED_FILES_JSON` into a normalized
|
|
77
|
+
* declaration. Returns an "unspecified" result on a missing, blank, invalid-JSON,
|
|
78
|
+
* non-array, or empty (after normalization) value — the guard no-ops in every
|
|
79
|
+
* such case.
|
|
80
|
+
*/
|
|
81
|
+
export function parseDeclaredTouchedFilesFromEnv(env = process.env) {
|
|
82
|
+
const raw = env[DECLARED_TOUCHED_FILES_ENV];
|
|
83
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
84
|
+
return { specified: false };
|
|
85
|
+
}
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(raw);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { specified: false };
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(parsed))
|
|
94
|
+
return { specified: false };
|
|
95
|
+
const files = normalizeDeclaredTouchedFiles(parsed);
|
|
96
|
+
if (files.length === 0)
|
|
97
|
+
return { specified: false };
|
|
98
|
+
return { specified: true, files };
|
|
99
|
+
}
|
|
100
|
+
const defaultSpawnSync = (command, args, options) => spawnSync(command, args, options);
|
|
101
|
+
/**
|
|
102
|
+
* Collect the files changed on the current branch relative to `origin/main`
|
|
103
|
+
* using a list-based `git diff --name-only origin/main...HEAD` invocation with
|
|
104
|
+
* `shell: false`. Byte-identical files (unchanged at the branch tip) are excluded
|
|
105
|
+
* by git itself. Any spawn error / non-zero exit yields `{ ok: false }` so the
|
|
106
|
+
* caller can fail open. Never spawns a shell.
|
|
107
|
+
*/
|
|
108
|
+
export function collectBranchChangedFiles(opts = {}) {
|
|
109
|
+
const baseRef = opts.baseRef ?? FILE_SCOPE_GUARD_BASE_REF;
|
|
110
|
+
const spawn = opts.spawnSyncFn ?? defaultSpawnSync;
|
|
111
|
+
let result;
|
|
112
|
+
try {
|
|
113
|
+
result = spawn("git", ["diff", "--name-only", `${baseRef}...HEAD`], {
|
|
114
|
+
cwd: opts.cwd,
|
|
115
|
+
encoding: "utf-8",
|
|
116
|
+
shell: false,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return { ok: false, files: [] };
|
|
121
|
+
}
|
|
122
|
+
if (result.error || result.status !== 0) {
|
|
123
|
+
return { ok: false, files: [] };
|
|
124
|
+
}
|
|
125
|
+
const stdout = typeof result.stdout === "string"
|
|
126
|
+
? result.stdout
|
|
127
|
+
: result.stdout?.toString("utf-8") ?? "";
|
|
128
|
+
const files = [];
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const line of stdout.split("\n")) {
|
|
131
|
+
const norm = normalizeRepoRelativePath(line);
|
|
132
|
+
if (norm && !seen.has(norm)) {
|
|
133
|
+
seen.add(norm);
|
|
134
|
+
files.push(norm);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, files };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Compare `changedFiles` against a declaration using EXACT-PATH matching (v1: no
|
|
141
|
+
* directory-ownership inference). Returns `checked:false`/no warning when the
|
|
142
|
+
* declaration is unspecified. Otherwise flags every changed file not in the
|
|
143
|
+
* declared set as out-of-scope and builds a bounded, secret-free warning naming
|
|
144
|
+
* the ticket, the out-of-scope files, and the declared set size.
|
|
145
|
+
*/
|
|
146
|
+
export function analyzeDiffScope(input) {
|
|
147
|
+
if (!input.declared.specified) {
|
|
148
|
+
return { checked: false, outOfScopeFiles: [], warning: null };
|
|
149
|
+
}
|
|
150
|
+
const declaredSet = new Set(input.declared.files);
|
|
151
|
+
const outOfScope = input.changedFiles
|
|
152
|
+
.filter((f) => !declaredSet.has(f))
|
|
153
|
+
.sort();
|
|
154
|
+
if (outOfScope.length === 0) {
|
|
155
|
+
return { checked: true, outOfScopeFiles: [], warning: null };
|
|
156
|
+
}
|
|
157
|
+
const ticket = input.ticketKey && input.ticketKey.trim().length > 0
|
|
158
|
+
? input.ticketKey.trim()
|
|
159
|
+
: "unknown-ticket";
|
|
160
|
+
const warning = `[file-scope-guard] ${ticket}: ${outOfScope.length} file(s) changed outside the ` +
|
|
161
|
+
`declared touched-file set (${input.declared.files.length} declared): ` +
|
|
162
|
+
`${outOfScope.join(", ")}. Warn-only — PR creation continues.`;
|
|
163
|
+
return { checked: true, outOfScopeFiles: outOfScope, warning };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* CLI entrypoint: parse the declaration from the env, collect the branch diff,
|
|
167
|
+
* analyze scope, and print a warning for an out-of-scope diff. ALWAYS returns
|
|
168
|
+
* exit code `0` — an unspecified declaration, a git failure, or an out-of-scope
|
|
169
|
+
* diff all leave PR creation unblocked (warn-only / fail-open). Output is bounded
|
|
170
|
+
* and secret-free; the scope warning goes to stdout (explicit CLI-mode output),
|
|
171
|
+
* the fail-open diagnostic to stderr.
|
|
172
|
+
*/
|
|
173
|
+
export function runFileScopeGuardCli(deps = {}) {
|
|
174
|
+
const env = deps.env ?? process.env;
|
|
175
|
+
const writeOut = deps.writeOut ?? ((m) => process.stdout.write(`${m}\n`));
|
|
176
|
+
const writeErr = deps.writeErr ?? ((m) => process.stderr.write(`${m}\n`));
|
|
177
|
+
const ticketKey = env[FILE_SCOPE_GUARD_TICKET_KEY_ENV];
|
|
178
|
+
const declared = parseDeclaredTouchedFilesFromEnv(env);
|
|
179
|
+
if (!declared.specified) {
|
|
180
|
+
// No/blank/invalid/empty declaration → nothing to check (fail-open no-op).
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const collected = collectBranchChangedFiles({
|
|
184
|
+
cwd: deps.cwd,
|
|
185
|
+
spawnSyncFn: deps.spawnSyncFn,
|
|
186
|
+
});
|
|
187
|
+
if (!collected.ok) {
|
|
188
|
+
writeErr(`[file-scope-guard] ${ticketKey ?? "unknown-ticket"}: unable to check file ` +
|
|
189
|
+
`scope (git diff failed); continuing — PR creation is not blocked.`);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
const analysis = analyzeDiffScope({
|
|
193
|
+
ticketKey,
|
|
194
|
+
declared,
|
|
195
|
+
changedFiles: collected.files,
|
|
196
|
+
});
|
|
197
|
+
if (analysis.warning) {
|
|
198
|
+
writeOut(analysis.warning);
|
|
199
|
+
}
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GitHub PR mergeability normalization + conflict classification (BAPI-494).
|
|
3
|
+
*
|
|
4
|
+
* The conductor gained ZERO mergeability awareness before this ticket: a sibling
|
|
5
|
+
* PR left `CONFLICTING`/`DIRTY` by an overlapping merge would sit at
|
|
6
|
+
* `ready_for_review` forever (catalog A4 / run-report F3). This module is the
|
|
7
|
+
* single, dependency-light source of truth for reading GitHub's
|
|
8
|
+
* `mergeable`/`mergeStateStatus` fields and deciding whether a PR is un-mergeable,
|
|
9
|
+
* so PR discovery, the done-gate binding resolution, and the local merge executor
|
|
10
|
+
* all classify a conflict the SAME way.
|
|
11
|
+
*
|
|
12
|
+
* Every helper is PURE, NON-THROWING, and FAIL-CLOSED: an unknown/missing/malformed
|
|
13
|
+
* mergeability value is treated as non-conflicting/unknown, never as a conflict, and
|
|
14
|
+
* raw `gh` process output is only ever matched against sanitized text — it is never
|
|
15
|
+
* returned, logged, or copied into event data.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Normalize a raw GitHub mergeability field. Returns a trimmed, uppercased string
|
|
19
|
+
* for a non-empty string value, and `null` for every other shape (missing, empty,
|
|
20
|
+
* whitespace-only, non-string). Never throws.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeGhMergeabilityValue(value) {
|
|
23
|
+
if (typeof value !== "string")
|
|
24
|
+
return null;
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
if (trimmed.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
return trimmed.toUpperCase();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Read `mergeable` and `mergeStateStatus` from an already-parsed `gh pr view`
|
|
32
|
+
* JSON record and return a normalized {@link GhPrMergeability}. Unknown/malformed
|
|
33
|
+
* fields become `null` (fail-closed) so a valid PR with an unreadable mergeability
|
|
34
|
+
* value is never mistaken for a conflict.
|
|
35
|
+
*/
|
|
36
|
+
export function parseGhPrMergeabilityFields(record) {
|
|
37
|
+
return {
|
|
38
|
+
mergeable: normalizeGhMergeabilityValue(record.mergeable),
|
|
39
|
+
mergeStateStatus: normalizeGhMergeabilityValue(record.mergeStateStatus),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* True only for the two authoritative conflict signals GitHub exposes:
|
|
44
|
+
* `mergeable === "CONFLICTING"` or `mergeStateStatus === "DIRTY"`. Every other
|
|
45
|
+
* combination — mergeable/clean, unknown, or null — is NOT a conflict. Fail-closed.
|
|
46
|
+
*/
|
|
47
|
+
export function isPrMergeConflict(mergeability) {
|
|
48
|
+
return mergeability.mergeable === "CONFLICTING" || mergeability.mergeStateStatus === "DIRTY";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Sanitized signatures that a `gh pr merge` failure was caused by a merge
|
|
52
|
+
* conflict / non-fast-forward condition (rather than an auth, rate-limit, network,
|
|
53
|
+
* or not-found failure). Matched case-insensitively against the sanitized failure
|
|
54
|
+
* text; the raw output itself is never returned or logged.
|
|
55
|
+
*/
|
|
56
|
+
const MERGE_CONFLICT_OUTPUT_SIGNATURES = [
|
|
57
|
+
"merge conflict",
|
|
58
|
+
"merge conflicts",
|
|
59
|
+
"conflicting",
|
|
60
|
+
"conflict with the base branch",
|
|
61
|
+
"not possible to fast-forward",
|
|
62
|
+
"non-fast-forward",
|
|
63
|
+
"not mergeable",
|
|
64
|
+
"is not mergeable",
|
|
65
|
+
"cannot be cleanly created",
|
|
66
|
+
"would create a merge conflict",
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Classify raw `gh pr merge` failure output as a LIKELY merge-conflict /
|
|
70
|
+
* non-fast-forward failure. Reads only `stdout`/`stderr` text for defensive
|
|
71
|
+
* substring matching and returns a boolean — it never returns, logs, or otherwise
|
|
72
|
+
* exposes the raw process output. Returns `false` for unrelated failures
|
|
73
|
+
* (authentication, rate limit, network timeout, repository not found, generic
|
|
74
|
+
* GraphQL errors).
|
|
75
|
+
*/
|
|
76
|
+
export function isLikelyGhMergeConflictOutput(output) {
|
|
77
|
+
if (!output)
|
|
78
|
+
return false;
|
|
79
|
+
const stdout = typeof output.stdout === "string" ? output.stdout : "";
|
|
80
|
+
const stderr = typeof output.stderr === "string" ? output.stderr : "";
|
|
81
|
+
const haystack = `${stdout}\n${stderr}`.toLowerCase();
|
|
82
|
+
if (haystack.trim().length === 0)
|
|
83
|
+
return false;
|
|
84
|
+
return MERGE_CONFLICT_OUTPUT_SIGNATURES.some((sig) => haystack.includes(sig));
|
|
85
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { spawnSync } from "child_process";
|
|
20
20
|
import { pollCiChecksForCommit, } from "./bridge-api-client.js";
|
|
21
|
+
import { isLikelyGhMergeConflictOutput, isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
|
|
21
22
|
const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
|
|
22
23
|
/**
|
|
23
24
|
* Hard wall-clock cap on every `gh` subprocess. The epic-tick runs in a single
|
|
@@ -180,7 +181,52 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
180
181
|
// 4. Provider merge.
|
|
181
182
|
const merge = run("gh", ["pr", "merge", String(pr), `--${method}`, "--match-head-commit", expectedSha], ghEnv);
|
|
182
183
|
if (merge.status !== 0) {
|
|
183
|
-
|
|
184
|
+
// A hung `gh` (killed by the wall-clock timeout) is operationally distinct
|
|
185
|
+
// from a permission/conflict failure — keep it mapped to gh_merge_timeout.
|
|
186
|
+
if (merge.timedOut) {
|
|
187
|
+
const reason = "gh_merge_timeout";
|
|
188
|
+
return buildResponse(request, "failed", reason, false, [
|
|
189
|
+
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
190
|
+
{ type: "merge.failed", status: "failed", reason, details: baseDetails },
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
// BAPI-494: classify a conflict / non-fast-forward failure distinctly so it
|
|
194
|
+
// folds to `blocked` and remediates instead of hiding behind gh_merge_failed.
|
|
195
|
+
// First match the sanitized failure output; if inconclusive, do ONE follow-up
|
|
196
|
+
// mergeability re-read (only AFTER a merge failure — never before a successful
|
|
197
|
+
// or guarded merge) and check it. Raw stdout/stderr is used only for defensive
|
|
198
|
+
// classification and is never copied into event data or logs.
|
|
199
|
+
let mergeability = { mergeable: null, mergeStateStatus: null };
|
|
200
|
+
let isConflict = isLikelyGhMergeConflictOutput(merge);
|
|
201
|
+
if (!isConflict) {
|
|
202
|
+
const recheck = run("gh", ["pr", "view", String(pr), "--json", "mergeable,mergeStateStatus"], ghEnv);
|
|
203
|
+
if (recheck.status === 0) {
|
|
204
|
+
try {
|
|
205
|
+
mergeability = parseGhPrMergeabilityFields(JSON.parse(recheck.stdout));
|
|
206
|
+
isConflict = isPrMergeConflict(mergeability);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* best-effort classification only */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (isConflict) {
|
|
214
|
+
const reason = "gh_merge_conflict";
|
|
215
|
+
// Head-scoped to the attempted head SHA so the fold blocks the exact head; a
|
|
216
|
+
// later rebase clears it via the stale-head logic. mergeable/mergeStateStatus
|
|
217
|
+
// are included only when known.
|
|
218
|
+
const conflictDetails = {
|
|
219
|
+
...baseDetails,
|
|
220
|
+
head_sha: expectedSha,
|
|
221
|
+
...(mergeability.mergeable ? { mergeable: mergeability.mergeable } : {}),
|
|
222
|
+
...(mergeability.mergeStateStatus ? { mergeStateStatus: mergeability.mergeStateStatus } : {}),
|
|
223
|
+
};
|
|
224
|
+
return buildResponse(request, "failed", reason, false, [
|
|
225
|
+
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
226
|
+
{ type: "merge.conflict", status: "failed", reason, details: conflictDetails },
|
|
227
|
+
]);
|
|
228
|
+
}
|
|
229
|
+
const mergeFailReason = "gh_merge_failed";
|
|
184
230
|
return buildResponse(request, "failed", mergeFailReason, false, [
|
|
185
231
|
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
186
232
|
{ type: "merge.failed", status: "failed", reason: mergeFailReason, details: baseDetails },
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level merge-identity primitives (BAPI-493).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `merge-ledger.ts` so the typed event-accessor layer
|
|
5
|
+
* (`event-accessors.ts`) can build a canonical {@link MergeActionIdentity}
|
|
6
|
+
* without importing the merge-ledger store/emit surface (which would create an
|
|
7
|
+
* import cycle: merge-ledger → event-accessors → merge-ledger).
|
|
8
|
+
*
|
|
9
|
+
* This module depends ONLY on the pure boundary validators in `git-ci-types.ts`.
|
|
10
|
+
* It performs no I/O and holds no VCS write credentials. The deterministic action
|
|
11
|
+
* key `merge:{repo}:{pr}:{head_sha}:{gate}` is kept in lock-step with the Python
|
|
12
|
+
* `build_merge_action_key`; the gate-identity segment mirrors the Python
|
|
13
|
+
* `normalize_gate_identity`.
|
|
14
|
+
*/
|
|
15
|
+
import { normalizePrNumber, normalizeRepoName, normalizeSha } from "./git-ci-types.js";
|
|
16
|
+
/**
|
|
17
|
+
* Compose the stable gate-identity segment, mirroring the Python
|
|
18
|
+
* `normalize_gate_identity`: `{name}@{config_hash}` when a hash is present
|
|
19
|
+
* (lower-cased), otherwise just the gate name.
|
|
20
|
+
*/
|
|
21
|
+
export function buildGateIdentity(gateName, configHash) {
|
|
22
|
+
const name = gateName.trim();
|
|
23
|
+
const hash = typeof configHash === "string" ? configHash.trim() : "";
|
|
24
|
+
return hash ? `${name}@${hash.toLowerCase()}` : name;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build the deterministic action key. Normalizes repo / PR / head SHA exactly as
|
|
28
|
+
* the Python side does (lower-cased SHA, trimmed repo, positive integer PR) so the
|
|
29
|
+
* conductor-computed key and the API-recomputed key are byte-identical. Throws on
|
|
30
|
+
* any invalid component. The branch name is never part of the key.
|
|
31
|
+
*/
|
|
32
|
+
export function makeMergeActionKey(repo, prNumber, headSha, gateIdentity) {
|
|
33
|
+
const r = normalizeRepoName(repo);
|
|
34
|
+
const pr = normalizePrNumber(prNumber);
|
|
35
|
+
const sha = normalizeSha(headSha);
|
|
36
|
+
const gate = (gateIdentity ?? "").trim();
|
|
37
|
+
if (r === null || pr === null || sha === null || gate.length === 0) {
|
|
38
|
+
throw new Error("invalid merge action key component");
|
|
39
|
+
}
|
|
40
|
+
return `merge:${r}:${pr}:${sha}:${gate}`;
|
|
41
|
+
}
|