@levr-one/cli 0.12.7 → 0.12.9

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 (35) hide show
  1. package/dist/{addHandler-ybX54_3f.js → addHandler-CD1pda0z.js} +7 -7
  2. package/dist/{appendHandler-CKkl9ymw.js → appendHandler-BYOoCNXJ.js} +9 -9
  3. package/dist/{artifact-upload-Ba801tb6.js → artifact-upload-t6kL20b5.js} +2 -2
  4. package/dist/{attachHandler-CfNvVAV2.js → attachHandler-Ojk7mc2E.js} +9 -9
  5. package/dist/bindingsHandler-Dxx2LIci.js +2781 -0
  6. package/dist/change-scope-DBmyRWZV.js +379 -0
  7. package/dist/changedCoverageHandler-B8Zh5WIK.js +200 -0
  8. package/dist/cli.js +300 -15
  9. package/dist/{connect-yq3fGXkW.js → connect-A5VSJUv5.js} +3 -3
  10. package/dist/coverage-defaults-Ct3KNnGl.js +24 -0
  11. package/dist/{currentHandler-PaLZKLGF.js → currentHandler-DmffKHyZ.js} +1 -1
  12. package/dist/dependencyAuditHandler-CdpW-IvY.js +291 -0
  13. package/dist/dist-DU3htaZ_.js +325 -0
  14. package/dist/{find-workspace-4Us2vEb7.js → find-workspace-IWolSyrf.js} +1 -1
  15. package/dist/gate-op-BINrIT2l.js +278 -0
  16. package/dist/{importHandler-bYBWYC_0.js → importHandler-86cjc2Zs.js} +6 -6
  17. package/dist/inspect-CAxNgTwj.js +4 -0
  18. package/dist/{inspect-BbL7kDe-.js → inspect-CinRBSv6.js} +2 -277
  19. package/dist/{listHandler-Du_YGzre.js → listHandler-BoCzGUDK.js} +5 -5
  20. package/dist/{loginHandler-PbKmDAGo.js → loginHandler-CXmNViCo.js} +6 -6
  21. package/dist/{logoutHandler-DxnzGPYx.js → logoutHandler-DwTURB_A.js} +2 -2
  22. package/dist/{pushHandler-BulraEwP.js → pushHandler-BkCmzQz7.js} +9 -330
  23. package/dist/{resolve-token-DKEXT7SA.js → resolve-token-D1zteEfm.js} +2 -2
  24. package/dist/{resolve-workspace-D8y7twod.js → resolve-workspace-CJN1plpd.js} +2 -2
  25. package/dist/{runHandler-B-u-0eRk.js → runHandler-BLkPzImG.js} +8 -7
  26. package/dist/{sdk-client-BgzKzVIR.js → sdk-client-CSy2LOEh.js} +1 -1
  27. package/dist/{selectHandler-C0gs-Q70.js → selectHandler-Dp74pefc.js} +5 -5
  28. package/dist/{statusHandler-Dnnh80ZN.js → statusHandler-ByTUMdKK.js} +3 -3
  29. package/dist/{token-refresh-BpFuU4Ij.js → token-refresh-DEmYbJ8V.js} +1 -1
  30. package/package.json +1 -1
  31. package/dist/inspect-EGW-cgVD.js +0 -3
  32. /package/dist/{env-DjeRwmZ7.js → env-Cv-anxJA.js} +0 -0
  33. /package/dist/{refsHandler--ySvLjmX.js → refsHandler-BPlcdJvR.js} +0 -0
  34. /package/dist/{sleep-CGBHjsj5.js → sleep-DNLj0FMk.js} +0 -0
  35. /package/dist/{workspace-store-Bmw104Qa.js → workspace-store-BJyB_WEG.js} +0 -0
