@bridge_gpt/mcp-server 0.2.67 → 0.2.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +272 -51
  2. package/build/agent-launchers/claude-executor-adapter.js +27 -0
  3. package/build/agents.generated.js +22 -2
  4. package/build/bitbucket-workspace-remote.js +136 -0
  5. package/build/bootstrap-project-identity.js +24 -0
  6. package/build/bridge-permissions.js +1362 -0
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-settings.js +102 -2
  9. package/build/cli-help.js +27 -0
  10. package/build/codex-home.js +9 -1
  11. package/build/codex-launcher-pin.js +598 -0
  12. package/build/command-assets-doctor.js +24 -70
  13. package/build/command-provisioning.js +29 -5
  14. package/build/commands.generated.js +9 -9
  15. package/build/conductor/bridge-api-client.js +714 -0
  16. package/build/conductor/command-drift-production.js +92 -0
  17. package/build/conductor/command-drift.js +89 -0
  18. package/build/conductor/doctor.js +69 -26
  19. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  20. package/build/conductor/epic-implementer-cli.js +76 -22
  21. package/build/conductor/gate-discovery.js +890 -0
  22. package/build/conductor/install-doctor.js +15 -3
  23. package/build/conductor/json-output.js +63 -0
  24. package/build/conductor/readiness-cli.js +95 -10
  25. package/build/conductor/readiness-sections.js +369 -3
  26. package/build/conductor/readiness.js +211 -8
  27. package/build/conductor/recovery-cli.js +110 -4
  28. package/build/conductor/recovery-operations.js +99 -1
  29. package/build/connect-bitbucket-api.js +41 -6
  30. package/build/connect-bitbucket.js +106 -33
  31. package/build/connect-github-api.js +33 -14
  32. package/build/credential-store.js +207 -47
  33. package/build/docs.generated.js +57 -3
  34. package/build/doctor.js +198 -4
  35. package/build/drive-epic.js +45 -8
  36. package/build/epic-coherence-input.test-fixtures.js +4 -3
  37. package/build/epic-implementer-bin.js +1369 -25
  38. package/build/epic-workarounds-path.js +39 -0
  39. package/build/executor/cli.js +30 -0
  40. package/build/executor/completion-contract.js +20 -5
  41. package/build/executor/deps.js +4 -1
  42. package/build/executor/gate-qualification-job.js +499 -0
  43. package/build/executor/guarded-fast-forward-push.js +129 -0
  44. package/build/executor/http-client.js +43 -4
  45. package/build/executor/integration-job.js +122 -8
  46. package/build/executor/job-errors.js +8 -1
  47. package/build/executor/job-runner.js +589 -47
  48. package/build/executor/likely-tests.js +290 -0
  49. package/build/executor/live-worker-registry.js +1 -1
  50. package/build/executor/observation.js +31 -2
  51. package/build/executor/packaged-recipe.js +106 -0
  52. package/build/executor/permissions.js +115 -28
  53. package/build/executor/platform.js +1 -1
  54. package/build/executor/preflight.js +61 -12
  55. package/build/executor/process-containment.js +36 -12
  56. package/build/executor/process.js +15 -7
  57. package/build/executor/prompt-spec.js +33 -1
  58. package/build/executor/recently-failed-tests-file.js +71 -0
  59. package/build/executor/reconcile-overlap-job.js +41 -23
  60. package/build/executor/required-verification.js +1 -1
  61. package/build/executor/required-work-ledger.js +105 -17
  62. package/build/executor/retirement.js +69 -0
  63. package/build/executor/runner.js +114 -11
  64. package/build/executor/test-clock.js +15 -0
  65. package/build/executor/types.js +32 -0
  66. package/build/executor/viewer-tabs.js +58 -8
  67. package/build/executor/worker-command.js +23 -7
  68. package/build/executor/worker-diagnostics.js +37 -0
  69. package/build/executor/worker-finalization.js +31 -5
  70. package/build/executor/worker-guard-hook.js +179 -25
  71. package/build/executor/worker-log.js +4 -3
  72. package/build/executor/worker-verification-guidance.js +37 -0
  73. package/build/executor/worktree.js +88 -11
  74. package/build/first-build-advisory.js +97 -0
  75. package/build/generated-assets-advisory.js +14 -0
  76. package/build/generated-assets-catalog.js +66 -0
  77. package/build/generated-assets-context.js +46 -0
  78. package/build/generated-assets-dependencies.js +26 -0
  79. package/build/generated-assets-doctor.js +50 -0
  80. package/build/generated-assets-health.js +65 -0
  81. package/build/generated-assets-host.js +17 -0
  82. package/build/generated-assets-inventory.js +17 -0
  83. package/build/generated-assets-paths.js +60 -0
  84. package/build/generated-assets-publisher.js +133 -0
  85. package/build/generated-assets-remedy.js +25 -0
  86. package/build/generated-assets-repair.js +72 -0
  87. package/build/generated-assets-session.js +71 -0
  88. package/build/generated-assets-test-support.js +35 -0
  89. package/build/index.js +1109 -695
  90. package/build/init.js +54 -177
  91. package/build/install-bridge-conductor-gate.js +1372 -0
  92. package/build/install-bridge-conductor.js +289 -36
  93. package/build/install-bridge.js +722 -252
  94. package/build/launcher-config-inspection.js +184 -19
  95. package/build/mcp-host-targets.js +4 -0
  96. package/build/mcp-identity.js +7 -0
  97. package/build/mcp-install-state.js +50 -4
  98. package/build/merge-workflow-install.js +3 -4
  99. package/build/pipeline-utils.js +6 -2
  100. package/build/pipelines.generated.js +22 -142
  101. package/build/plan-execution-ledger.js +2 -32
  102. package/build/plane/manifest.js +16 -1
  103. package/build/plane/preflight.js +54 -1
  104. package/build/printed-remedy-commands.js +16 -1
  105. package/build/readme.generated.js +1 -1
  106. package/build/repair-assets.js +93 -0
  107. package/build/run-unit-tests-launcher.js +130 -49
  108. package/build/secret-redaction.js +33 -0
  109. package/build/session-upgrade-notice.js +86 -0
  110. package/build/setup-epic.js +108 -3
  111. package/build/start-tickets-prereqs.js +70 -16
  112. package/build/start-tickets.js +44 -5
  113. package/build/tool-call-advisory.js +162 -0
  114. package/build/tool-surface-gating.js +32 -57
  115. package/build/update-check.js +97 -16
  116. package/build/update-status.js +61 -71
  117. package/build/upgrade-advice.js +15 -11
  118. package/build/upgrade-cli.js +398 -17
  119. package/build/version.generated.js +3 -3
  120. package/build/worker-guard-hook-bin.js +3 -2
  121. package/build/worker-guard-hook-main.js +1 -1
  122. package/build/worktree-core.js +15 -2
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
  124. package/docs/EPIC-IMPLEMENTER.md +68 -2
  125. package/package.json +3 -3
  126. package/pipelines/greenfield-setup.json +4 -128
  127. package/pipelines/implement-ticket.json +3 -2
