@capagents/forgeqe 1.3.2 → 2.0.1

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 (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
@@ -0,0 +1,173 @@
1
+ import { readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import YAML from "yaml"
4
+
5
+ import { serviceSpec, type ServiceSpec } from "./config.ts"
6
+
7
+ /**
8
+ * A docker-compose file as ForgeQE services (docs/CONTAINERS.md). Each service is translated to a forgeqe.yaml service
9
+ * and checked by the same rules, so a compose file can declare nothing a forgeqe.yaml service cannot: settings that
10
+ * widen what a container reaches on this machine (privileged, network_mode, cap_add, devices, a build) are refused,
11
+ * a bind mount is read-only, a named volume is left out (the container keeps no data between runs), and `${NAME}`
12
+ * becomes a secret passed by name. Compose itself never runs.
13
+ */
14
+
15
+ /** Settings that make no difference to how ForgeQE runs the container, or that it sets itself. */
16
+ const IGNORED = new Set(["container_name", "restart", "labels", "expose", "healthcheck", "networks", "hostname", "stop_grace_period", "logging", "init", "tty", "stdin_open", "platform", "pull_policy", "deploy", "profiles"])
17
+ const SUPPORTED = new Set(["image", "ports", "environment", "volumes", "command", "depends_on", "x-forgeqe"])
18
+
19
+ const VAR = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)(?::?-[^}]*)?\}?$/
20
+
21
+ /** A compose command string as arguments: split on spaces, with quotes kept together (compose does not use a shell). */
22
+ export function splitCommand(s: string): string[] {
23
+ const out: string[] = []
24
+ const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
25
+ for (let m = re.exec(s); m; m = re.exec(s)) out.push(m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : m[2] !== undefined ? m[2] : m[3]!)
26
+ return out
27
+ }
28
+
29
+ function composePorts(raw: unknown, where: string): (string | number)[] | undefined {
30
+ if (raw === undefined || raw === null) return undefined
31
+ if (!Array.isArray(raw)) throw new Error(`${where}.ports must be a list.`)
32
+ return raw.map((p, i) => {
33
+ const at = `${where}.ports[${i}]`
34
+ if (p && typeof p === "object") {
35
+ const o = p as Record<string, unknown>
36
+ if (o.protocol && o.protocol !== "tcp") throw new Error(`${at}: only tcp ports are supported.`)
37
+ if (o.host_ip && o.host_ip !== "127.0.0.1") throw new Error(`${at}: host_ip ${o.host_ip}: services are reachable from this machine only (127.0.0.1).`)
38
+ return o.published !== undefined ? `${o.published}:${o.target}` : Number(o.target)
39
+ }
40
+ let s = String(p)
41
+ if (/\/(udp|sctp)$/.test(s)) throw new Error(`${at}: only tcp ports are supported.`)
42
+ s = s.replace(/\/tcp$/, "")
43
+ // 127.0.0.1:15432:5432 is what ForgeQE does anyway; any other address is refused by the forgeqe.yaml rules.
44
+ return s.startsWith("127.0.0.1:") ? s.slice("127.0.0.1:".length) : s
45
+ })
46
+ }
47
+
48
+ function composeEnv(raw: unknown, where: string): { env: Record<string, string>; secrets: string[] } {
49
+ const env: Record<string, string> = {}
50
+ const secrets: string[] = []
51
+ if (raw === undefined || raw === null) return { env, secrets }
52
+ const pairs: [string, unknown][] = Array.isArray(raw)
53
+ ? raw.map((e) => {
54
+ const s = String(e)
55
+ const eq = s.indexOf("=")
56
+ // A bare NAME passes the variable through: a secret by name.
57
+ return eq < 0 ? [s, `\${${s}}`] : [s.slice(0, eq), s.slice(eq + 1)]
58
+ })
59
+ : typeof raw === "object"
60
+ ? Object.entries(raw as Record<string, unknown>).map(([k, v]) => [k, v === null ? `\${${k}}` : v])
61
+ : (() => {
62
+ throw new Error(`${where}.environment must be a mapping or a list.`)
63
+ })()
64
+ for (const [k, v] of pairs) {
65
+ const s = String(v)
66
+ const m = VAR.exec(s)
67
+ if (m) {
68
+ // KEY: ${NAME}: the container's KEY is the value of NAME. ForgeQE passes variables by their own name only.
69
+ if (m[1] !== k) throw new Error(`${where}.environment.${k}: \${${m[1]}} under another name is not supported; name the variable ${k} in .env, or list it under secrets in forgeqe.yaml.`)
70
+ secrets.push(k)
71
+ } else if (s.includes("$")) {
72
+ throw new Error(`${where}.environment.${k}: a value that mixes text and \${…} is not supported; use a plain value, or \${${k}} alone.`)
73
+ } else env[k] = s
74
+ }
75
+ return { env, secrets }
76
+ }
77
+
78
+ function composeVolumes(raw: unknown, where: string, notes: string[]): string[] {
79
+ if (raw === undefined || raw === null) return []
80
+ if (!Array.isArray(raw)) throw new Error(`${where}.volumes must be a list.`)
81
+ const out: string[] = []
82
+ raw.forEach((v, i) => {
83
+ const at = `${where}.volumes[${i}]`
84
+ let source: string
85
+ let target: string
86
+ let mode = ""
87
+ if (v && typeof v === "object") {
88
+ const o = v as Record<string, unknown>
89
+ if (o.type && o.type !== "bind" && o.type !== "volume") throw new Error(`${at}: type ${o.type} is not supported.`)
90
+ if (o.type === "volume") {
91
+ notes.push(`${at}: the named volume ${o.source || ""} is left out; the container keeps no data between runs.`)
92
+ return
93
+ }
94
+ source = String(o.source || "")
95
+ target = String(o.target || "")
96
+ mode = o.read_only === false ? "rw" : ""
97
+ } else {
98
+ const parts = String(v).split(":")
99
+ // C:\path:/target on Windows: the drive letter is part of the source.
100
+ if (parts.length > 2 && /^[A-Za-z]$/.test(parts[0]!)) parts.splice(0, 2, `${parts[0]}:${parts[1]}`)
101
+ if (parts.length === 1) {
102
+ notes.push(`${at}: an anonymous volume (${parts[0]}) is left out.`)
103
+ return
104
+ }
105
+ ;[source = "", target = "", mode = ""] = parts
106
+ }
107
+ if (!/^(\.|\/|~|[A-Za-z]:)/.test(source)) {
108
+ notes.push(`${at}: the named volume ${source} is left out; the container keeps no data between runs.`)
109
+ return
110
+ }
111
+ if (source.startsWith("~")) throw new Error(`${at}: ${source} is outside the project; mounts are folders inside it.`)
112
+ if (mode.split(",").includes("rw")) notes.push(`${at}: mounted read-only (ForgeQE mounts nothing writable).`)
113
+ out.push(`${source}:${target}`)
114
+ })
115
+ return out
116
+ }
117
+
118
+ /** The services of a compose file, translated and checked; and what was left out. */
119
+ export function readCompose(file: string, root: string): { services: Record<string, ServiceSpec>; notes: string[] } {
120
+ let doc: unknown
121
+ try {
122
+ doc = YAML.parse(readFileSync(file, "utf8"))
123
+ } catch (e) {
124
+ throw new Error(`${path.relative(root, file)}: not valid YAML (${e instanceof Error ? e.message : e}).`)
125
+ }
126
+ const rel = path.relative(root, file) || path.basename(file)
127
+ const services = doc && typeof doc === "object" ? (doc as Record<string, unknown>).services : undefined
128
+ if (!services || typeof services !== "object" || Array.isArray(services)) throw new Error(`${rel} has no services.`)
129
+ const out: Record<string, ServiceSpec> = {}
130
+ const notes: string[] = []
131
+ // Paths in a compose file are relative to the file's folder.
132
+ const dir = path.dirname(file)
133
+ for (const [raw, def] of Object.entries(services as Record<string, unknown>)) {
134
+ const name = raw.toLowerCase().replace(/_/g, "-")
135
+ const where = `${rel} services.${raw}`
136
+ if (!def || typeof def !== "object" || Array.isArray(def)) throw new Error(`${where} must be a mapping.`)
137
+ const d = def as Record<string, unknown>
138
+ const refused = Object.keys(d).filter((k) => !SUPPORTED.has(k) && !IGNORED.has(k))
139
+ if (refused.length) {
140
+ const why = refused.includes("build")
141
+ ? " A build runs the repository's Dockerfile: build and tag the image first, and name it under image."
142
+ : refused.includes("env_file")
143
+ ? " env_file reads a file into the container: list the variables under environment as ${NAME} instead."
144
+ : " ForgeQE never runs a container privileged, on the host network, with added capabilities or devices, or with the runtime's socket."
145
+ throw new Error(`${where}: ${refused.join(", ")} ${refused.length === 1 ? "is" : "are"} not supported.${why}`)
146
+ }
147
+ const x = d["x-forgeqe"] && typeof d["x-forgeqe"] === "object" ? (d["x-forgeqe"] as Record<string, unknown>) : {}
148
+ const { env, secrets } = composeEnv(d.environment, where)
149
+ const ports = composePorts(d.ports, where)
150
+ const command = d.command === undefined || d.command === null ? undefined : Array.isArray(d.command) ? d.command : splitCommand(String(d.command))
151
+ const spec = serviceSpec(
152
+ name,
153
+ {
154
+ image: d.image,
155
+ ...(ports ? { ports } : {}),
156
+ env,
157
+ secrets,
158
+ volumes: composeVolumes(d.volumes, where, notes),
159
+ ...(command ? { command } : {}),
160
+ ...(d.depends_on ? { depends_on: Array.isArray(d.depends_on) ? d.depends_on.map((n) => String(n).toLowerCase().replace(/_/g, "-")) : Object.keys(d.depends_on as object).map((n) => n.toLowerCase().replace(/_/g, "-")) } : {}),
161
+ ...(x.ready ? { ready: x.ready } : {}),
162
+ ...(x.timeout ? { timeout: x.timeout } : {}),
163
+ ...(x.description ? { description: x.description } : {}),
164
+ },
165
+ dir,
166
+ where,
167
+ )
168
+ // Without x-forgeqe.ready, a service with ports is ready when its first port takes connections.
169
+ if (!spec.ready && spec.ports.length) spec.ready = { tcp: spec.ports[0]!.container }
170
+ out[name] = { ...spec, from: rel }
171
+ }
172
+ return { services: out, notes }
173
+ }
@@ -0,0 +1,283 @@
1
+ import { existsSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { readCompose } from "./compose.ts"
5
+
6
+ /**
7
+ * `containers:` in forgeqe.yaml (docs/CONTAINERS.md): the services ForgeQE may start in Docker or Podman for a test run
8
+ * (a database, a queue, the API under test), and the image mocks run on. The project declares every service; ForgeQE's
9
+ * agent can start, stop and read only these, and builds each command itself, so no image, flag or mount comes from a
10
+ * model.
11
+ */
12
+
13
+ export type ContainerRuntime = "docker" | "podman"
14
+
15
+ export type ServicePort = { host: number; container: number }
16
+
17
+ export type ServiceVolume = { host: string; container: string }
18
+
19
+ /** How ForgeQE knows a service is ready: a port that accepts connections, or an HTTP path that answers below 500. */
20
+ export type ServiceReady = { tcp: number } | { http: string; port: number }
21
+
22
+ export type ServiceSpec = {
23
+ name: string
24
+ image: string
25
+ ports: ServicePort[]
26
+ /** Plain values (no secrets). */
27
+ env: Record<string, string>
28
+ /** Names of environment variables passed through by name: the value never lands on a command line. */
29
+ secrets: string[]
30
+ /** Read-only mounts of folders or files inside the project. */
31
+ volumes: ServiceVolume[]
32
+ /** Arguments for the image's entrypoint. */
33
+ command: string[]
34
+ ready?: ServiceReady
35
+ /** Seconds to wait for `ready` (default 60). */
36
+ timeout: number
37
+ description?: string
38
+ /** Services that start before this one. */
39
+ dependsOn: string[]
40
+ /** Where it was declared: forgeqe.yaml, or a compose file. */
41
+ from?: string
42
+ }
43
+
44
+ /** containers.tests: the image the repository's tests run in, when run_tests runs them in a container. */
45
+ export type TestsContainer = {
46
+ image: string
47
+ /** Declared services started first; the tests reach each by its name (db:5432). */
48
+ services: string[]
49
+ env: Record<string, string>
50
+ secrets: string[]
51
+ }
52
+
53
+ export type ContainersConfig = {
54
+ /** docker or podman; unset: whichever is on PATH (docker first). */
55
+ runtime?: ContainerRuntime
56
+ services: Record<string, ServiceSpec>
57
+ /** The WireMock image mocks run on. */
58
+ mockImage: string
59
+ /** The folder `volumes` must stay inside: the folder of forgeqe.yaml. */
60
+ root: string
61
+ /** Compose files the services were read from. */
62
+ compose: string[]
63
+ tests?: TestsContainer
64
+ /** What compose files had that ForgeQE left out (named volumes, rw mounts made read-only). */
65
+ notes: string[]
66
+ }
67
+
68
+ /** The WireMock release mocks run on, pinned. */
69
+ export const WIREMOCK_IMAGE = "wiremock/wiremock:3.9.1"
70
+
71
+ export const SERVICE_NAME = /^[a-z][a-z0-9-]{0,39}$/
72
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
73
+ /**
74
+ * An image with an exact tag or a digest: [registry[:port]/]name:tag or name@sha256:… Never untagged or `latest`, which change
75
+ * under the same name. Starts with a letter or digit, so it can never read as a flag.
76
+ */
77
+ const IMAGE = /^[a-z0-9][a-z0-9.-]{0,100}(?::\d{1,5})?(?:\/[a-z0-9][a-z0-9._-]{0,100}){0,5}(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/
78
+ /** A path inside the container: absolute, no `..`, none of the characters the runtimes split mounts on. */
79
+ const CONTAINER_PATH = /^\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._@+/-]{0,200}$/
80
+
81
+ export function checkImage(image: string, where: string): string {
82
+ if (!IMAGE.test(image)) throw new Error(`${where} must be an image name with an exact tag or a digest (postgres:16.4, or name@sha256:…).`)
83
+ const digest = image.includes("@sha256:")
84
+ const tag = image.split("@")[0]!.split("/").pop()!.split(":")[1]
85
+ if (!digest && !tag) throw new Error(`${where} (${image}) has no tag: name an exact version (postgres:16.4) or a digest, so the same image runs every time.`)
86
+ if (!digest && tag === "latest") throw new Error(`${where} (${image}) uses latest, which changes under the same name: name an exact version or a digest.`)
87
+ return image
88
+ }
89
+
90
+ function port(v: unknown, where: string): number {
91
+ const n = typeof v === "number" ? v : typeof v === "string" && /^\d+$/.test(v) ? Number(v) : NaN
92
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`${where} must be a port number (1-65535).`)
93
+ return n
94
+ }
95
+
96
+ function ports(raw: unknown, where: string): ServicePort[] {
97
+ if (raw === undefined || raw === null) return []
98
+ if (!Array.isArray(raw)) throw new Error(`${where} must be a list of ports: 5432, or "15432:5432" (this machine:container).`)
99
+ return raw.map((p, i) => {
100
+ const at = `${where}[${i}]`
101
+ if (typeof p === "number") return { host: port(p, at), container: port(p, at) }
102
+ const s = String(p)
103
+ const m = /^(\d{1,5}):(\d{1,5})$/.exec(s)
104
+ if (m) return { host: port(m[1], at), container: port(m[2], at) }
105
+ // An address ("0.0.0.0:5432:5432") is refused: ports are always bound to this machine only.
106
+ if (/^\d{1,5}$/.test(s)) return { host: port(s, at), container: port(s, at) }
107
+ throw new Error(`${at} must be a port (5432) or "this machine:container" ("15432:5432"); services are reachable from this machine only.`)
108
+ })
109
+ }
110
+
111
+ function env(raw: unknown, where: string): Record<string, string> {
112
+ if (raw === undefined || raw === null) return {}
113
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must map variable names to plain values.`)
114
+ const out: Record<string, string> = {}
115
+ for (const [k, v] of Object.entries(raw)) {
116
+ if (!ENV_NAME.test(k)) throw new Error(`${where}.${k}: not a variable name (letters, digits and _).`)
117
+ if (v === null || typeof v === "object") throw new Error(`${where}.${k} must be a plain value; list secrets under secrets instead.`)
118
+ const s = String(v)
119
+ if (/[\0\r\n]/.test(s)) throw new Error(`${where}.${k} must be one line.`)
120
+ out[k] = s
121
+ }
122
+ return out
123
+ }
124
+
125
+ function names(raw: unknown, where: string): string[] {
126
+ if (raw === undefined || raw === null) return []
127
+ if (!Array.isArray(raw) || !raw.every((n) => typeof n === "string" && ENV_NAME.test(n))) {
128
+ throw new Error(`${where} must be a list of environment variable names (letters, digits and _); forgeqe.yaml holds no secret values.`)
129
+ }
130
+ return [...new Set(raw as string[])]
131
+ }
132
+
133
+ function volumes(raw: unknown, where: string, root: string): ServiceVolume[] {
134
+ if (raw === undefined || raw === null) return []
135
+ if (!Array.isArray(raw)) throw new Error(`${where} must be a list of "./folder:/path/in/container" mounts.`)
136
+ return raw.map((v, i) => {
137
+ const at = `${where}[${i}]`
138
+ const s = String(v)
139
+ const cut = s.lastIndexOf(":")
140
+ // ":ro" and other options are ForgeQE's to set: every mount is read-only.
141
+ if (cut <= 0 || /:(ro|rw|z|Z)$/.test(s)) throw new Error(`${at} must be "./folder:/path/in/container" (mounts are always read-only; leave the options out).`)
142
+ const host = s.slice(0, cut)
143
+ const container = s.slice(cut + 1)
144
+ if (!CONTAINER_PATH.test(container) || container === "/") throw new Error(`${at}: ${container} must be an absolute path in the container, not / and without ..`)
145
+ if (/[\0\r\n,]/.test(host)) throw new Error(`${at}: ${host} is not a usable path.`)
146
+ return { host: path.resolve(root, host), container }
147
+ })
148
+ }
149
+
150
+ function ready(raw: unknown, where: string, ps: ServicePort[]): ServiceReady | undefined {
151
+ if (raw === undefined || raw === null) return undefined
152
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be {tcp: PORT} or {http: /path, port: PORT}.`)
153
+ const r = raw as Record<string, unknown>
154
+ const own = (p: number) => {
155
+ if (!ps.some((x) => x.container === p)) throw new Error(`${where}: port ${p} is not one of the service's ports.`)
156
+ return p
157
+ }
158
+ if (r.tcp !== undefined) return { tcp: own(port(r.tcp, `${where}.tcp`)) }
159
+ if (r.http !== undefined) {
160
+ const p = String(r.http)
161
+ if (!/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/?-]{0,200}$/.test(p)) throw new Error(`${where}.http must be a path such as /health.`)
162
+ const container = r.port === undefined && ps.length === 1 ? ps[0]!.container : port(r.port, `${where}.port`)
163
+ return { http: p, port: own(container) }
164
+ }
165
+ throw new Error(`${where} must be {tcp: PORT} or {http: /path, port: PORT}.`)
166
+ }
167
+
168
+ const SERVICE_KEYS = new Set(["image", "ports", "env", "secrets", "volumes", "command", "ready", "timeout", "description", "depends_on"])
169
+
170
+ export function serviceSpec(name: string, raw: unknown, root: string, where = `containers.services.${name}`): ServiceSpec {
171
+ if (!SERVICE_NAME.test(name)) throw new Error(`${where}: a service name is lower-case letters, digits and -, starting with a letter.`)
172
+ if (name.startsWith("mock-")) throw new Error(`${where}: names starting with mock- are kept for mock_service's mocks.`)
173
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be a mapping with an image.`)
174
+ const r = raw as Record<string, unknown>
175
+ // Anything else (privileged, network, user, cap_add, devices…) would widen what the container can reach on this
176
+ // machine; ForgeQE runs every container the same restricted way, so such settings are refused, not ignored.
177
+ const unknown = Object.keys(r).filter((k) => !SERVICE_KEYS.has(k))
178
+ if (unknown.length) throw new Error(`${where}: ${unknown.join(", ")} ${unknown.length === 1 ? "is" : "are"} not supported. A service has ${[...SERVICE_KEYS].join(", ")}; ForgeQE never runs a container privileged, on the host network, or with the container runtime's socket.`)
179
+ if (typeof r.image !== "string") throw new Error(`${where}.image is required.`)
180
+ const ps = ports(r.ports, `${where}.ports`)
181
+ const hostPorts = ps.map((p) => p.host)
182
+ if (new Set(hostPorts).size !== hostPorts.length) throw new Error(`${where}.ports uses a port of this machine twice.`)
183
+ const command = r.command === undefined || r.command === null ? [] : r.command
184
+ if (!Array.isArray(command) || !command.every((c) => ["string", "number"].includes(typeof c) && !/[\0\r\n]/.test(String(c)))) {
185
+ throw new Error(`${where}.command must be a list of arguments, not one command string.`)
186
+ }
187
+ const timeout = r.timeout === undefined ? 60 : Number(r.timeout)
188
+ if (!Number.isFinite(timeout) || timeout < 1 || timeout > 600) throw new Error(`${where}.timeout must be 1 to 600 seconds.`)
189
+ return {
190
+ name,
191
+ image: checkImage(r.image, `${where}.image`),
192
+ ports: ps,
193
+ env: env(r.env, `${where}.env`),
194
+ secrets: names(r.secrets, `${where}.secrets`),
195
+ volumes: volumes(r.volumes, `${where}.volumes`, root),
196
+ command: (command as unknown[]).map(String),
197
+ ready: ready(r.ready, `${where}.ready`, ps),
198
+ timeout,
199
+ ...(r.description ? { description: String(r.description).trim() } : {}),
200
+ dependsOn: dependsOn(r.depends_on, where),
201
+ }
202
+ }
203
+
204
+ function dependsOn(raw: unknown, where: string): string[] {
205
+ if (raw === undefined || raw === null) return []
206
+ const list = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.keys(raw) : null
207
+ if (!list || !list.every((n) => typeof n === "string" && SERVICE_NAME.test(n))) throw new Error(`${where}.depends_on must list service names.`)
208
+ return [...new Set(list as string[])]
209
+ }
210
+
211
+ /** The services to start for `picked` (default: all), each after the ones it depends on. */
212
+ export function startOrder(services: Record<string, ServiceSpec>, picked?: string[]): ServiceSpec[] {
213
+ const out: ServiceSpec[] = []
214
+ const state = new Map<string, "visiting" | "done">()
215
+ const visit = (name: string, from?: string) => {
216
+ const s = services[name]
217
+ if (!s) throw new Error(`${from ? `${from} depends on ${name}, which` : name} is not a declared service.`)
218
+ if (state.get(name) === "done") return
219
+ if (state.get(name) === "visiting") throw new Error(`The services depend on each other in a circle (${name}).`)
220
+ state.set(name, "visiting")
221
+ for (const d of s.dependsOn) visit(d, name)
222
+ state.set(name, "done")
223
+ out.push(s)
224
+ }
225
+ for (const n of picked?.length ? picked : Object.keys(services)) visit(n)
226
+ return out
227
+ }
228
+
229
+ export function containersConfig(raw: Record<string, unknown>, base: string): ContainersConfig {
230
+ const runtime = raw.runtime === undefined || raw.runtime === null ? undefined : raw.runtime
231
+ if (runtime !== undefined && runtime !== "docker" && runtime !== "podman") throw new Error("containers.runtime in forgeqe.yaml must be docker or podman.")
232
+ const services: Record<string, ServiceSpec> = {}
233
+ const rawServices = raw.services === undefined || raw.services === null ? {} : raw.services
234
+ if (typeof rawServices !== "object" || Array.isArray(rawServices)) throw new Error("containers.services in forgeqe.yaml must map service names to their settings.")
235
+ for (const [name, s] of Object.entries(rawServices)) services[name] = serviceSpec(name, s, base)
236
+ const notes: string[] = []
237
+ const compose = composeFiles(raw.compose, base)
238
+ for (const file of compose) {
239
+ const read = readCompose(file, base)
240
+ notes.push(...read.notes)
241
+ // forgeqe.yaml's own declaration of a service wins over the compose file's.
242
+ for (const [name, spec] of Object.entries(read.services)) if (!services[name]) services[name] = spec
243
+ }
244
+ for (const s of Object.values(services)) for (const d of s.dependsOn) if (!services[d]) throw new Error(`containers: ${s.name} depends on ${d}, which is not a declared service.`)
245
+ const mock = raw.mock && typeof raw.mock === "object" ? (raw.mock as Record<string, unknown>) : {}
246
+ const mockImage = mock.image === undefined ? WIREMOCK_IMAGE : checkImage(String(mock.image), "containers.mock.image")
247
+ const tests = testsContainer(raw.tests, services)
248
+ return { ...(runtime ? { runtime } : {}), services, mockImage, root: base, compose, ...(tests ? { tests } : {}), notes }
249
+ }
250
+
251
+ function composeFiles(raw: unknown, base: string): string[] {
252
+ if (raw === undefined || raw === null) return []
253
+ const list = Array.isArray(raw) ? raw : [raw]
254
+ return list.map((f) => {
255
+ const file = path.resolve(base, String(f))
256
+ const rel = path.relative(base, file)
257
+ if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`containers.compose: ${f} is outside the project folder.`)
258
+ if (!/\.ya?ml$/i.test(file)) throw new Error(`containers.compose: ${f} is not a compose file (.yaml or .yml).`)
259
+ if (!existsSync(file)) throw new Error(`containers.compose: ${f} does not exist.`)
260
+ return file
261
+ })
262
+ }
263
+
264
+ const TESTS_KEYS = new Set(["image", "services", "env", "secrets"])
265
+
266
+ function testsContainer(raw: unknown, services: Record<string, ServiceSpec>): TestsContainer | undefined {
267
+ if (raw === undefined || raw === null || raw === false) return undefined
268
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("containers.tests must be a mapping with an image.")
269
+ const r = raw as Record<string, unknown>
270
+ const unknown = Object.keys(r).filter((k) => !TESTS_KEYS.has(k))
271
+ if (unknown.length) throw new Error(`containers.tests: ${unknown.join(", ")} not supported (it has ${[...TESTS_KEYS].join(", ")}).`)
272
+ if (typeof r.image !== "string") throw new Error("containers.tests.image is required.")
273
+ const svc = dependsOn(r.services, "containers.tests").map((n) => {
274
+ if (!services[n]) throw new Error(`containers.tests.services: ${n} is not a declared service.`)
275
+ return n
276
+ })
277
+ return { image: checkImage(r.image, "containers.tests.image"), services: svc, env: env(r.env, "containers.tests.env"), secrets: names(r.secrets, "containers.tests.secrets") }
278
+ }
279
+
280
+ /** The config's containers: settings, or none declared (configs built in code, without forgeqe.yaml). */
281
+ export function containersOf(cfg: { containers?: ContainersConfig }): ContainersConfig {
282
+ return cfg.containers || { services: {}, mockImage: WIREMOCK_IMAGE, root: process.cwd(), compose: [], notes: [] }
283
+ }
@@ -0,0 +1,156 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { NetworkCall } from "../api/traffic.ts"
5
+ import type { ContractSet, JsonSchema } from "../api/types.ts"
6
+ import { SERVICE_NAME, type ServiceSpec } from "./config.ts"
7
+
8
+ /**
9
+ * Mock services (docs/CONTAINERS.md): WireMock stubs from the API calls a browser run recorded (the answers the app
10
+ * really got) or from an API description (answers made from its examples and schemas). The stubs are JSON files in
11
+ * the mock's folder, mounted read-only into a WireMock container on 127.0.0.1.
12
+ */
13
+
14
+ export type Stub = {
15
+ request: { method: string; urlPath?: string; urlPathPattern?: string; queryParameters?: Record<string, { equalTo: string }> }
16
+ response: { status: number; headers?: Record<string, string>; body?: string }
17
+ metadata: { forgeqe: { from: "run" | "spec"; id: string } }
18
+ }
19
+
20
+ export type MockBuild = { stubs: Stub[]; skipped: string[] }
21
+
22
+ const MAX_STUBS = 500
23
+ const MAX_BODY = 1_000_000
24
+
25
+ /** Stubs from a run's calls: one per method, path and query, the last answer seen; the app's own API only. */
26
+ export function stubsFromCalls(calls: NetworkCall[], opts: { thirdParty?: boolean } = {}): MockBuild {
27
+ const skipped: string[] = []
28
+ const byKey = new Map<string, Stub>()
29
+ for (const c of calls) {
30
+ let u: URL
31
+ try {
32
+ u = new URL(c.url)
33
+ } catch {
34
+ continue
35
+ }
36
+ const label = `${c.method} ${u.pathname}`
37
+ if (c.third_party && !opts.thirdParty) {
38
+ skipped.push(`${label} on ${u.host}: another site's call`)
39
+ continue
40
+ }
41
+ if (c.failed || !c.response?.status) {
42
+ skipped.push(`${label}: the call failed (${c.failed || "no answer"})`)
43
+ continue
44
+ }
45
+ if (c.response.truncated) skipped.push(`${label}: the recorded answer was cut, so the mock answers with the part recorded`)
46
+ const query: Record<string, { equalTo: string }> = {}
47
+ for (const [k, v] of u.searchParams) query[k] = { equalTo: v }
48
+ const key = `${c.method.toUpperCase()} ${u.pathname}?${[...u.searchParams].map(([k, v]) => `${k}=${v}`).sort().join("&")}`
49
+ const mime = c.response.mime || c.response.headers?.["content-type"] || c.response.headers?.["Content-Type"]
50
+ byKey.set(key, {
51
+ request: { method: c.method.toUpperCase(), urlPath: u.pathname, ...(Object.keys(query).length ? { queryParameters: query } : {}) },
52
+ response: { status: c.response.status, ...(mime ? { headers: { "Content-Type": mime } } : {}), ...(c.response.body !== undefined ? { body: c.response.body.slice(0, MAX_BODY) } : {}) },
53
+ metadata: { forgeqe: { from: "run", id: label } },
54
+ })
55
+ }
56
+ const stubs = [...byKey.values()]
57
+ if (stubs.length > MAX_STUBS) skipped.push(`${stubs.length - MAX_STUBS} more operations: a mock keeps at most ${MAX_STUBS}`)
58
+ return { stubs: stubs.slice(0, MAX_STUBS), skipped: [...new Set(skipped)] }
59
+ }
60
+
61
+ /** A value that fits a JSON schema: its example, else one made from its type (for answers from a description). */
62
+ export function sampleOf(schema: JsonSchema | undefined, depth = 0): unknown {
63
+ if (!schema || depth > 6) return null
64
+ if (schema.example !== undefined) return schema.example
65
+ if (schema.enum?.length) return schema.enum[0]
66
+ const alt = schema.oneOf?.[0] || schema.anyOf?.[0]
67
+ if (alt) return sampleOf(alt, depth + 1)
68
+ if (schema.allOf?.length) {
69
+ const parts = schema.allOf.map((s) => sampleOf(s, depth + 1))
70
+ return parts.every((p) => p && typeof p === "object" && !Array.isArray(p)) ? Object.assign({}, ...parts) : parts[0]
71
+ }
72
+ const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type
73
+ switch (type || (schema.properties ? "object" : schema.items ? "array" : undefined)) {
74
+ case "object": {
75
+ const out: Record<string, unknown> = {}
76
+ for (const [k, v] of Object.entries(schema.properties || {}).slice(0, 30)) out[k] = sampleOf(v, depth + 1)
77
+ return out
78
+ }
79
+ case "array":
80
+ return schema.items ? [sampleOf(schema.items, depth + 1)] : []
81
+ case "integer":
82
+ return 1
83
+ case "number":
84
+ return 1.5
85
+ case "boolean":
86
+ return true
87
+ case "string":
88
+ return schema.format === "date-time" ? "2026-01-01T00:00:00Z" : schema.format === "date" ? "2026-01-01" : schema.format === "uuid" ? "00000000-0000-4000-8000-000000000000" : schema.format === "email" ? "user@example.com" : schema.format === "uri" ? "https://example.com/" : "string"
89
+ default:
90
+ return null
91
+ }
92
+ }
93
+
94
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
95
+
96
+ /** A REST path template as a WireMock path pattern: each {param} matches one path segment. */
97
+ export function pathPattern(template: string): string {
98
+ return `^${template.split(/\{[^}/]+\}/).map(escapeRe).join("[^/]+")}$`
99
+ }
100
+
101
+ /** Stubs from a contract set made from a description: REST operations answer with their example or schema. */
102
+ export function stubsFromContracts(set: ContractSet): MockBuild {
103
+ const skipped: string[] = []
104
+ const stubs: Stub[] = []
105
+ for (const c of set.contracts) {
106
+ if (c.protocol !== "rest") {
107
+ skipped.push(`${c.id}: ${c.protocol} is not mocked (REST only)`)
108
+ continue
109
+ }
110
+ const basePath = set.baseUrl ? new URL(set.baseUrl, "http://x").pathname.replace(/\/$/, "") : ""
111
+ const template = `${basePath}${c.path.startsWith("/") ? "" : "/"}${c.path}`
112
+ const templated = /\{[^}]+\}/.test(template)
113
+ const body = c.expect.schema ? JSON.stringify(sampleOf(c.expect.schema)) : undefined
114
+ stubs.push({
115
+ request: { method: c.method.toUpperCase(), ...(templated ? { urlPathPattern: pathPattern(template) } : { urlPath: template }) },
116
+ response: { status: c.expect.status || 200, ...(body !== undefined ? { headers: { "Content-Type": c.expect.contentType || "application/json" }, body } : {}) },
117
+ metadata: { forgeqe: { from: "spec", id: c.id } },
118
+ })
119
+ }
120
+ return { stubs: stubs.slice(0, MAX_STUBS), skipped }
121
+ }
122
+
123
+ export function checkMockName(name: string): string {
124
+ const n = name.trim().toLowerCase()
125
+ if (!SERVICE_NAME.test(n) || n.length > 30) throw new Error(`A mock's name is lower-case letters, digits and -, starting with a letter (at most 30): got "${name}".`)
126
+ return n
127
+ }
128
+
129
+ /** Write the stubs as WireMock mapping files in `<dir>/mappings`, replacing what an earlier build wrote. */
130
+ export function writeStubs(dir: string, stubs: Stub[]): string {
131
+ const mappings = path.join(dir, "mappings")
132
+ rmSync(mappings, { recursive: true, force: true })
133
+ mkdirSync(mappings, { recursive: true })
134
+ stubs.forEach((s, i) => writeFileSync(path.join(mappings, `${String(i + 1).padStart(3, "0")}.json`), `${JSON.stringify(s, null, 2)}\n`))
135
+ return mappings
136
+ }
137
+
138
+ /** The WireMock service for a mock: its stubs mounted read-only, on a port of this machine. */
139
+ export function mockSpec(name: string, image: string, hostPort: number): ServiceSpec {
140
+ return {
141
+ name: `mock-${name}`,
142
+ image,
143
+ ports: [{ host: hostPort, container: 8080 }],
144
+ env: {},
145
+ secrets: [],
146
+ volumes: [],
147
+ // Cross-origin answers, so a web app on another local port can call the mock.
148
+ command: ["--enable-stub-cors"],
149
+ ready: { http: "/__admin/mappings", port: 8080 },
150
+ timeout: 90,
151
+ description: `Mock ${name} (WireMock)`,
152
+ dependsOn: [],
153
+ }
154
+ }
155
+
156
+ export const WIREMOCK_MAPPINGS = "/home/wiremock/mappings"