@bridge_gpt/mcp-server 0.2.67 → 0.2.68
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/README.md +178 -15
- package/build/agent-launchers/claude-executor-adapter.js +27 -0
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +8 -8
- package/build/conductor/bridge-api-client.js +714 -0
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +69 -26
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +76 -22
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/readiness-cli.js +95 -10
- package/build/conductor/readiness-sections.js +369 -3
- package/build/conductor/readiness.js +211 -8
- package/build/conductor/recovery-cli.js +110 -4
- package/build/conductor/recovery-operations.js +99 -1
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +57 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic.js +45 -8
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1374 -25
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +30 -0
- package/build/executor/completion-contract.js +18 -5
- package/build/executor/deps.js +4 -1
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +8 -1
- package/build/executor/job-runner.js +589 -47
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +31 -2
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +61 -12
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +105 -17
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +114 -11
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +31 -5
- package/build/executor/worker-guard-hook.js +179 -25
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +88 -11
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +964 -768
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +289 -36
- package/build/install-bridge.js +544 -37
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +22 -142
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/manifest.js +16 -1
- package/build/plane/preflight.js +54 -1
- package/build/printed-remedy-commands.js +16 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +108 -3
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +29 -5
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +3 -2
- package/build/worker-guard-hook-main.js +1 -1
- package/build/worktree-core.js +15 -2
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
- package/docs/EPIC-IMPLEMENTER.md +68 -2
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- 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
|
+
}
|