| 2196 | * injected map for testing; defaults to the real SERVICES map. |
| 2197 | */ |
| 2198 | export function assertClosureValid( |
| 2199 | requested: string[] = Object.keys(SERVICES), |
| 2200 | services: Record<string, ClosureEntry & { dispatchName?: string }> = SERVICES, |
| 2201 | ): void { |
| 2202 | const problems: string[] = []; |
| 2203 | |
| 2204 | // Dangling runtimeDeps / serviceRefs targets. |
| 2205 | for (const [key, entry] of Object.entries(services)) { |
| 2206 | for (const dep of entry.runtimeDeps ?? []) { |
| 2207 | if (!Object.hasOwn(services, dep)) { |
| 2208 | problems.push( |
| 2209 | ` - runtimeDeps "${dep}" on "${key}" is not an SSOT key in SERVICES`, |
| 2210 | ); |
| 2211 | } |
| 2212 | } |
| 2213 | const refs = (entry as { serviceRefs?: { target: string }[] }).serviceRefs; |
| 2214 | for (const ref of refs ?? []) { |
| 2215 | if (!Object.hasOwn(services, ref.target)) { |
| 2216 | problems.push( |
| 2217 | ` - serviceRefs target "${ref.target}" on "${key}" is not an SSOT key in SERVICES`, |
| 2218 | ); |
| 2219 | } |
| 2220 | } |
| 2221 | } |
| 2222 | |
| 2223 | // The SSOT must declare at least one Tier-1 verification service. |
| 2224 | const hasTier1 = Object.values(services).some((e) => tierOf(e) === 1); |
| 2225 | if (!hasTier1) { |
| 2226 | problems.push( |
| 2227 | ` - no Tier-1 verification service (promoteTier:1) in SERVICES — an equivalence-gated promote has no control plane to re-sweep on`, |
| 2228 | ); |
| 2229 | } |
| 2230 | |
| 2231 | // The computed closure must be non-empty. Skip this clause when the |
| 2232 | // dangling-dep / missing-Tier-1 problems above already fired, since |
| 2233 | // computePromoteClosure would throw on an unknown requested name before we |
| 2234 | // could surface the curated message. |
| 2235 | if (problems.length === 0) { |
| 2236 | let plan: ClosurePlan | undefined; |
| 2237 | try { |
| 2238 | plan = computePromoteClosure(requested, services); |
| 2239 | } catch (err) { |
| 2240 | problems.push(` - computePromoteClosure threw: ${String(err)}`); |
| 2241 | } |
| 2242 | if (plan !== undefined && plan.services.length === 0) { |
| 2243 | problems.push( |
| 2244 | ` - the computed promote closure is EMPTY — refusing to silently promote nothing`, |
| 2245 | ); |
| 2246 | } |
| 2247 | } |
| 2248 | |
| 2249 | if (problems.length > 0) { |
| 2250 | throw new Error( |
| 2251 | `railway-envs promote-closure invariant violated:\n${problems.join( |
| 2252 | "\n", |
| 2253 | )}\n` + |
| 2254 | `Fix: every runtimeDeps / serviceRefs target must be an existing ` + |
| 2255 | `SSOT key, the SSOT must declare at least one Tier-1 (promoteTier:1) ` + |