@serverkgg/bridge 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -2,7 +2,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { runValidate } from "./validate";
5
+ import { BridgeKind, BridgeSetupStepKind } from "../core";
6
+ import { GuideOpenTab } from "../guides";
7
+ import type { BridgeSetupModule } from "../modules";
8
+ import { runValidate, setupReportLines } from "./validate";
9
+
10
+ const text = (value: string) => ({
11
+ ar: value,
12
+ en: value,
13
+ });
6
14
 
7
15
  describe("validate", () => {
8
16
  let root = "";
@@ -87,3 +95,110 @@ describe("validate", () => {
87
95
  await expect(validate()).rejects.toThrow("missing serverk.yml");
88
96
  });
89
97
  });
98
+
99
+ describe("the setup report", () => {
100
+ test("says none for a driver that declares no setup", () => {
101
+ expect(setupReportLines(undefined)).toEqual([
102
+ " setup: none",
103
+ ]);
104
+ });
105
+
106
+ test("names every step, its kind, whether it is required, and where it lands", () => {
107
+ const setup: BridgeSetupModule = {
108
+ kind: BridgeKind.Setup,
109
+ steps: [
110
+ {
111
+ kind: BridgeSetupStepKind.Form,
112
+ id: "license",
113
+ title: text("Licence"),
114
+ required: true,
115
+ tab: "license",
116
+ section: "license",
117
+ fields: [
118
+ "LICENSE_KEY",
119
+ ],
120
+ },
121
+ {
122
+ kind: BridgeSetupStepKind.Driver,
123
+ id: "verify",
124
+ title: text("Verify"),
125
+ required: true,
126
+ requiresRunning: true,
127
+ },
128
+ {
129
+ kind: BridgeSetupStepKind.Open,
130
+ id: "txadmin",
131
+ title: text("txAdmin"),
132
+ required: false,
133
+ target: {
134
+ tab: GuideOpenTab.Panel,
135
+ tabId: "license",
136
+ },
137
+ },
138
+ {
139
+ kind: BridgeSetupStepKind.Open,
140
+ id: "invite",
141
+ title: text("Invite"),
142
+ required: false,
143
+ target: {
144
+ tab: GuideOpenTab.Access,
145
+ },
146
+ },
147
+ ],
148
+ submit: async () => {
149
+ return;
150
+ },
151
+ };
152
+
153
+ expect(setupReportLines(setup)).toEqual([
154
+ " setup: 4 step(s)",
155
+ " license — form, required → license/license [LICENSE_KEY]",
156
+ " verify — driver, required → driver (needs the game running)",
157
+ ' txadmin — open, optional → panel tab "license"',
158
+ " invite — open, optional → access",
159
+ ]);
160
+ });
161
+
162
+ test("leaves a driver step that needs nothing running with no target", () => {
163
+ expect(
164
+ setupReportLines({
165
+ kind: BridgeKind.Setup,
166
+ steps: [
167
+ {
168
+ kind: BridgeSetupStepKind.Driver,
169
+ id: "sign-in",
170
+ title: text("Sign in"),
171
+ required: false,
172
+ },
173
+ ],
174
+ submit: async () => {
175
+ return;
176
+ },
177
+ }),
178
+ ).toEqual([
179
+ " setup: 1 step(s)",
180
+ " sign-in — driver, optional → driver",
181
+ ]);
182
+ });
183
+
184
+ test("reads an omitted required as required, the way the platform does", () => {
185
+ expect(
186
+ setupReportLines({
187
+ kind: BridgeKind.Setup,
188
+ steps: [
189
+ {
190
+ kind: BridgeSetupStepKind.Driver,
191
+ id: "sign-in",
192
+ title: text("Sign in"),
193
+ },
194
+ ],
195
+ submit: async () => {
196
+ return;
197
+ },
198
+ }),
199
+ ).toEqual([
200
+ " setup: 1 step(s)",
201
+ " sign-in — driver, required → driver",
202
+ ]);
203
+ });
204
+ });
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { parseArgs } from "node:util";
5
5
  import { z } from "zod";