@@ -0,0 +1,2781 @@
1
+ import "./gate-op-BINrIT2l.js";
2
+ import { client, configureClient } from "./sdk-client-CSy2LOEh.js";
3
+ import "./token-refresh-DEmYbJ8V.js";
4
+ import "./env-Cv-anxJA.js";
5
+ import { resolveToken } from "./resolve-token-D1zteEfm.js";
6
+ import "./workspace-store-BJyB_WEG.js";
7
+ import { resolveWorkspaceFor } from "./resolve-workspace-CJN1plpd.js";
8
+ import { z } from "zod";
9
+ import * as fs from "node:fs";
10
+ import * as path$1 from "node:path";
11
+
12
+ //#region ../sdk-public/dist/gen/gate-binding-transfer/functions.js
13
+ /** GET /v1/gate/bindings/export */
14
+ const gateBindingTransferExportConfigV1 = (options) => {
15
+ return (options?.client ?? client).get({
16
+ security: [{
17
+ scheme: "bearer",
18
+ type: "http"
19
+ }],
20
+ url: "/v1/gate/bindings/export",
21
+ ...options
22
+ });
23
+ };
24
+ /** POST /v1/gate/bindings/import */
25
+ const gateBindingTransferImportConfigV1 = (options) => {
26
+ return (options?.client ?? client).post({
27
+ security: [{
28
+ scheme: "bearer",
29
+ type: "http"
30
+ }],
31
+ url: "/v1/gate/bindings/import",
32
+ ...options
33
+ });
34
+ };
35
+
36
+ //#endregion
37
+ //#region ../shared/dist/constants/related-type.constants.js
38
+ /**
39
+ * Related type constants for polymorphic entities (attachments, label assignments, etc.)
40
+ *
41
+ * These are the normalized short-form related_type values used in the database.
42
+ * The old Levr PHP format ('App\\Models\\Test') has been replaced with simple strings ('test').
43
+ */
44
+ /**
45
+ * Map of entity names to their related_type database values.
46
+ * Use RELATED_TYPE_MAP.Test instead of hardcoding 'test'.
47
+ */
48
+ const RELATED_TYPE_MAP = {
49
+ Test: "test",
50
+ Step: "step",
51
+ Folder: "folder",
52
+ Plan: "plan",
53
+ Defect: "defect",
54
+ Requirement: "requirement",
55
+ RunResult: "run_result",
56
+ RunResultStep: "run_result_step",
57
+ ImportJob: "import_job",
58
+ Run: "run",
59
+ Issue: "issue",
60
+ Project: "project",
61
+ Initiative: "initiative",
62
+ Cycle: "cycle",
63
+ Staged: "staged",
64
+ DataSet: "data_set",
65
+ RunResultVariant: "run_result_variant",
66
+ ResearchStep: "research_step",
67
+ ProjectUpdate: "project_update",
68
+ AutomationRunResult: "automation_run_result",
69
+ Unlinked: "unlinked"
70
+ };
71
+ /**
72
+ * Array of allowed related type values (for validation/enum)
73
+ */
74
+ const RELATED_TYPES = Object.values(RELATED_TYPE_MAP);
75
+
76
+ //#endregion
77
+ //#region ../shared/dist/constants/file-upload.constants.js
78
+ /** Maximum file size in bytes (50 MB) */
79
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
80
+
81
+ //#endregion
82
+ //#region ../shared/dist/constants/provider-type.constants.js
83
+ /**
84
+ * LLM provider type enum + display-name map.
85
+ *
86
+ * Single source of truth shared between `apps/backender` (provider/profile
87
+ * services, seeds) and `apps/client` (profile form dropdowns, synced-profile
88
+ * provider-name display). The backender re-exports these from
89
+ * `provider-type.enum.ts` for backward compatibility; the client imports them
90
+ * from `@levr/shared` directly.
91
+ *
92
+ * NOTE: `ProviderTypeApiBaseUrl` is intentionally NOT moved here — it is
93
+ * backend-only (no client needs default provider base URLs) and stays local to
94
+ * `apps/backender/src/provider/provider-type.enum.ts`.
95
+ */
96
+ var ProviderType;
97
+ (function(ProviderType$1) {
98
+ ProviderType$1[ProviderType$1["OpenAI"] = 1] = "OpenAI";
99
+ ProviderType$1[ProviderType$1["Anthropic"] = 2] = "Anthropic";
100
+ ProviderType$1[ProviderType$1["Groq"] = 3] = "Groq";
101
+ ProviderType$1[ProviderType$1["Gemini"] = 4] = "Gemini";
102
+ ProviderType$1[ProviderType$1["FireworksAI"] = 5] = "FireworksAI";
103
+ ProviderType$1[ProviderType$1["MistralAI"] = 6] = "MistralAI";
104
+ ProviderType$1[ProviderType$1["OpenRouter"] = 7] = "OpenRouter";
105
+ })(ProviderType || (ProviderType = {}));
106
+ const ProviderTypeNames = {
107
+ [ProviderType.OpenAI]: "OpenAI",
108
+ [ProviderType.Anthropic]: "Anthropic",
109
+ [ProviderType.Groq]: "Groq",
110
+ [ProviderType.Gemini]: "Gemini",
111
+ [ProviderType.FireworksAI]: "FireworksAI",
112
+ [ProviderType.MistralAI]: "MistralAI",
113
+ [ProviderType.OpenRouter]: "OpenRouter"
114
+ };
115
+
116
+ //#endregion
117
+ //#region ../shared/dist/constants/browser-capture-kinds.js
118
+ /**
119
+ * Levr Browser capture kinds (ENG-7189 D1): the one place that says what each
120
+ * kind of browser capture is — its stored and wire types, file name, size
121
+ * cap, label and viewer. The router's reserve/finalise, the MCP "not saved"
122
+ * note, the gateway attach binding and the web client's Captures list all
123
+ * read this registry; none of them branches on a tool name.
124
+ *
125
+ * A capture tool names its kind on its `@levr/browser-protocol` spec
126
+ * (`captureKind`, a plain string: the extension never imports this package).
127
+ * Backender's `browser-captures.spec.ts` checks both sides list the same
128
+ * tools.
129
+ *
130
+ * Pure data, browser-safe, no imports.
131
+ */
132
+ const MIB = 1024 * 1024;
133
+ const OCTET_STREAM = "application/octet-stream";
134
+ /**
135
+ * The slowest uplink a full-size capture upload is given time for: about
136
+ * 1.4 Mbit/s, so a 50 MiB recording gets 300 s (ENG-7189 D5, R1-C6).
137
+ */
138
+ const CAPTURE_MIN_UPLINK_BITS_PER_SECOND = 14e5;
139
+ /** How long `maxBytes` takes at the minimum uplink, in whole seconds (ms). */
140
+ function uploadTimeoutFor(maxBytes) {
141
+ return Math.ceil(maxBytes * 8 / CAPTURE_MIN_UPLINK_BITS_PER_SECOND) * 1e3;
142
+ }
143
+ const CAPTURE_KINDS = Object.freeze({
144
+ screenshot: {
145
+ id: "screenshot",
146
+ labelKey: "browserCaptures.kindScreenshot",
147
+ label: "Screenshot",
148
+ noun: "screenshot",
149
+ tools: ["browser_screenshot", "browser_screenshot_focused_tab"],
150
+ mime: "image/png",
151
+ uploadMime: "image/png",
152
+ ext: "png",
153
+ filenamePrefix: "screenshot",
154
+ maxBytes: 50 * MIB,
155
+ viewer: "image",
156
+ formats: { jpeg: {
157
+ mime: "image/jpeg",
158
+ uploadMime: "image/jpeg",
159
+ ext: "jpg"
160
+ } },
161
+ toolLabels: { browser_screenshot_focused_tab: {
162
+ labelKey: "browserCaptures.kindFocusedTab",
163
+ label: "Screenshot of the focused tab"
164
+ } }
165
+ },
166
+ trace: {
167
+ id: "trace",
168
+ labelKey: "browserCaptures.kindTrace",
169
+ label: "Performance trace",
170
+ noun: "trace",
171
+ tools: ["browser_stop_performance_trace"],
172
+ mime: "application/json",
173
+ uploadMime: OCTET_STREAM,
174
+ ext: "json",
175
+ filenamePrefix: "trace",
176
+ maxBytes: 50 * MIB,
177
+ uploadTimeoutMs: uploadTimeoutFor(50 * MIB),
178
+ viewer: "trace"
179
+ },
180
+ console: {
181
+ id: "console",
182
+ labelKey: "browserCaptures.kindConsole",
183
+ label: "Console log",
184
+ noun: "console log",
185
+ tools: ["browser_save_console_logs"],
186
+ mime: "application/json",
187
+ uploadMime: OCTET_STREAM,
188
+ ext: "json",
189
+ filenamePrefix: "console",
190
+ maxBytes: 5 * MIB,
191
+ viewer: "console"
192
+ },
193
+ har: {
194
+ id: "har",
195
+ labelKey: "browserCaptures.kindHar",
196
+ label: "Network log",
197
+ noun: "network log",
198
+ tools: ["browser_save_har"],
199
+ mime: "application/json",
200
+ uploadMime: OCTET_STREAM,
201
+ ext: "har",
202
+ filenamePrefix: "network",
203
+ maxBytes: 25 * MIB,
204
+ uploadTimeoutMs: uploadTimeoutFor(25 * MIB),
205
+ viewer: "har"
206
+ },
207
+ screencast: {
208
+ id: "screencast",
209
+ labelKey: "browserCaptures.kindRecording",
210
+ label: "Recording",
211
+ noun: "recording",
212
+ tools: ["browser_stop_recording"],
213
+ mime: "video/webm",
214
+ uploadMime: "video/webm",
215
+ ext: "webm",
216
+ filenamePrefix: "recording",
217
+ maxBytes: 50 * MIB,
218
+ uploadTimeoutMs: uploadTimeoutFor(50 * MIB),
219
+ viewer: "video"
220
+ }
221
+ });
222
+ /** Every tool of every kind. */
223
+ const CAPTURE_KIND_TOOLS = Object.freeze(Object.values(CAPTURE_KINDS).flatMap((kind) => kind.tools));
224
+ /** Every wire Content-Type a capture upload may carry. */
225
+ const CAPTURE_UPLOAD_MIMES = Object.freeze([...new Set(Object.values(CAPTURE_KINDS).flatMap((kind) => [kind.uploadMime, ...Object.values(kind.formats ?? {}).map((f) => f.uploadMime)]))]);
226
+
227
+ //#endregion
228
+ //#region ../shared/dist/constants/first-task-nudge.constants.js
229
+ /**
230
+ * The day-after first-task nudge email's opt-out (ENG-6396, plan ENG-6341
231
+ * D13 — user decision 2026-10-12).
232
+ *
233
+ * The nudge is a one-time getting-started email, not a notification: it is
234
+ * sent by DEFAULT and is independent of `notification_preference` (whose
235
+ * email channel stays opt-in). A person stops it with
236
+ * `user_preference.first_task_nudge_email = false`, from Settings >
237
+ * Notifications or the one-click link in the email itself. The server job
238
+ * and the web client read the column through `wantsFirstTaskNudge`, and the
239
+ * email's link is built from `FIRST_TASK_NUDGE_OPT_OUT_PATH`, which the
240
+ * settings page recognises.
241
+ */
242
+ /** The settings section that holds the toggle. */
243
+ const FIRST_TASK_NUDGE_SETTINGS_SECTION = "notifications";
244
+ /** Query parameter the email's opt-out link carries. */
245
+ const FIRST_TASK_NUDGE_OPT_OUT_PARAM = "opt-out";
246
+ /** The value of `FIRST_TASK_NUDGE_OPT_OUT_PARAM` that turns the nudge off. */
247
+ const FIRST_TASK_NUDGE_OPT_OUT_VALUE = "first-task-email";
248
+ /**
249
+ * Workspace-relative path of the email's one-click opt-out link (append it to
250
+ * `<client>/w/<url_key>`). Opening it turns the nudge off for the signed-in
251
+ * person and shows the setting, where it can be turned back on.
252
+ */
253
+ const FIRST_TASK_NUDGE_OPT_OUT_PATH = `/settings/${FIRST_TASK_NUDGE_SETTINGS_SECTION}?${FIRST_TASK_NUDGE_OPT_OUT_PARAM}=${FIRST_TASK_NUDGE_OPT_OUT_VALUE}`;
254
+
255
+ //#endregion
256
+ //#region ../shared/dist/estimates/estimate-scale.js
257
+ /**
258
+ * T-shirt stored integer → label. 0 is the allow-zero tier.
259
+ *
260
+ * The gaps (no 4, 6, 7, …) are the point: these are Fibonacci values, not
261
+ * positions. `intToLabel` falls back to the raw number for anything absent, so
262
+ * a legacy ordinal that escaped the migration renders as "4" rather than
263
+ * silently masquerading as a size.
264
+ */
265
+ const TSHIRT_LABEL_BY_INT = {
266
+ 0: "-",
267
+ 1: "XS",
268
+ 2: "S",
269
+ 3: "M",
270
+ 5: "L",
271
+ 8: "XL",
272
+ 13: "XXL",
273
+ 21: "XXXL"
274
+ };
275
+ const TSHIRT_INT_BY_LABEL = Object.fromEntries(Object.entries(TSHIRT_LABEL_BY_INT).map(([int, label]) => [label, Number(int)]));
276
+
277
+ //#endregion
278
+ //#region ../shared/dist/cycles/status-fixtures.js
279
+ /** Offset applied to fixture numbers so they cannot collide with real cycles. */
280
+ const ORDERING_NUMBER_BASE = 8100;
281
+ const ORDERING_FIXTURES = [
282
+ {
283
+ key: "renumbered",
284
+ start_date: "2026-05-01",
285
+ end_date: "2026-05-14",
286
+ number: ORDERING_NUMBER_BASE + 5,
287
+ because: "oldest start_date but the HIGHEST number — a back-dated create, a reassigned number, or an import. `number` ordering wrongly puts it first"
288
+ },
289
+ {
290
+ key: "oldest",
291
+ start_date: "2026-06-01",
292
+ end_date: "2026-06-14",
293
+ number: ORDERING_NUMBER_BASE + 1,
294
+ because: "falls outside a window of 3 under the canonical ordering"
295
+ },
296
+ {
297
+ key: "mid",
298
+ start_date: "2026-06-15",
299
+ end_date: "2026-06-28",
300
+ number: ORDERING_NUMBER_BASE + 2,
301
+ because: "the third and last member of the canonical window"
302
+ },
303
+ {
304
+ key: "tied-early",
305
+ start_date: "2026-07-01",
306
+ end_date: "2026-07-14",
307
+ number: ORDERING_NUMBER_BASE + 3,
308
+ because: "shares a start_date with `tied-late`; the LOWER number, so it sorts second of the pair under DESC"
309
+ },
310
+ {
311
+ key: "tied-late",
312
+ start_date: "2026-07-01",
313
+ end_date: "2026-07-14",
314
+ number: ORDERING_NUMBER_BASE + 4,
315
+ because: "shares a start_date with `tied-early`; the HIGHER number, so the tiebreaker must place it first"
316
+ }
317
+ ];
318
+
319
+ //#endregion
320
+ //#region ../shared/dist/runs/gate-command-pattern.js
321
+ /** Package managers, fetchers, interpreters, shells, and wrappers. */
322
+ const PATTERN_WRAPPER_PROGRAMS = [
323
+ "yarn",
324
+ "npm",
325
+ "pnpm",
326
+ "bun",
327
+ "npx",
328
+ "node",
329
+ "deno",
330
+ "tsx",
331
+ "ts-node",
332
+ "python",
333
+ "python3",
334
+ "ruby",
335
+ "perl",
336
+ "sh",
337
+ "bash",
338
+ "zsh",
339
+ "pwsh",
340
+ "env",
341
+ "sudo",
342
+ "timeout",
343
+ "nice",
344
+ "xargs"
345
+ ];
346
+ /**
347
+ * Sub-fetchers and inline-code flags. A wrapper whose first fixed positional
348
+ * is one of these, or another wrapper, is too broad unless a later fixed
349
+ * positional is a real argument. Inline-code flags refuse even then.
350
+ */
351
+ const PATTERN_SUB_FETCHERS = [
352
+ "dlx",
353
+ "exec",
354
+ "x",
355
+ "node",
356
+ "run-script",
357
+ "eval"
358
+ ];
359
+ const PATTERN_INLINE_CODE_FLAGS = [
360
+ "-e",
361
+ "-c",
362
+ "-p",
363
+ "--eval",
364
+ "--print",
365
+ "--input-type"
366
+ ];
367
+ /** git subcommands that do not modify the repository. */
368
+ const GIT_READ_ONLY_SUBCOMMANDS = [
369
+ "status",
370
+ "diff",
371
+ "log",
372
+ "show",
373
+ "rev-parse",
374
+ "ls-files",
375
+ "blame",
376
+ "grep",
377
+ "describe",
378
+ "shortlog",
379
+ "name-rev",
380
+ "cat-file",
381
+ "rev-list",
382
+ "version",
383
+ "help",
384
+ "annotate",
385
+ "for-each-ref",
386
+ "merge-base",
387
+ "check-ignore",
388
+ "check-attr",
389
+ "count-objects",
390
+ "var",
391
+ "interpret-trailers"
392
+ ];
393
+ const WRAPPERS = new Set(PATTERN_WRAPPER_PROGRAMS);
394
+ const SUB_FETCHERS = new Set(PATTERN_SUB_FETCHERS);
395
+ const INLINE_FLAGS = new Set(PATTERN_INLINE_CODE_FLAGS);
396
+ const GIT_READ_ONLY = new Set(GIT_READ_ONLY_SUBCOMMANDS);
397
+
398
+ //#endregion
399
+ //#region ../shared/dist/runs/evidence-utilities.js
400
+ /**
401
+ * Evidence Utilities — shared logic for creating issue_test_evidence records
402
+ * when execution results change status.
403
+ *
404
+ * Used by the backend (tq/run/run-result-variant.service.ts) after rollup
405
+ * to automatically record evidence. Can also be used by the client for
406
+ * optimistic evidence creation.
407
+ */
408
+ /**
409
+ * The evidence vocabulary — the TypeScript source of truth (ENG-4257 D1).
410
+ *
411
+ * Mirrors BOTH per-table Postgres enums (`issue_test_evidence_evidence_type_enum`
412
+ * and `issue_automation_test_evidence_evidence_type_enum`), which are extended
413
+ * SEPARATELY — per-table enums are the codebase convention and the generator
414
+ * mints `{table}_{column}_enum`. The three-way agreement spec in
415
+ * `apps/backender/src/tq/evidence/evidence-vocabulary-agreement.spec.ts`
416
+ * asserts this tuple set-equals `Object.values` of both generated enums, so a
417
+ * value added to the specs without running codegen, or to one enum and not the
418
+ * other, fails a test instead of diverging silently.
419
+ *
420
+ * `superseded` is a RETRACTION, not an observation: a previously recorded
421
+ * verdict has been withdrawn (result reset to Retest/Pending, or its run
422
+ * deleted). The ledger is append-only and never mutates history — a retraction
423
+ * is a newer row that outranks the verdict it withdraws. (The one exception
424
+ * is a REWORDING, never a new observation: the ENG-5030 data migration
425
+ * retypes `pending` rows written for blocked results to `blocked` in place,
426
+ * because appending would stamp a fresh recorded_at and falsely outrank a
427
+ * later verdict.)
428
+ *
429
+ * `override` and `not_applicable` are EXCUSES, not observations either
430
+ * (ENG-4187): a human accepted the link's state and authorized completion
431
+ * anyway (`override`), or declared that the test cannot apply to this issue
432
+ * (`not_applicable`). Both carry a required `reason`, assert nothing about
433
+ * execution, and stand until newer evidence supersedes them. They classify to
434
+ * the `waived` verdict in link-verification.ts — never to `verified`, so the
435
+ * coverage widget does not count a waived link as covered.
436
+ *
437
+ * `pending` is an OBLIGATION, and the newest member. A run exists for this
438
+ * link and has not answered yet. It is what separates "queued, waiting" from
439
+ * "never run", which had no separate word before the vocabulary alignment —
440
+ * both read `unverified` and the projection could not tell a user which one
441
+ * they were looking at.
442
+ *
443
+ * It is written where every other verdict is written: run creation and a
444
+ * retest append a row, and the real verdict supersedes it by recency. The
445
+ * projection never queries `run_result` — `verified` and `failing` do not, and
446
+ * `pending` earns its place by working the same way.
447
+ *
448
+ * `blocked` is the other obligation (ENG-5030). The tester could not run the
449
+ * test — something external stopped it — so nothing was observed and an
450
+ * answer is still owed, exactly as with `pending`; what the word adds is WHY.
451
+ * ENG-5027 wrote `pending` for a Blocked result while Blocked had no storage
452
+ * type of its own; with the type real, the ledger says the honest thing.
453
+ * Classifies to `blocked` in link-verification.ts: blocks Done like
454
+ * `pending`, is not coverage, and never asserts a failure.
455
+ */
456
+ const EVIDENCE_TYPES = [
457
+ "detection",
458
+ "pass",
459
+ "fail",
460
+ "regression",
461
+ "verification",
462
+ "flaky",
463
+ "superseded",
464
+ "override",
465
+ "not_applicable",
466
+ "pending",
467
+ "skipped",
468
+ "blocked"
469
+ ];
470
+ /**
471
+ * [issue:ENG-6034] Which evidence kinds record an EXECUTION. The rest record a
472
+ * DECISION: nobody ran anything, so no executor may be attributed to them. A
473
+ * waiver (`override`, legacy `not_applicable`) and a retraction
474
+ * (`superseded`) are acts about the obligation; `pending` says an answer is
475
+ * owed; `skipped` is an abstention that reads as waived.
476
+ *
477
+ * `issue_test_evidence.executor_type` is NULL for decision rows. Every writer
478
+ * reads this one table — the client-write stamp in
479
+ * IssueTestEvidenceService.create, the server writer in
480
+ * RunResultVariantService, and the client's display helper
481
+ * (`evidenceExecutorType`) — so none can disagree about which rows had an
482
+ * executor.
483
+ *
484
+ * A `Record` over the whole union rather than a list of exceptions: adding a
485
+ * kind to `EVIDENCE_TYPES` without deciding here is a compile error, instead
486
+ * of the new kind silently defaulting to "an execution" and being stamped.
487
+ */
488
+ const IS_EXECUTION = {
489
+ detection: true,
490
+ pass: true,
491
+ fail: true,
492
+ regression: true,
493
+ verification: true,
494
+ flaky: true,
495
+ blocked: true,
496
+ superseded: false,
497
+ override: false,
498
+ not_applicable: false,
499
+ pending: false,
500
+ skipped: false
501
+ };
502
+ const NOT_EXECUTED_EVIDENCE_TYPES = EVIDENCE_TYPES.filter((t) => !IS_EXECUTION[t]);
503
+
504
+ //#endregion
505
+ //#region ../shared/dist/runs/automation-failure.js
506
+ /**
507
+ * What counts as an automation FAILURE — defined once (ENG-6017, absorbs
508
+ * ENG-6141).
509
+ *
510
+ * An automation result is a failure when its status is `failed`, `errored`,
511
+ * `timed_out` or `interrupted`. Run-level surfaces always agreed on that; the
512
+ * test-level signals (health fail rate, worst offenders, suite ranking, flip
513
+ * rate, recent-results strip, PR comment) read `failed` alone, so a test that
514
+ * only errored, timed out or was interrupted looked healthy. Every consumer
515
+ * reads this module instead of keeping its own list:
516
+ *
517
+ * - backender `RED_AUTOMATION_RESULT_STATUS_TYPES` (gen automation-run-result
518
+ * service) is built from `AUTOMATION_FAILURE_STATUS_TYPES`;
519
+ * - `AutomationBuilderService.tallyResult` and the rollup `errored_count` arm
520
+ * (`rollup-aggregate.util.ts`) bucket `AUTOMATION_ERRORED_BUCKET_STATUS_TYPES`;
521
+ * - `TestHealthService` (transition and readout) and the client suite widget
522
+ * compute the rate through `automationFailRate`;
523
+ * - widget-queries `automationFailRateExpr` is the SQL twin of
524
+ * `automationFailRate` (drift-tested in `dialect-portability.test.ts`);
525
+ * - the flip-rate scorer, the recent-results strip and the PR comment list
526
+ * filter on `AUTOMATION_FAILURE_STATUS_TYPES`;
527
+ * - the client status pill and the run-detail failed filter.
528
+ *
529
+ * Pure and browser-safe: no imports.
530
+ */
531
+ /** Every automation result status that is a failure. */
532
+ const AUTOMATION_FAILURE_STATUS_TYPES = [
533
+ "failed",
534
+ "errored",
535
+ "timed_out",
536
+ "interrupted"
537
+ ];
538
+ const FAILURE_SET = new Set(AUTOMATION_FAILURE_STATUS_TYPES);
539
+
540
+ //#endregion
541
+ //#region ../shared/dist/runs/link-verification.js
542
+ /** Latest-evidence types that satisfy a verifies/accepts link. */
543
+ const PASSING_EVIDENCE_TYPES = ["pass", "verification"];
544
+ /**
545
+ * The complement of PASSING_EVIDENCE_TYPES over the full vocabulary (ENG-4257
546
+ * D2) — DERIVED, never hand-written, so the coverage widget's inverted SQL
547
+ * predicate (D5) is built from the same list the classifier below is total
548
+ * over and cannot disagree with it. The partition test in
549
+ * link-verification.test.ts pins PASSING ∪ NON_PASSING = EVIDENCE_TYPES and
550
+ * PASSING ∩ NON_PASSING = ∅.
551
+ */
552
+ const NON_PASSING_EVIDENCE_TYPES = EVIDENCE_TYPES.filter((t) => !PASSING_EVIDENCE_TYPES.includes(t));
553
+
554
+ //#endregion
555
+ //#region ../shared/dist/runs/gate-command-validation.js
556
+ /** Plain file and text inspection — the presence and pattern checks. */
557
+ const INSPECTION_BINARIES = [
558
+ "grep",
559
+ "test",
560
+ "[",
561
+ "[[",
562
+ "wc",
563
+ "cat",
564
+ "head",
565
+ "tail",
566
+ "awk",
567
+ "sed",
568
+ "sort",
569
+ "uniq",
570
+ "echo",
571
+ "printf",
572
+ "tr",
573
+ "cut",
574
+ "diff",
575
+ "find",
576
+ "ls",
577
+ "xargs",
578
+ "cd",
579
+ "timeout",
580
+ "rg",
581
+ "tee",
582
+ "shellcheck",
583
+ "actionlint",
584
+ "jq",
585
+ "cmp"
586
+ ];
587
+ /**
588
+ * Arguments a binary may never be given — second R4 follow-up. `jq` (1.7.1,
589
+ * checked) has no in-place option; `-i` / `--in-place` are refused anyway so
590
+ * a jq build or wrapper that grew one (as `yq -i` and `sed -i` have) can
591
+ * never rewrite a file from a gate. A short-flag cluster holding `i` counts.
592
+ */
593
+ const FORBIDDEN_ARGS = new Map([["jq", /(?:^|\s)(?:-[A-Za-z]*i[A-Za-z]*|--in-place)(?:=|\s|$)/]]);
594
+ /**
595
+ * A short option anywhere in a cluster, attached value or not: `-r x`,
596
+ * `-rx`, `-vr x`. A cluster is read only up to the first letter that takes a
597
+ * value (`valueLetters`): in `-cfoo.yml` the `c` takes `foo.yml`, so its `o`
598
+ * is part of a path, not a flag.
599
+ */
600
+ function shortFlagArg(letter, valueLetters) {
601
+ return /* @__PURE__ */ new RegExp(`(?:^|[\\s<>|;&(])-(?:(?![${valueLetters}])[A-Za-z])*${letter}`);
602
+ }
603
+ /**
604
+ * One argument flag, alone, with an attached `=value`, or run into a shell
605
+ * operator (`--report-path>x`): anything but another flag-name character
606
+ * ends it.
607
+ */
608
+ function flagArg(flag) {
609
+ return /* @__PURE__ */ new RegExp(`(?:^|[\\s<>|;&(])${flag.replace(/-/g, "\\-")}(?![A-Za-z0-9_-])`);
610
+ }
611
+ /**
612
+ * The words the shell would pass, split at whitespace AND at the operators
613
+ * that end a word (`--config=auto>/tmp/log` passes `--config=auto`), with
614
+ * one layer of surrounding quotes removed. A `#` that starts a word begins a
615
+ * comment, and a redirection's target (`>--metrics=off`, `2> x`) is a file,
616
+ * so neither is an argument.
617
+ */
618
+ function argWords(rest) {
619
+ return rest.replace(/(^|\s)#.*$/s, "").replace(/(?:(?<=^|[\s<>|;&()])\d+)?(?:<<?|>>?)&?\s*[^\s<>|;&()]*/g, " ").split(/[\s<>|;&()]+/).filter((word) => word.length > 0).map((word) => word.replace(/^(['"])(.*)\1$/, "$2"));
620
+ }
621
+ /**
622
+ * [issue:ENG-7618 D3.2] A config semgrep fetches from the network: a URL,
623
+ * `auto`, or a registry ruleset (`p/…`, `r/…`, `s/…`). Registry rules are
624
+ * downloaded at run time and are under the Semgrep Rules License.
625
+ */
626
+ function isRemoteSemgrepConfig(value) {
627
+ return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//") || value === "auto" || /^[prs]\//.test(value);
628
+ }
629
+ /**
630
+ * [issue:ENG-7618 D3.2] `semgrep scan` / `opengrep scan` must name a LOCAL
631
+ * rule set and turn telemetry off. Without an explicit `--config` semgrep
632
+ * picks one from its own default source (`SEMGREP_RULES`, or the registry
633
+ * depending on version) and sends metrics. Every spelling is read: `--config
634
+ * X`, `--config=X`, `-c X`, `-cX`, `-c=X`, `-f X`, `-fX` and `-f=X` for the
635
+ * config, and
636
+ * `--metrics X` / `--metrics=X` for metrics, of which the LAST one counts (so
637
+ * a trailing `--metrics=on` undoes an earlier `off`).
638
+ */
639
+ /**
640
+ * semgrep's short options that take a value: `-c`/`-f` config, `-e`
641
+ * pattern, `-l` language, `-o` output, `-j` jobs.
642
+ */
643
+ const SEMGREP_VALUE_LETTERS = /[cfeloj]/;
644
+ /** semgrep's long options that take the next word as their value. */
645
+ const SEMGREP_LONG_VALUE_FLAGS = new Set([
646
+ "--pattern",
647
+ "--lang",
648
+ "--output",
649
+ "--jobs",
650
+ "--include",
651
+ "--exclude",
652
+ "--exclude-rule",
653
+ "--severity",
654
+ "--timeout",
655
+ "--timeout-threshold",
656
+ "--max-memory",
657
+ "--max-target-bytes",
658
+ "--max-chars-per-line",
659
+ "--max-lines-per-finding",
660
+ "--baseline-commit",
661
+ "--project-root",
662
+ "--replacement",
663
+ "--sarif-output",
664
+ "--json-output",
665
+ "--text-output",
666
+ "--emacs-output",
667
+ "--vim-output",
668
+ "--gitlab-sast-output",
669
+ "--gitlab-secrets-output",
670
+ "--junit-xml-output"
671
+ ]);
672
+ function semgrepRequiredArgs(rest) {
673
+ const words = argWords(rest);
674
+ const configs = [];
675
+ let metrics;
676
+ for (let i = 0; i < words.length; i++) {
677
+ const word = words[i] ?? "";
678
+ const next = words[i + 1];
679
+ let m;
680
+ if (word === "--") break;
681
+ if (word === "--config") {
682
+ if (next !== void 0) configs.push(next);
683
+ i++;
684
+ } else if (m = /^--config=(.*)$/.exec(word)) configs.push(m[1] ?? "");
685
+ else if (m = /^-([A-Za-z]+)(=?)(.*)$/.exec(word)) {
686
+ const letters = m[1] ?? "";
687
+ const at = letters.search(SEMGREP_VALUE_LETTERS);
688
+ if (at === -1) continue;
689
+ const attached = letters.slice(at + 1) + (m[2] ?? "") + (m[3] ?? "");
690
+ const value = attached.replace(/^=/, "");
691
+ const isConfig = letters[at] === "c" || letters[at] === "f";
692
+ if (attached.length > 0) {
693
+ if (isConfig) configs.push(value);
694
+ } else if (next !== void 0) {
695
+ if (isConfig) configs.push(next);
696
+ i++;
697
+ }
698
+ } else if (word === "--metrics") {
699
+ metrics = next;
700
+ i++;
701
+ } else if (m = /^--metrics=(.*)$/.exec(word)) metrics = m[1];
702
+ else if (SEMGREP_LONG_VALUE_FLAGS.has(word)) i++;
703
+ }
704
+ const named = configs.filter((value) => value.length > 0);
705
+ if (named.length === 0) return "must name a local rule set with --config <path>";
706
+ const globbed = named.find((value) => /[*?[\]{}]/.test(value));
707
+ if (globbed !== void 0) return `may not use the glob config "${globbed}": name the rule set's path`;
708
+ const remote = named.find(isRemoteSemgrepConfig);
709
+ if (remote !== void 0) return `may not use the remote config "${remote}" (a URL, auto, or a p/ r/ s/ registry ruleset); point --config at a local path`;
710
+ if (metrics !== "off") return `must turn telemetry off with --metrics=off (the last --metrics value counts); found ${metrics === void 0 ? "no --metrics argument" : `--metrics=${metrics}`}`;
711
+ return null;
712
+ }
713
+ const SEMGREP_RULE = {
714
+ globals: /^/,
715
+ allowed: new Set(["scan"]),
716
+ refuseUncheckable: true,
717
+ forbiddenArgs: [
718
+ {
719
+ pattern: flagArg("--output"),
720
+ name: "--output"
721
+ },
722
+ {
723
+ pattern: shortFlagArg("o", "cfelj"),
724
+ name: "-o"
725
+ },
726
+ {
727
+ pattern: /(?:^|[\s<>|;&(])--[a-z0-9-]+-output(?![A-Za-z0-9_-])/,
728
+ name: "--<format>-output"
729
+ },
730
+ {
731
+ pattern: flagArg("--autofix"),
732
+ name: "--autofix"
733
+ },
734
+ {
735
+ pattern: flagArg("--fix"),
736
+ name: "--fix"
737
+ }
738
+ ],
739
+ requiredArgs: semgrepRequiredArgs
740
+ };
741
+ const SUBCOMMAND_ALLOWLISTS = new Map([
742
+ ["git", {
743
+ globals: /^(?:(?:-C\s+\S+|--no-pager|--no-optional-locks)\s+)*/,
744
+ allowed: new Set([
745
+ "diff",
746
+ "diff-tree",
747
+ "diff-index",
748
+ "diff-files",
749
+ "log",
750
+ "shortlog",
751
+ "show",
752
+ "show-ref",
753
+ "status",
754
+ "ls-files",
755
+ "ls-tree",
756
+ "ls-remote",
757
+ "grep",
758
+ "rev-parse",
759
+ "rev-list",
760
+ "merge-base",
761
+ "cat-file",
762
+ "blame",
763
+ "for-each-ref",
764
+ "describe",
765
+ "name-rev",
766
+ "check-ignore",
767
+ "check-attr"
768
+ ]),
769
+ forbiddenArgs: [{
770
+ pattern: flagArg("--output"),
771
+ name: "--output (it writes a file)"
772
+ }]
773
+ }],
774
+ ["helm", {
775
+ globals: /^/,
776
+ allowed: new Set(["lint", "template"])
777
+ }],
778
+ ["gitleaks", {
779
+ globals: /^/,
780
+ allowed: new Set(["dir", "detect"]),
781
+ refuseUncheckable: true,
782
+ forbiddenArgs: [
783
+ {
784
+ pattern: flagArg("--report-path"),
785
+ name: "--report-path"
786
+ },
787
+ {
788
+ pattern: flagArg("--diagnostics"),
789
+ name: "--diagnostics"
790
+ },
791
+ {
792
+ pattern: flagArg("--diagnostics-dir"),
793
+ name: "--diagnostics-dir"
794
+ },
795
+ {
796
+ pattern: shortFlagArg("r", "cibfls"),
797
+ name: "-r"
798
+ }
799
+ ]
800
+ }],
801
+ ["semgrep", SEMGREP_RULE],
802
+ ["opengrep", SEMGREP_RULE],
803
+ ["trivy", {
804
+ globals: /^/,
805
+ allowed: new Set(["fs", "config"]),
806
+ refuseUncheckable: true,
807
+ forbiddenArgs: [
808
+ {
809
+ pattern: flagArg("--output"),
810
+ name: "--output"
811
+ },
812
+ {
813
+ pattern: shortFlagArg("o", "cfst"),
814
+ name: "-o"
815
+ },
816
+ {
817
+ pattern: flagArg("--download-db-only"),
818
+ name: "--download-db-only"
819
+ },
820
+ {
821
+ pattern: flagArg("--download-java-db-only"),
822
+ name: "--download-java-db-only"
823
+ },
824
+ {
825
+ pattern: flagArg("--server"),
826
+ name: "--server"
827
+ },
828
+ {
829
+ pattern: flagArg("--cache-dir"),
830
+ name: "--cache-dir"
831
+ },
832
+ {
833
+ pattern: flagArg("--module-dir"),
834
+ name: "--module-dir"
835
+ },
836
+ {
837
+ pattern: flagArg("--db-repository"),
838
+ name: "--db-repository"
839
+ },
840
+ {
841
+ pattern: flagArg("--java-db-repository"),
842
+ name: "--java-db-repository"
843
+ },
844
+ {
845
+ pattern: flagArg("--checks-bundle-repository"),
846
+ name: "--checks-bundle-repository"
847
+ },
848
+ {
849
+ pattern: flagArg("--cache-backend"),
850
+ name: "--cache-backend"
851
+ },
852
+ {
853
+ pattern: /(?:^|[\s<>|;&(])--redis-[a-z-]+(?![A-Za-z0-9_-])/,
854
+ name: "--redis-*"
855
+ },
856
+ {
857
+ pattern: flagArg("--rekor-url"),
858
+ name: "--rekor-url"
859
+ },
860
+ {
861
+ pattern: flagArg("--sbom-sources"),
862
+ name: "--sbom-sources"
863
+ },
864
+ {
865
+ pattern: flagArg("server"),
866
+ name: "server"
867
+ }
868
+ ]
869
+ }]
870
+ ]);
871
+ /**
872
+ * ENG-5106 L1 review round 3 F-003 — the utility `xargs` runs: the first word
873
+ * after its options, skipping each option's VALUE (`-n 1`, `-I {}`,
874
+ * `--max-args 1`; attached forms `-n1`, `--max-args=1` carry their own).
875
+ * A prefix regex let `xargs -n 1 test -n` through as a non-`test` command.
876
+ */
877
+ const XARGS_SHORT_WITH_VALUE = new Set([
878
+ "a",
879
+ "d",
880
+ "E",
881
+ "I",
882
+ "L",
883
+ "n",
884
+ "P",
885
+ "s"
886
+ ]);
887
+ const XARGS_LONG_WITH_VALUE = new Set([
888
+ "--arg-file",
889
+ "--delimiter",
890
+ "--max-args",
891
+ "--max-procs",
892
+ "--max-chars",
893
+ "--process-slot-var"
894
+ ]);
895
+ /**
896
+ * Review round 4 F-004 / F-009 — what `xargs` may run: read-only utilities.
897
+ * Parsing every way to reach `test` (bundled `-rn 1`, quoted `'test'`,
898
+ * `timeout 5 test`, `env test`, `sh -c 'test …'`) was a losing game; live
899
+ * gates use `grep` alone. Anything else is refused.
900
+ */
901
+ const XARGS_UTILITIES = new Set([
902
+ "grep",
903
+ "egrep",
904
+ "fgrep",
905
+ "rg",
906
+ "wc",
907
+ "cat",
908
+ "head",
909
+ "tail",
910
+ "ls",
911
+ "basename",
912
+ "dirname",
913
+ "stat",
914
+ "file",
915
+ "echo",
916
+ "printf",
917
+ "sort",
918
+ "uniq",
919
+ "cut",
920
+ "tr",
921
+ "jq"
922
+ ]);
923
+ /**
924
+ * The utility `xargs` runs and that utility's own arguments. Quoted words
925
+ * arrive already masked to `""` by `scanShellCommand`, so a quoted utility
926
+ * is the empty string here — and is refused like any other.
927
+ */
928
+ function xargsUtility(args) {
929
+ const words = args.trim().split(/\s+/).filter(Boolean);
930
+ const at = (k) => k < words.length ? {
931
+ utility: words[k],
932
+ rest: words.slice(k + 1).join(" ")
933
+ } : null;
934
+ for (let k = 0; k < words.length; k++) {
935
+ const w = words[k];
936
+ if (w === "--") return at(k + 1);
937
+ if (w.startsWith("--")) {
938
+ if (!w.includes("=") && XARGS_LONG_WITH_VALUE.has(w)) k++;
939
+ continue;
940
+ }
941
+ if (w.startsWith("-") && w.length > 1) {
942
+ for (let j = 1; j < w.length; j++) if (XARGS_SHORT_WITH_VALUE.has(w[j])) {
943
+ if (j === w.length - 1) k++;
944
+ break;
945
+ }
946
+ continue;
947
+ }
948
+ return at(k);
949
+ }
950
+ return null;
951
+ }
952
+ const baseName = (word) => word.slice(word.lastIndexOf("/") + 1);
953
+ function isTestUtility(word) {
954
+ if (word === null) return false;
955
+ const base = baseName(word);
956
+ return base === "test" || base === "[";
957
+ }
958
+ /**
959
+ * The refusal for a subcommand-restricted binary, or null when this use is
960
+ * allowed (or the binary is not restricted).
961
+ */
962
+ function subcommandRefusal(token, args) {
963
+ const xargsRuns = token === "xargs" ? xargsUtility(args) : null;
964
+ if (xargsRuns !== null && isTestUtility(xargsRuns.utility)) return "\"xargs test\" passes on empty input, so this check cannot fail (ENG-4761). Redeploy the gate: current templates test the search result with test -n \"$(…)\"";
965
+ if (xargsRuns !== null) {
966
+ const utility = baseName(xargsRuns.utility);
967
+ if (!XARGS_UTILITIES.has(utility)) return `"xargs" may only run a read-only utility (${[...XARGS_UTILITIES].join(", ")}), not ` + (utility === "\"\"" || utility === "" ? "a quoted word" : `"${utility}"`);
968
+ return subcommandRefusal(utility, xargsRuns.rest);
969
+ }
970
+ if (FORBIDDEN_ARGS.get(token)?.test(args)) return `"${token}" may not edit a file in place (-i / --in-place)`;
971
+ const rule = SUBCOMMAND_ALLOWLISTS.get(token);
972
+ if (!rule) return null;
973
+ const rest = args.replace(rule.globals, "");
974
+ const sub = rest.split(/\s+/)[0] ?? "";
975
+ if (!rule.allowed.has(sub)) return `"${token} ${sub || "(no subcommand)"}" is not allowed; ${token} may only run ${[...rule.allowed].join(", ")}`;
976
+ const words = rest.split(/\s+/).filter(Boolean);
977
+ const uncheckable = rule.refuseUncheckable ? words.find((w) => {
978
+ if (w.includes("\"\"") || w.includes("\\") || w.includes("$")) return true;
979
+ if (/\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(w)) return true;
980
+ return /[*?[]/.test(w);
981
+ }) : void 0;
982
+ if (uncheckable !== void 0) return `"${token} ${sub}" may not quote, escape or expand an argument (${uncheckable}): the word the shell runs could not be checked`;
983
+ const refusedArg = rule.forbiddenArgs?.find(({ pattern }) => pattern.test(rest));
984
+ if (refusedArg) return `"${token} ${sub}" may not use ${refusedArg.name}`;
985
+ const missing = rule.requiredArgs?.(rest);
986
+ if (missing) return `"${token} ${sub}" ${missing}`;
987
+ return null;
988
+ }
989
+ /** Interpreters and shells the seeded templates and L3/L4 gates use. */
990
+ const INTERPRETER_BINARIES = [
991
+ "node",
992
+ "python3",
993
+ "python",
994
+ "bash",
995
+ "sh",
996
+ "perl",
997
+ "ruby",
998
+ "php"
999
+ ];
1000
+ /**
1001
+ * Package managers, build tools and test runners a command binding may
1002
+ * supply — review R4-01. Every toolchain the binding docs and specs name
1003
+ * (`running-gates.mdx`, `gate-bindings.tool.spec.ts`,
1004
+ * `gate-type-resolver-command-slots.spec.ts`, the toolchain migration).
1005
+ */
1006
+ const GATE_TOOLCHAIN_BINARIES = [
1007
+ "yarn",
1008
+ "npx",
1009
+ "npm",
1010
+ "pnpm",
1011
+ "bun",
1012
+ "bunx",
1013
+ "deno",
1014
+ "tsc",
1015
+ "tsgo",
1016
+ "go",
1017
+ "cargo",
1018
+ "pytest",
1019
+ "uv",
1020
+ "poetry",
1021
+ "pipenv",
1022
+ "hatch",
1023
+ "tox",
1024
+ "phpunit",
1025
+ "pest",
1026
+ "composer",
1027
+ "bundle",
1028
+ "rspec",
1029
+ "rake",
1030
+ "mvn",
1031
+ "gradle",
1032
+ "dotnet",
1033
+ "swift",
1034
+ "mix",
1035
+ "make"
1036
+ ];
1037
+ /** Semantic judges (L4), invoked directly or piped into. */
1038
+ const GATE_JUDGE_BINARIES = [
1039
+ "agy",
1040
+ "codex",
1041
+ "gemini"
1042
+ ];
1043
+ /**
1044
+ * Every binary a gate command may start a command with, in one list — what a
1045
+ * refusal message names.
1046
+ */
1047
+ const GATE_COMMAND_BINARIES = [
1048
+ ...INSPECTION_BINARIES,
1049
+ ...INTERPRETER_BINARIES,
1050
+ ...GATE_TOOLCHAIN_BINARIES,
1051
+ ...GATE_JUDGE_BINARIES
1052
+ ];
1053
+ const ALLOWED_BINARIES = new Set(GATE_COMMAND_BINARIES);
1054
+ /**
1055
+ * Shell builtins a command may be. `exit`/`true`/`false` end real gates
1056
+ * (`…; exit $?`); `set -e`/`set -o pipefail` and `read` are common in
1057
+ * multi-line checks; `:` is the no-op. `export` is handled separately (it
1058
+ * must only assign).
1059
+ */
1060
+ const ALLOWED_BUILTINS = new Set([
1061
+ "exit",
1062
+ "true",
1063
+ "false",
1064
+ "set",
1065
+ "read",
1066
+ ":"
1067
+ ]);
1068
+ /**
1069
+ * Runners a project installs locally and invokes by path — review R4-01
1070
+ * (`./vendor/bin/phpunit` on prod ENG-113..115, `node_modules/.bin/vitest`).
1071
+ */
1072
+ const PROJECT_LOCAL_RUNNERS = new Set([
1073
+ "phpunit",
1074
+ "pest",
1075
+ "paratest",
1076
+ "phpstan",
1077
+ "psalm",
1078
+ "rspec",
1079
+ "rake",
1080
+ "vitest",
1081
+ "jest",
1082
+ "playwright",
1083
+ "mocha",
1084
+ "tsc",
1085
+ "tsx",
1086
+ "eslint",
1087
+ "pytest"
1088
+ ]);
1089
+ /**
1090
+ * Is `token` a project-local runner (`./vendor/bin/phpunit`,
1091
+ * `vendor/bin/pest`, `./node_modules/.bin/vitest`, `bin/rspec`) or a build
1092
+ * wrapper checked into the repo (`./gradlew`, `./mvnw`)?
1093
+ */
1094
+ function isProjectLocalTool(token) {
1095
+ if (token === "./gradlew" || token === "./mvnw") return true;
1096
+ const m = /^(?:\.\/)?(?:vendor\/bin|node_modules\/\.bin|bin)\/([\w.-]+)$/.exec(token);
1097
+ return m !== null && PROJECT_LOCAL_RUNNERS.has(m[1]);
1098
+ }
1099
+ /** Binaries `!` may negate — review R4-03. */
1100
+ const NEGATABLE_BINARIES = new Set([
1101
+ "grep",
1102
+ "test",
1103
+ "[",
1104
+ "[["
1105
+ ]);
1106
+ function isAllowedBinary(token) {
1107
+ return ALLOWED_BINARIES.has(token) || ALLOWED_BUILTINS.has(token) || token.endsWith("/grep") || isProjectLocalTool(token);
1108
+ }
1109
+ /**
1110
+ * Words after which the next word is again in command position: compound
1111
+ * openers and `!`. Review round 4 F-001 added `if`, `while`, `until` and `!`
1112
+ * (`if case x in x) touch M ;; esac; then …` slipped past without them).
1113
+ */
1114
+ const COMMAND_OPENERS = new Set([
1115
+ "then",
1116
+ "do",
1117
+ "else",
1118
+ "elif",
1119
+ "if",
1120
+ "while",
1121
+ "until",
1122
+ "{",
1123
+ "!"
1124
+ ]);
1125
+ /**
1126
+ * Is `s[i]` at the start of a word in COMMAND position — the start of the
1127
+ * text (`from`), or after `;` `|` `&` `(` `)` a newline, or after an opener
1128
+ * word that is itself in command position? A loop, not recursion (review
1129
+ * round 4 F-013: 40 KB of `then ` overflowed the stack).
1130
+ */
1131
+ function isInCommandPosition(s, i, from) {
1132
+ let at = i;
1133
+ for (;;) {
1134
+ let p = at - 1;
1135
+ while (p >= from && (s[p] === " " || s[p] === " ")) p--;
1136
+ if (p < from) return true;
1137
+ if (";|&()\n".includes(s[p])) return true;
1138
+ let q = p;
1139
+ while (q >= from && /[a-z{!]/.test(s[q])) q--;
1140
+ const word = s.slice(q + 1, p + 1);
1141
+ if (!(q < from || /[\s;|&()]/.test(s[q])) || !COMMAND_OPENERS.has(word)) return false;
1142
+ if (q < from) return true;
1143
+ at = q + 1;
1144
+ }
1145
+ }
1146
+ /**
1147
+ * Review round 4 (root cause A) — what this scanner does NOT model, it
1148
+ * REFUSES rather than guesses. Each of these makes bash read the text
1149
+ * differently from `scanShellCommand`, and every one was reproduced
1150
+ * running a command the validator never saw:
1151
+ *
1152
+ * - `$'…'` anywhere (ANSI-C quoting: `\'` does not end it);
1153
+ * - a heredoc `<<` anywhere (its body is data to bash, commands to us);
1154
+ * - inside a `$( … )`, `<( … )` or `>( … )`: a word-start `#` (a comment
1155
+ * can hide the `)`), a `${` (`${x/)/y}` ends the substitution early for
1156
+ * us), or `case` in command position (its pattern `)`s).
1157
+ *
1158
+ * Review round 6 F-001: process substitutions are bodies bash runs exactly
1159
+ * like `$( )`, so they are read the same way — `grep x <(echo #)` followed
1160
+ * by a newline and `touch p` ran the `touch` while this looked only at `$(`.
1161
+ *
1162
+ * Measured against every live gate step on staging (10,643; 6,217
1163
+ * accepted today): none uses any of these.
1164
+ */
1165
+ function unmodelledConstruct(t, inSubstitution = false) {
1166
+ let quote = null;
1167
+ for (let i = 0; i < t.length; i++) {
1168
+ const c = t[i];
1169
+ if (quote === "'") {
1170
+ if (c === "'") quote = null;
1171
+ continue;
1172
+ }
1173
+ if (c === "\\") {
1174
+ i++;
1175
+ continue;
1176
+ }
1177
+ if (c === "$" && t[i + 1] === "(") {
1178
+ const close = closeOfParen(t, i + 2);
1179
+ if (close === -1) return null;
1180
+ const inner = unmodelledConstruct(t.slice(i + 2, close - 1), true);
1181
+ if (inner) return inner;
1182
+ i = close - 1;
1183
+ continue;
1184
+ }
1185
+ if (quote === "\"") {
1186
+ if (c === "\"") quote = null;
1187
+ else if (inSubstitution && c === "$" && t[i + 1] === "{") return "`${` inside a substitution";
1188
+ continue;
1189
+ }
1190
+ if ((c === "<" || c === ">") && t[i + 1] === "(") {
1191
+ const close = closeOfParen(t, i + 2);
1192
+ if (close === -1) return null;
1193
+ const inner = unmodelledConstruct(t.slice(i + 2, close - 1), true);
1194
+ if (inner) return inner;
1195
+ i = close - 1;
1196
+ continue;
1197
+ }
1198
+ if (c === "'" || c === "\"") {
1199
+ if (c === "'" && t[i - 1] === "$") return "`$'…'` quoting";
1200
+ quote = c;
1201
+ continue;
1202
+ }
1203
+ if (c === "<" && t[i + 1] === "<" && t[i + 2] !== "<" && t[i - 1] !== "<") return "a heredoc (`<<`)";
1204
+ if (!inSubstitution) continue;
1205
+ if (c === "#" && (i === 0 || /[\s;|&(]/.test(t[i - 1]))) return "a `#` comment inside a substitution";
1206
+ if (c === "$" && t[i + 1] === "{") return "`${` inside a substitution";
1207
+ if (c === "c" && t.startsWith("case", i) && /^(?:\s|$)/.test(t.slice(i + 4, i + 5)) && (i === 0 || /[\s;|&()]/.test(t[i - 1])) && isInCommandPosition(t, i, 0)) return "`case` inside a substitution";
1208
+ }
1209
+ return null;
1210
+ }
1211
+ /**
1212
+ * Index just past the `)` that closes a `(` whose body starts at `from`, or
1213
+ * -1. Quote-aware: a `)` inside quotes does not close it.
1214
+ *
1215
+ * Exported for `splitRespectingQuotes` (ENG-5106 L1 review F-017): the
1216
+ * splitter that decides what the executor RUNS must read a substitution
1217
+ * exactly as this validator does, so there is one copy.
1218
+ */
1219
+ function closeOfParen(s, from) {
1220
+ let depth = 1;
1221
+ let quote = null;
1222
+ const cases = [];
1223
+ const wordAt = (i, word) => s.startsWith(word, i) && /^(?:[\s;|&)]|$)/.test(s.slice(i + word.length, i + word.length + 1)) && isInCommandPosition(s, i, from);
1224
+ for (let i = from; i < s.length; i++) {
1225
+ const c = s[i];
1226
+ if (quote === "'") {
1227
+ if (c === "'") quote = null;
1228
+ continue;
1229
+ }
1230
+ if (c === "\\") {
1231
+ i++;
1232
+ continue;
1233
+ }
1234
+ if (quote === "\"") {
1235
+ if (c === "\"") quote = null;
1236
+ continue;
1237
+ }
1238
+ if (c === "'" || c === "\"") quote = c;
1239
+ else if (c === "c" && wordAt(i, "case")) {
1240
+ cases.push(depth);
1241
+ i += 3;
1242
+ } else if (c === "e" && wordAt(i, "esac") && cases.at(-1) === depth) {
1243
+ cases.pop();
1244
+ i += 3;
1245
+ } else if (c === "(") depth++;
1246
+ else if (c === ")") {
1247
+ if (cases.at(-1) === depth) continue;
1248
+ if (--depth === 0) return i + 1;
1249
+ }
1250
+ }
1251
+ return -1;
1252
+ }
1253
+ /**
1254
+ * Split a command at every top-level shell separator, and collect every
1255
+ * substitution the shell would EXECUTE — ENG-5967 F-008, review R4-09.
1256
+ *
1257
+ * Single quotes are the one context the shell does not look inside, so a
1258
+ * `$(` there is literal (the L4 templates' `bash -c '…'` body) and is not
1259
+ * collected; in double quotes and bare text it runs, and is. A process
1260
+ * substitution (`<( )`, `>( )`) runs only unquoted. `$(( … ))` is arithmetic,
1261
+ * not a command: only the substitutions inside it are collected.
1262
+ */
1263
+ function scanShellCommand(s) {
1264
+ const segments = [];
1265
+ const substitutions = [];
1266
+ let backtick = false;
1267
+ let unbalanced = false;
1268
+ let cur = "";
1269
+ let quote = null;
1270
+ const cut = (sepAfter) => {
1271
+ segments.push({
1272
+ text: cur,
1273
+ sepAfter
1274
+ });
1275
+ cur = "";
1276
+ };
1277
+ for (let i = 0; i < s.length; i++) {
1278
+ const c = s[i];
1279
+ if (quote === "'") {
1280
+ if (c === "'") quote = null;
1281
+ continue;
1282
+ }
1283
+ if (c === "\\") {
1284
+ if (quote === null) cur += s.slice(i, i + 2);
1285
+ i++;
1286
+ continue;
1287
+ }
1288
+ if (c === "`") {
1289
+ backtick = true;
1290
+ continue;
1291
+ }
1292
+ if (c === "$" && s[i + 1] === "(") {
1293
+ const arithmetic = s[i + 2] === "(";
1294
+ const close = closeOfParen(s, i + 2);
1295
+ if (close === -1) {
1296
+ unbalanced = true;
1297
+ break;
1298
+ }
1299
+ const body = s.slice(i + 2, close - 1);
1300
+ if (arithmetic) {
1301
+ const inner = scanShellCommand(body.slice(1, -1));
1302
+ substitutions.push(...inner.substitutions);
1303
+ backtick ||= inner.backtick;
1304
+ unbalanced ||= inner.unbalanced;
1305
+ } else substitutions.push(body);
1306
+ if (quote === null) cur += "\"\"";
1307
+ i = close - 1;
1308
+ continue;
1309
+ }
1310
+ if (quote === "\"") {
1311
+ if (c === "\"") quote = null;
1312
+ continue;
1313
+ }
1314
+ if ((c === "<" || c === ">") && s[i + 1] === "(") {
1315
+ const close = closeOfParen(s, i + 2);
1316
+ if (close === -1) {
1317
+ unbalanced = true;
1318
+ break;
1319
+ }
1320
+ substitutions.push(s.slice(i + 2, close - 1));
1321
+ cur += "\"\"";
1322
+ i = close - 1;
1323
+ continue;
1324
+ }
1325
+ if (c === "'" || c === "\"") {
1326
+ quote = c;
1327
+ cur += "\"\"";
1328
+ continue;
1329
+ }
1330
+ if (c === ";" || c === "\n") {
1331
+ cut(";");
1332
+ continue;
1333
+ }
1334
+ if (c === "|") {
1335
+ if (s[i + 1] === "|") {
1336
+ i++;
1337
+ cut("||");
1338
+ } else cut("|");
1339
+ continue;
1340
+ }
1341
+ if (c === "&") {
1342
+ const prev = s[i - 1];
1343
+ if (prev === ">" || prev === "<" || s[i + 1] === ">") {
1344
+ cur += c;
1345
+ continue;
1346
+ }
1347
+ if (s[i + 1] === "&") {
1348
+ i++;
1349
+ cut("&&");
1350
+ } else cut("&");
1351
+ continue;
1352
+ }
1353
+ cur += c;
1354
+ }
1355
+ if (quote !== null) unbalanced = true;
1356
+ cut(null);
1357
+ return {
1358
+ segments,
1359
+ substitutions,
1360
+ backtick,
1361
+ unbalanced
1362
+ };
1363
+ }
1364
+ /** Reserved words that OPEN or CONTINUE a compound command before a command. */
1365
+ const LEADING_RESERVED = /^(?:if|then|elif|else|do|while|until)(?=\s|$)\s*/;
1366
+ /** Words that CLOSE a compound command; nothing runs from them. */
1367
+ const CLOSING_WORDS = /^(?:fi|done|esac|\}|\)+)(?=\s|$|\)|;)/;
1368
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=\S*(?:\s+|$)/;
1369
+ /**
1370
+ * [issue:ENG-7618 review N1] A scanner reads every option it has from an
1371
+ * environment variable too (`TRIVY_OUTPUT`, `TRIVY_CACHE_DIR`,
1372
+ * `TRIVY_DB_REPOSITORY`, …), so a gate setting one reaches each option the
1373
+ * scanner rules refuse without ever typing the flag. A gate command may not
1374
+ * set any variable in a scanner's namespace, by assignment, `export` or `env`.
1375
+ */
1376
+ const SCANNER_ENV_NAME = /^(?:TRIVY|SEMGREP|GITLEAKS)_/;
1377
+ const SCANNER_ENV_REFUSAL = "may not set a scanner's environment variables (TRIVY_*, SEMGREP_*, GITLEAKS_*): each reaches an option the scanner rules refuse";
1378
+ const SCANNER_TOKENS = new Set([
1379
+ "trivy",
1380
+ "semgrep",
1381
+ "opengrep",
1382
+ "gitleaks"
1383
+ ]);
1384
+ /** A scanner's config and ignore files, which a gate must not write. */
1385
+ const SCANNER_CONFIG_FILE = /(?:^|\/)(?:trivy\.ya?ml|\.trivy[^\s/]*|\.gitleaks[^\s/]*|\.semgrep[^\s/]*)$/;
1386
+ /**
1387
+ * [issue:ENG-7618 review round 3] Checks for a command that runs a scanner,
1388
+ * over the routes that reach a refused option without typing its flag. They
1389
+ * are DEFENCE IN DEPTH, not the boundary: a command slot is supplied only by
1390
+ * an Owner or Admin (the gate-impl capability, GateImplPolicyService), and a
1391
+ * text checker cannot model everything bash can do. What is refused:
1392
+ * - `set -a` / `set -o allexport`, under which `read` and `printf -v`
1393
+ * export what they assign (F1); and `read` / `printf -v` into a scanner
1394
+ * variable anywhere (F1);
1395
+ * - writing a scanner config or ignore file by redirect or `tee`: trivy loads
1396
+ * `trivy.yaml` from the working directory unasked (F2);
1397
+ * - a `--config` / `-c` that is absolute or climbs out with `..` (F2);
1398
+ * - setting HOME, TMPDIR, XDG_* or a proxy variable, which move trivy's cache
1399
+ * and route its downloads (F3).
1400
+ */
1401
+ function scannerCommandRefusal(command) {
1402
+ const words = command.split(/[\s;&|()]+/).filter(Boolean);
1403
+ if (/(?:^|[\s;&|(])(?:read|printf)\b[^;&|]*?\b(?:TRIVY|SEMGREP|GITLEAKS)_/.test(command)) {
1404
+ if (/(?:^|[\s;&|(])read\s(?:[^;&|]*\s)?(?:TRIVY|SEMGREP|GITLEAKS)_\w*/.test(command) || /(?:^|[\s;&|(])printf\s+(?:-\S+\s+)*-v\s*(?:TRIVY|SEMGREP|GITLEAKS)_/.test(command)) return SCANNER_ENV_REFUSAL;
1405
+ }
1406
+ if (!words.some((w) => SCANNER_TOKENS.has(w))) return null;
1407
+ if (/(?:^|[\s;&|(])set\s+(?:-o\s+allexport\b|(?:\S+\s+)*-[A-Za-z]*a[A-Za-z]*(?:\s|$))/.test(command) || /(?:^|[\s;&|(])set\s+(?:\S+\s+)*-o\s+allexport\b/.test(command)) return "may not turn on `set -a` / `set -o allexport` in a command that runs a scanner: what it assigns reaches the scanner as environment variables";
1408
+ const redirected = [...command.matchAll(/>{1,2}\|?\s*(['"]?)([^\s;&|<>'"]+)\1/g)].map((m) => m[2]);
1409
+ const teed = [...command.matchAll(/(?:^|[\s;&|(])tee\s+((?:-\S+\s+)*)([^\s;&|<>]+)/g)].map((m) => m[2]);
1410
+ const written = [...redirected, ...teed].find((t) => SCANNER_CONFIG_FILE.test(t));
1411
+ if (written) return `may not write the scanner config file ${written}: a scanner reads it as its options`;
1412
+ for (const m of command.matchAll(/(?:^|[\s;&|(])(?:--config(?:=|\s+)|-c\s*=?\s*)(['"]?)([^\s;&|<>'"]+)\1/g)) {
1413
+ const value = m[2];
1414
+ if (value.startsWith("//") || value.includes("://")) continue;
1415
+ if (value.startsWith("/") || value.startsWith("~") || /(?:^|\/)\.\.(?:\/|$)/.test(value)) return `a scanner --config must be a path inside the repository (no leading /, ~ or ..); got ${value}`;
1416
+ }
1417
+ if (/(?:^|[\s;&|(])(?:export\s+(?:\S+\s+)*|env\s+(?:\S+\s+)*)?(?:HOME|TMPDIR|XDG_[A-Z_]+|(?:HTTPS?|ALL|NO|FTP)_PROXY|(?:https?|all|no|ftp)_proxy)\+?=/.test(command)) return "may not set HOME, TMPDIR, XDG_* or a proxy variable in a command that runs a scanner: they move its cache and route its downloads";
1418
+ return null;
1419
+ }
1420
+ const ENV_WRAPPER = /^env(?:\s+(?:-i|-0|--ignore-environment|--null|-u\s+\S+|--unset=\S+|[A-Za-z_][A-Za-z0-9_]*=\S*))*\s+(?=\S)/;
1421
+ const FUNCTION_DEF = /^(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\s*\)\s*/;
1422
+ /**
1423
+ * Read one top-level segment: strip the shell grammar in front of its command
1424
+ * and say which binary it runs (or that it runs nothing).
1425
+ */
1426
+ function readSegment(segment, state) {
1427
+ let p = segment.trim();
1428
+ let negated = false;
1429
+ for (let guard = 0; guard < 64; guard++) {
1430
+ if (!p) return { kind: "skip" };
1431
+ if (state.inCase) {
1432
+ if (/^esac(?=\s|$|;|\))/.test(p)) {
1433
+ state.inCase = false;
1434
+ p = p.slice(4).trim();
1435
+ continue;
1436
+ }
1437
+ const arm = /^\(?[^\s()]*\)\s*/.exec(p);
1438
+ if (arm) {
1439
+ p = p.slice(arm[0].length);
1440
+ continue;
1441
+ }
1442
+ if (/^[^\s()]+$/.test(p)) return { kind: "skip" };
1443
+ }
1444
+ const caseHead = /^case\s+\S+\s+in(?=\s|$)\s*/.exec(p);
1445
+ if (caseHead) {
1446
+ state.inCase = true;
1447
+ p = p.slice(caseHead[0].length);
1448
+ continue;
1449
+ }
1450
+ if (/^for(?=\s|\(\()/.test(p)) return { kind: "skip" };
1451
+ if (CLOSING_WORDS.test(p)) return { kind: "skip" };
1452
+ const reserved = LEADING_RESERVED.exec(p);
1453
+ if (reserved) {
1454
+ p = p.slice(reserved[0].length);
1455
+ continue;
1456
+ }
1457
+ if (p.startsWith("{") && (p.length === 1 || /\s/.test(p[1]))) {
1458
+ p = p.slice(1).trim();
1459
+ continue;
1460
+ }
1461
+ if (p.startsWith("(") && !p.startsWith("((")) {
1462
+ p = p.slice(1).trim();
1463
+ continue;
1464
+ }
1465
+ if (p.startsWith("((")) return { kind: "skip" };
1466
+ if (/^!(?=\s|$)/.test(p)) {
1467
+ negated = true;
1468
+ p = p.slice(1).trim();
1469
+ if (!p) return {
1470
+ kind: "invalid",
1471
+ reason: "a bare `!` negates nothing"
1472
+ };
1473
+ continue;
1474
+ }
1475
+ const fn = FUNCTION_DEF.exec(p);
1476
+ if (fn) {
1477
+ state.functions.add(fn[1]);
1478
+ p = p.slice(fn[0].length);
1479
+ continue;
1480
+ }
1481
+ const assignment = ASSIGNMENT.exec(p);
1482
+ if (assignment) {
1483
+ if (SCANNER_ENV_NAME.test(assignment[0])) return {
1484
+ kind: "invalid",
1485
+ reason: SCANNER_ENV_REFUSAL
1486
+ };
1487
+ p = p.slice(assignment[0].length);
1488
+ if (!p) return { kind: "skip" };
1489
+ continue;
1490
+ }
1491
+ const env = ENV_WRAPPER.exec(p);
1492
+ if (env) {
1493
+ if (env[0].split(/\s+/).some((w) => w.includes("=") && SCANNER_ENV_NAME.test(w))) return {
1494
+ kind: "invalid",
1495
+ reason: SCANNER_ENV_REFUSAL
1496
+ };
1497
+ p = p.slice(env[0].length);
1498
+ continue;
1499
+ }
1500
+ break;
1501
+ }
1502
+ if (!p) return { kind: "skip" };
1503
+ const token = p.split(/\s+/)[0].replace(/\)+$/, "");
1504
+ if (!token) return { kind: "skip" };
1505
+ if (token === "export") {
1506
+ const args$1 = p.split(/\s+/).slice(1);
1507
+ if (!args$1.every((a) => /^[A-Za-z_][A-Za-z0-9_]*(?:=\S*)?$/.test(a))) return {
1508
+ kind: "invalid",
1509
+ reason: "`export` may only name or assign variables"
1510
+ };
1511
+ if (args$1.some((a) => SCANNER_ENV_NAME.test(a))) return {
1512
+ kind: "invalid",
1513
+ reason: SCANNER_ENV_REFUSAL
1514
+ };
1515
+ return { kind: "skip" };
1516
+ }
1517
+ const args = p.slice(p.split(/\s+/)[0].length).trim();
1518
+ if (token === "timeout") {
1519
+ const wrapped = timeoutCommand(args);
1520
+ if (wrapped === null) return {
1521
+ kind: "invalid",
1522
+ reason: "`timeout` must name the command it runs"
1523
+ };
1524
+ const inner = readSegment(wrapped, state);
1525
+ return inner.kind === "command" ? {
1526
+ ...inner,
1527
+ negated
1528
+ } : inner;
1529
+ }
1530
+ return {
1531
+ kind: "command",
1532
+ token,
1533
+ negated,
1534
+ args
1535
+ };
1536
+ }
1537
+ /**
1538
+ * The command `timeout` runs: what follows its options and its DURATION
1539
+ * (GNU and BSD `timeout`: `-s SIG`, `-k DUR`, `--signal=SIG`,
1540
+ * `--kill-after=DUR`, `--preserve-status`, `--foreground`, `-v`), or null
1541
+ * when there is none.
1542
+ */
1543
+ function timeoutCommand(args) {
1544
+ const words = args.split(/\s+/).filter(Boolean);
1545
+ let k = 0;
1546
+ while (k < words.length) {
1547
+ const w = words[k];
1548
+ if (w === "--") {
1549
+ k++;
1550
+ break;
1551
+ }
1552
+ if (w === "-s" || w === "-k" || w === "--signal" || w === "--kill-after") {
1553
+ k += 2;
1554
+ continue;
1555
+ }
1556
+ if (w.startsWith("-")) {
1557
+ k++;
1558
+ continue;
1559
+ }
1560
+ break;
1561
+ }
1562
+ const rest = words.slice(k + 1);
1563
+ return rest.length > 0 ? rest.join(" ") : null;
1564
+ }
1565
+ /**
1566
+ * Validates that a gate command is well-constructed. Returns null when it may
1567
+ * be handed out and run, or the reason it may not.
1568
+ */
1569
+ function validateGateCommand(command, technique) {
1570
+ const who = technique ? `${technique} command` : "Gate command";
1571
+ const trimmed = command.trim();
1572
+ if (trimmed.startsWith("@api:")) return null;
1573
+ if (!trimmed) return null;
1574
+ const unmodelled = unmodelledConstruct(trimmed);
1575
+ if (unmodelled) return `${who} uses ${unmodelled}, which this checker cannot read the way bash does; rewrite it without`;
1576
+ const scanned = scanShellCommand(trimmed);
1577
+ if (scanned.backtick) return `${who} uses a backtick command substitution; write $( … ) so its command can be checked`;
1578
+ if (scanned.unbalanced) return `${who} has an unterminated quote or substitution`;
1579
+ const scannerRefusal = scannerCommandRefusal(trimmed);
1580
+ if (scannerRefusal) return `${who}: ${scannerRefusal}`;
1581
+ const state = {
1582
+ inCase: false,
1583
+ functions: /* @__PURE__ */ new Set()
1584
+ };
1585
+ const reads = scanned.segments.map((segment) => segment.text.trim().startsWith("@api:") ? { kind: "skip" } : readSegment(segment.text, state));
1586
+ for (let i = 0; i < reads.length; i++) {
1587
+ const read = reads[i];
1588
+ if (read.kind === "skip") continue;
1589
+ if (read.kind === "invalid") return `${who}: ${read.reason}`;
1590
+ const { token, negated } = read;
1591
+ const refusedUse = subcommandRefusal(token, read.args);
1592
+ if (refusedUse !== null) return `${who}: ${refusedUse}`;
1593
+ if (!isAllowedBinary(token) && !SUBCOMMAND_ALLOWLISTS.has(token) && !state.functions.has(token)) return `${who} uses disallowed binary "${token}". Allowed: ${GATE_COMMAND_BINARIES.join(", ")}; and with read-only subcommands only: ${[...SUBCOMMAND_ALLOWLISTS.keys()].join(", ")}`;
1594
+ if (negated) {
1595
+ let last = i;
1596
+ while (scanned.segments[last].sepAfter === "|" && last + 1 < reads.length) last++;
1597
+ const tail = reads[last];
1598
+ const tailToken = tail.kind === "command" ? tail.token : token;
1599
+ if (!NEGATABLE_BINARIES.has(tailToken) && !tailToken.endsWith("/grep")) return `${who} negates "${tailToken}" with \`!\`; only grep and test may be negated — a negated test runner or typecheck turns a failure into exit 0`;
1600
+ }
1601
+ }
1602
+ for (const inner of scanned.substitutions) {
1603
+ if (!inner.trim()) return `${who} has an empty substitution`;
1604
+ const problem = validateGateCommand(inner, technique);
1605
+ if (problem) return `${problem} (inside a $( ), <( ) or >( ) substitution)`;
1606
+ }
1607
+ return null;
1608
+ }
1609
+
1610
+ //#endregion
1611
+ //#region ../shared/dist/runs/gate-verdict.js
1612
+ /**
1613
+ * Wrapper commands that run the command after them — ENG-5967 review R3-03.
1614
+ * `time yarn test`, `env CI=1 yarn test`, `nice -n 5 yarn test`,
1615
+ * `exec yarn test`, `timeout 60 yarn test`, `… | xargs yarn test` all RUN
1616
+ * `yarn test`; matched before stripping, a runner behind one was plain shell
1617
+ * output and a run that ran nothing passed.
1618
+ */
1619
+ const WRAPPER_PREFIX_RE = /* @__PURE__ */ new RegExp("^(?:" + [
1620
+ String.raw`timeout(?:\s+(?:-[sk]\s*\S+|--\S+|-[A-Za-z]+))*(?:\s+\d+(?:\.\d+)?[smhd]?)?`,
1621
+ String.raw`time(?:\s+-p)?`,
1622
+ String.raw`(?:(?:\/usr)?\/bin\/)?env(?:\s+(?:-i|-u\s*\S+|--ignore-environment|--unset=\S+|-))*`,
1623
+ String.raw`command`,
1624
+ String.raw`stdbuf(?:\s+-[ioe]\s*\S+)*`,
1625
+ String.raw`nice(?:\s+(?:-n\s*-?\d+|-\d+|--adjustment=-?\d+))?`,
1626
+ String.raw`exec(?:\s+-[cl]+)*`,
1627
+ String.raw`nohup`,
1628
+ String.raw`xargs(?:\s+(?:-[ILnPsdE]\s*\S+|--\S+|-[A-Za-z0-9]+))*`
1629
+ ].join("|") + String.raw`)\s+`);
1630
+ /**
1631
+ * Any `test*` script counts, not a fixed alternation — `test:behavioral`,
1632
+ * `test:integration`, `test:cov` and future names must all be authoritative.
1633
+ * Also covers the workspace-dispatch forms (`yarn workspace <pkg> <script>`,
1634
+ * `yarn --cwd <dir> <script>`) and the npm/npx equivalents.
1635
+ */
1636
+ const TEST_RUNNER_RE = new RegExp([
1637
+ String.raw`^yarn(?:\s+(?:-s|--silent|--verbose|--offline|--frozen-lockfile|--immutable|workspace\s+\S+|--cwd\s+\S+))*\s+(?:run\s+)?(?:test(?::[\w-]+)?\b|vitest\b|jest\b)`,
1638
+ String.raw`^yarn\s+workspaces\s+foreach(?:\s+(?:--(?:include|exclude|from|jobs)(?:=|\s+)\S+|-j\s+\S+|-[A-Za-z]+|--[\w-]+(?:=\S+)?))*\s+(?:run\s+)?test(?::[\w-]+)?\b`,
1639
+ String.raw`^npm\s+(?:run\s+)?test(?::[\w-]+)?\b`,
1640
+ String.raw`^pnpm\s+(?:(?:--filter|-F|--dir|-C)\s+\S+\s+|(?:-r|--recursive)\s+)*(?:run\s+)?test(?::[\w-]+)?\b`,
1641
+ String.raw`^pnpm\s+(?:exec\s+|dlx\s+)?(?:vitest|jest|playwright)\b`,
1642
+ String.raw`^npx\s+(?:(?:--yes|-y|--no-install|--no|--quiet|-q|-p\s+\S+|--package(?:=|\s+)\S+)\s+)*(?:vitest|jest|playwright)\b`,
1643
+ String.raw`^node\s+(?:-\S+\s+)*\S*\b(?:vitest|jest|playwright|mocha|_mocha)\b\S*(?:\s|$)`,
1644
+ String.raw`^(?:(?:\.\/)?node_modules\/\.bin\/)?(?:vitest|jest|playwright)(?:\s|$)`,
1645
+ String.raw`^(?:php\s+(?:-d\s*\S+\s+)*)?(?:\.\/)?(?:vendor\/bin\/|bin\/)?(?:phpunit|pest|paratest)(?:\s|$)`,
1646
+ String.raw`^php\s+artisan\s+test(?:\s|$)`,
1647
+ String.raw`^composer\s+(?:run(?:-script)?\s+)?test(?:[:-][\w-]+)?(?:\s|$)`,
1648
+ String.raw`^(?:(?:uv|poetry|pipenv|hatch)\s+run\s+)?(?:python[\d.]*\s+-m\s+)?(?:py\.test|pytest)(?:\s|$)`,
1649
+ String.raw`^go\s+test(?:\s|$)`,
1650
+ String.raw`^cargo\s+(?:\+\S+\s+)?(?:test|nextest)(?:\s|$)`,
1651
+ String.raw`^(?:\.\/)?mvnw?(?:\s+\S+)*?\s+(?:test|verify|integration-test)(?:\s|$)`,
1652
+ String.raw`^(?:\.\/)?gradlew?(?:\s+\S+)*?\s+(?:\S*:)?(?:test|check)(?:\s|$)`,
1653
+ String.raw`^dotnet\s+test(?:\s|$)`,
1654
+ String.raw`^(?:bundle\s+exec\s+)?(?:bin\/)?rspec(?:\s|$)`,
1655
+ String.raw`^(?:npx\s+(?:(?:--yes|-y|--no-install|--no|-p\s+\S+|--package(?:=|\s+)\S+)\s+)*|yarn\s+(?:run\s+)?|pnpm\s+(?:exec\s+)?|(?:\.\/)?node_modules\/\.bin\/)?mocha(?:\s|$)`,
1656
+ String.raw`^yarn\s+playwright\s+test(?:\s|$)`
1657
+ ].join("|"));
1658
+ /**
1659
+ * The CLIs whose answer IS a gate's verdict. One list feeds both regexes
1660
+ * below, so a judge recognised on the non-zero branch is also read on exit 0.
1661
+ * ENG-7698: grok was missing, so a grok `{"result": "pass", …}` whose reason
1662
+ * said "fail closed" went to the generic shell scan and recorded a fail.
1663
+ */
1664
+ const JUDGE_BINARIES = "agy|codex|gemini|grok";
1665
+ /**
1666
+ * Semantic judges, matched anywhere in the segment — L4 templates invoke them
1667
+ * after a pipe (`echo "..." | agy -p`) or inside a wrapper
1668
+ * (`timeout 120 bash -c '... | agy ...'`), so an anchored test would miss them.
1669
+ */
1670
+ const SEMANTIC_RUNNER_RE = /* @__PURE__ */ new RegExp(`(?:^|[\\s|;&(])(?:${JUDGE_BINARIES})(?:\\s|$)`);
1671
+ /**
1672
+ * A judge INVOKED in command position — `agy …`, `| agy …`, `$(agy …`,
1673
+ * `timeout 120 agy …`, `bash -c '… $(agy …) …'` — ENG-5967 B.
1674
+ *
1675
+ * Narrower than `isSemanticVerdictAuthoritative` on purpose. That one decides
1676
+ * what a NON-zero exit means, where a false positive only costs a soft-pass
1677
+ * nobody should have had. This one decides that an EXIT-0 step's verdict is
1678
+ * the judge's stated answer, overriding the expectation — so `grep gemini
1679
+ * src/x.ts`, which mentions a judge without running one, must not match.
1680
+ *
1681
+ * Review R3-07: decided on the command's STRUCTURE, never on its text. A
1682
+ * regex over the raw string matched `grep -Fc '$(agy --model' template.ts`
1683
+ * — single-quoted, so `$(` is literal and nothing runs — and a green
1684
+ * presence check on a judge template read `SEMANTIC_UNKNOWN`. Every command
1685
+ * the shell would run (each list command and pipe stage, every `$( … )` /
1686
+ * backtick body outside single quotes, the script of `bash|sh -c`, behind
1687
+ * any wrapper) is a candidate; a judge is one whose FIRST word is the binary.
1688
+ */
1689
+ const JUDGE_COMMAND_RE = /* @__PURE__ */ new RegExp(`^(?:${JUDGE_BINARIES})(?:\\s|$)`);
1690
+
1691
+ //#endregion
1692
+ //#region ../shared/dist/runs/gate-binding-config.js
1693
+ /**
1694
+ * Gate binding config validation (ENG-7284 D4). Pure: no framework imports.
1695
+ *
1696
+ * The one validator for a `gate_binding.config`, shared by the server
1697
+ * (`GateBindingService`, which turns a `GateBindingConfigError` into a 400)
1698
+ * and by local tooling that writes bindings (the D7 apply script), so a config
1699
+ * refused by one is refused by the other with the same message.
1700
+ *
1701
+ * A config is a plain object. Three roots are read:
1702
+ *
1703
+ * - `cmd` and `impl`: slot trees that bottom out in a non-empty command
1704
+ * string, an argv leaf `{ cwd, argv }`, a `{ legacy }` leaf, or a
1705
+ * path-prefix map of those.
1706
+ * - `params`: per-gate-type param defaults, `params.<gate_key>.<param>`, with
1707
+ * string leaves only (e.g. `params.ast_pattern.language: "Rust"`). Only the
1708
+ * params a resolver reads (BINDING_PARAM_DEFAULTS) are accepted, and, when
1709
+ * the binding's gate key is known, only under that key: the resolver reads
1710
+ * `params.<gate_key>` from that gate type's own bindings and nowhere else.
1711
+ *
1712
+ * Any other top-level key — a typo (`cdm`), or the flat `cmd.typecheck` form —
1713
+ * is refused (review M4). It would be stored, never read, and its commands
1714
+ * would bypass every check.
1715
+ *
1716
+ * A wrong shape here is a gate that quietly resolves `unconfigured` forever
1717
+ * with nothing pointing at the typo, so it is refused at write.
1718
+ */
1719
+ /**
1720
+ * The binding param defaults each RESOLVER reads, from
1721
+ * `params.<the binding's gate key>`. A param not listed here would be stored
1722
+ * and ignored, so it is refused at write.
1723
+ */
1724
+ const BINDING_PARAM_DEFAULTS = {
1725
+ ast_pattern: ["language"],
1726
+ symbol_referenced: ["language"]
1727
+ };
1728
+ const CONFIG_ROOTS = new Set([
1729
+ "cmd",
1730
+ "impl",
1731
+ "params"
1732
+ ]);
1733
+ /** A malformed binding config. `path` names the offending node. */
1734
+ var GateBindingConfigError = class extends Error {
1735
+ path;
1736
+ constructor(message, path$2 = null) {
1737
+ super(message);
1738
+ this.name = "GateBindingConfigError";
1739
+ this.path = path$2;
1740
+ }
1741
+ };
1742
+ /** An object with an `argv` array is one slot leaf, not a path-prefix map. */
1743
+ function isArgvLeaf(value) {
1744
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
1745
+ return Array.isArray(value.argv);
1746
+ }
1747
+ /** `{ legacy: command }` is one slot leaf. */
1748
+ function isLegacyLeaf(value) {
1749
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
1750
+ return typeof value.legacy === "string";
1751
+ }
1752
+ function isPlainObject$1(value) {
1753
+ return value != null && typeof value === "object" && !Array.isArray(value);
1754
+ }
1755
+ /**
1756
+ * Throws `GateBindingConfigError` when `config` is not a well-formed gate
1757
+ * binding config. `null`/`undefined` is "no config" and passes.
1758
+ */
1759
+ function assertWellFormedGateBindingConfig(config, gateKey, resolver) {
1760
+ if (config == null) return;
1761
+ if (!isPlainObject$1(config)) throw new GateBindingConfigError("config must be a JSON object.");
1762
+ for (const root of Object.keys(config)) {
1763
+ if (CONFIG_ROOTS.has(root)) continue;
1764
+ const dotted = /^(cmd|impl|params)\./.exec(root);
1765
+ throw new GateBindingConfigError(dotted ? `config key "${root}" is the flat form; nest it instead ({ "${dotted[1]}": { ... } }). A flat key is never read.` : `config key "${root}" is not a binding root; use cmd, impl or params.`, root);
1766
+ }
1767
+ for (const slotRoot of ["cmd", "impl"]) {
1768
+ const tree = config[slotRoot];
1769
+ if (tree === void 0) continue;
1770
+ assertSlotTree(tree, slotRoot);
1771
+ }
1772
+ if (config.params !== void 0) assertParamsRoot(config.params, gateKey, resolver);
1773
+ }
1774
+ function assertSlotTree(node, pathSoFar) {
1775
+ if (typeof node === "string") {
1776
+ if (node.trim().length === 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" is a blank string — an empty command is treated as unbound; omit it instead.`, pathSoFar);
1777
+ return;
1778
+ }
1779
+ if (isArgvLeaf(node)) {
1780
+ assertArgvLeaf(node, pathSoFar);
1781
+ return;
1782
+ }
1783
+ if (isLegacyLeaf(node)) {
1784
+ assertLegacyLeaf(node, pathSoFar);
1785
+ return;
1786
+ }
1787
+ if (isPlainObject$1(node)) {
1788
+ for (const [key, child] of Object.entries(node)) {
1789
+ if (key === "argv" || key === "cwd") throw new GateBindingConfigError(`config slot "${pathSoFar}.${key}" uses "${key}" as a path-prefix key. Those names are argv-leaf fields; put the command in { cwd, argv }.`, `${pathSoFar}.${key}`);
1790
+ assertSlotTree(child, `${pathSoFar}.${key}`);
1791
+ }
1792
+ return;
1793
+ }
1794
+ throw new GateBindingConfigError(`config slot "${pathSoFar}" must be a command string, an argv leaf { cwd, argv }, a { legacy } command, or a nested object of them (got ${Array.isArray(node) ? "array" : typeof node}).`, pathSoFar);
1795
+ }
1796
+ function assertArgvLeaf(node, pathSoFar) {
1797
+ const extra = Object.keys(node).filter((key) => key !== "cwd" && key !== "argv");
1798
+ if (extra.length > 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" is an argv leaf with extra keys (${extra.join(", ")}). An argv leaf is only { cwd, argv }.`, pathSoFar);
1799
+ if (node.argv.length === 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" has an empty argv — an empty command is treated as unbound; omit it instead.`, pathSoFar);
1800
+ if (node.argv.some((element) => typeof element !== "string" || element.length === 0)) throw new GateBindingConfigError(`config slot "${pathSoFar}" argv must be non-empty strings.`, pathSoFar);
1801
+ if (node.cwd !== void 0 && (typeof node.cwd !== "string" || node.cwd.length === 0)) throw new GateBindingConfigError(`config slot "${pathSoFar}" cwd must be a non-empty string.`, pathSoFar);
1802
+ }
1803
+ function assertLegacyLeaf(node, pathSoFar) {
1804
+ const extra = Object.keys(node).filter((key) => key !== "legacy");
1805
+ if (extra.length > 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" is a legacy leaf with extra keys (${extra.join(", ")}).`, pathSoFar);
1806
+ if (node.legacy.trim().length === 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" is a blank legacy command — an empty command is treated as unbound; omit it instead.`, pathSoFar);
1807
+ }
1808
+ /**
1809
+ * `params.<gate_key>.<param>`: exactly two levels of objects, then a
1810
+ * non-blank string. No slot maps and no argv leaves — a param default is a
1811
+ * value the resolver substitutes, never a command.
1812
+ */
1813
+ function assertParamsRoot(node, bindingGateKey, resolver) {
1814
+ if (!isPlainObject$1(node)) throw new GateBindingConfigError(`config "params" must be an object of gate keys (got ${Array.isArray(node) ? "array" : typeof node}).`, "params");
1815
+ for (const [gateKey, perGate] of Object.entries(node)) {
1816
+ const gatePath = `params.${gateKey}`;
1817
+ if (bindingGateKey !== void 0 && gateKey !== bindingGateKey) throw new GateBindingConfigError(`config "${gatePath}" is on a "${bindingGateKey}" binding; params for "${gateKey}" are read only from a "${gateKey}" binding.`, gatePath);
1818
+ const readable = resolver === void 0 ? BINDING_PARAM_DEFAULTS[gateKey] : resolver ? BINDING_PARAM_DEFAULTS[resolver] : void 0;
1819
+ if (!readable) throw new GateBindingConfigError(`config "${gatePath}": "${gateKey}" reads no binding params (resolvers that do: ${Object.keys(BINDING_PARAM_DEFAULTS).join(", ")}).`, gatePath);
1820
+ if (!isPlainObject$1(perGate)) throw new GateBindingConfigError(`config "${gatePath}" must be an object of param defaults (got ${Array.isArray(perGate) ? "array" : typeof perGate}).`, gatePath);
1821
+ for (const [param, value] of Object.entries(perGate)) {
1822
+ const paramPath = `${gatePath}.${param}`;
1823
+ if (!readable.includes(param)) throw new GateBindingConfigError(`config "${paramPath}": "${gateKey}" reads only ${readable.join(", ")} from a binding.`, paramPath);
1824
+ if (typeof value !== "string") throw new GateBindingConfigError(`config "${paramPath}" must be a string (got ${Array.isArray(value) ? "array" : value === null ? "null" : typeof value}).`, paramPath);
1825
+ if (value.trim().length === 0) throw new GateBindingConfigError(`config "${paramPath}" is a blank string; omit it instead.`, paramPath);
1826
+ }
1827
+ }
1828
+ }
1829
+
1830
+ //#endregion
1831
+ //#region ../shared/dist/runs/binding-command-migration.js
1832
+ /** An object with an `argv` array is one slot leaf, not a path-prefix map. */
1833
+ function isArgvSlotValue(value) {
1834
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
1835
+ return Array.isArray(value.argv);
1836
+ }
1837
+ /** `{ legacy: command }` is one slot leaf. */
1838
+ function isLegacySlotValue(value) {
1839
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
1840
+ return typeof value.legacy === "string";
1841
+ }
1842
+ /** Shell text of a resolved slot, placeholders left unquoted. */
1843
+ function resolvedSlotText(slot) {
1844
+ if (slot.kind === "legacy") return slot.command ?? "";
1845
+ const argv = (slot.argv ?? []).join(" ");
1846
+ if (!slot.cwd || slot.cwd === ".") return argv;
1847
+ return `cd ${slot.cwd} && ${argv}`;
1848
+ }
1849
+
1850
+ //#endregion
1851
+ //#region ../shared/dist/runs/gate-command-authoring.js
1852
+ /**
1853
+ * Write-time validation of a rendered gate command (ENG-6176).
1854
+ *
1855
+ * Pure domain: no NestJS and no database. Callers turn a refusal into a
1856
+ * 400 `GATE_COMMAND_REFUSED` and write nothing.
1857
+ *
1858
+ * `fail_closed`: a command the validator refuses is never written, deployed,
1859
+ * or issued. An unknown placeholder type gets a generic sample and is
1860
+ * validated. It is never skipped.
1861
+ */
1862
+ const GATE_COMMAND_REFUSED = "GATE_COMMAND_REFUSED";
1863
+ const PLACEHOLDER = /\{([^{}]+)\}/g;
1864
+ /**
1865
+ * One sample per `{placeholder}` in `template`.
1866
+ *
1867
+ * Named samples win over the declared type: `{workspace_dir}` is
1868
+ * `apps/sample` and `{test_file}` is `src/sample.spec.ts`. A declared
1869
+ * `path` is `src/sample.ts` and a declared `string` is `sample`. Anything
1870
+ * else, including a placeholder with no declaration, is the generic
1871
+ * `sample` (`fail_closed`).
1872
+ */
1873
+ function samplePlaceholderValues(template, paramSchema = {}) {
1874
+ const values = {};
1875
+ for (const match of template.matchAll(PLACEHOLDER)) {
1876
+ const name = match[1];
1877
+ if (!name || values[name] !== void 0) continue;
1878
+ values[name] = sampleFor(name, paramSchema[name]?.type);
1879
+ }
1880
+ return values;
1881
+ }
1882
+ function sampleFor(name, declaredType) {
1883
+ if (name === "workspace_dir") return "apps/sample";
1884
+ if (name === "test_file") return "src/sample.spec.ts";
1885
+ if (declaredType === "path") return "src/sample.ts";
1886
+ if (declaredType === "string") return "sample";
1887
+ return "sample";
1888
+ }
1889
+ function renderGateCommandTemplate(template, values) {
1890
+ return template.replace(PLACEHOLDER, (whole, name) => Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole);
1891
+ }
1892
+ /**
1893
+ * Every command under `config.cmd` and `config.impl`.
1894
+ *
1895
+ * A path-prefix map is a command per prefix. An argv leaf is one command:
1896
+ * its unquoted `cd <cwd> && argv` text, so the allowlist sees the same
1897
+ * shape a string slot used to store. A `{ legacy }` leaf is that string.
1898
+ * The slot key is the path (`cmd.typecheck`, or `cmd.test.unit.scripts/`
1899
+ * for a map). `sandbox_policy` is not a command template.
1900
+ */
1901
+ function commandSlotsInBindingConfig(config) {
1902
+ if (config == null || typeof config !== "object" || Array.isArray(config)) return [];
1903
+ const record = config;
1904
+ const slots = [];
1905
+ collectCommandStrings(record.cmd, "cmd", slots);
1906
+ collectCommandStrings(record.impl, "impl", slots);
1907
+ return slots;
1908
+ }
1909
+ /**
1910
+ * `resolver_template.template`, when the payload sets one.
1911
+ * Slot key is `resolver_template.template`. Declared param types drive samples.
1912
+ */
1913
+ function commandSlotInResolverTemplate(resolverTemplate) {
1914
+ if (resolverTemplate == null || typeof resolverTemplate !== "object" || Array.isArray(resolverTemplate)) return null;
1915
+ const record = resolverTemplate;
1916
+ if (typeof record.template !== "string") return null;
1917
+ const template = record.template.trim();
1918
+ if (template.length === 0) return null;
1919
+ return {
1920
+ slotKey: "resolver_template.template",
1921
+ template,
1922
+ paramSchema: paramSchemaOf(record.param_schema)
1923
+ };
1924
+ }
1925
+ /**
1926
+ * Every command a `resolver_template` carries: its shell `template` and an
1927
+ * argv `op_template` (ENG-6180 review). The op_template is what the renderer
1928
+ * issues, so it is validated at write time exactly like the template string,
1929
+ * as its unquoted `cd <cwd> && argv` text. Slot keys are
1930
+ * `resolver_template.template` and `resolver_template.op_template`.
1931
+ */
1932
+ function commandSlotsInResolverTemplate(resolverTemplate) {
1933
+ const slots = [];
1934
+ const templateSlot = commandSlotInResolverTemplate(resolverTemplate);
1935
+ if (templateSlot) slots.push(templateSlot);
1936
+ if (resolverTemplate == null || typeof resolverTemplate !== "object" || Array.isArray(resolverTemplate)) return slots;
1937
+ const record = resolverTemplate;
1938
+ const op = record.op_template;
1939
+ if (op == null || typeof op !== "object" || Array.isArray(op)) return slots;
1940
+ const opRecord = op;
1941
+ if (opRecord.op !== "argv") return slots;
1942
+ const argv = opRecord.argv;
1943
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((element) => typeof element !== "string" || element.length === 0)) {
1944
+ slots.push({
1945
+ slotKey: "resolver_template.op_template",
1946
+ template: "",
1947
+ paramSchema: paramSchemaOf(record.param_schema)
1948
+ });
1949
+ return slots;
1950
+ }
1951
+ const cwdRaw = opRecord.cwd;
1952
+ const cwd = typeof cwdRaw === "string" && cwdRaw.length > 0 ? cwdRaw : ".";
1953
+ slots.push({
1954
+ slotKey: "resolver_template.op_template",
1955
+ template: resolvedSlotText({
1956
+ kind: "argv",
1957
+ cwd,
1958
+ argv
1959
+ }),
1960
+ paramSchema: paramSchemaOf(record.param_schema)
1961
+ });
1962
+ return slots;
1963
+ }
1964
+ function collectCommandStrings(value, path$2, slots) {
1965
+ if (typeof value === "string") {
1966
+ const template = value.trim();
1967
+ if (template.length > 0) slots.push({
1968
+ slotKey: path$2,
1969
+ template
1970
+ });
1971
+ return;
1972
+ }
1973
+ if (isArgvSlotValue(value)) {
1974
+ if (value.argv.length === 0 || value.argv.some((element) => typeof element !== "string")) return;
1975
+ const cwdRaw = value.cwd;
1976
+ const template = resolvedSlotText({
1977
+ kind: "argv",
1978
+ cwd: typeof cwdRaw === "string" && cwdRaw.length > 0 ? cwdRaw : ".",
1979
+ argv: value.argv
1980
+ });
1981
+ if (template.trim().length > 0) slots.push({
1982
+ slotKey: path$2,
1983
+ template
1984
+ });
1985
+ return;
1986
+ }
1987
+ if (isLegacySlotValue(value)) {
1988
+ if (value.legacy.trim().length > 0) slots.push({
1989
+ slotKey: path$2,
1990
+ template: value.legacy
1991
+ });
1992
+ return;
1993
+ }
1994
+ if (value != null && typeof value === "object" && !Array.isArray(value)) for (const [key, child] of Object.entries(value)) collectCommandStrings(child, `${path$2}.${key}`, slots);
1995
+ }
1996
+ function paramSchemaOf(value) {
1997
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return;
1998
+ const schema = {};
1999
+ for (const [key, decl] of Object.entries(value)) if (decl != null && typeof decl === "object" && !Array.isArray(decl)) {
2000
+ const type = decl.type;
2001
+ schema[key] = { type: typeof type === "string" ? type : void 0 };
2002
+ }
2003
+ return schema;
2004
+ }
2005
+ /**
2006
+ * Render `template` with real values when they were given, otherwise with
2007
+ * typed samples, then run `validate`. A refusal names the slot, the rendered
2008
+ * command, and the validator's reason.
2009
+ */
2010
+ function validateRenderedSlotCommand(input) {
2011
+ const values = input.paramValues ?? samplePlaceholderValues(input.template, input.paramSchema ?? {});
2012
+ const rendered = renderGateCommandTemplate(input.template, values);
2013
+ const reason = rendered.trim().length === 0 ? "fail_closed: the command is empty" : input.validate(rendered);
2014
+ if (reason == null) return {
2015
+ ok: true,
2016
+ rendered
2017
+ };
2018
+ return {
2019
+ ok: false,
2020
+ code: GATE_COMMAND_REFUSED,
2021
+ rendered,
2022
+ reason: `${GATE_COMMAND_REFUSED}: slot ${input.slotKey} rendered \`${rendered}\`: ${reason}`
2023
+ };
2024
+ }
2025
+
2026
+ //#endregion
2027
+ //#region ../shared/dist/runs/verification-technique.js
2028
+ /**
2029
+ * The six techniques, in DISPLAY order. The order is not a ranking: a
2030
+ * Semantic check is not "deeper" than a Presence check, and no code may treat
2031
+ * a later entry as stronger than an earlier one.
2032
+ */
2033
+ const VERIFICATION_TECHNIQUES = [
2034
+ "presence",
2035
+ "pattern",
2036
+ "structure",
2037
+ "semantic",
2038
+ "execution",
2039
+ "state"
2040
+ ];
2041
+ const VerificationTechniqueSchema = z.enum(VERIFICATION_TECHNIQUES);
2042
+ function isVerificationTechnique(value) {
2043
+ return typeof value === "string" && VERIFICATION_TECHNIQUES.includes(value);
2044
+ }
2045
+ /**
2046
+ * Profiles as explicit technique sets. `full` is the only profile that
2047
+ * verifies everything; `quick` and `standard` are fix-loop aids. A gate's
2048
+ * profile follows its DEFINITION's technique, never a step's label.
2049
+ */
2050
+ const PROFILE_TECHNIQUES = {
2051
+ quick: new Set([
2052
+ "presence",
2053
+ "pattern",
2054
+ "state"
2055
+ ]),
2056
+ standard: new Set([
2057
+ "presence",
2058
+ "pattern",
2059
+ "state",
2060
+ "structure"
2061
+ ]),
2062
+ full: new Set(VERIFICATION_TECHNIQUES)
2063
+ };
2064
+
2065
+ //#endregion
2066
+ //#region ../shared/dist/runs/gate-bindings-payload.js
2067
+ /** A file, binding, config or command the import refuses. */
2068
+ var PayloadError = class extends Error {
2069
+ constructor(message) {
2070
+ super(message);
2071
+ this.name = "PayloadError";
2072
+ }
2073
+ };
2074
+ const TOP_KEYS = new Set([
2075
+ "description",
2076
+ "definitions",
2077
+ "bindings"
2078
+ ]);
2079
+ const BINDING_KEYS = new Set([
2080
+ "gate_key",
2081
+ "repo_key",
2082
+ "path_prefix",
2083
+ "enabled",
2084
+ "config",
2085
+ "note",
2086
+ "delete"
2087
+ ]);
2088
+ const DELETE_ENTRY_KEYS = new Set([
2089
+ "gate_key",
2090
+ "repo_key",
2091
+ "path_prefix",
2092
+ "note",
2093
+ "delete"
2094
+ ]);
2095
+ const DEFINITION_KEYS = new Set([
2096
+ "key",
2097
+ "name",
2098
+ "description",
2099
+ "technique",
2100
+ "category",
2101
+ "resolver_template"
2102
+ ]);
2103
+ const DEFINITION_KEY_RE = /^[a-z0-9_]+$/;
2104
+ function isPlainObject(value) {
2105
+ return value != null && typeof value === "object" && !Array.isArray(value);
2106
+ }
2107
+ function optionalTierValue(entry, field, at) {
2108
+ const value = entry[field];
2109
+ if (value === void 0 || value === null) return null;
2110
+ if (typeof value !== "string" || value.trim() === "") throw new PayloadError(`${at}.${field} must be a non-empty string or null`);
2111
+ return value;
2112
+ }
2113
+ /** The key a binding's tier is matched on. */
2114
+ function bindingTierKey(b) {
2115
+ return JSON.stringify([
2116
+ b.gate_key,
2117
+ b.repo_key ?? null,
2118
+ b.path_prefix ?? null
2119
+ ]);
2120
+ }
2121
+ function parseBinding(entry, at) {
2122
+ if (!isPlainObject(entry)) throw new PayloadError(`${at} is not an object`);
2123
+ for (const key of Object.keys(entry)) if (!BINDING_KEYS.has(key)) throw new PayloadError(`${at}: unknown field "${key}"`);
2124
+ const gateKey = entry.gate_key;
2125
+ if (typeof gateKey !== "string" || gateKey.trim() === "") throw new PayloadError(`${at}.gate_key must be a non-empty string`);
2126
+ if (entry.note !== void 0 && typeof entry.note !== "string") throw new PayloadError(`${at}.note must be a string`);
2127
+ if (entry.delete !== void 0 && typeof entry.delete !== "boolean") throw new PayloadError(`${at}.delete must be a boolean`);
2128
+ const tier = {
2129
+ gate_key: gateKey,
2130
+ repo_key: optionalTierValue(entry, "repo_key", at),
2131
+ path_prefix: optionalTierValue(entry, "path_prefix", at)
2132
+ };
2133
+ const note = entry.note ? { note: entry.note } : {};
2134
+ if (entry.delete === true) {
2135
+ for (const key of Object.keys(entry)) if (!DELETE_ENTRY_KEYS.has(key)) throw new PayloadError(`${at}: a "delete": true entry names the binding to delete and nothing else; remove "${key}"`);
2136
+ return {
2137
+ ...tier,
2138
+ enabled: false,
2139
+ config: {},
2140
+ ...note,
2141
+ delete: true
2142
+ };
2143
+ }
2144
+ const config = entry.config ?? {};
2145
+ if (!isPlainObject(config)) throw new PayloadError(`${at}.config must be an object`);
2146
+ if (entry.enabled !== void 0 && typeof entry.enabled !== "boolean") throw new PayloadError(`${at}.enabled must be a boolean`);
2147
+ return {
2148
+ ...tier,
2149
+ enabled: entry.enabled !== false,
2150
+ config,
2151
+ ...note
2152
+ };
2153
+ }
2154
+ function parseDefinition(entry, at) {
2155
+ if (!isPlainObject(entry)) throw new PayloadError(`${at} is not an object`);
2156
+ if ("delete" in entry) throw new PayloadError(`${at}: a definition cannot be deleted by import (that removes every gate of the type); delete it on purpose instead`);
2157
+ for (const key$1 of Object.keys(entry)) if (!DEFINITION_KEYS.has(key$1)) throw new PayloadError(`${at}: unknown field "${key$1}"`);
2158
+ const key = entry.key;
2159
+ if (typeof key !== "string" || !DEFINITION_KEY_RE.test(key)) throw new PayloadError(`${at}.key must be lowercase snake_case`);
2160
+ if (typeof entry.name !== "string" || entry.name.trim() === "") throw new PayloadError(`${at}.name must be a non-empty string`);
2161
+ for (const field of ["description", "category"]) {
2162
+ const v = entry[field];
2163
+ if (v !== void 0 && v !== null && typeof v !== "string") throw new PayloadError(`${at}.${field} must be a string or null`);
2164
+ }
2165
+ if (entry.technique !== void 0 && !isVerificationTechnique(entry.technique)) throw new PayloadError(`${at}.technique "${String(entry.technique)}" is not a verification technique`);
2166
+ const template = entry.resolver_template;
2167
+ if (template !== void 0 && template !== null && !isPlainObject(template)) throw new PayloadError(`${at}.resolver_template must be an object or null`);
2168
+ const def = {
2169
+ key,
2170
+ name: entry.name
2171
+ };
2172
+ if (entry.description !== void 0) def.description = entry.description;
2173
+ if (entry.technique !== void 0) def.technique = entry.technique;
2174
+ if (entry.category !== void 0) def.category = entry.category;
2175
+ if (template !== void 0) def.resolver_template = template;
2176
+ return def;
2177
+ }
2178
+ /** Parse and shape-check a gate configuration file. Throws `PayloadError`. */
2179
+ function parsePayload(raw) {
2180
+ if (!isPlainObject(raw) || !Array.isArray(raw.bindings)) throw new PayloadError("the file must be an object with a \"bindings\" array");
2181
+ for (const key of Object.keys(raw)) if (!TOP_KEYS.has(key)) throw new PayloadError(`unknown field "${key}"`);
2182
+ if (raw.description !== void 0 && typeof raw.description !== "string") throw new PayloadError("\"description\" must be a string");
2183
+ if (raw.definitions !== void 0 && !Array.isArray(raw.definitions)) throw new PayloadError("\"definitions\" must be an array");
2184
+ const seenTier = /* @__PURE__ */ new Set();
2185
+ const bindings = raw.bindings.map((entry, i) => {
2186
+ const b = parseBinding(entry, `bindings[${i}]`);
2187
+ const tier = bindingTierKey(b);
2188
+ if (seenTier.has(tier)) throw new PayloadError(`bindings[${i}]: the tier (gate_key "${b.gate_key}", repo_key ${b.repo_key ?? "null"}, path_prefix ${b.path_prefix ?? "null"}) appears twice`);
2189
+ seenTier.add(tier);
2190
+ return b;
2191
+ });
2192
+ const seenKey = /* @__PURE__ */ new Set();
2193
+ const definitions = (raw.definitions ?? []).map((entry, i) => {
2194
+ const d = parseDefinition(entry, `definitions[${i}]`);
2195
+ if (seenKey.has(d.key)) throw new PayloadError(`definitions[${i}]: key "${d.key}" appears twice`);
2196
+ seenKey.add(d.key);
2197
+ return d;
2198
+ });
2199
+ return {
2200
+ ...typeof raw.description === "string" ? { description: raw.description } : {},
2201
+ definitions,
2202
+ bindings
2203
+ };
2204
+ }
2205
+ /** Every command a config issues, with the slot path it sits at. */
2206
+ function commandLeaves(config) {
2207
+ const out = [];
2208
+ const walk = (node, at, key) => {
2209
+ if (typeof node === "string") {
2210
+ out.push({
2211
+ path: at,
2212
+ command: node,
2213
+ ...key ? { key } : {}
2214
+ });
2215
+ return;
2216
+ }
2217
+ if (!isPlainObject(node)) return;
2218
+ if (Array.isArray(node.argv)) {
2219
+ out.push({
2220
+ path: at,
2221
+ command: node.argv.map(String).join(" "),
2222
+ ...key ? { key } : {}
2223
+ });
2224
+ return;
2225
+ }
2226
+ if (typeof node.legacy === "string") {
2227
+ out.push({
2228
+ path: at,
2229
+ command: node.legacy,
2230
+ ...key ? { key } : {}
2231
+ });
2232
+ return;
2233
+ }
2234
+ for (const [k, child] of Object.entries(node)) walk(child, `${at}.${k}`, k);
2235
+ };
2236
+ for (const root of ["cmd", "impl"]) if (config[root] !== void 0) walk(config[root], root);
2237
+ return out;
2238
+ }
2239
+ /**
2240
+ * Every check that needs no network or database: each binding config's shape
2241
+ * and each command slot, rendered with typed samples and run through the
2242
+ * gate command allowlist exactly as the server checks it at write time; and
2243
+ * each definition's resolver template commands. Throws `PayloadError` naming
2244
+ * the entry. Delete entries carry no config and are not checked.
2245
+ *
2246
+ * Which `params` a binding may carry depends on its definition's resolver.
2247
+ * Locally that is known for a definition in the file and for the system gate
2248
+ * types (the key is the resolver); for any other key the `params` subtree is
2249
+ * left to the server, which checks every row against the stored definition,
2250
+ * so a valid file is never refused here. The server passes
2251
+ * `{ configShape: false }` and runs that per-row check itself.
2252
+ */
2253
+ function validatePayload(payload, opts = {}) {
2254
+ const fileResolver = new Map(payload.definitions.map((d) => {
2255
+ const r = d.resolver_template?.resolver;
2256
+ return [d.key, typeof r === "string" ? r : null];
2257
+ }));
2258
+ for (const def of payload.definitions) for (const slot of commandSlotsInResolverTemplate(def.resolver_template ?? null)) {
2259
+ const result = validateRenderedSlotCommand({
2260
+ slotKey: slot.slotKey,
2261
+ template: slot.template,
2262
+ paramSchema: slot.paramSchema,
2263
+ validate: validateGateCommand
2264
+ });
2265
+ if (!result.ok) throw new PayloadError(`definition ${def.key}: ${result.reason}`);
2266
+ }
2267
+ for (const binding of payload.bindings) {
2268
+ if (binding.delete) continue;
2269
+ try {
2270
+ if (opts.configShape !== false) {
2271
+ const key = binding.gate_key;
2272
+ if (fileResolver.has(key)) assertWellFormedGateBindingConfig(binding.config, key, fileResolver.get(key));
2273
+ else if (Object.prototype.hasOwnProperty.call(BINDING_PARAM_DEFAULTS, key)) assertWellFormedGateBindingConfig(binding.config, key);
2274
+ else {
2275
+ const { params: _left,...rest } = binding.config;
2276
+ assertWellFormedGateBindingConfig(rest, key);
2277
+ }
2278
+ }
2279
+ } catch (e) {
2280
+ if (e instanceof GateBindingConfigError) throw new PayloadError(`${binding.gate_key}: ${e.message}`);
2281
+ throw e;
2282
+ }
2283
+ for (const slot of commandSlotsInBindingConfig(binding.config)) {
2284
+ const result = validateRenderedSlotCommand({
2285
+ slotKey: slot.slotKey,
2286
+ template: slot.template,
2287
+ paramSchema: slot.paramSchema,
2288
+ validate: validateGateCommand
2289
+ });
2290
+ if (!result.ok) throw new PayloadError(`${binding.gate_key}: ${result.reason}`);
2291
+ }
2292
+ }
2293
+ }
2294
+ /**
2295
+ * The files a file's lanes name, repository-relative: script paths in a
2296
+ * command, `tests/<name>.rs` for each `--test <name>`, and path-prefix keys
2297
+ * that name a file. Placeholders (`{path}`) are not files.
2298
+ */
2299
+ function namedFiles(payload) {
2300
+ const files = /* @__PURE__ */ new Set();
2301
+ for (const binding of payload.bindings) for (const leaf of commandLeaves(binding.config)) {
2302
+ const words = leaf.command.split(/\s+/);
2303
+ words.forEach((word, i) => {
2304
+ if (word === "--test" && words[i + 1] && !words[i + 1].includes("{")) files.add(`tests/${words[i + 1]}.rs`);
2305
+ else if (/^[\w.-]+(?:\/[\w.-]+)+\.[A-Za-z0-9]+$/.test(word) && !word.startsWith("@")) files.add(word);
2306
+ });
2307
+ if (leaf.key && /\.[A-Za-z0-9]+$/.test(leaf.key) && leaf.key.includes("/")) files.add(leaf.key);
2308
+ }
2309
+ return [...files].sort();
2310
+ }
2311
+ /** `namedFiles` for which `exists(relativePath)` is false. */
2312
+ function missingFiles(payload, exists) {
2313
+ return namedFiles(payload).filter((f) => !exists(f));
2314
+ }
2315
+
2316
+ //#endregion
2317
+ //#region ../shared/dist/runs/gate-expectations.js
2318
+ /** The branch names a positive grep count may be judged by. */
2319
+ const POSITIVE_COUNT = [
2320
+ "count OP N",
2321
+ ">= N",
2322
+ ">= N each",
2323
+ "both >= N",
2324
+ "PASS"
2325
+ ];
2326
+ /** The branch names a NEGATIVE grep count may be judged by. */
2327
+ const NEGATIVE_COUNT = ["zero", "count OP N"];
2328
+ const expect = (defaultExpected, accepts) => ({
2329
+ defaultExpected,
2330
+ verdictSource: "expectation",
2331
+ ...accepts ? { accepts } : {}
2332
+ });
2333
+ const RUNNER = {
2334
+ defaultExpected: null,
2335
+ verdictSource: "runner"
2336
+ };
2337
+ const JUDGE = {
2338
+ defaultExpected: null,
2339
+ verdictSource: "judge"
2340
+ };
2341
+ /**
2342
+ * Every system gate type's expectation contract — ENG-5967 A.
2343
+ *
2344
+ * The user-decided defaults (binding): `file_exists` EXISTS, `file_not_exists`
2345
+ * NOT_EXISTS, `file_contains`/`module_imports` `>= 1`, `file_not_contains`/
2346
+ * `no_stubs` `0`, `done_criteria_check` `all met`, `shell` `exit 0`. The rest
2347
+ * follow the same reading of their own templates: a grep count asks `>= 1`
2348
+ * (or `0` when it is a prohibition), a script whose exit code is its verdict
2349
+ * asks `exit 0`, and a check that prints one token asks for that token.
2350
+ *
2351
+ * `seed_data_count` deliberately has NO default: "there should be exactly N"
2352
+ * has no N anyone but the author knows, so deploy refuses it without one.
2353
+ */
2354
+ const GATE_TYPE_EXPECTATIONS = {
2355
+ file_exists: expect("EXISTS", [
2356
+ "EXISTS",
2357
+ "exit 0",
2358
+ "contains:"
2359
+ ]),
2360
+ entity_generated: expect("EXISTS", [
2361
+ "EXISTS",
2362
+ "exit 0",
2363
+ "contains:"
2364
+ ]),
2365
+ file_not_exists: expect("NOT_EXISTS", [
2366
+ "NOT_EXISTS",
2367
+ "exit 0",
2368
+ "contains:"
2369
+ ]),
2370
+ file_contains: expect(">= 1", POSITIVE_COUNT),
2371
+ module_imports: expect(">= 1", POSITIVE_COUNT),
2372
+ schema_has_field: expect(">= 1", POSITIVE_COUNT),
2373
+ entity_has_column: expect(">= 1", POSITIVE_COUNT),
2374
+ service_has_method: expect(">= 1", POSITIVE_COUNT),
2375
+ assertion_floor: expect(">= 1", POSITIVE_COUNT),
2376
+ seed_data_count: {
2377
+ defaultExpected: null,
2378
+ verdictSource: "expectation",
2379
+ accepts: POSITIVE_COUNT.concat(["zero"])
2380
+ },
2381
+ file_not_contains: expect("0", NEGATIVE_COUNT),
2382
+ no_stubs: expect("0", NEGATIVE_COUNT),
2383
+ symbol_removed: expect("0", NEGATIVE_COUNT),
2384
+ done_criteria_check: expect("all met", [
2385
+ "all met",
2386
+ "exit 0",
2387
+ "contains:"
2388
+ ]),
2389
+ schema_registered: expect("PASS", ["PASS", "contains:"]),
2390
+ shell: expect("exit 0"),
2391
+ typecheck: expect("exit 0"),
2392
+ lint: expect("exit 0"),
2393
+ changed_line_coverage: expect("exit 0"),
2394
+ secret_scan: expect("exit 0"),
2395
+ security_testing: expect("exit 0"),
2396
+ iac_misconfig: expect("exit 0"),
2397
+ dependency_audit: expect("exit 0"),
2398
+ symbol_referenced: expect("exit 0"),
2399
+ ast_pattern: expect("exit 0"),
2400
+ schema_column_set: expect("exit 0"),
2401
+ migration_revert_scope: expect("exit 0"),
2402
+ migration_contains: expect("exit 0"),
2403
+ unit_test_pass: RUNNER,
2404
+ e2e_test_pass: RUNNER,
2405
+ spec_conformance: JUDGE,
2406
+ behavioral_intent: JUDGE,
2407
+ edge_case_coverage: JUDGE,
2408
+ guided: {
2409
+ defaultExpected: null,
2410
+ verdictSource: "observation"
2411
+ }
2412
+ };
2413
+
2414
+ //#endregion
2415
+ //#region ../shared/dist/schemas/test-data.zod.js
2416
+ const GherkinKeywordSchema = z.enum([
2417
+ "Given",
2418
+ "When",
2419
+ "Then",
2420
+ "And",
2421
+ "But",
2422
+ "*"
2423
+ ]);
2424
+ const DataTableDataSchema = z.object({ rows: z.array(z.array(z.string())).describe("") }).describe("");
2425
+ /**
2426
+ * A GUIDED step — plan ENG-4825 D-F / R7.1, R7.2, R7.8.
2427
+ *
2428
+ * A gate a human *or an agent* performs: an instruction and what should be
2429
+ * true afterwards. It is the third step shape, alongside the legacy shell
2430
+ * (`method`) and the declarative (`type` + `params`) forms.
2431
+ *
2432
+ * ## Why `guided` and not `manual`
2433
+ *
2434
+ * Seven real manual tests in the corpus already carry `type: 'manual'` on
2435
+ * their steps. Reusing that marker would retroactively turn them into gates an
2436
+ * agent is expected to perform and record per-step results for, which is not
2437
+ * what their authors wrote. A new word costs nothing and cannot collide.
2438
+ *
2439
+ * ## Why the field is `step` and not `method`
2440
+ *
2441
+ * `method` means "a shell command to run". A guided step is an instruction to
2442
+ * follow, and calling it `method` would invite every executor that shells out
2443
+ * on `method` to try. The normaliser must NOT rename it — see
2444
+ * `test.service.ts`.
2445
+ *
2446
+ * ## Ordering matters — this arm goes FIRST in every union
2447
+ *
2448
+ * The competitor is the DECLARATIVE arm, which accepts any `type` string and
2449
+ * has no `step` field: under `.strict()` it rejects, but under `.passthrough()`
2450
+ * a guided step matching it would keep `step` only by accident of the
2451
+ * passthrough, and a later `.strict()` consumer would silently drop the
2452
+ * instruction. First place makes the match deterministic instead.
2453
+ */
2454
+ const GuidedStepItemSchema = z.object({
2455
+ id: z.string().uuid().optional(),
2456
+ type: z.literal("guided"),
2457
+ step: z.string().describe(""),
2458
+ expected_result: z.string().optional().describe(""),
2459
+ result: z.string().optional().describe(""),
2460
+ technique: VerificationTechniqueSchema.optional().describe("")
2461
+ });
2462
+ const StepItemSchema = z.object({
2463
+ id: z.string().uuid().optional(),
2464
+ method: z.string(),
2465
+ expected_result: z.string().optional(),
2466
+ shared_step_id: z.string().uuid().optional(),
2467
+ keyword: GherkinKeywordSchema.optional(),
2468
+ data_table: DataTableDataSchema.optional().describe(""),
2469
+ technique: VerificationTechniqueSchema.optional().describe("")
2470
+ });
2471
+ const PreconditionItemSchema = z.object({
2472
+ id: z.string().uuid().optional(),
2473
+ condition: z.string(),
2474
+ shared_precondition_id: z.string().uuid().optional(),
2475
+ keyword: GherkinKeywordSchema.optional(),
2476
+ data_table: DataTableDataSchema.optional().describe("")
2477
+ });
2478
+ /**
2479
+ * Strict test data schema for MCP tools.
2480
+ * Rejects unknown fields so LLMs receive clear schema guidance.
2481
+ */
2482
+ const TestDataSchemaStrict = z.object({
2483
+ description: z.string().optional().describe(""),
2484
+ shared_precondition_id: z.string().uuid().nullable().optional().describe(""),
2485
+ preconditions: z.array(PreconditionItemSchema).optional().describe(""),
2486
+ steps: z.array(z.union([GuidedStepItemSchema, StepItemSchema])).optional().describe("")
2487
+ }).strict().describe("");
2488
+
2489
+ //#endregion
2490
+ //#region ../shared/dist/schemas/review-loop-events.zod.js
2491
+ /**
2492
+ * ENG-3822 (D17 of ENG-3320) — wire format for review-loop escape events
2493
+ * published by `apps/github-app` to the JetStream subject
2494
+ * `jobs.backender.review_loop.event` (BACKENDER_RX stream) and consumed
2495
+ * by backender's `ReviewLoopEventConsumer`, which feeds the existing
2496
+ * `ReviewLoopEscapeService` (D13). Replaces the retired
2497
+ * `POST /v1/internal/github/review-loop/event` internal HTTP endpoint.
2498
+ *
2499
+ * This is the SINGLE source of truth for the payload shape — both sides
2500
+ * parse/build through these schemas; never hand-roll the type on either
2501
+ * side (the jira `@levr/jira-shared` payload-schema precedent).
2502
+ *
2503
+ * ## What the event carries — and deliberately does NOT (dedupe vs DB)
2504
+ *
2505
+ * github-app persists webhook data to the shared DB BEFORE these events
2506
+ * are published (`webhook.service.ts` awaits the persistence handlers
2507
+ * first), so the event only carries what the DB does **not** already
2508
+ * reliably hold at consume time:
2509
+ *
2510
+ * - PR `body` / `head_sha` / `merged_at`: NOT carried. Reliably present on
2511
+ * `github_pull_request` (upserted by `PullRequestService.upsertFromWebhook`
2512
+ * before publish; the publisher gates on the same repo-linked +
2513
+ * `pr_sync_enabled` conditions as the upsert). The consumer hydrates them
2514
+ * from the DB row.
2515
+ * - PR changed filenames: CARRIED. Nothing persists them; github-app fetches
2516
+ * them (metadata only, never contents) at publish time.
2517
+ * - `merge_commit_sha`: CARRIED. `github_pull_request` has no such column.
2518
+ * - push commit `{sha, message}` pairs: CARRIED. `github_commit` persistence
2519
+ * is best-effort — capped at 50 commits per push and fail-soft per row
2520
+ * (`CommitService.processCommit` returns null on save failure), so the DB
2521
+ * is NOT a reliable source for the exact commit set of this push.
2522
+ *
2523
+ * The `event` discriminator values intentionally match the
2524
+ * `REVIEW_LOOP_PUSH_EVENT` / `REVIEW_LOOP_PR_MERGED_EVENT` constants in
2525
+ * backender's `review-loop-escape.service.ts` (the D13 HTTP contract this
2526
+ * transport replaces) so the consumer's event→DTO mapping is 1:1.
2527
+ */
2528
+ const REVIEW_LOOP_EVENT_PUSH = "push";
2529
+ const REVIEW_LOOP_EVENT_PR_MERGED = "pull_request_merged";
2530
+ /** A pushed commit — message is required for `This reverts commit <sha>` detection. */
2531
+ const ReviewLoopCommitSchema = z.object({
2532
+ sha: z.string().min(1),
2533
+ message: z.string()
2534
+ });
2535
+ const ReviewLoopPushEventSchema = z.object({
2536
+ event: z.literal(REVIEW_LOOP_EVENT_PUSH),
2537
+ workspace_id: z.string().uuid(),
2538
+ github_repository_id: z.string().uuid(),
2539
+ repo_full_name: z.string(),
2540
+ ref: z.string(),
2541
+ commits: z.array(ReviewLoopCommitSchema).min(1),
2542
+ producer_id: z.string()
2543
+ });
2544
+ const ReviewLoopPrMergedEventSchema = z.object({
2545
+ event: z.literal(REVIEW_LOOP_EVENT_PR_MERGED),
2546
+ workspace_id: z.string().uuid(),
2547
+ github_repository_id: z.string().uuid(),
2548
+ repo_full_name: z.string(),
2549
+ installation_id: z.number().int(),
2550
+ pr_number: z.number().int().positive(),
2551
+ merge_commit_sha: z.string().nullable(),
2552
+ changed_files: z.array(z.string()),
2553
+ changed_files_truncated: z.boolean(),
2554
+ producer_id: z.string()
2555
+ });
2556
+ /**
2557
+ * Discriminated union over `event`. An unknown `event` value fails parse —
2558
+ * the consumer must treat that as an explicit reject (loud log + term),
2559
+ * never a silent ack (the webhook default-throw rule, transposed).
2560
+ */
2561
+ const ReviewLoopEventSchema = z.discriminatedUnion("event", [ReviewLoopPushEventSchema, ReviewLoopPrMergedEventSchema]);
2562
+
2563
+ //#endregion
2564
+ //#region ../shared/dist/first-task/first-task-prompt.js
2565
+ /**
2566
+ * The framing of a first-task prompt — plan ENG-6341 D4 rev 3.1 (ENG-6763).
2567
+ *
2568
+ * The client builds the prompts the user pastes into their coding agent from
2569
+ * these constants (the first-task card, the own-feature dialog), and the
2570
+ * backender strips the same framing from the request before choosing a recipe
2571
+ * and titling the Plan and the task (`extractFirstTaskRequest`). One source,
2572
+ * so client and server cannot drift.
2573
+ *
2574
+ * Pure and browser-safe: no imports.
2575
+ */
2576
+ /** The product name the app's prompts carry (the email words its own brand). */
2577
+ const FIRST_TASK_PROMPT_BRAND = "Levr";
2578
+ /**
2579
+ * The opening of a first-task prompt for a brand: `Use <brand> to hold you to
2580
+ * this task: `. Review F-061: the nudge email builds its prompt with the
2581
+ * brand it is sent under, so the backender strips this framing for ANY
2582
+ * single-word brand, not only the literal "Levr".
2583
+ */
2584
+ function firstTaskPromptPrefix(brandName) {
2585
+ return `Use ${brandName} to hold you to this task: `;
2586
+ }
2587
+ /** Opens every first-task prompt; an instruction to the agent, never the task. */
2588
+ const FIRST_TASK_PROMPT_PREFIX = firstTaskPromptPrefix(FIRST_TASK_PROMPT_BRAND);
2589
+ /**
2590
+ * The fixed todo-starter task, in the first-task card's words (plan ENG-6341
2591
+ * D4, the `todo-starter` recipe). Code review F-007 (ENG-7148): ONE source for
2592
+ * the card the user copies from and the `get_context` example the agent reads.
2593
+ */
2594
+ const FIRST_TASK_TODO_TASK = "build a tiny TypeScript todo app with tests: add, complete, delete; reject empty or whitespace-only titles; completing a todo twice is a no-op.";
2595
+ /**
2596
+ * The todo-starter prompt: what the first-task card copies and what
2597
+ * `get_context` offers an owner's agent as `first_task.example_prompt`.
2598
+ */
2599
+ const FIRST_TASK_TODO_PROMPT = `${FIRST_TASK_PROMPT_PREFIX}in this empty folder, ${FIRST_TASK_TODO_TASK}`;
2600
+
2601
+ //#endregion
2602
+ //#region ../shared/dist/ai-pricing/rate-card.js
2603
+ const PER_TOKEN = 1 / 1e6;
2604
+
2605
+ //#endregion
2606
+ //#region ../shared/dist/schemas/ai-usage-batch.zod.js
2607
+ const AI_USAGE_BATCH_VERSION = 1;
2608
+ const AiUsageBatchEntrySchema = z.object({
2609
+ workspace_id: z.string().uuid().nullable(),
2610
+ provider: z.string().min(1).max(64),
2611
+ model: z.string().min(1).max(128),
2612
+ calls: z.number().int().positive().max(1e7),
2613
+ input_tokens: z.number().int().nonnegative().max(0xe8d4a51000).nullable(),
2614
+ latency_ms: z.number().int().nonnegative().max(0xe8d4a51000)
2615
+ });
2616
+ const AiUsageBatchSchema = z.object({
2617
+ v: z.literal(AI_USAGE_BATCH_VERSION),
2618
+ batch_id: z.string().min(1).max(64),
2619
+ flushed_at: z.string().datetime(),
2620
+ entries: z.array(AiUsageBatchEntrySchema).min(1).max(5e3)
2621
+ });
2622
+
2623
+ //#endregion
2624
+ //#region src/commands/gates/bindingsHandler.ts
2625
+ function fail(code, cmd, message) {
2626
+ return {
2627
+ exitCode: code,
2628
+ stdout: "",
2629
+ stderr: `gates bindings ${cmd}: ${message}\n`
2630
+ };
2631
+ }
2632
+ /** The server's own message for a refusal (Nest puts it in `message`). */
2633
+ function serverMessage(body, status) {
2634
+ if (body && typeof body === "object") {
2635
+ const m = body.message;
2636
+ if (typeof m === "string") return m;
2637
+ if (Array.isArray(m)) return m.map(String).join("; ");
2638
+ }
2639
+ if (typeof body === "string" && body.trim()) return body.trim();
2640
+ return `HTTP ${status}`;
2641
+ }
2642
+ function tierLabel(b) {
2643
+ if (b.repo_key == null) return "workspace-wide";
2644
+ return b.path_prefix ? `${b.repo_key}:${b.path_prefix}` : b.repo_key;
2645
+ }
2646
+ /** `export`: the file on stdout, or written to `--out`. */
2647
+ async function executeBindingsExport(flags, deps) {
2648
+ let reply;
2649
+ try {
2650
+ reply = await deps.exportConfig();
2651
+ } catch (err) {
2652
+ return fail(1, "export", `request failed: ${err instanceof Error ? err.message : String(err)}`);
2653
+ }
2654
+ if (reply.status !== 200) return fail(1, "export", serverMessage(reply.body, reply.status));
2655
+ const text = `${JSON.stringify(reply.body, null, 2)}\n`;
2656
+ if (!flags.out) return {
2657
+ exitCode: 0,
2658
+ stdout: text,
2659
+ stderr: ""
2660
+ };
2661
+ try {
2662
+ deps.writeFile(flags.out, text);
2663
+ } catch (err) {
2664
+ return fail(2, "export", `cannot write ${flags.out}: ${err instanceof Error ? err.message : String(err)}`);
2665
+ }
2666
+ const body = reply.body;
2667
+ return {
2668
+ exitCode: 0,
2669
+ stdout: "",
2670
+ stderr: `Wrote ${flags.out}: ${body.bindings?.length ?? 0} binding(s), ${body.definitions?.length ?? 0} definition(s).\n`
2671
+ };
2672
+ }
2673
+ /** `import <file>`: check locally, then apply (or preview with --dry-run). */
2674
+ async function executeBindingsImport(flags, file, deps) {
2675
+ let raw;
2676
+ try {
2677
+ raw = JSON.parse(deps.readFile(file));
2678
+ } catch (err) {
2679
+ return fail(2, "import", `cannot read ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
2680
+ }
2681
+ let payload;
2682
+ try {
2683
+ payload = parsePayload(raw);
2684
+ validatePayload(payload);
2685
+ } catch (err) {
2686
+ if (err instanceof PayloadError) return fail(2, "import", `${file} refused, nothing sent: ${err.message}`);
2687
+ throw err;
2688
+ }
2689
+ const out = [];
2690
+ if (flags["repo-root"]) {
2691
+ const root = flags["repo-root"];
2692
+ const missing = missingFiles(payload, (rel) => deps.exists(root, rel));
2693
+ for (const f of namedFiles(payload)) out.push(`${missing.includes(f) ? "MISSING" : "present"} ${f}`);
2694
+ if (missing.length > 0) return {
2695
+ exitCode: 1,
2696
+ stdout: `${out.join("\n")}\n`,
2697
+ stderr: `gates bindings import: ${missing.length} file(s) the lanes name are missing under ${root}; nothing sent\n`
2698
+ };
2699
+ }
2700
+ let reply;
2701
+ try {
2702
+ reply = await deps.importConfig(raw, flags["dry-run"]);
2703
+ } catch (err) {
2704
+ return fail(1, "import", `request failed: ${err instanceof Error ? err.message : String(err)}` + (flags["dry-run"] ? "" : " (the import may or may not have been applied; run it with --dry-run to see)"));
2705
+ }
2706
+ if (reply.status !== 200) return fail(1, "import", serverMessage(reply.body, reply.status));
2707
+ const result = reply.body;
2708
+ for (const d of result.definitions ?? []) out.push(`${d.action.padEnd(8)} definition ${d.key}`);
2709
+ for (const b of result.bindings ?? []) {
2710
+ const differs$1 = b.action === "differs" ? " (NOT applied: the stored row differs)" : "";
2711
+ const note = b.note ? ` # ${b.note}` : "";
2712
+ out.push(`${b.action.padEnd(8)} ${b.gate_key} [${tierLabel(b)}]${differs$1}${note}`);
2713
+ }
2714
+ const rows = [...result.definitions ?? [], ...result.bindings ?? []];
2715
+ const written = rows.filter((r) => r.applied).length;
2716
+ out.push(result.dry_run ? "# Dry run: nothing written. Run again without --dry-run to apply." : `# Applied: ${written} row(s) written.`);
2717
+ const differs = rows.filter((r) => r.action === "differs").length;
2718
+ return {
2719
+ exitCode: differs > 0 ? 1 : 0,
2720
+ stdout: `${out.join("\n")}\n`,
2721
+ stderr: differs > 0 ? `gates bindings import: ${differs} row(s) differ from what the workspace stores and were not applied; change them in the workspace on purpose, or delete them and import again.\n` : ""
2722
+ };
2723
+ }
2724
+ /** The real dependencies: the logged-in backend (the public SDK), the disk. */
2725
+ async function liveDeps(workspaceFlag) {
2726
+ let auth;
2727
+ try {
2728
+ auth = await resolveToken();
2729
+ } catch (err) {
2730
+ return fail(1, "auth", err instanceof Error ? err.message : "Authentication failed.");
2731
+ }
2732
+ configureClient(auth);
2733
+ try {
2734
+ const ws = await resolveWorkspaceFor(auth, workspaceFlag);
2735
+ if (ws) client.setConfig({
2736
+ ...client.getConfig(),
2737
+ workspaceId: ws.workspaceId
2738
+ });
2739
+ } catch (err) {
2740
+ return fail(1, "workspace", err instanceof Error ? err.message : "Workspace resolution failed.");
2741
+ }
2742
+ const reply = (r) => ({
2743
+ status: r.response.status,
2744
+ body: r.ok ? r.data : r.error
2745
+ });
2746
+ return {
2747
+ exportConfig: async () => reply(await gateBindingTransferExportConfigV1()),
2748
+ importConfig: async (file, dryRun) => reply(await gateBindingTransferImportConfigV1({
2749
+ body: file,
2750
+ ...dryRun ? { query: { dry_run: "true" } } : {}
2751
+ })),
2752
+ readFile: (file) => fs.readFileSync(file, "utf8"),
2753
+ writeFile: (file, content) => fs.writeFileSync(file, content),
2754
+ exists: (root, rel) => fs.existsSync(path$1.join(root, rel))
2755
+ };
2756
+ }
2757
+ function emit(ctx, outcome) {
2758
+ if (outcome.stdout) ctx.process.stdout.write(outcome.stdout);
2759
+ if (outcome.stderr) ctx.process.stderr.write(outcome.stderr);
2760
+ ctx.process.exitCode = outcome.exitCode;
2761
+ }
2762
+ async function gatesBindingsExportHandler(flags) {
2763
+ const deps = await liveDeps(flags["workspace-id"]);
2764
+ if ("exitCode" in deps) return emit(this, deps);
2765
+ emit(this, await executeBindingsExport(flags, deps));
2766
+ }
2767
+ async function gatesBindingsImportHandler(flags, file) {
2768
+ let preflight = null;
2769
+ try {
2770
+ validatePayload(parsePayload(JSON.parse(fs.readFileSync(file, "utf8"))));
2771
+ } catch (err) {
2772
+ preflight = fail(2, "import", err instanceof PayloadError ? `${file} refused, nothing sent: ${err.message}` : `cannot read ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
2773
+ }
2774
+ if (preflight) return emit(this, preflight);
2775
+ const deps = await liveDeps(flags["workspace-id"]);
2776
+ if ("exitCode" in deps) return emit(this, deps);
2777
+ emit(this, await executeBindingsImport(flags, file, deps));
2778
+ }
2779
+
2780
+ //#endregion
2781
+ export { gatesBindingsExportHandler, gatesBindingsImportHandler };