@@ -0,0 +1,890 @@
1
+ /**
2
+ * Deterministic, read-only CI gate discovery (BAPI-1391).
3
+ *
4
+ * The installer has to propose a CI gate for a repository it has never seen:
5
+ * which provider check runs must pass, or, when the repository has no provider
6
+ * CI, which local commands stand in for them. This module gathers that proposal
7
+ * from two sources only — the repository's WORKING TREE, read through an
8
+ * injected {@link GateDiscoveryFs}, and the provider's check history, asked
9
+ * through an injected {@link ProviderHistoryLookup}. No LLM is involved, so the
10
+ * same tree and the same history always yield the same proposal.
11
+ *
12
+ * ## Why a declared name is never a proposed name
13
+ *
14
+ * A workflow file says what a job is CALLED, not what the provider REPORTS. A
15
+ * matrix job reports `<name> (<values>)`, an expression name is evaluated at run
16
+ * time, a reusable-workflow job reports `<caller> / <callee>`, and a CircleCI or
17
+ * Jenkins status name is a convention, not a guarantee. A gate built on a name
18
+ * that never reports would wait forever. So configuration only ever produces
19
+ * LOOKUP names; a provider candidate exists only when the history lookup said
20
+ * `reported` for that exact name. Names whose shape may vary are recorded as
21
+ * evidence and never looked up at all.
22
+ *
23
+ * ## Why "no history" and "empty history" stay distinct
24
+ *
25
+ * A lookup that succeeded and saw nothing is a FACT ("these names never
26
+ * reported"); a lookup that failed is an ABSENCE of facts. Collapsing the two
27
+ * would let an outage masquerade as "this repository has no CI". The history
28
+ * state therefore has three values — `available`, `unavailable`, and
29
+ * `not_queried` — and a successful empty answer is always `available`.
30
+ *
31
+ * ## Invariants
32
+ *
33
+ * - Read-only and bounded: at most {@link MAX_WORKFLOW_FILES} workflow files and
34
+ * {@link MAX_DISCOVERY_FILE_BYTES} per file. The node seam refuses absolute
35
+ * paths and `..` segments and resolves symlinks before reading, so nothing
36
+ * outside the repository root is opened.
37
+ * - Hooks (`.husky/`, `.pre-commit-config.yaml`, `.git/hooks/pre-push`) are
38
+ * evidence only. Their bodies are shell, and shell is never turned into a
39
+ * command.
40
+ * - Local commands are argv arrays, never shell strings. A declaration that is
41
+ * ambiguous or unsupported is omitted rather than guessed at.
42
+ * - Customer names are kept verbatim (trimmed only). Nothing here assumes the
43
+ * Bridge API repository's own layout.
44
+ * - Deterministic order everywhere: file names sorted by code unit, jobs in file
45
+ * order, local candidates in a fixed order.
46
+ */
47
+ import fs from "node:fs/promises";
48
+ import path from "node:path";
49
+ // ---------------------------------------------------------------------------
50
+ // Bounds and fixed vocabularies
51
+ // ---------------------------------------------------------------------------
52
+ /** Workflow files read per discovery, after sorting by name. */
53
+ export const MAX_WORKFLOW_FILES = 64;
54
+ /** Largest file the collector will read; larger files are treated as absent. */
55
+ export const MAX_DISCOVERY_FILE_BYTES = 256 * 1024;
56
+ /** Names sent to the provider-history lookup (the server's cap). */
57
+ export const MAX_LOOKUP_NAMES = 32;
58
+ /** Timeout carried by every local command candidate. */
59
+ export const LOCAL_COMMAND_TIMEOUT_SECONDS = 1800;
60
+ /** Shape every local command `name` must match. */
61
+ export const GATE_COMMAND_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
62
+ /** Jenkins' multibranch status names, asked of history when a Jenkinsfile exists. */
63
+ export const JENKINS_LOOKUP_NAMES = [
64
+ "continuous-integration/jenkins/branch",
65
+ "continuous-integration/jenkins/pr-merge",
66
+ ];
67
+ /** The script body `npm init` writes; it is a placeholder, not a test command. */
68
+ export const NPM_PLACEHOLDER_TEST_SCRIPT = 'echo "Error: no test specified" && exit 1';
69
+ const WORKFLOWS_DIR = ".github/workflows";
70
+ const CIRCLECI_CONFIG = ".circleci/config.yml";
71
+ /**
72
+ * Client-side git hook names recognised under `.husky/`. Restricting to known
73
+ * names keeps husky's own `_` helper directory and dotfiles out of the evidence.
74
+ */
75
+ const GIT_CLIENT_HOOK_NAMES = [
76
+ "applypatch-msg",
77
+ "commit-msg",
78
+ "post-checkout",
79
+ "post-commit",
80
+ "post-merge",
81
+ "post-rewrite",
82
+ "pre-applypatch",
83
+ "pre-commit",
84
+ "pre-merge-commit",
85
+ "pre-push",
86
+ "pre-rebase",
87
+ "prepare-commit-msg",
88
+ ];
89
+ // ---------------------------------------------------------------------------
90
+ // Node filesystem seam
91
+ // ---------------------------------------------------------------------------
92
+ const PATH_REFUSED = "gate discovery refuses a path outside the repository root";
93
+ /**
94
+ * Split a repository-relative path into safe segments, or throw. Absolute
95
+ * paths (POSIX or Windows), backslashes, NUL bytes, and `..` segments are all
96
+ * refused: the seam's callers only ever pass fixed names and directory entries,
97
+ * so any of these signals a bug, and a bug must not become an escape.
98
+ */
99
+ function safeSegments(relPath) {
100
+ if (typeof relPath !== "string" ||
101
+ relPath.includes("\0") ||
102
+ relPath.includes("\\") ||
103
+ path.posix.isAbsolute(relPath) ||
104
+ path.win32.isAbsolute(relPath)) {
105
+ throw new Error(PATH_REFUSED);
106
+ }
107
+ const segments = relPath.split("/").filter((s) => s !== "" && s !== ".");
108
+ if (segments.some((s) => s === ".."))
109
+ throw new Error(PATH_REFUSED);
110
+ return segments;
111
+ }
112
+ function isWithin(root, candidate) {
113
+ const rel = path.relative(root, candidate);
114
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
115
+ }
116
+ /**
117
+ * The real {@link GateDiscoveryFs}, over `node:fs/promises`.
118
+ *
119
+ * Every access resolves the target with `realpath` and re-checks containment
120
+ * against the resolved root, so a symlink inside the repository cannot lead a
121
+ * read outside it. Every I/O failure reads as "absent" (null / false); only an
122
+ * invalid path argument throws.
123
+ */
124
+ export function createNodeGateDiscoveryFs(repoRoot) {
125
+ let realRoot = null;
126
+ const root = () => {
127
+ realRoot ??= fs.realpath(repoRoot).catch(() => null);
128
+ return realRoot;
129
+ };
130
+ /** The contained real path for `relPath`, or null when absent or escaping. */
131
+ const resolve = async (relPath) => {
132
+ const segments = safeSegments(relPath);
133
+ const base = await root();
134
+ if (base === null)
135
+ return null;
136
+ try {
137
+ const real = await fs.realpath(path.join(base, ...segments));
138
+ return isWithin(base, real) ? real : null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ };
144
+ return {
145
+ async readText(relPath) {
146
+ const target = await resolve(relPath);
147
+ if (target === null)
148
+ return null;
149
+ let handle = null;
150
+ try {
151
+ handle = await fs.open(target, "r");
152
+ const stat = await handle.stat();
153
+ if (!stat.isFile() || stat.size > MAX_DISCOVERY_FILE_BYTES)
154
+ return null;
155
+ // Read one byte past the cap so a file that grew after fstat is still refused.
156
+ const buffer = Buffer.alloc(MAX_DISCOVERY_FILE_BYTES + 1);
157
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
158
+ if (bytesRead > MAX_DISCOVERY_FILE_BYTES)
159
+ return null;
160
+ return buffer.subarray(0, bytesRead).toString("utf8");
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ finally {
166
+ await handle?.close().catch(() => undefined);
167
+ }
168
+ },
169
+ async listDir(relPath) {
170
+ const target = await resolve(relPath);
171
+ if (target === null)
172
+ return null;
173
+ try {
174
+ const stat = await fs.stat(target);
175
+ if (!stat.isDirectory())
176
+ return null;
177
+ return await fs.readdir(target);
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ },
183
+ async exists(relPath) {
184
+ return (await resolve(relPath)) !== null;
185
+ },
186
+ };
187
+ }
188
+ /** Non-blank, non-comment lines with their indentation measured in spaces. */
189
+ function significantLines(text) {
190
+ const out = [];
191
+ for (const raw of text.split(/\r?\n/)) {
192
+ const trimmed = raw.trim();
193
+ if (trimmed === "" || trimmed.startsWith("#"))
194
+ continue;
195
+ const indent = raw.length - raw.trimStart().length;
196
+ const leading = raw.slice(0, indent);
197
+ out.push({ indent, text: trimmed, tabIndent: leading.includes("\t") });
198
+ }
199
+ return out;
200
+ }
201
+ /** Characters that start a YAML construct this scanner deliberately does not read. */
202
+ const UNSUPPORTED_SCALAR_STARTS = new Set(["|", ">", "&", "*", "!", "{", "[", "%", "@", "`"]);
203
+ const UNSUPPORTED_KEY_STARTS = new Set([...UNSUPPORTED_SCALAR_STARTS, "-", "?", ":", ",", "]", "}", "#"]);
204
+ /** Split `key: rest` (key optionally quoted). Null when the line is not a mapping entry. */
205
+ function splitKeyLine(text) {
206
+ let key;
207
+ let after;
208
+ const quote = text[0];
209
+ if (quote === '"' || quote === "'") {
210
+ const close = text.indexOf(quote, 1);
211
+ if (close < 0)
212
+ return null;
213
+ key = text.slice(1, close);
214
+ after = text.slice(close + 1).trimStart();
215
+ if (!after.startsWith(":"))
216
+ return null;
217
+ after = after.slice(1);
218
+ if (after !== "" && !/^\s/.test(after))
219
+ return null;
220
+ }
221
+ else {
222
+ if (UNSUPPORTED_KEY_STARTS.has(text[0] ?? ""))
223
+ return null;
224
+ let colon = -1;
225
+ for (let i = 0; i < text.length; i++) {
226
+ const next = text[i + 1];
227
+ if (text[i] === ":" && (next === undefined || next === " " || next === "\t")) {
228
+ colon = i;
229
+ break;
230
+ }
231
+ if (text[i] === "#" && i > 0 && /\s/.test(text[i - 1] ?? ""))
232
+ return null;
233
+ }
234
+ if (colon < 0)
235
+ return null;
236
+ key = text.slice(0, colon).trimEnd();
237
+ after = text.slice(colon + 1);
238
+ }
239
+ if (key === "")
240
+ return null;
241
+ let rest = after.trim();
242
+ if (rest.startsWith("#"))
243
+ rest = "";
244
+ return { key, rest };
245
+ }
246
+ /** True when what follows a closed quoted scalar is nothing or a comment. */
247
+ function onlyCommentAfter(remainder) {
248
+ const r = remainder.trim();
249
+ return r === "" || r.startsWith("#");
250
+ }
251
+ /**
252
+ * Read a single-line YAML scalar. Plain, single-quoted, and double-quoted
253
+ * scalars with simple escapes are understood; anything else (block scalars,
254
+ * anchors, aliases, tags, flow collections, unknown escapes) is `unclear`.
255
+ */
256
+ export function parseInlineScalar(rest) {
257
+ const text = rest.trim();
258
+ if (text === "" || text.startsWith("#"))
259
+ return { kind: "empty" };
260
+ if (text[0] === '"') {
261
+ let value = "";
262
+ for (let i = 1; i < text.length; i++) {
263
+ const ch = text[i];
264
+ if (ch === "\\") {
265
+ const next = text[i + 1];
266
+ if (next === '"' || next === "\\" || next === "/") {
267
+ value += next;
268
+ i++;
269
+ continue;
270
+ }
271
+ return { kind: "unclear" };
272
+ }
273
+ if (ch === '"') {
274
+ return onlyCommentAfter(text.slice(i + 1)) ? { kind: "value", value: value.trim() } : { kind: "unclear" };
275
+ }
276
+ value += ch;
277
+ }
278
+ return { kind: "unclear" };
279
+ }
280
+ if (text[0] === "'") {
281
+ let value = "";
282
+ for (let i = 1; i < text.length; i++) {
283
+ const ch = text[i];
284
+ if (ch === "'") {
285
+ if (text[i + 1] === "'") {
286
+ value += "'";
287
+ i++;
288
+ continue;
289
+ }
290
+ return onlyCommentAfter(text.slice(i + 1)) ? { kind: "value", value: value.trim() } : { kind: "unclear" };
291
+ }
292
+ value += ch;
293
+ }
294
+ return { kind: "unclear" };
295
+ }
296
+ if (UNSUPPORTED_SCALAR_STARTS.has(text[0] ?? "") || /^[-?:](\s|$)/.test(text)) {
297
+ return { kind: "unclear" };
298
+ }
299
+ const comment = text.search(/\s#/);
300
+ const value = (comment >= 0 ? text.slice(0, comment) : text).trim();
301
+ // A plain scalar may not contain `: ` — seeing one means this is not a scalar.
302
+ if (value === "" || /:(\s|$)/.test(value))
303
+ return { kind: "unclear" };
304
+ return { kind: "value", value };
305
+ }
306
+ /**
307
+ * Scan the top-level `jobs:` map of a CI YAML file.
308
+ *
309
+ * Line based and indentation tracked: only a column-0 `jobs:` is honoured, job
310
+ * keys are read at the first nested indentation, and `name:` / `strategy:` /
311
+ * `matrix:` / `uses:` at each job's direct-child indentation (a `matrix:` is
312
+ * also recognised anywhere beneath `strategy:`). Anything the scanner cannot
313
+ * read confidently marks the job `unclear` rather than guessing a name.
314
+ */
315
+ export function scanJobsBlock(text) {
316
+ const lines = significantLines(text);
317
+ const start = lines.findIndex((l) => {
318
+ if (l.indent !== 0)
319
+ return false;
320
+ const kv = splitKeyLine(l.text);
321
+ return kv !== null && kv.key === "jobs";
322
+ });
323
+ if (start < 0)
324
+ return { found: false, unclear: false, jobs: [] };
325
+ if (splitKeyLine(lines[start].text)?.rest !== "") {
326
+ // Flow-style or aliased `jobs:` — not something a line scanner can read.
327
+ return { found: true, unclear: true, jobs: [] };
328
+ }
329
+ const jobs = [];
330
+ let unclear = false;
331
+ let jobIndent = null;
332
+ let current = null;
333
+ let childIndent = null;
334
+ let inStrategy = false;
335
+ let afterName = false;
336
+ for (let i = start + 1; i < lines.length; i++) {
337
+ const line = lines[i];
338
+ if (line.indent === 0)
339
+ break;
340
+ if (line.tabIndent) {
341
+ unclear = true;
342
+ if (current)
343
+ current.unclear = true;
344
+ continue;
345
+ }
346
+ jobIndent ??= line.indent;
347
+ if (line.indent < jobIndent) {
348
+ unclear = true;
349
+ if (current)
350
+ current.unclear = true;
351
+ continue;
352
+ }
353
+ if (line.indent === jobIndent) {
354
+ const kv = splitKeyLine(line.text);
355
+ childIndent = null;
356
+ inStrategy = false;
357
+ afterName = false;
358
+ if (kv === null) {
359
+ unclear = true;
360
+ current = null;
361
+ continue;
362
+ }
363
+ current = { key: kv.key, name: null, matrix: false, expression: false, reusable: false, unclear: kv.rest !== "" };
364
+ jobs.push(current);
365
+ continue;
366
+ }
367
+ if (current === null)
368
+ continue;
369
+ childIndent ??= line.indent;
370
+ if (line.indent < childIndent) {
371
+ current.unclear = true;
372
+ continue;
373
+ }
374
+ if (line.indent === childIndent) {
375
+ inStrategy = false;
376
+ afterName = false;
377
+ const kv = splitKeyLine(line.text);
378
+ if (kv === null) {
379
+ current.unclear = true;
380
+ continue;
381
+ }
382
+ if (kv.key === "name") {
383
+ const parsed = parseInlineScalar(kv.rest);
384
+ if (parsed.kind === "value") {
385
+ current.name = parsed.value;
386
+ if (parsed.value.includes("${{"))
387
+ current.expression = true;
388
+ }
389
+ else {
390
+ current.unclear = true;
391
+ }
392
+ afterName = true;
393
+ }
394
+ else if (kv.key === "strategy") {
395
+ // An inline strategy (typically `${{ fromJSON(...) }}`) may carry a matrix.
396
+ if (kv.rest !== "")
397
+ current.matrix = true;
398
+ else
399
+ inStrategy = true;
400
+ }
401
+ else if (kv.key === "matrix") {
402
+ current.matrix = true;
403
+ }
404
+ else if (kv.key === "uses") {
405
+ current.reusable = true;
406
+ }
407
+ continue;
408
+ }
409
+ // Deeper than the job's direct children.
410
+ if (afterName) {
411
+ // A plain scalar continued onto the next line: the name read so far is partial.
412
+ current.unclear = true;
413
+ afterName = false;
414
+ }
415
+ if (inStrategy && splitKeyLine(line.text)?.key === "matrix")
416
+ current.matrix = true;
417
+ }
418
+ return {
419
+ found: true,
420
+ unclear,
421
+ jobs: jobs.map((j) => ({
422
+ key: j.key,
423
+ name: j.name,
424
+ variance: j.unclear
425
+ ? "unclear"
426
+ : j.matrix
427
+ ? "matrix"
428
+ : j.expression
429
+ ? "expression"
430
+ : j.reusable
431
+ ? "reusable_workflow"
432
+ : null,
433
+ })),
434
+ };
435
+ }
436
+ /**
437
+ * A GitHub Actions workflow's jobs. The check-run name a job reports is its
438
+ * `name:` when present, else its key — exact only when `variance` is null.
439
+ */
440
+ export function scanWorkflowJobs(text) {
441
+ const scan = scanJobsBlock(text);
442
+ return {
443
+ ...scan,
444
+ jobs: scan.jobs.map((j) => j.variance === null && (j.name ?? j.key).trim() === "" ? { ...j, variance: "unclear" } : j),
445
+ };
446
+ }
447
+ /** A CircleCI config's job keys. A CircleCI job has no reported-name override. */
448
+ export function scanCircleCiJobs(text) {
449
+ const scan = scanJobsBlock(text);
450
+ return { ...scan, jobs: scan.jobs.map((j) => ({ ...j, name: null })) };
451
+ }
452
+ /** The exact reported check name for a workflow job, or null when it may vary. */
453
+ export function workflowJobReportedName(job) {
454
+ if (job.variance !== null)
455
+ return null;
456
+ const name = (job.name ?? job.key).trim();
457
+ return name === "" ? null : name;
458
+ }
459
+ /** The status CircleCI reports for a job — a LOOKUP name, never proposed unless reported. */
460
+ export function circleCiStatusName(jobKey) {
461
+ return `ci/circleci: ${jobKey.trim()}`;
462
+ }
463
+ const MAKE_GATE_TARGETS = ["test", "check", "lint"];
464
+ /**
465
+ * Makefile targets named exactly `test`, `check`, or `lint`, in that fixed
466
+ * order. A rule line (`test:` or `test::`) counts; a variable assignment
467
+ * (`test := ...`, `test ::= ...`) does not.
468
+ */
469
+ export function scanMakefileTargets(text) {
470
+ const found = new Set();
471
+ for (const line of text.split(/\r?\n/)) {
472
+ const m = /^(test|check|lint)\s*:(?!=|:=)/.exec(line);
473
+ if (m)
474
+ found.add(m[1]);
475
+ }
476
+ return MAKE_GATE_TARGETS.filter((t) => found.has(t));
477
+ }
478
+ /** True when an INI/TOML file has a section header line exactly equal to `header`. */
479
+ export function hasIniSection(text, header) {
480
+ return text.split(/\r?\n/).some((line) => line.replace(/\s+[#;].*$/, "").trim() === header);
481
+ }
482
+ /** Argv for a package.json script under a package manager. Never a shell string. */
483
+ export function packageScriptArgv(pm, script) {
484
+ if (pm === "yarn")
485
+ return ["yarn", script];
486
+ if (pm === "npm" && script === "test")
487
+ return ["npm", "test"];
488
+ return [pm, "run", script];
489
+ }
490
+ /** True when a package.json script value is a usable command (not empty, not the npm placeholder). */
491
+ export function isUsablePackageScript(value) {
492
+ if (typeof value !== "string")
493
+ return false;
494
+ const trimmed = value.trim();
495
+ return trimmed !== "" && trimmed !== NPM_PLACEHOLDER_TEST_SCRIPT;
496
+ }
497
+ /** True when `name` is a valid local command name. */
498
+ export function isGateCommandName(name) {
499
+ return GATE_COMMAND_NAME_PATTERN.test(name);
500
+ }
501
+ // ---------------------------------------------------------------------------
502
+ // Rendering and proposal helpers
503
+ // ---------------------------------------------------------------------------
504
+ const VARIANCE_TEXT = {
505
+ matrix: "matrix",
506
+ expression: "expression",
507
+ reusable_workflow: "reusable workflow",
508
+ unclear: "unclear structure",
509
+ };
510
+ /**
511
+ * A short, renderer-neutral one-line description, e.g.
512
+ * `.github/workflows/ci.yml job "unit-tests" (name may vary: matrix)`.
513
+ */
514
+ export function describeEvidence(e) {
515
+ let text = e.detail ? `${e.source} ${e.detail}` : e.source;
516
+ if (e.nameMayVary && !text.includes("name may vary"))
517
+ text += " (name may vary)";
518
+ return text;
519
+ }
520
+ /** The proposal kind implied by a selection; `none` when nothing is selected. */
521
+ export function deriveProposalKind(providerChecks, localCommandIds) {
522
+ if (providerChecks.length > 0 && localCommandIds.length > 0)
523
+ return "mixed";
524
+ if (providerChecks.length > 0)
525
+ return "provider";
526
+ if (localCommandIds.length > 0)
527
+ return "local";
528
+ return "none";
529
+ }
530
+ const PROVIDER_OUTCOMES = new Set(["reported", "unseen", "evidence_unavailable"]);
531
+ /**
532
+ * Classify a lookup response into a history state. A successful answer with no
533
+ * reported names is `available`; only a failed lookup or a stated
534
+ * `unavailableReason` is `unavailable`. Malformed responses read as unavailable.
535
+ */
536
+ export function historyStateFrom(response) {
537
+ if (typeof response !== "object" || response === null)
538
+ return { status: "unavailable" };
539
+ const r = response;
540
+ if (r.ok !== true)
541
+ return { status: "unavailable" };
542
+ if (r.unavailableReason !== null)
543
+ return { status: "unavailable" };
544
+ if (typeof r.outcomes !== "object" || r.outcomes === null)
545
+ return { status: "unavailable" };
546
+ return { status: "available", complete: r.complete === true };
547
+ }
548
+ /** The outcome the lookup reported for exactly `name`, or null when it said nothing valid. */
549
+ function outcomeFor(outcomes, name) {
550
+ if (typeof outcomes !== "object" || outcomes === null)
551
+ return null;
552
+ // Own-property only, so names like `constructor` never read the prototype.
553
+ if (!Object.prototype.hasOwnProperty.call(outcomes, name))
554
+ return null;
555
+ const value = outcomes[name];
556
+ return typeof value === "string" && PROVIDER_OUTCOMES.has(value) ? value : null;
557
+ }
558
+ // ---------------------------------------------------------------------------
559
+ // Collection
560
+ // ---------------------------------------------------------------------------
561
+ function byCodeUnit(a, b) {
562
+ return a < b ? -1 : a > b ? 1 : 0;
563
+ }
564
+ /** A directory entry the collector is willing to join onto a path. */
565
+ function isPlainEntry(name) {
566
+ return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && !name.includes("\0");
567
+ }
568
+ /** Read through the seam, re-applying the size cap so an injected fs cannot exceed it. */
569
+ async function readBounded(fsSeam, relPath) {
570
+ const text = await fsSeam.readText(relPath);
571
+ if (text === null || Buffer.byteLength(text, "utf8") > MAX_DISCOVERY_FILE_BYTES)
572
+ return null;
573
+ return text;
574
+ }
575
+ /** Lookup names in first-seen order, each with the evidence that produced it. */
576
+ class LookupNames {
577
+ order = [];
578
+ evidence = new Map();
579
+ add(name, e) {
580
+ const existing = this.evidence.get(name);
581
+ if (existing) {
582
+ existing.push(e);
583
+ return;
584
+ }
585
+ this.order.push(name);
586
+ this.evidence.set(name, [e]);
587
+ }
588
+ entries() {
589
+ return this.order.map((name) => ({ name, evidence: this.evidence.get(name) ?? [] }));
590
+ }
591
+ }
592
+ function jobDetail(job) {
593
+ let detail = `job "${job.key}"`;
594
+ if (job.name !== null && job.name !== job.key)
595
+ detail += ` named "${job.name}"`;
596
+ if (job.variance !== null)
597
+ detail += ` (name may vary: ${VARIANCE_TEXT[job.variance]})`;
598
+ return detail;
599
+ }
600
+ async function collectWorkflows(fsSeam, evidence, lookups) {
601
+ const entries = await fsSeam.listDir(WORKFLOWS_DIR);
602
+ if (entries === null)
603
+ return;
604
+ const files = entries
605
+ .filter((n) => isPlainEntry(n) && /\.ya?ml$/.test(n))
606
+ .sort(byCodeUnit)
607
+ .slice(0, MAX_WORKFLOW_FILES);
608
+ for (const file of files) {
609
+ const source = `${WORKFLOWS_DIR}/${file}`;
610
+ const text = await readBounded(fsSeam, source);
611
+ if (text === null)
612
+ continue;
613
+ const scan = scanWorkflowJobs(text);
614
+ if (scan.jobs.length === 0) {
615
+ // A workflow file whose jobs could not be read is still evidence of provider CI.
616
+ evidence.push({
617
+ kind: "github_workflow_job",
618
+ source,
619
+ detail: `jobs could not be read (name may vary: ${VARIANCE_TEXT.unclear})`,
620
+ nameMayVary: true,
621
+ });
622
+ continue;
623
+ }
624
+ for (const job of scan.jobs) {
625
+ const reported = workflowJobReportedName(job);
626
+ const e = {
627
+ kind: "github_workflow_job",
628
+ source,
629
+ detail: jobDetail(job),
630
+ nameMayVary: reported === null,
631
+ };
632
+ evidence.push(e);
633
+ if (reported !== null)
634
+ lookups.add(reported, e);
635
+ }
636
+ }
637
+ }
638
+ async function collectCircleCi(fsSeam, evidence, lookups) {
639
+ const text = await readBounded(fsSeam, CIRCLECI_CONFIG);
640
+ if (text === null)
641
+ return;
642
+ const scan = scanCircleCiJobs(text);
643
+ if (scan.jobs.length === 0) {
644
+ evidence.push({
645
+ kind: "circleci_job",
646
+ source: CIRCLECI_CONFIG,
647
+ detail: `jobs could not be read (name may vary: ${VARIANCE_TEXT.unclear})`,
648
+ nameMayVary: true,
649
+ });
650
+ return;
651
+ }
652
+ for (const job of scan.jobs) {
653
+ const exact = job.variance === null && job.key.trim() !== "";
654
+ const e = {
655
+ kind: "circleci_job",
656
+ source: CIRCLECI_CONFIG,
657
+ detail: exact
658
+ ? `job "${job.key}" (reports as "${circleCiStatusName(job.key)}")`
659
+ : `job "${job.key}" (name may vary: ${VARIANCE_TEXT[job.variance ?? "unclear"]})`,
660
+ nameMayVary: !exact,
661
+ };
662
+ evidence.push(e);
663
+ if (exact)
664
+ lookups.add(circleCiStatusName(job.key), e);
665
+ }
666
+ }
667
+ async function collectOtherProviders(fsSeam, evidence, lookups) {
668
+ if ((await readBounded(fsSeam, "Jenkinsfile")) !== null) {
669
+ const e = { kind: "jenkinsfile", source: "Jenkinsfile", detail: null, nameMayVary: false };
670
+ evidence.push(e);
671
+ for (const name of JENKINS_LOOKUP_NAMES)
672
+ lookups.add(name, e);
673
+ }
674
+ if ((await fsSeam.listDir(".buildkite")) !== null) {
675
+ // The pipeline slug is not in the repository, so there is no name to look up.
676
+ evidence.push({ kind: "buildkite", source: ".buildkite", detail: "pipeline slug unknown", nameMayVary: false });
677
+ }
678
+ }
679
+ /** Hooks are evidence only: their bodies are shell and are never run or parsed. */
680
+ async function collectHooks(fsSeam, evidence) {
681
+ const husky = await fsSeam.listDir(".husky");
682
+ if (husky !== null) {
683
+ const present = new Set(husky);
684
+ for (const hook of GIT_CLIENT_HOOK_NAMES) {
685
+ if (!present.has(hook))
686
+ continue;
687
+ const source = `.husky/${hook}`;
688
+ if ((await readBounded(fsSeam, source)) === null)
689
+ continue;
690
+ evidence.push({ kind: "husky_hook", source, detail: "hook (evidence only)", nameMayVary: false });
691
+ }
692
+ }
693
+ if ((await readBounded(fsSeam, ".pre-commit-config.yaml")) !== null) {
694
+ evidence.push({
695
+ kind: "pre_commit_config",
696
+ source: ".pre-commit-config.yaml",
697
+ detail: "hook configuration (evidence only)",
698
+ nameMayVary: false,
699
+ });
700
+ }
701
+ // In a linked worktree `.git` is a FILE pointing elsewhere; only a real directory is inspected.
702
+ if ((await fsSeam.listDir(".git")) !== null && (await readBounded(fsSeam, ".git/hooks/pre-push")) !== null) {
703
+ evidence.push({
704
+ kind: "git_pre_push_hook",
705
+ source: ".git/hooks/pre-push",
706
+ detail: "hook (evidence only)",
707
+ nameMayVary: false,
708
+ });
709
+ }
710
+ }
711
+ function localCandidate(id, name, argv, confidence, evidence) {
712
+ return { id, name, argv, timeoutSeconds: LOCAL_COMMAND_TIMEOUT_SECONDS, confidence, evidence };
713
+ }
714
+ const PACKAGE_GATE_SCRIPTS = ["test", "lint", "check"];
715
+ /**
716
+ * Resolve the package manager from lockfiles. More than one manager's lockfile
717
+ * is ambiguous — running the wrong one could pass a gate the real toolchain
718
+ * fails — so it resolves to null and the scripts become evidence only.
719
+ */
720
+ async function detectPackageManager(fsSeam) {
721
+ const managers = [];
722
+ if (await fsSeam.exists("pnpm-lock.yaml"))
723
+ managers.push("pnpm");
724
+ if (await fsSeam.exists("yarn.lock"))
725
+ managers.push("yarn");
726
+ if (await fsSeam.exists("package-lock.json"))
727
+ managers.push("npm");
728
+ if (managers.length > 1)
729
+ return null;
730
+ return managers[0] ?? "npm";
731
+ }
732
+ async function collectPackageJson(fsSeam, evidence, candidates) {
733
+ const text = await readBounded(fsSeam, "package.json");
734
+ if (text === null)
735
+ return;
736
+ let scripts;
737
+ try {
738
+ const parsed = JSON.parse(text);
739
+ if (typeof parsed !== "object" || parsed === null)
740
+ return;
741
+ const raw = parsed.scripts;
742
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
743
+ return;
744
+ scripts = raw;
745
+ }
746
+ catch {
747
+ return; // Unparseable package.json: unsupported, omitted.
748
+ }
749
+ const usable = PACKAGE_GATE_SCRIPTS.filter((s) => Object.prototype.hasOwnProperty.call(scripts, s) && isUsablePackageScript(scripts[s]));
750
+ if (usable.length === 0)
751
+ return;
752
+ const pm = await detectPackageManager(fsSeam);
753
+ for (const script of usable) {
754
+ if (pm === null) {
755
+ evidence.push({
756
+ kind: "package_json_script",
757
+ source: "package.json",
758
+ detail: `script "${script}" (omitted: lockfiles name more than one package manager)`,
759
+ nameMayVary: false,
760
+ });
761
+ continue;
762
+ }
763
+ const e = { kind: "package_json_script", source: "package.json", detail: `script "${script}"`, nameMayVary: false };
764
+ evidence.push(e);
765
+ candidates.push(localCandidate(`${pm}:${script}`, `${pm}-${script}`, packageScriptArgv(pm, script), script === "test" ? "high" : "medium", [e]));
766
+ }
767
+ }
768
+ async function collectMakefile(fsSeam, evidence, candidates) {
769
+ const text = await readBounded(fsSeam, "Makefile");
770
+ if (text === null)
771
+ return;
772
+ for (const target of scanMakefileTargets(text)) {
773
+ const e = { kind: "makefile_target", source: "Makefile", detail: `target "${target}"`, nameMayVary: false };
774
+ evidence.push(e);
775
+ candidates.push(localCandidate(`make:${target}`, `make-${target}`, ["make", target], "medium", [e]));
776
+ }
777
+ }
778
+ async function collectPython(fsSeam, evidence, candidates) {
779
+ const pytestEvidence = [];
780
+ const pyEvidence = (source, detail) => ({
781
+ kind: "python_test_config",
782
+ source,
783
+ detail,
784
+ nameMayVary: false,
785
+ });
786
+ const pyproject = await readBounded(fsSeam, "pyproject.toml");
787
+ if (pyproject !== null && hasIniSection(pyproject, "[tool.pytest.ini_options]")) {
788
+ pytestEvidence.push(pyEvidence("pyproject.toml", "[tool.pytest.ini_options]"));
789
+ }
790
+ if ((await readBounded(fsSeam, "pytest.ini")) !== null) {
791
+ pytestEvidence.push(pyEvidence("pytest.ini", "pytest configuration"));
792
+ }
793
+ const toxIni = await readBounded(fsSeam, "tox.ini");
794
+ const setupCfg = await readBounded(fsSeam, "setup.cfg");
795
+ for (const [source, text] of [["tox.ini", toxIni], ["setup.cfg", setupCfg]]) {
796
+ if (text === null)
797
+ continue;
798
+ for (const header of ["[pytest]", "[tool:pytest]"]) {
799
+ if (hasIniSection(text, header))
800
+ pytestEvidence.push(pyEvidence(source, header));
801
+ }
802
+ }
803
+ evidence.push(...pytestEvidence);
804
+ if (pytestEvidence.length > 0) {
805
+ candidates.push(localCandidate("python:pytest", "pytest", ["python", "-m", "pytest"], "high", pytestEvidence));
806
+ }
807
+ if (toxIni !== null && hasIniSection(toxIni, "[tox]")) {
808
+ const e = pyEvidence("tox.ini", "[tox]");
809
+ evidence.push(e);
810
+ candidates.push(localCandidate("tox", "tox", ["tox"], "medium", [e]));
811
+ }
812
+ if ((await readBounded(fsSeam, "noxfile.py")) !== null) {
813
+ const e = pyEvidence("noxfile.py", "nox sessions");
814
+ evidence.push(e);
815
+ candidates.push(localCandidate("nox", "nox", ["nox"], "medium", [e]));
816
+ }
817
+ }
818
+ /** Ask history about `names`; never throws, and never calls the lookup with nothing. */
819
+ async function queryHistory(lookupHistory, names) {
820
+ if (names.length === 0)
821
+ return { state: { status: "not_queried" }, outcomes: null };
822
+ let response;
823
+ try {
824
+ response = await lookupHistory([...names]);
825
+ }
826
+ catch {
827
+ return { state: { status: "unavailable" }, outcomes: null };
828
+ }
829
+ const state = historyStateFrom(response);
830
+ const outcomes = state.status === "available" ? response.outcomes : null;
831
+ return { state, outcomes };
832
+ }
833
+ /**
834
+ * Collect a gate proposal from the working tree and provider history.
835
+ *
836
+ * The default selection is the reported provider checks; when there are none,
837
+ * the HIGH-confidence local candidates. When there is nothing at all the
838
+ * proposal says so explicitly (`kind: "none"`, `noCandidates: true`) rather
839
+ * than presenting an empty provider list as a gate.
840
+ */
841
+ export async function discoverGate(input) {
842
+ const { fs: fsSeam, lookupHistory } = input;
843
+ const evidence = [];
844
+ const localCandidates = [];
845
+ const lookups = new LookupNames();
846
+ await collectWorkflows(fsSeam, evidence, lookups);
847
+ await collectCircleCi(fsSeam, evidence, lookups);
848
+ await collectOtherProviders(fsSeam, evidence, lookups);
849
+ await collectHooks(fsSeam, evidence);
850
+ await collectPackageJson(fsSeam, evidence, localCandidates);
851
+ await collectMakefile(fsSeam, evidence, localCandidates);
852
+ await collectPython(fsSeam, evidence, localCandidates);
853
+ const all = lookups.entries();
854
+ const queried = all.slice(0, MAX_LOOKUP_NAMES);
855
+ const overflow = all.slice(MAX_LOOKUP_NAMES);
856
+ const { state: history, outcomes } = await queryHistory(lookupHistory, queried.map((e) => e.name));
857
+ const providerCandidates = [];
858
+ const unreportedProviderNames = [];
859
+ for (const entry of queried) {
860
+ // When history is not `available`, no outcome is trusted, even one the response carried.
861
+ const outcome = history.status === "available" ? outcomeFor(outcomes, entry.name) : null;
862
+ if (outcome === "reported") {
863
+ providerCandidates.push({ name: entry.name, outcome, confidence: "high", evidence: entry.evidence });
864
+ }
865
+ else {
866
+ unreportedProviderNames.push({
867
+ name: entry.name,
868
+ outcome,
869
+ confidence: outcome === "unseen" ? "low" : "medium",
870
+ evidence: entry.evidence,
871
+ });
872
+ }
873
+ }
874
+ // Names past the lookup cap were never asked about: unknown, never proposed.
875
+ for (const entry of overflow) {
876
+ unreportedProviderNames.push({ name: entry.name, outcome: null, confidence: "medium", evidence: entry.evidence });
877
+ }
878
+ const validLocal = localCandidates.filter((c) => isGateCommandName(c.name));
879
+ const providerChecks = providerCandidates.map((c) => c.name);
880
+ const localCommandIds = providerChecks.length > 0 ? [] : validLocal.filter((c) => c.confidence === "high").map((c) => c.id);
881
+ return {
882
+ providerCandidates,
883
+ unreportedProviderNames,
884
+ localCandidates: validLocal,
885
+ evidence,
886
+ history,
887
+ proposed: { kind: deriveProposalKind(providerChecks, localCommandIds), providerChecks, localCommandIds },
888
+ noCandidates: providerCandidates.length === 0 && validLocal.length === 0,
889
+ };
890
+ }