6
- import { type GuidePack, readGuidePack, readGuidePackage } from "../guides";
6
+ import { GuideOpenTab, type GuideOpenTarget, type GuidePack, readGuidePack, readGuidePackage } from "../guides";
7
7
  import {
8
8
  declaredFiles,
9
9
  type GuidePackage,
@@ -13,8 +13,8 @@ import {
13
13
  readAssets,
14
14
  readPackageDocument,
15
15
  } from "../manifest";
16
- import type { BridgeDriver } from "../modules";
17
- import { isBridgeEventName } from "../protocol";
16
+ import type { BridgeDriver, BridgeSetupModule } from "../modules";
17
+ import { type BridgeSetupStep, BridgeSetupStepKind, isBridgeEventName } from "../protocol";
18
18
  import { validateDriver, validatePortActivation } from "../runtime/bridgeDriver";
19
19
  import { directorySource } from "./directorySource";
20
20
 
@@ -186,6 +186,49 @@ const reportActionTargets = (driver: BridgeDriver) => {
186
186
  }
187
187
  };
188
188
 
189
+ const openTargetOf = (target: GuideOpenTarget) => {
190
+ if (target.tab === GuideOpenTab.Panel) {
191
+ return `panel tab "${target.tabId}"`;
192
+ }
193
+
194
+ if (target.tab === GuideOpenTab.Files) {
195
+ return target.path ? `files at "${target.path}"` : "files";
196
+ }
197
+
198
+ return target.tab;
199
+ };
200
+
201
+ const setupTargetOf = (step: BridgeSetupStep) => {
202
+ if (step.kind === BridgeSetupStepKind.Form) {
203
+ const fields = step.fields ?? [];
204
+
205
+ return `→ ${step.tab}/${step.section}${fields.length === 0 ? "" : ` [${fields.join(", ")}]`}`;
206
+ }
207
+
208
+ if (step.kind === BridgeSetupStepKind.Open) {
209
+ return `→ ${openTargetOf(step.target)}`;
210
+ }
211
+
212
+ return step.requiresRunning ? "→ driver (needs the game running)" : "→ driver";
213
+ };
214
+
215
+ export const setupReportLines = (setup: BridgeSetupModule | undefined): string[] => {
216
+ if (!setup) {
217
+ return [
218
+ " setup: none",
219
+ ];
220
+ }
221
+
222
+ return [
223
+ ` setup: ${setup.steps.length} step(s)`,
224
+ ...setup.steps.map((step) => {
225
+ const target = setupTargetOf(step);
226
+
227
+ return ` ${step.id} — ${step.kind}, ${step.required === false ? "optional" : "required"}${target === "" ? "" : ` ${target}`}`;
228
+ }),
229
+ ];
230
+ };
231
+
189
232
  const validateGamePackage = async (source: PackageSource, manifest: Manifest, dir: string, driverArg?: string) => {
190
233
  const assets = readAssets(source, manifest);
191
234
  const guideFiles = manifest.guides.flatMap((guide) => [
@@ -230,7 +273,10 @@ const validateGamePackage = async (source: PackageSource, manifest: Manifest, di
230
273
  ` driver: ${Object.keys(driver.modules ?? {}).length} panel module(s), ${driver.panel?.tabs.length ?? 0} tab(s)`,
231
274
  );
232
275
  console.log(` pending: ${driver.pending ? `declared (${driver.pending.extensions.length} extension(s))` : "none"}`);
233
- console.log(` setup: ${driver.setup ? `${driver.setup.steps.length} step(s)` : "none"}`);
276
+ for (const line of setupReportLines(driver.setup)) {
277
+ console.log(line);
278
+ }
279
+
234
280
  reportEvents(driver);
235
281
  reportActionTargets(driver);
236
282
  };
@@ -16,6 +16,7 @@ export enum GuideSurface {
16
16
  Files = "files",
17
17
  Backups = "backups",
18
18
  Panel = "panel",
19
+ Setup = "setup",
19
20
  }
20
21
 
21
22
  export const guideTagSchema = z.object({
@@ -1,6 +1,7 @@
1
1
  export enum BridgeControl {
2
2
  Boolean = "boolean",
3
3
  Number = "number",
4
+ Secret = "secret",
4
5
  Select = "select",
5
6
  Slider = "slider",
6
7
  Text = "text",
@@ -11,6 +11,7 @@ export enum BridgeDetailFormat {
11
11
  Bytes = "bytes",
12
12
  Date = "date",
13
13
  Number = "number",
14
+ Secret = "secret",
14
15
  Text = "text",
15
16
  }
16
17
 
@@ -32,6 +32,8 @@ export interface BridgeField {
32
32
  max?: number;
33
33
  step?: number;
34
34
  maxLength?: number;
35
+ pattern?: string;
36
+ patternHint?: LocalizedText;
35
37
  options?: BridgeOption[] | BridgeOptionsRef;
36
38
  visibleWhen?: BridgeVisibleWhen;
37
39
  }
@@ -40,6 +40,8 @@ export const BRIDGE_PENDING_FILES_LIMIT = 64;
40
40
 
41
41
  export const BRIDGE_SETUP_STEPS_LIMIT = 8;
42
42
 
43
+ export const BRIDGE_SETUP_ACTIONS_LIMIT = 4;
44
+
43
45
  export const BRIDGE_ID_LENGTH_LIMIT = 32;
44
46
 
45
47
  export const BRIDGE_KEY_LENGTH_LIMIT = 64;
@@ -53,3 +55,9 @@ export const BRIDGE_PLACEHOLDER_LENGTH_LIMIT = 256;
53
55
  export const BRIDGE_STAGING_LENGTH_LIMIT = 128;
54
56
 
55
57
  export const BRIDGE_EXTENSION_LENGTH_LIMIT = 16;
58
+
59
+ export const BRIDGE_PATTERN_LENGTH_LIMIT = 256;
60
+
61
+ export const BRIDGE_SETUP_CODE_LENGTH_LIMIT = 64;
62
+
63
+ export const BRIDGE_SETUP_DETAIL_LENGTH_LIMIT = 256;
@@ -1155,6 +1155,11 @@ const driverWithSetup = (setup: BridgeSetupModule): BridgeDriver => ({
1155
1155
  fields: [
1156
1156
  field("SERVER_NAME"),
1157
1157
  field("MOTD"),
1158
+ {
1159
+ key: "ADMIN_PASSWORD",
1160
+ control: BridgeControl.Secret,
1161
+ label: text("Admin password"),
1162
+ },
1158
1163
  ],
1159
1164
  },
1160
1165
  ],
@@ -1172,6 +1177,40 @@ const driverStep = (id: string) => ({
1172
1177
  const setupWith = (steps: BridgeSetupStep[]): BridgeSetupModule => ({
1173
1178
  kind: BridgeKind.Setup,
1174
1179
  steps,
1180
+ submit: async () => {
1181
+ return;
1182
+ },
1183
+ });
1184
+
1185
+ const setupWithoutSubmit = (steps: BridgeSetupStep[]): BridgeSetupModule => ({
1186
+ kind: BridgeKind.Setup,
1187
+ steps,
1188
+ });
1189
+
1190
+ const driverWithFields = (fields: BridgeField[]): BridgeDriver => ({
1191
+ install: {
1192
+ kind: BridgeKind.Install,
1193
+ run: async () => {
1194
+ return;
1195
+ },
1196
+ },
1197
+ lifecycle: {
1198
+ kind: BridgeKind.Lifecycle,
1199
+ command: async () => [
1200
+ "run",
1201
+ ],
1202
+ },
1203
+ panel: {
1204
+ tabs: [
1205
+ {
1206
+ id: "settings",
1207
+ title: text("Settings"),
1208
+ sections: [
1209
+ formSection("server", fields),
1210
+ ],
1211
+ },
1212
+ ],
1213
+ },
1175
1214
  });
1176
1215
 
1177
1216
  describe("the setup singleton", () => {
@@ -1370,6 +1409,58 @@ describe("the setup singleton", () => {
1370
1409
  ).not.toThrow();
1371
1410
  });
1372
1411
 
1412
+ test("takes a form step over a secret field", () => {
1413
+ expect(() =>
1414
+ validateDriver(
1415
+ driverWithSetup(
1416
+ setupWith([
1417
+ {
1418
+ kind: BridgeSetupStepKind.Form,
1419
+ id: "password",
1420
+ title: text("Password"),
1421
+ tab: "settings",
1422
+ section: "server",
1423
+ fields: [
1424
+ "ADMIN_PASSWORD",
1425
+ ],
1426
+ },
1427
+ ]),
1428
+ ),
1429
+ ),
1430
+ ).not.toThrow();
1431
+ });
1432
+
1433
+ test("rejects a driver step while the setup exports no submit", () => {
1434
+ expect(() =>
1435
+ validateDriver(
1436
+ driverWithSetup(
1437
+ setupWithoutSubmit([
1438
+ driverStep("sign-in"),
1439
+ ]),
1440
+ ),
1441
+ ),
1442
+ ).toThrow("the setup module declares a driver step but exports no submit");
1443
+ });
1444
+
1445
+ test("takes a setup with no submit while every step is answered elsewhere", () => {
1446
+ expect(() =>
1447
+ validateDriver(
1448
+ driverWithSetup(
1449
+ setupWithoutSubmit([
1450
+ {
1451
+ kind: BridgeSetupStepKind.Open,
1452
+ id: "invite",
1453
+ title: text("Invite"),
1454
+ target: {
1455
+ tab: GuideOpenTab.Access,
1456
+ },
1457
+ },
1458
+ ]),
1459
+ ),
1460
+ ),
1461
+ ).not.toThrow();
1462
+ });
1463
+
1373
1464
  test("reserves the setup module id for the singleton", () => {
1374
1465
  expect(() =>
1375
1466
  validateDriver({
@@ -1395,3 +1486,152 @@ describe("the setup singleton", () => {
1395
1486
  ).toThrow('module id "setup" is reserved');
1396
1487
  });
1397
1488
  });
1489
+
1490
+ describe("field patterns", () => {
1491
+ test("takes a pattern on a text field and on a secret one", () => {
1492
+ expect(() =>
1493
+ validateDriver(
1494
+ driverWithFields([
1495
+ {
1496
+ key: "LICENSE_KEY",
1497
+ control: BridgeControl.Secret,
1498
+ label: text("Licence key"),
1499
+ pattern: "^cfxk_\\w{1,60}_\\w{1,20}$",
1500
+ patternHint: text("A licence key starts with cfxk_"),
1501
+ },
1502
+ {
1503
+ key: "SERVER_NAME",
1504
+ control: BridgeControl.Text,
1505
+ label: text("Name"),
1506
+ pattern: "^.{1,32}$",
1507
+ },
1508
+ ]),
1509
+ ),
1510
+ ).not.toThrow();
1511
+ });
1512
+
1513
+ test("rejects a pattern that is not a regular expression", () => {
1514
+ expect(() =>
1515
+ validateDriver(
1516
+ driverWithFields([
1517
+ {
1518
+ key: "LICENSE_KEY",
1519
+ control: BridgeControl.Text,
1520
+ label: text("Licence key"),
1521
+ pattern: "^cfxk_(",
1522
+ },
1523
+ ]),
1524
+ ),
1525
+ ).toThrow('declares pattern "^cfxk_(", which is not a valid regular expression');
1526
+ });
1527
+
1528
+ test("takes the FiveM licence pattern, whose repeats are all bounded", () => {
1529
+ expect(() =>
1530
+ validateDriver(
1531
+ driverWithFields([
1532
+ {
1533
+ key: "LICENSE_KEY",
1534
+ control: BridgeControl.Secret,
1535
+ label: text("Licence key"),
1536
+ pattern: "^(?:cfxk_\\w{1,60}_\\w{1,20}|\\w{32})$",
1537
+ },
1538
+ ]),
1539
+ ),
1540
+ ).not.toThrow();
1541
+ });
1542
+
1543
+ test("rejects a pattern that repeats a group which already repeats", () => {
1544
+ expect(() =>
1545
+ validateDriver(
1546
+ driverWithFields([
1547
+ {
1548
+ key: "LICENSE_KEY",
1549
+ control: BridgeControl.Text,
1550
+ label: text("Licence key"),
1551
+ pattern: "^(a+)+$",
1552
+ },
1553
+ ]),
1554
+ ),
1555
+ ).toThrow('declares pattern "^(a+)+$", which repeats a group that already repeats');
1556
+ });
1557
+
1558
+ test("rejects a starred group whose body repeats without a bound", () => {
1559
+ expect(() =>
1560
+ validateDriver(
1561
+ driverWithFields([
1562
+ {
1563
+ key: "SERVER_NAME",
1564
+ control: BridgeControl.Text,
1565
+ label: text("Name"),
1566
+ pattern: "^(\\w+\\s?)*$",
1567
+ },
1568
+ ]),
1569
+ ),
1570
+ ).toThrow("repeats a group that already repeats");
1571
+ });
1572
+
1573
+ test("rejects a pattern longer than the platform takes", () => {
1574
+ expect(() =>
1575
+ validateDriver(
1576
+ driverWithFields([
1577
+ {
1578
+ key: "LICENSE_KEY",
1579
+ control: BridgeControl.Text,
1580
+ label: text("Licence key"),
1581
+ pattern: `^${"a".repeat(256)}$`,
1582
+ },
1583
+ ]),
1584
+ ),
1585
+ ).toThrow("pattern is 258 characters — the platform takes at most 256");
1586
+ });
1587
+
1588
+ test("rejects a pattern hint with no pattern to explain", () => {
1589
+ expect(() =>
1590
+ validateDriver(
1591
+ driverWithFields([
1592
+ {
1593
+ key: "LICENSE_KEY",
1594
+ control: BridgeControl.Text,
1595
+ label: text("Licence key"),
1596
+ patternHint: text("A licence key starts with cfxk_"),
1597
+ },
1598
+ ]),
1599
+ ),
1600
+ ).toThrow("declares a pattern hint with no pattern to explain");
1601
+ });
1602
+
1603
+ test("rejects a pattern on a control that never matches one", () => {
1604
+ expect(() =>
1605
+ validateDriver(
1606
+ driverWithFields([
1607
+ {
1608
+ key: "WHITELIST",
1609
+ control: BridgeControl.Boolean,
1610
+ label: text("Whitelist"),
1611
+ pattern: "^true$",
1612
+ },
1613
+ ]),
1614
+ ),
1615
+ ).toThrow('declares a pattern on a "boolean" control — only text and secret controls match one');
1616
+ });
1617
+
1618
+ test("rejects a secret control that offers options", () => {
1619
+ expect(() =>
1620
+ validateDriver(
1621
+ driverWithFields([
1622
+ {
1623
+ key: "ADMIN_PASSWORD",
1624
+ control: BridgeControl.Secret,
1625
+ label: text("Admin password"),
1626
+ options: [
1627
+ {
1628
+ value: "letmein",
1629
+ label: text("letmein"),
1630
+ },
1631
+ ],
1632
+ },
1633
+ ]),
1634
+ ),
1635
+ ).toThrow("is a secret control with options — a secret is typed, never chosen from a list");
1636
+ });
1637
+ });
@@ -9,6 +9,7 @@ import { GuideOpenTab } from "../guides";
9
9
  import type { ManifestPort } from "../manifest";
10
10
  import type { BridgeDriver, BridgePanelModule } from "../modules";
11
11
  import {
12
+ BridgeControl,
12
13
  type BridgeField,
13
14
  BridgeFormTarget,
14
15
  BridgeLayout,
@@ -32,6 +33,7 @@ import {
32
33
  BRIDGE_MODULES_LIMIT,
33
34
  BRIDGE_OPTION_VALUE_LENGTH_LIMIT,
34
35
  BRIDGE_PANEL_TABS_LIMIT,
36
+ BRIDGE_PATTERN_LENGTH_LIMIT,
35
37
  BRIDGE_PLACEHOLDER_LENGTH_LIMIT,
36
38
  BRIDGE_ROW_ACTIONS_LIMIT,
37
39
  BRIDGE_SECTION_ACTIONS_LIMIT,
@@ -121,6 +123,110 @@ const moduleOf = (modules: Record<string, BridgePanelModule>, id: string, where:
121
123
  return module as BridgePanelModule;
122
124
  };
123
125
 
126
+ const compilePattern = (pattern: string): RegExp | null => {
127
+ try {
128
+ return new RegExp(pattern);
129
+ } catch {
130
+ return null;
131
+ }
132
+ };
133
+
134
+ const OPEN_ENDED_REPEAT = /^\{\d+,\}/;
135
+
136
+ const repeatsWithoutBound = (source: string): boolean => {
137
+ let index = 0;
138
+ let inClass = false;
139
+
140
+ while (index < source.length) {
141
+ const char = source[index];
142
+
143
+ if (char === "\\") {
144
+ index += 2;
145
+
146
+ continue;
147
+ }
148
+
149
+ if (inClass) {
150
+ inClass = char !== "]";
151
+ index += 1;
152
+
153
+ continue;
154
+ }
155
+
156
+ if (char === "[") {
157
+ inClass = true;
158
+ index += 1;
159
+
160
+ continue;
161
+ }
162
+
163
+ if (char === "*" || char === "+" || (char === "{" && OPEN_ENDED_REPEAT.test(source.slice(index)))) {
164
+ return true;
165
+ }
166
+
167
+ index += 1;
168
+ }
169
+
170
+ return false;
171
+ };
172
+
173
+ const nestsQuantifiers = (pattern: string): boolean => {
174
+ const opened: number[] = [];
175
+
176
+ let index = 0;
177
+ let inClass = false;
178
+
179
+ while (index < pattern.length) {
180
+ const char = pattern[index];
181
+
182
+ if (char === "\\") {
183
+ index += 2;
184
+
185
+ continue;
186
+ }
187
+
188
+ if (inClass) {
189
+ inClass = char !== "]";
190
+ index += 1;
191
+
192
+ continue;
193
+ }
194
+
195
+ if (char === "[") {
196
+ inClass = true;
197
+ index += 1;
198
+
199
+ continue;
200
+ }
201
+
202
+ if (char === "(") {
203
+ opened.push(index);
204
+ index += 1;
205
+
206
+ continue;
207
+ }
208
+
209
+ if (char === ")") {
210
+ const start = opened.pop();
211
+
212
+ index += 1;
213
+
214
+ const quantified =
215
+ pattern[index] === "*" || pattern[index] === "+" || OPEN_ENDED_REPEAT.test(pattern.slice(index));
216
+
217
+ if (start !== undefined && quantified && repeatsWithoutBound(pattern.slice(start + 1, index - 1))) {
218
+ return true;
219
+ }
220
+
221
+ continue;
222
+ }
223
+
224
+ index += 1;
225
+ }
226
+
227
+ return false;
228
+ };
229
+
124
230
  const validateVisibleWhen = (visibleWhen: BridgeVisibleWhen | undefined, where: string) => {
125
231
  if (!visibleWhen) {
126
232
  return;
@@ -149,12 +255,39 @@ const validateFields = (modules: Record<string, BridgePanelModule>, fields: Brid
149
255
  validateText(field.label, "label", at);
150
256
  validateText(field.help, "help", at);
151
257
  validateText(field.warning, "warning", at);
258
+ validateText(field.patternHint, "pattern hint", at);
152
259
  validateVisibleWhen(field.visibleWhen, at);
153
260
 
154
261
  if (field.placeholder) {
155
262
  lengthIn(field.placeholder, BRIDGE_PLACEHOLDER_LENGTH_LIMIT, "placeholder", at);
156
263
  }
157
264
 
265
+ if (field.pattern !== undefined) {
266
+ if (field.control !== BridgeControl.Text && field.control !== BridgeControl.Secret) {
267
+ fail(`${at} declares a pattern on a "${field.control}" control — only text and secret controls match one`);
268
+ }
269
+
270
+ lengthIn(field.pattern, BRIDGE_PATTERN_LENGTH_LIMIT, "pattern", at);
271
+
272
+ if (!compilePattern(field.pattern)) {
273
+ fail(`${at} declares pattern "${field.pattern}", which is not a valid regular expression`);
274
+ }
275
+
276
+ if (nestsQuantifiers(field.pattern)) {
277
+ fail(
278
+ `${at} declares pattern "${field.pattern}", which repeats a group that already repeats — a value can make it run for minutes`,
279
+ );
280
+ }
281
+ }
282
+
283
+ if (field.patternHint && field.pattern === undefined) {
284
+ fail(`${at} declares a pattern hint with no pattern to explain`);
285
+ }
286
+
287
+ if (field.control === BridgeControl.Secret && field.options) {
288
+ fail(`${at} is a secret control with options — a secret is typed, never chosen from a list`);
289
+ }
290
+
158
291
  if (Array.isArray(field.options)) {
159
292
  countIn(field.options.length, BRIDGE_FIELD_OPTIONS_LIMIT, "options", at);
160
293
 
@@ -515,6 +648,10 @@ const validateSetup = (driver: BridgeDriver) => {
515
648
  }
516
649
  }
517
650
  }
651
+
652
+ if (!setup.submit && setup.steps.some((step) => step.kind === BridgeSetupStepKind.Driver)) {
653
+ fail(`${where} declares a driver step but exports no submit — a driver step is answered through submit`);
654
+ }
518
655
  };
519
656
 
520
657
  const validateTab = (modules: Record<string, BridgePanelModule>, tab: BridgeTab) => {