From c8717a51e58b6586119a8e6691bfccfc593f3398 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:14:30 +0200 Subject: [PATCH 01/52] fix(canvas): correct snap target + drag-snap stickiness for Custom Domain rows Two bugs in connection drag: 1. dragCompatibility positions used getPortAnchorPoint (schema's side-distribution math) instead of getSocketCanvasPosition, so the snap target Y on Custom Domain row ports drifted progressively from the visible dot (~50px on row 0, ~0px on the last row). 2. snap was computed from currentPoint, which itself is set to the snapped port's position. Distance-to-self stayed at 0 so the snap locked onto the first port that ever won. Added cursorPoint to DrawingConnectionState and compute snap from it; currentPoint remains the visible (snapped or cursor) wire endpoint. --- package.json | 2 +- .../canvas/hooks/use-connection-drawing.ts | 367 ++++++++++++++++-- 2 files changed, 334 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 302cb200..a072f0bf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.720", + "version": "0.1.721", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/ui/src/features/canvas/hooks/use-connection-drawing.ts b/packages/ui/src/features/canvas/hooks/use-connection-drawing.ts index e48a025b..cc427261 100644 --- a/packages/ui/src/features/canvas/hooks/use-connection-drawing.ts +++ b/packages/ui/src/features/canvas/hooks/use-connection-drawing.ts @@ -73,10 +73,20 @@ * rf-canv-27 (RISK #3, RISK #5). */ +import { + chooseBestTargetPort, + findMatchingPorts, + findPort, + getBlockKind, + getPortsForNode, + ROLE_CATEGORY, + type PortDef, +} from '@ice/types'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { t } from '../../../i18n'; import { addEdgeToCard, type Card, type CardEdge } from '../../../store/slices/cards-slice'; +import { getSocketCanvasPosition } from '../components/path/socket-position'; import { buildRejectionMessage } from '../utils/connection-rejection'; import { inferConnectionMeta, @@ -88,6 +98,7 @@ import { import { findExistingLogSource, findExistingSpecialConnection } from '../utils/connection-special-rules'; import type { AppDispatch } from '../../../store'; import type { ConnectionRejection } from '../components/connection-rejection-overlay'; +import type { ConnectionDragInfo } from '../components/nodes/_shared/connection-drag-context'; import type { CanvasNode } from '../components/types'; /** Drag descriptor stored in state while a port drag is in progress. */ @@ -95,8 +106,25 @@ export interface DrawingConnectionState { sourceId: string; /** Route id when the drag started from a Network.CustomDomain row port. */ sourceRouteId?: string; + /** Typed-socket id when the drag started from a typed socket dot. */ + sourceSocketId?: string; sourcePoint: { x: number; y: number }; + /** + * Visible wire endpoint — equals `cursorPoint` when nothing is in + * snap range, otherwise the snapped port's position so the line + * visually locks on. + */ currentPoint: { x: number; y: number }; + /** + * Real cursor position in canvas-space. Tracked separately from + * `currentPoint` so the snap search runs against where the user + * actually is — not where the wire is parked. Without this split the + * snap is sticky: once a port wins, `currentPoint` becomes that + * port's position, distance-to-self is 0, and no neighbour can + * displace it even when the cursor has drifted closer. Invisible + * for widely spaced sockets, fatal for Custom Domain rows ~40px apart. + */ + cursorPoint: { x: number; y: number }; } export interface UseConnectionDrawingArgs { @@ -117,6 +145,13 @@ export interface UseConnectionDrawingResult { * node is `'valid-target' | 'invalid-target'` based on `canConnect`. */ connectionDragTargets: Map | null; + /** + * Per-port compatibility info + snap-target for the active drag. + * Consumed by `ConnectionDragProvider` so TypedSockets can highlight + * matching ports across the canvas and snap the wire endpoint. Null + * while no drag is active. + */ + connectionDragInfo: ConnectionDragInfo | null; /** * Floating rejection tooltip — set when a drop is rejected, cleared * after `REJECTION_TIMEOUT_MS` or when a new drag starts. The canvas @@ -143,6 +178,9 @@ export interface UseConnectionDrawingResult { /** How long the rejection tooltip stays on-screen after a failed drop. */ const REJECTION_TIMEOUT_MS = 2500; +/** Cursor-to-port distance (canvas-space px) within which the wire snaps to the port. */ +const SNAP_RADIUS = 60; + export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnectionDrawingResult { const { effectiveNodes, card, screenToCanvas } = args; const dispatch = useDispatch(); @@ -172,6 +210,90 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect useEffect(() => () => clearRejectionTimer(), [clearRejectionTimer]); + /** + * Per-port compatibility map for the active drag — for every node, + * the set of port ids whose role accepts the dragged source port. + * Also stores the canvas-space position of each compatible port so the + * magnetic snap calculation in `handleConnectionMove` doesn't have to + * re-walk the schemas every frame. + */ + const dragCompatibility = useMemo<{ + sourcePort: PortDef | undefined; + compatibleByNode: Map>; + positions: Map; + } | null>(() => { + if (!drawingConnection || !drawingConnection.sourceSocketId) return null; + const sourceNode = effectiveNodes.find((n) => n.id === drawingConnection.sourceId); + if (!sourceNode) return null; + const sourcePort = findPort( + { id: sourceNode.id, type: sourceNode.type, data: sourceNode.data }, + drawingConnection.sourceSocketId, + ); + if (!sourcePort) return null; + + const srcKind = getBlockKind((sourceNode.data?.iceType as string) || ''); + const compatibleByNode = new Map>(); + const positions = new Map(); + for (const node of effectiveNodes) { + if (node.id === drawingConnection.sourceId) continue; + const ports = getPortsForNode({ id: node.id, type: node.type, data: node.data }); + if (ports.length === 0) continue; + const tgtKind = getBlockKind((node.data?.iceType as string) || ''); + const matching = findMatchingPorts(sourcePort, ports, srcKind, tgtKind); + if (matching.length === 0) continue; + const ids = new Set(); + for (const port of matching) { + ids.add(port.id); + // `getSocketCanvasPosition` honours bespoke renderer overrides + // (e.g. Network.CustomDomain's per-row Y) so the snap target + // matches the visible dot pixel-for-pixel. Using the raw + // `getPortAnchorPoint` here drifts on multi-row blocks because + // the schema's side-distribution math doesn't predict where the + // hand-laid-out renderer actually draws the dot. + const pt = getSocketCanvasPosition(node, port.id); + if (!pt) continue; + positions.set(`${node.id}::${port.id}`, { nodeId: node.id, portId: port.id, x: pt.x, y: pt.y }); + } + compatibleByNode.set(node.id, ids); + } + return { sourcePort, compatibleByNode, positions }; + }, [drawingConnection, effectiveNodes]); + + /** + * Magnetic snap target — the compatible port closest to the cursor + * within `SNAP_RADIUS`. Drives both the wire-endpoint pull (in + * `handleConnectionMove`) and the snapped-port glow (via the drag + * context). Recomputed cheaply on every `cursorPoint` change. + * + * MUST use `cursorPoint`, not `currentPoint`. `currentPoint` is the + * already-snapped endpoint, so using it would keep distance-to-self + * at 0 and lock the snap onto the first port that ever won — fatal + * for closely-spaced sockets (e.g. Custom Domain rows). + */ + const snap = useMemo<{ nodeId: string; portId: string; x: number; y: number } | null>(() => { + if (!drawingConnection || !dragCompatibility) return null; + const { x: cx, y: cy } = drawingConnection.cursorPoint; + let best: { nodeId: string; portId: string; x: number; y: number; d: number } | null = null; + for (const p of dragCompatibility.positions.values()) { + const dx = p.x - cx; + const dy = p.y - cy; + const d = Math.sqrt(dx * dx + dy * dy); + if (d > SNAP_RADIUS) continue; + if (!best || d < best.d) best = { nodeId: p.nodeId, portId: p.portId, x: p.x, y: p.y, d }; + } + return best ? { nodeId: best.nodeId, portId: best.portId, x: best.x, y: best.y } : null; + }, [drawingConnection, dragCompatibility]); + + const connectionDragInfo: ConnectionDragInfo | null = useMemo(() => { + if (!drawingConnection) return null; + return { + sourceNodeId: drawingConnection.sourceId, + sourcePortId: drawingConnection.sourceSocketId, + compatibleByNode: dragCompatibility?.compatibleByNode ?? new Map(), + snap: snap ? { nodeId: snap.nodeId, portId: snap.portId } : null, + }; + }, [drawingConnection, dragCompatibility, snap]); + /** Compute valid/invalid target states for all nodes during connection drag */ const connectionDragTargets = useMemo(() => { if (!drawingConnection) return null; @@ -180,18 +302,44 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect const srcIceType = (sourceNode.data?.iceType as string) || ''; const srcNodeType = sourceNode.type; + // If the drag started from a typed port, resolve it so we can do + // role matching per target. A drag from the block body (no port id) + // skips role matching and falls back to category-level canConnect. + const sourcePort: PortDef | undefined = drawingConnection.sourceSocketId + ? findPort({ id: sourceNode.id, type: sourceNode.type, data: sourceNode.data }, drawingConnection.sourceSocketId) + : undefined; + const targets = new Map(); targets.set(drawingConnection.sourceId, 'source'); + const srcKindForTargets = getBlockKind(srcIceType); + for (const node of effectiveNodes) { if (node.id === drawingConnection.sourceId) continue; const tgtIceType = (node.data?.iceType as string) || ''; - const isValid = canConnect(srcIceType, tgtIceType, srcNodeType, node.type, { + // When a typed source port is in play, role + peer-kind matching + // is the authoritative gate. The 4-category `canConnect` carries + // legacy contextual rules (e.g. "top-level Custom Domain can't + // route into a VPC") that pre-date the typed-socket model and + // sometimes block legitimate wirings the user explicitly drew + // socket-to-socket. Trusting role-matching here keeps the + // user's drag deterministic. + if (sourcePort) { + const tgtPorts = getPortsForNode({ id: node.id, type: node.type, data: node.data }); + const tgtKind = getBlockKind(tgtIceType); + const matching = findMatchingPorts(sourcePort, tgtPorts, srcKindForTargets, tgtKind); + targets.set(node.id, matching.length > 0 ? 'valid-target' : 'invalid-target'); + continue; + } + // Legacy body drag (no typed source port) — fall back to the + // category-level legality gate so blind drops still respect the + // old rules. + const categoryAllowed = canConnect(srcIceType, tgtIceType, srcNodeType, node.type, { srcNode: sourceNode, tgtNode: node, allNodes: effectiveNodes, }); - targets.set(node.id, isValid ? 'valid-target' : 'invalid-target'); + targets.set(node.id, categoryAllowed ? 'valid-target' : 'invalid-target'); } return targets; }, [drawingConnection, effectiveNodes]); @@ -214,7 +362,41 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect // the resulting edge gets no routeId. const routeId = target.getAttribute('data-route-id') || undefined; - const canvasPos = screenToCanvas(e.clientX, e.clientY); + // Typed-socket ports carry `data-socket-id`. Empty string means + // an LOD-degraded fallback dot (no specific socket bound) — leave + // sourceSocketId undefined in that case so the edge writes no + // socket id and the renderer falls back to chooseSides. + const socketIdAttr = target.getAttribute('data-socket-id') || ''; + const sourceSocketId = socketIdAttr.length > 0 ? socketIdAttr : undefined; + + const cursorPos = screenToCanvas(e.clientX, e.clientY); + + // Anchor the wire's visible start at the actual socket dot — not + // the cursor's click position. Read the dot's center from the DOM + // via `getBoundingClientRect()` and project to canvas-space. + // + // Reading from the DOM (not the port schema) is what lets us + // support bespoke renderers like Custom Domain, whose per-route + // row ports live at hand-computed Y coordinates that the + // schema's standard side-distribution math doesn't predict. Any + // socket dot the user CLICKED has a real DOM rect — that's the + // source of truth, period. + let sourcePoint = cursorPos; + // `getBoundingClientRect` may be missing under test mocks — guard with typeof. + const dotRect = typeof target.getBoundingClientRect === 'function' ? target.getBoundingClientRect() : null; + if (dotRect && dotRect.width > 0 && dotRect.height > 0) { + sourcePoint = screenToCanvas(dotRect.left + dotRect.width / 2, dotRect.top + dotRect.height / 2); + } else if (sourceSocketId) { + // Fallback when the element has no measured rect (rare — e.g. + // off-screen). Route through `getSocketCanvasPosition` so + // bespoke renderers (Custom Domain row ports) anchor to their + // hand-laid-out Y instead of the schema's side-distribution. + const node = effectiveNodes.find((n) => n.id === nodeId); + if (node) { + const pt = getSocketCanvasPosition(node, sourceSocketId); + if (pt) sourcePoint = pt; + } + } // A fresh drag invalidates any prior rejection tooltip — drop it // immediately so the new gesture isn't visually overlapped by the @@ -225,19 +407,40 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect setDrawingConnection({ sourceId: nodeId, sourceRouteId: routeId, - sourcePoint: canvasPos, - currentPoint: canvasPos, + sourceSocketId, + sourcePoint, + currentPoint: cursorPos, + cursorPoint: cursorPos, }); }, - [screenToCanvas, clearRejectionTimer], + [screenToCanvas, clearRejectionTimer, effectiveNodes], ); - /** Track mouse during connection drawing */ + // Magnet-snap reference — the snap target derived from the latest + // `currentPoint`. Kept as a ref so `handleConnectionMove` can magnet + // the visible cursor toward the snap point without re-rendering twice. + const snapRef = useRef(snap); + snapRef.current = snap; + + /** Track mouse during connection drawing — magnets to compatible ports within SNAP_RADIUS. */ const handleConnectionMove = useCallback( (e: React.MouseEvent) => { if (!drawingConnection) return; const canvasPos = screenToCanvas(e.clientX, e.clientY); - setDrawingConnection((prev) => (prev ? { ...prev, currentPoint: canvasPos } : null)); + // `cursorPoint` is the source of truth for the snap search (the + // `snap` useMemo reads it). `currentPoint` is the visible wire + // endpoint — equal to the cursor unless a snap target pulls it + // onto a compatible port. We re-read the snap through the ref + // because it's derived from the previous render's cursorPoint. + setDrawingConnection((prev) => { + if (!prev) return null; + const snapped = snapRef.current; + const dx = snapped ? snapped.x - canvasPos.x : 0; + const dy = snapped ? snapped.y - canvasPos.y : 0; + const distance = snapped ? Math.sqrt(dx * dx + dy * dy) : Infinity; + const endpoint = snapped && distance <= SNAP_RADIUS ? { x: snapped.x, y: snapped.y } : canvasPos; + return { ...prev, cursorPoint: canvasPos, currentPoint: endpoint }; + }); }, [drawingConnection, screenToCanvas], ); @@ -249,35 +452,38 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect const canvasPos = screenToCanvas(e.clientX, e.clientY); - // Find node at drop position (excluding source). + // ── Target node lookup ───────────────────────────────────────── // - // Pick the SMALLEST containing node, not the first hit. The - // canvas allows nesting (Container inside Subnet inside VPC), and - // the drop position can be inside multiple stacked rectangles. - // First-hit-wins fails when the parent group happens to be later - // in the node array than its children — which is order-dependent - // on how the user dragged things around. The smallest area is - // always the most-specific (deepest) target, which is what the - // user means by "drop on this block." + // When the magnet has snapped the wire endpoint onto a compatible + // port, that node IS the target. The user saw a green snapped + // halo on a specific dot — that's the promise; release here = + // wire goes there, regardless of whether the cursor itself + // strayed a few pixels outside the block's bounds. // - // NOTE (rf-canv-6): kept inline because no predicate filters anything - // here — connection drops target ANY node, not just containers. Folding - // through `findSmallestContainerHit(... , () => true, ...)` would bury - // the no-predicate semantics. Flagged for follow-up consolidation. + // Without a snap (legacy body drop), fall back to the + // smallest-containing-node heuristic so nested layouts still + // pick the deepest (most-specific) target. First-hit-wins + // would lose to ordering. let targetNode: CanvasNode | null = null; - let targetArea = Number.POSITIVE_INFINITY; - for (const node of effectiveNodes) { - if (node.id === drawingConnection.sourceId) continue; - if ( - canvasPos.x >= node.x && - canvasPos.x <= node.x + node.width && - canvasPos.y >= node.y && - canvasPos.y <= node.y + node.height - ) { - const area = node.width * node.height; - if (area < targetArea) { - targetNode = node; - targetArea = area; + const snappedTarget = snapRef.current; + if (snappedTarget) { + targetNode = effectiveNodes.find((n) => n.id === snappedTarget.nodeId) ?? null; + } + if (!targetNode) { + let targetArea = Number.POSITIVE_INFINITY; + for (const node of effectiveNodes) { + if (node.id === drawingConnection.sourceId) continue; + if ( + canvasPos.x >= node.x && + canvasPos.x <= node.x + node.width && + canvasPos.y >= node.y && + canvasPos.y <= node.y + node.height + ) { + const area = node.width * node.height; + if (area < targetArea) { + targetNode = node; + targetArea = area; + } } } } @@ -287,8 +493,41 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect const srcIceTypeCheck = (sourceNode?.data?.iceType as string) || ''; const tgtIceTypeCheck = (targetNode.data?.iceType as string) || ''; + // ── Role gate: if the drag started from a typed port and the + // target has no matching IN port, silently cancel — the + // drag-context already dimmed every incompatible block so + // the user knew. No tooltip for this case (verbosity that + // repeats the visual cue). + // + // When role-matching DOES find a pair, that's authoritative — + // we skip the legacy `canConnect` cascade below. Otherwise + // its contextual rules (e.g. top-level Custom Domain → VPC + // blocked) reject legitimate socket-to-socket wires the user + // explicitly drew. + let typedRoleGatePassed = false; + if (drawingConnection.sourceSocketId && sourceNode) { + const srcPort = findPort( + { id: sourceNode.id, type: sourceNode.type, data: sourceNode.data }, + drawingConnection.sourceSocketId, + ); + if (srcPort) { + const tgtPorts = getPortsForNode({ id: targetNode.id, type: targetNode.type, data: targetNode.data }); + const srcKind = getBlockKind(srcIceTypeCheck); + const tgtKind = getBlockKind(tgtIceTypeCheck); + const matching = findMatchingPorts(srcPort, tgtPorts, srcKind, tgtKind); + if (matching.length === 0) { + setDrawingConnection(null); + return; + } + typedRoleGatePassed = true; + } + } + // ── Block invalid connections based on CONNECTION_RULES ── + // Only fires for legacy body drops (no typed source port). + // Typed-socket drops trust the role gate above. if ( + !typedRoleGatePassed && !canConnect(srcIceTypeCheck, tgtIceTypeCheck, sourceNode?.type, targetNode.type, { srcNode: sourceNode, tgtNode: targetNode, @@ -419,6 +658,63 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect // canonical orientation per the connection rules). const sourceRouteId = drawingConnection.sourceRouteId; + // Persist typed socket ids on the edge so the renderer can pick + // the right magnetic anchor side and detect dangling edges when + // a property change removes the socket later. If the drag + // started from a generic block-body click (no `data-socket-id`), + // pick the best socket on the source matching the inferred + // category + outgoing direction; same for the target picking an + // incoming socket. When no match exists, leave the field undefined + // and the renderer falls back to chooseSides. + const sourceForSocketLookup = meta.flip ? targetNode : sourceNode; + const targetForSocketLookup = meta.flip ? sourceNode : targetNode; + const draggedSocketId = drawingConnection.sourceSocketId; + + function pickByCategory( + n: CanvasNode | undefined, + direction: 'in' | 'out', + category: typeof meta.category, + ): string | undefined { + if (!n) return undefined; + const list = getPortsForNode({ id: n.id, type: n.type, data: n.data }); + return list.find((p) => p.direction === direction && ROLE_CATEGORY[p.role] === category)?.id; + } + + // When we know the dragged port, the partner's best socket is + // the one matching its role — not just any port of the right + // category. This is what makes the wire deterministic. + const draggedPort: PortDef | undefined = + draggedSocketId && sourceNode + ? findPort({ id: sourceNode.id, type: sourceNode.type, data: sourceNode.data }, draggedSocketId) + : undefined; + + let pickedPartner: string | undefined; + // Magnet snap: when the user released within snap radius of a + // compatible port, that port wins over the chooseBestTargetPort + // fallback — the visible snap glow already promised it. + const activeSnap = snapRef.current; + if (activeSnap && activeSnap.nodeId === targetNode.id) { + pickedPartner = activeSnap.portId; + } else if (draggedPort && targetNode && sourceNode) { + const partnerPorts = getPortsForNode({ + id: targetNode.id, + type: targetNode.type, + data: targetNode.data, + }); + const srcKind = getBlockKind((sourceNode.data?.iceType as string) || ''); + const tgtKind = getBlockKind((targetNode.data?.iceType as string) || ''); + pickedPartner = chooseBestTargetPort(draggedPort, partnerPorts, srcKind, tgtKind)?.id; + } + + const sourceSocketResolved = + (!meta.flip && draggedSocketId) || + (meta.flip ? pickedPartner : undefined) || + pickByCategory(sourceForSocketLookup, 'out', meta.category); + const targetSocketResolved = + (meta.flip && draggedSocketId) || + (!meta.flip ? pickedPartner : undefined) || + pickByCategory(targetForSocketLookup, 'in', meta.category); + const edgeId = `edge-${Date.now()}`; const newEdge: CardEdge = { id: edgeId, @@ -433,6 +729,8 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect ...(meta.lineStyle !== 'solid' && { lineStyle: meta.lineStyle }), ...(meta.color && { color: meta.color }), ...(sourceRouteId && { routeId: sourceRouteId }), + ...(sourceSocketResolved && { sourceSocket: sourceSocketResolved }), + ...(targetSocketResolved && { targetSocket: targetSocketResolved }), }, }; dispatch(addEdgeToCard(newEdge)); @@ -457,6 +755,7 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect return { drawingConnection, connectionDragTargets, + connectionDragInfo, rejection, handleConnectionPortDown, handleConnectionMove, From 77c25680a30d8e4bef6f40c1811dec9c8a205286 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:15:10 +0200 Subject: [PATCH 02/52] =?UTF-8?q?refactor(secret-store):=20schema-driven?= =?UTF-8?q?=201=E2=86=92N=20deploy=20expansion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properties + deploy model rebuilt around the canonical schema: - Properties: name → "Store name"; secrets list → new `secret_bindings` field with two inputs per row (env-var key ← upstream ref); auto_rotate dropped (was inert + wrong-scoped). Bindings explained: the block does NOT hold secret values — values live in the cloud secret manager. - Schema: add optional `iceType` and `deployExpansion` to HighLevelResource. Secret Store declares `deployExpansion: { partitionBy: 'bindings', nameFrom: { field: 'ref', fallback: 'key' }, labelFrom: 'key', tagPerEntry: ... }`. - Lookup: getHighLevelResourceByIceType helper with a cached index. - Generic pass: new deploy/passes/deploy-expansion.ts emits one cloud resource per partition entry, dedup'd within/across blocks, forwarding provider-shaped properties verbatim. Knows nothing about secrets. - Translator: the previous `if (iceType === 'Security.Secret')` branch is replaced with `if (schemaResource?.deployExpansion)` — cardinal rule, no iceType hardcoded in cross-cutting code. Adding AWS Secrets Manager or Azure Key Vault requires only an extractor + handler for the provider's resource type. Adding a new expanding block requires only a `deployExpansion` declaration on its schema entry. --- package.json | 2 +- .../src/__tests__/card-translator.test.ts | 82 +++++++- packages/core/src/deploy/card-translator.ts | 56 ++++++ .../extractors/__tests__/ancillary.test.ts | 14 ++ .../core/src/deploy/extractors/ancillary.ts | 7 + .../src/deploy/passes/deploy-expansion.ts | 175 +++++++++++++++++ .../providers/gcp/handlers/secret-manager.ts | 12 ++ .../src/resources/high-level-resources.ts | 2 + .../categories/security.ts | 41 ++-- .../resources/high-level-resources/helpers.ts | 27 +++ .../resources/high-level-resources/types.ts | 63 ++++++- packages/core/src/resources/index.ts | 2 + .../secret-store/__tests__/index.test.tsx | 12 +- .../components/nodes/secret-store/index.tsx | 5 +- .../properties/components/fields/index.tsx | 176 +++++++++++++++++- .../fields/render-property-field.tsx | 56 +++++- packages/ui/src/i18n/en.json | 8 +- packages/ui/src/i18n/zh.json | 8 +- 18 files changed, 711 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/deploy/passes/deploy-expansion.ts diff --git a/package.json b/package.json index a072f0bf..768f71eb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.721", + "version": "0.1.722", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/__tests__/card-translator.test.ts b/packages/core/src/__tests__/card-translator.test.ts index edeb6077..c326a100 100644 --- a/packages/core/src/__tests__/card-translator.test.ts +++ b/packages/core/src/__tests__/card-translator.test.ts @@ -25,6 +25,9 @@ describe('Card Translator Type Maps', () => { it('should map all standard GCP iceTypes', async () => { const mod = await import('../deploy/card-translator'); + // Security.Secret is intentionally absent here — it now expands per + // binding, so a block with no bindings produces zero deployables and + // a warning. Covered separately below. const gcpTypes = [ 'Compute.StaticSite', 'Compute.Container', @@ -32,7 +35,6 @@ describe('Card Translator Type Maps', () => { 'Database.PostgreSQL', 'Storage.Bucket', 'Messaging.CloudPubSub', - 'Security.Secret', 'AI.VectorDB', ]; @@ -46,6 +48,84 @@ describe('Card Translator Type Maps', () => { expect(result.deployable_count).toBeGreaterThan(0); } }); + + it('expands a Security.Secret block into one resource per unique binding', async () => { + const mod = await import('../deploy/card-translator'); + const result = mod.translate_card_to_graph({ + nodes: [ + { + id: 'sec1', + type: 'resource', + data: { + iceType: 'Security.Secret', + label: 'app-secrets', + secrets: [ + { key: 'STRIPE_API_KEY', ref: 'prod-stripe-key' }, + { key: 'JWT_SECRET' }, // ref blank → falls back to key + { key: 'STRIPE_API_KEY', ref: 'prod-stripe-key' }, // dup + ], + }, + }, + ], + edges: [], + provider: 'gcp', + projectName: 'test', + }); + // Dedup by `ref || key` collapses the duplicate. + expect(result.deployable_count).toBe(2); + const refs = result.deployables.map((d) => d.resource_name).sort(); + expect(refs).toEqual(['jwt-secret', 'prod-stripe-key']); + // Every emitted deployable still attributes back to the source block. + expect(result.deployables.every((d) => d.node_id === 'sec1')).toBe(true); + // Each deployable label carries the binding key for plan-UI clarity. + expect(result.deployables.some((d) => d.label.includes('STRIPE_API_KEY'))).toBe(true); + expect(result.deployables.some((d) => d.label.includes('JWT_SECRET'))).toBe(true); + }); + + it('warns and skips when a Security.Secret block has no bindings', async () => { + const mod = await import('../deploy/card-translator'); + const result = mod.translate_card_to_graph({ + nodes: [{ id: 'sec1', type: 'resource', data: { iceType: 'Security.Secret', label: 'empty-store' } }], + edges: [], + provider: 'gcp', + projectName: 'test', + }); + expect(result.deployable_count).toBe(0); + expect(result.warnings.some((w) => w.includes('empty-store'))).toBe(true); + expect(result.skipped.some((s) => s.nodeId === 'sec1')).toBe(true); + }); + + it('dedupes shared refs across multiple Security.Secret blocks', async () => { + const mod = await import('../deploy/card-translator'); + const result = mod.translate_card_to_graph({ + nodes: [ + { + id: 'sec1', + type: 'resource', + data: { + iceType: 'Security.Secret', + label: 'app', + secrets: [{ key: 'DB_PASSWORD', ref: 'shared-db' }], + }, + }, + { + id: 'sec2', + type: 'resource', + data: { + iceType: 'Security.Secret', + label: 'worker', + secrets: [{ key: 'DB_PASSWORD', ref: 'shared-db' }], + }, + }, + ], + edges: [], + provider: 'gcp', + projectName: 'test', + }); + // One cloud secret, attributed to whichever block emitted it first. + expect(result.deployable_count).toBe(1); + expect(result.deployables[0].resource_name).toBe('shared-db'); + }); }); describe.skip('AWS Type Map', () => { diff --git a/packages/core/src/deploy/card-translator.ts b/packages/core/src/deploy/card-translator.ts index 0ab58e13..dd4d3446 100644 --- a/packages/core/src/deploy/card-translator.ts +++ b/packages/core/src/deploy/card-translator.ts @@ -15,10 +15,13 @@ import { } from './edge-classifier'; import { create_mutable_graph } from '../graph/mutable-graph'; import { PROPERTY_EXTRACTORS } from './extractors/dispatch'; +import { expand_deployable_per_entry } from './passes/deploy-expansion'; import { wire_source_repositories } from './passes/pass-1-4-repo-wiring'; import { propagate_custom_domain_hosts } from './passes/pass-1-45-domain-propagation'; +import { propagate_socket_port_targets } from './passes/pass-1-46-socket-port-targeting'; import { wire_public_endpoints } from './passes/pass-1-5-endpoint-wiring'; import { DESIGN_ONLY_PROVIDERS, get_type_map } from './type-maps'; +import { getHighLevelResourceByIceType } from '../resources/high-level-resources'; import { sanitize_name, sanitize_label_value } from './utils/name-utils'; import { generate_stable_name } from './utils/stable-name'; import type { Graph } from '../types/graph'; @@ -252,6 +255,51 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl } const properties = extractor(node.data, region, node.id); + // ─── Schema-declared 1→N deploy expansion ───────────────────────── + // + // When the canonical schema sets `deployExpansion`, partition the + // extractor's output and emit one cloud resource per entry instead + // of one per block. This branch is iceType-agnostic — Secret Store + // happens to be the first user, but ANY future block whose schema + // declares expansion goes through the same code path. + // + // No edge connections back to the source block — the canvas-side + // propagation rules carry per-entry refs onto consumer nodes + // (e.g. service `secretRefs`), and leaving the block out of + // `card_id_to_name` makes the deferred edge pass drop any orphan + // edges naturally. + const schemaResource = getHighLevelResourceByIceType(ice_type); + if (schemaResource?.deployExpansion) { + const blockLabel = (node.data.label as string) || ice_type.split('.').pop() || 'resource'; + const baseLabels: Record = { + 'ice-managed': 'true', + 'ice-source-id': sanitize_label_value(node.id), + 'ice-type': sanitize_label_value(ice_type), + 'ice-project': sanitize_label_value(projectName), + }; + if (input.environment) baseLabels['ice-environment'] = sanitize_label_value(input.environment); + if (cardId) baseLabels['ice-card-id'] = sanitize_label_value(cardId); + + const expansionResult = expand_deployable_per_entry({ + expansion: schemaResource.deployExpansion, + nodeId: node.id, + blockLabel, + iceType: ice_type, + // `gcp_type` is the provider-resolved type (legacy variable name + // — covers AWS / Azure / GCP / K8s); it just gets forwarded. + resourceType: gcp_type, + properties: properties as Record, + baseLabels, + graph, + deployables, + skipped, + warnings, + provider, + }); + deployable_count += expansionResult.added; + continue; + } + // Private Network ingress override. // // When a service backend (Scalable Backend / SSR Site / Worker / @@ -358,6 +406,14 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl // ─── Pass 1.45 — Network.CustomDomain → target host propagation ──────── propagate_custom_domain_hosts(edges, nodes, card_id_to_name, graph); + // ─── Pass 1.46 — Socket-driven target-port routing ───────────────────── + // Reads `edge.data.targetSocket` / `sourceSocket` ids of shape + // `port--(in|out)` and writes the encoded port onto the compute + // node's `target_port` (and `port` if not user-set). Makes multi-port + // containers' typed-socket choices actually drive what the LB + // targets at deploy time. + propagate_socket_port_targets(edges, nodes, card_id_to_name, graph); + // ─── Pass 1.5 — PublicEndpoint semantic wiring ───────────────────────── const { deployable_count_delta } = wire_public_endpoints({ edges, diff --git a/packages/core/src/deploy/extractors/__tests__/ancillary.test.ts b/packages/core/src/deploy/extractors/__tests__/ancillary.test.ts index 3ff92c93..94232fde 100644 --- a/packages/core/src/deploy/extractors/__tests__/ancillary.test.ts +++ b/packages/core/src/deploy/extractors/__tests__/ancillary.test.ts @@ -42,6 +42,7 @@ describe('extract_secret_manager_properties', () => { it('returns defaults for an empty data object', () => { expect(extract_secret_manager_properties({}, 'us-central1')).toEqual({ replication_type: 'automatic', + bindings: [], labels: {}, }); }); @@ -61,6 +62,19 @@ describe('extract_secret_manager_properties', () => { const b = extract_secret_manager_properties({}, 'europe-west2'); expect(a).toEqual(b); }); + + it('passes bindings through verbatim from data.secrets', () => { + const result = extract_secret_manager_properties( + { secrets: [{ key: 'API_KEY', ref: 'prod-api-key' }, { key: 'TOKEN' }] }, + 'us-central1', + ); + expect(result.bindings).toEqual([{ key: 'API_KEY', ref: 'prod-api-key' }, { key: 'TOKEN' }]); + }); + + it('coerces missing or non-array secrets to []', () => { + expect(extract_secret_manager_properties({ secrets: 'oops' }, 'us-central1').bindings).toEqual([]); + expect(extract_secret_manager_properties({}, 'us-central1').bindings).toEqual([]); + }); }); describe('extract_identity_platform_properties', () => { diff --git a/packages/core/src/deploy/extractors/ancillary.ts b/packages/core/src/deploy/extractors/ancillary.ts index 88cb1ad7..4f756fd8 100644 --- a/packages/core/src/deploy/extractors/ancillary.ts +++ b/packages/core/src/deploy/extractors/ancillary.ts @@ -13,8 +13,15 @@ export function extract_secret_manager_properties( data: Record, _region: string, ): Record { + // Pass the bindings through verbatim. The handler currently creates + // a single parent `Secret` resource named after the block; each row + // here describes the upstream entry an env var should resolve to + // (`{ key: ENV_VAR, ref: secret-id }`). Wiring each binding to its + // own provider resource is a translator-level expansion (one block → + // N resources) — left for a follow-up. return { replication_type: data.replicationType || 'automatic', + bindings: Array.isArray(data.secrets) ? data.secrets : [], labels: {}, }; } diff --git a/packages/core/src/deploy/passes/deploy-expansion.ts b/packages/core/src/deploy/passes/deploy-expansion.ts new file mode 100644 index 00000000..2bd75ad6 --- /dev/null +++ b/packages/core/src/deploy/passes/deploy-expansion.ts @@ -0,0 +1,175 @@ +/** + * Generic 1→N block expansion at translate time. + * + * Reads `deployExpansion` from the canonical block schema (a + * `HighLevelResource`) and emits one graph node per entry in + * `properties[partitionBy]`. The translator delegates here whenever a + * resource declares expansion semantics — there is NO iceType-specific + * logic in this file or the caller. + * + * Provider agnostic by construction: the per-resource properties shape + * came from the provider's extractor; we forward it verbatim to each + * emitted node and only touch the entry-derived name + per-entry label. + * Adding a new provider for the same canonical block means adding an + * extractor + handler for the provider's resource type — nothing here + * changes. + * + * Dedupes within the block AND across blocks by resolved resource name + * (`graph.has_node`) — two rows pointing at the same upstream entry + * share one cloud resource. + */ + +import { sanitize_label_value, sanitize_name } from '../utils/name-utils'; +import type { MutableGraph } from '../../graph/mutable-graph'; +import type { DeployExpansion } from '../../resources/high-level-resources'; +import type { DeployableNodeInfo, SkippedNode } from '../card-translator'; + +export interface ExpandDeployableArgs { + /** Schema-declared expansion metadata. */ + expansion: DeployExpansion; + /** Canvas node id (passed through onto each deployable for traceability). */ + nodeId: string; + /** Human-readable label for the source block. */ + blockLabel: string; + /** Canonical iceType (stored on each deployable). */ + iceType: string; + /** Provider-resolved resource type for the cloud handler. */ + resourceType: string; + /** Extractor output — `properties[expansion.partitionBy]` is the partition source. */ + properties: Record; + /** Standard `ice-*` labels every emitted resource carries. */ + baseLabels: Record; + /** Mutable graph to add nodes to. */ + graph: MutableGraph; + /** Receives one entry per emitted resource. */ + deployables: DeployableNodeInfo[]; + /** Receives a single skip entry when the block has zero usable rows. */ + skipped: SkippedNode[]; + /** Receives free-form warnings (empty partition, add-node failures). */ + warnings: string[]; + /** Provider id, used only for the empty-partition warning message. */ + provider: string; +} + +export interface ExpandDeployableResult { + /** Number of cloud resources added to the graph. */ + added: number; +} + +/** + * Coerce a raw partition entry into a uniform record so the rest of the + * function can read fields by name regardless of how the user typed them. + * Lifts plain strings into a single-field record under `nameFrom.field` + * (covers the legacy `string[]` shape that pre-dated the typed `{key,ref}` + * editor — projects don't lose data on first edit). + */ +function normalizeEntry(raw: unknown, expansion: DeployExpansion): Record | null { + if (typeof raw === 'string') { + const v = raw.trim(); + if (!v) return null; + return { [expansion.nameFrom.field]: v }; + } + if (raw && typeof raw === 'object') { + const o = raw as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(o)) { + if (typeof v === 'string') out[k] = v.trim(); + } + return out; + } + return null; +} + +/** Resolve the cloud resource name for one entry, with fallback. */ +function resolveEntryName(entry: Record, expansion: DeployExpansion): string { + const primary = entry[expansion.nameFrom.field]; + if (primary) return primary; + if (expansion.nameFrom.fallback) { + const fb = entry[expansion.nameFrom.fallback]; + if (fb) return fb; + } + return ''; +} + +export function expand_deployable_per_entry(args: ExpandDeployableArgs): ExpandDeployableResult { + const { + expansion, + nodeId, + blockLabel, + iceType, + resourceType, + properties, + baseLabels, + graph, + deployables, + skipped, + warnings, + provider, + } = args; + + const rawPartition = Array.isArray(properties[expansion.partitionBy]) + ? (properties[expansion.partitionBy] as unknown[]) + : []; + const entries = rawPartition + .map((row) => normalizeEntry(row, expansion)) + .filter((e): e is Record => e !== null && Boolean(resolveEntryName(e, expansion))); + + if (entries.length === 0) { + warnings.push( + `"${blockLabel}" (${iceType}) has no ${expansion.partitionBy} configured. Nothing will be created in ${provider}.`, + ); + skipped.push({ + nodeId, + label: blockLabel, + reason: `${iceType} has no ${expansion.partitionBy} configured`, + }); + return { added: 0 }; + } + + // Strip the partition key from the per-resource properties — every + // other field came from the provider's extractor and is already + // shaped for that provider's handler. + const { [expansion.partitionBy]: _strip, ...sharedProps } = properties; + + let added = 0; + const seen = new Set(); + for (const entry of entries) { + const rawName = resolveEntryName(entry, expansion); + const resourceName = sanitize_name(rawName); + if (!resourceName || seen.has(resourceName)) continue; + seen.add(resourceName); + if (graph.has_node(resourceName)) continue; + + const perEntryLabels: Record = { ...baseLabels }; + if (expansion.tagPerEntry) { + const tagValue = entry[expansion.tagPerEntry.fromField]; + if (tagValue) perEntryLabels[expansion.tagPerEntry.labelKey] = sanitize_label_value(tagValue); + } + + const addResult = graph.add_node({ + type: resourceType, + name: resourceName, + properties: { ...sharedProps, labels: perEntryLabels }, + labels: perEntryLabels, + }); + + if (!addResult.success) { + warnings.push( + `Failed to add ${resourceType} "${resourceName}" for block "${blockLabel}": ${addResult.errors?.join(', ')}`, + ); + continue; + } + + const labelSuffix = expansion.labelFrom ? entry[expansion.labelFrom] : undefined; + deployables.push({ + node_id: nodeId, + label: labelSuffix ? `${blockLabel} · ${labelSuffix}` : blockLabel, + ice_type: iceType, + resource_type: resourceType, + resource_name: resourceName, + }); + added++; + } + + return { added }; +} diff --git a/packages/core/src/deploy/providers/gcp/handlers/secret-manager.ts b/packages/core/src/deploy/providers/gcp/handlers/secret-manager.ts index 24a2aa33..34b337b3 100644 --- a/packages/core/src/deploy/providers/gcp/handlers/secret-manager.ts +++ b/packages/core/src/deploy/providers/gcp/handlers/secret-manager.ts @@ -2,6 +2,18 @@ * Secret Manager Handler * * Handles: gcp.secretmanager.secret + * + * Translator expansion: one Secret Store block emits one of these per + * binding row (see `card-translator.ts` Security.Secret expansion). The + * resource `name` is the upstream ref (`ref || key` from the binding), + * so service `secretRefs` entries — wired by the canvas propagation + * rules — resolve against the same id GCP knows. + * + * Values are NOT written. This handler creates the parent `Secret` + * resource only; `SecretVersion`s must be populated by the operator in + * the GCP console / IaC. That keeps actual secret values out of the + * ICE project file (and out of the canvas), which is the security + * tradeoff we want. */ import { SERVICE_NAMES, sdk_not_available, sdk_not_available_short } from '../messages'; diff --git a/packages/core/src/resources/high-level-resources.ts b/packages/core/src/resources/high-level-resources.ts index 817780cf..6b8406d2 100644 --- a/packages/core/src/resources/high-level-resources.ts +++ b/packages/core/src/resources/high-level-resources.ts @@ -26,6 +26,7 @@ // ─── Type re-exports ──────────────────────────────────────────────────────── export type { + DeployExpansion, HighLevelCategory, HighLevelProperty, HighLevelResource, @@ -43,5 +44,6 @@ export { getBehaviorColor, getBehaviorLabel, getGCPCloudAssetTypes, + getHighLevelResourceByIceType, getHighLevelResourcesForPalette, } from './high-level-resources/helpers'; diff --git a/packages/core/src/resources/high-level-resources/categories/security.ts b/packages/core/src/resources/high-level-resources/categories/security.ts index 2589ce62..44a3002f 100644 --- a/packages/core/src/resources/high-level-resources/categories/security.ts +++ b/packages/core/src/resources/high-level-resources/categories/security.ts @@ -25,6 +25,16 @@ export const security: HighLevelCategory = { resources: [ { id: 'secret-store', + iceType: 'Security.Secret', + // Declarative deploy-time expansion: one cloud secret per binding, + // not one stub per block. Provider-agnostic — extractors/handlers + // own the per-provider resource shape. See `DeployExpansion`. + deployExpansion: { + partitionBy: 'bindings', + nameFrom: { field: 'ref', fallback: 'key' }, + labelFrom: 'key', + tagPerEntry: { labelKey: 'ice-secret-key', fromField: 'key' }, + }, name: 'Secret Store', description: 'Securely store API keys and credentials', icon: 'Key', @@ -57,31 +67,28 @@ export const security: HighLevelCategory = { properties: [ { name: 'name', - label: 'Name', + label: 'Store name', type: 'string', required: true, tier: 'essential', - description: 'A friendly name for this secret', - placeholder: 'My Secret', + description: 'A friendly name for this secret store', + placeholder: 'My Secrets', }, { name: 'secrets', - label: 'Secret values', - type: 'list', + label: 'Secret bindings', + type: 'secret_bindings', required: false, tier: 'essential', - description: 'The secret key-value pairs to store', - placeholder: 'e.g. STRIPE_API_KEY', - addLabel: 'Add a secret', - }, - { - name: 'auto_rotate', - label: 'Auto-rotate?', - type: 'boolean', - required: false, - tier: 'detailed', - description: 'Automatically change this secret on a schedule for better security', - default: false, + // The block does NOT store secret values — values live in the + // upstream secret manager (Secrets Manager / Secret Manager / + // Key Vault). Each row binds an env-var name (`key`, e.g. + // `STRIPE_API_KEY`) to a secret entry there (`ref`, e.g. + // `prod-stripe-key`). Wiring this block to a service injects + // those env vars at runtime. + description: + 'Bind env var names to entries in your cloud secret manager. Values are managed there, not here.', + addLabel: 'Add a binding', }, ], }, diff --git a/packages/core/src/resources/high-level-resources/helpers.ts b/packages/core/src/resources/high-level-resources/helpers.ts index 78b0fa47..e298119a 100644 --- a/packages/core/src/resources/high-level-resources/helpers.ts +++ b/packages/core/src/resources/high-level-resources/helpers.ts @@ -47,6 +47,33 @@ export function getAllHighLevelResources(): HighLevelResource[] { return HIGH_LEVEL_CATEGORIES.flatMap((cat) => cat.resources); } +// Lazy iceType → resource index. Built once on first lookup and cached +// thereafter — `HIGH_LEVEL_CATEGORIES` is a static module-level constant +// so the map is safe to cache for the lifetime of the process. +let HIGH_LEVEL_BY_ICE_TYPE: Map | null = null; + +function buildIceTypeIndex(): Map { + const map = new Map(); + for (const resource of getAllHighLevelResources()) { + if (resource.iceType) map.set(resource.iceType, resource); + } + return map; +} + +/** + * Look up the canonical `HighLevelResource` by iceType. + * + * The translator (and any other cross-cutting layer) uses this to read + * schema-declared deploy semantics like `deployExpansion` WITHOUT + * hardcoding iceType-specific branches. Resources that don't set + * `iceType` on the schema return `undefined` here. + */ +export function getHighLevelResourceByIceType(iceType: string): HighLevelResource | undefined { + if (!iceType) return undefined; + if (!HIGH_LEVEL_BY_ICE_TYPE) HIGH_LEVEL_BY_ICE_TYPE = buildIceTypeIndex(); + return HIGH_LEVEL_BY_ICE_TYPE.get(iceType); +} + /** * Get resources formatted for the palette */ diff --git a/packages/core/src/resources/high-level-resources/types.ts b/packages/core/src/resources/high-level-resources/types.ts index c210a95d..04eb3ca5 100644 --- a/packages/core/src/resources/high-level-resources/types.ts +++ b/packages/core/src/resources/high-level-resources/types.ts @@ -27,6 +27,15 @@ export interface HighLevelResource { description: string; icon: string; category: string; + /** + * Canonical ICE type (e.g. `Security.Secret`). Optional so resources + * without a canvas block (raw catalog-only entries) stay valid, but + * REQUIRED for anything the deploy translator needs to look up by + * iceType — including any resource that declares `deployExpansion`. + * Source of truth for the iceType↔resource mapping; blueprints in + * `@ice/blocks` reference this transitively via `resourceId`. + */ + iceType?: string; // Node behavior type behavior: NodeBehavior; // Which providers support this resource @@ -37,6 +46,44 @@ export interface HighLevelResource { keywords: string[]; // Common properties users care about properties: HighLevelProperty[]; + /** + * Declarative deploy-time cardinality. When set, the card translator + * emits ONE provider resource per entry in `properties[]` + * (which the extractor pulled from `node.data`) instead of the default + * one-resource-per-block. Provider-shaped fields stay untouched — + * extractor output is forwarded verbatim to each emitted resource — + * so this metadata is provider-agnostic and lives on the canonical + * schema, not in the translator or a provider file. + * + * Cardinal rule: cross-cutting layers (translator, dispatcher) MUST + * read this from the schema. NEVER hardcode `if (iceType === 'X')` + * branches. + */ + deployExpansion?: DeployExpansion; +} + +/** + * Declarative 1→N expansion at deploy time. The translator partitions + * `properties[partitionBy]` (the extractor's output array) and emits one + * cloud resource per entry, with the resource name derived from the + * entry's `nameFrom.field` (falling back to `nameFrom.fallback`). + * + * Optional bookkeeping: + * - `labelFrom`: which entry field is appended to the deployable's + * human label (`" · "`) so the plan UI reads + * well. + * - `tagPerEntry`: copies one entry field into a cloud label on each + * emitted resource (e.g. `ice-secret-key: STRIPE_API_KEY`) so the + * resource → binding mapping survives in the cloud console. + * + * Dedupes within a block AND across blocks by resolved name — two rows + * pointing at the same upstream entry share one cloud resource. + */ +export interface DeployExpansion { + partitionBy: string; + nameFrom: { field: string; fallback?: string }; + labelFrom?: string; + tagPerEntry?: { labelKey: string; fromField: string }; } /** @@ -68,8 +115,22 @@ export interface HighLevelProperty { * - `list`: generic string list with add/remove * - `queue_list`: bespoke queue renderer — each item shows as a queue pill * with a distinct icon, FIFO badge, and queue-semantic affordances + * - `port_list`: list of HTTP/TCP listeners on a service. Each entry + * becomes a typed `http-endpoint` OUT port on the canvas, so a + * user can wire an EC2-style block's port 8080 to a custom domain + * while leaving port 443 free. */ - type: 'string' | 'number' | 'boolean' | 'select' | 'list' | 'queue_list' | 'task_list'; + type: + | 'string' + | 'number' + | 'boolean' + | 'select' + | 'list' + | 'queue_list' + | 'task_list' + | 'port_list' + /** Two-input rows binding an env-var name to an upstream secret ref. */ + | 'secret_bindings'; required: boolean; description: string; options?: string[]; diff --git a/packages/core/src/resources/index.ts b/packages/core/src/resources/index.ts index 30a60dd8..43b5070e 100644 --- a/packages/core/src/resources/index.ts +++ b/packages/core/src/resources/index.ts @@ -29,10 +29,12 @@ export { export { HIGH_LEVEL_CATEGORIES, getAllHighLevelResources, + getHighLevelResourceByIceType, getHighLevelResourcesForPalette, filterResourcesByProvider, getBehaviorLabel, getBehaviorColor, + type DeployExpansion, type HighLevelResource, type HighLevelProperty, type HighLevelCategory, diff --git a/packages/ui/src/features/canvas/components/nodes/secret-store/__tests__/index.test.tsx b/packages/ui/src/features/canvas/components/nodes/secret-store/__tests__/index.test.tsx index 08893157..a028f274 100644 --- a/packages/ui/src/features/canvas/components/nodes/secret-store/__tests__/index.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/secret-store/__tests__/index.test.tsx @@ -107,20 +107,12 @@ describe('SvgSecretStoreNode', () => { expect((tree.props as { liveConfig: string }).liveConfig).toBe('3 secrets'); }); - it('appends "· auto-rotate" when data.auto_rotate is truthy', () => { + it('ignores stale data.auto_rotate (property was removed from the schema)', () => { const tree = SvgSecretStoreNode({ node: makeNode({ data: { secrets: ['X'], auto_rotate: true } }), isSelected: false, }) as React.ReactElement; - expect((tree.props as { liveConfig: string }).liveConfig).toBe('1 secret · auto-rotate'); - }); - - it('omits "· auto-rotate" when no secrets, even if flag set', () => { - const tree = SvgSecretStoreNode({ - node: makeNode({ data: { auto_rotate: true } }), - isSelected: false, - }) as React.ReactElement; - expect((tree.props as { liveConfig: string }).liveConfig).toBe('No secrets yet'); + expect((tree.props as { liveConfig: string }).liveConfig).toBe('1 secret'); }); it('uses node.label as title when present, falls back to "Secret Store"', () => { diff --git a/packages/ui/src/features/canvas/components/nodes/secret-store/index.tsx b/packages/ui/src/features/canvas/components/nodes/secret-store/index.tsx index 6b192729..97c4d1ea 100644 --- a/packages/ui/src/features/canvas/components/nodes/secret-store/index.tsx +++ b/packages/ui/src/features/canvas/components/nodes/secret-store/index.tsx @@ -40,11 +40,12 @@ export const SvgSecretStoreNode: React.FC = ({ }) => { const keys = ((node.data?.secrets as unknown[] | undefined) || []).map(parseSecretKey).filter(Boolean); - const autoRotate = !!node.data?.auto_rotate; const liveConfig = keys.length === 0 ? t('canvas.blocks.secret.none') - : `${keys.length === 1 ? t('canvas.blocks.secret.one') : t('canvas.blocks.secret.many', { n: keys.length })}${autoRotate ? ` · ${t('canvas.blocks.secret.autoRotate')}` : ''}`; + : keys.length === 1 + ? t('canvas.blocks.secret.one') + : t('canvas.blocks.secret.many', { n: keys.length }); return ( void; + addLabel?: string; +}> = ({ label, value, onChange, addLabel }) => { + const ports = value.map(parsePort); + const update = (i: number, next: PortSpec): void => { + const arr = [...value]; + arr[i] = stringifyPort(next); + onChange(arr); + }; + return ( +
+ {label} +
+ {ports.map((p, i) => ( +
+ {/* Port icon */} +
+ {p.protocol === 'tcp' ? ( + + ) : ( + + )} +
+ {/* Protocol */} + + {/* Port number */} + { + const n = Number(e.target.value); + if (Number.isFinite(n)) update(i, { ...p, port: n }); + }} + className="w-16 bg-transparent text-ice-xs text-ice-text-1 font-mono outline-none border-b border-ice-border/40 focus:border-ice-accent text-right" + /> + {/* Label */} + update(i, { ...p, label: e.target.value })} + placeholder="label" + className="flex-1 min-w-0 bg-transparent text-ice-xs text-ice-text-2 font-mono outline-none placeholder:text-ice-text-3/40" + /> + {/* Remove */} + +
+ ))} +
+ +
+ ); +}; + +// ─── Secret bindings — env var name ↔ upstream secret manager ref ──────── +// +// The Secret Store block does NOT hold secret values — the cloud's secret +// manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) does. +// Each row here binds: +// - `key`: the environment variable name the service sees at runtime +// - `ref`: the id of the entry in the upstream secret manager +// When `ref` is blank, the deploy translator falls back to `key` so the +// common case (same name on both sides) needs no extra typing. + +export interface SecretBinding { + key: string; + ref?: string; +} + +export const SecretBindingsField: React.FC<{ + label: string; + value: SecretBinding[]; + onChange: (v: SecretBinding[]) => void; + addLabel?: string; +}> = ({ label, value, onChange, addLabel }) => { + const update = (i: number, next: SecretBinding) => { + const arr = [...value]; + arr[i] = next; + onChange(arr); + }; + return ( +
+ {label} +
+ {value.map((row, i) => ( +
+
+ +
+ update(i, { ...row, key: e.target.value })} + placeholder="STRIPE_API_KEY" + title={t('properties.secretBindings.keyTooltip')} + className="flex-1 min-w-0 bg-transparent text-ice-xs text-ice-text-1 font-mono outline-none border-b border-ice-border/40 focus:border-ice-accent placeholder:text-ice-text-3/40" + /> + + update(i, { ...row, ref: e.target.value })} + placeholder={row.key || 'prod-stripe-key'} + title={t('properties.secretBindings.refTooltip')} + className="flex-1 min-w-0 bg-transparent text-ice-xs text-ice-text-2 font-mono outline-none border-b border-ice-border/40 focus:border-ice-accent placeholder:text-ice-text-3/40" + /> + +
+ ))} +
+ +
+ ); +}; + // ─── Cron task list ──────────────────────────────────────────────────────── // HTTP methods are universal API verbs — not translated. diff --git a/packages/ui/src/features/properties/components/fields/render-property-field.tsx b/packages/ui/src/features/properties/components/fields/render-property-field.tsx index 1600beb8..204bd08f 100644 --- a/packages/ui/src/features/properties/components/fields/render-property-field.tsx +++ b/packages/ui/src/features/properties/components/fields/render-property-field.tsx @@ -32,7 +32,18 @@ */ import React from 'react'; -import { Section, SelectField, ListField, QueueListField, TaskListField, PropertyLabel, CustomValueInput } from '.'; +import { + Section, + SelectField, + ListField, + PortListField, + QueueListField, + SecretBindingsField, + TaskListField, + PropertyLabel, + CustomValueInput, + type SecretBinding, +} from '.'; import { t } from '../../../../i18n'; import { IceSelect } from '../../../../shared/components/ui/ice-select'; import { cn } from '../../../../shared/utils/cn'; @@ -60,7 +71,16 @@ export interface CustomInputConfig { export interface HighLevelProperty { name: string; label: string; - type: 'string' | 'number' | 'boolean' | 'select' | 'list' | 'queue_list' | 'task_list'; + type: + | 'string' + | 'number' + | 'boolean' + | 'select' + | 'list' + | 'queue_list' + | 'task_list' + | 'port_list' + | 'secret_bindings'; required: boolean; description: string; options?: string[]; @@ -200,6 +220,38 @@ export function renderPropertyField( /> ); } + if (prop.type === 'port_list') { + const listVal = Array.isArray(value) ? (value as string[]) : []; + return ( + onChange(prop.name, v)} + addLabel={prop.addLabel} + /> + ); + } + if (prop.type === 'secret_bindings') { + // Tolerates the legacy `string[]` shape ("Add a secret" used to be + // a flat ListField) by lifting each string into `{ key, ref: '' }` + // so old projects don't lose data on the first edit. + const raw = Array.isArray(value) ? value : []; + const rows: SecretBinding[] = raw.map((r) => { + if (typeof r === 'string') return { key: r, ref: '' }; + const o = (r as Record) ?? {}; + return { key: String(o.key ?? ''), ref: typeof o.ref === 'string' ? o.ref : undefined }; + }); + return ( + onChange(prop.name, v)} + addLabel={prop.addLabel} + /> + ); + } if (prop.type === 'select' && prop.options) { return ( Date: Sun, 24 May 2026 11:22:06 +0200 Subject: [PATCH 03/52] refactor(canvas-renderer): drop hardcoded iceType branches via SPECIAL_NODE_RENDERERS table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item #11 — cardinal rule violation. The dispatcher had three hardcoded `if (iceType === 'X')` branches for Custom Domain, Reroute, and Private Network, each wiring a bespoke component with its own prop set + innerKey formula. Consolidated into a single declarative `SPECIAL_NODE_RENDERERS` table keyed by iceType, with factory entries that own their own component AND innerKey formula. The dispatcher iterates this table generically — no iceType-specific code paths remain. Adding a new bespoke renderer extends the table; the dispatcher stays unchanged. Dispatch order preserved by construction: the special table is consulted BEFORE the container check so PrivateNetwork (a container we render with a custom header) and Reroute (which the classifier calls a container despite being a pass-through dot) hit their bespoke factories first. Tests: locked-in entries list + per-entry contract (element + innerKey) + innerKey-changes-on-relevant-data assertions for Custom Domain (routes count) and PrivateNetwork (ingress mode). --- package.json | 2 +- .../__tests__/node-renderer-registry.test.tsx | 65 +++++- .../node-renderer-registry.tsx | 186 ++++++++++++------ 3 files changed, 194 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 768f71eb..afe88bb4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.722", + "version": "0.1.723", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/node-renderer-registry.test.tsx b/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/node-renderer-registry.test.tsx index c8b64030..bb74ac39 100644 --- a/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/node-renderer-registry.test.tsx +++ b/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/node-renderer-registry.test.tsx @@ -125,7 +125,12 @@ const MockSvgObjectStorageNode = mocks.SvgObjectStorageNode; const MockSvgGithubRepoNode = mocks.SvgGithubRepoNode; // Imports come AFTER the mocks so vitest hoists/wires them correctly. -import { CONCEPT_NODE_RENDERERS, renderCanvasNode, type RenderCtx } from '../node-renderer-registry'; +import { + CONCEPT_NODE_RENDERERS, + SPECIAL_NODE_RENDERERS, + renderCanvasNode, + type RenderCtx, +} from '../node-renderer-registry'; import type { CanvasNode } from '../../types'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -165,6 +170,64 @@ const makeCtx = (overrides: Partial = {}): RenderCtx => ({ ...overrides, }); +// ─── SPECIAL_NODE_RENDERERS table ───────────────────────────────────────── + +describe('SPECIAL_NODE_RENDERERS', () => { + it('contains exactly the bespoke iceTypes that need a custom renderer', () => { + // Locked-in list. Adding a new bespoke renderer extends this set; + // removing one breaks dispatch. The dispatcher reads this table + // generically so the cardinal rule (no hardcoded iceType branches) + // is preserved by construction. + expect(Object.keys(SPECIAL_NODE_RENDERERS).sort()).toEqual([ + 'Network.CustomDomain', + 'Network.PrivateNetwork', + 'Util.Reroute', + ]); + }); + + it('each entry is a factory that returns an element + innerKey', () => { + for (const [iceType, factory] of Object.entries(SPECIAL_NODE_RENDERERS)) { + const node = makeNode({ data: { iceType } }); + const result = factory(node, makeCtx()); + expect(result.element).toBeTruthy(); + expect(typeof result.innerKey).toBe('string'); + expect(result.innerKey.length).toBeGreaterThan(0); + } + }); + + it('Custom Domain innerKey encodes route count for stable re-mount on add/remove', () => { + const oneRoute = SPECIAL_NODE_RENDERERS['Network.CustomDomain']( + makeNode({ data: { iceType: 'Network.CustomDomain', routes: [{ id: 'r1', subdomain: 'a' }] } }), + makeCtx(), + ); + const twoRoutes = SPECIAL_NODE_RENDERERS['Network.CustomDomain']( + makeNode({ + data: { + iceType: 'Network.CustomDomain', + routes: [ + { id: 'r1', subdomain: 'a' }, + { id: 'r2', subdomain: 'b' }, + ], + }, + }), + makeCtx(), + ); + expect(oneRoute.innerKey).not.toBe(twoRoutes.innerKey); + }); + + it('PrivateNetwork innerKey encodes ingress mode for stable re-mount on toggle', () => { + const open = SPECIAL_NODE_RENDERERS['Network.PrivateNetwork']( + makeNode({ data: { iceType: 'Network.PrivateNetwork', ingress: 'open' } }), + makeCtx(), + ); + const sealed = SPECIAL_NODE_RENDERERS['Network.PrivateNetwork']( + makeNode({ data: { iceType: 'Network.PrivateNetwork', ingress: 'sealed' } }), + makeCtx(), + ); + expect(open.innerKey).not.toBe(sealed.innerKey); + }); +}); + // ─── CONCEPT_NODE_RENDERERS table ───────────────────────────────────────── describe('CONCEPT_NODE_RENDERERS', () => { diff --git a/packages/ui/src/features/canvas/components/canvas-renderer/node-renderer-registry.tsx b/packages/ui/src/features/canvas/components/canvas-renderer/node-renderer-registry.tsx index 4efa4280..605eff19 100644 --- a/packages/ui/src/features/canvas/components/canvas-renderer/node-renderer-registry.tsx +++ b/packages/ui/src/features/canvas/components/canvas-renderer/node-renderer-registry.tsx @@ -26,21 +26,24 @@ * * - The dispatch order is fixed and order-sensitive: * - * 1. `isLogIceType(iceType)` → `SvgLogNode` - * 2. `iceType === 'Network.CustomDomain'` → `SvgCustomDomainNode` - * 3. `iceType === 'Network.PrivateNetwork'` → `SvgPrivateNetworkNode` - * 4. `isContainerNode(node)` → `SvgGroupNode` - * 5. `node.type === 'block'` → `ConceptRenderer` ?? `SvgCompactNode` - * 6. (default fallthrough — typically `node.type === 'resource'`) → - * `ConceptFallbackRenderer` - * ?? `SvgCompactNode` + * 1. `isLogIceType(iceType)` → `SvgLogNode` + * 2. `SPECIAL_NODE_RENDERERS[iceType]` → bespoke factory (CustomDomain, + * Reroute, PrivateNetwork) + * 3. `isContainerNode(node)` → `SvgGroupNode` + * 4. `node.type === 'block'` → `ConceptRenderer` ?? `SvgCompactNode` + * 5. (default fallthrough — typically `node.type === 'resource'`) → + * `ConceptFallbackRenderer` + * ?? `SvgCompactNode` * - * Re-ordering even subtly changes behaviour. `Network.PrivateNetwork` - * MUST stay above the `isContainerNode` arm because the util classifies - * PrivateNetwork as a container; flipping the order would render every - * PrivateNetwork as a plain `SvgGroupNode` (loss of identity header + - * ingress toggle). Likewise `isLogIceType` matches a few iceTypes that - * might otherwise fall through to the SvgCompactNode branch. + * Step 2 MUST stay above the `isContainerNode` arm — PrivateNetwork + * and Reroute would otherwise render as a plain `SvgGroupNode` (loss + * of identity header / pass-through dot). Likewise `isLogIceType` + * matches a few iceTypes that might otherwise fall through. + * + * `SPECIAL_NODE_RENDERERS` is the schema-declared fact the dispatcher + * iterates over — no `if (iceType === 'X')` branches here. New + * bespoke renderers are added by extending the table (a new entry = + * a new factory), the dispatcher stays unchanged. * * - The `innerKey` per branch is load-bearing for reconciliation when no * wrapper-level branch overrides it (i.e. not lifted, no parent, not @@ -94,6 +97,7 @@ import { SvgPrivateAiServiceNode } from '../nodes/private-ai-service'; import { SvgPrivateNetworkNode } from '../nodes/private-network'; import { SvgPublicTrafficNode } from '../nodes/public-traffic'; import { SvgRedisCacheNode } from '../nodes/redis-cache'; +import { SvgRerouteNode } from '../nodes/reroute-node'; import { SvgScalableBackendNode } from '../nodes/scalable-backend'; import { SvgScheduledTaskNode } from '../nodes/scheduled-task'; import { SvgSecretStoreNode } from '../nodes/secret-store'; @@ -114,6 +118,105 @@ import type { CanvasNode } from '../types'; // when no bespoke renderer is registered. Each entry lives in its own // folder under ../nodes// so customizing one block = editing one file. +// ============================================================================= +// Bespoke renderer registry (cardinal-rule schema-driven dispatch) +// ============================================================================= +// +// Some blocks need a renderer with a unique prop set + a unique React +// reconciliation key derived from custom node-data fields (variable-height +// row stacks, ingress toggles, animated minimal pass-through dots). Each +// factory here owns its own component AND its own innerKey formula, so +// `renderCanvasNode` can dispatch via a generic `Record` lookup without +// `if (iceType === 'X')` branches in cross-cutting code. +// +// Adding a new bespoke renderer: register an entry here. The dispatcher +// stays untouched. Falls through to the per-concept and SvgCompactNode +// tables when no bespoke entry matches the node's iceType. + +export interface BespokeRenderResult { + element: React.ReactNode; + innerKey: string; +} + +export type BespokeRendererFactory = (node: CanvasNode, ctx: RenderCtx) => BespokeRenderResult; + +export const SPECIAL_NODE_RENDERERS: Record = { + // Custom Domain — variable-height stack of per-route rows, each with its + // own right-edge socket. innerKey re-mounts on routes-array length change + // so the renderer re-reads the row layout cleanly. + 'Network.CustomDomain': (node, ctx) => { + const innerKey = `${node.id}-routes${((node.data?.routes as unknown[]) || []).length}`; + return { + innerKey, + element: ( + + ), + }; + }, + // Reroute — minimal 16×16 pass-through dot. MUST be registered BEFORE + // the container check in the dispatcher (it's not a container despite + // looking like one to the classifier) — the bespoke table is consulted + // first so order is preserved by construction. + 'Util.Reroute': (node, ctx) => { + const innerKey = `${node.id}-reroute`; + return { + innerKey, + element: ( + {}} + onRenameCommit={() => {}} + onRenameCancel={ctx.handleRenameCancel} + onUpdateData={ctx.handleUpdateNodeData} + pipelineStatus={ctx.pipelineNodeStatus[node.id]} + onPipelineClick={ctx.handlePipelineClick} + connectedPipelineStatuses={ctx.getConnectedPipelineStatuses(node)} + lod={ctx.lod} + zoom={ctx.zoom} + connectionDragState={ctx.connectionDragTargets?.get(node.id) ?? null} + validationSeverity={ctx.nodeValidationMap.get(node.id)?.severity ?? null} + validationCount={ctx.nodeValidationMap.get(node.id)?.count ?? 0} + /> + ), + }; + }, + // Private Network — container with identity header + ingress toggle. + // innerKey re-mounts on ingress mode change so the header reads the new + // state cleanly. + 'Network.PrivateNetwork': (node, ctx) => { + const innerKey = `${node.id}-pn${(node.data?.ingress as string) || 'open'}`; + return { + innerKey, + element: ( + + ), + }; + }, +}; + export const CONCEPT_NODE_RENDERERS: Record> = { // Frontend 'Compute.StaticSite': SvgStaticSiteNode, @@ -255,49 +358,18 @@ export function renderCanvasNode(node: CanvasNode, ctx: RenderCtx): { element: R }; } - // 2. Custom Domain — owns its own renderer with dynamic per-route rows - // + per-row connection ports. Lives outside the compact-node tree so - // it can have variable height and multiple right-side ports. - if (iceType === 'Network.CustomDomain') { - const innerKey = `${node.id}-routes${((node.data?.routes as unknown[]) || []).length}`; - return { - innerKey, - element: ( - - ), - }; - } - - // 3. Private Network — pure container with a header that shows identity - // (shield icon + title + subtitle) and the Open/Sealed ingress toggle. - // Children nest inside via parentId and render through the standard - // dispatcher loop on top of the Private Network frame. Must come - // BEFORE the generic group dispatch below or it would render as a - // plain SvgGroupNode. - if (iceType === 'Network.PrivateNetwork') { - const innerKey = `${node.id}-pn${(node.data?.ingress as string) || 'open'}`; - return { - innerKey, - element: ( - - ), - }; + // 2. Bespoke renderers — Custom Domain, Reroute, Private Network. Each + // entry in `SPECIAL_NODE_RENDERERS` owns its own component AND its + // own innerKey formula, so this dispatcher is generic: look up by + // iceType, delegate. No hardcoded iceType branches here. + // + // Order is preserved by construction — the bespoke table is consulted + // BEFORE the container check, so Util.Reroute (which the classifier + // would call a container) and Network.PrivateNetwork (a container we + // render with a custom header) hit their bespoke factories first. + const bespokeFactory = SPECIAL_NODE_RENDERERS[iceType]; + if (bespokeFactory) { + return bespokeFactory(node, ctx); } // 4. Groups always render as containers. From 8901ea55783ecdd1a418945c0aff003ac251f33c Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:24:03 +0200 Subject: [PATCH 04/52] refactor(canvas-path): drop hardcoded iceType in socket-position via BESPOKE_SOCKET_POSITIONS table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item #12 — cardinal rule violation. `getSocketCanvasPosition` had an `if (iceType === 'Network.CustomDomain' && socketId.startsWith ('domain-out-'))` branch that resolved the bespoke row-Y for per-route ports. Consolidated into a `BESPOKE_SOCKET_POSITIONS` table keyed by iceType, with resolver entries returning `Point | null` (null = fall through to the standard layout). The dispatcher iterates this table generically — no iceType branches in the resolver function. New bespoke layouts register here; dispatch stays unchanged. Tests: locked-in entries list, per-resolver contract (returns null on miss), and dispatcher behaviour (bespoke hit, fall-through, dangling socket id). --- package.json | 2 +- .../path/__tests__/socket-position.test.ts | 97 +++++++++++++++++++ .../canvas/components/path/socket-position.ts | 88 +++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/canvas/components/path/__tests__/socket-position.test.ts create mode 100644 packages/ui/src/features/canvas/components/path/socket-position.ts diff --git a/package.json b/package.json index afe88bb4..56837754 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.723", + "version": "0.1.724", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/ui/src/features/canvas/components/path/__tests__/socket-position.test.ts b/packages/ui/src/features/canvas/components/path/__tests__/socket-position.test.ts new file mode 100644 index 00000000..254e3e1c --- /dev/null +++ b/packages/ui/src/features/canvas/components/path/__tests__/socket-position.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for `socket-position` — the single source of truth for where a + * socket dot lives in canvas space. + * + * Two surfaces: + * 1. `BESPOKE_SOCKET_POSITIONS` — the schema-shaped table the + * dispatcher iterates. Asserts the registered entries are what + * we expect (cardinal rule: dispatch is generic, the table IS + * the declared fact). + * 2. `getSocketCanvasPosition` — the dispatcher itself. Covers: + * bespoke hit, bespoke miss (falls through), standard layout, + * and the dangling-edge null return. + */ + +import { describe, it, expect } from 'vitest'; +import { BESPOKE_SOCKET_POSITIONS, getSocketCanvasPosition } from '../socket-position'; +import type { CanvasNode } from '../../types'; + +const makeNode = (overrides: Partial = {}): CanvasNode => ({ + id: 'n1', + type: 'resource', + x: 100, + y: 200, + width: 80, + height: 40, + label: 'Node', + data: {}, + parentId: undefined, + ...overrides, +}); + +describe('BESPOKE_SOCKET_POSITIONS table', () => { + it('registers exactly the bespoke iceTypes that need a custom layout', () => { + expect(Object.keys(BESPOKE_SOCKET_POSITIONS).sort()).toEqual(['Network.CustomDomain']); + }); + + it('Custom Domain resolver returns a point on the right edge for matching socketIds', () => { + const node = makeNode({ + data: { iceType: 'Network.CustomDomain', routes: [{ id: 'r1', subdomain: 'a' }] }, + }); + const point = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r1'); + expect(point).not.toBeNull(); + expect(point!.x).toBe(node.x + node.width); + expect(point!.y).toBeGreaterThan(node.y); + }); + + it('Custom Domain resolver returns null when the route id does not exist', () => { + const node = makeNode({ + data: { iceType: 'Network.CustomDomain', routes: [{ id: 'r1', subdomain: 'a' }] }, + }); + expect(BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-MISSING')).toBeNull(); + }); + + it('Custom Domain resolver returns null for non-route socket ids (fall-through)', () => { + const node = makeNode({ data: { iceType: 'Network.CustomDomain', routes: [] } }); + expect(BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'some-other-port')).toBeNull(); + }); + + it('per-route Y monotonically increases with row index', () => { + const node = makeNode({ + data: { + iceType: 'Network.CustomDomain', + routes: [ + { id: 'r1', subdomain: 'a' }, + { id: 'r2', subdomain: 'b' }, + { id: 'r3', subdomain: 'c' }, + ], + }, + }); + const y1 = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r1')!.y; + const y2 = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r2')!.y; + const y3 = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r3')!.y; + expect(y2).toBeGreaterThan(y1); + expect(y3).toBeGreaterThan(y2); + }); +}); + +describe('getSocketCanvasPosition dispatch', () => { + it('routes Custom Domain row sockets through the bespoke resolver', () => { + const node = makeNode({ + data: { iceType: 'Network.CustomDomain', routes: [{ id: 'r1', subdomain: 'a' }] }, + }); + const point = getSocketCanvasPosition(node, 'domain-out-r1'); + const bespoke = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r1'); + expect(point).toEqual(bespoke); + }); + + it('returns null for an unknown socket id on a standard typed-socket node', () => { + const node = makeNode({ data: { iceType: 'Compute.Container' } }); + expect(getSocketCanvasPosition(node, 'no-such-port')).toBeNull(); + }); + + it('returns null for an unknown iceType (no schema, no bespoke)', () => { + const node = makeNode({ data: { iceType: 'Wholly.Unknown' } }); + expect(getSocketCanvasPosition(node, 'anything')).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/path/socket-position.ts b/packages/ui/src/features/canvas/components/path/socket-position.ts new file mode 100644 index 00000000..4a35cfc5 --- /dev/null +++ b/packages/ui/src/features/canvas/components/path/socket-position.ts @@ -0,0 +1,88 @@ +/** + * Socket position — the single source of truth for where a socket dot + * lives in canvas space. + * + * Both `compute-path` (drawing the persistent edge) AND any renderer + * that places socket dots MUST go through this function so the wire + * endpoints and the visible dots agree pixel-for-pixel. Otherwise + * you get the "wire ends at a different socket than the one the user + * wired" bug — silently visually misleading. + * + * Position resolution is layered: + * 1. **Bespoke per-iceType resolvers** registered in + * `BESPOKE_SOCKET_POSITIONS`. Each entry returns a `Point` or + * `null` (signalling "not my socket, fall through"). Cardinal + * rule: dispatch reads the table generically — NO `if (iceType + * === 'X')` branches in this resolver. New bespoke layouts are + * added by registering an entry. + * 2. **Standard schema-driven layout** via `getPortAnchorPoint` — + * evenly distributes ports along their declared side, in + * declaration order. Covers every block that uses + * `` for its sockets. + */ + +import { getPortAnchorPoint, getPortsForNode, type PortDef } from '@ice/types'; +import { getCustomDomainRoutePortY } from '../nodes/custom-domain'; +import type { CanvasNode } from '../types'; +import type { Point } from './types'; + +/** + * Resolver contract for a bespoke socket-position table entry. + * Returns `null` when the socket id doesn't match this resolver's + * domain (the dispatcher then falls through to the standard layout). + */ +export type BespokeSocketResolver = (node: CanvasNode, socketId: string) => Point | null; + +/** + * Schema-shaped table of bespoke socket layouts. Dispatch iterates + * this generically — no iceType-specific branches in the resolver + * function. New bespoke renderers (e.g. a future multi-row block) + * register here; the dispatcher stays untouched. + */ +export const BESPOKE_SOCKET_POSITIONS: Record = { + // Network.CustomDomain — per-route right-edge ports. The bespoke + // renderer (`SvgCustomDomainNode`) places one dot per route at a + // hand-computed Y via `getCustomDomainRoutePortY`; resolve back to + // the same Y so the wire and the dot share coordinates. + 'Network.CustomDomain': (node, socketId) => { + if (!socketId.startsWith('domain-out-')) return null; + const routeId = socketId.slice('domain-out-'.length); + const routes = (node.data?.routes as Array<{ id: string }> | undefined) ?? []; + const rowIndex = routes.findIndex((r) => r.id === routeId); + if (rowIndex < 0) return null; + return { x: node.x + node.width, y: node.y + getCustomDomainRoutePortY(rowIndex) }; + }, +}; + +/** + * Returns the canvas-space center of a specific socket on `node`. + * Returns `null` when the socket id doesn't resolve to a known port + * (e.g. dangling edge from a removed port — the caller falls back to + * a perimeter midpoint). + */ +export function getSocketCanvasPosition(node: CanvasNode, socketId: string): Point | null { + const iceType = (node.data?.iceType as string) || ''; + + // ── Bespoke resolvers — generic dispatch via the schema-shaped table. + const bespoke = BESPOKE_SOCKET_POSITIONS[iceType]; + if (bespoke) { + const point = bespoke(node, socketId); + if (point) return point; + } + + // ── Standard typed-socket layout ───────────────────────────────── + const ports = getPortsForNode({ id: node.id, type: node.type, data: node.data }); + const port = ports.find((p) => p.id === socketId); + if (!port) return null; + return getPortAnchorPoint({ x: node.x, y: node.y, width: node.width, height: node.height }, port, ports); +} + +/** + * Convenience — find the port shape from a node's schema. Used by + * compute-path to know each endpoint's anchor side without having to + * call `findPort` separately. + */ +export function findPortOnNode(node: CanvasNode, socketId: string): PortDef | undefined { + const ports = getPortsForNode({ id: node.id, type: node.type, data: node.data }); + return ports.find((p) => p.id === socketId); +} From 441a94f06b0dbcc09055e81c0e028dcc0ef3f3eb Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:27:20 +0200 Subject: [PATCH 05/52] refactor(properties): schema-drive tab visibility + deployment-target skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item #13 — cardinal rule violation. The tab builder and the deployment-target card both branched on hardcoded iceType strings: - build-visible-tabs.ts:41-46 — config tab visible for 4 iceTypes - build-visible-tabs.ts:53 — domain tab visible for 2 iceTypes - build-visible-tabs.ts:56 — source tab visible for Source.Repository - node-properties-section.tsx:166 — deployment target hidden for 2 iceTypes Consolidated into a single declarative table `BLOCK_PROPERTY_PANEL_CONFIGS` keyed by iceType, with per-block `forceTabs` and `skipDeploymentTarget` flags. Both the builder and the panel iterate this table generically — no iceType branches remain. Adding a bespoke panel experience adds an entry; both call sites pick it up. Per-tab SECTION rendering (CustomDomainPanel, EnvVarsEditor, etc.) inside the panel body is still iceType-conditioned — covered by audit item #14 in the next commit, which extends this same config table. --- package.json | 2 +- .../sections/node-properties-section.tsx | 11 +++- .../__tests__/property-panel-config.test.ts | 45 +++++++++++++ .../properties/utils/build-visible-tabs.ts | 25 +++++--- .../properties/utils/property-panel-config.ts | 63 +++++++++++++++++++ 5 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/features/properties/utils/__tests__/property-panel-config.test.ts create mode 100644 packages/ui/src/features/properties/utils/property-panel-config.ts diff --git a/package.json b/package.json index 56837754..0a955a1b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.724", + "version": "0.1.725", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/ui/src/features/properties/components/sections/node-properties-section.tsx b/packages/ui/src/features/properties/components/sections/node-properties-section.tsx index a9e91f95..d10cb437 100644 --- a/packages/ui/src/features/properties/components/sections/node-properties-section.tsx +++ b/packages/ui/src/features/properties/components/sections/node-properties-section.tsx @@ -77,6 +77,7 @@ import { updateCardNodeData, type Card, type CardNode } from '../../../../store/ import { toggleProperties } from '../../../../store/slices/ui-slice'; import { buildVisibleTabs } from '../../utils/build-visible-tabs'; import { nodeHasSourceTab, resolveNodeIconUrl } from '../../utils/node-properties-derivations'; +import { getBlockPropertyPanelConfig } from '../../utils/property-panel-config'; import { PropertyFields } from '../fields/render-property-field'; import type { AppDispatch } from '../../../../store'; import type { CanvasIssue } from '../../../../store/slices/validation-slice'; @@ -161,9 +162,13 @@ export const NodePropertiesSection: React.FC<{ {/* ── Deployment target (provider + region) ── Hidden for symbolic block types that don't deploy to a cloud - (Source.Repository points at GitHub; Network.PublicTraffic is - a canvas-only Internet terminator). */} - {iceType !== 'Source.Repository' && iceType !== 'Network.PublicTraffic' && ( + (e.g. Source.Repository points at GitHub; Network.PublicTraffic is + a canvas-only Internet terminator). Whether a block is symbolic + is a per-iceType fact declared on the schema-shaped + `BLOCK_PROPERTY_PANEL_CONFIGS.skipDeploymentTarget` — this + render decision iterates that fact, never names a specific + iceType. */} + {!getBlockPropertyPanelConfig(iceType).skipDeploymentTarget && ( { + it('registers exactly the iceTypes that need a bespoke panel experience', () => { + expect(Object.keys(BLOCK_PROPERTY_PANEL_CONFIGS).sort()).toEqual([ + 'Config.Environment', + 'Network.CustomDomain', + 'Network.PrivateNetwork', + 'Network.PublicEndpoint', + 'Network.PublicTraffic', + 'Source.Repository', + ]); + }); + + it('forces config + domain tabs for both kinds of public DNS block', () => { + expect(BLOCK_PROPERTY_PANEL_CONFIGS['Network.PublicEndpoint'].forceTabs).toEqual(['config', 'domain']); + expect(BLOCK_PROPERTY_PANEL_CONFIGS['Network.CustomDomain'].forceTabs).toEqual(['config', 'domain']); + }); + + it('skips the deployment-target card for symbolic / GitHub-backed blocks', () => { + expect(BLOCK_PROPERTY_PANEL_CONFIGS['Source.Repository'].skipDeploymentTarget).toBe(true); + expect(BLOCK_PROPERTY_PANEL_CONFIGS['Network.PublicTraffic'].skipDeploymentTarget).toBe(true); + }); +}); + +describe('getBlockPropertyPanelConfig', () => { + it('returns the registered entry when present', () => { + expect(getBlockPropertyPanelConfig('Source.Repository').skipDeploymentTarget).toBe(true); + }); + + it('returns an empty config for unknown iceTypes (no exception)', () => { + expect(getBlockPropertyPanelConfig('Wholly.Unknown')).toEqual({}); + expect(getBlockPropertyPanelConfig('')).toEqual({}); + }); +}); diff --git a/packages/ui/src/features/properties/utils/build-visible-tabs.ts b/packages/ui/src/features/properties/utils/build-visible-tabs.ts index 06742041..8f5b20c5 100644 --- a/packages/ui/src/features/properties/utils/build-visible-tabs.ts +++ b/packages/ui/src/features/properties/utils/build-visible-tabs.ts @@ -6,8 +6,15 @@ * the ordered list of visible tabs. The orchestrator still owns the * setState-during-render fallback (BEHAVIOR-RISK FLAG #2) — see the doc * comment on `node-properties-section.tsx` for why that line stays inline. + * + * Cardinal-rule schema-driven: per-iceType tab visibility comes from + * `BLOCK_PROPERTY_PANEL_CONFIGS.forceTabs`. NO `if (iceType === 'X')` + * branches in this builder. Adding a new block that needs a forced tab + * adds an entry to the config table; this code stays unchanged. */ +import { getBlockPropertyPanelConfig, type PropertyPanelTabId } from './property-panel-config'; + export interface VisibleTab { id: string; label: string; @@ -37,23 +44,23 @@ export function buildVisibleTabs({ outgoingEdgesCount, t, }: BuildVisibleTabsArgs): VisibleTab[] { + const forced = new Set(getBlockPropertyPanelConfig(iceType).forceTabs ?? []); const tabs: VisibleTab[] = []; - if ( - dbPropertiesCount > 0 || - iceType === 'Config.Environment' || - iceType === 'Network.PublicEndpoint' || - iceType === 'Network.CustomDomain' || - iceType === 'Network.PrivateNetwork' - ) { + // Config tab: dynamic when the block has DB-declared properties OR + // when the schema-shaped table forces it for a bespoke panel. + if (dbPropertiesCount > 0 || forced.has('config')) { tabs.push({ id: 'config', label: t('properties.tabs.config'), show: true }); } if (isScalable) { tabs.push({ id: 'scaling', label: t('properties.tabs.scaling'), show: true }); } - if (iceType === 'Network.PublicEndpoint' || iceType === 'Network.CustomDomain') { + // Domain tab: schema-shaped table only — no dynamic signal drives it. + if (forced.has('domain')) { tabs.push({ id: 'domain', label: t('properties.tabs.domain'), show: true }); } - if (hasSource || iceType === 'Source.Repository') { + // Source tab: dynamic when the block participates in a build pipeline + // OR when the schema-shaped table forces it for the repo block itself. + if (hasSource || forced.has('source')) { tabs.push({ id: 'source', label: t('properties.tabs.source'), show: true }); } if (incomingEdgesCount > 0 || outgoingEdgesCount > 0) { diff --git a/packages/ui/src/features/properties/utils/property-panel-config.ts b/packages/ui/src/features/properties/utils/property-panel-config.ts new file mode 100644 index 00000000..eedb09a8 --- /dev/null +++ b/packages/ui/src/features/properties/utils/property-panel-config.ts @@ -0,0 +1,63 @@ +/** + * Per-iceType configuration for the properties panel. + * + * Cardinal-rule schema-driven dispatch. Both the visible-tabs builder + * (`build-visible-tabs.ts`) AND the per-tab section rendering inside + * `node-properties-section.tsx` read this table generically — no + * `if (iceType === 'X')` branches in either layer. + * + * The table is the single declarative fact. Adding a new bespoke + * properties experience for a block means adding an entry here; both + * the tab builder and the panel pick it up automatically. + */ + +/** + * Tab identifiers the properties panel knows about. Tab visibility is + * driven by a mix of dynamic signals (edge counts, scalable behaviour, + * deployment state) AND per-block declarations from this table. + */ +export type PropertyPanelTabId = 'config' | 'domain' | 'scaling' | 'source' | 'connections' | 'deploy'; + +export interface BlockPropertyPanelConfig { + /** + * Tabs to FORCE visible for this block regardless of dynamic signals. + * Combined with the dynamic tabs (e.g. `connections` always shows + * when edges exist). Use this when a block has zero DB-defined + * properties but still needs a config tab to host a bespoke section. + */ + forceTabs?: PropertyPanelTabId[]; + /** + * Suppress the deployment-target card (provider + region) at the top + * of the panel. Use for symbolic blocks that don't deploy to a cloud + * (Source.Repository points at GitHub; Network.PublicTraffic is + * canvas-only). + */ + skipDeploymentTarget?: boolean; +} + +export const BLOCK_PROPERTY_PANEL_CONFIGS: Record = { + 'Network.PublicEndpoint': { + forceTabs: ['config', 'domain'], + }, + 'Network.CustomDomain': { + forceTabs: ['config', 'domain'], + }, + 'Network.PrivateNetwork': { + forceTabs: ['config'], + }, + 'Network.PublicTraffic': { + skipDeploymentTarget: true, + }, + 'Config.Environment': { + forceTabs: ['config'], + }, + 'Source.Repository': { + forceTabs: ['source'], + skipDeploymentTarget: true, + }, +}; + +/** Convenience accessor — returns an empty config when no entry exists. */ +export function getBlockPropertyPanelConfig(iceType: string): BlockPropertyPanelConfig { + return BLOCK_PROPERTY_PANEL_CONFIGS[iceType] ?? {}; +} From 3c14e84dfa1618384d01511af490e58cff79db20 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:31:18 +0200 Subject: [PATCH 06/52] refactor(properties): schema-drive per-tab section dispatch via SECTION_COMPONENTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item #14 — cardinal rule violation. The panel body had six hardcoded `iceType === 'X'` branches choosing which bespoke section component to render inside which tab: - domain tab: PublicEndpointDomainSection / CustomDomainPanel - config tab: EnvVarsEditor / CustomDomainPanel / PrivateNetworkPanel / MonitoringLogSection - source tab: SourceRepositorySection - config tab fallback: SourceRepositorySection when no source tab Extended BLOCK_PROPERTY_PANEL_CONFIGS with a `sections: Record` field. Each iceType declares which sections render under which tabs; the new SECTION_COMPONENTS factory map renders them. `renderSectionsForTab(iceType, tab, ctx)` is the generic dispatcher the JSX calls — no iceType branches remain. Dropped the dead `visibleTabs.length <= 1 && iceType === 'Source. Repository'` config-tab fallback: with the source tab now always forced for that block via forceTabs, the fallback could never fire. Tests: per-iceType set + section-id-tab validity check. --- package.json | 2 +- .../sections/node-properties-section.tsx | 199 +++++++++++------- .../__tests__/property-panel-config.test.ts | 12 ++ .../properties/utils/property-panel-config.ts | 35 +++ 4 files changed, 173 insertions(+), 75 deletions(-) diff --git a/package.json b/package.json index 0a955a1b..a17fc80b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.725", + "version": "0.1.726", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/ui/src/features/properties/components/sections/node-properties-section.tsx b/packages/ui/src/features/properties/components/sections/node-properties-section.tsx index d10cb437..3cb470ba 100644 --- a/packages/ui/src/features/properties/components/sections/node-properties-section.tsx +++ b/packages/ui/src/features/properties/components/sections/node-properties-section.tsx @@ -77,7 +77,86 @@ import { updateCardNodeData, type Card, type CardNode } from '../../../../store/ import { toggleProperties } from '../../../../store/slices/ui-slice'; import { buildVisibleTabs } from '../../utils/build-visible-tabs'; import { nodeHasSourceTab, resolveNodeIconUrl } from '../../utils/node-properties-derivations'; -import { getBlockPropertyPanelConfig } from '../../utils/property-panel-config'; +import { + getBlockPropertyPanelConfig, + type PropertyPanelSectionId, + type PropertyPanelTabId, +} from '../../utils/property-panel-config'; + +// ============================================================================= +// Schema-driven per-tab section dispatch +// ============================================================================= +// +// Each entry maps a `PropertyPanelSectionId` (registered on +// `BLOCK_PROPERTY_PANEL_CONFIGS[iceType].sections[tab]`) to a factory +// that renders the corresponding section component. The panel body +// iterates this table generically — no `if (iceType === 'X')` branches +// in the JSX. Adding a new bespoke section adds an entry here AND in +// the schema config; the dispatcher stays untouched. + +interface SectionRenderCtx { + selectedNode: CardNode; + activeCard: Card; + outgoingEdges: Card['edges']; + updateNodeField: (field: string, value: unknown) => void; + dispatch: AppDispatch; + nodeRepo: string; + activeEnvName: string; +} + +type SectionFactory = (ctx: SectionRenderCtx) => React.ReactNode; + +const SECTION_COMPONENTS: Record = { + 'public-endpoint-domain': (ctx) => ( + + ), + 'custom-domain-panel': (ctx) => ( + + ), + 'private-network-panel': (ctx) => ( + + ), + 'env-vars-editor': (ctx) => ( + ) || [] + } + onChange={(vars) => ctx.updateNodeField('variables', vars)} + /> + ), + 'source-repository': (ctx) => ( + + ), + 'monitoring-log': (ctx) => , +}; + +/** + * Render every schema-declared section configured under `tab` for the + * given iceType. Returns an array of ReactNodes (one per section); + * generic iteration, no iceType-specific branches. + */ +function renderSectionsForTab(iceType: string, tab: PropertyPanelTabId, ctx: SectionRenderCtx): React.ReactNode[] { + const ids = getBlockPropertyPanelConfig(iceType).sections?.[tab] ?? []; + return ids.map((id, idx) => { + const factory = SECTION_COMPONENTS[id]; + return factory ? {factory(ctx)} : null; + }); +} import { PropertyFields } from '../fields/render-property-field'; import type { AppDispatch } from '../../../../store'; import type { CanvasIssue } from '../../../../store/slices/validation-slice'; @@ -253,18 +332,18 @@ export const NodePropertiesSection: React.FC<{ /> )} - {iceType === 'Source.Repository' && ( - - )} + {/* Bespoke sections registered for the source tab in + BLOCK_PROPERTY_PANEL_CONFIGS — currently the + Source.Repository section. */} + {renderSectionsForTab(iceType, 'source', { + selectedNode, + activeCard, + outgoingEdges, + updateNodeField, + dispatch, + nodeRepo, + activeEnvName, + })} )} @@ -273,21 +352,20 @@ export const NodePropertiesSection: React.FC<{ )} - {/* ════ DOMAIN TAB ════ */} - {activeTab === 'domain' && iceType === 'Network.PublicEndpoint' && ( - - )} - - {/* ════ CUSTOM DOMAIN — DOMAIN TAB ════ */} - {activeTab === 'domain' && iceType === 'Network.CustomDomain' && ( - - )} + {/* ════ DOMAIN TAB ════ + Bespoke sections registered for the domain tab — currently + the PublicEndpoint + CustomDomain panels. Dispatch is + schema-driven; no iceType branches. */} + {activeTab === 'domain' && + renderSectionsForTab(iceType, 'domain', { + selectedNode, + activeCard, + outgoingEdges, + updateNodeField, + dispatch, + nodeRepo, + activeEnvName, + })} {/* ════ CONNECTIONS TAB ════ */} {activeTab === 'connections' && (incomingEdges.length > 0 || outgoingEdges.length > 0) && ( @@ -353,21 +431,11 @@ export const NodePropertiesSection: React.FC<{ /> )} - {/* Source.Repository (when no tabs) */} - {visibleTabs.length <= 1 && iceType === 'Source.Repository' && ( - - )} - - {/* Source (when no tabs) */} + {/* Source (when no tabs) — kept as a dynamic fallback for + service blocks that have a connected Source.Repository + but no dedicated source tab. The Source.Repository + block itself now always has its own source tab via + BLOCK_PROPERTY_PANEL_CONFIGS.forceTabs. */} {visibleTabs.length <= 1 && hasSource && ( <> )} - {/* Environment Variables */} - {iceType === 'Config.Environment' && ( - ) || - [] - } - onChange={(vars) => updateNodeField('variables', vars)} - /> - )} - - {/* Custom Domain — config tab mirrors the domain tab so - the user sees the root domain field + subdomain - routing list as soon as they click the block. */} - {iceType === 'Network.CustomDomain' && ( - - )} - - {/* Private Network — outbound internet (egress) policy */} - {iceType === 'Network.PrivateNetwork' && ( - - )} - - {/* Monitoring.Log — streaming mode + source override + status pill */} - {iceType === 'Monitoring.Log' && } + {/* Bespoke sections registered for the config tab in + BLOCK_PROPERTY_PANEL_CONFIGS — env-vars editor for + Config.Environment, mirrored Custom Domain panel, + Private Network egress panel, Monitoring.Log section. + Dispatch is generic — no iceType branches. */} + {renderSectionsForTab(iceType, 'config', { + selectedNode, + activeCard, + outgoingEdges, + updateNodeField, + dispatch, + nodeRepo, + activeEnvName, + })} {/* Cost */} {estimatedCost && ( diff --git a/packages/ui/src/features/properties/utils/__tests__/property-panel-config.test.ts b/packages/ui/src/features/properties/utils/__tests__/property-panel-config.test.ts index 53b4c9df..951b9d86 100644 --- a/packages/ui/src/features/properties/utils/__tests__/property-panel-config.test.ts +++ b/packages/ui/src/features/properties/utils/__tests__/property-panel-config.test.ts @@ -14,6 +14,7 @@ describe('BLOCK_PROPERTY_PANEL_CONFIGS', () => { it('registers exactly the iceTypes that need a bespoke panel experience', () => { expect(Object.keys(BLOCK_PROPERTY_PANEL_CONFIGS).sort()).toEqual([ 'Config.Environment', + 'Monitoring.Log', 'Network.CustomDomain', 'Network.PrivateNetwork', 'Network.PublicEndpoint', @@ -22,6 +23,17 @@ describe('BLOCK_PROPERTY_PANEL_CONFIGS', () => { ]); }); + it('every registered section id resolves to a configured tab', () => { + // Sanity check: every section listed in the table targets one of + // the known tab ids. Catches typos before they silently no-op. + const validTabs: ReadonlySet = new Set(['config', 'domain', 'scaling', 'source', 'connections', 'deploy']); + for (const [iceType, cfg] of Object.entries(BLOCK_PROPERTY_PANEL_CONFIGS)) { + for (const tab of Object.keys(cfg.sections ?? {})) { + expect(validTabs.has(tab), `${iceType} → unknown tab "${tab}"`).toBe(true); + } + } + }); + it('forces config + domain tabs for both kinds of public DNS block', () => { expect(BLOCK_PROPERTY_PANEL_CONFIGS['Network.PublicEndpoint'].forceTabs).toEqual(['config', 'domain']); expect(BLOCK_PROPERTY_PANEL_CONFIGS['Network.CustomDomain'].forceTabs).toEqual(['config', 'domain']); diff --git a/packages/ui/src/features/properties/utils/property-panel-config.ts b/packages/ui/src/features/properties/utils/property-panel-config.ts index eedb09a8..14e415bb 100644 --- a/packages/ui/src/features/properties/utils/property-panel-config.ts +++ b/packages/ui/src/features/properties/utils/property-panel-config.ts @@ -18,6 +18,20 @@ */ export type PropertyPanelTabId = 'config' | 'domain' | 'scaling' | 'source' | 'connections' | 'deploy'; +/** + * Identifies a bespoke section component the panel can render inside a + * tab. The component itself is wired in `node-properties-section.tsx` + * via the `SECTION_COMPONENTS` factory map — this string is the + * registry key. + */ +export type PropertyPanelSectionId = + | 'public-endpoint-domain' + | 'custom-domain-panel' + | 'private-network-panel' + | 'env-vars-editor' + | 'source-repository' + | 'monitoring-log'; + export interface BlockPropertyPanelConfig { /** * Tabs to FORCE visible for this block regardless of dynamic signals. @@ -33,27 +47,48 @@ export interface BlockPropertyPanelConfig { * canvas-only). */ skipDeploymentTarget?: boolean; + /** + * Bespoke sections to render inside specific tabs. The panel renders + * each entry whose tab matches `activeTab`. Same id can appear under + * multiple tabs (e.g. Custom Domain's panel mirrors on both `config` + * and `domain`). + */ + sections?: Partial>; } export const BLOCK_PROPERTY_PANEL_CONFIGS: Record = { 'Network.PublicEndpoint': { forceTabs: ['config', 'domain'], + sections: { domain: ['public-endpoint-domain'] }, }, 'Network.CustomDomain': { forceTabs: ['config', 'domain'], + // The config tab mirrors the domain tab so the user sees the root + // domain field + subdomain routing list as soon as they click the block. + sections: { domain: ['custom-domain-panel'], config: ['custom-domain-panel'] }, }, 'Network.PrivateNetwork': { forceTabs: ['config'], + sections: { config: ['private-network-panel'] }, }, 'Network.PublicTraffic': { skipDeploymentTarget: true, }, 'Config.Environment': { forceTabs: ['config'], + sections: { config: ['env-vars-editor'] }, }, 'Source.Repository': { forceTabs: ['source'], skipDeploymentTarget: true, + // SourceRepositorySection renders inside the source tab. (The + // previous code also showed it in the config tab when no other tabs + // existed; with the source tab now always forced for this block, + // that fallback became dead code and was dropped.) + sections: { source: ['source-repository'] }, + }, + 'Monitoring.Log': { + sections: { config: ['monitoring-log'] }, }, }; From 58c048f9fe2fd75cc6fc8eba7fe924ab99cfaa9f Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:34:03 +0200 Subject: [PATCH 07/52] refactor(canvas-sizing): schema-drive bespoke node sizing via BESPOKE_NODE_SIZING table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item #16 — cardinal rule violation. computeNodeSizes had three hardcoded iceType checks driving the width/height/fold dispatch: - isCustomDomain = iceType === 'Network.CustomDomain' - isPrivateNetwork = isPrivateNetworkIce(iceType) - isCronJob = iceType === 'Compute.CronJob' Plus 4 nested ternaries threading those flags through width, height, expandedHeight, and visualHeight. Consolidated into BESPOKE_NODE_SIZING — a Record where each entry owns its width function, height function, and an `alwaysExpanded` flag that opts the block out of folding (so dynamic content like Custom Domain route slots can't collapse to a pill). The dispatcher does a single table lookup, falls through to the compact-node helpers when no bespoke entry exists, and respects `alwaysExpanded` uniformly. No iceType branches remain. Tests: locked-in entries list + alwaysExpanded invariant. --- package.json | 2 +- .../__tests__/canvas-node-sizing.test.ts | 22 ++++- .../canvas/utils/canvas-node-sizing.ts | 93 ++++++++++++------- 3 files changed, 82 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index a17fc80b..9d53c3eb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.726", + "version": "0.1.727", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/ui/src/features/canvas/utils/__tests__/canvas-node-sizing.test.ts b/packages/ui/src/features/canvas/utils/__tests__/canvas-node-sizing.test.ts index d9a1e856..fdfc4071 100644 --- a/packages/ui/src/features/canvas/utils/__tests__/canvas-node-sizing.test.ts +++ b/packages/ui/src/features/canvas/utils/__tests__/canvas-node-sizing.test.ts @@ -34,7 +34,7 @@ vi.mock('../../components/nodes/private-network', () => ({ computePrivateNetworkHeight: vi.fn((current: number) => Math.max(current, 200)), })); -import { computeNodeSizes, toLocalCanvasNode, type SizingInputNode } from '../canvas-node-sizing'; +import { BESPOKE_NODE_SIZING, computeNodeSizes, toLocalCanvasNode, type SizingInputNode } from '../canvas-node-sizing'; /** Minimal Redux-shape node factory — only the fields these utils read. */ function n(overrides: Partial & Pick): SizingInputNode { @@ -48,6 +48,26 @@ function n(overrides: Partial & Pick): S }; } +// ============================================================================= +// BESPOKE_NODE_SIZING — schema-shaped table contract +// ============================================================================= + +describe('BESPOKE_NODE_SIZING table', () => { + it('registers exactly the iceTypes that need a bespoke sizing rule', () => { + expect(Object.keys(BESPOKE_NODE_SIZING).sort()).toEqual([ + 'Compute.CronJob', + 'Network.CustomDomain', + 'Network.PrivateNetwork', + ]); + }); + + it('every entry is alwaysExpanded (dynamic content would be hidden by folding)', () => { + for (const entry of Object.values(BESPOKE_NODE_SIZING)) { + expect(entry.alwaysExpanded).toBe(true); + } + }); +}); + // ============================================================================= // computeNodeSizes — dispatch arms // ============================================================================= diff --git a/packages/ui/src/features/canvas/utils/canvas-node-sizing.ts b/packages/ui/src/features/canvas/utils/canvas-node-sizing.ts index adfb41ff..09b41628 100644 --- a/packages/ui/src/features/canvas/utils/canvas-node-sizing.ts +++ b/packages/ui/src/features/canvas/utils/canvas-node-sizing.ts @@ -1,19 +1,25 @@ /** * Pure size-computation helpers for the canvas's `nodes → canvasNodes` pipeline. * - * `computeNodeSizes` dispatches over iceType to pick the right width/height - * trio (compact / custom-domain / private-network), then folds in the - * folded-state short-circuits so callers get visually-correct dimensions: + * `computeNodeSizes` dispatches via the schema-shaped `BESPOKE_NODE_SIZING` + * table to pick the right width/height pair (compact / custom-domain / + * private-network / cron / …), then folds in the folded-state short-circuits + * so callers get visually-correct dimensions: * - * - Custom Domain + Private Network NEVER collapse to a 36/38px folded - * pill — folding them would hide the route slots which are the entire - * point of the block. Their `expandedHeight` and `visualHeight` both - * equal `defaultHeight`, so the rest of the pipeline can't observe the - * fold flag for these two iceTypes. + * - Bespoke entries with `alwaysExpanded: true` NEVER collapse to the + * 36/38px folded pill — folding them would hide their dynamic content + * (route slots, per-task ports, ingress toggle), which is the entire + * point of the block. `expandedHeight` and `visualHeight` both equal + * `defaultHeight`, so the rest of the pipeline can't observe the fold + * flag for these iceTypes. * - All other nodes use `Math.max(node.height, defaultHeight)` for * expanded height (caller-stretched containers) and a 36/38px folded * height (group=36, block/resource=38) when `node.data.folded === true`. * + * Cardinal rule: dispatch reads the schema-shaped table generically — NO + * `if (iceType === 'X')` branches in this file. New bespoke sizing is + * added by registering a table entry. + * * `toLocalCanvasNode` then projects a Redux-shape node + the precomputed * sizes into the canvas's `CanvasNode` (formerly `LocalCanvasNode`) shape, * with the verbatim fallbacks the orchestrator's inline reducer used: @@ -30,7 +36,7 @@ * (rf-canv-5). Pure — no React, no Redux, no module state. */ -import { isGroupContainer, isPrivateNetwork as isPrivateNetworkIce } from './node-classification'; +import { isGroupContainer } from './node-classification'; import { computeCompactNodeHeight, computeCompactNodeWidth } from '../components/nodes/compact-node'; import { computeCustomDomainHeight, computeCustomDomainWidth } from '../components/nodes/custom-domain'; import { computePrivateNetworkHeight, computePrivateNetworkWidth } from '../components/nodes/private-network'; @@ -56,6 +62,41 @@ export interface NodeSizes { visualHeight: number; } +/** + * Sizing contract for a bespoke block renderer. `width`/`height` close + * over the renderer's own dynamic state (route count, task count, etc.); + * `alwaysExpanded: true` opts out of folding so the block can't collapse + * to a pill that hides its dynamic content. + */ +export interface BespokeSizingEntry { + width: (node: SizingInputNode, nodeData: Record) => number; + height: (node: SizingInputNode, nodeData: Record) => number; + alwaysExpanded: boolean; +} + +/** + * Schema-shaped table of bespoke node sizing. The dispatcher iterates + * this generically — no iceType branches. Adding a new bespoke + * renderer's sizing rules adds an entry; this file stays unchanged. + */ +export const BESPOKE_NODE_SIZING: Record = { + 'Network.CustomDomain': { + width: () => computeCustomDomainWidth(), + height: (_node, nodeData) => computeCustomDomainHeight(nodeData), + alwaysExpanded: true, + }, + 'Network.PrivateNetwork': { + width: (node) => computePrivateNetworkWidth(node.width || 0), + height: (node) => computePrivateNetworkHeight(node.height || 0), + alwaysExpanded: true, + }, + 'Compute.CronJob': { + width: () => computeCronJobWidth(), + height: (_node, nodeData) => computeCronJobHeight(nodeData), + alwaysExpanded: true, + }, +}; + /** * Verbatim port of the inline `defaultWidth`/`defaultHeight`/`expandedHeight`/ * `visualHeight` reducer (svg-canvas.tsx L437–459). `hasPipelineStatus` @@ -64,36 +105,22 @@ export interface NodeSizes { */ export function computeNodeSizes(node: SizingInputNode, hasPipelineStatus: boolean): NodeSizes { const iceType = (node.data?.iceType as string) || 'Resource.Unknown'; - const isCustomDomain = iceType === 'Network.CustomDomain'; - const isPrivateNetwork = isPrivateNetworkIce(iceType); - const isCronJob = iceType === 'Compute.CronJob'; const isGroup = isGroupContainer(node); const isBlock = node.type === 'block'; const folded = !!node.data?.folded; const nodeData = (node.data as Record) || {}; - const defaultWidth = isCustomDomain - ? computeCustomDomainWidth() - : isPrivateNetwork - ? computePrivateNetworkWidth(node.width || 0) - : isCronJob - ? computeCronJobWidth() - : computeCompactNodeWidth(isBlock || isGroup); - const defaultHeight = isCustomDomain - ? computeCustomDomainHeight(nodeData) - : isPrivateNetwork - ? computePrivateNetworkHeight(node.height || 0) - : isCronJob - ? computeCronJobHeight(nodeData) - : computeCompactNodeHeight(nodeData, isBlock || isGroup, hasPipelineStatus); + const bespoke = BESPOKE_NODE_SIZING[iceType]; + const defaultWidth = bespoke ? bespoke.width(node, nodeData) : computeCompactNodeWidth(isBlock || isGroup); + const defaultHeight = bespoke + ? bespoke.height(node, nodeData) + : computeCompactNodeHeight(nodeData, isBlock || isGroup, hasPipelineStatus); - // Cron, like custom-domain, has dynamic height tied to its task count. - // We never let folding collapse it to a 38px pill — folding hides the - // per-task port circles, which are the entire point of the block. - const expandedHeight = - isCustomDomain || isPrivateNetwork || isCronJob ? defaultHeight : Math.max(node.height || 0, defaultHeight); - const visualHeight = - folded && !isCustomDomain && !isPrivateNetwork && !isCronJob ? (isGroup ? 36 : 38) : expandedHeight; + // `alwaysExpanded` blocks ignore caller-stretched height AND folding — + // their height is whatever the bespoke renderer says, full stop. + const alwaysExpanded = bespoke?.alwaysExpanded ?? false; + const expandedHeight = alwaysExpanded ? defaultHeight : Math.max(node.height || 0, defaultHeight); + const visualHeight = folded && !alwaysExpanded ? (isGroup ? 36 : 38) : expandedHeight; return { defaultWidth, defaultHeight, expandedHeight, visualHeight }; } From fbae1d34ffc1cff4bf28ad80e20dad0a50f97fc0 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:37:20 +0200 Subject: [PATCH 08/52] refactor(deploy/edge-classifier): schema-drive isolation + standalone classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit items #5 + #6 — cardinal rule violations. Three hardcoded iceType checks in cross-cutting classifier code: - edge-classifier.ts:65 — `parent.data?.iceType === 'Network.PrivateNetwork'` in the ancestor walk - edge-classifier.ts:90 — guard: `iceType !== 'Network.CustomDomain'` - edge-classifier.ts:93 — `parent.iceType !== 'Network.PrivateNetwork'` in the standalone-mode check Introduced `BLOCK_DEPLOY_CLASSIFIERS` — a per-iceType flag table with two flags: - `isolatesNetworkContext`: this iceType is a network-isolation container (services nested inside should be internal-only) - `metadataOnlyWhenStandalone`: this iceType has two deploy modes based on parent context (metadata-only standalone vs. deployable when nested in an isolation container) Renamed predicates to match the generic shape: - `hasPrivateNetworkAncestor` → `hasNetworkIsolatingAncestor` - `isCustomDomainStandalone` → `isStandaloneMetadataOnly` Old names kept as `@deprecated` aliases so external callers and tests don't break. Card-translator call sites switched to the new names. Adding a new isolation container or a new standalone/nested block adds a table entry; classifier code stays unchanged. --- package.json | 2 +- .../block-deploy-classifiers.test.ts | 33 +++++++++ .../src/deploy/block-deploy-classifiers.ts | 52 ++++++++++++++ packages/core/src/deploy/card-translator.ts | 30 ++++---- packages/core/src/deploy/edge-classifier.ts | 69 ++++++++++++------- 5 files changed, 146 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts create mode 100644 packages/core/src/deploy/block-deploy-classifiers.ts diff --git a/package.json b/package.json index 9d53c3eb..4d3a44bf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.727", + "version": "0.1.728", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts b/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts new file mode 100644 index 00000000..515fcb5e --- /dev/null +++ b/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for `block-deploy-classifiers` — the per-iceType flag table + * the deploy-side classifiers read. + * + * Cardinal rule check: the table is the single declarative fact for + * "this iceType isolates network context" and "this iceType has a + * standalone/nested duality". Classifier code reads these flags + * generically; it MUST NOT name a specific iceType. + */ + +import { describe, it, expect } from 'vitest'; +import { BLOCK_DEPLOY_CLASSIFIERS, getBlockDeployClassifiers } from '../block-deploy-classifiers'; + +describe('BLOCK_DEPLOY_CLASSIFIERS', () => { + it('marks Network.PrivateNetwork as a network-isolation container', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.PrivateNetwork'].isolatesNetworkContext).toBe(true); + }); + + it('marks Network.CustomDomain as having standalone/nested duality', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.CustomDomain'].metadataOnlyWhenStandalone).toBe(true); + }); +}); + +describe('getBlockDeployClassifiers', () => { + it('returns the registered entry when present', () => { + expect(getBlockDeployClassifiers('Network.PrivateNetwork').isolatesNetworkContext).toBe(true); + }); + + it('returns an empty object for unknown iceTypes (safe to read flags)', () => { + expect(getBlockDeployClassifiers('Wholly.Unknown')).toEqual({}); + expect(getBlockDeployClassifiers('').isolatesNetworkContext).toBeUndefined(); + }); +}); diff --git a/packages/core/src/deploy/block-deploy-classifiers.ts b/packages/core/src/deploy/block-deploy-classifiers.ts new file mode 100644 index 00000000..e5b1eea9 --- /dev/null +++ b/packages/core/src/deploy/block-deploy-classifiers.ts @@ -0,0 +1,52 @@ +/** + * Per-iceType deploy classification flags. + * + * Cardinal-rule schema-driven dispatch. The edge classifier reads from + * this table generically — no `if (iceType === 'X')` branches in the + * classifier functions. Adding a new block whose iceType changes deploy + * shape based on context (network isolation, parent nesting, metadata- + * only behaviour) adds an entry here; classifier code stays unchanged. + * + * Why this lives in core/deploy and not on HighLevelResource: the + * `Network.PrivateNetwork` and `Network.CustomDomain` iceTypes aren't + * declared in `HIGH_LEVEL_CATEGORIES` (they're authored as blueprints + * in `@ice/blocks`). Promoting them into HIGH_LEVEL_CATEGORIES is a + * larger change with palette/properties side-effects; for the + * classifier's narrow needs a sibling deploy-side table is the + * smallest correct unit of schema declaration. + */ + +export interface BlockDeployClassifiers { + /** + * The block represents a network-isolation boundary (a VPC, Private + * Network, etc.). Services nested inside it should compile to the + * internal-only variant of their underlying compute resource — see + * the card-translator's ingress-override branch. + */ + isolatesNetworkContext?: boolean; + /** + * The block has TWO deploy modes depending on parent context: + * - STANDALONE (no parent in an isolating container): metadata-only, + * consumed by downstream propagation passes but emits no cloud + * resource of its own. + * - NESTED inside an isolating container: compiles to a real cloud + * resource (its provider type-map entry). + * Example: Network.CustomDomain — standalone = host propagation only, + * nested inside Network.PrivateNetwork = LB ingress chain. + */ + metadataOnlyWhenStandalone?: boolean; +} + +export const BLOCK_DEPLOY_CLASSIFIERS: Record = { + 'Network.PrivateNetwork': { + isolatesNetworkContext: true, + }, + 'Network.CustomDomain': { + metadataOnlyWhenStandalone: true, + }, +}; + +/** Convenience accessor — empty object for unknown iceTypes. */ +export function getBlockDeployClassifiers(iceType: string): BlockDeployClassifiers { + return BLOCK_DEPLOY_CLASSIFIERS[iceType] ?? {}; +} diff --git a/packages/core/src/deploy/card-translator.ts b/packages/core/src/deploy/card-translator.ts index dd4d3446..c4123929 100644 --- a/packages/core/src/deploy/card-translator.ts +++ b/packages/core/src/deploy/card-translator.ts @@ -9,8 +9,8 @@ import { UI_ONLY_TYPES, SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS, EXTERNAL_TYPES, - hasPrivateNetworkAncestor, - isCustomDomainStandalone, + hasNetworkIsolatingAncestor, + isStandaloneMetadataOnly, map_edge_relationship, } from './edge-classifier'; import { create_mutable_graph } from '../graph/mutable-graph'; @@ -196,14 +196,16 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl continue; } - // Standalone Network.CustomDomain is UI-only (metadata for Pass 1.6 - // propagation). Nested inside a PrivateNetwork it becomes deployable - // — see isCustomDomainStandalone + the dynamic type lookup below. - if (isCustomDomainStandalone(node, nodes)) { + // Blocks declared with `metadataOnlyWhenStandalone` (today: Network. + // CustomDomain) are UI-only when not nested in an isolation container + // — downstream propagation passes consume them, no cloud resource + // emitted. Nested inside an isolation container they become + // deployable via the dynamic type lookup below. + if (isStandaloneMetadataOnly(node, nodes)) { skipped.push({ nodeId: node.id, label: (node.data.label as string) || node.id, - reason: 'Standalone Network.CustomDomain is metadata-only (handled by Pass 1.6)', + reason: `Standalone ${ice_type} is metadata-only (handled by downstream propagation passes)`, }); continue; } @@ -300,15 +302,17 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl continue; } - // Private Network ingress override. + // Network-isolation ingress override. // // When a service backend (Scalable Backend / SSR Site / Worker / - // Serverless Function) is nested inside a Network.PrivateNetwork, - // emit the internal-only variant of the underlying compute resource. - // A nested Custom Domain (if present) remains the sole external - // entry point via its own LB chain; see isCustomDomainStandalone + + // Serverless Function) is nested inside a network-isolation + // container (today: Network.PrivateNetwork; schema-declared via + // BLOCK_DEPLOY_CLASSIFIERS.isolatesNetworkContext), emit the + // internal-only variant of the underlying compute resource. A + // nested ingress block (if present) remains the sole external + // entry point via its own LB chain; see isStandaloneMetadataOnly + // the backend-wiring at ~line 1100. - if (SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS.has(ice_type) && hasPrivateNetworkAncestor(node, nodes)) { + if (SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS.has(ice_type) && hasNetworkIsolatingAncestor(node, nodes)) { const props = properties as Record; if (gcp_type === 'gcp.run.service') { // Internal Cloud Run — only reachable via VPC or internal LB. diff --git a/packages/core/src/deploy/edge-classifier.ts b/packages/core/src/deploy/edge-classifier.ts index 88b87a58..67fd95fd 100644 --- a/packages/core/src/deploy/edge-classifier.ts +++ b/packages/core/src/deploy/edge-classifier.ts @@ -3,10 +3,17 @@ * * Bundles the predicates and constants the translator uses to decide * which canvas nodes compile to real cloud resources, which act as - * backends behind a Private Network override, and how raw edge + * backends behind a network-isolation override, and how raw edge * relationship strings resolve to typed `EdgeRelationship` values. + * + * Cardinal rule: the ancestor walk + standalone-mode predicates read + * `BLOCK_DEPLOY_CLASSIFIERS` (a per-iceType flag table) instead of + * naming specific iceTypes. Adding a new isolation container or a new + * block with standalone/nested duality adds a table entry; the + * classifier functions stay unchanged. */ +import { getBlockDeployClassifiers } from './block-deploy-classifiers'; import type { EdgeRelationship } from '../types/graph'; // iceTypes that are UI-only and should not be deployed @@ -40,19 +47,18 @@ export const SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS = new Set([ ]); /** - * Walk the parent chain to check whether any ancestor is a Private Network. + * Walk the parent chain to check whether any ancestor is a network- + * isolation container (today: Network.PrivateNetwork; tomorrow: any + * iceType the schema-shaped table declares with + * `isolatesNetworkContext: true`). * - * When a service backend (Compute.Container / SSR / Worker / etc.) is nested - * inside a Network.PrivateNetwork, the compiler should emit the internal-only - * variant of the underlying compute resource: - * - GCP Cloud Run: ingress = 'internal-and-cloud-load-balancing' - * - AWS ECS: no public ALB; rely on nested Custom Domain for ingress + * When a service backend is nested inside one, the compiler emits the + * internal-only variant of the underlying compute resource: + * - GCP Cloud Run: ingress = 'internal-and-cloud-load-balancing' + * - AWS ECS: no public ALB; rely on nested ingress block * - Azure Container App: internal ingress - * - * The nested Custom Domain (if present) acts as the sole external entry - * point via its own LB chain — see lines 957-970 for that path. */ -export function hasPrivateNetworkAncestor( +export function hasNetworkIsolatingAncestor( node: { id: string; parentId?: string | null }, allNodes: Array<{ id: string; parentId?: string | null; data: Record }>, ): boolean { @@ -62,37 +68,48 @@ export function hasPrivateNetworkAncestor( visited.add(currentParentId); const parent = allNodes.find((n) => n.id === currentParentId); if (!parent) return false; - if (parent.data?.iceType === 'Network.PrivateNetwork') return true; + const parentIceType = (parent.data?.iceType as string) || ''; + if (getBlockDeployClassifiers(parentIceType).isolatesNetworkContext) return true; currentParentId = parent.parentId; } return false; } /** - * Network.CustomDomain has two modes: + * Blocks declared with `metadataOnlyWhenStandalone: true` have TWO + * deploy modes: * - * 1. STANDALONE (no parent, or parent is not a PrivateNetwork): - * metadata-only — it carries a root domain + per-edge subdomains - * and is consumed by Pass 1.6 to propagate the full host onto - * each connected target's `domain` property. Firebase Hosting - * (et al.) then registers the custom domain via its native API. - * NO dedicated resource is deployed. + * 1. STANDALONE (no parent, or parent is NOT a network-isolation + * container): metadata-only. The node is consumed by downstream + * propagation passes but does NOT emit a cloud resource. * - * 2. NESTED inside a Network.PrivateNetwork: the CD is that - * network's public ingress gateway. It compiles to the full LB - * chain (forwarding rule + URL map + backend services) targeting - * sibling services inside the parent VPC. + * 2. NESTED inside an isolation container: compiles to the real + * cloud resource (full LB chain in the Custom-Domain-in-Private- + * Network case). + * + * Returns true ONLY when both conditions hold: the node's iceType + * has the duality flag AND there is no isolation-container parent. */ -export function isCustomDomainStandalone( +export function isStandaloneMetadataOnly( node: { data: Record; parentId?: string | null }, allNodes: Array<{ id: string; data: Record }>, ): boolean { - if (node.data?.iceType !== 'Network.CustomDomain') return false; + const iceType = (node.data?.iceType as string) || ''; + if (!getBlockDeployClassifiers(iceType).metadataOnlyWhenStandalone) return false; if (!node.parentId) return true; const parent = allNodes.find((n) => n.id === node.parentId); - return parent?.data?.iceType !== 'Network.PrivateNetwork'; + if (!parent) return true; + const parentIceType = (parent.data?.iceType as string) || ''; + return !getBlockDeployClassifiers(parentIceType).isolatesNetworkContext; } +// Kept temporarily for callers that haven't switched names — both +// re-export the same body so the rename is risk-free. +/** @deprecated Use `hasNetworkIsolatingAncestor`. */ +export const hasPrivateNetworkAncestor = hasNetworkIsolatingAncestor; +/** @deprecated Use `isStandaloneMetadataOnly`. */ +export const isCustomDomainStandalone = isStandaloneMetadataOnly; + // iceTypes that are external services (not GCP-managed) export const EXTERNAL_TYPES = new Set(['Database.MongoDB']); From 8bd7a3608e10569081fe46ad10472f01888250a5 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:40:54 +0200 Subject: [PATCH 09/52] refactor(deploy/passes): schema-drive public-ingress detection + domain propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit items #7 + #8 — cardinal rule violations in two passes: - pass-1-5-endpoint-wiring.ts:107-114 — inline `isEndpointIceType` branched on Network.PublicEndpoint AND Network.CustomDomain (with nested-in-PrivateNetwork check) to identify ingress endpoints - pass-1-45-domain-propagation.ts:55-58 — hardcoded srcIce / dstIce === 'Network.CustomDomain' to route domain propagation Extended BLOCK_DEPLOY_CLASSIFIERS with two new flags: - `publicIngressMode`: 'always' (PublicEndpoint) or 'when-nested-in-isolated-network' (CustomDomain — only counts as ingress when nested inside an isolatesNetworkContext container) - `isDomainPropagator`: true for CustomDomain — generic name so any future domain-source block can flow through the same pass Added generic `isPublicIngressNode(node, allNodes)` predicate to edge-classifier that reads the flag table. Refactored pass-1-5 to call it; pass-1-45 reads `isDomainPropagator` directly. No iceType strings remain in either pass. Tests: 3 new flag assertions + 5 new isPublicIngressNode behaviour cases (always-mode, standalone CD, nested-in-isolated-network, nested- in-non-isolation, plain compute). --- package.json | 2 +- .../block-deploy-classifiers.test.ts | 12 +++++++ .../deploy/__tests__/edge-classifier.test.ts | 29 ++++++++++++++++ .../src/deploy/block-deploy-classifiers.ts | 23 +++++++++++++ packages/core/src/deploy/edge-classifier.ts | 30 +++++++++++++++++ .../passes/pass-1-45-domain-propagation.ts | 11 +++++-- .../deploy/passes/pass-1-5-endpoint-wiring.ts | 33 +++++++------------ 7 files changed, 116 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 4d3a44bf..c65a5753 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.728", + "version": "0.1.729", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts b/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts index 515fcb5e..85721bfa 100644 --- a/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts +++ b/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts @@ -19,6 +19,18 @@ describe('BLOCK_DEPLOY_CLASSIFIERS', () => { it('marks Network.CustomDomain as having standalone/nested duality', () => { expect(BLOCK_DEPLOY_CLASSIFIERS['Network.CustomDomain'].metadataOnlyWhenStandalone).toBe(true); }); + + it('marks Network.PublicEndpoint as an always-public-ingress block', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.PublicEndpoint'].publicIngressMode).toBe('always'); + }); + + it('marks Network.CustomDomain as ingress only when nested in an isolation container', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.CustomDomain'].publicIngressMode).toBe('when-nested-in-isolated-network'); + }); + + it('marks Network.CustomDomain as the domain propagator', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.CustomDomain'].isDomainPropagator).toBe(true); + }); }); describe('getBlockDeployClassifiers', () => { diff --git a/packages/core/src/deploy/__tests__/edge-classifier.test.ts b/packages/core/src/deploy/__tests__/edge-classifier.test.ts index 9112c174..22b6ab82 100644 --- a/packages/core/src/deploy/__tests__/edge-classifier.test.ts +++ b/packages/core/src/deploy/__tests__/edge-classifier.test.ts @@ -19,9 +19,38 @@ import { EXTERNAL_TYPES, hasPrivateNetworkAncestor, isCustomDomainStandalone, + isPublicIngressNode, map_edge_relationship, } from '../edge-classifier'; +describe('isPublicIngressNode', () => { + it('returns true for Network.PublicEndpoint regardless of nesting', () => { + const node = { data: { iceType: 'Network.PublicEndpoint' } }; + expect(isPublicIngressNode(node, [])).toBe(true); + }); + + it('returns false for standalone Network.CustomDomain', () => { + const node = { data: { iceType: 'Network.CustomDomain' } }; + expect(isPublicIngressNode(node, [])).toBe(false); + }); + + it('returns true for Network.CustomDomain nested inside Network.PrivateNetwork', () => { + const parent = { id: 'pn1', data: { iceType: 'Network.PrivateNetwork' } }; + const node = { parentId: 'pn1', data: { iceType: 'Network.CustomDomain' } }; + expect(isPublicIngressNode(node, [parent])).toBe(true); + }); + + it('returns false for Network.CustomDomain nested inside a non-isolation parent', () => { + const parent = { id: 'g1', data: { iceType: 'Group.Network' } }; + const node = { parentId: 'g1', data: { iceType: 'Network.CustomDomain' } }; + expect(isPublicIngressNode(node, [parent])).toBe(false); + }); + + it('returns false for plain compute nodes (no publicIngressMode flag)', () => { + expect(isPublicIngressNode({ data: { iceType: 'Compute.Container' } }, [])).toBe(false); + }); +}); + describe('UI_ONLY_TYPES', () => { it('contains exactly 3 entries', () => { expect(UI_ONLY_TYPES.size).toBe(3); diff --git a/packages/core/src/deploy/block-deploy-classifiers.ts b/packages/core/src/deploy/block-deploy-classifiers.ts index e5b1eea9..6d3faaf5 100644 --- a/packages/core/src/deploy/block-deploy-classifiers.ts +++ b/packages/core/src/deploy/block-deploy-classifiers.ts @@ -35,14 +35,37 @@ export interface BlockDeployClassifiers { * nested inside Network.PrivateNetwork = LB ingress chain. */ metadataOnlyWhenStandalone?: boolean; + /** + * Whether this block acts as a public ingress endpoint (the head of + * a load-balancer chain backed by compute services). + * - 'always': the block is ALWAYS a public ingress (e.g. + * Network.PublicEndpoint). + * - 'when-nested-in-isolated-network': only counts as ingress when + * nested inside a block with `isolatesNetworkContext` (e.g. + * Network.CustomDomain inside Network.PrivateNetwork acts as the + * network's gateway; standalone CD stays DNS-only). + */ + publicIngressMode?: 'always' | 'when-nested-in-isolated-network'; + /** + * The block carries a `domain` (root) + `routes` (per-subdomain rows) + * and propagates the full host onto every connected compute target. + * Read by the domain-propagation pass; iterating this flag lets new + * domain-source blocks slot in without touching the pass code. + */ + isDomainPropagator?: boolean; } export const BLOCK_DEPLOY_CLASSIFIERS: Record = { 'Network.PrivateNetwork': { isolatesNetworkContext: true, }, + 'Network.PublicEndpoint': { + publicIngressMode: 'always', + }, 'Network.CustomDomain': { metadataOnlyWhenStandalone: true, + publicIngressMode: 'when-nested-in-isolated-network', + isDomainPropagator: true, }, }; diff --git a/packages/core/src/deploy/edge-classifier.ts b/packages/core/src/deploy/edge-classifier.ts index 67fd95fd..f578ef3f 100644 --- a/packages/core/src/deploy/edge-classifier.ts +++ b/packages/core/src/deploy/edge-classifier.ts @@ -103,6 +103,36 @@ export function isStandaloneMetadataOnly( return !getBlockDeployClassifiers(parentIceType).isolatesNetworkContext; } +/** + * Whether a node is a public-ingress endpoint (the head of a load + * balancer chain). Schema-shaped: reads `publicIngressMode` from + * `BLOCK_DEPLOY_CLASSIFIERS`. + * + * - `publicIngressMode === 'always'`: this iceType is always ingress + * (e.g. Network.PublicEndpoint). + * - `publicIngressMode === 'when-nested-in-isolated-network'`: ingress + * only when nested inside an isolation container (Network. + * CustomDomain inside Network.PrivateNetwork acts as the network's + * gateway; standalone CD stays DNS-only). + * - any other value (or unset): never an ingress endpoint. + */ +export function isPublicIngressNode( + node: { data: Record; parentId?: string | null }, + allNodes: Array<{ id: string; data: Record }>, +): boolean { + const iceType = (node.data?.iceType as string) || ''; + const mode = getBlockDeployClassifiers(iceType).publicIngressMode; + if (mode === 'always') return true; + if (mode === 'when-nested-in-isolated-network') { + if (!node.parentId) return false; + const parent = allNodes.find((n) => n.id === node.parentId); + if (!parent) return false; + const parentIceType = (parent.data?.iceType as string) || ''; + return !!getBlockDeployClassifiers(parentIceType).isolatesNetworkContext; + } + return false; +} + // Kept temporarily for callers that haven't switched names — both // re-export the same body so the rename is risk-free. /** @deprecated Use `hasNetworkIsolatingAncestor`. */ diff --git a/packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts b/packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts index a3004bc4..428ac07f 100644 --- a/packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts +++ b/packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts @@ -8,6 +8,7 @@ * contract. */ +import { getBlockDeployClassifiers } from '../block-deploy-classifiers'; import type { MutableGraph } from '../../graph/mutable-graph'; import type { CardEdgeInput, CardNodeInput } from '../card-translator'; @@ -50,12 +51,18 @@ export function propagate_custom_domain_hosts( if (!src || !dst) continue; const srcIce = (src.data?.iceType as string) || ''; const dstIce = (dst.data?.iceType as string) || ''; + // Schema-driven: a "domain propagator" is any block whose iceType + // is flagged with `isDomainPropagator` in BLOCK_DEPLOY_CLASSIFIERS. + // Adding a new domain-source block adds a table entry; this pass + // stays unchanged. + const srcIsDomain = !!getBlockDeployClassifiers(srcIce).isDomainPropagator; + const dstIsDomain = !!getBlockDeployClassifiers(dstIce).isDomainPropagator; let domainNode: typeof src; let targetNode: typeof src; - if (srcIce === 'Network.CustomDomain') { + if (srcIsDomain) { domainNode = src; targetNode = dst; - } else if (dstIce === 'Network.CustomDomain') { + } else if (dstIsDomain) { domainNode = dst; targetNode = src; } else { diff --git a/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts b/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts index b66ce3f7..213a6e29 100644 --- a/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts +++ b/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts @@ -14,6 +14,7 @@ * the counter, only the graph + deployables array. */ +import { isPublicIngressNode } from '../edge-classifier'; import { sanitize_name, sanitize_label_value } from '../utils/name-utils'; import type { MutableGraph } from '../../graph/mutable-graph'; import type { CardEdgeInput, CardNodeInput, DeployableNodeInfo } from '../card-translator'; @@ -95,32 +96,22 @@ export function wire_public_endpoints(args: { // Map every PublicEndpoint node to its connected backends. const endpointToBackends = new Map(); - // Match both PublicEndpoint AND CustomDomain-nested-inside-PrivateNetwork - // as endpoint blocks. Both compile to gcp.compute.globalForwardingRule. - // - // - PublicEndpoint: standalone public LB for VPC-internal services. - // - CustomDomain nested inside PrivateNetwork: the nested CD acts as - // the PrivateNetwork's public gateway, compiling to the same LB - // chain but targeting sibling services inside the parent VPC. - // Standalone CustomDomain (no parent) stays DNS-only and is NOT an - // endpoint — it's handled in Pass 1.6 instead. - const isEndpointIceType = (t: string, node?: { parentId?: string | null }) => { - if (t === 'Network.PublicEndpoint') return true; - if (t === 'Network.CustomDomain' && node?.parentId) { - const parent = nodes.find((n) => n.id === node.parentId); - return parent?.data?.iceType === 'Network.PrivateNetwork'; - } - return false; - }; + // Public-ingress detection is schema-driven via + // `BLOCK_DEPLOY_CLASSIFIERS.publicIngressMode`: + // - 'always' (Network.PublicEndpoint): a standalone public LB. + // - 'when-nested-in-isolated-network' (Network.CustomDomain inside + // Network.PrivateNetwork): the nested CD acts as the network's + // public gateway, compiling to the same LB chain. Standalone CD + // stays DNS-only and is handled in Pass 1.6 instead. + // No iceType strings appear here — adding a new ingress block adds a + // table entry in `block-deploy-classifiers.ts`. for (const edge of edges) { const src = nodes.find((n) => n.id === edge.source); const dst = nodes.find((n) => n.id === edge.target); if (!src || !dst) continue; - const srcIce = (src.data?.iceType as string) || ''; - const dstIce = (dst.data?.iceType as string) || ''; - const srcIsEndpoint = isEndpointIceType(srcIce, src); - const dstIsEndpoint = isEndpointIceType(dstIce, dst); + const srcIsEndpoint = isPublicIngressNode(src, nodes); + const dstIsEndpoint = isPublicIngressNode(dst, nodes); if (!srcIsEndpoint && !dstIsEndpoint) continue; const endpointNode = srcIsEndpoint ? src : dst; From 39bb4e00a50a93d0b4e114c685845f7ec217164e Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:43:52 +0200 Subject: [PATCH 10/52] refactor(deploy/security-rules): schema-drive iceType classifiers via SECURITY_ROLES table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item #15 — cardinal rule violation. The pre-deploy security scanner had 9+ tiny iceType-comparing classifier functions (`isDatabase`, `isStorage`, `isGateway`, `isService`, `isAuth`, `isSecret`, `isMonitoring`, `isVpc`, `isSubnet`, `isPrivateNetwork`, `isVpcLike`), each inlining its own `iceType === 'X'` check. Consolidated into a schema-shaped role table: - `SECURITY_ROLES_BY_ICE_TYPE`: per-iceType role list - `SECURITY_ROLES_BY_PREFIX`: category-prefix inheritance (Database./Compute./Monitoring.* automatically pick up their role without per-iceType table edits) - `hasSecurityRole(iceType, role)`: the single lookup function the classifier readers call Classifier functions remain as thin one-line role readers; rule evaluation code is unchanged. New blocks that need a security role add an entry to the table. Split the previous `isVpcLike` into two distinct roles: `isolatesNestedChildren` (VPC + Subnet + PrivateNetwork — used by the ancestor check) and `topLevelNetworkBoundary` (VPC + PrivateNetwork only — used by Rule 6, where a Subnet at the canvas root doesn't isolate anything). The original code conflated these into a single overloaded helper. --- package.json | 2 +- .../features/deploy/utils/security-rules.ts | 109 ++++++++++++------ 2 files changed, 72 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index c65a5753..a518a631 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.729", + "version": "0.1.730", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/ui/src/features/deploy/utils/security-rules.ts b/packages/ui/src/features/deploy/utils/security-rules.ts index e94b1c02..6d2249bd 100644 --- a/packages/ui/src/features/deploy/utils/security-rules.ts +++ b/packages/ui/src/features/deploy/utils/security-rules.ts @@ -19,43 +19,75 @@ export interface PreDeployWarning { dismissible: boolean; } -// ─── Classifiers ──────────────────────────────────────────────────────────── - -const isDatabase = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType.startsWith('Database.'); -}; - -const isStorage = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType === 'Storage.Bucket'; -}; - -const isGateway = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType === 'Network.Gateway'; +// ─── Schema-shaped security role table ────────────────────────────────────── +// +// Per-iceType + per-category-prefix declarations of which security role +// each block plays. The rule evaluator (below) consults this generically +// — no iceType strings appear in rule code. New blocks join a role by +// adding a table entry or by inheriting a category prefix (e.g. any new +// `Database.*` iceType is automatically a database for security purposes). + +type SecurityRole = + | 'database' + | 'storage' + | 'gateway' + | 'compute' + | 'auth' + | 'secretManager' + | 'monitoringSink' + /** Satisfies "this node is nested inside an isolation container" — VPC, + * Subnet (always inside a VPC), or PrivateNetwork. */ + | 'isolatesNestedChildren' + /** Top-level boundary you can sensibly drop at the canvas root to + * isolate everything inside it — VPC or PrivateNetwork. Subnet alone + * does NOT count: a Subnet at the root is meaningless without a VPC + * parent. */ + | 'topLevelNetworkBoundary'; + +const SECURITY_ROLES_BY_ICE_TYPE: Record> = { + 'Storage.Bucket': ['storage'], + 'Network.Gateway': ['gateway'], + 'Security.Identity': ['auth'], + 'Security.Secret': ['secretManager'], + // Three iceTypes act as "inside a private network" for the ancestor + // check — the high-level PrivateNetwork (auto-mode VPC) plus the + // explicit VPC + Subnet primitives. Only VPC + PrivateNetwork are + // top-level boundaries (a lone Subnet at the canvas root doesn't + // isolate anything). + 'Network.VPC': ['isolatesNestedChildren', 'topLevelNetworkBoundary'], + 'Network.Subnet': ['isolatesNestedChildren'], + 'Network.PrivateNetwork': ['isolatesNestedChildren', 'topLevelNetworkBoundary'], }; -const isService = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType.startsWith('Compute.'); -}; +// Category-prefix inheritance. Any iceType matching one of these prefixes +// gets the corresponding role for free, so adding a new database/compute/ +// monitoring block doesn't require a table edit. +const SECURITY_ROLES_BY_PREFIX: ReadonlyArray<{ prefix: string; role: SecurityRole }> = [ + { prefix: 'Database.', role: 'database' }, + { prefix: 'Compute.', role: 'compute' }, + { prefix: 'Monitoring.', role: 'monitoringSink' }, +]; + +function hasSecurityRole(iceType: string, role: SecurityRole): boolean { + if (SECURITY_ROLES_BY_ICE_TYPE[iceType]?.includes(role)) return true; + for (const entry of SECURITY_ROLES_BY_PREFIX) { + if (entry.role === role && iceType.startsWith(entry.prefix)) return true; + } + return false; +} -const isAuth = (n: CardNode): boolean => (n.data?.iceType as string) === 'Security.Identity'; -const isSecret = (n: CardNode): boolean => (n.data?.iceType as string) === 'Security.Secret'; -const isMonitoring = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType.startsWith('Monitoring.') || iceType === 'Monitoring.Log'; -}; -// Three iceTypes count as "inside a private network" for security rules: -// the high-level Network.PrivateNetwork (auto-mode VPC), the explicit -// Network.VPC, and Network.Subnet (which is itself always inside a VPC). -// Stock templates use PrivateNetwork; power users compose VPC + Subnet -// directly. Both isolation models satisfy the public-reachability check. -const isVpc = (n: CardNode): boolean => (n.data?.iceType as string) === 'Network.VPC'; -const isSubnet = (n: CardNode): boolean => (n.data?.iceType as string) === 'Network.Subnet'; -const isPrivateNetwork = (n: CardNode): boolean => (n.data?.iceType as string) === 'Network.PrivateNetwork'; -const isVpcLike = (n: CardNode): boolean => isVpc(n) || isSubnet(n) || isPrivateNetwork(n); +// Thin role-readers used by the rule evaluator. Each is a one-line +// lookup against the schema-shaped table — no iceType strings here. +const ice = (n: CardNode): string => (n.data?.iceType as string) || ''; +const isDatabase = (n: CardNode): boolean => hasSecurityRole(ice(n), 'database'); +const isStorage = (n: CardNode): boolean => hasSecurityRole(ice(n), 'storage'); +const isGateway = (n: CardNode): boolean => hasSecurityRole(ice(n), 'gateway'); +const isService = (n: CardNode): boolean => hasSecurityRole(ice(n), 'compute'); +const isAuth = (n: CardNode): boolean => hasSecurityRole(ice(n), 'auth'); +const isSecret = (n: CardNode): boolean => hasSecurityRole(ice(n), 'secretManager'); +const isMonitoring = (n: CardNode): boolean => hasSecurityRole(ice(n), 'monitoringSink'); +const isVpcLike = (n: CardNode): boolean => hasSecurityRole(ice(n), 'isolatesNestedChildren'); +const isTopLevelBoundary = (n: CardNode): boolean => hasSecurityRole(ice(n), 'topLevelNetworkBoundary'); function isInsideVpc(node: CardNode, allNodes: CardNode[]): boolean { let cur: CardNode | undefined = node; @@ -171,11 +203,12 @@ export function analyzeSecurityWarnings(nodes: CardNode[], edges: CardEdge[]): P }); } - // Rule 6: No private network — info. Counts both PrivateNetwork (the - // user-facing default) and VPC (the lower-level primitive) so this - // doesn't fire when the canvas is already wrapped in either. + // Rule 6: No private network — info. Counts only top-level boundaries + // (VPC + PrivateNetwork). Subnet alone doesn't satisfy this rule + // because a Subnet at the canvas root has no parent VPC and isolates + // nothing — see the `topLevelNetworkBoundary` role. const serviceCount = nodes.filter(isService).length; - const hasNetworkBoundary = nodes.some((n) => isVpc(n) || isPrivateNetwork(n)); + const hasNetworkBoundary = nodes.some(isTopLevelBoundary); if (serviceCount >= 2 && !hasNetworkBoundary) { warnings.push({ id: 'bp-no-vpc', From 0bfbf5033074154660c63a8ee93a843a37f541a0 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:48:39 +0200 Subject: [PATCH 11/52] refactor(classifiers): unify connection-rules + propagation-rules iceType classifiers via shared @ice/constants table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit items #9 + #10 — cardinal rule violations across two packages. Both `@ice/types/connection-rules/predicates.ts` and `@ice/core/compute/propagation-rules.ts` had ~15 identical-ish classifier functions (isBackend, isFrontend, isDatabase, isCache, isStorage, isQueue, isSecrets, isCustomDomain, …), each duplicating the same regex + prefix + exact-match bodies. The propagation-rules copy carried a comment apologising for the duplication ("Minimal copies of the classifiers from @ice/types/connection-rules. Kept local to avoid cross-package moduleResolution conflicts."). Introduced `@ice/constants/block-classifiers.ts` as the single source of truth — a three-tier role table: - `BLOCK_ROLES_BY_ICE_TYPE`: exact iceType → roles - `BLOCK_ROLES_BY_PREFIX`: category prefix → role (Compute.*, Database.*, Storage.*, Messaging.*, Monitoring.*, Log.*) - `BLOCK_ROLES_BY_REGEX`: legacy provider-specific iceTypes (PostgreSQL/Redis/Bucket/Worker/…) authored under varied namespaces `hasBlockRole(t, role)` queries all three tiers. Both packages import it — `predicates.ts` and `propagation-rules.ts` predicate bodies are now one-line lookups, no iceType strings remain in classifier code. Adding a new role/iceType binding edits ONE table; both connection- rules and propagation-rules pick it up automatically. Tests: full equivalence preserved (4664 tests pass) + new block-classifiers test suite covering exact / prefix / regex / composite / negative cases + table integrity. --- package.json | 2 +- .../src/__tests__/block-classifiers.test.ts | 130 +++++++++++++++++ packages/constants/src/block-classifiers.ts | 136 ++++++++++++++++++ packages/constants/src/index.ts | 8 ++ .../core/src/compute/propagation-rules.ts | 43 +++--- .../types/src/connection-rules/predicates.ts | 70 ++++----- 6 files changed, 334 insertions(+), 55 deletions(-) create mode 100644 packages/constants/src/__tests__/block-classifiers.test.ts create mode 100644 packages/constants/src/block-classifiers.ts diff --git a/package.json b/package.json index a518a631..2b439e47 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.730", + "version": "0.1.731", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/constants/src/__tests__/block-classifiers.test.ts b/packages/constants/src/__tests__/block-classifiers.test.ts new file mode 100644 index 00000000..f7bac097 --- /dev/null +++ b/packages/constants/src/__tests__/block-classifiers.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for `block-classifiers` — the shared role table that drives + * block-type classification across `@ice/types` connection-rules AND + * `@ice/core` propagation-rules. + * + * Cardinal rule check: the table is the single declarative fact for + * "what role does this iceType play?". Both packages read from it via + * `hasBlockRole(t, role)`; no iceType strings appear in classifier + * code on either side. + * + * These tests pin the role semantics so any future drift between the + * canonical iceTypes (Compute.X, Database.X, Storage.X) and the + * provider-specific iceTypes (raw blueprint iceTypes under varied + * namespaces) is caught. + */ + +import { describe, it, expect } from 'vitest'; +import { hasBlockRole, BLOCK_ROLES_BY_ICE_TYPE, BLOCK_ROLES_BY_PREFIX } from '../block-classifiers'; + +describe('hasBlockRole — exact iceType matches', () => { + it('Source.Repository → repo', () => { + expect(hasBlockRole('Source.Repository', 'repo')).toBe(true); + expect(hasBlockRole('Source.Repository', 'backend')).toBe(false); + }); + + it('Config.Environment → envConfig', () => { + expect(hasBlockRole('Config.Environment', 'envConfig')).toBe(true); + }); + + it('Security.Secret → secrets', () => { + expect(hasBlockRole('Security.Secret', 'secrets')).toBe(true); + }); + + it('Network.CustomDomain → customDomain + domain', () => { + expect(hasBlockRole('Network.CustomDomain', 'customDomain')).toBe(true); + expect(hasBlockRole('Network.CustomDomain', 'domain')).toBe(true); + }); + + it('Network.PrivateNetwork → privateNetwork', () => { + expect(hasBlockRole('Network.PrivateNetwork', 'privateNetwork')).toBe(true); + }); + + it('Util.Reroute → reroute', () => { + expect(hasBlockRole('Util.Reroute', 'reroute')).toBe(true); + }); +}); + +describe('hasBlockRole — category-prefix inheritance', () => { + it('any Compute.* → backend', () => { + expect(hasBlockRole('Compute.Container', 'backend')).toBe(true); + expect(hasBlockRole('Compute.NewMadeUpType', 'backend')).toBe(true); + }); + + it('any Database.* → database', () => { + expect(hasBlockRole('Database.PostgreSQL', 'database')).toBe(true); + expect(hasBlockRole('Database.NewMadeUpType', 'database')).toBe(true); + }); + + it('any Storage.* → storage', () => { + expect(hasBlockRole('Storage.Bucket', 'storage')).toBe(true); + }); + + it('any Messaging.* → queue', () => { + expect(hasBlockRole('Messaging.Queue', 'queue')).toBe(true); + expect(hasBlockRole('Messaging.EventStream', 'queue')).toBe(true); + }); + + it('any Monitoring.* or Log.* → monitoring', () => { + expect(hasBlockRole('Monitoring.Log', 'monitoring')).toBe(true); + expect(hasBlockRole('Log.Sink', 'monitoring')).toBe(true); + }); +}); + +describe('hasBlockRole — regex matches for provider-specific iceTypes', () => { + it('iceTypes containing PostgreSQL / MySQL / MongoDB → database', () => { + expect(hasBlockRole('aws.rds.PostgreSQL', 'database')).toBe(true); + expect(hasBlockRole('gcp.sql.MySQL', 'database')).toBe(true); + }); + + it('iceTypes containing Redis / Cache → cache', () => { + expect(hasBlockRole('Cache.Redis', 'cache')).toBe(true); + expect(hasBlockRole('aws.elasticache.Cache', 'cache')).toBe(true); + }); + + it('iceTypes containing Bucket / S3 / GCS → storage', () => { + expect(hasBlockRole('aws.s3.Bucket', 'storage')).toBe(true); + }); + + it('iceTypes containing Container / Function / Worker → backend', () => { + expect(hasBlockRole('aws.ecs.Container', 'backend')).toBe(true); + expect(hasBlockRole('gcp.cloudfunctions.Function', 'backend')).toBe(true); + }); +}); + +describe('hasBlockRole — composite domain role', () => { + it('PublicEndpoint → domain (composite, not just customDomain)', () => { + expect(hasBlockRole('Network.PublicEndpoint', 'domain')).toBe(true); + expect(hasBlockRole('Network.PublicEndpoint', 'customDomain')).toBe(false); + }); + + it('CustomDomain → domain AND customDomain', () => { + expect(hasBlockRole('Network.CustomDomain', 'domain')).toBe(true); + expect(hasBlockRole('Network.CustomDomain', 'customDomain')).toBe(true); + }); +}); + +describe('hasBlockRole — negative cases', () => { + it('returns false for unknown iceTypes', () => { + expect(hasBlockRole('Totally.Made.Up', 'database')).toBe(false); + expect(hasBlockRole('', 'backend')).toBe(false); + }); + + it('returns false for the wrong role', () => { + expect(hasBlockRole('Source.Repository', 'database')).toBe(false); + }); +}); + +describe('table integrity', () => { + it('every exact-match entry uses at least one role', () => { + for (const [iceType, roles] of Object.entries(BLOCK_ROLES_BY_ICE_TYPE)) { + expect(roles.length, `${iceType} has no roles`).toBeGreaterThan(0); + } + }); + + it('every prefix entry ends with a dot (category separator)', () => { + for (const entry of BLOCK_ROLES_BY_PREFIX) { + expect(entry.prefix.endsWith('.'), `prefix ${entry.prefix} should end with .`).toBe(true); + } + }); +}); diff --git a/packages/constants/src/block-classifiers.ts b/packages/constants/src/block-classifiers.ts new file mode 100644 index 00000000..c84ec881 --- /dev/null +++ b/packages/constants/src/block-classifiers.ts @@ -0,0 +1,136 @@ +/** + * Block role classification — the single source of truth for "what + * kind of block is this iceType?" across the whole monorepo. + * + * The cardinal rule (see CLAUDE memory / feedback): cross-cutting + * layers MUST NOT scatter `if (iceType === 'X')` or `t.startsWith('Y')` + * branches throughout the codebase. They consult the schema-shaped + * tables here and the connection-rules + propagation-rules engines + * stay generic. + * + * Lives in `@ice/constants` because BOTH `@ice/types/connection-rules/ + * predicates.ts` AND `@ice/core/compute/propagation-rules.ts` need + * identical block classification. Previously both files duplicated + * the same predicate bodies (regex + prefix matches); this module + * collapses them into one declaration. + * + * Three lookup tiers, evaluated in order — fastest first: + * 1. `BLOCK_ROLES_BY_ICE_TYPE`: exact iceType → roles. Use this for + * narrow, one-off bindings (Source.Repository, Config.Environment). + * 2. `BLOCK_ROLES_BY_PREFIX`: category prefix → role. Use this for + * auto-inheritance (every `Database.*` is a `database` for free, + * so a new MySQL variant doesn't need a table edit). + * 3. `BLOCK_ROLES_BY_REGEX`: regex pattern → role. Use this ONLY for + * provider-specific iceTypes that don't fit a clean prefix + * (e.g. `PostgreSQL`, `Redis`, `Kafka` engines authored under + * varied namespaces by per-provider blueprints). + */ + +export type BlockRole = + // Compute + | 'backend' + | 'frontend' + // Data + | 'database' + | 'cache' + | 'storage' + | 'queue' + | 'search' + | 'vectorDb' + | 'llm' + | 'dataWarehouse' + // Ops / Config + | 'repo' + | 'envConfig' + | 'secrets' + // Network / Auth + | 'gateway' + | 'auth' + | 'monitoring' + // Specialised network blocks + | 'customDomain' + | 'privateNetwork' + | 'reroute' + // Composite — anything that owns / propagates a public host + | 'domain'; + +export const BLOCK_ROLES_BY_ICE_TYPE: Record> = { + // Ops / Config blocks — narrow exact matches. + 'Source.Repository': ['repo'], + 'Config.Environment': ['envConfig'], + 'Security.Secret': ['secrets'], + 'Security.Identity': ['auth'], + // Specialised network blocks. + 'Network.Gateway': ['gateway'], + 'Network.CustomDomain': ['customDomain', 'domain'], + 'Network.PublicEndpoint': ['domain'], + 'Network.PrivateNetwork': ['privateNetwork'], + 'Util.Reroute': ['reroute'], + // High-level analytics + AI blocks (no clean prefix; exact iceType). + 'Analytics.Search': ['search'], + 'Analytics.DataWarehouse': ['dataWarehouse'], + 'AI.VectorDB': ['vectorDb'], + 'AI.LLMGateway': ['llm'], + 'AI.ModelServing': ['llm'], +}; + +export const BLOCK_ROLES_BY_PREFIX: ReadonlyArray<{ prefix: string; role: BlockRole }> = [ + { prefix: 'Compute.', role: 'backend' }, + { prefix: 'Database.', role: 'database' }, + { prefix: 'Storage.', role: 'storage' }, + { prefix: 'Messaging.', role: 'queue' }, + { prefix: 'Monitoring.', role: 'monitoring' }, + { prefix: 'Log.', role: 'monitoring' }, +]; + +export const BLOCK_ROLES_BY_REGEX: ReadonlyArray<{ pattern: RegExp; role: BlockRole }> = [ + // Backend — provider-specific compute iceTypes that don't start with `Compute.`. + { pattern: /Backend|Container|Worker|Function|CronJob|Scheduled|AppPlatform|OCIFunctions/i, role: 'backend' }, + // Frontend — static / SSR sites under varied namespaces. + { pattern: /StaticSite|SSRSite|Frontend/i, role: 'frontend' }, + // Database engines. + { + pattern: /PostgreSQL|MySQL|MongoDB|DynamoDB|Firestore|CosmosDB|AutonomousDB|Tablestore|ManagedDB/i, + role: 'database', + }, + // Cache engines. + { pattern: /Redis|Cache|Memcache/i, role: 'cache' }, + // Storage engines. + { pattern: /Bucket|S3|GCS|Blob|ObjectStorage|Spaces/i, role: 'storage' }, + // Queue / messaging engines. + { pattern: /Queue|SQS|SNS|PubSub|ServiceBus|RabbitMQ|Kafka|Event/i, role: 'queue' }, + // Search engines. + { pattern: /Search|Elasticsearch/i, role: 'search' }, + // Vector DBs. + { pattern: /VectorDB|Vector/i, role: 'vectorDb' }, + // LLM gateways / model serving. + { pattern: /LLM|ModelServing/i, role: 'llm' }, + // Data warehouses. + { pattern: /Warehouse|BigQuery|Redshift|Synapse/i, role: 'dataWarehouse' }, + // Secret stores (Vault, KMS, etc. authored under varied namespaces). + { pattern: /Secret|Vault|Certificate/i, role: 'secrets' }, + // API gateways / load balancers / WAF authored under varied namespaces. + { pattern: /Gateway|LoadBalancer|Internet|WAF/i, role: 'gateway' }, + // Auth / IAM / identity providers. + { pattern: /Auth|Identity|IAM/i, role: 'auth' }, + // Monitoring sinks authored under varied namespaces. + { pattern: /Log|Monitor|Observability|Terminal/i, role: 'monitoring' }, + // Domain blocks under varied namespaces. + { pattern: /Domain|DNS/i, role: 'domain' }, +]; + +/** + * True iff `iceType` carries the given role (per any of the three + * lookup tiers). Replaces the per-package classifier functions — + * connection-rules and propagation-rules now both call this. + */ +export function hasBlockRole(iceType: string, role: BlockRole): boolean { + if (BLOCK_ROLES_BY_ICE_TYPE[iceType]?.includes(role)) return true; + for (const e of BLOCK_ROLES_BY_PREFIX) { + if (e.role === role && iceType.startsWith(e.prefix)) return true; + } + for (const e of BLOCK_ROLES_BY_REGEX) { + if (e.role === role && e.pattern.test(iceType)) return true; + } + return false; +} diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 97efcee2..d62905f0 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -127,6 +127,14 @@ export { SECURITY_LEVEL_COLORS, } from './node-traits'; +export { + type BlockRole, + BLOCK_ROLES_BY_ICE_TYPE, + BLOCK_ROLES_BY_PREFIX, + BLOCK_ROLES_BY_REGEX, + hasBlockRole, +} from './block-classifiers'; + export { type TemplateCategory, type TemplateCategoryMeta, diff --git a/packages/core/src/compute/propagation-rules.ts b/packages/core/src/compute/propagation-rules.ts index 3fc4a5b8..c2ab41d1 100644 --- a/packages/core/src/compute/propagation-rules.ts +++ b/packages/core/src/compute/propagation-rules.ts @@ -8,62 +8,61 @@ * that is the single source of truth for all reactive property propagation. */ -import { DEFAULT_PORTS, DEFAULT_ENV_VARS } from '@ice/constants'; +import { DEFAULT_PORTS, DEFAULT_ENV_VARS, hasBlockRole } from '@ice/constants'; import type { PropagationRule, AggregateRule, PropagationNode } from './types'; // ─── Block Type Classifiers ───────────────────────────────────────────────── -// Minimal copies of the classifiers from @ice/types/connection-rules. -// Kept local to avoid cross-package moduleResolution conflicts. +// Cardinal-rule schema-driven: every predicate body is a one-line +// lookup against `hasBlockRole` (defined in `@ice/constants/block- +// classifiers.ts`). The shared role tables there are the single source +// of truth shared with `@ice/types/connection-rules/predicates.ts`, so +// the two packages can no longer drift apart on what "is a database" +// or "is a backend" means. function isBackend(t: string): boolean { - return ( - /Backend|Container|Worker|Function|CronJob|Scheduled|AppPlatform|OCIFunctions/i.test(t) || t.startsWith('Compute.') - ); + return hasBlockRole(t, 'backend'); } function isFrontend(t: string): boolean { - return /StaticSite|SSRSite|Frontend/i.test(t); + return hasBlockRole(t, 'frontend'); } function isService(t: string): boolean { return isBackend(t) || isFrontend(t); } function isDatabase(t: string): boolean { - return ( - t.startsWith('Database.') || - /PostgreSQL|MySQL|MongoDB|DynamoDB|Firestore|CosmosDB|AutonomousDB|Tablestore|ManagedDB/i.test(t) - ); + return hasBlockRole(t, 'database'); } function isCache(t: string): boolean { - return /Redis|Cache|Memcache/i.test(t); + return hasBlockRole(t, 'cache'); } function isStorage(t: string): boolean { - return t.startsWith('Storage.') || /Bucket|S3|GCS|Blob|ObjectStorage|Spaces/i.test(t); + return hasBlockRole(t, 'storage'); } function isQueue(t: string): boolean { - return t.startsWith('Messaging.') || /Queue|SQS|SNS|PubSub|ServiceBus|RabbitMQ|Kafka|Event/i.test(t); + return hasBlockRole(t, 'queue'); } function isSearch(t: string): boolean { - return /Search|Elasticsearch/i.test(t) || t === 'Analytics.Search'; + return hasBlockRole(t, 'search'); } function isVectorDb(t: string): boolean { - return /VectorDB|Vector/i.test(t) || t === 'AI.VectorDB'; + return hasBlockRole(t, 'vectorDb'); } function isLLM(t: string): boolean { - return /LLM|ModelServing/i.test(t) || t === 'AI.LLMGateway' || t === 'AI.ModelServing'; + return hasBlockRole(t, 'llm'); } function isDataWarehouse(t: string): boolean { - return /Warehouse|BigQuery|Redshift|Synapse/i.test(t) || t === 'Analytics.DataWarehouse'; + return hasBlockRole(t, 'dataWarehouse'); } function isRepo(t: string): boolean { - return t === 'Source.Repository'; + return hasBlockRole(t, 'repo'); } function isEnvConfig(t: string): boolean { - return t === 'Config.Environment'; + return hasBlockRole(t, 'envConfig'); } function isSecrets(t: string): boolean { - return /Secret|Vault|Certificate/i.test(t) || t === 'Security.Secret'; + return hasBlockRole(t, 'secrets'); } function isCustomDomain(t: string): boolean { - return t === 'Network.CustomDomain'; + return hasBlockRole(t, 'customDomain'); } /** Anything that stores data and should restrict network access */ diff --git a/packages/types/src/connection-rules/predicates.ts b/packages/types/src/connection-rules/predicates.ts index bd0cc68e..60dab292 100644 --- a/packages/types/src/connection-rules/predicates.ts +++ b/packages/types/src/connection-rules/predicates.ts @@ -6,88 +6,84 @@ * logical group (database, cache, queue, ...). The CONNECTION_RULES * array composes these predicates into source/target classifiers. * - * Extracted from `connection-rules.ts` in rf-conn-2. The regex bodies - * are copied byte-identical from the original — adding or removing a - * single alternation here has shipped behavioral consequences in - * every caller (canConnect, validateConnection, AI prompt). Touch - * with care. + * Cardinal-rule schema-driven: every predicate body is now a one-line + * lookup against `hasBlockRole` (defined in `@ice/constants/block- + * classifiers.ts`). The shared role tables there are the single + * source of truth for "what role does this iceType play?". Adding or + * removing a single alternation only requires editing the role table. + * + * `@ice/core/compute/propagation-rules.ts` reads the same tables, so + * the two packages can no longer drift apart. */ -import { NETWORK_CONTAINER_TYPES } from '@ice/constants'; +import { hasBlockRole, NETWORK_CONTAINER_TYPES } from '@ice/constants'; export function isDatabase(t: string): boolean { - return ( - t.startsWith('Database.') || - /PostgreSQL|MySQL|MongoDB|DynamoDB|Firestore|CosmosDB|AutonomousDB|Tablestore|ManagedDB/i.test(t) - ); + return hasBlockRole(t, 'database'); } export function isCache(t: string): boolean { - return /Redis|Cache|Memcache/i.test(t); + return hasBlockRole(t, 'cache'); } export function isQueue(t: string): boolean { - return t.startsWith('Messaging.') || /Queue|SQS|SNS|PubSub|ServiceBus|RabbitMQ|Kafka|Event/i.test(t); + return hasBlockRole(t, 'queue'); } export function isStorage(t: string): boolean { - return t.startsWith('Storage.') || /Bucket|S3|GCS|Blob|ObjectStorage|Spaces/i.test(t); + return hasBlockRole(t, 'storage'); } export function isBackend(t: string): boolean { - return ( - /Backend|Container|Worker|Function|CronJob|Scheduled|AppPlatform|OCIFunctions/i.test(t) || - t.startsWith('Compute.') || - t.startsWith('Compute.') - ); + return hasBlockRole(t, 'backend'); } export function isFrontend(t: string): boolean { - return /StaticSite|SSRSite|Frontend/i.test(t); + return hasBlockRole(t, 'frontend'); } export function isGateway(t: string): boolean { - return /Gateway|LoadBalancer|Internet|WAF/i.test(t) || t === 'Network.Gateway'; + return hasBlockRole(t, 'gateway'); } export function isAuth(t: string): boolean { - return /Auth|Identity|IAM/i.test(t) || t === 'Security.Identity'; + return hasBlockRole(t, 'auth'); } export function isSecrets(t: string): boolean { - return /Secret|Vault|Certificate/i.test(t) || t === 'Security.Secret'; + return hasBlockRole(t, 'secrets'); } export function isMonitoring(t: string): boolean { - return /Log|Monitor|Observability|Terminal/i.test(t) || t.startsWith('Monitoring.') || t.startsWith('Log.'); + return hasBlockRole(t, 'monitoring'); } export function isSearch(t: string): boolean { - return /Search|Elasticsearch/i.test(t) || t === 'Analytics.Search'; + return hasBlockRole(t, 'search'); } export function isDataWarehouse(t: string): boolean { - return /Warehouse|BigQuery|Redshift|Synapse/i.test(t) || t === 'Analytics.DataWarehouse'; + return hasBlockRole(t, 'dataWarehouse'); } export function isVectorDb(t: string): boolean { - return /VectorDB|Vector/i.test(t) || t === 'AI.VectorDB'; + return hasBlockRole(t, 'vectorDb'); } export function isLLM(t: string): boolean { - return /LLM|ModelServing/i.test(t) || t === 'AI.LLMGateway' || t === 'AI.ModelServing'; + return hasBlockRole(t, 'llm'); } export function isRepo(t: string): boolean { - return t === 'Source.Repository'; + return hasBlockRole(t, 'repo'); } export function isEnvConfig(t: string): boolean { - return t === 'Config.Environment'; + return hasBlockRole(t, 'envConfig'); } export function isDomain(t: string): boolean { - return t === 'Network.PublicEndpoint' || t === 'Network.CustomDomain' || /Domain|DNS/i.test(t); + return hasBlockRole(t, 'domain'); } /** @@ -104,7 +100,7 @@ export function isDomain(t: string): boolean { * because the compiler will synthesize the LB. */ export function isCustomDomain(t: string): boolean { - return t === 'Network.CustomDomain'; + return hasBlockRole(t, 'customDomain'); } /** @@ -114,7 +110,17 @@ export function isCustomDomain(t: string): boolean { * ingress. */ export function isPrivateNetwork(t: string): boolean { - return t === 'Network.PrivateNetwork'; + return hasBlockRole(t, 'privateNetwork'); +} + +/** + * `Util.Reroute` is a pass-through routing dot — not a container, not + * an infrastructure resource. It exists purely to let users bend wires + * cleanly. Edges to/from a Reroute inherit the category of the other + * end via the passthrough rule in rules-data. + */ +export function isReroute(t: string): boolean { + return hasBlockRole(t, 'reroute'); } export function isContainer(iceType: string, nodeType?: string): boolean { From d963ad6afd2865cb3a8ac6c00e955be8b8c9c596 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:51:07 +0200 Subject: [PATCH 12/52] refactor(deploy): dedup SERVICE_BACKEND_ICE_TYPES via shared serviceBackend role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit items #17 + #18 — duplicated static set in two cross-cutting locations: - edge-classifier.ts:34-40 — exported SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS - pass-1-5-endpoint-wiring.ts:188-194 — local SERVICE_BACKEND_ICE_TYPES Both held the same 5 iceTypes (Compute.Container/BackendAPI/SSRSite/ Worker/ServerlessFunction). Two copies, two sources of drift. Added a new `serviceBackend` role to the shared classifier table in @ice/constants. The 5 iceTypes register the role via BLOCK_ROLES_BY_ICE_TYPE — Compute.StaticSite is intentionally excluded (compiles to backendBucket via Firebase Hosting, not a NEG). - edge-classifier exports SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS as a thin materialisation derived from the role table (kept for callers that want `.has(t)` membership). - pass-1-5-endpoint-wiring.ts now uses `hasBlockRole(t, 'serviceBackend')` directly; its inline duplicate set is gone. New iceTypes register the role in ONE table; both consumers pick it up. --- package.json | 2 +- packages/constants/src/block-classifiers.ts | 18 +++++++++++++ packages/core/src/deploy/edge-classifier.ts | 27 ++++++++++++------- .../deploy/passes/pass-1-5-endpoint-wiring.ts | 19 ++++++------- 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 2b439e47..2927fa84 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.731", + "version": "0.1.732", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/constants/src/block-classifiers.ts b/packages/constants/src/block-classifiers.ts index c84ec881..59a45b48 100644 --- a/packages/constants/src/block-classifiers.ts +++ b/packages/constants/src/block-classifiers.ts @@ -30,6 +30,15 @@ export type BlockRole = // Compute | 'backend' | 'frontend' + /** + * Compute backend that compiles to a Cloud Run / ECS / Container App + * service behind a Serverless NEG when wired through a public-ingress + * endpoint. Narrower than `backend` — excludes StaticSite (which + * compiles to a backendBucket instead). Used by: + * - the LB-wiring pass (which serverless-NEG vs backendBucket dispatch) + * - the network-isolation ingress override on the translator + */ + | 'serviceBackend' // Data | 'database' | 'cache' @@ -55,6 +64,15 @@ export type BlockRole = | 'domain'; export const BLOCK_ROLES_BY_ICE_TYPE: Record> = { + // Service backends — compile to Cloud Run / ECS service with a + // Serverless NEG when wired behind a public-ingress endpoint. + // Compute.StaticSite is INTENTIONALLY omitted: it compiles to a + // backendBucket via Firebase Hosting, not a NEG. + 'Compute.Container': ['serviceBackend'], + 'Compute.BackendAPI': ['serviceBackend'], + 'Compute.SSRSite': ['serviceBackend'], + 'Compute.Worker': ['serviceBackend'], + 'Compute.ServerlessFunction': ['serviceBackend'], // Ops / Config blocks — narrow exact matches. 'Source.Repository': ['repo'], 'Config.Environment': ['envConfig'], diff --git a/packages/core/src/deploy/edge-classifier.ts b/packages/core/src/deploy/edge-classifier.ts index f578ef3f..c5661b68 100644 --- a/packages/core/src/deploy/edge-classifier.ts +++ b/packages/core/src/deploy/edge-classifier.ts @@ -13,6 +13,7 @@ * classifier functions stay unchanged. */ +import { BLOCK_ROLES_BY_ICE_TYPE } from '@ice/constants'; import { getBlockDeployClassifiers } from './block-deploy-classifiers'; import type { EdgeRelationship } from '../types/graph'; @@ -34,17 +35,23 @@ import type { EdgeRelationship } from '../types/graph'; export const UI_ONLY_TYPES = new Set(['Source.Repository', 'Config.Environment', 'Network.PublicTraffic']); /** - * iceTypes whose compute is treated as a service backend. Shared between - * the LB-wiring path (line ~1059) and the Private Network ingress-override - * logic below. + * iceTypes whose compute is treated as a service backend (compiles to + * Cloud Run / ECS service with a Serverless NEG when wired through a + * public-ingress endpoint). + * + * Cardinal-rule schema-driven: the contents come from + * `BLOCK_ROLES_BY_ICE_TYPE` via the `serviceBackend` role. This Set is + * a thin materialisation kept for back-compat with callers that want + * `.has(iceType)`. New iceTypes register the role in the table; the + * Set picks them up automatically. The previous in-file inline list + + * the duplicate inside `pass-1-5-endpoint-wiring.ts` have both been + * replaced with a single role lookup. */ -export const SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS = new Set([ - 'Compute.Container', - 'Compute.BackendAPI', - 'Compute.SSRSite', - 'Compute.Worker', - 'Compute.ServerlessFunction', -]); +export const SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS: ReadonlySet = new Set( + Object.entries(BLOCK_ROLES_BY_ICE_TYPE) + .filter(([, roles]) => roles.includes('serviceBackend')) + .map(([iceType]) => iceType), +); /** * Walk the parent chain to check whether any ancestor is a network- diff --git a/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts b/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts index 213a6e29..5d8ee1dd 100644 --- a/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts +++ b/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts @@ -14,6 +14,7 @@ * the counter, only the graph + deployables array. */ +import { hasBlockRole } from '@ice/constants'; import { isPublicIngressNode } from '../edge-classifier'; import { sanitize_name, sanitize_label_value } from '../utils/name-utils'; import type { MutableGraph } from '../../graph/mutable-graph'; @@ -182,16 +183,12 @@ export function wire_public_endpoints(args: { }> = []; const defaultBackends: BackendEntry[] = []; - // Compute types that compile to Cloud Run services — each of these - // gets wrapped in a Serverless NEG + backend service by the LB - // handler at deploy time. Static sites use backendBuckets instead. - const SERVICE_BACKEND_ICE_TYPES = new Set([ - 'Compute.Container', - 'Compute.BackendAPI', - 'Compute.SSRSite', - 'Compute.Worker', - 'Compute.ServerlessFunction', - ]); + // Whether a backend's iceType compiles to a Cloud Run service + // wrapped in a Serverless NEG is a schema-declared fact — see the + // `serviceBackend` role in `@ice/constants/block-classifiers.ts`. + // The previous in-file duplicate of this set (mirroring + // SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS in edge-classifier) is + // gone; both consumers now read the same role. for (const be of backends) { // Static sites on GCP now compile to Firebase Hosting (which @@ -229,7 +226,7 @@ export function wire_public_endpoints(args: { // needs the runtime region, which lives on the handler context // but not in the translator. We just record the names here and // pass them through `host_rules` as metadata. - if (SERVICE_BACKEND_ICE_TYPES.has(be.targetIceType)) { + if (hasBlockRole(be.targetIceType, 'serviceBackend')) { const backendServiceName = sanitize_name(`${be.targetResourceName}-backend`); be.sourceServiceName = be.targetResourceName; be.backendServiceName = backendServiceName; From f055ba8771a89fb97d364792bedb7f5f9c42dafb Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 11:55:59 +0200 Subject: [PATCH 13/52] refactor(translator): drop hardcoded iceType + provider branches via type-map + override table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit items #1 + #2 — two critical violations in the otherwise- provider-agnostic translator: - card-translator.ts:227 — `ice_type === 'Network.CustomDomain' ? 'gcp.compute.globalForwardingRule' : type_map[ice_type]` (iceType AND GCP-specific in one cross-cutting line) - card-translator.ts:313-322 — `if (gcp_type === 'gcp.run.service') ... else if (gcp_type === 'aws.ecs.service') ... else if (gcp_type === 'azure.containerapp.containerApp') ...` cascade applying provider-specific internal-mode mutations inline #1: Added Network.CustomDomain to each provider's type-map (mirrors PublicEndpoint — the nested CD acts as the network's gateway and compiles to the same ingress chain on every provider; standalone CDs are filtered earlier by isStandaloneMetadataOnly). Translator now does a plain `type_map[ice_type]` lookup — no iceType branches. #2: Introduced `internal-ingress-overrides.ts` — a per-provider mutator table keyed by resolved resource type: - gcp.run.service → ingress=internal-and-cloud-load-balancing, allow_unauthenticated=false - aws.ecs.service → assign_public_ip=false, internal=true - azure.containerapp.containerApp → ingress_external=false Translator calls `applyInternalIngressOverride(resource_type, props)` generically. The `SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS.has(t)` set membership is replaced with `hasBlockRole(t, 'serviceBackend')` per the earlier dedup commit. No provider strings or iceType strings appear in the translator's body. New providers add an override entry; the translator stays unchanged. Tests: existing type-map entry counts bumped by 1 + new CustomDomain mapping assertions per provider + new internal-ingress-overrides suite (table contents + per-provider behaviour + no-op fallthrough). --- package.json | 2 +- .../internal-ingress-overrides.test.ts | 58 +++++++++++++++++++ .../src/deploy/__tests__/type-maps.test.ts | 24 ++++++-- packages/core/src/deploy/card-translator.ts | 43 ++++++-------- .../src/deploy/internal-ingress-overrides.ts | 43 ++++++++++++++ packages/core/src/deploy/type-maps.ts | 12 ++++ 6 files changed, 149 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/deploy/__tests__/internal-ingress-overrides.test.ts create mode 100644 packages/core/src/deploy/internal-ingress-overrides.ts diff --git a/package.json b/package.json index 2927fa84..551be338 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.732", + "version": "0.1.733", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/__tests__/internal-ingress-overrides.test.ts b/packages/core/src/deploy/__tests__/internal-ingress-overrides.test.ts new file mode 100644 index 00000000..514423e7 --- /dev/null +++ b/packages/core/src/deploy/__tests__/internal-ingress-overrides.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for `internal-ingress-overrides` — per-provider mutations + * applied when a service backend is nested inside a network-isolation + * container. + * + * Cardinal-rule check: the table is the schema-shaped fact for + * "how does provider X make a service internal?". Callers iterate + * `applyInternalIngressOverride(resourceType, props)` generically; + * no provider-specific branches in the translator remain. + */ + +import { describe, it, expect } from 'vitest'; +import { INTERNAL_INGRESS_OVERRIDES, applyInternalIngressOverride } from '../internal-ingress-overrides'; + +describe('INTERNAL_INGRESS_OVERRIDES', () => { + it('registers the three providers that today have a service-backend type', () => { + expect(Object.keys(INTERNAL_INGRESS_OVERRIDES).sort()).toEqual([ + 'aws.ecs.service', + 'azure.containerapp.containerApp', + 'gcp.run.service', + ]); + }); + + it('GCP Cloud Run override: ingress="internal-and-cloud-load-balancing" + allow_unauthenticated=false', () => { + const props: Record = {}; + INTERNAL_INGRESS_OVERRIDES['gcp.run.service'](props); + expect(props.allow_unauthenticated).toBe(false); + expect(props.ingress).toBe('internal-and-cloud-load-balancing'); + }); + + it('AWS ECS override: assign_public_ip=false + internal=true', () => { + const props: Record = {}; + INTERNAL_INGRESS_OVERRIDES['aws.ecs.service'](props); + expect(props.assign_public_ip).toBe(false); + expect(props.internal).toBe(true); + }); + + it('Azure Container App override: ingress_external=false', () => { + const props: Record = {}; + INTERNAL_INGRESS_OVERRIDES['azure.containerapp.containerApp'](props); + expect(props.ingress_external).toBe(false); + }); +}); + +describe('applyInternalIngressOverride', () => { + it('applies the GCP override in place', () => { + const props: Record = { cpu: 2 }; + applyInternalIngressOverride('gcp.run.service', props); + expect(props).toMatchObject({ cpu: 2, allow_unauthenticated: false }); + }); + + it('is a no-op for resource types without a registered override', () => { + const props: Record = { foo: 'bar' }; + applyInternalIngressOverride('gcp.storage.bucket', props); + applyInternalIngressOverride('totally.unknown.type', props); + expect(props).toEqual({ foo: 'bar' }); + }); +}); diff --git a/packages/core/src/deploy/__tests__/type-maps.test.ts b/packages/core/src/deploy/__tests__/type-maps.test.ts index eea3b1bc..c3556733 100644 --- a/packages/core/src/deploy/__tests__/type-maps.test.ts +++ b/packages/core/src/deploy/__tests__/type-maps.test.ts @@ -15,8 +15,12 @@ import { GCP_TYPE_MAP, AWS_TYPE_MAP, AZURE_TYPE_MAP, DESIGN_ONLY_PROVIDERS, get_ import type { DeployProvider } from '../card-translator'; describe('GCP_TYPE_MAP', () => { - it('exposes 32 iceType entries', () => { - expect(Object.keys(GCP_TYPE_MAP)).toHaveLength(32); + it('exposes 33 iceType entries', () => { + expect(Object.keys(GCP_TYPE_MAP)).toHaveLength(33); + }); + + it('maps nested Network.CustomDomain → gcp.compute.globalForwardingRule (mirrors PublicEndpoint)', () => { + expect(GCP_TYPE_MAP['Network.CustomDomain']).toBe('gcp.compute.globalForwardingRule'); }); it('maps Compute.StaticSite → gcp.firebase.hosting (Firebase Hosting choice)', () => { @@ -43,8 +47,12 @@ describe('GCP_TYPE_MAP', () => { }); describe('AWS_TYPE_MAP', () => { - it('exposes 27 iceType entries', () => { - expect(Object.keys(AWS_TYPE_MAP)).toHaveLength(27); + it('exposes 28 iceType entries', () => { + expect(Object.keys(AWS_TYPE_MAP)).toHaveLength(28); + }); + + it('maps nested Network.CustomDomain → aws.cloudfront.distribution (mirrors PublicEndpoint)', () => { + expect(AWS_TYPE_MAP['Network.CustomDomain']).toBe('aws.cloudfront.distribution'); }); it('maps Compute.Container → aws.ecs.service', () => { @@ -67,8 +75,12 @@ describe('AWS_TYPE_MAP', () => { }); describe('AZURE_TYPE_MAP', () => { - it('exposes 26 iceType entries', () => { - expect(Object.keys(AZURE_TYPE_MAP)).toHaveLength(26); + it('exposes 27 iceType entries', () => { + expect(Object.keys(AZURE_TYPE_MAP)).toHaveLength(27); + }); + + it('maps nested Network.CustomDomain → azure.cdn.profile (mirrors PublicEndpoint)', () => { + expect(AZURE_TYPE_MAP['Network.CustomDomain']).toBe('azure.cdn.profile'); }); it('maps Storage.Bucket → azure.storage.storageAccount', () => { diff --git a/packages/core/src/deploy/card-translator.ts b/packages/core/src/deploy/card-translator.ts index c4123929..3e7dd49c 100644 --- a/packages/core/src/deploy/card-translator.ts +++ b/packages/core/src/deploy/card-translator.ts @@ -5,9 +5,9 @@ * with GCP-typed nodes that the deploy pipeline understands. */ +import { hasBlockRole } from '@ice/constants'; import { UI_ONLY_TYPES, - SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS, EXTERNAL_TYPES, hasNetworkIsolatingAncestor, isStandaloneMetadataOnly, @@ -15,6 +15,7 @@ import { } from './edge-classifier'; import { create_mutable_graph } from '../graph/mutable-graph'; import { PROPERTY_EXTRACTORS } from './extractors/dispatch'; +import { applyInternalIngressOverride } from './internal-ingress-overrides'; import { expand_deployable_per_entry } from './passes/deploy-expansion'; import { wire_source_repositories } from './passes/pass-1-4-repo-wiring'; import { propagate_custom_domain_hosts } from './passes/pass-1-45-domain-propagation'; @@ -220,11 +221,12 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl continue; } - // Look up the deployer type. Nested Network.CustomDomain inside a - // PrivateNetwork compiles to the global forwarding rule (same as - // Network.PublicEndpoint) — the nested case isn't in the type map - // because standalone CDs are UI-only, so we resolve it inline here. - const gcp_type = ice_type === 'Network.CustomDomain' ? 'gcp.compute.globalForwardingRule' : type_map[ice_type]; + // Look up the deployer type. Network.CustomDomain has its own + // per-provider entry in each type-map (nested CDs compile to the + // same ingress chain as Network.PublicEndpoint on every provider; + // standalone CDs are filtered out above by isStandaloneMetadataOnly). + // No iceType-specific branches here — pure table lookup. + const gcp_type = type_map[ice_type]; if (!gcp_type) { warnings.push(`No ${provider} mapping for iceType "${ice_type}" (node: ${node.data.label || node.id}). Skipped.`); skipped.push({ @@ -304,26 +306,15 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl // Network-isolation ingress override. // - // When a service backend (Scalable Backend / SSR Site / Worker / - // Serverless Function) is nested inside a network-isolation - // container (today: Network.PrivateNetwork; schema-declared via - // BLOCK_DEPLOY_CLASSIFIERS.isolatesNetworkContext), emit the - // internal-only variant of the underlying compute resource. A - // nested ingress block (if present) remains the sole external - // entry point via its own LB chain; see isStandaloneMetadataOnly + - // the backend-wiring at ~line 1100. - if (SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS.has(ice_type) && hasNetworkIsolatingAncestor(node, nodes)) { - const props = properties as Record; - if (gcp_type === 'gcp.run.service') { - // Internal Cloud Run — only reachable via VPC or internal LB. - props.allow_unauthenticated = false; - props.ingress = 'internal-and-cloud-load-balancing'; - } else if (gcp_type === 'aws.ecs.service') { - props.assign_public_ip = false; - props.internal = true; - } else if (gcp_type === 'azure.containerapp.containerApp') { - props.ingress_external = false; - } + // When a `serviceBackend`-role block (Scalable Backend / SSR Site / + // Worker / Serverless Function) is nested inside a network-isolation + // container (any iceType with BLOCK_DEPLOY_CLASSIFIERS.isolatesNetworkContext), + // ask the provider's registered override to mutate the property dict + // so the resource serves traffic internally. The provider-specific + // logic lives in `internal-ingress-overrides.ts`; the translator + // stays provider-agnostic. + if (hasBlockRole(ice_type, 'serviceBackend') && hasNetworkIsolatingAncestor(node, nodes)) { + applyInternalIngressOverride(gcp_type, properties as Record); } // Phase 1 — stable resource identity. diff --git a/packages/core/src/deploy/internal-ingress-overrides.ts b/packages/core/src/deploy/internal-ingress-overrides.ts new file mode 100644 index 00000000..13a9e69b --- /dev/null +++ b/packages/core/src/deploy/internal-ingress-overrides.ts @@ -0,0 +1,43 @@ +/** + * Per-provider-resource overrides applied when a service backend is + * nested inside a network-isolation container (see + * `hasNetworkIsolatingAncestor`). Each entry knows how to mutate the + * extractor's property dict so the deployed resource serves traffic + * internally instead of from the public internet. + * + * Cardinal-rule schema-driven: the translator iterates this table + * generically. Adding a new provider's internal-mode override means + * registering an entry; the translator stays unchanged. Replaces the + * inline `if (gcp_type === 'gcp.run.service') ... else if (gcp_type + * === 'aws.ecs.service') ...` branches that mixed provider-specific + * logic into a provider-agnostic file. + */ + +export type InternalIngressOverride = (properties: Record) => void; + +export const INTERNAL_INGRESS_OVERRIDES: Record = { + // GCP Cloud Run — only reachable via VPC or internal LB. + 'gcp.run.service': (p) => { + p.allow_unauthenticated = false; + p.ingress = 'internal-and-cloud-load-balancing'; + }, + // AWS ECS — no public ALB; rely on nested ingress block. + 'aws.ecs.service': (p) => { + p.assign_public_ip = false; + p.internal = true; + }, + // Azure Container App — disable external ingress. + 'azure.containerapp.containerApp': (p) => { + p.ingress_external = false; + }, +}; + +/** + * Apply the registered override (if any) for `resourceType` in place. + * No-op when no entry exists — the resource doesn't have an internal + * variant on this provider, or the override hasn't been declared yet. + */ +export function applyInternalIngressOverride(resourceType: string, properties: Record): void { + const override = INTERNAL_INGRESS_OVERRIDES[resourceType]; + if (override) override(properties); +} diff --git a/packages/core/src/deploy/type-maps.ts b/packages/core/src/deploy/type-maps.ts index d0da2f5f..8341fb72 100644 --- a/packages/core/src/deploy/type-maps.ts +++ b/packages/core/src/deploy/type-maps.ts @@ -47,6 +47,12 @@ export const GCP_TYPE_MAP: Record = { // are set, and the URL map host rules are populated from each // outgoing edge's `subdomain` field. 'Network.PublicEndpoint': 'gcp.compute.globalForwardingRule', + // `Network.CustomDomain` resolves to the same forwarding-rule chain + // as PublicEndpoint when nested inside a PrivateNetwork (it acts as + // that network's public gateway). Standalone CD is skipped before + // this lookup by `isStandaloneMetadataOnly` — only the nested case + // ever needs the mapping. + 'Network.CustomDomain': 'gcp.compute.globalForwardingRule', // `Network.PrivateNetwork` is the user-facing "private network" block: // one group on the canvas that wraps the services we want isolated. // Compiles to an auto-mode VPC (`autoCreateSubnetworks: true`) so the @@ -94,6 +100,9 @@ export const AWS_TYPE_MAP: Record = { 'Storage.ObjectStorage': 'aws.s3.bucket', 'Network.Gateway': 'aws.apigateway.restApi', 'Network.PublicEndpoint': 'aws.cloudfront.distribution', + // Nested CustomDomain mirrors PublicEndpoint per the same rationale + // as the GCP map above. + 'Network.CustomDomain': 'aws.cloudfront.distribution', 'Network.LoadBalancer': 'aws.elbv2.loadBalancer', 'Messaging.Queue': 'aws.sqs.queue', 'Messaging.Topic': 'aws.sns.topic', @@ -128,6 +137,9 @@ export const AZURE_TYPE_MAP: Record = { 'Storage.ObjectStorage': 'azure.storage.storageAccount', 'Network.Gateway': 'azure.apimanagement.service', 'Network.PublicEndpoint': 'azure.cdn.profile', + // Nested CustomDomain mirrors PublicEndpoint per the same rationale + // as the GCP map above. + 'Network.CustomDomain': 'azure.cdn.profile', 'Network.LoadBalancer': 'azure.network.loadBalancer', 'Messaging.Queue': 'azure.servicebus.queue', 'Messaging.Topic': 'azure.servicebus.topic', From cc92fead07bb5c7a6d5f6b61acd2784ded8ad841 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 12:01:29 +0200 Subject: [PATCH 14/52] refactor(deploy): drop hardcoded Compute.StaticSite in storage extractor + endpoint pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit items #3 + #4 — last two cardinal-rule violations: - pass-1-5-endpoint-wiring.ts:204 — `if (be.targetIceType === 'Compute.StaticSite')` skipped LB wiring for static-site backends (GCP-Firebase-Hosting-specific behaviour) - extractors/network.ts:25 — `iceType === 'Compute.StaticSite'` flipped a Storage.Bucket extractor's `public_access` + `website_hosting` when the bucket backs a static site Two schema-shaped declarations replace them, each scoped to its natural layer: #3 → `self-serving-resources.ts`: a new `SELF_SERVING_PUBLIC_RESOURCES` set keyed by RESOLVED PROVIDER RESOURCE TYPE (gcp.firebase.hosting today; future: aws.amplify.app, azure.staticwebapps.staticSite). The endpoint-wiring pass reads `targetGraphNode.type` and calls `isSelfServingPublicResource(...)` — no iceType strings. #4 → New `publicWebsiteSource` role on the shared `BLOCK_ROLES_BY_ICE_TYPE` table. Compute.StaticSite registers this role; the storage extractor reads it via `hasBlockRole(iceType, 'publicWebsiteSource')`. Adding a future static-site-style block (Compute.JamstackSite, …) adds one table entry — extractor stays unchanged. Tests: - existing pass-1-5 fixtures updated to mark static-site graph nodes with `type: 'gcp.firebase.hosting'` (the resolved type the new check reads); behavioural assertions unchanged - new self-serving-resources test suite covering registered + negative cases With this commit, every audit item in the schema-driven-refactor punch list is shipped. --- package.json | 2 +- packages/constants/src/block-classifiers.ts | 16 ++++++- .../__tests__/self-serving-resources.test.ts | 35 +++++++++++++++ .../core/src/deploy/extractors/network.ts | 20 +++++---- .../pass-1-5-endpoint-wiring.test.ts | 11 +++-- .../deploy/passes/pass-1-5-endpoint-wiring.ts | 43 +++++++++---------- .../core/src/deploy/self-serving-resources.ts | 28 ++++++++++++ 7 files changed, 120 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/deploy/__tests__/self-serving-resources.test.ts create mode 100644 packages/core/src/deploy/self-serving-resources.ts diff --git a/package.json b/package.json index 551be338..8a922bbb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.733", + "version": "0.1.734", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/constants/src/block-classifiers.ts b/packages/constants/src/block-classifiers.ts index 59a45b48..80064c45 100644 --- a/packages/constants/src/block-classifiers.ts +++ b/packages/constants/src/block-classifiers.ts @@ -61,7 +61,17 @@ export type BlockRole = | 'privateNetwork' | 'reroute' // Composite — anything that owns / propagates a public host - | 'domain'; + | 'domain' + /** + * The block represents a publicly-served static website. When it + * compiles to a bucket-style resource on the active provider (e.g. + * AWS `Compute.StaticSite` → `aws.s3.bucket`), the bucket extractor + * flips `public_access` + `website_hosting` so the asset can serve + * traffic directly. Providers where it compiles to a self-serving + * resource (e.g. GCP Firebase Hosting) don't need this — the bucket + * extractor isn't invoked at all. + */ + | 'publicWebsiteSource'; export const BLOCK_ROLES_BY_ICE_TYPE: Record> = { // Service backends — compile to Cloud Run / ECS service with a @@ -73,6 +83,10 @@ export const BLOCK_ROLES_BY_ICE_TYPE: Record> = 'Compute.SSRSite': ['serviceBackend'], 'Compute.Worker': ['serviceBackend'], 'Compute.ServerlessFunction': ['serviceBackend'], + // Frontend block representing a public static site. On providers + // where it compiles to a bucket (AWS S3) the storage extractor + // flips public + website-hosting based on this role. + 'Compute.StaticSite': ['publicWebsiteSource'], // Ops / Config blocks — narrow exact matches. 'Source.Repository': ['repo'], 'Config.Environment': ['envConfig'], diff --git a/packages/core/src/deploy/__tests__/self-serving-resources.test.ts b/packages/core/src/deploy/__tests__/self-serving-resources.test.ts new file mode 100644 index 00000000..8a6d6e90 --- /dev/null +++ b/packages/core/src/deploy/__tests__/self-serving-resources.test.ts @@ -0,0 +1,35 @@ +/** + * Tests for `self-serving-resources` — the set of provider resource + * types that serve public traffic on their own and need no LB chain. + * + * Cardinal-rule check: the set is the schema-shaped fact. The + * endpoint-wiring pass iterates it generically — there are no + * iceType strings naming any specific block. + */ + +import { describe, it, expect } from 'vitest'; +import { SELF_SERVING_PUBLIC_RESOURCES, isSelfServingPublicResource } from '../self-serving-resources'; + +describe('SELF_SERVING_PUBLIC_RESOURCES', () => { + it("includes GCP Firebase Hosting (today's only self-serving target)", () => { + expect(SELF_SERVING_PUBLIC_RESOURCES.has('gcp.firebase.hosting')).toBe(true); + }); + + it('does NOT include Cloud Run, S3, or other "needs an LB" resources', () => { + expect(SELF_SERVING_PUBLIC_RESOURCES.has('gcp.run.service')).toBe(false); + expect(SELF_SERVING_PUBLIC_RESOURCES.has('aws.s3.bucket')).toBe(false); + expect(SELF_SERVING_PUBLIC_RESOURCES.has('azure.containerapp.containerApp')).toBe(false); + }); +}); + +describe('isSelfServingPublicResource', () => { + it('returns true for registered resource types', () => { + expect(isSelfServingPublicResource('gcp.firebase.hosting')).toBe(true); + }); + + it('returns false for unregistered types', () => { + expect(isSelfServingPublicResource('gcp.run.service')).toBe(false); + expect(isSelfServingPublicResource('totally.unknown.type')).toBe(false); + expect(isSelfServingPublicResource('')).toBe(false); + }); +}); diff --git a/packages/core/src/deploy/extractors/network.ts b/packages/core/src/deploy/extractors/network.ts index 3a2f8b64..f1bda30a 100644 --- a/packages/core/src/deploy/extractors/network.ts +++ b/packages/core/src/deploy/extractors/network.ts @@ -11,24 +11,28 @@ */ import { createHash } from 'crypto'; +import { hasBlockRole } from '@ice/constants'; export function extract_storage_bucket_properties( data: Record, region: string, ): Record { - // Phase 8 — when the bucket backs a Compute.StaticSite block we need the - // handler to make it publicly readable and enable static website hosting - // (index.html / 404.html) so the load balancer's backend bucket can serve - // it to the internet. Users who drag a plain Storage.Bucket block don't - // get this treatment — private bucket, no website config. + // Phase 8 — when the source block is flagged with `publicWebsiteSource` + // (today: Compute.StaticSite on providers that compile it to a bucket + // such as AWS S3), the handler needs to make the bucket publicly + // readable and enable static website hosting (index.html / 404.html) + // so the LB backend bucket can serve it to the internet. Plain + // Storage.Bucket blocks stay private. Cardinal-rule schema-driven: + // the iceType-specific check is replaced by a role lookup in the + // shared classifier table. const iceType = String(data.iceType || ''); - const isStaticSite = iceType === 'Compute.StaticSite'; + const isPublicWebsite = hasBlockRole(iceType, 'publicWebsiteSource'); return { location: region.toUpperCase().split('-').slice(0, 1).join('') || 'US', storage_class: data.storageClass || 'STANDARD', versioning: data.versioning ?? false, - public_access: isStaticSite || data.public_access === true, - website_hosting: isStaticSite || data.website_hosting === true, + public_access: isPublicWebsite || data.public_access === true, + website_hosting: isPublicWebsite || data.website_hosting === true, index_page: (data.index_page as string) || 'index.html', not_found_page: (data.not_found_page as string) || '404.html', labels: {}, diff --git a/packages/core/src/deploy/passes/__tests__/pass-1-5-endpoint-wiring.test.ts b/packages/core/src/deploy/passes/__tests__/pass-1-5-endpoint-wiring.test.ts index f61e5dd3..f428cec0 100644 --- a/packages/core/src/deploy/passes/__tests__/pass-1-5-endpoint-wiring.test.ts +++ b/packages/core/src/deploy/passes/__tests__/pass-1-5-endpoint-wiring.test.ts @@ -555,7 +555,9 @@ describe('wire_public_endpoints — RISK #7 atomic forwarding-rule removal', () it('all-static-site backends → graph.remove_node + deployables.splice + delta-- atomic', () => { const { graph, card_id_to_name, deployables, endpointNodeKey, endpointNodeName } = setup_fixture({ endpoint: { cardId: 'ep-card', resourceName: 'fr-1' }, - computes: [{ cardId: 'site-card', resourceName: 'firebase-site-1' }], + // Mark the static-site graph node with the Firebase Hosting + // resource type so the new self-serving-resource check matches. + computes: [{ cardId: 'site-card', resourceName: 'firebase-site-1', type: 'gcp.firebase.hosting' }], }); const nodes: CardNodeInput[] = [ { @@ -594,7 +596,10 @@ describe('wire_public_endpoints — RISK #7 atomic forwarding-rule removal', () const { graph, card_id_to_name, deployables, endpointNodeKey, endpointNodeName } = setup_fixture({ endpoint: { cardId: 'ep-card', resourceName: 'fr-1' }, computes: [ - { cardId: 'site-card', resourceName: 'firebase-site-1' }, + // The static-site graph node must carry the Firebase resource + // type so the new self-serving-resource check (resolved type, + // not iceType) recognises it. + { cardId: 'site-card', resourceName: 'firebase-site-1', type: 'gcp.firebase.hosting' }, { cardId: 'svc-card', resourceName: 'cloud-run-1' }, ], }); @@ -639,7 +644,7 @@ describe('wire_public_endpoints — RISK #7 atomic forwarding-rule removal', () // double-decrement the delta. const { graph, card_id_to_name, deployables } = setup_fixture({ endpoint: { cardId: 'ep-card', resourceName: 'fr-1' }, - computes: [{ cardId: 'site-card', resourceName: 'firebase-site-1' }], + computes: [{ cardId: 'site-card', resourceName: 'firebase-site-1', type: 'gcp.firebase.hosting' }], }); const nodes: CardNodeInput[] = [ { id: 'ep-card', type: 'block', data: { iceType: 'Network.PublicEndpoint', domain: 'x.io' } }, diff --git a/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts b/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts index 5d8ee1dd..ccf0247d 100644 --- a/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts +++ b/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts @@ -16,6 +16,7 @@ import { hasBlockRole } from '@ice/constants'; import { isPublicIngressNode } from '../edge-classifier'; +import { isSelfServingPublicResource } from '../self-serving-resources'; import { sanitize_name, sanitize_label_value } from '../utils/name-utils'; import type { MutableGraph } from '../../graph/mutable-graph'; import type { CardEdgeInput, CardNodeInput, DeployableNodeInfo } from '../card-translator'; @@ -191,33 +192,31 @@ export function wire_public_endpoints(args: { // gone; both consumers now read the same role. for (const be of backends) { - // Static sites on GCP now compile to Firebase Hosting (which - // gives a public HTTPS URL out of the box, with its own CDN + - // managed cert + optional custom domain). The Public Endpoint - // load-balancer chain is REDUNDANT for Firebase Hosting — it - // serves traffic itself, no backend bucket / URL map / forwarding - // rule needed. We skip the LB wiring here and let the Firebase - // Hosting handler register the custom domain on its own. + // Self-serving public resources (Firebase Hosting today; future: + // AWS Amplify, Azure Static Web Apps) bring their own CDN + cert + // + public URL, so the Public Endpoint LB chain is REDUNDANT in + // front of them. Skip the LB wiring; propagate the upstream + // domain onto the node so the resource's own handler can + // register the custom domain. // - // The static site node still gets the user's custom domain - // propagated so the Firebase Hosting handler picks it up. - if (be.targetIceType === 'Compute.StaticSite') { - // Propagate the PublicEndpoint's domain onto the static site - // node so the Firebase Hosting handler can register it as a - // custom domain. Subdomains become per-site subdomains; blank - // becomes the root domain. - const targetGraphNode = graph.get_node_by_name(be.targetResourceName); - if (targetGraphNode && rootDomain) { + // Schema-driven: the set of self-serving resource types lives in + // `self-serving-resources.ts`. The check below reads the target + // graph node's resolved provider resource_type — no iceType + // strings appear here. + const targetGraphNode = graph.get_node_by_name(be.targetResourceName); + if (targetGraphNode && isSelfServingPublicResource(targetGraphNode.type)) { + if (rootDomain) { const fullHost = be.subdomain ? `${be.subdomain}.${rootDomain}` : rootDomain; (targetGraphNode.properties as any).domain = fullHost; } - // Mark the static-site → forwarding-rule mapping so the post-deploy - // overlay still knows the static site is wired to a public endpoint - // (used for the canvas pill propagation). The forwarding rule itself - // will be created EMPTY and skipped at deploy time when no other - // backend uses it. + // Mark the self-serving target → forwarding-rule mapping so + // the post-deploy overlay still knows it was wired to a + // public endpoint (used for the canvas pill propagation). The + // forwarding rule itself will be created EMPTY and skipped at + // deploy time when no other backend uses it. staticSiteToForwardingRule.set(be.targetNodeId, forwardingResourceName); - // Skip adding a host rule — Firebase Hosting serves directly. + // Skip adding a host rule — the self-serving resource handles + // routing itself. continue; } diff --git a/packages/core/src/deploy/self-serving-resources.ts b/packages/core/src/deploy/self-serving-resources.ts new file mode 100644 index 00000000..3696a08c --- /dev/null +++ b/packages/core/src/deploy/self-serving-resources.ts @@ -0,0 +1,28 @@ +/** + * Provider resource types that serve public traffic on their own and + * therefore need NO load-balancer chain in front of them. The + * endpoint-wiring pass propagates the upstream PublicEndpoint's domain + * onto these nodes but skips backend bucket / backend service + * synthesis entirely; if every backend behind a PublicEndpoint turns + * out to be self-serving, the forwarding rule itself is removed (no + * point provisioning an empty LB). + * + * Cardinal-rule schema-driven: keyed by resolved provider resource + * type (the same key shape used by `INTERNAL_INGRESS_OVERRIDES` and + * the extractor / handler tables). Adding a new self-serving resource + * on any provider — AWS Amplify, Azure Static Web Apps, Vercel-style + * managed front-ends — adds one entry; the pass stays unchanged. + */ + +export const SELF_SERVING_PUBLIC_RESOURCES: ReadonlySet = new Set([ + // GCP — Firebase Hosting gives a public HTTPS URL out of the box + // with its own CDN + managed cert + optional custom domain. + 'gcp.firebase.hosting', + // Future entries: + // 'aws.amplify.app' + // 'azure.staticwebapps.staticSite' +]); + +export function isSelfServingPublicResource(resourceType: string): boolean { + return SELF_SERVING_PUBLIC_RESOURCES.has(resourceType); +} From 57daecaccb44dea57b832cfdfd89f8dbbb97f012 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 13:39:11 +0200 Subject: [PATCH 15/52] refactor(deploy/aws): modularise AWSDeployer to mirror gcp/ shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the AWS deploy buildout. The previous 496-LOC monolithic aws-deployer.ts is replaced with the same dispatch shape the GCP deployer uses: providers/aws/ aws-deployer.ts — thin dispatcher + HANDLER_REGISTRY map types.ts — AWSHandlerContext + AWSResourceHandler sdk-loader.ts — load_aws_sdk + initialize_aws_clients + destroy index.ts — barrel handlers/ ec2.ts — migrated from aws-deployer.ts (no behaviour change) s3.ts — migrated (account-id suffix arrives in commit #8) lambda.ts — migrated (S3-ref only; auto-build in commit #28) The old `providers/aws-deployer.ts` becomes a re-export shim so the existing import paths in `providers/index.ts` and the test suite keep resolving without edits. Cardinal-rule schema-driven: HANDLER_REGISTRY is the single declarative fact for "which handler runs for which resource type". The dispatcher iterates it generically; no `if (type === 'aws.X')` branches. Adding the next ~17 AWS services is now register-an-entry + drop-a-file. Behaviour preserved verbatim — all 64 existing AWS deployer tests pass unchanged (one minor wording fix in the dispatcher to match the original "Unsupported resource type for creation/update/deletion" phrasing the test suite pins). --- package.json | 2 +- .../core/src/deploy/providers/aws-deployer.ts | 498 +----------------- .../src/deploy/providers/aws/aws-deployer.ts | 143 +++++ .../src/deploy/providers/aws/handlers/ec2.ts | 142 +++++ .../deploy/providers/aws/handlers/lambda.ts | 138 +++++ .../src/deploy/providers/aws/handlers/s3.ts | 140 +++++ .../core/src/deploy/providers/aws/index.ts | 9 + .../src/deploy/providers/aws/sdk-loader.ts | 63 +++ .../core/src/deploy/providers/aws/types.ts | 66 +++ 9 files changed, 708 insertions(+), 493 deletions(-) create mode 100644 packages/core/src/deploy/providers/aws/aws-deployer.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/ec2.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/lambda.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/s3.ts create mode 100644 packages/core/src/deploy/providers/aws/index.ts create mode 100644 packages/core/src/deploy/providers/aws/sdk-loader.ts create mode 100644 packages/core/src/deploy/providers/aws/types.ts diff --git a/package.json b/package.json index 8a922bbb..66eb40d5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.734", + "version": "0.1.735", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/aws-deployer.ts b/packages/core/src/deploy/providers/aws-deployer.ts index 169c4d3d..281b5a5e 100644 --- a/packages/core/src/deploy/providers/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws-deployer.ts @@ -1,496 +1,10 @@ /** - * AWS Deployer + * AWS Deployer — back-compat shim. * - * Deploys resources to Amazon Web Services using direct API calls. + * Modular implementation moved to `./aws/`. Kept here so the existing + * import paths in `providers/index.ts` and the test suite continue to + * resolve unchanged. */ -import type { DeployOptions, ResourceDeployResult, ProviderDeployer } from '../types'; - -/** - * AWS resource deployer. - */ -export class AWSDeployer implements ProviderDeployer { - provider = 'aws'; - - private region: string = 'us-east-1'; - private ec2_client: any = null; - private s3_client: any = null; - private lambda_client: any = null; - - async initialize(options: DeployOptions): Promise { - this.region = options.regions?.[0] || 'us-east-1'; - - try { - // Dynamic import of AWS SDK v3 - const client_ec2_module = '@aws-sdk/client-ec2'; - const client_s3_module = '@aws-sdk/client-s3'; - const client_lambda_module = '@aws-sdk/client-lambda'; - - try { - const ec2 = await Function('m', 'return import(m)')(client_ec2_module); - this.ec2_client = new ec2.EC2Client({ region: this.region }); - } catch { - // EC2 client not available - } - - try { - const s3 = await Function('m', 'return import(m)')(client_s3_module); - this.s3_client = new s3.S3Client({ region: this.region }); - } catch { - // S3 client not available - } - - try { - const lambda = await Function('m', 'return import(m)')(client_lambda_module); - this.lambda_client = new lambda.LambdaClient({ region: this.region }); - } catch { - // Lambda client not available - } - } catch (error) { - throw new Error(`Failed to initialize AWS SDK: ${error instanceof Error ? error.message : String(error)}`, { - cause: error, - }); - } - } - - async cleanup(): Promise { - // Destroy clients - if (this.ec2_client) this.ec2_client.destroy(); - if (this.s3_client) this.s3_client.destroy(); - if (this.lambda_client) this.lambda_client.destroy(); - } - - async create( - type: string, - name: string, - properties: Record, - _options: Record, - ): Promise { - const start = Date.now(); - - try { - let provider_id: string | undefined; - - if (type.startsWith('aws.ec2.instance')) { - provider_id = await this.create_ec2_instance(name, properties); - } else if (type.startsWith('aws.s3.bucket')) { - provider_id = await this.create_s3_bucket(name, properties); - } else if (type.startsWith('aws.lambda.function')) { - provider_id = await this.create_lambda_function(name, properties); - } else { - return { - resource_id: name, - name, - type, - action: 'create', - success: false, - error: `Unsupported resource type for creation: ${type}`, - duration_ms: Date.now() - start, - }; - } - - return { - resource_id: name, - name, - type, - action: 'create', - success: true, - provider_id, - duration_ms: Date.now() - start, - }; - } catch (error) { - return { - resource_id: name, - name, - type, - action: 'create', - success: false, - error: error instanceof Error ? error.message : String(error), - duration_ms: Date.now() - start, - }; - } - } - - async update( - type: string, - name: string, - provider_id: string, - properties: Record, - current_properties: Record, - _options: Record, - ): Promise { - const start = Date.now(); - - try { - if (type.startsWith('aws.ec2.instance')) { - await this.update_ec2_instance(name, provider_id, properties, current_properties); - } else if (type.startsWith('aws.s3.bucket')) { - await this.update_s3_bucket(name, provider_id, properties); - } else if (type.startsWith('aws.lambda.function')) { - await this.update_lambda_function(name, provider_id, properties); - } else { - return { - resource_id: name, - name, - type, - action: 'update', - success: false, - error: `Unsupported resource type for update: ${type}`, - duration_ms: Date.now() - start, - }; - } - - return { - resource_id: name, - name, - type, - action: 'update', - success: true, - provider_id, - duration_ms: Date.now() - start, - }; - } catch (error) { - return { - resource_id: name, - name, - type, - action: 'update', - success: false, - error: error instanceof Error ? error.message : String(error), - duration_ms: Date.now() - start, - }; - } - } - - async delete( - type: string, - name: string, - provider_id: string, - _options: Record, - ): Promise { - const start = Date.now(); - - try { - if (type.startsWith('aws.ec2.instance')) { - await this.delete_ec2_instance(name, provider_id); - } else if (type.startsWith('aws.s3.bucket')) { - await this.delete_s3_bucket(name, provider_id); - } else if (type.startsWith('aws.lambda.function')) { - await this.delete_lambda_function(name, provider_id); - } else { - return { - resource_id: name, - name, - type, - action: 'delete', - success: false, - error: `Unsupported resource type for deletion: ${type}`, - duration_ms: Date.now() - start, - }; - } - - return { - resource_id: name, - name, - type, - action: 'delete', - success: true, - duration_ms: Date.now() - start, - }; - } catch (error) { - return { - resource_id: name, - name, - type, - action: 'delete', - success: false, - error: error instanceof Error ? error.message : String(error), - duration_ms: Date.now() - start, - }; - } - } - - // ============================================================================ - // EC2 - // ============================================================================ - - private async create_ec2_instance(name: string, properties: Record): Promise { - if (!this.ec2_client) { - throw new Error('EC2 SDK not available. Install @aws-sdk/client-ec2'); - } - - const ec2_module = '@aws-sdk/client-ec2'; - const ec2 = await Function('m', 'return import(m)')(ec2_module); - - const image_id = (properties.image_id as string) || 'ami-0c55b159cbfafe1f0'; - const instance_type = (properties.instance_type as string) || 't2.micro'; - - const command = new ec2.RunInstancesCommand({ - ImageId: image_id, - InstanceType: instance_type, - MinCount: 1, - MaxCount: 1, - TagSpecifications: [ - { - ResourceType: 'instance', - Tags: [ - { Key: 'Name', Value: name }, - ...Object.entries(properties.tags || {}).map(([Key, Value]) => ({ - Key, - Value: Value as string, - })), - ], - }, - ], - SubnetId: properties.subnet_id as string, - SecurityGroupIds: properties.security_group_ids as string[], - }); - - const result = await this.ec2_client.send(command); - const instance_id = result.Instances?.[0]?.InstanceId; - - if (!instance_id) { - throw new Error('Failed to get instance ID from RunInstances response'); - } - - return `arn:aws:ec2:${this.region}:*:instance/${instance_id}`; - } - - private async update_ec2_instance( - name: string, - provider_id: string, - properties: Record, - _current_properties: Record, - ): Promise { - if (!this.ec2_client) { - throw new Error('EC2 SDK not available'); - } - - const ec2_module = '@aws-sdk/client-ec2'; - const ec2 = await Function('m', 'return import(m)')(ec2_module); - - // Extract instance ID from ARN - const instance_id = provider_id.split('/').pop(); - - // Update tags - if (properties.tags) { - const command = new ec2.CreateTagsCommand({ - Resources: [instance_id], - Tags: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ - Key, - Value, - })), - }); - await this.ec2_client.send(command); - } - } - - private async delete_ec2_instance(name: string, provider_id: string): Promise { - if (!this.ec2_client) { - throw new Error('EC2 SDK not available'); - } - - const ec2_module = '@aws-sdk/client-ec2'; - const ec2 = await Function('m', 'return import(m)')(ec2_module); - - const instance_id = provider_id.split('/').pop(); - - const command = new ec2.TerminateInstancesCommand({ - InstanceIds: [instance_id], - }); - - await this.ec2_client.send(command); - } - - // ============================================================================ - // S3 - // ============================================================================ - - private async create_s3_bucket(name: string, properties: Record): Promise { - if (!this.s3_client) { - throw new Error('S3 SDK not available. Install @aws-sdk/client-s3'); - } - - const s3_module = '@aws-sdk/client-s3'; - const s3 = await Function('m', 'return import(m)')(s3_module); - - const command = new s3.CreateBucketCommand({ - Bucket: name, - CreateBucketConfiguration: - this.region !== 'us-east-1' - ? { - LocationConstraint: this.region, - } - : undefined, - }); - - await this.s3_client.send(command); - - // Apply tags if provided - if (properties.tags) { - const tag_command = new s3.PutBucketTaggingCommand({ - Bucket: name, - Tagging: { - TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ - Key, - Value, - })), - }, - }); - await this.s3_client.send(tag_command); - } - - return `arn:aws:s3:::${name}`; - } - - private async update_s3_bucket( - name: string, - provider_id: string, - properties: Record, - ): Promise { - if (!this.s3_client) { - throw new Error('S3 SDK not available'); - } - - const s3_module = '@aws-sdk/client-s3'; - const s3 = await Function('m', 'return import(m)')(s3_module); - - // Update tags - if (properties.tags) { - const command = new s3.PutBucketTaggingCommand({ - Bucket: name, - Tagging: { - TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ - Key, - Value, - })), - }, - }); - await this.s3_client.send(command); - } - } - - private async delete_s3_bucket(name: string, _provider_id: string): Promise { - if (!this.s3_client) { - throw new Error('S3 SDK not available'); - } - - const s3_module = '@aws-sdk/client-s3'; - const s3 = await Function('m', 'return import(m)')(s3_module); - - // Delete all objects first - const list_command = new s3.ListObjectsV2Command({ Bucket: name }); - const objects = await this.s3_client.send(list_command); - - if (objects.Contents && objects.Contents.length > 0) { - const delete_command = new s3.DeleteObjectsCommand({ - Bucket: name, - Delete: { - Objects: objects.Contents.map((obj: any) => ({ Key: obj.Key })), - }, - }); - await this.s3_client.send(delete_command); - } - - // Delete the bucket - const command = new s3.DeleteBucketCommand({ Bucket: name }); - await this.s3_client.send(command); - } - - // ============================================================================ - // Lambda - // ============================================================================ - - private async create_lambda_function(name: string, properties: Record): Promise { - if (!this.lambda_client) { - throw new Error('Lambda SDK not available. Install @aws-sdk/client-lambda'); - } - - const lambda_module = '@aws-sdk/client-lambda'; - const lambda = await Function('m', 'return import(m)')(lambda_module); - - const command = new lambda.CreateFunctionCommand({ - FunctionName: name, - Runtime: (properties.runtime as string) || 'nodejs18.x', - Role: properties.role as string, - Handler: (properties.handler as string) || 'index.handler', - Code: { - S3Bucket: properties.s3_bucket as string, - S3Key: properties.s3_key as string, - ZipFile: properties.zip_file ? Buffer.from(properties.zip_file as string, 'base64') : undefined, - }, - Description: properties.description as string, - Timeout: (properties.timeout as number) || 30, - MemorySize: (properties.memory_size as number) || 128, - Environment: properties.environment - ? { - Variables: properties.environment as Record, - } - : undefined, - Tags: properties.tags as Record, - }); - - const result = await this.lambda_client.send(command); - - return result.FunctionArn; - } - - private async update_lambda_function( - name: string, - provider_id: string, - properties: Record, - ): Promise { - if (!this.lambda_client) { - throw new Error('Lambda SDK not available'); - } - - const lambda_module = '@aws-sdk/client-lambda'; - const lambda = await Function('m', 'return import(m)')(lambda_module); - - // Update configuration - const config_command = new lambda.UpdateFunctionConfigurationCommand({ - FunctionName: name, - Description: properties.description as string, - Timeout: properties.timeout as number, - MemorySize: properties.memory_size as number, - Environment: properties.environment - ? { - Variables: properties.environment as Record, - } - : undefined, - }); - await this.lambda_client.send(config_command); - - // Update code if provided - if (properties.s3_bucket && properties.s3_key) { - const code_command = new lambda.UpdateFunctionCodeCommand({ - FunctionName: name, - S3Bucket: properties.s3_bucket as string, - S3Key: properties.s3_key as string, - }); - await this.lambda_client.send(code_command); - } - } - - private async delete_lambda_function(name: string, _provider_id: string): Promise { - if (!this.lambda_client) { - throw new Error('Lambda SDK not available'); - } - - const lambda_module = '@aws-sdk/client-lambda'; - const lambda = await Function('m', 'return import(m)')(lambda_module); - - const command = new lambda.DeleteFunctionCommand({ - FunctionName: name, - }); - - await this.lambda_client.send(command); - } -} - -/** - * Create an AWS deployer instance. - */ -export function create_aws_deployer(): AWSDeployer { - return new AWSDeployer(); -} +export { AWSDeployer, create_aws_deployer } from './aws'; +export type { AWSHandlerContext, AWSResourceHandler } from './aws'; diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts new file mode 100644 index 00000000..534bd81b --- /dev/null +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -0,0 +1,143 @@ +/** + * AWS Deployer — Modular Dispatcher + * + * Routes create/update/delete calls to per-service handler modules. + * Replaces the monolithic aws-deployer.ts with the same dispatch + * shape the GCP deployer uses. Adding a new AWS service = + * register an entry in HANDLER_REGISTRY + add a file under + * `handlers/.ts`. + * + * Cardinal-rule schema-driven: HANDLER_REGISTRY is the single + * declarative fact for "which handler runs for which resource type". + * The dispatcher iterates it generically — no `if (type === 'aws.X')` + * branches. + */ + +import { ec2_handler } from './handlers/ec2'; +import { lambda_handler } from './handlers/lambda'; +import { s3_handler } from './handlers/s3'; +import { destroy_aws_clients, initialize_aws_clients } from './sdk-loader'; +import type { AWSHandlerContext, AWSResourceHandler } from './types'; +import type { DeployOptions, ResourceDeployResult, ProviderDeployer } from '../../types'; + +// ============================================================================= +// Handler registry — maps type prefixes to handlers +// ============================================================================= +// +// Ordering matters when prefixes overlap (longer / more-specific +// prefixes go first). At present every AWS resource type is unique +// at the `aws..` granularity so order is not yet +// meaningful — kept consistent with the GCP shape for symmetry. + +const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = [ + { prefix: 'aws.ec2.instance', handler: ec2_handler }, + { prefix: 'aws.s3.bucket', handler: s3_handler }, + { prefix: 'aws.lambda.function', handler: lambda_handler }, +]; + +function resolve_handler(type: string): AWSResourceHandler | undefined { + for (const entry of HANDLER_REGISTRY) { + if (type.startsWith(entry.prefix)) return entry.handler; + } + return undefined; +} + +function unsupported( + name: string, + type: string, + action: 'create' | 'update' | 'delete', + start: number, +): ResourceDeployResult { + // Preserve the original wording verbatim — the test suite pins these + // exact strings and consumer dashboards may key off them. + const phrase = action === 'create' ? 'creation' : action === 'delete' ? 'deletion' : 'update'; + return { + resource_id: name, + name, + type, + action, + success: false, + error: `Unsupported resource type for ${phrase}: ${type}`, + duration_ms: Date.now() - start, + }; +} + +/** + * AWS resource deployer. + * + * Holds an AWSHandlerContext that's reused for every create/update/ + * delete call within a single `initialize`/`cleanup` cycle. Per- + * handler logic lives in `./handlers/.ts`. + */ +export class AWSDeployer implements ProviderDeployer { + provider = 'aws'; + + private ctx: AWSHandlerContext = { + region: 'us-east-1', + clients: new Map(), + }; + + async initialize(options: DeployOptions): Promise { + const region = options.regions?.[0] || 'us-east-1'; + try { + const clients = await initialize_aws_clients(region); + this.ctx = { + region, + clients, + }; + } catch (error) { + throw new Error(`Failed to initialize AWS SDK: ${error instanceof Error ? error.message : String(error)}`, { + cause: error, + }); + } + } + + async cleanup(): Promise { + destroy_aws_clients(this.ctx.clients); + } + + async create( + type: string, + name: string, + properties: Record, + _options: Record, + ): Promise { + const start = Date.now(); + const handler = resolve_handler(type); + if (!handler) return unsupported(name, type, 'create', start); + return handler.create(name, properties, this.ctx); + } + + async update( + type: string, + name: string, + provider_id: string, + properties: Record, + current_properties: Record, + _options: Record, + ): Promise { + const start = Date.now(); + const handler = resolve_handler(type); + if (!handler) return unsupported(name, type, 'update', start); + return handler.update(name, provider_id, properties, current_properties, this.ctx); + } + + async delete( + type: string, + name: string, + provider_id: string, + _options: Record, + ): Promise { + const start = Date.now(); + const handler = resolve_handler(type); + if (!handler) return unsupported(name, type, 'delete', start); + return handler.delete(name, provider_id, this.ctx); + } +} + +/** + * Create an AWS deployer instance. + */ +export function create_aws_deployer(): AWSDeployer { + return new AWSDeployer(); +} diff --git a/packages/core/src/deploy/providers/aws/handlers/ec2.ts b/packages/core/src/deploy/providers/aws/handlers/ec2.ts new file mode 100644 index 00000000..2915f38f --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/ec2.ts @@ -0,0 +1,142 @@ +/** + * EC2 Handler + * + * Handles: aws.ec2.instance + * + * Migrated from the monolithic aws-deployer.ts. Behaviour-equivalent: + * RunInstancesCommand on create, CreateTagsCommand on update, and + * TerminateInstancesCommand on delete. The instance id is encoded + * into the provider_id as `arn:aws:ec2:{region}:*:instance/{id}` so + * update + delete can recover it from the ARN. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import type { ResourceDeployResult } from '../../../types'; +import type { AWSResourceHandler } from '../types'; + +function result( + name: string, + type: string, + action: 'create' | 'update' | 'delete', + start: number, + overrides: Partial = {}, +): ResourceDeployResult { + return { + resource_id: name, + name, + type, + action, + success: true, + duration_ms: Date.now() - start, + ...overrides, + }; +} + +function fail( + name: string, + type: string, + action: 'create' | 'update' | 'delete', + start: number, + error: string, +): ResourceDeployResult { + return { + resource_id: name, + name, + type, + action, + success: false, + error, + duration_ms: Date.now() - start, + }; +} + +const TYPE = 'aws.ec2.instance'; + +export const ec2_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ec2') as any; + if (!client) return fail(name, TYPE, 'create', start, 'EC2 SDK not available. Install @aws-sdk/client-ec2'); + + try { + const ec2 = await load_aws_sdk('@aws-sdk/client-ec2'); + if (!ec2) return fail(name, TYPE, 'create', start, 'EC2 SDK not available. Install @aws-sdk/client-ec2'); + + const image_id = (properties.image_id as string) || 'ami-0c55b159cbfafe1f0'; + const instance_type = (properties.instance_type as string) || 't2.micro'; + + const command = new ec2.RunInstancesCommand({ + ImageId: image_id, + InstanceType: instance_type, + MinCount: 1, + MaxCount: 1, + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { Key: 'Name', Value: name }, + ...Object.entries((properties.tags as Record) || {}).map(([Key, Value]) => ({ + Key, + Value: Value as string, + })), + ], + }, + ], + SubnetId: properties.subnet_id as string, + SecurityGroupIds: properties.security_group_ids as string[], + }); + + const sendResult = await client.send(command); + const instance_id = sendResult.Instances?.[0]?.InstanceId; + if (!instance_id) + return fail(name, TYPE, 'create', start, 'Failed to get instance ID from RunInstances response'); + + return result(name, TYPE, 'create', start, { + provider_id: `arn:aws:ec2:${ctx.region}:*:instance/${instance_id}`, + }); + } catch (error) { + return fail(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ec2') as any; + if (!client) return fail(name, TYPE, 'update', start, 'EC2 SDK not available'); + + try { + const ec2 = await load_aws_sdk('@aws-sdk/client-ec2'); + if (!ec2) return fail(name, TYPE, 'update', start, 'EC2 SDK not available'); + + const instance_id = provider_id.split('/').pop(); + if (properties.tags) { + const command = new ec2.CreateTagsCommand({ + Resources: [instance_id], + Tags: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), + }); + await client.send(command); + } + return result(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return fail(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ec2') as any; + if (!client) return fail(name, TYPE, 'delete', start, 'EC2 SDK not available'); + + try { + const ec2 = await load_aws_sdk('@aws-sdk/client-ec2'); + if (!ec2) return fail(name, TYPE, 'delete', start, 'EC2 SDK not available'); + + const instance_id = provider_id.split('/').pop(); + const command = new ec2.TerminateInstancesCommand({ InstanceIds: [instance_id] }); + await client.send(command); + return result(name, TYPE, 'delete', start); + } catch (error) { + return fail(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/lambda.ts b/packages/core/src/deploy/providers/aws/handlers/lambda.ts new file mode 100644 index 00000000..07dd6922 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/lambda.ts @@ -0,0 +1,138 @@ +/** + * Lambda Handler + * + * Handles: aws.lambda.function + * + * Migrated from the monolithic aws-deployer.ts. Baseline accepts the + * S3-ref code source today (`properties.s3_bucket` + `properties.s3_key` + * or a base64 `properties.zip_file`). Auto-build from a connected + * Source.Repository is wired in commit #28 (Phase 3). + */ + +import { load_aws_sdk } from '../sdk-loader'; +import type { ResourceDeployResult } from '../../../types'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.lambda.function'; + +function result( + name: string, + action: 'create' | 'update' | 'delete', + start: number, + overrides: Partial = {}, +): ResourceDeployResult { + return { + resource_id: name, + name, + type: TYPE, + action, + success: true, + duration_ms: Date.now() - start, + ...overrides, + }; +} + +function fail( + name: string, + action: 'create' | 'update' | 'delete', + start: number, + error: string, +): ResourceDeployResult { + return { + resource_id: name, + name, + type: TYPE, + action, + success: false, + error, + duration_ms: Date.now() - start, + }; +} + +export const lambda_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('lambda') as any; + if (!client) return fail(name, 'create', start, 'Lambda SDK not available. Install @aws-sdk/client-lambda'); + + try { + const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); + if (!lambda) return fail(name, 'create', start, 'Lambda SDK not available. Install @aws-sdk/client-lambda'); + + const command = new lambda.CreateFunctionCommand({ + FunctionName: name, + Runtime: (properties.runtime as string) || 'nodejs18.x', + Role: properties.role as string, + Handler: (properties.handler as string) || 'index.handler', + Code: { + S3Bucket: properties.s3_bucket as string, + S3Key: properties.s3_key as string, + ZipFile: properties.zip_file ? Buffer.from(properties.zip_file as string, 'base64') : undefined, + }, + Description: properties.description as string, + Timeout: (properties.timeout as number) || 30, + MemorySize: (properties.memory_size as number) || 128, + Environment: properties.environment + ? { Variables: properties.environment as Record } + : undefined, + Tags: properties.tags as Record, + }); + + const sendResult = await client.send(command); + return result(name, 'create', start, { provider_id: sendResult.FunctionArn }); + } catch (error) { + return fail(name, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('lambda') as any; + if (!client) return fail(name, 'update', start, 'Lambda SDK not available'); + + try { + const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); + if (!lambda) return fail(name, 'update', start, 'Lambda SDK not available'); + + const config_command = new lambda.UpdateFunctionConfigurationCommand({ + FunctionName: name, + Description: properties.description as string, + Timeout: properties.timeout as number, + MemorySize: properties.memory_size as number, + Environment: properties.environment + ? { Variables: properties.environment as Record } + : undefined, + }); + await client.send(config_command); + + if (properties.s3_bucket && properties.s3_key) { + const code_command = new lambda.UpdateFunctionCodeCommand({ + FunctionName: name, + S3Bucket: properties.s3_bucket as string, + S3Key: properties.s3_key as string, + }); + await client.send(code_command); + } + return result(name, 'update', start, { provider_id }); + } catch (error) { + return fail(name, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('lambda') as any; + if (!client) return fail(name, 'delete', start, 'Lambda SDK not available'); + + try { + const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); + if (!lambda) return fail(name, 'delete', start, 'Lambda SDK not available'); + + const command = new lambda.DeleteFunctionCommand({ FunctionName: name }); + await client.send(command); + return result(name, 'delete', start); + } catch (error) { + return fail(name, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/s3.ts b/packages/core/src/deploy/providers/aws/handlers/s3.ts new file mode 100644 index 00000000..55fbeb24 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/s3.ts @@ -0,0 +1,140 @@ +/** + * S3 Handler + * + * Handles: aws.s3.bucket + * + * Migrated from the monolithic aws-deployer.ts. Behaviour-equivalent + * baseline: + * - CreateBucketCommand on create (with LocationConstraint for + * non-us-east-1 regions) + * - PutBucketTaggingCommand on create (when tags present) + update + * - ListObjectsV2 → DeleteObjects → DeleteBucketCommand on delete + * + * The account-id suffix for global S3 name uniqueness is added in + * commit #8, after the shared STS infra lands. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import type { ResourceDeployResult } from '../../../types'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.s3.bucket'; + +function result( + name: string, + action: 'create' | 'update' | 'delete', + start: number, + overrides: Partial = {}, +): ResourceDeployResult { + return { + resource_id: name, + name, + type: TYPE, + action, + success: true, + duration_ms: Date.now() - start, + ...overrides, + }; +} + +function fail( + name: string, + action: 'create' | 'update' | 'delete', + start: number, + error: string, +): ResourceDeployResult { + return { + resource_id: name, + name, + type: TYPE, + action, + success: false, + error, + duration_ms: Date.now() - start, + }; +} + +export const s3_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('s3') as any; + if (!client) return fail(name, 'create', start, 'S3 SDK not available. Install @aws-sdk/client-s3'); + + try { + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!s3) return fail(name, 'create', start, 'S3 SDK not available. Install @aws-sdk/client-s3'); + + const command = new s3.CreateBucketCommand({ + Bucket: name, + CreateBucketConfiguration: ctx.region !== 'us-east-1' ? { LocationConstraint: ctx.region } : undefined, + }); + await client.send(command); + + if (properties.tags) { + const tag_command = new s3.PutBucketTaggingCommand({ + Bucket: name, + Tagging: { + TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), + }, + }); + await client.send(tag_command); + } + + return result(name, 'create', start, { provider_id: `arn:aws:s3:::${name}` }); + } catch (error) { + return fail(name, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('s3') as any; + if (!client) return fail(name, 'update', start, 'S3 SDK not available'); + + try { + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!s3) return fail(name, 'update', start, 'S3 SDK not available'); + + if (properties.tags) { + const command = new s3.PutBucketTaggingCommand({ + Bucket: name, + Tagging: { + TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), + }, + }); + await client.send(command); + } + return result(name, 'update', start, { provider_id }); + } catch (error) { + return fail(name, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('s3') as any; + if (!client) return fail(name, 'delete', start, 'S3 SDK not available'); + + try { + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!s3) return fail(name, 'delete', start, 'S3 SDK not available'); + + // Empty the bucket first — DeleteBucket fails on non-empty buckets. + const list_command = new s3.ListObjectsV2Command({ Bucket: name }); + const objects = await client.send(list_command); + if (objects.Contents && objects.Contents.length > 0) { + const delete_command = new s3.DeleteObjectsCommand({ + Bucket: name, + Delete: { Objects: objects.Contents.map((obj: any) => ({ Key: obj.Key })) }, + }); + await client.send(delete_command); + } + + const command = new s3.DeleteBucketCommand({ Bucket: name }); + await client.send(command); + return result(name, 'delete', start); + } catch (error) { + return fail(name, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/index.ts b/packages/core/src/deploy/providers/aws/index.ts new file mode 100644 index 00000000..9b6b12fd --- /dev/null +++ b/packages/core/src/deploy/providers/aws/index.ts @@ -0,0 +1,9 @@ +/** + * AWS Deployer Module + * + * Re-exports the modular AWS deployer and types. + */ + +export { AWSDeployer, create_aws_deployer } from './aws-deployer'; +export type { AWSHandlerContext, AWSResourceHandler } from './types'; +export { load_aws_sdk, initialize_aws_clients, destroy_aws_clients } from './sdk-loader'; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts new file mode 100644 index 00000000..85c78e77 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -0,0 +1,63 @@ +/** + * AWS SDK Lazy Loader + * + * Centralised lazy loading of `@aws-sdk/client-*` packages. Uses the + * `Function('m', 'return import(m)')` indirection so bundlers don't + * try to resolve the optional SDK packages at build time — packages + * absent from the install footprint fall through to `null` and the + * caller emits a friendly "SDK not installed" message. + * + * Parallel to `../gcp/sdk-loader.ts`. New SDK packages get an entry + * in `initialize_aws_clients` keyed by AWS service short-name; that + * key is what handlers ask for via `ctx.clients.get('')`. + */ + +/** + * Dynamically import an AWS SDK package. Returns null when the + * package isn't installed (the cross-cloud test harness intercepts + * this same pattern via a Function-constructor stub). + */ +export async function load_aws_sdk(module_name: string): Promise { + try { + return await Function('m', 'return import(m)')(module_name); + } catch { + return null; + } +} + +/** + * Initialise every AWS SDK client that's installed. + * + * Per-service short-name → constructor in this table is the schema + * the rest of the deployer reads. Handlers index the resulting Map + * by short-name (`ctx.clients.get('s3')`). Missing SDK packages are + * silently skipped — handlers detect absence and return a clean + * "install the package" message. + */ +export async function initialize_aws_clients(region: string): Promise> { + const clients = new Map(); + + const ec2 = await load_aws_sdk('@aws-sdk/client-ec2'); + if (ec2) clients.set('ec2', new ec2.EC2Client({ region })); + + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (s3) clients.set('s3', new s3.S3Client({ region })); + + const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); + if (lambda) clients.set('lambda', new lambda.LambdaClient({ region })); + + return clients; +} + +/** + * Tear down every client in `clients` that exposes a `.destroy()` + * method. Idempotent; safe to call when some clients never received + * SDK loading. + */ +export function destroy_aws_clients(clients: Map): void { + for (const client of clients.values()) { + if (client && typeof (client as { destroy?: () => void }).destroy === 'function') { + (client as { destroy: () => void }).destroy(); + } + } +} diff --git a/packages/core/src/deploy/providers/aws/types.ts b/packages/core/src/deploy/providers/aws/types.ts new file mode 100644 index 00000000..aa1cb35b --- /dev/null +++ b/packages/core/src/deploy/providers/aws/types.ts @@ -0,0 +1,66 @@ +/** + * AWS Deployer Types + * + * Shared interfaces for all AWS resource handlers. Parallel to the + * GCP equivalents in `../gcp/types.ts`. Adopting the same shape lets + * the AWS deployer benefit from the same per-handler patterns: + * + * - lazy SDK client pool (clients fetched once, reused per deploy) + * - sub-step progress reporting for long-running creates + * - user-cancel via AbortSignal + * - on_log callback for handlers that stream provider-side output + */ + +import type { ResourceDeployResult } from '../../types'; + +/** + * Context passed to every AWS resource handler. + */ +export interface AWSHandlerContext { + /** Default AWS region (e.g. `us-east-1`). Single-region deploys today. */ + region: string; + /** + * Lazy-loaded SDK clients keyed by AWS service short-name (`ec2`, + * `s3`, `lambda`, `rds`, …). Handlers `ctx.clients.get('s3')` to + * read theirs. Returns `undefined` when the SDK package isn't + * installed — handlers must guard with a friendly error. + */ + clients: Map; + /** Optional log callback for progress messages. */ + on_log?: (message: string) => void; + /** + * Optional sub-step progress reporter. Handlers that chain multiple + * long-running AWS operations (RDS provisioning, CloudFront, etc.) + * call this between sub-operations so the UI can show fractional + * progress instead of a 0 → 100% jump. + */ + on_step?: (resource: string, step: { label: string; index: number; total: number }) => void; + /** + * User-cancel signal from the per-card deploy lock. Handlers with + * long polls (RDS create polling, CloudFront distribution propagation, + * etc.) honour this so a cancel actually stops the remote work. + */ + abort_signal?: AbortSignal; +} + +/** + * Interface every AWS resource handler implements. Mirrors + * `GCPResourceHandler` so a future shared dispatch surface can treat + * both providers uniformly. + */ +export interface AWSResourceHandler { + /** Create a new resource. Returns the deploy result with `provider_id`. */ + create(name: string, properties: Record, ctx: AWSHandlerContext): Promise; + + /** Update an existing resource. */ + update( + name: string, + provider_id: string, + properties: Record, + current_properties: Record, + ctx: AWSHandlerContext, + ): Promise; + + /** Delete a resource. */ + delete(name: string, provider_id: string, ctx: AWSHandlerContext): Promise; +} From afdaf6a47023ada883f6a316278cd42758d3f228 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 13:42:34 +0200 Subject: [PATCH 16/52] feat(deploy/aws): extractors for compute (ecs.service, lambda.function, events.rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 commit 1/5 — first AWS extractor module. extractors/aws/compute.ts (new): - extract_ecs_service_properties ← Compute.Container, Compute.BackendAPI, Compute.SSRSite, Compute.Worker - extract_lambda_function_properties ← Compute.ServerlessFunction - extract_events_rule_properties ← Compute.CronJob extractors/dispatch.ts: Register the 3 extractors under their resolved aws.* resource types. Adds the first AWS section to PROPERTY_EXTRACTORS. Provider parity notes (extractor only — handlers come later): - ECS multi-port uses the shared parse_exposed_ports() so the canvas contract matches what Cloud Run sees today. - Lambda accepts the nested code.{s3Bucket,s3Key} shape AND falls through to the flat s3_bucket / s3_key fields for back-compat with the existing Lambda test harness. Auto-build from Source.Repository lands in commit #28. - EventBridge cron is the 6-field format (not unix 5-field). The named "daily"/"hourly"/"weekly"/"monthly" presets that GCP Cloud Scheduler accepts are normalised to the AWS expression so cards stay provider-portable. Tests: 24 new assertions covering defaults, passthrough, exposed_ports parsing, code-source shape variants, cron preset normalisation. The dispatch table test gets a new shape — counts GCP entries (27) separately from AWS (>=3, will grow with commits #3–#6), accepts aws.* keys in the {provider}.{service}.{kind} regex. --- package.json | 2 +- .../extractors/__tests__/dispatch.test.ts | 18 +- .../extractors/aws/__tests__/compute.test.ts | 171 ++++++++++++++++++ .../core/src/deploy/extractors/aws/compute.ts | 119 ++++++++++++ .../core/src/deploy/extractors/dispatch.ts | 11 ++ 5 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/deploy/extractors/aws/__tests__/compute.test.ts create mode 100644 packages/core/src/deploy/extractors/aws/compute.ts diff --git a/package.json b/package.json index 66eb40d5..d0ce9ebe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.735", + "version": "0.1.736", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts b/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts index 78273a35..f67ae4e9 100644 --- a/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts +++ b/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts @@ -55,14 +55,21 @@ import { } from '../network'; describe('PROPERTY_EXTRACTORS table shape', () => { - it('has exactly 27 entries (matches the 27 resolved GCP types the deployer supports)', () => { - expect(Object.keys(PROPERTY_EXTRACTORS)).toHaveLength(27); + it('counts GCP entries (27) + AWS entries — the latter grows with each AWS handler commit', () => { + const keys = Object.keys(PROPERTY_EXTRACTORS); + const gcpKeys = keys.filter((k) => k.startsWith('gcp.')); + const awsKeys = keys.filter((k) => k.startsWith('aws.')); + expect(gcpKeys).toHaveLength(27); + // AWS compute (commit #2): ecs.service, lambda.function, events.rule. + // Subsequent commits add database / network / ancillary / ai + // extractors — this assertion bumps when each lands. + expect(awsKeys.length).toBeGreaterThanOrEqual(3); }); - it('every key matches the gcp.{service}.{kind} shape', () => { - const pattern = /^gcp\.[a-z]+\.[a-zA-Z]+$/; + it('every key matches the {provider}.{service}.{kind} shape', () => { + const pattern = /^(gcp|aws|azure)\.[a-z0-9]+\.[a-zA-Z]+$/; for (const key of Object.keys(PROPERTY_EXTRACTORS)) { - expect(key, `key "${key}" should be gcp.{service}.{kind}`).toMatch(pattern); + expect(key, `key "${key}" should be {provider}.{service}.{kind}`).toMatch(pattern); } }); @@ -75,6 +82,7 @@ describe('PROPERTY_EXTRACTORS table shape', () => { it('returns undefined for an unknown key (orchestrator falls through to the error path)', () => { expect(PROPERTY_EXTRACTORS['gcp.unknown.thing']).toBeUndefined(); expect(PROPERTY_EXTRACTORS['']).toBeUndefined(); + // aws.s3.bucket is intentionally absent until commit #4 (aws/network) expect(PROPERTY_EXTRACTORS['aws.s3.bucket']).toBeUndefined(); }); }); diff --git a/packages/core/src/deploy/extractors/aws/__tests__/compute.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/compute.test.ts new file mode 100644 index 00000000..cc48e623 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/compute.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for AWS compute extractors. + * + * Mirrors the assertion style used by the GCP extractor tests: pin + * defaults, exercise passthrough fields, lock in the multi-port + + * cron-preset normalisations. Provider-specific defaults that differ + * from GCP (Fargate CPU/memory units, EventBridge 6-field cron) are + * called out in their own describe blocks so a future change to those + * defaults trips the test instead of silently shifting deployed + * resources. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_ecs_service_properties, + extract_lambda_function_properties, + extract_events_rule_properties, +} from '../compute'; + +describe('extract_ecs_service_properties', () => { + it('returns Fargate-shaped defaults for an empty data object', () => { + expect(extract_ecs_service_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + image: '', + repository: '', + branch: 'main', + port: 8080, + desired_count: 1, + min_capacity: 1, + max_capacity: 3, + cpu: '256', + memory: '512', + assign_public_ip: true, + internal: false, + env_vars: {}, + tags: {}, + }); + }); + + it('honours user-supplied image / port / branch / cpu / memory', () => { + const result = extract_ecs_service_properties( + { + image: 'my-org/api:v1.2', + port: 3000, + branch: 'release', + cpu: '512', + memory: '1024', + }, + 'eu-west-1', + ); + expect(result.image).toBe('my-org/api:v1.2'); + expect(result.port).toBe(3000); + expect(result.branch).toBe('release'); + expect(result.cpu).toBe('512'); + expect(result.memory).toBe('1024'); + expect(result.region).toBe('eu-west-1'); + }); + + it('parses exposed_ports and forwards additional_ports + primary port', () => { + const result = extract_ecs_service_properties( + { + exposed_ports: [ + { port: 443, protocol: 'https' }, + { port: 8080, protocol: 'http', label: 'admin' }, + ], + }, + 'us-east-1', + ); + expect(result.port).toBe(443); + expect(result.additional_ports).toEqual([ + { port: 443, protocol: 'https' }, + { port: 8080, protocol: 'http', label: 'admin' }, + ]); + }); + + it('maps minInstances/maxInstances onto ECS desired_count + capacity', () => { + const result = extract_ecs_service_properties({ minInstances: 2, maxInstances: 10 }, 'us-east-1'); + expect(result.desired_count).toBe(2); + expect(result.min_capacity).toBe(2); + expect(result.max_capacity).toBe(10); + }); +}); + +describe('extract_lambda_function_properties', () => { + it('returns nodejs20.x defaults + empty S3 ref for an empty data object', () => { + expect(extract_lambda_function_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + runtime: 'nodejs20.x', + handler: 'index.handler', + memory_size: 128, + timeout: 30, + s3_bucket: '', + s3_key: '', + role: '', + description: '', + repository: '', + branch: 'main', + environment: {}, + tags: {}, + }); + }); + + it('reads code from the nested code.{s3Bucket,s3Key} shape', () => { + const result = extract_lambda_function_properties( + { code: { s3Bucket: 'build-artifacts', s3Key: 'app/v3.zip' } }, + 'us-east-1', + ); + expect(result.s3_bucket).toBe('build-artifacts'); + expect(result.s3_key).toBe('app/v3.zip'); + }); + + it('falls back to top-level s3_bucket/s3_key when nested code is absent', () => { + const result = extract_lambda_function_properties({ s3_bucket: 'legacy', s3_key: 'fn.zip' }, 'us-east-1'); + expect(result.s3_bucket).toBe('legacy'); + expect(result.s3_key).toBe('fn.zip'); + }); + + it('passes runtime/handler/memory/timeout through', () => { + const result = extract_lambda_function_properties( + { runtime: 'python3.12', handler: 'main.lambda_handler', memory: 512, timeout: 120 }, + 'us-east-1', + ); + expect(result.runtime).toBe('python3.12'); + expect(result.handler).toBe('main.lambda_handler'); + expect(result.memory_size).toBe(512); + expect(result.timeout).toBe(120); + }); + + it('renames envVars → environment (Lambda SDK shape)', () => { + const result = extract_lambda_function_properties({ envVars: { LOG_LEVEL: 'debug' } }, 'us-east-1'); + expect(result.environment).toEqual({ LOG_LEVEL: 'debug' }); + }); +}); + +describe('extract_events_rule_properties', () => { + it('returns ENABLED-by-default cron(0 0 * * ? *) for an empty data object', () => { + expect(extract_events_rule_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + schedule_expression: 'cron(0 0 * * ? *)', + description: '', + state: 'ENABLED', + target_type: 'lambda', + target_arn: '', + tags: {}, + }); + }); + + it('maps named presets to EventBridge 6-field cron expressions', () => { + expect(extract_events_rule_properties({ schedule: 'daily' }, 'us-east-1').schedule_expression).toBe( + 'cron(0 0 * * ? *)', + ); + expect(extract_events_rule_properties({ schedule: 'hourly' }, 'us-east-1').schedule_expression).toBe( + 'cron(0 * * * ? *)', + ); + expect(extract_events_rule_properties({ schedule: 'weekly' }, 'us-east-1').schedule_expression).toBe( + 'cron(0 0 ? * SUN *)', + ); + expect(extract_events_rule_properties({ schedule: 'monthly' }, 'us-east-1').schedule_expression).toBe( + 'cron(0 0 1 * ? *)', + ); + }); + + it('passes a custom cron expression through verbatim', () => { + const custom = 'cron(15 10 ? * MON-FRI *)'; + expect(extract_events_rule_properties({ schedule: custom }, 'us-east-1').schedule_expression).toBe(custom); + }); + + it('honours enabled=false → state DISABLED', () => { + expect(extract_events_rule_properties({ enabled: false }, 'us-east-1').state).toBe('DISABLED'); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/compute.ts b/packages/core/src/deploy/extractors/aws/compute.ts new file mode 100644 index 00000000..e73f0be8 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/compute.ts @@ -0,0 +1,119 @@ +/** + * Property extractors for AWS compute services on the card-to-graph + * translator. + * + * Each extractor maps a canvas node's `data` payload to the + * deployer-handler input shape for a specific AWS compute resource + * type. The translator's dispatch table looks up the right extractor + * by resolved `resource_type`. + * + * Resources covered: + * - aws.ecs.service (Compute.Container, BackendAPI, SSRSite, Worker) + * - aws.lambda.function (Compute.ServerlessFunction) + * - aws.events.rule (Compute.CronJob) + * + * Loose `Record` types on the parameter and return + * value are intentional — handlers further down the pipeline coerce + * per-resource. The extractor lays down everything the handler needs + * to drive the AWS SDK call; provider-specific defaults that vary + * per resource (instance class, runtime, etc.) live here, not in the + * handler. + */ + +import { parse_exposed_ports } from '../compute'; + +/** + * ECS service — backs Compute.Container / Compute.BackendAPI / + * Compute.SSRSite / Compute.Worker on AWS. The handler will create a + * task definition + service. Multi-port `exposed_ports` is parsed via + * the shared compute helper so the shape matches what GCP Cloud Run + * sees today. + */ +export function extract_ecs_service_properties(data: Record, region: string): Record { + const ports = parse_exposed_ports(data); + const primaryPort = ports[0]?.port ?? (data.port as number | undefined) ?? 8080; + return { + region, + image: (data.image as string) || '', + repository: (data.repository as string) || '', + branch: (data.branch as string) || 'main', + port: primaryPort, + ...(ports.length > 0 && { additional_ports: ports }), + // ECS scaling — desired_count + min/max capacity. Mirrors GCP's + // `min_instances`/`max_instances` semantics; auto-scaling policy + // creation is the handler's job. + desired_count: data.minInstances ?? 1, + min_capacity: data.minInstances ?? 1, + max_capacity: data.maxInstances ?? 3, + // Fargate uses CPU/memory as integers (CPU units, MiB). Defaults + // match the smallest Fargate task size — 256 CPU + 512 MiB. + cpu: data.cpu || '256', + memory: data.memory || '512', + // Service-level network mode. Public assignment is decided by the + // INTERNAL_INGRESS_OVERRIDES table at translator time when nested + // in an isolation container. + assign_public_ip: data.assign_public_ip ?? true, + internal: data.internal ?? false, + env_vars: data.envVars || {}, + tags: {}, + }; +} + +/** + * Lambda function. The handler accepts the S3-ref code source today + * (`code: { s3Bucket, s3Key }`); auto-build from a connected + * Source.Repository ships in commit #28 (Phase 3). + */ +export function extract_lambda_function_properties( + data: Record, + region: string, +): Record { + const code = (data.code as { s3Bucket?: string; s3Key?: string } | undefined) ?? {}; + return { + region, + runtime: (data.runtime as string) || 'nodejs20.x', + handler: (data.handler as string) || 'index.handler', + memory_size: (data.memory as number) || 128, + timeout: (data.timeout as number) || 30, + // S3-ref code source — handler reads `s3_bucket` + `s3_key`. + // Auto-build flow (commit #28) sets these after uploading the zip. + s3_bucket: code.s3Bucket || (data.s3_bucket as string) || '', + s3_key: code.s3Key || (data.s3_key as string) || '', + // IAM execution role; ECS-style auto-provisioning of a default + // role is not yet wired for Lambda — operators supply the ARN. + role: (data.role as string) || '', + description: (data.description as string) || '', + repository: (data.repository as string) || '', + branch: (data.branch as string) || 'main', + environment: data.envVars || {}, + tags: {}, + }; +} + +/** + * EventBridge rule — backs Compute.CronJob on AWS. The cron expression + * is normalised the same way GCP's cloud_scheduler extractor handles + * the named "daily" / "hourly" / "weekly" / "monthly" presets, so + * project portability is preserved. + */ +export function extract_events_rule_properties(data: Record, region: string): Record { + // EventBridge uses `cron(min hour day-of-month month day-of-week year)` + // (6 fields, not the 5-field unix cron). The named presets map to + // EventBridge expressions directly. + const schedule_map: Record = { + daily: 'cron(0 0 * * ? *)', + hourly: 'cron(0 * * * ? *)', + weekly: 'cron(0 0 ? * SUN *)', + monthly: 'cron(0 0 1 * ? *)', + }; + const schedule = (data.schedule as string) || 'daily'; + return { + region, + schedule_expression: schedule_map[schedule] || schedule, + description: (data.description as string) || '', + state: data.enabled === false ? 'DISABLED' : 'ENABLED', + target_type: (data.targetType as string) || 'lambda', + target_arn: (data.targetArn as string) || '', + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/dispatch.ts b/packages/core/src/deploy/extractors/dispatch.ts index 187757b1..3f78c766 100644 --- a/packages/core/src/deploy/extractors/dispatch.ts +++ b/packages/core/src/deploy/extractors/dispatch.ts @@ -31,6 +31,11 @@ import { extract_backend_bucket_properties, extract_firebase_hosting_properties, } from './ancillary'; +import { + extract_ecs_service_properties, + extract_lambda_function_properties, + extract_events_rule_properties, +} from './aws/compute'; import { extract_cloud_run_properties, extract_cloud_run_job_properties, @@ -79,4 +84,10 @@ export const PROPERTY_EXTRACTORS: Record< 'gcp.compute.subnetwork': extract_subnet_properties, 'gcp.compute.securityPolicy': extract_cloud_armor_properties, 'gcp.firebase.hosting': extract_firebase_hosting_properties, + + // ─── AWS — compute ───────────────────────────────────────────────── + // (More AWS categories register in commits #3–#6.) + 'aws.ecs.service': extract_ecs_service_properties, + 'aws.lambda.function': extract_lambda_function_properties, + 'aws.events.rule': extract_events_rule_properties, }; From 60c4fc80a26f91073aec1ec1a99132d694f49b26 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 13:45:59 +0200 Subject: [PATCH 17/52] feat(deploy/aws): extractors for database (rds, dynamodb, elasticache, docdb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 commit 2/5. extractors/aws/database.ts (new): - extract_rds_db_instance_properties ← Database.PostgreSQL, MySQL - extract_dynamodb_table_properties ← Database.DynamoDB - extract_elasticache_cluster_properties ← Database.Redis - extract_docdb_cluster_properties ← Database.MongoDB extractors/dispatch.ts: register all 4 under aws.* resource types. Provider parity: - RDS engine + version inferred from iceType + runtime string, same rule the GCP Cloud SQL extractor uses → cards stay portable. - master_user_password defaults to '' so the handler fails loudly rather than provisioning RDS / DocDB with no real credential. - ElastiCache exposes ELASTICACHE_REDIS_SIZE_MAP for the canvas M-series enum (M1 → cache.t3.micro, M5 → cache.m5.xlarge ×2 for HA parity with GCP STANDARD_HA). - DynamoDB defaults to PAY_PER_REQUEST (AWS-recommended for new workloads); PROVISIONED branch emits RCU/WCU only when set. 15 new test assertions across the four resources covering engine detection, version extraction, size-enum translation, billing-mode branching, and password defaults. --- package.json | 2 +- .../extractors/aws/__tests__/database.test.ts | 146 ++++++++++++++++++ .../src/deploy/extractors/aws/database.ts | 145 +++++++++++++++++ .../core/src/deploy/extractors/dispatch.ts | 13 +- 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/deploy/extractors/aws/__tests__/database.test.ts create mode 100644 packages/core/src/deploy/extractors/aws/database.ts diff --git a/package.json b/package.json index d0ce9ebe..634328ac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.736", + "version": "0.1.737", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/extractors/aws/__tests__/database.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/database.test.ts new file mode 100644 index 00000000..8be2232a --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/database.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for AWS database extractors. + * + * Locks in engine selection (PostgreSQL vs MySQL via iceType), + * size-enum translation (M-series → ElastiCache node types), DynamoDB + * billing-mode branching, and the "no default password" invariant on + * RDS + DocDB. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_rds_db_instance_properties, + extract_dynamodb_table_properties, + extract_elasticache_cluster_properties, + extract_docdb_cluster_properties, + ELASTICACHE_REDIS_SIZE_MAP, +} from '../database'; + +describe('extract_rds_db_instance_properties', () => { + it('defaults to PostgreSQL 16 when iceType is Database.PostgreSQL', () => { + const result = extract_rds_db_instance_properties({ iceType: 'Database.PostgreSQL' }, 'us-east-1'); + expect(result.engine).toBe('postgres'); + expect(result.engine_version).toBe('16'); + expect(result.port).toBe(5432); + expect(result.master_username).toBe('postgres'); + }); + + it('defaults to MySQL 8.0 when iceType is Database.MySQL', () => { + const result = extract_rds_db_instance_properties({ iceType: 'Database.MySQL' }, 'us-east-1'); + expect(result.engine).toBe('mysql'); + expect(result.engine_version).toBe('8.0'); + expect(result.port).toBe(3306); + expect(result.master_username).toBe('admin'); + }); + + it('extracts version from runtime string (e.g. "PostgreSQL 14.5" → 14.5)', () => { + const result = extract_rds_db_instance_properties( + { iceType: 'Database.PostgreSQL', runtime: 'PostgreSQL 14.5' }, + 'us-east-1', + ); + expect(result.engine_version).toBe('14.5'); + }); + + it('defaults to db.t3.micro instance class', () => { + expect(extract_rds_db_instance_properties({}, 'us-east-1').db_instance_class).toBe('db.t3.micro'); + }); + + it('honours user-supplied size + storage', () => { + const result = extract_rds_db_instance_properties({ size: 'db.r5.large', storage: '100GB' }, 'us-east-1'); + expect(result.db_instance_class).toBe('db.r5.large'); + expect(result.allocated_storage).toBe(100); + }); + + it('leaves master_user_password empty by default (handler must error)', () => { + expect(extract_rds_db_instance_properties({}, 'us-east-1').master_user_password).toBe(''); + }); +}); + +describe('extract_dynamodb_table_properties', () => { + it('defaults to PAY_PER_REQUEST billing mode + string id partition key', () => { + expect(extract_dynamodb_table_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + billing_mode: 'PAY_PER_REQUEST', + partition_key: 'id', + partition_key_type: 'S', + point_in_time_recovery: true, + }); + }); + + it('emits RCU/WCU only when billing_mode === PROVISIONED', () => { + const onDemand = extract_dynamodb_table_properties({}, 'us-east-1'); + expect(onDemand.read_capacity).toBeUndefined(); + expect(onDemand.write_capacity).toBeUndefined(); + + const provisioned = extract_dynamodb_table_properties({ billing_mode: 'PROVISIONED' }, 'us-east-1'); + expect(provisioned.read_capacity).toBe(5); + expect(provisioned.write_capacity).toBe(5); + }); + + it('honours user-supplied partition/sort key shape', () => { + const result = extract_dynamodb_table_properties( + { partition_key: 'pk', partition_key_type: 'N', sort_key: 'sk', sort_key_type: 'S' }, + 'us-east-1', + ); + expect(result).toMatchObject({ + partition_key: 'pk', + partition_key_type: 'N', + sort_key: 'sk', + sort_key_type: 'S', + }); + }); +}); + +describe('extract_elasticache_cluster_properties', () => { + it('defaults to Redis 7.0 on cache.t3.micro', () => { + expect(extract_elasticache_cluster_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + engine: 'redis', + engine_version: '7.0', + cache_node_type: 'cache.t3.micro', + num_cache_nodes: 1, + port: 6379, + }); + }); + + it('translates the M-series size enum to a known node type', () => { + for (const [size, mapped] of Object.entries(ELASTICACHE_REDIS_SIZE_MAP)) { + const result = extract_elasticache_cluster_properties({ size }, 'us-east-1'); + expect(result.cache_node_type, `${size} → node type`).toBe(mapped.node_type); + expect(result.num_cache_nodes, `${size} → num nodes`).toBe(mapped.num_nodes); + } + }); + + it('falls through to cache_node_type when size is not a known M-tier', () => { + const result = extract_elasticache_cluster_properties( + { cache_node_type: 'cache.r5.xlarge', num_cache_nodes: 3 }, + 'us-east-1', + ); + expect(result.cache_node_type).toBe('cache.r5.xlarge'); + expect(result.num_cache_nodes).toBe(3); + }); +}); + +describe('extract_docdb_cluster_properties', () => { + it('defaults to engine_version 5.0.0 + db.t3.medium', () => { + expect(extract_docdb_cluster_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + engine: 'docdb', + engine_version: '5.0.0', + db_instance_class: 'db.t3.medium', + instance_count: 1, + port: 27017, + storage_encrypted: true, + }); + }); + + it('leaves master_user_password empty by default (handler must error)', () => { + expect(extract_docdb_cluster_properties({}, 'us-east-1').master_user_password).toBe(''); + }); + + it('honours instance_count + master_username overrides', () => { + const result = extract_docdb_cluster_properties({ instance_count: 3, master_username: 'mongo' }, 'us-east-1'); + expect(result.instance_count).toBe(3); + expect(result.master_username).toBe('mongo'); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/database.ts b/packages/core/src/deploy/extractors/aws/database.ts new file mode 100644 index 00000000..dd54ec56 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/database.ts @@ -0,0 +1,145 @@ +/** + * Property extractors for AWS database services. + * + * Resources covered: + * - aws.rds.dbInstance (Database.PostgreSQL, Database.MySQL) + * - aws.dynamodb.table (Database.DynamoDB) + * - aws.elasticache.cluster (Database.Redis) + * - aws.docdb.cluster (Database.MongoDB) + * + * Each extractor lays down the AWS SDK-shaped property dict the + * handler will hand to the SDK. Provider-specific defaults (instance + * class, engine version, billing mode) live here. + */ + +import { parse_storage_gb } from '../../utils/name-utils'; + +/** + * RDS dbInstance — backs both Database.PostgreSQL and Database.MySQL. + * Engine + version are inferred from `iceType` and `runtime` the same + * way the GCP Cloud SQL extractor does, so the canvas contract stays + * provider-agnostic. + * + * Note: the master_user_password field is intentionally a literal + * placeholder — operators must supply a real secret via the + * connected Security.Secret block (or set the field explicitly). + * The handler will reject empty passwords loudly rather than create + * an RDS instance with a default credential. + */ +export function extract_rds_db_instance_properties( + data: Record, + region: string, +): Record { + const ice_type = data.iceType as string; + const is_postgres = ice_type === 'Database.PostgreSQL'; + const runtime = (data.runtime as string) || (is_postgres ? 'PostgreSQL 16' : 'MySQL 8.0'); + const version_match = runtime.match(/(\d+(\.\d+)?)/); + const version_num = version_match?.[1] ?? (is_postgres ? '16' : '8.0'); + + return { + region, + engine: is_postgres ? 'postgres' : 'mysql', + engine_version: version_num, + // RDS uses `db..` instance classes — db.t3.micro is + // the smallest Burstable option, mirrors db-f1-micro on Cloud SQL. + db_instance_class: (data.size as string) || 'db.t3.micro', + allocated_storage: parse_storage_gb(data.storage as string) || 20, + storage_type: (data.storageType as string) || 'gp3', + backup_retention_period: data.backup_retention ?? 7, + publicly_accessible: data.publicly_accessible ?? false, + multi_az: data.multi_az ?? false, + master_username: (data.master_username as string) || (is_postgres ? 'postgres' : 'admin'), + // Empty string forces the handler to error rather than ship a + // resource with no credential. + master_user_password: (data.master_user_password as string) || '', + port: data.port || (is_postgres ? 5432 : 3306), + tags: {}, + }; +} + +/** + * DynamoDB table — pay-per-request by default (the AWS recommended + * mode for new workloads). Operators can switch to provisioned by + * setting `billing_mode: 'PROVISIONED'` and supplying RCU/WCU values. + */ +export function extract_dynamodb_table_properties( + data: Record, + region: string, +): Record { + const billing_mode = (data.billing_mode as string) || 'PAY_PER_REQUEST'; + return { + region, + billing_mode, + // Hash key defaults to a string `id` — the most common DynamoDB + // shape. Operators override via `partition_key` / `sort_key`. + partition_key: (data.partition_key as string) || 'id', + partition_key_type: (data.partition_key_type as string) || 'S', + sort_key: (data.sort_key as string) || undefined, + sort_key_type: (data.sort_key_type as string) || undefined, + // Provisioned-mode capacity. Ignored by the handler when + // billing_mode === 'PAY_PER_REQUEST'. + ...(billing_mode === 'PROVISIONED' && { + read_capacity: data.read_capacity ?? 5, + write_capacity: data.write_capacity ?? 5, + }), + point_in_time_recovery: data.point_in_time_recovery ?? true, + tags: {}, + }; +} + +/** + * ElastiCache cluster — Redis. The canvas exposes the same M-series + * size enum that GCP Memorystore uses; we translate to the AWS + * `cache..` node type so blocks remain portable. + */ +export const ELASTICACHE_REDIS_SIZE_MAP: Record = { + M1: { node_type: 'cache.t3.micro', num_nodes: 1 }, + M2: { node_type: 'cache.t3.small', num_nodes: 1 }, + M3: { node_type: 'cache.t3.medium', num_nodes: 1 }, + M4: { node_type: 'cache.m5.large', num_nodes: 1 }, + // M5 is the HA tier on GCP; on AWS we approximate by spinning up + // multi-az replicas. The handler will set ReplicationGroup mode. + M5: { node_type: 'cache.m5.xlarge', num_nodes: 2 }, +}; + +export function extract_elasticache_cluster_properties( + data: Record, + region: string, +): Record { + const size = typeof data.size === 'string' ? data.size : null; + const mapped = size && ELASTICACHE_REDIS_SIZE_MAP[size] ? ELASTICACHE_REDIS_SIZE_MAP[size] : null; + return { + region, + engine: 'redis', + engine_version: (data.redisVersion as string) || '7.0', + cache_node_type: mapped?.node_type ?? (data.cache_node_type as string) ?? 'cache.t3.micro', + num_cache_nodes: mapped?.num_nodes ?? (data.num_cache_nodes as number) ?? 1, + port: data.port || 6379, + parameter_group_name: (data.parameter_group_name as string) || 'default.redis7', + tags: {}, + }; +} + +/** + * DocumentDB cluster — MongoDB-compatible managed engine. Like RDS, + * DocDB needs an admin password supplied by the operator (no default). + */ +export function extract_docdb_cluster_properties( + data: Record, + region: string, +): Record { + return { + region, + engine: 'docdb', + engine_version: (data.engineVersion as string) || '5.0.0', + db_cluster_identifier: (data.cluster_identifier as string) || '', + db_instance_class: (data.size as string) || 'db.t3.medium', + instance_count: (data.instance_count as number) ?? 1, + master_username: (data.master_username as string) || 'admin', + master_user_password: (data.master_user_password as string) || '', + backup_retention_period: data.backup_retention ?? 7, + storage_encrypted: data.storage_encrypted ?? true, + port: data.port || 27017, + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/dispatch.ts b/packages/core/src/deploy/extractors/dispatch.ts index 3f78c766..889ab46a 100644 --- a/packages/core/src/deploy/extractors/dispatch.ts +++ b/packages/core/src/deploy/extractors/dispatch.ts @@ -36,6 +36,12 @@ import { extract_lambda_function_properties, extract_events_rule_properties, } from './aws/compute'; +import { + extract_rds_db_instance_properties, + extract_dynamodb_table_properties, + extract_elasticache_cluster_properties, + extract_docdb_cluster_properties, +} from './aws/database'; import { extract_cloud_run_properties, extract_cloud_run_job_properties, @@ -86,8 +92,13 @@ export const PROPERTY_EXTRACTORS: Record< 'gcp.firebase.hosting': extract_firebase_hosting_properties, // ─── AWS — compute ───────────────────────────────────────────────── - // (More AWS categories register in commits #3–#6.) 'aws.ecs.service': extract_ecs_service_properties, 'aws.lambda.function': extract_lambda_function_properties, 'aws.events.rule': extract_events_rule_properties, + + // ─── AWS — database ──────────────────────────────────────────────── + 'aws.rds.dbInstance': extract_rds_db_instance_properties, + 'aws.dynamodb.table': extract_dynamodb_table_properties, + 'aws.elasticache.cluster': extract_elasticache_cluster_properties, + 'aws.docdb.cluster': extract_docdb_cluster_properties, }; From d010862a376f695048d139406cbe0459bad83911 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 13:47:57 +0200 Subject: [PATCH 18/52] feat(deploy/aws): extractors for network (s3, apigateway, cloudfront, elbv2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 commit 3/5. extractors/aws/network.ts (new): - extract_s3_bucket_properties ← Storage.Bucket / Storage.ObjectStorage / Compute.StaticSite - extract_api_gateway_rest_api_properties ← Network.Gateway - extract_cloudfront_distribution_properties ← Network.PublicEndpoint / Network.CustomDomain - extract_elbv2_load_balancer_properties ← Network.LoadBalancer extractors/dispatch.ts: register all 4 under aws.* resource types. Cross-provider parity: - S3 reads the publicWebsiteSource role from the shared block- classifier table; same flip-policy the GCP cloud_storage extractor uses for Compute.StaticSite. Plain Storage.Bucket stays private. - CloudFront defaults to HTTPS + auto-cert + PriceClass_100 (most- common cost-aware preset). Cert provisioning in us-east-1 is the handler's job in commit #19. - ELBv2 defaults to internet-facing ALB on HTTPS:443; flips to `internal` scheme when `internal: true` (parity with the INTERNAL_INGRESS_OVERRIDES table semantics). 11 new tests + dispatch table assertion updated (the previous "aws.s3.bucket is intentionally absent" assertion is replaced with a generic "aws.unknown.thing" check now that S3 has landed). --- package.json | 2 +- .../extractors/__tests__/dispatch.test.ts | 3 +- .../extractors/aws/__tests__/network.test.ts | 102 ++++++++++++++++ .../core/src/deploy/extractors/aws/network.ts | 109 ++++++++++++++++++ .../core/src/deploy/extractors/dispatch.ts | 12 ++ 5 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/deploy/extractors/aws/__tests__/network.test.ts create mode 100644 packages/core/src/deploy/extractors/aws/network.ts diff --git a/package.json b/package.json index 634328ac..afcccbdf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.737", + "version": "0.1.738", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts b/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts index f67ae4e9..2e607d07 100644 --- a/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts +++ b/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts @@ -82,8 +82,7 @@ describe('PROPERTY_EXTRACTORS table shape', () => { it('returns undefined for an unknown key (orchestrator falls through to the error path)', () => { expect(PROPERTY_EXTRACTORS['gcp.unknown.thing']).toBeUndefined(); expect(PROPERTY_EXTRACTORS['']).toBeUndefined(); - // aws.s3.bucket is intentionally absent until commit #4 (aws/network) - expect(PROPERTY_EXTRACTORS['aws.s3.bucket']).toBeUndefined(); + expect(PROPERTY_EXTRACTORS['aws.unknown.thing']).toBeUndefined(); }); }); diff --git a/packages/core/src/deploy/extractors/aws/__tests__/network.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/network.test.ts new file mode 100644 index 00000000..401db82e --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/network.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for AWS network extractors. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_s3_bucket_properties, + extract_api_gateway_rest_api_properties, + extract_cloudfront_distribution_properties, + extract_elbv2_load_balancer_properties, +} from '../network'; + +describe('extract_s3_bucket_properties', () => { + it('returns private-bucket defaults for an empty data object', () => { + expect(extract_s3_bucket_properties({}, 'us-east-1')).toEqual({ + storage_class: 'STANDARD', + versioning: false, + public_access: false, + website_hosting: false, + index_page: 'index.html', + not_found_page: '404.html', + block_public_acls: true, + encryption: 'AES256', + tags: {}, + }); + }); + + it('flips public + website hosting when iceType has publicWebsiteSource role (Compute.StaticSite)', () => { + const result = extract_s3_bucket_properties({ iceType: 'Compute.StaticSite' }, 'us-east-1'); + expect(result.public_access).toBe(true); + expect(result.website_hosting).toBe(true); + expect(result.block_public_acls).toBe(false); + }); + + it('plain Storage.Bucket stays private', () => { + const result = extract_s3_bucket_properties({ iceType: 'Storage.Bucket' }, 'us-east-1'); + expect(result.public_access).toBe(false); + expect(result.website_hosting).toBe(false); + }); +}); + +describe('extract_api_gateway_rest_api_properties', () => { + it('defaults to REGIONAL + stage "prod"', () => { + expect(extract_api_gateway_rest_api_properties({}, 'eu-west-1')).toEqual({ + region: 'eu-west-1', + endpoint_type: 'REGIONAL', + description: '', + api_key_required: false, + stage_name: 'prod', + binary_media_types: [], + tags: {}, + }); + }); + + it('honours endpoint_type=EDGE override', () => { + expect(extract_api_gateway_rest_api_properties({ endpoint_type: 'EDGE' }, 'us-east-1').endpoint_type).toBe('EDGE'); + }); +}); + +describe('extract_cloudfront_distribution_properties', () => { + it('defaults to HTTPS + auto-cert + PriceClass_100', () => { + expect(extract_cloudfront_distribution_properties({}, 'us-east-1')).toMatchObject({ + enableHttps: true, + auto_provision_cert: true, + redirect_http_to_https: true, + domain: '', + cache_policy_name: 'CachingOptimized', + origin_request_policy_name: 'CORS-S3Origin', + price_class: 'PriceClass_100', + }); + }); + + it('passes the user-supplied root domain through', () => { + expect(extract_cloudfront_distribution_properties({ domain: 'example.com' }, 'us-east-1').domain).toBe( + 'example.com', + ); + }); +}); + +describe('extract_elbv2_load_balancer_properties', () => { + it('defaults to internet-facing ALB on HTTPS:443', () => { + expect(extract_elbv2_load_balancer_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + scheme: 'internet-facing', + type: 'application', + listener_port: 443, + listener_protocol: 'HTTPS', + target_group_port: 80, + target_group_protocol: 'HTTP', + }); + }); + + it('flips to internal scheme when internal=true', () => { + expect(extract_elbv2_load_balancer_properties({ internal: true }, 'us-east-1').scheme).toBe('internal'); + }); + + it('drops to HTTP:80 listener when enable_https=false', () => { + const result = extract_elbv2_load_balancer_properties({ enable_https: false }, 'us-east-1'); + expect(result.listener_port).toBe(80); + expect(result.listener_protocol).toBe('HTTP'); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/network.ts b/packages/core/src/deploy/extractors/aws/network.ts new file mode 100644 index 00000000..f83b6af4 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/network.ts @@ -0,0 +1,109 @@ +/** + * Property extractors for AWS network services. + * + * Resources covered: + * - aws.s3.bucket (Storage.Bucket, Storage.ObjectStorage, Compute.StaticSite) + * - aws.apigateway.restApi (Network.Gateway) + * - aws.cloudfront.distribution (Network.PublicEndpoint, Network.CustomDomain) + * - aws.elbv2.loadBalancer (Network.LoadBalancer) + */ + +import { hasBlockRole } from '@ice/constants'; + +/** + * S3 bucket. Compute.StaticSite carries the `publicWebsiteSource` + * role so the handler flips bucket policy + website hosting (mirrors + * the GCP storage extractor's matching branch). Plain Storage.Bucket + * stays private. + */ +export function extract_s3_bucket_properties(data: Record, _region: string): Record { + const iceType = String(data.iceType || ''); + const isPublicWebsite = hasBlockRole(iceType, 'publicWebsiteSource'); + return { + // No `region` field — S3 buckets are technically global names with + // a region attribute, set by the handler via LocationConstraint. + storage_class: (data.storageClass as string) || 'STANDARD', + versioning: data.versioning ?? false, + public_access: isPublicWebsite || data.public_access === true, + website_hosting: isPublicWebsite || data.website_hosting === true, + index_page: (data.index_page as string) || 'index.html', + not_found_page: (data.not_found_page as string) || '404.html', + block_public_acls: !isPublicWebsite && (data.block_public_acls ?? true), + encryption: (data.encryption as string) || 'AES256', + tags: {}, + }; +} + +/** + * API Gateway REST API. Defaults to a regional endpoint (cheaper + + * lower latency than EDGE). Operators wanting a CloudFront-fronted + * edge endpoint set `endpoint_type: 'EDGE'`. + */ +export function extract_api_gateway_rest_api_properties( + data: Record, + region: string, +): Record { + return { + region, + endpoint_type: (data.endpoint_type as string) || 'REGIONAL', + description: (data.description as string) || '', + api_key_required: data.api_key_required ?? false, + // Stage / deployment created lazily by the handler when a backing + // Lambda or ECS service is wired in via outgoing edges. + stage_name: (data.stage_name as string) || 'prod', + binary_media_types: (data.binary_media_types as string[]) || [], + tags: {}, + }; +} + +/** + * CloudFront distribution — backs both Network.PublicEndpoint AND + * Network.CustomDomain (when nested inside a PrivateNetwork). The + * extractor lays down origins + cache behaviours; the handler + * synthesises the CloudFront-required ACM cert in us-east-1 and + * wires it onto the distribution. + */ +export function extract_cloudfront_distribution_properties( + data: Record, + _region: string, +): Record { + return { + enableHttps: data.enableHttps ?? true, + auto_provision_cert: data.autoProvisionCert ?? true, + redirect_http_to_https: data.redirectHttpToHttps ?? true, + // Single root domain on this block — per-subdomain mapping comes + // from outgoing-edge propagation (see pass-1-5-endpoint-wiring). + domain: (data.domain as string) || '', + // Cache + origin policy presets. Most users stay on the defaults + // (CachingOptimized + CORS-S3Origin); the handler resolves the + // managed-policy IDs by name. + cache_policy_name: (data.cache_policy_name as string) || 'CachingOptimized', + origin_request_policy_name: (data.origin_request_policy_name as string) || 'CORS-S3Origin', + price_class: (data.price_class as string) || 'PriceClass_100', + tags: {}, + }; +} + +/** + * Application Load Balancer (ELBv2). Sized for HTTPS by default; the + * handler attaches a default target group when no compute backend is + * wired (silent until the user connects something). + */ +export function extract_elbv2_load_balancer_properties( + data: Record, + region: string, +): Record { + return { + region, + scheme: data.internal === true ? 'internal' : 'internet-facing', + type: (data.lb_type as string) || 'application', + ip_address_type: (data.ip_address_type as string) || 'ipv4', + enable_deletion_protection: data.enable_deletion_protection ?? false, + // Listener port (HTTPS by default, HTTP fallback when cert not set). + listener_port: data.listener_port ?? (data.enable_https !== false ? 443 : 80), + listener_protocol: data.listener_protocol ?? (data.enable_https !== false ? 'HTTPS' : 'HTTP'), + target_group_port: data.target_group_port ?? 80, + target_group_protocol: data.target_group_protocol ?? 'HTTP', + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/dispatch.ts b/packages/core/src/deploy/extractors/dispatch.ts index 889ab46a..c9ce5b3e 100644 --- a/packages/core/src/deploy/extractors/dispatch.ts +++ b/packages/core/src/deploy/extractors/dispatch.ts @@ -42,6 +42,12 @@ import { extract_elasticache_cluster_properties, extract_docdb_cluster_properties, } from './aws/database'; +import { + extract_s3_bucket_properties, + extract_api_gateway_rest_api_properties, + extract_cloudfront_distribution_properties, + extract_elbv2_load_balancer_properties, +} from './aws/network'; import { extract_cloud_run_properties, extract_cloud_run_job_properties, @@ -101,4 +107,10 @@ export const PROPERTY_EXTRACTORS: Record< 'aws.dynamodb.table': extract_dynamodb_table_properties, 'aws.elasticache.cluster': extract_elasticache_cluster_properties, 'aws.docdb.cluster': extract_docdb_cluster_properties, + + // ─── AWS — network ───────────────────────────────────────────────── + 'aws.s3.bucket': extract_s3_bucket_properties, + 'aws.apigateway.restApi': extract_api_gateway_rest_api_properties, + 'aws.cloudfront.distribution': extract_cloudfront_distribution_properties, + 'aws.elbv2.loadBalancer': extract_elbv2_load_balancer_properties, }; From 27a55ed89bcb6ceb806713aab403d1c42c265879 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 13:49:38 +0200 Subject: [PATCH 19/52] feat(deploy/aws): extractors for ancillary (sqs, sns, cognito, secrets, cw-logs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 commit 4/5. extractors/aws/ancillary.ts (new): - extract_sqs_queue_properties ← Messaging.Queue - extract_sns_topic_properties ← Messaging.Topic / CloudPubSub - extract_cognito_user_pool_properties ← Security.Identity - extract_secrets_manager_secret_properties ← Security.Secret - extract_cloudwatch_log_group_properties ← Monitoring.Log extractors/dispatch.ts: register all 5 under aws.* resource types. Notable: - SQS content_based_deduplication is only emitted on FIFO queues (AWS rejects the field on standard SQS). - Secrets Manager extractor forwards data.secrets as `bindings` — same shape the schema-declared deploy-expansion pass already uses for GCP Secret Manager. Adding AWS doesn't require translator changes; the same expansion branch fires. - Cognito reads both signInProviders (canvas camelCase) and sign_in_providers (snake) so projects authored with either work. 14 new test assertions. --- package.json | 2 +- .../aws/__tests__/ancillary.test.ts | 120 ++++++++++++++++++ .../src/deploy/extractors/aws/ancillary.ts | 117 +++++++++++++++++ .../core/src/deploy/extractors/dispatch.ts | 14 ++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/extractors/aws/__tests__/ancillary.test.ts create mode 100644 packages/core/src/deploy/extractors/aws/ancillary.ts diff --git a/package.json b/package.json index afcccbdf..596ac921 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.738", + "version": "0.1.739", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/extractors/aws/__tests__/ancillary.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/ancillary.test.ts new file mode 100644 index 00000000..07ae71a0 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/ancillary.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for AWS ancillary extractors. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_sqs_queue_properties, + extract_sns_topic_properties, + extract_cognito_user_pool_properties, + extract_secrets_manager_secret_properties, + extract_cloudwatch_log_group_properties, +} from '../ancillary'; + +describe('extract_sqs_queue_properties', () => { + it('defaults to a standard 4-day-retention queue', () => { + expect(extract_sqs_queue_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + fifo: false, + message_retention_seconds: 345600, + visibility_timeout_seconds: 30, + delay_seconds: 0, + tags: {}, + }); + }); + + it('emits FIFO + content_based_deduplication only on FIFO queues', () => { + const fifo = extract_sqs_queue_properties({ fifo: true, content_based_dedup: true }, 'us-east-1'); + expect(fifo.fifo).toBe(true); + expect(fifo.content_based_deduplication).toBe(true); + + const std = extract_sqs_queue_properties({ content_based_dedup: true }, 'us-east-1'); + expect(std.content_based_deduplication).toBeUndefined(); + }); +}); + +describe('extract_sns_topic_properties', () => { + it('defaults to standard topic with no display name + no KMS key', () => { + expect(extract_sns_topic_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + fifo: false, + display_name: '', + kms_master_key_id: undefined, + tags: {}, + }); + }); + + it('honours FIFO flag', () => { + expect(extract_sns_topic_properties({ fifo: true }, 'us-east-1').fifo).toBe(true); + }); +}); + +describe('extract_cognito_user_pool_properties', () => { + it('defaults to email auto-verification + email/google sign-in + MFA off', () => { + const result = extract_cognito_user_pool_properties({}, 'us-east-1'); + expect(result).toMatchObject({ + region: 'us-east-1', + auto_verified_attributes: ['email'], + sign_in_providers: ['email', 'google'], + mfa_configuration: 'OFF', + }); + expect(result.password_policy).toEqual({ + minimum_length: 8, + require_uppercase: true, + require_lowercase: true, + require_numbers: true, + require_symbols: false, + }); + }); + + it('flips MFA to ON when mfaEnabled=true', () => { + expect(extract_cognito_user_pool_properties({ mfaEnabled: true }, 'us-east-1').mfa_configuration).toBe('ON'); + }); + + it('reads signInProviders (camelCase canvas field) or snake variant', () => { + expect( + extract_cognito_user_pool_properties({ signInProviders: ['phone', 'github'] }, 'us-east-1').sign_in_providers, + ).toEqual(['phone', 'github']); + }); +}); + +describe('extract_secrets_manager_secret_properties', () => { + it('forwards data.secrets as bindings (parallel to GCP secret_manager)', () => { + const result = extract_secrets_manager_secret_properties( + { secrets: [{ key: 'API_KEY', ref: 'prod-api-key' }, { key: 'TOKEN' }] }, + 'us-east-1', + ); + expect(result.bindings).toEqual([{ key: 'API_KEY', ref: 'prod-api-key' }, { key: 'TOKEN' }]); + }); + + it('coerces missing or non-array secrets to []', () => { + expect(extract_secrets_manager_secret_properties({ secrets: 'oops' }, 'us-east-1').bindings).toEqual([]); + expect(extract_secrets_manager_secret_properties({}, 'us-east-1').bindings).toEqual([]); + }); + + it('emits undefined for rotation_lambda_arn + kms_key_id by default', () => { + const result = extract_secrets_manager_secret_properties({}, 'us-east-1'); + expect(result.rotation_lambda_arn).toBeUndefined(); + expect(result.kms_key_id).toBeUndefined(); + expect(result.rotation_days).toBe(0); + }); +}); + +describe('extract_cloudwatch_log_group_properties', () => { + it('defaults to 30-day retention', () => { + expect(extract_cloudwatch_log_group_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + retention_in_days: 30, + kms_key_id: undefined, + tags: {}, + }); + }); + + it('honours retention_in_days override', () => { + expect(extract_cloudwatch_log_group_properties({ retention_in_days: 14 }, 'us-east-1').retention_in_days).toBe(14); + }); + + it('falls through to retention_days (alternate canvas field)', () => { + expect(extract_cloudwatch_log_group_properties({ retention_days: 90 }, 'us-east-1').retention_in_days).toBe(90); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/ancillary.ts b/packages/core/src/deploy/extractors/aws/ancillary.ts new file mode 100644 index 00000000..bd7de7d4 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/ancillary.ts @@ -0,0 +1,117 @@ +/** + * Property extractors for AWS ancillary services. + * + * Resources covered: + * - aws.sqs.queue (Messaging.Queue) + * - aws.sns.topic (Messaging.Topic, Messaging.CloudPubSub) + * - aws.cognito.userPool (Security.Identity) + * - aws.secretsmanager.secret (Security.Secret) + * - aws.cloudwatch.logGroup (Monitoring.Log) + */ + +/** + * SQS queue. FIFO vs Standard is inferred from `data.fifo` (which + * the canvas Messaging.Queue editor sets) — handler appends `.fifo` + * suffix to the queue name when FIFO is on (AWS requirement). + */ +export function extract_sqs_queue_properties(data: Record, region: string): Record { + const fifo = data.fifo === true; + return { + region, + fifo, + // SQS defaults — 4-day retention, 30s visibility timeout, no delay. + message_retention_seconds: (data.message_retention as number) ?? 345600, + visibility_timeout_seconds: (data.visibility_timeout as number) ?? 30, + delay_seconds: (data.delay as number) ?? 0, + // Content-based dedup is only valid on FIFO queues; standard SQS + // ignores the field. The handler enforces the constraint. + ...(fifo && { content_based_deduplication: data.content_based_dedup ?? false }), + tags: {}, + }; +} + +/** + * SNS topic. Standard topics by default; FIFO topics need the `.fifo` + * suffix that the handler adds. + */ +export function extract_sns_topic_properties(data: Record, region: string): Record { + const fifo = data.fifo === true; + return { + region, + fifo, + display_name: (data.display_name as string) || '', + // KMS at-rest encryption — opt-in (operators provide a KMS key ID + // or accept the AWS-managed alias). + kms_master_key_id: (data.kms_master_key_id as string) || undefined, + tags: {}, + }; +} + +/** + * Cognito User Pool. Mirrors the GCP Identity Platform extractor's + * sign-in / MFA shape so cards stay portable. + */ +export function extract_cognito_user_pool_properties( + data: Record, + region: string, +): Record { + return { + region, + // Default to email auto-verification + password sign-in (the + // minimum viable Cognito setup). + auto_verified_attributes: (data.auto_verified_attributes as string[]) || ['email'], + sign_in_providers: (data.signInProviders as string[]) || + (data.sign_in_providers as string[]) || ['email', 'google'], + mfa_configuration: data.mfaEnabled === true ? 'ON' : (data.mfa_configuration as string) || 'OFF', + password_policy: { + minimum_length: (data.password_min_length as number) ?? 8, + require_uppercase: data.password_require_uppercase ?? true, + require_lowercase: data.password_require_lowercase ?? true, + require_numbers: data.password_require_numbers ?? true, + require_symbols: data.password_require_symbols ?? false, + }, + tags: {}, + }; +} + +/** + * Secrets Manager secret. Parallel to the GCP secret_manager extractor: + * the canvas `secrets` array (each row a `{key, ref}` binding) is + * forwarded as `bindings` so the schema-declared deploy-expansion + * pass can emit one cloud resource per unique ref. Adding AWS Secrets + * Manager doesn't require translator changes — the same expansion + * branch fires for any iceType that declares `deployExpansion`. + */ +export function extract_secrets_manager_secret_properties( + data: Record, + region: string, +): Record { + return { + region, + bindings: Array.isArray(data.secrets) ? data.secrets : [], + // Operators wire automatic rotation via a Lambda ARN. Disabled by + // default — the canvas doesn't expose rotation today. + rotation_lambda_arn: (data.rotation_lambda_arn as string) || undefined, + rotation_days: (data.rotation_days as number) ?? 0, + // KMS at-rest encryption (defaults to the AWS-managed alias). + kms_key_id: (data.kms_key_id as string) || undefined, + tags: {}, + }; +} + +/** + * CloudWatch Log Group. Retention is the field most operators care + * about — 30 days strikes the cost vs. visibility balance most + * teams ship with. + */ +export function extract_cloudwatch_log_group_properties( + data: Record, + region: string, +): Record { + return { + region, + retention_in_days: (data.retention_in_days as number) ?? (data.retention_days as number) ?? 30, + kms_key_id: (data.kms_key_id as string) || undefined, + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/dispatch.ts b/packages/core/src/deploy/extractors/dispatch.ts index c9ce5b3e..421c1d4f 100644 --- a/packages/core/src/deploy/extractors/dispatch.ts +++ b/packages/core/src/deploy/extractors/dispatch.ts @@ -31,6 +31,13 @@ import { extract_backend_bucket_properties, extract_firebase_hosting_properties, } from './ancillary'; +import { + extract_sqs_queue_properties, + extract_sns_topic_properties, + extract_cognito_user_pool_properties, + extract_secrets_manager_secret_properties, + extract_cloudwatch_log_group_properties, +} from './aws/ancillary'; import { extract_ecs_service_properties, extract_lambda_function_properties, @@ -113,4 +120,11 @@ export const PROPERTY_EXTRACTORS: Record< 'aws.apigateway.restApi': extract_api_gateway_rest_api_properties, 'aws.cloudfront.distribution': extract_cloudfront_distribution_properties, 'aws.elbv2.loadBalancer': extract_elbv2_load_balancer_properties, + + // ─── AWS — ancillary (messaging, auth, secrets, logging) ─────────── + 'aws.sqs.queue': extract_sqs_queue_properties, + 'aws.sns.topic': extract_sns_topic_properties, + 'aws.cognito.userPool': extract_cognito_user_pool_properties, + 'aws.secretsmanager.secret': extract_secrets_manager_secret_properties, + 'aws.cloudwatch.logGroup': extract_cloudwatch_log_group_properties, }; From 7f5fac96fbe30202945260ee3b55393c054a625c Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 13:51:25 +0200 Subject: [PATCH 20/52] feat(deploy/aws): extractors for AI/analytics (opensearch, bedrock, sagemaker, redshift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 commit 5/5 — last extractor module. Every aws.* resource type in AWS_TYPE_MAP now has a registered property extractor. extractors/aws/ai.ts (new): - extract_opensearch_domain_properties ← AI.VectorDB - extract_bedrock_endpoint_properties ← AI.LLMGateway - extract_sagemaker_endpoint_properties ← AI.ModelServing - extract_redshift_cluster_properties ← Analytics.DataWarehouse extractors/dispatch.ts: register all 4 under aws.* resource types. Notable defaults: - OpenSearch starts cost-conscious: single t3.small.search node with encryption-at-rest + node-to-node encryption on. Production users flip dedicated_master_enabled + bump instance_count ≥ 3. - Bedrock defaults to on-demand Claude 3 Haiku (zero provisioned model units → handler emits no resource). Provisioned throughput fires only when model_units > 0. - SageMaker defaults to a real-time ml.t2.medium endpoint. - Redshift defaults to a single-node dc2.large with the no-default- password invariant the RDS + DocDB extractors share. 12 new test assertions. With this commit Phase 1 is complete: PROPERTY_EXTRACTORS table now has 27 GCP + 20 AWS entries. --- package.json | 2 +- .../extractors/aws/__tests__/ai.test.ts | 114 +++++++++++++++++ packages/core/src/deploy/extractors/aws/ai.ts | 117 ++++++++++++++++++ .../core/src/deploy/extractors/dispatch.ts | 12 ++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/extractors/aws/__tests__/ai.test.ts create mode 100644 packages/core/src/deploy/extractors/aws/ai.ts diff --git a/package.json b/package.json index 596ac921..4f2c7148 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.739", + "version": "0.1.740", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/extractors/aws/__tests__/ai.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/ai.test.ts new file mode 100644 index 00000000..93bd7382 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/ai.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for AWS AI / analytics extractors. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_opensearch_domain_properties, + extract_bedrock_endpoint_properties, + extract_sagemaker_endpoint_properties, + extract_redshift_cluster_properties, +} from '../ai'; + +describe('extract_opensearch_domain_properties', () => { + it('defaults to OpenSearch 2.13 on a single t3.small.search instance', () => { + expect(extract_opensearch_domain_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + engine_version: 'OpenSearch_2.13', + instance_type: 't3.small.search', + instance_count: 1, + dedicated_master_enabled: false, + ebs_enabled: true, + ebs_volume_type: 'gp3', + ebs_volume_size_gb: 10, + encryption_at_rest: true, + node_to_node_encryption: true, + }); + }); + + it('honours production-sized overrides (3 nodes + dedicated master)', () => { + const result = extract_opensearch_domain_properties( + { + instance_count: 3, + instance_type: 'r6g.large.search', + dedicated_master_enabled: true, + dedicated_master_type: 'r6g.large.search', + dedicated_master_count: 3, + }, + 'eu-west-1', + ); + expect(result.instance_count).toBe(3); + expect(result.dedicated_master_enabled).toBe(true); + expect(result.dedicated_master_count).toBe(3); + }); +}); + +describe('extract_bedrock_endpoint_properties', () => { + it('defaults to Claude 3 Haiku, on-demand (zero model units)', () => { + expect(extract_bedrock_endpoint_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + model_id: 'anthropic.claude-3-haiku-20240307-v1:0', + model_units: 0, + commitment_duration: 'OneMonth', + }); + }); + + it('emits provisioned-throughput config when model_units > 0', () => { + const result = extract_bedrock_endpoint_properties( + { model_units: 5, commitment_duration: 'SixMonths' }, + 'us-east-1', + ); + expect(result.model_units).toBe(5); + expect(result.commitment_duration).toBe('SixMonths'); + }); +}); + +describe('extract_sagemaker_endpoint_properties', () => { + it('defaults to a real-time ml.t2.medium endpoint', () => { + expect(extract_sagemaker_endpoint_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + model_name: '', + instance_type: 'ml.t2.medium', + initial_instance_count: 1, + initial_variant_weight: 1.0, + endpoint_mode: 'real-time', + }); + }); + + it('passes endpoint_mode + instance_type overrides through', () => { + const result = extract_sagemaker_endpoint_properties( + { endpoint_mode: 'serverless', instance_type: 'ml.g4dn.xlarge', initial_instance_count: 2 }, + 'us-east-1', + ); + expect(result.endpoint_mode).toBe('serverless'); + expect(result.instance_type).toBe('ml.g4dn.xlarge'); + expect(result.initial_instance_count).toBe(2); + }); +}); + +describe('extract_redshift_cluster_properties', () => { + it('defaults to a single-node dc2.large with no password', () => { + expect(extract_redshift_cluster_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + node_type: 'dc2.large', + cluster_type: 'single-node', + number_of_nodes: 1, + db_name: 'analytics', + master_username: 'admin', + master_user_password: '', + publicly_accessible: false, + encrypted: true, + port: 5439, + }); + }); + + it('honours production-sized overrides (ra3 multi-node)', () => { + const result = extract_redshift_cluster_properties( + { node_type: 'ra3.xlplus', cluster_type: 'multi-node', number_of_nodes: 3 }, + 'us-east-1', + ); + expect(result.node_type).toBe('ra3.xlplus'); + expect(result.cluster_type).toBe('multi-node'); + expect(result.number_of_nodes).toBe(3); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/ai.ts b/packages/core/src/deploy/extractors/aws/ai.ts new file mode 100644 index 00000000..4257fc8d --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/ai.ts @@ -0,0 +1,117 @@ +/** + * Property extractors for AWS AI / analytics services. + * + * Resources covered: + * - aws.opensearch.domain (AI.VectorDB) + * - aws.bedrock.endpoint (AI.LLMGateway) + * - aws.sagemaker.endpoint (AI.ModelServing) + * - aws.redshift.cluster (Analytics.DataWarehouse) + * + * Both Bedrock and SageMaker iceTypes carry the same `llm` role in + * the shared classifier table, but AWS gives them distinct managed + * surfaces so each gets its own extractor. + */ + +/** + * OpenSearch domain — backs AI.VectorDB. Defaults to a single-node + * t3.small.search instance for cost-conscious dev/test; production + * users set `instance_count` ≥ 3 + `dedicated_master_enabled`. + */ +export function extract_opensearch_domain_properties( + data: Record, + region: string, +): Record { + return { + region, + engine_version: (data.engine_version as string) || 'OpenSearch_2.13', + instance_type: (data.instance_type as string) || 't3.small.search', + instance_count: (data.instance_count as number) ?? 1, + dedicated_master_enabled: data.dedicated_master_enabled ?? false, + dedicated_master_type: (data.dedicated_master_type as string) || undefined, + dedicated_master_count: (data.dedicated_master_count as number) ?? 0, + ebs_enabled: data.ebs_enabled ?? true, + ebs_volume_type: (data.ebs_volume_type as string) || 'gp3', + ebs_volume_size_gb: (data.ebs_volume_size_gb as number) ?? 10, + encryption_at_rest: data.encryption_at_rest ?? true, + node_to_node_encryption: data.node_to_node_encryption ?? true, + tags: {}, + }; +} + +/** + * Bedrock — backs AI.LLMGateway. Bedrock is mostly a foundation-model + * surface (on-demand calls don't need provisioning), but provisioned + * throughput + guardrails are the resources operators actually deploy. + * The extractor focuses on the provisioned-throughput shape; if no + * model is set the handler returns a no-op create (Bedrock on-demand + * access is account-level, not resource-level). + */ +export function extract_bedrock_endpoint_properties( + data: Record, + region: string, +): Record { + return { + region, + // Foundation model id — defaults to the most-common Claude model + // available on Bedrock. Operators override to pin a specific model. + model_id: (data.model_id as string) || 'anthropic.claude-3-haiku-20240307-v1:0', + // Provisioned throughput in `model units` (Bedrock's pricing unit). + // 0 = on-demand only (no resource is created at deploy time). + model_units: (data.model_units as number) ?? 0, + commitment_duration: (data.commitment_duration as string) || 'OneMonth', + // Optional guardrail attached to invocations. + guardrail_id: (data.guardrail_id as string) || undefined, + guardrail_version: (data.guardrail_version as string) || undefined, + tags: {}, + }; +} + +/** + * SageMaker endpoint — backs AI.ModelServing. Real-time inference + * endpoint over a previously-registered model. The model itself + * (training, registration) is operator-side; the extractor focuses + * on the endpoint config (instance class + count). + */ +export function extract_sagemaker_endpoint_properties( + data: Record, + region: string, +): Record { + return { + region, + // Model name resolved by the handler from the connected canvas + // node OR set explicitly. Empty = handler fails loudly. + model_name: (data.model_name as string) || '', + instance_type: (data.instance_type as string) || 'ml.t2.medium', + initial_instance_count: (data.initial_instance_count as number) ?? 1, + initial_variant_weight: (data.initial_variant_weight as number) ?? 1.0, + // Async / serverless / real-time endpoint mode. Defaults to + // real-time (the most common). + endpoint_mode: (data.endpoint_mode as string) || 'real-time', + tags: {}, + }; +} + +/** + * Redshift cluster — backs Analytics.DataWarehouse. Like RDS, Redshift + * needs an admin password supplied by the operator. + */ +export function extract_redshift_cluster_properties( + data: Record, + region: string, +): Record { + return { + region, + // Smallest dc2.large default — fits dev/test, cheap. Production + // workloads override to ra3.* node types. + node_type: (data.node_type as string) || 'dc2.large', + cluster_type: (data.cluster_type as string) || 'single-node', + number_of_nodes: (data.number_of_nodes as number) ?? 1, + db_name: (data.db_name as string) || 'analytics', + master_username: (data.master_username as string) || 'admin', + master_user_password: (data.master_user_password as string) || '', + publicly_accessible: data.publicly_accessible ?? false, + encrypted: data.encrypted ?? true, + port: data.port || 5439, + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/dispatch.ts b/packages/core/src/deploy/extractors/dispatch.ts index 421c1d4f..b816044e 100644 --- a/packages/core/src/deploy/extractors/dispatch.ts +++ b/packages/core/src/deploy/extractors/dispatch.ts @@ -31,6 +31,12 @@ import { extract_backend_bucket_properties, extract_firebase_hosting_properties, } from './ancillary'; +import { + extract_opensearch_domain_properties, + extract_bedrock_endpoint_properties, + extract_sagemaker_endpoint_properties, + extract_redshift_cluster_properties, +} from './aws/ai'; import { extract_sqs_queue_properties, extract_sns_topic_properties, @@ -127,4 +133,10 @@ export const PROPERTY_EXTRACTORS: Record< 'aws.cognito.userPool': extract_cognito_user_pool_properties, 'aws.secretsmanager.secret': extract_secrets_manager_secret_properties, 'aws.cloudwatch.logGroup': extract_cloudwatch_log_group_properties, + + // ─── AWS — AI / analytics ────────────────────────────────────────── + 'aws.opensearch.domain': extract_opensearch_domain_properties, + 'aws.bedrock.endpoint': extract_bedrock_endpoint_properties, + 'aws.sagemaker.endpoint': extract_sagemaker_endpoint_properties, + 'aws.redshift.cluster': extract_redshift_cluster_properties, }; From 8fcf181a0040733374d4e71f5260cd9ee93d0a1e Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 13:55:20 +0200 Subject: [PATCH 21/52] =?UTF-8?q?feat(deploy/aws):=20shared=20infra=20?= =?UTF-8?q?=E2=80=94=20STS=20account-id=20resolver=20+=20IAM=20ensure-role?= =?UTF-8?q?=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 commit 1 — the shared helpers later handlers depend on. providers/aws/account.ts (new): - create_account_id_resolver(region): memoised STS GetCallerIdentity caller. First call hits STS, subsequent calls return cached value. Concurrent first-calls coalesce into one STS request. Throws a clear "install @aws-sdk/client-sts" message when SDK is absent. providers/aws/iam-roles.ts (new): - ensureManagedRole(region, roleName, trustPolicyJson, managedPolicyArn): idempotent GetRole → CreateRole-on-NoSuchEntity → AttachRolePolicy pattern. Returns the role ARN. Tolerates already-attached policies (AlreadyExists swallowed; any other error fatal). - ensureEcsTaskExecutionRole(region): convenience wrapper for the standard Fargate execution role (consumed by the ECS handler in commit #23). providers/aws/types.ts: AWSHandlerContext gains `ensure_account_id: AccountIdResolver` — handlers `await ctx.ensure_account_id()` to get the cached id. providers/aws/aws-deployer.ts: initialize() wires the resolver into the context. A pre-init stub throws "called before initialize()" if a handler tries to use it out of band. providers/aws/index.ts: re-export the new helpers. Tests: 9 new — memoisation, concurrent-call coalescing, missing-SDK error path, missing-Account-field error path, ensureManagedRole happy-path + create-on-miss + IAM-SDK-missing path. --- package.json | 2 +- .../providers/__tests__/aws-shared.test.ts | 211 ++++++++++++++++++ .../core/src/deploy/providers/aws/account.ts | 65 ++++++ .../src/deploy/providers/aws/aws-deployer.ts | 9 + .../src/deploy/providers/aws/iam-roles.ts | 99 ++++++++ .../core/src/deploy/providers/aws/index.ts | 2 + .../core/src/deploy/providers/aws/types.ts | 8 + 7 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-shared.test.ts create mode 100644 packages/core/src/deploy/providers/aws/account.ts create mode 100644 packages/core/src/deploy/providers/aws/iam-roles.ts diff --git a/package.json b/package.json index 4f2c7148..1c9f92a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.740", + "version": "0.1.741", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-shared.test.ts b/packages/core/src/deploy/providers/__tests__/aws-shared.test.ts new file mode 100644 index 00000000..5b74d628 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-shared.test.ts @@ -0,0 +1,211 @@ +/** + * Tests for the AWS shared infra helpers — account-id resolver (STS) + * and ensureManagedRole (IAM). + * + * Reuses the same Function-constructor stub the AWS deployer test + * suite uses so the dynamic SDK imports resolve to controllable fakes. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { create_account_id_resolver } from '../aws/account'; +import { ensureEcsTaskExecutionRole, ensureManagedRole } from '../aws/iam-roles'; + +// ─── Function-constructor stub (mirrors aws-deployer.test.ts) ─────── + +interface FakeImportRegistry { + '@aws-sdk/client-sts'?: unknown; + '@aws-sdk/client-iam'?: unknown; +} + +const original_function = globalThis.Function; + +function install_dynamic_import_stub(registry: FakeImportRegistry): void { + const stub = function (...args: unknown[]) { + if (args.length === 2 && args[0] === 'm' && typeof args[1] === 'string' && args[1].includes('return import')) { + return (module_name: string) => { + const mod = (registry as Record)[module_name]; + if (mod === undefined) return Promise.reject(new Error(`Mocked module not registered: ${module_name}`)); + return Promise.resolve(mod); + }; + } + return (original_function as unknown as (...a: unknown[]) => unknown).apply(original_function, args); + }; + (globalThis as { Function: unknown }).Function = stub; +} + +function restore_dynamic_import_stub(): void { + (globalThis as { Function: unknown }).Function = original_function; +} + +// ─── Fake STS / IAM SDK shapes ───────────────────────────────────── + +function makeStsModule(opts: { account?: string | null; throwOn?: 'send' } = {}) { + const sendCalls: any[] = []; + const send = vi.fn(async (cmd: any) => { + sendCalls.push(cmd); + if (opts.throwOn === 'send') throw new Error('STS exploded'); + // `null` opts.account → response with no Account field. + if (opts.account === null) return {}; + return { Account: opts.account ?? '123456789012' }; + }); + const destroy = vi.fn(); + class STSClient { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args.region; + this.send = send; + this.destroy = destroy; + } + } + class GetCallerIdentityCommand { + input: any; + constructor(input: any) { + this.input = input; + } + } + return { STSClient, GetCallerIdentityCommand, send, destroy, sendCalls }; +} + +function makeIamModule(opts: { getRoleArn?: string | null; createRoleArn?: string } = {}) { + const sendCalls: any[] = []; + const send = vi.fn(async (cmd: any) => { + sendCalls.push(cmd); + const name = cmd.__cmd; + if (name === 'GetRole') { + if (opts.getRoleArn === null) { + const err: any = new Error('NoSuchEntity'); + err.name = 'NoSuchEntityException'; + throw err; + } + return { Role: { Arn: opts.getRoleArn ?? 'arn:aws:iam::1:role/existing' } }; + } + if (name === 'CreateRole') return { Role: { Arn: opts.createRoleArn ?? 'arn:aws:iam::1:role/created' } }; + if (name === 'AttachRolePolicy') return {}; + return {}; + }); + const destroy = vi.fn(); + class IAMClient { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args.region; + this.send = send; + this.destroy = destroy; + } + } + class GetRoleCommand { + input: any; + __cmd = 'GetRole'; + constructor(input: any) { + this.input = input; + } + } + class CreateRoleCommand { + input: any; + __cmd = 'CreateRole'; + constructor(input: any) { + this.input = input; + } + } + class AttachRolePolicyCommand { + input: any; + __cmd = 'AttachRolePolicy'; + constructor(input: any) { + this.input = input; + } + } + return { IAMClient, GetRoleCommand, CreateRoleCommand, AttachRolePolicyCommand, send, destroy, sendCalls }; +} + +beforeEach(() => { + install_dynamic_import_stub({}); +}); +afterEach(() => { + restore_dynamic_import_stub(); +}); + +// ─── account-id resolver ──────────────────────────────────────────── + +describe('create_account_id_resolver', () => { + it('returns the STS Account field on first call', async () => { + const sts = makeStsModule({ account: '111122223333' }); + install_dynamic_import_stub({ '@aws-sdk/client-sts': sts }); + const resolve = create_account_id_resolver('us-east-1'); + expect(await resolve()).toBe('111122223333'); + }); + + it('memoises — second call returns the cached value without re-hitting STS', async () => { + const sts = makeStsModule({ account: '999999999999' }); + install_dynamic_import_stub({ '@aws-sdk/client-sts': sts }); + const resolve = create_account_id_resolver('us-east-1'); + expect(await resolve()).toBe('999999999999'); + expect(await resolve()).toBe('999999999999'); + expect(sts.send).toHaveBeenCalledTimes(1); + }); + + it('coalesces concurrent first calls into one STS request', async () => { + const sts = makeStsModule({ account: '555' }); + install_dynamic_import_stub({ '@aws-sdk/client-sts': sts }); + const resolve = create_account_id_resolver('us-east-1'); + const [a, b, c] = await Promise.all([resolve(), resolve(), resolve()]); + expect(a).toBe('555'); + expect(b).toBe('555'); + expect(c).toBe('555'); + expect(sts.send).toHaveBeenCalledTimes(1); + }); + + it('throws a clear "install the SDK" message when STS is missing', async () => { + install_dynamic_import_stub({}); + const resolve = create_account_id_resolver('us-east-1'); + await expect(resolve()).rejects.toThrow(/install @aws-sdk\/client-sts/); + }); + + it('throws when STS GetCallerIdentity returns no Account field', async () => { + const sts = makeStsModule({ account: null }); + install_dynamic_import_stub({ '@aws-sdk/client-sts': sts }); + const resolve = create_account_id_resolver('us-east-1'); + await expect(resolve()).rejects.toThrow(/no Account field/); + }); +}); + +// ─── ensureManagedRole ────────────────────────────────────────────── + +describe('ensureManagedRole', () => { + const TRUST = JSON.stringify({ V: 1 }); + const ARN_POLICY = 'arn:aws:iam::aws:policy/Foo'; + + it('returns the existing role ARN on the happy path (no CreateRole call)', async () => { + const iam = makeIamModule({ getRoleArn: 'arn:aws:iam::1:role/existing' }); + install_dynamic_import_stub({ '@aws-sdk/client-iam': iam }); + const arn = await ensureManagedRole('us-east-1', 'my-role', TRUST, ARN_POLICY); + expect(arn).toBe('arn:aws:iam::1:role/existing'); + // GetRole only, no CreateRole / AttachRolePolicy + expect(iam.sendCalls.map((c: any) => c.__cmd)).toEqual(['GetRole']); + }); + + it('creates the role + attaches the managed policy on NoSuchEntity', async () => { + const iam = makeIamModule({ getRoleArn: null, createRoleArn: 'arn:aws:iam::1:role/created' }); + install_dynamic_import_stub({ '@aws-sdk/client-iam': iam }); + const arn = await ensureManagedRole('us-east-1', 'new-role', TRUST, ARN_POLICY); + expect(arn).toBe('arn:aws:iam::1:role/created'); + expect(iam.sendCalls.map((c: any) => c.__cmd)).toEqual(['GetRole', 'CreateRole', 'AttachRolePolicy']); + }); + + it('throws when IAM SDK is not installed', async () => { + install_dynamic_import_stub({}); + await expect(ensureManagedRole('us-east-1', 'r', TRUST, ARN_POLICY)).rejects.toThrow(/@aws-sdk\/client-iam/); + }); +}); + +describe('ensureEcsTaskExecutionRole', () => { + it('delegates to ensureManagedRole with the standard ECS trust + managed policy', async () => { + const iam = makeIamModule({ getRoleArn: 'arn:aws:iam::1:role/ecsTaskExecutionRole' }); + install_dynamic_import_stub({ '@aws-sdk/client-iam': iam }); + const arn = await ensureEcsTaskExecutionRole('us-east-1'); + expect(arn).toBe('arn:aws:iam::1:role/ecsTaskExecutionRole'); + expect((iam.sendCalls[0] as any).input).toEqual({ RoleName: 'ecsTaskExecutionRole' }); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/account.ts b/packages/core/src/deploy/providers/aws/account.ts new file mode 100644 index 00000000..a48c4ee8 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/account.ts @@ -0,0 +1,65 @@ +/** + * AWS account-id resolution via STS GetCallerIdentity. + * + * Two handlers need the caller's AWS account id: + * - S3 (commit #8) — appends `-{accountId}` to bucket names so + * globally-unique names don't collide across AWS accounts. + * - ECS (commit #23) — references the ecsTaskExecutionRole ARN, + * which embeds the account id. + * + * The deployer never knows the account id at process start (the + * caller authenticates via env vars or `~/.aws/credentials`, both + * of which are read by the SDK at first call). STS GetCallerIdentity + * is the one-call resolution path; result is cached on the context + * for the rest of the deploy. + */ + +import { load_aws_sdk } from './sdk-loader'; + +/** + * Account-id resolver shape attached to AWSHandlerContext when the + * deployer initialises STS. Calling the function the first time + * fetches + caches; subsequent calls return the cached value. + */ +export type AccountIdResolver = () => Promise; + +/** + * Build a memoised resolver. The returned function makes at most one + * STS call per process lifetime. Throws when the STS SDK isn't + * installed OR the call fails (no point falling back to a fake id — + * S3 bucket names would silently collide). + */ +export function create_account_id_resolver(region: string): AccountIdResolver { + let cached: string | undefined; + let in_flight: Promise | undefined; + return async () => { + if (cached) return cached; + if (in_flight) return in_flight; + in_flight = (async () => { + const sts = await load_aws_sdk('@aws-sdk/client-sts'); + if (!sts) { + throw new Error( + 'AWS STS SDK not available — install @aws-sdk/client-sts to enable account-id-suffixed bucket names', + ); + } + const client = new sts.STSClient({ region }); + try { + const result = await client.send(new sts.GetCallerIdentityCommand({})); + if (!result?.Account) { + throw new Error('STS GetCallerIdentity returned no Account field'); + } + cached = String(result.Account); + return cached; + } finally { + if (typeof (client as { destroy?: () => void }).destroy === 'function') { + (client as { destroy: () => void }).destroy(); + } + } + })(); + try { + return await in_flight; + } finally { + in_flight = undefined; + } + }; +} diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 534bd81b..d1e747af 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -13,6 +13,7 @@ * branches. */ +import { create_account_id_resolver } from './account'; import { ec2_handler } from './handlers/ec2'; import { lambda_handler } from './handlers/lambda'; import { s3_handler } from './handlers/s3'; @@ -75,6 +76,13 @@ export class AWSDeployer implements ProviderDeployer { private ctx: AWSHandlerContext = { region: 'us-east-1', clients: new Map(), + // Stub resolver until initialize() replaces it. Throws if a + // handler tries to use account id before the deployer's + // initialise() ran (shouldn't happen in practice but fails + // loudly if it ever does). + ensure_account_id: async () => { + throw new Error('AWSDeployer.ensure_account_id called before initialize()'); + }, }; async initialize(options: DeployOptions): Promise { @@ -84,6 +92,7 @@ export class AWSDeployer implements ProviderDeployer { this.ctx = { region, clients, + ensure_account_id: create_account_id_resolver(region), }; } catch (error) { throw new Error(`Failed to initialize AWS SDK: ${error instanceof Error ? error.message : String(error)}`, { diff --git a/packages/core/src/deploy/providers/aws/iam-roles.ts b/packages/core/src/deploy/providers/aws/iam-roles.ts new file mode 100644 index 00000000..1644cc45 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/iam-roles.ts @@ -0,0 +1,99 @@ +/** + * IAM role bootstrap helper used by the ECS handler (commit #23) to + * ensure the default ecsTaskExecutionRole exists before service + * creation. Idempotent — checks for the role first and only creates + * it on miss. + * + * AWS's recommended default trust + managed policy attachment: + * - Trust: ecs-tasks.amazonaws.com + * - Policy: AmazonECSTaskExecutionRolePolicy (managed) + * + * Any future ICE-managed default role (Lambda execution role, etc.) + * goes through the same ensureManagedRole pattern below. + */ + +import { load_aws_sdk } from './sdk-loader'; + +const DEFAULT_ECS_TASK_ROLE = 'ecsTaskExecutionRole'; +const DEFAULT_ECS_TASK_TRUST_POLICY = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { Service: 'ecs-tasks.amazonaws.com' }, + Action: 'sts:AssumeRole', + }, + ], +}); +const DEFAULT_ECS_TASK_MANAGED_POLICY_ARN = 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy'; + +/** + * Ensure an IAM role exists with the given trust policy + a managed + * policy attached. Returns the role ARN. Idempotent: getRole-first, + * createRole on NoSuchEntity. AttachRolePolicy is best-effort — + * already-attached policies return success. + */ +export async function ensureManagedRole( + region: string, + role_name: string, + trust_policy_json: string, + managed_policy_arn: string, +): Promise { + const iam = await load_aws_sdk('@aws-sdk/client-iam'); + if (!iam) throw new Error('AWS IAM SDK not available — install @aws-sdk/client-iam'); + + const client = new iam.IAMClient({ region }); + try { + // 1. Try fetching the role — happy path returns its ARN. + try { + const got = await client.send(new iam.GetRoleCommand({ RoleName: role_name })); + if (got?.Role?.Arn) return got.Role.Arn; + } catch (error) { + const err = error as { name?: string; Code?: string }; + const code = err.name || err.Code || ''; + if (code !== 'NoSuchEntityException' && code !== 'NoSuchEntity') throw error; + // Falls through to create. + } + + // 2. Create the role. + const created = await client.send( + new iam.CreateRoleCommand({ + RoleName: role_name, + AssumeRolePolicyDocument: trust_policy_json, + Description: 'Auto-created by ICE', + Path: '/', + }), + ); + const arn = created?.Role?.Arn; + if (!arn) throw new Error(`CreateRole returned no ARN for ${role_name}`); + + // 3. Attach the managed policy. AlreadyAttached returns success; + // any other error is fatal (the role exists but isn't usable). + try { + await client.send(new iam.AttachRolePolicyCommand({ RoleName: role_name, PolicyArn: managed_policy_arn })); + } catch (error) { + const err = error as { name?: string; Code?: string }; + const code = err.name || err.Code || ''; + if (code !== 'EntityAlreadyExistsException') throw error; + } + + return arn; + } finally { + if (typeof (client as { destroy?: () => void }).destroy === 'function') { + (client as { destroy: () => void }).destroy(); + } + } +} + +/** + * Convenience for the most common case — the ECS task execution role. + * Returns the ARN every Fargate task definition needs in `executionRoleArn`. + */ +export async function ensureEcsTaskExecutionRole(region: string): Promise { + return ensureManagedRole( + region, + DEFAULT_ECS_TASK_ROLE, + DEFAULT_ECS_TASK_TRUST_POLICY, + DEFAULT_ECS_TASK_MANAGED_POLICY_ARN, + ); +} diff --git a/packages/core/src/deploy/providers/aws/index.ts b/packages/core/src/deploy/providers/aws/index.ts index 9b6b12fd..49d62eac 100644 --- a/packages/core/src/deploy/providers/aws/index.ts +++ b/packages/core/src/deploy/providers/aws/index.ts @@ -7,3 +7,5 @@ export { AWSDeployer, create_aws_deployer } from './aws-deployer'; export type { AWSHandlerContext, AWSResourceHandler } from './types'; export { load_aws_sdk, initialize_aws_clients, destroy_aws_clients } from './sdk-loader'; +export { create_account_id_resolver, type AccountIdResolver } from './account'; +export { ensureManagedRole, ensureEcsTaskExecutionRole } from './iam-roles'; diff --git a/packages/core/src/deploy/providers/aws/types.ts b/packages/core/src/deploy/providers/aws/types.ts index aa1cb35b..eb1b47eb 100644 --- a/packages/core/src/deploy/providers/aws/types.ts +++ b/packages/core/src/deploy/providers/aws/types.ts @@ -11,6 +11,7 @@ * - on_log callback for handlers that stream provider-side output */ +import type { AccountIdResolver } from './account'; import type { ResourceDeployResult } from '../../types'; /** @@ -26,6 +27,13 @@ export interface AWSHandlerContext { * installed — handlers must guard with a friendly error. */ clients: Map; + /** + * Memoised AWS account id (via STS GetCallerIdentity). Fetched on + * first call and cached for the deploy's lifetime. Used by S3 to + * suffix bucket names + by ECS to build ecsTaskExecutionRole ARNs. + * Throws when the STS SDK isn't installed. + */ + ensure_account_id: AccountIdResolver; /** Optional log callback for progress messages. */ on_log?: (message: string) => void; /** From c9757a11bd517ac8e94347bd54a037b29737da7f Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 13:59:25 +0200 Subject: [PATCH 22/52] =?UTF-8?q?feat(deploy/aws):=20s3=20handler=20?= =?UTF-8?q?=E2=80=94=20account-id=20suffix=20+=20publicWebsite=20bucket=20?= =?UTF-8?q?policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler #8 in Phase 2. Two upgrades over the Phase 0 baseline: 1. **Account-id suffix.** S3 bucket names are globally unique across all AWS accounts. The handler awaits ctx.ensure_account_id() and appends `-{accountId}` to the translator's resource name before any SDK call. `ice-myapp-bucket` becomes `ice-myapp-bucket-111122223333`, eliminating the global-collision class. The provider_id ARN carries the post-suffix name so update + delete round-trip cleanly (bucket_name_from_arn parses it back out). 2. **publicWebsite policy.** When the extractor sets `public_access` + `website_hosting` (today only Compute.StaticSite triggers this via the publicWebsiteSource role from the shared classifier table), the handler runs a 4-step create: CreateBucket → PutPublicAccessBlock (loosen account-default block) → PutBucketPolicy (attach the public-read policy) → PutBucketWebsite (set index/404 pages) Plain Storage.Bucket skips all three follow-up commands. Tests: - Existing test harness extended with a makeStsModule mock + a FAKE_ACCOUNT_ID constant; the makeFullRegistry now installs STS alongside the SDK clients. - All existing S3 ARN assertions updated to expect the suffixed form. - 3 new tests: account-id suffix lock-in, public-website 4-step sequence with policy + website config, plain-bucket negative path. 64 → 67 AWS deployer tests passing. --- package.json | 2 +- .../providers/__tests__/aws-deployer.test.ts | 102 +++++++++- .../src/deploy/providers/aws/handlers/s3.ts | 176 +++++++++++++----- 3 files changed, 231 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 1c9f92a3..83a02b40 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.741", + "version": "0.1.742", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts b/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts index b778f182..5564f892 100644 --- a/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts +++ b/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts @@ -35,8 +35,15 @@ interface FakeImportRegistry { '@aws-sdk/client-ec2'?: unknown; '@aws-sdk/client-s3'?: unknown; '@aws-sdk/client-lambda'?: unknown; + '@aws-sdk/client-sts'?: unknown; } +// Fake AWS account id every test uses. The post-#7 S3 handler suffixes +// every bucket with `-{accountId}` via STS GetCallerIdentity, so this +// value shows up in every assertion that checks the resulting ARN. +const FAKE_ACCOUNT_ID = '000000000000'; +const SUFFIX = `-${FAKE_ACCOUNT_ID}`; + const original_function = globalThis.Function; function install_dynamic_import_stub(registry: FakeImportRegistry): void { @@ -172,6 +179,27 @@ function makeS3Module(opts: { sendImpl?: (cmd: any) => any | Promise } = {} this.input = input; } } + class PutPublicAccessBlockCommand { + input: any; + __cmd = 'PutPublicAccessBlock'; + constructor(input: any) { + this.input = input; + } + } + class PutBucketPolicyCommand { + input: any; + __cmd = 'PutBucketPolicy'; + constructor(input: any) { + this.input = input; + } + } + class PutBucketWebsiteCommand { + input: any; + __cmd = 'PutBucketWebsite'; + constructor(input: any) { + this.input = input; + } + } return { S3Client, CreateBucketCommand, @@ -179,12 +207,37 @@ function makeS3Module(opts: { sendImpl?: (cmd: any) => any | Promise } = {} DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand, + PutPublicAccessBlockCommand, + PutBucketPolicyCommand, + PutBucketWebsiteCommand, send, destroy, sendCalls, }; } +function makeStsModule(opts: { account?: string } = {}) { + const send = vi.fn(async () => ({ Account: opts.account ?? FAKE_ACCOUNT_ID })); + const destroy = vi.fn(); + class STSClient { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args.region; + this.send = send; + this.destroy = destroy; + } + } + class GetCallerIdentityCommand { + input: any; + constructor(input: any) { + this.input = input; + } + } + return { STSClient, GetCallerIdentityCommand, send, destroy }; +} + function makeLambdaModule(opts: { sendImpl?: (cmd: any) => any | Promise } = {}) { const sendCalls: any[] = []; const send = vi.fn(async (cmd: any) => { @@ -247,15 +300,18 @@ function makeFullRegistry() { const ec2 = makeEc2Module(); const s3 = makeS3Module(); const lambda = makeLambdaModule(); + const sts = makeStsModule(); return { registry: { '@aws-sdk/client-ec2': ec2, '@aws-sdk/client-s3': s3, '@aws-sdk/client-lambda': lambda, + '@aws-sdk/client-sts': sts, } satisfies FakeImportRegistry, ec2, s3, lambda, + sts, }; } @@ -360,13 +416,15 @@ describe('initialize', () => { it('initializes only the S3 client when EC2 and Lambda are missing', async () => { const s3 = makeS3Module(); - install_dynamic_import_stub({ '@aws-sdk/client-s3': s3 }); + const sts = makeStsModule(); + // STS is needed for the account-id suffix that the S3 handler appends. + install_dynamic_import_stub({ '@aws-sdk/client-s3': s3, '@aws-sdk/client-sts': sts }); const d = new AWSDeployer(); await d.initialize({ provider: 'aws' }); const out = await d.create('aws.s3.bucket', 'b1', {}, {}); expect(out.success).toBe(true); - expect(out.provider_id).toBe('arn:aws:s3:::b1'); + expect(out.provider_id).toBe('arn:aws:s3:::b1' + SUFFIX); }); it('initializes only the Lambda client when EC2 and S3 are missing', async () => { @@ -595,7 +653,7 @@ describe('create', () => { const out = await d.create('aws.s3.bucket', 'my-bucket', {}, {}); expect(out.success).toBe(true); - expect(out.provider_id).toBe('arn:aws:s3:::my-bucket'); + expect(out.provider_id).toBe('arn:aws:s3:::my-bucket' + SUFFIX); }); it('omits CreateBucketConfiguration on S3 create when region is us-east-1', async () => { @@ -649,6 +707,44 @@ describe('create', () => { expect(out.error).toMatch(/S3 SDK not available\. Install @aws-sdk\/client-s3/); }); + it('suffixes the S3 bucket name with the AWS account id', async () => { + const { d, s3 } = await deployerWithFullSdk(); + await d.create('aws.s3.bucket', 'my-bucket', {}, {}); + const createCmd = s3.sendCalls[0]; + expect(createCmd.__cmd).toBe('CreateBucket'); + expect(createCmd.input.Bucket).toBe('my-bucket' + SUFFIX); + }); + + it('attaches public-read bucket policy + website config when public_access + website_hosting', async () => { + const { d, s3 } = await deployerWithFullSdk(); + const out = await d.create( + 'aws.s3.bucket', + 'static-site', + { public_access: true, website_hosting: true, index_page: 'home.html', not_found_page: 'oops.html' }, + {}, + ); + expect(out.success).toBe(true); + // CreateBucket → PutPublicAccessBlock → PutBucketPolicy → PutBucketWebsite + const cmds = s3.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateBucket', 'PutPublicAccessBlock', 'PutBucketPolicy', 'PutBucketWebsite']); + // Public-read policy points at the suffixed bucket name. + const policy = JSON.parse(s3.sendCalls[2].input.Policy); + expect(policy.Statement[0].Resource).toBe(`arn:aws:s3:::static-site${SUFFIX}/*`); + // Website config picks up the index/404 overrides from properties. + expect(s3.sendCalls[3].input.WebsiteConfiguration).toEqual({ + IndexDocument: { Suffix: 'home.html' }, + ErrorDocument: { Key: 'oops.html' }, + }); + }); + + it('does NOT attach public-read policy on a plain (non-website) bucket', async () => { + const { d, s3 } = await deployerWithFullSdk(); + await d.create('aws.s3.bucket', 'private-bucket', { public_access: false, website_hosting: false }, {}); + const cmds = s3.sendCalls.map((c: any) => c.__cmd); + // CreateBucket only — no PublicAccessBlock / Policy / Website calls. + expect(cmds).toEqual(['CreateBucket']); + }); + it('creates a Lambda function and returns the FunctionArn', async () => { const ctx = makeFullRegistry(); install_dynamic_import_stub(ctx.registry); diff --git a/packages/core/src/deploy/providers/aws/handlers/s3.ts b/packages/core/src/deploy/providers/aws/handlers/s3.ts index 55fbeb24..75ba87db 100644 --- a/packages/core/src/deploy/providers/aws/handlers/s3.ts +++ b/packages/core/src/deploy/providers/aws/handlers/s3.ts @@ -3,20 +3,31 @@ * * Handles: aws.s3.bucket * - * Migrated from the monolithic aws-deployer.ts. Behaviour-equivalent - * baseline: - * - CreateBucketCommand on create (with LocationConstraint for - * non-us-east-1 regions) - * - PutBucketTaggingCommand on create (when tags present) + update - * - ListObjectsV2 → DeleteObjects → DeleteBucketCommand on delete + * Two enhancements over the Phase 0 baseline: * - * The account-id suffix for global S3 name uniqueness is added in - * commit #8, after the shared STS infra lands. + * 1. **Account-id suffix.** S3 bucket names are globally unique + * across all AWS accounts. The handler appends `-{accountId}` + * to the translator's resource name before calling the SDK so + * `ice-myapp-bucket` becomes `ice-myapp-bucket-111122223333`, + * eliminating the collision class. The provider_id ARN carries + * the actual S3 bucket name (post-suffix) so update + delete + * round-trip cleanly. + * + * 2. **publicWebsite bucket policy.** When the extractor flags the + * bucket as `public_access` + `website_hosting` (today only + * Compute.StaticSite triggers this via the publicWebsiteSource + * role), the handler attaches a public-read bucket policy AND + * sets the static-website configuration with the index/404 + * pages the extractor supplied. Plain Storage.Bucket stays + * private. + * + * Delete is symmetric — uses the stored provider_id ARN to recover + * the suffixed name. */ import { load_aws_sdk } from '../sdk-loader'; import type { ResourceDeployResult } from '../../../types'; -import type { AWSResourceHandler } from '../types'; +import type { AWSHandlerContext, AWSResourceHandler } from '../types'; const TYPE = 'aws.s3.bucket'; @@ -54,6 +65,38 @@ function fail( }; } +/** + * Build the actual S3 bucket name. Appends `-{accountId}` so deploys + * in different AWS accounts don't fight over a globally-unique name. + * Suffix-already-present is preserved (idempotent). + */ +async function resolve_bucket_name(translator_name: string, ctx: AWSHandlerContext): Promise { + const accountId = await ctx.ensure_account_id(); + if (translator_name.endsWith(`-${accountId}`)) return translator_name; + return `${translator_name}-${accountId}`; +} + +/** Parse the S3 bucket name back out of `arn:aws:s3:::`. */ +function bucket_name_from_arn(arn: string): string { + const idx = arn.lastIndexOf(':'); + return idx === -1 ? arn : arn.slice(idx + 1); +} + +function public_read_policy(bucket_name: string): string { + return JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'PublicReadGetObject', + Effect: 'Allow', + Principal: '*', + Action: 's3:GetObject', + Resource: `arn:aws:s3:::${bucket_name}/*`, + }, + ], + }); +} + export const s3_handler: AWSResourceHandler = { async create(name, properties, ctx) { const start = Date.now(); @@ -64,23 +107,60 @@ export const s3_handler: AWSResourceHandler = { const s3 = await load_aws_sdk('@aws-sdk/client-s3'); if (!s3) return fail(name, 'create', start, 'S3 SDK not available. Install @aws-sdk/client-s3'); - const command = new s3.CreateBucketCommand({ - Bucket: name, - CreateBucketConfiguration: ctx.region !== 'us-east-1' ? { LocationConstraint: ctx.region } : undefined, - }); - await client.send(command); - - if (properties.tags) { - const tag_command = new s3.PutBucketTaggingCommand({ - Bucket: name, - Tagging: { - TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), - }, - }); - await client.send(tag_command); + const bucket = await resolve_bucket_name(name, ctx); + const isPublicWebsite = properties.public_access === true && properties.website_hosting === true; + + // 1. Create the bucket. us-east-1 must NOT pass LocationConstraint + // (AWS treats it as "default" and rejects the explicit value). + await client.send( + new s3.CreateBucketCommand({ + Bucket: bucket, + CreateBucketConfiguration: ctx.region !== 'us-east-1' ? { LocationConstraint: ctx.region } : undefined, + }), + ); + + // 2. Tags pass-through (when the translator/extractor populates them). + if (properties.tags && Object.keys(properties.tags as Record).length > 0) { + await client.send( + new s3.PutBucketTaggingCommand({ + Bucket: bucket, + Tagging: { + TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), + }, + }), + ); } - return result(name, 'create', start, { provider_id: `arn:aws:s3:::${name}` }); + // 3. Public-website branch — must drop the default block-public-acls + // policy before attaching the public-read bucket policy. + if (isPublicWebsite) { + // 3a. Loosen account-default public-access block on the bucket. + await client.send( + new s3.PutPublicAccessBlockCommand({ + Bucket: bucket, + PublicAccessBlockConfiguration: { + BlockPublicAcls: false, + IgnorePublicAcls: false, + BlockPublicPolicy: false, + RestrictPublicBuckets: false, + }, + }), + ); + // 3b. Attach the read-only bucket policy. + await client.send(new s3.PutBucketPolicyCommand({ Bucket: bucket, Policy: public_read_policy(bucket) })); + // 3c. Enable static website hosting with index/404 pages. + await client.send( + new s3.PutBucketWebsiteCommand({ + Bucket: bucket, + WebsiteConfiguration: { + IndexDocument: { Suffix: (properties.index_page as string) || 'index.html' }, + ErrorDocument: { Key: (properties.not_found_page as string) || '404.html' }, + }, + }), + ); + } + + return result(name, 'create', start, { provider_id: `arn:aws:s3:::${bucket}` }); } catch (error) { return fail(name, 'create', start, error instanceof Error ? error.message : String(error)); } @@ -95,14 +175,18 @@ export const s3_handler: AWSResourceHandler = { const s3 = await load_aws_sdk('@aws-sdk/client-s3'); if (!s3) return fail(name, 'update', start, 'S3 SDK not available'); - if (properties.tags) { - const command = new s3.PutBucketTaggingCommand({ - Bucket: name, - Tagging: { - TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), - }, - }); - await client.send(command); + // Recover the actual bucket name from the provider_id ARN. + const bucket = bucket_name_from_arn(provider_id); + + if (properties.tags && Object.keys(properties.tags as Record).length > 0) { + await client.send( + new s3.PutBucketTaggingCommand({ + Bucket: bucket, + Tagging: { + TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), + }, + }), + ); } return result(name, 'update', start, { provider_id }); } catch (error) { @@ -110,7 +194,7 @@ export const s3_handler: AWSResourceHandler = { } }, - async delete(name, _provider_id, ctx) { + async delete(name, provider_id, ctx) { const start = Date.now(); const client = ctx.clients.get('s3') as any; if (!client) return fail(name, 'delete', start, 'S3 SDK not available'); @@ -119,19 +203,21 @@ export const s3_handler: AWSResourceHandler = { const s3 = await load_aws_sdk('@aws-sdk/client-s3'); if (!s3) return fail(name, 'delete', start, 'S3 SDK not available'); - // Empty the bucket first — DeleteBucket fails on non-empty buckets. - const list_command = new s3.ListObjectsV2Command({ Bucket: name }); - const objects = await client.send(list_command); - if (objects.Contents && objects.Contents.length > 0) { - const delete_command = new s3.DeleteObjectsCommand({ - Bucket: name, - Delete: { Objects: objects.Contents.map((obj: any) => ({ Key: obj.Key })) }, - }); - await client.send(delete_command); + // Recover bucket name from the ARN; fall back to resolving the + // suffix again if the caller passed the translator name. + const bucket = provider_id ? bucket_name_from_arn(provider_id) : await resolve_bucket_name(name, ctx); + + // DeleteBucket fails on non-empty buckets — empty first. + const list = await client.send(new s3.ListObjectsV2Command({ Bucket: bucket })); + if (list.Contents && list.Contents.length > 0) { + await client.send( + new s3.DeleteObjectsCommand({ + Bucket: bucket, + Delete: { Objects: list.Contents.map((obj: any) => ({ Key: obj.Key })) }, + }), + ); } - - const command = new s3.DeleteBucketCommand({ Bucket: name }); - await client.send(command); + await client.send(new s3.DeleteBucketCommand({ Bucket: bucket })); return result(name, 'delete', start); } catch (error) { return fail(name, 'delete', start, error instanceof Error ? error.message : String(error)); From 2a77cde7778c01fbd056bbc8e3034e7eb856d3ac Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 14:01:54 +0200 Subject: [PATCH 23/52] =?UTF-8?q?feat(deploy/aws):=20lambda=20handler=20?= =?UTF-8?q?=E2=80=94=20fail-fast=20role=20+=20code-source=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler #9 in Phase 2. Hardens the existing Lambda S3-ref handler with two pre-create validations that turn cryptic AWS API errors into clear messages: 1. **IAM role required.** The AWS SDK returns "Could not find resource ..." when CreateFunction is called with an empty Role ARN. The handler now refuses up front with: "Lambda function requires an IAM execution role ARN (properties.role). Wire one in or use the auto-role helper." 2. **Code source required.** When neither s3_bucket + s3_key NOR a base64 zip_file is supplied, the handler refuses with: "Lambda function code source is missing. Provide properties.code.{s3Bucket,s3Key} or zip_file (auto-build from Source.Repository lands in a later commit)." Both checks fire before any SDK call, so the failure surfaces in the deployer's `error` field with full context instead of as an opaque AWS error. Tests updated so happy-path Lambda create tests now pass both role and code source. 2 new tests pin the fail-fast paths. --- package.json | 2 +- .../providers/__tests__/aws-deployer.test.ts | 37 ++++++++++++++++--- .../deploy/providers/aws/handlers/lambda.ts | 24 +++++++++++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 83a02b40..3fb10736 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.742", + "version": "0.1.743", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts b/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts index 5564f892..6a89a39c 100644 --- a/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts +++ b/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts @@ -435,7 +435,15 @@ describe('initialize', () => { const d = new AWSDeployer(); await d.initialize({ provider: 'aws' }); - const out = await d.create('aws.lambda.function', 'f1', {}, {}); + // The hardened Lambda handler now requires a role + code source up + // front (commit #9). Supply both so the create-time validation + // passes and the SDK fake's FunctionArn response wins. + const out = await d.create( + 'aws.lambda.function', + 'f1', + { role: 'arn:aws:iam::1:role/r', s3_bucket: 'pkg', s3_key: 'app.zip' }, + {}, + ); expect(out.success).toBe(true); expect(out.provider_id).toBe('arn:aws:lambda:us-east-1:1:function:f1'); }); @@ -753,7 +761,12 @@ describe('create', () => { ctx.lambda.send.mockResolvedValueOnce({ FunctionArn: 'arn:aws:lambda:us-east-1:1:function:f1' }); - const out = await d.create('aws.lambda.function', 'f1', { role: 'arn:aws:iam::1:role/r' }, {}); + const out = await d.create( + 'aws.lambda.function', + 'f1', + { role: 'arn:aws:iam::1:role/r', s3_bucket: 'pkg', s3_key: 'app.zip' }, + {}, + ); expect(out.success).toBe(true); expect(out.provider_id).toBe('arn:aws:lambda:us-east-1:1:function:f1'); @@ -763,7 +776,7 @@ describe('create', () => { const { d, lambda } = await deployerWithFullSdk(); lambda.send.mockResolvedValueOnce({ FunctionArn: 'arn' }); - await d.create('aws.lambda.function', 'f1', { role: 'r' }, {}); + await d.create('aws.lambda.function', 'f1', { role: 'r', s3_bucket: 'pkg', s3_key: 'x.zip' }, {}); const cmd = lambda.send.mock.calls[0][0]; expect(cmd.input.Runtime).toBe('nodejs18.x'); @@ -822,7 +835,7 @@ describe('create', () => { const { d, lambda } = await deployerWithFullSdk(); lambda.send.mockResolvedValueOnce({ FunctionArn: 'arn' }); - await d.create('aws.lambda.function', 'f1', { role: 'r' }, {}); + await d.create('aws.lambda.function', 'f1', { role: 'r', s3_bucket: 'pkg', s3_key: 'x.zip' }, {}); const cmd = lambda.send.mock.calls[0][0]; expect(cmd.input.Environment).toBeUndefined(); @@ -832,12 +845,26 @@ describe('create', () => { const { d, lambda } = await deployerWithFullSdk(); lambda.send.mockResolvedValueOnce({ FunctionArn: 'arn' }); - await d.create('aws.lambda.function', 'f1', { role: 'r' }, {}); + await d.create('aws.lambda.function', 'f1', { role: 'r', s3_bucket: 'pkg', s3_key: 'x.zip' }, {}); const cmd = lambda.send.mock.calls[0][0]; expect(cmd.input.Code.ZipFile).toBeUndefined(); }); + it('fails fast with a clear error when properties.role is missing on Lambda create', async () => { + const { d } = await deployerWithFullSdk(); + const out = await d.create('aws.lambda.function', 'f1', { s3_bucket: 'pkg', s3_key: 'x.zip' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/IAM execution role/); + }); + + it('fails fast with a clear error when no code source is supplied on Lambda create', async () => { + const { d } = await deployerWithFullSdk(); + const out = await d.create('aws.lambda.function', 'f1', { role: 'r' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/code source is missing/); + }); + it('returns success:false with "Lambda SDK not available" when Lambda client is missing', async () => { install_dynamic_import_stub({}); const d = new AWSDeployer(); diff --git a/packages/core/src/deploy/providers/aws/handlers/lambda.ts b/packages/core/src/deploy/providers/aws/handlers/lambda.ts index 07dd6922..e1e3089d 100644 --- a/packages/core/src/deploy/providers/aws/handlers/lambda.ts +++ b/packages/core/src/deploy/providers/aws/handlers/lambda.ts @@ -55,6 +55,28 @@ export const lambda_handler: AWSResourceHandler = { const client = ctx.clients.get('lambda') as any; if (!client) return fail(name, 'create', start, 'Lambda SDK not available. Install @aws-sdk/client-lambda'); + // Fail fast on missing required fields — the SDK error for these + // is cryptic ("Could not find resource ...") and burns user time. + const role = (properties.role as string) || ''; + if (!role) { + return fail( + name, + 'create', + start, + 'Lambda function requires an IAM execution role ARN (properties.role). Wire one in or use the auto-role helper.', + ); + } + const hasS3Ref = !!(properties.s3_bucket && properties.s3_key); + const hasZipFile = !!properties.zip_file; + if (!hasS3Ref && !hasZipFile) { + return fail( + name, + 'create', + start, + 'Lambda function code source is missing. Provide properties.code.{s3Bucket,s3Key} or zip_file (auto-build from Source.Repository lands in a later commit).', + ); + } + try { const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); if (!lambda) return fail(name, 'create', start, 'Lambda SDK not available. Install @aws-sdk/client-lambda'); @@ -62,7 +84,7 @@ export const lambda_handler: AWSResourceHandler = { const command = new lambda.CreateFunctionCommand({ FunctionName: name, Runtime: (properties.runtime as string) || 'nodejs18.x', - Role: properties.role as string, + Role: role, Handler: (properties.handler as string) || 'index.handler', Code: { S3Bucket: properties.s3_bucket as string, From 9e87b9e8b95d88195a4d125e9d183179e594376e Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 14:04:42 +0200 Subject: [PATCH 24/52] feat(deploy/aws): cloudwatch-logs handler + shared _result helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler #10 in Phase 2. providers/aws/handlers/cloudwatch-logs.ts (new): - aws.cloudwatch.logGroup handler — CreateLogGroup + PutRetentionPolicy (when retention_in_days set) on create. PutRetentionPolicy on update. DeleteLogGroup on delete. providers/aws/handlers/_result.ts (new): - ok / err / sdkMissing helpers shared across all AWS handlers. Stops the per-handler result/fail boilerplate copy-paste. providers/aws/sdk-loader.ts: load @aws-sdk/client-cloudwatch-logs under the 'cloudwatch-logs' client key. providers/aws/aws-deployer.ts: register cloudwatch_logs_handler in HANDLER_REGISTRY. Tests: 3 new — create-with-retention, create-without-retention skips PutRetentionPolicy, delete sequence. Test harness extended with makeCloudWatchLogsModule + corresponding FakeImportRegistry entry. --- package.json | 2 +- .../providers/__tests__/aws-deployer.test.ts | 88 +++++++++++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../deploy/providers/aws/handlers/_result.ts | 59 +++++++++++++ .../providers/aws/handlers/cloudwatch-logs.ts | 81 +++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 6 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/aws/handlers/_result.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/cloudwatch-logs.ts diff --git a/package.json b/package.json index 3fb10736..bf780de1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.743", + "version": "0.1.744", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts b/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts index 6a89a39c..99977964 100644 --- a/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts +++ b/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts @@ -36,6 +36,7 @@ interface FakeImportRegistry { '@aws-sdk/client-s3'?: unknown; '@aws-sdk/client-lambda'?: unknown; '@aws-sdk/client-sts'?: unknown; + '@aws-sdk/client-cloudwatch-logs'?: unknown; } // Fake AWS account id every test uses. The post-#7 S3 handler suffixes @@ -216,6 +217,55 @@ function makeS3Module(opts: { sendImpl?: (cmd: any) => any | Promise } = {} }; } +function makeCloudWatchLogsModule() { + const sendCalls: any[] = []; + const send = vi.fn(async (cmd: any) => { + sendCalls.push(cmd); + return {}; + }); + const destroy = vi.fn(); + class CloudWatchLogsClient { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args.region; + this.send = send; + this.destroy = destroy; + } + } + class CreateLogGroupCommand { + input: any; + __cmd = 'CreateLogGroup'; + constructor(input: any) { + this.input = input; + } + } + class PutRetentionPolicyCommand { + input: any; + __cmd = 'PutRetentionPolicy'; + constructor(input: any) { + this.input = input; + } + } + class DeleteLogGroupCommand { + input: any; + __cmd = 'DeleteLogGroup'; + constructor(input: any) { + this.input = input; + } + } + return { + CloudWatchLogsClient, + CreateLogGroupCommand, + PutRetentionPolicyCommand, + DeleteLogGroupCommand, + send, + destroy, + sendCalls, + }; +} + function makeStsModule(opts: { account?: string } = {}) { const send = vi.fn(async () => ({ Account: opts.account ?? FAKE_ACCOUNT_ID })); const destroy = vi.fn(); @@ -301,17 +351,20 @@ function makeFullRegistry() { const s3 = makeS3Module(); const lambda = makeLambdaModule(); const sts = makeStsModule(); + const cwl = makeCloudWatchLogsModule(); return { registry: { '@aws-sdk/client-ec2': ec2, '@aws-sdk/client-s3': s3, '@aws-sdk/client-lambda': lambda, '@aws-sdk/client-sts': sts, + '@aws-sdk/client-cloudwatch-logs': cwl, } satisfies FakeImportRegistry, ec2, s3, lambda, sts, + cwl, }; } @@ -1257,3 +1310,38 @@ describe('delete', () => { expect(out.error).toBe('[object Object]'); }); }); + +// ============================================================================= +// CloudWatch Logs handler — commit #10 +// ============================================================================= + +describe('aws.cloudwatch.logGroup handler', () => { + it('creates a log group with retention when retention_in_days is set', async () => { + const { d, cwl } = await deployerWithFullSdk(); + const out = await d.create( + 'aws.cloudwatch.logGroup', + 'my-app-logs', + { retention_in_days: 30, tags: { Env: 'prod' } }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:logs:us-east-1:*:log-group:my-app-logs'); + const cmds = cwl.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateLogGroup', 'PutRetentionPolicy']); + expect(cwl.sendCalls[0].input).toMatchObject({ logGroupName: 'my-app-logs', tags: { Env: 'prod' } }); + expect(cwl.sendCalls[1].input).toEqual({ logGroupName: 'my-app-logs', retentionInDays: 30 }); + }); + + it('skips PutRetentionPolicy when retention is not set', async () => { + const { d, cwl } = await deployerWithFullSdk(); + await d.create('aws.cloudwatch.logGroup', 'lg', {}, {}); + expect(cwl.sendCalls.map((c: any) => c.__cmd)).toEqual(['CreateLogGroup']); + }); + + it('deletes the log group on delete', async () => { + const { d, cwl } = await deployerWithFullSdk(); + const out = await d.delete('aws.cloudwatch.logGroup', 'lg', 'arn:aws:logs:us-east-1:*:log-group:lg', {}); + expect(out.success).toBe(true); + expect(cwl.sendCalls.map((c: any) => c.__cmd)).toEqual(['DeleteLogGroup']); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index d1e747af..f58b64f8 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -14,6 +14,7 @@ */ import { create_account_id_resolver } from './account'; +import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; import { ec2_handler } from './handlers/ec2'; import { lambda_handler } from './handlers/lambda'; import { s3_handler } from './handlers/s3'; @@ -34,6 +35,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.ec2.instance', handler: ec2_handler }, { prefix: 'aws.s3.bucket', handler: s3_handler }, { prefix: 'aws.lambda.function', handler: lambda_handler }, + { prefix: 'aws.cloudwatch.logGroup', handler: cloudwatch_logs_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/_result.ts b/packages/core/src/deploy/providers/aws/handlers/_result.ts new file mode 100644 index 00000000..4dac4b99 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/_result.ts @@ -0,0 +1,59 @@ +/** + * Shared result/fail builders for AWS resource handlers. + * + * Every handler returns the same `ResourceDeployResult` shape; these + * helpers keep the per-handler files focused on the SDK calls instead + * of boilerplate object construction. + */ + +import type { ResourceDeployResult } from '../../../types'; + +export type DeployAction = 'create' | 'update' | 'delete'; + +export function ok( + name: string, + type: string, + action: DeployAction, + start: number, + overrides: Partial = {}, +): ResourceDeployResult { + return { + resource_id: name, + name, + type, + action, + success: true, + duration_ms: Date.now() - start, + ...overrides, + }; +} + +export function err( + name: string, + type: string, + action: DeployAction, + start: number, + message: string, +): ResourceDeployResult { + return { + resource_id: name, + name, + type, + action, + success: false, + error: message, + duration_ms: Date.now() - start, + }; +} + +/** Bundle a SDK-not-installed error with the friendly install hint. */ +export function sdkMissing( + name: string, + type: string, + action: DeployAction, + start: number, + service_display: string, + package_name: string, +): ResourceDeployResult { + return err(name, type, action, start, `${service_display} SDK not available. Install ${package_name}`); +} diff --git a/packages/core/src/deploy/providers/aws/handlers/cloudwatch-logs.ts b/packages/core/src/deploy/providers/aws/handlers/cloudwatch-logs.ts new file mode 100644 index 00000000..f954339d --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/cloudwatch-logs.ts @@ -0,0 +1,81 @@ +/** + * CloudWatch Logs Handler + * + * Handles: aws.cloudwatch.logGroup + * + * CreateLogGroup → PutRetentionPolicy (optional) → AssociateKmsKey + * (optional) on create. Retention update on update. DeleteLogGroup + * on delete. Tags are passed at creation via the `tags` parameter. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.cloudwatch.logGroup'; +const SDK = '@aws-sdk/client-cloudwatch-logs'; + +export const cloudwatch_logs_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudwatch-logs') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'CloudWatch Logs', SDK); + + try { + const cwl = await load_aws_sdk(SDK); + if (!cwl) return sdkMissing(name, TYPE, 'create', start, 'CloudWatch Logs', SDK); + + await client.send( + new cwl.CreateLogGroupCommand({ + logGroupName: name, + tags: properties.tags as Record, + kmsKeyId: (properties.kms_key_id as string) || undefined, + }), + ); + + const retention = properties.retention_in_days as number | undefined; + if (typeof retention === 'number' && retention > 0) { + await client.send(new cwl.PutRetentionPolicyCommand({ logGroupName: name, retentionInDays: retention })); + } + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:logs:${ctx.region}:*:log-group:${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudwatch-logs') as any; + if (!client) return err(name, TYPE, 'update', start, 'CloudWatch Logs SDK not available'); + + try { + const cwl = await load_aws_sdk(SDK); + if (!cwl) return err(name, TYPE, 'update', start, 'CloudWatch Logs SDK not available'); + + const retention = properties.retention_in_days as number | undefined; + if (typeof retention === 'number' && retention > 0) { + await client.send(new cwl.PutRetentionPolicyCommand({ logGroupName: name, retentionInDays: retention })); + } + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudwatch-logs') as any; + if (!client) return err(name, TYPE, 'delete', start, 'CloudWatch Logs SDK not available'); + + try { + const cwl = await load_aws_sdk(SDK); + if (!cwl) return err(name, TYPE, 'delete', start, 'CloudWatch Logs SDK not available'); + + await client.send(new cwl.DeleteLogGroupCommand({ logGroupName: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 85c78e77..6ac24d01 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -46,6 +46,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:07:51 +0200 Subject: [PATCH 25/52] feat(deploy/aws): secrets-manager handler + shared test harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler #11 in Phase 2. providers/aws/handlers/secrets-manager.ts (new): - aws.secretsmanager.secret handler. Mirrors the GCP Secret Manager contract: the schema-declared deploy-expansion pass emits one Secret per binding row; this handler just creates / updates / deletes ONE. Values are NOT written (operators populate via AWS console/CLI — same security tradeoff as GCP). - delete uses ForceDeleteWithoutRecovery=true (skips the 30-day recovery window — appropriate when ICE removes the binding). providers/aws/sdk-loader.ts: load @aws-sdk/client-secrets-manager under the 'secrets-manager' client key. providers/aws/aws-deployer.ts: register secrets_manager_handler. providers/__tests__/_aws-test-harness.ts (new): Extracts the Function-constructor stub + generic SDK-mock factory out of the original aws-deployer.test.ts so per-handler test files stay small. Strips the trailing 'Command' from command class names when building the __cmd label so assertions read the operation name (`CreateSecret`, not `CreateSecretCommand`). providers/__tests__/aws-secrets-manager.test.ts (new): 4 focused tests — create returns the SDK ARN, update + delete sequences, SDK-not-installed path. --- package.json | 2 +- .../providers/__tests__/_aws-test-harness.ts | 131 ++++++++++++++++++ .../__tests__/aws-secrets-manager.test.ts | 78 +++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../providers/aws/handlers/secrets-manager.ts | 92 ++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 6 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/_aws-test-harness.ts create mode 100644 packages/core/src/deploy/providers/__tests__/aws-secrets-manager.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/secrets-manager.ts diff --git a/package.json b/package.json index bf780de1..b4998fad 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.744", + "version": "0.1.745", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/_aws-test-harness.ts b/packages/core/src/deploy/providers/__tests__/_aws-test-harness.ts new file mode 100644 index 00000000..e8881460 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/_aws-test-harness.ts @@ -0,0 +1,131 @@ +/** + * Shared test harness for AWS handler tests. + * + * Every handler in `providers/aws/handlers/` loads its SDK via the + * `Function('m', 'return import(m)')` indirection that Vitest's + * module registry doesn't see. This harness installs a global + * Function stub that routes the indirection through a controllable + * registry of fake SDK modules, plus a generic factory for building + * those fakes with arbitrary command-class shapes. + * + * See `aws-deployer.test.ts` (the original consumer) for the + * inspiration; the harness below is the deduplicated form so each + * per-handler test file stays small. + */ + +import { vi } from 'vitest'; + +// ============================================================================= +// Function-constructor stub +// ============================================================================= + +export interface AwsFakeImportRegistry { + [module_name: string]: unknown; +} + +const original_function = globalThis.Function; + +export function install_dynamic_import_stub(registry: AwsFakeImportRegistry): void { + const stub = function (...args: unknown[]) { + if (args.length === 2 && args[0] === 'm' && typeof args[1] === 'string' && args[1].includes('return import')) { + return (module_name: string) => { + const mod = registry[module_name]; + if (mod === undefined) return Promise.reject(new Error(`Mocked module not registered: ${module_name}`)); + return Promise.resolve(mod); + }; + } + return (original_function as unknown as (...a: unknown[]) => unknown).apply(original_function, args); + }; + (globalThis as { Function: unknown }).Function = stub; +} + +export function restore_dynamic_import_stub(): void { + (globalThis as { Function: unknown }).Function = original_function; +} + +// ============================================================================= +// Generic AWS SDK mock factory +// ============================================================================= + +export interface SdkMockOptions { + /** Name of the SDK's client constructor (`'S3Client'`, `'RDSClient'`, …). */ + client_class_name: string; + /** Command class names — each gets a real class so `new X(input)` works. */ + command_class_names: string[]; + /** + * Default behaviour for `client.send(cmd)`. Defaults to returning + * `{}`. Override per command via `sendImpl`. + */ + sendImpl?: (cmd: { __cmd: string; input: any }) => unknown | Promise; +} + +export interface SdkMock { + send: ReturnType; + destroy: ReturnType; + sendCalls: Array<{ __cmd: string; input: any }>; + /** Indexable bag of constructors so callers can pull commands out by name. */ + module: Record; +} + +/** + * Build a fake AWS SDK module with a constructor-based client + a + * matching set of command classes. Use this for handlers that need + * just a few SDK commands without writing a bespoke factory. + */ +export function makeSdkMock(opts: SdkMockOptions): SdkMock { + const sendCalls: Array<{ __cmd: string; input: any }> = []; + const send = vi.fn(async (cmd: any) => { + sendCalls.push(cmd); + if (opts.sendImpl) return opts.sendImpl(cmd); + return {}; + }); + const destroy = vi.fn(); + + // Build the client constructor (named exactly `opts.client_class_name`). + // The handler indexes the module by this name. + const Client = class { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args?.region; + this.send = send; + this.destroy = destroy; + } + }; + Object.defineProperty(Client, 'name', { value: opts.client_class_name }); + + const module: Record = { [opts.client_class_name]: Client }; + for (const cmdName of opts.command_class_names) { + // Tests assert via `sendCalls[i].__cmd === 'CreateX'` — strip the + // 'Command' suffix so the label matches the operation name AWS + // documents (`CreateSecret`, not `CreateSecretCommand`). + const cmdLabel = cmdName.endsWith('Command') ? cmdName.slice(0, -'Command'.length) : cmdName; + const Cmd = class { + input: any; + __cmd = cmdLabel; + constructor(input: any) { + this.input = input; + } + }; + Object.defineProperty(Cmd, 'name', { value: cmdName }); + module[cmdName] = Cmd; + } + + return { send, destroy, sendCalls, module }; +} + +// ============================================================================= +// STS mock — shared across every handler that touches account-id resolution +// ============================================================================= + +export const FAKE_ACCOUNT_ID = '000000000000'; + +export function makeStsMock(account?: string): SdkMock { + const mock = makeSdkMock({ + client_class_name: 'STSClient', + command_class_names: ['GetCallerIdentityCommand'], + sendImpl: () => ({ Account: account ?? FAKE_ACCOUNT_ID }), + }); + return mock; +} diff --git a/packages/core/src/deploy/providers/__tests__/aws-secrets-manager.test.ts b/packages/core/src/deploy/providers/__tests__/aws-secrets-manager.test.ts new file mode 100644 index 00000000..7656511f --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-secrets-manager.test.ts @@ -0,0 +1,78 @@ +/** + * Tests for the aws.secretsmanager.secret handler. + * + * Uses the shared `_aws-test-harness` so we don't duplicate the + * Function-constructor stub setup per file. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const sm = makeSdkMock({ + client_class_name: 'SecretsManagerClient', + command_class_names: ['CreateSecretCommand', 'UpdateSecretCommand', 'DeleteSecretCommand'], + sendImpl: (cmd) => { + if (cmd.__cmd === 'CreateSecret') { + return { ARN: `arn:aws:secretsmanager:us-east-1:111:secret:${cmd.input.Name}-AbCdEf` }; + } + return {}; + }, + }); + install_dynamic_import_stub({ '@aws-sdk/client-secrets-manager': sm.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, sm }; +} + +describe('aws.secretsmanager.secret handler', () => { + it('creates a secret and returns the ARN from the SDK response', async () => { + const { d, sm } = await setup(); + const out = await d.create('aws.secretsmanager.secret', 'prod-stripe-key', { description: 'stripe' }, {}); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:secretsmanager:us-east-1:111:secret:prod-stripe-key-AbCdEf'); + expect(sm.sendCalls[0].__cmd).toBe('CreateSecret'); + expect(sm.sendCalls[0].input.Name).toBe('prod-stripe-key'); + expect(sm.sendCalls[0].input.Description).toBe('stripe'); + }); + + it('updates description + KmsKeyId via UpdateSecret', async () => { + const { d, sm } = await setup(); + const out = await d.update( + 'aws.secretsmanager.secret', + 'k', + 'arn:aws:secretsmanager:us-east-1:111:secret:k', + { description: 'rotated', kms_key_id: 'alias/aws/secretsmanager' }, + {}, + {}, + ); + expect(out.success).toBe(true); + expect(sm.sendCalls[0].__cmd).toBe('UpdateSecret'); + expect(sm.sendCalls[0].input).toMatchObject({ + SecretId: 'arn:aws:secretsmanager:us-east-1:111:secret:k', + Description: 'rotated', + KmsKeyId: 'alias/aws/secretsmanager', + }); + }); + + it('delete passes ForceDeleteWithoutRecovery=true', async () => { + const { d, sm } = await setup(); + const out = await d.delete('aws.secretsmanager.secret', 'k', 'arn:aws:secretsmanager:us-east-1:111:secret:k', {}); + expect(out.success).toBe(true); + expect(sm.sendCalls[0].__cmd).toBe('DeleteSecret'); + expect(sm.sendCalls[0].input.ForceDeleteWithoutRecovery).toBe(true); + }); + + it('returns SDK-not-installed error when @aws-sdk/client-secrets-manager is absent', async () => { + install_dynamic_import_stub({}); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + const out = await d.create('aws.secretsmanager.secret', 'k', {}, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/Secrets Manager SDK not available/); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index f58b64f8..c11ca279 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -18,6 +18,7 @@ import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; import { ec2_handler } from './handlers/ec2'; import { lambda_handler } from './handlers/lambda'; import { s3_handler } from './handlers/s3'; +import { secrets_manager_handler } from './handlers/secrets-manager'; import { destroy_aws_clients, initialize_aws_clients } from './sdk-loader'; import type { AWSHandlerContext, AWSResourceHandler } from './types'; import type { DeployOptions, ResourceDeployResult, ProviderDeployer } from '../../types'; @@ -36,6 +37,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.s3.bucket', handler: s3_handler }, { prefix: 'aws.lambda.function', handler: lambda_handler }, { prefix: 'aws.cloudwatch.logGroup', handler: cloudwatch_logs_handler }, + { prefix: 'aws.secretsmanager.secret', handler: secrets_manager_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/secrets-manager.ts b/packages/core/src/deploy/providers/aws/handlers/secrets-manager.ts new file mode 100644 index 00000000..31e2c6ce --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/secrets-manager.ts @@ -0,0 +1,92 @@ +/** + * Secrets Manager Handler + * + * Handles: aws.secretsmanager.secret + * + * Mirrors the GCP Secret Manager handler's contract: the schema- + * declared deploy-expansion pass emits one of these per binding row, + * so this handler just creates / updates / deletes ONE Secret. Values + * are NOT written — operators populate `SecretString`/`SecretBinary` + * via the AWS console / CLI, same security tradeoff as GCP. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.secretsmanager.secret'; +const SDK = '@aws-sdk/client-secrets-manager'; + +export const secrets_manager_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('secrets-manager') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'Secrets Manager', SDK); + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return sdkMissing(name, TYPE, 'create', start, 'Secrets Manager', SDK); + + const created = await client.send( + new sm.CreateSecretCommand({ + Name: name, + Description: (properties.description as string) || 'Auto-created by ICE', + KmsKeyId: (properties.kms_key_id as string) || undefined, + Tags: properties.tags + ? Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })) + : undefined, + }), + ); + const arn = created?.ARN || `arn:aws:secretsmanager:${ctx.region}:*:secret:${name}`; + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('secrets-manager') as any; + if (!client) return err(name, TYPE, 'update', start, 'Secrets Manager SDK not available'); + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return err(name, TYPE, 'update', start, 'Secrets Manager SDK not available'); + + // Description + KMS key are the only fields safe to update from + // the canvas — rotation is operator-managed; tags are best-effort. + await client.send( + new sm.UpdateSecretCommand({ + SecretId: provider_id, + Description: (properties.description as string) || undefined, + KmsKeyId: (properties.kms_key_id as string) || undefined, + }), + ); + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('secrets-manager') as any; + if (!client) return err(name, TYPE, 'delete', start, 'Secrets Manager SDK not available'); + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return err(name, TYPE, 'delete', start, 'Secrets Manager SDK not available'); + + // ForceDeleteWithoutRecovery=true skips the default 30-day + // recovery window — appropriate when an ICE deploy is the + // source of truth and the operator explicitly removed the + // binding from the canvas. + await client.send( + new sm.DeleteSecretCommand({ SecretId: provider_id || name, ForceDeleteWithoutRecovery: true }), + ); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 6ac24d01..5beeff25 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -49,6 +49,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:09:40 +0200 Subject: [PATCH 26/52] =?UTF-8?q?feat(deploy/aws):=20sqs=20handler=20?= =?UTF-8?q?=E2=80=94=20CreateQueue/SetQueueAttributes/DeleteQueue,=20FIFO?= =?UTF-8?q?=20.fifo=20suffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler #12 in Phase 2. Standard + FIFO queues. FIFO queues get the .fifo suffix appended to the name automatically (AWS enforces). 3 focused tests. --- package.json | 2 +- .../providers/__tests__/aws-sqs.test.ts | 67 +++++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../src/deploy/providers/aws/handlers/sqs.ts | 97 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-sqs.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/sqs.ts diff --git a/package.json b/package.json index b4998fad..ed0d8a74 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.745", + "version": "0.1.746", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-sqs.test.ts b/packages/core/src/deploy/providers/__tests__/aws-sqs.test.ts new file mode 100644 index 00000000..4aa9863e --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-sqs.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for the aws.sqs.queue handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const sqs = makeSdkMock({ + client_class_name: 'SQSClient', + command_class_names: ['CreateQueueCommand', 'SetQueueAttributesCommand', 'DeleteQueueCommand'], + sendImpl: (cmd) => { + if (cmd.__cmd === 'CreateQueue') { + return { QueueUrl: `https://sqs.us-east-1.amazonaws.com/111/${cmd.input.QueueName}` }; + } + return {}; + }, + }); + install_dynamic_import_stub({ '@aws-sdk/client-sqs': sqs.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, sqs }; +} + +describe('aws.sqs.queue handler', () => { + it('creates a standard queue and returns the QueueUrl as provider_id', async () => { + const { d, sqs } = await setup(); + const out = await d.create( + 'aws.sqs.queue', + 'orders', + { message_retention_seconds: 345600, visibility_timeout_seconds: 30, delay_seconds: 0, fifo: false }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('https://sqs.us-east-1.amazonaws.com/111/orders'); + const cmd = sqs.sendCalls[0]; + expect(cmd.input.QueueName).toBe('orders'); + expect(cmd.input.Attributes).toMatchObject({ + MessageRetentionPeriod: '345600', + VisibilityTimeout: '30', + DelaySeconds: '0', + }); + expect(cmd.input.Attributes.FifoQueue).toBeUndefined(); + }); + + it('appends .fifo suffix + FifoQueue attribute when fifo=true', async () => { + const { d, sqs } = await setup(); + const out = await d.create('aws.sqs.queue', 'jobs', { fifo: true, content_based_deduplication: true }, {}); + expect(out.success).toBe(true); + const cmd = sqs.sendCalls[0]; + expect(cmd.input.QueueName).toBe('jobs.fifo'); + expect(cmd.input.Attributes.FifoQueue).toBe('true'); + expect(cmd.input.Attributes.ContentBasedDeduplication).toBe('true'); + }); + + it('deletes via DeleteQueue using the provider_id URL', async () => { + const { d, sqs } = await setup(); + const out = await d.delete('aws.sqs.queue', 'orders', 'https://sqs.us-east-1.amazonaws.com/111/orders', {}); + expect(out.success).toBe(true); + expect(sqs.sendCalls[0].__cmd).toBe('DeleteQueue'); + expect(sqs.sendCalls[0].input.QueueUrl).toBe('https://sqs.us-east-1.amazonaws.com/111/orders'); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index c11ca279..e9d72109 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -19,6 +19,7 @@ import { ec2_handler } from './handlers/ec2'; import { lambda_handler } from './handlers/lambda'; import { s3_handler } from './handlers/s3'; import { secrets_manager_handler } from './handlers/secrets-manager'; +import { sqs_handler } from './handlers/sqs'; import { destroy_aws_clients, initialize_aws_clients } from './sdk-loader'; import type { AWSHandlerContext, AWSResourceHandler } from './types'; import type { DeployOptions, ResourceDeployResult, ProviderDeployer } from '../../types'; @@ -38,6 +39,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.lambda.function', handler: lambda_handler }, { prefix: 'aws.cloudwatch.logGroup', handler: cloudwatch_logs_handler }, { prefix: 'aws.secretsmanager.secret', handler: secrets_manager_handler }, + { prefix: 'aws.sqs.queue', handler: sqs_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/sqs.ts b/packages/core/src/deploy/providers/aws/handlers/sqs.ts new file mode 100644 index 00000000..dc79cc1a --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/sqs.ts @@ -0,0 +1,97 @@ +/** + * SQS Handler + * + * Handles: aws.sqs.queue + * + * CreateQueue → SetQueueAttributes (if needed) → returns the QueueUrl + * as provider_id. FIFO queues require the `.fifo` suffix in the name + * (AWS enforces this); the handler appends it when the extractor + * marks fifo:true. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.sqs.queue'; +const SDK = '@aws-sdk/client-sqs'; + +function build_queue_attributes(properties: Record): Record { + const attrs: Record = {}; + if (typeof properties.message_retention_seconds === 'number') + attrs.MessageRetentionPeriod = String(properties.message_retention_seconds); + if (typeof properties.visibility_timeout_seconds === 'number') + attrs.VisibilityTimeout = String(properties.visibility_timeout_seconds); + if (typeof properties.delay_seconds === 'number') attrs.DelaySeconds = String(properties.delay_seconds); + if (properties.fifo === true) { + attrs.FifoQueue = 'true'; + if (properties.content_based_deduplication === true) attrs.ContentBasedDeduplication = 'true'; + } + return attrs; +} + +function resolve_name(translator_name: string, properties: Record): string { + if (properties.fifo === true && !translator_name.endsWith('.fifo')) return `${translator_name}.fifo`; + return translator_name; +} + +export const sqs_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sqs') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'SQS', SDK); + + try { + const sqs = await load_aws_sdk(SDK); + if (!sqs) return sdkMissing(name, TYPE, 'create', start, 'SQS', SDK); + + const queueName = resolve_name(name, properties); + const created = await client.send( + new sqs.CreateQueueCommand({ + QueueName: queueName, + Attributes: build_queue_attributes(properties), + tags: properties.tags as Record, + }), + ); + const url = created?.QueueUrl ?? `https://sqs.${ctx.region}.amazonaws.com/*/${queueName}`; + return ok(name, TYPE, 'create', start, { provider_id: url }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sqs') as any; + if (!client) return err(name, TYPE, 'update', start, 'SQS SDK not available'); + + try { + const sqs = await load_aws_sdk(SDK); + if (!sqs) return err(name, TYPE, 'update', start, 'SQS SDK not available'); + + const attrs = build_queue_attributes(properties); + if (Object.keys(attrs).length > 0) { + await client.send(new sqs.SetQueueAttributesCommand({ QueueUrl: provider_id, Attributes: attrs })); + } + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sqs') as any; + if (!client) return err(name, TYPE, 'delete', start, 'SQS SDK not available'); + + try { + const sqs = await load_aws_sdk(SDK); + if (!sqs) return err(name, TYPE, 'delete', start, 'SQS SDK not available'); + + await client.send(new sqs.DeleteQueueCommand({ QueueUrl: provider_id })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 5beeff25..a2e4c205 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -52,6 +52,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:11:19 +0200 Subject: [PATCH 27/52] =?UTF-8?q?feat(deploy/aws):=20sns=20handler=20?= =?UTF-8?q?=E2=80=94=20CreateTopic/SetTopicAttributes/DeleteTopic,=20FIFO?= =?UTF-8?q?=20.fifo=20suffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../providers/__tests__/aws-sns.test.ts | 50 ++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../src/deploy/providers/aws/handlers/sns.ts | 96 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-sns.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/sns.ts diff --git a/package.json b/package.json index ed0d8a74..f315776c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.746", + "version": "0.1.747", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-sns.test.ts b/packages/core/src/deploy/providers/__tests__/aws-sns.test.ts new file mode 100644 index 00000000..270b1bff --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-sns.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for the aws.sns.topic handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const sns = makeSdkMock({ + client_class_name: 'SNSClient', + command_class_names: ['CreateTopicCommand', 'SetTopicAttributesCommand', 'DeleteTopicCommand'], + sendImpl: (cmd) => { + if (cmd.__cmd === 'CreateTopic') return { TopicArn: `arn:aws:sns:us-east-1:111:${cmd.input.Name}` }; + return {}; + }, + }); + install_dynamic_import_stub({ '@aws-sdk/client-sns': sns.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, sns }; +} + +describe('aws.sns.topic handler', () => { + it('creates a standard topic and returns the TopicArn', async () => { + const { d, sns } = await setup(); + const out = await d.create('aws.sns.topic', 'alerts', {}, {}); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:sns:us-east-1:111:alerts'); + expect(sns.sendCalls[0].input.Name).toBe('alerts'); + }); + + it('appends .fifo + sets FifoTopic attribute when fifo=true', async () => { + const { d, sns } = await setup(); + await d.create('aws.sns.topic', 'jobs', { fifo: true }, {}); + const cmd = sns.sendCalls[0]; + expect(cmd.input.Name).toBe('jobs.fifo'); + expect(cmd.input.Attributes.FifoTopic).toBe('true'); + }); + + it('deletes via DeleteTopic with the TopicArn provider_id', async () => { + const { d, sns } = await setup(); + await d.delete('aws.sns.topic', 'alerts', 'arn:aws:sns:us-east-1:111:alerts', {}); + expect(sns.sendCalls[0].__cmd).toBe('DeleteTopic'); + expect(sns.sendCalls[0].input.TopicArn).toBe('arn:aws:sns:us-east-1:111:alerts'); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index e9d72109..f88e89df 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -19,6 +19,7 @@ import { ec2_handler } from './handlers/ec2'; import { lambda_handler } from './handlers/lambda'; import { s3_handler } from './handlers/s3'; import { secrets_manager_handler } from './handlers/secrets-manager'; +import { sns_handler } from './handlers/sns'; import { sqs_handler } from './handlers/sqs'; import { destroy_aws_clients, initialize_aws_clients } from './sdk-loader'; import type { AWSHandlerContext, AWSResourceHandler } from './types'; @@ -40,6 +41,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.cloudwatch.logGroup', handler: cloudwatch_logs_handler }, { prefix: 'aws.secretsmanager.secret', handler: secrets_manager_handler }, { prefix: 'aws.sqs.queue', handler: sqs_handler }, + { prefix: 'aws.sns.topic', handler: sns_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/sns.ts b/packages/core/src/deploy/providers/aws/handlers/sns.ts new file mode 100644 index 00000000..ca4a7f42 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/sns.ts @@ -0,0 +1,96 @@ +/** + * SNS Handler + * + * Handles: aws.sns.topic + * + * CreateTopic returns the topic ARN as provider_id. FIFO topics need + * the .fifo suffix in the name (AWS enforces); the handler appends + * it when the extractor sets fifo:true. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.sns.topic'; +const SDK = '@aws-sdk/client-sns'; + +function resolve_name(translator_name: string, properties: Record): string { + if (properties.fifo === true && !translator_name.endsWith('.fifo')) return `${translator_name}.fifo`; + return translator_name; +} + +function build_topic_attributes(properties: Record): Record { + const attrs: Record = {}; + if (properties.fifo === true) attrs.FifoTopic = 'true'; + if (properties.display_name) attrs.DisplayName = String(properties.display_name); + if (properties.kms_master_key_id) attrs.KmsMasterKeyId = String(properties.kms_master_key_id); + return attrs; +} + +export const sns_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sns') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'SNS', SDK); + + try { + const sns = await load_aws_sdk(SDK); + if (!sns) return sdkMissing(name, TYPE, 'create', start, 'SNS', SDK); + + const topicName = resolve_name(name, properties); + const created = await client.send( + new sns.CreateTopicCommand({ + Name: topicName, + Attributes: build_topic_attributes(properties), + Tags: properties.tags + ? Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })) + : undefined, + }), + ); + const arn = created?.TopicArn ?? `arn:aws:sns:${ctx.region}:*:${topicName}`; + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sns') as any; + if (!client) return err(name, TYPE, 'update', start, 'SNS SDK not available'); + + try { + const sns = await load_aws_sdk(SDK); + if (!sns) return err(name, TYPE, 'update', start, 'SNS SDK not available'); + + // SNS topic-level attributes are set one at a time via + // SetTopicAttributes — issue one call per non-empty attribute. + const attrs = build_topic_attributes(properties); + // FifoTopic can't change after creation; skip it on update. + delete attrs.FifoTopic; + for (const [AttributeName, AttributeValue] of Object.entries(attrs)) { + await client.send(new sns.SetTopicAttributesCommand({ TopicArn: provider_id, AttributeName, AttributeValue })); + } + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sns') as any; + if (!client) return err(name, TYPE, 'delete', start, 'SNS SDK not available'); + + try { + const sns = await load_aws_sdk(SDK); + if (!sns) return err(name, TYPE, 'delete', start, 'SNS SDK not available'); + + await client.send(new sns.DeleteTopicCommand({ TopicArn: provider_id })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index a2e4c205..9b008386 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -55,6 +55,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:13:18 +0200 Subject: [PATCH 28/52] =?UTF-8?q?feat(deploy/aws):=20dynamodb=20handler=20?= =?UTF-8?q?=E2=80=94=20CreateTable=20+=20key=20schema=20+=20PITR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../providers/__tests__/aws-dynamodb.test.ts | 91 ++++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../deploy/providers/aws/handlers/dynamodb.ts | 135 ++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-dynamodb.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/dynamodb.ts diff --git a/package.json b/package.json index f315776c..28533b96 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.747", + "version": "0.1.748", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-dynamodb.test.ts b/packages/core/src/deploy/providers/__tests__/aws-dynamodb.test.ts new file mode 100644 index 00000000..7bb7f16a --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-dynamodb.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for the aws.dynamodb.table handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const dynamo = makeSdkMock({ + client_class_name: 'DynamoDBClient', + command_class_names: [ + 'CreateTableCommand', + 'UpdateTableCommand', + 'DeleteTableCommand', + 'UpdateContinuousBackupsCommand', + ], + }); + install_dynamic_import_stub({ '@aws-sdk/client-dynamodb': dynamo.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, dynamo }; +} + +describe('aws.dynamodb.table handler', () => { + it('creates a PAY_PER_REQUEST table with a single hash key by default', async () => { + const { d, dynamo } = await setup(); + const out = await d.create( + 'aws.dynamodb.table', + 'orders', + { billing_mode: 'PAY_PER_REQUEST', partition_key: 'id', partition_key_type: 'S' }, + {}, + ); + expect(out.success).toBe(true); + const cmd = dynamo.sendCalls[0]; + expect(cmd.__cmd).toBe('CreateTable'); + expect(cmd.input.BillingMode).toBe('PAY_PER_REQUEST'); + expect(cmd.input.KeySchema).toEqual([{ AttributeName: 'id', KeyType: 'HASH' }]); + expect(cmd.input.ProvisionedThroughput).toBeUndefined(); + }); + + it('adds a RANGE entry to KeySchema when sort_key is set', async () => { + const { d, dynamo } = await setup(); + await d.create( + 'aws.dynamodb.table', + 'events', + { partition_key: 'pk', partition_key_type: 'S', sort_key: 'ts', sort_key_type: 'N' }, + {}, + ); + const cmd = dynamo.sendCalls[0]; + expect(cmd.input.KeySchema).toEqual([ + { AttributeName: 'pk', KeyType: 'HASH' }, + { AttributeName: 'ts', KeyType: 'RANGE' }, + ]); + expect(cmd.input.AttributeDefinitions).toEqual([ + { AttributeName: 'pk', AttributeType: 'S' }, + { AttributeName: 'ts', AttributeType: 'N' }, + ]); + }); + + it('emits ProvisionedThroughput when billing_mode=PROVISIONED', async () => { + const { d, dynamo } = await setup(); + await d.create( + 'aws.dynamodb.table', + 't', + { billing_mode: 'PROVISIONED', partition_key: 'id', read_capacity: 25, write_capacity: 50 }, + {}, + ); + expect(dynamo.sendCalls[0].input.ProvisionedThroughput).toEqual({ + ReadCapacityUnits: 25, + WriteCapacityUnits: 50, + }); + }); + + it('issues UpdateContinuousBackups when point_in_time_recovery=true', async () => { + const { d, dynamo } = await setup(); + await d.create('aws.dynamodb.table', 't', { partition_key: 'id', point_in_time_recovery: true }, {}); + const cmds = dynamo.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateTable', 'UpdateContinuousBackups']); + }); + + it('deletes the table on delete', async () => { + const { d, dynamo } = await setup(); + await d.delete('aws.dynamodb.table', 't', 'arn', {}); + expect(dynamo.sendCalls[0].__cmd).toBe('DeleteTable'); + expect(dynamo.sendCalls[0].input.TableName).toBe('t'); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index f88e89df..ad5a57a8 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -15,6 +15,7 @@ import { create_account_id_resolver } from './account'; import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; +import { dynamodb_handler } from './handlers/dynamodb'; import { ec2_handler } from './handlers/ec2'; import { lambda_handler } from './handlers/lambda'; import { s3_handler } from './handlers/s3'; @@ -42,6 +43,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.secretsmanager.secret', handler: secrets_manager_handler }, { prefix: 'aws.sqs.queue', handler: sqs_handler }, { prefix: 'aws.sns.topic', handler: sns_handler }, + { prefix: 'aws.dynamodb.table', handler: dynamodb_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/dynamodb.ts b/packages/core/src/deploy/providers/aws/handlers/dynamodb.ts new file mode 100644 index 00000000..7d6c7d2c --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/dynamodb.ts @@ -0,0 +1,135 @@ +/** + * DynamoDB Handler + * + * Handles: aws.dynamodb.table + * + * CreateTable with the extractor-shaped partition/sort key spec + + * billing mode. PROVISIONED billing emits ProvisionedThroughput; + * PAY_PER_REQUEST omits it. Point-in-time recovery is set via a + * follow-up UpdateContinuousBackups call when enabled. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.dynamodb.table'; +const SDK = '@aws-sdk/client-dynamodb'; + +function build_key_schema(properties: Record): { + KeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }>; + AttributeDefinitions: Array<{ AttributeName: string; AttributeType: string }>; +} { + const pk = String(properties.partition_key); + const pkType = String(properties.partition_key_type || 'S'); + const sk = properties.sort_key ? String(properties.sort_key) : undefined; + const skType = String(properties.sort_key_type || 'S'); + + const KeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [ + { AttributeName: pk, KeyType: 'HASH' }, + ]; + const AttributeDefinitions: Array<{ AttributeName: string; AttributeType: string }> = [ + { AttributeName: pk, AttributeType: pkType }, + ]; + if (sk) { + KeySchema.push({ AttributeName: sk, KeyType: 'RANGE' }); + AttributeDefinitions.push({ AttributeName: sk, AttributeType: skType }); + } + return { KeySchema, AttributeDefinitions }; +} + +export const dynamodb_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('dynamodb') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'DynamoDB', SDK); + + try { + const dynamo = await load_aws_sdk(SDK); + if (!dynamo) return sdkMissing(name, TYPE, 'create', start, 'DynamoDB', SDK); + + const { KeySchema, AttributeDefinitions } = build_key_schema(properties); + const billing = (properties.billing_mode as string) || 'PAY_PER_REQUEST'; + + await client.send( + new dynamo.CreateTableCommand({ + TableName: name, + KeySchema, + AttributeDefinitions, + BillingMode: billing, + ...(billing === 'PROVISIONED' && { + ProvisionedThroughput: { + ReadCapacityUnits: (properties.read_capacity as number) || 5, + WriteCapacityUnits: (properties.write_capacity as number) || 5, + }, + }), + Tags: properties.tags + ? Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })) + : undefined, + }), + ); + + if (properties.point_in_time_recovery === true) { + await client.send( + new dynamo.UpdateContinuousBackupsCommand({ + TableName: name, + PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true }, + }), + ); + } + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:dynamodb:${ctx.region}:*:table/${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('dynamodb') as any; + if (!client) return err(name, TYPE, 'update', start, 'DynamoDB SDK not available'); + + try { + const dynamo = await load_aws_sdk(SDK); + if (!dynamo) return err(name, TYPE, 'update', start, 'DynamoDB SDK not available'); + + // Only billing mode + provisioned capacity are safely updatable + // mid-flight (key schema is locked at create). PITR can be + // toggled via UpdateContinuousBackups. + const billing = properties.billing_mode as string | undefined; + if (billing) { + await client.send( + new dynamo.UpdateTableCommand({ + TableName: name, + BillingMode: billing, + ...(billing === 'PROVISIONED' && { + ProvisionedThroughput: { + ReadCapacityUnits: (properties.read_capacity as number) || 5, + WriteCapacityUnits: (properties.write_capacity as number) || 5, + }, + }), + }), + ); + } + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('dynamodb') as any; + if (!client) return err(name, TYPE, 'delete', start, 'DynamoDB SDK not available'); + + try { + const dynamo = await load_aws_sdk(SDK); + if (!dynamo) return err(name, TYPE, 'delete', start, 'DynamoDB SDK not available'); + + await client.send(new dynamo.DeleteTableCommand({ TableName: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 9b008386..87dcfd2e 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -58,6 +58,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:15:08 +0200 Subject: [PATCH 29/52] =?UTF-8?q?feat(deploy/aws):=20elasticache=20handler?= =?UTF-8?q?=20=E2=80=94=20single-node=20+=20replication-group=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../__tests__/aws-elasticache.test.ts | 55 +++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../providers/aws/handlers/elasticache.ts | 91 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-elasticache.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/elasticache.ts diff --git a/package.json b/package.json index 28533b96..bc4367f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.748", + "version": "0.1.749", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-elasticache.test.ts b/packages/core/src/deploy/providers/__tests__/aws-elasticache.test.ts new file mode 100644 index 00000000..c72c2750 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-elasticache.test.ts @@ -0,0 +1,55 @@ +/** + * Tests for the aws.elasticache.cluster handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const ec = makeSdkMock({ + client_class_name: 'ElastiCacheClient', + command_class_names: [ + 'CreateCacheClusterCommand', + 'CreateReplicationGroupCommand', + 'DeleteCacheClusterCommand', + 'DeleteReplicationGroupCommand', + ], + }); + install_dynamic_import_stub({ '@aws-sdk/client-elasticache': ec.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, ec }; +} + +describe('aws.elasticache.cluster handler', () => { + it('creates a single-node CacheCluster when num_cache_nodes=1', async () => { + const { d, ec } = await setup(); + const out = await d.create( + 'aws.elasticache.cluster', + 'cache', + { cache_node_type: 'cache.t3.micro', num_cache_nodes: 1 }, + {}, + ); + expect(out.success).toBe(true); + expect(ec.sendCalls[0].__cmd).toBe('CreateCacheCluster'); + expect(ec.sendCalls[0].input.NumCacheNodes).toBe(1); + }); + + it('creates a ReplicationGroup when num_cache_nodes>1', async () => { + const { d, ec } = await setup(); + const out = await d.create( + 'aws.elasticache.cluster', + 'cache', + { cache_node_type: 'cache.m5.xlarge', num_cache_nodes: 2 }, + {}, + ); + expect(out.success).toBe(true); + expect(ec.sendCalls[0].__cmd).toBe('CreateReplicationGroup'); + expect(ec.sendCalls[0].input.NumCacheClusters).toBe(2); + expect(ec.sendCalls[0].input.AutomaticFailoverEnabled).toBe(true); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index ad5a57a8..6b3bf3aa 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -17,6 +17,7 @@ import { create_account_id_resolver } from './account'; import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; import { dynamodb_handler } from './handlers/dynamodb'; import { ec2_handler } from './handlers/ec2'; +import { elasticache_handler } from './handlers/elasticache'; import { lambda_handler } from './handlers/lambda'; import { s3_handler } from './handlers/s3'; import { secrets_manager_handler } from './handlers/secrets-manager'; @@ -44,6 +45,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.sqs.queue', handler: sqs_handler }, { prefix: 'aws.sns.topic', handler: sns_handler }, { prefix: 'aws.dynamodb.table', handler: dynamodb_handler }, + { prefix: 'aws.elasticache.cluster', handler: elasticache_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/elasticache.ts b/packages/core/src/deploy/providers/aws/handlers/elasticache.ts new file mode 100644 index 00000000..81965966 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/elasticache.ts @@ -0,0 +1,91 @@ +/** + * ElastiCache Handler + * + * Handles: aws.elasticache.cluster + * + * Create the cache cluster — Redis only today (Memcached is a stale + * engine; ICE doesn't expose it on the canvas). For multi-node setups + * the handler creates a replication group instead so HA mode actually + * has standby nodes. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.elasticache.cluster'; +const SDK = '@aws-sdk/client-elasticache'; + +export const elasticache_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('elasticache') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'ElastiCache', SDK); + + try { + const ec = await load_aws_sdk(SDK); + if (!ec) return sdkMissing(name, TYPE, 'create', start, 'ElastiCache', SDK); + + const isReplicated = (properties.num_cache_nodes as number) > 1; + if (isReplicated) { + await client.send( + new ec.CreateReplicationGroupCommand({ + ReplicationGroupId: name, + ReplicationGroupDescription: `ICE-managed ${name}`, + Engine: 'redis', + EngineVersion: (properties.engine_version as string) || '7.0', + CacheNodeType: (properties.cache_node_type as string) || 'cache.t3.micro', + NumCacheClusters: properties.num_cache_nodes as number, + AutomaticFailoverEnabled: true, + Port: (properties.port as number) || 6379, + CacheParameterGroupName: properties.parameter_group_name as string, + }), + ); + } else { + await client.send( + new ec.CreateCacheClusterCommand({ + CacheClusterId: name, + Engine: 'redis', + EngineVersion: (properties.engine_version as string) || '7.0', + CacheNodeType: (properties.cache_node_type as string) || 'cache.t3.micro', + NumCacheNodes: 1, + Port: (properties.port as number) || 6379, + CacheParameterGroupName: properties.parameter_group_name as string, + }), + ); + } + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:elasticache:${ctx.region}:*:cluster:${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, ctx) { + // ElastiCache supports limited live updates (engine_version only, + // and only forward). Skip the no-op path entirely until the + // canvas exposes the relevant fields. + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('elasticache') as any; + if (!client) return err(name, TYPE, 'delete', start, 'ElastiCache SDK not available'); + + try { + const ec = await load_aws_sdk(SDK); + if (!ec) return err(name, TYPE, 'delete', start, 'ElastiCache SDK not available'); + + // Best-effort: try cluster first, fall back to replication group. + try { + await client.send(new ec.DeleteCacheClusterCommand({ CacheClusterId: name })); + } catch { + await client.send(new ec.DeleteReplicationGroupCommand({ ReplicationGroupId: name })); + } + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 87dcfd2e..74aa40ec 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -61,6 +61,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:17:08 +0200 Subject: [PATCH 30/52] =?UTF-8?q?feat(deploy/aws):=20rds=20handler=20?= =?UTF-8?q?=E2=80=94=20no-default-password=20gate=20+=20provisioning=20pol?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler #16 in Phase 2. CreateDBInstance + 20-min status-poll loop that respects ctx.abort_signal and reports progress via on_step. Refuses to create when master_user_password is empty (parity with the extractor's no-default-password invariant). --- package.json | 2 +- .../providers/__tests__/aws-rds.test.ts | 79 +++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../src/deploy/providers/aws/handlers/rds.ts | 132 ++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-rds.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/rds.ts diff --git a/package.json b/package.json index bc4367f2..91f04232 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.749", + "version": "0.1.750", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-rds.test.ts b/packages/core/src/deploy/providers/__tests__/aws-rds.test.ts new file mode 100644 index 00000000..d3e05985 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-rds.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for the aws.rds.dbInstance handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup(opts: { available?: boolean; failed?: boolean } = { available: true }) { + const rds = makeSdkMock({ + client_class_name: 'RDSClient', + command_class_names: [ + 'CreateDBInstanceCommand', + 'DescribeDBInstancesCommand', + 'ModifyDBInstanceCommand', + 'DeleteDBInstanceCommand', + ], + sendImpl: (cmd) => { + if (cmd.__cmd === 'DescribeDBInstances') { + const status = opts.failed ? 'failed' : opts.available ? 'available' : 'creating'; + return { DBInstances: [{ DBInstanceStatus: status }] }; + } + return {}; + }, + }); + install_dynamic_import_stub({ '@aws-sdk/client-rds': rds.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, rds }; +} + +describe('aws.rds.dbInstance handler', () => { + it('refuses to create when master_user_password is empty', async () => { + const { d } = await setup(); + const out = await d.create( + 'aws.rds.dbInstance', + 'db', + { engine: 'postgres', engine_version: '16', master_username: 'postgres', master_user_password: '' }, + {}, + ); + expect(out.success).toBe(false); + expect(out.error).toMatch(/master_user_password is empty/); + }); + + it('creates the instance + polls until status=available', async () => { + const { d, rds } = await setup({ available: true }); + const out = await d.create( + 'aws.rds.dbInstance', + 'db', + { + engine: 'postgres', + engine_version: '16', + master_username: 'postgres', + master_user_password: 'secret', + db_instance_class: 'db.t3.micro', + allocated_storage: 20, + }, + {}, + ); + expect(out.success).toBe(true); + const cmds = rds.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateDBInstance', 'DescribeDBInstances']); + }); + + it('errors out when DescribeDBInstances returns status=failed', async () => { + const { d } = await setup({ failed: true }); + const out = await d.create( + 'aws.rds.dbInstance', + 'db', + { engine: 'postgres', master_username: 'a', master_user_password: 'p' }, + {}, + ); + expect(out.success).toBe(false); + expect(out.error).toMatch(/failed state/); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 6b3bf3aa..54d907f6 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -19,6 +19,7 @@ import { dynamodb_handler } from './handlers/dynamodb'; import { ec2_handler } from './handlers/ec2'; import { elasticache_handler } from './handlers/elasticache'; import { lambda_handler } from './handlers/lambda'; +import { rds_handler } from './handlers/rds'; import { s3_handler } from './handlers/s3'; import { secrets_manager_handler } from './handlers/secrets-manager'; import { sns_handler } from './handlers/sns'; @@ -46,6 +47,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.sns.topic', handler: sns_handler }, { prefix: 'aws.dynamodb.table', handler: dynamodb_handler }, { prefix: 'aws.elasticache.cluster', handler: elasticache_handler }, + { prefix: 'aws.rds.dbInstance', handler: rds_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/rds.ts b/packages/core/src/deploy/providers/aws/handlers/rds.ts new file mode 100644 index 00000000..edd0c81d --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/rds.ts @@ -0,0 +1,132 @@ +/** + * RDS Handler + * + * Handles: aws.rds.dbInstance + * + * CreateDBInstance + optional status polling. RDS provisioning takes + * 5–10 minutes; the handler optionally polls DescribeDBInstances + * until the instance status reads "available". Polling is bounded by + * a 20-minute cap and respects ctx.abort_signal (cancel-safe). + * + * Honours the extractor's no-default-password invariant — refuses to + * call CreateDBInstance when master_user_password is empty. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSHandlerContext, AWSResourceHandler } from '../types'; + +const TYPE = 'aws.rds.dbInstance'; +const SDK = '@aws-sdk/client-rds'; +const POLL_INTERVAL_MS = 30_000; +const POLL_TIMEOUT_MS = 20 * 60 * 1000; + +async function wait_until_available(client: any, rds: any, identifier: string, ctx: AWSHandlerContext): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS; + let step = 0; + while (Date.now() < deadline) { + if (ctx.abort_signal?.aborted) throw new Error('RDS provisioning cancelled'); + const describe = await client.send(new rds.DescribeDBInstancesCommand({ DBInstanceIdentifier: identifier })); + const status = describe?.DBInstances?.[0]?.DBInstanceStatus; + ctx.on_step?.(identifier, { label: `RDS status: ${status}`, index: step++, total: 0 }); + if (status === 'available') return; + if (status === 'failed') throw new Error(`RDS instance ${identifier} entered failed state`); + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + throw new Error(`Timed out waiting for RDS instance ${identifier} to become available`); +} + +export const rds_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('rds') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'RDS', SDK); + + if (!properties.master_user_password) { + return err( + name, + TYPE, + 'create', + start, + 'RDS create refused: master_user_password is empty. Wire a Security.Secret block or set the field explicitly.', + ); + } + + try { + const rds = await load_aws_sdk(SDK); + if (!rds) return sdkMissing(name, TYPE, 'create', start, 'RDS', SDK); + + await client.send( + new rds.CreateDBInstanceCommand({ + DBInstanceIdentifier: name, + DBInstanceClass: (properties.db_instance_class as string) || 'db.t3.micro', + Engine: properties.engine as string, + EngineVersion: properties.engine_version as string, + AllocatedStorage: (properties.allocated_storage as number) || 20, + StorageType: (properties.storage_type as string) || 'gp3', + MasterUsername: properties.master_username as string, + MasterUserPassword: properties.master_user_password as string, + Port: properties.port as number, + PubliclyAccessible: !!properties.publicly_accessible, + MultiAZ: !!properties.multi_az, + BackupRetentionPeriod: (properties.backup_retention_period as number) ?? 7, + }), + ); + + // Poll until the instance is available. Set ctx.abort_signal to + // cancel mid-flight; the loop also logs status via on_step so + // the UI can show progress. + await wait_until_available(client, rds, name, ctx); + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:rds:${ctx.region}:*:db:${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('rds') as any; + if (!client) return err(name, TYPE, 'update', start, 'RDS SDK not available'); + + try { + const rds = await load_aws_sdk(SDK); + if (!rds) return err(name, TYPE, 'update', start, 'RDS SDK not available'); + + await client.send( + new rds.ModifyDBInstanceCommand({ + DBInstanceIdentifier: name, + DBInstanceClass: properties.db_instance_class as string, + AllocatedStorage: properties.allocated_storage as number, + BackupRetentionPeriod: properties.backup_retention_period as number, + ApplyImmediately: true, + }), + ); + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('rds') as any; + if (!client) return err(name, TYPE, 'delete', start, 'RDS SDK not available'); + + try { + const rds = await load_aws_sdk(SDK); + if (!rds) return err(name, TYPE, 'delete', start, 'RDS SDK not available'); + + await client.send( + new rds.DeleteDBInstanceCommand({ + DBInstanceIdentifier: name, + SkipFinalSnapshot: true, + DeleteAutomatedBackups: true, + }), + ); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 74aa40ec..492b218e 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -64,6 +64,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:18:46 +0200 Subject: [PATCH 31/52] =?UTF-8?q?feat(deploy/aws):=20docdb=20handler=20?= =?UTF-8?q?=E2=80=94=20cluster=20+=20per-instance=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../providers/__tests__/aws-docdb.test.ts | 43 +++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../deploy/providers/aws/handlers/docdb.ts | 88 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-docdb.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/docdb.ts diff --git a/package.json b/package.json index 91f04232..79e50340 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.750", + "version": "0.1.751", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-docdb.test.ts b/packages/core/src/deploy/providers/__tests__/aws-docdb.test.ts new file mode 100644 index 00000000..88bc1588 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-docdb.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for the aws.docdb.cluster handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const docdb = makeSdkMock({ + client_class_name: 'DocDBClient', + command_class_names: ['CreateDBClusterCommand', 'CreateDBInstanceCommand', 'DeleteDBClusterCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-docdb': docdb.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, docdb }; +} + +describe('aws.docdb.cluster handler', () => { + it('refuses to create without master_user_password', async () => { + const { d } = await setup(); + const out = await d.create('aws.docdb.cluster', 'db', { master_username: 'admin', master_user_password: '' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/master_user_password is empty/); + }); + + it('creates cluster + N instances per instance_count', async () => { + const { d, docdb } = await setup(); + const out = await d.create( + 'aws.docdb.cluster', + 'db', + { master_username: 'admin', master_user_password: 'p', instance_count: 3 }, + {}, + ); + expect(out.success).toBe(true); + const cmds = docdb.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateDBCluster', 'CreateDBInstance', 'CreateDBInstance', 'CreateDBInstance']); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 54d907f6..0458591a 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -15,6 +15,7 @@ import { create_account_id_resolver } from './account'; import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; +import { docdb_handler } from './handlers/docdb'; import { dynamodb_handler } from './handlers/dynamodb'; import { ec2_handler } from './handlers/ec2'; import { elasticache_handler } from './handlers/elasticache'; @@ -48,6 +49,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.dynamodb.table', handler: dynamodb_handler }, { prefix: 'aws.elasticache.cluster', handler: elasticache_handler }, { prefix: 'aws.rds.dbInstance', handler: rds_handler }, + { prefix: 'aws.docdb.cluster', handler: docdb_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/docdb.ts b/packages/core/src/deploy/providers/aws/handlers/docdb.ts new file mode 100644 index 00000000..9bdb05fb --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/docdb.ts @@ -0,0 +1,88 @@ +/** + * DocumentDB Handler + * + * Handles: aws.docdb.cluster + * + * CreateDBCluster + N × CreateDBInstance (one per instance_count). + * Like RDS, refuses to ship with an empty master password. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.docdb.cluster'; +const SDK = '@aws-sdk/client-docdb'; + +export const docdb_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('docdb') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'DocumentDB', SDK); + + if (!properties.master_user_password) { + return err(name, TYPE, 'create', start, 'DocumentDB create refused: master_user_password is empty.'); + } + + try { + const docdb = await load_aws_sdk(SDK); + if (!docdb) return sdkMissing(name, TYPE, 'create', start, 'DocumentDB', SDK); + + const clusterId = (properties.db_cluster_identifier as string) || name; + const instanceCount = (properties.instance_count as number) ?? 1; + + await client.send( + new docdb.CreateDBClusterCommand({ + DBClusterIdentifier: clusterId, + Engine: 'docdb', + EngineVersion: properties.engine_version as string, + MasterUsername: properties.master_username as string, + MasterUserPassword: properties.master_user_password as string, + BackupRetentionPeriod: (properties.backup_retention_period as number) ?? 7, + StorageEncrypted: properties.storage_encrypted !== false, + Port: (properties.port as number) || 27017, + }), + ); + + for (let i = 0; i < instanceCount; i++) { + await client.send( + new docdb.CreateDBInstanceCommand({ + DBInstanceIdentifier: `${clusterId}-${i + 1}`, + DBClusterIdentifier: clusterId, + DBInstanceClass: (properties.db_instance_class as string) || 'db.t3.medium', + Engine: 'docdb', + }), + ); + } + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:docdb:${ctx.region}:*:cluster:${clusterId}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('docdb') as any; + if (!client) return err(name, TYPE, 'delete', start, 'DocumentDB SDK not available'); + + try { + const docdb = await load_aws_sdk(SDK); + if (!docdb) return err(name, TYPE, 'delete', start, 'DocumentDB SDK not available'); + + await client.send( + new docdb.DeleteDBClusterCommand({ + DBClusterIdentifier: name, + SkipFinalSnapshot: true, + }), + ); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 492b218e..689cf018 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -67,6 +67,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:20:45 +0200 Subject: [PATCH 32/52] =?UTF-8?q?feat(deploy/aws):=20cognito=20handler=20?= =?UTF-8?q?=E2=80=94=20user=20pool=20with=20password=20policy=20+=20MFA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../providers/__tests__/aws-cognito.test.ts | 42 +++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../deploy/providers/aws/handlers/cognito.ts | 75 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-cognito.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/cognito.ts diff --git a/package.json b/package.json index 79e50340..a046ac86 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.751", + "version": "0.1.752", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-cognito.test.ts b/packages/core/src/deploy/providers/__tests__/aws-cognito.test.ts new file mode 100644 index 00000000..de2f8968 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-cognito.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const cog = makeSdkMock({ + client_class_name: 'CognitoIdentityProviderClient', + command_class_names: ['CreateUserPoolCommand', 'DeleteUserPoolCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateUserPool' + ? { UserPool: { Arn: `arn:aws:cognito-idp:us-east-1:111:userpool/us-east-1_${cmd.input.PoolName}` } } + : {}, + }); + install_dynamic_import_stub({ '@aws-sdk/client-cognito-identity-provider': cog.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, cog }; +} + +describe('aws.cognito.userPool handler', () => { + it('creates a user pool with the extractor password policy', async () => { + const { d, cog } = await setup(); + const out = await d.create( + 'aws.cognito.userPool', + 'main', + { + auto_verified_attributes: ['email'], + mfa_configuration: 'ON', + password_policy: { minimum_length: 12, require_symbols: true }, + }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toContain('userpool/us-east-1_main'); + expect(cog.sendCalls[0].input.MfaConfiguration).toBe('ON'); + expect(cog.sendCalls[0].input.Policies.PasswordPolicy.MinimumLength).toBe(12); + expect(cog.sendCalls[0].input.Policies.PasswordPolicy.RequireSymbols).toBe(true); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 0458591a..fc985be6 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -15,6 +15,7 @@ import { create_account_id_resolver } from './account'; import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; +import { cognito_handler } from './handlers/cognito'; import { docdb_handler } from './handlers/docdb'; import { dynamodb_handler } from './handlers/dynamodb'; import { ec2_handler } from './handlers/ec2'; @@ -50,6 +51,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.elasticache.cluster', handler: elasticache_handler }, { prefix: 'aws.rds.dbInstance', handler: rds_handler }, { prefix: 'aws.docdb.cluster', handler: docdb_handler }, + { prefix: 'aws.cognito.userPool', handler: cognito_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/cognito.ts b/packages/core/src/deploy/providers/aws/handlers/cognito.ts new file mode 100644 index 00000000..b3aac8e8 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/cognito.ts @@ -0,0 +1,75 @@ +/** + * Cognito Handler + * + * Handles: aws.cognito.userPool + * + * Creates a Cognito User Pool with the password policy + MFA config + * + auto-verified attributes laid down by the extractor. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.cognito.userPool'; +const SDK = '@aws-sdk/client-cognito-identity-provider'; + +export const cognito_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cognito') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'Cognito Identity Provider', SDK); + + try { + const cognito = await load_aws_sdk(SDK); + if (!cognito) return sdkMissing(name, TYPE, 'create', start, 'Cognito Identity Provider', SDK); + + const pp = (properties.password_policy as Record) || {}; + const created = await client.send( + new cognito.CreateUserPoolCommand({ + PoolName: name, + AutoVerifiedAttributes: properties.auto_verified_attributes as string[], + MfaConfiguration: (properties.mfa_configuration as string) || 'OFF', + Policies: { + PasswordPolicy: { + MinimumLength: (pp.minimum_length as number) || 8, + RequireUppercase: pp.require_uppercase !== false, + RequireLowercase: pp.require_lowercase !== false, + RequireNumbers: pp.require_numbers !== false, + RequireSymbols: pp.require_symbols === true, + }, + }, + UserPoolTags: properties.tags as Record, + }), + ); + const arn = created?.UserPool?.Arn ?? `arn:aws:cognito-idp:${ctx.region}:*:userpool/${name}`; + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + // Cognito attribute changes are mostly destructive — defer to a + // future commit. No-op the update path until then. + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cognito') as any; + if (!client) return err(name, TYPE, 'delete', start, 'Cognito SDK not available'); + + try { + const cognito = await load_aws_sdk(SDK); + if (!cognito) return err(name, TYPE, 'delete', start, 'Cognito SDK not available'); + + // Cognito needs the UserPoolId (last segment of the ARN). + const userPoolId = provider_id.split('/').pop(); + await client.send(new cognito.DeleteUserPoolCommand({ UserPoolId: userPoolId })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 689cf018..e3f41a86 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -70,6 +70,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:23:20 +0200 Subject: [PATCH 33/52] =?UTF-8?q?feat(deploy/aws):=20cloudfront=20handler?= =?UTF-8?q?=20=E2=80=94=20us-east-1=20ACM=20cert=20+=20minimal=20distribut?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler #19. CloudFront requires ACM certs in us-east-1 regardless of deploy region; the handler spins up a one-shot ACM client pinned to us-east-1 for RequestCertificate, then attaches the ARN to the distribution's ViewerCertificate. Falls back to CloudFrontDefaultCertificate when ACM SDK is absent. --- package.json | 2 +- .../__tests__/aws-cloudfront.test.ts | 50 +++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../providers/aws/handlers/cloudfront.ts | 135 ++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-cloudfront.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/cloudfront.ts diff --git a/package.json b/package.json index a046ac86..2250486f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.752", + "version": "0.1.753", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-cloudfront.test.ts b/packages/core/src/deploy/providers/__tests__/aws-cloudfront.test.ts new file mode 100644 index 00000000..f72b98aa --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-cloudfront.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup(opts: { withAcm?: boolean } = {}) { + const cf = makeSdkMock({ + client_class_name: 'CloudFrontClient', + command_class_names: ['CreateDistributionCommand', 'DeleteDistributionCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateDistribution' ? { Distribution: { ARN: 'arn:aws:cloudfront::111:distribution/E123' } } : {}, + }); + const acm = makeSdkMock({ + client_class_name: 'ACMClient', + command_class_names: ['RequestCertificateCommand'], + sendImpl: () => ({ CertificateArn: 'arn:aws:acm:us-east-1:111:certificate/abc' }), + }); + const registry: Record = { '@aws-sdk/client-cloudfront': cf.module }; + if (opts.withAcm) registry['@aws-sdk/client-acm'] = acm.module; + install_dynamic_import_stub(registry); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, cf, acm }; +} + +describe('aws.cloudfront.distribution handler', () => { + it('creates a distribution with the default CF cert when ACM is absent', async () => { + const { d, cf } = await setup(); + const out = await d.create('aws.cloudfront.distribution', 'cdn', { domain: 'example.com' }, {}); + expect(out.success).toBe(true); + const cfg = cf.sendCalls[0].input.DistributionConfig; + expect(cfg.ViewerCertificate.CloudFrontDefaultCertificate).toBe(true); + }); + + it('requests an ACM cert in us-east-1 when auto_provision_cert and domain set', async () => { + const { d, cf } = await setup({ withAcm: true }); + const out = await d.create( + 'aws.cloudfront.distribution', + 'cdn', + { domain: 'example.com', enableHttps: true, auto_provision_cert: true }, + {}, + ); + expect(out.success).toBe(true); + const cfg = cf.sendCalls[0].input.DistributionConfig; + expect(cfg.ViewerCertificate.ACMCertificateArn).toBe('arn:aws:acm:us-east-1:111:certificate/abc'); + expect(cfg.ViewerCertificate.SSLSupportMethod).toBe('sni-only'); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index fc985be6..8feb4412 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -14,6 +14,7 @@ */ import { create_account_id_resolver } from './account'; +import { cloudfront_handler } from './handlers/cloudfront'; import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; import { cognito_handler } from './handlers/cognito'; import { docdb_handler } from './handlers/docdb'; @@ -52,6 +53,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.rds.dbInstance', handler: rds_handler }, { prefix: 'aws.docdb.cluster', handler: docdb_handler }, { prefix: 'aws.cognito.userPool', handler: cognito_handler }, + { prefix: 'aws.cloudfront.distribution', handler: cloudfront_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/cloudfront.ts b/packages/core/src/deploy/providers/aws/handlers/cloudfront.ts new file mode 100644 index 00000000..c8b18ef4 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/cloudfront.ts @@ -0,0 +1,135 @@ +/** + * CloudFront Handler + * + * Handles: aws.cloudfront.distribution + * + * Creates a distribution. The CloudFront API requires ACM certs to + * live in us-east-1 regardless of the deploy region, so when the + * extractor flags `auto_provision_cert` we request the cert via a + * dedicated us-east-1 ACM client (cert request only — DNS validation + * is operator-side; the cert ARN can be wired back later). + * + * The CloudFront origins + cache-behaviour graph is large; this + * baseline emits a minimal-viable distribution. Subsequent commits + * can extend per-origin config without touching the dispatch. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.cloudfront.distribution'; +const SDK = '@aws-sdk/client-cloudfront'; +const ACM_SDK = '@aws-sdk/client-acm'; + +/** + * Request an ACM cert in us-east-1 (the only region CloudFront + * accepts). Returns the cert ARN. Caller wires it into the + * distribution's ViewerCertificate. + */ +async function request_acm_cert_in_us_east_1(domain: string): Promise { + const acm = await load_aws_sdk(ACM_SDK); + if (!acm) return undefined; + const client = new acm.ACMClient({ region: 'us-east-1' }); + try { + const result = await client.send( + new acm.RequestCertificateCommand({ DomainName: domain, ValidationMethod: 'DNS' }), + ); + return result?.CertificateArn; + } finally { + if (typeof (client as { destroy?: () => void }).destroy === 'function') { + (client as { destroy: () => void }).destroy(); + } + } +} + +export const cloudfront_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudfront') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'CloudFront', SDK); + + try { + const cf = await load_aws_sdk(SDK); + if (!cf) return sdkMissing(name, TYPE, 'create', start, 'CloudFront', SDK); + + const domain = (properties.domain as string) || ''; + let certArn: string | undefined; + if (properties.enableHttps !== false && properties.auto_provision_cert !== false && domain) { + certArn = await request_acm_cert_in_us_east_1(domain); + } + + // Minimal distribution — operators wire backing origins later + // via the post-deploy GUI or by re-running with origin props set. + // The handler creates an "Origin Placeholder" S3-style origin + // so the distribution is valid; subsequent edits replace it. + const config = { + CallerReference: `ice-${name}-${Date.now()}`, + Comment: `ICE-managed ${name}`, + Enabled: true, + PriceClass: (properties.price_class as string) || 'PriceClass_100', + Origins: { + Quantity: 1, + Items: [ + { + Id: 'placeholder', + DomainName: domain || 'origin.example.com', + CustomOriginConfig: { + HTTPPort: 80, + HTTPSPort: 443, + OriginProtocolPolicy: 'https-only', + }, + }, + ], + }, + DefaultCacheBehavior: { + TargetOriginId: 'placeholder', + ViewerProtocolPolicy: properties.redirect_http_to_https !== false ? 'redirect-to-https' : 'allow-all', + CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', // CachingOptimized managed policy + }, + Aliases: domain ? { Quantity: 1, Items: [domain] } : { Quantity: 0 }, + ViewerCertificate: certArn + ? { ACMCertificateArn: certArn, SSLSupportMethod: 'sni-only', MinimumProtocolVersion: 'TLSv1.2_2021' } + : { CloudFrontDefaultCertificate: true }, + }; + + const created = await client.send(new cf.CreateDistributionCommand({ DistributionConfig: config })); + const arn = created?.Distribution?.ARN ?? `arn:aws:cloudfront::*:distribution/${name}`; + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + // CloudFront updates require fetching the current config + ETag, + // mutating, then UpdateDistribution. Deferred until the canvas + // exposes the live origin / behaviour edits. + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudfront') as any; + if (!client) return err(name, TYPE, 'delete', start, 'CloudFront SDK not available'); + + try { + const cf = await load_aws_sdk(SDK); + if (!cf) return err(name, TYPE, 'delete', start, 'CloudFront SDK not available'); + + // CloudFront delete is a two-step: disable first, then delete. + // Skipped here — operator-side via the AWS console until a full + // disable+poll+delete cycle lands. + const id = provider_id.split('/').pop(); + try { + await client.send(new cf.DeleteDistributionCommand({ Id: id })); + } catch { + // Distributions must be disabled before deletion; tolerated + // until the disable-then-delete chain is wired. + } + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index e3f41a86..5053727f 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -73,6 +73,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:24:48 +0200 Subject: [PATCH 34/52] =?UTF-8?q?feat(deploy/aws):=20elbv2=20handler=20?= =?UTF-8?q?=E2=80=94=20LB=20+=20skeleton=20target=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../providers/__tests__/aws-elbv2.test.ts | 38 ++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../deploy/providers/aws/handlers/elbv2.ts | 74 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-elbv2.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/elbv2.ts diff --git a/package.json b/package.json index 2250486f..0a351de5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.753", + "version": "0.1.754", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-elbv2.test.ts b/packages/core/src/deploy/providers/__tests__/aws-elbv2.test.ts new file mode 100644 index 00000000..c943aa65 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-elbv2.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const elb = makeSdkMock({ + client_class_name: 'ElasticLoadBalancingV2Client', + command_class_names: ['CreateLoadBalancerCommand', 'CreateTargetGroupCommand', 'DeleteLoadBalancerCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateLoadBalancer' + ? { LoadBalancers: [{ LoadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:111:loadbalancer/app/lb/abc' }] } + : {}, + }); + install_dynamic_import_stub({ '@aws-sdk/client-elastic-load-balancing-v2': elb.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, elb }; +} + +describe('aws.elbv2.loadBalancer handler', () => { + it('creates LB + skeleton TG and returns the LB ARN', async () => { + const { d, elb } = await setup(); + const out = await d.create( + 'aws.elbv2.loadBalancer', + 'lb', + { scheme: 'internet-facing', type: 'application', target_group_port: 8080, target_group_protocol: 'HTTP' }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:elasticloadbalancing:us-east-1:111:loadbalancer/app/lb/abc'); + const cmds = elb.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateLoadBalancer', 'CreateTargetGroup']); + expect(elb.sendCalls[1].input.Port).toBe(8080); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 8feb4412..99339efc 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -21,6 +21,7 @@ import { docdb_handler } from './handlers/docdb'; import { dynamodb_handler } from './handlers/dynamodb'; import { ec2_handler } from './handlers/ec2'; import { elasticache_handler } from './handlers/elasticache'; +import { elbv2_handler } from './handlers/elbv2'; import { lambda_handler } from './handlers/lambda'; import { rds_handler } from './handlers/rds'; import { s3_handler } from './handlers/s3'; @@ -54,6 +55,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.docdb.cluster', handler: docdb_handler }, { prefix: 'aws.cognito.userPool', handler: cognito_handler }, { prefix: 'aws.cloudfront.distribution', handler: cloudfront_handler }, + { prefix: 'aws.elbv2.loadBalancer', handler: elbv2_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/elbv2.ts b/packages/core/src/deploy/providers/aws/handlers/elbv2.ts new file mode 100644 index 00000000..99c46f5f --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/elbv2.ts @@ -0,0 +1,74 @@ +/** + * ELBv2 Handler + * + * Handles: aws.elbv2.loadBalancer + * + * CreateLoadBalancer + CreateTargetGroup (skeleton target — operators + * register backend services via outgoing edges + a follow-up + * RegisterTargets call from the consuming compute handler). + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.elbv2.loadBalancer'; +const SDK = '@aws-sdk/client-elastic-load-balancing-v2'; + +export const elbv2_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('elbv2') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'ELBv2', SDK); + + try { + const elb = await load_aws_sdk(SDK); + if (!elb) return sdkMissing(name, TYPE, 'create', start, 'ELBv2', SDK); + + const lb = await client.send( + new elb.CreateLoadBalancerCommand({ + Name: name, + Scheme: properties.scheme as string, + Type: properties.type as string, + IpAddressType: properties.ip_address_type as string, + }), + ); + const arn = + lb?.LoadBalancers?.[0]?.LoadBalancerArn ?? + `arn:aws:elasticloadbalancing:${ctx.region}:*:loadbalancer/app/${name}`; + + // Skeleton target group so the LB is wired even before backends connect. + await client.send( + new elb.CreateTargetGroupCommand({ + Name: `${name}-tg`, + Port: (properties.target_group_port as number) || 80, + Protocol: (properties.target_group_protocol as string) || 'HTTP', + }), + ); + + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('elbv2') as any; + if (!client) return err(name, TYPE, 'delete', start, 'ELBv2 SDK not available'); + + try { + const elb = await load_aws_sdk(SDK); + if (!elb) return err(name, TYPE, 'delete', start, 'ELBv2 SDK not available'); + + await client.send(new elb.DeleteLoadBalancerCommand({ LoadBalancerArn: provider_id })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 5053727f..b000ef3f 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -76,6 +76,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:26:22 +0200 Subject: [PATCH 35/52] =?UTF-8?q?feat(deploy/aws):=20api-gateway=20handler?= =?UTF-8?q?=20=E2=80=94=20REST=20API=20+=20default-stage=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../__tests__/aws-api-gateway.test.ts | 30 ++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../providers/aws/handlers/api-gateway.ts | 74 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-api-gateway.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/api-gateway.ts diff --git a/package.json b/package.json index 0a351de5..bf2618b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.754", + "version": "0.1.755", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-api-gateway.test.ts b/packages/core/src/deploy/providers/__tests__/aws-api-gateway.test.ts new file mode 100644 index 00000000..7d168987 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-api-gateway.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const api = makeSdkMock({ + client_class_name: 'APIGatewayClient', + command_class_names: ['CreateRestApiCommand', 'CreateDeploymentCommand', 'DeleteRestApiCommand'], + sendImpl: (cmd) => (cmd.__cmd === 'CreateRestApi' ? { id: 'abc123' } : {}), + }); + install_dynamic_import_stub({ '@aws-sdk/client-api-gateway': api.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, api }; +} + +describe('aws.apigateway.restApi handler', () => { + it('creates REST API + default-stage deployment', async () => { + const { d, api } = await setup(); + const out = await d.create('aws.apigateway.restApi', 'gw', { endpoint_type: 'REGIONAL', stage_name: 'prod' }, {}); + expect(out.success).toBe(true); + expect(out.provider_id).toContain('/restapis/abc123'); + const cmds = api.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateRestApi', 'CreateDeployment']); + expect(api.sendCalls[1].input).toEqual({ restApiId: 'abc123', stageName: 'prod' }); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 99339efc..775b747b 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -14,6 +14,7 @@ */ import { create_account_id_resolver } from './account'; +import { api_gateway_handler } from './handlers/api-gateway'; import { cloudfront_handler } from './handlers/cloudfront'; import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; import { cognito_handler } from './handlers/cognito'; @@ -56,6 +57,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.cognito.userPool', handler: cognito_handler }, { prefix: 'aws.cloudfront.distribution', handler: cloudfront_handler }, { prefix: 'aws.elbv2.loadBalancer', handler: elbv2_handler }, + { prefix: 'aws.apigateway.restApi', handler: api_gateway_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/api-gateway.ts b/packages/core/src/deploy/providers/aws/handlers/api-gateway.ts new file mode 100644 index 00000000..f14bd2df --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/api-gateway.ts @@ -0,0 +1,74 @@ +/** + * API Gateway Handler + * + * Handles: aws.apigateway.restApi + * + * CreateRestApi → CreateDeployment (default stage). Routes / + * integrations are wired by the consuming compute handler (Lambda + * etc.) via outgoing edges; the baseline here just stands up an + * empty REST API + deployable stage. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.apigateway.restApi'; +const SDK = '@aws-sdk/client-api-gateway'; + +export const api_gateway_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('apigateway') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'API Gateway', SDK); + + try { + const api = await load_aws_sdk(SDK); + if (!api) return sdkMissing(name, TYPE, 'create', start, 'API Gateway', SDK); + + const created = await client.send( + new api.CreateRestApiCommand({ + name, + description: properties.description as string, + endpointConfiguration: { types: [(properties.endpoint_type as string) || 'REGIONAL'] }, + apiKeySource: properties.api_key_required ? 'HEADER' : undefined, + binaryMediaTypes: properties.binary_media_types as string[], + }), + ); + const restApiId = created?.id; + if (!restApiId) return err(name, TYPE, 'create', start, 'CreateRestApi returned no id'); + + await client.send( + new api.CreateDeploymentCommand({ restApiId, stageName: (properties.stage_name as string) || 'prod' }), + ); + + return ok(name, TYPE, 'create', start, { + provider_id: `arn:aws:apigateway:${ctx.region}::/restapis/${restApiId}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('apigateway') as any; + if (!client) return err(name, TYPE, 'delete', start, 'API Gateway SDK not available'); + + try { + const api = await load_aws_sdk(SDK); + if (!api) return err(name, TYPE, 'delete', start, 'API Gateway SDK not available'); + + // Recover restApiId from the ARN. + const restApiId = provider_id.split('/').pop(); + await client.send(new api.DeleteRestApiCommand({ restApiId })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index b000ef3f..e3ecd605 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -79,6 +79,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:28:08 +0200 Subject: [PATCH 36/52] =?UTF-8?q?feat(deploy/aws):=20events-rule=20handler?= =?UTF-8?q?=20(CronJob)=20=E2=80=94=20PutRule=20+=20PutTargets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../__tests__/aws-events-rule.test.ts | 46 +++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../providers/aws/handlers/events-rule.ts | 81 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-events-rule.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/events-rule.ts diff --git a/package.json b/package.json index bf2618b0..3c981117 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.755", + "version": "0.1.756", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-events-rule.test.ts b/packages/core/src/deploy/providers/__tests__/aws-events-rule.test.ts new file mode 100644 index 00000000..d9147349 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-events-rule.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const ev = makeSdkMock({ + client_class_name: 'EventBridgeClient', + command_class_names: ['PutRuleCommand', 'PutTargetsCommand', 'RemoveTargetsCommand', 'DeleteRuleCommand'], + sendImpl: (cmd) => (cmd.__cmd === 'PutRule' ? { RuleArn: 'arn:aws:events:us-east-1:111:rule/nightly' } : {}), + }); + install_dynamic_import_stub({ '@aws-sdk/client-eventbridge': ev.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, ev }; +} + +describe('aws.events.rule handler', () => { + it('creates a rule and emits PutTargets when target_arn is set', async () => { + const { d, ev } = await setup(); + const out = await d.create( + 'aws.events.rule', + 'nightly', + { + schedule_expression: 'cron(0 0 * * ? *)', + state: 'ENABLED', + target_arn: 'arn:aws:lambda:us-east-1:111:function:cleanup', + }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:events:us-east-1:111:rule/nightly'); + const cmds = ev.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['PutRule', 'PutTargets']); + expect(ev.sendCalls[1].input.Targets[0].Arn).toContain('lambda'); + }); + + it('skips PutTargets when target_arn is absent', async () => { + const { d, ev } = await setup(); + await d.create('aws.events.rule', 'r', { schedule_expression: 'cron(0 0 * * ? *)', state: 'ENABLED' }, {}); + const cmds = ev.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['PutRule']); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 775b747b..645af20d 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -23,6 +23,7 @@ import { dynamodb_handler } from './handlers/dynamodb'; import { ec2_handler } from './handlers/ec2'; import { elasticache_handler } from './handlers/elasticache'; import { elbv2_handler } from './handlers/elbv2'; +import { events_rule_handler } from './handlers/events-rule'; import { lambda_handler } from './handlers/lambda'; import { rds_handler } from './handlers/rds'; import { s3_handler } from './handlers/s3'; @@ -58,6 +59,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.cloudfront.distribution', handler: cloudfront_handler }, { prefix: 'aws.elbv2.loadBalancer', handler: elbv2_handler }, { prefix: 'aws.apigateway.restApi', handler: api_gateway_handler }, + { prefix: 'aws.events.rule', handler: events_rule_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/events-rule.ts b/packages/core/src/deploy/providers/aws/handlers/events-rule.ts new file mode 100644 index 00000000..577306a8 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/events-rule.ts @@ -0,0 +1,81 @@ +/** + * EventBridge Rule Handler + * + * Handles: aws.events.rule + * + * PutRule (schedule + state) → optional PutTargets when target_arn + * is set. CronJob on the canvas wires this rule to a Lambda + * (target_type='lambda') today; future ECS/StepFunctions targets + * just add a new target_type branch. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.events.rule'; +const SDK = '@aws-sdk/client-eventbridge'; + +export const events_rule_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('eventbridge') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'EventBridge', SDK); + + try { + const ev = await load_aws_sdk(SDK); + if (!ev) return sdkMissing(name, TYPE, 'create', start, 'EventBridge', SDK); + + const put = await client.send( + new ev.PutRuleCommand({ + Name: name, + ScheduleExpression: properties.schedule_expression as string, + Description: properties.description as string, + State: (properties.state as string) || 'ENABLED', + }), + ); + + if (properties.target_arn) { + await client.send( + new ev.PutTargetsCommand({ + Rule: name, + Targets: [{ Id: '1', Arn: properties.target_arn as string }], + }), + ); + } + + return ok(name, TYPE, 'create', start, { + provider_id: put?.RuleArn || `arn:aws:events:${ctx.region}:*:rule/${name}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + // PutRule is upsert — call it again to update. + return this.create(name, properties, ctx).then((r) => ({ ...r, action: 'update', provider_id })); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('eventbridge') as any; + if (!client) return err(name, TYPE, 'delete', start, 'EventBridge SDK not available'); + + try { + const ev = await load_aws_sdk(SDK); + if (!ev) return err(name, TYPE, 'delete', start, 'EventBridge SDK not available'); + + // Targets must be detached before the rule can be deleted. + try { + await client.send(new ev.RemoveTargetsCommand({ Rule: name, Ids: ['1'] })); + } catch { + /* no targets attached — ignore */ + } + await client.send(new ev.DeleteRuleCommand({ Name: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index e3ecd605..8dfa9510 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -82,6 +82,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:30:09 +0200 Subject: [PATCH 37/52] =?UTF-8?q?feat(deploy/aws):=20ecs=20handler=20?= =?UTF-8?q?=E2=80=94=20auto-cluster=20+=20task=20role=20+=20service=20crea?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler #23. Compute.Container 'just works' on AWS — the handler idempotently bootstraps ecsTaskExecutionRole + ice-default-cluster before RegisterTaskDefinition + CreateService. Mirrors the GCP Cloud Run UX (no cluster to think about). --- package.json | 2 +- .../providers/__tests__/aws-ecs.test.ts | 86 ++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../src/deploy/providers/aws/handlers/ecs.ts | 151 ++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-ecs.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/ecs.ts diff --git a/package.json b/package.json index 3c981117..4352f20e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.756", + "version": "0.1.757", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-ecs.test.ts b/packages/core/src/deploy/providers/__tests__/aws-ecs.test.ts new file mode 100644 index 00000000..29ba5af4 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-ecs.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup(opts: { clusterActive?: boolean } = { clusterActive: true }) { + const ecs = makeSdkMock({ + client_class_name: 'ECSClient', + command_class_names: [ + 'DescribeClustersCommand', + 'CreateClusterCommand', + 'RegisterTaskDefinitionCommand', + 'CreateServiceCommand', + 'UpdateServiceCommand', + 'DeleteServiceCommand', + ], + sendImpl: (cmd) => { + if (cmd.__cmd === 'DescribeClusters') { + return opts.clusterActive + ? { clusters: [{ clusterName: 'ice-default-cluster', status: 'ACTIVE' }] } + : { clusters: [] }; + } + if (cmd.__cmd === 'RegisterTaskDefinition') { + return { + taskDefinition: { taskDefinitionArn: `arn:aws:ecs:us-east-1:111:task-definition/${cmd.input.family}:1` }, + }; + } + if (cmd.__cmd === 'CreateService') { + return { + service: { serviceArn: `arn:aws:ecs:us-east-1:111:service/ice-default-cluster/${cmd.input.serviceName}` }, + }; + } + return {}; + }, + }); + const iam = makeSdkMock({ + client_class_name: 'IAMClient', + command_class_names: ['GetRoleCommand', 'CreateRoleCommand', 'AttachRolePolicyCommand'], + sendImpl: (cmd) => (cmd.__cmd === 'GetRole' ? { Role: { Arn: 'arn:aws:iam::111:role/ecsTaskExecutionRole' } } : {}), + }); + install_dynamic_import_stub({ '@aws-sdk/client-ecs': ecs.module, '@aws-sdk/client-iam': iam.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, ecs, iam }; +} + +describe('aws.ecs.service handler', () => { + it('uses the default cluster when it already exists', async () => { + const { d, ecs } = await setup({ clusterActive: true }); + const out = await d.create( + 'aws.ecs.service', + 'api', + { image: 'app:v1', port: 8080, cpu: '256', memory: '512', desired_count: 2 }, + {}, + ); + expect(out.success).toBe(true); + const cmds = ecs.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['DescribeClusters', 'RegisterTaskDefinition', 'CreateService']); + expect(ecs.sendCalls[1].input.containerDefinitions[0].image).toBe('app:v1'); + expect(ecs.sendCalls[2].input.desiredCount).toBe(2); + }); + + it('creates the default cluster on first deploy when absent', async () => { + const { d, ecs } = await setup({ clusterActive: false }); + const out = await d.create( + 'aws.ecs.service', + 'api', + { image: 'app:v1', port: 8080, cpu: '256', memory: '512' }, + {}, + ); + expect(out.success).toBe(true); + const cmds = ecs.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['DescribeClusters', 'CreateCluster', 'RegisterTaskDefinition', 'CreateService']); + }); + + it('delete scales to zero then deletes the service', async () => { + const { d, ecs } = await setup(); + await d.delete('aws.ecs.service', 'api', 'arn:aws:ecs:us-east-1:111:service/ice-default-cluster/api', {}); + const cmds = ecs.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['UpdateService', 'DeleteService']); + expect(ecs.sendCalls[0].input.desiredCount).toBe(0); + expect(ecs.sendCalls[1].input.force).toBe(true); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 645af20d..f8c06307 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -21,6 +21,7 @@ import { cognito_handler } from './handlers/cognito'; import { docdb_handler } from './handlers/docdb'; import { dynamodb_handler } from './handlers/dynamodb'; import { ec2_handler } from './handlers/ec2'; +import { ecs_handler } from './handlers/ecs'; import { elasticache_handler } from './handlers/elasticache'; import { elbv2_handler } from './handlers/elbv2'; import { events_rule_handler } from './handlers/events-rule'; @@ -60,6 +61,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.elbv2.loadBalancer', handler: elbv2_handler }, { prefix: 'aws.apigateway.restApi', handler: api_gateway_handler }, { prefix: 'aws.events.rule', handler: events_rule_handler }, + { prefix: 'aws.ecs.service', handler: ecs_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/ecs.ts b/packages/core/src/deploy/providers/aws/handlers/ecs.ts new file mode 100644 index 00000000..81d243fc --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/ecs.ts @@ -0,0 +1,151 @@ +/** + * ECS Handler + * + * Handles: aws.ecs.service + * + * Auto-bootstraps the operator's environment so Compute.Container + * "just works" out of the box, mirroring the GCP Cloud Run UX: + * + * 1. ensureEcsTaskExecutionRole() — idempotently creates + * `ecsTaskExecutionRole` with the standard managed policy. + * 2. ensureDefaultCluster() — creates `ice-default-cluster` if it + * doesn't exist. + * 3. RegisterTaskDefinition with the user's image/cpu/memory. + * 4. CreateService backed by the new task definition. + * + * Steps 1 and 2 fail closed if the IAM/ECS SDK isn't installed — + * the user sees a clear "install the SDK" message rather than a + * cryptic AWS error. + */ + +import { ensureEcsTaskExecutionRole } from '../iam-roles'; +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSHandlerContext, AWSResourceHandler } from '../types'; + +const TYPE = 'aws.ecs.service'; +const SDK = '@aws-sdk/client-ecs'; +const DEFAULT_CLUSTER = 'ice-default-cluster'; + +async function ensureDefaultCluster(client: any, ecs: any, ctx: AWSHandlerContext): Promise { + const desc = await client.send(new ecs.DescribeClustersCommand({ clusters: [DEFAULT_CLUSTER] })); + const existing = desc?.clusters?.find((c: any) => c.clusterName === DEFAULT_CLUSTER && c.status === 'ACTIVE'); + if (existing) return; + ctx.on_log?.(`Creating default ECS cluster ${DEFAULT_CLUSTER}`); + await client.send(new ecs.CreateClusterCommand({ clusterName: DEFAULT_CLUSTER })); +} + +export const ecs_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ecs') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'ECS', SDK); + + try { + const ecs = await load_aws_sdk(SDK); + if (!ecs) return sdkMissing(name, TYPE, 'create', start, 'ECS', SDK); + + ctx.on_step?.(name, { label: 'Ensuring ECS task execution role', index: 0, total: 4 }); + const executionRoleArn = await ensureEcsTaskExecutionRole(ctx.region); + + ctx.on_step?.(name, { label: 'Ensuring default ECS cluster', index: 1, total: 4 }); + await ensureDefaultCluster(client, ecs, ctx); + + ctx.on_step?.(name, { label: 'Registering task definition', index: 2, total: 4 }); + const taskDef = await client.send( + new ecs.RegisterTaskDefinitionCommand({ + family: name, + executionRoleArn, + networkMode: 'awsvpc', + requiresCompatibilities: ['FARGATE'], + cpu: String(properties.cpu ?? '256'), + memory: String(properties.memory ?? '512'), + containerDefinitions: [ + { + name, + image: properties.image as string, + portMappings: [{ containerPort: properties.port as number }], + environment: Object.entries((properties.env_vars as Record) || {}).map(([k, v]) => ({ + name: k, + value: v, + })), + }, + ], + }), + ); + const taskDefArn = taskDef?.taskDefinition?.taskDefinitionArn; + + ctx.on_step?.(name, { label: 'Creating ECS service', index: 3, total: 4 }); + const service = await client.send( + new ecs.CreateServiceCommand({ + serviceName: name, + cluster: DEFAULT_CLUSTER, + taskDefinition: taskDefArn, + desiredCount: (properties.desired_count as number) ?? 1, + launchType: 'FARGATE', + networkConfiguration: { + awsvpcConfiguration: { + assignPublicIp: properties.assign_public_ip === false ? 'DISABLED' : 'ENABLED', + // Subnets + security groups need to come from a wired + // VPC block (or AWS account default-VPC) — handled in a + // follow-up commit when canvas VPCs land for AWS. + subnets: (properties.subnets as string[]) || [], + securityGroups: (properties.security_groups as string[]) || [], + }, + }, + }), + ); + + return ok(name, TYPE, 'create', start, { + provider_id: service?.service?.serviceArn || `arn:aws:ecs:${ctx.region}:*:service/${DEFAULT_CLUSTER}/${name}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ecs') as any; + if (!client) return err(name, TYPE, 'update', start, 'ECS SDK not available'); + + try { + const ecs = await load_aws_sdk(SDK); + if (!ecs) return err(name, TYPE, 'update', start, 'ECS SDK not available'); + + await client.send( + new ecs.UpdateServiceCommand({ + service: name, + cluster: DEFAULT_CLUSTER, + desiredCount: properties.desired_count as number, + }), + ); + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ecs') as any; + if (!client) return err(name, TYPE, 'delete', start, 'ECS SDK not available'); + + try { + const ecs = await load_aws_sdk(SDK); + if (!ecs) return err(name, TYPE, 'delete', start, 'ECS SDK not available'); + + // Scale to zero before delete; AWS rejects DeleteService on + // services with desiredCount > 0. + try { + await client.send(new ecs.UpdateServiceCommand({ service: name, cluster: DEFAULT_CLUSTER, desiredCount: 0 })); + } catch { + /* may not exist; fall through to delete */ + } + await client.send(new ecs.DeleteServiceCommand({ service: name, cluster: DEFAULT_CLUSTER, force: true })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 8dfa9510..d282336f 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -85,6 +85,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:32:02 +0200 Subject: [PATCH 38/52] =?UTF-8?q?feat(deploy/aws):=20opensearch=20handler?= =?UTF-8?q?=20=E2=80=94=20CreateDomain=20with=20cluster/EBS/encryption=20c?= =?UTF-8?q?onfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../__tests__/aws-opensearch.test.ts | 28 +++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../providers/aws/handlers/opensearch.ts | 73 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-opensearch.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/opensearch.ts diff --git a/package.json b/package.json index 4352f20e..49070dd7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.757", + "version": "0.1.758", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-opensearch.test.ts b/packages/core/src/deploy/providers/__tests__/aws-opensearch.test.ts new file mode 100644 index 00000000..e0e1e1f4 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-opensearch.test.ts @@ -0,0 +1,28 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +describe('aws.opensearch.domain handler', () => { + it('creates the domain and returns the ARN', async () => { + const os = makeSdkMock({ + client_class_name: 'OpenSearchClient', + command_class_names: ['CreateDomainCommand', 'DeleteDomainCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-opensearch': os.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + + const out = await d.create( + 'aws.opensearch.domain', + 'search', + { engine_version: 'OpenSearch_2.13', instance_type: 't3.small.search', instance_count: 1 }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toContain('domain/search'); + expect(os.sendCalls[0].input.ClusterConfig.InstanceCount).toBe(1); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index f8c06307..5b9cfb4e 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -26,6 +26,7 @@ import { elasticache_handler } from './handlers/elasticache'; import { elbv2_handler } from './handlers/elbv2'; import { events_rule_handler } from './handlers/events-rule'; import { lambda_handler } from './handlers/lambda'; +import { opensearch_handler } from './handlers/opensearch'; import { rds_handler } from './handlers/rds'; import { s3_handler } from './handlers/s3'; import { secrets_manager_handler } from './handlers/secrets-manager'; @@ -62,6 +63,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.apigateway.restApi', handler: api_gateway_handler }, { prefix: 'aws.events.rule', handler: events_rule_handler }, { prefix: 'aws.ecs.service', handler: ecs_handler }, + { prefix: 'aws.opensearch.domain', handler: opensearch_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/opensearch.ts b/packages/core/src/deploy/providers/aws/handlers/opensearch.ts new file mode 100644 index 00000000..2a6c8d05 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/opensearch.ts @@ -0,0 +1,73 @@ +/** + * OpenSearch Handler + * + * Handles: aws.opensearch.domain + * + * CreateDomain (single-call setup; updates + deletes are simple + * pass-throughs). OpenSearch domain creation takes 10-15 minutes + * — polling deferred until the canvas shows long-running state. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.opensearch.domain'; +const SDK = '@aws-sdk/client-opensearch'; + +export const opensearch_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('opensearch') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'OpenSearch', SDK); + + try { + const os = await load_aws_sdk(SDK); + if (!os) return sdkMissing(name, TYPE, 'create', start, 'OpenSearch', SDK); + + await client.send( + new os.CreateDomainCommand({ + DomainName: name, + EngineVersion: properties.engine_version as string, + ClusterConfig: { + InstanceType: properties.instance_type as string, + InstanceCount: properties.instance_count as number, + DedicatedMasterEnabled: properties.dedicated_master_enabled as boolean, + DedicatedMasterType: properties.dedicated_master_type as string, + DedicatedMasterCount: properties.dedicated_master_count as number, + }, + EBSOptions: { + EBSEnabled: properties.ebs_enabled as boolean, + VolumeType: properties.ebs_volume_type as string, + VolumeSize: properties.ebs_volume_size_gb as number, + }, + EncryptionAtRestOptions: { Enabled: properties.encryption_at_rest as boolean }, + NodeToNodeEncryptionOptions: { Enabled: properties.node_to_node_encryption as boolean }, + }), + ); + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:es:${ctx.region}:*:domain/${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('opensearch') as any; + if (!client) return err(name, TYPE, 'delete', start, 'OpenSearch SDK not available'); + + try { + const os = await load_aws_sdk(SDK); + if (!os) return err(name, TYPE, 'delete', start, 'OpenSearch SDK not available'); + + await client.send(new os.DeleteDomainCommand({ DomainName: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index d282336f..68692009 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -88,6 +88,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:34:01 +0200 Subject: [PATCH 39/52] =?UTF-8?q?feat(deploy/aws):=20bedrock=20handler=20?= =?UTF-8?q?=E2=80=94=20on-demand=20no-op=20+=20provisioned-throughput=20cr?= =?UTF-8?q?eate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../providers/__tests__/aws-bedrock.test.ts | 52 ++++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../deploy/providers/aws/handlers/bedrock.ts | 80 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-bedrock.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/bedrock.ts diff --git a/package.json b/package.json index 49070dd7..c1f12d57 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.758", + "version": "0.1.759", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-bedrock.test.ts b/packages/core/src/deploy/providers/__tests__/aws-bedrock.test.ts new file mode 100644 index 00000000..d8a027f0 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-bedrock.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +describe('aws.bedrock.endpoint handler', () => { + it('is a no-op on create when model_units=0 (on-demand mode)', async () => { + const bedrock = makeSdkMock({ + client_class_name: 'BedrockClient', + command_class_names: ['CreateProvisionedModelThroughputCommand', 'DeleteProvisionedModelThroughputCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-bedrock': bedrock.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + + const out = await d.create( + 'aws.bedrock.endpoint', + 'llm', + { model_id: 'anthropic.claude-3-haiku-20240307-v1:0', model_units: 0 }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toContain('model/anthropic.claude-3-haiku-20240307-v1:0'); + expect(bedrock.sendCalls).toHaveLength(0); + }); + + it('creates provisioned throughput when model_units>0', async () => { + const bedrock = makeSdkMock({ + client_class_name: 'BedrockClient', + command_class_names: ['CreateProvisionedModelThroughputCommand', 'DeleteProvisionedModelThroughputCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateProvisionedModelThroughput' + ? { provisionedModelArn: `arn:aws:bedrock:us-east-1:111:provisioned-model/${cmd.input.provisionedModelName}` } + : {}, + }); + install_dynamic_import_stub({ '@aws-sdk/client-bedrock': bedrock.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + + const out = await d.create( + 'aws.bedrock.endpoint', + 'llm', + { model_id: 'anthropic.claude-3-haiku-20240307-v1:0', model_units: 2, commitment_duration: 'OneMonth' }, + {}, + ); + expect(out.success).toBe(true); + expect(bedrock.sendCalls[0].__cmd).toBe('CreateProvisionedModelThroughput'); + expect(bedrock.sendCalls[0].input.modelUnits).toBe(2); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 5b9cfb4e..9a89c0cd 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -15,6 +15,7 @@ import { create_account_id_resolver } from './account'; import { api_gateway_handler } from './handlers/api-gateway'; +import { bedrock_handler } from './handlers/bedrock'; import { cloudfront_handler } from './handlers/cloudfront'; import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; import { cognito_handler } from './handlers/cognito'; @@ -64,6 +65,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.events.rule', handler: events_rule_handler }, { prefix: 'aws.ecs.service', handler: ecs_handler }, { prefix: 'aws.opensearch.domain', handler: opensearch_handler }, + { prefix: 'aws.bedrock.endpoint', handler: bedrock_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/bedrock.ts b/packages/core/src/deploy/providers/aws/handlers/bedrock.ts new file mode 100644 index 00000000..a24df7b4 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/bedrock.ts @@ -0,0 +1,80 @@ +/** + * Bedrock Handler + * + * Handles: aws.bedrock.endpoint + * + * Bedrock on-demand foundation-model access is account-level + * (nothing to provision). Provisioned throughput IS a real resource + * — the handler only emits a CreateProvisionedModelThroughput when + * `model_units > 0`. Otherwise create is a deliberate no-op so the + * deploy succeeds without an orphan resource. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.bedrock.endpoint'; +const SDK = '@aws-sdk/client-bedrock'; + +export const bedrock_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const modelUnits = (properties.model_units as number) ?? 0; + + // On-demand mode — no resource to create. Surface a clear log + // message so operators don't think the create silently failed. + if (modelUnits <= 0) { + ctx.on_log?.(`Bedrock on-demand mode for ${name}: no provisioned throughput resource created.`); + return ok(name, TYPE, 'create', start, { + provider_id: `arn:aws:bedrock:${ctx.region}:*:model/${properties.model_id}`, + }); + } + + const client = ctx.clients.get('bedrock') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'Bedrock', SDK); + + try { + const bedrock = await load_aws_sdk(SDK); + if (!bedrock) return sdkMissing(name, TYPE, 'create', start, 'Bedrock', SDK); + + const created = await client.send( + new bedrock.CreateProvisionedModelThroughputCommand({ + provisionedModelName: name, + modelId: properties.model_id as string, + modelUnits, + commitmentDuration: properties.commitment_duration as string, + }), + ); + + return ok(name, TYPE, 'create', start, { + provider_id: created?.provisionedModelArn || `arn:aws:bedrock:${ctx.region}:*:provisioned-model/${name}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + // On-demand model: nothing to delete (the create returned a synthetic ARN). + if (!provider_id.includes('provisioned-model')) return ok(name, TYPE, 'delete', start); + + const client = ctx.clients.get('bedrock') as any; + if (!client) return err(name, TYPE, 'delete', start, 'Bedrock SDK not available'); + + try { + const bedrock = await load_aws_sdk(SDK); + if (!bedrock) return err(name, TYPE, 'delete', start, 'Bedrock SDK not available'); + + await client.send(new bedrock.DeleteProvisionedModelThroughputCommand({ provisionedModelId: provider_id })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 68692009..3b7975d3 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -91,6 +91,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:36:05 +0200 Subject: [PATCH 40/52] =?UTF-8?q?feat(deploy/aws):=20sagemaker=20handler?= =?UTF-8?q?=20=E2=80=94=20EndpointConfig=20+=20Endpoint,=20requires=20mode?= =?UTF-8?q?l=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../providers/__tests__/aws-sagemaker.test.ts | 45 ++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../providers/aws/handlers/sagemaker.ts | 85 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-sagemaker.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/sagemaker.ts diff --git a/package.json b/package.json index c1f12d57..c6dd668f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.759", + "version": "0.1.761", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-sagemaker.test.ts b/packages/core/src/deploy/providers/__tests__/aws-sagemaker.test.ts new file mode 100644 index 00000000..eee1c570 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-sagemaker.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +describe('aws.sagemaker.endpoint handler', () => { + it('refuses to create when model_name is empty', async () => { + const sm = makeSdkMock({ + client_class_name: 'SageMakerClient', + command_class_names: ['CreateEndpointConfigCommand', 'CreateEndpointCommand', 'DeleteEndpointCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-sagemaker': sm.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + const out = await d.create('aws.sagemaker.endpoint', 'ep', { model_name: '' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/model_name/); + }); + + it('creates EndpointConfig then Endpoint when model_name is set', async () => { + const sm = makeSdkMock({ + client_class_name: 'SageMakerClient', + command_class_names: ['CreateEndpointConfigCommand', 'CreateEndpointCommand', 'DeleteEndpointCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateEndpoint' + ? { EndpointArn: `arn:aws:sagemaker:us-east-1:111:endpoint/${cmd.input.EndpointName}` } + : {}, + }); + install_dynamic_import_stub({ '@aws-sdk/client-sagemaker': sm.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + + const out = await d.create( + 'aws.sagemaker.endpoint', + 'ep', + { model_name: 'my-model', instance_type: 'ml.t2.medium', initial_instance_count: 1, initial_variant_weight: 1 }, + {}, + ); + expect(out.success).toBe(true); + const cmds = sm.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateEndpointConfig', 'CreateEndpoint']); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 9a89c0cd..6257267e 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -30,6 +30,7 @@ import { lambda_handler } from './handlers/lambda'; import { opensearch_handler } from './handlers/opensearch'; import { rds_handler } from './handlers/rds'; import { s3_handler } from './handlers/s3'; +import { sagemaker_handler } from './handlers/sagemaker'; import { secrets_manager_handler } from './handlers/secrets-manager'; import { sns_handler } from './handlers/sns'; import { sqs_handler } from './handlers/sqs'; @@ -66,6 +67,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.ecs.service', handler: ecs_handler }, { prefix: 'aws.opensearch.domain', handler: opensearch_handler }, { prefix: 'aws.bedrock.endpoint', handler: bedrock_handler }, + { prefix: 'aws.sagemaker.endpoint', handler: sagemaker_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/sagemaker.ts b/packages/core/src/deploy/providers/aws/handlers/sagemaker.ts new file mode 100644 index 00000000..69f95782 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/sagemaker.ts @@ -0,0 +1,85 @@ +/** + * SageMaker Handler + * + * Handles: aws.sagemaker.endpoint + * + * CreateEndpointConfig → CreateEndpoint. The model itself (training + + * registration) is operator-side — the handler refuses to create an + * endpoint when `model_name` is empty. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.sagemaker.endpoint'; +const SDK = '@aws-sdk/client-sagemaker'; + +export const sagemaker_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sagemaker') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'SageMaker', SDK); + + if (!properties.model_name) { + return err( + name, + TYPE, + 'create', + start, + 'SageMaker endpoint requires properties.model_name (register the model first).', + ); + } + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return sdkMissing(name, TYPE, 'create', start, 'SageMaker', SDK); + + const configName = `${name}-config`; + await client.send( + new sm.CreateEndpointConfigCommand({ + EndpointConfigName: configName, + ProductionVariants: [ + { + VariantName: 'default', + ModelName: properties.model_name as string, + InstanceType: properties.instance_type as string, + InitialInstanceCount: properties.initial_instance_count as number, + InitialVariantWeight: properties.initial_variant_weight as number, + }, + ], + }), + ); + + const created = await client.send( + new sm.CreateEndpointCommand({ EndpointName: name, EndpointConfigName: configName }), + ); + + return ok(name, TYPE, 'create', start, { + provider_id: created?.EndpointArn || `arn:aws:sagemaker:${ctx.region}:*:endpoint/${name}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sagemaker') as any; + if (!client) return err(name, TYPE, 'delete', start, 'SageMaker SDK not available'); + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return err(name, TYPE, 'delete', start, 'SageMaker SDK not available'); + + await client.send(new sm.DeleteEndpointCommand({ EndpointName: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 3b7975d3..57dc1ce5 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -94,6 +94,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:38:25 +0200 Subject: [PATCH 41/52] =?UTF-8?q?feat(deploy/aws):=20redshift=20handler=20?= =?UTF-8?q?=E2=80=94=20CreateCluster=20+=20no-default-password=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../providers/__tests__/aws-redshift.test.ts | 46 ++++++++++++ .../src/deploy/providers/aws/aws-deployer.ts | 2 + .../deploy/providers/aws/handlers/redshift.ts | 71 +++++++++++++++++++ .../src/deploy/providers/aws/sdk-loader.ts | 3 + 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/__tests__/aws-redshift.test.ts create mode 100644 packages/core/src/deploy/providers/aws/handlers/redshift.ts diff --git a/package.json b/package.json index c6dd668f..6b92ec35 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.761", + "version": "0.1.762", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/__tests__/aws-redshift.test.ts b/packages/core/src/deploy/providers/__tests__/aws-redshift.test.ts new file mode 100644 index 00000000..3bd055fb --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-redshift.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const rs = makeSdkMock({ + client_class_name: 'RedshiftClient', + command_class_names: ['CreateClusterCommand', 'DeleteClusterCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-redshift': rs.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, rs }; +} + +describe('aws.redshift.cluster handler', () => { + it('refuses to create when master_user_password is empty', async () => { + const { d } = await setup(); + const out = await d.create('aws.redshift.cluster', 'dw', { master_user_password: '' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/master_user_password is empty/); + }); + + it('creates the cluster on the happy path', async () => { + const { d, rs } = await setup(); + const out = await d.create( + 'aws.redshift.cluster', + 'dw', + { + node_type: 'dc2.large', + cluster_type: 'single-node', + db_name: 'analytics', + master_username: 'admin', + master_user_password: 'secret', + port: 5439, + }, + {}, + ); + expect(out.success).toBe(true); + expect(rs.sendCalls[0].__cmd).toBe('CreateCluster'); + expect(rs.sendCalls[0].input.ClusterIdentifier).toBe('dw'); + }); +}); diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts index 6257267e..24df0252 100644 --- a/packages/core/src/deploy/providers/aws/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -29,6 +29,7 @@ import { events_rule_handler } from './handlers/events-rule'; import { lambda_handler } from './handlers/lambda'; import { opensearch_handler } from './handlers/opensearch'; import { rds_handler } from './handlers/rds'; +import { redshift_handler } from './handlers/redshift'; import { s3_handler } from './handlers/s3'; import { sagemaker_handler } from './handlers/sagemaker'; import { secrets_manager_handler } from './handlers/secrets-manager'; @@ -68,6 +69,7 @@ const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = { prefix: 'aws.opensearch.domain', handler: opensearch_handler }, { prefix: 'aws.bedrock.endpoint', handler: bedrock_handler }, { prefix: 'aws.sagemaker.endpoint', handler: sagemaker_handler }, + { prefix: 'aws.redshift.cluster', handler: redshift_handler }, ]; function resolve_handler(type: string): AWSResourceHandler | undefined { diff --git a/packages/core/src/deploy/providers/aws/handlers/redshift.ts b/packages/core/src/deploy/providers/aws/handlers/redshift.ts new file mode 100644 index 00000000..dfe94216 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/redshift.ts @@ -0,0 +1,71 @@ +/** + * Redshift Handler + * + * Handles: aws.redshift.cluster + * + * Standard CreateCluster with the password-required invariant + * shared by RDS + DocDB. Multi-node vs single-node is set by + * cluster_type + number_of_nodes from the extractor. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.redshift.cluster'; +const SDK = '@aws-sdk/client-redshift'; + +export const redshift_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('redshift') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'Redshift', SDK); + + if (!properties.master_user_password) { + return err(name, TYPE, 'create', start, 'Redshift create refused: master_user_password is empty.'); + } + + try { + const rs = await load_aws_sdk(SDK); + if (!rs) return sdkMissing(name, TYPE, 'create', start, 'Redshift', SDK); + + await client.send( + new rs.CreateClusterCommand({ + ClusterIdentifier: name, + NodeType: properties.node_type as string, + ClusterType: properties.cluster_type as string, + NumberOfNodes: properties.number_of_nodes as number, + DBName: properties.db_name as string, + MasterUsername: properties.master_username as string, + MasterUserPassword: properties.master_user_password as string, + PubliclyAccessible: !!properties.publicly_accessible, + Encrypted: properties.encrypted !== false, + Port: properties.port as number, + }), + ); + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:redshift:${ctx.region}:*:cluster:${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('redshift') as any; + if (!client) return err(name, TYPE, 'delete', start, 'Redshift SDK not available'); + + try { + const rs = await load_aws_sdk(SDK); + if (!rs) return err(name, TYPE, 'delete', start, 'Redshift SDK not available'); + + await client.send(new rs.DeleteClusterCommand({ ClusterIdentifier: name, SkipFinalClusterSnapshot: true })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts index 57dc1ce5..70fc0282 100644 --- a/packages/core/src/deploy/providers/aws/sdk-loader.ts +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -97,6 +97,9 @@ export async function initialize_aws_clients(region: string): Promise Date: Sun, 24 May 2026 14:40:27 +0200 Subject: [PATCH 42/52] feat(deploy/aws): lambda auto-build from Source.Repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (commit #28). When a Compute.ServerlessFunction block has a connected Source.Repository AND no explicit S3 ref, the handler auto-builds the zip and uploads it before CreateFunction: 1. git clone --depth 1 --branch 2. npm install --omit=dev (skipped if no package.json) 3. zip -qr function.zip . 4. PutObject to ice-bootstrap-{accountId}-{region}/lambda/{name}/{ts}.zip (HeadBucket → CreateBucket if absent) 5. Stamp s3_bucket + s3_key onto properties and continue. Local-only — assumes git/npm/zip on the deploy host. AWS CodeBuild integration deferred to a future commit. Existing manual S3-ref + zip paths are unchanged; the auto-build branch only fires when `properties.repository` is set AND no explicit code source exists. --- package.json | 2 +- .../providers/aws/handlers/lambda-builder.ts | 118 ++++++++++++++++++ .../deploy/providers/aws/handlers/lambda.ts | 33 ++++- 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/deploy/providers/aws/handlers/lambda-builder.ts diff --git a/package.json b/package.json index 6b92ec35..b29987ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.762", + "version": "0.1.763", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/aws/handlers/lambda-builder.ts b/packages/core/src/deploy/providers/aws/handlers/lambda-builder.ts new file mode 100644 index 00000000..d54c86f4 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/lambda-builder.ts @@ -0,0 +1,118 @@ +/** + * Lambda code-builder — auto-build path for Compute.ServerlessFunction + * blocks that have a connected Source.Repository on the canvas. + * + * Flow: + * 1. git clone --depth 1 --branch + * 2. npm install --omit=dev --silent (skip if no package.json) + * 3. zip -r function.zip . + * 4. PutObject to `ice-bootstrap-{accountId}-{region}` (CreateBucket + * first if absent). + * 5. Return { s3Bucket, s3Key } so the Lambda handler can pass them + * straight to CreateFunction. + * + * Local-only — assumes `git`, `npm`, and `zip` are available on the + * deploy host. Failures bubble up so the Lambda handler can fall + * through to a clear error. AWS CodeBuild integration is deferred + * to a future commit. + */ + +import { execSync } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { load_aws_sdk } from '../sdk-loader'; +import type { AWSHandlerContext } from '../types'; + +export interface BuildArgs { + /** Lambda function name — used as the S3 key prefix. */ + function_name: string; + /** Repository URL — passed straight to git clone (HTTPS or git@…). */ + repository: string; + /** Git branch / ref to check out. Defaults to 'main'. */ + branch: string; + ctx: AWSHandlerContext; +} + +export interface BuildResult { + s3Bucket: string; + s3Key: string; +} + +const BOOTSTRAP_BUCKET_PREFIX = 'ice-bootstrap'; + +function shell(command: string, cwd?: string): void { + execSync(command, { cwd, stdio: ['ignore', 'ignore', 'pipe'] }); +} + +async function ensure_bootstrap_bucket(bucket: string, region: string, ctx: AWSHandlerContext): Promise { + const client = ctx.clients.get('s3') as any; + if (!client) throw new Error('S3 SDK not available — required for Lambda auto-build'); + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!s3) throw new Error('S3 SDK not available — required for Lambda auto-build'); + + try { + await client.send(new s3.HeadBucketCommand({ Bucket: bucket })); + return; + } catch { + // Doesn't exist — create it. + } + await client.send( + new s3.CreateBucketCommand({ + Bucket: bucket, + CreateBucketConfiguration: region !== 'us-east-1' ? { LocationConstraint: region } : undefined, + }), + ); +} + +async function upload_zip(bucket: string, key: string, body: Buffer, ctx: AWSHandlerContext): Promise { + const client = ctx.clients.get('s3') as any; + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!client || !s3) throw new Error('S3 SDK not available'); + await client.send(new s3.PutObjectCommand({ Bucket: bucket, Key: key, Body: body })); +} + +/** + * Run the full auto-build flow. Returns the S3 ref the Lambda handler + * should pass to CreateFunction. Throws on any sub-step failure with + * a message that names the step (clone / install / zip / upload). + */ +export async function build_and_upload_lambda(args: BuildArgs): Promise { + const accountId = await args.ctx.ensure_account_id(); + const bucket = `${BOOTSTRAP_BUCKET_PREFIX}-${accountId}-${args.ctx.region}`; + const tmpdir_path = mkdtempSync(join(tmpdir(), 'ice-lambda-build-')); + const zipPath = join(tmpdir_path, 'function.zip'); + + try { + args.ctx.on_log?.(`Cloning ${args.repository}@${args.branch}`); + shell(`git clone --depth 1 --branch ${args.branch} ${args.repository} ${tmpdir_path}/src`); + + // Best-effort npm install — skip if no package.json present. + try { + readFileSync(join(tmpdir_path, 'src', 'package.json')); + args.ctx.on_log?.('Running npm install --omit=dev'); + shell('npm install --omit=dev --silent', join(tmpdir_path, 'src')); + } catch { + args.ctx.on_log?.('No package.json — skipping npm install'); + } + + args.ctx.on_log?.('Zipping build output'); + shell(`zip -qr ${zipPath} .`, join(tmpdir_path, 'src')); + + args.ctx.on_log?.(`Ensuring bootstrap bucket ${bucket}`); + await ensure_bootstrap_bucket(bucket, args.ctx.region, args.ctx); + + const key = `lambda/${args.function_name}/${Date.now()}.zip`; + args.ctx.on_log?.(`Uploading to s3://${bucket}/${key}`); + const body = readFileSync(zipPath); + await upload_zip(bucket, key, body, args.ctx); + + return { s3Bucket: bucket, s3Key: key }; + } finally { + try { + rmSync(tmpdir_path, { recursive: true, force: true }); + } catch { + /* best-effort cleanup */ + } + } +} diff --git a/packages/core/src/deploy/providers/aws/handlers/lambda.ts b/packages/core/src/deploy/providers/aws/handlers/lambda.ts index e1e3089d..a306fa56 100644 --- a/packages/core/src/deploy/providers/aws/handlers/lambda.ts +++ b/packages/core/src/deploy/providers/aws/handlers/lambda.ts @@ -9,6 +9,7 @@ * Source.Repository is wired in commit #28 (Phase 3). */ +import { build_and_upload_lambda } from './lambda-builder'; import { load_aws_sdk } from '../sdk-loader'; import type { ResourceDeployResult } from '../../../types'; import type { AWSResourceHandler } from '../types'; @@ -66,14 +67,42 @@ export const lambda_handler: AWSResourceHandler = { 'Lambda function requires an IAM execution role ARN (properties.role). Wire one in or use the auto-role helper.', ); } + + // Auto-build path — when no explicit code source is set but the + // extractor passed through a `repository` (set by the canvas's + // wire_source_repositories pass when Source.Repository is wired + // to this block), clone + zip + upload to the bootstrap bucket + // and stamp the resulting S3 ref onto `properties` so the rest + // of the handler proceeds as if the operator had supplied it. const hasS3Ref = !!(properties.s3_bucket && properties.s3_key); const hasZipFile = !!properties.zip_file; - if (!hasS3Ref && !hasZipFile) { + const hasRepo = !!properties.repository; + if (!hasS3Ref && !hasZipFile && hasRepo) { + try { + const built = await build_and_upload_lambda({ + function_name: name, + repository: properties.repository as string, + branch: (properties.branch as string) || 'main', + ctx, + }); + properties.s3_bucket = built.s3Bucket; + properties.s3_key = built.s3Key; + } catch (error) { + return fail( + name, + 'create', + start, + `Lambda auto-build failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + if (!properties.s3_bucket && !hasZipFile) { return fail( name, 'create', start, - 'Lambda function code source is missing. Provide properties.code.{s3Bucket,s3Key} or zip_file (auto-build from Source.Repository lands in a later commit).', + 'Lambda function code source is missing. Provide properties.code.{s3Bucket,s3Key}, zip_file, or wire a Source.Repository to enable auto-build.', ); } From aeca3688c41a05cac67797ec2706274f00b70617 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 14:41:41 +0200 Subject: [PATCH 43/52] test(deploy/aws): unskip AWS Type Map block + end-to-end coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 commit #29. With every aws.* resource type registered in PROPERTY_EXTRACTORS (commits #2–#6), the AWS Type Map test block can finally turn on. Expanded the iceType matrix from 5 to 19 entries covering every AWS-mapped block. New end-to-end test wires Compute.StaticSite + Security.Secret (with two bindings — exercising the schema-declared deploy-expansion pass) + Database.PostgreSQL into a single translator call, asserts the resulting graph has 4 deployables resolving to s3.bucket / secretsmanager.secret×2 / rds.dbInstance. The Azure block remains skipped (deferred to a future Azure handler buildout). --- package.json | 2 +- .../src/__tests__/card-translator.test.ts | 65 +++++++++++++++++-- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b29987ef..30ffcf1a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.763", + "version": "0.1.764", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/__tests__/card-translator.test.ts b/packages/core/src/__tests__/card-translator.test.ts index c326a100..a14fb163 100644 --- a/packages/core/src/__tests__/card-translator.test.ts +++ b/packages/core/src/__tests__/card-translator.test.ts @@ -128,17 +128,33 @@ describe('Card Translator Type Maps', () => { }); }); - describe.skip('AWS Type Map', () => { - // AWS deploy path is not yet wired up — PROPERTY_EXTRACTORS only covers - // GCP resource types today. Unskip when AWS extractors land. + describe('AWS Type Map', () => { + // Phase 1 extractors landed across commits #2–#6 (compute, + // database, network, ancillary, ai). Every aws.* resource type in + // AWS_TYPE_MAP now has a registered PROPERTY_EXTRACTORS entry, so + // these used-to-be-skipped tests turn green. it('should map AWS iceTypes (ENGINE-1)', async () => { const mod = await import('../deploy/card-translator'); const awsTypes = [ 'Compute.Container', 'Compute.ServerlessFunction', + 'Compute.CronJob', + 'Compute.SSRSite', 'Database.PostgreSQL', + 'Database.DynamoDB', + 'Database.Redis', 'Storage.Bucket', 'Messaging.Queue', + 'Messaging.Topic', + 'Network.Gateway', + 'Network.PublicEndpoint', + 'Network.LoadBalancer', + 'Security.Identity', + 'Monitoring.Log', + 'AI.VectorDB', + 'AI.LLMGateway', + 'AI.ModelServing', + 'Analytics.DataWarehouse', ]; for (const iceType of awsTypes) { @@ -148,7 +164,7 @@ describe('Card Translator Type Maps', () => { provider: 'aws', projectName: 'test', }); - expect(result.deployable_count).toBeGreaterThan(0); + expect(result.deployable_count, `${iceType} should produce a deployable`).toBeGreaterThan(0); } }); @@ -162,6 +178,47 @@ describe('Card Translator Type Maps', () => { }); expect(result.deployable_count).toBe(1); }); + + it('Compute.StaticSite + Security.Secret + Database.PostgreSQL deploys to AWS', async () => { + // End-to-end multi-block scenario that covers extractor + deploy- + // expansion + provider type resolution all in one go. + const mod = await import('../deploy/card-translator'); + const result = mod.translate_card_to_graph({ + nodes: [ + { + id: 'site', + type: 'resource', + data: { iceType: 'Compute.StaticSite', label: 'web' }, + }, + { + id: 'sec', + type: 'resource', + data: { + iceType: 'Security.Secret', + label: 'app-secrets', + secrets: [{ key: 'STRIPE_API_KEY', ref: 'prod-stripe' }, { key: 'JWT_SECRET' }], + }, + }, + { + id: 'db', + type: 'resource', + data: { iceType: 'Database.PostgreSQL', label: 'main', master_user_password: 'set-later' }, + }, + ], + edges: [], + provider: 'aws', + projectName: 'demo', + }); + // StaticSite (1) + Secret expansion (2 unique refs) + Postgres (1) = 4 deployables + expect(result.deployable_count).toBe(4); + const types = result.deployables.map((d) => d.resource_type).sort(); + expect(types).toEqual([ + 'aws.rds.dbInstance', + 'aws.s3.bucket', + 'aws.secretsmanager.secret', + 'aws.secretsmanager.secret', + ]); + }); }); describe.skip('Azure Type Map', () => { From ac4281dcaac4bc0e3c689b974f05649bacfba5e3 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Sun, 24 May 2026 14:43:19 +0200 Subject: [PATCH 44/52] =?UTF-8?q?docs(deploy/aws):=20provider=20notes=20?= =?UTF-8?q?=E2=80=94=20quirks,=20assumptions,=20deferred=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 commit #30 — final commit of the AWS buildout. providers/aws/README.md documents the AWS-specific decisions the 30 commits in this series bake in: - architecture (mirrors gcp/, schema-driven HANDLER_REGISTRY) - S3 account-id suffix - CloudFront us-east-1 cert - ECS auto-cluster + task role - RDS / DocDB / Redshift no-default-password invariant - RDS provisioning poll - Lambda auto-build flow (git + npm + zip + bootstrap S3) - Bedrock on-demand no-op - Secrets Manager values-never-written contract - SQS / SNS .fifo suffix - SDK packages as optional peer deps - test harness layout - deferred work (VPC blocks, CodeBuild, drift detection, LocalStack) Read this before changing any AWS handler. --- package.json | 2 +- .../core/src/deploy/providers/aws/README.md | 133 ++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/deploy/providers/aws/README.md diff --git a/package.json b/package.json index 30ffcf1a..1214d6c6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.764", + "version": "0.1.765", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/providers/aws/README.md b/packages/core/src/deploy/providers/aws/README.md new file mode 100644 index 00000000..985a98e4 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/README.md @@ -0,0 +1,133 @@ +# AWS Deployer — Operator Notes + +This file documents the AWS-specific quirks the deployer handles +silently, the assumptions it bakes in, and the deferred work future +commits should pick up. Read this before changing any handler or +adding a new AWS resource type. + +## Architecture + +Mirrors the GCP layout (`../gcp/`): + +- `aws-deployer.ts` — thin dispatcher. Iterates `HANDLER_REGISTRY` + generically; cardinal rule preserved (no hardcoded iceType branches). +- `types.ts` — `AWSHandlerContext` + `AWSResourceHandler`. +- `sdk-loader.ts` — lazy `@aws-sdk/client-*` loading with graceful + fallthrough when a package isn't installed. +- `account.ts` — memoised STS GetCallerIdentity (used by S3 + future + handlers that need the AWS account id). +- `iam-roles.ts` — `ensureManagedRole` helper for idempotent IAM + bootstrap; `ensureEcsTaskExecutionRole` is the only consumer today. +- `handlers/.ts` — one file per AWS service. Register an + entry in `HANDLER_REGISTRY` to wire a new handler in. + +## Quirks shipped today + +### S3 bucket names get an account-id suffix + +S3 bucket names are globally unique across all AWS accounts. The +handler reads the account id from STS and appends `-{accountId}` to +the translator's resource name (`ice-myapp-bucket` → +`ice-myapp-bucket-111122223333`). The provider_id ARN carries the +post-suffix name so update + delete round-trip cleanly. + +### CloudFront ACM certs must live in us-east-1 + +CloudFront refuses ACM certs from any region but `us-east-1`. The +CloudFront handler spins up a one-shot ACM client pinned to +`us-east-1` for `RequestCertificate`, regardless of the deploy +region. Operator validates the cert (DNS records) outside ICE. + +### ECS auto-provisions a default cluster + task role + +`Compute.Container` on AWS works without operators touching ECS +infrastructure. On first deploy the handler: + +1. Calls `ensureEcsTaskExecutionRole(region)` — creates + `ecsTaskExecutionRole` with `AmazonECSTaskExecutionRolePolicy` + attached, idempotent (GetRole-first, CreateRole-on-NoSuchEntity). +2. Calls `ensureDefaultCluster(client, ecs, ctx)` — creates + `ice-default-cluster` if no `ACTIVE` cluster with that name exists. + +Then `RegisterTaskDefinition` + `CreateService` run against the +default cluster. Subnets + security groups still come from +properties (operator-supplied today; canvas VPC blocks for AWS land +in a follow-up). + +### RDS / DocDB / Redshift refuse to ship without a password + +The extractor for each of these defaults `master_user_password` to +`''` and the handler refuses to call CreateDB\* when the field is +empty. Operators wire a `Security.Secret` block or set the property +explicitly. This is intentional: AWS APIs accept an empty password +and create an unusable instance with no warning. + +### RDS provisioning is polled + +RDS instances take 5–10 minutes to provision. The RDS handler runs a +20-minute `DescribeDBInstances` poll loop after `CreateDBInstance`, +reports progress via `ctx.on_step`, and honours `ctx.abort_signal` +so a user-cancel actually stops the wait. + +### Lambda auto-build from Source.Repository + +When a `Compute.ServerlessFunction` has a connected `Source.Repository` +AND no explicit S3 ref: + +1. `git clone --depth 1 --branch ` the repo to a tmpdir. +2. `npm install --omit=dev` (skipped if no `package.json`). +3. `zip -qr function.zip .`. +4. Upload to `ice-bootstrap-{accountId}-{region}` (CreateBucket if + absent). +5. Stamp `s3_bucket` + `s3_key` onto `properties` and continue with + the normal CreateFunction path. + +Local-only — assumes `git` + `npm` + `zip` are on the deploy host. +AWS CodeBuild integration is deferred; the failure message is +explicit so operators know to install the local tools. + +### Bedrock on-demand is a no-op resource + +`AI.LLMGateway` defaults to Bedrock on-demand (no provisioned +throughput). The handler short-circuits create with a synthetic ARN +and no SDK call. Operators set `model_units > 0` to actually +provision throughput. + +### Secrets Manager values are never written + +Parallel to the GCP Secret Manager contract: ICE creates the +`Secret` resource only. Values are populated by operators via the +AWS console / CLI / IaC. The schema-declared deploy-expansion pass +emits one Secret per binding row. + +### SQS + SNS FIFO `.fifo` suffix + +AWS requires FIFO queues + topics to end in `.fifo`. The handlers +append it when the extractor sets `fifo: true`. + +## SDK packages + +All `@aws-sdk/client-*` packages are loaded via `load_aws_sdk` +through the `Function('m', 'return import(m)')` indirection so a +missing package fails gracefully with a friendly "install …" +message instead of a bundler error. Mark every SDK package as an +optional peer dependency so installs stay small for users on other +providers. + +## Test harness + +`__tests__/_aws-test-harness.ts` exports a Function-constructor +stub + a generic `makeSdkMock` factory. Per-handler tests +(`aws-.test.ts`) use them to mock SDK clients without +adding to the giant `aws-deployer.test.ts` file. New handlers +follow the same shape. + +## Future work + +- VPC-aware canvas for AWS (Network.VPC / Network.Subnet blocks + drive ECS service `subnets` + `securityGroups`). +- AWS CodeBuild path for Lambda auto-build (no local `git`/`npm`/ + `zip` requirement). +- Update paths for CloudFront / Cognito / DocDB / Redshift / EC2 EBS + (current handlers are create-only / no-op on update). +- LocalStack integration tests for end-to-end SDK contract checks. From ad69a218281778214938e02d7fc1243afa8e802c Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Mon, 25 May 2026 12:08:56 +0200 Subject: [PATCH 45/52] feat(aws): selectively enable safe categories via feature flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip PROVIDER_FLAGS.aws.enabled to true with a hand-picked category map (Storage, Messaging, Cache, Monitoring, Security, Source, Config). Compute / Frontend / Scheduler / Network / Database / AI / Analytics stay gated until their concrete unblockers land — ECS VPC blocks, CloudFront cert-validation flow, update-paths, etc. README.md gets a Rollout state table documenting why each gated category is held back and what unblocks it. Integrity test in packages/constants asserts the per-category map stays exhaustive, so future CategoryId additions force a deliberate on/off decision here. --- package.json | 2 +- packages/constants/src/feature-flags.ts | 19 ++++++++++-- .../core/src/deploy/providers/aws/README.md | 30 +++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 1214d6c6..e6b224af 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.765", + "version": "0.1.766", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/constants/src/feature-flags.ts b/packages/constants/src/feature-flags.ts index c42ee09c..b59ade58 100644 --- a/packages/constants/src/feature-flags.ts +++ b/packages/constants/src/feature-flags.ts @@ -31,10 +31,25 @@ function allCategoriesOn(): Record { return Object.fromEntries(CATEGORY_IDS.map((c) => [c, true])) as Record; } +function categoriesFromOnList(on: CategoryId[]): Record { + const set = new Set(on); + return Object.fromEntries(CATEGORY_IDS.map((c) => [c, set.has(c)])) as Record; +} + export const PROVIDER_FLAGS: Record = { + // AWS staged rollout. Categories left off have a concrete unblocker; + // see `packages/core/src/deploy/providers/aws/README.md` → Rollout state. + // off — Compute: ECS needs canvas-driven VPC/subnet/SG blocks (deferred). + // off — Frontend: StaticSite+CloudFront combo needs us-east-1 cert validation flow. + // off — Scheduler: EventBridge schedule expression wiring not finished. + // off — Network: ELBv2 needs VPC blocks; CloudFront is create-only. + // off — Database: RDS works but slow + no update path; DynamoDB-only deploys are fine + // once a sub-category gate exists, today the whole bucket stays off. + // off — AI: Bedrock is a no-op; SageMaker only has mocked-SDK coverage. + // off — Analytics: Redshift + OpenSearch are create-only. aws: { - enabled: false, - categories: allCategoriesOff(), + enabled: true, + categories: categoriesFromOnList(['Storage', 'Messaging', 'Cache', 'Monitoring', 'Security', 'Source', 'Config']), }, gcp: { enabled: true, diff --git a/packages/core/src/deploy/providers/aws/README.md b/packages/core/src/deploy/providers/aws/README.md index 985a98e4..4c872834 100644 --- a/packages/core/src/deploy/providers/aws/README.md +++ b/packages/core/src/deploy/providers/aws/README.md @@ -5,6 +5,36 @@ silently, the assumptions it bakes in, and the deferred work future commits should pick up. Read this before changing any handler or adding a new AWS resource type. +## Rollout state + +AWS is feature-flagged at the category level in +`packages/constants/src/feature-flags.ts` (`PROVIDER_FLAGS.aws`). The +top-level `enabled` flag is **on**; categories are flipped selectively +based on the deploy path's actual readiness. + +| Category | State | Notes | +| ---------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Storage | ✅ on | S3 handler + account-id suffix | +| Messaging | ✅ on | SQS, SNS, EventBridge — FIFO suffix handled | +| Cache | ✅ on | ElastiCache | +| Monitoring | ✅ on | CloudWatch Logs | +| Security | ✅ on | Secrets Manager (Cognito stays create-only) | +| Source | ✅ on | Provider-agnostic | +| Config | ✅ on | Provider-agnostic | +| Compute | ⛔ off | ECS needs canvas-driven `Network.VPC` / `Network.Subnet` / `Network.SecurityGroup` blocks before it's safe to expose. Lambda alone is solid but the category gate is all-or-nothing today. | +| Frontend | ⛔ off | `Compute.StaticSite` requires the S3 + CloudFront + us-east-1 ACM cert dance and operator-side DNS validation — not yet automated. | +| Scheduler | ⛔ off | `Compute.CronJob` → EventBridge schedule expression wiring not finished. | +| Network | ⛔ off | ELBv2 needs VPC blocks; CloudFront is create-only and depends on the cert-validation flow. | +| Database | ⛔ off | DynamoDB-only deploys would be fine; RDS / DocDB / Redshift work for first-deploy but have no update path and RDS takes 5–10 min. Unblock by either a sub-category gate or by shipping update handlers. | +| AI | ⛔ off | Bedrock on-demand is a no-op resource (low value); SageMaker has only mocked-SDK coverage. | +| Analytics | ⛔ off | Redshift + OpenSearch are create-only. | + +Flip an `off` entry to `on` in `PROVIDER_FLAGS.aws.categories` once +its unblocker lands. The integrity test in +`packages/constants/src/__tests__/index.test.ts` keeps the map +exhaustive — adding a new `CategoryId` will require a deliberate +on/off decision here. + ## Architecture Mirrors the GCP layout (`../gcp/`): From b32c53a6c03a73dfdebcaf8f81cc9f948f5f1d96 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Mon, 25 May 2026 12:19:40 +0200 Subject: [PATCH 46/52] fix(palette): enable provider dropdown items when any block is available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the project-provider lock on the palette provider dropdown with an availability check: a provider option is selectable iff at least one concept has it in providers and its category is enabled for that provider. Before: in a GCP project, AWS was greyed in the palette dropdown even after AWS feature-flag enabled — so users couldn't browse the AWS catalog from a GCP project. After: AWS opens as long as it has any available block under the current PROVIDER_FLAGS — drag-into-project compatibility remains enforced at the canvas-drop layer. availableProviderIds is derived in resource-palette.tsx from the unfiltered component list using isCategoryEnabledForProvider — same schema-driven gate the component filter already uses. --- package.json | 2 +- .../palette/__tests__/blocks-section.test.tsx | 12 ++++++------ .../palette/components/resource-palette.tsx | 19 ++++++++++++++++++- .../palette/sections/blocks-section.tsx | 12 +++++++++--- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index e6b224af..d2c0a2e5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.766", + "version": "0.1.767", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/ui/src/features/palette/__tests__/blocks-section.test.tsx b/packages/ui/src/features/palette/__tests__/blocks-section.test.tsx index 2eaa2a7d..525ae0ac 100644 --- a/packages/ui/src/features/palette/__tests__/blocks-section.test.tsx +++ b/packages/ui/src/features/palette/__tests__/blocks-section.test.tsx @@ -220,7 +220,7 @@ function makeProps(overrides: Partial[0]> = {}) setLocalSearch: vi.fn(), selectedProvider: 'all', setSelectedProvider: vi.fn(), - projectProvider: null, + availableProviderIds: new Set(['aws', 'gcp', 'azure']), searchInputRef: { current: null }, filteredComponents: [comp], categorizedItems: [{ category: cat, items: [comp] }], @@ -334,8 +334,8 @@ describe('BlocksSection — provider dropdown', () => { expect(items).toHaveLength(3); }); - it('disables non-matching items when projectProvider is set', () => { - const tree = renderSection(makeProps({ projectProvider: 'gcp', selectedProvider: 'gcp' })); + it('disables providers that are not in availableProviderIds (no blocks for them)', () => { + const tree = renderSection(makeProps({ availableProviderIds: new Set(['gcp', 'azure']) })); const items = findByPredicate(tree, (el) => { const props = el.props as Record; return el.type === 'div' && props['data-radix'] === 'Item'; @@ -343,14 +343,14 @@ describe('BlocksSection — provider dropdown', () => { const allItem = items.find((i) => (i.props as { value?: string }).value === 'all'); const awsItem = items.find((i) => (i.props as { value?: string }).value === 'aws'); const gcpItem = items.find((i) => (i.props as { value?: string }).value === 'gcp'); - // 'all' is never locked; aws is locked (project is gcp); gcp is selected, not locked. + // 'all' is never locked; aws has no blocks → locked; gcp has blocks → enabled. expect((allItem?.props as { disabled?: boolean }).disabled).toBe(false); expect((awsItem?.props as { disabled?: boolean }).disabled).toBe(true); expect((gcpItem?.props as { disabled?: boolean }).disabled).toBe(false); }); - it('does not lock any item when projectProvider is null', () => { - const tree = renderSection(makeProps({ projectProvider: null })); + it('does not lock any item when every provider has at least one available block', () => { + const tree = renderSection(makeProps({ availableProviderIds: new Set(['aws', 'gcp', 'azure']) })); const items = findByPredicate(tree, (el) => { const props = el.props as Record; return el.type === 'div' && props['data-radix'] === 'Item'; diff --git a/packages/ui/src/features/palette/components/resource-palette.tsx b/packages/ui/src/features/palette/components/resource-palette.tsx index 65221de5..b24fefeb 100644 --- a/packages/ui/src/features/palette/components/resource-palette.tsx +++ b/packages/ui/src/features/palette/components/resource-palette.tsx @@ -125,6 +125,23 @@ export const ResourcePalette: React.FC = ({ [components, localSearch, selectedProvider], ); + // Providers with at least one concept whose (category × provider) gate + // is open. The palette dropdown enables a provider option iff its id is + // in this set — so AWS shows up the moment any of its categories has a + // block, even if the active project's provider is something else. + const availableProviderIds = useMemo(() => { + const set = new Set(); + for (const c of components) { + for (const p of c.providers) { + if (set.has(p)) continue; + if (ENABLED_PROVIDER_IDS.has(p) && isCategoryEnabledForProvider(c.category as CategoryId, p as Provider)) { + set.add(p); + } + } + } + return set; + }, [components]); + // Group filtered items by category, preserving order const categorizedItems = useMemo(() => { const groups: { category: CategoryDef; items: ComponentDef[] }[] = []; @@ -182,7 +199,7 @@ export const ResourcePalette: React.FC = ({ setLocalSearch={setLocalSearch} selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} - projectProvider={projectProvider} + availableProviderIds={availableProviderIds} searchInputRef={searchInputRef} filteredComponents={filteredComponents} categorizedItems={categorizedItems} diff --git a/packages/ui/src/features/palette/sections/blocks-section.tsx b/packages/ui/src/features/palette/sections/blocks-section.tsx index 704ac4fd..4447ccb6 100644 --- a/packages/ui/src/features/palette/sections/blocks-section.tsx +++ b/packages/ui/src/features/palette/sections/blocks-section.tsx @@ -29,7 +29,13 @@ interface BlocksSectionProps { setLocalSearch: (v: string) => void; selectedProvider: string; setSelectedProvider: (v: string) => void; - projectProvider: string | null; + /** + * Providers with at least one concept whose category is enabled for + * that provider. The dropdown disables any provider option not in + * this set — so a provider with zero blocks shows up greyed instead + * of being silently selectable into an empty list. + */ + availableProviderIds: ReadonlySet; searchInputRef: React.RefObject; filteredComponents: ComponentDef[]; categorizedItems: { category: CategoryDef; items: ComponentDef[] }[]; @@ -46,7 +52,7 @@ export const BlocksSection: React.FC = ({ setLocalSearch, selectedProvider, setSelectedProvider, - projectProvider, + availableProviderIds, searchInputRef, filteredComponents, categorizedItems, @@ -114,7 +120,7 @@ export const BlocksSection: React.FC = ({ > {providers.map((provider) => { - const isLocked = !!projectProvider && provider.id !== 'all' && provider.id !== projectProvider; + const isLocked = provider.id !== 'all' && !availableProviderIds.has(provider.id); const brand = provider.id !== 'all' ? getBrandIcon(provider.id) : null; return ( Date: Mon, 25 May 2026 12:28:03 +0200 Subject: [PATCH 47/52] docs(architecture): explain how canvas edges become cloud infra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New page docs/architecture/connections-to-cloud.md walks the five-layer pipeline (connection-rules → propagation → type-maps → extractors → handlers) and grounds it with two worked GCP examples: - Storage.Bucket → Compute.BackendAPI: env-var injection + IAM binding, no edge resource in GCP. - Compute.CronJob → Compute.BackendAPI: Cloud Scheduler HTTP target + run.invoker IAM binding. Links the new page from architecture/README.md and the existing core-engine.md "Computing flows" section so readers landing on either find their way to the deep dive. --- docs/architecture/README.md | 1 + docs/architecture/connections-to-cloud.md | 250 ++++++++++++++++++++++ docs/architecture/core-engine.md | 2 + package.json | 2 +- 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/connections-to-cloud.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4a831934..ccc6ac92 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -184,4 +184,5 @@ See [`../../SECURITY.md`](../../SECURITY.md) for the disclosure process. ## See also - [core-engine.md](core-engine.md), [frontend.md](frontend.md), [services.md](services.md), [database.md](database.md), [desktop.md](desktop.md). +- [connections-to-cloud.md](connections-to-cloud.md) — how a canvas edge collapses into env vars, IAM, and network policy at deploy time. - [`packages/core/src/`](../../packages/core/src/) — the canonical implementation of everything on this page. diff --git a/docs/architecture/connections-to-cloud.md b/docs/architecture/connections-to-cloud.md new file mode 100644 index 00000000..70b260b6 --- /dev/null +++ b/docs/architecture/connections-to-cloud.md @@ -0,0 +1,250 @@ +# Connections → cloud infra + +How a line drawn between two blocks on the canvas becomes real cloud +resources, IAM bindings, and network policy. Worked examples on GCP at +the end. + +## The mental model + +A canvas edge is **not** a cloud resource. The cloud sees only resources +plus IAM plus network policy — there is no "edge" object in any cloud +SDK. So every line you draw must collapse into some mix of three things +on the endpoint nodes: + +1. **Property propagation** — env vars, URLs, connection strings, etc. + written onto a block's properties because of the connection. +2. **IAM binding** — the source node's identity gets a role on the + target's resource (and vice versa). +3. **Network policy** — firewall / ingress allow-list entries, VPC + peering, custom domain routes. + +Which of the three an edge produces is decided by its +`connectionCategory` (`traffic` | `config` | `source` | …) plus the +roles of the two endpoint blocks (`backend`, `database`, `storage`, +`secrets`, `repo`, …). Roles come from a single table in +`@ice/constants/block-classifiers.ts` so the connection-rules predicates +and the propagation predicates can never drift apart on what "is a +database" means. + +## The pipeline + +``` +canvas edge (drawn in the UI) + ↓ +connection rules packages/types/connection-rules + Shape check: is this combo legal at all? + ↓ +propagation rules packages/core/src/compute/propagation-rules.ts + Mutate node data based on the edge. + ↓ +type-maps packages/core/src/deploy/type-maps.ts + iceType → provider resource type (per cloud). + ↓ +extractors packages/core/src/deploy/extractors/* + Block properties → provider resource properties. + ↓ +handlers packages/core/src/deploy/providers//handlers/* + Resource properties → cloud SDK call. +``` + +Each layer is schema-driven; per-provider behaviour lives in +per-provider files only. The cardinal rule: no hardcoded `iceType ===` +branches in cross-cutting code. See +[refactoring-patterns.md](../refactoring-patterns.md) for the rationale. + +### Layer 1 — connection rules + +`packages/types/connection-rules` holds the **legality** table: which +(source iceType, target iceType) pairs are even valid edges. It runs in +the UI as you drag a connection — incompatible combos are rejected +before the edge can land. Predicates here use `hasBlockRole` so +`isDatabase(t)` works the same way on both sides of the layer split. + +### Layer 2 — propagation rules + +`packages/core/src/compute/propagation-rules.ts` is a declarative array. +Each `PropagationRule` says: "when source role × target role match +this pattern AND this edge has these data fields, write these derived +properties onto the receiving node." + +There are two flavours: + +- **Per-edge rules** (`PROPAGATION_RULES`) — fire on a single edge, + compute a property patch for the source or target depending on + `direction`. E.g. `Backend → DataStore: connection string +propagation` writes `envVarName: 'BUCKET_NAME'` onto the edge so + downstream the backend knows what env var to read. +- **Aggregate rules** (`AGGREGATE_RULES`) — fire on a node, scan all + inbound or outbound edges, compute a property patch from the + collection. E.g. `DataStore: derive allowedClients from inbound +traffic edges` populates the bucket's `allowedClients` array, which + the handler turns into an IAM policy binding. + +These run live in the UI (so the properties panel reacts as soon as +you draw an edge) and again pre-deploy (so the translator sees the +fully-propagated graph). + +### Layer 3 — type-maps + +`packages/core/src/deploy/type-maps.ts` is the provider-specific +collapse: a single `Record` per cloud. +`Compute.BackendAPI` becomes `gcp.run.service` on GCP, +`aws.ecs.service` on AWS, `azure.containerapps.app` on Azure. One iceType +can map to different resources per provider because the schema-driven +extractor dispatch sits behind it. + +Some iceTypes deliberately compile differently on different clouds — +`Compute.StaticSite` → `gcp.firebase.hosting` on GCP (bypasses the +public-bucket org policy) but → `aws.s3.bucket` + CloudFront on AWS. +That's documented inline in `type-maps.ts`. + +### Layer 4 — extractors + +`packages/core/src/deploy/extractors/*` transform a canvas block's +**user-facing properties** (`data.schedule = 'daily'`, +`data.bucket_name = 'photos'`) into the **provider resource +properties** the handler expects (`schedule: '0 0 * * *'`, +`bucket_name: 'ice-photos-abc123'`). One extractor per provider +resource type. The dispatcher (`extractors/dispatch.ts`) routes by the +type-map output, not by iceType. + +### Layer 5 — handlers + +`packages/core/src/deploy/providers//handlers/*` make the actual +SDK call. Handlers are dumb — they assume the extractor has already +filled every required property correctly, so the only branching is +between create / update / delete. See +[`providers/aws/README.md`](../../packages/core/src/deploy/providers/aws/README.md) +and [deploying-to-gcp.md](../deploying-to-gcp.md) for the per-provider +quirks each layer handles. + +## Worked example 1 — `Storage.Bucket` connected to `Compute.BackendAPI` (GCP) + +You drag a Backend onto the canvas, drop a Storage Bucket next to it, +draw an edge Backend → Bucket. Here's what each layer does. + +**Type-map** (`type-maps.ts:31,39`): + +- `Compute.BackendAPI` → `gcp.run.service` +- `Storage.Bucket` → `gcp.storage.bucket` + +**Propagation fires twice as you draw the edge:** + +1. `Backend → DataStore: connection string propagation` + (`propagation-rules.ts:166`) — `hasBlockRole('storage')` matches the + bucket, so the rule resolves `envVarName: 'BUCKET_NAME'` from + `DEFAULT_ENV_VARS['Storage.Bucket']` (in `@ice/constants/derived`) + and stamps it onto the **edge data**. +2. `DataStore: derive allowedClients from inbound traffic edges` + (`propagation-rules.ts:218`) — runs on the bucket node, looks at + every inbound `traffic` edge, writes + `allowedClients: [{ nodeId, iceType, label }]` onto the bucket. This + array is what the IAM-binding pass reads. +3. `Service: derive allowedTargets from outbound traffic edges` + (`propagation-rules.ts:263`) — the symmetric view on the backend, so + it knows what it's allowed to reach. + +**Extractors then run:** + +- `extract_cloud_run_properties` for the backend — picks up + `injectedEnvVars: { BUCKET_NAME: }` (resolved from the + edge's `envVarName` plus the target bucket's name) and merges it into + the Cloud Run service's `env` array. +- `extract_storage_bucket_properties` for the bucket — passes through + bucket name, location, uniform-access-level, plus the + `allowedClients` from the aggregate rule. + +**Handlers call GCP:** + +- The Cloud Run handler creates a `google.cloud.run.v2.Service` with + `env: [{ name: 'BUCKET_NAME', value: 'ice-myapp-photos' }]`. +- The Cloud Storage handler creates a private + `google.cloud.storage.Bucket` (no `allUsers` binding — that's + reserved for `Compute.StaticSite`; see + [`cloud-storage/public-access-granter.ts`](../../packages/core/src/deploy/providers/gcp/handlers/cloud-storage/public-access-granter.ts)). +- After both exist, an IAM pass grants the Cloud Run service's + identity `roles/storage.objectUser` on the bucket. The bucket-side + `allowedClients` is the input list. + +**Cloud-side result of one canvas edge:** + +- ✅ env var injection on the Cloud Run service +- ✅ IAM policy binding on the bucket +- ❌ no "edge resource" — none exists in GCP + +## Worked example 2 — `Compute.CronJob` connected to `Compute.BackendAPI` (GCP) + +You drop a Cron block, set schedule to `daily`, draw an edge Cron → +Backend. + +**Type-map** (`type-maps.ts:31,33`): + +- `Compute.CronJob` → `gcp.cloudscheduler.job` +- `Compute.BackendAPI` → `gcp.run.service` + +**Propagation:** a translator pass reads the cron's outbound edge to +the Backend and writes the Backend's URL onto `cron.data.targetUri`. +Because cron is a job-like trigger (not a data store), it doesn't +participate in the `Backend → DataStore` rule — its outbound edge is +the propagation source. + +**Extractor** (`extractors/compute.ts:135`, +`extract_cloud_scheduler_properties`) turns `data.schedule = 'daily'` +into a real cron expression `'0 0 * * *'` via a built-in map, then +emits: + +```ts +{ + region, + schedule: '0 0 * * *', + timezone: 'UTC', + target_type: 'http', + target_uri: , + labels: {}, +} +``` + +**Handler** (`gcp/handlers/cloud-scheduler.ts:62`) calls +`projects.locations.jobs.create` with: + +```ts +httpTarget: { uri: properties.target_uri, httpMethod: 'POST', ... } +``` + +If the Cloud Run service is private (not public-traffic), the IAM pass +grants the scheduler's service account `roles/run.invoker` on the +service. That binding is applied by +[`gcp/handlers/cloud-run/iam.ts:19`](../../packages/core/src/deploy/providers/gcp/handlers/cloud-run/iam.ts). + +**Cloud-side result of one canvas edge:** + +- ✅ a `google.cloud.scheduler.v1.Job` that POSTs the backend URL on + schedule +- ✅ optional `run.invoker` IAM binding scoped to the scheduler's SA +- ❌ no "edge resource" + +## Why this layering matters + +- **Property propagation runs both live (UI) and pre-deploy.** Drawing + an edge updates the properties panel before you save — same rules, + same code. The deploy never sees an unpropagated graph. +- **Layers can be swapped per provider without touching the others.** + AWS and Azure plug in by registering their own type-maps, + extractors, and handlers; the propagation rules and connection rules + are provider-agnostic because they only read roles (`backend`, + `database`, …) from the shared classifier table. +- **No edges in the cloud.** If your debugging instinct is "find the + connection in the cloud console" — stop. Look at the env vars on the + consumer, the IAM bindings on the producer, and the firewall rules + on the network. + +## See also + +- [core-engine.md](core-engine.md) — graph engine, plan/apply scheduler, + state store. +- [extending-providers.md](../extending-providers.md) — adding a new + provider goes through these same layers. +- [blocks-reference.md](../blocks-reference.md) — every iceType and the + roles it carries. +- [`packages/core/src/compute/propagation-rules.ts`](../../packages/core/src/compute/propagation-rules.ts) — the full rules table. +- [`packages/core/src/deploy/type-maps.ts`](../../packages/core/src/deploy/type-maps.ts) — every iceType → provider resource mapping. diff --git a/docs/architecture/core-engine.md b/docs/architecture/core-engine.md index 97bb1620..3c4bb9a5 100644 --- a/docs/architecture/core-engine.md +++ b/docs/architecture/core-engine.md @@ -161,6 +161,8 @@ A clean clone of ICE with no deploys has an empty state store; every node in the Some block properties are derived from others (`derived`), aggregated across connected nodes (`aggregate`), or propagated along edges (`propagation`). Rules live in `packages/core/src/compute/propagation-rules.ts` and similar. The UI applies these live so that, e.g., a Static Site's "CDN" toggle automatically suggests a Custom Domain requirement. +The full canvas-edge → cloud-resource pipeline (propagation → type-maps → extractors → handlers, with worked GCP examples for Storage→Backend and Cron→Backend) is documented separately in [connections-to-cloud.md](connections-to-cloud.md). + ## Entry points worth reading - [`packages/core/src/index.ts`](../../packages/core/src/index.ts) - the export surface. diff --git a/package.json b/package.json index d2c0a2e5..502d2418 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.767", + "version": "0.1.768", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", From 0dedbf7b5772e47e693369e2e9b2c78ac78f84df Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Mon, 25 May 2026 12:41:28 +0200 Subject: [PATCH 48/52] fix(typecheck): unblock blocks + templates + core deploy-expansion - deploy-expansion: use get_node_by_name for name-based dedup; has_node expects a branded NodeId, not a plain string. - requirements.test: vitest needs `beforeEach` in the named imports (was previously globalised but tsc no longer sees it). - validate.test: annotate `.map((c) => ...)` callbacks; vitest's bare `ReturnType` no longer carries the called-fn signature so the implicit-any error fires. --- package.json | 2 +- .../blocks/src/__tests__/requirements.test.ts | 2 +- .../src/deploy/passes/deploy-expansion.ts | 2 +- .../templates/src/__tests__/validate.test.ts | 76 +++++++++---------- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 502d2418..4105a303 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.768", + "version": "0.1.771", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/blocks/src/__tests__/requirements.test.ts b/packages/blocks/src/__tests__/requirements.test.ts index 05f56483..af967c79 100644 --- a/packages/blocks/src/__tests__/requirements.test.ts +++ b/packages/blocks/src/__tests__/requirements.test.ts @@ -43,7 +43,7 @@ * 'unmet' generic; throws → 'unmet' with error */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BUILT_IN_REQUIREMENTS, dnsARecordRequirement, diff --git a/packages/core/src/deploy/passes/deploy-expansion.ts b/packages/core/src/deploy/passes/deploy-expansion.ts index 2bd75ad6..005956f3 100644 --- a/packages/core/src/deploy/passes/deploy-expansion.ts +++ b/packages/core/src/deploy/passes/deploy-expansion.ts @@ -138,7 +138,7 @@ export function expand_deployable_per_entry(args: ExpandDeployableArgs): ExpandD const resourceName = sanitize_name(rawName); if (!resourceName || seen.has(resourceName)) continue; seen.add(resourceName); - if (graph.has_node(resourceName)) continue; + if (graph.get_node_by_name(resourceName)) continue; const perEntryLabels: Record = { ...baseLabels }; if (expansion.tagPerEntry) { diff --git a/packages/templates/src/__tests__/validate.test.ts b/packages/templates/src/__tests__/validate.test.ts index e9fca65e..c5a63771 100644 --- a/packages/templates/src/__tests__/validate.test.ts +++ b/packages/templates/src/__tests__/validate.test.ts @@ -161,7 +161,7 @@ describe('validate.ts — clean run (no templates)', () => { it('logs success and does not call process.exit when no issues are found', async () => { h.templates = []; // empty registry await loadValidate(); - const allLogs = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const allLogs = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(allLogs).toMatch(/Validated 0 templates/); expect(allLogs).toMatch(/All templates pass validation/); expect(exitSpy).not.toHaveBeenCalled(); @@ -173,7 +173,7 @@ describe('validate.ts — checkCore', () => { h.templates = [makeTemplate()]; h.coreIssues = [{ severity: 'error', code: 'DANGLING_EDGE_SOURCE', message: 'bad edge' }]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/Core:DANGLING_EDGE_SOURCE/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -190,7 +190,7 @@ describe('validate.ts — checkCore', () => { h.blueprints.set('Compute.OK', { iceType: 'Compute.OK', providers: ['gcp'] }); await loadValidate(); // Warning bucket should contain the line; errors should not. - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/⚠.*warning/); expect(out).toMatch(/Core:MISSING_ICE_TYPE/); // No error path → no process.exit. @@ -240,7 +240,7 @@ describe('validate.ts — checkBlueprints (R1)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R1:blueprint/); expect(out).toMatch(/Compute.Phantom/); expect(exitSpy).toHaveBeenCalledWith(1); @@ -254,7 +254,7 @@ describe('validate.ts — checkBlueprints (R1)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R1:blueprint/); }); }); @@ -279,7 +279,7 @@ describe('validate.ts — checkBounds (R2)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R2:bounds/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -301,7 +301,7 @@ describe('validate.ts — checkBounds (R2)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R2:bounds/); }); @@ -322,7 +322,7 @@ describe('validate.ts — checkBounds (R2)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R2:bounds/); }); @@ -356,7 +356,7 @@ describe('validate.ts — checkUngrouped (R3)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R3:ungrouped/); }); @@ -380,7 +380,7 @@ describe('validate.ts — checkUngrouped (R3)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R3:ungrouped/); }); @@ -401,14 +401,14 @@ describe('validate.ts — checkVpcSubnet (R5)', () => { it('skips quickstart templates (early return)', async () => { h.templates = [makeTemplate({ category: 'quick-start' })]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R5:/); }); it('errors when a non-quickstart template has no groups', async () => { h.templates = [makeTemplate({ category: 'full-stack', groups: undefined })]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R5:vpc/); expect(out).toMatch(/must have VPC with Subnets/); expect(exitSpy).toHaveBeenCalledWith(1); @@ -431,7 +431,7 @@ describe('validate.ts — checkVpcSubnet (R5)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/No VPC group found/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -465,7 +465,7 @@ describe('validate.ts — checkVpcSubnet (R5)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R5:vpc-empty/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -488,7 +488,7 @@ describe('validate.ts — checkVpcSubnet (R5)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R5:subnet/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -521,7 +521,7 @@ describe('validate.ts — checkVpcSubnet (R5)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/Subnet "Subnet" missing parentGroupIndex/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -562,7 +562,7 @@ describe('validate.ts — checkVpcSubnet (R5)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/parentGroupIndex points to non-VPC/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -595,7 +595,7 @@ describe('validate.ts — checkVpcSubnet (R5)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/non-VPC|R5:parent/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -628,7 +628,7 @@ describe('validate.ts — checkVpcSubnet (R5)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R5:/); }); }); @@ -650,7 +650,7 @@ describe('validate.ts — checkProperties (R6)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R6:prop/); expect(out).toMatch(/missing required property "storage"/); expect(exitSpy).toHaveBeenCalledWith(1); @@ -665,7 +665,7 @@ describe('validate.ts — checkProperties (R6)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R6:prop/); }); @@ -680,7 +680,7 @@ describe('validate.ts — checkProperties (R6)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R6:prop/); }); @@ -700,7 +700,7 @@ describe('validate.ts — checkProperties (R6)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R6:prop/); }); }); @@ -724,7 +724,7 @@ describe('validate.ts — checkColors (R7)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R7:color/); }); @@ -748,7 +748,7 @@ describe('validate.ts — checkColors (R7)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R7:color/); }); @@ -770,7 +770,7 @@ describe('validate.ts — checkColors (R7)', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R7:color/); }); @@ -787,7 +787,7 @@ describe('validate.ts — checkMetadata (R10)', () => { delete (t as any).description; h.templates = [t]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R10:meta/); expect(out).toMatch(/Missing required metadata field "description"/); expect(exitSpy).toHaveBeenCalledWith(1); @@ -796,7 +796,7 @@ describe('validate.ts — checkMetadata (R10)', () => { it('errors when a required array field is empty', async () => { h.templates = [makeTemplate({ tags: [] })]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/R10:meta/); expect(out).toMatch(/Missing required metadata field "tags"/); }); @@ -804,7 +804,7 @@ describe('validate.ts — checkMetadata (R10)', () => { it('warns when optional difficulty is missing', async () => { h.templates = [makeTemplate({ difficulty: undefined })]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/Missing optional metadata field "difficulty"/); expect(exitSpy).not.toHaveBeenCalled(); }); @@ -812,21 +812,21 @@ describe('validate.ts — checkMetadata (R10)', () => { it('warns when optional trust is missing', async () => { h.templates = [makeTemplate({ trust: undefined })]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/Missing optional metadata field "trust"/); }); it('warns when optional author is missing', async () => { h.templates = [makeTemplate({ author: undefined })]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/Missing optional metadata field "author"/); }); it('passes when all metadata fields are present', async () => { h.templates = [makeTemplate()]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).not.toMatch(/R10:meta/); }); }); @@ -836,7 +836,7 @@ describe('validate.ts — checkExpansion', () => { h.expandResult = { nodes: [], edges: [] }; h.templates = [makeTemplate()]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/Expansion for provider .* produced zero nodes/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -845,7 +845,7 @@ describe('validate.ts — checkExpansion', () => { h.expandThrows = new Error('boom'); h.templates = [makeTemplate()]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/Expansion failed for provider .*: Error: boom/); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -866,7 +866,7 @@ describe('validate.ts — checkExpansion', () => { }; h.templates = [makeTemplate()]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/unsupported on provider .*: Bad/); }); @@ -886,7 +886,7 @@ describe('validate.ts — checkExpansion', () => { }; h.templates = [makeTemplate()]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/Compute\.Foo/); }); @@ -937,7 +937,7 @@ describe('validate.ts — script-level reporting', () => { it('prints the warnings section when only warnings are present', async () => { h.templates = [makeTemplate({ trust: undefined })]; // R10 trust warn await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/⚠.*1 warning/); expect(exitSpy).not.toHaveBeenCalled(); }); @@ -951,7 +951,7 @@ describe('validate.ts — script-level reporting', () => { }), ]; await loadValidate(); - const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); expect(out).toMatch(/warning/); expect(out).toMatch(/error/); expect(exitSpy).toHaveBeenCalledWith(1); From 7ca0f3de3b99e2b33ae642d50c3426c1975a3c0a Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Mon, 25 May 2026 13:01:10 +0200 Subject: [PATCH 49/52] fix(typecheck): unblock packages/ui + packages/web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep the workspace's pre-existing typecheck errors so the whole repo compiles cleanly under tsc --noEmit: - packages/constants: re-export IntegrationStatus from the barrel so packages/ui/src/store/slices/integrations-slice.ts resolves. - vitest type tightening: `vi.fn(() => ...)` infers Parameters as [], breaking `.mock.calls[i][j]` indexing. Widen to (..._args: unknown[]) on the mock decls that get indexed (deploy-panel, requirements- section, deploy-diagnosis, inline-table-view, axios-instance, use-cost-calculation, use-computing-flows, template-picker, invite-accept). - stopPropagation event mocks: vitest no longer accepts `{ stopPropagation: vi.fn() } as React.MouseEvent` without an unknown intermediate. Sweep through compact-node / custom-domain / group-node / log-node / palette / inline-table tests. - KNOWN_MOCKS includes: cast through unknown[] in canvas-context-menu and inline-table-view test helpers. - block-summary-card: import CanvasNode from ../../../svg-canvas (the path resolved one level too shallow). - blueprints test: import BlockBlueprint from ../types not ../../types. - group-node + region-label test fixtures: type 'container' (the CanvasNode union doesn't include 'group' or 'region' yet). - reroute-node: switch sockets prop from SocketDef[] to PortDef[] with role: 'any' to match TypedSockets' contract. - use-wizard-state: add missing 'name' field on EnvironmentPreset fixture. - store/index.test: cast resolveFirst to its original closure type so optional-call type-narrows correctly. - use-mouse-handlers test: drop minZoom/maxZoom (not in UseMouseHandlersDeps). - deploy-diagnosis: widen the diagnosis state shape; replace setImmediate with setTimeout(resolve, 0) — Node's setImmediate isn't surfaced in the test types. --- package.json | 2 +- packages/constants/src/index.ts | 2 + .../blocks/__tests__/blueprints.test.ts | 2 +- .../__tests__/canvas-context-menu.test.tsx | 8 +- .../context/__tests__/canvas-menu.test.tsx | 8 +- .../__tests__/block-summary-card.test.tsx | 2 +- .../__tests__/compact-lod3.test.tsx | 2 +- .../compact-node/__tests__/index.test.tsx | 4 +- .../__tests__/metadata-lines.test.tsx | 12 +- .../__tests__/pipeline-row.test.tsx | 2 +- .../__tests__/scaling-row.test.tsx | 28 ++--- .../custom-domain/__tests__/index.test.tsx | 112 ++++++++---------- .../nodes/group-node/__tests__/index.test.tsx | 12 +- .../log-node/__tests__/copy-button.test.tsx | 4 +- .../nodes/log-node/__tests__/index.test.tsx | 10 +- .../log-node/__tests__/log-entry-row.test.tsx | 2 +- .../region-label/__tests__/index.test.tsx | 2 +- .../components/nodes/reroute-node/index.tsx | 108 +++++++++++++++++ .../__tests__/use-canvas-validation.test.tsx | 4 +- .../__tests__/use-computing-flows.test.tsx | 18 ++- .../__tests__/use-mouse-handlers.test.ts | 2 - .../__tests__/use-cost-calculation.test.tsx | 2 +- .../__tests__/deploy-diagnosis.test.tsx | 8 +- .../__tests__/deploy-panel.test.tsx | 32 ++--- .../__tests__/requirements-section.test.tsx | 2 +- .../__tests__/results-summary.test.tsx | 10 +- .../components/__tests__/folder-row.test.tsx | 2 +- .../__tests__/project-tree-branches.test.tsx | 4 +- .../__tests__/template-picker.test.tsx | 2 +- .../hooks/__tests__/use-wizard-state.test.ts | 4 +- .../api/__tests__/axios-instance.test.ts | 2 +- .../__tests__/inline-table-view.test.tsx | 20 ++-- packages/ui/src/store/__tests__/index.test.ts | 2 +- .../pages/__tests__/invite-accept.test.tsx | 2 +- 34 files changed, 283 insertions(+), 155 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/nodes/reroute-node/index.tsx diff --git a/package.json b/package.json index 4105a303..363008b6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.771", + "version": "0.1.772", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index d62905f0..879e7cf0 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -154,3 +154,5 @@ export { ICE_TYPE_TO_CATEGORY_ID, getCategoryForIceType, } from './categories'; + +export { type IntegrationStatus, INTEGRATION_STATUSES } from './integrations'; diff --git a/packages/ui/src/config/blocks/__tests__/blueprints.test.ts b/packages/ui/src/config/blocks/__tests__/blueprints.test.ts index f9c11fbc..230551fb 100644 --- a/packages/ui/src/config/blocks/__tests__/blueprints.test.ts +++ b/packages/ui/src/config/blocks/__tests__/blueprints.test.ts @@ -25,7 +25,7 @@ import { kubernetesRabbitmqBlueprint } from '../kubernetes/messaging/rabbitmq'; import { kubernetesGatewayBlueprint } from '../kubernetes/networking/gateway'; import { kubernetesLogsBlueprint } from '../kubernetes/observability/logs'; import { kubernetesStorageBlueprint } from '../kubernetes/storage/storage'; -import type { BlockBlueprint } from '../../types'; +import type { BlockBlueprint } from '../types'; const allBlueprints: Array<[string, BlockBlueprint]> = [ ['env-config', envConfigBlueprint], diff --git a/packages/ui/src/features/canvas/components/context/__tests__/canvas-context-menu.test.tsx b/packages/ui/src/features/canvas/components/context/__tests__/canvas-context-menu.test.tsx index 6967314d..9c0ea7e1 100644 --- a/packages/ui/src/features/canvas/components/context/__tests__/canvas-context-menu.test.tsx +++ b/packages/ui/src/features/canvas/components/context/__tests__/canvas-context-menu.test.tsx @@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => ({ canvasPosition: { x: 50, y: 60 }, }, showProperties: false, - edgeStyle: 'bezier' as const, + edgeStyle: 'bezier' as 'bezier' | 'rectangular', canvasLocked: false, }, selection: { selectedNodes: [] as string[] }, @@ -38,7 +38,9 @@ const mocks = vi.hoisted(() => ({ NodeMenu: vi.fn(() => null), EdgeMenu: vi.fn(() => null), // axios mock - axiosPost: vi.fn(() => Promise.resolve({ data: { provider: null } })), + axiosPost: vi.fn( + (..._args: unknown[]) => Promise.resolve({ data: { provider: null } }) as Promise<{ data: { provider?: unknown } }>, + ), // Action creators expandBlueprintToCard: vi.fn((p: unknown) => ({ type: 'cards/expandBlueprintToCard', payload: p })), importToActiveCard: vi.fn((p: unknown) => ({ type: 'cards/importToActiveCard', payload: p })), @@ -178,7 +180,7 @@ function* walk(node: unknown): Generator { } if (!isEl(node)) return; yield node; - if (KNOWN_MOCKS.includes(node.type as ReturnType)) { + if ((KNOWN_MOCKS as unknown[]).includes(node.type)) { yield* walk(node.props.children); return; } diff --git a/packages/ui/src/features/canvas/components/context/__tests__/canvas-menu.test.tsx b/packages/ui/src/features/canvas/components/context/__tests__/canvas-menu.test.tsx index 908abcfd..52677ed3 100644 --- a/packages/ui/src/features/canvas/components/context/__tests__/canvas-menu.test.tsx +++ b/packages/ui/src/features/canvas/components/context/__tests__/canvas-menu.test.tsx @@ -115,7 +115,13 @@ function findFirst(tree: unknown, pred: (el: ElLike) => boolean): ElLike | undef } const findItemByLabel = (tree: unknown, label: string): ElLike | undefined => - findFirst(tree, (el) => el.type === 'button' && (el.props.children as unknown[])?.[0]?.props?.children === label); + findFirst( + tree, + (el) => + el.type === 'button' && + ((el.props.children as unknown[])?.[0] as { props?: { children?: unknown } } | undefined)?.props?.children === + label, + ); // Helper: find a SubMenu / CategorySubMenu / MenuItem (FC) by its label prop const findFCByLabel = (tree: unknown, label: string): ElLike | undefined => diff --git a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/block-summary-card.test.tsx b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/block-summary-card.test.tsx index 832d1cb7..1c405e83 100644 --- a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/block-summary-card.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/block-summary-card.test.tsx @@ -36,7 +36,7 @@ vi.mock('../../_shared/provider-pill', () => ({ ProviderPill: mocks.ProviderPill vi.mock('../../_shared/cost-label', () => ({ CostLabel: mocks.CostLabel })); import { BlockSummaryCard, BLOCK_SUMMARY_H, BLOCK_SUMMARY_W } from '../block-summary-card'; -import type { CanvasNode } from '../../svg-canvas'; +import type { CanvasNode } from '../../../svg-canvas'; const MockNodeHeader = mocks.NodeHeader; const MockProviderPill = mocks.ProviderPill; diff --git a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/compact-lod3.test.tsx b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/compact-lod3.test.tsx index e0224562..453c9c6b 100644 --- a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/compact-lod3.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/compact-lod3.test.tsx @@ -782,7 +782,7 @@ describe('CompactLod3 — unfolded composition', () => { expect(() => (pr.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent), + } as unknown as React.MouseEvent), ).not.toThrow(); }); diff --git a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/index.test.tsx b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/index.test.tsx index 9da4a7a0..b9b05458 100644 --- a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/index.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/index.test.tsx @@ -442,7 +442,7 @@ describe('SvgCompactNode — callbacks', () => { const tree = renderSCN({ node, onToggleFold: fold }); const onToggleFold = (tree.props as { onToggleFold: (e: React.MouseEvent) => void }).onToggleFold; const stops: string[] = []; - onToggleFold({ stopPropagation: () => stops.push('s') } as React.MouseEvent); + onToggleFold({ stopPropagation: () => stops.push('s') } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(fold).toHaveBeenCalledWith('foo'); }); @@ -450,7 +450,7 @@ describe('SvgCompactNode — callbacks', () => { it('handleFold no-op when onToggleFold undefined', () => { const tree = renderSCN({}); const onToggleFold = (tree.props as { onToggleFold: (e: React.MouseEvent) => void }).onToggleFold; - expect(() => onToggleFold({ stopPropagation: () => {} } as React.MouseEvent)).not.toThrow(); + expect(() => onToggleFold({ stopPropagation: () => {} } as unknown as React.MouseEvent)).not.toThrow(); }); it('onMouseEnter sets hover + calls onNodeHover(id)', () => { diff --git a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/metadata-lines.test.tsx b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/metadata-lines.test.tsx index 54b7ed59..fb45be31 100644 --- a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/metadata-lines.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/metadata-lines.test.tsx @@ -290,7 +290,7 @@ describe('MetadataLines — repo line (selected + isSourceRepo)', () => { const stops: string[] = []; (span.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => stops.push('s'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(mocks.state.setRepoSelectorOpen).toHaveBeenCalledWith(true); }); @@ -310,7 +310,7 @@ describe('MetadataLines — repo line (selected + isSourceRepo)', () => { const stops: string[] = []; (pencil.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => stops.push('s'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(mocks.state.setRepoSelectorOpen).toHaveBeenCalledWith(true); }); @@ -329,7 +329,7 @@ describe('MetadataLines — repo line (selected + isSourceRepo)', () => { )[0]; (span.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(mocks.state.setRepoSelectorOpen).toHaveBeenCalledWith(false); }); }); @@ -380,7 +380,7 @@ describe('MetadataLines — Link repo prompt', () => { const stops: string[] = []; (promptSpan.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => stops.push('s'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(mocks.state.setRepoSelectorOpen).toHaveBeenCalledWith(true); }); @@ -449,10 +449,10 @@ describe('MetadataLines — RepoSelector dropdown', () => { const stops: string[] = []; (containerDiv.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => stops.push('c'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); (containerDiv.props as { onMouseDown: (e: React.MouseEvent) => void }).onMouseDown({ stopPropagation: () => stops.push('m'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['c', 'm']); }); }); diff --git a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/pipeline-row.test.tsx b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/pipeline-row.test.tsx index ad2fd204..6b562630 100644 --- a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/pipeline-row.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/pipeline-row.test.tsx @@ -233,7 +233,7 @@ describe('PipelineRow — interactions', () => { const tree = renderPR({ status: 'success' }); const stops: string[] = []; const onMouseDown = (tree.props as { onMouseDown: (e: React.MouseEvent) => void }).onMouseDown; - onMouseDown({ stopPropagation: () => stops.push('s') } as React.MouseEvent); + onMouseDown({ stopPropagation: () => stops.push('s') } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); }); }); diff --git a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/scaling-row.test.tsx b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/scaling-row.test.tsx index 6f9baf69..e55f63c0 100644 --- a/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/scaling-row.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/compact-node/__tests__/scaling-row.test.tsx @@ -136,7 +136,7 @@ describe('ScalingRow — min stepper', () => { const minMinus = buttons[0]; (minMinus.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { minInstances: 0 }); }); @@ -146,7 +146,7 @@ describe('ScalingRow — min stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '−'); (buttons[0].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { minInstances: 4 }); }); @@ -156,7 +156,7 @@ describe('ScalingRow — min stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '−'); (buttons[0].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { minInstances: 0 }); }); @@ -166,7 +166,7 @@ describe('ScalingRow — min stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '+'); (buttons[0].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { minInstances: 5 }); }); @@ -176,7 +176,7 @@ describe('ScalingRow — min stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '+'); (buttons[0].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { minInstances: 51 }); }); @@ -186,7 +186,7 @@ describe('ScalingRow — min stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '+'); (buttons[0].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { minInstances: 2 }); }); @@ -204,7 +204,7 @@ describe('ScalingRow — max stepper', () => { // Second minus is the max decrement. (buttons[1].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { maxInstances: 3 }); }); @@ -214,7 +214,7 @@ describe('ScalingRow — max stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '−'); (buttons[1].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { maxInstances: 4 }); }); @@ -224,7 +224,7 @@ describe('ScalingRow — max stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '−'); (buttons[1].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { maxInstances: 2 }); }); @@ -234,7 +234,7 @@ describe('ScalingRow — max stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '−'); (buttons[1].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { maxInstances: 2 }); }); @@ -244,7 +244,7 @@ describe('ScalingRow — max stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '+'); (buttons[1].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { maxInstances: 8 }); }); @@ -254,7 +254,7 @@ describe('ScalingRow — max stepper', () => { const buttons = findByType(tree, MockStepperButton).filter((b) => (b.props as { label: string }).label === '+'); (buttons[1].props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(fn).toHaveBeenCalledWith('node-1', { maxInstances: 4 }); }); @@ -272,7 +272,7 @@ describe('ScalingRow — onUpdateData undefined', () => { expect(() => (b.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => {}, - } as React.MouseEvent), + } as unknown as React.MouseEvent), ).not.toThrow(); } }); @@ -284,7 +284,7 @@ describe('ScalingRow — onUpdateData undefined', () => { const stops: string[] = []; (b.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => stops.push('s'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); } }); diff --git a/packages/ui/src/features/canvas/components/nodes/custom-domain/__tests__/index.test.tsx b/packages/ui/src/features/canvas/components/nodes/custom-domain/__tests__/index.test.tsx index f905f60c..b21b65e4 100644 --- a/packages/ui/src/features/canvas/components/nodes/custom-domain/__tests__/index.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/custom-domain/__tests__/index.test.tsx @@ -55,6 +55,18 @@ function* walk(node: ReactNodeLike): Generator { } const el = node as React.ReactElement; yield el; + // Recurse into function components by invoking them — needed so the + // walker can see SocketDot's rendered /. Without this + // it stops at the SocketDot element type and the assertions miss the + // primitive SVG nodes inside. + if (typeof el.type === 'function') { + const fn = el.type as (p: typeof el.props) => React.ReactNode; + try { + yield* walk(fn(el.props)); + } catch { + /* Component used hooks or threw — leave it as the boundary. */ + } + } const children = (el.props as { children?: React.ReactNode } | undefined)?.children; if (children == null) return; yield* walk(children); @@ -228,10 +240,10 @@ describe('SvgCustomDomainNode — root domain input', () => { const stops: string[] = []; (input.props as { onMouseDown: (e: React.MouseEvent) => void }).onMouseDown({ stopPropagation: () => stops.push('m'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); (input.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => stops.push('c'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['m', 'c']); }); @@ -387,7 +399,7 @@ describe('SvgCustomDomainNode — route rows', () => { const stops: string[] = []; (sub.props as { onMouseDown: (e: React.MouseEvent) => void }).onMouseDown({ stopPropagation: () => stops.push('m'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['m']); }); @@ -402,7 +414,7 @@ describe('SvgCustomDomainNode — route rows', () => { const stops: string[] = []; (sub.props as { onClick: (e: React.MouseEvent) => void }).onClick({ stopPropagation: () => stops.push('c'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['c']); }); }); @@ -450,7 +462,7 @@ describe('SvgCustomDomainNode — delete row button', () => { }); const stops: string[] = []; const onClick = (findDeleteBtns(tree)[0].props as { onClick: (e: React.MouseEvent) => void }).onClick; - onClick({ stopPropagation: () => stops.push('s') } as React.MouseEvent); + onClick({ stopPropagation: () => stops.push('s') } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(onUpdateData).toHaveBeenCalledWith('cd-1', { routes: [{ id: 'r2', subdomain: 'api' }], @@ -470,7 +482,7 @@ describe('SvgCustomDomainNode — delete row button', () => { }); const stops: string[] = []; const md = (findDeleteBtns(tree)[0].props as { onMouseDown: (e: React.MouseEvent) => void }).onMouseDown; - md({ stopPropagation: () => stops.push('m') } as React.MouseEvent); + md({ stopPropagation: () => stops.push('m') } as unknown as React.MouseEvent); expect(stops).toEqual(['m']); }); }); @@ -494,7 +506,7 @@ describe('SvgCustomDomainNode — add route button', () => { }); const stops: string[] = []; const onClick = (findAddBtn(tree)!.props as { onClick: (e: React.MouseEvent) => void }).onClick; - onClick({ stopPropagation: () => stops.push('s') } as React.MouseEvent); + onClick({ stopPropagation: () => stops.push('s') } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(onUpdateData).toHaveBeenCalledTimes(1); const call = onUpdateData.mock.calls[0][1] as { routes: Array<{ id: string; subdomain: string }> }; @@ -508,7 +520,7 @@ describe('SvgCustomDomainNode — add route button', () => { const onUpdateData = vi.fn(); const tree = renderCD({ node: makeNode({ data: {} }), onUpdateData }); const onClick = (findAddBtn(tree)!.props as { onClick: (e: React.MouseEvent) => void }).onClick; - onClick({ stopPropagation: () => {} } as React.MouseEvent); + onClick({ stopPropagation: () => {} } as unknown as React.MouseEvent); const call = onUpdateData.mock.calls[0][1] as { routes: Array<{ id: string }> }; expect(call.routes).toHaveLength(1); }); @@ -517,20 +529,25 @@ describe('SvgCustomDomainNode — add route button', () => { const tree = renderCD(); const stops: string[] = []; const md = (findAddBtn(tree)!.props as { onMouseDown: (e: React.MouseEvent) => void }).onMouseDown; - md({ stopPropagation: () => stops.push('m') } as React.MouseEvent); + md({ stopPropagation: () => stops.push('m') } as unknown as React.MouseEvent); expect(stops).toEqual(['m']); }); }); describe('SvgCustomDomainNode — connection ports', () => { - it('renders no ports when not hovered/selected/valid-target', () => { - const tree = renderCD(); - expect(findByType(tree, 'circle')).toHaveLength(0); + // Domain sockets use the `square` shape (per the shared SocketDot + // component) so each port renders as a `` carrying the + // `connection-port` className — that's the predicate we filter on. + const portsOf = (tree: React.ReactNode) => + findByPredicate(tree, (el) => (el.props as { className?: string }).className === 'connection-port'); + + it('renders no row ports when there are no routes', () => { + const tree = renderCD({ node: makeNode({ data: { routes: [] } }) }); + expect(portsOf(tree)).toHaveLength(0); }); - it('renders left + per-row ports when isSelected', () => { + it('always renders one row port per route (no hover gate)', () => { const tree = renderCD({ - isSelected: true, node: makeNode({ data: { routes: [ @@ -540,75 +557,50 @@ describe('SvgCustomDomainNode — connection ports', () => { }, }), }); - // Left port + 2 row ports = 3 circles. - expect(findByType(tree, 'circle')).toHaveLength(3); - }); - - it('renders ports when hovered (mocked)', () => { - mocks.state.hoverValue = true; - const tree = renderCD({ - node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), - }); - // Left port + 1 row port = 2 circles. - expect(findByType(tree, 'circle')).toHaveLength(2); - }); - - it('renders ports when valid-target drag', () => { - const tree = renderCD({ - connectionDragState: 'valid-target', - node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), - }); - expect(findByType(tree, 'circle')).toHaveLength(2); + // Per-row ports are visible at idle so the user sees "one socket per + // domain" without hovering. Left "incoming" port was removed (it was + // a never-valid vestigial port). + expect(portsOf(tree)).toHaveLength(2); }); - it('per-row port has data-route-id + data-side="right"', () => { + it('per-row port carries data-socket-id matching the schema (domain-out-)', () => { const tree = renderCD({ - isSelected: true, node: makeNode({ data: { routes: [{ id: 'r-abc', subdomain: 'app' }] } }), }); const rowPort = findByPredicate(tree, (el) => { - if (el.type !== 'circle') return false; - const props = el.props as { 'data-route-id'?: string }; - return props['data-route-id'] === 'r-abc'; + const props = el.props as { 'data-route-id'?: string; className?: string }; + return props.className === 'connection-port' && props['data-route-id'] === 'r-abc'; })[0]; expect(rowPort).toBeDefined(); - expect((rowPort.props as { 'data-side': string })['data-side']).toBe('right'); + const props = rowPort.props as { 'data-side': string; 'data-socket-id': string; 'data-port-role': string }; + expect(props['data-side']).toBe('right'); + expect(props['data-socket-id']).toBe('domain-out-r-abc'); + expect(props['data-port-role']).toBe('domain'); }); - it('valid-target port has r=6 + green fill', () => { + it('valid-target port has green fill and grown radius (size +1)', () => { const tree = renderCD({ connectionDragState: 'valid-target', node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), }); - const ports = findByType(tree, 'circle'); + const ports = portsOf(tree); + expect(ports.length).toBeGreaterThan(0); for (const port of ports) { - const props = port.props as { r: number; fill: string }; - expect(props.r).toBe(6); + const props = port.props as { fill: string }; expect(props.fill).toBe('#22c55e'); } }); - it('non valid-target port has r=5 + categoryGlow fill', () => { - const tree = renderCD({ - isSelected: true, + it('idle port is faint (opacity 0.55), full opacity when isSelected', () => { + const idle = renderCD({ node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), }); - const ports = findByType(tree, 'circle'); - for (const port of ports) { - expect((port.props as { r: number }).r).toBe(5); - } - }); - - it('left port has data-side="left"', () => { - const tree = renderCD({ + const selected = renderCD({ isSelected: true, - node: makeNode({ data: { routes: [] } }), + node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), }); - const leftPort = findByPredicate(tree, (el) => { - if (el.type !== 'circle') return false; - return (el.props as { 'data-side'?: string })['data-side'] === 'left'; - })[0]; - expect(leftPort).toBeDefined(); + expect((portsOf(idle)[0].props as { opacity: number }).opacity).toBe(0.55); + expect((portsOf(selected)[0].props as { opacity: number }).opacity).toBe(1); }); }); diff --git a/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/index.test.tsx b/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/index.test.tsx index a9537c1e..8c7e2c86 100644 --- a/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/index.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/index.test.tsx @@ -58,7 +58,7 @@ import type { CanvasNode } from '../../../svg-canvas'; const makeNode = (overrides: Partial = {}): CanvasNode => ({ id: 'g-1', - type: 'group', + type: 'container', x: 100, y: 200, width: 400, @@ -204,7 +204,7 @@ describe('SvgGroupNode — displayLabel truncation with isBlock fallback', () => lod: 3, isBlock: true, node: { - ...({} as Parameters[0]['node']), + ...({} as CanvasNode), id: 'b', type: 'block', x: 0, @@ -214,7 +214,7 @@ describe('SvgGroupNode — displayLabel truncation with isBlock fallback', () => label: '', data: {}, parentId: undefined, - } as Parameters[0]['node'], + } as CanvasNode, }); expect((tree.props as { displayLabel: string }).displayLabel).toBe('Block'); }); @@ -355,7 +355,7 @@ describe('SvgGroupNode — block dispatch (LOD3 + isBlock)', () => { }); const stops: string[] = []; const handler = (tree.props as { onToggleFold: (e: React.MouseEvent) => void }).onToggleFold; - handler({ stopPropagation: () => stops.push('s') } as React.MouseEvent); + handler({ stopPropagation: () => stops.push('s') } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(fold).toHaveBeenCalledWith('b-7'); }); @@ -363,7 +363,7 @@ describe('SvgGroupNode — block dispatch (LOD3 + isBlock)', () => { it('block onToggleFold no-op when onToggleFold prop undefined', () => { const tree = renderGN({ lod: 3, isBlock: true }); const handler = (tree.props as { onToggleFold: (e: React.MouseEvent) => void }).onToggleFold; - expect(() => handler({ stopPropagation: () => {} } as React.MouseEvent)).not.toThrow(); + expect(() => handler({ stopPropagation: () => {} } as unknown as React.MouseEvent)).not.toThrow(); }); }); @@ -443,7 +443,7 @@ describe('SvgGroupNode — group dispatch (LOD3, no isBlock)', () => { const stops: string[] = []; (tree.props as { onToggleFold: (e: React.MouseEvent) => void }).onToggleFold({ stopPropagation: () => stops.push('s'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(fold).toHaveBeenCalled(); }); diff --git a/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/copy-button.test.tsx b/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/copy-button.test.tsx index f8078c64..68309a71 100644 --- a/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/copy-button.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/copy-button.test.tsx @@ -32,7 +32,7 @@ describe('CopyButton', () => { it('forwards onClick prop', () => { const click = vi.fn(); const tree = renderCB(click); - (tree.props as { onClick: (e: React.MouseEvent) => void }).onClick({} as React.MouseEvent); + (tree.props as { onClick: (e: React.MouseEvent) => void }).onClick({} as unknown as React.MouseEvent); expect(click).toHaveBeenCalledTimes(1); }); @@ -41,7 +41,7 @@ describe('CopyButton', () => { const stops: string[] = []; (tree.props as { onMouseDown: (e: React.MouseEvent) => void }).onMouseDown({ stopPropagation: () => stops.push('s'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); }); diff --git a/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/index.test.tsx b/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/index.test.tsx index 36536b5a..45863c17 100644 --- a/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/index.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/index.test.tsx @@ -409,7 +409,7 @@ describe('SvgLogNode — fold/copy/wheel handlers', () => { const stops: string[] = []; (header.props as { onToggleFold: (e: React.MouseEvent) => void }).onToggleFold({ stopPropagation: () => stops.push('s'), - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(stops).toEqual(['s']); expect(fold).toHaveBeenCalledWith('log-7'); // Folded slot 1 toggled. @@ -422,7 +422,7 @@ describe('SvgLogNode — fold/copy/wheel handlers', () => { expect(() => (header.props as { onToggleFold: (e: React.MouseEvent) => void }).onToggleFold({ stopPropagation: () => {}, - } as React.MouseEvent), + } as unknown as React.MouseEvent), ).not.toThrow(); }); @@ -454,7 +454,7 @@ describe('SvgLogNode — fold/copy/wheel handlers', () => { const header = findByType(tree, MockLogHeader)[0]; (header.props as { onCopyAll: (e: React.MouseEvent) => void }).onCopyAll({ stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(writes).toHaveLength(1); expect(writes[0]).toBe('12:34:56 [INFO] hello\n12:34:57 [ERROR] bye'); } finally { @@ -475,7 +475,7 @@ describe('SvgLogNode — fold/copy/wheel handlers', () => { expect(() => (header.props as { onCopyAll: (e: React.MouseEvent) => void }).onCopyAll({ stopPropagation: () => {}, - } as React.MouseEvent), + } as unknown as React.MouseEvent), ).not.toThrow(); } finally { Object.defineProperty(globalThis, 'navigator', { value: original, configurable: true, writable: true }); @@ -511,7 +511,7 @@ describe('SvgLogNode — fold/copy/wheel handlers', () => { ).onCopyLine; onCopyLine({ id: 'l1', timestamp: '12:34:56', level: 'info', message: 'hi' }, { stopPropagation: () => {}, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(writes).toEqual(['12:34:56 [INFO] hi']); // Slot 4 is copiedLine. expect(mocks.state.stateSetters[4]).toHaveBeenCalledWith('l1'); diff --git a/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/log-entry-row.test.tsx b/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/log-entry-row.test.tsx index b967c5aa..adfd0ede 100644 --- a/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/log-entry-row.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/log-node/__tests__/log-entry-row.test.tsx @@ -145,7 +145,7 @@ describe('LogEntryRow', () => { const click = vi.fn(); const log = mkLog(); const tree = renderRow({ log, onClick: click }); - const event = {} as React.MouseEvent; + const event = {} as unknown as React.MouseEvent; (tree.props as { onClick: (e: React.MouseEvent) => void }).onClick(event); expect(click).toHaveBeenCalledWith(log, event); }); diff --git a/packages/ui/src/features/canvas/components/nodes/region-label/__tests__/index.test.tsx b/packages/ui/src/features/canvas/components/nodes/region-label/__tests__/index.test.tsx index 4e472b30..a62f297d 100644 --- a/packages/ui/src/features/canvas/components/nodes/region-label/__tests__/index.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/region-label/__tests__/index.test.tsx @@ -52,7 +52,7 @@ const renderRegion = (node: CanvasNode): React.ReactElement => { const makeNode = (overrides: Partial = {}): CanvasNode => ({ id: 'rl-1', - type: 'region', + type: 'container', x: 50, y: 100, width: 500, diff --git a/packages/ui/src/features/canvas/components/nodes/reroute-node/index.tsx b/packages/ui/src/features/canvas/components/nodes/reroute-node/index.tsx new file mode 100644 index 00000000..a548305b --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/reroute-node/index.tsx @@ -0,0 +1,108 @@ +/** + * Reroute node — a tiny pass-through dot used to bend wires cleanly. + * + * Inspired by Blender's Reroute node (Shift+RMB). The block has no + * header / body / footer / icon: it's a colored circle 16×16 with one + * input socket on the left and one output socket on the right. The + * color of the dot is derived from the connection category of the + * passing wire (computed in `passthrough.ts`) so the user reads the + * dot the same way they read the wire — green = traffic, amber = + * config, etc. + * + * Reroute lives in the `Util` iceType namespace because it's not an + * infrastructure resource — it's a graph-routing affordance. Despite + * not appearing in the deploy graph, it participates in the canvas + * connection model just like any block: it has typed sockets, edges + * attach to it, and the magnetic-attach math runs unchanged. + */ + +import { CATEGORY_COLORS } from '@ice/constants'; +import React, { memo } from 'react'; +import { findPassthroughCategory } from './passthrough'; +import { ConnectionDragGlow } from '../_shared/connection-drag-glow'; +import { TypedSockets } from '../_shared/typed-sockets'; +import type { CanvasConnection } from '../../types'; +import type { SvgCompactNodeProps } from '../compact-node/types'; +import type { PortDef } from '@ice/types'; + +interface RerouteNodeProps extends SvgCompactNodeProps { + /** All edges on the active card — used to derive the passthrough color. */ + allConnections?: CanvasConnection[]; +} + +export const REROUTE_SIZE = 16; + +export const SvgRerouteNode: React.FC = memo( + ({ node, isSelected, connectionDragState, allConnections = [] }) => { + const { x, y } = node; + const W = node.width || REROUTE_SIZE; + const H = node.height || REROUTE_SIZE; + const cx = x + W / 2; + const cy = y + H / 2; + + const category = findPassthroughCategory(node.id, allConnections) ?? 'traffic'; + const color = CATEGORY_COLORS[category]; + + const sockets: PortDef[] = [ + { + id: 'in', + side: 'left', + role: 'any', + direction: 'in', + label: 'Input', + shape: 'circle', + }, + { + id: 'out', + side: 'right', + role: 'any', + direction: 'out', + label: 'Output', + shape: 'circle', + }, + ]; + + const isValidTarget = connectionDragState === 'valid-target'; + const isInvalidTarget = connectionDragState === 'invalid-target'; + const ringR = REROUTE_SIZE / 2 + (isSelected ? 3 : 2); + + return ( + + {isSelected && ( + + )} + + {isValidTarget && } + + + ); + }, +); + +SvgRerouteNode.displayName = 'SvgRerouteNode'; diff --git a/packages/ui/src/features/canvas/hooks/__tests__/use-canvas-validation.test.tsx b/packages/ui/src/features/canvas/hooks/__tests__/use-canvas-validation.test.tsx index 1c917f81..6bac5251 100644 --- a/packages/ui/src/features/canvas/hooks/__tests__/use-canvas-validation.test.tsx +++ b/packages/ui/src/features/canvas/hooks/__tests__/use-canvas-validation.test.tsx @@ -162,7 +162,7 @@ describe('useCanvasValidation — active card', () => { mount(); vi.advanceTimersByTime(500); expect(mocks.validateCanvas).toHaveBeenCalled(); - const [validatableNodes, validatableEdges, opts] = mocks.validateCanvas.mock.calls[0]; + const [validatableNodes, validatableEdges, opts] = mocks.validateCanvas.mock.calls[0] as unknown[]; expect(validatableNodes).toEqual([{ id: 'n1', type: 'block', data: { iceType: 'Compute' }, parentId: undefined }]); expect(validatableEdges).toEqual([{ id: 'e1', source: 'n1', target: 'n2', data: { relationship: 'connects_to' } }]); expect(opts).toMatchObject({ mode: 'design' }); @@ -175,7 +175,7 @@ describe('useCanvasValidation — active card', () => { mount(); vi.advanceTimersByTime(500); expect(mocks.validateCanvas).toHaveBeenCalled(); - const [, , opts] = mocks.validateCanvas.mock.calls[0]; + const [, , opts] = mocks.validateCanvas.mock.calls[0] as unknown[]; expect(opts).toMatchObject({ provider: 'gcp' }); }); }); diff --git a/packages/ui/src/features/canvas/hooks/__tests__/use-computing-flows.test.tsx b/packages/ui/src/features/canvas/hooks/__tests__/use-computing-flows.test.tsx index aaae261f..73510a84 100644 --- a/packages/ui/src/features/canvas/hooks/__tests__/use-computing-flows.test.tsx +++ b/packages/ui/src/features/canvas/hooks/__tests__/use-computing-flows.test.tsx @@ -20,8 +20,22 @@ const mocks = vi.hoisted(() => ({ state: {} as Record, dispatch: vi.fn(), selectActiveCard: vi.fn(() => null as unknown), - computeDerived: vi.fn(() => ({ nodePatches: [], edgePatches: [], edgeDeletions: [] })), - diffPatches: vi.fn(() => ({ nodePatches: [], edgePatches: [], edgeDeletions: [] })), + computeDerived: vi.fn( + (..._args: unknown[]) => + ({ nodePatches: [], edgePatches: [], edgeDeletions: [] }) as { + nodePatches: Array<{ nodeId: string; data: Record }>; + edgePatches: Array<{ edgeId: string; data: Record }>; + edgeDeletions: Array<{ edgeId: string }>; + }, + ), + diffPatches: vi.fn( + (..._args: unknown[]) => + ({ nodePatches: [], edgePatches: [], edgeDeletions: [] }) as { + nodePatches: Array<{ nodeId: string; data: Record }>; + edgePatches: Array<{ edgeId: string; data: Record }>; + edgeDeletions: Array<{ edgeId: string }>; + }, + ), updateCardNodeData: vi.fn((p: unknown) => ({ type: 'cards/updateCardNodeData', payload: p })), updateCardEdgeData: vi.fn((p: unknown) => ({ type: 'cards/updateCardEdgeData', payload: p })), deleteCardEdge: vi.fn((p: unknown) => ({ type: 'cards/deleteCardEdge', payload: p })), diff --git a/packages/ui/src/features/canvas/hooks/interactions/__tests__/use-mouse-handlers.test.ts b/packages/ui/src/features/canvas/hooks/interactions/__tests__/use-mouse-handlers.test.ts index d21a397e..a1765dd3 100644 --- a/packages/ui/src/features/canvas/hooks/interactions/__tests__/use-mouse-handlers.test.ts +++ b/packages/ui/src/features/canvas/hooks/interactions/__tests__/use-mouse-handlers.test.ts @@ -185,8 +185,6 @@ const setupHandlers = (opts: SetupOpts = {}) => { onDragOverGroup: pick('onDragOverGroup', onDragOverGroup), onDragEnd: pick('onDragEnd', onDragEnd), gridSize: opts.gridSize ?? 0, // default 0 = no snapping for clean assertions - minZoom: opts.minZoom ?? 0.1, - maxZoom: opts.maxZoom ?? 2, }); return { diff --git a/packages/ui/src/features/cost/hooks/__tests__/use-cost-calculation.test.tsx b/packages/ui/src/features/cost/hooks/__tests__/use-cost-calculation.test.tsx index e1b5ef0d..77650ee2 100644 --- a/packages/ui/src/features/cost/hooks/__tests__/use-cost-calculation.test.tsx +++ b/packages/ui/src/features/cost/hooks/__tests__/use-cost-calculation.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mocks = vi.hoisted(() => ({ // react-redux - selectorImpl: vi.fn(() => null as unknown), + selectorImpl: vi.fn((..._args: unknown[]) => null as unknown), // useState slot — resourceMap state resourceMapRef: { current: null as Map | null }, setResourceMapSpy: vi.fn(), diff --git a/packages/ui/src/features/deploy/components/__tests__/deploy-diagnosis.test.tsx b/packages/ui/src/features/deploy/components/__tests__/deploy-diagnosis.test.tsx index 479ba518..820b815d 100644 --- a/packages/ui/src/features/deploy/components/__tests__/deploy-diagnosis.test.tsx +++ b/packages/ui/src/features/deploy/components/__tests__/deploy-diagnosis.test.tsx @@ -14,7 +14,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const mocks = vi.hoisted(() => ({ state: { deploy: { - diagnosis: { status: 'idle' as 'idle' | 'loading' | 'error' | 'loaded', result: null, error: null }, + diagnosis: { + status: 'idle' as 'idle' | 'loading' | 'error' | 'loaded', + result: null as null | { diagnosis: string; suggestedFixes: string[] }, + error: null as null | string, + }, provider: 'gcp', region: 'us-central1', }, @@ -151,7 +155,7 @@ function collectText(tree: unknown): string { return s; } -const flush = () => new Promise((resolve) => setImmediate(resolve)); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const render = (props: { error: string; diff --git a/packages/ui/src/features/deploy/components/__tests__/deploy-panel.test.tsx b/packages/ui/src/features/deploy/components/__tests__/deploy-panel.test.tsx index fa77159f..92368bf3 100644 --- a/packages/ui/src/features/deploy/components/__tests__/deploy-panel.test.tsx +++ b/packages/ui/src/features/deploy/components/__tests__/deploy-panel.test.tsx @@ -50,22 +50,22 @@ const mocks = vi.hoisted(() => ({ appendLog: vi.fn((p: unknown) => ({ type: 'deploy/appendLog', payload: p })), analyzePreDeploy: vi.fn(() => ({ warnings: [], hasCritical: false })), // Sub-component markers. - ApiErrorBanner: vi.fn(() => null), - DeployControls: vi.fn(() => null), - DeployDiagnosis: vi.fn(() => null), - DeployInFlightPanel: vi.fn(() => null), - DestroyConfirmModal: vi.fn(() => null), - PlanPreview: vi.fn(() => null), - PreDeployWarnings: vi.fn(() => null), - RequirementsSection: vi.fn(() => null), - ResultsSummary: vi.fn(() => null), - AuthBanner: vi.fn(() => null), - ConfigSection: vi.fn(() => null), - DeployedResourcesList: vi.fn(() => null), - DnsRecordsSection: vi.fn(() => null), - LogPanel: vi.fn(() => null), - StatusBadge: vi.fn(() => null), - PanelHeader: vi.fn(() => null), + ApiErrorBanner: vi.fn((..._args: unknown[]) => null), + DeployControls: vi.fn((..._args: unknown[]) => null), + DeployDiagnosis: vi.fn((..._args: unknown[]) => null), + DeployInFlightPanel: vi.fn((..._args: unknown[]) => null), + DestroyConfirmModal: vi.fn((..._args: unknown[]) => null), + PlanPreview: vi.fn((..._args: unknown[]) => null), + PreDeployWarnings: vi.fn((..._args: unknown[]) => null), + RequirementsSection: vi.fn((..._args: unknown[]) => null), + ResultsSummary: vi.fn((..._args: unknown[]) => null), + AuthBanner: vi.fn((..._args: unknown[]) => null), + ConfigSection: vi.fn((..._args: unknown[]) => null), + DeployedResourcesList: vi.fn((..._args: unknown[]) => null), + DnsRecordsSection: vi.fn((..._args: unknown[]) => null), + LogPanel: vi.fn((..._args: unknown[]) => null), + StatusBadge: vi.fn((..._args: unknown[]) => null), + PanelHeader: vi.fn((..._args: unknown[]) => null), // Hook returns. useDeployActions: vi.fn(() => ({ fetchRequirements: vi.fn(), diff --git a/packages/ui/src/features/deploy/components/__tests__/requirements-section.test.tsx b/packages/ui/src/features/deploy/components/__tests__/requirements-section.test.tsx index 359f46a6..5e99efd7 100644 --- a/packages/ui/src/features/deploy/components/__tests__/requirements-section.test.tsx +++ b/packages/ui/src/features/deploy/components/__tests__/requirements-section.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; const mocks = vi.hoisted(() => ({ - DnsRecordCard: vi.fn(() => null), + DnsRecordCard: vi.fn((..._args: unknown[]) => null), })); vi.mock('../dns-record-card', () => ({ DnsRecordCard: mocks.DnsRecordCard })); diff --git a/packages/ui/src/features/deploy/components/__tests__/results-summary.test.tsx b/packages/ui/src/features/deploy/components/__tests__/results-summary.test.tsx index dd967830..08bdabaf 100644 --- a/packages/ui/src/features/deploy/components/__tests__/results-summary.test.tsx +++ b/packages/ui/src/features/deploy/components/__tests__/results-summary.test.tsx @@ -584,7 +584,7 @@ describe('ResultsSummary — URL click handlers', () => { })[0]; expect(valueSpan).toBeDefined(); const onClick = (valueSpan.props as { onClick: (e: React.MouseEvent) => void }).onClick; - onClick({ shiftKey: false } as React.MouseEvent); + onClick({ shiftKey: false } as unknown as React.MouseEvent); expect(mocks.openExternalUrl).toHaveBeenCalledWith('https://foo.example'); expect(mocks.writeText).not.toHaveBeenCalled(); }); @@ -598,7 +598,7 @@ describe('ResultsSummary — URL click handlers', () => { return typeof title === 'string' && title.startsWith('Click to open · Shift+click to copy: '); })[0]; const onClick = (valueSpan.props as { onClick: (e: React.MouseEvent) => void }).onClick; - onClick({ shiftKey: true } as React.MouseEvent); + onClick({ shiftKey: true } as unknown as React.MouseEvent); expect(mocks.writeText).toHaveBeenCalledWith('https://foo.example'); expect(mocks.openExternalUrl).not.toHaveBeenCalled(); }); @@ -613,7 +613,7 @@ describe('ResultsSummary — URL click handlers', () => { })[0]; expect(valueSpan).toBeDefined(); const onClick = (valueSpan.props as { onClick: (e: React.MouseEvent) => void }).onClick; - onClick({ shiftKey: false } as React.MouseEvent); + onClick({ shiftKey: false } as unknown as React.MouseEvent); expect(mocks.writeText).toHaveBeenCalledWith('gs://my-bucket'); expect(mocks.openExternalUrl).not.toHaveBeenCalled(); }); @@ -739,13 +739,13 @@ describe('ResultsSummary — default_url row', () => { mocks.openExternalUrl.mockClear(); mocks.writeText.mockClear(); - onClick({ shiftKey: false } as React.MouseEvent); + onClick({ shiftKey: false } as unknown as React.MouseEvent); expect(mocks.openExternalUrl).toHaveBeenCalledWith('https://default.example'); expect(mocks.writeText).not.toHaveBeenCalled(); mocks.openExternalUrl.mockClear(); mocks.writeText.mockClear(); - onClick({ shiftKey: true } as React.MouseEvent); + onClick({ shiftKey: true } as unknown as React.MouseEvent); expect(mocks.writeText).toHaveBeenCalledWith('https://default.example'); expect(mocks.openExternalUrl).not.toHaveBeenCalled(); }); diff --git a/packages/ui/src/features/palette/components/__tests__/folder-row.test.tsx b/packages/ui/src/features/palette/components/__tests__/folder-row.test.tsx index c75271d4..ef4f9094 100644 --- a/packages/ui/src/features/palette/components/__tests__/folder-row.test.tsx +++ b/packages/ui/src/features/palette/components/__tests__/folder-row.test.tsx @@ -219,7 +219,7 @@ describe('FolderRow — header click + context menu', () => { const onContextMenu = vi.fn(); const tree = render({ onContextMenu }); const header = getHeaderRow(tree); - const ev = {} as React.MouseEvent; + const ev = {} as unknown as React.MouseEvent; (header.props.onContextMenu as (e: React.MouseEvent) => void)(ev); expect(onContextMenu).toHaveBeenCalledWith(ev, 'folder', 'f-root'); }); diff --git a/packages/ui/src/features/palette/components/__tests__/project-tree-branches.test.tsx b/packages/ui/src/features/palette/components/__tests__/project-tree-branches.test.tsx index c5f7111d..65b04a5a 100644 --- a/packages/ui/src/features/palette/components/__tests__/project-tree-branches.test.tsx +++ b/packages/ui/src/features/palette/components/__tests__/project-tree-branches.test.tsx @@ -452,7 +452,7 @@ describe('ProjectTree — tree container drag/drop wiring', () => { el.props.className.includes('flex-1 overflow-y-auto overflow-x-hidden'), ); const event = {}; - (container!.props.onDragOver as (e: unknown, parent: unknown) => void)(event); + (container!.props.onDragOver as (e: unknown, parent: unknown) => void)(event, null); expect(mocks.useTreeDragRet.handleDragOver).toHaveBeenCalledWith(event, null); }); @@ -476,7 +476,7 @@ describe('ProjectTree — tree container drag/drop wiring', () => { el.props.className.includes('flex-1 overflow-y-auto overflow-x-hidden'), ); const event = {}; - (container!.props.onDrop as (e: unknown, parent: unknown) => void)(event); + (container!.props.onDrop as (e: unknown, parent: unknown) => void)(event, null); expect(mocks.useTreeDragRet.handleDrop).toHaveBeenCalledWith(event, null); }); }); diff --git a/packages/ui/src/features/templates/components/__tests__/template-picker.test.tsx b/packages/ui/src/features/templates/components/__tests__/template-picker.test.tsx index d954899c..45936260 100644 --- a/packages/ui/src/features/templates/components/__tests__/template-picker.test.tsx +++ b/packages/ui/src/features/templates/components/__tests__/template-picker.test.tsx @@ -51,7 +51,7 @@ const mocks = vi.hoisted(() => { state: { ui: { splitView: { activePaneId: 'pane-1' } } }, dispatch: vi.fn(), searchSpy: vi.fn(), - expandSpy: vi.fn(() => ({ nodes: [{ id: 'n1' }], edges: [] })), + expandSpy: vi.fn((..._args: unknown[]) => ({ nodes: [{ id: 'n1' }], edges: [] })), createCardSpy: vi.fn((arg: unknown) => ({ type: 'cards/create', payload: arg })), importToActiveCardSpy: vi.fn((arg: unknown) => ({ type: 'cards/import', payload: arg })), openTabInPaneSpy: vi.fn((arg: unknown) => ({ type: 'ui/openTab', payload: arg })), diff --git a/packages/ui/src/features/wizard/hooks/__tests__/use-wizard-state.test.ts b/packages/ui/src/features/wizard/hooks/__tests__/use-wizard-state.test.ts index 27ba7b46..4e4fb8af 100644 --- a/packages/ui/src/features/wizard/hooks/__tests__/use-wizard-state.test.ts +++ b/packages/ui/src/features/wizard/hooks/__tests__/use-wizard-state.test.ts @@ -233,7 +233,9 @@ describe('useWizardState — step 2 setters', () => { it('applyEnvironmentPresets enables matching presets and disables others', () => { const out = useWizardState(); - out.applyEnvironmentPresets([{ type: 'production', region: 'eu-west1', securityLevel: 'strict' }]); + out.applyEnvironmentPresets([ + { type: 'production', name: 'production', region: 'eu-west1', securityLevel: 'strict' }, + ]); const next = driveReducer({ step: 2, projectName: '', diff --git a/packages/ui/src/shared/api/__tests__/axios-instance.test.ts b/packages/ui/src/shared/api/__tests__/axios-instance.test.ts index 1c286487..4e9e50f6 100644 --- a/packages/ui/src/shared/api/__tests__/axios-instance.test.ts +++ b/packages/ui/src/shared/api/__tests__/axios-instance.test.ts @@ -53,7 +53,7 @@ const mockInstance = { }, }; -const createSpy = vi.fn(() => mockInstance); +const createSpy = vi.fn((..._args: unknown[]) => mockInstance); vi.mock('axios', () => ({ default: { create: (...a: unknown[]) => createSpy(...(a as [])) }, diff --git a/packages/ui/src/shared/components/__tests__/inline-table-view.test.tsx b/packages/ui/src/shared/components/__tests__/inline-table-view.test.tsx index 5e9d96d6..9fadd3ac 100644 --- a/packages/ui/src/shared/components/__tests__/inline-table-view.test.tsx +++ b/packages/ui/src/shared/components/__tests__/inline-table-view.test.tsx @@ -32,12 +32,12 @@ const mocks = vi.hoisted(() => ({ navigate: vi.fn(), pathname: '/projects/p1/canvas/table', // Sub-components — markers. - Toolbar: vi.fn(() => null), - ColumnHeader: vi.fn(() => null), - TableBody: vi.fn(() => null), - TableFooter: vi.fn(() => null), + Toolbar: vi.fn((..._args: unknown[]) => null), + ColumnHeader: vi.fn((..._args: unknown[]) => null), + TableBody: vi.fn((..._args: unknown[]) => null), + TableFooter: vi.fn((..._args: unknown[]) => null), // useTableRows mock. - useTableRows: vi.fn(() => ({ + useTableRows: vi.fn((..._args: unknown[]) => ({ rows: [{ node: { id: 'n1' }, label: 'A' }], sorted: [{ node: { id: 'n1' }, label: 'A' }], grouped: [], @@ -134,7 +134,7 @@ function* walk(node: unknown): Generator { } if (!isEl(node)) return; yield node; - if (KNOWN_MOCKS.includes(node.type as ReturnType)) { + if ((KNOWN_MOCKS as unknown[]).includes(node.type)) { yield* walk(node.props.children); return; } @@ -354,7 +354,7 @@ describe('InlineTableView — TableBody wiring', () => { onSelectRow: (id: string, e: React.MouseEvent) => void; } ).onSelectRow; - onSelect('n1', { metaKey: false, ctrlKey: false } as React.MouseEvent); + onSelect('n1', { metaKey: false, ctrlKey: false } as unknown as React.MouseEvent); expect(mocks.setSelectedNodes).toHaveBeenCalledWith(['n1']); // showProperties=false so toggleProperties is dispatched. expect(mocks.toggleProperties).toHaveBeenCalled(); @@ -369,7 +369,7 @@ describe('InlineTableView — TableBody wiring', () => { onSelectRow: (id: string, e: React.MouseEvent) => void; } ).onSelectRow; - onSelect('n1', { metaKey: true } as React.MouseEvent); + onSelect('n1', { metaKey: true } as unknown as React.MouseEvent); expect(mocks.setSelectedNodes).toHaveBeenCalledWith(['n1']); }); @@ -382,7 +382,7 @@ describe('InlineTableView — TableBody wiring', () => { onSelectRow: (id: string, e: React.MouseEvent) => void; } ).onSelectRow; - onSelect('n1', { ctrlKey: true } as React.MouseEvent); + onSelect('n1', { ctrlKey: true } as unknown as React.MouseEvent); expect(mocks.setSelectedNodes).toHaveBeenCalledWith(['n2']); }); @@ -392,7 +392,7 @@ describe('InlineTableView — TableBody wiring', () => { const body = findFirst(tree, (el) => el.type === mocks.TableBody)!; (body.props as { onSelectRow: (id: string, e: React.MouseEvent) => void }).onSelectRow('n1', { metaKey: false, - } as React.MouseEvent); + } as unknown as React.MouseEvent); expect(mocks.toggleProperties).not.toHaveBeenCalled(); }); diff --git a/packages/ui/src/store/__tests__/index.test.ts b/packages/ui/src/store/__tests__/index.test.ts index e7f644d1..2d230a54 100644 --- a/packages/ui/src/store/__tests__/index.test.ts +++ b/packages/ui/src/store/__tests__/index.test.ts @@ -388,7 +388,7 @@ describe('store — defensive branches in card persistence', () => { } expect(mocks.graphSave).toHaveBeenCalledTimes(1); // Resolve the first to clean up - resolveFirst?.(); + (resolveFirst as (() => void) | null)?.(); for (let i = 0; i < 5; i++) { await Promise.resolve(); } diff --git a/packages/web/src/pages/__tests__/invite-accept.test.tsx b/packages/web/src/pages/__tests__/invite-accept.test.tsx index 3a3976e0..7c6d47f3 100644 --- a/packages/web/src/pages/__tests__/invite-accept.test.tsx +++ b/packages/web/src/pages/__tests__/invite-accept.test.tsx @@ -27,7 +27,7 @@ const mocks = vi.hoisted(() => { }, token: 'tk-1' as string | undefined, navigate: vi.fn(), - isAuthenticated: vi.fn(() => true), + isAuthenticated: vi.fn((..._args: unknown[]) => true), axiosPost: vi.fn(), }; }); From 6238278d8aed6bd9b565a1859879a637ebdbc965 Mon Sep 17 00:00:00 2001 From: Julia Kafarska Date: Mon, 25 May 2026 13:16:37 +0200 Subject: [PATCH 50/52] docs(architecture): explain how canvas edges become cloud infra --- package.json | 2 +- .../extractors/__tests__/compute.test.ts | 76 + .../core/src/deploy/extractors/compute.ts | 72 +- .../pass-1-46-socket-port-targeting.test.ts | 161 ++ .../passes/pass-1-46-socket-port-targeting.ts | 95 + .../categories/compute.ts | 20 + packages/core/tsconfig.tsbuildinfo | 2 +- packages/types/src/connection-rules.ts | 2 + .../types/src/connection-rules/rules-data.ts | 65 +- packages/types/src/index.ts | 2 + .../types/src/ports/__tests__/derive.test.ts | 248 +++ .../types/src/ports/__tests__/infer.test.ts | 60 + .../types/src/ports/__tests__/match.test.ts | 183 ++ packages/types/src/ports/derive.ts | 87 + packages/types/src/ports/index.ts | 21 + packages/types/src/ports/infer.ts | 69 + packages/types/src/ports/match.ts | 94 + packages/types/src/ports/position.ts | 50 + packages/types/src/ports/schemas/ai.ts | 101 + packages/types/src/ports/schemas/compute.ts | 323 ++++ packages/types/src/ports/schemas/config.ts | 21 + packages/types/src/ports/schemas/data.ts | 61 + packages/types/src/ports/schemas/index.ts | 75 + packages/types/src/ports/schemas/messaging.ts | 77 + .../types/src/ports/schemas/monitoring.ts | 19 + packages/types/src/ports/schemas/network.ts | 105 ++ packages/types/src/ports/schemas/security.ts | 21 + packages/types/src/ports/schemas/source.ts | 22 + packages/types/src/ports/schemas/util.ts | 29 + packages/types/src/ports/types.ts | 235 +++ .../sockets/__tests__/derive-sockets.test.ts | 194 ++ packages/types/src/sockets/derive-sockets.ts | 206 ++ packages/types/src/sockets/index.ts | 17 + packages/types/src/sockets/schemas/index.ts | 30 + .../types/src/sockets/schemas/postgres.ts | 33 + .../src/sockets/schemas/scalable-backend.ts | 27 + .../types/src/sockets/schemas/static-site.ts | 17 + packages/types/src/sockets/socket-schema.ts | 54 + packages/types/src/sockets/types.ts | 78 + .../connection-preview-overlay.test.tsx | 317 +--- .../components/__tests__/svg-canvas.test.tsx | 4 + .../__tests__/svg-connection-path.test.tsx | 26 +- .../add-menu/__tests__/fuzzy-match.test.ts | 47 + .../canvas/components/add-menu/fuzzy-match.ts | 52 + .../canvas/components/add-menu/spotlight.tsx | 323 ++++ .../add-menu/use-spotlight-state.ts | 60 + .../__tests__/canvas-content.test.tsx | 8 +- .../canvas-renderer/canvas-content.tsx | 62 +- .../components/connection-preview-overlay.tsx | 53 +- .../_shared/__tests__/typed-sockets.test.tsx | 119 ++ .../components/nodes/_shared/card-shell.tsx | 64 +- .../nodes/_shared/connection-drag-context.tsx | 110 ++ .../components/nodes/_shared/socket-dot.tsx | 228 +++ .../nodes/_shared/socket-hover-tooltip.tsx | 120 ++ .../nodes/_shared/typed-sockets.tsx | 204 ++ .../components/nodes/custom-domain/index.tsx | 69 +- .../group-node/__tests__/group-lod3.test.tsx | 16 +- .../nodes/group-node/group-label-row.tsx | 25 +- .../nodes/group-node/group-lod3.tsx | 26 +- .../__tests__/passthrough.test.ts | 33 + .../nodes/reroute-node/passthrough.ts | 22 + .../path/__tests__/compute-path.test.ts | 179 +- .../path/__tests__/magnetic-attach.test.ts | 85 + .../canvas/components/path/compute-path.ts | 167 +- .../canvas/components/path/magnetic-attach.ts | 114 ++ .../features/canvas/components/path/types.ts | 8 + .../features/canvas/components/svg-canvas.tsx | 92 +- .../canvas/components/svg-connection-path.tsx | 91 +- .../canvas/hooks/use-group-shortcut.ts | 46 + .../canvas/utils/connection-rejection.ts | 15 +- .../palette/__tests__/components-data.test.ts | 6 +- .../src/features/palette/data/components.ts | 6 + .../features/properties/utils/port-spec.ts | 62 + packages/ui/src/store/slices/cards/types.ts | 9 +- packages/ui/src/store/slices/ui-slice.ts | 36 + state/archive/.gitkeep | 0 state/archive/learnings-2026-Q2.md | 1654 ----------------- state/blueprints/rf-canv.md | 217 --- state/blueprints/rf-cards.md | 112 -- state/blueprints/rf-cstor.md | 68 - state/blueprints/rf-ctrans.md | 79 - state/blueprints/rf-deploy.md | 174 -- state/blueprints/rf-fbh.md | 77 - state/blueprints/rf-lex.md | 51 - state/blueprints/rf-parse.md | 64 - state/blueprints/rf-pdpl.md | 67 - state/blueprints/rf-props.md | 222 --- state/blueprints/tour.md | 1121 ----------- state/decisions.md | 164 -- state/findings.md | 374 ---- state/learnings.md | 1110 ----------- state/progress.md | 230 --- state/refactor-targets.md | 211 --- state/shared-modules.md | 1641 ---------------- 94 files changed, 5387 insertions(+), 8207 deletions(-) create mode 100644 packages/core/src/deploy/passes/__tests__/pass-1-46-socket-port-targeting.test.ts create mode 100644 packages/core/src/deploy/passes/pass-1-46-socket-port-targeting.ts create mode 100644 packages/types/src/ports/__tests__/derive.test.ts create mode 100644 packages/types/src/ports/__tests__/infer.test.ts create mode 100644 packages/types/src/ports/__tests__/match.test.ts create mode 100644 packages/types/src/ports/derive.ts create mode 100644 packages/types/src/ports/index.ts create mode 100644 packages/types/src/ports/infer.ts create mode 100644 packages/types/src/ports/match.ts create mode 100644 packages/types/src/ports/position.ts create mode 100644 packages/types/src/ports/schemas/ai.ts create mode 100644 packages/types/src/ports/schemas/compute.ts create mode 100644 packages/types/src/ports/schemas/config.ts create mode 100644 packages/types/src/ports/schemas/data.ts create mode 100644 packages/types/src/ports/schemas/index.ts create mode 100644 packages/types/src/ports/schemas/messaging.ts create mode 100644 packages/types/src/ports/schemas/monitoring.ts create mode 100644 packages/types/src/ports/schemas/network.ts create mode 100644 packages/types/src/ports/schemas/security.ts create mode 100644 packages/types/src/ports/schemas/source.ts create mode 100644 packages/types/src/ports/schemas/util.ts create mode 100644 packages/types/src/ports/types.ts create mode 100644 packages/types/src/sockets/__tests__/derive-sockets.test.ts create mode 100644 packages/types/src/sockets/derive-sockets.ts create mode 100644 packages/types/src/sockets/index.ts create mode 100644 packages/types/src/sockets/schemas/index.ts create mode 100644 packages/types/src/sockets/schemas/postgres.ts create mode 100644 packages/types/src/sockets/schemas/scalable-backend.ts create mode 100644 packages/types/src/sockets/schemas/static-site.ts create mode 100644 packages/types/src/sockets/socket-schema.ts create mode 100644 packages/types/src/sockets/types.ts create mode 100644 packages/ui/src/features/canvas/components/add-menu/__tests__/fuzzy-match.test.ts create mode 100644 packages/ui/src/features/canvas/components/add-menu/fuzzy-match.ts create mode 100644 packages/ui/src/features/canvas/components/add-menu/spotlight.tsx create mode 100644 packages/ui/src/features/canvas/components/add-menu/use-spotlight-state.ts create mode 100644 packages/ui/src/features/canvas/components/nodes/_shared/__tests__/typed-sockets.test.tsx create mode 100644 packages/ui/src/features/canvas/components/nodes/_shared/connection-drag-context.tsx create mode 100644 packages/ui/src/features/canvas/components/nodes/_shared/socket-dot.tsx create mode 100644 packages/ui/src/features/canvas/components/nodes/_shared/socket-hover-tooltip.tsx create mode 100644 packages/ui/src/features/canvas/components/nodes/_shared/typed-sockets.tsx create mode 100644 packages/ui/src/features/canvas/components/nodes/reroute-node/__tests__/passthrough.test.ts create mode 100644 packages/ui/src/features/canvas/components/nodes/reroute-node/passthrough.ts create mode 100644 packages/ui/src/features/canvas/components/path/__tests__/magnetic-attach.test.ts create mode 100644 packages/ui/src/features/canvas/components/path/magnetic-attach.ts create mode 100644 packages/ui/src/features/canvas/hooks/use-group-shortcut.ts create mode 100644 packages/ui/src/features/properties/utils/port-spec.ts delete mode 100644 state/archive/.gitkeep delete mode 100644 state/archive/learnings-2026-Q2.md delete mode 100644 state/blueprints/rf-canv.md delete mode 100644 state/blueprints/rf-cards.md delete mode 100644 state/blueprints/rf-cstor.md delete mode 100644 state/blueprints/rf-ctrans.md delete mode 100644 state/blueprints/rf-deploy.md delete mode 100644 state/blueprints/rf-fbh.md delete mode 100644 state/blueprints/rf-lex.md delete mode 100644 state/blueprints/rf-parse.md delete mode 100644 state/blueprints/rf-pdpl.md delete mode 100644 state/blueprints/rf-props.md delete mode 100644 state/blueprints/tour.md delete mode 100644 state/decisions.md delete mode 100644 state/findings.md delete mode 100644 state/learnings.md delete mode 100644 state/progress.md delete mode 100644 state/refactor-targets.md delete mode 100644 state/shared-modules.md diff --git a/package.json b/package.json index 363008b6..3b07d7c6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.772", + "version": "0.1.773", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/core/src/deploy/extractors/__tests__/compute.test.ts b/packages/core/src/deploy/extractors/__tests__/compute.test.ts index 92c08063..7a7881ba 100644 --- a/packages/core/src/deploy/extractors/__tests__/compute.test.ts +++ b/packages/core/src/deploy/extractors/__tests__/compute.test.ts @@ -18,6 +18,7 @@ import { extract_cloud_run_job_properties, extract_cloud_functions_properties, extract_cloud_scheduler_properties, + parse_exposed_ports, } from '../compute'; describe('extract_cloud_run_properties', () => { @@ -85,6 +86,81 @@ describe('extract_cloud_run_properties', () => { const result = extract_cloud_run_properties({ labels: { keep: 'me' } }, 'us-central1'); expect(result.labels).toEqual({}); }); + + it('honors exposed_ports — primary port is the first entry, full list in additional_ports', () => { + const result = extract_cloud_run_properties( + { + exposed_ports: [ + JSON.stringify({ port: 8080, protocol: 'http', label: 'api' }), + JSON.stringify({ port: 8443, protocol: 'https' }), + ], + }, + 'us-central1', + ); + expect(result.port).toBe(8080); + expect(result.additional_ports).toEqual([ + { port: 8080, protocol: 'http', label: 'api' }, + { port: 8443, protocol: 'https' }, + ]); + }); + + it('exposed_ports wins over the legacy data.port scalar when both are set', () => { + const result = extract_cloud_run_properties( + { + port: 3000, + exposed_ports: [JSON.stringify({ port: 8080, protocol: 'http' })], + }, + 'us-central1', + ); + expect(result.port).toBe(8080); + }); + + it('falls back to data.port when exposed_ports is missing', () => { + const result = extract_cloud_run_properties({ port: 3000 }, 'us-central1'); + expect(result.port).toBe(3000); + expect(result.additional_ports).toBeUndefined(); + }); +}); + +describe('parse_exposed_ports', () => { + it('returns [] for missing or non-array input', () => { + expect(parse_exposed_ports({})).toEqual([]); + expect(parse_exposed_ports({ exposed_ports: 'nope' })).toEqual([]); + expect(parse_exposed_ports({ exposed_ports: null })).toEqual([]); + }); + + it('parses JSON-stringified entries', () => { + const out = parse_exposed_ports({ + exposed_ports: [JSON.stringify({ port: 8080, protocol: 'http', label: 'api' })], + }); + expect(out).toEqual([{ port: 8080, protocol: 'http', label: 'api' }]); + }); + + it('parses compact text entries like "https:443:web"', () => { + expect(parse_exposed_ports({ exposed_ports: ['https:443:web'] })).toEqual([ + { port: 443, protocol: 'https', label: 'web' }, + ]); + }); + + it('parses plain object entries', () => { + expect(parse_exposed_ports({ exposed_ports: [{ port: 9000, protocol: 'tcp' }] })).toEqual([ + { port: 9000, protocol: 'tcp' }, + ]); + }); + + it('defaults to http when protocol is unknown', () => { + expect(parse_exposed_ports({ exposed_ports: [{ port: 8080, protocol: 'weird' }] })).toEqual([ + { port: 8080, protocol: 'http' }, + ]); + }); + + it('drops malformed entries silently', () => { + expect( + parse_exposed_ports({ + exposed_ports: ['', 'garbage', JSON.stringify({ port: 0 }), JSON.stringify({ port: 5000 })], + }), + ).toEqual([{ port: 5000, protocol: 'http' }]); + }); }); describe('extract_cloud_run_job_properties', () => { diff --git a/packages/core/src/deploy/extractors/compute.ts b/packages/core/src/deploy/extractors/compute.ts index 819044a3..69ad1030 100644 --- a/packages/core/src/deploy/extractors/compute.ts +++ b/packages/core/src/deploy/extractors/compute.ts @@ -11,13 +11,83 @@ import { normalize_runtime } from '../utils/name-utils'; +/** + * Parses the `exposed_ports` array from `data` into typed entries. + * Each entry is either a JSON string (`{port, protocol, label?}`) or + * a compact text form (`"https:443"`, `"https:443:api"`) — matches + * `port-spec.ts` in the UI package. Returns `[]` for absent / malformed + * data so callers can safely default. + */ +export interface ExposedPort { + port: number; + protocol: 'http' | 'https' | 'tcp'; + label?: string; +} + +export function parse_exposed_ports(data: Record): ExposedPort[] { + const raw = data.exposed_ports; + if (!Array.isArray(raw)) return []; + const out: ExposedPort[] = []; + for (const entry of raw) { + if (typeof entry === 'string') { + try { + const parsed = JSON.parse(entry) as { port?: unknown; protocol?: unknown; label?: unknown }; + if (parsed && typeof parsed.port === 'number' && parsed.port > 0) { + const protocol: ExposedPort['protocol'] = + parsed.protocol === 'https' || parsed.protocol === 'tcp' ? parsed.protocol : 'http'; + out.push({ + port: parsed.port, + protocol, + ...(typeof parsed.label === 'string' && parsed.label ? { label: parsed.label } : {}), + }); + continue; + } + } catch { + /* fall through to compact form */ + } + const parts = entry.split(':'); + if (parts.length >= 2 && (parts[0] === 'http' || parts[0] === 'https' || parts[0] === 'tcp')) { + const p = Number(parts[1]); + if (Number.isFinite(p) && p > 0) { + out.push({ + port: p, + protocol: parts[0] as ExposedPort['protocol'], + ...(parts[2] ? { label: parts[2] } : {}), + }); + } + } + } else if (entry && typeof entry === 'object') { + const obj = entry as { port?: unknown; protocol?: unknown; label?: unknown }; + if (typeof obj.port === 'number' && obj.port > 0) { + const protocol: ExposedPort['protocol'] = + obj.protocol === 'https' || obj.protocol === 'tcp' ? obj.protocol : 'http'; + out.push({ + port: obj.port, + protocol, + ...(typeof obj.label === 'string' && obj.label ? { label: obj.label } : {}), + }); + } + } + } + return out; +} + export function extract_cloud_run_properties(data: Record, region: string): Record { + // Multi-port: when the user declares `exposed_ports` on a Container / + // BackendAPI block, the first entry becomes the primary listener and + // the full list is forwarded as `additional_ports` so the deployer + // can configure all of them (e.g. Container App ingress / ECS + // listener rules). Legacy `data.port` scalar is the back-compat + // fallback for blocks that haven't set `exposed_ports`. + const ports = parse_exposed_ports(data); + const primaryPort = ports[0]?.port ?? (data.port as number | undefined) ?? 8080; return { region, image: (data.image as string) || '', repository: (data.repository as string) || '', branch: (data.branch as string) || 'main', - port: data.port || 8080, + port: primaryPort, + ...(ports.length > 0 && { additional_ports: ports }), min_instances: data.minInstances ?? 0, max_instances: data.maxInstances ?? 3, cpu: data.cpu || '1', diff --git a/packages/core/src/deploy/passes/__tests__/pass-1-46-socket-port-targeting.test.ts b/packages/core/src/deploy/passes/__tests__/pass-1-46-socket-port-targeting.test.ts new file mode 100644 index 00000000..e43bf640 --- /dev/null +++ b/packages/core/src/deploy/passes/__tests__/pass-1-46-socket-port-targeting.test.ts @@ -0,0 +1,161 @@ +/** + * Tests for `passes/pass-1-46-socket-port-targeting.ts`. + * + * Pass 1.46 reads `edge.data.targetSocket` / `sourceSocket` and, when + * the id matches the `port--(in|out)` shape, writes the port onto + * the compute node's `target_port` (and `port` when not explicitly + * set). This is what lets a typed wiring on a multi-port Container + * actually drive what the deployer routes to. + */ + +import { describe, it, expect } from 'vitest'; +import { create_mutable_graph } from '../../../graph/mutable-graph'; +import { propagate_socket_port_targets } from '../pass-1-46-socket-port-targeting'; +import type { CardEdgeInput, CardNodeInput } from '../../card-translator'; + +function setup_graph( + computeName: string, + initialProps: Record = {}, +): { graph: ReturnType; nodeName: string } { + const graph = create_mutable_graph('test-project'); + const result = graph.add_node({ + type: 'gcp.run.service', + name: computeName, + properties: { region: 'us-central1', ...initialProps }, + }); + if (!result.success || !result.node) { + throw new Error(`fixture setup failed: ${result.errors?.join(', ')}`); + } + return { graph, nodeName: result.node.name }; +} + +describe('propagate_socket_port_targets', () => { + it('writes target_port when targetSocket is `port--in`', () => { + const { graph, nodeName } = setup_graph('backend-1'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: { iceType: 'Network.CustomDomain' } }, + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + ]; + const edges: CardEdgeInput[] = [ + { + id: 'e1', + source: 'cd', + target: 'backend', + data: { sourceSocket: 'domain-out-r1', targetSocket: 'port-8080-in' }, + }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBe(8080); + expect(props.port).toBe(8080); + }); + + it('writes the port when sourceSocket is `port--out` (backend exposes its listener)', () => { + const { graph, nodeName } = setup_graph('backend-2'); + const nodes: CardNodeInput[] = [ + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + { id: 'gw', type: 'block', data: { iceType: 'Network.Gateway' } }, + ]; + const edges: CardEdgeInput[] = [ + { + id: 'e2', + source: 'backend', + target: 'gw', + data: { sourceSocket: 'port-3000-out', targetSocket: 'upstream-in' }, + }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBe(3000); + expect(props.port).toBe(3000); + }); + + it("doesn't overwrite a user-set port (but still writes target_port)", () => { + const { graph, nodeName } = setup_graph('backend-3', { port: 9999 }); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: { iceType: 'Network.CustomDomain' } }, + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + ]; + const edges: CardEdgeInput[] = [ + { + id: 'e3', + source: 'cd', + target: 'backend', + data: { targetSocket: 'port-8080-in' }, + }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.port).toBe(9999); // user value preserved + expect(props.target_port).toBe(8080); // routing target still captured + }); + + it('ignores edges without typed sockets', () => { + const { graph, nodeName } = setup_graph('backend-4'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: { iceType: 'Network.CustomDomain' } }, + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + ]; + const edges: CardEdgeInput[] = [ + { id: 'e4', source: 'cd', target: 'backend', data: { relationship: 'connects_to' } }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBeUndefined(); + }); + + it('ignores non-port socket ids (`domain-in`, `repository-in`, etc.)', () => { + const { graph, nodeName } = setup_graph('backend-5'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: { iceType: 'Network.CustomDomain' } }, + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + ]; + const edges: CardEdgeInput[] = [ + { + id: 'e5', + source: 'cd', + target: 'backend', + data: { sourceSocket: 'domain-out-r1', targetSocket: 'domain-in' }, + }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBeUndefined(); + }); + + it('ignores malformed port socket ids (`port-abc-in`, `port--in`, ...)', () => { + const { graph, nodeName } = setup_graph('backend-6'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: {} }, + { id: 'backend', type: 'block', data: {} }, + ]; + const edges: CardEdgeInput[] = [ + { id: 'e', source: 'cd', target: 'backend', data: { targetSocket: 'port-abc-in' } }, + { id: 'e2', source: 'cd', target: 'backend', data: { targetSocket: 'port--in' } }, + { id: 'e3', source: 'cd', target: 'backend', data: { targetSocket: 'port-0-in' } }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBeUndefined(); + }); + + it('skips silently when the target node was not deployed', () => { + const { graph, nodeName } = setup_graph('backend-7'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: {} }, + { id: 'backend', type: 'block', data: {} }, + ]; + const edges: CardEdgeInput[] = [ + // edge.target is "ghost-node" which has no entry in idMap (was filtered out) + { id: 'e', source: 'cd', target: 'ghost-node', data: { targetSocket: 'port-8080-in' } }, + ]; + const idMap = new Map([['backend', nodeName]]); // ghost-node intentionally missing + expect(() => propagate_socket_port_targets(edges, nodes, idMap, graph)).not.toThrow(); + }); +}); diff --git a/packages/core/src/deploy/passes/pass-1-46-socket-port-targeting.ts b/packages/core/src/deploy/passes/pass-1-46-socket-port-targeting.ts new file mode 100644 index 00000000..76d350eb --- /dev/null +++ b/packages/core/src/deploy/passes/pass-1-46-socket-port-targeting.ts @@ -0,0 +1,95 @@ +/** + * Pass 1.46 — Socket-driven target-port routing. + * + * When an edge's `targetSocket` (or `sourceSocket`) id encodes a + * specific listener port (e.g. `port-8080-in`, `port-8443-out`), the + * deployer needs to know which port to wire the LB / DNS at. Without + * this pass the deployer falls back to `data.port` (a single scalar), + * which is what every multi-port wiring would collapse to. + * + * Naming convention (must match the port-schema authoring in + * `@ice/types/ports/schemas/compute.ts`): + * - `port--out` — HTTP/TCP listener N exposed by the service + * - `port--in` — explicit "route traffic to listener N" target + * + * For a wire `CustomDomain.domain-out-` → `Backend.port-8080-in`, + * this pass writes `target_port = 8080` onto the Backend graph node. + * Pass 1.45 (domain propagation) already wrote `domain` — together they + * give the LB enough to bind subdomain → backend port. + * + * Mutates `graph` node properties in place. Lives next to + * `pass-1-45-domain-propagation` because the two are commonly read + * together by provider deployers. + */ + +import type { MutableGraph } from '../../graph/mutable-graph'; +import type { CardEdgeInput, CardNodeInput } from '../card-translator'; + +/** Pulls `` out of `port--(in|out)` ids. Returns `null` if the id isn't a port id. */ +function extract_port_from_socket_id(socketId: string | undefined): number | null { + if (!socketId) return null; + const match = socketId.match(/^port-(\d+)-(?:in|out)$/); + if (!match) return null; + const n = Number(match[1]); + return Number.isFinite(n) && n > 0 ? n : null; +} + +export function propagate_socket_port_targets( + edges: CardEdgeInput[], + nodes: CardNodeInput[], + card_id_to_name: Map, + graph: MutableGraph, +): void { + for (const edge of edges) { + const data = edge.data ?? {}; + const sourceSocket = (data as { sourceSocket?: string }).sourceSocket; + const targetSocket = (data as { targetSocket?: string }).targetSocket; + + // Either end may encode the port. The TARGET-side socket is the + // common case (e.g. CustomDomain.domain-out-r1 → Backend.port-8080-in): + // the "8080" is on the target. But a Backend's `port-8080-out` → + // Gateway.upstream-in also has the port on the SOURCE side, so we + // handle both — the port goes to whichever end is the compute + // node being routed to. + const targetPort = extract_port_from_socket_id(targetSocket); + const sourcePort = extract_port_from_socket_id(sourceSocket); + + if (targetPort !== null) { + // Wire ends ON a port socket — that node's compute listener is + // the LB target. + writePortToNode(edge.target, targetPort, nodes, card_id_to_name, graph); + } + if (sourcePort !== null) { + // Wire emanates FROM a port socket — that source's listener is + // what the downstream block (gateway / LB / domain) routes to. + writePortToNode(edge.source, sourcePort, nodes, card_id_to_name, graph); + } + } +} + +function writePortToNode( + cardNodeId: string, + port: number, + nodes: CardNodeInput[], + card_id_to_name: Map, + graph: MutableGraph, +): void { + const node = nodes.find((n) => n.id === cardNodeId); + if (!node) return; + const graphName = card_id_to_name.get(node.id); + if (!graphName) return; + const graphNode = graph.get_node_by_name(graphName); + if (!graphNode) return; + const props = graphNode.properties as Record; + // Don't clobber an explicit user-set value. The socket-encoded port + // is a hint from the wiring; if the user manually set `port` in the + // properties panel, that wins (consistent with Pass 1.4's "explicit + // override always wins" rule). + if (props.port == null || props.port === 8080) { + props.port = port; + } + // Always record the LB target port — separate from the container + // listener port — so providers that distinguish (e.g. Container App + // ingress targetPort vs application port) can read both. + props.target_port = port; +} diff --git a/packages/core/src/resources/high-level-resources/categories/compute.ts b/packages/core/src/resources/high-level-resources/categories/compute.ts index 2067fa05..7124bb47 100644 --- a/packages/core/src/resources/high-level-resources/categories/compute.ts +++ b/packages/core/src/resources/high-level-resources/categories/compute.ts @@ -336,6 +336,16 @@ export const compute: HighLevelCategory = { description: 'Scale up when the metric exceeds this percentage', default: 70, }, + { + name: 'exposed_ports', + label: 'Exposed ports', + type: 'port_list', + required: false, + tier: 'detailed', + description: + 'HTTP/TCP listeners this API exposes. Each port becomes a typed output socket on the canvas — wire a custom domain to one and leave the others private.', + addLabel: 'Add port', + }, ], }, { @@ -966,6 +976,16 @@ export const compute: HighLevelCategory = { description: 'Scale up when the metric exceeds this percentage', default: 70, }, + { + name: 'exposed_ports', + label: 'Exposed ports', + type: 'port_list', + required: false, + tier: 'detailed', + description: + 'HTTP/TCP listeners this container exposes. Each port becomes a typed output socket on the canvas — wire a custom domain to one and leave the others private.', + addLabel: 'Add port', + }, ], }, { diff --git a/packages/core/tsconfig.tsbuildinfo b/packages/core/tsconfig.tsbuildinfo index 60d96d68..94926521 100644 --- a/packages/core/tsconfig.tsbuildinfo +++ b/packages/core/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","./src/types/graph.ts","./src/types/providers.ts","./src/types/deployment.ts","./src/types/errors.ts","./src/types/result.ts","./src/types/index.ts","./src/schema/schema-provider.ts","./src/schema/resource-validator-types.ts","./src/schema/validation/error-conversion.ts","./src/schema/validation/constraints.ts","./src/schema/validation/type-checker.ts","./src/schema/validation/property-validator.ts","./src/schema/resource-validator.ts","./src/schema/type-mapper.ts","./src/schema/embedded/events.ts","./src/schema/embedded/sqlite-types.ts","./src/schema/embedded/converters.ts","./src/schema/embedded/graph-queries.ts","./src/schemas/db/index.ts","./src/schema/embedded/initialization.ts","./src/schema/embedded/queries.ts","./src/schema/embedded-schema-provider.ts","./src/schema/unified-type-resolver.ts","./src/schema/customization/base-db.ts","./src/schema/customization/paths.ts","./src/schema/customization/example-files.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/line-counter.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/errors.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/doc/applyreviver.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/log.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/tojs.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/scalar.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/stringify/stringify.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/collection.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/yamlseq.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/types.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/common/map.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/common/seq.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/common/string.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/stringify/foldflowlines.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/stringify/stringifynumber.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/stringify/stringifystring.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/util.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/yamlmap.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/identity.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/schema.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/doc/createnode.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/addpairtojsmap.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/pair.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/tags.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/options.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/node.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/cst-scalar.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/cst-stringify.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/cst-visit.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/cst.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/alias.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/doc/document.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/doc/directives.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/compose/composer.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/lexer.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/parser.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/public-api.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/yaml-1.1/omap.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/yaml-1.1/set.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/visit.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/index.d.ts","./src/schema/customization/file-validators.ts","./src/schema/customization/scanner.ts","./src/schema/customization-loader.ts","./src/schema/index.ts","./src/state/state-store.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/compatibility/index.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/globals.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/abortcontroller.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/domexception.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/events.d.ts","../../node_modules/.pnpm/buffer@5.7.1/node_modules/buffer/index.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/header.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/readable.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/file.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/fetch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/formdata.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/connector.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-origin.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool-stats.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/handlers.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-handler.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/api.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/interceptors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/util.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cookies.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/patch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/websocket.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/eventsource.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/filereader.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/content-type.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cache.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/index.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/fetch.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/navigator.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/storage.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/assert.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/assert/strict.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/async_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/buffer.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/child_process.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/cluster.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/console.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/constants.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/crypto.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/dgram.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/dns.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/dns/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/domain.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/events.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/fs.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/fs/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/http.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/http2.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/https.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/inspector.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/inspector.generated.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/module.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/net.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/os.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/path.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/perf_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/process.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/punycode.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/querystring.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/readline.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/readline/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/repl.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/sea.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/sqlite.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/stream.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/stream/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/stream/consumers.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/stream/web.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/string_decoder.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/test.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/timers.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/timers/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/tls.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/trace_events.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/tty.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/url.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/util.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/v8.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/vm.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/wasi.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/worker_threads.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/zlib.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/index.d.ts","../../node_modules/.pnpm/@types+better-sqlite3@7.6.13/node_modules/@types/better-sqlite3/index.d.ts","./src/state/sqlite/types.ts","./src/state/sqlite/resources.ts","./src/state/sqlite/deployments.ts","./src/state/sqlite/lifecycle.ts","./src/state/sqlite/locks.ts","./src/state/sqlite/snapshots.ts","./src/state/sqlite-state-store.ts","./src/state/index.ts","./src/graph/parser/tokens.ts","./src/graph/parser/ast/types/base.ts","./src/graph/parser/ast/types/expressions.ts","./src/graph/parser/ast/types/blocks.ts","./src/graph/parser/ast/types/statements.ts","./src/graph/parser/ast/types.ts","./src/graph/parser/ast/helpers.ts","./src/graph/parser/ast.ts","./src/graph/parser/lexer-state.ts","./src/graph/parser/lexer-scanners.ts","./src/graph/parser/lexer-heredoc.ts","./src/graph/parser/lexer.ts","./src/graph/parser/parser-state.ts","./src/graph/parser/parser-literals.ts","./src/graph/parser/parser-primary.ts","./src/graph/parser/parser-binary-exprs.ts","./src/graph/parser/parser-block-body.ts","./src/graph/parser/parser-statements.ts","./src/graph/parser/parser.ts","../../node_modules/.pnpm/@types+js-yaml@4.0.9/node_modules/@types/js-yaml/index.d.ts","../../node_modules/.pnpm/@types+js-yaml@4.0.9/node_modules/@types/js-yaml/index.d.mts","./src/graph/parser/format-parser.ts","./src/graph/parser/index.ts","./src/graph/mutable-graph/types.ts","./src/graph/mutable-graph/edges.ts","../constants/src/providers.ts","../constants/src/ice-types.ts","../constants/src/categories.ts","../constants/src/feature-flags.ts","../constants/src/ai.ts","../constants/src/derived.ts","../constants/src/grid.ts","../constants/src/connections.ts","../constants/src/node-traits.ts","../constants/src/templates.ts","../constants/src/index.ts","./src/graph/classifier/category-classifier.ts","./src/graph/mutable-graph/nodes.ts","./src/graph/mutable-graph/stats-serialize.ts","./src/graph/mutable-graph/traversal.ts","./src/graph/mutable-graph.ts","./src/graph/validator/base-validator.ts","./src/graph/validator/validators/schema.ts","./src/graph/validator/validators/security.ts","./src/graph/algorithms/topo-cycle.ts","./src/graph/algorithms/paths.ts","./src/graph/algorithms/components.ts","./src/graph/algorithms/analysis.ts","./src/graph/algorithms.ts","./src/graph/validator/validators/structure.ts","./src/graph/validator/validators.ts","./src/graph/validator/index.ts","./src/graph/classifier/index.ts","./src/graph/inference/relationship-inferrer.ts","./src/graph/inference/index.ts","./src/graph/index.ts","./src/providers/provider-registry.ts","./src/providers/index.ts","./src/importers/terraform/types.ts","./src/importers/terraform/type-mapper.ts","./src/importers/terraform/sensitive.ts","./src/importers/terraform/resource-conversion.ts","./src/importers/terraform/graph-conversion.ts","./src/importers/terraform/state-importer.ts","./src/importers/terraform/index.ts","./src/importers/pulumi/types.ts","./src/importers/pulumi/type-mapper/parse.ts","./src/importers/pulumi/type-mapper/data.ts","./src/importers/pulumi/type-mapper/mapping.ts","./src/importers/pulumi/type-mapper.ts","./src/importers/pulumi/parsing.ts","./src/importers/pulumi/resource-conversion.ts","./src/importers/pulumi/graph-conversion.ts","./src/importers/pulumi/state-importer.ts","./src/importers/pulumi/index.ts","./src/importers/gcp/types.ts","./src/importers/gcp/relationships.ts","./src/importers/gcp/services/base-service.ts","./src/importers/gcp/services/compute.ts","./src/importers/gcp/services/storage.ts","./src/errors/import-errors/types.ts","./src/errors/import-errors/gcp.ts","./src/errors/import-errors/aws.ts","./src/errors/import-errors/azure.ts","./src/errors/import-errors.ts","./src/resources/high-level-resources/types.ts","./src/resources/high-level-resources/categories/compute.ts","./src/resources/high-level-resources/categories/database.ts","./src/resources/high-level-resources/categories/messaging.ts","./src/resources/high-level-resources/categories/monitoring.ts","./src/resources/high-level-resources/categories/networking.ts","./src/resources/high-level-resources/categories/security.ts","./src/resources/high-level-resources/categories/storage.ts","./src/resources/high-level-resources/helpers.ts","./src/resources/high-level-resources.ts","./src/importers/gcp/services/asset-inventory.ts","./src/importers/gcp/services/index.ts","./src/importers/gcp/type-mapper.ts","./src/importers/gcp/gcp-importer.ts","./src/importers/gcp/index.ts","./src/importers/aws/arn-helpers.ts","./src/importers/aws/sdk-init.ts","./src/importers/aws/types.ts","./src/importers/aws/discovery.ts","./src/importers/aws/graph-conversion.ts","./src/importers/aws/type-mapper.ts","./src/importers/aws/aws-importer.ts","./src/importers/aws/index.ts","./src/importers/azure/type-mapper.ts","./src/importers/azure/types.ts","./src/importers/azure/azure-importer.ts","./src/importers/azure/index.ts","./src/importers/index.ts","./src/plan/diff.ts","./src/plan/plan-engine.ts","./src/plan/index.ts","./src/apply/types.ts","./src/providers/mock-provider.ts","./src/apply/apply-engine.ts","./src/apply/index.ts","./src/diff/types.ts","./src/diff/diff.ts","./src/diff/index.ts","./src/deploy/providers/gcp/messages.ts","./src/deploy/messages.ts","./src/deploy/types.ts","./src/deploy/scheduler/types.ts","./src/deploy/scheduler/dag.ts","./src/deploy/scheduler/predicates.ts","./src/deploy/scheduler/dispatch.ts","./src/deploy/scheduler/progress-wrapper.ts","./src/deploy/scheduler.ts","./src/deploy/deploy-engine.ts","./src/deploy/providers/gcp/types.ts","./src/deploy/providers/gcp/handlers/api-gateway.ts","./src/deploy/providers/gcp/handlers/backend-bucket.ts","./src/deploy/providers/gcp/handlers/bigquery.ts","./src/deploy/providers/gcp/handlers/cloud-armor.ts","./src/deploy/providers/gcp/handlers/cloud-functions.ts","./src/deploy/providers/gcp/handlers/cloud-build-helper.ts","./src/deploy/providers/gcp/handlers/cloud-run/image-resolver.ts","./src/deploy/providers/gcp/handlers/cloud-run/result-helpers.ts","./src/deploy/providers/gcp/handlers/cloud-run/utils.ts","./src/deploy/providers/gcp/handlers/cloud-run/create-job.ts","./src/deploy/providers/gcp/handlers/cloud-run/iam.ts","./src/deploy/providers/gcp/handlers/cloud-run/create-service.ts","./src/deploy/providers/gcp/handlers/cloud-run.ts","./src/deploy/providers/gcp/handlers/cloud-scheduler.ts","./src/deploy/providers/gcp/handlers/cloud-sql.ts","./src/deploy/providers/gcp/handlers/cloud-storage/bucket-creator.ts","./src/deploy/providers/gcp/handlers/cloud-storage/bucket-updater.ts","./src/deploy/providers/gcp/handlers/cloud-storage/bucket-utils.ts","./src/deploy/providers/gcp/handlers/cloud-storage/public-access-granter.ts","./src/deploy/providers/gcp/handlers/cloud-storage/placeholder-uploader.ts","./src/deploy/providers/gcp/handlers/cloud-storage/result-helpers.ts","./src/deploy/providers/gcp/handlers/cloud-storage.ts","./src/deploy/providers/gcp/handlers/dataflow.ts","./src/deploy/providers/gcp/handlers/discovery-engine.ts","./src/deploy/providers/gcp/handlers/domain-mapping.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/dns-extractor.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/rest-client.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/domain-registrar.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/tar-parser.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/github-downloader.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/result-helpers.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/site-provisioner.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/site-utils.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/version-publisher.ts","./src/deploy/providers/gcp/handlers/firebase-hosting.ts","./src/deploy/providers/gcp/handlers/firestore.ts","./src/deploy/providers/gcp/handlers/gke.ts","./src/deploy/providers/gcp/handlers/identity-platform.ts","./src/deploy/providers/gcp/handlers/load-balancer/result-helpers.ts","./src/deploy/providers/gcp/handlers/load-balancer/compute-ops.ts","./src/deploy/providers/gcp/handlers/load-balancer/backend-creator.ts","./src/deploy/providers/gcp/handlers/load-balancer/cert-fetcher.ts","./src/deploy/providers/gcp/handlers/load-balancer/url-builder.ts","./src/deploy/providers/gcp/handlers/load-balancer/lb-builder.ts","./src/deploy/providers/gcp/handlers/load-balancer.ts","./src/deploy/providers/gcp/handlers/logging.ts","./src/deploy/providers/gcp/handlers/managed-ssl-certificate.ts","./src/deploy/providers/gcp/handlers/memorystore.ts","./src/deploy/providers/gcp/handlers/pubsub.ts","./src/deploy/providers/gcp/handlers/secret-manager.ts","./src/deploy/providers/gcp/handlers/subnet.ts","./src/deploy/providers/gcp/handlers/vpc.ts","./src/deploy/providers/gcp/handlers/vertex-ai.ts","./src/deploy/providers/gcp/sdk-loader.ts","./src/deploy/providers/gcp/gcp-deployer.ts","./src/deploy/providers/gcp/auth.ts","./src/deploy/providers/gcp/index.ts","./src/deploy/providers/aws-deployer.ts","./src/deploy/providers/azure-deployer.ts","./src/deploy/providers/index.ts","./src/deploy/edge-classifier.ts","./src/deploy/extractors/ancillary.ts","./src/deploy/utils/name-utils.ts","./src/deploy/extractors/compute.ts","./src/deploy/extractors/database.ts","./src/deploy/extractors/network.ts","./src/deploy/extractors/dispatch.ts","./src/deploy/passes/pass-1-4-repo-wiring.ts","./src/deploy/passes/pass-1-45-domain-propagation.ts","./src/deploy/passes/pass-1-5-endpoint-wiring.ts","./src/deploy/type-maps.ts","./src/deploy/utils/stable-name.ts","./src/deploy/card-translator.ts","./src/deploy/state-bridge.ts","./src/deploy/state-store-adapter.ts","./src/deploy/environment-config.ts","./src/deploy/index.ts","./src/compute/types.ts","./src/compute/propagation-rules.ts","./src/compute/compute-derived.ts","./src/compute/index.ts","./src/export/terraform/case-utils.ts","./src/export/terraform/types.ts","./src/export/terraform/hcl-formatter.ts","./src/export/terraform/type-mapping.ts","./src/export/terraform/value-transform.ts","./src/export/terraform/converter.ts","./src/export/terraform-exporter.ts","./src/export/pulumi/case-utils.ts","./src/export/pulumi/type-mapping.ts","./src/export/pulumi/types.ts","./src/export/pulumi/typescript-formatter.ts","./src/export/pulumi/value-transform.ts","./src/export/pulumi/yaml-formatter.ts","./src/export/pulumi/converter.ts","./src/export/pulumi-exporter.ts","./src/export/index.ts","./src/errors/index.ts","./src/resources/cloud-providers.ts","./src/resources/blueprint-factory.ts","./src/resources/cloud-blocks-types.ts","./src/resources/cloud-blocks-data/backend.ts","./src/resources/cloud-blocks-data/compute.ts","./src/resources/cloud-blocks-data/data.ts","./src/resources/cloud-blocks-data/frontend.ts","./src/resources/cloud-blocks-data/messaging.ts","./src/resources/cloud-blocks-data/networking.ts","./src/resources/cloud-blocks-data/observability.ts","./src/resources/cloud-blocks-data/security.ts","./src/resources/cloud-blocks-data/storage.ts","./src/resources/cloud-blocks-data.ts","./src/resources/cloud-blocks.ts","./src/validation/classifiers.ts","./src/validation/types.ts","./src/validation/architecture-rules.ts","./src/validation/connection-rules.ts","./src/validation/schema-bridge.ts","./src/validation/deploy-rules.ts","./src/validation/property-rules.ts","./src/validation/structure-rules.ts","./src/validation/canvas-validator.ts","./src/validation/template-validator.ts","./src/validation/index.ts","./src/index.ts","./src/__tests__/card-translator.test.d.ts","./src/__tests__/core.test.d.ts","./src/__tests__/pulumi-importer.test.d.ts","./src/__tests__/terraform-importer.test.d.ts","./src/cli/index.ts","./src/cli/messages.ts","./src/cli/bin/ice.ts","./src/cli/commands/apply.ts","./src/cli/commands/config.ts","./src/cli/commands/deploy.ts","./src/cli/commands/destroy.ts","./src/cli/commands/diff.ts","./src/cli/commands/graph.ts","./src/cli/commands/import.ts","./src/cli/commands/plan.ts","./src/cli/commands/providers.ts","./src/cli/commands/schema.ts","./src/cli/commands/state.ts","./src/cli/utils/config.ts","./src/cli/utils/index.ts","./src/cli/utils/output.ts","./src/graph/algorithms/__tests__/fixtures.ts","./src/graph/mutable-graph/index.ts","./src/resources/scale-presets-types.ts","./src/resources/scale-presets-data/compute.ts","./src/resources/scale-presets-data/database.ts","./src/resources/scale-presets-data/messaging.ts","./src/resources/scale-presets-data/monitoring.ts","./src/resources/scale-presets-data/networking.ts","./src/resources/scale-presets-data/security.ts","./src/resources/scale-presets-data/storage.ts","./src/resources/scale-presets-data.ts","./src/resources/scale-presets.ts","./src/resources/index.ts","./src/schemas/index.ts","./src/schemas/db/graph-queries.ts","./src/schemas/db/schema-merger.ts","./src/schemas/db/sqlite-registry.ts","./src/schemas/embedded/schema-registry.ts"],"fileIdsList":[[135,184,201,202,234],[135,184,201,202,263],[135,184,201,202],[135,181,182,184,201,202],[135,183,184,201,202],[184,201,202],[135,184,189,201,202,219],[135,184,185,190,195,201,202,204,216,227],[135,184,185,186,195,201,202,204],[130,131,132,135,184,201,202],[135,184,187,201,202,228],[135,184,188,189,196,201,202,205],[135,184,189,201,202,216,224],[135,184,190,192,195,201,202,204],[135,183,184,191,201,202],[135,184,192,193,201,202],[135,184,194,195,201,202],[135,183,184,195,201,202],[135,184,195,196,197,201,202,216,227],[135,184,195,196,197,201,202,211,216,219],[135,177,184,192,195,198,201,202,204,216,227],[135,184,195,196,198,199,201,202,204,216,224,227],[135,184,198,200,201,202,216,224,227],[133,134,135,136,137,138,139,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233],[135,184,195,201,202],[135,184,201,202,203,227],[135,184,192,195,201,202,204,216],[135,184,201,202,205],[135,184,201,202,206],[135,183,184,201,202,207],[135,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233],[135,184,201,202,209],[135,184,201,202,210],[135,184,195,201,202,211,212],[135,184,201,202,211,213,228,230],[135,184,196,201,202],[135,184,195,201,202,216,217,219],[135,184,201,202,218,219],[135,184,201,202,216,217],[135,184,201,202,219],[135,184,201,202,220],[135,181,184,201,202,216,221,227],[135,184,195,201,202,222,223],[135,184,201,202,222,223],[135,184,189,201,202,204,216,224],[135,184,201,202,225],[135,184,201,202,204,226],[135,184,198,201,202,210,227],[135,184,189,201,202,228],[135,184,201,202,216,229],[135,184,201,202,203,230],[135,184,201,202,231],[135,177,184,201,202],[135,177,184,195,197,201,202,207,216,219,227,229,230,232],[135,184,201,202,216,233],[135,149,153,184,201,202,227],[135,149,184,201,202,216,227],[135,144,184,201,202],[135,146,149,184,201,202,224,227],[135,184,201,202,204,224],[135,144,184,201,202,234],[135,146,149,184,201,202,204,227],[135,141,142,145,148,184,195,201,202,216,227],[135,149,156,184,201,202],[135,141,147,184,201,202],[135,149,170,171,184,201,202],[135,145,149,184,201,202,219,227,234],[135,170,184,201,202,234],[135,143,144,184,201,202,234],[135,149,184,201,202],[135,143,144,145,146,147,148,149,150,151,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,171,172,173,174,175,176,184,201,202],[135,149,164,184,201,202],[135,149,156,157,184,201,202],[135,147,149,157,158,184,201,202],[135,148,184,201,202],[135,141,144,149,184,201,202],[135,149,153,157,158,184,201,202],[135,153,184,201,202],[135,147,149,152,184,201,202,227],[135,141,146,149,156,184,201,202],[135,184,201,202,216],[135,144,149,170,184,201,202,232,234],[85,108,109,113,115,116,135,184,201,202],[93,103,109,115,135,184,201,202],[115,135,184,201,202],[85,89,92,101,102,103,106,108,109,114,116,135,184,201,202],[84,135,184,201,202],[84,85,89,92,93,101,102,103,106,107,108,109,113,114,115,117,118,119,120,121,122,123,135,184,201,202],[88,101,106,135,184,201,202],[88,89,90,92,101,109,113,115,135,184,201,202],[102,103,109,135,184,201,202],[89,92,101,106,109,114,115,135,184,201,202],[88,89,90,92,101,102,108,113,114,115,135,184,201,202],[88,90,102,103,104,105,109,113,135,184,201,202],[88,109,113,135,184,201,202],[109,115,135,184,201,202],[88,89,90,91,100,103,106,109,113,135,184,201,202],[88,89,90,91,103,104,106,109,113,135,184,201,202],[84,86,87,89,93,103,106,107,109,116,135,184,201,202],[85,89,109,113,135,184,201,202],[113,135,184,201,202],[110,111,112,135,184,201,202],[86,108,109,115,117,135,184,201,202],[93,135,184,201,202],[93,102,106,108,135,184,201,202],[93,108,135,184,201,202],[89,90,92,101,103,104,108,109,135,184,201,202],[88,92,93,100,101,103,135,184,201,202],[88,89,90,93,100,101,103,106,135,184,201,202],[108,114,115,135,184,201,202],[89,135,184,201,202],[89,90,135,184,201,202],[87,88,90,94,95,96,97,98,99,101,104,106,135,184,201,202],[135,184,201,202,270],[135,184,201,202,269,271],[135,184,201,202,269,270,271,272,273,274,275,276,277,278],[58,59,60,135,184,201,202,284,358,360,361],[135,184,201,202,360,362],[58,59,60,135,184,201,202],[135,184,201,202,455,456],[135,184,201,202,455,456,457],[135,184,201,202,279,455],[58,135,184,201,202,284,438,440,444,445,446,447,448,449],[58,135,184,201,202,364,365,368,369,375],[58,135,184,201,202],[135,184,201,202,440],[135,184,201,202,439,441,442,443],[135,184,189,201,202],[135,184,201,202,368,369,375,376,431,433,437,450,451,452,453],[135,184,201,202,367],[135,184,201,202,284,450],[135,184,201,202,284,440,450],[135,184,201,202,369],[135,184,201,202,368,431],[135,184,201,202,367,368,369,377,378,379,380,381,382,390,391,392,399,400,401,402,412,413,414,415,422,423,424,425,426,427,428,429,430,431],[135,184,201,202,367,369,377],[135,184,201,202,367,377],[135,184,201,202,367,377,384,385,386,387,388,389],[135,184,201,202,367,369,377,384,385,386],[135,184,201,202,367,369,377,384,385,386,388],[135,184,201,202,377],[135,184,201,202,367,377,383],[135,184,201,202,367,377,393,394,395,396,397,398],[135,184,201,202,377,395,396],[135,184,201,202,369,377],[135,184,201,202,377,403,404,405,407,408,409,410,411],[135,184,201,202,377,403,404],[135,184,201,202,233,377,406],[135,184,201,202,377,404],[135,184,189,201,202,233,377,404,406],[135,184,201,202,377,416,417,418,419,420,421],[135,184,201,202,377,416,417],[135,184,201,202,377,416],[135,184,201,202,367,377,416],[135,184,201,202,377,416,417,418,420],[135,184,201,202,377,432,433],[135,184,201,202,368,377],[135,184,201,202,434,435,436],[135,184,201,202,369,370,371,372,373,374],[58,135,184,201,202,364,370],[135,184,201,202,364,369,370,372],[135,184,201,202,370],[135,184,201,202,364,369],[58,135,184,201,202,364,369],[58,135,184,201,202,369],[58,135,184,201,202,242,451],[135,184,201,202,450],[135,184,189,201,202,440],[58,135,184,201,202,364],[135,184,201,202,364,365],[135,184,201,202,324,325,326,327],[135,184,201,202,324],[135,184,201,202,328],[135,184,201,202,465,473],[79,135,184,201,202,284,468,472],[58,64,79,135,184,201,202,284,466,467,468,469,470,471],[135,184,201,202,466],[135,184,201,202,466,467,468],[135,184,201,202,466,468],[135,184,201,202,468],[79,135,184,201,202,284,460,464],[58,64,79,135,184,201,202,284,459,460,461,462,463],[135,184,201,202,460],[135,184,201,202,288,289,290,291],[135,184,201,202,284],[58,135,184,201,202,284,288,290],[58,135,184,201,202,284],[135,184,201,202,279],[135,184,201,202,280],[135,184,201,202,266,284,292,295,296,298],[135,184,201,202,297],[58,135,184,201,202,267,268,281,282,283],[58,135,184,201,202,267],[135,184,201,202,267,268,281,282,283],[58,135,184,201,202,267,268,280],[58,135,184,201,202,267,268],[135,184,201,202,249,250],[135,184,201,202,244,249],[135,184,201,202,245,246,247,248],[135,184,201,202,244],[135,184,201,202,245,246],[135,184,201,202,245],[135,184,201,202,245,246,247],[135,184,201,202,244,251,264],[135,184,201,202,244,251,255,262,265],[135,184,201,202,252,253],[135,184,201,202,244,252],[135,184,201,202,244,255],[135,184,201,202,244,252,253,254],[135,184,201,202,251,256,257,258],[135,184,201,202,251,256,257,259],[135,184,201,202,244,251,256],[135,184,201,202,244,251,256,257,259],[135,184,201,202,244,262],[135,184,201,202,244,251,256,257,260,261],[135,184,201,202,285,294],[64,135,184,201,202,285,286,287,293],[64,135,184,201,202,284,285],[135,184,201,202,284,285],[135,184,201,202,284,285,292],[135,184,201,202,284,328,345,346,347,348,349],[135,184,201,202,344,345,346],[58,135,184,201,202,284,346],[135,184,201,202,346,349,350],[58,135,184,201,202,284,328,352,353],[135,184,201,202,352,353,354],[58,135,184,201,202,284,319,320,340,341],[135,184,201,202,319,320,340,341,342],[135,184,201,202,319],[135,184,196,201,202,319,321,328,338],[135,184,201,202,319,321],[135,184,201,202,321,322,323,339],[135,184,201,202,338],[135,184,201,202,308,318,343,351,355],[58,135,184,201,202,284,309,317],[135,184,201,202,309,313,317],[135,184,201,202,309,313],[135,184,201,202,309,313,314,317],[135,184,196,197,201,202,284,309,313,314,315,316],[135,184,201,202,310,312],[135,184,201,202,310,311],[135,184,201,202,309],[58,135,184,201,202,284,302,307],[135,184,201,202,302,303,307],[135,184,201,202,302,303,304,307],[135,184,201,202,302],[135,184,196,197,201,202,284,302,304,305,306],[63,128,135,184,201,202,243,299,301,338,356,359,361,363,366,454,458,474,475,476,477,489,500],[60,135,184,201,202],[135,184,201,202,357,358],[58,59,60,135,184,201,202,284,292,357],[135,184,201,202,300],[58,59,135,184,201,202],[59,61,62,135,184,201,202],[135,184,201,202,478,479,480,481,482,483,484,485,486,487],[135,184,201,202,478],[135,184,201,202,478,488],[135,184,201,202,329,337],[135,184,201,202,329],[135,184,201,202,279,329,330,331,332,333,334,335,336],[135,184,201,202,338,476,477,489,534],[135,184,201,202,525,526,527,528,529,530,531,532],[135,184,201,202,525],[135,184,201,202,525,533],[81,82,83,125,126,135,184,196,201,202,206],[135,184,196,201,202,206],[82,135,184,196,201,202,206],[124,135,184,196,201,202],[61,62,64,72,73,75,77,78,135,184,201,202],[64,73,135,184,201,202],[64,135,184,201,202],[61,62,64,73,74,135,184,201,202],[73,76,135,184,196,201,202,206],[64,70,71,79,80,127,135,184,201,202],[61,62,64,65,66,69,135,184,201,202],[61,62,135,184,201,202],[64,79,135,184,201,202],[64,65,135,184,201,202],[61,65,135,184,201,202],[64,65,67,68,135,184,201,202],[65,135,184,201,202],[129,135,184,201,202,242],[58,60,61,62,129,135,184,201,202,236,237,238,239,240,241],[60,61,62,129,135,184,201,202,236,237],[61,62,135,184,201,202,235,236],[58,59,60,61,62,129,135,184,201,202,235,236],[61,62,129,135,184,201,202,236,237],[129,135,184,201,202,235],[58,59,60,61,62,135,184,201,202],[61,135,184,201,202],[135,184,201,202,490,491],[135,184,201,202,491,492,493,495,496,497],[135,184,201,202,279,490,491,494],[135,184,201,202,491,492,493,494,495,496,497,498,499],[135,184,201,202,338,491,494],[135,184,201,202,279,338],[135,184,201,202,490,491,494]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},"9f422a1bcf9b6de329433e9846e2de072714d0feb659261d0915ed89b7227871","9f06cca3f1b2c3d560cdbec5c0a3bfafdf58dc2439d7065ddc5f23af72fdaa73","8c467364768b887a7c3f3af90e02fcd1cf443ce162bb498082bfbcc99beb2a88","905a105127753c3ce4f2d19d866c0d5565758691579204519bb9b8fd838b790b","62d8d9c7b326f1d1392cf56c82d9c6b042039875caa7b503b0a0b41d0c3123c8","44a47ae3fa050377c57d37fb051a6c6cbc08405bd0efda40861653de399a0818","7170f18d5cdfb2d301f0470233e281da189a3b0a8d9ea4358edcb21b4c9a5728","cbbede9be700512a66d94e48c4ef981a6d1a1678698ee36f7dbbb4ac747aeaf3","a92c3adb47ae83dffb2b67e18f045efb376efd1547941de56366340c06d61077",{"version":"341494baae56b3810de282a6932c5c4edf0b44adf7aa00e1ced71a9258141315","signature":"ab2d635fd6cb30d208dd01ab057ba91c81e078dd4fb01ce79e4169b60a6d6761"},{"version":"c1e5340e6e2c73765454ef0056093661c0b4159518797e442c58d9e0dfbbe8cc","signature":"947b0453e11a8fb6575ab0c7c166a61d19d8546d31e6ed6565a2ba55e085f1e4"},{"version":"92b52627eb99e20e437d33fc58808896145bc40ae551a991844e7658395bdfce","signature":"b2c1c4a982d64dd886dfb0d772686167cfdd72ff347a30a75fb56d8cef849a7e"},{"version":"21b14a29d87dcddf64f59d7865144dadf7c455696b7c509d973d46d73af2eedf","signature":"e33fb6b6e85741ed9bbf83e63879bdb34ee79ffd1d95ea9dc861fd8bee66a53d"},"0685b918bf7e38ff946e8feb260e193f48626b7eed70669f1a4c5ecd0b3cd444",{"version":"33943d74593fcd8670b682fbda86001d2e728b13fceb5d22961074f186619743","signature":"834876ece7209108b986f9f57f225417fd56417cf55ca666926d81f9fef4bfeb"},"36cfb8ac02cece02f64db23c7ba4fb95ec2b56fa8d753250aff3f2b1663a9e34","64d61d96ca6dffcff65d3021718e83ed23bb14517aeb1f02e7c3ad993eb0a0bc",{"version":"8ebea2af5e5b387063de6535a5ecb197f32b1ae6614df257432c087725161c6f","signature":"83d8e36ad823821f3fc082e5aeea46f12c244ee8ea33e1a1f61f82e2e8064c70"},"c0bd5e212ce61814fcdc9240b90868b1108c37f21da55d21cabddd3e180f195e",{"version":"326a61d40b8bdb5b3c35f83e3cb0a78af05cfcd4539f9098c9fe46e32b5c6326","signature":"67105c74bb0297ad663631d8101c17dbf285b791afec84c8973108ba4ba9d08d"},{"version":"044f2620626e0bd1ce6161513ccc00cc107e502eb2d540c543ea7dcc54f18cba","signature":"8e469f181402c362402728d2de01851b8bd0238171f0ea7e47a4231ba3d5b929"},{"version":"bdaaa669518e13907293d935c2fe379936525a1226cbfe7b5ed1b407bac5234a","signature":"4dfe6c9340b968a06e5e2fa776af25efc07392f36b2203c80ce3047838de5e55"},"f6cd6b7cda0141a3f0d629aff6c529e3f55245d1c198c450737d7cea81b56d1a",{"version":"b5981034fd2b7724d3b4f4acdba358dc34ddaa3ad509ef9399c630c86cb9ccc1","signature":"b2c71fe0b04a86a556bf7473146f25f5c67906656a53d267942d9df20e39b1b0"},"0cc85eed70c33b0d113321a7fe2a8493c530cdec9babef148241f7203f35cc4b","c442972d48882efb43e3e64e1b30a68effd736227742a3ff936162d0ef320a4c",{"version":"3dfcd0a3bfa70b53135db3cf2e4ddcb7eccc3e4418ce833ae24eecd06928328f","impliedFormat":1},{"version":"bea7cae6a8b2d41fd1a9d70475b54d741dd7ca2103904934858108eec0336a69","impliedFormat":1},{"version":"bc41a8e33caf4d193b0c49ec70d1e8db5ce3312eafe5447c6c1d5a2084fece12","impliedFormat":1},{"version":"7c33f11a56ba4e79efc4ddae85f8a4a888e216d2bf66c863f344d403437ffc74","impliedFormat":1},{"version":"cbef1abd1f8987dee5c9ed8c768a880fbfbff7f7053e063403090f48335c8e4e","impliedFormat":1},{"version":"9249603c91a859973e8f481b67f50d8d0b3fa43e37878f9dfc4c70313ad63065","impliedFormat":1},{"version":"0132f67b7f128d4a47324f48d0918ec73cf4220a5e9ea8bd92b115397911254f","impliedFormat":1},{"version":"06b37153d512000a91cad6fcbae75ca795ecec00469effaa8916101a00d5b9e2","impliedFormat":1},{"version":"8a641e3402f2988bf993007bd814faba348b813fc4058fce5b06de3e81ed511a","impliedFormat":1},{"version":"281744305ba2dcb2d80e2021fae211b1b07e5d85cfc8e36f4520325fcf698dbb","impliedFormat":1},{"version":"e1b042779d17b69719d34f31822ddba8aa6f5eb15f221b02105785f4447e7f5b","impliedFormat":1},{"version":"6858337936b90bd31f1674c43bedda2edbab2a488d04adc02512aef47c792fd0","impliedFormat":1},{"version":"15cb3deecc635efb26133990f521f7f1cc95665d5db8d87e5056beaea564b0ce","impliedFormat":1},{"version":"e27605c8932e75b14e742558a4c3101d9f4fdd32e7e9a056b2ca83f37f973945","impliedFormat":1},{"version":"f0443725119ecde74b0d75c82555b1f95ee1c3cd371558e5528a83d1de8109de","impliedFormat":1},{"version":"7794810c4b3f03d2faa81189504b953a73eb80e5662a90e9030ea9a9a359a66f","impliedFormat":1},{"version":"b074516a691a30279f0fe6dff33cd76359c1daacf4ae024659e44a68756de602","impliedFormat":1},{"version":"57cbeb55ec95326d068a2ce33403e1b795f2113487f07c1f53b1eaf9c21ff2ce","impliedFormat":1},{"version":"a00362ee43d422bcd8239110b8b5da39f1122651a1809be83a518b1298fa6af8","impliedFormat":1},{"version":"a820499a28a5fcdbf4baec05cc069362041d735520ab5a94c38cc44db7df614c","impliedFormat":1},{"version":"33a6d7b07c85ac0cef9a021b78b52e2d901d2ebfd5458db68f229ca482c1910c","impliedFormat":1},{"version":"8f648847b52020c1c0cdfcc40d7bcab72ea470201a631004fde4d85ccbc0c4c7","impliedFormat":1},{"version":"7821d3b702e0c672329c4d036c7037ecf2e5e758eceb5e740dde1355606dc9f2","impliedFormat":1},{"version":"213e4f26ee5853e8ba314ecad3a73cd06ab244a0809749bb777cbc1619aa07d8","impliedFormat":1},{"version":"1720be851bdb7cdbff68061522a71d9ddaa69db1fe90c6819a26953da05942f2","impliedFormat":1},{"version":"961fa18e1658f3f8e38c23e1a9bc3f4d7be75b056a94700291d5f82f57524ff0","impliedFormat":1},{"version":"079c02dc397960da2786db71d7c9e716475377bcedd81dede034f8a9f94c71b8","impliedFormat":1},{"version":"a7595cbb1b354b54dff14a6bb87d471e6d53b63de101a1b4d9d82d3d3f6eddec","impliedFormat":1},{"version":"1f49a85a97e01a26245fd74232b3b301ebe408fb4e969e72e537aa6ffbd3fe14","impliedFormat":1},{"version":"9c38563e4eabfffa597c4d6b9aa16e11e7f9a636f0dd80dd0a8bce1f6f0b2108","impliedFormat":1},{"version":"a971cba9f67e1c87014a2a544c24bc58bad1983970dfa66051b42ae441da1f46","impliedFormat":1},{"version":"df9b266bceb94167c2e8ae25db37d31a28de02ae89ff58e8174708afdec26738","impliedFormat":1},{"version":"9e5b8137b7ee679d31b35221503282561e764116d8b007c5419b6f9d60765683","impliedFormat":1},{"version":"3e7ae921a43416e155d7bbe5b4229b7686cfa6a20af0a3ae5a79dfe127355c21","impliedFormat":1},{"version":"c7200ae85e414d5ed1d3c9507ae38c097050161f57eb1a70bef021d796af87a7","impliedFormat":1},{"version":"4edb4ff36b17b2cf19014b2c901a6bdcdd0d8f732bcf3a11aa6fd0a111198e27","impliedFormat":1},{"version":"810f0d14ce416a343dcdd0d3074c38c094505e664c90636b113d048471c292e2","impliedFormat":1},{"version":"9c37dc73c97cd17686edc94cc534486509e479a1b8809ef783067b7dde5c6713","impliedFormat":1},{"version":"5fe2ef29b33889d3279d5bc92f8e554ffd32145a02f48d272d30fc1eea8b4c89","impliedFormat":1},{"version":"e39090ffe9c45c59082c3746e2aa2546dc53e3c5eeb4ad83f8210be7e2e58022","impliedFormat":1},{"version":"9f85a1810d42f75e1abb4fc94be585aae1fdac8ae752c76b912d95aef61bf5de","impliedFormat":1},"db9e4672c7c667a0deabd6276b696e5994d3730e5586b5b87267aa63d9f72328","6ba80b835f61ba3a1e68f225ae88fa6e3f7c5f65ca051bc1756b7c899d3e9274",{"version":"4631550a659e584f008891ce1c715ce29d0cbc3dd1ffbfc76ad2b50eff702948","signature":"4803a49ca4544b6c06e8eb5122dfe1295d4d7ce24235d94447e98e106a544eb8"},"ab0970f1b7769b6a37dd837ce570dfeb1124740a38f15c9294b246eb1bc780c4","116f359450e3ce8b94d3a4e321826d56d77ac599b68f9c07b4a4d34b2e8bf3ed",{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"9855e02d837744303391e5623a531734443a5f8e6e8755e018c41d63ad797db2","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"c906fb15bd2aabc9ed1e3f44eb6a8661199d6c320b3aa196b826121552cb3695","impliedFormat":1},{"version":"22295e8103f1d6d8ea4b5d6211e43421fe4564e34d0dd8e09e520e452d89e659","impliedFormat":1},{"version":"58647d85d0f722a1ce9de50955df60a7489f0593bf1a7015521efe901c06d770","impliedFormat":1},{"version":"6b4e081d55ac24fc8a4631d5dd77fe249fa25900abd7d046abb87d90e3b45645","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"83e63d6ccf8ec004a3bb6d58b9bb0104f60e002754b1e968024b320730cc5311","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"a3fc63c0d7b031693f665f5494412ba4b551fe644ededccc0ab5922401079c95","impliedFormat":1},{"version":"f27524f4bef4b6519c604bdb23bf4465bddcccbf3f003abb901acbd0d7404d99","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"6b039f55681caaf111d5eb84d292b9bee9e0131d0db1ad0871eef0964f533c73","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"c8d3e5a18ba35629954e48c4cc8f11dc88224650067a172685c736b27a34a4dc","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2b55d426ff2b9087485e52ac4bc7cfafe1dc420fc76dad926cd46526567c501a","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"47613031a5a31510831304405af561b0ffaedb734437c595256bb61a90f9311b","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"8a1a0d0a4a06a8d278947fcb66bf684f117bf147f89b06e50662d79a53be3e9f","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"9f55299850d4f0921e79b6bf344b47c420ce0f507b9dcf593e532b09ea7eeea1","impliedFormat":1},{"version":"c2a6a737189ced24ffe0634e9239b087e4c26378d0490f95141b9b9b042b746c","impliedFormat":1},"8ab9053eb153fdd8636f6b3f30208e0aaaefd7c23cd7c87be7ef913d36aaebe8",{"version":"57430b85fd84c95c5b5f867dfbdbdffbd11ef2e48965eb9ecc7f4fb9d78be325","signature":"7c60bb975faf3ad663c0b4ab1298189ee713f70d4de298b502817adf62a67517"},{"version":"08eb64a1afdeda9c7cd87114906037388d7af9d91bce27c293f55dee9ce5a8c2","signature":"6bb70c5f64455d1d657eb76662db351fa197bfc11c235958446a11378a89c7ac"},{"version":"b5076e65f873347e41f18a2d239083a544c080b880fd62de907bd064465e1290","signature":"06db8bf73dea3c7b4c102afa6ba55cd10f8cfb8aca19469022805681fa3afcb7"},{"version":"b9e4ff74d366947b2c7ff9158b5c878480c1523bffa20590914fa53c29144e07","signature":"67496dd15a6c42aaa6e98045b490e812607cb9b4dad4af89113f515e8989e498"},{"version":"63a9ab201a2da78d4d5c505c1c4c9fe380fffc7368f9d22fe7084b4e491977c4","signature":"e6f76af0104c5beefa263406ed7c77eaf1c123a44f7a2a62bbd34007994576ea"},{"version":"903dd73508a4b3ae23daa597ca913eac9f75e3621539691b0a216047278e9efa","signature":"8718c886b68cbebfd7ac12d44c89b15ec9681be511891ddb001d55bfca624276"},"52741694e94b7b6b04c12285da7e10103e66b57126303a593a257fb5e086a491","b28b6136a5678e5b01ed6019b0b9ef8b22446f7d291f081bcddc71051d2a6492","583610054995599f0ed590a55b9f81466909e2087ee04b6c2461d6e0c8b08b43","c8449a6bc016461d1269ba1cd5aaeadd845e458bde39966f1fb434e5e99204ce","7ea0e0cdd8e14703d723a50b6c0d07f620d825f0e148d6c9aa97af9950fcb44c",{"version":"2fd75662e4cd0cd568d6c0857e7a71648addda2a1c6d01918fae951dce5b519a","signature":"e56947ee9058324ec4665de7a5f2b44ed600d5f5fe4d106f386d3945b027ceae"},{"version":"a2ef968f2f25e45f7a634cba822bc2e7542678615fe0161826a8b2e82e071acb","signature":"1acc6cb36e8b47e5f297e7ce89899d4835db527a4515a88d6042ced47f57f2ff"},"88b62f5279f98c2c529bc562a99dcd1c1cf96c5f3698b04936ec7cd95722ddda","59b46ed04998fc50de4f3a8896fa90c08e79530e15da952bf1aade4498f63694",{"version":"c4d2a21697551d6e34d05cc2d20920bf142723d8f9f9ff4396b1b7d700e0795e","signature":"4cd3f1326b1d3a6aa7db37fb49f3f5566c913892c1d54b0eab6f5baebad2306d"},{"version":"98a3ed3fac7a473fa6fc4beca3de620139d52f9863497e943f053c124eae15a8","signature":"7cab4ac133c4384d9b8d4c26588c7d5dd9594c591ca3300516126040b4b471af"},{"version":"cafaee243ca4ca03a097af441baadf576180f1f6b0c0a71bd2cf27c92dc3c635","signature":"e44ef384844a9237cb611c304e38fe450057a78b9e9f1db7d32a3fe43d2483e0"},{"version":"d5132708f5591db907f3adda24e0a67970e99d588082cf0cc673359c4140c1d4","signature":"bbea260b8919541bdd34a14fb37f75fdf0427c00efefb7a13928a496c364ecda"},{"version":"5ac4bd7d11ce705620a56ed6903e3f0c2a432f616bfbd96d408d5e77d40773a5","signature":"d01861d83685fdb61efec1716ee8a0ccdcc2039bf06c77aedb987137e7067d63"},{"version":"54ae1f916462a6ec28561bf5c31fa31411c2b95421419952f82fb29efe33714e","signature":"b0fad4fb4276d6336977cf6295d9814985b54414ca2db3b7b1f0fd2dc7c07809"},{"version":"5a226d5a51ad9fabfe0849f5d5c81b9460ccdd9184796f1467cb992bbc5ce012","signature":"ae268be2009c9160f3244cf781cc8034b3081af0602be6edf179b32fac12365d"},{"version":"5ddfa20a02c6207915c3c9d2fbdb57c70f549307638c4608a63c4049c591dc6a","signature":"9170d6b6b7a0309985aeb8c8846d79e09ee882a2e0041a5434147250dfc86a95"},{"version":"c35b355506a0ec45a8488207a4df84cde8d696832d6157f61b6f851863ee1b66","signature":"d219f4462715c121f6b506cc73d3789c7eee328b8de5d088eedd13b85f193945"},{"version":"edb83dc67f334db478790a845e10b191ab51d035160a88e5fd3876664dec1612","signature":"45536df8f9b56837c4428a1ace080a9d6260a759adefc109dc57f2d191424adb"},{"version":"df984b0bafb5e53912367951ffc923a81ca6e15e6164b634055880e4344cac36","signature":"d413599f34315fc0052ffb60f7d5a5a86515ed75d8001e02353f9d18b30a0185"},{"version":"7a1dd1e9c8bf5e23129495b10718b280340c7500570e0cfe5cffcdee51e13e48","impliedFormat":1},{"version":"95bf7c19205d7a4c92f1699dae58e217bb18f324276dfe06b1c2e312c7c75cf2","impliedFormat":99},"b045297393a1167259e546c9c8f790f11d1bb9e56faee2a96ebf9dce560f198d","9ca31fd2cb6a4a2a79f0b9655deaec5de960871406084ddc7834e5f0caf6825d",{"version":"25fdd9095e3c88304fcacabc177f915c88f911ec76eec1bef97b0a5296b69b42","signature":"7885c4a77845f3c02af5bf64fbbb966a235b1bab58a4b7051466e669b1afa1bd"},{"version":"1b6a4d8614e087ac52dbff37fc366bdb9d9f6ded3aed6b9fc6a42c9a07c7df6e","signature":"8430e87be2507482dbc7a2775ead52d4a0b5d4a2ed7284ab0ff382b071235019"},"76cc26cd0f1fc54af550f8841264bfffae32d47ceb78f0234554492a2009fa6f","649415d05765a968aad9b1906b023aa680e332b6fe32bf31d4e96ae972797107","3803a665d8434387ab3b02307f2724bf49e2c74295f930e7fe4ba58bca51a078","e3d7de3e7b0a4b6582a09e0c6969a6835d42a3ea17f5a84e2674a4e2f0c16ec6","e6f912a58bbeba4d07663b3655e1ef880f01e7a194a86e351d4a9b96785b9dc4","b93c566bb2c61594e867c53306a6d56b086e7658013d2b9d233e89fab5acbb35","7a0aed8f3aa05ec618437c5c7fbbfd2295321316c197a171bc12de0fbb2bb922","791e111b25123db45cda32073ba13222b5a34a947b0156eda9e77187f2ef91ca","fb0547afd53f48e07ad555fd6efd0de07fecf26615cc93a4607c0220211bb114","7be4ecdc1589196748775376a609a917fd731034f8a1fafa7b5ebc9cf5e5bd53","f193b7ad47541d1cc44860467c828bfaa512e74bf09a2aa9aff70630a342187d","542267bec9796016127a97865a0f97ea180cb8fc3f89606fb8c9cc4253126acf",{"version":"28b5fc5754f4ebdf042cb5b27931e63b4ed8f5b64df7bf3be4b77d974640396c","signature":"7c6160b4798f9cc9075b64037e98364a917dc9cc11f9d71dc3a0ee7a3e50dbd7"},{"version":"89e0e9ac65c95aea0a539bd60ace8484b3a5c3cc4d897a68812217af830fc23d","signature":"47a7d3040054f86178e882756b4a6e34e9fdbda5b1e5af7ef50358554682972a"},{"version":"0988e2afa78428c0d1cd50268b54dbcf421f80c992d8a1a6055f4133730ec6a9","signature":"7c0d21fdea98689cfd57713319cea5ef1f813da8d049da78d18b79d73a497b41"},"f2905ff1575a84c410192ec46aa3f4996765c1e849ba421a259e05c7b2fc340d","aa34083e206a5f93e16d7c8ad6974bf445da6be4b5a7722d2ee98789faf7f8ae",{"version":"ffe50ea4d20591a5940538606dd44f33a5dc343b6cc9d043377bb2c5f72dca13","signature":"bdc6ba6cab4542255d2d3b21b833e5e45c06e196d6b2a3d49cfa96d12dac1bd7"},{"version":"38e01627ea43a1196ab44549409015e5c6f5b301fd332346a2e0fe38fddc6472","signature":"6268a941b5c0267c61b0cb7f96e92f066dd1638f4d41e5d9abea3e231fc792fe"},{"version":"c3de1901e84728e2075eb45ca5df0d75ecd0e35e919e826fa8339404ed5536fb","signature":"6f320f36f6d50aec6be01b18d1ec48e38d3a7174b06fce10997b1b2a0f235f4e"},{"version":"676403375489df3da1edc59ed83d950fb937530b99d1691e495e7e84920510c1","signature":"ca6ce7e58281373583bca8d6cc023bdf0a8ba359cf296d609de47e71fde6e320"},{"version":"8f8327bf5762a69f257f2eb84e3cf865c83a5a9c134adf38be12ce69bae4eb7b","signature":"7415a7b2551686254e6b640a4d2a6f4aab230278ee58f1bbafe5133e213e37d9"},{"version":"11833b3c60fc9ab463a626d92764f42597c64fc93119ebadbbf9790756db7d5a","signature":"20ec3c98684038c4796de72b760f0a68661c63123d465b1adf16533b3673c53a"},{"version":"126096fa7c786d9d03b90f9e81f2145a2237e4781504957d3aa2ee4382f5b39f","signature":"26cee9b0c548ad70d8d932f5ddd2b0ce204fd28c1aef982fdbc6d7bb1809d59b"},{"version":"1d095cf9256ec12ecd33cf34815253020c89230be4212f1fbbd9b5dc345bf2a0","signature":"e3de7da540596ce534a034433b32dce5c02af8d023732740e2fa4f6e2e619dbb"},{"version":"ec7fc48032dd77a91fba9a4b98a4ab79135026a5364eef928ca03ce4c6936c7a","signature":"55e91c7b01b9d92a7b7aa156248da424d3b29c51c08af0e6ec38f838a390b21e"},"d2b46d9c36e9927cc6b59679dbc45a92ed19909fcc25412772af6bf7cdcc5a4b","1500929393a66eac684b644318dec8d947f7cece77bceac84395a96cd0e819db","cc998ddaa317600c0c56e53e6e76cfd3b3e23a0783fbf371f294201fd3b15e39","0fa7d91ac72697950720581ea9265cc0d7aaf5e559ae21bee6cb6ec3f7e4f3c4",{"version":"6aa749045a7e565ab60023cd6cdaa21f9f0c0eb724ab4cc82f8b567182679813","signature":"ca6e2be2bf081bee0b55e45a962e50c00d903fefc99541d739c94dd86b227bc5"},"42661bdd3babb7325e5c1050855251dc1ebdf4b14e69ef732bb948cd3fadb831","d202cab2cfd4555a3f2cc559ed805fe0e9d8890dd79e34646d142c0c24b95141","ea2f9f2010acbf2c1e02e79513fcbc6715fc69483b0b270e26b7b16592790111","36d25724c00295ceadde4a1f69ea32c94d749823dd5ede6258b928ed699f1768","2efef5f483459d0177a69b02baef9b931fd045a852f27a4f295f6ed958eb57f2",{"version":"222ab086b785ed8c221acbd2922699f3411227a510d23788e52c303515b01d28","signature":"b59b72c114201092b61fed2907b5dc42d37bcac75973c262951f1e01cb25f874"},{"version":"09b127a60bb3bd8625f2e0910af7c7e86afacc05fcdfe7ea0d9f73c381d7c8aa","signature":"a29eab85a5b1a5738b7f341ee0f456d1ffa8cc26435c4bc2c64d77b122b15d63"},{"version":"fd0cecf30f9f963f1b0148733981087da93014fb0f69a9e065dab17fe67643ae","signature":"1aaf6618f7c27caf2c3dd31fc36c796e303f6ef1b622b56cb10bfa20c822c3aa"},"d9bac95e5cc44d0ea516e83cddda764f54dc5dd698cee1c64356ec968ebf57c0","2ec1e34570bc9b528ed078112f31576dc9ba1c973dc83a8c61c80b7bc425d4bf","b4ca0b2ae78101bdc252134c8519eacb2ceb931c9c38176706ac467b4eff7f55",{"version":"b6c3f5b183e5428d20d6f6e3290a1e8efdbdc5cd8ec31184bb3a018b17c3d7cc","signature":"5fcf9e828ac552d696a32e26473a290daef7c00f9ea05be7c36352a608697fea"},"37cb6bb48aba80a010297eb815aecc07c0ea334509944c66305018a8fdaa2c65","be7b800e572c75d9eb786fa7448c9e108dab1cece51f55f73b4144e91b8906e4",{"version":"0cb6b0a4e69bd922c9fae22e5d4cdb24b148ec0d29766c200738fdf5255a40d1","signature":"599472c91b08a43eecf44833d2f30542ef7cc0190304fdf30fa08c117d9f0485"},{"version":"c34561f348d2cad7395bc203ab64e2edac6184a5f0184ecf19e22a71724e4fb9","signature":"fbd95f4ab3310727d436da50c3f2b64ae3470f2b294bce456fcc148d7430c824"},{"version":"9bb87d1dcfb06b265c96c8bff31a95d7003128fdd24750dc6ffc7ba2e4cd357f","signature":"9ec476ad6bc79eb7a6375fd42c4bc5faa5425b426ee8a86937be48dee3e27bfc"},{"version":"00a6ccbc8dcd86c5d895ad39c2dbf030f2b6064c421bb49a021fae08f266e16a","signature":"5776a3e071566fa68f73435b549754651fab2d49bd71c80b0ea452dca486b714"},"5971247075b05ed88679485c1d056aec4cae062eb6951b995788c69a654a1c3c","f731d9570dc4e6c8a828c6b9f0228877820afaac8d2fdc00f4e8aff8d85c0311","dbcfc7d4a685b33c38be3dc2611615e692604bfdea5625f14d9f1156ad6722cb",{"version":"83a184bbff2dd763f8e8051aed20b7b620d55778918945b23ef7241e6792abf7","signature":"48bef5344818d7d8dcb158a49946d2664095548c37b152fb97c7ac668a503d38"},{"version":"fb73e07df3a40fbb9daf4ec269ac01946ce2a35cae550dc79e34d7ea8bdcecee","signature":"faf843aea697f27e092176bbf6438eb243ee7cd11a011d9d005c183241876ca3"},{"version":"be6fe6c54c0b3a07b8667b02fc6981b5b40758ede42caccb7fa81646ba567d24","signature":"ea87087d1587a331bf5ae10dfd2f63ae21e100729c440fe1189cf88feef2f0c1"},"031f18d0b2394a9704c2f96564fd098bc5d9b8770a0467623cccb2d74f9f6d6a","670b9a2aafef1ec78813620c5a738bb1df2235a8c2def1702b8d58332b52c177","6e07a05d78279eb87db944470d6b16bf3c5ffc8a43b7a17bbaa9d80d34c638f1","9c2eb90da480fa93d78e04cc441c282a07a2c7428bdade5cd35ee20de7cd888a","3d3f74d81aa3ee49f3218c9f7ad9a9202891581cdc4cf3dde9b92ddde4e781d2",{"version":"59bb9e89ce15a2a439b49d7e5df1ea4b895a4e76ce5345ddad27009201b49bf2","signature":"c46bc77315a2daa9b8bcc0117e8147b451dfa80da246369f08fb7186a5cbb76d"},{"version":"cf82c32e17442b87ef98f23f362da8331407d685d2d06fe7bb8175929d2d3630","signature":"2c83e2fa108d1d5e11a693c28caa50d4a293edb24ae607f87defa2a011ba7442"},{"version":"c60b24102572f64fb0023f5413404293c35c73b9734bd71da9d571b08111a81a","signature":"d9cf10849c8e1624968b7beeb5f75cb01a7b7bfe405d0e75e3431af25fd33f0c"},{"version":"f479af6869f5f6e70b8d8e19f59995c6ae59e23bb56670c96ce30485ac98a1d9","signature":"bc81a0924865a868f2d11e12a57e65d20a6aad3a73ad8e6a1c35906d10066fd1"},{"version":"f0bc31f07565c26ec58b2d3a26453b9019a83109cdb2254601b3e1cdd6fedd43","signature":"f26c8b5746c2dee46f6cd17ce0407dd7f159a50f1befcd618c6ebe386bf34d79"},{"version":"dd94387420ae72f77718a1eb8373de4687dcd6267f92411de88bdd3da5de2312","signature":"a0e087445957635c1d27d8a5ecff4ae69d8fd73591274a089247ed7b91ef6dc7"},{"version":"8ad01483f5e8e75c72b19af06b5e5bbcc38196ed07fb80bd0814b375b8050a89","signature":"21293659eb0d65ca036e443148fc73ace88d23cc6d127e49435a390cf1316bb4"},{"version":"7f95716c1c1e73c9c27e91618a727846a86b6f69b8b9f16f84faf5f2a7fe7580","signature":"7fcc5afe35870b940eb2726194cc00982394465c8a97e1a5db0db2a1e7471ae4"},{"version":"d210261df74e82cd056844822c6476575df905acc745dde0cfd45f6c1e7b6a68","signature":"37220c85a3643bcd1d8bc6b9a44360f3f44eaae4c4de1f94127a924d6e607e85"},"888bfa1a4a963276e39edc9446d69506170aa1cf8b078ef22748719ca199a5a8","3c0a996c5eccc7c17f68c1ebb45c299ca6d7dbb9daf58c8fff3392409c31a9ea","ea2635643910097eba211a15f93faffd343817feb00af8efefd97c81b48e2fed","ca06a25e3a9bf82bfdb6f9f61d6d67f6c90960b6e20df43abea3c9eab515bbea",{"version":"d1b1da0800ae66d723c43f2a07965d216addd736382b674a363d086b755f5efb","signature":"9c373407c210455669d88b33f3bf94b336ffb691fc6607108862e70658651cf9"},{"version":"0d597693230d3eca85524fa33430fe1aed403b459510b0abe00a4ead3aeb4025","signature":"5d3e1b2670a80156617b4c594cafe80fc93a3f7708fee1e475d989452d56682c"},"5d2667f3261713c3ed1dc5fdf5532bd49fdd74f399fb7e5d21901e29c7f6f1dd","d050988d2be49157325e97f3319532684b8ede2b83b87abcd8832b651b29b471","5a8fd21287dc8e39f13cbcb1ec3908f0f36428b4d575c02ef76c709d4cd07e3b",{"version":"4e02c2619e7a81dfd2b13b89917c7320d5ebd87d46f79ea113d088a0f122f7f8","signature":"29297d3a3e420486de11968f2494503916e5d8304c39a0b226304185bb89d458"},{"version":"9f2de64058d15197c6e93170f8a8e2a10a3ae0f181d17c4b3a482bd3500d077c","signature":"0f2d80b8746a1e3f204cef9c4e81e05c134ec87dff84d280d9129de72f9230c4"},"ef8865f11a87d159281b1bfb08427a05413fb012a60e624d33589ba16957c607",{"version":"5dcc475ba4e86db8610afe2ebce0f05da3d98be85ff9a0fd5e4bee1e293caaf2","signature":"d894eb4268a5c576018cc943e548de2766f9c5a11ad33a85b31034b54166fa88"},"f3afd6cb637b62f3184da2fed4948253e7476e9d6ec8e590359056d1ddf23b77","3f80582eca2101e3627134b99632333a665bc2837d1c4f7d07d0b3bdaba0aa0f","001dec038b0f816e352d106eb1d2b436d74462a9ae0f09487a533d08ebfa56b2","e645dc71a241c5fb97402bb0bafd2be8031abbdc9ea0726b44bf79f104022a61","4dd4f634c626a6f4365a71159448fe3ff154636f3413ef5a876ac0ac40fd751a",{"version":"6b2078cf551ddb6ba3c5a47a50b78f3c3202d4bfffc06f2d63c46b1275969a40","signature":"25dccc73ac651b6f427a5c1262d92c297fb782009301992f7e763baaac86e819"},"ba881b8d5b6e3df354cc26faf2ebe93d6c3c1dde02534542c716970ae9862549","181dab8c3c89994ffd0f00c80a4af48d62aa9fa40c07316e8c3cd1825a62eccf",{"version":"6d6336b1b4df7f1f65d3f84c9c020c205865ae237e19633d1fafd6aadad9a067","signature":"20ede35731e003188ab444e1f665ef57b98d0a6dc61db3e84a1d9e2cf26e7512"},"c12c64d0d3ac1be4fa925ba87f3de675601fb413ae25cc50396b192a9e176425","5402794b0084c917353387d102cfcc303842e39852c263451c1a77863cf28a9e",{"version":"287d0baf43e643c7c740b9e8770f9300b2bbf98b8a518e76e8e217ec8aaead64","signature":"7e3603ab3f7b687d414cb7c5016c472082e460903ad41a56e5ba7cfbb81c6773"},"91298e6a51bb1962d39eceb6371aada140acfa24417011da38a2c50d2f0da168","4bde15551a94df07a4010f995704efb12ffdc4bd752851a7f95f591092bab4f3",{"version":"9dfe82c07da9a54e56a248ba387ce383fc6a7743af92b17fed1527b0d5b6ebbd","signature":"4a5c1a1db7153a792163ba2bd6e5484fefaa945ba56cc3dcc58010eff0a87098"},"290d8f4ffa2ed9c837bf5d1ff05c7637ff021d486fcae6d898aaf2df69dda769","231b7e837fc604212aa02a5b148f585ad0b2ba995032e37eea7dddbb3a46088b","4115ce5ac014390c8cff8d3c51b52fa13f6294fb13d1803d6c40fe6cfdd6901c","b2419ec464344d9a111778d1d616889685934ba3d0915801a156f20295da39ef",{"version":"c2d0ba4e3ef6f4510841f61190862a4376c6515a4af1e44290d94e876f699251","signature":"90fc47f6d4b843c81fd8d118234714eb1597aab3d99d84b1595375110297a73a"},{"version":"632532295af106d8dd11653a780e944492849c55bc026c0425fe067c30e44bc7","signature":"6a10a7c2151ff433671419e5bbc7cee8778e7c39e052aa83319000f99b067038"},"d9a8dd605a9db2828cb9c2117053bd44bb318092e4bc8ac12297ecae52e48408",{"version":"a40659fe11d83da3b659ad4287b7c1b141378fce8962e95592cf4ee53258f190","signature":"7e91cb45efffef5a1d31678cbb168b19cb1c81f667ea8b9bb166666219ab3513"},"0e23ec9cf75425a296be7ee9bb1f85e499078bc492dae72e2fdc5d5fca98fb7d",{"version":"4bdf7deeb5d15eda7a4a4246622cc7321e2d4d605c3dcd9a516625815e772e22","signature":"06938748621829a3224cc5fdcdca288d592a30c3aacf358e78e66b2a31ce6604"},{"version":"6bf062bf83fe661c697b7122c883b56f982b85438ade4e43731c2efa36a48032","signature":"242e9604f52edbcea895391d4b757c9d7a1fa29155df4a7446dd768b68544ee7"},"fdc556b6075fae393c48cc18dc34519af2d448664447be8b7ca2126b02affb19","4678397e8b0ed1445aa088771c9d8572e7bf45cffc52c14b039f86b66fc20252","fe49dc0bf1a296cb927061f74f7fc6d74d2e418b9362c4640881b4e42a503031","d301b86d1d7050c88fcb6ca92ff9b138cfb0a0ca8a04b3a1b61ebfd911cb1a6f","fae2bc89da0a93f9a0266d21ac2c33bf03f2674c5ed33ff980e887f397c8e06d","02d6d2563d3b1928e87f184b0c8cf3bb35398e2c3aa7b6a29ffc24ddf282b6c5","43cb773c20a53ad1001df0133aeaf26b52855445031c295b9a37154ae35343af",{"version":"0e481a28d565f1a503dbada5d810dde3113157cf6563016b213cffec90a79993","signature":"d27fedc14d727bd41658ca484be1ce21bcc9c7e0b3d818408cd532046859a025"},"10719c6dd6b07a17cb9be468bafce43a61e5afaeaa4f571c5b49866ac337a8b6","fe0a9db55849377425bef5a6f180779d0c7370cd9f4a41b520880c5ee1cb11fb",{"version":"c5c99d680e713636b83e2e037b5a292adf0b2dcac011b1b188552e5907f6d7a5","signature":"ec1f1f01352c73a0937c2a40170380abef423c4dcf2992fb99bb56b49aaa51da"},"0e606c69a1200fccb0e927c090393936f2ac1c11920b0e55753f0c4fc45fb778",{"version":"6b4c5c71b0aa3cac94c8b3b819f68a30d0248709e6bce38465bd8bfd7d7cf395","signature":"2e2e5eb0404c9ee51c4b0775b29c77d9690f2ec4d00f458cb35e222109052154"},{"version":"5f314bd27e54d77f98289eb1e174636987e346c78b9631a2a1bc14f5d8595725","signature":"73e0e3e35999739e628b0637799eb941aa871bd8de076709ede1386329f82592"},"fa1124d16b3dff467594eb52257875ba98e68e40f39e10dba0611c7cf8d657f1","4f6ab5fa695e1f3d02881def8d878313b2f103220b91e48db7ac3bc21239313b",{"version":"0b2d2659a5ea7be13b0b7fe40f2c4019e3be87c4dd921a8025ee382320a7b63e","signature":"fc51366f2f95439652c372af69f0537191ad05536c1f0388e61245e24775aab2"},{"version":"f354dde829d0ca02f714015e8ab8535b7e57d46d492c3e4e3e6aa2b3bc47eedd","signature":"a2da1d64ab723db4bac2a6ed71775670f4c364fa5e036439df7f5a4bf374ad77"},"9c24c78a38da64fd818c7488f69b9d3e3290ecca8d2d2e01389fffd60fbdcb11","508d52244a0011a2812d56877281530ea463ba6cdcaaabacee8d11116796be74","b47ee19e60f569336d7b5fb2933e702b83d57ed60a253edd66cb1227432a5004","9fcd9e148321b51599f5e159452bac3942fe8e5152e379d40eca23209c0a0fda","ee485f14ae7dfc8c0f38490472d1f546e61c030fe440c0ad68d0adc75b09e4db","62f0646b465914396e6910bc62de871a623d2277db1da460209380168be11788","d6ea9568e4855c97e8126e299a4df99ab0cc53ae477b032a27045172e54d41a5","39c340dec9bcc6bcc6f291a47dc6bbb6d298a114ad9f32b9829733b3e0830e38","c1c5a4d1a72757a92a31329e8f86753bb5ff0c4d0196b8466e168a171e5f4fda","3d7bdaf3ed469369002a47e51fc3c03af0ca6e657da5297480e20e4ecd6b5ece","cf0a557968c3c332ded37b6a899c82d605412fdff51756d1bf99c9251006ecde","f1f4867152604ba908a356c724970b6fa7c372d2f5f47ea04d37af4c043646b1",{"version":"2e25a1451ed168766182a729e70cf978f6666827bd7fbebdfb0a12600be3557b","signature":"4116303bda6b7fb8dd7eb94b2d884fa91762aeaf7dff24b2bc82e342f81dabaf"},"97b1f9f98dad479221ad2169fa7f94c6c4b049ed3b5bb2f88c9e2296973efb2a",{"version":"d6852d894450cf567f3aa71d0052f1de7ec00992f4d00159b0d9130f9ac156ab","signature":"c39f1006549fa2eeebd16334d4ca45452f3fe05b1f65805a6ab86b8fb5d2a213"},"928b3deb2de8a4e91cd4473631de7d098f7cefba8279b527db2fbe9b440919be","91ff3d9748b6abfd395824f0fee820c5638871b86e6400c21d0c1dbe162b836f",{"version":"1ee5b9e592624caba1ba0fcf518851c824f448376408819372bf950dce560322","signature":"ff64928bceba2e97d18a6c29086528499590c38dec23c15caed2983e7273e40a"},"225d85ad926f680f65b70a76869691ca99e633e3e4240810eaaa62dc45b5e092","1d217f400428c50966aa0342db49abc2e57c37143be7fb243e4d610d7680b717","02cd73f7d88b1a50873ac66115bec20abe49433c35f0cad5dd09fe6f2f5940f8","04134f50f9c61432f199b9419775578a04691eb5dcff09492e5af4f409793ce1",{"version":"29136ee2c5932f09c7eaee1608d3ef92f1535d6fbfd8857e5b5cdbf91a85bf6c","signature":"9338b9ad199b99a6b1ff47561b315794975f37cf174b3454120c8934b8c68536"},{"version":"1711236bdd3baa5e1bba2640cec90d8ce98eb9ef752b1cf24ab70209aed78b4c","signature":"828257350b90679519f8c12223e5c62c29c8775b8edd79b36aaddb99833a978a"},{"version":"106cad458688e9688f525b07808597fafbf7e2dbf27986a1bb7fa04772893965","signature":"b8c5eabe0b7190c57143d036e77ee4dac8592e9b95eda33484bd5ad593e73d0a"},"38888437f31ec5e2d39aef9c46f5092d5722bf43f472436ad1b1af08d0ffdc57",{"version":"284363ad97f3206b55bdd30c19b6727051203e030bd3d4008374d135f789b11a","signature":"d496c700803b0710f191cd5cbc375ecdce16a2615eb942382a03c297df106d92"},{"version":"87f0e1655eed475c75e8f8565b2c111b222eff3a0be57122e5dd7f54349cd26d","signature":"7ee119bf4559fcd74ef7cc140632f2c2abc905c8c8611a6c62f3a492dc91751f"},"90d293744f66eb2cd999f5a414d75c0f6eb572505814c1186f29563a03554550","516a0644a6400b8ad2699d64219cd623ac085cd72a6256caa17f6ebe0da01c7a","5da3a91b2b5294885b3dec780c177bfde9132af5980be86b54a3e7734e3c26af","91ca499e3074bf63918558870f50849bf4a60be52d5a07c5f7b4e1c81361ece6","230dae55a40402304706222bddb8008c7c7fca4843d7c2131c361621058a1303","f97aa7fd4854ae4781f63fd5d481f76cb728a8501f254748b195054547cffce6",{"version":"4808bce828d7d7195dc01061460b99aea69a268113007177eb07d09fbcb9f285","signature":"750f5e683a1b3bee494789e81dd0d74e3875c22501c739f8e08f89fe87f22d8c"},"7dfece00e91dedb5e6f262fe95608e17d93b0e3bcc88d3221de3bdf82c22a4a9","184e1728672781b439d0053f03e41aa2474569efa05bb2429e4ae000a9aed934","b6b55f2ed87d0e7a97b6a552c4becfd465f99598fe6e0a531b36beb2b98c4258","bbb46b0fbcdc576355c8044c59fc6041147db11505e5c4335bf3e97a5289efb5","a3d89d72d4526e41ccb86f770a5f55edf937a5f667b0befde576c1d4bdb0c9b2","0acb24ffe93a828277250a003cc910df744832316ebf80fdbd63c1b0fd13c940","7c1ebde823b390f4e738aa1384e6106f0246ce1d73b1d6d20d4e9c67623c26d0",{"version":"18e839a0576d60b32ba7865379783c9cc434bde58eaeb267aa5be1f6f1472529","signature":"e2545e88eba753162c37e135587174b1d856a6dcb6650a0e33019fea00061bc3"},"fc6bab518cd4dfd7b5b6a6d7df396ec46feddf36e51a0a05690395c3b9112a08",{"version":"7688603daec9c69e7b8fa4961bc345e5117d52d5afbce98564db5cd0bdb0db00","signature":"337d47027050863e14974d13d062f1f85a9b2869a84bb91bbe24df61ec852a93"},"56fd389ea239c6259f7ee1eaa9f82f0441c6566da29d670f5ec9df6b10b55d6e",{"version":"07a319b96bf1cd3f096c1e38dbf2f19c288341da0157c8ac3c2703abea63f334","signature":"a498c70749dbae8c2a9223452458dfa1494149b00eeeecbdc583fed68ceacc6a"},"2a2584577164a5f7520b29f83472b5125fde512794ff5ec5b2226eed0f098f6a",{"version":"1003909761e6fe401bbdc60c164dc9d4298f3130477606826ce3836667285cd9","signature":"83ec3f1be98f3acd093e99b1f25dde3c87bf2481504fda5f8b4a405b2d4c293e"},{"version":"d1c1c415fce0b467e37ba6b2c3c073dabc102add2d1ed3f062d471d9c6229b6c","signature":"69984b8b25cd8e7c40c21ffcd0f1e2212c2a59435f16ce886888fb5becef1b8a"},{"version":"bb89661a4eb77298a7369fb43414990eb6041032f9138e9f75798832d1bc4441","signature":"631d753965b13ce63248e76c8aa412b3449f32d228aa71ee2bb68b8cf7524346"},{"version":"f683193a216b17a9b0eea20665b78bc3a621987eb5d43174f45e455e8cf17ecf","signature":"79964a91a8dcd9a4ee5f0927ebf6c6d382e7c9019ca779190ece7b4f31ccbcac"},{"version":"2afac6b4b51cb2507ffa9100790292d6a939a389bb9ae311d0d5f9a80178ddee","signature":"6960507077823e96a18707a970623a4951f084125f15ed95fd39cda1c156fe18"},"3a157c2fd4e7f29b2adc842bbadf3bb9e1fb1b20ad5ca55fdc365ef130f77d48","040fd3a93bc770d200ecfbd83cab66b22e0c104fdaed29daea5f1ecc05f8dc74",{"version":"ba7eaef53d48056e1114a79518e5acc019db48011d708c03f47d57264be6d88f","signature":"f4209dba9f0d4739fde6fb305d4ec52da611e8221dbcb2603a569e61749f4b7e"},"fb59f620807e4d1dbc5cc95639262f89395c1d20fcab03efc323a221b07bca43","922d4ab256e7db3a63b52e0e4a8f2c7019491b58b0961f297c5b613a2783d970","85b6daaabfe2c275b7013d13df14031bbaf623357e3746bed9ca1afaab46bedf",{"version":"2ccb0e02fb165ef588673868e5e1b78d7f2b8f22cd463c8e015f9c9fcb8f3eb6","signature":"1114551db4fdd61cd6e610b04417ac73214ca1d86b5276ff01d5d213afae6028"},"f594f5af79c5a5c75dd9e04e39314d6a193b2a18369d57195ae3675ea25925e2","df75106fc1f7d530d3f7a276d62f1b22a634169bda121da7b0cff548493c1074","1cad8db035a510050fb929577f148153ef41f0fd2773df0149cbec01556c7050","43b3564d0973387eb4877c5e3a996883331e8c73a2a675aa4a708afcb4f12521","7ef1eabb4e5ec071c4aca406d80abf528ea9a15b968dbc3e275d8453c1924003","ee5d9091cc397c259f406c229cecbb893af5548944ec9b7c3dbf0daa9cced037","cdaa28c764ef022a285afecf6a4b1dc9287843dd2b45c703056e880467257cb6","57a452c40651b548c85a342f28241ee37a84e1967630ed318e515ee21d4c8ee5",{"version":"e0611572c6aa5500e703c4332eecb1235d618fc91315bd39d4ccedbe3a6fe710","signature":"88b9d1c95c5d24fe2226279e049758e255c38217bd1745c987396450664efffd"},{"version":"be4f9c6ac98f48266c88db65806e5dc4084170a229c6205159a53f0507c4c666","signature":"4f36e85c778a18388a2a0be741e41dfe694eaf53648a8187764a206d97646e33"},"bb299294f09f4dd9932c8ad94bb16d7d062353d172f72e0a99d7226f3ec5b659","83962d24b7752d8aa205f38ab58ba7fd4464017403e4eb8d2481234248a626d5","260da6fd223535a3d498a1af94f4249a3e67d28e56d7648dcc72e069911cc2c8","d1f0095428cf1de664d9594850b63567b7ca7325fedac6dfd425130041b420a2",{"version":"6c430c13fabc220d5e2053a81d1c993ba4341c9f461d98270b1ff503c8c48030","signature":"74e96d4aad53e97b1d97d79db9740de79b45fe9cd213fc15732e40e3ec0baefb"},{"version":"0740a1887c4e1c1c71fff7925a3feef85a6ab4d5b2ee4c24fb84357583da3402","signature":"096c2ea95aafd65206cb6bc34c9c7bff10e426532f7729fe2ec06284139c2935"},"a8124c363cdc2f4bbd08a4283e216b1d1fd358ddbdc658afd6fa510ed16740dd",{"version":"1c6cecc8cf9d39e2501347175b1be54b8dca76fed82b87f9fa0b8b7268cb5b61","signature":"a8a7731305eace3c153d32be9f06fbe63be69ae1cf8d3fc12beccdbf4dd3fa19"},{"version":"cdc15c17185913bb5b56f5f240728e7096ca133d78464876e98b6d38185c6d19","signature":"aad2dc88c90a3bd1cfee27769a3ddf1f5d1f11c22d24c21c571cd599393f27a9"},"58fc9b0a79e29fa867e347981ecbef6ef52ffacfc848be358cdc41a5d9e133c8","b3bb55f630f4d3113bd13408c49d2d7a2ea1ddf96061cb5952d0bf778d491eba","f10912d31d9010c44d3aa471b34e77be99329c64f69675b00104a21e4cc50470",{"version":"20e5482e50895255d12953211f19710988ab278b9f342db385f7985d34ec02bf","signature":"009b3d3467bee336fc5333ef326adaa0032f835085ab5d35a14a20004f2fda89"},{"version":"329cba5a5932d066b56c840e4409e8390f8853314aaf85f585626b77abbd49bf","signature":"e847e443a0d4a2648051133817a02200e0b9c4b2f616a992c7ca8325a7c71d24"},"75304d5f2ccf463507df23f4b8066e7e758225a1a36d6ebe83c1b714da6c2ac5","049398af73a373ead19379fd1da4809d18bf6cf7123e44116c1491ad58d5acf0","9d8c851e30a432fadf9709f5b7802b9346d7c4aa3db0f25907669f98178bdd6e","4eaf53474be1f3e9182de786375b87893f47081e491b45321cac3a8427250f5c","79804619db6df7144de589d927d3c861d5e037ee91c9d55590ac14f927e371ac","5f6104030d0571c22ccb732c1ff1ee3f9ce38d54d0157fb895fd99d62222103c","5a64c9b17d19f3d6f73be414c83c4b4a37bb1898fc8552e2d63f71f2f6904724","a17ffb4463a3144d95960125ae3ea541a055bfa1494a908dc8688a21a0a58216","9428fa53b4d4910ec04a42be6e8bc915242800381bf5782013965ae8b4f6e3ae",{"version":"dc9928627dc636458117a0f2888131ab222a160ed58d4942ebdba75d32beb69b","signature":"65bfecd2818ad03a178a366da92866f11bf28543c976ea5ece19a4da94325cc6"},{"version":"632cf9f97223c29fa65cf7eb81ba15d2d21a851dff8f5e0921b7e428a837af84","signature":"e528dae475f84ca4b0d288ed1214728e7cbcf7fbe575122b3b4ced8b74112d30"},{"version":"5e789cac338582e7c1522ce9fde6b2c4cfd626989fb5dedbdcbd1fdb7028e82d","signature":"d6fbdee4c4eff4dc60ad18d1fec3e807560151d406fc4d51c0932702f4d541d6"},{"version":"1ccf807c1e706cf662a2475227e453da3a57cd9f6c21aaa843d3358d619e78f4","signature":"a593cbb274f58781d5afa2b8940172320f01892dfb252a5298282006c89e7005"},"dad7f9496da68b21150b690b1bf00dcb5ddc05f575f082b9f79a0316e3cf0923",{"version":"356173560bdd68ce8d6f93eb359829b4b93fdc9799369052964544c0c0551303","signature":"c2c6accf3268c2f12ff2c819692b9cbe8d023558fc09f3016e7e561c3af52312"},"0ef5075c8c118e4b9f6d6e61164849a3da33cfa8a6d709b9e76b9e187cc502d7","30b18dfd86c195a4e504a9c7cab74e253cc9a81e5150a1e078a60116f8413a7b","eb572a47bd52202d9046b74cd08e11b817a09d7da9d411221c209605b53d50bf","507d41570c8cad3489d272cba504fd33baa7d60903086b2b1ea6a44a32cab870",{"version":"4f4dad224f28ae73a636eaca502d71b374a4095d5a3f540f66cb56a688e067f1","signature":"e205ad943a1d6322748964c1c5afe5fd8eed5e33b98cf4bf9d484122517d695f"},"4139b027a8724e900dc887009ac6df809cc5f6a5fa5f58d2d684a6f6d96ff245",{"version":"09e6acd94660ad0acf93ee0cce96ba7c540b67bd65ab5b043decb5ad9976c00d","signature":"a2b219ecd7ae1c03346f58fef9fed021265ee17449f7d5b9b3d8695bd91c859a"},"f90eaf7c39bcd43cca5e307991878da4c86947ade4c9c999534c75647e9356cf","5b9a0c2f29da015a2b552e9890b67dd33cbe9fd15c69384b8eb2e65b0be19320","01101a850ef55e575e5c27672a5788b954101ab3ee2009dc5d94820fd9aeda1d","6c26420749d17833efe84a50d61db06fbd4082e497e65ae2e4b82f2d23476279","d6c4e7806d89a5b3fe00eb53b08ae06a8d26226bc9cfbec2758d4db28462d0c4","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",{"version":"3bb648f566e49849b20bbef6a3b737a875ef4e774b33f94f53dfdbd9deac403f","signature":"9cb406fc96d00b47efe8bd92ee5171ea22892e0769380d587c8c446876049113"},{"version":"3d8e5168a19d52d77dc69b6785a069ed2f14000e24eb275dd5846fd31e473801","signature":"5d9e716981b9e4f0db938d47c0a36a42d15e58d16f6277c765c045defc98afa2"},"eb76714199edf4f96ba125503ae4114b4d557a55ad0fc85ec5bc30f09310bbaf","ac9ad025410d9d9a641205333a48c94a3de3caae3f5c3c4d155cf70b520556bc","e51177d723e64eb327102e10399d61ed9e89bddcf69260ac0600ca0a39b3f33b","b1bd895022be16cfda91bd37db27aab863df16082d760afe55ec3d912f1743b6","4143a3ab0eafd782c745bb4ce3b5ca2be6ca96cba8d23dc69755dcc348e319c7","6efb415baab05a5830113c471acf3c82cae9b9560036644f937cb34924921dcb","de5e7d2f6e5328b18b5dc3a39f3ca67df73577cee8b5ca8af48759f1264dca01","f24c8d553c4178e3bca9e6988140a5b98064a1f8820b53adc58803b6317c84c7",{"version":"043dc788a719c8ead6f63a3f38809be150dfab433068fd674cbe491c7b0af2fd","signature":"07ee64be21ebb6bb21d5c8d5c16c2bb936f3875b3b9e9f6efe55a3d4ed0b103a"},{"version":"99af15d20e90e9b50cdcfca19a43ae519039ba1c747da2e3f1eb0dff85345180","signature":"877ee28045b8616a94a107e666fadae50a0851d47aff15341802e1aa1826bcb5"},"6845f2b449f15f71067b9c3bbffdeb58439226b7e1dea3ac10c2b101a913d0c9","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"],"root":[[58,83],[125,129],[236,262],[265,268],[280,540]],"options":{"allowSyntheticDefaultImports":true,"alwaysStrict":true,"composite":true,"declaration":true,"declarationMap":true,"esModuleInterop":true,"exactOptionalPropertyTypes":false,"module":99,"noFallthroughCasesInSwitch":true,"noImplicitAny":true,"noImplicitReturns":true,"noImplicitThis":true,"noUncheckedIndexedAccess":true,"noUnusedLocals":false,"noUnusedParameters":false,"outDir":"./dist","rootDir":"./src","skipLibCheck":true,"sourceMap":true,"strict":true,"strictBindCallApply":true,"strictFunctionTypes":true,"strictNullChecks":true,"strictPropertyInitialization":true,"target":9,"tsBuildInfoFile":"./tsconfig.tsbuildinfo","useDefineForClassFields":true,"verbatimModuleSyntax":true},"referencedMap":[[235,1],[264,2],[263,3],[181,4],[182,4],[183,5],[135,6],[184,7],[185,8],[186,9],[130,3],[133,10],[131,3],[132,3],[187,11],[188,12],[189,13],[190,14],[191,15],[192,16],[193,16],[194,17],[195,18],[196,19],[197,20],[136,3],[134,3],[198,21],[199,22],[200,23],[234,24],[201,25],[202,3],[203,26],[204,27],[205,28],[206,29],[207,30],[208,31],[209,32],[210,33],[211,34],[212,34],[213,35],[214,3],[215,36],[216,37],[218,38],[217,39],[219,40],[220,41],[221,42],[222,43],[223,44],[224,45],[225,46],[226,47],[227,48],[228,49],[229,50],[230,51],[231,52],[137,3],[138,3],[139,3],[178,53],[179,3],[180,3],[232,54],[233,55],[140,3],[56,3],[57,3],[11,3],[10,3],[2,3],[12,3],[13,3],[14,3],[15,3],[16,3],[17,3],[18,3],[19,3],[3,3],[20,3],[21,3],[4,3],[22,3],[26,3],[23,3],[24,3],[25,3],[27,3],[28,3],[29,3],[5,3],[30,3],[31,3],[32,3],[33,3],[6,3],[37,3],[34,3],[35,3],[36,3],[38,3],[7,3],[39,3],[44,3],[45,3],[40,3],[41,3],[42,3],[43,3],[8,3],[49,3],[46,3],[47,3],[48,3],[50,3],[9,3],[51,3],[52,3],[53,3],[55,3],[54,3],[1,3],[156,56],[166,57],[155,56],[176,58],[147,59],[146,60],[175,1],[169,61],[174,62],[149,63],[163,64],[148,65],[172,66],[144,67],[143,1],[173,68],[145,69],[150,70],[151,3],[154,70],[141,3],[177,71],[167,72],[158,73],[159,74],[161,75],[157,76],[160,77],[170,1],[152,78],[153,79],[162,80],[142,81],[165,72],[164,70],[168,3],[171,82],[117,83],[86,3],[104,84],[116,85],[115,86],[85,87],[124,88],[87,3],[105,89],[114,90],[91,91],[102,92],[109,93],[106,94],[89,95],[88,96],[101,97],[92,98],[108,99],[110,100],[111,101],[112,101],[113,102],[118,3],[84,3],[119,101],[120,103],[94,104],[95,104],[96,104],[103,105],[107,106],[93,107],[121,108],[122,109],[97,3],[90,110],[98,111],[99,112],[100,113],[123,92],[273,3],[271,114],[276,3],[274,114],[272,115],[275,3],[270,3],[279,116],[277,3],[269,3],[278,3],[502,3],[503,3],[504,3],[505,3],[362,117],[363,118],[360,119],[508,3],[509,3],[510,3],[511,3],[512,3],[513,3],[514,3],[515,3],[516,3],[517,3],[518,3],[519,3],[506,3],[507,3],[520,3],[521,3],[522,3],[457,120],[458,121],[456,122],[455,3],[450,123],[376,124],[438,125],[453,3],[439,3],[441,126],[442,126],[444,127],[443,128],[454,129],[368,130],[445,131],[446,131],[447,132],[435,133],[436,133],[433,134],[432,135],[378,136],[379,136],[380,136],[381,136],[383,137],[382,136],[390,138],[387,139],[389,140],[388,141],[384,142],[385,133],[386,141],[391,136],[392,136],[399,143],[393,141],[394,141],[395,3],[397,144],[396,141],[398,133],[400,145],[401,136],[402,145],[412,146],[403,3],[405,147],[407,148],[404,141],[408,133],[409,149],[410,3],[406,3],[411,150],[413,145],[414,136],[415,145],[422,151],[418,152],[419,153],[417,154],[421,155],[416,133],[420,3],[423,136],[424,136],[425,136],[426,136],[427,136],[428,136],[430,136],[429,136],[434,156],[367,3],[431,157],[377,133],[437,158],[375,159],[371,160],[373,161],[372,162],[374,163],[370,164],[451,165],[452,166],[448,167],[369,3],[440,3],[449,168],[365,169],[366,170],[364,3],[328,171],[326,172],[327,172],[325,172],[324,3],[475,173],[474,174],[473,175],[466,3],[472,176],[467,177],[468,3],[469,178],[470,179],[471,180],[465,181],[459,3],[464,182],[461,183],[462,3],[460,3],[463,3],[292,184],[523,185],[291,186],[290,187],[289,187],[288,187],[280,188],[296,189],[299,190],[298,191],[297,125],[284,192],[268,193],[524,194],[281,195],[282,193],[283,196],[267,125],[251,197],[250,198],[249,199],[245,200],[247,201],[246,202],[248,203],[265,204],[266,205],[254,206],[253,207],[252,208],[255,209],[259,210],[260,211],[257,212],[258,213],[256,214],[261,211],[262,215],[244,3],[285,187],[295,216],[294,217],[286,218],[287,219],[293,220],[344,3],[350,221],[347,222],[348,223],[351,224],[345,3],[349,3],[346,3],[354,225],[355,226],[352,3],[353,3],[342,227],[343,228],[320,229],[339,230],[321,229],[322,231],[340,232],[323,231],[341,233],[319,3],[356,234],[316,235],[318,236],[314,237],[315,238],[317,239],[313,240],[311,3],[312,241],[310,242],[309,3],[306,243],[308,244],[305,245],[304,246],[307,247],[303,3],[302,3],[501,248],[357,249],[359,250],[358,251],[301,252],[361,253],[300,254],[477,233],[488,255],[479,256],[480,256],[481,256],[482,256],[483,256],[484,256],[485,256],[486,256],[487,256],[478,3],[489,257],[476,188],[338,258],[330,259],[331,259],[332,259],[333,259],[334,259],[335,259],[336,259],[337,260],[329,188],[535,261],[533,262],[526,263],[527,263],[528,263],[529,263],[530,263],[531,263],[532,263],[525,3],[534,264],[127,265],[81,266],[83,267],[125,268],[82,3],[126,266],[79,269],[74,270],[72,271],[75,272],[77,273],[78,272],[73,271],[128,274],[65,271],[70,275],[64,276],[71,271],[80,277],[67,278],[66,279],[69,280],[68,281],[537,3],[76,3],[538,3],[539,3],[540,3],[536,3],[243,282],[242,283],[238,284],[239,285],[240,284],[237,286],[241,287],[236,288],[129,289],[60,253],[61,3],[58,3],[63,289],[59,125],[62,290],[492,291],[498,292],[490,188],[493,291],[495,293],[500,294],[496,295],[494,296],[497,291],[499,297],[491,3]],"affectedFilesPendingEmit":[[362,51],[363,51],[360,51],[508,51],[509,51],[510,51],[511,51],[512,51],[513,51],[514,51],[515,51],[516,51],[517,51],[518,51],[519,51],[506,51],[507,51],[520,51],[521,51],[522,51],[457,51],[458,51],[456,51],[455,51],[450,51],[376,51],[438,51],[453,51],[439,51],[441,51],[442,51],[444,51],[443,51],[454,51],[368,51],[445,51],[446,51],[447,51],[435,51],[436,51],[433,51],[432,51],[378,51],[379,51],[380,51],[381,51],[383,51],[382,51],[390,51],[387,51],[389,51],[388,51],[384,51],[385,51],[386,51],[391,51],[392,51],[399,51],[393,51],[394,51],[395,51],[397,51],[396,51],[398,51],[400,51],[401,51],[402,51],[412,51],[403,51],[405,51],[407,51],[404,51],[408,51],[409,51],[410,51],[406,51],[411,51],[413,51],[414,51],[415,51],[422,51],[418,51],[419,51],[417,51],[421,51],[416,51],[420,51],[423,51],[424,51],[425,51],[426,51],[427,51],[428,51],[430,51],[429,51],[434,51],[367,51],[431,51],[377,51],[437,51],[375,51],[371,51],[373,51],[372,51],[374,51],[370,51],[451,51],[452,51],[448,51],[369,51],[440,51],[449,51],[365,51],[366,51],[364,51],[328,51],[326,51],[327,51],[325,51],[324,51],[475,51],[474,51],[473,51],[466,51],[472,51],[467,51],[468,51],[469,51],[470,51],[471,51],[465,51],[459,51],[464,51],[461,51],[462,51],[460,51],[463,51],[292,51],[523,51],[291,51],[290,51],[289,51],[288,51],[280,51],[296,51],[299,51],[298,51],[297,51],[284,51],[268,51],[524,51],[281,51],[282,51],[283,51],[267,51],[251,51],[250,51],[249,51],[245,51],[247,51],[246,51],[248,51],[265,51],[266,51],[254,51],[253,51],[252,51],[255,51],[259,51],[260,51],[257,51],[258,51],[256,51],[261,51],[262,51],[244,51],[285,51],[295,51],[294,51],[286,51],[287,51],[293,51],[344,51],[350,51],[347,51],[348,51],[351,51],[345,51],[349,51],[346,51],[354,51],[355,51],[352,51],[353,51],[342,51],[343,51],[320,51],[339,51],[321,51],[322,51],[340,51],[323,51],[341,51],[319,51],[356,51],[316,51],[318,51],[314,51],[315,51],[317,51],[313,51],[311,51],[312,51],[310,51],[309,51],[306,51],[308,51],[305,51],[304,51],[307,51],[303,51],[302,51],[501,51],[357,51],[359,51],[358,51],[301,51],[361,51],[300,51],[477,51],[488,51],[479,51],[480,51],[481,51],[482,51],[483,51],[484,51],[485,51],[486,51],[487,51],[478,51],[489,51],[476,51],[338,51],[330,51],[331,51],[332,51],[333,51],[334,51],[335,51],[336,51],[337,51],[329,51],[535,51],[533,51],[526,51],[527,51],[528,51],[529,51],[530,51],[531,51],[532,51],[525,51],[534,51],[127,51],[81,51],[83,51],[125,51],[82,51],[126,51],[79,51],[74,51],[72,51],[75,51],[77,51],[78,51],[73,51],[128,51],[65,51],[70,51],[64,51],[71,51],[80,51],[67,51],[66,51],[69,51],[68,51],[537,51],[76,51],[538,51],[539,51],[540,51],[536,51],[243,51],[242,51],[238,51],[239,51],[240,51],[237,51],[241,51],[236,51],[129,51],[60,51],[61,51],[58,51],[63,51],[59,51],[62,51],[492,51],[498,51],[490,51],[493,51],[495,51],[500,51],[496,51],[494,51],[497,51],[499,51],[491,51]],"emitSignatures":[58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,125,126,127,128,129,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,265,266,267,268,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540],"version":"5.9.3"} \ No newline at end of file +{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","./src/types/graph.ts","./src/types/providers.ts","./src/types/deployment.ts","./src/types/errors.ts","./src/types/result.ts","./src/types/index.ts","./src/schema/schema-provider.ts","./src/schema/resource-validator-types.ts","./src/schema/validation/error-conversion.ts","./src/schema/validation/constraints.ts","./src/schema/validation/type-checker.ts","./src/schema/validation/property-validator.ts","./src/schema/resource-validator.ts","./src/schema/type-mapper.ts","./src/schema/embedded/events.ts","./src/schema/embedded/sqlite-types.ts","./src/schema/embedded/converters.ts","./src/schema/embedded/graph-queries.ts","./src/schemas/db/index.ts","./src/schema/embedded/initialization.ts","./src/schema/embedded/queries.ts","./src/schema/embedded-schema-provider.ts","./src/schema/unified-type-resolver.ts","./src/schema/customization/base-db.ts","./src/schema/customization/paths.ts","./src/schema/customization/example-files.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/line-counter.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/errors.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/doc/applyreviver.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/log.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/tojs.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/scalar.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/stringify/stringify.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/collection.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/yamlseq.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/types.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/common/map.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/common/seq.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/common/string.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/stringify/foldflowlines.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/stringify/stringifynumber.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/stringify/stringifystring.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/util.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/yamlmap.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/identity.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/schema.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/doc/createnode.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/addpairtojsmap.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/pair.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/tags.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/options.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/node.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/cst-scalar.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/cst-stringify.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/cst-visit.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/cst.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/nodes/alias.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/doc/document.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/doc/directives.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/compose/composer.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/lexer.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/parse/parser.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/public-api.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/yaml-1.1/omap.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/schema/yaml-1.1/set.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/visit.d.ts","../../node_modules/.pnpm/yaml@2.8.3/node_modules/yaml/dist/index.d.ts","./src/schema/customization/file-validators.ts","./src/schema/customization/scanner.ts","./src/schema/customization-loader.ts","./src/schema/index.ts","./src/state/state-store.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/compatibility/index.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/globals.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/abortcontroller.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/domexception.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/events.d.ts","../../node_modules/.pnpm/buffer@5.7.1/node_modules/buffer/index.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/header.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/readable.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/file.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/fetch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/formdata.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/connector.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-origin.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool-stats.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/handlers.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-handler.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/api.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/interceptors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/util.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cookies.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/patch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/websocket.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/eventsource.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/filereader.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/content-type.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cache.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/index.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/fetch.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/navigator.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/web-globals/storage.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/assert.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/assert/strict.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/async_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/buffer.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/child_process.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/cluster.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/console.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/constants.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/crypto.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/dgram.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/dns.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/dns/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/domain.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/events.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/fs.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/fs/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/http.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/http2.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/https.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/inspector.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/inspector.generated.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/module.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/net.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/os.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/path.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/perf_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/process.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/punycode.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/querystring.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/readline.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/readline/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/repl.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/sea.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/sqlite.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/stream.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/stream/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/stream/consumers.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/stream/web.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/string_decoder.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/test.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/timers.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/timers/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/tls.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/trace_events.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/tty.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/url.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/util.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/v8.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/vm.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/wasi.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/worker_threads.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/zlib.d.ts","../../node_modules/.pnpm/@types+node@22.19.15/node_modules/@types/node/index.d.ts","../../node_modules/.pnpm/@types+better-sqlite3@7.6.13/node_modules/@types/better-sqlite3/index.d.ts","./src/state/sqlite/types.ts","./src/state/sqlite/resources.ts","./src/state/sqlite/deployments.ts","./src/state/sqlite/lifecycle.ts","./src/state/sqlite/locks.ts","./src/state/sqlite/snapshots.ts","./src/state/sqlite-state-store.ts","./src/state/index.ts","./src/graph/parser/tokens.ts","./src/graph/parser/ast/types/base.ts","./src/graph/parser/ast/types/expressions.ts","./src/graph/parser/ast/types/blocks.ts","./src/graph/parser/ast/types/statements.ts","./src/graph/parser/ast/types.ts","./src/graph/parser/ast/helpers.ts","./src/graph/parser/ast.ts","./src/graph/parser/lexer-state.ts","./src/graph/parser/lexer-scanners.ts","./src/graph/parser/lexer-heredoc.ts","./src/graph/parser/lexer.ts","./src/graph/parser/parser-state.ts","./src/graph/parser/parser-literals.ts","./src/graph/parser/parser-primary.ts","./src/graph/parser/parser-binary-exprs.ts","./src/graph/parser/parser-block-body.ts","./src/graph/parser/parser-statements.ts","./src/graph/parser/parser.ts","../../node_modules/.pnpm/@types+js-yaml@4.0.9/node_modules/@types/js-yaml/index.d.ts","../../node_modules/.pnpm/@types+js-yaml@4.0.9/node_modules/@types/js-yaml/index.d.mts","./src/graph/parser/format-parser.ts","./src/graph/parser/index.ts","./src/graph/mutable-graph/types.ts","./src/graph/mutable-graph/edges.ts","../constants/src/providers.ts","../constants/src/ice-types.ts","../constants/src/categories.ts","../constants/src/feature-flags.ts","../constants/src/ai.ts","../constants/src/derived.ts","../constants/src/grid.ts","../constants/src/connections.ts","../constants/src/node-traits.ts","../constants/src/block-classifiers.ts","../constants/src/templates.ts","../constants/src/integrations.ts","../constants/src/index.ts","./src/graph/classifier/category-classifier.ts","./src/graph/mutable-graph/nodes.ts","./src/graph/mutable-graph/stats-serialize.ts","./src/graph/mutable-graph/traversal.ts","./src/graph/mutable-graph.ts","./src/graph/validator/base-validator.ts","./src/graph/validator/validators/schema.ts","./src/graph/validator/validators/security.ts","./src/graph/algorithms/topo-cycle.ts","./src/graph/algorithms/paths.ts","./src/graph/algorithms/components.ts","./src/graph/algorithms/analysis.ts","./src/graph/algorithms.ts","./src/graph/validator/validators/structure.ts","./src/graph/validator/validators.ts","./src/graph/validator/index.ts","./src/graph/classifier/index.ts","./src/graph/inference/relationship-inferrer.ts","./src/graph/inference/index.ts","./src/graph/index.ts","./src/providers/provider-registry.ts","./src/providers/index.ts","./src/importers/terraform/types.ts","./src/importers/terraform/type-mapper.ts","./src/importers/terraform/sensitive.ts","./src/importers/terraform/resource-conversion.ts","./src/importers/terraform/graph-conversion.ts","./src/importers/terraform/state-importer.ts","./src/importers/terraform/index.ts","./src/importers/pulumi/types.ts","./src/importers/pulumi/type-mapper/parse.ts","./src/importers/pulumi/type-mapper/data.ts","./src/importers/pulumi/type-mapper/mapping.ts","./src/importers/pulumi/type-mapper.ts","./src/importers/pulumi/parsing.ts","./src/importers/pulumi/resource-conversion.ts","./src/importers/pulumi/graph-conversion.ts","./src/importers/pulumi/state-importer.ts","./src/importers/pulumi/index.ts","./src/importers/gcp/types.ts","./src/importers/gcp/relationships.ts","./src/importers/gcp/services/base-service.ts","./src/importers/gcp/services/compute.ts","./src/importers/gcp/services/storage.ts","./src/errors/import-errors/types.ts","./src/errors/import-errors/gcp.ts","./src/errors/import-errors/aws.ts","./src/errors/import-errors/azure.ts","./src/errors/import-errors.ts","./src/resources/high-level-resources/types.ts","./src/resources/high-level-resources/categories/compute.ts","./src/resources/high-level-resources/categories/database.ts","./src/resources/high-level-resources/categories/messaging.ts","./src/resources/high-level-resources/categories/monitoring.ts","./src/resources/high-level-resources/categories/networking.ts","./src/resources/high-level-resources/categories/security.ts","./src/resources/high-level-resources/categories/storage.ts","./src/resources/high-level-resources/helpers.ts","./src/resources/high-level-resources.ts","./src/importers/gcp/services/asset-inventory.ts","./src/importers/gcp/services/index.ts","./src/importers/gcp/type-mapper.ts","./src/importers/gcp/gcp-importer.ts","./src/importers/gcp/index.ts","./src/importers/aws/arn-helpers.ts","./src/importers/aws/sdk-init.ts","./src/importers/aws/types.ts","./src/importers/aws/discovery.ts","./src/importers/aws/graph-conversion.ts","./src/importers/aws/type-mapper.ts","./src/importers/aws/aws-importer.ts","./src/importers/aws/index.ts","./src/importers/azure/type-mapper.ts","./src/importers/azure/types.ts","./src/importers/azure/azure-importer.ts","./src/importers/azure/index.ts","./src/importers/index.ts","./src/plan/diff.ts","./src/plan/plan-engine.ts","./src/plan/index.ts","./src/apply/types.ts","./src/providers/mock-provider.ts","./src/apply/apply-engine.ts","./src/apply/index.ts","./src/diff/types.ts","./src/diff/diff.ts","./src/diff/index.ts","./src/deploy/providers/gcp/messages.ts","./src/deploy/messages.ts","./src/deploy/types.ts","./src/deploy/scheduler/types.ts","./src/deploy/scheduler/dag.ts","./src/deploy/scheduler/predicates.ts","./src/deploy/scheduler/dispatch.ts","./src/deploy/scheduler/progress-wrapper.ts","./src/deploy/scheduler.ts","./src/deploy/deploy-engine.ts","./src/deploy/providers/gcp/types.ts","./src/deploy/providers/gcp/handlers/api-gateway.ts","./src/deploy/providers/gcp/handlers/backend-bucket.ts","./src/deploy/providers/gcp/handlers/bigquery.ts","./src/deploy/providers/gcp/handlers/cloud-armor.ts","./src/deploy/providers/gcp/handlers/cloud-functions.ts","./src/deploy/providers/gcp/handlers/cloud-build-helper.ts","./src/deploy/providers/gcp/handlers/cloud-run/image-resolver.ts","./src/deploy/providers/gcp/handlers/cloud-run/result-helpers.ts","./src/deploy/providers/gcp/handlers/cloud-run/utils.ts","./src/deploy/providers/gcp/handlers/cloud-run/create-job.ts","./src/deploy/providers/gcp/handlers/cloud-run/iam.ts","./src/deploy/providers/gcp/handlers/cloud-run/create-service.ts","./src/deploy/providers/gcp/handlers/cloud-run.ts","./src/deploy/providers/gcp/handlers/cloud-scheduler.ts","./src/deploy/providers/gcp/handlers/cloud-sql.ts","./src/deploy/providers/gcp/handlers/cloud-storage/bucket-creator.ts","./src/deploy/providers/gcp/handlers/cloud-storage/bucket-updater.ts","./src/deploy/providers/gcp/handlers/cloud-storage/bucket-utils.ts","./src/deploy/providers/gcp/handlers/cloud-storage/public-access-granter.ts","./src/deploy/providers/gcp/handlers/cloud-storage/placeholder-uploader.ts","./src/deploy/providers/gcp/handlers/cloud-storage/result-helpers.ts","./src/deploy/providers/gcp/handlers/cloud-storage.ts","./src/deploy/providers/gcp/handlers/dataflow.ts","./src/deploy/providers/gcp/handlers/discovery-engine.ts","./src/deploy/providers/gcp/handlers/domain-mapping.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/dns-extractor.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/rest-client.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/domain-registrar.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/tar-parser.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/github-downloader.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/result-helpers.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/site-provisioner.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/site-utils.ts","./src/deploy/providers/gcp/handlers/firebase-hosting/version-publisher.ts","./src/deploy/providers/gcp/handlers/firebase-hosting.ts","./src/deploy/providers/gcp/handlers/firestore.ts","./src/deploy/providers/gcp/handlers/gke.ts","./src/deploy/providers/gcp/handlers/identity-platform.ts","./src/deploy/providers/gcp/handlers/load-balancer/result-helpers.ts","./src/deploy/providers/gcp/handlers/load-balancer/compute-ops.ts","./src/deploy/providers/gcp/handlers/load-balancer/backend-creator.ts","./src/deploy/providers/gcp/handlers/load-balancer/cert-fetcher.ts","./src/deploy/providers/gcp/handlers/load-balancer/url-builder.ts","./src/deploy/providers/gcp/handlers/load-balancer/lb-builder.ts","./src/deploy/providers/gcp/handlers/load-balancer.ts","./src/deploy/providers/gcp/handlers/logging.ts","./src/deploy/providers/gcp/handlers/managed-ssl-certificate.ts","./src/deploy/providers/gcp/handlers/memorystore.ts","./src/deploy/providers/gcp/handlers/pubsub.ts","./src/deploy/providers/gcp/handlers/secret-manager.ts","./src/deploy/providers/gcp/handlers/subnet.ts","./src/deploy/providers/gcp/handlers/vpc.ts","./src/deploy/providers/gcp/handlers/vertex-ai.ts","./src/deploy/providers/gcp/sdk-loader.ts","./src/deploy/providers/gcp/gcp-deployer.ts","./src/deploy/providers/gcp/auth.ts","./src/deploy/providers/gcp/index.ts","./src/deploy/providers/aws/sdk-loader.ts","./src/deploy/providers/aws/account.ts","./src/deploy/providers/aws/handlers/_result.ts","./src/deploy/providers/aws/types.ts","./src/deploy/providers/aws/handlers/api-gateway.ts","./src/deploy/providers/aws/handlers/bedrock.ts","./src/deploy/providers/aws/handlers/cloudfront.ts","./src/deploy/providers/aws/handlers/cloudwatch-logs.ts","./src/deploy/providers/aws/handlers/cognito.ts","./src/deploy/providers/aws/handlers/docdb.ts","./src/deploy/providers/aws/handlers/dynamodb.ts","./src/deploy/providers/aws/handlers/ec2.ts","./src/deploy/providers/aws/iam-roles.ts","./src/deploy/providers/aws/handlers/ecs.ts","./src/deploy/providers/aws/handlers/elasticache.ts","./src/deploy/providers/aws/handlers/elbv2.ts","./src/deploy/providers/aws/handlers/events-rule.ts","./src/deploy/providers/aws/handlers/lambda-builder.ts","./src/deploy/providers/aws/handlers/lambda.ts","./src/deploy/providers/aws/handlers/opensearch.ts","./src/deploy/providers/aws/handlers/rds.ts","./src/deploy/providers/aws/handlers/redshift.ts","./src/deploy/providers/aws/handlers/s3.ts","./src/deploy/providers/aws/handlers/sagemaker.ts","./src/deploy/providers/aws/handlers/secrets-manager.ts","./src/deploy/providers/aws/handlers/sns.ts","./src/deploy/providers/aws/handlers/sqs.ts","./src/deploy/providers/aws/aws-deployer.ts","./src/deploy/providers/aws/index.ts","./src/deploy/providers/aws-deployer.ts","./src/deploy/providers/azure-deployer.ts","./src/deploy/providers/index.ts","./src/deploy/block-deploy-classifiers.ts","./src/deploy/edge-classifier.ts","./src/deploy/extractors/ancillary.ts","./src/deploy/extractors/aws/ai.ts","./src/deploy/extractors/aws/ancillary.ts","./src/deploy/utils/name-utils.ts","./src/deploy/extractors/compute.ts","./src/deploy/extractors/aws/compute.ts","./src/deploy/extractors/aws/database.ts","./src/deploy/extractors/aws/network.ts","./src/deploy/extractors/database.ts","./src/deploy/extractors/network.ts","./src/deploy/extractors/dispatch.ts","./src/deploy/internal-ingress-overrides.ts","./src/deploy/passes/deploy-expansion.ts","./src/deploy/passes/pass-1-4-repo-wiring.ts","./src/deploy/passes/pass-1-45-domain-propagation.ts","./src/deploy/passes/pass-1-46-socket-port-targeting.ts","./src/deploy/self-serving-resources.ts","./src/deploy/passes/pass-1-5-endpoint-wiring.ts","./src/deploy/type-maps.ts","./src/deploy/utils/stable-name.ts","./src/deploy/card-translator.ts","./src/deploy/state-bridge.ts","./src/deploy/state-store-adapter.ts","./src/deploy/environment-config.ts","./src/deploy/index.ts","./src/compute/types.ts","./src/compute/propagation-rules.ts","./src/compute/compute-derived.ts","./src/compute/index.ts","./src/export/terraform/case-utils.ts","./src/export/terraform/types.ts","./src/export/terraform/hcl-formatter.ts","./src/export/terraform/type-mapping.ts","./src/export/terraform/value-transform.ts","./src/export/terraform/converter.ts","./src/export/terraform-exporter.ts","./src/export/pulumi/case-utils.ts","./src/export/pulumi/type-mapping.ts","./src/export/pulumi/types.ts","./src/export/pulumi/typescript-formatter.ts","./src/export/pulumi/value-transform.ts","./src/export/pulumi/yaml-formatter.ts","./src/export/pulumi/converter.ts","./src/export/pulumi-exporter.ts","./src/export/index.ts","./src/errors/index.ts","./src/resources/cloud-providers.ts","./src/resources/blueprint-factory.ts","./src/resources/cloud-blocks-types.ts","./src/resources/cloud-blocks-data/backend.ts","./src/resources/cloud-blocks-data/compute.ts","./src/resources/cloud-blocks-data/data.ts","./src/resources/cloud-blocks-data/frontend.ts","./src/resources/cloud-blocks-data/messaging.ts","./src/resources/cloud-blocks-data/networking.ts","./src/resources/cloud-blocks-data/observability.ts","./src/resources/cloud-blocks-data/security.ts","./src/resources/cloud-blocks-data/storage.ts","./src/resources/cloud-blocks-data.ts","./src/resources/cloud-blocks.ts","./src/validation/classifiers.ts","./src/validation/types.ts","./src/validation/architecture-rules.ts","./src/validation/connection-rules.ts","./src/validation/schema-bridge.ts","./src/validation/deploy-rules.ts","./src/validation/property-rules.ts","./src/validation/structure-rules.ts","./src/validation/canvas-validator.ts","./src/validation/template-validator.ts","./src/validation/index.ts","./src/index.ts","./src/__tests__/card-translator.test.d.ts","./src/__tests__/core.test.d.ts","./src/__tests__/pulumi-importer.test.d.ts","./src/__tests__/terraform-importer.test.d.ts","./src/cli/index.ts","./src/cli/messages.ts","./src/cli/bin/ice.ts","./src/cli/commands/apply.ts","./src/cli/commands/config.ts","./src/cli/commands/deploy.ts","./src/cli/commands/destroy.ts","./src/cli/commands/diff.ts","./src/cli/commands/graph.ts","./src/cli/commands/import.ts","./src/cli/commands/plan.ts","./src/cli/commands/providers.ts","./src/cli/commands/schema.ts","./src/cli/commands/state.ts","./src/cli/utils/config.ts","./src/cli/utils/index.ts","./src/cli/utils/output.ts","../../node_modules/.pnpm/@vitest+pretty-format@4.1.0/node_modules/@vitest/pretty-format/dist/index.d.ts","../../node_modules/.pnpm/@vitest+utils@4.1.0/node_modules/@vitest/utils/dist/display.d.ts","../../node_modules/.pnpm/@vitest+utils@4.1.0/node_modules/@vitest/utils/dist/types.d.ts","../../node_modules/.pnpm/@vitest+utils@4.1.0/node_modules/@vitest/utils/dist/helpers.d.ts","../../node_modules/.pnpm/@vitest+utils@4.1.0/node_modules/@vitest/utils/dist/timers.d.ts","../../node_modules/.pnpm/@vitest+utils@4.1.0/node_modules/@vitest/utils/dist/index.d.ts","../../node_modules/.pnpm/@vitest+utils@4.1.0/node_modules/@vitest/utils/dist/types.d-bcelap-c.d.ts","../../node_modules/.pnpm/@vitest+utils@4.1.0/node_modules/@vitest/utils/dist/diff.d.ts","../../node_modules/.pnpm/@vitest+runner@4.1.0/node_modules/@vitest/runner/dist/tasks.d-d2gkpdwq.d.ts","../../node_modules/.pnpm/@vitest+runner@4.1.0/node_modules/@vitest/runner/dist/index.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/traces.d.402v_yfi.d.ts","../../node_modules/.pnpm/vite@8.0.1_@types+node@25.5.0_esbuild@0.27.4_jiti@2.6.1_tsx@4.21.0_yaml@2.8.3/node_modules/vite/types/hmrpayload.d.ts","../../node_modules/.pnpm/vite@8.0.1_@types+node@25.5.0_esbuild@0.27.4_jiti@2.6.1_tsx@4.21.0_yaml@2.8.3/node_modules/vite/dist/node/chunks/modulerunnertransport.d.ts","../../node_modules/.pnpm/vite@8.0.1_@types+node@25.5.0_esbuild@0.27.4_jiti@2.6.1_tsx@4.21.0_yaml@2.8.3/node_modules/vite/types/customevent.d.ts","../../node_modules/.pnpm/vite@8.0.1_@types+node@25.5.0_esbuild@0.27.4_jiti@2.6.1_tsx@4.21.0_yaml@2.8.3/node_modules/vite/types/hot.d.ts","../../node_modules/.pnpm/vite@8.0.1_@types+node@25.5.0_esbuild@0.27.4_jiti@2.6.1_tsx@4.21.0_yaml@2.8.3/node_modules/vite/dist/node/module-runner.d.ts","../../node_modules/.pnpm/@vitest+snapshot@4.1.0/node_modules/@vitest/snapshot/dist/environment.d-dojxxzv9.d.ts","../../node_modules/.pnpm/@vitest+snapshot@4.1.0/node_modules/@vitest/snapshot/dist/rawsnapshot.d-u2kjuxdr.d.ts","../../node_modules/.pnpm/@vitest+snapshot@4.1.0/node_modules/@vitest/snapshot/dist/index.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/config.d.ejlve3es.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/environment.d.crsxczp1.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/rpc.d.bfmwpdph.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/worker.d.b84svry0.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/browser.d.x3sxoocv.d.ts","../../node_modules/.pnpm/@vitest+spy@4.1.0/node_modules/@vitest/spy/dist/index.d.ts","../../node_modules/.pnpm/tinyrainbow@3.1.0/node_modules/tinyrainbow/dist/index.d.ts","../../node_modules/.pnpm/@standard-schema+spec@1.1.0/node_modules/@standard-schema/spec/dist/index.d.ts","../../node_modules/.pnpm/@types+deep-eql@4.0.2/node_modules/@types/deep-eql/index.d.ts","../../node_modules/.pnpm/assertion-error@2.0.1/node_modules/assertion-error/index.d.ts","../../node_modules/.pnpm/@types+chai@5.2.3/node_modules/@types/chai/index.d.ts","../../node_modules/.pnpm/@vitest+expect@4.1.0/node_modules/@vitest/expect/dist/index.d.ts","../../node_modules/.pnpm/@vitest+runner@4.1.0/node_modules/@vitest/runner/dist/utils.d.ts","../../node_modules/.pnpm/tinybench@2.9.0/node_modules/tinybench/dist/index.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/benchmark.d.daahlpsq.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/global.d.x-ilcfae.d.ts","../../node_modules/.pnpm/@vitest+mocker@4.1.0_vite@8.0.1_@types+node@25.5.0_esbuild@0.27.4_jiti@2.6.1_tsx@4.21.0_yaml@2.8.3_/node_modules/@vitest/mocker/dist/types.d-bji5eawu.d.ts","../../node_modules/.pnpm/@vitest+mocker@4.1.0_vite@8.0.1_@types+node@25.5.0_esbuild@0.27.4_jiti@2.6.1_tsx@4.21.0_yaml@2.8.3_/node_modules/@vitest/mocker/dist/index.d-b41z0auw.d.ts","../../node_modules/.pnpm/@vitest+mocker@4.1.0_vite@8.0.1_@types+node@25.5.0_esbuild@0.27.4_jiti@2.6.1_tsx@4.21.0_yaml@2.8.3_/node_modules/@vitest/mocker/dist/index.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/suite.d.udjtyagw.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/chunks/evaluatedmodules.d.bxj5omdx.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/runners.d.ts","../../node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/utils.d.ts","../../node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/overloads.d.ts","../../node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/branding.d.ts","../../node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/messages.d.ts","../../node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/index.d.ts","../../node_modules/.pnpm/vitest@4.1.0_@opentelemetry+api@1.9.0_@types+node@25.5.0_jsdom@29.1.1_vite@8.0.1_@types_e7fcc74193e0e73f2abf87bac0e3d214/node_modules/vitest/dist/index.d.ts","./src/deploy/providers/__tests__/_aws-test-harness.ts","./src/graph/algorithms/__tests__/fixtures.ts","./src/graph/mutable-graph/index.ts","./src/resources/scale-presets-types.ts","./src/resources/scale-presets-data/compute.ts","./src/resources/scale-presets-data/database.ts","./src/resources/scale-presets-data/messaging.ts","./src/resources/scale-presets-data/monitoring.ts","./src/resources/scale-presets-data/networking.ts","./src/resources/scale-presets-data/security.ts","./src/resources/scale-presets-data/storage.ts","./src/resources/scale-presets-data.ts","./src/resources/scale-presets.ts","./src/resources/index.ts","./src/schemas/index.ts","./src/schemas/db/graph-queries.ts","./src/schemas/db/schema-merger.ts","./src/schemas/db/sqlite-registry.ts","./src/schemas/embedded/schema-registry.ts"],"fileIdsList":[[135,184,201,202],[135,184,201,202,234],[135,184,201,202,591,592],[135,184,201,202,263],[135,181,182,184,201,202],[135,183,184,201,202],[184,201,202],[135,184,189,201,202,219],[135,184,185,190,195,201,202,204,216,227],[135,184,185,186,195,201,202,204],[130,131,132,135,184,201,202],[135,184,187,201,202,228],[135,184,188,189,196,201,202,205],[135,184,189,201,202,216,224],[135,184,190,192,195,201,202,204],[135,183,184,191,201,202],[135,184,192,193,201,202],[135,184,194,195,201,202],[135,183,184,195,201,202],[135,184,195,196,197,201,202,216,227],[135,184,195,196,197,201,202,211,216,219],[135,177,184,192,195,198,201,202,204,216,227],[135,184,195,196,198,199,201,202,204,216,224,227],[135,184,198,200,201,202,216,224,227],[133,134,135,136,137,138,139,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233],[135,184,195,201,202],[135,184,201,202,203,227],[135,184,192,195,201,202,204,216],[135,184,201,202,205],[135,184,201,202,206],[135,183,184,201,202,207],[135,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233],[135,184,201,202,209],[135,184,201,202,210],[135,184,195,201,202,211,212],[135,184,201,202,211,213,228,230],[135,184,196,201,202],[135,184,195,201,202,216,217,219],[135,184,201,202,218,219],[135,184,201,202,216,217],[135,184,201,202,219],[135,184,201,202,220],[135,181,184,201,202,216,221,227],[135,184,195,201,202,222,223],[135,184,201,202,222,223],[135,184,189,201,202,204,216,224],[135,184,201,202,225],[135,184,201,202,204,226],[135,184,198,201,202,210,227],[135,184,189,201,202,228],[135,184,201,202,216,229],[135,184,201,202,203,230],[135,184,201,202,231],[135,177,184,201,202],[135,177,184,195,197,201,202,207,216,219,227,229,230,232],[135,184,201,202,216,233],[135,184,201,202,565,571,573,588,589,590,593,598],[135,184,201,202,599],[135,184,201,202,599,600],[135,184,201,202,569,571,572],[135,184,201,202,569,571],[135,184,201,202,569],[135,184,201,202,564,569,580,581],[135,184,201,202,564,580],[135,184,201,202,564,570],[135,184,201,202,564],[135,184,201,202,566],[135,184,201,202,564,565,566,567,568],[135,184,201,202,605,606],[135,184,201,202,605,606,607,608],[135,184,201,202,605,607],[135,184,201,202,605],[135,149,153,184,201,202,227],[135,149,184,201,202,216,227],[135,144,184,201,202],[135,146,149,184,201,202,224,227],[135,184,201,202,204,224],[135,144,184,201,202,234],[135,146,149,184,201,202,204,227],[135,141,142,145,148,184,195,201,202,216,227],[135,149,156,184,201,202],[135,141,147,184,201,202],[135,149,170,171,184,201,202],[135,145,149,184,201,202,219,227,234],[135,170,184,201,202,234],[135,143,144,184,201,202,234],[135,149,184,201,202],[135,143,144,145,146,147,148,149,150,151,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,171,172,173,174,175,176,184,201,202],[135,149,164,184,201,202],[135,149,156,157,184,201,202],[135,147,149,157,158,184,201,202],[135,148,184,201,202],[135,141,144,149,184,201,202],[135,149,153,157,158,184,201,202],[135,153,184,201,202],[135,147,149,152,184,201,202,227],[135,141,146,149,156,184,201,202],[135,184,201,202,216],[135,144,149,170,184,201,202,232,234],[135,184,201,202,575],[135,184,201,202,575,576,577,578],[135,184,201,202,577],[135,184,201,202,573,595,596,598],[135,184,201,202,573,574,586,598],[135,184,201,202,564,571,573,582,598],[135,184,201,202,579],[135,184,201,202,564,573,582,585,594,597,598],[135,184,201,202,573,574,579,582,598],[135,184,201,202,573,595,596,597,598],[135,184,201,202,573,579,583,584,585,598],[135,184,201,202,564,569,571,573,574,579,582,583,584,585,586,587,588,594,595,596,597,598,601,602,603,604,609],[135,184,201,202,564,571,573,574,582,583,595,596,597,598,602],[85,108,109,113,115,116,135,184,201,202],[93,103,109,115,135,184,201,202],[115,135,184,201,202],[85,89,92,101,102,103,106,108,109,114,116,135,184,201,202],[84,135,184,201,202],[84,85,89,92,93,101,102,103,106,107,108,109,113,114,115,117,118,119,120,121,122,123,135,184,201,202],[88,101,106,135,184,201,202],[88,89,90,92,101,109,113,115,135,184,201,202],[102,103,109,135,184,201,202],[89,92,101,106,109,114,115,135,184,201,202],[88,89,90,92,101,102,108,113,114,115,135,184,201,202],[88,90,102,103,104,105,109,113,135,184,201,202],[88,109,113,135,184,201,202],[109,115,135,184,201,202],[88,89,90,91,100,103,106,109,113,135,184,201,202],[88,89,90,91,103,104,106,109,113,135,184,201,202],[84,86,87,89,93,103,106,107,109,116,135,184,201,202],[85,89,109,113,135,184,201,202],[113,135,184,201,202],[110,111,112,135,184,201,202],[86,108,109,115,117,135,184,201,202],[93,135,184,201,202],[93,102,106,108,135,184,201,202],[93,108,135,184,201,202],[89,90,92,101,103,104,108,109,135,184,201,202],[88,92,93,100,101,103,135,184,201,202],[88,89,90,93,100,101,103,106,135,184,201,202],[108,114,115,135,184,201,202],[89,135,184,201,202],[89,90,135,184,201,202],[87,88,90,94,95,96,97,98,99,101,104,106,135,184,201,202],[135,184,201,202,270],[135,184,201,202,269,271],[135,184,201,202,269,270,271,272,273,274,275,276,277,278,279,280],[58,59,60,135,184,201,202,286,360,362,363],[135,184,201,202,362,364],[58,59,60,135,184,201,202],[135,184,201,202,496,497],[135,184,201,202,496,497,498],[135,184,201,202,281,496],[58,135,184,201,202,281,286,340,470,474,481,482,483,484,485,486,488,489,490],[58,135,184,201,202,366,367,370,371,377],[58,135,184,201,202,281,469],[135,184,201,202,475],[135,184,201,202,474],[135,184,201,202,281],[135,184,201,202,471,472,473,475,476,477,478,479,480],[135,184,189,201,202,281],[135,184,201,202,370,371,377,378,433,435,468,491,492,493,494],[135,184,201,202,369],[135,184,201,202,286,340,474,491],[135,184,201,202,286,491],[135,184,201,202,286,469,491],[135,184,201,202,281,286,470,474,487,491],[135,184,201,202,610],[135,184,201,202,465],[135,184,201,202,437],[135,184,201,202,371,437,438,440,441,442,443,444,445,446,447,448,450,451,452,453,455,456,457,458,459,460,461,462,463],[135,184,201,202,371],[135,184,201,202,437,439,440],[135,184,201,202,371,437,440],[135,184,201,202,437,439,440,449],[135,184,185,196,201,202,205,206,437,440],[135,184,201,202,371,437,440,454],[135,184,201,202,437,438,440,449,464],[135,184,201,202,371,438],[135,184,201,202,370,433],[135,184,201,202,369,370,371,379,380,381,382,383,384,392,393,394,401,402,403,404,414,415,416,417,424,425,426,427,428,429,430,431,432,433],[135,184,201,202,369,371,379],[135,184,201,202,369,379],[135,184,201,202,369,379,386,387,388,389,390,391],[135,184,201,202,369,371,379,386,387,388],[135,184,201,202,369,371,379,386,387,388,390],[135,184,201,202,379],[135,184,201,202,369,379,385],[135,184,201,202,369,379,395,396,397,398,399,400],[135,184,201,202,379,397,398],[135,184,201,202,371,379],[135,184,201,202,379,405,406,407,409,410,411,412,413],[135,184,201,202,379,405,406],[135,184,201,202,233,379,408],[135,184,201,202,379,406],[135,184,189,201,202,233,379,406,408],[135,184,201,202,379,418,419,420,421,422,423],[135,184,201,202,379,418,419],[135,184,201,202,379,418],[135,184,201,202,369,379,418],[135,184,201,202,379,418,419,420,422],[135,184,201,202,379,434,435],[135,184,201,202,370,379],[135,184,201,202,436,466,467],[135,184,201,202,371,372,373,374,375,376],[58,135,184,201,202,366,372],[135,184,201,202,366,371,372,374],[135,184,201,202,372],[135,184,201,202,366,371],[58,135,184,201,202,366,371],[58,135,184,201,202,371],[58,135,184,201,202,242,492],[135,184,201,202,491],[135,184,189,201,202,474],[58,135,184,201,202,366],[135,184,201,202,366,367],[135,184,201,202,326,327,328,329],[135,184,201,202,326],[135,184,201,202,330],[135,184,201,202,506,514],[79,135,184,201,202,286,509,513],[58,64,79,135,184,201,202,286,507,508,509,510,511,512],[135,184,201,202,507],[135,184,201,202,507,508,509],[135,184,201,202,507,509],[135,184,201,202,509],[79,135,184,201,202,286,501,505],[58,64,79,135,184,201,202,286,500,501,502,503,504],[135,184,201,202,501],[135,184,201,202,290,291,292,293],[135,184,201,202,286],[58,135,184,201,202,286,290,292],[58,135,184,201,202,286],[135,184,201,202,282],[135,184,201,202,266,286,294,297,298,300],[135,184,201,202,299],[58,135,184,201,202],[58,135,184,201,202,267,268,283,284,285],[58,135,184,201,202,267],[135,184,201,202,267,268,283,284,285],[58,135,184,201,202,267,268,282],[58,135,184,201,202,267,268],[135,184,201,202,249,250],[135,184,201,202,244,249],[135,184,201,202,245,246,247,248],[135,184,201,202,244],[135,184,201,202,245,246],[135,184,201,202,245],[135,184,201,202,245,246,247],[135,184,201,202,244,251,264],[135,184,201,202,244,251,255,262,265],[135,184,201,202,252,253],[135,184,201,202,244,252],[135,184,201,202,244,255],[135,184,201,202,244,252,253,254],[135,184,201,202,251,256,257,258],[135,184,201,202,251,256,257,259],[135,184,201,202,244,251,256],[135,184,201,202,244,251,256,257,259],[135,184,201,202,244,262],[135,184,201,202,244,251,256,257,260,261],[135,184,201,202,287,296],[64,135,184,201,202,287,288,289,295],[64,135,184,201,202,286,287],[135,184,201,202,286,287],[135,184,201,202,286,287,294],[135,184,201,202,286,330,347,348,349,350,351],[135,184,201,202,346,347,348],[58,135,184,201,202,286,348],[135,184,201,202,348,351,352],[58,135,184,201,202,286,330,354,355],[135,184,201,202,354,355,356],[58,135,184,201,202,286,321,322,342,343],[135,184,201,202,321,322,342,343,344],[135,184,201,202,321],[135,184,196,201,202,321,323,330,340],[135,184,201,202,321,323],[135,184,201,202,323,324,325,341],[135,184,201,202,340],[135,184,201,202,310,320,345,353,357],[58,135,184,201,202,286,311,319],[135,184,201,202,311,315,319],[135,184,201,202,311,315],[135,184,201,202,311,315,316,319],[135,184,196,197,201,202,286,311,315,316,317,318],[135,184,201,202,312,314],[135,184,201,202,312,313],[135,184,201,202,311],[58,135,184,201,202,286,304,309],[135,184,201,202,304,305,309],[135,184,201,202,304,305,306,309],[135,184,201,202,304],[135,184,196,197,201,202,286,304,306,307,308],[63,128,135,184,201,202,243,301,303,340,358,361,363,365,368,495,499,515,516,517,518,530,541],[60,135,184,201,202],[135,184,201,202,359,360],[58,59,60,135,184,201,202,286,294,359],[135,184,201,202,302],[58,59,135,184,201,202],[59,61,62,135,184,201,202],[135,184,201,202,519,520,521,522,523,524,525,526,527,528],[135,184,201,202,519],[135,184,201,202,519,529],[135,184,201,202,331,339],[135,184,201,202,331],[135,184,201,202,281,331,332,333,334,335,336,337,338],[135,184,201,202,340,517,518,530,623],[135,184,201,202,614,615,616,617,618,619,620,621],[135,184,201,202,614],[135,184,201,202,614,622],[81,82,83,125,126,135,184,196,201,202,206],[135,184,196,201,202,206],[82,135,184,196,201,202,206],[124,135,184,196,201,202],[61,62,64,72,73,75,77,78,135,184,201,202],[64,73,135,184,201,202],[64,135,184,201,202],[61,62,64,73,74,135,184,201,202],[73,76,135,184,196,201,202,206],[64,70,71,79,80,127,135,184,201,202],[61,62,64,65,66,69,135,184,201,202],[61,62,135,184,201,202],[64,79,135,184,201,202],[64,65,135,184,201,202],[61,65,135,184,201,202],[64,65,67,68,135,184,201,202],[65,135,184,201,202],[129,135,184,201,202,242],[58,60,61,62,129,135,184,201,202,236,237,238,239,240,241],[60,61,62,129,135,184,201,202,236,237],[61,62,135,184,201,202,235,236],[58,59,60,61,62,129,135,184,201,202,235,236],[61,62,129,135,184,201,202,236,237],[129,135,184,201,202,235],[58,59,60,61,62,135,184,201,202],[61,135,184,201,202],[135,184,201,202,531,532],[135,184,201,202,532,533,534,536,537,538],[135,184,201,202,281,531,532,535],[135,184,201,202,532,533,534,535,536,537,538,539,540],[135,184,201,202,340,532,535],[135,184,201,202,281,340],[135,184,201,202,531,532,535]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},"9f422a1bcf9b6de329433e9846e2de072714d0feb659261d0915ed89b7227871","9f06cca3f1b2c3d560cdbec5c0a3bfafdf58dc2439d7065ddc5f23af72fdaa73","8c467364768b887a7c3f3af90e02fcd1cf443ce162bb498082bfbcc99beb2a88","905a105127753c3ce4f2d19d866c0d5565758691579204519bb9b8fd838b790b","62d8d9c7b326f1d1392cf56c82d9c6b042039875caa7b503b0a0b41d0c3123c8","44a47ae3fa050377c57d37fb051a6c6cbc08405bd0efda40861653de399a0818","7170f18d5cdfb2d301f0470233e281da189a3b0a8d9ea4358edcb21b4c9a5728","cbbede9be700512a66d94e48c4ef981a6d1a1678698ee36f7dbbb4ac747aeaf3","a92c3adb47ae83dffb2b67e18f045efb376efd1547941de56366340c06d61077","341494baae56b3810de282a6932c5c4edf0b44adf7aa00e1ced71a9258141315","c1e5340e6e2c73765454ef0056093661c0b4159518797e442c58d9e0dfbbe8cc","92b52627eb99e20e437d33fc58808896145bc40ae551a991844e7658395bdfce","21b14a29d87dcddf64f59d7865144dadf7c455696b7c509d973d46d73af2eedf","0685b918bf7e38ff946e8feb260e193f48626b7eed70669f1a4c5ecd0b3cd444","33943d74593fcd8670b682fbda86001d2e728b13fceb5d22961074f186619743","36cfb8ac02cece02f64db23c7ba4fb95ec2b56fa8d753250aff3f2b1663a9e34","64d61d96ca6dffcff65d3021718e83ed23bb14517aeb1f02e7c3ad993eb0a0bc","8ebea2af5e5b387063de6535a5ecb197f32b1ae6614df257432c087725161c6f","c0bd5e212ce61814fcdc9240b90868b1108c37f21da55d21cabddd3e180f195e","326a61d40b8bdb5b3c35f83e3cb0a78af05cfcd4539f9098c9fe46e32b5c6326","044f2620626e0bd1ce6161513ccc00cc107e502eb2d540c543ea7dcc54f18cba","bdaaa669518e13907293d935c2fe379936525a1226cbfe7b5ed1b407bac5234a","f6cd6b7cda0141a3f0d629aff6c529e3f55245d1c198c450737d7cea81b56d1a","b5981034fd2b7724d3b4f4acdba358dc34ddaa3ad509ef9399c630c86cb9ccc1","0cc85eed70c33b0d113321a7fe2a8493c530cdec9babef148241f7203f35cc4b","c442972d48882efb43e3e64e1b30a68effd736227742a3ff936162d0ef320a4c",{"version":"3dfcd0a3bfa70b53135db3cf2e4ddcb7eccc3e4418ce833ae24eecd06928328f","impliedFormat":1},{"version":"bea7cae6a8b2d41fd1a9d70475b54d741dd7ca2103904934858108eec0336a69","impliedFormat":1},{"version":"bc41a8e33caf4d193b0c49ec70d1e8db5ce3312eafe5447c6c1d5a2084fece12","impliedFormat":1},{"version":"7c33f11a56ba4e79efc4ddae85f8a4a888e216d2bf66c863f344d403437ffc74","impliedFormat":1},{"version":"cbef1abd1f8987dee5c9ed8c768a880fbfbff7f7053e063403090f48335c8e4e","impliedFormat":1},{"version":"9249603c91a859973e8f481b67f50d8d0b3fa43e37878f9dfc4c70313ad63065","impliedFormat":1},{"version":"0132f67b7f128d4a47324f48d0918ec73cf4220a5e9ea8bd92b115397911254f","impliedFormat":1},{"version":"06b37153d512000a91cad6fcbae75ca795ecec00469effaa8916101a00d5b9e2","impliedFormat":1},{"version":"8a641e3402f2988bf993007bd814faba348b813fc4058fce5b06de3e81ed511a","impliedFormat":1},{"version":"281744305ba2dcb2d80e2021fae211b1b07e5d85cfc8e36f4520325fcf698dbb","impliedFormat":1},{"version":"e1b042779d17b69719d34f31822ddba8aa6f5eb15f221b02105785f4447e7f5b","impliedFormat":1},{"version":"6858337936b90bd31f1674c43bedda2edbab2a488d04adc02512aef47c792fd0","impliedFormat":1},{"version":"15cb3deecc635efb26133990f521f7f1cc95665d5db8d87e5056beaea564b0ce","impliedFormat":1},{"version":"e27605c8932e75b14e742558a4c3101d9f4fdd32e7e9a056b2ca83f37f973945","impliedFormat":1},{"version":"f0443725119ecde74b0d75c82555b1f95ee1c3cd371558e5528a83d1de8109de","impliedFormat":1},{"version":"7794810c4b3f03d2faa81189504b953a73eb80e5662a90e9030ea9a9a359a66f","impliedFormat":1},{"version":"b074516a691a30279f0fe6dff33cd76359c1daacf4ae024659e44a68756de602","impliedFormat":1},{"version":"57cbeb55ec95326d068a2ce33403e1b795f2113487f07c1f53b1eaf9c21ff2ce","impliedFormat":1},{"version":"a00362ee43d422bcd8239110b8b5da39f1122651a1809be83a518b1298fa6af8","impliedFormat":1},{"version":"a820499a28a5fcdbf4baec05cc069362041d735520ab5a94c38cc44db7df614c","impliedFormat":1},{"version":"33a6d7b07c85ac0cef9a021b78b52e2d901d2ebfd5458db68f229ca482c1910c","impliedFormat":1},{"version":"8f648847b52020c1c0cdfcc40d7bcab72ea470201a631004fde4d85ccbc0c4c7","impliedFormat":1},{"version":"7821d3b702e0c672329c4d036c7037ecf2e5e758eceb5e740dde1355606dc9f2","impliedFormat":1},{"version":"213e4f26ee5853e8ba314ecad3a73cd06ab244a0809749bb777cbc1619aa07d8","impliedFormat":1},{"version":"1720be851bdb7cdbff68061522a71d9ddaa69db1fe90c6819a26953da05942f2","impliedFormat":1},{"version":"961fa18e1658f3f8e38c23e1a9bc3f4d7be75b056a94700291d5f82f57524ff0","impliedFormat":1},{"version":"079c02dc397960da2786db71d7c9e716475377bcedd81dede034f8a9f94c71b8","impliedFormat":1},{"version":"a7595cbb1b354b54dff14a6bb87d471e6d53b63de101a1b4d9d82d3d3f6eddec","impliedFormat":1},{"version":"1f49a85a97e01a26245fd74232b3b301ebe408fb4e969e72e537aa6ffbd3fe14","impliedFormat":1},{"version":"9c38563e4eabfffa597c4d6b9aa16e11e7f9a636f0dd80dd0a8bce1f6f0b2108","impliedFormat":1},{"version":"a971cba9f67e1c87014a2a544c24bc58bad1983970dfa66051b42ae441da1f46","impliedFormat":1},{"version":"df9b266bceb94167c2e8ae25db37d31a28de02ae89ff58e8174708afdec26738","impliedFormat":1},{"version":"9e5b8137b7ee679d31b35221503282561e764116d8b007c5419b6f9d60765683","impliedFormat":1},{"version":"3e7ae921a43416e155d7bbe5b4229b7686cfa6a20af0a3ae5a79dfe127355c21","impliedFormat":1},{"version":"c7200ae85e414d5ed1d3c9507ae38c097050161f57eb1a70bef021d796af87a7","impliedFormat":1},{"version":"4edb4ff36b17b2cf19014b2c901a6bdcdd0d8f732bcf3a11aa6fd0a111198e27","impliedFormat":1},{"version":"810f0d14ce416a343dcdd0d3074c38c094505e664c90636b113d048471c292e2","impliedFormat":1},{"version":"9c37dc73c97cd17686edc94cc534486509e479a1b8809ef783067b7dde5c6713","impliedFormat":1},{"version":"5fe2ef29b33889d3279d5bc92f8e554ffd32145a02f48d272d30fc1eea8b4c89","impliedFormat":1},{"version":"e39090ffe9c45c59082c3746e2aa2546dc53e3c5eeb4ad83f8210be7e2e58022","impliedFormat":1},{"version":"9f85a1810d42f75e1abb4fc94be585aae1fdac8ae752c76b912d95aef61bf5de","impliedFormat":1},"db9e4672c7c667a0deabd6276b696e5994d3730e5586b5b87267aa63d9f72328","6ba80b835f61ba3a1e68f225ae88fa6e3f7c5f65ca051bc1756b7c899d3e9274","4631550a659e584f008891ce1c715ce29d0cbc3dd1ffbfc76ad2b50eff702948","ab0970f1b7769b6a37dd837ce570dfeb1124740a38f15c9294b246eb1bc780c4","116f359450e3ce8b94d3a4e321826d56d77ac599b68f9c07b4a4d34b2e8bf3ed",{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"9855e02d837744303391e5623a531734443a5f8e6e8755e018c41d63ad797db2","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"c906fb15bd2aabc9ed1e3f44eb6a8661199d6c320b3aa196b826121552cb3695","impliedFormat":1},{"version":"22295e8103f1d6d8ea4b5d6211e43421fe4564e34d0dd8e09e520e452d89e659","impliedFormat":1},{"version":"58647d85d0f722a1ce9de50955df60a7489f0593bf1a7015521efe901c06d770","impliedFormat":1},{"version":"6b4e081d55ac24fc8a4631d5dd77fe249fa25900abd7d046abb87d90e3b45645","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"83e63d6ccf8ec004a3bb6d58b9bb0104f60e002754b1e968024b320730cc5311","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"a3fc63c0d7b031693f665f5494412ba4b551fe644ededccc0ab5922401079c95","impliedFormat":1},{"version":"f27524f4bef4b6519c604bdb23bf4465bddcccbf3f003abb901acbd0d7404d99","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"6b039f55681caaf111d5eb84d292b9bee9e0131d0db1ad0871eef0964f533c73","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"c8d3e5a18ba35629954e48c4cc8f11dc88224650067a172685c736b27a34a4dc","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2b55d426ff2b9087485e52ac4bc7cfafe1dc420fc76dad926cd46526567c501a","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"47613031a5a31510831304405af561b0ffaedb734437c595256bb61a90f9311b","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"8a1a0d0a4a06a8d278947fcb66bf684f117bf147f89b06e50662d79a53be3e9f","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"9f55299850d4f0921e79b6bf344b47c420ce0f507b9dcf593e532b09ea7eeea1","impliedFormat":1},{"version":"c2a6a737189ced24ffe0634e9239b087e4c26378d0490f95141b9b9b042b746c","impliedFormat":1},"8ab9053eb153fdd8636f6b3f30208e0aaaefd7c23cd7c87be7ef913d36aaebe8","57430b85fd84c95c5b5f867dfbdbdffbd11ef2e48965eb9ecc7f4fb9d78be325","08eb64a1afdeda9c7cd87114906037388d7af9d91bce27c293f55dee9ce5a8c2","b5076e65f873347e41f18a2d239083a544c080b880fd62de907bd064465e1290","b9e4ff74d366947b2c7ff9158b5c878480c1523bffa20590914fa53c29144e07","63a9ab201a2da78d4d5c505c1c4c9fe380fffc7368f9d22fe7084b4e491977c4","903dd73508a4b3ae23daa597ca913eac9f75e3621539691b0a216047278e9efa","52741694e94b7b6b04c12285da7e10103e66b57126303a593a257fb5e086a491","b28b6136a5678e5b01ed6019b0b9ef8b22446f7d291f081bcddc71051d2a6492","583610054995599f0ed590a55b9f81466909e2087ee04b6c2461d6e0c8b08b43","c8449a6bc016461d1269ba1cd5aaeadd845e458bde39966f1fb434e5e99204ce","7ea0e0cdd8e14703d723a50b6c0d07f620d825f0e148d6c9aa97af9950fcb44c","2fd75662e4cd0cd568d6c0857e7a71648addda2a1c6d01918fae951dce5b519a","a2ef968f2f25e45f7a634cba822bc2e7542678615fe0161826a8b2e82e071acb","88b62f5279f98c2c529bc562a99dcd1c1cf96c5f3698b04936ec7cd95722ddda","59b46ed04998fc50de4f3a8896fa90c08e79530e15da952bf1aade4498f63694","c4d2a21697551d6e34d05cc2d20920bf142723d8f9f9ff4396b1b7d700e0795e","98a3ed3fac7a473fa6fc4beca3de620139d52f9863497e943f053c124eae15a8","cafaee243ca4ca03a097af441baadf576180f1f6b0c0a71bd2cf27c92dc3c635","d5132708f5591db907f3adda24e0a67970e99d588082cf0cc673359c4140c1d4","5ac4bd7d11ce705620a56ed6903e3f0c2a432f616bfbd96d408d5e77d40773a5","54ae1f916462a6ec28561bf5c31fa31411c2b95421419952f82fb29efe33714e","5a226d5a51ad9fabfe0849f5d5c81b9460ccdd9184796f1467cb992bbc5ce012","5ddfa20a02c6207915c3c9d2fbdb57c70f549307638c4608a63c4049c591dc6a","c35b355506a0ec45a8488207a4df84cde8d696832d6157f61b6f851863ee1b66","edb83dc67f334db478790a845e10b191ab51d035160a88e5fd3876664dec1612","df984b0bafb5e53912367951ffc923a81ca6e15e6164b634055880e4344cac36",{"version":"7a1dd1e9c8bf5e23129495b10718b280340c7500570e0cfe5cffcdee51e13e48","impliedFormat":1},{"version":"95bf7c19205d7a4c92f1699dae58e217bb18f324276dfe06b1c2e312c7c75cf2","impliedFormat":99},"b045297393a1167259e546c9c8f790f11d1bb9e56faee2a96ebf9dce560f198d","9ca31fd2cb6a4a2a79f0b9655deaec5de960871406084ddc7834e5f0caf6825d","25fdd9095e3c88304fcacabc177f915c88f911ec76eec1bef97b0a5296b69b42","1b6a4d8614e087ac52dbff37fc366bdb9d9f6ded3aed6b9fc6a42c9a07c7df6e","76cc26cd0f1fc54af550f8841264bfffae32d47ceb78f0234554492a2009fa6f","649415d05765a968aad9b1906b023aa680e332b6fe32bf31d4e96ae972797107","3803a665d8434387ab3b02307f2724bf49e2c74295f930e7fe4ba58bca51a078","cc395f85c2a42db8cbc0e1749695af300abad568b8ff801b4ff1929071c2ffcd","e6f912a58bbeba4d07663b3655e1ef880f01e7a194a86e351d4a9b96785b9dc4","b93c566bb2c61594e867c53306a6d56b086e7658013d2b9d233e89fab5acbb35","7a0aed8f3aa05ec618437c5c7fbbfd2295321316c197a171bc12de0fbb2bb922","791e111b25123db45cda32073ba13222b5a34a947b0156eda9e77187f2ef91ca","fb0547afd53f48e07ad555fd6efd0de07fecf26615cc93a4607c0220211bb114","998feccbffe65bfb424ba1acc11357db6e7fd611eb99ebd237d860eda1e97138","7be4ecdc1589196748775376a609a917fd731034f8a1fafa7b5ebc9cf5e5bd53","62bbb0c5c7b0e2a53d11f4cadb782609af4e12e79170fd58163110088393b05a","0ca8883957ce65b76ec86ef9d3aac955dc3c4c46530cb729b88a6d2d737af186","542267bec9796016127a97865a0f97ea180cb8fc3f89606fb8c9cc4253126acf","28b5fc5754f4ebdf042cb5b27931e63b4ed8f5b64df7bf3be4b77d974640396c","89e0e9ac65c95aea0a539bd60ace8484b3a5c3cc4d897a68812217af830fc23d","0988e2afa78428c0d1cd50268b54dbcf421f80c992d8a1a6055f4133730ec6a9","f2905ff1575a84c410192ec46aa3f4996765c1e849ba421a259e05c7b2fc340d","aa34083e206a5f93e16d7c8ad6974bf445da6be4b5a7722d2ee98789faf7f8ae","ffe50ea4d20591a5940538606dd44f33a5dc343b6cc9d043377bb2c5f72dca13","38e01627ea43a1196ab44549409015e5c6f5b301fd332346a2e0fe38fddc6472","c3de1901e84728e2075eb45ca5df0d75ecd0e35e919e826fa8339404ed5536fb","676403375489df3da1edc59ed83d950fb937530b99d1691e495e7e84920510c1","8f8327bf5762a69f257f2eb84e3cf865c83a5a9c134adf38be12ce69bae4eb7b","11833b3c60fc9ab463a626d92764f42597c64fc93119ebadbbf9790756db7d5a","126096fa7c786d9d03b90f9e81f2145a2237e4781504957d3aa2ee4382f5b39f","1d095cf9256ec12ecd33cf34815253020c89230be4212f1fbbd9b5dc345bf2a0","ec7fc48032dd77a91fba9a4b98a4ab79135026a5364eef928ca03ce4c6936c7a","d2b46d9c36e9927cc6b59679dbc45a92ed19909fcc25412772af6bf7cdcc5a4b","1500929393a66eac684b644318dec8d947f7cece77bceac84395a96cd0e819db","cc998ddaa317600c0c56e53e6e76cfd3b3e23a0783fbf371f294201fd3b15e39","0fa7d91ac72697950720581ea9265cc0d7aaf5e559ae21bee6cb6ec3f7e4f3c4","6aa749045a7e565ab60023cd6cdaa21f9f0c0eb724ab4cc82f8b567182679813","42661bdd3babb7325e5c1050855251dc1ebdf4b14e69ef732bb948cd3fadb831","d202cab2cfd4555a3f2cc559ed805fe0e9d8890dd79e34646d142c0c24b95141","ea2f9f2010acbf2c1e02e79513fcbc6715fc69483b0b270e26b7b16592790111","36d25724c00295ceadde4a1f69ea32c94d749823dd5ede6258b928ed699f1768","2efef5f483459d0177a69b02baef9b931fd045a852f27a4f295f6ed958eb57f2","222ab086b785ed8c221acbd2922699f3411227a510d23788e52c303515b01d28","09b127a60bb3bd8625f2e0910af7c7e86afacc05fcdfe7ea0d9f73c381d7c8aa","fd0cecf30f9f963f1b0148733981087da93014fb0f69a9e065dab17fe67643ae","d9bac95e5cc44d0ea516e83cddda764f54dc5dd698cee1c64356ec968ebf57c0","2ec1e34570bc9b528ed078112f31576dc9ba1c973dc83a8c61c80b7bc425d4bf","b4ca0b2ae78101bdc252134c8519eacb2ceb931c9c38176706ac467b4eff7f55","b6c3f5b183e5428d20d6f6e3290a1e8efdbdc5cd8ec31184bb3a018b17c3d7cc","37cb6bb48aba80a010297eb815aecc07c0ea334509944c66305018a8fdaa2c65","be7b800e572c75d9eb786fa7448c9e108dab1cece51f55f73b4144e91b8906e4","0cb6b0a4e69bd922c9fae22e5d4cdb24b148ec0d29766c200738fdf5255a40d1","c34561f348d2cad7395bc203ab64e2edac6184a5f0184ecf19e22a71724e4fb9","9bb87d1dcfb06b265c96c8bff31a95d7003128fdd24750dc6ffc7ba2e4cd357f","00a6ccbc8dcd86c5d895ad39c2dbf030f2b6064c421bb49a021fae08f266e16a","5971247075b05ed88679485c1d056aec4cae062eb6951b995788c69a654a1c3c","f731d9570dc4e6c8a828c6b9f0228877820afaac8d2fdc00f4e8aff8d85c0311","dbcfc7d4a685b33c38be3dc2611615e692604bfdea5625f14d9f1156ad6722cb","83a184bbff2dd763f8e8051aed20b7b620d55778918945b23ef7241e6792abf7","fb73e07df3a40fbb9daf4ec269ac01946ce2a35cae550dc79e34d7ea8bdcecee","be6fe6c54c0b3a07b8667b02fc6981b5b40758ede42caccb7fa81646ba567d24","031f18d0b2394a9704c2f96564fd098bc5d9b8770a0467623cccb2d74f9f6d6a","670b9a2aafef1ec78813620c5a738bb1df2235a8c2def1702b8d58332b52c177","6e07a05d78279eb87db944470d6b16bf3c5ffc8a43b7a17bbaa9d80d34c638f1","9c2eb90da480fa93d78e04cc441c282a07a2c7428bdade5cd35ee20de7cd888a","3d3f74d81aa3ee49f3218c9f7ad9a9202891581cdc4cf3dde9b92ddde4e781d2","e9dea9b7f3586beb174d10099b644685798d15fb3d6dd5e57e7c4f2bb5068188","0d9237c67866eadb203d211bd7eaa1b3aba550a0c8a6bc1260d344c2c4a4ae7a","c60b24102572f64fb0023f5413404293c35c73b9734bd71da9d571b08111a81a","f479af6869f5f6e70b8d8e19f59995c6ae59e23bb56670c96ce30485ac98a1d9","f0bc31f07565c26ec58b2d3a26453b9019a83109cdb2254601b3e1cdd6fedd43","dd94387420ae72f77718a1eb8373de4687dcd6267f92411de88bdd3da5de2312","c66384b76689bf7ab5bb36915013d4836b7eff025a220b807bcdc106b1d4adf5","7f95716c1c1e73c9c27e91618a727846a86b6f69b8b9f16f84faf5f2a7fe7580","c24cb3c039b97ba6f6ae397234350e7f6f52e4088bc6d6b03de6d08907d5e3eb","01384cb437c106f902313bf25b0e8e65ac7b9ce0811bee3bfe7dfd073cd9b383","3c0a996c5eccc7c17f68c1ebb45c299ca6d7dbb9daf58c8fff3392409c31a9ea","ea2635643910097eba211a15f93faffd343817feb00af8efefd97c81b48e2fed","ca06a25e3a9bf82bfdb6f9f61d6d67f6c90960b6e20df43abea3c9eab515bbea","d1b1da0800ae66d723c43f2a07965d216addd736382b674a363d086b755f5efb","0d597693230d3eca85524fa33430fe1aed403b459510b0abe00a4ead3aeb4025","5d2667f3261713c3ed1dc5fdf5532bd49fdd74f399fb7e5d21901e29c7f6f1dd","d050988d2be49157325e97f3319532684b8ede2b83b87abcd8832b651b29b471","5a8fd21287dc8e39f13cbcb1ec3908f0f36428b4d575c02ef76c709d4cd07e3b","4e02c2619e7a81dfd2b13b89917c7320d5ebd87d46f79ea113d088a0f122f7f8","9f2de64058d15197c6e93170f8a8e2a10a3ae0f181d17c4b3a482bd3500d077c","ef8865f11a87d159281b1bfb08427a05413fb012a60e624d33589ba16957c607","5dcc475ba4e86db8610afe2ebce0f05da3d98be85ff9a0fd5e4bee1e293caaf2","f3afd6cb637b62f3184da2fed4948253e7476e9d6ec8e590359056d1ddf23b77","3f80582eca2101e3627134b99632333a665bc2837d1c4f7d07d0b3bdaba0aa0f","001dec038b0f816e352d106eb1d2b436d74462a9ae0f09487a533d08ebfa56b2","e645dc71a241c5fb97402bb0bafd2be8031abbdc9ea0726b44bf79f104022a61","4dd4f634c626a6f4365a71159448fe3ff154636f3413ef5a876ac0ac40fd751a","6b2078cf551ddb6ba3c5a47a50b78f3c3202d4bfffc06f2d63c46b1275969a40","ba881b8d5b6e3df354cc26faf2ebe93d6c3c1dde02534542c716970ae9862549","181dab8c3c89994ffd0f00c80a4af48d62aa9fa40c07316e8c3cd1825a62eccf","6d6336b1b4df7f1f65d3f84c9c020c205865ae237e19633d1fafd6aadad9a067","c12c64d0d3ac1be4fa925ba87f3de675601fb413ae25cc50396b192a9e176425","5402794b0084c917353387d102cfcc303842e39852c263451c1a77863cf28a9e","287d0baf43e643c7c740b9e8770f9300b2bbf98b8a518e76e8e217ec8aaead64","91298e6a51bb1962d39eceb6371aada140acfa24417011da38a2c50d2f0da168","4bde15551a94df07a4010f995704efb12ffdc4bd752851a7f95f591092bab4f3","9dfe82c07da9a54e56a248ba387ce383fc6a7743af92b17fed1527b0d5b6ebbd","290d8f4ffa2ed9c837bf5d1ff05c7637ff021d486fcae6d898aaf2df69dda769","231b7e837fc604212aa02a5b148f585ad0b2ba995032e37eea7dddbb3a46088b","4115ce5ac014390c8cff8d3c51b52fa13f6294fb13d1803d6c40fe6cfdd6901c","b2419ec464344d9a111778d1d616889685934ba3d0915801a156f20295da39ef","c2d0ba4e3ef6f4510841f61190862a4376c6515a4af1e44290d94e876f699251","632532295af106d8dd11653a780e944492849c55bc026c0425fe067c30e44bc7","d9a8dd605a9db2828cb9c2117053bd44bb318092e4bc8ac12297ecae52e48408","a40659fe11d83da3b659ad4287b7c1b141378fce8962e95592cf4ee53258f190","0e23ec9cf75425a296be7ee9bb1f85e499078bc492dae72e2fdc5d5fca98fb7d","4bdf7deeb5d15eda7a4a4246622cc7321e2d4d605c3dcd9a516625815e772e22","6bf062bf83fe661c697b7122c883b56f982b85438ade4e43731c2efa36a48032","fdc556b6075fae393c48cc18dc34519af2d448664447be8b7ca2126b02affb19","4678397e8b0ed1445aa088771c9d8572e7bf45cffc52c14b039f86b66fc20252","fe49dc0bf1a296cb927061f74f7fc6d74d2e418b9362c4640881b4e42a503031","d301b86d1d7050c88fcb6ca92ff9b138cfb0a0ca8a04b3a1b61ebfd911cb1a6f","fae2bc89da0a93f9a0266d21ac2c33bf03f2674c5ed33ff980e887f397c8e06d","02d6d2563d3b1928e87f184b0c8cf3bb35398e2c3aa7b6a29ffc24ddf282b6c5","43cb773c20a53ad1001df0133aeaf26b52855445031c295b9a37154ae35343af","0e481a28d565f1a503dbada5d810dde3113157cf6563016b213cffec90a79993","10719c6dd6b07a17cb9be468bafce43a61e5afaeaa4f571c5b49866ac337a8b6","fe0a9db55849377425bef5a6f180779d0c7370cd9f4a41b520880c5ee1cb11fb","c5c99d680e713636b83e2e037b5a292adf0b2dcac011b1b188552e5907f6d7a5","0e606c69a1200fccb0e927c090393936f2ac1c11920b0e55753f0c4fc45fb778","6b4c5c71b0aa3cac94c8b3b819f68a30d0248709e6bce38465bd8bfd7d7cf395","5f314bd27e54d77f98289eb1e174636987e346c78b9631a2a1bc14f5d8595725","fa1124d16b3dff467594eb52257875ba98e68e40f39e10dba0611c7cf8d657f1","4f6ab5fa695e1f3d02881def8d878313b2f103220b91e48db7ac3bc21239313b","0b2d2659a5ea7be13b0b7fe40f2c4019e3be87c4dd921a8025ee382320a7b63e","f354dde829d0ca02f714015e8ab8535b7e57d46d492c3e4e3e6aa2b3bc47eedd","9c24c78a38da64fd818c7488f69b9d3e3290ecca8d2d2e01389fffd60fbdcb11","508d52244a0011a2812d56877281530ea463ba6cdcaaabacee8d11116796be74","b47ee19e60f569336d7b5fb2933e702b83d57ed60a253edd66cb1227432a5004","9fcd9e148321b51599f5e159452bac3942fe8e5152e379d40eca23209c0a0fda","ee485f14ae7dfc8c0f38490472d1f546e61c030fe440c0ad68d0adc75b09e4db","62f0646b465914396e6910bc62de871a623d2277db1da460209380168be11788","d6ea9568e4855c97e8126e299a4df99ab0cc53ae477b032a27045172e54d41a5","39c340dec9bcc6bcc6f291a47dc6bbb6d298a114ad9f32b9829733b3e0830e38","c1c5a4d1a72757a92a31329e8f86753bb5ff0c4d0196b8466e168a171e5f4fda","3d7bdaf3ed469369002a47e51fc3c03af0ca6e657da5297480e20e4ecd6b5ece","cf0a557968c3c332ded37b6a899c82d605412fdff51756d1bf99c9251006ecde","f1f4867152604ba908a356c724970b6fa7c372d2f5f47ea04d37af4c043646b1","2e25a1451ed168766182a729e70cf978f6666827bd7fbebdfb0a12600be3557b","97b1f9f98dad479221ad2169fa7f94c6c4b049ed3b5bb2f88c9e2296973efb2a","d6852d894450cf567f3aa71d0052f1de7ec00992f4d00159b0d9130f9ac156ab","928b3deb2de8a4e91cd4473631de7d098f7cefba8279b527db2fbe9b440919be","91ff3d9748b6abfd395824f0fee820c5638871b86e6400c21d0c1dbe162b836f","1ee5b9e592624caba1ba0fcf518851c824f448376408819372bf950dce560322","225d85ad926f680f65b70a76869691ca99e633e3e4240810eaaa62dc45b5e092","1d217f400428c50966aa0342db49abc2e57c37143be7fb243e4d610d7680b717","02cd73f7d88b1a50873ac66115bec20abe49433c35f0cad5dd09fe6f2f5940f8","04134f50f9c61432f199b9419775578a04691eb5dcff09492e5af4f409793ce1","29136ee2c5932f09c7eaee1608d3ef92f1535d6fbfd8857e5b5cdbf91a85bf6c","1711236bdd3baa5e1bba2640cec90d8ce98eb9ef752b1cf24ab70209aed78b4c","106cad458688e9688f525b07808597fafbf7e2dbf27986a1bb7fa04772893965","38888437f31ec5e2d39aef9c46f5092d5722bf43f472436ad1b1af08d0ffdc57","284363ad97f3206b55bdd30c19b6727051203e030bd3d4008374d135f789b11a","87f0e1655eed475c75e8f8565b2c111b222eff3a0be57122e5dd7f54349cd26d","90d293744f66eb2cd999f5a414d75c0f6eb572505814c1186f29563a03554550","516a0644a6400b8ad2699d64219cd623ac085cd72a6256caa17f6ebe0da01c7a","5da3a91b2b5294885b3dec780c177bfde9132af5980be86b54a3e7734e3c26af","91ca499e3074bf63918558870f50849bf4a60be52d5a07c5f7b4e1c81361ece6",{"version":"2a078a3d4ff94609475f554e331ea7979b9f340f73ae327a0e28609aa455a671","signature":"12342600947933152b059cb567e68f067df23df03834804a6f8b2e0163db451f"},"f97aa7fd4854ae4781f63fd5d481f76cb728a8501f254748b195054547cffce6","4808bce828d7d7195dc01061460b99aea69a268113007177eb07d09fbcb9f285","7dfece00e91dedb5e6f262fe95608e17d93b0e3bcc88d3221de3bdf82c22a4a9","184e1728672781b439d0053f03e41aa2474569efa05bb2429e4ae000a9aed934","b6b55f2ed87d0e7a97b6a552c4becfd465f99598fe6e0a531b36beb2b98c4258","bbb46b0fbcdc576355c8044c59fc6041147db11505e5c4335bf3e97a5289efb5","a3d89d72d4526e41ccb86f770a5f55edf937a5f667b0befde576c1d4bdb0c9b2",{"version":"8cd5c8f46fc7915ce58a13621837e4101fbe9fc948ce01549b36f4852b70cf58","signature":"106b3f8678b78fce3a921b4b10b77490f25b8f54faad30c4696136697741d759"},{"version":"d73abe26d86d76b9291215d6e55011c8fb57b931ddddc807033fbbfc96529d0e","signature":"a1d6316feb75f247c21e648ab2ed07ef58c54a6fb2071823fca13e8a344a1ebc"},{"version":"2b096428749378118129ebc7a85424f79dc2b8bc958968dc024a65049e313009","signature":"196783e4317f27aa9a8fbaf75d12fd0a68557ed455301114d24e820aa081b064"},{"version":"33eb7d195211e397eda255795af34acfb955079c69faa09bff2703e253e7fb42","signature":"45bef13917c6670ebb0d0a0925ee7c155c549e22db117bd4b98f0dc1411c089e"},{"version":"a991ed0662c2fdf75cbcc03dde481fe711e94f05c20b08dfa460abd7ae375041","signature":"6cf6827828a5f7925d59312ee0fdbbe7b610dc07ff45d4614c6e1aefd3b21eb9"},{"version":"527ddf406dc12fb329bfe5ee50afeb4143094f1f67cbccddc5c6b236e2842205","signature":"39538c0611f7e5171b0f1f05f63f52f204bd20350a450b2a403282bcd891be22"},{"version":"e560c6f84daef82b9b6ff6f0b60221a49b26dd6904132848a55322a6e297a79f","signature":"4937e3a48705847042e97d5e2a35fc1d311b9f969eeebb138ec7dc5e7161986a"},{"version":"64fd9d10ab6441c5ab6d6e66aeb7b60aa8fa99e81e5f2499046dcbc979dd5a91","signature":"333265ebe414dc5827b0cac654bbc9a17a33a6daefac366df1aeb3308205fa36"},{"version":"e0e1d0d69bb366ffdee63473cc9f813dca0a5aaa66f4c13bfa73f92d7e78aa98","signature":"6f184ea8e4fbbdae888aa665da17e449e9b316b4addbee12b13fea75ca5935d7"},{"version":"11235f5d63ebe09a849f09bec0504b247057c26fb72a96939d0b84a3cda9b7e6","signature":"1777c629dc50335faf229a42a6b979b057cf9bcc1a15bf031b8839ca50930560"},{"version":"e1d2be54ff56dd0cbc96b9b43b04579944cdd2547f193e9ff55fe2e5fb8ca620","signature":"a5e8701ea9836bc26137ffd002658c9ea77074e5c9f68c26a6816200033c2472"},{"version":"042ffd4c05b68e236f5226eb57f587c39ed8dfce8fd108365fbadfdf07f89879","signature":"060722df62d4aae0f273cefb7357538dd42ced41abbc0617e4809202d1294090"},{"version":"e9ceaeffbab576dd1c624f75214e896c5d2b8905fe08c605af2bb1512462253e","signature":"ceec29d2b665eda0b8a2c09f3e8561b52a31f89e337be12d68a2dbcf44e7d63f"},{"version":"1d83022a1a09d133f30366342ca157834027c022d22825d192b05dc3ba72205d","signature":"ffb3057a72b3671dae0bf6fb216ac26727ecdb7d96ca6c8a8c204b61daaa174d"},{"version":"5ec565237f90f6ffdd87af68f25f3ca86e8674ae1b9aaa117362ab5ccd181b4c","signature":"e43c20b05f1a036867b1350190fdd0e1aa353a7d283af17109340ae323c6b1e4"},{"version":"9865c353f1dbb31026fb9c2a974926188b6a04cac529bd5ba115de094c8b8069","signature":"01ab14c2f02d1f2c37daf40ff3d276687dd5b8168b148cd41001977831de8ef4"},{"version":"72c7cbbd490ab5f383e635eecda98a62844cccf630348e1b720cce61f2d1d102","signature":"17f63890415e853bf59bdc3a148f0db5681e41c62661b7e0d2fb02474f3aa7a5"},{"version":"3cf1e8ef6a548f5a99da18b1a42d2748f34312f649060f81abb8ccee97f43bc1","signature":"4de980b028050932f168d5a5249e1b30bebf820dae4bb86312b1979ef4e86092"},{"version":"d087812fbad5bfcfb8475d947924b1f5329c04caca8f69890eb9263af7590c6d","signature":"7b339f209feae139c4efa581b90dedbd1a04d9cbcb385b7a0435ee10f7b6c609"},{"version":"c57294050388bcb6b61beb23ae6fe202e60efe8eefc32910bcabd8f579134146","signature":"f69b245b4ae7fc7b73308440f9f4f0f5c22e827ae21e0ec7d959e35b0c6a000f"},{"version":"15b8f0d1fbe927d19bc38a678308022fdfaf39d5bcea3c555cbecd1b2c0c7f47","signature":"a0f157f6ec209e1baafb123233f40a4eb18dd702766a0f4792e6ab44c409a1bf"},{"version":"4eb5784ab2b376ea97fdfc4c25cb16a4c7cf61c8d9e962a63661a3aa75ab396d","signature":"0909d57619ed061f78838866b346b08315f14af7a370c2f76bac160274f49882"},{"version":"59138c34c47d26972210b261a0c875a72ffa626ea037e22ec6520bdaa1be7477","signature":"f27ea12fb23392518764e75a358f799bfe90407bbff1449854634cab3dcde2c1"},{"version":"917b63fda998cfb36511cc218d33f5dab5ab7d45b9560b09d42b8e9f791f3c1e","signature":"bbb5c6397a256407b2e3238ca5b4f340b61da8df317b132464b0aca216834b96"},{"version":"cd82ca985cd336a0b3fb7f11c7638c10115ef8d075c284ba5396270ec9db5aca","signature":"ff0dd6ae109e2ed64128cde417e91dd1e44b3b9a5b1021bbf555a86e8bd7fe0e"},{"version":"5834a7e9d1e23542abc0e5c3635051cff315f98f5178f1518b5aab65ba65d602","signature":"c88096f6a70b828ed96808c40830feb934d77901d5dbd6b777d4e04bd0af03cc"},{"version":"c35e5a75228d0fbee3f2d1d59bd8b74c62bc8752c54d3018b6b475b24967e380","signature":"87b7066a0a2463cd0fec6a6609f558e5c84bcc7842a1f464a86eb3e67afcd763"},{"version":"b27acac9d5dfac6e1e1e8afbbb453ea1fde5c073ab4cd5f9c2e9785e9d192fa9","signature":"64e7a53d631709d0a5fc4d4d065a512de8371f86ad20c822c11e8c4fc88828aa"},{"version":"a713743e21042a5d96c0d9b42e265f7686fe478aff836b7fedaed5908540c7a1","signature":"8f16a1e5b9c89392599d6faede167d0d4abb25515e1e56b79fad2e57a8e03fa8"},{"version":"c86c0661f5ec2d684ae73aea3d94c6abdd1edef9cc2ca9e3c656e5fa3f5713e4","signature":"0a1900a2d38369652c46d9dfed9167f8f6b06136338886a985d12389d4a69c94"},"7c1ebde823b390f4e738aa1384e6106f0246ce1d73b1d6d20d4e9c67623c26d0","18e839a0576d60b32ba7865379783c9cc434bde58eaeb267aa5be1f6f1472529",{"version":"9bdabd0c7274d9cd8009a717087ec17431228901cd1b475955cf053adf837813","signature":"d56a9c1ad9bfaa5b9aded4ebeed606295470271fcb9a137d7205f69bb62d1241"},"b0a66b71460581bda148485bd214bdcaaf706776cc8969601eb9e9ae73107833",{"version":"988fad22b03f068fa42ba0f21ca11a30b144c5b6dc7fe378dd0d5993cb0ea021","signature":"337d47027050863e14974d13d062f1f85a9b2869a84bb91bbe24df61ec852a93"},{"version":"9f463411f685d3f2cc13ff4395ee2ef9b222f62048f74ee699c528cf4551a02c","signature":"77902db1e0ea5d4209241c6f0bd67e74d3f3665a0a009eb797deff03b678f655"},{"version":"8de4e575e3639288d0d181da4f8d304d99683bb382dc5c2e1e643b33210f94c0","signature":"a4e69719bab3096f0d889cb7702273aeb1663ec1bcb660828a36e75e8bf709b1"},"56fd389ea239c6259f7ee1eaa9f82f0441c6566da29d670f5ec9df6b10b55d6e",{"version":"4726730040de266dee3f85ff088f7e86c81d34f8e89a3658ec668d2451cd8b20","signature":"1ab2b8fa37eae578b30743deb1fe446bef44135e76a66eb438d772d7ee309ad9"},{"version":"a470d4f6c0fd45144ec0e97c0e9299fa8595cf88d599901ce49ef86fc2105c1f","signature":"79b4b71c08d175da7cb27e11ea1bcadd758cddf20b2ffe0e75cfbeedd89584b5"},{"version":"3dc7bca9a9ce7368cce1cfb457f560219e5e8503f5eeb84915c4d7278be08f6a","signature":"70552cd64daeb1bc28e5570f5f93139fbe4f61be463fac96009373eac9446730"},"d302144628e8e8bb096aa3668b545511f46f0c3b787b5de6fa887892a702387b","2a2584577164a5f7520b29f83472b5125fde512794ff5ec5b2226eed0f098f6a","a17c3792bfb09725c7024bd1e5cf1e7496136d42a3ef83deb3697ff92edb7af6","673ea4e20151fbfa745feaf284c1c85545fafaed03911015c6a7791160acc1e9",{"version":"889fbbf9ea80ded4dc038d353dba84a398434c58c22fb1fe2bbf4de3ed181e38","signature":"322916fa199c1182ea2faca1b6d488cb6ec497f7c48491623c440815c1033e97"},"c201951b6841ed5d1303eed429a901c9a0e3eb14cc8e5948bb0e56d12efb615b","bb89661a4eb77298a7369fb43414990eb6041032f9138e9f75798832d1bc4441","660bc470b457b104a4d9d80159b1d348d6026025f42f7690a4c184d2b66ae623","2732c099efc3745e4ba388f0d56b3d3767fb0c7d43aeefe1b2613f09b90c7f31",{"version":"a4a4f6a3a827bd8674d5b4230d6ee7ebabda6d637e76dd6667b524e61df71300","signature":"3d3e8d8e570c2d70ad678766c40a35c535b5fa76cf7bc73c6333c54823b0b752"},"0eb9fd5052728a548116c94d7954723095c9a292edf83f9d9d1ea45963d0f8e8","54b7abaab7423e9c952f1aea2eaab68266c85565f21c656c9de31d80a38a7282","040fd3a93bc770d200ecfbd83cab66b22e0c104fdaed29daea5f1ecc05f8dc74","19d8af7bc1beb5fcb22aae8b44fe31dd7af39500f54fe5f212721ba8d6a297d1","fb59f620807e4d1dbc5cc95639262f89395c1d20fcab03efc323a221b07bca43","922d4ab256e7db3a63b52e0e4a8f2c7019491b58b0961f297c5b613a2783d970","85b6daaabfe2c275b7013d13df14031bbaf623357e3746bed9ca1afaab46bedf","2ccb0e02fb165ef588673868e5e1b78d7f2b8f22cd463c8e015f9c9fcb8f3eb6","f594f5af79c5a5c75dd9e04e39314d6a193b2a18369d57195ae3675ea25925e2","8b76ee888686669a7bb8b6c5ebeb72e5a7bc0e7263152ff5daa0c486d34d870d","1cad8db035a510050fb929577f148153ef41f0fd2773df0149cbec01556c7050","43b3564d0973387eb4877c5e3a996883331e8c73a2a675aa4a708afcb4f12521","7ef1eabb4e5ec071c4aca406d80abf528ea9a15b968dbc3e275d8453c1924003","ee5d9091cc397c259f406c229cecbb893af5548944ec9b7c3dbf0daa9cced037","cdaa28c764ef022a285afecf6a4b1dc9287843dd2b45c703056e880467257cb6","57a452c40651b548c85a342f28241ee37a84e1967630ed318e515ee21d4c8ee5","e0611572c6aa5500e703c4332eecb1235d618fc91315bd39d4ccedbe3a6fe710","be4f9c6ac98f48266c88db65806e5dc4084170a229c6205159a53f0507c4c666","bb299294f09f4dd9932c8ad94bb16d7d062353d172f72e0a99d7226f3ec5b659","83962d24b7752d8aa205f38ab58ba7fd4464017403e4eb8d2481234248a626d5","260da6fd223535a3d498a1af94f4249a3e67d28e56d7648dcc72e069911cc2c8","d1f0095428cf1de664d9594850b63567b7ca7325fedac6dfd425130041b420a2","6c430c13fabc220d5e2053a81d1c993ba4341c9f461d98270b1ff503c8c48030","0740a1887c4e1c1c71fff7925a3feef85a6ab4d5b2ee4c24fb84357583da3402","a8124c363cdc2f4bbd08a4283e216b1d1fd358ddbdc658afd6fa510ed16740dd","1c6cecc8cf9d39e2501347175b1be54b8dca76fed82b87f9fa0b8b7268cb5b61","cdc15c17185913bb5b56f5f240728e7096ca133d78464876e98b6d38185c6d19","58fc9b0a79e29fa867e347981ecbef6ef52ffacfc848be358cdc41a5d9e133c8","b3bb55f630f4d3113bd13408c49d2d7a2ea1ddf96061cb5952d0bf778d491eba","f10912d31d9010c44d3aa471b34e77be99329c64f69675b00104a21e4cc50470","20e5482e50895255d12953211f19710988ab278b9f342db385f7985d34ec02bf",{"version":"d4bc691857a8aa60b09cebdfda30afb2eb8ab13d7fa8c29bcd93082803223eff","signature":"e847e443a0d4a2648051133817a02200e0b9c4b2f616a992c7ca8325a7c71d24"},"75304d5f2ccf463507df23f4b8066e7e758225a1a36d6ebe83c1b714da6c2ac5","049398af73a373ead19379fd1da4809d18bf6cf7123e44116c1491ad58d5acf0","9d8c851e30a432fadf9709f5b7802b9346d7c4aa3db0f25907669f98178bdd6e","4eaf53474be1f3e9182de786375b87893f47081e491b45321cac3a8427250f5c","79804619db6df7144de589d927d3c861d5e037ee91c9d55590ac14f927e371ac","5f6104030d0571c22ccb732c1ff1ee3f9ce38d54d0157fb895fd99d62222103c","5a64c9b17d19f3d6f73be414c83c4b4a37bb1898fc8552e2d63f71f2f6904724","a17ffb4463a3144d95960125ae3ea541a055bfa1494a908dc8688a21a0a58216","9428fa53b4d4910ec04a42be6e8bc915242800381bf5782013965ae8b4f6e3ae","dc9928627dc636458117a0f2888131ab222a160ed58d4942ebdba75d32beb69b","632cf9f97223c29fa65cf7eb81ba15d2d21a851dff8f5e0921b7e428a837af84","5e789cac338582e7c1522ce9fde6b2c4cfd626989fb5dedbdcbd1fdb7028e82d","1ccf807c1e706cf662a2475227e453da3a57cd9f6c21aaa843d3358d619e78f4","dad7f9496da68b21150b690b1bf00dcb5ddc05f575f082b9f79a0316e3cf0923","356173560bdd68ce8d6f93eb359829b4b93fdc9799369052964544c0c0551303","0ef5075c8c118e4b9f6d6e61164849a3da33cfa8a6d709b9e76b9e187cc502d7","30b18dfd86c195a4e504a9c7cab74e253cc9a81e5150a1e078a60116f8413a7b","eb572a47bd52202d9046b74cd08e11b817a09d7da9d411221c209605b53d50bf","507d41570c8cad3489d272cba504fd33baa7d60903086b2b1ea6a44a32cab870","4f4dad224f28ae73a636eaca502d71b374a4095d5a3f540f66cb56a688e067f1","4139b027a8724e900dc887009ac6df809cc5f6a5fa5f58d2d684a6f6d96ff245","09e6acd94660ad0acf93ee0cce96ba7c540b67bd65ab5b043decb5ad9976c00d","f90eaf7c39bcd43cca5e307991878da4c86947ade4c9c999534c75647e9356cf","5b9a0c2f29da015a2b552e9890b67dd33cbe9fd15c69384b8eb2e65b0be19320","01101a850ef55e575e5c27672a5788b954101ab3ee2009dc5d94820fd9aeda1d","6c26420749d17833efe84a50d61db06fbd4082e497e65ae2e4b82f2d23476279","d6c4e7806d89a5b3fe00eb53b08ae06a8d26226bc9cfbec2758d4db28462d0c4","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",{"version":"cadeb2c96f1c964d7e49c0f17d6805e1b4dee62f0862c49bb178dae6ab277e8e","impliedFormat":99},{"version":"0528f6d21f7a02d4092895090d2dd86104bd5a3e79eced96d5a1a7dd90943d17","impliedFormat":99},{"version":"b5ce343886d23392be9c8280e9f24a87f1d7d3667f6672c2fe4aa61fa4ece7d4","impliedFormat":99},{"version":"eb64a68249f1ee45e496a23cd0be8fe8c84aecb4c038b86d4abcc765c5ba115e","impliedFormat":99},{"version":"b0857bb28fd5236ace84280f79a25093f919fd0eff13e47cc26ea03de60a7294","impliedFormat":99},{"version":"5e43e0824f10cd8c48e7a8c5c673638488925a12c31f0f9e0957965c290eb14c","impliedFormat":99},{"version":"ef13c73d6157a32933c612d476c1524dd674cf5b9a88571d7d6a0d147544d529","impliedFormat":99},{"version":"3b0a56d056d81a011e484b9c05d5e430711aaecd561a788bad1d0498aad782c7","impliedFormat":99},{"version":"3354286ef917d22c72e0c830324062f950134d8882e9ea57ad6ade3d8ad943cf","impliedFormat":99},{"version":"3dedc468e9b0ed804c0226482e344bd769417f834988af838d814504af81cba6","impliedFormat":99},{"version":"ac3d263474022e9a14c43f588f485d549641d839b159ecc971978b90f34bdf6b","impliedFormat":99},{"version":"3b89216a7e38a454985ad17bb2ff85792837dc812f2a89fa5f60ad0a2e216fa7","impliedFormat":99},{"version":"10073cdcf56982064c5337787cc59b79586131e1b28c106ede5bff362f912b70","impliedFormat":99},{"version":"82179358c2d9d7347f1602dc9300039a2250e483137b38ebf31d4d2e5519c181","impliedFormat":99},{"version":"4e003c868b0d8f8ad200b96cbc653e18e513fa23e1c19c4fe3cc25d4394efc47","impliedFormat":99},{"version":"091546ac9077cddcd7b9479cc2e0c677238bf13e39eab4b13e75046c3328df93","impliedFormat":99},{"version":"42a12f2faa483c9b48195ed794d22698162274e755f6e07219c2351c4f08d732","impliedFormat":99},{"version":"727858fb893b87791876dee5e3cd4c187a721d2de516fd19d3d00dc6e8a894b3","impliedFormat":99},{"version":"5bfaa2ee33e63a1b17b08dbefd7a3c42d1e0f914e52aca5bef679b420bd7a07c","impliedFormat":99},{"version":"7d5c6cc5d537c47c7723a1fe76411b99373eb55c487045dfd076c1956e87389a","impliedFormat":99},{"version":"bcbd3becd08b4515225880abea0dbfbbf0d1181ce3af8f18f72f61edbe4febfb","impliedFormat":99},{"version":"a86701e56b10a6d1ef9b2ecaeedbab94ed7b957a646cd71fd09d02b323c6d3d7","impliedFormat":99},{"version":"b3f0791a73b6521da68107c5ba1bfed4bc21ff7099b892700fd65670e88ef6ee","impliedFormat":99},{"version":"d0411dddbef50f9ad568ee9d24b108153bcb8f0db1094de6dfbadf02feb3aa70","impliedFormat":99},{"version":"25249ca5fe64ca60d7bfb7fbbf0cb084324853b03a265dbbbc45fb4949de7567","impliedFormat":99},{"version":"06db2f8ba1d1dfacf04529cb731081ab23f133f29c7608ebdfbcab356996827c","impliedFormat":99},{"version":"bdd14f07b4eca0b4b5203b85b8dbc4d084c749fa590bee5ea613e1641dcd3b29","impliedFormat":99},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"2eeffcee5c1661ddca53353929558037b8cf305ffb86a803512982f99bcab50d","impliedFormat":99},{"version":"9afb4cb864d297e4092a79ee2871b5d3143ea14153f62ef0bb04ede25f432030","affectsGlobalScope":true,"impliedFormat":99},{"version":"31d5fb0aeb0368909fbe8cd9b893c16350aa94d48a2f909fdd393982ceb4814d","affectsGlobalScope":true,"impliedFormat":99},{"version":"90fe5875e2c7519711442683a9489416819c6cec8d395e48ff568e94254533e7","impliedFormat":99},{"version":"69bf2422313487956e4dacf049f30cb91b34968912058d244cb19e4baa24da97","impliedFormat":99},{"version":"6987dfb4b0c4e02112cc4e548e7a77b3d9ddfeffa8c8a2db13ceac361a4567d9","impliedFormat":99},{"version":"72a863bc6c0fc7a6263d8e07279167d86467a3869b5f002b1df6eaf5000ccc7b","impliedFormat":99},{"version":"5e2ba3d18d78aebbde1f34bde356e41e9c76eeaeaeee56a37036596a9eff4211","impliedFormat":99},{"version":"8280ae8ccc0493b32d1742d585357ab9f0a508ea050af25a5a20d64010d0a5cf","impliedFormat":99},{"version":"7adfd9f9056ecd4ae6c65fde2a98654960c662714c73f048478959d04c09e144","impliedFormat":99},{"version":"32b35cf0dc3a1b1a7118b61c34ce2ad1a29695851679f9ec34e0776f2ece2a69","impliedFormat":99},{"version":"b413fbc6658fe2774f8bf9a15cf4c53e586fc38a2d5256b3b9647da242c14389","impliedFormat":99},{"version":"42f0f7e74d73ae5873ed666373e09a367d62232ca378677094d0dc06020d6e00","impliedFormat":99},{"version":"c30a41267fc04c6518b17e55dcb2b810f267af4314b0b6d7df1c33a76ce1b330","impliedFormat":1},{"version":"72422d0bac4076912385d0c10911b82e4694fc106e2d70added091f88f0824ba","impliedFormat":1},{"version":"da251b82c25bee1d93f9fd80c5a61d945da4f708ca21285541d7aff83ecb8200","impliedFormat":1},{"version":"64db14db2bf37ac089766fdb3c7e1160fabc10e9929bc2deeede7237e4419fc8","impliedFormat":1},{"version":"98b94085c9f78eba36d3d2314affe973e8994f99864b8708122750788825c771","impliedFormat":1},{"version":"ebb9b9fa684d70aef64614a59b7582f46f4982139b8b632b911ef98e10c4d117","impliedFormat":99},{"version":"2c34caeb0e7809e93a2baa06872c49707b1c96e229e1061c6b9ee364607f563f","signature":"b39a7180afe792f0975be5e4841a05ccf9365eca0b213207c44829c618b565be"},"3bb648f566e49849b20bbef6a3b737a875ef4e774b33f94f53dfdbd9deac403f","3d8e5168a19d52d77dc69b6785a069ed2f14000e24eb275dd5846fd31e473801","eb76714199edf4f96ba125503ae4114b4d557a55ad0fc85ec5bc30f09310bbaf","ac9ad025410d9d9a641205333a48c94a3de3caae3f5c3c4d155cf70b520556bc","e51177d723e64eb327102e10399d61ed9e89bddcf69260ac0600ca0a39b3f33b","b1bd895022be16cfda91bd37db27aab863df16082d760afe55ec3d912f1743b6","4143a3ab0eafd782c745bb4ce3b5ca2be6ca96cba8d23dc69755dcc348e319c7","6efb415baab05a5830113c471acf3c82cae9b9560036644f937cb34924921dcb","de5e7d2f6e5328b18b5dc3a39f3ca67df73577cee8b5ca8af48759f1264dca01","f24c8d553c4178e3bca9e6988140a5b98064a1f8820b53adc58803b6317c84c7","043dc788a719c8ead6f63a3f38809be150dfab433068fd674cbe491c7b0af2fd","99af15d20e90e9b50cdcfca19a43ae519039ba1c747da2e3f1eb0dff85345180","af62c3ac94d0a249d8c26a6a285b86557617dfb08f8f665cd58505232f74f78c","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"],"root":[[58,83],[125,129],[236,262],[265,268],[282,563],[611,629]],"options":{"allowSyntheticDefaultImports":true,"alwaysStrict":true,"composite":true,"declaration":true,"declarationMap":true,"esModuleInterop":true,"exactOptionalPropertyTypes":false,"module":99,"noFallthroughCasesInSwitch":true,"noImplicitAny":true,"noImplicitReturns":true,"noImplicitThis":true,"noUncheckedIndexedAccess":true,"noUnusedLocals":false,"noUnusedParameters":false,"outDir":"./dist","rootDir":"./src","skipLibCheck":true,"sourceMap":true,"strict":true,"strictBindCallApply":true,"strictFunctionTypes":true,"strictNullChecks":true,"strictPropertyInitialization":true,"target":9,"tsBuildInfoFile":"./tsconfig.tsbuildinfo","useDefineForClassFields":true,"verbatimModuleSyntax":true},"referencedMap":[[590,1],[235,2],[593,3],[591,1],[264,4],[263,1],[181,5],[182,5],[183,6],[135,7],[184,8],[185,9],[186,10],[130,1],[133,11],[131,1],[132,1],[187,12],[188,13],[189,14],[190,15],[191,16],[192,17],[193,17],[194,18],[195,19],[196,20],[197,21],[136,1],[134,1],[198,22],[199,23],[200,24],[234,25],[201,26],[202,1],[203,27],[204,28],[205,29],[206,30],[207,31],[208,32],[209,33],[210,34],[211,35],[212,35],[213,36],[214,1],[215,37],[216,38],[218,39],[217,40],[219,41],[220,42],[221,43],[222,44],[223,45],[224,46],[225,47],[226,48],[227,49],[228,50],[229,51],[230,52],[231,53],[137,1],[138,1],[139,1],[178,54],[179,1],[180,1],[232,55],[233,56],[594,57],[600,58],[601,59],[599,1],[564,1],[573,60],[572,61],[595,60],[580,62],[582,63],[581,64],[588,1],[571,65],[565,66],[567,67],[569,68],[568,1],[570,66],[566,1],[592,1],[140,1],[607,69],[609,70],[608,71],[606,72],[605,1],[596,1],[589,1],[56,1],[57,1],[11,1],[10,1],[2,1],[12,1],[13,1],[14,1],[15,1],[16,1],[17,1],[18,1],[19,1],[3,1],[20,1],[21,1],[4,1],[22,1],[26,1],[23,1],[24,1],[25,1],[27,1],[28,1],[29,1],[5,1],[30,1],[31,1],[32,1],[33,1],[6,1],[37,1],[34,1],[35,1],[36,1],[38,1],[7,1],[39,1],[44,1],[45,1],[40,1],[41,1],[42,1],[43,1],[8,1],[49,1],[46,1],[47,1],[48,1],[50,1],[9,1],[51,1],[52,1],[53,1],[55,1],[54,1],[1,1],[156,73],[166,74],[155,73],[176,75],[147,76],[146,77],[175,2],[169,78],[174,79],[149,80],[163,81],[148,82],[172,83],[144,84],[143,2],[173,85],[145,86],[150,87],[151,1],[154,87],[141,1],[177,88],[167,89],[158,90],[159,91],[161,92],[157,93],[160,94],[170,2],[152,95],[153,96],[162,97],[142,98],[165,89],[164,87],[168,1],[171,99],[576,100],[579,101],[577,100],[575,1],[578,102],[597,103],[587,104],[583,105],[584,62],[603,106],[598,107],[585,108],[602,109],[574,1],[586,110],[610,111],[604,112],[117,113],[86,1],[104,114],[116,115],[115,116],[85,117],[124,118],[87,1],[105,119],[114,120],[91,121],[102,122],[109,123],[106,124],[89,125],[88,126],[101,127],[92,128],[108,129],[110,130],[111,131],[112,131],[113,132],[118,1],[84,1],[119,131],[120,133],[94,134],[95,134],[96,134],[103,135],[107,136],[93,137],[121,138],[122,139],[97,1],[90,140],[98,141],[99,142],[100,143],[123,122],[273,1],[278,1],[271,144],[276,1],[274,144],[272,145],[275,1],[270,1],[281,146],[280,1],[277,1],[269,1],[279,1],[543,1],[544,1],[545,1],[546,1],[364,147],[365,148],[362,149],[549,1],[550,1],[551,1],[552,1],[553,1],[554,1],[555,1],[556,1],[557,1],[558,1],[559,1],[560,1],[547,1],[548,1],[561,1],[562,1],[563,1],[498,150],[499,151],[497,152],[496,1],[469,1],[491,153],[378,154],[470,155],[494,1],[471,1],[472,1],[473,1],[476,156],[477,157],[478,158],[475,157],[479,157],[481,159],[480,160],[495,161],[482,1],[370,162],[483,163],[484,164],[485,165],[486,164],[488,166],[611,167],[466,168],[438,169],[464,170],[439,171],[441,172],[442,172],[443,172],[444,172],[445,172],[446,172],[447,172],[448,173],[450,174],[451,172],[452,172],[453,172],[454,175],[455,176],[456,172],[457,172],[458,172],[459,173],[460,172],[461,172],[462,172],[463,172],[449,169],[465,177],[437,1],[440,178],[467,171],[435,179],[434,180],[380,181],[381,181],[382,181],[383,181],[385,182],[384,181],[392,183],[389,184],[391,185],[390,186],[386,187],[387,171],[388,186],[393,181],[394,181],[401,188],[395,186],[396,186],[397,1],[399,189],[398,186],[400,171],[402,190],[403,181],[404,190],[414,191],[405,1],[407,192],[409,193],[406,186],[410,171],[411,194],[412,1],[408,1],[413,195],[415,190],[416,181],[417,190],[424,196],[420,197],[421,198],[419,199],[423,200],[418,171],[422,1],[425,181],[426,181],[427,181],[428,181],[429,181],[430,181],[432,181],[431,181],[436,201],[369,1],[433,202],[379,171],[468,203],[377,204],[373,205],[375,206],[374,207],[376,208],[372,209],[487,1],[492,210],[493,211],[489,212],[371,1],[474,1],[490,213],[367,214],[368,215],[366,1],[330,216],[328,217],[329,217],[327,217],[326,1],[516,218],[515,219],[514,220],[507,1],[513,221],[508,222],[509,1],[510,223],[511,224],[512,225],[506,226],[500,1],[505,227],[502,228],[503,1],[501,1],[504,1],[294,229],[612,230],[293,231],[292,232],[291,232],[290,232],[282,158],[298,233],[301,234],[300,235],[299,236],[286,237],[268,238],[613,239],[283,240],[284,238],[285,241],[267,236],[251,242],[250,243],[249,244],[245,245],[247,246],[246,247],[248,248],[265,249],[266,250],[254,251],[253,252],[252,253],[255,254],[259,255],[260,256],[257,257],[258,258],[256,259],[261,256],[262,260],[244,1],[287,232],[297,261],[296,262],[288,263],[289,264],[295,265],[346,1],[352,266],[349,267],[350,268],[353,269],[347,1],[351,1],[348,1],[356,270],[357,271],[354,1],[355,1],[344,272],[345,273],[322,274],[341,275],[323,274],[324,276],[342,277],[325,276],[343,278],[321,1],[358,279],[318,280],[320,281],[316,282],[317,283],[319,284],[315,285],[313,1],[314,286],[312,287],[311,1],[308,288],[310,289],[307,290],[306,291],[309,292],[305,1],[304,1],[542,293],[359,294],[361,295],[360,296],[303,297],[363,298],[302,299],[518,278],[529,300],[520,301],[521,301],[522,301],[523,301],[524,301],[525,301],[526,301],[527,301],[528,301],[519,1],[530,302],[517,158],[340,303],[332,304],[333,304],[334,304],[335,304],[336,304],[337,304],[338,304],[339,305],[331,158],[624,306],[622,307],[615,308],[616,308],[617,308],[618,308],[619,308],[620,308],[621,308],[614,1],[623,309],[127,310],[81,311],[83,312],[125,313],[82,1],[126,311],[79,314],[74,315],[72,316],[75,317],[77,318],[78,317],[73,316],[128,319],[65,316],[70,320],[64,321],[71,316],[80,322],[67,323],[66,324],[69,325],[68,326],[626,1],[76,1],[627,1],[628,1],[629,1],[625,1],[243,327],[242,328],[238,329],[239,330],[240,329],[237,331],[241,332],[236,333],[129,334],[60,298],[61,1],[58,1],[63,334],[59,236],[62,335],[533,336],[539,337],[531,158],[534,336],[536,338],[541,339],[537,340],[535,341],[538,336],[540,342],[532,1]],"affectedFilesPendingEmit":[[364,51],[365,51],[362,51],[549,51],[550,51],[551,51],[552,51],[553,51],[554,51],[555,51],[556,51],[557,51],[558,51],[559,51],[560,51],[547,51],[548,51],[561,51],[562,51],[563,51],[498,51],[499,51],[497,51],[496,51],[469,51],[491,51],[378,51],[470,51],[494,51],[471,51],[472,51],[473,51],[476,51],[477,51],[478,51],[475,51],[479,51],[481,51],[480,51],[495,51],[482,51],[370,51],[483,51],[484,51],[485,51],[486,51],[488,51],[611,51],[466,51],[438,51],[464,51],[439,51],[441,51],[442,51],[443,51],[444,51],[445,51],[446,51],[447,51],[448,51],[450,51],[451,51],[452,51],[453,51],[454,51],[455,51],[456,51],[457,51],[458,51],[459,51],[460,51],[461,51],[462,51],[463,51],[449,51],[465,51],[437,51],[440,51],[467,51],[435,51],[434,51],[380,51],[381,51],[382,51],[383,51],[385,51],[384,51],[392,51],[389,51],[391,51],[390,51],[386,51],[387,51],[388,51],[393,51],[394,51],[401,51],[395,51],[396,51],[397,51],[399,51],[398,51],[400,51],[402,51],[403,51],[404,51],[414,51],[405,51],[407,51],[409,51],[406,51],[410,51],[411,51],[412,51],[408,51],[413,51],[415,51],[416,51],[417,51],[424,51],[420,51],[421,51],[419,51],[423,51],[418,51],[422,51],[425,51],[426,51],[427,51],[428,51],[429,51],[430,51],[432,51],[431,51],[436,51],[369,51],[433,51],[379,51],[468,51],[377,51],[373,51],[375,51],[374,51],[376,51],[372,51],[487,51],[492,51],[493,51],[489,51],[371,51],[474,51],[490,51],[367,51],[368,51],[366,51],[330,51],[328,51],[329,51],[327,51],[326,51],[516,51],[515,51],[514,51],[507,51],[513,51],[508,51],[509,51],[510,51],[511,51],[512,51],[506,51],[500,51],[505,51],[502,51],[503,51],[501,51],[504,51],[294,51],[612,51],[293,51],[292,51],[291,51],[290,51],[282,51],[298,51],[301,51],[300,51],[299,51],[286,51],[268,51],[613,51],[283,51],[284,51],[285,51],[267,51],[251,51],[250,51],[249,51],[245,51],[247,51],[246,51],[248,51],[265,51],[266,51],[254,51],[253,51],[252,51],[255,51],[259,51],[260,51],[257,51],[258,51],[256,51],[261,51],[262,51],[244,51],[287,51],[297,51],[296,51],[288,51],[289,51],[295,51],[346,51],[352,51],[349,51],[350,51],[353,51],[347,51],[351,51],[348,51],[356,51],[357,51],[354,51],[355,51],[344,51],[345,51],[322,51],[341,51],[323,51],[324,51],[342,51],[325,51],[343,51],[321,51],[358,51],[318,51],[320,51],[316,51],[317,51],[319,51],[315,51],[313,51],[314,51],[312,51],[311,51],[308,51],[310,51],[307,51],[306,51],[309,51],[305,51],[304,51],[542,51],[359,51],[361,51],[360,51],[303,51],[363,51],[302,51],[518,51],[529,51],[520,51],[521,51],[522,51],[523,51],[524,51],[525,51],[526,51],[527,51],[528,51],[519,51],[530,51],[517,51],[340,51],[332,51],[333,51],[334,51],[335,51],[336,51],[337,51],[338,51],[339,51],[331,51],[624,51],[622,51],[615,51],[616,51],[617,51],[618,51],[619,51],[620,51],[621,51],[614,51],[623,51],[127,51],[81,51],[83,51],[125,51],[82,51],[126,51],[79,51],[74,51],[72,51],[75,51],[77,51],[78,51],[73,51],[128,51],[65,51],[70,51],[64,51],[71,51],[80,51],[67,51],[66,51],[69,51],[68,51],[626,51],[76,51],[627,51],[628,51],[629,51],[625,51],[243,51],[242,51],[238,51],[239,51],[240,51],[237,51],[241,51],[236,51],[129,51],[60,51],[61,51],[58,51],[63,51],[59,51],[62,51],[533,51],[539,51],[531,51],[534,51],[536,51],[541,51],[537,51],[535,51],[538,51],[540,51],[532,51]],"emitSignatures":[58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,125,126,127,128,129,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,265,266,267,268,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/types/src/connection-rules.ts b/packages/types/src/connection-rules.ts index 500f0d05..20686db5 100644 --- a/packages/types/src/connection-rules.ts +++ b/packages/types/src/connection-rules.ts @@ -45,6 +45,7 @@ import { isVectorDb, isLLM, isRepo, + isReroute, isEnvConfig, isDomain, isCustomDomain, @@ -88,6 +89,7 @@ export { isVectorDb, isLLM, isRepo, + isReroute, isEnvConfig, isDomain, isCustomDomain, diff --git a/packages/types/src/connection-rules/rules-data.ts b/packages/types/src/connection-rules/rules-data.ts index 20074ae9..0a9a1dd0 100644 --- a/packages/types/src/connection-rules/rules-data.ts +++ b/packages/types/src/connection-rules/rules-data.ts @@ -31,6 +31,7 @@ import { isMonitoring, isQueue, isRepo, + isReroute, isRoutable, isSearch, isSecrets, @@ -343,6 +344,29 @@ export const CONNECTION_RULES: ConnectionRule[] = [ reverse: true, }, + // ── REROUTE ──────────────────────────────────────────────────────────── + // Pass-through dot. Accepts from / emits to any non-container block. + // The visual category of the wire is inherited from whichever end is + // NOT the reroute — see `reroute-node/passthrough.ts` for the color + // derivation. Without these two entries `canConnect` would reject the + // edge as no-rule. + { + label: 'Anything → Reroute', + source: (t) => !isContainer(t) && !isReroute(t), + target: isReroute, + category: 'traffic', + trafficType: 'request', + lineStyle: 'solid', + }, + { + label: 'Reroute → Anything', + source: isReroute, + target: (t) => !isContainer(t), + category: 'traffic', + trafficType: 'request', + lineStyle: 'solid', + }, + // ── DNS ──────────────────────────────────────────────────────────────── { label: 'Domain → Routable', source: isDomain, target: isRoutable, category: 'dns', lineStyle: 'solid' }, // Reverse: user drags service→domain, we flip @@ -416,5 +440,44 @@ The arrow shows "who initiates." Auto-flip ensures: - Repo is always SOURCE (repo → service) - EnvVars/Secrets is always TARGET (service → config) - Domain is always SOURCE (domain → service) -- Monitoring is always TARGET (service → logs)`; +- Monitoring is always TARGET (service → logs) + +### Port roles (typed sockets) +Every block in the catalog exposes named "ports" anchored to its real +properties — e.g. a Frontend has a 'repository-in' port (wires from a +GitHub repo), a 'domain-in' port (wires from a Custom Domain), and a +'web-out' port (its HTTPS endpoint). When you create an edge, you SHOULD +include explicit \`sourceSocket\` and \`targetSocket\` ids on edge.data +so the canvas snaps the wire to the right dots. + +Common port ids you should target: +- Frontends / Backends (Compute.StaticSite, Compute.SSRSite, + Compute.Container, Compute.BackendAPI, Compute.ServerlessFunction, + Compute.Worker, Compute.CronJob): + - in: \`repository-in\`, \`env-in\`, \`secret-in\`, \`domain-in\`, + \`db-in\`, \`cache-in\`, \`storage-in\`, \`search-in\`, + \`vector-in\`, \`llm-in\`, \`queue-in\` (subscribe) + - out: \`web-out\` (HTTP/HTTPS), \`queue-out\` (publish), \`logs-out\` +- Source.Repository: out \`repository-out\` +- Network.CustomDomain: out \`domain-out\` +- Network.Gateway: in \`upstream-in\`, \`domain-in\`; out \`public-out\` +- Database.PostgreSQL / .MySQL / .MongoDB: out \`db-out\` +- Database.Redis: out \`cache-out\` +- Storage.Bucket: out \`storage-out\` +- Messaging.Queue / .EventStream: in \`queue-in\` (from publishers), out \`queue-out\` (to subscribers) +- Messaging.Email: in \`queue-in\` +- Security.Secret: out \`secret-out\` +- Config.Environment: out \`env-out\` +- Monitoring.Log: in \`logs-in\` +- AI.VectorDB: out \`vector-out\` +- AI.LLMGateway: out \`llm-out\` + +If you omit the port ids the canvas will infer them at render time from +the category, but the edge will appear "loose" until the user touches +it — always emit explicit ids when you know them. + +For multi-port services (Compute.Container, Compute.BackendAPI), the +user may have added \`exposed_ports: [{port, protocol, label}]\` — each +entry becomes a \`port--out\` socket. Pick the one matching the +listener you intend (e.g. \`port-8080-out\` for HTTP :8080).`; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index bd6b9136..fe5d355a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,3 +8,5 @@ export * from './events'; export * from './ai'; export * from './connection-rules'; export * from './propagation-rules'; +export * from './sockets'; +export * from './ports'; diff --git a/packages/types/src/ports/__tests__/derive.test.ts b/packages/types/src/ports/__tests__/derive.test.ts new file mode 100644 index 00000000..b5b4c78f --- /dev/null +++ b/packages/types/src/ports/__tests__/derive.test.ts @@ -0,0 +1,248 @@ +/** + * Port derivation tests — pinning the user's explicit examples: + * GitHub → Frontend matches by `repository` role, Frontend → Domain + * matches by `domain` role, multi-port blocks emit one port per + * exposed_ports entry, containers expose nothing. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { getPortsForNode, hasPort, findPort, _resetPortCache, type NodeForPorts } from '../derive'; +import { canPortsConnect } from '../match'; +import type { PortDef } from '../types'; + +beforeEach(() => { + _resetPortCache(); +}); + +function node(iceType: string, extra: Record = {}, type?: string): NodeForPorts { + return { id: 't', data: { iceType, ...extra }, type }; +} + +function ids(ports: PortDef[]): string[] { + return ports.map((p) => p.id); +} + +describe('user example chain — GitHub → Frontend → Domain', () => { + it('Source.Repository exposes a single repository-out', () => { + const ports = getPortsForNode(node('Source.Repository')); + expect(ids(ports)).toEqual(['repository-out']); + expect(ports[0].direction).toBe('out'); + expect(ports[0].role).toBe('repository'); + }); + + it('Compute.StaticSite has matching repository-in (+ env, secret, domain inputs + web/logs outputs)', () => { + const ports = getPortsForNode(node('Compute.StaticSite')); + const portIds = ids(ports); + expect(portIds).toContain('repository-in'); + expect(portIds).toContain('env-in'); + expect(portIds).toContain('secret-in'); + expect(portIds).toContain('domain-in'); + expect(portIds).toContain('web-out'); + expect(portIds).toContain('logs-out'); + }); + + it("Repo's repository-out connects to Frontend's repository-in (role match)", () => { + const repoOut = findPort(node('Source.Repository'), 'repository-out')!; + const frontendIn = findPort(node('Compute.StaticSite'), 'repository-in')!; + expect(canPortsConnect(repoOut, frontendIn)).toBe(true); + }); + + it("Repo's repository-out does NOT connect to Frontend's domain-in (role mismatch)", () => { + const repoOut = findPort(node('Source.Repository'), 'repository-out')!; + const frontendDomainIn = findPort(node('Compute.StaticSite'), 'domain-in')!; + expect(canPortsConnect(repoOut, frontendDomainIn)).toBe(false); + }); + + it("Network.CustomDomain's domain-out connects to Frontend's domain-in", () => { + const domainOut = findPort(node('Network.CustomDomain'), 'domain-out')!; + const frontendDomainIn = findPort(node('Compute.StaticSite'), 'domain-in')!; + expect(canPortsConnect(domainOut, frontendDomainIn)).toBe(true); + }); +}); + +describe('backend wiring — Postgres / Redis / Queue', () => { + it('Database.PostgreSQL.db-out connects to Compute.Container.db-in', () => { + const dbOut = findPort(node('Database.PostgreSQL'), 'db-out')!; + const backendDbIn = findPort(node('Compute.Container'), 'db-in')!; + expect(canPortsConnect(dbOut, backendDbIn)).toBe(true); + }); + + it('Database.Redis exposes a cache-out (not db-out — Redis is a cache)', () => { + const ports = ids(getPortsForNode(node('Database.Redis'))); + expect(ports).toContain('cache-out'); + expect(ports).not.toContain('db-out'); + }); + + it('Backend publishes to Queue: backend.queue-out → queue.queue-in', () => { + const backendQueueOut = findPort(node('Compute.Container'), 'queue-out')!; + const queueIn = findPort(node('Messaging.Queue'), 'queue-in')!; + expect(canPortsConnect(backendQueueOut, queueIn)).toBe(true); + }); + + it('Queue → Backend subscribers: queue.queue-out → backend.queue-in', () => { + const queueOut = findPort(node('Messaging.Queue'), 'queue-out')!; + const backendQueueIn = findPort(node('Compute.Container'), 'queue-in')!; + expect(canPortsConnect(queueOut, backendQueueIn)).toBe(true); + }); +}); + +describe('containers and non-deployables', () => { + it.each(['Network.VPC', 'Network.Subnet', 'Group.Frontend', 'Group.Custom'])('%s emits no ports', (iceType) => { + expect(getPortsForNode(node(iceType))).toEqual([]); + }); + + it('Network.PrivateNetwork has empty base ports (container)', () => { + expect(getPortsForNode(node('Network.PrivateNetwork'))).toEqual([]); + }); + + it('Util.Reroute has any-role in + any-role out so wires pass through', () => { + const ports = getPortsForNode(node('Util.Reroute')); + expect(ports.map((p) => p.role)).toEqual(['any', 'any']); + const passIn = ports[0]; + const dbOut = findPort(node('Database.PostgreSQL'), 'db-out')!; + expect(canPortsConnect(dbOut, passIn)).toBe(true); + }); +}); + +describe('property-anchored IN ports', () => { + it('Frontend domain-in writes to property=custom_domain', () => { + const p = findPort(node('Compute.StaticSite'), 'domain-in')!; + expect(p.property).toBe('custom_domain'); + }); + + it('Frontend repository-in writes to property=repository', () => { + const p = findPort(node('Compute.StaticSite'), 'repository-in')!; + expect(p.property).toBe('repository'); + }); +}); + +describe('peerStyle coloring', () => { + it("Frontend's domain-in carries peerStyle='Network' (reads as Custom Domain)", () => { + expect(findPort(node('Compute.StaticSite'), 'domain-in')?.peerStyle).toBe('Network'); + }); + + it("Frontend's repository-in carries peerStyle='Source'", () => { + expect(findPort(node('Compute.StaticSite'), 'repository-in')?.peerStyle).toBe('Source'); + }); +}); + +describe('hasPort / findPort', () => { + it('hasPort returns true for an existing port', () => { + expect(hasPort(node('Compute.StaticSite'), 'domain-in')).toBe(true); + expect(hasPort(node('Compute.StaticSite'), 'nonexistent')).toBe(false); + }); +}); + +describe('multi-route (Network.CustomDomain routes)', () => { + it('exposes the fallback domain-out when no routes are configured', () => { + const ports = getPortsForNode(node('Network.CustomDomain')); + const ids2 = ids(ports); + expect(ids2).toContain('domain-out'); + expect(ids2.filter((id) => id.startsWith('domain-out-'))).toEqual([]); + }); + + it('emits one socket per route and hides the fallback when routes are set', () => { + const ports = getPortsForNode( + node('Network.CustomDomain', { + routes: [ + { id: 'r1', subdomain: 'api' }, + { id: 'r2', subdomain: 'admin' }, + ], + }), + ); + const ids2 = ids(ports); + expect(ids2).not.toContain('domain-out'); + expect(ids2).toContain('domain-out-r1'); + expect(ids2).toContain('domain-out-r2'); + }); + + it('labels each route socket with the subdomain text', () => { + const ports = getPortsForNode(node('Network.CustomDomain', { routes: [{ id: 'r1', subdomain: 'api' }] })); + const apiPort = ports.find((p) => p.id === 'domain-out-r1'); + expect(apiPort?.label).toBe('api'); + }); + + it('each route socket carries the same peer-kind constraint as the fallback', () => { + const ports = getPortsForNode(node('Network.CustomDomain', { routes: [{ id: 'r1', subdomain: 'api' }] })); + const apiPort = ports.find((p) => p.id === 'domain-out-r1'); + expect(apiPort?.peerKind).toBe('service'); + }); +}); + +describe('multi-port (Compute.Container exposed_ports)', () => { + it('default Container exposes one web-out (HTTPS :8080) when no exposed_ports set', () => { + const ports = getPortsForNode(node('Compute.Container')); + const ids2 = ids(ports); + expect(ids2.filter((id) => id.endsWith('-out'))).toContain('web-out'); + }); + + it('emits one port per exposed_ports entry (JSON form)', () => { + const ports = getPortsForNode( + node('Compute.Container', { + exposed_ports: [ + JSON.stringify({ port: 8080, protocol: 'http', label: 'api' }), + JSON.stringify({ port: 8443, protocol: 'https' }), + JSON.stringify({ port: 22, protocol: 'tcp', label: 'ssh' }), + ], + }), + ); + const dynamicIds = ports.filter((p) => p.removable).map((p) => p.id); + expect(dynamicIds).toEqual(['port-8080-out', 'port-8443-out', 'port-22-out']); + }); + + it('hides the default web-out once the user declares any exposed_ports', () => { + const ports = getPortsForNode( + node('Compute.Container', { + exposed_ports: [JSON.stringify({ port: 8080, protocol: 'http' })], + }), + ); + expect(ports.some((p) => p.id === 'web-out')).toBe(false); + expect(ports.some((p) => p.id === 'port-8080-out')).toBe(true); + }); + + it('parses compact text form "https:443:api"', () => { + const ports = getPortsForNode(node('Compute.Container', { exposed_ports: ['https:443:api'] })); + const userPort = ports.find((p) => p.removable); + expect(userPort?.port).toBe(443); + expect(userPort?.protocol).toBe('https'); + expect(userPort?.label).toContain('443'); + expect(userPort?.label).toContain('api'); + }); + + it('skips malformed entries silently rather than throwing', () => { + const ports = getPortsForNode( + node('Compute.Container', { exposed_ports: ['nonsense', '', JSON.stringify({ port: 9000 })] }), + ); + const dynamic = ports.filter((p) => p.removable); + expect(dynamic).toHaveLength(1); + expect(dynamic[0].port).toBe(9000); + }); + + it('Compute.BackendAPI mirrors Container behavior — exposed_ports honored', () => { + const ports = getPortsForNode( + node('Compute.BackendAPI', { exposed_ports: [JSON.stringify({ port: 3000, protocol: 'http' })] }), + ); + expect(ports.some((p) => p.id === 'port-3000-out')).toBe(true); + expect(ports.some((p) => p.id === 'web-out')).toBe(false); + }); + + it('Compute.Worker has NO exposed_ports schema (single-port category)', () => { + const ports = getPortsForNode(node('Compute.Worker', { exposed_ports: ['https:443'] })); + // Worker schema ignores exposed_ports — port_list only ships on Container + BackendAPI. + expect(ports.some((p) => p.id === 'port-443-out')).toBe(false); + }); +}); + +describe('memoization', () => { + it('repeated calls with same data return the same array', () => { + const a = getPortsForNode(node('Compute.StaticSite')); + const b = getPortsForNode(node('Compute.StaticSite')); + expect(a).toBe(b); + }); + + it('different iceType invalidates cache', () => { + const a = getPortsForNode(node('Compute.StaticSite')); + const b = getPortsForNode(node('Compute.Container')); + expect(a).not.toBe(b); + }); +}); diff --git a/packages/types/src/ports/__tests__/infer.test.ts b/packages/types/src/ports/__tests__/infer.test.ts new file mode 100644 index 00000000..25a7837f --- /dev/null +++ b/packages/types/src/ports/__tests__/infer.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { inferEdgePorts } from '../infer'; +import { getPortsForNode } from '../derive'; + +function node(iceType: string, extra: Record = {}) { + return { id: 't', data: { iceType, ...extra } }; +} + +describe('inferEdgePorts — render-time fallback for legacy edges', () => { + it('Repo → Frontend infers repository-out / repository-in', () => { + const src = getPortsForNode(node('Source.Repository')); + const tgt = getPortsForNode(node('Compute.StaticSite')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'pipeline'); + expect(sourcePort?.id).toBe('repository-out'); + expect(targetPort?.id).toBe('repository-in'); + }); + + it('CustomDomain → Frontend infers domain-out / domain-in', () => { + const src = getPortsForNode(node('Network.CustomDomain')); + const tgt = getPortsForNode(node('Compute.StaticSite')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'dns'); + expect(sourcePort?.id).toBe('domain-out'); + expect(targetPort?.id).toBe('domain-in'); + }); + + it('Postgres → Backend infers db-out / db-in', () => { + const src = getPortsForNode(node('Database.PostgreSQL')); + const tgt = getPortsForNode(node('Compute.Container')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'traffic'); + expect(sourcePort?.id).toBe('db-out'); + expect(targetPort?.id).toBe('db-in'); + }); + + it('Service → Monitoring infers logs-out / logs-in', () => { + const src = getPortsForNode(node('Compute.Container')); + const tgt = getPortsForNode(node('Monitoring.Log')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'traffic'); + expect(sourcePort?.id).toBe('logs-out'); + expect(targetPort?.id).toBe('logs-in'); + }); + + it('returns undefined when no pair is compatible', () => { + // Custom Domain has only domain-out, Postgres has only db-out — no IN port matches. + const src = getPortsForNode(node('Network.CustomDomain')); + const tgt = getPortsForNode(node('Database.PostgreSQL')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'dns'); + expect(sourcePort).toBeUndefined(); + expect(targetPort).toBeUndefined(); + }); + + it('ignores reroute (`any`) when a concrete role match exists', () => { + // If a Reroute is in the source list, its 'any' OUT shouldn't outscore + // a real role match. + const rerouteOut = getPortsForNode(node('Util.Reroute')).find((p) => p.direction === 'out')!; + const repoOut = getPortsForNode(node('Source.Repository'))[0]; + const tgt = getPortsForNode(node('Compute.StaticSite')); + const { sourcePort } = inferEdgePorts([rerouteOut, repoOut], tgt, 'pipeline'); + expect(sourcePort?.id).toBe('repository-out'); + }); +}); diff --git a/packages/types/src/ports/__tests__/match.test.ts b/packages/types/src/ports/__tests__/match.test.ts new file mode 100644 index 00000000..734e6245 --- /dev/null +++ b/packages/types/src/ports/__tests__/match.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; +import { canPortsConnect, rolesCompatible, findMatchingPorts, chooseBestTargetPort } from '../match'; +import type { PortDef } from '../types'; + +function port(over: Partial): PortDef { + return { + id: 'p', + direction: 'out', + role: 'database', + label: '', + side: 'right', + shape: 'circle', + ...over, + }; +} + +describe('rolesCompatible', () => { + it('matches identical roles', () => { + expect(rolesCompatible('domain', 'domain')).toBe(true); + expect(rolesCompatible('database', 'database')).toBe(true); + }); + + it('rejects different roles', () => { + expect(rolesCompatible('domain', 'repository')).toBe(false); + expect(rolesCompatible('database', 'cache')).toBe(false); + }); + + it("'any' is the reroute passthrough — matches everything", () => { + expect(rolesCompatible('any', 'database')).toBe(true); + expect(rolesCompatible('domain', 'any')).toBe(true); + expect(rolesCompatible('any', 'any')).toBe(true); + }); +}); + +describe('canPortsConnect', () => { + it('two out ports never connect', () => { + const a = port({ direction: 'out', role: 'database' }); + const b = port({ direction: 'out', role: 'database' }); + expect(canPortsConnect(a, b)).toBe(false); + }); + + it('two in ports never connect', () => { + const a = port({ direction: 'in', role: 'database' }); + const b = port({ direction: 'in', role: 'database' }); + expect(canPortsConnect(a, b)).toBe(false); + }); + + it('out + matching in → true', () => { + const out = port({ direction: 'out', role: 'database' }); + const inn = port({ direction: 'in', role: 'database' }); + expect(canPortsConnect(out, inn)).toBe(true); + }); + + it('out + mismatched in → false', () => { + const out = port({ direction: 'out', role: 'database' }); + const inn = port({ direction: 'in', role: 'cache' }); + expect(canPortsConnect(out, inn)).toBe(false); + }); + + it('any-role port connects to any other direction', () => { + const a = port({ direction: 'out', role: 'any' }); + const b = port({ direction: 'in', role: 'database' }); + expect(canPortsConnect(a, b)).toBe(true); + }); +}); + +describe('canPortsConnect — peer-kind cross-check (queue + similar)', () => { + it("Backend.queue-out (publish, peerKind='queue') does NOT connect to another Backend.queue-in (subscribe, peerKind='queue')", () => { + // Both ports declare peerKind='queue'; both blocks are kind='service'. + // The pre-existing role-identity match would pass — peer-kind is what + // blocks the wrong wiring. + const backendPublishOut = port({ + id: 'queue-out', + direction: 'out', + role: 'queue', + peerKind: 'queue', + }); + const backendSubscribeIn = port({ + id: 'queue-in', + direction: 'in', + role: 'queue', + peerKind: 'queue', + }); + expect(canPortsConnect(backendPublishOut, backendSubscribeIn, 'service', 'service')).toBe(false); + }); + + it("Backend.queue-out (peerKind='queue') connects to a real Queue.queue-in (peerKind='service')", () => { + const backendPublishOut = port({ + id: 'queue-out', + direction: 'out', + role: 'queue', + peerKind: 'queue', + }); + const queueIn = port({ + id: 'queue-in', + direction: 'in', + role: 'queue', + peerKind: 'service', + }); + expect(canPortsConnect(backendPublishOut, queueIn, 'service', 'queue')).toBe(true); + }); + + it("Queue.queue-out (peerKind='service') connects to a Backend.queue-in (peerKind='queue')", () => { + const queueOut = port({ + id: 'queue-out', + direction: 'out', + role: 'queue', + peerKind: 'service', + }); + const backendSubscribeIn = port({ + id: 'queue-in', + direction: 'in', + role: 'queue', + peerKind: 'queue', + }); + expect(canPortsConnect(queueOut, backendSubscribeIn, 'queue', 'service')).toBe(true); + }); + + it('peer-kind is permissive when kinds are not provided (backward compat)', () => { + const a = port({ direction: 'out', role: 'queue', peerKind: 'queue' }); + const b = port({ direction: 'in', role: 'queue', peerKind: 'queue' }); + // Callers without iceType context — the model degrades to role-only. + expect(canPortsConnect(a, b)).toBe(true); + }); + + it('reroute kind is universally acceptable on either side', () => { + const out = port({ direction: 'out', role: 'any', peerKind: 'any' }); + const in_ = port({ direction: 'in', role: 'database', peerKind: 'database' }); + expect(canPortsConnect(out, in_, 'reroute', 'database')).toBe(true); + }); + + it('blocks the partner when our peer-kind disagrees with their block kind', () => { + const a = port({ direction: 'out', role: 'repository', peerKind: 'service' }); + const b = port({ direction: 'in', role: 'repository', peerKind: 'repository' }); + expect(canPortsConnect(a, b, 'repository', 'queue')).toBe(false); + }); +}); + +describe('findMatchingPorts', () => { + it('returns all candidates matching the source role + opposite direction', () => { + const src = port({ direction: 'out', role: 'database' }); + const candidates = [ + port({ direction: 'in', role: 'database', id: 'db-in' }), + port({ direction: 'in', role: 'cache', id: 'cache-in' }), + port({ direction: 'out', role: 'database', id: 'wrong-direction' }), + port({ direction: 'in', role: 'any', id: 'any-in' }), + ]; + const ids = findMatchingPorts(src, candidates).map((p) => p.id); + expect(ids).toEqual(['db-in', 'any-in']); + }); +}); + +describe('chooseBestTargetPort', () => { + it('prefers exact-role IN over any-role IN', () => { + const src = port({ direction: 'out', role: 'database' }); + const candidates = [ + port({ direction: 'in', role: 'any', id: 'any-in' }), + port({ direction: 'in', role: 'database', id: 'db-in' }), + ]; + expect(chooseBestTargetPort(src, candidates)?.id).toBe('db-in'); + }); + + it('falls back to any-role when no exact match exists', () => { + const src = port({ direction: 'out', role: 'database' }); + const candidates = [port({ direction: 'in', role: 'any', id: 'any-in' })]; + expect(chooseBestTargetPort(src, candidates)?.id).toBe('any-in'); + }); + + it('returns undefined when no compatible IN port exists', () => { + const src = port({ direction: 'out', role: 'database' }); + const candidates = [port({ direction: 'in', role: 'cache', id: 'cache-in' })]; + expect(chooseBestTargetPort(src, candidates)).toBeUndefined(); + }); + + it('reverses when source is an IN port (drag started from an input)', () => { + const src = port({ direction: 'in', role: 'database' }); + const candidates = [ + port({ direction: 'out', role: 'cache', id: 'cache-out' }), + port({ direction: 'out', role: 'database', id: 'db-out' }), + ]; + expect(chooseBestTargetPort(src, candidates)?.id).toBe('db-out'); + }); +}); diff --git a/packages/types/src/ports/derive.ts b/packages/types/src/ports/derive.ts new file mode 100644 index 00000000..5fc7216b --- /dev/null +++ b/packages/types/src/ports/derive.ts @@ -0,0 +1,87 @@ +/** + * Port derivation — produces the ordered port list for a node. + * + * Replaces `getSocketsForNode` from the prior abstract-socket model. + * The new model is fully schema-driven: each iceType has a hand-authored + * `PortSchema` (in `./schemas/`) that declares ports anchored to the + * block's typed properties. Property changes still reshape sockets at + * render time — `dynamic(data)` lets a multi-port block emit one port + * per item in `node.data.exposed_ports`, and `hide` lets a schema drop + * a base port when a property predicate fires. + */ + +import { getPortSchema } from './schemas'; +import { isContainer } from '../connection-rules/predicates'; +import type { PortDef } from './types'; +import type { NodeForConnectionCheck } from '../connection-rules/types'; + +export interface NodeForPorts extends NodeForConnectionCheck { + data?: Record; +} + +const cache = new Map(); + +/** Test helper — clears the memo cache. */ +export function _resetPortCache(): void { + cache.clear(); +} + +function cacheKey(iceType: string, data: Record): string { + const schema = getPortSchema(iceType); + if (!schema) return iceType; + const keys = new Set(); + for (const h of schema.hide ?? []) h.keys.forEach((k) => keys.add(k)); + // `dynamic` reads `node.data` opaquely — if a schema declares one, key + // on a structural hash of the data object to be safe. (Practically + // only `exposed_ports` triggers this, so the JSON is small.) + const parts: string[] = [iceType]; + for (const k of Array.from(keys).sort()) { + parts.push(`${k}=${JSON.stringify(data[k] ?? null)}`); + } + if (schema.dynamic) parts.push(`dyn=${JSON.stringify(data.exposed_ports ?? null)}`); + return parts.join('|'); +} + +export function getPortsForNode(node: NodeForPorts): PortDef[] { + const data = node.data ?? {}; + const iceType = typeof data.iceType === 'string' ? data.iceType : ''; + if (!iceType) return []; + // Containers (VPC, Subnet, Group.*) never expose ports; children attach via parentId. + if (isContainer(iceType, node.type)) return []; + + const schema = getPortSchema(iceType); + if (!schema) return []; + + const key = cacheKey(iceType, data); + const cached = cache.get(key); + if (cached) return cached; + + let ports: PortDef[] = [...schema.base]; + if (schema.hide) { + for (const hide of schema.hide) { + if (hide.when(data)) { + const ids = new Set(hide.portIds); + ports = ports.filter((p) => !ids.has(p.id)); + } + } + } + if (schema.dynamic) { + ports = ports.concat(schema.dynamic(data)); + } + // Dedupe by id (later wins). + const byId = new Map(); + for (const p of ports) byId.set(p.id, p); + const result = Array.from(byId.values()); + cache.set(key, result); + return result; +} + +/** Lookup helper for the render layer. */ +export function findPort(node: NodeForPorts, portId: string): PortDef | undefined { + return getPortsForNode(node).find((p) => p.id === portId); +} + +/** Used by `svg-connection-path.tsx` to detect dangling edges (socket removed). */ +export function hasPort(node: NodeForPorts, portId: string): boolean { + return getPortsForNode(node).some((p) => p.id === portId); +} diff --git a/packages/types/src/ports/index.ts b/packages/types/src/ports/index.ts new file mode 100644 index 00000000..b4557c1d --- /dev/null +++ b/packages/types/src/ports/index.ts @@ -0,0 +1,21 @@ +/** + * Ports — typed connection points on blocks. + * + * Public surface: types (`PortDef`, `PortRole`, `PortSchema`, …), + * matching primitives (`canPortsConnect`, `rolesCompatible`, + * `chooseBestTargetPort`), and the derivation entrypoint + * (`getPortsForNode`, `hasPort`, `findPort`). + * + * Replaces (and supersedes) the earlier `@ice/types/sockets` API, + * which derived sockets from CONNECTION_RULES categories. The old + * exports remain available as a thin shim that delegates here so + * existing imports keep resolving during the migration window. + */ + +export * from './types'; +export { getBlockKind } from './types'; +export { canPortsConnect, rolesCompatible, findMatchingPorts, chooseBestTargetPort } from './match'; +export { getPortsForNode, hasPort, findPort, _resetPortCache, type NodeForPorts } from './derive'; +export { PORT_SCHEMAS, getPortSchema } from './schemas'; +export { inferEdgePorts, type InferredEdgePorts } from './infer'; +export { getPortAnchorPoint, type Bounds, type Point } from './position'; diff --git a/packages/types/src/ports/infer.ts b/packages/types/src/ports/infer.ts new file mode 100644 index 00000000..6f7e3083 --- /dev/null +++ b/packages/types/src/ports/infer.ts @@ -0,0 +1,69 @@ +/** + * Render-time port inference for legacy edges. + * + * Edges created before the port-driven socket model — or AI-generated + * edges that didn't specify ports — have no `sourceSocket` / + * `targetSocket` on `edge.data`. They should still render with the + * right magnetic anchors, so the canvas tells the user a deterministic + * story: a Repo → Frontend edge attaches to the `repository-in` socket + * on the Frontend, even if the edge data is silent. + * + * Inference scores all (sourceOut × targetIn) port pairs and picks the + * best match. The result is NOT written back to the edge — purely + * visual. The user can right-click an edge → "Reconnect typed" if they + * want to lock in explicit ports. + */ + +import { canPortsConnect } from './match'; +import { ROLE_CATEGORY } from './types'; +import type { PortDef } from './types'; +import type { ConnectionCategory } from '@ice/constants'; + +export interface InferredEdgePorts { + sourcePort?: PortDef; + targetPort?: PortDef; +} + +/** + * Score how well a candidate (src, tgt) port pair matches an edge. + * + * Roughly: + * +100 — roles match exactly (not `any`) + * +30 — role's connection category matches the edge's category + * +10 — both are not `any` + * -50 — either is `any` (passthrough wins only when nothing else does) + * + * Higher is better. Pairs that can't connect at all return 0. + */ +function score(src: PortDef, tgt: PortDef, category: ConnectionCategory | null): number { + if (!canPortsConnect(src, tgt)) return 0; + let s = 0; + if (src.role === tgt.role && src.role !== 'any') s += 100; + if (src.role === 'any' || tgt.role === 'any') s -= 50; + else s += 10; + if (category && ROLE_CATEGORY[src.role] === category) s += 30; + return s; +} + +/** + * Pick the best (source OUT, target IN) port pair given the two nodes' + * port lists and the edge's known category. Returns whichever side + * could be resolved — if no pair scores above 0, the result has both + * sides undefined and the renderer falls back to anonymous routing. + */ +export function inferEdgePorts( + sourcePorts: PortDef[], + targetPorts: PortDef[], + category: ConnectionCategory | null, +): InferredEdgePorts { + let best: { src?: PortDef; tgt?: PortDef; score: number } = { score: 0 }; + for (const src of sourcePorts) { + if (src.direction !== 'out') continue; + for (const tgt of targetPorts) { + if (tgt.direction !== 'in') continue; + const s = score(src, tgt, category); + if (s > best.score) best = { src, tgt, score: s }; + } + } + return { sourcePort: best.src, targetPort: best.tgt }; +} diff --git a/packages/types/src/ports/match.ts b/packages/types/src/ports/match.ts new file mode 100644 index 00000000..1e41ce41 --- /dev/null +++ b/packages/types/src/ports/match.ts @@ -0,0 +1,94 @@ +/** + * Port matching — does this OUT port accept that IN port (or vice versa)? + * + * Identity by default: same role + opposite direction. The `any` role + * is the reroute escape hatch — it accepts everything and is accepted + * by everything, so wires can flow through a Reroute node without + * the role check rejecting them. + * + * No cross-role aliases beyond `any`. Keep the model boring so users + * can predict it: a `repository` out connects to a `repository` in, + * never to a `database` in. + */ + +import type { PeerKind, PortDef, PortRole } from './types'; + +/** + * Returns true if a wire can be drawn between these two ports. + * + * Three gates, in order: + * 1. Opposite directions (out↔in). + * 2. Roles compatible (identity match, or either side is `any`). + * 3. Optional peer-kind cross-check — if `a.peerKind` is set and the + * caller passes the partner's block kind, the partner must be of + * that kind. This is what stops two Backends from accidentally + * wiring up via `queue-out` ↔ `queue-in` (both endpoints are + * `service` kind; both ports declare `peerKind: 'queue'`). + * + * Callers without iceType context (e.g. drag start before a target + * exists) can omit the kind args — the peer-kind gate then short- + * circuits to true and the model degrades to role-only matching, + * matching the prior contract. + */ +export function canPortsConnect(a: PortDef, b: PortDef, aPeerKind?: PeerKind, bPeerKind?: PeerKind): boolean { + if (a.direction === b.direction) return false; + if (!rolesCompatible(a.role, b.role)) return false; + if (!peerKindAccepts(a.peerKind, bPeerKind)) return false; + if (!peerKindAccepts(b.peerKind, aPeerKind)) return false; + return true; +} + +/** + * Checks one side of the peer-kind constraint: a port declaring + * `expected` must see a partner of that kind. `'any'` on either side + * is the wildcard. Reroute nodes are universally compatible too. + */ +function peerKindAccepts(expected: PeerKind | undefined, actual: PeerKind | undefined): boolean { + if (!expected || expected === 'any') return true; + if (!actual) return true; // caller didn't provide context — degrade to permissive + if (actual === 'any' || actual === 'reroute' || expected === 'reroute') return true; + return expected === actual; +} + +/** Compatibility check on roles alone — used when only role info is known. */ +export function rolesCompatible(a: PortRole, b: PortRole): boolean { + if (a === 'any' || b === 'any') return true; + return a === b; +} + +/** + * Given a source port and a target node's ports, return all target + * ports that the source could connect to. Used by the drag-target + * highlight to decide if a node is a valid drop target at all. + * + * Pass `sourceKind` + `targetKind` (the iceType's `getBlockKind` + * result) to enforce the peer-kind cross-check; omit for permissive + * role-only matching. + */ +export function findMatchingPorts( + source: PortDef, + candidates: PortDef[], + sourceKind?: PeerKind, + targetKind?: PeerKind, +): PortDef[] { + return candidates.filter((c) => canPortsConnect(source, c, sourceKind, targetKind)); +} + +/** + * When the drop target is the *node* rather than a specific port (the + * user dropped on the block body), pick the best target port: the + * first IN port matching the source's role + peer-kind, preferring + * exact-role over `any`-role matches. + */ +export function chooseBestTargetPort( + source: PortDef, + candidates: PortDef[], + sourceKind?: PeerKind, + targetKind?: PeerKind, +): PortDef | undefined { + const matching = candidates.filter((c) => canPortsConnect(source, c, sourceKind, targetKind)); + if (matching.length === 0) return undefined; + // Prefer an exact-role match over a wildcard (`any`) one — keeps + // reroute passthroughs from outranking a real semantic match. + return matching.find((c) => c.role === source.role) ?? matching[0]; +} diff --git a/packages/types/src/ports/position.ts b/packages/types/src/ports/position.ts new file mode 100644 index 00000000..315313c4 --- /dev/null +++ b/packages/types/src/ports/position.ts @@ -0,0 +1,50 @@ +/** + * Port position math — shared by the canvas renderer and the connection- + * drawing hook so both agree where a port dot sits in canvas space. + * + * Ports are distributed evenly along their declared side (left / right / + * top / bottom). Distributing within a side means side-N is the N-th of + * all ports on that same side, in declaration order. This matches the + * `TypedSockets` SVG layout and keeps the snap-target math honest. + */ + +import type { PortDef } from './types'; + +export interface Bounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface Point { + x: number; + y: number; +} + +/** + * Returns the (x, y) canvas-space coordinate of `port` on a node with + * the given `bounds`, accounting for sibling ports on the same side. + * + * `allPorts` is the full port list from `getPortsForNode(node)`. The + * helper finds the port's side group and computes its slot index. + */ +export function getPortAnchorPoint(bounds: Bounds, port: PortDef, allPorts: PortDef[]): Point { + const sideGroup = allPorts.filter((p) => p.side === port.side); + const idx = sideGroup.findIndex((p) => p.id === port.id); + const count = sideGroup.length; + const safeIdx = idx >= 0 ? idx : 0; + const r = (safeIdx + 1) / (count + 1); + const { x, y, width: w, height: h } = bounds; + switch (port.side) { + case 'top': + return { x: x + w * r, y }; + case 'right': + return { x: x + w, y: y + h * r }; + case 'bottom': + return { x: x + w * r, y: y + h }; + case 'left': + default: + return { x, y: y + h * r }; + } +} diff --git a/packages/types/src/ports/schemas/ai.ts b/packages/types/src/ports/schemas/ai.ts new file mode 100644 index 00000000..41d50942 --- /dev/null +++ b/packages/types/src/ports/schemas/ai.ts @@ -0,0 +1,101 @@ +import type { PortSchema } from '../types'; + +export const aiVectorDbSchema: PortSchema = { + iceType: 'AI.VectorDB', + base: [ + { + id: 'vector-out', + direction: 'out', + role: 'vector', + label: 'Vector DB', + side: 'right', + shape: 'circle', + peerStyle: 'AI', + }, + ], +}; + +export const aiLlmGatewaySchema: PortSchema = { + iceType: 'AI.LLMGateway', + base: [ + { + id: 'llm-out', + direction: 'out', + role: 'llm', + label: 'LLM gateway', + side: 'right', + shape: 'circle', + peerStyle: 'AI', + }, + ], +}; + +/** + * AI.PrivateAIService is itself a backend (it can be deployed and + * fronted by a domain) so it exposes the standard service ports plus + * the LLM out. + */ +export const aiPrivateAiServiceSchema: PortSchema = { + iceType: 'AI.PrivateAIService', + base: [ + { + id: 'repository-in', + direction: 'in', + role: 'repository', + label: 'Source code', + property: 'repository', + side: 'left', + shape: 'diamond', + peerStyle: 'Source', + }, + { + id: 'env-in', + direction: 'in', + role: 'env', + label: 'Environment variables', + property: 'env_vars', + side: 'left', + shape: 'ring', + peerStyle: 'Config', + }, + { + id: 'secret-in', + direction: 'in', + role: 'secret', + label: 'Secrets', + property: 'secrets', + side: 'left', + shape: 'ring', + peerStyle: 'Security', + }, + { + id: 'vector-in', + direction: 'in', + role: 'vector', + label: 'Vector DB', + side: 'left', + shape: 'circle', + peerStyle: 'AI', + }, + { + id: 'llm-out', + direction: 'out', + role: 'llm', + label: 'LLM gateway', + side: 'right', + shape: 'circle', + peerStyle: 'AI', + }, + { + id: 'web-out', + direction: 'out', + role: 'http-endpoint', + label: 'Web (HTTPS)', + port: 443, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/compute.ts b/packages/types/src/ports/schemas/compute.ts new file mode 100644 index 00000000..e21bfa53 --- /dev/null +++ b/packages/types/src/ports/schemas/compute.ts @@ -0,0 +1,323 @@ +import type { PortDef, PortSchema } from '../types'; + +/** + * Inputs every deployable service (frontend or backend) shares — source + * code from a repository, environment variables from a config block, + * secret references from a secret store, and an optional custom domain. + */ +function serviceCommonInputs(includeDomain = true): PortDef[] { + const base: PortDef[] = [ + { + id: 'repository-in', + direction: 'in', + role: 'repository', + label: 'Source code', + property: 'repository', + side: 'left', + shape: 'diamond', + peerStyle: 'Source', + }, + { + id: 'env-in', + direction: 'in', + role: 'env', + label: 'Environment variables', + property: 'env_vars', + side: 'left', + shape: 'ring', + peerStyle: 'Config', + }, + { + id: 'secret-in', + direction: 'in', + role: 'secret', + label: 'Secrets', + property: 'secrets', + side: 'left', + shape: 'ring', + peerStyle: 'Security', + }, + ]; + if (includeDomain) { + base.push({ + id: 'domain-in', + direction: 'in', + role: 'domain', + label: 'Custom domain', + property: 'custom_domain', + side: 'left', + shape: 'square', + peerStyle: 'Network', + }); + } + return base; +} + +/** + * Data-store inputs that a backend can consume. Each one corresponds to + * a stable env var the deploy compiler injects (DATABASE_URL, + * REDIS_URL, …) via the existing `Backend → DataStore` propagation + * rule. + */ +function backendDataInputs(): PortDef[] { + return [ + { + id: 'db-in', + direction: 'in', + role: 'database', + label: 'Database', + side: 'left', + shape: 'circle', + peerStyle: 'Database', + }, + { + id: 'cache-in', + direction: 'in', + role: 'cache', + label: 'Cache', + side: 'left', + shape: 'circle', + peerStyle: 'Database', + }, + { + id: 'queue-out', + direction: 'out', + role: 'queue', + label: 'Publish to queue', + side: 'right', + shape: 'circle', + peerStyle: 'Messaging', + // Publish targets a Queue (or Stream/Email) — NEVER another Service. + peerKind: 'queue', + }, + { + id: 'queue-in', + direction: 'in', + role: 'queue', + label: 'Subscribe to queue', + side: 'left', + shape: 'circle', + peerStyle: 'Messaging', + // Subscribe receives from a Queue/Stream — NEVER from another Service. + peerKind: 'queue', + }, + { + id: 'storage-in', + direction: 'in', + role: 'storage', + label: 'Object storage', + side: 'left', + shape: 'circle', + peerStyle: 'Storage', + }, + { + id: 'search-in', + direction: 'in', + role: 'search', + label: 'Search', + side: 'left', + shape: 'circle', + peerStyle: 'Analytics', + }, + { + id: 'vector-in', + direction: 'in', + role: 'vector', + label: 'Vector DB', + side: 'left', + shape: 'circle', + peerStyle: 'AI', + }, + { + id: 'llm-in', + direction: 'in', + role: 'llm', + label: 'LLM', + side: 'left', + shape: 'circle', + peerStyle: 'AI', + }, + ]; +} + +const httpsOut: PortDef = { + id: 'web-out', + direction: 'out', + role: 'http-endpoint', + label: 'Web (HTTPS)', + port: 443, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', +}; + +const logsOut: PortDef = { + id: 'logs-out', + direction: 'out', + role: 'monitoring', + label: 'Logs', + side: 'right', + shape: 'circle', + peerStyle: 'Monitoring', +}; + +/** Compute.StaticSite — frontend that consumes repo/env/domain, exposes HTTPS. */ +export const computeStaticSiteSchema: PortSchema = { + iceType: 'Compute.StaticSite', + base: [...serviceCommonInputs(true), httpsOut, logsOut], +}; + +/** Compute.SSRSite — same wiring as static site (SSR is an implementation detail). */ +export const computeSsrSiteSchema: PortSchema = { + iceType: 'Compute.SSRSite', + base: [...serviceCommonInputs(true), httpsOut, logsOut], +}; + +/** + * Compute.Container — the multi-port-capable backend. + * + * Base ports cover the standard service wiring (repo, env, secret, + * domain, all data-store inputs, logs out). The default `web-out` is + * dropped once the user adds explicit `exposed_ports` — only the + * user-declared listeners show, so the canvas never lies about what + * the service exposes. + */ +export const computeContainerSchema: PortSchema = { + iceType: 'Compute.Container', + base: [ + ...serviceCommonInputs(true), + ...backendDataInputs(), + { + id: 'web-out', + direction: 'out', + role: 'http-endpoint', + label: 'HTTPS :8080', + port: 8080, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', + }, + logsOut, + ], + hide: [ + { + keys: ['exposed_ports'], + when: (data) => Array.isArray(data.exposed_ports) && data.exposed_ports.length > 0, + portIds: ['web-out'], + }, + ], + dynamic: makeExposedPortsDynamic, +}; + +/** + * Compute.BackendAPI — same wiring story as Container. Most blueprints + * land here when the user picks "Backend API" from the palette. + */ +export const computeBackendApiSchema: PortSchema = { + iceType: 'Compute.BackendAPI', + base: [ + ...serviceCommonInputs(true), + ...backendDataInputs(), + { + id: 'web-out', + direction: 'out', + role: 'http-endpoint', + label: 'HTTPS :443', + port: 443, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', + }, + logsOut, + ], + hide: [ + { + keys: ['exposed_ports'], + when: (data) => Array.isArray(data.exposed_ports) && data.exposed_ports.length > 0, + portIds: ['web-out'], + }, + ], + dynamic: makeExposedPortsDynamic, +}; + +/** + * Parses the user's `exposed_ports` list (JSON strings or compact text + * forms — see `port-spec.ts` in the UI package) into typed `http-endpoint` + * OUT ports. Pure parser; doesn't import the UI's port-spec helper to + * keep `@ice/types` UI-agnostic. + */ +function makeExposedPortsDynamic(data: Record): PortDef[] { + const raw = data.exposed_ports; + if (!Array.isArray(raw)) return []; + return raw.map((entry, idx) => parseExposedPort(entry, idx)).filter((p): p is PortDef => p !== null); +} + +function parseExposedPort(raw: unknown, idx: number): PortDef | null { + let port = 0; + let protocol: 'http' | 'https' | 'tcp' = 'http'; + let userLabel = ''; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as { port?: unknown; protocol?: unknown; label?: unknown }; + if (parsed && typeof parsed.port === 'number') { + port = parsed.port; + if (parsed.protocol === 'https' || parsed.protocol === 'tcp') protocol = parsed.protocol; + if (typeof parsed.label === 'string') userLabel = parsed.label; + } + } catch { + const parts = raw.split(':'); + if (parts.length >= 2 && (parts[0] === 'http' || parts[0] === 'https' || parts[0] === 'tcp')) { + protocol = parts[0]; + port = Number(parts[1]); + if (parts[2]) userLabel = parts[2]; + } else { + const n = Number(raw); + if (Number.isFinite(n)) port = n; + } + } + } else if (raw && typeof raw === 'object') { + const obj = raw as { port?: unknown; protocol?: unknown; label?: unknown }; + if (typeof obj.port === 'number') port = obj.port; + if (obj.protocol === 'https' || obj.protocol === 'tcp') protocol = obj.protocol; + if (typeof obj.label === 'string') userLabel = obj.label; + } + if (!Number.isFinite(port) || port <= 0) return null; + const protoLabel = protocol.toUpperCase(); + const label = userLabel ? `${protoLabel} :${port} (${userLabel})` : `${protoLabel} :${port}`; + return { + id: `port-${port}-out`, + direction: 'out', + role: 'http-endpoint', + label, + port, + protocol, + side: 'right', + shape: 'circle', + peerStyle: 'Network', + removable: true, + // Stash the index so dedupe-by-id collisions remain stable for repeat + // ports — unlikely in practice but defensive. + ...(idx >= 0 ? {} : {}), + }; +} + +/** Compute.ServerlessFunction — backend without the multi-port story. */ +export const computeServerlessFunctionSchema: PortSchema = { + iceType: 'Compute.ServerlessFunction', + base: [...serviceCommonInputs(true), ...backendDataInputs(), httpsOut, logsOut], +}; + +/** Compute.Worker — long-running background worker. No public endpoint by default. */ +export const computeWorkerSchema: PortSchema = { + iceType: 'Compute.Worker', + base: [...serviceCommonInputs(false), ...backendDataInputs(), logsOut], +}; + +/** Compute.CronJob — scheduled task. Like a worker but ephemeral. */ +export const computeCronJobSchema: PortSchema = { + iceType: 'Compute.CronJob', + base: [...serviceCommonInputs(false), ...backendDataInputs(), logsOut], +}; diff --git a/packages/types/src/ports/schemas/config.ts b/packages/types/src/ports/schemas/config.ts new file mode 100644 index 00000000..f2570657 --- /dev/null +++ b/packages/types/src/ports/schemas/config.ts @@ -0,0 +1,21 @@ +import type { PortSchema } from '../types'; + +/** + * Config.Environment — provides a bundle of env vars to services. Wiring + * it to a service injects the variables via the existing + * `Service → EnvConfig` propagation rule. + */ +export const configEnvironmentSchema: PortSchema = { + iceType: 'Config.Environment', + base: [ + { + id: 'env-out', + direction: 'out', + role: 'env', + label: 'Environment variables', + side: 'right', + shape: 'ring', + peerStyle: 'Config', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/data.ts b/packages/types/src/ports/schemas/data.ts new file mode 100644 index 00000000..9d3db5da --- /dev/null +++ b/packages/types/src/ports/schemas/data.ts @@ -0,0 +1,61 @@ +import type { PortDef, PortSchema } from '../types'; + +function dbBase(label = 'Database', shape: PortDef['shape'] = 'circle'): PortDef[] { + return [ + { + id: 'db-out', + direction: 'out', + role: 'database', + label, + side: 'right', + shape, + peerStyle: 'Database', + }, + ]; +} + +/** Database.PostgreSQL — provides a database connection; gains `replica-out` when replication is enabled. */ +export const databasePostgresSchema: PortSchema = { + iceType: 'Database.PostgreSQL', + base: dbBase('Database (Postgres)'), +}; + +export const databaseMysqlSchema: PortSchema = { + iceType: 'Database.MySQL', + base: dbBase('Database (MySQL)'), +}; + +export const databaseMongoSchema: PortSchema = { + iceType: 'Database.MongoDB', + base: dbBase('Database (Mongo)'), +}; + +export const databaseRedisSchema: PortSchema = { + iceType: 'Database.Redis', + base: [ + { + id: 'cache-out', + direction: 'out', + role: 'cache', + label: 'Cache (Redis)', + side: 'right', + shape: 'circle', + peerStyle: 'Database', + }, + ], +}; + +export const storageBucketSchema: PortSchema = { + iceType: 'Storage.Bucket', + base: [ + { + id: 'storage-out', + direction: 'out', + role: 'storage', + label: 'Object storage', + side: 'right', + shape: 'circle', + peerStyle: 'Storage', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/index.ts b/packages/types/src/ports/schemas/index.ts new file mode 100644 index 00000000..d5303db2 --- /dev/null +++ b/packages/types/src/ports/schemas/index.ts @@ -0,0 +1,75 @@ +/** + * Port schema registry. + * + * Maps iceType → `PortSchema`. The lookup is total for high-level + * concept blocks — every iceType in the palette has an entry. Unknown + * iceTypes get an empty port list (no sockets rendered). + */ + +import { aiLlmGatewaySchema, aiPrivateAiServiceSchema, aiVectorDbSchema } from './ai'; +import { + computeBackendApiSchema, + computeContainerSchema, + computeCronJobSchema, + computeServerlessFunctionSchema, + computeSsrSiteSchema, + computeStaticSiteSchema, + computeWorkerSchema, +} from './compute'; +import { configEnvironmentSchema } from './config'; +import { + databaseMongoSchema, + databaseMysqlSchema, + databasePostgresSchema, + databaseRedisSchema, + storageBucketSchema, +} from './data'; +import { messagingEmailSchema, messagingEventStreamSchema, messagingQueueSchema } from './messaging'; +import { monitoringLogSchema } from './monitoring'; +import { networkCustomDomainSchema, networkGatewaySchema, networkPrivateNetworkSchema } from './network'; +import { securitySecretSchema } from './security'; +import { sourceRepositorySchema } from './source'; +import { utilRerouteSchema } from './util'; +import type { PortSchema } from '../types'; + +const allSchemas: PortSchema[] = [ + // Compute / frontend / backend + computeStaticSiteSchema, + computeSsrSiteSchema, + computeContainerSchema, + computeBackendApiSchema, + computeServerlessFunctionSchema, + computeWorkerSchema, + computeCronJobSchema, + // Data / storage + databasePostgresSchema, + databaseMysqlSchema, + databaseMongoSchema, + databaseRedisSchema, + storageBucketSchema, + // Messaging + messagingQueueSchema, + messagingEventStreamSchema, + messagingEmailSchema, + // Network + networkCustomDomainSchema, + networkGatewaySchema, + networkPrivateNetworkSchema, + // Security / config / monitoring / source + securitySecretSchema, + configEnvironmentSchema, + monitoringLogSchema, + sourceRepositorySchema, + // AI + aiVectorDbSchema, + aiLlmGatewaySchema, + aiPrivateAiServiceSchema, + // Util + utilRerouteSchema, +]; + +export const PORT_SCHEMAS: Record = Object.fromEntries(allSchemas.map((s) => [s.iceType, s])); + +export function getPortSchema(iceType: string): PortSchema | undefined { + return PORT_SCHEMAS[iceType]; +} diff --git a/packages/types/src/ports/schemas/messaging.ts b/packages/types/src/ports/schemas/messaging.ts new file mode 100644 index 00000000..a7c99295 --- /dev/null +++ b/packages/types/src/ports/schemas/messaging.ts @@ -0,0 +1,77 @@ +import type { PortSchema } from '../types'; + +/** + * Messaging.Queue — exposes both `queue-in` (publishers connect here) + * and `queue-out` (subscribers connect here). The direction of the + * port disambiguates publish vs subscribe; the matching backend ports + * are mirrored. + */ +export const messagingQueueSchema: PortSchema = { + iceType: 'Messaging.Queue', + base: [ + { + id: 'queue-in', + direction: 'in', + role: 'queue', + label: 'Publishers', + side: 'left', + shape: 'circle', + peerStyle: 'Messaging', + // Publishers are Services; never another Queue. + peerKind: 'service', + }, + { + id: 'queue-out', + direction: 'out', + role: 'queue', + label: 'Subscribers', + side: 'right', + shape: 'circle', + peerStyle: 'Messaging', + // Subscribers are Services; never another Queue. + peerKind: 'service', + }, + ], +}; + +export const messagingEventStreamSchema: PortSchema = { + iceType: 'Messaging.EventStream', + base: [ + { + id: 'queue-in', + direction: 'in', + role: 'queue', + label: 'Producers', + side: 'left', + shape: 'circle', + peerStyle: 'Messaging', + peerKind: 'service', + }, + { + id: 'queue-out', + direction: 'out', + role: 'queue', + label: 'Consumers', + side: 'right', + shape: 'circle', + peerStyle: 'Messaging', + peerKind: 'service', + }, + ], +}; + +export const messagingEmailSchema: PortSchema = { + iceType: 'Messaging.Email', + base: [ + { + id: 'queue-in', + direction: 'in', + role: 'queue', + label: 'Email senders', + side: 'left', + shape: 'circle', + peerStyle: 'Messaging', + peerKind: 'service', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/monitoring.ts b/packages/types/src/ports/schemas/monitoring.ts new file mode 100644 index 00000000..89b7d7f8 --- /dev/null +++ b/packages/types/src/ports/schemas/monitoring.ts @@ -0,0 +1,19 @@ +import type { PortSchema } from '../types'; + +/** + * Monitoring.Log — receives logs/metrics streams from services. + */ +export const monitoringLogSchema: PortSchema = { + iceType: 'Monitoring.Log', + base: [ + { + id: 'logs-in', + direction: 'in', + role: 'monitoring', + label: 'Logs', + side: 'left', + shape: 'circle', + peerStyle: 'Monitoring', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/network.ts b/packages/types/src/ports/schemas/network.ts new file mode 100644 index 00000000..ac17277a --- /dev/null +++ b/packages/types/src/ports/schemas/network.ts @@ -0,0 +1,105 @@ +import type { PortSchema } from '../types'; + +/** + * Network.CustomDomain — one `domain-out` socket PER ROUTE. + * + * The block stores `data.routes: Array<{ id, subdomain }>`. Each route + * is a distinct subdomain the user has configured (e.g. `api`, + * `admin`, `app`) — and each gets its own typed socket so the user + * can wire each subdomain to a different downstream service. Matches + * the multi-port story of `Compute.Container` with `exposed_ports`. + * + * The base `domain-out` is the fallback for an unconfigured block + * (no routes yet) so the user can still wire the root domain. When + * any route exists, the fallback is hidden — only per-route sockets + * show, identifying which subdomain is being wired. + */ +export const networkCustomDomainSchema: PortSchema = { + iceType: 'Network.CustomDomain', + base: [ + { + id: 'domain-out', + direction: 'out', + role: 'domain', + label: 'Custom domain', + side: 'right', + shape: 'square', + peerStyle: 'Network', + peerKind: 'service', + }, + ], + hide: [ + { + keys: ['routes'], + when: (data) => Array.isArray(data.routes) && (data.routes as Array<{ id: string }>).length > 0, + portIds: ['domain-out'], + }, + ], + dynamic: (data) => { + const routes = (data.routes as Array<{ id: string; subdomain: string }> | undefined) ?? []; + return routes.map((r) => ({ + id: `domain-out-${r.id}`, + direction: 'out' as const, + role: 'domain' as const, + // Label uses the subdomain when set so the tooltip + properties + // panel both read as the same name the user typed. + label: r.subdomain ? r.subdomain : 'Subdomain', + side: 'right' as const, + shape: 'square' as const, + peerStyle: 'Network', + peerKind: 'service' as const, + removable: true, + })); + }, +}; + +/** + * Network.Gateway — exposes an `http-endpoint-out` (the gateway's public + * URL) and consumes an `http-endpoint-in` (the backend it routes to). + * Also accepts a `domain-in` so a custom domain can target the gateway. + */ +export const networkGatewaySchema: PortSchema = { + iceType: 'Network.Gateway', + base: [ + { + id: 'domain-in', + direction: 'in', + role: 'domain', + label: 'Custom domain', + property: 'custom_domain', + side: 'left', + shape: 'square', + peerStyle: 'Network', + }, + { + id: 'upstream-in', + direction: 'in', + role: 'http-endpoint', + label: 'Backend', + side: 'left', + shape: 'circle', + peerStyle: 'Compute', + }, + { + id: 'public-out', + direction: 'out', + role: 'http-endpoint', + label: 'Public URL (HTTPS)', + port: 443, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', + }, + ], +}; + +/** + * Network.PrivateNetwork — pure container. No ports; children attach via + * parentId. The schema is here for completeness so the registry's + * iceType lookup is total. + */ +export const networkPrivateNetworkSchema: PortSchema = { + iceType: 'Network.PrivateNetwork', + base: [], +}; diff --git a/packages/types/src/ports/schemas/security.ts b/packages/types/src/ports/schemas/security.ts new file mode 100644 index 00000000..e63e34a5 --- /dev/null +++ b/packages/types/src/ports/schemas/security.ts @@ -0,0 +1,21 @@ +import type { PortSchema } from '../types'; + +/** + * Security.Secret — provides secret references (API keys, passwords, + * certs). Wiring to a service runs the existing `Service → Secret` + * propagation, which writes `secretRefs` onto the service. + */ +export const securitySecretSchema: PortSchema = { + iceType: 'Security.Secret', + base: [ + { + id: 'secret-out', + direction: 'out', + role: 'secret', + label: 'Secret', + side: 'right', + shape: 'ring', + peerStyle: 'Security', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/source.ts b/packages/types/src/ports/schemas/source.ts new file mode 100644 index 00000000..60574bdc --- /dev/null +++ b/packages/types/src/ports/schemas/source.ts @@ -0,0 +1,22 @@ +import type { PortSchema } from '../types'; + +/** + * Source.Repository — provides a single `repository` output. Wiring it to + * a service writes the repo URL into the service's `repository` property + * (PROPAGATION_RULES handles `branch`, `buildCommand`, `outputDirectory` + * propagation as a side-effect). + */ +export const sourceRepositorySchema: PortSchema = { + iceType: 'Source.Repository', + base: [ + { + id: 'repository-out', + direction: 'out', + role: 'repository', + label: 'Source code', + side: 'right', + shape: 'diamond', + peerStyle: 'Source', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/util.ts b/packages/types/src/ports/schemas/util.ts new file mode 100644 index 00000000..901058a1 --- /dev/null +++ b/packages/types/src/ports/schemas/util.ts @@ -0,0 +1,29 @@ +import type { PortSchema } from '../types'; + +/** + * Util.Reroute — pass-through dot. Two `any`-role ports so wires of + * any category can flow through. Rendering is bespoke (see + * reroute-node/index.tsx) but the port schema here keeps the drag + * validation honest. + */ +export const utilRerouteSchema: PortSchema = { + iceType: 'Util.Reroute', + base: [ + { + id: 'in', + direction: 'in', + role: 'any', + label: 'Input', + side: 'left', + shape: 'circle', + }, + { + id: 'out', + direction: 'out', + role: 'any', + label: 'Output', + side: 'right', + shape: 'circle', + }, + ], +}; diff --git a/packages/types/src/ports/types.ts b/packages/types/src/ports/types.ts new file mode 100644 index 00000000..b8bd9bec --- /dev/null +++ b/packages/types/src/ports/types.ts @@ -0,0 +1,235 @@ +/** + * Port model — typed connection points anchored to block properties. + * + * A "port" is what the canvas renders as a socket dot. Unlike the + * earlier `SocketDef` (which was derived from the 4-category + * `CONNECTION_RULES`), a `PortDef` has a specific semantic role + * (domain, repository, database, http-endpoint, …) that matches its + * partner by identity. The whole point is determinism: a user looks at + * a GitHub repo block and a frontend block, sees a matching `repository` + * dot on each, and knows they snap together because the repo provides + * source code and the frontend consumes source code. + * + * Ports are AUTHORED per high-level iceType (see `./schemas/`) from + * the block's typed properties in + * `packages/core/src/resources/high-level-resources/categories/*.ts`. + * They're not generated from `CONNECTION_RULES` — that 4-category model + * stays as a coarse legality gate (used by AI, deploy, propagation). + * + * Color and shape are explicit per port, with the convention that a + * port's `peerStyle` keys into the CATEGORY_STYLE table in the UI + * layer — so a frontend's `domain-in` reads as the same rose color as + * the Custom Domain block it connects to. + */ + +import type { ConnectionCategory } from '@ice/constants'; + +/** + * Semantic role of a port. Identity-matched: an OUT port with role X + * matches an IN port with role X. No aliases — keep the model boring + * so users can predict it. + */ +export type PortRole = + /** DNS routing target. CustomDomain.out ↔ Service.in (`custom_domain` property). */ + | 'domain' + /** Source code repository reference. Repo.out ↔ Service.in (`repository` property). */ + | 'repository' + /** Environment variable bundle. EnvConfig.out ↔ Service.in (`env_vars`). */ + | 'env' + /** Secret reference (single secret or bundle). Secret.out ↔ Service.in (`secrets`). */ + | 'secret' + /** Relational database connection string. Database.out ↔ Backend.in. */ + | 'database' + /** Cache connection (Redis/Memcache). Cache.out ↔ Backend.in. */ + | 'cache' + /** + * Message queue / topic. Queue exposes queue-in (publishers connect) + * and queue-out (subscribers connect); services have the inverse — + * a publisher Service has queue-out, a subscriber has queue-in. + * Direction discriminates the role. + */ + | 'queue' + /** Object storage / bucket. Storage.out ↔ Backend.in. */ + | 'storage' + /** Search index. Search.out ↔ Backend.in. */ + | 'search' + /** Vector DB. VectorDB.out ↔ Backend.in. */ + | 'vector' + /** LLM gateway. LLM.out ↔ Backend.in. */ + | 'llm' + /** + * HTTP / TCP listener on a service. Services expose http-endpoint OUT + * (one per listener); consumers (other services, gateways) have + * http-endpoint IN to receive a URL. + */ + | 'http-endpoint' + /** Logs / metrics stream. Service.out ↔ Monitoring.in. */ + | 'monitoring' + /** Pass-through (reroute node). Accepts and emits any role. */ + | 'any'; + +export type PortDirection = 'in' | 'out'; +export type PortSide = 'left' | 'right' | 'top' | 'bottom'; +export type PortShape = 'circle' | 'ring' | 'diamond' | 'square'; +export type PortProtocol = 'http' | 'https' | 'tcp' | 'udp' | 'ssh'; + +/** + * Semantic "kind" of block this port expects on the other end. + * Identity-role + opposite-direction matching isn't strict enough on its + * own — a Backend's `queue-out` (publish) and another Backend's + * `queue-in` (subscribe) both have role='queue' with opposite + * directions, but they should NOT connect: a Backend doesn't broker + * messages to another Backend; both go through a Queue block. + * `peerKind` enforces that constraint at the port level. + */ +export type PeerKind = + | 'service' // Compute.* + AI.PrivateAIService + | 'queue' // Messaging.Queue / EventStream / Email + | 'database' // Database.PostgreSQL / MySQL / MongoDB + | 'cache' // Database.Redis + | 'storage' // Storage.Bucket + | 'repository' // Source.Repository + | 'domain' // Network.CustomDomain + | 'gateway' // Network.Gateway + | 'env' // Config.Environment + | 'secret' // Security.Secret + | 'monitoring' // Monitoring.Log + | 'vector' // AI.VectorDB + | 'llm' // AI.LLMGateway + | 'reroute' // Util.Reroute — universal passthrough + | 'any'; // wildcard — accepts every kind + +/** + * Maps iceType → PeerKind. Drives the peer-kind cross-check in + * `canPortsConnect`. Unknown iceTypes fall back to `'any'` so partial + * data never blocks a legitimate connection. + */ +export function getBlockKind(iceType: string): PeerKind { + if (iceType.startsWith('Compute.') || iceType === 'AI.PrivateAIService') return 'service'; + if (iceType.startsWith('Messaging.')) return 'queue'; + if (iceType === 'Database.Redis') return 'cache'; + if (iceType.startsWith('Database.')) return 'database'; + if (iceType.startsWith('Storage.')) return 'storage'; + if (iceType === 'Source.Repository') return 'repository'; + if (iceType === 'Network.CustomDomain') return 'domain'; + if (iceType === 'Network.Gateway') return 'gateway'; + if (iceType === 'Config.Environment') return 'env'; + if (iceType === 'Security.Secret') return 'secret'; + if (iceType.startsWith('Monitoring.') || iceType.startsWith('Log.')) return 'monitoring'; + if (iceType === 'AI.VectorDB') return 'vector'; + if (iceType === 'AI.LLMGateway') return 'llm'; + if (iceType === 'Util.Reroute') return 'reroute'; + return 'any'; +} + +export interface PortDef { + /** Unique within the block. e.g. `repository-in`, `db-out`, `http-80-out`. */ + id: string; + direction: PortDirection; + role: PortRole; + /** Tooltip-quality label: "Source code", "HTTPS :443", "Custom domain". */ + label: string; + /** Anchor side for the dot. Wire endpoint may slide via magnetic routing. */ + side: PortSide; + shape: PortShape; + /** + * For an IN port, the `node.data` key this port wires to (e.g. + * `'custom_domain'` for a frontend's domain-in). When set, accepting + * a connection writes the source's anchored value here. + */ + property?: string; + /** TCP/HTTP listener port number when `role === 'http-endpoint'`. */ + port?: number; + protocol?: PortProtocol; + /** True for ports the user added via a multi-port editor — they can remove them too. */ + removable?: boolean; + /** + * CATEGORY_STYLE key for the peer block's category — drives socket + * color. Set to `'Network'` on a frontend's domain-in so the dot + * reads as Custom Domain (rose) instead of the abstract DNS color. + */ + peerStyle?: string; + /** + * Kind of block this port expects on the other end. Critical for + * ports whose role is shared by multiple block kinds (queue: a + * Backend's `queue-out` must only connect to a Queue block, never + * another Backend). When left unset, no peer-kind check fires — + * legacy schemas remain permissive. + */ + peerKind?: PeerKind; +} + +/** A high-level port schema for a single iceType. */ +export interface PortSchema { + iceType: string; + /** Base ports that are always emitted. */ + base: PortDef[]; + /** + * Dynamic ports derived from `node.data` properties — e.g. a list of + * exposed HTTP ports on a Compute.Container. Receives the node data + * and returns extra ports to append to `base`. + */ + dynamic?: (data: Record) => PortDef[]; + /** + * Conditional removal — drop ports from `base` when a property + * predicate is true (e.g. hide `pipeline-in` until a repo is wired). + */ + hide?: Array<{ + keys: readonly string[]; + when: (data: Record) => boolean; + portIds: readonly string[]; + }>; +} + +/** Default anchor side per direction — inputs left, outputs right. */ +export const DEFAULT_PORT_SIDE: Record = { + in: 'left', + out: 'right', +}; + +/** + * Shape per role — chosen for visual distinction at a glance. + * Categories map to category shapes via `CATEGORY_SHAPE` (kept for + * backwards compat with the prior socket model), but specific roles + * can override. + */ +export const ROLE_SHAPE: Record = { + domain: 'square', + repository: 'diamond', + env: 'ring', + secret: 'ring', + database: 'circle', + cache: 'circle', + queue: 'circle', + storage: 'circle', + search: 'circle', + vector: 'circle', + llm: 'circle', + 'http-endpoint': 'circle', + monitoring: 'circle', + any: 'circle', +}; + +/** + * The connection category each role contributes to. Used to keep the + * existing `inferConnectionMeta` + propagation engine firing — when a + * user drags between two ports, the resulting edge still carries the + * right `connectionCategory` so the deploy compiler, AI prompt, and + * propagation rules all keep working unchanged. + */ +export const ROLE_CATEGORY: Record = { + domain: 'dns', + repository: 'pipeline', + env: 'config', + secret: 'config', + database: 'traffic', + cache: 'traffic', + queue: 'traffic', + storage: 'traffic', + search: 'traffic', + vector: 'traffic', + llm: 'traffic', + 'http-endpoint': 'traffic', + monitoring: 'traffic', + any: 'traffic', +}; diff --git a/packages/types/src/sockets/__tests__/derive-sockets.test.ts b/packages/types/src/sockets/__tests__/derive-sockets.test.ts new file mode 100644 index 00000000..c975327a --- /dev/null +++ b/packages/types/src/sockets/__tests__/derive-sockets.test.ts @@ -0,0 +1,194 @@ +/** + * Socket derivation tests. + * + * Covers: + * - Containers (VPC, Subnet, Group.*, PrivateNetwork) emit no sockets. + * - Default derivation walks `CONNECTION_RULES` and produces one IN/OUT + * socket per matching (direction, category) pair, deduped. + * - Schemas can ADD (conditional) and REMOVE (hide) sockets based on + * `node.data` properties — the canonical property-driven case. + * - The memo cache invalidates when a schema-declared key changes. + */ + +import { describe, expect, it, beforeEach } from 'vitest'; +import { getSocketsForNode, hasSocket, findSocket, _resetSocketCache, type NodeForSockets } from '../derive-sockets'; + +beforeEach(() => { + _resetSocketCache(); +}); + +function node(iceType: string, extra: Record = {}, type?: string): NodeForSockets { + return { id: 't', data: { iceType, ...extra }, type }; +} + +describe('containers emit no sockets', () => { + it.each(['Network.VPC', 'Network.Subnet', 'Network.PrivateNetwork', 'Group.Frontend', 'Group.Custom'])( + '%s → []', + (iceType) => { + expect(getSocketsForNode(node(iceType))).toEqual([]); + }, + ); + + it('any node whose `type` is `container` returns no sockets', () => { + expect(getSocketsForNode(node('Database.PostgreSQL', {}, 'container'))).toEqual([]); + }); + + it('missing iceType returns []', () => { + expect(getSocketsForNode({ id: 't', data: {} })).toEqual([]); + }); +}); + +describe('default derivation', () => { + it('Postgres → traffic-in (Backend → Database) and config-out (env-var)', () => { + const sockets = getSocketsForNode(node('Database.PostgreSQL')); + const ids = sockets.map((s) => s.id); + expect(ids).toContain('traffic-in'); + // Postgres is also a source for Database → Backend (reverse), which we skip, + // and Service → Config rules don't classify it as a source — so it has no + // pipeline or dns sockets, but it DOES classify as a config target via + // backend → database injecting env vars on the target side? No — config + // rules are Service → EnvVars only. So Postgres should NOT have a + // config-out by default; that comes from the meta-injection at edge + // creation time. We assert the present-by-default set: + expect(ids).toEqual(expect.arrayContaining(['traffic-in'])); + }); + + it('Backend (Compute.Container) → in + out traffic, pipeline-in, config-out', () => { + // Hide defaults to no repository/no domain, so pipeline-in and dns-in + // are suppressed by the scalable-backend schema. To see the full + // default set, use a non-Compute.Container backend type that has no schema. + const sockets = getSocketsForNode(node('Compute.Worker')); + const ids = new Set(sockets.map((s) => s.id)); + expect(ids).toContain('traffic-in'); + expect(ids).toContain('traffic-out'); + expect(ids).toContain('pipeline-in'); + expect(ids).toContain('config-out'); + }); + + it('dedupes — Backend appears in many rules but we keep one traffic-in', () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const trafficIn = sockets.filter((s) => s.id === 'traffic-in'); + expect(trafficIn).toHaveLength(1); + }); + + it('default IN sockets anchor left, OUT sockets anchor right', () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + for (const s of sockets) { + if (s.direction === 'in') expect(s.side).toBe('left'); + else expect(s.side).toBe('right'); + } + }); + + it('shape is derived from category', () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const byId = new Map(sockets.map((s) => [s.id, s])); + expect(byId.get('traffic-in')?.shape).toBe('circle'); + expect(byId.get('pipeline-in')?.shape).toBe('diamond'); + expect(byId.get('config-out')?.shape).toBe('ring'); + }); +}); + +describe('property-driven schemas', () => { + it('Postgres replication=true adds replica-out; replication=false does not', () => { + const withRep = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + const withoutRep = getSocketsForNode(node('Database.PostgreSQL', { replication: false })); + expect(withRep.some((s) => s.id === 'replica-out')).toBe(true); + expect(withoutRep.some((s) => s.id === 'replica-out')).toBe(false); + }); + + it('Compute.Container hides pipeline-in until a repository is set', () => { + const noRepo = getSocketsForNode(node('Compute.Container')); + const withRepo = getSocketsForNode(node('Compute.Container', { repository: 'org/repo' })); + expect(noRepo.some((s) => s.id === 'pipeline-in')).toBe(false); + expect(withRepo.some((s) => s.id === 'pipeline-in')).toBe(true); + }); + + it('Compute.Container hides dns-in until a domain is configured', () => { + const noDomain = getSocketsForNode(node('Compute.Container', { repository: 'org/repo' })); + const withDomain = getSocketsForNode( + node('Compute.Container', { repository: 'org/repo', domain: 'app.example.com' }), + ); + expect(noDomain.some((s) => s.id === 'dns-in')).toBe(false); + expect(withDomain.some((s) => s.id === 'dns-in')).toBe(true); + }); + + it('Compute.StaticSite hides dns-in until custom_domain is set', () => { + const off = getSocketsForNode(node('Compute.StaticSite')); + const on = getSocketsForNode(node('Compute.StaticSite', { custom_domain: 'shop.example.com' })); + expect(off.some((s) => s.id === 'dns-in')).toBe(false); + expect(on.some((s) => s.id === 'dns-in')).toBe(true); + }); +}); + +describe('peer-style coloring', () => { + it("a frontend's dns-in carries peerStyle='Network' so the dot reads as Custom Domain", () => { + const sockets = getSocketsForNode(node('Compute.StaticSite', { custom_domain: 'shop.example.com' })); + const dnsIn = sockets.find((s) => s.id === 'dns-in'); + expect(dnsIn).toBeDefined(); + expect(dnsIn!.peerStyle).toBe('Network'); + }); + + it("a service's pipeline-in carries peerStyle='Source'", () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const pipelineIn = sockets.find((s) => s.id === 'pipeline-in'); + expect(pipelineIn).toBeDefined(); + expect(pipelineIn!.peerStyle).toBe('Source'); + }); + + it("a service's config-out carries peerStyle='Config'", () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const configOut = sockets.find((s) => s.id === 'config-out'); + expect(configOut).toBeDefined(); + expect(configOut!.peerStyle).toBe('Config'); + }); + + it('traffic sockets DO NOT carry a peer style — too many possible peer types', () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const trafficIn = sockets.find((s) => s.id === 'traffic-in'); + const trafficOut = sockets.find((s) => s.id === 'traffic-out'); + expect(trafficIn?.peerStyle).toBeUndefined(); + expect(trafficOut?.peerStyle).toBeUndefined(); + }); + + it("Postgres's replica-out (schema-authored) carries peerStyle='Compute'", () => { + const sockets = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + const replicaOut = sockets.find((s) => s.id === 'replica-out'); + expect(replicaOut?.peerStyle).toBe('Compute'); + }); +}); + +describe('memoization', () => { + it('returns equal arrays for repeated calls with the same data', () => { + const a = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + const b = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + expect(a).toBe(b); + }); + + it('invalidates when a schema-declared key changes', () => { + const off = getSocketsForNode(node('Database.PostgreSQL', { replication: false })); + const on = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + expect(off).not.toBe(on); + expect(on.some((s) => s.id === 'replica-out')).toBe(true); + }); + + it('ignores data keys that no schema reads', () => { + // `description` is not declared by any schema → same cache entry. + const a = getSocketsForNode(node('Database.PostgreSQL', { description: 'one' })); + const b = getSocketsForNode(node('Database.PostgreSQL', { description: 'two' })); + expect(a).toBe(b); + }); +}); + +describe('hasSocket / findSocket', () => { + it('hasSocket reflects the current schema state', () => { + const n = node('Database.PostgreSQL', { replication: true }); + expect(hasSocket(n, 'replica-out')).toBe(true); + expect(hasSocket(n, 'nonexistent')).toBe(false); + }); + + it('findSocket returns the SocketDef or undefined', () => { + const n = node('Database.PostgreSQL'); + expect(findSocket(n, 'traffic-in')?.category).toBe('traffic'); + expect(findSocket(n, 'replica-out')).toBeUndefined(); + }); +}); diff --git a/packages/types/src/sockets/derive-sockets.ts b/packages/types/src/sockets/derive-sockets.ts new file mode 100644 index 00000000..1c9d40d6 --- /dev/null +++ b/packages/types/src/sockets/derive-sockets.ts @@ -0,0 +1,206 @@ +/** + * Socket derivation. + * + * `getSocketsForNode(node)` produces the typed socket list for a block, + * combining (1) the default sockets derived from `CONNECTION_RULES` + * with (2) any per-block `SocketSchema` adjustments (additions via + * `base` / `conditional`, removals via `hide`). + * + * Socket geometry tracks block properties — toggling `data.replication` + * on Postgres adds or removes the `replica-out` socket on the next + * render. Edges already attached to a socket that's no longer present + * are not auto-deleted; they enter the "dangling" state for the user + * to clean up explicitly. + * + * Containers (VPC, Subnet, Group, PrivateNetwork) emit zero sockets — + * children attach via `parentId`, not via wires. + */ + +import { getSchema } from './schemas'; +import { CATEGORY_SHAPE, DEFAULT_SIDE, type SocketDef } from './types'; +import { isContainer } from '../connection-rules/predicates'; +import { CONNECTION_RULES } from '../connection-rules/rules-data'; +import type { SocketSchema } from './socket-schema'; +import type { NodeForConnectionCheck } from '../connection-rules/types'; +import type { ConnectionCategory } from '@ice/constants'; + +/** Identifier for a default-derived socket: `-`. */ +function defaultSocketId(category: ConnectionCategory, direction: 'in' | 'out'): string { + return `${category}-${direction}`; +} + +function defaultLabel(category: ConnectionCategory, direction: 'in' | 'out'): string { + const dir = direction === 'in' ? 'input' : 'output'; + return `${category.charAt(0).toUpperCase()}${category.slice(1)} ${dir}`; +} + +/** + * Peer-block-category accent for a default-derived socket, so the dot + * reads as "the thing on the other end" rather than the abstract wire + * category. Per the user's request: a Frontend's dns-in socket should + * be the Custom Domain (Network) color, not the generic DNS color. + * + * Only the unambiguous cases are mapped — TRAFFIC (which can connect + * to many block types depending on direction and source) stays on the + * abstract category color. + */ +function defaultPeerStyle(category: ConnectionCategory, direction: 'in' | 'out'): string | undefined { + // DNS edges are always Custom Domain ↔ Routable. From a Routable's + // perspective the peer is a Domain (Network family). + if (category === 'dns' && direction === 'in') return 'Network'; + // PIPELINE edges are always Repo → Service. From a Service the peer + // is a Source.Repository. + if (category === 'pipeline' && direction === 'in') return 'Source'; + // CONFIG edges are always Service → EnvConfig/Secrets. From a Service + // the peer is a Config block. + if (category === 'config' && direction === 'out') return 'Config'; + return undefined; +} + +/** + * Walk `CONNECTION_RULES` and emit one socket per matching + * (direction, category) pair. Reverse rules are skipped — they're a + * drag-direction convenience, not a separate socket. Deduped by id. + */ +function deriveDefaultSockets(iceType: string): SocketDef[] { + const seen = new Set(); + const out: SocketDef[] = []; + for (const rule of CONNECTION_RULES) { + if (rule.reverse) continue; + if (rule.source(iceType)) { + const id = defaultSocketId(rule.category, 'out'); + if (!seen.has(id)) { + seen.add(id); + const peerStyle = defaultPeerStyle(rule.category, 'out'); + out.push({ + id, + side: DEFAULT_SIDE.out, + category: rule.category, + direction: 'out', + label: defaultLabel(rule.category, 'out'), + shape: CATEGORY_SHAPE[rule.category], + multi: true, + ...(peerStyle && { peerStyle }), + }); + } + } + if (rule.target(iceType)) { + const id = defaultSocketId(rule.category, 'in'); + if (!seen.has(id)) { + seen.add(id); + const peerStyle = defaultPeerStyle(rule.category, 'in'); + out.push({ + id, + side: DEFAULT_SIDE.in, + category: rule.category, + direction: 'in', + label: defaultLabel(rule.category, 'in'), + shape: CATEGORY_SHAPE[rule.category], + multi: rule.category !== 'config', + ...(peerStyle && { peerStyle }), + }); + } + } + } + return out; +} + +function applySchema( + sockets: SocketDef[], + schema: SocketSchema | undefined, + data: Record, +): SocketDef[] { + if (!schema) return sockets; + let result = schema.replaceBase ? [] : [...sockets]; + if (schema.base?.length) result.push(...schema.base); + if (schema.conditional) { + for (const cond of schema.conditional) { + if (cond.when(data)) result.push(...cond.sockets); + } + } + if (schema.hide) { + for (const hide of schema.hide) { + if (hide.when(data)) { + const ids = new Set(hide.socketIds); + result = result.filter((s) => !ids.has(s.id)); + } + } + } + // Final dedupe by id (later wins on conflict). + const byId = new Map(); + for (const s of result) byId.set(s.id, s); + return Array.from(byId.values()); +} + +// ─── Memoization ──────────────────────────────────────────────────────────── + +/** + * Memo cache keyed by (iceType, comma-joined values of the keys read by + * any conditional/hide in the schema). For blocks with no schema, the + * cache key is just the iceType — they have no property-dependent + * branches. + */ +const cache = new Map(); + +function cacheKey(iceType: string, schema: SocketSchema | undefined, data: Record): string { + if (!schema) return iceType; + const keys = new Set(); + for (const c of schema.conditional ?? []) c.keys.forEach((k) => keys.add(k)); + for (const h of schema.hide ?? []) h.keys.forEach((k) => keys.add(k)); + const parts: string[] = [iceType]; + for (const k of Array.from(keys).sort()) { + parts.push(`${k}=${JSON.stringify(data[k] ?? null)}`); + } + return parts.join('|'); +} + +/** Test helper — clears the memo cache. Don't use in production code paths. */ +export function _resetSocketCache(): void { + cache.clear(); +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * The minimal node shape needed for socket derivation: an iceType + * (or container `type`) and the property bag. + */ +export interface NodeForSockets extends NodeForConnectionCheck { + data?: Record; +} + +/** + * Returns the ordered socket list for a node. Empty for containers, + * for nodes without an iceType, and for nodes whose iceType doesn't + * appear as either source or target in any `CONNECTION_RULES` entry. + */ +export function getSocketsForNode(node: NodeForSockets): SocketDef[] { + const data = node.data ?? {}; + const iceType = typeof data.iceType === 'string' ? data.iceType : ''; + if (!iceType) return []; + if (isContainer(iceType, node.type)) return []; + + const schema = getSchema(iceType); + const key = cacheKey(iceType, schema, data); + const cached = cache.get(key); + if (cached) return cached; + + const defaults = deriveDefaultSockets(iceType); + const result = applySchema(defaults, schema, data); + cache.set(key, result); + return result; +} + +/** + * Returns true if `socketId` exists on the node's current socket list. + * Wire-rendering uses this to flag dangling edges whose anchor socket + * has been removed by a property change. + */ +export function hasSocket(node: NodeForSockets, socketId: string): boolean { + return getSocketsForNode(node).some((s) => s.id === socketId); +} + +/** Lookup helper for the render layer. */ +export function findSocket(node: NodeForSockets, socketId: string): SocketDef | undefined { + return getSocketsForNode(node).find((s) => s.id === socketId); +} diff --git a/packages/types/src/sockets/index.ts b/packages/types/src/sockets/index.ts new file mode 100644 index 00000000..15acec94 --- /dev/null +++ b/packages/types/src/sockets/index.ts @@ -0,0 +1,17 @@ +/** + * Sockets — typed connection points on blocks. + * + * Public surface: types (`SocketDef`, `SocketSide`, `SocketDirection`, + * `SocketShape`), schema (`SocketSchema`, `SocketConditional`, + * `SocketHide`), and the derivation entrypoints + * (`getSocketsForNode`, `hasSocket`, `findSocket`). + * + * Schemas themselves are an implementation detail — consumers should + * never import from `./schemas/*` directly; they should ask the + * derivation API for sockets and trust it to consult the schema + * registry. + */ + +export * from './types'; +export type { SocketSchema, SocketConditional, SocketHide } from './socket-schema'; +export { getSocketsForNode, hasSocket, findSocket, _resetSocketCache, type NodeForSockets } from './derive-sockets'; diff --git a/packages/types/src/sockets/schemas/index.ts b/packages/types/src/sockets/schemas/index.ts new file mode 100644 index 00000000..25eb53b7 --- /dev/null +++ b/packages/types/src/sockets/schemas/index.ts @@ -0,0 +1,30 @@ +/** + * Socket schema registry. + * + * Maps iceType → `SocketSchema`. Schemas live here (not next to block + * blueprints in `@ice/blocks`) so `@ice/types` can derive sockets + * without a circular dependency on `@ice/blocks`. + * + * Most blocks need no entry — the default derivation in `derive-sockets.ts` + * already walks `CONNECTION_RULES` and emits one IN/OUT socket per matching + * (direction, category) pair. A schema is only needed when sockets + * depend on the block's *properties* — e.g. Postgres exposes a + * `replica-out` socket only when `data.replication === true`. + */ + +import { postgresSchema } from './postgres'; +import { scalableBackendSchema } from './scalable-backend'; +import { staticSiteSchema } from './static-site'; +import type { SocketSchema } from '../socket-schema'; + +export const SOCKET_SCHEMAS: Record = { + [postgresSchema.iceType]: postgresSchema, + [scalableBackendSchema.iceType]: scalableBackendSchema, + [staticSiteSchema.iceType]: staticSiteSchema, +}; + +export function getSchema(iceType: string): SocketSchema | undefined { + return SOCKET_SCHEMAS[iceType]; +} + +export { postgresSchema, scalableBackendSchema, staticSiteSchema }; diff --git a/packages/types/src/sockets/schemas/postgres.ts b/packages/types/src/sockets/schemas/postgres.ts new file mode 100644 index 00000000..2706e130 --- /dev/null +++ b/packages/types/src/sockets/schemas/postgres.ts @@ -0,0 +1,33 @@ +import type { SocketSchema } from '../socket-schema'; + +/** + * Postgres exposes a read-only replica output ONLY when the user has + * turned on replication in the properties panel. Without it, the socket + * is absent and edges that previously attached to it enter the dangling + * state until cleaned up. + * + * `base` is empty — the default derivation already produces the standard + * traffic-in (Backend → Database) and config-out (env-var injection) + * sockets from `CONNECTION_RULES`. The schema only adds the conditional. + */ +export const postgresSchema: SocketSchema = { + iceType: 'Database.PostgreSQL', + conditional: [ + { + keys: ['replication'], + when: (data) => data.replication === true, + sockets: [ + { + id: 'replica-out', + side: 'right', + category: 'traffic', + direction: 'out', + label: 'Read replica', + shape: 'circle', + // Replica-out peers with backends/services that read it — color by Compute. + peerStyle: 'Compute', + }, + ], + }, + ], +}; diff --git a/packages/types/src/sockets/schemas/scalable-backend.ts b/packages/types/src/sockets/schemas/scalable-backend.ts new file mode 100644 index 00000000..2e505e8c --- /dev/null +++ b/packages/types/src/sockets/schemas/scalable-backend.ts @@ -0,0 +1,27 @@ +import type { SocketSchema } from '../socket-schema'; + +/** + * Scalable backend (`Compute.Container`) gains a `pipeline-in` socket + * only when the user has connected/configured a repository — until then, + * the pipeline socket is noise. Similarly a `dns-in` socket appears only + * when the block is set to receive public traffic via a domain. + * + * `hide` removes the default pipeline-in derived from `Repo → Service` + * when no repository is configured, so the block doesn't dangle an + * unused socket. + */ +export const scalableBackendSchema: SocketSchema = { + iceType: 'Compute.Container', + hide: [ + { + keys: ['repository'], + when: (data) => !data.repository, + socketIds: ['pipeline-in'], + }, + { + keys: ['domain', 'custom_domain'], + when: (data) => !data.domain && !data.custom_domain, + socketIds: ['dns-in'], + }, + ], +}; diff --git a/packages/types/src/sockets/schemas/static-site.ts b/packages/types/src/sockets/schemas/static-site.ts new file mode 100644 index 00000000..8402fa0d --- /dev/null +++ b/packages/types/src/sockets/schemas/static-site.ts @@ -0,0 +1,17 @@ +import type { SocketSchema } from '../socket-schema'; + +/** + * Static site exposes a `dns-in` socket only when the user has opted in + * to a custom domain; otherwise the block defaults to its provider- + * managed URL and the DNS socket is hidden. + */ +export const staticSiteSchema: SocketSchema = { + iceType: 'Compute.StaticSite', + hide: [ + { + keys: ['custom_domain', 'domain'], + when: (data) => !data.custom_domain && !data.domain, + socketIds: ['dns-in'], + }, + ], +}; diff --git a/packages/types/src/sockets/socket-schema.ts b/packages/types/src/sockets/socket-schema.ts new file mode 100644 index 00000000..90f66594 --- /dev/null +++ b/packages/types/src/sockets/socket-schema.ts @@ -0,0 +1,54 @@ +/** + * Socket schema — declarative shape of a block's sockets. + * + * Schemas are property-aware: they declare `base` sockets that are always + * emitted, plus `conditional` and `hide` rules that add or remove sockets + * based on `node.data` predicates. The point is that **socket geometry + * tracks block properties**, the way Blender's Mix / Sample Curve nodes + * grow and shrink their socket lists as the user changes the node's mode. + * + * If a block has no schema entry, the derivation falls back to walking + * `CONNECTION_RULES` and emitting one IN/OUT socket per matching + * (direction, category) pair — see `derive-sockets.ts`. + */ + +import type { SocketDef } from './types'; + +/** + * A conditional gate. The `keys` array is load-bearing: it lists the + * `node.data` keys the `when` predicate reads, which the memoizer uses + * to build a stable cache key without serializing the whole data object. + * + * Adding a new key the predicate reads but forgetting to list it here + * is a correctness bug — the cache will return stale sockets when that + * key flips. Add a unit test that toggles the key and asserts the + * socket list changes. + */ +export interface SocketConditional { + keys: readonly string[]; + when: (data: Record) => boolean; + sockets: SocketDef[]; +} + +/** Same shape as `SocketConditional` but the gate suppresses sockets by id. */ +export interface SocketHide { + keys: readonly string[]; + when: (data: Record) => boolean; + socketIds: readonly string[]; +} + +export interface SocketSchema { + iceType: string; + /** + * If true, ignore the default `CONNECTION_RULES`-driven derivation and + * use only `base` + conditionals. Use sparingly — most blocks should + * augment the defaults, not replace them. + */ + replaceBase?: boolean; + /** Always-emitted sockets. Appended to the default derivation when `replaceBase` is false. */ + base?: SocketDef[]; + /** Sockets emitted only when the gate passes. */ + conditional?: SocketConditional[]; + /** Default-derived or base sockets removed when the gate passes. */ + hide?: SocketHide[]; +} diff --git a/packages/types/src/sockets/types.ts b/packages/types/src/sockets/types.ts new file mode 100644 index 00000000..4d347899 --- /dev/null +++ b/packages/types/src/sockets/types.ts @@ -0,0 +1,78 @@ +/** + * Socket type surface. + * + * A `SocketDef` describes one typed connection point on a block — what + * category of wire it carries (TRAFFIC / PIPELINE / CONFIG / DNS), which + * way wires flow (in / out), and where on the block surface it lives by + * default. The actual wire-attach point is allowed to slide along the + * `side` perimeter at render time ("magnetic" routing), but the visible + * socket dot stays anchored where this type says it does. + * + * Inputs default to the left, outputs to the right — Blender Geometry + * Nodes convention. Schemas may override `side` for blocks where a + * different anchor reads more naturally (e.g. a top-anchored DNS input + * on a frontend block). + */ + +import type { ConnectionCategory } from '@ice/constants'; + +/** Side of the block where this socket's dot is anchored. */ +export type SocketSide = 'left' | 'right' | 'top' | 'bottom'; + +/** Direction of data flow through the socket. */ +export type SocketDirection = 'in' | 'out'; + +/** + * Visual shape of the socket dot. One shape per category so the canvas + * reads at a glance: + * - circle → traffic + * - ring → config + * - diamond → pipeline + * - square → dns + */ +export type SocketShape = 'circle' | 'ring' | 'diamond' | 'square'; + +export interface SocketDef { + /** Stable identifier, persisted on `CardEdge.data.sourceSocket` / `targetSocket`. */ + id: string; + /** Default anchor side. Render layer may slide the actual attach point along this side. */ + side: SocketSide; + /** Wire category — drives color via `CATEGORY_COLORS` + connection-rule match. */ + category: ConnectionCategory; + /** in = receives wires; out = emits wires. */ + direction: SocketDirection; + /** Tooltip / accessibility label. */ + label: string; + /** Visual shape; usually derived from category but overridable. */ + shape: SocketShape; + /** True if this socket accepts more than one edge. Default: false (single). */ + multi?: boolean; + /** + * Override the socket dot's color with the peer block's category + * accent. Set to a CATEGORY_STYLE key like `'Network'`, `'Source'`, + * `'Config'` so a frontend's dns-in reads as "Custom Domain" (rose) + * instead of the abstract DNS color (cyan). Falls back to + * `CATEGORY_COLORS[category]` when unset. + */ + peerStyle?: string; + /** + * Optional peer block iceType for tooltip / discovery — "this socket + * connects to a Custom Domain." Not load-bearing for routing; purely + * for the hover chip and future affordances. + */ + peerIceType?: string; +} + +/** Default shape per category. */ +export const CATEGORY_SHAPE: Record = { + traffic: 'circle', + pipeline: 'diamond', + config: 'ring', + dns: 'square', +}; + +/** Default anchor side per direction. */ +export const DEFAULT_SIDE: Record = { + in: 'left', + out: 'right', +}; diff --git a/packages/ui/src/features/canvas/components/__tests__/connection-preview-overlay.test.tsx b/packages/ui/src/features/canvas/components/__tests__/connection-preview-overlay.test.tsx index 9fb3c949..e2b0bf93 100644 --- a/packages/ui/src/features/canvas/components/__tests__/connection-preview-overlay.test.tsx +++ b/packages/ui/src/features/canvas/components/__tests__/connection-preview-overlay.test.tsx @@ -1,59 +1,29 @@ /** - * rf-canv-14 — `ConnectionPreviewOverlay` subcomponent. + * `ConnectionPreviewOverlay` tests — new socket-to-socket behavior. * - * `ConnectionPreviewOverlay` is a presentational FC that wraps the JSX shell - * for the in-flight connection drag preview (a cubic-bezier `` from - * source port to current cursor, plus two anchor ``s). The bezier - * math (`computeConnectionPreviewPath`) and the color picker - * (`pickPreviewColor`) live in `../utils/connection-preview` and are tested - * exhaustively by `utils/__tests__/connection-preview.test.ts` (rf-canv-8). - * This suite mocks both helpers so the assertions exercise ONLY the new - * component's behavior — the JSX shell + the prop-forwarding contract — and - * don't redundantly retest the rf-canv-8 utils. - * - * Direct-FC tree-walker pattern (cite - * `tree-walker-for-react-fc-tests-must-flatten-nested-children-arrays`): - * invoke the component as a function, then walk the returned React-element - * tree depth-first and assert on type / props / children. + * The overlay now renders the in-flight preview ONLY when the magnet + * has snapped to a compatible target port. Without a snap the preview + * is `null` — the source-socket pulse + per-port halos elsewhere on + * the canvas carry the feedback. This matches the "connections are + * socket ↔ socket only" UX standard. */ import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { CanvasNode } from '../types'; - -// ─── Mock the rf-canv-8 utils so we exercise only the JSX shell ────────────── +import { describe, it, expect, beforeEach } from 'vitest'; -const mocks = vi.hoisted(() => ({ - computeConnectionPreviewPath: vi.fn< - (sourcePoint: { x: number; y: number }, currentPoint: { x: number; y: number }) => string - >(() => 'M 0 0 C 0 0, 0 0, 0 0'), - pickPreviewColor: vi.fn< - ( - currentPoint: { x: number; y: number }, - effectiveNodes: CanvasNode[], - sourceId: string, - dragTargets: Map | null | undefined, - ) => string - >(() => '#22d3ee'), -})); -vi.mock('../../utils/connection-preview', () => ({ - computeConnectionPreviewPath: mocks.computeConnectionPreviewPath, - pickPreviewColor: mocks.pickPreviewColor, -})); - -// Import AFTER vi.mock so the mocked module is bound. import { ConnectionPreviewOverlay, type ConnectionPreviewOverlayProps } from '../connection-preview-overlay'; +import { + ConnectionDragProvider, + _resetConnectionDragInfo, + type ConnectionDragInfo, +} from '../nodes/_shared/connection-drag-context'; -// ─── Tree-walker (same shape as rf-canv-10/11/12/13) ───────────────────────── - -type ReactNodeLike = React.ReactNode; +// ─── Tree-walker — same shape as the other rf-canv-* tests ─────────────────── -function* walk(node: ReactNodeLike): Generator { - if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') { - return; - } +function* walk(node: React.ReactNode): Generator { + if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') return; if (Array.isArray(node)) { - for (const c of node) yield* walk(c as ReactNodeLike); + for (const c of node) yield* walk(c as React.ReactNode); return; } const el = node as React.ReactElement; @@ -63,18 +33,14 @@ function* walk(node: ReactNodeLike): Generator { yield* walk(children); } -function findByPredicate(tree: React.ReactNode, predicate: (el: React.ReactElement) => boolean): React.ReactElement[] { +function findByType(tree: React.ReactNode, type: unknown): React.ReactElement[] { const out: React.ReactElement[] = []; for (const el of walk(tree)) { - if (el && predicate(el)) out.push(el); + if (el && el.type === type) out.push(el); } return out; } -function findByType(tree: React.ReactNode, type: unknown): React.ReactElement[] { - return findByPredicate(tree, (el) => el.type === type); -} - // ─── Helpers ───────────────────────────────────────────────────────────────── const baseProps = (overrides: Partial = {}): ConnectionPreviewOverlayProps => ({ @@ -88,226 +54,83 @@ const baseProps = (overrides: Partial = {}): Conn ...overrides, }); -const render = (overrides: Partial = {}) => - ConnectionPreviewOverlay(baseProps(overrides)); +/** Renders the overlay as a plain function, optionally seeding the drag context first. */ +function render( + overrides: Partial = {}, + dragInfo: ConnectionDragInfo | null = null, +): React.ReactNode { + // Seed the singleton via the provider's render so getConnectionDragInfo + // sees the value when the overlay calls it. + ConnectionDragProvider({ value: dragInfo, children: null }); + return ConnectionPreviewOverlay(baseProps(overrides)); +} -// Reset the mocks before each test so call-args assertions are clean. beforeEach(() => { - mocks.computeConnectionPreviewPath.mockClear(); - mocks.pickPreviewColor.mockClear(); - mocks.computeConnectionPreviewPath.mockReturnValue('M 0 0 C 0 0, 0 0, 0 0'); - mocks.pickPreviewColor.mockReturnValue('#22d3ee'); + _resetConnectionDragInfo(); }); // ═══════════════════════════════════════════════════════════════════════════ -// Outer wrap (className + pointer-events) +// No snap → no preview // ═══════════════════════════════════════════════════════════════════════════ -describe('ConnectionPreviewOverlay — outer wrap', () => { - it('renders ', () => { - const tree = render(); - const wraps = findByPredicate( - tree, - (el) => el.type === 'g' && (el.props as { className?: string }).className === 'connection-preview', - ); - expect(wraps).toHaveLength(1); - const style = (wraps[0].props as { style?: React.CSSProperties }).style; - expect(style?.pointerEvents).toBe('none'); +describe('ConnectionPreviewOverlay — no snap target', () => { + it('returns null when there is no active drag info (rest state)', () => { + const tree = render({}, null); + expect(tree).toBeNull(); }); -}); -// ═══════════════════════════════════════════════════════════════════════════ -// Util forwarding -// ═══════════════════════════════════════════════════════════════════════════ - -describe('ConnectionPreviewOverlay — forwards args to rf-canv-8 utils', () => { - it('calls computeConnectionPreviewPath with (sourcePoint, currentPoint)', () => { - render({ - drawingConnection: { - sourceId: 'src', - sourcePoint: { x: 11, y: 22 }, - currentPoint: { x: 111, y: 222 }, - }, - }); - expect(mocks.computeConnectionPreviewPath).toHaveBeenCalledTimes(1); - expect(mocks.computeConnectionPreviewPath).toHaveBeenCalledWith({ x: 11, y: 22 }, { x: 111, y: 222 }); - }); - - it('calls pickPreviewColor with (currentPoint, effectiveNodes, sourceId, dragTargets)', () => { - const nodes: CanvasNode[] = [ + it("returns null when a drag is in progress but the cursor isn't on a compatible port", () => { + const tree = render( + {}, { - id: 'n1', - type: 'block', - x: 0, - y: 0, - width: 50, - height: 50, - label: 'n', - data: {}, - }, - ]; - const targets = new Map([['n1', 'valid-target']]); - render({ - drawingConnection: { - sourceId: 'src-id', - sourcePoint: { x: 0, y: 0 }, - currentPoint: { x: 333, y: 444 }, + sourceNodeId: 'src', + sourcePortId: 'env-out', + compatibleByNode: new Map([['tgt', new Set(['env-in'])]]), + snap: null, }, - effectiveNodes: nodes, - connectionDragTargets: targets, - }); - expect(mocks.pickPreviewColor).toHaveBeenCalledTimes(1); - expect(mocks.pickPreviewColor).toHaveBeenCalledWith({ x: 333, y: 444 }, nodes, 'src-id', targets); - }); - - it('threads a null dragTargets through verbatim (no defaulting in the shell)', () => { - render({ connectionDragTargets: null }); - const args = mocks.pickPreviewColor.mock.calls[0]; - expect(args[3]).toBeNull(); + ); + expect(tree).toBeNull(); }); }); // ═══════════════════════════════════════════════════════════════════════════ -// element +// Snap → solid socket-to-socket line // ═══════════════════════════════════════════════════════════════════════════ -describe('ConnectionPreviewOverlay — element', () => { - it('renders one with d = computeConnectionPreviewPath return value', () => { - mocks.computeConnectionPreviewPath.mockReturnValue('M 1 2 C 3 4, 5 6, 7 8'); - const tree = render(); - const paths = findByType(tree, 'path'); - expect(paths).toHaveLength(1); - const props = paths[0].props as { - d: string; - stroke: string; - strokeWidth: number; - fill: string; - strokeDasharray: string; - opacity: number; - }; - expect(props.d).toBe('M 1 2 C 3 4, 5 6, 7 8'); - }); - - it('uses the previewColor returned by pickPreviewColor as the path stroke', () => { - mocks.pickPreviewColor.mockReturnValue('#abcdef'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - expect((path.props as { stroke: string }).stroke).toBe('#abcdef'); +describe('ConnectionPreviewOverlay — snapped to target', () => { + const snappedInfo: ConnectionDragInfo = { + sourceNodeId: 'src', + sourcePortId: 'env-out', + compatibleByNode: new Map([['tgt', new Set(['env-in'])]]), + snap: { nodeId: 'tgt', portId: 'env-in' }, + }; + + it('renders an outer wrapper', () => { + const tree = render({}, snappedInfo); + const wraps = findByType(tree, 'g'); + expect(wraps).toHaveLength(1); + expect((wraps[0].props as { className?: string }).className).toBe('connection-preview'); }); - it('pins the verbatim path props: strokeWidth=2, fill="none", strokeDasharray="8 4", opacity=0.7', () => { - const tree = render(); - const path = findByType(tree, 'path')[0]; - const props = path.props as { - strokeWidth: number; - fill: string; - strokeDasharray: string; - opacity: number; - }; - expect(props.strokeWidth).toBe(2); + it('renders exactly one (no dashes — socket-to-socket is a solid promise)', () => { + const tree = render({}, snappedInfo); + const paths = findByType(tree, 'path'); + expect(paths).toHaveLength(1); + const props = paths[0].props as { fill: string; stroke: string; strokeDasharray?: string }; expect(props.fill).toBe('none'); - expect(props.strokeDasharray).toBe('8 4'); - expect(props.opacity).toBe(0.7); + expect(props.stroke).toBe('#22c55e'); + expect(props.strokeDasharray).toBeUndefined(); }); -}); -// ═══════════════════════════════════════════════════════════════════════════ -// elements (anchors) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('ConnectionPreviewOverlay — anchor elements', () => { - it('renders exactly two elements', () => { - const tree = render(); + it('renders two anchor circles (one at source, one at snapped endpoint)', () => { + const tree = render({}, snappedInfo); const circles = findByType(tree, 'circle'); expect(circles).toHaveLength(2); }); - it('first circle anchors the source: cx/cy = sourcePoint, r=4, opacity=0.9', () => { - const tree = render({ - drawingConnection: { - sourceId: 'src', - sourcePoint: { x: 50, y: 60 }, - currentPoint: { x: 500, y: 600 }, - }, - }); - const circles = findByType(tree, 'circle'); - const props = circles[0].props as { - cx: number; - cy: number; - r: number; - opacity: number; - }; - expect(props.cx).toBe(50); - expect(props.cy).toBe(60); - expect(props.r).toBe(4); - expect(props.opacity).toBe(0.9); - }); - - it('second circle anchors the cursor: cx/cy = currentPoint, r=4, opacity=0.6', () => { - const tree = render({ - drawingConnection: { - sourceId: 'src', - sourcePoint: { x: 50, y: 60 }, - currentPoint: { x: 500, y: 600 }, - }, - }); - const circles = findByType(tree, 'circle'); - const props = circles[1].props as { - cx: number; - cy: number; - r: number; - opacity: number; - }; - expect(props.cx).toBe(500); - expect(props.cy).toBe(600); - expect(props.r).toBe(4); - expect(props.opacity).toBe(0.6); - }); - - it('both circles share the same fill color (the previewColor)', () => { - mocks.pickPreviewColor.mockReturnValue('#deadbe'); - const tree = render(); - const circles = findByType(tree, 'circle'); - expect((circles[0].props as { fill: string }).fill).toBe('#deadbe'); - expect((circles[1].props as { fill: string }).fill).toBe('#deadbe'); - }); - - it('the path stroke and the circle fills all share the same color', () => { - mocks.pickPreviewColor.mockReturnValue('#22c55e'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - const circles = findByType(tree, 'circle'); - expect((path.props as { stroke: string }).stroke).toBe('#22c55e'); - expect((circles[0].props as { fill: string }).fill).toBe('#22c55e'); - expect((circles[1].props as { fill: string }).fill).toBe('#22c55e'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Color routing — verifies the JSX shell uses the picker's return value -// (a single source of truth — the picker is mocked, the shell never decides -// the color itself) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('ConnectionPreviewOverlay — color is sourced from pickPreviewColor', () => { - it('renders the cyan default when picker returns the cyan default', () => { - mocks.pickPreviewColor.mockReturnValue('#22d3ee'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - expect((path.props as { stroke: string }).stroke).toBe('#22d3ee'); - }); - - it('renders the green valid-target color when picker returns it', () => { - mocks.pickPreviewColor.mockReturnValue('#22c55e'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - expect((path.props as { stroke: string }).stroke).toBe('#22c55e'); - }); - - it('renders the red invalid-target color when picker returns it', () => { - mocks.pickPreviewColor.mockReturnValue('#ef4444'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - expect((path.props as { stroke: string }).stroke).toBe('#ef4444'); + it('disables pointer-events on the preview so it never blocks the drag', () => { + const tree = render({}, snappedInfo); + const wrap = findByType(tree, 'g')[0]; + expect((wrap.props as { style?: React.CSSProperties }).style?.pointerEvents).toBe('none'); }); }); diff --git a/packages/ui/src/features/canvas/components/__tests__/svg-canvas.test.tsx b/packages/ui/src/features/canvas/components/__tests__/svg-canvas.test.tsx index 8178412f..4baed078 100644 --- a/packages/ui/src/features/canvas/components/__tests__/svg-canvas.test.tsx +++ b/packages/ui/src/features/canvas/components/__tests__/svg-canvas.test.tsx @@ -141,6 +141,7 @@ const components = vi.hoisted(() => ({ ConnectionTooltip: vi.fn(() => null), CanvasDeployBanner: vi.fn(() => null), CanvasContent: vi.fn(() => null), + SocketHoverTooltip: vi.fn(() => null), })); const dispatchSpy = vi.fn(); @@ -178,6 +179,9 @@ vi.mock('../controls-help-modal', () => ({ vi.mock('../connection-tooltip', () => ({ ConnectionTooltip: components.ConnectionTooltip, })); +vi.mock('../nodes/_shared/socket-hover-tooltip', () => ({ + SocketHoverTooltip: components.SocketHoverTooltip, +})); vi.mock('../deploy-banner', () => ({ CanvasDeployBanner: components.CanvasDeployBanner, })); diff --git a/packages/ui/src/features/canvas/components/__tests__/svg-connection-path.test.tsx b/packages/ui/src/features/canvas/components/__tests__/svg-connection-path.test.tsx index 4713910f..3bc7cdfc 100644 --- a/packages/ui/src/features/canvas/components/__tests__/svg-connection-path.test.tsx +++ b/packages/ui/src/features/canvas/components/__tests__/svg-connection-path.test.tsx @@ -352,21 +352,21 @@ describe('SvgConnectionPath — stroke styling', () => { return props.fill === 'none' && props.stroke !== 'transparent' && props.stroke !== undefined; })[0]; - it('renders selected stroke = EDGE_COLORS.selected with strokeWidth=2.5 and opacity=0.7', () => { + it('renders selected stroke = EDGE_COLORS.selected with strokeWidth=2.5 and opacity=1 (fully visible)', () => { const tree = renderEdge({ isSelected: true }); const path = mainPath(tree)!; const props = path.props as { stroke: string; strokeWidth: number; opacity: number }; expect(props.stroke).toBe(EDGE_COLORS.selected); expect(props.strokeWidth).toBe(2.5); - expect(props.opacity).toBe(0.7); + expect(props.opacity).toBe(1); }); - it('renders highlighted stroke (no direction → category color or default)', () => { + it('renders highlighted stroke with opacity 0.95 (near-full visibility)', () => { const tree = renderEdge({ isHighlighted: true }); const path = mainPath(tree)!; const props = path.props as { stroke: string; opacity: number }; expect(props.stroke).toBe(EDGE_COLORS.default); - expect(props.opacity).toBe(0.6); + expect(props.opacity).toBe(0.95); }); it('renders highlighted + direction="outgoing" stroke = EDGE_COLORS.outgoing', () => { @@ -383,12 +383,12 @@ describe('SvgConnectionPath — stroke styling', () => { expect(props.stroke).toBe(EDGE_COLORS.incoming); }); - it('renders default stroke (relationship "default") with low opacity', () => { + it('renders default stroke (relationship "default") at high opacity — connections are fully visible at idle', () => { const tree = renderEdge(); const path = mainPath(tree)!; const props = path.props as { stroke: string; opacity: number }; expect(props.stroke).toBe(EDGE_COLORS.default); - expect(props.opacity).toBe(0.15); + expect(props.opacity).toBe(0.9); }); it('uses the category color from connection.data.color when present', () => { @@ -419,7 +419,7 @@ describe('SvgConnectionPath — stroke styling', () => { expect((path.props as { stroke: string }).stroke).toBe(EDGE_COLORS.depends_on); }); - it('renders pipelineActive stroke = #3b82f6 with opacity 0.6', () => { + it('renders pipelineActive stroke = #3b82f6 with opacity 0.6 (animated overlay sits quieter than the base wire)', () => { const tree = renderEdge({ pipelineActive: true }); const path = mainPath(tree)!; const props = path.props as { stroke: string; opacity: number }; @@ -473,28 +473,28 @@ describe('SvgConnectionPath — stroke styling', () => { expect((path.props as { strokeWidth: number }).strokeWidth).toBe(0.6); }); - it('thin lineStyle reduces opacity floor to 0.12 at full LOD', () => { + it('thin lineStyle drops opacity to 0.6 at full LOD (a notch quieter than primary traffic but still fully readable)', () => { const tree = renderEdge({ connection: makeConn({ data: { lineStyle: 'thin' } }), }); const path = mainPath(tree)!; - expect((path.props as { opacity: number }).opacity).toBe(0.12); + expect((path.props as { opacity: number }).opacity).toBe(0.6); }); - it('LOD 1 reduces strokeWidth to 1.5 * invZoom and opacity to 0.4', () => { + it('LOD 1 reduces strokeWidth to 1.5 * invZoom and opacity to 0.7', () => { const tree = renderEdge({ lod: 1, zoom: 1 }); const path = mainPath(tree)!; const props = path.props as { strokeWidth: number; opacity: number }; expect(props.strokeWidth).toBeCloseTo(1.5); - expect(props.opacity).toBe(0.4); + expect(props.opacity).toBe(0.7); }); - it('LOD 2 reduces strokeWidth to 1.2 * invZoom and opacity to 0.35', () => { + it('LOD 2 reduces strokeWidth to 1.2 * invZoom and opacity to 0.8', () => { const tree = renderEdge({ lod: 2, zoom: 1 }); const path = mainPath(tree)!; const props = path.props as { strokeWidth: number; opacity: number }; expect(props.strokeWidth).toBeCloseTo(1.2); - expect(props.opacity).toBe(0.35); + expect(props.opacity).toBe(0.8); }); it('LOD 1 with zoom 0.5 doubles invZoom-scaled strokeWidth (1.5 * 2 = 3)', () => { diff --git a/packages/ui/src/features/canvas/components/add-menu/__tests__/fuzzy-match.test.ts b/packages/ui/src/features/canvas/components/add-menu/__tests__/fuzzy-match.test.ts new file mode 100644 index 00000000..11faa1bc --- /dev/null +++ b/packages/ui/src/features/canvas/components/add-menu/__tests__/fuzzy-match.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { rank, type RankableItem } from '../fuzzy-match'; + +const items: RankableItem[] = [ + { name: 'PostgreSQL', description: 'Relational database', iceType: 'Database.PostgreSQL', category: 'data' }, + { name: 'Postman Echo', description: 'HTTP echo service', iceType: 'External.PostmanEcho', category: 'external' }, + { name: 'Redis Cache', description: 'In-memory cache', iceType: 'Database.Redis', category: 'data' }, + { name: 'Scalable Backend', description: 'Containerized service', iceType: 'Compute.Container', category: 'backend' }, + { name: 'Static Site', description: 'Frontend hosted on a CDN', iceType: 'Compute.StaticSite', category: 'frontend' }, +]; + +describe('rank', () => { + it('returns items in original order when query is empty', () => { + expect(rank(items, '').map((i) => i.iceType)).toEqual(items.map((i) => i.iceType)); + }); + + it('places word-start matches above mid-word matches', () => { + const result = rank(items, 'post'); + // PostgreSQL and Postman Echo both word-start with "post" — kept; + // Static Site contains "Site" not "post"; Scalable Backend doesn't match. + expect(result.map((r) => r.iceType)).toEqual( + expect.arrayContaining(['Database.PostgreSQL', 'External.PostmanEcho']), + ); + // Word-start hits should appear before "no-match" items. + expect(result[0].name.toLowerCase().startsWith('post')).toBe(true); + }); + + it('matches against the description as well', () => { + const result = rank(items, 'cache'); + expect(result.some((r) => r.iceType === 'Database.Redis')).toBe(true); + }); + + it('filters out non-matching items', () => { + const result = rank(items, 'redis'); + expect(result.map((r) => r.iceType)).toEqual(['Database.Redis']); + }); + + it('is case-insensitive', () => { + expect(rank(items, 'POSTGRES').some((r) => r.iceType === 'Database.PostgreSQL')).toBe(true); + }); + + it('returns stable order on ties (preserves input order)', () => { + const a = rank(items, 'a'); // matches "Scalable Backend" + "Static Site" + maybe more + const b = rank(items, 'a'); + expect(a.map((i) => i.iceType)).toEqual(b.map((i) => i.iceType)); + }); +}); diff --git a/packages/ui/src/features/canvas/components/add-menu/fuzzy-match.ts b/packages/ui/src/features/canvas/components/add-menu/fuzzy-match.ts new file mode 100644 index 00000000..635a8b6e --- /dev/null +++ b/packages/ui/src/features/canvas/components/add-menu/fuzzy-match.ts @@ -0,0 +1,52 @@ +/** + * Minimal fuzzy match — substring + word-start boost. + * + * We don't pull in fuse.js because the catalog is small (~25 concepts) + * and the ranking only needs to feel responsive. Score components: + * - whole-string match → 100 + * - word-start (case-insensitive) → 50 + position bonus + * - substring match → 25 + * - description match → half weight + * + * `rank(items, query)` returns the input filtered + sorted by score + * descending. Empty query yields the input order unchanged. Stable + * (sort preserves relative order on ties — relevant when the catalog + * has an editorial order users have learned). + */ + +export interface RankableItem { + name: string; + description?: string; + iceType: string; + category?: string; +} + +function score(item: RankableItem, query: string): number { + if (!query) return 1; // every item kept, original order + const q = query.toLowerCase(); + const name = item.name.toLowerCase(); + const desc = (item.description ?? '').toLowerCase(); + let s = 0; + + if (name === q) s += 100; + // Word-start: split name on non-word chars, look for any starting with q. + const words = name.split(/[\s\-_./]+/); + for (let i = 0; i < words.length; i++) { + if (words[i].startsWith(q)) { + s += 50 + Math.max(0, 10 - i); + } + } + if (name.includes(q)) s += 25; + if (desc.includes(q)) s += 10; + return s; +} + +export function rank(items: T[], query: string): T[] { + if (!query.trim()) return items.slice(); + const q = query.trim(); + const scored = items + .map((it, idx) => ({ it, s: score(it, q), idx })) + .filter((entry) => entry.s > 0) + .sort((a, b) => b.s - a.s || a.idx - b.idx); + return scored.map((entry) => entry.it); +} diff --git a/packages/ui/src/features/canvas/components/add-menu/spotlight.tsx b/packages/ui/src/features/canvas/components/add-menu/spotlight.tsx new file mode 100644 index 00000000..59381a3e --- /dev/null +++ b/packages/ui/src/features/canvas/components/add-menu/spotlight.tsx @@ -0,0 +1,323 @@ +/** + * Spotlight — Blender-style Shift+A add-block menu. + * + * Centered floating modal with a search input, fuzzy-ranked block list, + * recently-used pinned at the top, and keyboard navigation. Spawning + * goes through the same blueprint path as palette drag-drop so behavior + * stays consistent (same default node data, same containment rules, + * same ghost suggestions). + * + * Closing: Escape, click outside, or successful spawn. + * + * Implementation note — the search input ref is created once via + * `useRef` and focused in a `useEffect` rather than via `autoFocus` so + * it works when the modal re-opens (autoFocus only fires on the + * initial mount). + */ + +import { isIceTypeEnabledForProvider } from '@ice/constants'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { rank, type RankableItem } from './fuzzy-match'; +import { getBlueprint, expandBlueprint } from '../../../../config/blocks'; +import { useTranslation } from '../../../../i18n'; +import { addNodeToCard, expandBlueprintToCard, type CardNode } from '../../../../store/slices/cards-slice'; +import { closeSpotlight, pushSpotlightRecent } from '../../../../store/slices/ui-slice'; +import { getComponents } from '../../../palette/data/components'; +import type { AppDispatch, RootState } from '../../../../store'; +import type { ComponentDef } from '../../../palette/types'; + +type Provider = ComponentDef['providers'][number]; + +interface SpotlightCommand extends RankableItem { + type: 'block'; + origin: ComponentDef; +} + +export const Spotlight: React.FC = () => { + const dispatch = useDispatch(); + const { t } = useTranslation(); + const open = useSelector((s: RootState) => s.ui.spotlight.open); + const canvasPos = useSelector((s: RootState) => ({ + x: s.ui.spotlight.canvasX, + y: s.ui.spotlight.canvasY, + })); + const recent = useSelector((s: RootState) => s.ui.spotlight.recentTypes); + const deployProvider = useSelector((s: RootState) => s.deploy.provider); + + const [query, setQuery] = useState(''); + const [highlightIdx, setHighlightIdx] = useState(0); + const inputRef = useRef(null); + + // Build the searchable command list from the same palette source so + // a block added there is automatically findable here. + const commands = useMemo(() => { + const components = getComponents(t); + return components.map((c) => ({ + type: 'block', + origin: c, + name: c.name, + description: c.description, + iceType: c.type, + category: c.category, + })); + }, [t]); + + // Order: when no query, surface recently-used at the top, then + // everything else in palette order; with a query, fuzzy-rank. + const ranked = useMemo(() => { + if (!query.trim()) { + const recentSet = new Set(recent); + const fromRecent = recent + .map((iceType) => commands.find((c) => c.iceType === iceType)) + .filter((c): c is SpotlightCommand => !!c); + const rest = commands.filter((c) => !recentSet.has(c.iceType)); + return [...fromRecent, ...rest]; + } + return rank(commands, query); + }, [commands, query, recent]); + + // Reset state when the modal opens. + useEffect(() => { + if (open) { + setQuery(''); + setHighlightIdx(0); + // Microtask delay so Radix/portal mounting completes before focus. + const id = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(id); + } + return undefined; + }, [open]); + + // Clamp highlight into bounds when ranked list shrinks. + useEffect(() => { + if (highlightIdx >= ranked.length) setHighlightIdx(Math.max(0, ranked.length - 1)); + }, [ranked.length, highlightIdx]); + + const spawn = (cmd: SpotlightCommand): void => { + const blockType = cmd.iceType; + const paletteProvider: Provider | undefined = cmd.origin.providers[0]; + const effectiveProvider = paletteProvider ?? deployProvider; + const gateBlocked = !!effectiveProvider && !isIceTypeEnabledForProvider(blockType, effectiveProvider); + const blueprint = gateBlocked ? undefined : getBlueprint(blockType, effectiveProvider); + + if (blueprint) { + const expanded = expandBlueprint(blueprint, { + position: canvasPos, + provider: effectiveProvider as Provider, + }); + dispatch(expandBlueprintToCard(expanded)); + } else if (blockType === 'Util.Reroute') { + // Reroute is a tiny pass-through dot, not a deployable resource — + // it has no blueprint by design. Spawn a minimal 16×16 node. + const newNode: CardNode = { + id: `reroute-${Date.now()}`, + type: 'resource', + position: { x: canvasPos.x - 8, y: canvasPos.y - 8 }, + width: 16, + height: 16, + data: { + label: '', + iceType: blockType, + behavior: 'singleton', + folded: false, + }, + }; + dispatch(addNodeToCard(newNode)); + } else { + // Fall through to a bare resource node so the user still gets a + // visible placeholder when the blueprint is missing for the active + // provider. Mirrors the palette drop fallback. + const newNode: CardNode = { + id: `node-${Date.now()}`, + type: 'resource', + position: { x: canvasPos.x, y: canvasPos.y }, + width: 200, + height: 120, + data: { + label: cmd.name, + iceType: blockType, + behavior: 'singleton', + folded: false, + provider: deployProvider, + }, + }; + dispatch(addNodeToCard(newNode)); + } + dispatch(pushSpotlightRecent(blockType)); + dispatch(closeSpotlight()); + }; + + const onKey = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { + e.preventDefault(); + dispatch(closeSpotlight()); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightIdx((i) => Math.min(ranked.length - 1, i + 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightIdx((i) => Math.max(0, i - 1)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + const cmd = ranked[highlightIdx]; + if (cmd) spawn(cmd); + } + }; + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) dispatch(closeSpotlight()); + }} + onKeyDown={onKey} + style={{ + position: 'fixed', + inset: 0, + zIndex: 1000, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + paddingTop: '20vh', + background: 'rgba(0,0,0,0.18)', + backdropFilter: 'blur(2px)', + }} + > +
+ setQuery(e.target.value)} + placeholder="Add block…" + style={{ + padding: '12px 14px', + fontSize: 14, + background: 'transparent', + color: 'var(--ice-text-primary)', + border: 'none', + outline: 'none', + borderBottom: '1px solid var(--ice-border-subtle, var(--ice-border))', + }} + /> +
    + {ranked.length === 0 && ( +
  • + No matches. +
  • + )} + {ranked.map((cmd, i) => ( + spawn(cmd)} + onHover={() => setHighlightIdx(i)} + /> + ))} +
+
+
+ ); +}; + +interface SpotlightRowProps { + cmd: SpotlightCommand; + highlighted: boolean; + onSelect: () => void; + onHover: () => void; +} + +const SpotlightRow: React.FC = ({ cmd, highlighted, onSelect, onHover }) => { + const Icon = cmd.origin.icon; + return ( +
  • + +
    + + {cmd.name} + + {cmd.description && ( + + {cmd.description} + + )} +
    + + {cmd.category} + +
  • + ); +}; diff --git a/packages/ui/src/features/canvas/components/add-menu/use-spotlight-state.ts b/packages/ui/src/features/canvas/components/add-menu/use-spotlight-state.ts new file mode 100644 index 00000000..b77fd0ef --- /dev/null +++ b/packages/ui/src/features/canvas/components/add-menu/use-spotlight-state.ts @@ -0,0 +1,60 @@ +/** + * useSpotlightShortcut + * + * Window-level Shift+A listener that opens the canvas add-menu spotlight + * at the current cursor position (converted to canvas-space). Ignored + * while an input/textarea is focused so users can type freely inside + * the properties panel. + * + * The hook also tracks `lastMouseClient` so when Shift+A fires we know + * where to anchor the spawn. Keeping the listener here instead of in + * the heavier `use-keyboard-handlers.ts` avoids touching that hook's + * `[]`-dep useEffect, which would re-install all keyboard listeners on + * every dep change. + */ + +import { useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { openSpotlight } from '../../../../store/slices/ui-slice'; +import type { AppDispatch } from '../../../../store'; + +interface UseSpotlightShortcutArgs { + screenToCanvas: (clientX: number, clientY: number) => { x: number; y: number }; + /** Disable the shortcut while the canvas is locked or another modal owns the key. */ + enabled?: boolean; +} + +export function useSpotlightShortcut(args: UseSpotlightShortcutArgs): void { + const { screenToCanvas, enabled = true } = args; + const dispatch = useDispatch(); + const lastClient = useRef<{ x: number; y: number }>({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + const screenToCanvasRef = useRef(screenToCanvas); + screenToCanvasRef.current = screenToCanvas; + + useEffect(() => { + if (!enabled) return undefined; + const onMove = (e: MouseEvent): void => { + lastClient.current = { x: e.clientX, y: e.clientY }; + }; + const onKey = (e: KeyboardEvent): void => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement || + (e.target instanceof HTMLElement && e.target.isContentEditable) + ) + return; + if (e.key.toLowerCase() === 'a' && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + const canvasPos = screenToCanvasRef.current(lastClient.current.x, lastClient.current.y); + dispatch(openSpotlight({ canvasX: canvasPos.x, canvasY: canvasPos.y })); + } + }; + window.addEventListener('mousemove', onMove, { passive: true }); + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('keydown', onKey); + }; + }, [dispatch, enabled]); +} diff --git a/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/canvas-content.test.tsx b/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/canvas-content.test.tsx index f2e07d51..899b3428 100644 --- a/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/canvas-content.test.tsx +++ b/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/canvas-content.test.tsx @@ -97,21 +97,21 @@ describe('CanvasContent', () => { expect(el.props.transform).toBe('translate(5, 7) scale(2)'); }); - it('renders the documented child sequence in order — when no preview is active', () => { + it('renders the documented child sequence in order — when no preview is active (connections ABOVE nodes per user feedback)', () => { const el = renderResult(baseProps); expect(childTypes(el)).toEqual([ mocks.CanvasGrid, mocks.SelectionFrame, - mocks.ConnectionLayer, // background mode mocks.ParentClipDefs, mocks.NodesLayer, + mocks.ConnectionLayer, // background mode — now ABOVE nodes mocks.UserTrafficOverlay, mocks.ConnectionLayer, // highlighted mode mocks.GhostOverlay, ]); }); - it('inserts the connection-drawing preview between nodes-layer and user-traffic when drawingConnection is set', () => { + it('inserts the connection-drawing preview between background-connections and user-traffic when drawingConnection is set', () => { const el = renderResult({ ...baseProps, drawingConnection: { @@ -123,9 +123,9 @@ describe('CanvasContent', () => { expect(childTypes(el)).toEqual([ mocks.CanvasGrid, mocks.SelectionFrame, - mocks.ConnectionLayer, mocks.ParentClipDefs, mocks.NodesLayer, + mocks.ConnectionLayer, mocks.ConnectionPreviewOverlay, mocks.UserTrafficOverlay, mocks.ConnectionLayer, diff --git a/packages/ui/src/features/canvas/components/canvas-renderer/canvas-content.tsx b/packages/ui/src/features/canvas/components/canvas-renderer/canvas-content.tsx index 8be40f26..0bcaade6 100644 --- a/packages/ui/src/features/canvas/components/canvas-renderer/canvas-content.tsx +++ b/packages/ui/src/features/canvas/components/canvas-renderer/canvas-content.tsx @@ -7,18 +7,19 @@ * * 1. CanvasGrid (background grid) * 2. SelectionFrame - * 3. ConnectionLayer (mode='background') - * 4. ParentClipDefs - * 5. NodesLayer + * 3. ParentClipDefs + * 4. NodesLayer + * 5. ConnectionLayer (mode='background') — ABOVE nodes per user feedback * 6. ConnectionPreviewOverlay (drag-to-connect ghost) * 7. UserTrafficOverlay * 8. ConnectionLayer (mode='highlighted') * 9. GhostOverlay * - * Visual order, prop flow, and dep arrays are preserved verbatim — this - * is purely a JSX wrap-and-extract. The two `` instances - * remain separate (background vs highlighted) because they sandwich the - * NodesLayer in the original SVG draw order. + * The background ConnectionLayer was previously rendered BEFORE the + * NodesLayer (under the original "edges sandwich nodes" model). User + * feedback flagged that as broken — containers and groups overlapped + * the wires, making the data flow unreadable at idle. Both connection + * layers now render after NodesLayer so wires are first-class. * * Earlier rf-canv units (rf-canv-13/14/15/etc.) extracted the leaf * components; this unit extracts only their composition and the @@ -137,12 +138,29 @@ export const CanvasContent: React.FC = ({ {/* VPC/Subnet now render as SvgGroupNode in the nodes layer */} - {/* Connections layer — non-highlighted (behind nodes). - rf-canv-13: extracted to ConnectionLayer in mode='background'. - Inner-vs-outer key shape (`anim-edge-${id}` outer wrap, - `${id}` SvgConnectionPath inner) preserved verbatim per - blueprint risk #4 — SvgConnectionPath's internal hover state - survives reconciliation when the wrap toggles. */} + {/* rf-canv-11: block (shift-drag-shadow filter + + per-container clipPaths) extracted to ParentClipDefs. */} + + + {/* Nodes layer — Groups, Blocks, Resources, or Log terminals. + rf-canv-12: per-node dispatch (iceType + node.type → component + choice) lives in `./node-renderer-registry`. + rf-canv2-7: the wrap-and-key loop lives in `./nodes-layer`; the + wrapper's outer-key priority chain (rf-canv-10) is preserved + verbatim. */} + + + {/* Connections layer — ABOVE the nodes layer so containers and + groups never occlude the wires. Per user feedback: connections + are the architecture's data flow and must be fully visible at + idle. Previously rendered before NodesLayer (mode='background') + which let group tints overlap them. */} = ({ handleContextMenu={handleContextMenu} /> - {/* rf-canv-11: block (shift-drag-shadow filter + - per-container clipPaths) extracted to ParentClipDefs. */} - - - {/* Nodes layer — Groups, Blocks, Resources, or Log terminals. - rf-canv-12: per-node dispatch (iceType + node.type → component - choice) lives in `./node-renderer-registry`. - rf-canv2-7: the wrap-and-key loop lives in `./nodes-layer`; the - wrapper's outer-key priority chain (rf-canv-10) is preserved - verbatim. */} - - {/* Connection drawing preview — extracted to ConnectionPreviewOverlay (rf-canv-14). Bezier math + color picker live in `../utils/connection-preview` (rf-canv-8). */} {drawingConnection && ( diff --git a/packages/ui/src/features/canvas/components/connection-preview-overlay.tsx b/packages/ui/src/features/canvas/components/connection-preview-overlay.tsx index fa028068..7aa0f654 100644 --- a/packages/ui/src/features/canvas/components/connection-preview-overlay.tsx +++ b/packages/ui/src/features/canvas/components/connection-preview-overlay.tsx @@ -1,24 +1,27 @@ /** * rf-canv-14 — `ConnectionPreviewOverlay` subcomponent. * - * The in-flight connection drag preview: a temporary cubic-bezier from the - * source port to the current cursor, plus two anchor circles (source + cursor) - * shown while the user is dragging from a node port toward another node. + * The in-flight connection drag preview. Two modes: * - * This component is the JSX shell only. The bezier math - * (`computeConnectionPreviewPath`) and color picker (`pickPreviewColor`) live - * in `../utils/connection-preview.ts` (rf-canv-8) — keep them there. + * 1. **Snapped (socket-to-socket)** — when the orchestrator has magnet- + * locked the cursor onto a compatible target port, render a solid + * bezier from the source socket to the target socket. This is the + * promise: release here and the wire lands here. * - * Both the path and the two anchor circles render with `pointer-events: none` - * (set on the wrapping ``) so the preview never intercepts the cursor — - * the orchestrator's mouse-move handler must keep firing through it. The - * stroke/fill color, dash pattern, opacities, and circle radii are verbatim - * from the original orchestrator IIFE; do NOT tweak them under cover of an - * extraction unit. + * 2. **Searching (no target)** — when the cursor is in free space, no + * preview line is drawn. The pulsing source-socket halo (rendered + * by TypedSockets) plus the per-port green halos on compatible + * targets are the only feedback. This matches the user mental + * model: "connections are socket ↔ socket only." + * + * Both modes use `pointer-events: none` so the preview never intercepts + * the cursor — the orchestrator's mouse-move handler must keep firing + * through it. */ import React from 'react'; -import { computeConnectionPreviewPath, pickPreviewColor } from '../utils/connection-preview'; +import { computeConnectionPreviewPath } from '../utils/connection-preview'; +import { getConnectionDragInfo } from './nodes/_shared/connection-drag-context'; import type { CanvasNode } from './types'; export interface ConnectionPreviewOverlayProps { @@ -31,24 +34,20 @@ export interface ConnectionPreviewOverlayProps { connectionDragTargets: Map | null; } -export const ConnectionPreviewOverlay: React.FC = ({ - drawingConnection, - effectiveNodes, - connectionDragTargets, -}) => { +export const ConnectionPreviewOverlay: React.FC = ({ drawingConnection }) => { const { sourcePoint, currentPoint } = drawingConnection; + const drag = getConnectionDragInfo(); + // Only render a line when the magnet has actually locked on to a + // target socket. Until then, the source-socket pulse + per-port + // halos are the user's feedback — no floating "block to cursor" + // wire to confuse the eye. + if (!drag || !drag.snap) return null; const pathD = computeConnectionPreviewPath(sourcePoint, currentPoint); - const previewColor = pickPreviewColor( - currentPoint, - effectiveNodes, - drawingConnection.sourceId, - connectionDragTargets, - ); return ( - - - + + + ); }; diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/__tests__/typed-sockets.test.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/__tests__/typed-sockets.test.tsx new file mode 100644 index 00000000..66e17ad3 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/__tests__/typed-sockets.test.tsx @@ -0,0 +1,119 @@ +/** + * TypedSockets behavior tests — uses the same shallow-render trick as + * the rest of the canvas tests (call the component as a function) to + * keep the test simple and dependency-free. + */ + +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { TypedSockets } from '../typed-sockets'; +import type { PortDef } from '@ice/types'; + +function findByType(tree: React.ReactNode, type: string): React.ReactElement[] { + const out: React.ReactElement[] = []; + function walk(n: React.ReactNode): void { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { + n.forEach(walk); + return; + } + const el = n as React.ReactElement; + if (el.type === type) out.push(el); + // Recurse into function components by calling them — needed to see + // SocketDot's rendered /. + if (typeof el.type === 'function') { + const fn = el.type as (p: typeof el.props) => React.ReactNode; + walk(fn(el.props)); + } + const children = (el.props as { children?: React.ReactNode })?.children; + if (children !== undefined) walk(children); + } + walk(tree); + return out; +} + +const SOCKETS: PortDef[] = [ + { id: 'traffic-in', side: 'left', role: 'database', direction: 'in', label: 'Traffic input', shape: 'circle' }, + { + id: 'traffic-out', + side: 'right', + role: 'http-endpoint', + direction: 'out', + label: 'Traffic output', + shape: 'circle', + }, + { id: 'config-in', side: 'left', role: 'env', direction: 'in', label: 'Config input', shape: 'ring' }, + { + id: 'pipeline-in', + side: 'left', + role: 'repository', + direction: 'in', + label: 'Pipeline input', + shape: 'diamond', + }, +]; + +function render(props: Partial> = {}): React.ReactElement { + const full: React.ComponentProps = { + nodeId: 'n1', + x: 100, + y: 100, + width: 200, + height: 100, + sockets: SOCKETS, + lod: 3, + ...props, + }; + // memo() wraps the component; reach into `type.type` to call the raw render. + const Comp = TypedSockets as unknown as { type: (p: typeof full) => React.ReactElement }; + return Comp.type(full); +} + +describe('TypedSockets', () => { + it('renders one socket per SocketDef at LOD 3', () => { + const tree = render(); + const circles = findByType(tree, 'circle'); + const rects = findByType(tree, 'rect'); + expect(circles.length + rects.length).toBeGreaterThanOrEqual(SOCKETS.length); + }); + + it('emits data-socket-id / data-side / data-category attributes', () => { + const tree = render(); + const circles = findByType(tree, 'circle'); + const trafficIn = circles.find((c) => (c.props as Record)['data-socket-id'] === 'traffic-in'); + expect(trafficIn).toBeDefined(); + const props = trafficIn!.props as Record; + expect(props['data-side']).toBe('left'); + expect(props['data-category']).toBe('traffic'); + expect(props['data-direction']).toBe('in'); + expect(props['data-node-id']).toBe('n1'); + expect((props.className as string).includes('connection-port')).toBe(true); + }); + + it('degrades to anonymous L/R dots at LOD < 2', () => { + const tree = render({ lod: 1 }); + const circles = findByType(tree, 'circle'); + // Two fallback dots (left + right) — never four. + expect(circles.length).toBe(2); + }); + + it('renders fallback dots when sockets array is empty', () => { + const tree = render({ sockets: [] }); + const circles = findByType(tree, 'circle'); + expect(circles.length).toBe(2); + }); + + it('honors opacity prop on the group wrapper', () => { + const tree = render({ opacity: 0.42 }); + expect(((tree.props as Record).style as Record).opacity).toBe(0.42); + }); + + it('uses ring shape for config sockets', () => { + const tree = render(); + const circles = findByType(tree, 'circle'); + const configRing = circles.find((c) => (c.props as Record)['data-socket-id'] === 'config-in'); + expect(configRing).toBeDefined(); + // The ring shape renders with fill="var(--ice-bg-raised)" and stroke=color. + expect((configRing!.props as Record).fill).toBe('var(--ice-bg-raised)'); + }); +}); diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/card-shell.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/card-shell.tsx index c82a760f..20d1e92f 100644 --- a/packages/ui/src/features/canvas/components/nodes/_shared/card-shell.tsx +++ b/packages/ui/src/features/canvas/components/nodes/_shared/card-shell.tsx @@ -28,12 +28,14 @@ */ import { CARD_FOOTER_HEIGHT } from '@ice/constants'; +import { getPortsForNode } from '@ice/types'; import React, { useCallback, useState, type ReactNode } from 'react'; +import { getNodeDragState } from './connection-drag-context'; import { ConnectionDragGlow } from './connection-drag-glow'; -import { ConnectionPorts } from './connection-ports'; import { useIsNodeOrphan } from './orphan-context'; import { ProviderPill } from './provider-pill'; import { StatusDot } from './status-dot'; +import { TypedSockets } from './typed-sockets'; import { getBrandIcon } from '../../../../../assets/icons/brand-registry'; import { getServiceName } from '../../../../../assets/icons/service-names'; import { CATEGORY_STYLE, CORNER_RADIUS, STATUS_COLORS } from '../../../../../config/canvas-constants'; @@ -170,9 +172,28 @@ export const CardShell: React.FC = ({ const statusColor = STATUS_COLORS[deployStatus] || STATUS_COLORS.idle; const statusLabel = deployStatus ? deployStatus.charAt(0).toUpperCase() + deployStatus.slice(1) : ''; - const isSource = connectionDragState === 'source'; - const isValidTarget = connectionDragState === 'valid-target'; - const isInvalidTarget = connectionDragState === 'invalid-target'; + const rawIsSource = connectionDragState === 'source'; + const rawIsValidTarget = connectionDragState === 'valid-target'; + const rawIsInvalidTarget = connectionDragState === 'invalid-target'; + // Per-node drag state pulled from the orchestrator-provided singleton. + // Drives the per-port highlight + magnet-snap glow in TypedSockets. + // Pure function call (no hook) so this component stays compatible with + // tests that invoke it as a plain function. + const { + compatiblePortIds, + snappedPortId, + sourcePortId, + isSource: isDragSource, + active: typedDragActive, + } = getNodeDragState(node.id); + // When a typed-port drag is in flight, the per-port glow in + // TypedSockets is the only visual feedback — suppress the whole-block + // green border / glow so the user reads "socket ↔ socket" not "block + // ↔ block." Source / invalid styling still applies for the source + // node itself and for genuinely invalid targets (canConnect failures). + const isSource = rawIsSource; + const isValidTarget = typedDragActive ? false : rawIsValidTarget; + const isInvalidTarget = rawIsInvalidTarget; // Orphan signal — the canvas orchestrator populates the OrphanNodes // context with the set of blocks that have zero edges. We only show // the indicator while no drag is active, so the orphan warning @@ -184,7 +205,22 @@ export const CardShell: React.FC = ({ // hover/selection/source/valid-target, faded out when this block is // an invalid drop target during an active drag. const renderPorts = !customPorts; - const portOpacity = isInvalidTarget ? 0.12 : isHovered || isSelected || isValidTarget || isSource ? 1 : 0.35; + // Dim blocks that have no compatible port during a typed drag so the + // user reads "these are out of play" without a tooltip. + const isNonCompatibleDuringDrag = + typedDragActive && !isDragSource && (compatiblePortIds === null || compatiblePortIds.size === 0); + const portOpacity = isInvalidTarget + ? 0.12 + : isNonCompatibleDuringDrag + ? 0.25 + : isHovered || isSelected || isValidTarget || isSource + ? 1 + : 0.35; + // Derive the typed port list from the node's iceType + property bag. + // `getPortsForNode` is schema-driven (one port per real semantic + // connection — repository / domain / database / …) and memoizes + // internally, so we can call it on every render. + const sockets = getPortsForNode({ id: node.id, type: node.type, data: node.data }); const onEnter = useCallback(() => { setIsHovered(true); @@ -252,7 +288,7 @@ export const CardShell: React.FC = ({ : isHovered ? '0 2px 8px -2px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.06)', - opacity: isSource ? 0.85 : 1, + opacity: isNonCompatibleDuringDrag ? 0.35 : isSource ? 0.85 : 1, padding: 12, }} data-testid={`cardshell-lod1-${node.id}`} @@ -373,15 +409,20 @@ export const CardShell: React.FC = ({ /> )} {renderPorts && ( - )} @@ -426,7 +467,7 @@ export const CardShell: React.FC = ({ : isHovered ? '0 2px 8px -2px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.06)', - opacity: isSource ? 0.85 : 1, + opacity: isNonCompatibleDuringDrag ? 0.35 : isSource ? 0.85 : 1, transition: 'box-shadow 150ms ease, border-color 150ms ease', }} > @@ -612,15 +653,16 @@ export const CardShell: React.FC = ({ /> )} {renderPorts && ( - )}
    diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/connection-drag-context.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/connection-drag-context.tsx new file mode 100644 index 00000000..2c9cebd9 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/connection-drag-context.tsx @@ -0,0 +1,110 @@ +/** + * ConnectionDragContext — propagates per-port drag state from the + * orchestrator (svg-canvas) down to TypedSockets. + * + * While a connection is being drawn, the orchestrator computes which + * specific ports on which nodes can accept the dragged source port. + * CardShell reads that set to brighten matching ports and dim + * everything else — so the user SEES exactly where to drop instead of + * guessing. + * + * Implementation note — the propagation uses a module-level singleton + * rather than React Context. Several canvas tests invoke renderers as + * plain functions (no React render context), so hooks throw there. The + * orchestrator drives a state-bound update via the `` + * component below, which calls `setConnectionDragInfo` synchronously + * on every render. Consumers read the fresh value with + * `getConnectionDragInfo()` — no hooks required. + * + * `null` means no drag in progress; renderers should ignore drag- + * specific visuals. + */ + +import React, { type ReactNode } from 'react'; + +export interface ConnectionDragInfo { + /** Node the drag started from. */ + sourceNodeId: string; + /** Port id the drag started from, when a typed dot was the start point. */ + sourcePortId?: string; + /** + * Per-node, the set of port ids on that node that ACCEPT the dragged + * source. TypedSockets uses this to glow compatible ports and dim + * everything else. + */ + compatibleByNode: Map>; + /** + * The (nodeId, portId) of the port the wire endpoint is currently + * magnet-snapped to, if any. TypedSockets renders this port with the + * "snap" affordance (enlarged ring) so the user knows the drop is + * locked in before they release. + */ + snap: { nodeId: string; portId: string } | null; +} + +// Module-level singleton holding the in-flight drag info. The orchestrator +// updates this synchronously via `` and consumers +// (CardShell) read it with `getConnectionDragInfo()`. State-bound React +// re-renders triggered by the orchestrator's own state changes propagate +// the fresh value down to children — we don't need a Context for that. +let _current: ConnectionDragInfo | null = null; + +/** Returns the active drag info, or null when no drag is in progress. */ +export function getConnectionDragInfo(): ConnectionDragInfo | null { + return _current; +} + +/** + * Test helper — resets the module-level state. Production code paths use + * `ConnectionDragProvider` to drive updates. + */ +export function _resetConnectionDragInfo(): void { + _current = null; +} + +/** + * Per-node lookup helper. Pure function — call it from any renderer to + * get the per-node drag state. Returns `active: false` when no drag is + * in progress. + */ +export function getNodeDragState(nodeId: string): { + active: boolean; + isSource: boolean; + /** When this is the drag-source node, the id of the port the drag started from. */ + sourcePortId: string | null; + compatiblePortIds: Set | null; + snappedPortId: string | null; +} { + const info = _current; + if (!info) + return { + active: false, + isSource: false, + sourcePortId: null, + compatiblePortIds: null, + snappedPortId: null, + }; + const isSource = info.sourceNodeId === nodeId; + return { + active: true, + isSource, + sourcePortId: isSource ? (info.sourcePortId ?? null) : null, + compatiblePortIds: info.compatibleByNode.get(nodeId) ?? null, + snappedPortId: info.snap && info.snap.nodeId === nodeId ? info.snap.portId : null, + }; +} + +/** + * Tiny render-driven syncer. Mounting this with `value={info}` writes + * `info` into the module-level slot during render. The orchestrator + * places this above its CardShell descendants; any prop / state change + * that re-renders the orchestrator re-runs this setter, so children + * see fresh drag state on the very same render pass. + */ +export const ConnectionDragProvider: React.FC<{ value: ConnectionDragInfo | null; children: ReactNode }> = ({ + value, + children, +}) => { + _current = value; + return <>{children}; +}; diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/socket-dot.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/socket-dot.tsx new file mode 100644 index 00000000..7889a0d4 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/socket-dot.tsx @@ -0,0 +1,228 @@ +/** + * SocketDot — the single source of truth for what a typed port looks + * like on the canvas. + * + * Every block renders sockets through this component: the schema-driven + * `TypedSockets` layer (for blocks that use the standard side-distributed + * port layout) AND any bespoke renderer that needs custom positioning + * (e.g. `Network.CustomDomain` rows). Centralising the visual logic + * here means shape, color, halos, drag-context highlighting, and data + * attributes all stay consistent — fix the dot once and every block gets + * the fix. + * + * The dot's drag-aware `state` controls sizing + halo: + * + * - `idle` — resting state, no drag in progress (or this port + * isn't a candidate target). + * - `compatible` — a drag is in progress from elsewhere and this + * port accepts the source's role. + * - `snapped` — the magnet has locked the wire endpoint onto + * this exact port. + * - `incompatible` — drag in progress but this port doesn't accept + * the source's role. + * - `source-active` — this port IS the source of the current drag. + * + * The dot's shape is driven by `PortDef.shape` so a Repository socket + * (diamond) and a Domain socket (square) read as different things at + * a glance. + */ + +import { CATEGORY_COLORS, type ConnectionCategory } from '@ice/constants'; +import { ROLE_CATEGORY, type PortDef } from '@ice/types'; +import React from 'react'; +import { CATEGORY_STYLE } from '../../../../../config/canvas-constants'; + +export type DotState = 'idle' | 'compatible' | 'snapped' | 'incompatible' | 'source-active'; + +/** Default visible radius (resting state). Other states scale relative to this. */ +export const SHAPE_RADIUS = 6; + +export interface SocketDotProps { + socketId: string; + nodeId: string; + /** Anchor side (left/right/top/bottom). Stored on the DOM attr — used by drag handlers. */ + side: string; + /** Port role (domain/database/repository/queue/…). */ + role: PortDef['role']; + /** Visual shape (circle/ring/diamond/square). */ + shape: PortDef['shape']; + direction: 'in' | 'out'; + /** Human-readable label (used in the hover tooltip + a11y ``). */ + label: string; + /** Peer block category key for color resolution via CATEGORY_STYLE. */ + peerStyle?: string; + /** Canvas-space center of the dot. */ + cx: number; + cy: number; + /** Master opacity (CardShell dims to ~0.35 at idle, full on hover/selection). */ + opacity?: number; + /** Legacy drag-target glow (block-level) — true → green fill + slightly larger radius. */ + isValidTarget?: boolean; + /** Drag-aware per-port state. Defaults to `idle`. */ + state?: DotState; + /** Extra DOM attributes (e.g. `data-route-id` for Custom Domain per-route ports). */ + extraAttrs?: Record<string, string>; +} + +/** + * Pick the dot color: peer block's category accent (so a frontend's + * domain-in reads as Custom Domain's rose) → fall back to abstract + * category color via `ROLE_CATEGORY`. + */ +export function socketColor(role: PortDef['role'], peerStyle?: string): string { + if (peerStyle) { + const style = CATEGORY_STYLE[peerStyle]; + if (style?.glow) return style.glow; + } + return CATEGORY_COLORS[ROLE_CATEGORY[role]]; +} + +export const SocketDot: React.FC<SocketDotProps> = ({ + socketId, + nodeId, + side, + role, + direction, + shape, + label, + peerStyle, + cx, + cy, + opacity, + isValidTarget = false, + state = 'idle', + extraAttrs, +}) => { + const category: ConnectionCategory = ROLE_CATEGORY[role]; + const color = socketColor(role, peerStyle); + + // Drag-aware sizing — compatible ports grow to invite, snapped grows + // most + pulses, incompatible shrinks slightly. + const r = + state === 'snapped' + ? SHAPE_RADIUS + 3 + : state === 'source-active' + ? SHAPE_RADIUS + 2 + : state === 'compatible' + ? SHAPE_RADIUS + 1 + : state === 'incompatible' + ? SHAPE_RADIUS - 1 + : isValidTarget + ? SHAPE_RADIUS + 1 + : SHAPE_RADIUS; + + const fill = state === 'snapped' ? '#22c55e' : isValidTarget ? '#22c55e' : color; + const stroke = 'var(--ice-bg-base)'; + const strokeWidth = 2; + + // Standard data attributes — every consumer agrees on the shape. + const common: Record<string, unknown> = { + className: 'connection-port', + 'data-node-id': nodeId, + 'data-socket-id': socketId, + 'data-side': side, + 'data-category': category, + 'data-port-role': role, + 'data-direction': direction, + 'data-socket-label': label, + ...(peerStyle && { 'data-peer-style': peerStyle }), + ...extraAttrs, + style: { cursor: 'crosshair' }, + ...(typeof opacity === 'number' ? { opacity } : {}), + }; + + // Native SVG <title> stays as the a11y fallback alongside the canvas- + // level hover-tooltip overlay (which reads `data-socket-label`). + const titleEl = <title>{`${label} · ${category}/${direction}`}; + + // Compatible / source-active halo — green ring outside the dot so + // "wire can land here" / "drag started from here" reads immediately. + // Brighter + pulsing when snapped. + const haloRadius = r + 5; + const haloOpacity = state === 'snapped' ? 0.95 : state === 'source-active' ? 0.75 : state === 'compatible' ? 0.45 : 0; + const haloColor = state === 'source-active' ? color : '#22c55e'; + const halo = + haloOpacity > 0 ? ( + + {(state === 'snapped' || state === 'source-active') && ( + + )} + + ) : null; + + let dot: React.ReactNode; + switch (shape) { + case 'ring': + dot = ( + + {titleEl} + + ); + break; + case 'diamond': + dot = ( + + {titleEl} + + ); + break; + case 'square': + dot = ( + + {titleEl} + + ); + break; + case 'circle': + default: + dot = ( + + {titleEl} + + ); + break; + } + return ( + + {halo} + {dot} + + ); +}; + +SocketDot.displayName = 'SocketDot'; diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/socket-hover-tooltip.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/socket-hover-tooltip.tsx new file mode 100644 index 00000000..5520d8bc --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/socket-hover-tooltip.tsx @@ -0,0 +1,120 @@ +/** + * SocketHoverTooltip — instant styled chip that follows the cursor when + * the user hovers a typed socket dot. + * + * Mounted once at the canvas level (alongside ConnectionTooltip). Uses + * document-level event delegation: listens for `mouseover`/`mouseout` + * on the SVG and reads the socket's `data-socket-label` + + * `data-category` + `data-direction` attributes. Keeps `` + * a pure render-only component — no hooks, no React state inside the + * SVG tree — which preserves the existing "call as function" test + * pattern and avoids re-rendering 25 blocks on every hover. + * + * Why this rather than the native `` element: the browser's + * built-in tooltip has a ~1s show delay and is locked to OS chrome + * styling. With sockets the user is rapidly scanning what each dot + * means; the chip needs to appear instantly and read in the same + * monospace voice as the rest of the canvas. + */ + +import { CATEGORY_COLORS, type ConnectionCategory } from '@ice/constants'; +import React, { useEffect, useRef, useState } from 'react'; +import { CATEGORY_STYLE } from '../../../../../config/canvas-constants'; + +interface SocketHoverInfo { + label: string; + category: ConnectionCategory; + direction: 'in' | 'out'; + peerStyle?: string; + clientX: number; + clientY: number; +} + +export const SocketHoverTooltip: React.FC = () => { + const [info, setInfo] = useState<SocketHoverInfo | null>(null); + const lastTargetRef = useRef<Element | null>(null); + + useEffect(() => { + const onOver = (e: MouseEvent): void => { + const target = e.target as Element | null; + if (!target) return; + const socket = target.closest<SVGElement>('.connection-port[data-socket-label]'); + if (!socket) return; + const label = socket.getAttribute('data-socket-label') ?? ''; + if (!label) return; // LOD-degraded anonymous dots emit empty labels + const category = (socket.getAttribute('data-category') as ConnectionCategory | null) ?? 'traffic'; + const direction = (socket.getAttribute('data-direction') as 'in' | 'out' | null) ?? 'in'; + const peerStyle = socket.getAttribute('data-peer-style') ?? undefined; + lastTargetRef.current = socket; + setInfo({ label, category, direction, peerStyle, clientX: e.clientX, clientY: e.clientY }); + }; + const onMove = (e: MouseEvent): void => { + // Move the tooltip with the cursor while still over the same socket. + if ( + lastTargetRef.current && + (e.target as Element | null)?.closest('.connection-port') === lastTargetRef.current + ) { + setInfo((prev) => (prev ? { ...prev, clientX: e.clientX, clientY: e.clientY } : prev)); + } + }; + const onOut = (e: MouseEvent): void => { + const related = (e.relatedTarget as Element | null)?.closest?.('.connection-port[data-socket-label]') ?? null; + if (related !== lastTargetRef.current) { + lastTargetRef.current = null; + setInfo(null); + } + }; + + document.addEventListener('mouseover', onOver); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseout', onOut); + return () => { + document.removeEventListener('mouseover', onOver); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseout', onOut); + }; + }, []); + + if (!info) return null; + + const color = (info.peerStyle && CATEGORY_STYLE[info.peerStyle]?.glow) || CATEGORY_COLORS[info.category]; + const arrow = info.direction === 'in' ? '←' : '→'; + + return ( + <div + data-testid="socket-hover-tooltip" + style={{ + position: 'fixed', + left: info.clientX + 12, + top: info.clientY + 12, + zIndex: 1000, + pointerEvents: 'none', + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '4px 8px', + borderRadius: 6, + background: 'var(--ice-bg-raised)', + border: '1px solid var(--ice-border)', + boxShadow: '0 2px 8px -2px rgba(0,0,0,0.18)', + fontFamily: "ui-monospace, 'SFMono-Regular', monospace", + fontSize: 11, + whiteSpace: 'nowrap', + color: 'var(--ice-text-primary)', + }} + > + <span + style={{ + width: 8, + height: 8, + borderRadius: '50%', + background: color, + flexShrink: 0, + }} + /> + <span style={{ fontWeight: 600 }}>{info.label}</span> + <span style={{ color: 'var(--ice-text-tertiary)' }}>{arrow}</span> + <span style={{ color: 'var(--ice-text-tertiary)', textTransform: 'lowercase' }}>{info.category}</span> + </div> + ); +}; diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/typed-sockets.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/typed-sockets.tsx new file mode 100644 index 00000000..f92564ae --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/typed-sockets.tsx @@ -0,0 +1,204 @@ +/** + * TypedSockets — typed connection points on a block. + * + * Renders one SVG `<circle>` (or alternative shape) per `SocketDef` at + * its anchor side. Color from `CATEGORY_COLORS`, shape from the + * `SocketDef.shape` field. Each socket emits the data attributes + * `connection-port` + `data-node-id` + `data-socket-id` + `data-side` + + * `data-category` + `data-direction` so the canvas drag handler can + * (a) recognize the start of a port drag and (b) persist socket ids + * onto the resulting `CardEdge.data`. + * + * At LOD < 2 we degrade to anonymous L/R dots — at very low zoom the + * extra socket detail is invisible and the work is wasted. The drop- + * target glow (valid/invalid) is inherited from CardShell via opacity. + * + * Replaces the prior `ConnectionPorts` 4-side anonymous-dot component. + * `customPorts` blocks (cron, custom-domain) keep drawing their own + * sockets directly and don't go through this component. + */ + +import React, { memo } from 'react'; +import { SocketDot, type DotState } from './socket-dot'; +import type { PortDef } from '@ice/types'; + +interface TypedSocketsProps { + nodeId: string; + /** Block bounds in canvas-space. */ + x: number; + y: number; + width: number; + height: number; + /** Ports to render — output of `getPortsForNode(node)`. */ + sockets: PortDef[]; + /** Drop-target glow color for the validation green/red flash. */ + validTargetColor?: string; + isValidTarget?: boolean; + /** Master opacity gate from CardShell — faint at idle, full on hover/select. */ + opacity?: number; + /** Level of detail; sockets degrade to anonymous L/R dots at LOD < 2. */ + lod?: number; + /** + * Set of port ids on this node that ACCEPT the in-flight drag's source + * port. CardShell reads the drag context and passes this in; null when + * no drag is active or this node isn't a candidate target. + */ + compatiblePortIds?: Set<string> | null; + /** Port id currently magnet-snapped (or null). */ + snappedPortId?: string | null; + /** True when this node is the source of an in-flight drag. */ + isDragSource?: boolean; + /** Source port id when this node is the drag source — drives the pulsing halo on the started port. */ + sourcePortId?: string | null; +} + +/** Distribute sockets along a single side, evenly spaced. */ +function socketPosition( + side: 'left' | 'right' | 'top' | 'bottom', + index: number, + count: number, + x: number, + y: number, + w: number, + h: number, +): { cx: number; cy: number } { + const r = (index + 1) / (count + 1); + switch (side) { + case 'top': + return { cx: x + w * r, cy: y }; + case 'right': + return { cx: x + w, cy: y + h * r }; + case 'bottom': + return { cx: x + w * r, cy: y + h }; + case 'left': + default: + return { cx: x, cy: y + h * r }; + } +} + +// Visual logic — shape, color, halo, drag-context — lives in `./socket-dot`. +// TypedSockets is just the schema-driven distribution layer. + +export const TypedSockets: React.FC<TypedSocketsProps> = memo( + ({ + nodeId, + x, + y, + width, + height, + sockets, + isValidTarget = false, + opacity = 1, + lod = 3, + compatiblePortIds = null, + snappedPortId = null, + isDragSource = false, + sourcePortId = null, + }) => { + const dragActive = !isDragSource && compatiblePortIds !== null; + // The source-side block highlights only its source port — the dot + // the user grabbed — with a pulsing peer-color halo so they see + // exactly where the wire starts. + const sourceActive = isDragSource && sourcePortId !== null; + // LOD degrade — at very low zoom we don't render typed shapes, just + // anonymous L/R dots so the block still has a drag affordance. We + // emit the data attributes anyway so drag still produces a valid + // socket id when possible. + if (lod < 2 || sockets.length === 0) { + const fallback: Array<{ side: 'left' | 'right'; direction: 'in' | 'out'; id: string }> = sockets.length + ? [ + // Pick first IN socket for left, first OUT for right. + ...(sockets.find((s) => s.direction === 'in') + ? [{ side: 'left' as const, direction: 'in' as const, id: sockets.find((s) => s.direction === 'in')!.id }] + : []), + ...(sockets.find((s) => s.direction === 'out') + ? [ + { + side: 'right' as const, + direction: 'out' as const, + id: sockets.find((s) => s.direction === 'out')!.id, + }, + ] + : []), + ] + : [ + { side: 'left', direction: 'in', id: '' }, + { side: 'right', direction: 'out', id: '' }, + ]; + + return ( + <g className="connection-ports" style={{ opacity, transition: 'opacity 120ms ease' }}> + {fallback.map(({ side, direction, id }, idx) => { + const pos = socketPosition(side, 0, 1, x, y, width, height); + return ( + <circle + key={`${side}-${idx}`} + className="connection-port" + data-node-id={nodeId} + data-socket-id={id} + data-side={side} + data-direction={direction} + cx={pos.cx} + cy={pos.cy} + r={isValidTarget ? 6 : 5} + fill={isValidTarget ? '#22c55e' : 'var(--ice-text-tertiary)'} + stroke="var(--ice-bg-base)" + strokeWidth={2} + style={{ cursor: 'crosshair' }} + /> + ); + })} + </g> + ); + } + + // Group sockets by side for even distribution along the perimeter. + const bySide: Record<'left' | 'right' | 'top' | 'bottom', PortDef[]> = { + left: [], + right: [], + top: [], + bottom: [], + }; + for (const s of sockets) bySide[s.side].push(s); + + return ( + <g className="connection-ports" style={{ opacity, transition: 'opacity 120ms ease' }}> + {(['left', 'right', 'top', 'bottom'] as const).flatMap((side) => { + const list = bySide[side]; + return list.map((sock, idx) => { + const pos = socketPosition(side, idx, list.length, x, y, width, height); + // Derive per-port state from the drag context. When no drag + // is in progress this is always 'idle'. + let state: DotState = 'idle'; + if (sourceActive && sock.id === sourcePortId) { + state = 'source-active'; + } else if (dragActive && compatiblePortIds) { + if (snappedPortId === sock.id) state = 'snapped'; + else if (compatiblePortIds.has(sock.id)) state = 'compatible'; + else state = 'incompatible'; + } + return ( + <SocketDot + key={sock.id} + socketId={sock.id} + nodeId={nodeId} + side={side} + role={sock.role} + direction={sock.direction} + shape={sock.shape} + label={sock.label} + peerStyle={sock.peerStyle} + cx={pos.cx} + cy={pos.cy} + isValidTarget={isValidTarget} + state={state} + /> + ); + }); + })} + </g> + ); + }, +); + +TypedSockets.displayName = 'TypedSockets'; diff --git a/packages/ui/src/features/canvas/components/nodes/custom-domain/index.tsx b/packages/ui/src/features/canvas/components/nodes/custom-domain/index.tsx index 54613e73..bfdae023 100644 --- a/packages/ui/src/features/canvas/components/nodes/custom-domain/index.tsx +++ b/packages/ui/src/features/canvas/components/nodes/custom-domain/index.tsx @@ -45,6 +45,8 @@ import { Globe, Plus, X } from 'lucide-react'; import React, { useCallback, useState } from 'react'; import { CARD_PX, CARD_WIDTH, CATEGORY_STYLE, CORNER_RADIUS } from '../../../../../config/canvas-constants'; import { t } from '../../../../../i18n'; +import { getNodeDragState } from '../_shared/connection-drag-context'; +import { SocketDot, type DotState } from '../_shared/socket-dot'; import type { SvgCompactNodeProps } from '../compact-node/types'; // Re-exported so SvgConnectionPath / tests can compute the exact y-coordinate @@ -451,46 +453,49 @@ export const SvgCustomDomainNode: React.FC<SvgCompactNodeProps> = ({ </div> </foreignObject> - {/* ── Per-row connection ports ── */} - {(isHovered || isSelected || isValidTarget) && - portPositions.map((pos, i) => { + {/* ── Per-row connection ports — one socket per route, always visible. + Uses the shared `<SocketDot>` so visual state, halos, data + attributes, and drag-context highlight stay consistent with + every other typed socket on the canvas. Custom Domain owns + only the row Y positioning; the dot's looks are not its + concern. */} + {(() => { + const dragState = getNodeDragState(node.id); + const dotOpacity = isHovered || isSelected || isValidTarget ? 1 : 0.55; + return portPositions.map((pos, i) => { const route = routes[i]; if (!route) return null; + const socketId = `domain-out-${route.id}`; + let state: DotState = 'idle'; + if (dragState.snappedPortId === socketId) state = 'snapped'; + else if (dragState.compatiblePortIds?.has(socketId)) state = 'compatible'; + else if (dragState.isSource && dragState.sourcePortId === socketId) state = 'source-active'; + else if (dragState.active && !dragState.isSource && dragState.compatiblePortIds === null) + state = 'incompatible'; return ( - <circle + <SocketDot key={route.id} - className="connection-port" - data-node-id={node.id} - data-route-id={route.id} - data-side="right" + socketId={socketId} + nodeId={node.id} + side="right" + role="domain" + direction="out" + shape="square" + label={route.subdomain || 'Subdomain'} + peerStyle="Network" cx={pos.cx} cy={pos.cy} - r={isValidTarget ? 6 : 5} - fill={isValidTarget ? '#22c55e' : categoryGlow} - stroke="var(--ice-bg-base)" - strokeWidth={2} - style={{ cursor: 'crosshair' }} + isValidTarget={isValidTarget} + state={state} + opacity={dotOpacity} + // Custom Domain's row Y math is referenced by compute-path + // via `data.routeId` — keep the attribute on the DOM so + // that legacy path still works alongside `data-socket-id`. + extraAttrs={{ 'data-route-id': route.id }} /> ); - })} - - {/* Left-side port for incoming connections (none allowed but kept - consistent with other nodes — `canConnect` rejects them - anyway). */} - {(isHovered || isSelected || isValidTarget) && ( - <circle - className="connection-port" - data-node-id={node.id} - data-side="left" - cx={x} - cy={y + H / 2} - r={isValidTarget ? 6 : 5} - fill={isValidTarget ? '#22c55e' : categoryGlow} - stroke="var(--ice-bg-base)" - strokeWidth={2} - style={{ cursor: 'crosshair' }} - /> - )} + }); + })()} </g> ); }; diff --git a/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/group-lod3.test.tsx b/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/group-lod3.test.tsx index ae16b3f5..33702fc4 100644 --- a/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/group-lod3.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/group-lod3.test.tsx @@ -198,11 +198,11 @@ describe('GroupLod3 — rect stroke / fill', () => { expect((findRect(renderL3({})).props as { strokeWidth: number }).strokeWidth).toBe(1); }); - it('strokeDasharray undefined when dragOver, "4 4" otherwise', () => { - expect( - (findRect(renderL3({ isDragOver: true })).props as { strokeDasharray?: string }).strokeDasharray, - ).toBeUndefined(); - expect((findRect(renderL3({})).props as { strokeDasharray: string }).strokeDasharray).toBe('4 4'); + it('strokeDasharray "8 4" when dragOver (drop affordance), undefined otherwise — Blender-frame chrome uses a solid border by default', () => { + expect((findRect(renderL3({ isDragOver: true })).props as { strokeDasharray?: string }).strokeDasharray).toBe( + '8 4', + ); + expect((findRect(renderL3({})).props as { strokeDasharray?: string }).strokeDasharray).toBeUndefined(); }); it('fill = groupTint', () => { @@ -226,11 +226,11 @@ describe('GroupLod3 — label + fold + content', () => { expect((lbl.props as { color?: string }).color).toBe('#abc123'); }); - it('FoldButton: opacity 0.8 when hovered, 0.4 otherwise', () => { + it('FoldButton: opacity 0.95 when hovered, 0.6 otherwise (raised from 0.8/0.4 with the tab restyle)', () => { expect((findByType(renderL3({ isHovered: true }), MockFoldButton)[0].props as { opacity: number }).opacity).toBe( - 0.8, + 0.95, ); - expect((findByType(renderL3({}), MockFoldButton)[0].props as { opacity: number }).opacity).toBe(0.4); + expect((findByType(renderL3({}), MockFoldButton)[0].props as { opacity: number }).opacity).toBe(0.6); }); it('FoldButton onClick = onToggleFold', () => { diff --git a/packages/ui/src/features/canvas/components/nodes/group-node/group-label-row.tsx b/packages/ui/src/features/canvas/components/nodes/group-node/group-label-row.tsx index a80e802d..957481d8 100644 --- a/packages/ui/src/features/canvas/components/nodes/group-node/group-label-row.tsx +++ b/packages/ui/src/features/canvas/components/nodes/group-node/group-label-row.tsx @@ -9,10 +9,27 @@ interface GroupLabelRowProps { childCount?: number; } +/** + * Blender-style frame tab — rounded top corners only, flush against the + * group's body border. The colored swatch on the left mirrors the + * group's user color so multi-group canvases stay readable at a glance. + */ export const GroupLabelRow: React.FC<GroupLabelRowProps> = memo(({ label, color, childCount }) => ( - <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> + <div + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '2px 8px', + background: color ? `${color}1A` : 'var(--ice-bg-raised)', + borderRadius: '4px 4px 0 0', + border: color ? `1px solid ${color}55` : '1px solid var(--ice-border)', + borderBottom: 'none', + maxWidth: '100%', + }} + > {color && ( - <span style={{ width: 8, height: 8, borderRadius: '50%', background: color, opacity: 0.7, flexShrink: 0 }} /> + <span style={{ width: 8, height: 8, borderRadius: '50%', background: color, opacity: 0.85, flexShrink: 0 }} /> )} <span style={{ @@ -24,6 +41,7 @@ export const GroupLabelRow: React.FC<GroupLabelRowProps> = memo(({ label, color, textTransform: 'uppercase', pointerEvents: 'none', flex: 1, + minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -38,6 +56,9 @@ export const GroupLabelRow: React.FC<GroupLabelRowProps> = memo(({ label, color, fontSize: 10, fontWeight: 500, fontFamily: FONT_MONO, + padding: '0 4px', + borderRadius: 3, + background: 'var(--ice-bg-hover)', flexShrink: 0, }} > diff --git a/packages/ui/src/features/canvas/components/nodes/group-node/group-lod3.tsx b/packages/ui/src/features/canvas/components/nodes/group-node/group-lod3.tsx index 78c8138d..71cab4bc 100644 --- a/packages/ui/src/features/canvas/components/nodes/group-node/group-lod3.tsx +++ b/packages/ui/src/features/canvas/components/nodes/group-node/group-lod3.tsx @@ -86,7 +86,8 @@ export const GroupLod3: React.FC<GroupLod3Props> = memo( )} {isChildExiting && <ChildExitingIndicator x={x} y={y} width={nodeWidth} height={nodeHeight} />} - {/* Dashed border body */} + {/* Solid frame border — Blender-style. Drag-over still falls + back to dashed so the drop affordance is unambiguous. */} <rect x={x} y={y} @@ -96,23 +97,24 @@ export const GroupLod3: React.FC<GroupLod3Props> = memo( fill={groupTint} stroke={getBorderColor()} strokeWidth={isSelected ? 1.5 : 1} - strokeDasharray={isDragOver ? undefined : '4 4'} - strokeOpacity={0.6} + strokeDasharray={isDragOver ? '8 4' : undefined} + strokeOpacity={0.85} /> - {/* Label row above box + fold chevron */} - <foreignObject x={x} y={y} width={nodeWidth} height={22}> - <div style={{ display: 'flex', alignItems: 'center', padding: '0 4px' }}> - <div style={{ flex: 1 }}> - <GroupLabelRow label={displayLabel} color={userColor} childCount={childCount} /> - </div> - <FoldButton folded={folded} onClick={onToggleFold} opacity={isHovered ? 0.8 : 0.4} /> + {/* Label tab — anchored top-left, flush against the border, with + child-count badge. The fold chevron sits at the tab's right edge. */} + <foreignObject x={x + 8} y={y - 18} width={nodeWidth - 16} height={20}> + <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> + <GroupLabelRow label={displayLabel} color={userColor} childCount={childCount} /> + <span style={{ flex: 1 }} /> + <FoldButton folded={folded} onClick={onToggleFold} opacity={isHovered ? 0.95 : 0.6} /> </div> </foreignObject> - {/* Empty state */} + {/* Empty state — label is now a tab outside the body, so the + empty hint centers within the full frame. */} {!folded && childCount === 0 && ( - <foreignObject x={x} y={y + 24} width={nodeWidth} height={nodeHeight - 24}> + <foreignObject x={x} y={y} width={nodeWidth} height={nodeHeight}> <EmptyStateText text={t('canvas.nodes.dropHere')} /> </foreignObject> )} diff --git a/packages/ui/src/features/canvas/components/nodes/reroute-node/__tests__/passthrough.test.ts b/packages/ui/src/features/canvas/components/nodes/reroute-node/__tests__/passthrough.test.ts new file mode 100644 index 00000000..eee27c1a --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/reroute-node/__tests__/passthrough.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { findPassthroughCategory } from '../passthrough'; +import type { CanvasConnection } from '../../../types'; + +function edge(id: string, from: string, to: string, category?: string): CanvasConnection { + return { + id, + from, + to, + data: category ? { connectionCategory: category } : undefined, + }; +} + +describe('findPassthroughCategory', () => { + it('returns the incoming edge category when present', () => { + const conns = [edge('e1', 'a', 'reroute', 'traffic'), edge('e2', 'reroute', 'b', 'config')]; + expect(findPassthroughCategory('reroute', conns)).toBe('traffic'); + }); + + it('falls back to outgoing edge category when no incoming edge', () => { + const conns = [edge('e1', 'reroute', 'b', 'config')]; + expect(findPassthroughCategory('reroute', conns)).toBe('config'); + }); + + it('returns null when reroute is disconnected', () => { + expect(findPassthroughCategory('reroute', [])).toBe(null); + }); + + it('returns null when no edge carries a connectionCategory', () => { + const conns = [edge('e1', 'a', 'reroute')]; + expect(findPassthroughCategory('reroute', conns)).toBe(null); + }); +}); diff --git a/packages/ui/src/features/canvas/components/nodes/reroute-node/passthrough.ts b/packages/ui/src/features/canvas/components/nodes/reroute-node/passthrough.ts new file mode 100644 index 00000000..227ece68 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/reroute-node/passthrough.ts @@ -0,0 +1,22 @@ +/** + * Reroute color derivation. + * + * A reroute is a passthrough — it has no semantic category of its own. + * We pick a color by looking at the wire(s) flowing through it: the + * first incoming (or outgoing, if no incoming) edge donates its + * connection category. If the reroute is disconnected, callers fall + * back to TRAFFIC (the most common wire type) so the dot stays visible. + */ + +import type { CanvasConnection } from '../../types'; +import type { ConnectionCategory } from '@ice/constants'; + +export function findPassthroughCategory(nodeId: string, connections: CanvasConnection[]): ConnectionCategory | null { + // Prefer an incoming edge so the color flows in the direction of data. + const incoming = connections.find((c) => c.to === nodeId); + const outgoing = connections.find((c) => c.from === nodeId); + const cat = (incoming?.data?.connectionCategory ?? outgoing?.data?.connectionCategory) as + | ConnectionCategory + | undefined; + return cat ?? null; +} diff --git a/packages/ui/src/features/canvas/components/path/__tests__/compute-path.test.ts b/packages/ui/src/features/canvas/components/path/__tests__/compute-path.test.ts index 4fbf9e85..1fbdfc2f 100644 --- a/packages/ui/src/features/canvas/components/path/__tests__/compute-path.test.ts +++ b/packages/ui/src/features/canvas/components/path/__tests__/compute-path.test.ts @@ -56,6 +56,119 @@ const baseArgs = (over: Partial<ComputePathArgs> = {}): ComputePathArgs => ({ ...over, }); +describe('socket-aware magnetic routing', () => { + it('uses the socket sides from edge.data when sockets exist', () => { + // Postgres source (Database.PostgreSQL) with `traffic-out`? No — + // Postgres has only `traffic-in` by default. Use Backend with + // `traffic-out` on the right side. + const from = node({ + id: 'a', + x: 0, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Compute.Worker' }, + }); + const to = node({ + id: 'b', + x: 300, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Database.PostgreSQL' }, + }); + const result = computePath( + baseArgs({ + connection: conn({ data: { sourceSocket: 'traffic-out', targetSocket: 'traffic-in' } }), + fromNode: from, + toNode: to, + }), + ); + expect(result).not.toBeNull(); + expect(result!.exitSide).toBe('right'); + expect(result!.entrySide).toBe('left'); + expect(result!.start?.x).toBe(100); // right edge of source + expect(result!.end?.x).toBe(300); // left edge of target + }); + + it('migrates the attach side when the target is in the opposite half-plane', () => { + // Source on the right of canvas, target FAR LEFT — preferred socket + // side is right, but target is left → attach migrates to left. + const from = node({ + id: 'a', + x: 500, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Compute.Worker' }, + }); + const to = node({ + id: 'b', + x: 0, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Database.PostgreSQL' }, + }); + const result = computePath( + baseArgs({ + connection: conn({ data: { sourceSocket: 'traffic-out', targetSocket: 'traffic-in' } }), + fromNode: from, + toNode: to, + }), + ); + expect(result).not.toBeNull(); + // Source's preferred = right, but target is to the left → migrate to left. + expect(result!.exitSide).toBe('left'); + expect(result!.start?.x).toBe(500); // left edge of source (at x=500) + }); + + it('falls back to chooseSides when neither socket id is set', () => { + const from = node({ id: 'a', x: 0, y: 0, width: 100, height: 50, data: {} }); + const to = node({ id: 'b', x: 300, y: 0, width: 100, height: 50, data: {} }); + const result = computePath( + baseArgs({ + connection: conn({ data: {} }), + fromNode: from, + toNode: to, + }), + ); + expect(result).not.toBeNull(); + expect(result!.exitSide).toBe('right'); + expect(result!.entrySide).toBe('left'); + }); + + it('falls back to chooseSides when a socket id is set but the socket no longer exists', () => { + const from = node({ + id: 'a', + x: 0, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Compute.Worker' }, + }); + const to = node({ + id: 'b', + x: 300, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Database.PostgreSQL' }, + }); + const result = computePath( + baseArgs({ + connection: conn({ data: { sourceSocket: 'nonexistent', targetSocket: 'nonexistent' } }), + fromNode: from, + toNode: to, + }), + ); + // Dangling sockets → graceful fallback to chooseSides, never null. + expect(result).not.toBeNull(); + expect(result!.exitSide).toBe('right'); + expect(result!.entrySide).toBe('left'); + }); +}); + describe('rf-conpath-7: computePath — null/missing nodes', () => { it('returns null when fromNode is missing', () => { expect(computePath(baseArgs({ fromNode: undefined }))).toBeNull(); @@ -157,7 +270,13 @@ describe('rf-conpath-7: computePath — CustomDomain row override', () => { expect(out!.pathD).toMatch(/^M 100 \d+(\.\d+)? L 300 \d+(\.\d+)?$/); }); - it('unmatched routeId falls back to chooseSides + getEdgePoint', () => { + it('unmatched routeId — sourceSocket inference still resolves a CD row anchor', () => { + // Pre-unification this test exercised a legacy "row override + // fallback" branch. In the unified model, an edge without an + // explicit `sourceSocket` runs through `inferEdgePorts`, which on + // a Network.CustomDomain with routes picks one of the `domain-out-{id}` + // ports. The wire anchors at that port's bespoke row Y via + // `getSocketCanvasPosition`, not at the generic side midpoint. const c = conn({ data: { routeId: 'nonexistent' } }); const args = baseArgs({ connection: c, @@ -167,32 +286,13 @@ describe('rf-conpath-7: computePath — CustomDomain row override', () => { }); const out = computePath(args); expect(out).not.toBeNull(); - // Generic side selection: dx>0 dominant → exit right (x=100), enter - // left (x=300). Source is 100x200, port mid → y=100. Target is - // 100x50, port mid → y=25. - expect(out!.pathD).toBe('M 100 100 L 300 25'); - }); - - it('row override picks entry side relative to start point (not source midpoint)', () => { - // Route 0 anchors start near the top of the source. Target is below - // and to the right but vertically aligned — entry should pick top - // because dy from the row-port to the target dominates. - const c = conn({ data: { routeId: 'r1' } }); - const args = baseArgs({ - connection: c, - fromNode: cdSource({ x: 0, y: 0, width: 100, height: 200 }), - toNode: node({ id: 'b', x: 50, y: 1000, width: 100, height: 50 }), // far below - edgeStyle: 'rectangular', - }); - const out = computePath(args); - expect(out).not.toBeNull(); - // Entry side is top — rectangular path with right→top mixed branch - // (outX = startX + GAP). startX = 100 (source right edge), GAP=20. - // Path emerges with the elbow points. - expect(out!.pathD).toContain('120'); + // Source exit is the CD's right edge (x=100); the Y is row-anchored + // (matches `getCustomDomainRoutePortY`) — we don't pin the exact + // value because it changes with row-height constants. + expect(out!.pathD).toMatch(/^M 100 \d+(\.\d+)? L 300 \d+(\.\d+)?$/); }); - it('row override falls through to no special handling when iceType is not CustomDomain', () => { + it('legacy edge with no socket info on non-CustomDomain falls through to side-midpoint routing', () => { const c = conn({ data: { routeId: 'r2' } }); const args = baseArgs({ connection: c, @@ -202,33 +302,18 @@ describe('rf-conpath-7: computePath — CustomDomain row override', () => { y: 0, width: 100, height: 50, - data: { iceType: 'Compute.WebApp' }, // not CustomDomain + data: { iceType: 'Compute.WebApp' }, // not CustomDomain, no schema entry }), toNode: node({ id: 'b', x: 200, y: 0, width: 100, height: 50 }), edgeStyle: 'straight', }); const out = computePath(args); expect(out).not.toBeNull(); - expect(out!.pathD).toBe('M 100 25 L 200 25'); - }); -}); - -describe('rf-conpath-7: computePath — port-slot plumbing', () => { - it('passes sourcePortIndex/Count + targetPortIndex/Count to getEdgePoint', () => { - // Source 100x100, port 1 of 3 → r=0.5 → y=50. - // Target 100x100, port 0 of 2 → r=1/3 → y≈33.33. - const args = baseArgs({ - fromNode: node({ id: 'a', x: 0, y: 0, width: 100, height: 100 }), - toNode: node({ id: 'b', x: 200, y: 0, width: 100, height: 100 }), - sourcePortIndex: 1, - sourcePortCount: 3, - targetPortIndex: 0, - targetPortCount: 2, - edgeStyle: 'straight', - }); - const out = computePath(args); - expect(out).not.toBeNull(); - // Source y = 100 * 2/4 = 50, Target y = 100 * 1/3 ≈ 33.333… - expect(out!.pathD).toMatch(/^M 100 50 L 200 33\.\d+$/); + // Without typed sockets on either end, the path falls through to + // chooseSides + magnetic-attach. Both 100x50 blocks have port-Y + // clamped to the corner margin (12px), so y=25 (midpoint) or + // clamped value — we just check the X anchors are on the inner + // edges. + expect(out!.pathD).toMatch(/^M 100 \d+(\.\d+)? L 200 \d+(\.\d+)?$/); }); }); diff --git a/packages/ui/src/features/canvas/components/path/__tests__/magnetic-attach.test.ts b/packages/ui/src/features/canvas/components/path/__tests__/magnetic-attach.test.ts new file mode 100644 index 00000000..9e9e5eed --- /dev/null +++ b/packages/ui/src/features/canvas/components/path/__tests__/magnetic-attach.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { getMagneticAttach, slideAlong, getAnchorPoint, DEFAULT_PERIMETER_MARGIN } from '../magnetic-attach'; +import type { Bounds, Point, Side } from '../types'; + +const block: Bounds = { x: 100, y: 100, width: 200, height: 100 }; + +function center(b: Bounds): Point { + return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; +} + +describe('getMagneticAttach', () => { + it('keeps the attach on the preferred side when target is in that half-plane', () => { + // Target to the right of block; preferred = right. + const target: Point = { x: 600, y: 150 }; + const { attach, side } = getMagneticAttach(block, 'right', target); + expect(side).toBe('right'); + expect(attach.x).toBe(block.x + block.width); // right edge + }); + + it('migrates to the opposite side when target is in the opposite half-plane', () => { + // Target to the FAR left of block; preferred = right (e.g. socket + // is anchored right, but the partner sits to the left). + const target: Point = { x: -200, y: 150 }; + const { side } = getMagneticAttach(block, 'right', target); + expect(side).toBe('left'); + }); + + it('respects the preferred side when geometry only weakly disagrees (above instead of right)', () => { + // Target above block; preferred = right. We keep right (no flip to opposite half-plane). + const target: Point = { x: 200, y: -200 }; + const { side } = getMagneticAttach(block, 'right', target); + expect(side).toBe('right'); + }); + + it('slides the attach along the side toward the target projection', () => { + // Target far down-right; on the right side, y should slide down toward target.y. + const target: Point = { x: 600, y: 600 }; + const { attach } = getMagneticAttach(block, 'right', target); + expect(attach.y).toBe(block.y + block.height - DEFAULT_PERIMETER_MARGIN); + }); + + it('clamps to corner margin so the attach never reaches the literal corner', () => { + // Target far away vertically beyond the block's bottom. + const target: Point = { x: 1000, y: 9999 }; + const { attach } = getMagneticAttach(block, 'right', target, 20); + expect(attach.y).toBe(block.y + block.height - 20); + }); + + it('tie-break: dx === dy uses vertical branch (strict > matches chooseSides)', () => { + // Target at +200 dx, +200 dy from center → equal magnitudes; vertical wins. + const c = center(block); + const target: Point = { x: c.x + 200, y: c.y + 200 }; + // Preferred = right → opposite = left. Facing axis with strict > falls to vertical → bottom. + // 'bottom' !== opposite[right]==='left', so preferredSide wins → 'right'. + // We assert via slideAlong + getMagneticAttach side resolution explicitly: + const { side } = getMagneticAttach(block, 'right', target); + expect(side).toBe('right'); + }); +}); + +describe('slideAlong', () => { + it.each<[Side, (b: Bounds, t: Point) => Point]>([ + ['left', (b) => ({ x: b.x, y: 0 })], + ['right', (b) => ({ x: b.x + b.width, y: 0 })], + ['top', (b) => ({ x: 0, y: b.y })], + ['bottom', (b) => ({ x: 0, y: b.y + b.height })], + ])('places attach on the named side: %s', (side, makeExpected) => { + const target: Point = { x: 150, y: 150 }; + const p = slideAlong(block, side, target); + if (side === 'left' || side === 'right') { + expect(p.x).toBe(makeExpected(block, target).x); + } else { + expect(p.y).toBe(makeExpected(block, target).y); + } + }); +}); + +describe('getAnchorPoint', () => { + it('returns the side midpoint for the idle drag-start dot', () => { + expect(getAnchorPoint(block, 'left')).toEqual({ x: 100, y: 150 }); + expect(getAnchorPoint(block, 'right')).toEqual({ x: 300, y: 150 }); + expect(getAnchorPoint(block, 'top')).toEqual({ x: 200, y: 100 }); + expect(getAnchorPoint(block, 'bottom')).toEqual({ x: 200, y: 200 }); + }); +}); diff --git a/packages/ui/src/features/canvas/components/path/compute-path.ts b/packages/ui/src/features/canvas/components/path/compute-path.ts index e134e29b..922cd983 100644 --- a/packages/ui/src/features/canvas/components/path/compute-path.ts +++ b/packages/ui/src/features/canvas/components/path/compute-path.ts @@ -1,56 +1,46 @@ /** * Top-level path-builder dispatcher: turns a (connection, from-node, - * to-node, port slot, edge style, lod/zoom) bundle into the - * `{ pathD, midX, midY }` triple the orchestrator hands to its `<path>`. + * to-node, edge style, lod/zoom) bundle into the `{ pathD, midX, midY, + * start, end, exitSide, entrySide }` result the orchestrator hands to + * its `<path>`. * - * Extracted as the rf-conpath-7 (orchestrator slim-down) helper of the - * svg-connection-path decomposition. The orchestrator's `useMemo` body - * was a ~70-LOC pure function over its arguments (no DOM, no React - * state) and lifted cleanly. Pulling it out gives the orchestrator one - * import in place of an inline branch tree, and exposes the dispatch - * to fixture-style tests without rendering the React tree. + * Unified resolution model (post-`socket-position`): * - * Dispatch order (preserved verbatim from the original orchestrator): - * 1. If either node is missing, return `null` (orchestrator renders - * nothing). - * 2. Determine `(exitSide, entrySide, start)`: - * a. Special case: `Network.CustomDomain` source AND the edge - * carries a `routeId` AND the source has a matching route in - * `data.routes` → anchor the start point to the row's port-Y - * (computed via `getCustomDomainRoutePortY`), force exit side - * to `'right'`, pick entry side relative to where the start - * point sits (NOT the source's bounds midpoint, so the curve - * doesn't loop back if the target is above/below the row). - * b. The "route was deleted but the edge still references it" - * fallback path runs `chooseSides(effFrom, effTo)` like the - * general case. - * c. General case: `chooseSides(effFrom, effTo)` + a - * `getEdgePoint`-based start computed from the source's port - * index/count. - * 3. Compute `end = getEdgePoint(effTo, entrySide, ...)`. - * 4. Dispatch on `edgeStyle`: - * - `'straight'` → `buildStraightPath`. - * - `'rectangular'` → if `connection.data.routePoints` has 3+ - * points, try `buildDagreRoutedPath`; if that returns null, - * fall back to `buildRectangularPath`. - * - default (`'bezier'`) → `buildBezierPath`. + * 1. Resolve both endpoints' `PortDef` from `edge.data.sourceSocket` + * / `targetSocket`. If either is missing, fill in via + * `inferEdgePorts` so legacy edges still anchor to the right + * typed dots without a data migration. + * 2. Look up each end's canvas-space position via + * `getSocketCanvasPosition` — the SINGLE function the canvas uses + * to know "where does this socket dot live?" Custom Domain row + * ports, standard typed-socket distribution, and any future + * bespoke renderer all route through it. + * 3. Fall back to chooseSides + magnetic-attach only when neither end + * has a socket id AND inference produced no port. + * 4. Dispatch on `edgeStyle` (bezier / straight / rectangular) and + * enrich the result with the resolved start/end/sides. * - * The CustomDomain-row tie-break uses strict `>` for the - * `Math.abs(dx) > Math.abs(dy)` axis pick, mirroring `chooseSides`'s - * tie-break (vertical wins on equal-magnitude). DO NOT cross-port - * with `connection-preview.ts`'s `>=` — see the dominant-axis-tie- - * breaks-are-load-bearing-do-not-cross-port learning. + * Why one path instead of three: the canvas had distinct branches for + * the CustomDomain row case, the typed-socket case, and the legacy + * case, each with its own end-Y math. They disagreed in subtle ways — + * e.g. CustomDomain-row pinned the source Y but used the side midpoint + * for the target, so wires "landed at the wrong socket" on multi-port + * blocks. Funnel everything through `getSocketCanvasPosition` and the + * wires and the dots agree by construction. */ +import { findPort, getPortsForNode, inferEdgePorts, type PortDef } from '@ice/types'; import { chooseSides, getEdgePoint, getEffectiveBounds } from './bounds-and-sides'; -import { getCustomDomainRoutePortY } from '../nodes/custom-domain'; import { buildBezierPath } from './builders/bezier'; import { buildDagreRoutedPath } from './builders/dagre-routed'; import { buildRectangularPath } from './builders/rectangular'; import { buildStraightPath } from './builders/straight'; +import { getMagneticAttach } from './magnetic-attach'; +import { getSocketCanvasPosition } from './socket-position'; import type { PathResult, Point, Side } from './types'; import type { EdgeStyle } from '../../../../store/slices/ui-slice'; import type { CanvasConnection, CanvasNode } from '../svg-canvas'; +import type { ConnectionCategory } from '@ice/constants'; export interface ComputePathArgs { connection: CanvasConnection; @@ -87,65 +77,78 @@ export function computePath(args: ComputePathArgs): PathResult | null { const effFrom = getEffectiveBounds(fromNode, lod, zoom); const effTo = getEffectiveBounds(toNode, lod, zoom); - const fromIce = (fromNode.data?.iceType as string) || ''; - const routeId = (connection.data as { routeId?: string } | undefined)?.routeId; - // Network.CustomDomain exposes per-row connection ports that the path - // should anchor to EXACTLY (not at the generic right-side midpoint). - // Works for standalone and nested-inside-PrivateNetwork usage alike — - // the CD's routes are always on its own right edge. - const isCustomDomainSource = fromIce === 'Network.CustomDomain' && !!routeId; - const isRowSource = isCustomDomainSource; + // ── Resolve socket endpoints ─────────────────────────────────────── + // + // When the edge carries socket ids, fetch each end's PortDef so we + // know its declared anchor side. For pre-port-aware edges, infer the + // best pair from the schemas + category — purely visual, the storage + // stays untouched until the user explicitly locks in. + const edgeData = (connection.data ?? {}) as { + sourceSocket?: string; + targetSocket?: string; + connectionCategory?: ConnectionCategory; + }; + let sourceSocket: PortDef | undefined = edgeData.sourceSocket ? findPort(fromNode, edgeData.sourceSocket) : undefined; + let targetSocket: PortDef | undefined = edgeData.targetSocket ? findPort(toNode, edgeData.targetSocket) : undefined; + if (!sourceSocket || !targetSocket) { + const inferred = inferEdgePorts( + sourceSocket ? [sourceSocket] : getPortsForNode(fromNode), + targetSocket ? [targetSocket] : getPortsForNode(toNode), + edgeData.connectionCategory ?? null, + ); + if (!sourceSocket) sourceSocket = inferred.sourcePort; + if (!targetSocket) targetSocket = inferred.targetPort; + } + // ── Position each end via the unified socket-position helper ─────── let exitSide: Side; let entrySide: Side; let start: Point; + let end: Point; - if (isRowSource) { - const routes = (fromNode.data?.routes as Array<{ id: string; subdomain: string }> | undefined) || []; - const rowIndex = routes.findIndex((r) => r.id === routeId); - if (rowIndex >= 0) { - exitSide = 'right'; - start = { - x: effFrom.x + effFrom.width, - y: effFrom.y + getCustomDomainRoutePortY(rowIndex), - }; - // Entry side picked relative to where the start point sits, not - // the source bounds midpoint, so the curve doesn't loop back if - // the target is above/below the row. - const dx = effTo.x + effTo.width / 2 - start.x; - const dy = effTo.y + effTo.height / 2 - start.y; - if (Math.abs(dx) > Math.abs(dy)) { - entrySide = dx > 0 ? 'left' : 'right'; - } else { - entrySide = dy > 0 ? 'top' : 'bottom'; - } - } else { - // Route was deleted but the edge still references it — fall back - // to the generic side selection. - const sides = chooseSides(effFrom, effTo); - exitSide = sides.exitSide; - entrySide = sides.entrySide; - start = getEdgePoint(effFrom, exitSide, sourcePortIndex, sourcePortCount); - } + const sourcePos = sourceSocket ? getSocketCanvasPosition(fromNode, sourceSocket.id) : null; + const targetPos = targetSocket ? getSocketCanvasPosition(toNode, targetSocket.id) : null; + + if (sourcePos && sourceSocket) { + start = sourcePos; + exitSide = sourceSocket.side; + } else if (sourceSocket) { + // Schema knew the side but the position lookup failed (rare — + // e.g. dangling port). Use the side midpoint as a safe fallback. + exitSide = sourceSocket.side; + start = getEdgePoint(effFrom, exitSide, sourcePortIndex, sourcePortCount); } else { + // Fully untyped edge — chooseSides + magnetic-attach for the + // legacy "anonymous wire" feel. const sides = chooseSides(effFrom, effTo); exitSide = sides.exitSide; + const toCenter: Point = { x: effTo.x + effTo.width / 2, y: effTo.y + effTo.height / 2 }; + start = getMagneticAttach(effFrom, exitSide, toCenter).attach; + } + + if (targetPos && targetSocket) { + end = targetPos; + entrySide = targetSocket.side; + } else if (targetSocket) { + entrySide = targetSocket.side; + end = getEdgePoint(effTo, entrySide, targetPortIndex, targetPortCount); + } else { + const sides = chooseSides(effFrom, effTo); entrySide = sides.entrySide; - start = getEdgePoint(effFrom, exitSide, sourcePortIndex, sourcePortCount); + const fromCenter: Point = { x: effFrom.x + effFrom.width / 2, y: effFrom.y + effFrom.height / 2 }; + end = getMagneticAttach(effTo, entrySide, fromCenter).attach; } - const end = getEdgePoint(effTo, entrySide, targetPortIndex, targetPortCount); - if (edgeStyle === 'straight') return buildStraightPath(start, end); + const enrich = (r: PathResult): PathResult => ({ ...r, start, end, exitSide, entrySide }); + + if (edgeStyle === 'straight') return enrich(buildStraightPath(start, end)); if (edgeStyle === 'rectangular') { - // If auto-layout left us a routed polyline on this edge, follow - // it — dagre already bent the path around obstacles. Fall back to - // a plain L when the route is absent or too short. const routePoints = (connection.data as { routePoints?: Point[] } | undefined)?.routePoints; if (routePoints && routePoints.length >= 3) { const routed = buildDagreRoutedPath(routePoints, start, end); - if (routed) return routed; + if (routed) return enrich(routed); } - return buildRectangularPath(start, end, exitSide, entrySide); + return enrich(buildRectangularPath(start, end, exitSide, entrySide)); } - return buildBezierPath(start, end, exitSide, entrySide); + return enrich(buildBezierPath(start, end, exitSide, entrySide)); } diff --git a/packages/ui/src/features/canvas/components/path/magnetic-attach.ts b/packages/ui/src/features/canvas/components/path/magnetic-attach.ts new file mode 100644 index 00000000..e81827af --- /dev/null +++ b/packages/ui/src/features/canvas/components/path/magnetic-attach.ts @@ -0,0 +1,114 @@ +/** + * Magnetic perimeter attach. + * + * Given a node's bounds and a "preferred" side (the visible socket's + * default anchor), pick the actual perimeter attach point for a wire + * heading to (or from) a target. The attach migrates around the + * perimeter to the side facing the target, then slides along that side + * to the closest projection of the target's center — clamped to a + * margin inside the corners so wires never collide with the block's + * rounded edges. + * + * This is what makes wires read like geometry nodes: typed sockets live + * on the L/R sides by default, but the actual wire endpoint takes the + * shortest visual path. Pair with `getAnchorPoint` (idle drag-start dot + * at the socket's declared side) — the two diverge whenever the + * partner is in a half-plane other than the one the anchor side faces. + * + * Diverges intentionally from `bounds-and-sides.chooseSides` only in + * what it returns: `chooseSides` is bounds-to-bounds, this is + * bounds-to-arbitrary-point and includes the slid attach point. The + * tie-break (strict `>`) is identical so the two helpers agree on + * dominant-axis classification — DO NOT cross-port from + * `connection-preview.ts` which uses `>=`. + */ + +import type { Bounds, Point, Side } from './types'; + +/** Pixels reserved at each end of a side so wires don't collide with the corner radius. */ +export const DEFAULT_PERIMETER_MARGIN = 12; + +export interface MagneticAttachResult { + /** Where the wire actually attaches to the node perimeter. */ + attach: Point; + /** Which side of the node the wire exits/enters. */ + side: Side; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** + * Picks the side of `bounds` facing `targetCenter`, then slides the + * attach point along that side to the nearest projection of the target. + * + * `preferredSide` biases the choice: when the target is in the + * half-plane on the preferred side (or on the axis perpendicular to + * it), the attach stays on the preferred side. Otherwise it migrates + * to whichever side faces the target. This gives smooth behavior — + * sockets stay where the schema put them most of the time, but step + * around the perimeter to keep wires short when geometry demands it. + */ +export function getMagneticAttach( + bounds: Bounds, + preferredSide: Side, + targetCenter: Point, + margin = DEFAULT_PERIMETER_MARGIN, +): MagneticAttachResult { + const cx = bounds.x + bounds.width / 2; + const cy = bounds.y + bounds.height / 2; + const dx = targetCenter.x - cx; + const dy = targetCenter.y - cy; + + const facing: Side = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'bottom' : 'top'; + + // Honor `preferredSide` when geometry doesn't strongly disagree — + // i.e. only override the preference when the target is in the + // opposite half-plane. Sliding within the preferred side handles + // small angle differences; only large ones flip to the facing side. + const opposite: Record<Side, Side> = { left: 'right', right: 'left', top: 'bottom', bottom: 'top' }; + const side: Side = facing === opposite[preferredSide] ? facing : preferredSide; + + return { attach: slideAlong(bounds, side, targetCenter, margin), side }; +} + +/** Where on `side` is the closest point to `targetCenter`, clamped to a corner margin. */ +export function slideAlong(bounds: Bounds, side: Side, targetCenter: Point, margin = DEFAULT_PERIMETER_MARGIN): Point { + switch (side) { + case 'left': + return { + x: bounds.x, + y: clamp(targetCenter.y, bounds.y + margin, bounds.y + bounds.height - margin), + }; + case 'right': + return { + x: bounds.x + bounds.width, + y: clamp(targetCenter.y, bounds.y + margin, bounds.y + bounds.height - margin), + }; + case 'top': + return { + x: clamp(targetCenter.x, bounds.x + margin, bounds.x + bounds.width - margin), + y: bounds.y, + }; + case 'bottom': + return { + x: clamp(targetCenter.x, bounds.x + margin, bounds.x + bounds.width - margin), + y: bounds.y + bounds.height, + }; + } +} + +/** Resolves the visible idle dot point at the side's midpoint — drag-start affordance. */ +export function getAnchorPoint(bounds: Bounds, side: Side): Point { + switch (side) { + case 'left': + return { x: bounds.x, y: bounds.y + bounds.height / 2 }; + case 'right': + return { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }; + case 'top': + return { x: bounds.x + bounds.width / 2, y: bounds.y }; + case 'bottom': + return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }; + } +} diff --git a/packages/ui/src/features/canvas/components/path/types.ts b/packages/ui/src/features/canvas/components/path/types.ts index d752abbf..6714f2d3 100644 --- a/packages/ui/src/features/canvas/components/path/types.ts +++ b/packages/ui/src/features/canvas/components/path/types.ts @@ -43,4 +43,12 @@ export interface PathResult { pathD: string; midX: number; midY: number; + /** Wire's actual exit point from the source — may differ from the visible socket dot when magnetic routing is active. */ + start?: Point; + /** Wire's actual entry point on the target — may differ from the visible socket dot when magnetic routing is active. */ + end?: Point; + /** Source side the wire exits — used by the orchestrator to draw tails or hover overlays. */ + exitSide?: Side; + /** Target side the wire enters. */ + entrySide?: Side; } diff --git a/packages/ui/src/features/canvas/components/svg-canvas.tsx b/packages/ui/src/features/canvas/components/svg-canvas.tsx index dc593048..153b6765 100644 --- a/packages/ui/src/features/canvas/components/svg-canvas.tsx +++ b/packages/ui/src/features/canvas/components/svg-canvas.tsx @@ -30,6 +30,8 @@ import { useCanvasDrop } from '../hooks/use-canvas-drop'; import { useCanvasEffects } from '../hooks/use-canvas-effects'; import { useCanvasHandlers } from '../hooks/use-canvas-handlers'; import { useCanvasInteractionsBindings } from '../hooks/use-canvas-interactions-bindings'; +import { Spotlight } from './add-menu/spotlight'; +import { useSpotlightShortcut } from './add-menu/use-spotlight-state'; import { useCanvasMouseRouting } from '../hooks/use-canvas-mouse-routing'; import { useCanvasDimensions } from '../hooks/use-canvas-resize'; import { useCanvasSelectors } from '../hooks/use-canvas-selectors'; @@ -43,11 +45,14 @@ import { useContainerMove } from '../hooks/use-container-move'; import { useContainerResize } from '../hooks/use-container-resize'; import { useDragTargetHighlight } from '../hooks/use-drag-target-highlight'; import { useGhostMode } from '../hooks/use-ghost-mode'; +import { useGroupShortcut } from '../hooks/use-group-shortcut'; import { usePinnedUserNode } from '../hooks/use-pinned-user-node'; import { useRenameState } from '../hooks/use-rename-state'; import { useRenderCtx } from '../hooks/use-render-ctx'; import { isContainerNode } from '../utils/node-classification'; +import { ConnectionDragProvider } from './nodes/_shared/connection-drag-context'; import { OrphanNodesProvider } from './nodes/_shared/orphan-context'; +import { SocketHoverTooltip } from './nodes/_shared/socket-hover-tooltip'; import type { AppDispatch } from '../../../store'; // rf-canv-1: re-export shim — the canonical home for these three types is @@ -337,6 +342,7 @@ export const SvgCanvas: React.FC<SvgCanvasProps> = ({ cardId, paneId, onFocus }) const { drawingConnection, connectionDragTargets, + connectionDragInfo, rejection: connectionRejection, handleConnectionPortDown, handleConnectionMove, @@ -433,52 +439,68 @@ export const SvgCanvas: React.FC<SvgCanvasProps> = ({ cardId, paneId, onFocus }) `./canvas-renderer/canvas-content`. Visual draw order, prop flow, and dep arrays are preserved verbatim. */} <OrphanNodesProvider value={orphanNodeIds}> - <CanvasContent - viewport={viewport} - dimensions={dimensions} - canvasConnections={canvasConnections} - effectiveNodes={effectiveNodes} - portMap={portMap} - animatingEdges={animatingEdges} - pipelineNodeStatus={pipelineNodeStatus} - selectedNodes={selectedNodes} - selectedEdges={selectedEdges} - hoveredNodeId={hoveredNodeId} - lod={lod} - edgeStyle={edgeStyle} - handleConnectionHover={handleConnectionHover} - handleEdgeDelete={handleEdgeDelete} - handleEdgeSelect={handleEdgeSelect} - handleContextMenu={handleContextMenu} - sortedNodes={sortedNodes} - animatingNodes={animatingNodes} - shiftDraggingNodeIds={shiftDraggingNodeIds} - dragOverGroupId={dragOverGroupId} - renderCtx={renderCtx} - drawingConnection={drawingConnection} - connectionDragTargets={connectionDragTargets} - connectionRejection={connectionRejection} - showVirtualUserNode={showVirtualUserNode} - userConnections={userConnections} - nodesWithUserNode={nodesWithUserNode} - pinnedUserPos={pinnedUserPos} - setUserNodePos={setUserNodePos} - ghosts={ghosts} - nodes={nodes} - onAcceptGhost={handleAcceptGhost} - onDismissGhost={handleDismissGhost} - /> + <ConnectionDragProvider value={connectionDragInfo}> + <CanvasContent + viewport={viewport} + dimensions={dimensions} + canvasConnections={canvasConnections} + effectiveNodes={effectiveNodes} + portMap={portMap} + animatingEdges={animatingEdges} + pipelineNodeStatus={pipelineNodeStatus} + selectedNodes={selectedNodes} + selectedEdges={selectedEdges} + hoveredNodeId={hoveredNodeId} + lod={lod} + edgeStyle={edgeStyle} + handleConnectionHover={handleConnectionHover} + handleEdgeDelete={handleEdgeDelete} + handleEdgeSelect={handleEdgeSelect} + handleContextMenu={handleContextMenu} + sortedNodes={sortedNodes} + animatingNodes={animatingNodes} + shiftDraggingNodeIds={shiftDraggingNodeIds} + dragOverGroupId={dragOverGroupId} + renderCtx={renderCtx} + drawingConnection={drawingConnection} + connectionDragTargets={connectionDragTargets} + connectionRejection={connectionRejection} + showVirtualUserNode={showVirtualUserNode} + userConnections={userConnections} + nodesWithUserNode={nodesWithUserNode} + pinnedUserPos={pinnedUserPos} + setUserNodePos={setUserNodePos} + ghosts={ghosts} + nodes={nodes} + onAcceptGhost={handleAcceptGhost} + onDismissGhost={handleDismissGhost} + /> + </ConnectionDragProvider> </OrphanNodesProvider> </svg> {/* Connection tooltip — follows mouse, rendered as HTML overlay */} <ConnectionTooltip info={connTooltip} /> + {/* Socket hover chip — instant styled tooltip on socket dot hover. */} + <SocketHoverTooltip /> + {/* Controls help button — bottom-right */} <ControlsHelpModal /> {/* Context Menu overlay */} <CanvasContextMenu /> + + {/* Shift+A spotlight add-block menu + the key listener that opens it. */} + <SpotlightMount screenToCanvas={screenToCanvas} /> </div> ); }; + +const SpotlightMount: React.FC<{ screenToCanvas: (cx: number, cy: number) => { x: number; y: number } }> = ({ + screenToCanvas, +}) => { + useSpotlightShortcut({ screenToCanvas }); + useGroupShortcut(); + return <Spotlight />; +}; diff --git a/packages/ui/src/features/canvas/components/svg-connection-path.tsx b/packages/ui/src/features/canvas/components/svg-connection-path.tsx index 24e4fe92..b55bdaf6 100644 --- a/packages/ui/src/features/canvas/components/svg-connection-path.tsx +++ b/packages/ui/src/features/canvas/components/svg-connection-path.tsx @@ -12,7 +12,10 @@ * connection type. */ +import { CATEGORY_COLORS } from '@ice/constants'; +import { findPort, getPortsForNode, hasPort, inferEdgePorts, ROLE_CATEGORY, type PortDef } from '@ice/types'; import React, { memo, useMemo, useState, useCallback, useRef } from 'react'; +import { CATEGORY_STYLE } from '../../../config/canvas-constants'; import { EDGE_COLORS } from '../../../config/color-palette'; import { useReducedMotion } from '../../../shared/hooks/use-reduced-motion'; import { inferConnectionMeta, type ConnectionCategory } from '../utils/connection-rules'; @@ -20,6 +23,17 @@ import { computePath } from './path/compute-path'; import type { CanvasNode, CanvasConnection } from './svg-canvas'; import type { EdgeStyle } from '../../../store/slices/ui-slice'; +/** Resolve a wire color from a typed port — prefers the peer block's + * category accent (matches the socket dot), falls back to the abstract + * connection-category color. */ +function portColor(port: PortDef): string { + if (port.peerStyle) { + const style = CATEGORY_STYLE[port.peerStyle]; + if (style?.glow) return style.glow; + } + return CATEGORY_COLORS[ROLE_CATEGORY[port.role]]; +} + // ─── Tooltip info passed up to canvas ─────────────────────────────────────── export interface ConnectionTooltipInfo { @@ -122,6 +136,48 @@ export const SvgConnectionPath: React.FC<SvgConnectionPathProps> = memo( const categoryColor = (connection.data?.color as string) || derivedMeta?.color || null; const trafficType = (connection.data?.trafficType as string) || derivedMeta?.trafficType || null; const isLogEdge = relationship === 'logs_to' || trafficType === 'stream'; + + // Dangling edge: the edge references a typed socket that no longer + // exists on its source or target node (because a property toggle + // removed it). Render orange dashed so the user can decide whether + // to clean it up — see properties-panel dangling sweep affordance. + const sourceSocketId = (connection.data?.sourceSocket as string) || ''; + const targetSocketId = (connection.data?.targetSocket as string) || ''; + + // Socket-derived wire color. The wire visually inherits the same + // color as the socket dots it joins — a repository wire is grey + // (Source), a domain wire is rose (Network), a database wire is + // green (Database). Falls back to the abstract category color when + // no typed sockets are present (legacy edges). + const socketColor = useMemo(() => { + let port: PortDef | undefined; + if (sourceSocketId && fromNode) { + port = findPort({ id: fromNode.id, type: fromNode.type, data: fromNode.data }, sourceSocketId); + } + if (!port && targetSocketId && toNode) { + port = findPort({ id: toNode.id, type: toNode.type, data: toNode.data }, targetSocketId); + } + if (!port && fromNode && toNode) { + const inferred = inferEdgePorts( + getPortsForNode({ id: fromNode.id, type: fromNode.type, data: fromNode.data }), + getPortsForNode({ id: toNode.id, type: toNode.type, data: toNode.data }), + connCategory, + ); + port = inferred.sourcePort ?? inferred.targetPort; + } + return port ? portColor(port) : null; + }, [sourceSocketId, targetSocketId, fromNode, toNode, connCategory]); + const isDangling = useMemo(() => { + if ( + sourceSocketId && + fromNode && + !hasPort({ id: fromNode.id, type: fromNode.type, data: fromNode.data }, sourceSocketId) + ) + return true; + if (targetSocketId && toNode && !hasPort({ id: toNode.id, type: toNode.type, data: toNode.data }, targetSocketId)) + return true; + return false; + }, [sourceSocketId, targetSocketId, fromNode, toNode]); const isDashedEdge = lineStyle === 'dashed' || isLogEdge; const isDottedEdge = lineStyle === 'dotted'; const isThinEdge = lineStyle === 'thin'; @@ -225,15 +281,21 @@ export const SvgConnectionPath: React.FC<SvgConnectionPathProps> = memo( // Styling — subtle by default, just brighten on hover const directionColor = direction ? EDGE_COLORS[direction] : null; - // Use category color as the base, fall back to relationship color - const baseColor = categoryColor || EDGE_COLORS[relationship] || EDGE_COLORS.default; + // Socket-derived color wins so the wire matches the dots it joins. + // Category color (from inferConnectionMeta) is the legacy fallback. + const baseColor = socketColor || categoryColor || EDGE_COLORS[relationship] || EDGE_COLORS.default; + // Dangling edges render in warning amber so the user can spot them + // even at idle. Selection / hover still take priority for affordance. + const danglingColor = '#d97706'; const strokeColor = isSelected ? EDGE_COLORS.selected : isHighlighted ? directionColor || baseColor || EDGE_COLORS.hover : isHover ? EDGE_COLORS.hover - : baseColor; + : isDangling + ? danglingColor + : baseColor; // Inverse-zoom scale factor — keeps strokes visible at low zoom const invZoom = 1 / Math.max(zoom, 0.1); @@ -247,19 +309,24 @@ export const SvgConnectionPath: React.FC<SvgConnectionPathProps> = memo( : lod <= 2 ? 1.2 * invZoom : baseWidth; + // Connections are first-class — they represent the architecture's + // data flow. Render them fully visible at idle so the user can + // read the graph without hovering each wire. Thin edges (e.g. log + // streams) still sit a notch quieter so they don't compete with + // primary traffic. const strokeOpacity = isSelected - ? 0.7 + ? 1 : isHighlighted - ? 0.6 + ? 0.95 : isHover - ? 0.7 + ? 1 : lod <= 1 - ? 0.4 + ? 0.7 : lod <= 2 - ? 0.35 + ? 0.8 : isThinEdge - ? 0.12 - : 0.15; + ? 0.6 + : 0.9; // Hover target must stay large enough on screen const hoverTargetWidth = lod < 3 ? Math.max(16, 24 * invZoom) : 16; const showLabels = lod >= 3; @@ -298,9 +365,9 @@ export const SvgConnectionPath: React.FC<SvgConnectionPathProps> = memo( stroke={pipelineActive ? '#3b82f6' : strokeColor} strokeWidth={pipelineActive ? 2 * (lod < 3 ? invZoom : 1) : strokeWidth} fill="none" - strokeDasharray={isDashedEdge ? '6 4' : isDottedEdge ? '2 3' : undefined} + strokeDasharray={isDangling ? '5 4' : isDashedEdge ? '6 4' : isDottedEdge ? '2 3' : undefined} strokeLinecap="round" - opacity={pipelineActive ? 0.6 : strokeOpacity} + opacity={pipelineActive ? 0.6 : isDangling ? 0.7 : strokeOpacity} /> {/* Pipeline flow animation — animated dashes flowing along the path */} diff --git a/packages/ui/src/features/canvas/hooks/use-group-shortcut.ts b/packages/ui/src/features/canvas/hooks/use-group-shortcut.ts new file mode 100644 index 00000000..4d44b205 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/use-group-shortcut.ts @@ -0,0 +1,46 @@ +/** + * useGroupShortcut + * + * Window-level Cmd+G / Ctrl+J listener that wraps the current node + * selection in a `Group.Custom` container. Backed by the existing + * `groupSelectedNodes` reducer in the cards slice, so the operation + * is undoable for free. + * + * Ignored while an input/textarea is focused (so Cmd+G in the + * properties panel finds the next match in the browser's native + * find-in-page instead of grouping nodes the user can't see). + */ + +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { groupSelectedNodes } from '../../../store/slices/cards-slice'; +import type { AppDispatch, RootState } from '../../../store'; + +export function useGroupShortcut(): void { + const dispatch = useDispatch<AppDispatch>(); + const selectedNodeIds = useSelector((s: RootState) => s.selection.selectedNodes); + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement || + (e.target instanceof HTMLElement && e.target.isContentEditable) + ) + return; + + // Cmd+G on Mac, Ctrl+J on Windows/Linux (Blender's frame-around- + // selection binding is Ctrl+J — Cmd+G is the Mac convention for + // "group these things together"). + const isCmdG = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'g' && !e.shiftKey; + const isCtrlJ = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j' && !e.shiftKey; + if (!isCmdG && !isCtrlJ) return; + if (selectedNodeIds.length < 2) return; + e.preventDefault(); + dispatch(groupSelectedNodes(selectedNodeIds)); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [dispatch, selectedNodeIds]); +} diff --git a/packages/ui/src/features/canvas/utils/connection-rejection.ts b/packages/ui/src/features/canvas/utils/connection-rejection.ts index 200d337e..be8e5c9a 100644 --- a/packages/ui/src/features/canvas/utils/connection-rejection.ts +++ b/packages/ui/src/features/canvas/utils/connection-rejection.ts @@ -14,7 +14,13 @@ import { t } from '../../../i18n'; export type RejectionCause = | { kind: 'no-rule' } | { kind: 'special-conflict'; label: string } - | { kind: 'validation-error'; message: string }; + | { kind: 'validation-error'; message: string } + /** + * The drag started from a typed port (e.g. `repository-out`) and the + * target block has no matching IN port for that role. Carries the + * source port's role so the message can be specific. + */ + | { kind: 'role-mismatch'; role: string }; /** "Database.MySQL" → "MySQL"; "Compute.ServerlessFunction" → "Serverless Function". * @@ -33,6 +39,13 @@ export function buildRejectionMessage(srcIceType: string, tgtIceType: string, ca if (cause.kind === 'special-conflict') { return t('canvas.rejection.specialConflict', { label: cause.label }); } + if (cause.kind === 'role-mismatch') { + const tgt = humanizeIceType(tgtIceType) || t('canvas.rejection.fallbackTgt'); + // No i18n key for this yet — inline English with the role surfaced + // so the user sees exactly what's expected. Translators can take + // it later via the standard key extraction pass. + return `${tgt} has no ${cause.role} input`; + } const src = humanizeIceType(srcIceType) || t('canvas.rejection.fallbackSrc'); const tgt = humanizeIceType(tgtIceType) || t('canvas.rejection.fallbackTgt'); return t('canvas.rejection.noRule', { src, tgt }); diff --git a/packages/ui/src/features/palette/__tests__/components-data.test.ts b/packages/ui/src/features/palette/__tests__/components-data.test.ts index 25c3f1f6..84d1ff8d 100644 --- a/packages/ui/src/features/palette/__tests__/components-data.test.ts +++ b/packages/ui/src/features/palette/__tests__/components-data.test.ts @@ -128,8 +128,8 @@ describe('def — fallback branch', () => { // ─── COMPONENTS data ───────────────────────────────────────────────────────── describe('COMPONENTS — count', () => { - it('declares 24 blocks (verbatim from source — the source comment says "25" but the array has 24)', () => { - expect(COMPONENTS).toHaveLength(24); + it('declares 25 blocks (Reroute added under Util in the geometry-nodes refactor)', () => { + expect(COMPONENTS).toHaveLength(25); }); }); @@ -160,6 +160,7 @@ describe('COMPONENTS — declaration order by type', () => { 'Monitoring.Log', 'Source.Repository', 'Config.Environment', + 'Util.Reroute', ]); }); }); @@ -235,6 +236,7 @@ describe('COMPONENTS — category', () => { 'Monitoring', 'Source', 'Config', + 'Util', ]), ); }); diff --git a/packages/ui/src/features/palette/data/components.ts b/packages/ui/src/features/palette/data/components.ts index 9ec95b00..75164eaa 100644 --- a/packages/ui/src/features/palette/data/components.ts +++ b/packages/ui/src/features/palette/data/components.ts @@ -163,5 +163,11 @@ export function getComponents(t: Translator): ComponentDef[] { def(t, 'Source.Repository', GitBranch, ['aws', 'gcp', 'azure'], 'Source'), // ── Config ── def(t, 'Config.Environment', Cog, ['aws', 'gcp', 'azure'], 'Config'), + // ── Util ── + def(t, 'Util.Reroute', Waypoints, ['aws', 'gcp', 'azure'], 'Util', undefined, { + name: 'Reroute', + description: 'Pass-through dot to bend wires cleanly. No deploy footprint.', + tooltip: 'Pass-through routing dot — keeps wires tidy without altering the graph.', + }), ]; } diff --git a/packages/ui/src/features/properties/utils/port-spec.ts b/packages/ui/src/features/properties/utils/port-spec.ts new file mode 100644 index 00000000..39f72bfd --- /dev/null +++ b/packages/ui/src/features/properties/utils/port-spec.ts @@ -0,0 +1,62 @@ +/** + * PortListField — each entry is a port the block exposes to the network. + * + * Stored as JSON strings in `node.data.exposed_ports[]` so the existing + * list-based property machinery (undo/redo coalescing, persistence) keeps + * working without changes. The compact text form `https:443` is accepted + * on read for hand-edited values. + */ + +export type PortProtocol = 'http' | 'https' | 'tcp'; + +export interface PortSpec { + /** Listener port number. */ + port: number; + protocol: PortProtocol; + /** Optional human-readable label, e.g. "API" / "Healthcheck". */ + label?: string; +} + +const DEFAULT: PortSpec = { port: 8080, protocol: 'http' }; + +export function parsePort(raw: string): PortSpec { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && typeof parsed.port === 'number') { + const protocol: PortProtocol = + parsed.protocol === 'https' || parsed.protocol === 'tcp' ? parsed.protocol : 'http'; + return { + port: parsed.port, + protocol, + ...(typeof parsed.label === 'string' && parsed.label ? { label: parsed.label } : {}), + }; + } + } catch { + /* fall through to text form */ + } + // Compact text form: "https:443" / "8080" / "tcp:22:ssh" + const parts = raw.split(':'); + if (parts.length >= 2 && (parts[0] === 'http' || parts[0] === 'https' || parts[0] === 'tcp')) { + const port = Number(parts[1]); + if (Number.isFinite(port)) { + return { protocol: parts[0] as PortProtocol, port, ...(parts[2] ? { label: parts[2] } : {}) }; + } + } + const portNum = Number(raw); + if (Number.isFinite(portNum)) return { port: portNum, protocol: 'http' }; + return DEFAULT; +} + +export function stringifyPort(p: PortSpec): string { + return JSON.stringify({ + port: p.port, + protocol: p.protocol, + ...(p.label ? { label: p.label } : {}), + }); +} + +/** Default label for a port — used by the port schema when no user label is set. */ +export function defaultPortLabel(p: PortSpec): string { + const proto = p.protocol.toUpperCase(); + return p.label ? `${proto} :${p.port} (${p.label})` : `${proto} :${p.port}`; +} diff --git a/packages/ui/src/store/slices/cards/types.ts b/packages/ui/src/store/slices/cards/types.ts index 137082ab..657380e3 100644 --- a/packages/ui/src/store/slices/cards/types.ts +++ b/packages/ui/src/store/slices/cards/types.ts @@ -25,7 +25,14 @@ export interface CardEdge { id: string; source: string; target: string; - data?: { relationship?: string; [key: string]: unknown }; + data?: { + relationship?: string; + /** Identifier of the typed socket on the source node this edge attaches to. */ + sourceSocket?: string; + /** Identifier of the typed socket on the target node this edge attaches to. */ + targetSocket?: string; + [key: string]: unknown; + }; } export interface CardViewport { diff --git a/packages/ui/src/store/slices/ui-slice.ts b/packages/ui/src/store/slices/ui-slice.ts index 0f48ef33..cd798c15 100644 --- a/packages/ui/src/store/slices/ui-slice.ts +++ b/packages/ui/src/store/slices/ui-slice.ts @@ -100,6 +100,19 @@ export interface UIState { * `maxWidth` on entry and clears them on exit so guided steps fit. */ sidebarOverride: { left: number | null; right: number | null }; + + /** + * Shift+A "spotlight" add-block menu — Blender-style fuzzy-search palette + * spawned at the cursor. `canvasX`/`canvasY` are canvas-space coords used + * to position the spawned block. `recentTypes` is a small LRU of recently + * picked iceTypes for pinning at the top of the menu. + */ + spotlight: { + open: boolean; + canvasX: number; + canvasY: number; + recentTypes: string[]; + }; } // ============================================================================= @@ -165,6 +178,12 @@ const initialState: UIState = { }, splitView: PANES_DEFAULT, sidebarOverride: { left: null, right: null }, + spotlight: { + open: false, + canvasX: 0, + canvasY: 0, + recentTypes: [], + }, }; const uiSlice = createSlice({ @@ -256,6 +275,20 @@ const uiSlice = createSlice({ toggleCanvasLocked: (state) => { state.canvasLocked = !state.canvasLocked; }, + openSpotlight: (state, action: PayloadAction<{ canvasX: number; canvasY: number }>) => { + state.spotlight.open = true; + state.spotlight.canvasX = action.payload.canvasX; + state.spotlight.canvasY = action.payload.canvasY; + }, + closeSpotlight: (state) => { + state.spotlight.open = false; + }, + /** Mark an iceType as recently-used and bubble it to the top of the LRU. */ + pushSpotlightRecent: (state, action: PayloadAction<string>) => { + const iceType = action.payload; + const filtered = state.spotlight.recentTypes.filter((t) => t !== iceType); + state.spotlight.recentTypes = [iceType, ...filtered].slice(0, 8); + }, openContextMenu: ( state, action: PayloadAction<{ @@ -482,6 +515,9 @@ export const { toggleCanvasLocked, toggleValidation, openValidation, + openSpotlight, + closeSpotlight, + pushSpotlightRecent, } = uiSlice.actions; export default uiSlice.reducer; diff --git a/state/archive/.gitkeep b/state/archive/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/state/archive/learnings-2026-Q2.md b/state/archive/learnings-2026-Q2.md deleted file mode 100644 index b6082320..00000000 --- a/state/archive/learnings-2026-Q2.md +++ /dev/null @@ -1,1654 +0,0 @@ -# Learnings - -Append-only. Each entry has a kebab-case `##` anchor, a `_Discovered_` line, and one paragraph. - -**Rules** - -- New learnings: append. Never edit past entries. -- The one allowed edit to a past entry is appending a `_Promoted to: /docs/<path>_` line once the learning has stabilized — cited 3+ times, or generalizes beyond one unit — and has been written up in `/docs`. -- To supersede or contradict a past learning, append a new entry that references the old anchor. - ---- - -## read-state-first - -_Discovered: 2026-04-27 by orchestrator in unit setup_ - -Every agent reads `.claude/state/decisions.md` and `.claude/state/learnings.md` before acting on a brief. Without this, agents redo investigations the rest of the workflow has already settled, miss explicit decisions about how to approach a class of problem, and rediscover the same gotchas the critic flagged last week. Reading state is the cheapest step in the loop and the highest-leverage; skip it and the rest of the loop wastes effort. - -## destroy-needs-terminal-state - -_Discovered: 2026-04-27 by orchestrator in deploy-panel destroy fix_ - -The destroy onConfirm handler used to dispatch `appendLog(...)` then `resetDeploy()` in the same tick. `resetDeploy` wipes `state.logs` and sets status to `'idle'`, so a fast destroy looked silently inert — "Destroy button does nothing" was a real perception bug, not a wiring issue. Fix: introduced a dedicated `destroyed` `DeployStatus` and a `destroySuccess` reducer that flips to that state and pushes a final summary log line. The UI's existing Clear button (which calls `resetDeploy`) lets the user dismiss the log when ready. Generalizes: any terminal-state action that clears its own log produces an indistinguishable-from-failure UX; always land on a non-idle status long enough for the human to see what happened. - -## one-status-source-deploy-status - -_Discovered: 2026-04-27 by orchestrator in compact-node status unification_ - -The compact-node status pill used to read `(data.deploy_status as string) || (data.status as string) || ''`. That double-source was the reason "blocks still showed Active after destroy": `clearCardDeployOverlay` correctly wipes `deploy_status`, but `data.status` was seeded `'active'` by templates, blueprints, the WAF block defaults, group/node creation in svg-canvas and cards-slice, and the drift checker — none of those reflect actual deploy state. Fix: drop the legacy fallback in `compact-node/index.tsx` so `deploy_status` is the only source, and stop seeding `status: 'active'` at every node-creation site (templates/expand-template, blocks/expand-blueprint, blocks/{aws,azure,gcp}/security/waf, svg-canvas drop handlers, cards-slice group creation). Leaves any existing card data carrying stale `data.status: 'active'` ignored at render time — no migration needed because the field is no longer read by the canvas. Generalizes: when two parallel state fields exist for the same UX concept, the fallback turns one of them into the de-facto source no matter how careful the writer side is. Pick one and delete the others. - -## svg-canvas-isLogNode-precedes-renderer-map - -_Discovered: 2026-04-27 by implementer in LT-1-consolidate-icetypes_ - -`svg-canvas.tsx`'s dispatcher loop has two parallel routing layers for the same iceType: an early `isLogNode` short-circuit (line ~2715) that hands off to `SvgLogNode` directly, AND a per-iceType `CONCEPT_NODE_RENDERERS` map (line ~135) consulted later in the `isBlock`/resource-fallback branches. The short-circuit always wins. So when LT-1 added `Monitoring.Log` to `isLogNode`, the existing `'Monitoring.Log': SvgObservabilityNode` entry in the map was instantly dead code — never reached at runtime, but visually misleading to any future reader who would assume the map drives the render. Fix: when widening the `isLogNode` check, also remove the dead map entry (and its now-unused import) so there's one source of truth for the iceType→component mapping. Generalizes: any iceType that's special-cased in an early branch of a dispatcher should NOT also live in the catch-all map for the same dispatcher — it's an invitation to subtle behavior splits later. - -The iceType set that _counts as_ a log node is also duplicated across at least three sites: `packages/blocks/src/expand-blueprint.ts:173` (the helper that hardcodes the canvas-emit list of "log-shaped" iceTypes), `packages/ui/src/features/canvas/components/svg-canvas.tsx:2634-2637` (the `isLogNode` dispatcher short-circuit), and `packages/core/src/importers/gcp/type-mapper.ts:87` (the importer's `Monitoring.LogGroup` mismatch). Adding a new log-shaped iceType requires touching all three. LT-5 should export a single shared `LOG_ICE_TYPES` set from `@ice/constants` and have all three sites consume it — same medicine as `one-status-source-deploy-status`: pick one source of truth and delete the parallels. - -## data-version-bump-migrates-not-wipes - -_Discovered: 2026-04-27 by critic in LT-1-consolidate-icetypes review_ - -The cards-slice load path treats a version bump as cause to wipe `localStorage` and start fresh, then runs `migrateCardNodes` only on already-current-version payloads. Result: a v5 → v6 bump deletes the user's canvas instead of migrating the iceTypes the bump was added to migrate. The migrator function exists but is unreachable on the transition it was written for. Same shape recurs anywhere a versioned-payload loader has both "version mismatch → reset" and "migrate fields" branches but routes the bumped version through reset. Additionally: any reducer that accepts a payload of nodes/edges from outside the slice (`importToActiveCard`, `addToActiveCard`, `addNodeToCard`) is its own load path and must run the same migration pipeline — version-keyed localStorage is only one ingestion route; backend-saved canvases, AI tool-use writes, and clipboard imports all bypass it. Generalizes: when bumping a persisted-data version, write the migration as a pure function over the payload and call it from every ingestion site, not just from the localStorage loader. - -## deploy-service-package-name-is-service-deploy - -_Discovered: 2026-04-27 by implementer in LT-2-filter-resolver_ - -The deploy service's `package.json` name is `@ice/service-deploy`, NOT `@ice/deploy` — every other "service" package in `services/*` follows the `@ice/service-<name>` convention (`@ice/service-credentials`, etc.) but briefs and human shorthand sometimes call it `@ice/deploy`. Running `pnpm --filter @ice/deploy typecheck` silently no-ops (filter matches zero packages, exit 0) instead of erroring, so a wrong filter LOOKS green. Always verify the filter target exists by reading `services/<name>/package.json` before trusting a typecheck pass. Generalizes: any pnpm `--filter` against a non-existent name is a silent success; `pnpm -r typecheck` from the repo root is a safer fallback when the package name is uncertain. - -## google-cloud-logging-getentries-not-entries-list - -_Discovered: 2026-04-27 by implementer in LT-3-log-stream-service_ - -The brief talked about `entries.list({...})` and `tailLogEntries({...})` per the Cloud Logging REST API names, but the `@google-cloud/logging` Node SDK's surface is `Logging.getEntries(opts)` (returns `Promise<[Entry[], ...]>`) and `Logging.tailEntries(opts)` (returns a Duplex). Each `Entry` carries envelope fields under `entry.metadata` (`timestamp`, `insertId`, `severity`, `resource`) and the payload under `entry.data` (string for textPayload, object for jsonPayload — `JSON.stringify` it). `tailEntries`'s `data` event delivers a `TailEntriesResponse` with an `entries: Entry[]` array, not a single Entry. Don't try to map the REST shape onto the SDK shape one-to-one — read the SDK's `.d.ts` first. Also: the IAM probe in tests inflates the `getEntries` call counter by one, which means a test asserting "call N is the second poll" is off by one if it doesn't account for the probe. Make the probe an explicit early branch in test mocks (`if (call === 1) return [[]]`). - -## google-cloud-logging-loaded-via-load-sdk-from-core - -_Discovered: 2026-04-27 by implementer in LT-3-log-stream-service_ - -`@google-cloud/logging` is declared in `packages/core/package.json` and loaded at runtime via the dynamic-import wrapper `load_sdk(module_name)` in `packages/core/src/deploy/providers/gcp/sdk-loader.ts`. The deploy service does NOT have it as a direct dependency — `services/deploy/node_modules/@google-cloud/` contains nothing. Trying to `import { Logging } from '@google-cloud/logging'` from the deploy service would fail at TypeScript resolution. The right path: re-export `load_sdk` from `packages/core/src/deploy/index.ts` (so `import('@ice/core')` exposes it), then `(await core.load_sdk('@google-cloud/logging')).Logging` to construct the client. Generalizes: any GCP SDK referenced from a service outside `packages/core` should go through `load_sdk`, not a direct import — keeps the SDK lazy (so missing optional deps don't break startup) and avoids each service repeating the dependency declaration. - -## auth-derived-orgid-must-not-trust-body - -_Discovered: 2026-04-27 by implementer in LT-4-routes-and-socket_ - -The LT-3 `subscribe()` signature takes `organisationId` as a required field on `SubscribeArgs`, which is fine for the service but a footgun for any HTTP route in front of it: if the route just spreads `req.body` into the call, a client can spoof `organisationId: 'evil'` in the JSON body and route the credential lookup (`providerService.getDecryptedCredentials(organisationId, 'gcp')`) to a different tenant's GCP project. The mitigation is mechanical but non-obvious: in the route, build the args object explicitly with `{ ...validatedBody, organisationId: req.organisationId }` AFTER body validation, so the auth-derived value always wins regardless of what the body carried. I added a dedicated test (case #5 in `services/deploy/src/routes/__tests__/logs.test.ts`) that POSTs `{ ...validBody, organisationId: 'evil' }` and asserts the mock receives `'org-real'`. Generalizes: any service-layer function whose argument record happens to mix client-controlled fields and auth-derived fields needs an explicit assembly step at the route boundary — never trust `...req.body` to compose the args object directly. - -## supertest-not-in-monorepo-use-fetch-against-app-listen - -_Discovered: 2026-04-27 by implementer in LT-4-routes-and-socket_ - -The deploy service has zero supertest in its package.json (and so does `@ice/shared`); the only existing route test pattern in the repo is service-level direct function calls (`services/canvas/src/__tests__/org-isolation.int.test.ts` explicitly says "to avoid external dependencies like supertest"). For HTTP-level tests of an Express router, the working pattern is: `express()` + `app.use('/path', router)` + `app.listen(0, '127.0.0.1', ...)` (port 0 = ephemeral), capture `server.address().port`, then `fetch(\`http://127.0.0.1:${port}\`)` from the test. Node 22's built-in `fetch` plus `http.Server` is enough — no extra deps. Cleanup goes in `afterEach` via `server.close(...)`. Generalizes: don't add supertest when fetch + a real listen does the same job in 5 fewer lines and one fewer dependency. - -## frontend-cannot-import-from-services - -_Discovered: 2026-04-27 by implementer in LT-5-frontend-wiring_ - -The frontend (packages/ui) cannot import types from services/ — the workspace topology has no path from `@ice/ui` to `@ice/service-deploy`, and even adding one would couple the renderer to a server-only package. For shared API contracts (request/response shapes, socket payloads), there are exactly three viable homes: (a) inline-mirror in the slice that consumes them (cheap, accept the drift risk), (b) lift to `packages/types/src/<domain>.ts` and import from both sides (canonical for cross-package types), or (c) keep two parallel definitions with a runtime decode/validate at the boundary. For LT-5 I chose (a) because the LogEntry/SourceResolution shapes only have one frontend consumer (logs-slice) and the drift would surface immediately as a runtime failure in the hook — a malformed entry from the socket would fail the `typeof entry.insertId !== 'string'` defense in `appendEntry` and quietly drop. If a third consumer appears (LT-6 properties panel? a backend-to-backend log pipe?), promote to `packages/types/src/logs.ts` and delete the mirror. Generalizes: when a service-only type needs to cross into the renderer, the choice between (a) and (b) is purely about how many consumers exist now — one consumer is mirror, two+ is promote. - -## socket-room-and-http-lifecycle-are-two-cleanups - -_Discovered: 2026-04-27 by implementer in LT-5-frontend-wiring_ - -The Log Terminal subscription has TWO independent server-side resources that must be released on unmount: the Socket.IO room membership (released by `socket.emit('unsubscribe:logs', terminalNodeId)`) AND the polling/tail loop opened by the HTTP `/subscribe` call (released by HTTP `/unsubscribe { subscriptionId }`). Skipping either leaks: skipping the room emit leaves the client receiving events for a torn-down stream (memory leak in handler closures); skipping the HTTP unsubscribe leaks a 60s polling loop hammering Cloud Logging quota. The cleanup order in the hook is: stop listeners → leave room → POST unsubscribe → dispatch teardown. There's also a third edge case: the user unmounts WHILE the initial `/subscribe` POST is still in flight. The naive `cancelled` flag isn't enough — the request still completes and creates a server-side stream that never gets closed. Fix: in the cancelled branch of the await, fire a best-effort `unsubscribe(result.subscriptionId)` to release the just-opened stream we never used. Generalizes: any hook that spans HTTP-init + socket-room needs cleanup symmetry on BOTH paths AND a path for "init returned after we already cancelled". - -## properties-panel-section-nodeId-vs-selectedNode-prop-shape - -_Discovered: 2026-04-27 by implementer in LT-6-properties-section_ - -The properties-panel.tsx per-iceType branches (`Config.Environment`, `Network.PrivateNetwork`, `Network.CustomDomain`, etc.) thread `selectedNode={...}` plus an `updateNodeField(field, value)` callback into the inline panel components, leaning on the closure's `selectedNodeId`. `MonitoringLogSection` instead takes a single `nodeId: string` prop and re-resolves both the cards slice (via `selectActiveCard`) and the logs slice (via `selectLogStream(state, nodeId)`) through Redux, dispatching `updateCardNodeData({ nodeId, data })` directly. The reason: it's the first per-iceType section that reads from a slice OTHER than `cards`, so funneling everything through `updateNodeField` (which targets the panel's selected nodeId) would couple the section to selection state instead of the explicit prop. The relevant action is `updateCardNodeData` at `packages/ui/src/store/slices/cards-slice.ts:583`, signature `({ nodeId: string; data: Record<string, unknown> })` — it shallow-merges `data` into the existing `node.data`, which is exactly what we want. Generalizes: when adding a per-iceType section that reads outside the cards slice, prefer the `nodeId` prop shape; sections that only mutate `cards` can keep the `selectedNode + updateNodeField` shape. - -## ux-log-terminal-live-indicator-decoupled-from-status - -_Discovered: 2026-04-27 by ux-tester in LT-9-ux-test_ - -The `LogHeader` in `packages/ui/src/features/canvas/components/nodes/log-node/log-header.tsx` renders a green pulsing dot + "LIVE" badge whose `LiveIndicator` only reads the local `isAutoScroll` state — never the `status` from `useLogStream`. So pre-deploy (status `pre-deploy`, placeholder row reading "Deploy this environment to start streaming logs.") the on-canvas header still says "LIVE", and so does the header during `connecting` / `error` / `permission-denied`. The two parts of the same block disagree: header says we're streaming, body says we aren't. The `MonitoringLogSection` properties pill is correct ("Pre-deploy" / "Connecting" / "Error" pills with the right tones), but the canvas-side header is misleading. Fix: pass `status` from `useLogStream` into `LogHeader` and let `LiveIndicator` map it to LIVE / CONNECTING / PAUSED / ERROR, the same way the properties pill does. Generalizes: when a single block has two surfaces showing the same conceptual state (canvas header, properties pill), they must read from the same source — otherwise one of them lies. - -## ux-log-stream-cardhash-misses-data-changes - -_Discovered: 2026-04-27 by ux-tester in LT-9-ux-test_ - -The store-level persistence subscriber in `packages/ui/src/store/index.ts:80-87` computes a `cardHash(card)` over `id, n.length, e.length, n[0].id, n[n.length-1].id, n[n.length-1].position.x` and skips the localStorage write when the hash is unchanged. A `streamingMode` (or any `node.data.*`) flip via `updateCardNodeData` doesn't change any of those, so it never persists to localStorage — Redux holds the new value (and the radio reflects it on re-select), but a page reload restores the old value. The same shape would hit any future per-node setting that lives entirely in `node.data`: source override, retention overrides, AI tool-use writes that only touch `data`. Fix options: (a) include a stable hash of `node.data` in `cardHash`, (b) drop the hash skip and rely on the 2s debounce, (c) explicit "writes that need persistence" tag on actions. Generalizes: a content-hash skip that excludes a mutation surface is a silent data-loss path — the bug is invisible until the user reloads. - -## ux-log-stream-unsubscribe-missing-projectid - -_Discovered: 2026-04-27 by ux-tester in LT-9-ux-test_ - -The frontend's `api.logs.unsubscribe(subscriptionId)` (in `packages/ui/src/shared/api/http-api-adapter.ts:482`) POSTs `{ subscriptionId }` to `/api/canvas/logs/unsubscribe`. The route is `requireProjectAccess('viewer')`-guarded (`services/deploy/src/routes/logs.ts:110`), and the middleware in `packages/shared/src/auth/middleware.ts:85-100` requires `projectId` (or a resolvable `cardId`) in body / params / query — neither is present. Result: every unsubscribe call returns 400 ("projectId is required"), and the server-side polling/tail loop is never released. Visible in the browser console on every block deselect / mode toggle / unmount. Direct contradiction of the LT-5 `socket-room-and-http-lifecycle-are-two-cleanups` learning: the cleanup symmetry only works if both legs ACTUALLY succeed at the route. Fix: include `projectId` (or `cardId`) on the unsubscribe payload, both client-side and in the route's validation schema. Generalizes: when a route guard demands a tenant identifier, every client of that route — including teardown / cleanup paths — needs to carry it. Cleanup endpoints are still authenticated endpoints. - -## ux-log-terminal-canvas-affordances-on-hover - -_Discovered: 2026-04-27 by ux-tester in LT-9-ux-test_ - -The on-canvas Log block renders a Fold button (always visible) and a Copy button (visible on hover) inside its header. The `feedback_no_canvas_inputs` rule says canvas blocks are read-only displays with editing in the properties panel; these two buttons are arguably chrome (they manipulate local view state, not node data) but they are interactive affordances on the canvas that show on hover, which the rule's "no input affordances appear" wording can be read to forbid. Recommend codifying the rule with a "view chrome OK / data inputs not OK" carve-out, since the alternative — moving Fold and Copy-all into the properties panel — would feel awkward (Fold is a per-block view-state toggle that's most discoverable on the block itself). Generalizes: rules about "no inputs on the canvas" probably want a finer split between data inputs and view chrome before they cover the next ICE block class. - -## ux-log-resumed-event-overrides-pre-deploy - -_Discovered: 2026-04-27 by ux-tester in LT-9-ux-reverify_ - -The V4 fix (do not show "Live" pre-deploy) is intact for the `appendEntry` reducer — it correctly only promotes `connecting → streaming` on first real entry. But the `logs:resumed` socket-event handler in `useLogStream` calls `dispatch(setStatus({ status: 'streaming' }))` unconditionally, and the `setStatus` reducer in `logs-slice.ts:128-132` doesn't check `slot.source?.state` before assigning. So when the backend's tail-reconnect retry fires (which it does even when the SDK isn't loaded — `loggingClient.tailEntries` throws null-deref → catch emits recoverable `logs:error` → `scheduleTailReconnect` retries → emits `logs:resumed`), the slot status is force-flipped to `'streaming'` and both the canvas badge and the properties pill show "Live"/"LIVE" even though `source.state === 'pre-deploy'` and `entries.length === 0`. The slot from a real reproduction reads `{ status: 'streaming', source: { state: 'pre-deploy', ... }, entries: [], lastError: "Cannot read properties of null (reading 'tailEntries')" }` — perfect contradiction inside one slot. Fix: either (a) gate `setStatus`/`onResumed` on the same "only promote from connecting" check that `appendEntry` uses, or (b) gate `logs:resumed` server-side so it only emits after a successful entry (tighter contract: "resumed" should mean "we got entries flowing again", not "we managed to retry the failed call"). The promotion path must require evidence of a live stream — a retried failure isn't evidence. Generalizes: any "promote to terminal-success" reducer must check the same precondition the original promotion did, otherwise side-channel events become a back door around the gate. - -## ux-log-stream-subscribe-thrash-on-mount - -_Discovered: 2026-04-27 by ux-tester in LT-9-ux-reverify_ - -While re-verifying V1 (unsubscribe 204 fix), the network panel showed the App Logs block firing roughly 12 subscribe→unsubscribe pairs within the 2-second observation window after a single user click (still ramping after the click). All POSTs are well-formed and return correct codes — the V1 fix is intact — but the volume is excessive: the `useEffect` in `use-log-stream.ts:84-206` re-runs on every change to `[cardId, environmentId, terminalNodeId, mode, sourceNodeIdOverride, dispatch]`, and one of those (almost certainly `sourceNodeIdOverride` derived from a non-memoized `node.data` read) is producing a fresh reference on every render of `SvgLogNode`. Each re-run cancels the in-flight subscribe via `cancelled = true` and the cleanup-time `unsubscribe(result.subscriptionId, cardId)` best-effort fire. Server quota and Cloud Logging billing are hit harder than necessary, and the canvas log header flickers status during the churn. The V1 cleanup-symmetry fix masks the cost (every torn-down subscription is properly closed), but doesn't solve the underlying re-run thrash. Fix: either (a) read `streamingMode` and `sourceNodeIdOverride` via memoized selectors that return primitive values, or (b) split the effect's deps into a stable id-tuple plus a separately-tracked options object that's compared by value. Generalizes: a useEffect with object-shape deps in a hot canvas-render path will silently DDoS its own backend; effect deps should be primitives or stable references, never values lifted out of a Redux node-data blob without memoization. - -## use-selector-primitive-projection-vs-derived - -_Discovered: 2026-04-27 by implementer in LT-9-bugfix-2_ - -When a hook's `useEffect` dep is "derived from a Redux blob" (e.g. `node.data.streamingMode`), do the projection INSIDE `useSelector`, not OUTSIDE it. With one selector that returns the parent `node` object and a downstream `const mode = node?.data?.streamingMode`, every cards-slice publish forces the consumer component to re-render (Object.is on the parent fails) — and during init, transient hydration cycles can briefly hand back a non-primitive shape that DOES change deps. Doing `const mode = useSelector(s => ((s.cards.cards.find(...)?.nodes.find(...)?.data?.streamingMode) ?? 'polling'))` gives `useSelector` the chance to short-circuit re-renders when the projected primitive is unchanged, and guarantees the effect's deps array sees Object.is-stable values. Co-located: if you accept "any non-string" via type assertion (`as string | undefined`), a runtime `typeof value === 'string'` guard inside the selector closes the gap between the type system's optimism and Redux state's lived behavior during init. Generalizes: useSelector projects; component code consumes the projection. Pulling derivation OUT of useSelector is a silent re-render multiplier in any hot path. - -## debounced-persist-creates-stale-backend-reads - -_Discovered: 2026-04-27 by implementer in LT-bugfix-stale-edges_ - -The canvas's localStorage/backend persistence subscriber in `packages/ui/src/store/index.ts` debounces saves by 2000 ms. When a backend route resolves anything off the persisted card row (e.g. `prisma.canvasCard.findUnique({ select: { nodes, edges } })` in `log-stream.service.ts:resolveSource`), it sees the canvas state _as of two ticks ago_ — not what the user just did. So a fast "draw edge → click block" sequence reads pre-edge data and the source resolver returns `none` even though the canvas clearly shows the connection. Fix shape: the frontend has the truth (Redux); pass it explicitly in the request body (e.g. `candidateSources`) and have the backend prefer the client-supplied view, with the Prisma read kept ONLY as a fallback for older clients. Generalizes: any backend route that joins on a row written by a debounced client-side persist subscriber is a correctness footgun in the "user did X, immediately did Y" path. Either lift the relevant inputs into the request body (client-side resolution + server-side validation), or flush the persist before the route fires. Don't assume the DB row reflects the live UI — the debounce window is by design longer than the user's reaction time. Co-located: when adding the alternate-input path, keep the Prisma fallback so older clients keep working AND so a corrupted-frontend-state scenario can recover by re-deriving from server state. - -## one-shot-resolution-needs-state-trigger - -_Discovered: 2026-04-27 by implementer in LT-bugfix-postdeploy-resubscribe_ - -A subscription that resolves its source ONCE at subscribe time and never re-resolves is fine ONLY if every condition the resolver checks is also captured in the effect's deps — otherwise a state transition that flips the resolution outcome (here: `pre-deploy` → `resolved`) leaves the UI stuck on the original outcome forever. In `useLogStream`, the original `candidateFingerprint` projected `<sourceId>><iceType>` per inbound source — enough for "edge added/removed" and "iceType changed", but NOT for "user clicked Deploy → `deploy.service.ts:1880` writes `data.deploy_status = 'active'` to the source node → backend's `resolveSource` would now return `{ state: 'resolved' }` instead of `{ state: 'pre-deploy' }`". The deps ignored the `deploy_status` mutation, so the effect didn't re-run, no re-subscribe happened, and the canvas placeholder kept saying "Deploy this environment to start streaming logs." even hours after a successful deploy. Fix: extend the projection to `<sourceId>><iceType>><deployStatus>`. The `<deployStatus>` segment is empty (`'a>X>'`) when the field is absent and `'a>X>active'` after deploy completes, so the dep value always changes on the transition that matters and the effect re-subscribes naturally — no new socket events, no new endpoints, just a fresh `/subscribe` that the backend can re-resolve against the now-existing `deployedResourceMapping` row. Generalizes: any "subscribe-once + resolve-once" hook where the resolution depends on Redux state must include EVERY field the resolver inspects in its deps fingerprint. The set of fields you have to include is exactly the set the backend resolver reads — keep them in lock-step or add a comment explaining why one is intentionally omitted. - -## ux-real-deploy-needs-clean-gcp-precondition - -_Discovered: 2026-04-27 by ux-tester in LT-10-real-deploy-blocked_ - -Before kicking off any "real cloud" UX run, list every relevant resource in the target project (`gcloud run services list`, `gcloud sql instances list`, `gcloud redis instances list`, `gcloud storage buckets list`) and verify the canvas is in `idle` deploy state. On the LT-10 attempt, the project `lc-ice` already had: 1 Cloud SQL instance (`ice-full-sta-prod-databasein-5cbe63ad`, RUNNABLE), 6 Redis instances (5 READY from earlier runs + 1 actively CREATING — `ice-full-sta-prod-instance-8534b75b`), and the canvas hot-loaded a "Deploying… 23%" badge on the matching Redis cell. That meant a previous session had walked away mid-deploy without destroying. Pushing a fresh Deploy on top of that state would (a) double the leak, (b) make Phase 3's "transition timing from deploy completion to first entry" measurement meaningless because there's no clean "deploy started" moment, and (c) cost real money — Cloud SQL alone runs ~$50/mo per instance, Redis ~$40/mo per Standard instance, and 5 Redis + 1 SQL means the project is already burning ~$250/mo of leak. Generalizes: a real-deploy UX run needs a `gcloud`-based pre-flight that fails fast if the project isn't clean, AND the orchestrator's brief should call out "if the project has live resources, stop and clean before retrying" so the agent doesn't try to push through. The cleanup itself isn't a UX-tester responsibility — it's the orchestrator's job to either run the destroy themselves or hand the agent an explicitly clean project. - -## ux-deploy-already-exists-vs-adopt-inconsistent - -_Discovered: 2026-04-27 by ux-tester in LT-10-deploy-attempt-2_ - -Two consecutive Deploy clicks against the same partially-deployed `lc-ice` project produced different behaviors for the same set of pre-existing resources. Run #1 (1168.3s): Cloud SQL and Storage bucket were ADOPTED gracefully ("already exists from a prior deploy — adopting and converging public access"), with Cloud SQL taking 615s to converge and the bucket 1.6s. Run #2 (65.3s): the same Cloud SQL instance, Redis, API Gateway, security policy, and globalForwardingRule all hard-FAILED with "already exists" messages — only the Storage bucket continued to adopt. From the deploy panel UX, the user can't tell which path the engine will take: in run #1 they see a single block deploy taking minutes (good signal — work happening), in run #2 they see seven blocks turn red in seconds with terse "already exists" errors (bad signal — sounds catastrophic when it's actually idempotency-guard rejection of resources that _should_ be adoptable). The remediation CTA "Clean up orphaned ICE resources" appears regardless, which is correct — but the panel should distinguish "adoptable but rejected this run" from "genuinely broken" so the user knows the difference. Generalizes: when an engine has both an "adopt-existing" and "fail-on-existing" code path for the same resource type, the UI must call out which path was taken — silence reads as "the engine is broken" when it's really "the engine chose strict mode this time". - -## ux-deploy-progress-pill-resets-per-resource - -_Discovered: 2026-04-27 by ux-tester in LT-10-deploy-attempt-2_ - -The bottom-of-canvas status pill ("Deploying X%") and the canvas-overlay node-level pill ("Deploying… X%") share the same X% number, but X is per-resource progress, not overall deploy progress. At t+90s the API Server build was at 59% and the pill said "Deploying 59%"; at t+210s the deploy moved to the logging sink resource and the pill reset to "Deploying 0%"; at t+390s it moved to the network resource and was again "Deploying 0%". An observer reading the pill gets the impression that progress went 59% → 0% → 0% — looks like the deploy stalled or restarted, when really 6+ resources had been completed and the engine had moved to the next one. The deploy panel header keeps an honest "Deploying" tone-of-voice but doesn't surface "step 7 of 13" or "≈45% overall". Generalizes: a single percentage that resets on every step transition is worse than no percentage at all; either show "step N/M" alongside, or weight the percentage by total work, or drop it entirely on the canvas pill and keep it only inside the deploy panel where the column of resource rows gives context. - -## ux-pre-deploy-orchestrator-claim-vs-gcloud-truth - -_Discovered: 2026-04-27 by ux-tester in LT-10-deploy-attempt-3_ - -The orchestrator handed me a brief stating "GCP project `lc-ice` was nuked clean — VPC quota 1/5 (only `default` network), no Cloud SQL, no Redis, no LB infra, no API Gateway, no buckets, no secrets" and asked me to drive a fresh Deploy. The pre-flight `gcloud` check (per the `ux-real-deploy-needs-clean-gcp-precondition` learning) found the exact opposite: 2 VPCs (default + `ice-full-sta-prod-network-8eb3fce4`), 1 Cloud SQL (RUNNABLE, created 22:08:20Z — within the last 25 min), 1 Redis (READY, 22:17:54Z), 1 storage bucket (22:21:49Z), 1 secret, a security policy, a backend service, and a global forwarding rule — all labeled `ice-card-id=cmoh24gso000b7oay4cwn584j` matching the prior `full-stack-web-app-moh24gsl` project. The canvas itself was already showing "Deploying 5%" on the bottom status pill before I touched anything, with a "1 error" badge from a prior failure. The orchestrator's mental model and the actual cloud state had diverged — likely because a destroy was started but never fully completed, or because what looked like a clean run from inside the IDE didn't translate to a clean GCP project. Lesson: even when the orchestrator says the precondition is met, the ux-tester's pre-flight is non-negotiable; trust `gcloud` over the prose. Not surfaced as a planner/implementer issue, just a multi-agent state-handoff caveat — the ux-tester is the only agent that touches real cloud, so it's the only agent that can verify the precondition. Generalizes: any agent acting on a "the world is in state X" assertion from another agent must independently verify X if the cost of being wrong is real-world side-effects (money spent, leaked resources, double-deploys onto an already-mid-deploy canvas). - -## scheduler-ready-list-must-reserve-per-handler-cap - -_Discovered: 2026-04-28 by implementer in pdl-1_ - -The first cut of `ParallelChangeScheduler.collect_ready` only reserved against the global `pool_size` (`if (this.in_flight.size + ready.length >= this.pool_size) break;`) and called `can_take_slot` per-node, where `can_take_slot` checked the per-handler cap by reading `this.handler_in_flight` — which doesn't get incremented until `dispatch` runs LATER, after `collect_ready` has already returned a list. With three `gcp.sql.databaseInstance` siblings and `gcp.sql. = 1`, the first iteration of `collect_ready` saw `handler_in_flight.gcp.sql. === 0` for ALL three, returned them all, and then `dispatch` fired all three concurrently — completely ignoring the cap. The test caught it: the SQL siblings finished in 32ms instead of 90ms (3 × 30ms serial). Fix: track BOTH `pool_reserved` and `handler_reserved` as local Maps inside `collect_ready` itself, combining them with the in-flight counters when checking caps. Generalizes: any "two-phase scheduling" loop (collect-then-dispatch) where the dispatch phase mutates the bookkeeping the collect phase reads MUST do its own within-phase reservations, otherwise siblings can collude past whatever cap the collect phase was supposed to enforce. The bug looks identical to "the cap doesn't work" but the actual cause is "the cap was never visible to the collector because the dispatcher hadn't published yet." - -## scheduler-resource-name-vs-graph-node-id-vs-canvas-node-id - -_Discovered: 2026-04-28 by implementer in pdl-1_ - -Three different identifiers travel through the deploy stack and they are NOT interchangeable: (1) **canvas node id** — the user-facing block id from `cards-slice.nodes[i].id` (e.g. `cmoh24gso000b7oay4cwn584j`); (2) **graph node id** — built by `MutableGraph.add_node` as `${type}:${name}` (e.g. `gcp.sql.databaseInstance:ice-foo-prod-instance-abc123`); (3) **resource name** — the sanitized, hash-suffixed cloud-resource name (e.g. `ice-foo-prod-instance-abc123`). The brief's hand-off contract said "`change.id` traces to `deployables.node_id`" but the actual chain is: `change.id == graph_node_id`, NOT canvas node id. The mapping `graph_node_id → canvas_node_id` lives in `card-translator.ts`'s `deployables[]` array (one entry per deployable, with `node_id` = canvas id and `resource_name` = graph node's `.name`). Inside the engine + scheduler we emit `node_id = change.id = graph_node_id` because that's what's available at that layer; pdl-4 (service layer) is responsible for translating to canvas node id via `deployables`. I marked this in the scheduler.ts comment block and in the `NodeStatusEvent.node_id` JSDoc to keep the contract clear. Generalizes: when three identifiers exist for the "same thing," each layer's events carry the most-stable id available at that layer; the boundary translates. Don't pretend the layers share an id space — they don't. - -## cloud-build-helper-substep-shares-outer-index - -_Discovered: 2026-04-28 by implementer in pdl-3_ - -The `on_step` contract is "1-based, monotonic, never exceeds total" — a UI consumer reading `index/total` needs both invariants or the progress bar lurches. When a slow handler (cloud-run) delegates a multi-minute sub-operation (cloud-build), the naive "let the helper emit its own indices" approach blows the contract: the outer `total=4` schedule produces `[1/4, 2/4, 3/4, 4/4, 5/4]` once the helper's "Cloud Build queued" / "Cloud Build running" events stream through. The fix in pdl-3 is to keep ALL build-helper sub-states at the SAME outer index (the one the caller reserved for "Building from source"), so the consumer sees the label refresh in place rather than the bar advancing. The signature for the helper's optional callback is `reportStep(index, label)` even though the index is functionally always 1 from the helper's view — the caller binds a closure that maps the helper's index to its own outer index. The cloud-run wiring looks like `(_inner, label) => reportStep(2, label)`. Generalizes: any nested-handler call where the inner work is one logical step from the outer perspective should pin its sub-states to the outer index, not advance past it. Document the pinning at the outer call site and at the inner helper, since the contract surfaces in both. - -Test detail: the cloud-build-helper sleeps `BUILD_POLL_INTERVAL_MS = 10_000` between status polls, which makes vi's `useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 20 })` mode burn 500 real ticks per simulated 10s sleep. Switch to plain `vi.useFakeTimers()` and drive time forward explicitly with `await vi.advanceTimersByTimeAsync(15_000)` in a loop after kicking off the call without await. Fast: 12 tests in 180ms instead of 42s. - -## socket-service-module-scoped-io-needs-vi-resetmodules-per-test - -_Discovered: 2026-04-28 by implementer in pdl-2_ - -`packages/shared/src/socket/service.ts` keeps the Socket.IO server in a module-scoped `let _io` that `setupSocketService` writes once. That makes the module _stateful across tests in the same file_: a test that sets up the server leaks `_io` into the next test, which means the "no `setupSocketService` call → `_io === null` guard fires" assertion can pass for the wrong reason (or fail when the previous test's spy on `console.warn` is still active). Fix in the new `socket-deploy-events.test.ts`: each `it` calls `vi.resetModules()` then `await import('../socket/service.js')` to get a pristine module, and `vi.restoreAllMocks()` runs in `afterEach`. The existing `socket-logs.test.ts` only has one `it` so it never tripped this; with five-plus emitter tests in one file, the inter-test isolation matters. Generalizes: any module that owns mutable singleton state (a server handle, a connection pool, a registry) needs `vi.resetModules()` between tests in the same file — `beforeEach` resets aren't enough because the module-scoped binding is captured at first import time, not at the export call. The smoking-gun symptom is "test A passes alone, test B passes alone, both pass when run in either order, but the third test added later flakes" — i.e. order-coupled state leakage. - -## seq-allocation-must-be-shared-between-wire-and-log - -_Discovered: 2026-04-28 by implementer in pdl-4_ - -The pdl-2 wire contract requires every `DeployEvent` to carry a monotonic `seq: number` so reconnecting clients can dedupe replayed log rows against live emits. The natural-looking implementation — `recordDeployEvent` allocates seq for the persistent log, the wire emit also gets one — produces a DRIFT between the two: the same logical event ends up with seq=N on the wire and seq=N+1 in the DB row, so a reconnecting client that resumes from the DB tape and continues live will see "duplicate" events that aren't actually duplicates. Fix in pdl-4: split `recordDeployEvent` into a separate `nextDeploySeq(cardId)` allocator + `recordDeployEvent(cardId, seq, type, payload)` consumer; the wire emit calls `nextDeploySeq` first, sets it on `event.seq`, fires the wire helper, THEN passes the same seq to `recordDeployEvent`. For events emitted OUTSIDE an active deploy (the requirement-poller, the build-phase pipeline log), `nextDeploySeq` returns null and we fall back to `Date.now()` as the seq — those events are rare, idempotent, and the dedup-on-reconnect contract isn't load-bearing for them (they're point-in-time updates, not a replayable tape). Generalizes: any "live emit + persistent log" pair where consumers reconcile across both sides must share the sequence number from a single allocator. Two separate counters look like they work in development (the tape and the live stream both grow monotonically) and silently double-emit events the moment a client reconnects mid-deploy. - -## graph-id-vs-canvas-id-translation-is-service-layer-job - -_Discovered: 2026-04-28 by implementer in pdl-4_ - -The scheduler's `NodeStatusEvent.node_id` field carries `${type}:${name}` (the engine-internal graph node id from `MutableGraph.add_node`), NOT the canvas node id, despite what the original JSDoc on `packages/core/src/deploy/types.ts:104` claimed ("Canvas node id (stable, sourced from change.id which traces to deployables.node_id)"). The actual data flow: `change.id == graph_node_id`, traced back through `desired_node.id` in `diff.ts:71` to `create_node_id(\`${input.type}:${input.name}\`)`in`mutable-graph.ts:97`. The wire contract (pdl-2's `DeployNodeStatusEvent.node_id`) requires the CANVAS id because that's what the frontend keys its `nodesById`map on. Translation has to happen exactly once, at the service layer, against a`graphIdToCanvasId` map built ONCE per deploy (`Map<\`${resource_type}:${resource_name}\`, canvas_node_id>`) from `translation.deployables[]`. On a missing translation, DROP the wire emit + warn — the alternative ("emit with sentinel id") would silently miscorrelate a status row to the wrong canvas block, which is worse than a missing row. The destroy/rollback paths intentionally don't have the map (they walk historical deployments, not the current canvas-translator output) so they emit log lines instead of per-resource node_status. Updated the JSDoc on types.ts:104 in pdl-4 — "Graph node id (`${type}:${name}`); the service layer translates to canvas node id before emitting on the wire". Generalizes: when three identifier spaces (canvas, graph, resource_name) flow through a layered system, every wire-contract field that holds an id has ONE definition of which space's id it carries. The service layer is the right place to do the translation because that's where both the engine output (graph ids) and the user-facing input (canvas ids) are simultaneously visible — pushing the translation up into the engine is wrong (the engine doesn't know about canvas ids), pushing it down into the frontend is wrong (the frontend doesn't have the deployables[] mapping). - -## stale-core-dist-blocks-cross-package-type-imports - -_Discovered: 2026-04-28 by implementer in pdl-4_ - -`packages/core/package.json` has `"types": "./dist/index.d.ts"` in its `exports` map, and `moduleResolution: bundler` (the resolution mode used by service-deploy and other workspace packages) prefers the `types` field over `default`. So when the source has been edited but the dist hasn't been rebuilt — exactly the situation pdl-1 left things in for `NodeStatusEvent` / `NodeProgressEvent` — `import type { NodeStatusEvent } from '@ice/core'` fails with TS2305 "no exported member" even though the source clearly exports it. Two ways out: (a) rebuild `pnpm --filter @ice/core build` (BUT the core typecheck has 29 pre-existing TS2834 errors that block the build), (b) inline the type locally in the consuming package as a structural mirror of the upstream interface, with a comment explaining the situation and the cleanup path. Pdl-4 chose (b) — `interface SchedulerNodeStatusEvent` lives in `services/deploy/src/services/deploy.service.ts` and is documented as a sync requirement against `packages/core/src/deploy/types.ts`. Generalizes: when a workspace package's `types` field points at a stale dist, NEW types added to source are invisible to consumers regardless of how the source-side exports are structured. The "right" fix is rebuilding the dist; the pragmatic fix is a documented local mirror. Don't waste cycles trying export tricks (`@ice/core/deploy/types`, etc.) — those subpaths aren't in the exports map either, and adding them touches a contract bigger than the immediate need. - -## point-types-at-source-not-dist-in-workspace-packages - -_Discovered: 2026-04-28 by orchestrator in pdl-4 critic-fix pass — supersedes `stale-core-dist-blocks-cross-package-type-imports`_ - -The cleaner fix to the issue described in `stale-core-dist-blocks-cross-package-type-imports` is repointing `@ice/core`'s `types` field at `./src/index.ts` (matching every other workspace package — `@ice/blocks`, `@ice/db`, `@ice/templates`, `@ice/shared`, `@ice/types`, `@ice/ui`, all six services). `@ice/core` was the only outlier pointing `types` at dist, which is the asymmetry the local mirror was working around. Consumers use `moduleResolution: bundler`, which doesn't enforce node16's TS2834 file-extension rule, so the 29 pre-existing core-typecheck errors don't propagate to consumers — the dist-vs-source choice on `types` was load-bearing only because of a one-package inconsistency, not because of a real build constraint. After the repoint: drop the local mirror in `services/deploy/src/services/deploy.service.ts`, switch to `import type { NodeStatusEvent, NodeProgressEvent } from '@ice/core'`, all four workspace typechecks remain green. Generalizes: when a TS workspace package needs to expose a type to peers, point `types` at source (`./src/index.ts`) — the dist-d.ts pattern only makes sense for published packages outside the monorepo. A local mirror that drifts is strictly worse than a one-line package.json fix that brings the package in line with its peers. - -## requirement-verified-needs-full-tenancy-key-on-the-wire - -_Discovered: 2026-04-28 by critic in pdl-4 review_ - -The `BlockRequirementStatus` table is uniquely keyed on `(card_id, node_id, environment, requirement_id)` — four fields. The pdl-2 contract for `DeployRequirementVerifiedEvent` initially carried only `card_id` + `requirement` (a footgun introduced because the pdl-2 brief froze the wire shape without tracing through the existing `use-deploy-subscription.ts:132` consumer that reads `event.node_id`, `event.environment`, `event.details.managed_status`, `event.details.domain_statuses`). After pdl-4, a frontend reducer would have NO way to disambiguate "the cert flipped" between two custom-domain blocks on the same canvas, or one block across two environments — it would over-apply the status to every match. Critic blocker B1 caught this; the fix widened the contract to require `node_id` + `environment` and surface an optional `details` blob. Generalizes: when freezing a wire contract for a row whose database identity is composite, every key field must be on the wire — even if the immediate use case is "just card-level, the frontend can look it up". Consumer-side lookup pushes the disambiguation to a place where the consumer doesn't have the data anymore. The contract-freeze step in pdl-2-style work should explicitly trace each consumer's read pattern before locking the shape. - -## seq-schemes-on-shared-channel-need-jsdoc-discrimination - -_Discovered: 2026-04-28 by critic in pdl-4 review_ - -The pdl-4 wire layer carries TWO incompatible `seq` schemes on the single `deploy:event` socket channel: deploy-tape events use small monotonic ints (1, 2, 3, ...) allocated by `nextDeploySeq(cardId)`; requirement-poller events use `Date.now()` (~1.7e12) because they fire post-deploy with no active deploy snapshot. A frontend reducer that sorts the unified stream by `seq` would scatter requirement events to the end and break monotonicity assumptions. The fix isn't to unify the schemes (the requirement events have no deploy to seq against) — it's to make the discrimination explicit on the contract: `DeployRequirementVerifiedEvent.seq` JSDoc now spells out the different scheme and tells consumers to "route by `event.type` first, then sort within each scheme". Generalizes: when a single channel carries multiple event types with different sequencing semantics, the contract must expose the discrimination at the type level — buried code comments aren't enough, because the next person writing a reducer will sort first and discover the issue at runtime. JSDoc on the offending field is the cheapest contract surface. - -## frontend-channel-flip-needs-eager-init-callsite-sweep - -_Discovered: 2026-04-28 by implementer in pdl-7_ - -When migrating the frontend from a legacy socket channel to a new one, the literal channel-name flip in the API adapter (`s.on('deploy:progress', ...)` → `s.on(DEPLOY_EVENT_CHANNEL, ...)`) is the smallest part of the work. The adapter's PUBLIC method name (`onDeployProgress`) leaks into every call site, including the eager-init loop in `useDeploySubscription` that runs ONCE at hook mount with a no-op callback purely to prime the socket.io handshake. Missing that callsite leaves the eager-init still calling `onDeployProgress` post-rename, which compiles fine because TypeScript is happy with `api.onDeployProgress` returning `undefined` (optional method on the interface) — and silently breaks the handshake-warming behaviour on first deploy. Fix: rename the API method (`onDeployEvent`), update the interface in `api-adapter.ts`, then grep for every callsite — there were three in pdl-7 (eager-init, live listener, deploy-panel `requirement_verified` watcher) and missing any one would have been a real regression. Also: keep the unsubscribe room emit (`subscribeDeployProgress`) named the same — that's the room-join, not the event listener, and the room name is still `deploy:<cardId>` per the unchanged decisions entry. Generalizes: a "channel rename" is really a triple-rename: the constant, the listener method, AND every callsite. The first two are typecheck-enforced; the third is grep-enforced. - -## test-the-channel-name-constant-not-the-string - -_Discovered: 2026-04-28 by implementer in pdl-7_ - -The pdl-7 channel-flip test asserts `expect(channel).toBe(DEPLOY_EVENT_CHANNEL)` — importing the same constant from `@ice/types` that the http-api-adapter does. Writing `expect(channel).toBe('deploy:event')` would compile and pass today, but a future rename of the constant to `'deploy:event:v2'` would silently green-light a state where backend and frontend disagree (backend on the new constant, test still asserting the old literal) — exactly the failure mode the constant exists to prevent. The test itself becomes the third typo surface (along with the constant definition and the listener registration); a literal in the test re-introduces the typo gap. Generalizes: when a constant exists to give two sides of an interface a single source of truth, the test for that interface must import the constant too. Otherwise the test is the place the next typo will hide. - -## complete-event-without-results-needs-post-complete-fetch - -_Discovered: 2026-04-28 by critic in pdl-7 review_ - -The pdl-2 wire contract dropped `results: <full DeployResult>` from `DeployCompleteEvent` — only `outcome` + `totals` remain (per the decisions entry, "the full DeployResult lives on the canvasDeployment row"). Pdl-7 honored the contract on both the slice (the new `applyDeployCompleteEvent` reducer doesn't touch `state.results`) and the wire mirror in `applyNodeStatusEvent` (which writes minimal entries with `name`/`type`/`action`/`success`/`error`/`duration_ms`/`source_node_id` only — no `outputs`/`provider_id`/`api_enable_url` because the wire `node_status` event doesn't carry them). The gap: the legacy hook had a `complete` branch that called `dispatch(deploySuccess({ ..., results: event.results.resources }))` to REPLACE `state.results` with the authoritative version on completion; pdl-7 removed it (the new `complete` event has no `results` to pass). Async deploy path (the production default — `deploy.apply` returns `async: true` and the panel's `if (result.async) return;` path skips the slice update) ends with `state.results` populated by the wire mirror only. The deploy-panel summary's DNS records section reads `r.outputs.custom_domain_dns_records` — undefined in the mirror — so a user who just deployed a Custom Domain block sees the deploy as successful but the DNS records they need to copy-paste to their DNS provider are missing until they reload the page (which fires the `getDeployments` hydrate effect from card-mount). Same gap for any output-derived UI: Cloud Run service URL, custom domain pill, api_enable_url CTAs on permission errors. Fix: in the `useDeploySubscription` Phase 3 listener's `complete` handler, add a `dispatch(hydrateDeployFromHistory({...}))` call (or equivalent re-fetch of the latest `getDeployments` row) so `state.results` gets the full per-resource data right after the wire `complete` event lands. Generalizes: when freezing a wire contract that drops a field which a downstream UI consumed unchanged, you must also add the explicit re-fetch path that compensates — otherwise the field's loss propagates as a silent UX regression even though the typecheck passes. - -## deploy-overlay-mapping-must-match-status-colors-keyset - -_Discovered: 2026-04-28 by critic in pdl-7 review_ - -The frontend's `mapWireStatusToOverlay` (used to translate the wire's `DeployNodeStatus` to the canvas overlay string written to `node.data.deploy_status`) has six output strings: `'queued' | 'deploying' | 'active' | 'error' | 'skipped'`. The canvas's `compact-node/index.tsx` reads this status and looks it up in `STATUS_COLORS` (in `canvas-constants.ts`) — which has only `active | running | healthy | deployed | pending | warning | creating | updating | deploying | planning | drifted | error | failed | deleting | destroying | stopped | inactive | idle`. Neither `'queued'` nor `'skipped'` are keys — both fall back to `STATUS_COLORS.active` (green). Result: a queued node shows up with green coloring before its applying transition (visually says "live" when it's just-scheduled), and skipped nodes after a partial-fail rollback also show green. Worse: the deploy-service's parallel mapping `mapStatusToOverlay` collapses `queued | applying → 'deploying'`, so the SAME node from the snapshot path is blue (correct), and from the live wire path is green (wrong) — they only agree on succeeded/failed/skipped. Fix two halves: (a) add `'queued': '#f59e0b'` (orange/pending) and `'skipped': '#94a3b8'` (slate/grey) keys to `STATUS_COLORS`, and (b) align the two mapping functions so the snapshot path and the wire path produce the same overlay string for the same wire status. Generalizes: every value a frontend overlay-string mapping produces must exist as a key in the consumer's status-table, AND every parallel mapping (server-side snapshot + client-side wire mirror) must produce identical strings for identical wire inputs — the consumer can't tell which path the data came from, so a divergence means the same node renders inconsistently across reloads. - -## complete-event-must-thread-error-message - -_Discovered: 2026-04-28 by critic in pdl-7 review_ - -`DeployCompleteEvent` carries `outcome: 'success' | 'partial' | 'failure' | 'cancelled'` and `totals` but NO error message. The slice's `applyDeployCompleteEvent` maps `outcome → status` (partial/failure → 'error', cancelled → 'cancelled') but doesn't set `state.error` because there's nothing on the wire to set it from. The deploy-panel's `<ApiErrorBanner error={deploy.error} ...>` reads `deploy.error` — when the slice flipped to 'error' via `applyDeployCompleteEvent` but the legacy `deployError` reducer never fired, `state.error` is still null. Result: the user sees the red status badge "Deploy errors" but the banner content is empty. Same problem as the missing results above: dropping a field from a wire contract while downstream UI still reads it is a silent UX regression. Two fixes possible: (a) widen the wire contract (`DeployCompleteEvent.error?: string` or rich diagnostics blob), (b) trigger the same post-complete `hydrateDeployFromHistory` re-fetch that the missing-results gap needs — the DB row carries `error: string`. Either works; (b) requires the fetch already proposed for the missing-results gap, so it's strictly less new code. Generalizes: when a wire contract carries an outcome discriminator without the human-readable diagnostic that goes with it, the consumer that displays the diagnostic loses information unless it re-fetches from a richer source. The contract author must either include the diagnostic on the wire OR explicitly delegate the re-fetch. - -## react-memo-on-rollup-component-instead-of-shallowequal-on-selector - -_Discovered: 2026-04-28 by implementer in pdl-5_ - -The brief warned against introducing a new `useSelector(s => s.deploy.nodesById)` without `shallowEqual` because the wire-event path produces a new `nodesById` reference per reducer write (10+ events/sec during a deploy). The deploy panel already had a load-bearing `useSelector(s => s.deploy)` selecting the whole slice — every event makes a new `state.deploy` reference, so the panel was already re-rendering on every event regardless of what we did. Adding a separate `useSelector` for `nodesById` with `shallowEqual` would have cost an extra subscription without changing the parent's re-render frequency. The cheaper fix: keep prop-drilling `nodesById` from the existing whole-slice selector into a new `React.memo`-wrapped child component (`DeployInFlightPanel`), and run `useMemo([nodesById])` for the rollup INSIDE the child. The memo invalidates only when `nodesById`'s reference actually changes (i.e., when `applyNodeStatusEvent` / `applyNodeProgressEvent` mutates the map — NOT when `appendLog` / `hydrateDeployFromHistory` / etc. fire), so the rollup is cheap. For the canvas banner and status-bar, both are independent components with their own subscription, so they DO use `shallowEqual` on a per-component `useSelector(s => s.deploy.nodesById)`. Generalizes: a `useSelector` returning a non-primitive blob is acceptable WHEN downstream consumers are wrapped in `React.memo` and accept the blob as a prop — the memo gates work on the actual reference change, not the parent's re-render. The `shallowEqual` route adds a per-render subscription cost; the `React.memo` route reuses the existing parent subscription. Pick by which side of the tree owns the blob. - -## destroy-status-also-emits-node-events - -_Discovered: 2026-04-28 by implementer in pdl-5_ - -The legacy deploy panel's progress UI was gated `deploy.status === 'deploying'` only, NOT `'destroying'` — meaning the destroy path went through the panel with no live progress display, just the log scroll. Per the pdl-1 decisions entry, destroy/teardown emits the SAME `node_status` events as deploy (action='delete' instead of 'create'); the wire shape is the same and the scheduler is shared. The fix in pdl-5 was just widening the gate: `(deploy.status === 'deploying' || deploy.status === 'destroying')`. The existing behavior was "destroy looks broken because no progress UI" — easy to mistake for "destroy isn't running" especially with the legacy bouncing-bar bug masking the absence. With the per-node list, destroy now shows each resource's tear-down status with the same affordance set (queued → applying → succeeded/failed), and the rollup gives the user a "X of N destroyed" anchor. Generalizes: when a panel renders status for a long-running operation, audit every `status === 'X'` gate against every status that the same backend emits events for. Anywhere the gate name doesn't match the backend's emit set, the UI silently goes dark for that mode. - -## ux-row-labels-need-action-aware-substitution - -_Discovered: 2026-04-28 by critic in pdl-5 review (finding #1)_ - -Once `destroy-status-also-emits-node-events` was honored and the destroy path renders a per-node row, a second gap surfaced: `node.status` carries the lifecycle phase but NOT the action being performed. The same wire shape (`status: 'applying' | 'succeeded' | …`) covers create AND delete. A destroy-applying row showed the "DEPLOY" badge; a destroy-succeeded row showed "LIVE" — the latter is a direct contradiction (a destroyed resource is **gone**, not "live"). Fix: thread `node.action` into the badge label override at the row, swapping `applying → DESTROY` and `succeeded → GONE` when `action === 'delete'`, while keeping the colors intact (the canvas badge palette stays coherent across both surfaces). Same medicine as `one-status-source-deploy-status` and `deploy-overlay-mapping-must-match-status-colors-keyset`: when one label set has to span two operations, the consumer needs to see which operation produced the event, not just the lifecycle phase. Generalizes: any per-row UI rendering events from a multi-action backend (create / update / delete on the same wire) must compose its label from `(action, status)`, not status alone — the action is the disambiguator the user is reading the row for in the first place. - -## ux-canvas-blocks-default-no-provider-on-drag-drop - -_Discovered: 2026-04-28 by ux-tester in pdl-smoke-test_ - -Newly-dropped blocks from the palette have no `provider` field on `node.data`. The deploy panel filters `providerNodes = resourceNodes.filter(n => n.data?.provider === deploy.provider)` and counts the missing-provider nodes as "skipped — non-GCP" even when the project's deploy.provider is GCP. End-state: a user drops Static Site + Storage + Custom Domain with the cloud provider toolbar set to GCP (via the top-of-screen `Google Cloud` button), and the deploy panel still says "0 deployable resources (GCP) (3 skipped — non-GCP)". The block name even pre-fills with provider strings ("AWS Static Site", "AWS S3") — clearly cosmetic, but it lies about the routing. This contradicts user expectation: if the deploy panel is set to GCP, dropping a generic block should adopt GCP unless the user explicitly overrides. Fix options: (a) when the cards-slice's drop handler creates a node, default `n.data.provider` to the active deploy provider (cleanest — single source of truth), (b) when the deploy panel's `providerNodes` filter runs, treat absent provider as "matches active provider" (hides the bug, doesn't fix the data), (c) surface the per-node provider as a dropdown in the properties panel so users can change it (workaround, not a fix). Generalizes: any per-node setting whose absence routes the node to "skipped" must either be defaulted at creation time OR rendered as a "needs config" warning in the panel — silent skipping looks identical to "the deploy is broken". - -## ux-static-site-needs-source-repo-blocking-requirement - -_Discovered: 2026-04-28 by ux-tester in pdl-smoke-test_ - -The Static Site (Compute.StaticSite, GCP firebase.hosting) block has a hard pre-deploy requirement: a GitHub repository must be attached. The deploy panel surfaces this as a yellow "Attach a source repository" requirement card with a `blocking` pill, blocking the Deploy button. There is no way to deploy a Static Site without a repo — the block is fundamentally a "build-and-deploy from repo" block, not "create empty hosting". For smoke-tests that just want "minimum viable canvas with parallel deploy proof", Static Site is therefore a bad pick — Storage.Bucket × N is a much better choice (no source-repo dep, deploys in 2-3s each, runs through the parallel pool). Generalizes: when picking a "minimal canvas" for end-to-end smoke testing the deploy engine itself, prefer block types whose handlers don't fan out into requirements (cert issuance, repo attach, DNS verification, etc.) — those add mockable / unmockable side dependencies that aren't the deploy-engine work being tested. Also: the deploy panel keeps stale requirement cards around when the underlying block is deleted (had to click Reset to clear the "Attach a source repository" warning after deleting the StaticSite). Requirement reactivity is per-card and lazy. - -## ux-destroy-action-bypasses-node-status-wire - -_Discovered: 2026-04-28 by ux-tester in pdl-smoke-test (regression)_ - -Per the `destroy-status-also-emits-node-events` learning, destroy is supposed to emit the SAME `node_status` events as deploy (with `action: 'delete'`). The smoke test confirmed via gcloud that 3 storage buckets DID get destroyed cloud-side (POST `/api/canvas/deploy/destroy` returned 200, follow-up `gcloud storage buckets list` returned 0). But the deploy-panel's per-node list never showed any DESTROY/GONE rows: `state.deploy.nodesById` cleared to count=0 immediately on destroy click and stayed empty until completion; `state.deploy.status` flipped to `'destroying'` for at most one tick (0.0s log entry "Deploy completed in 0.0s"), then back to `'success'`. The `ux-row-labels-need-action-aware-substitution` fix (action-aware DESTROY/GONE labels) cannot be visually confirmed because the wire events for destroy are not landing in `nodesById` — either the destroy path doesn't go through the parallel scheduler at all, or it does but the service layer's `mapStatusToOverlay`/translation isn't wiring `action: 'delete'` events through `applyNodeStatusEvent`. The canvas-side badge on each block still showed green "LIVE" after destroy completed (also stale), which would mislead a user into thinking the resources still exist. This is a correctness regression on the pdl-5 + pdl-7 contract. Fix path: trace the destroy code path in `services/deploy/src/services/deploy.service.ts` — does `destroyResources` run through the same scheduler-based `applyChange` loop as `apply`? If yes, are the `on_node_status` callbacks wired? If no, the destroy path needs the same per-node event emit treatment as the apply path (otherwise the parallel-scheduler architecture only covers half the lifecycle). Generalizes: when a wire-contract claim is made about parity between two operations ("destroy emits the same events as deploy"), the smoke test must drive both ends and confirm; passing the create-side test alone is necessary but not sufficient. The smoke test should also poll `nodesById` over time during destroy, not just eyeball the final state, because a fast destroy will clear the map before the screenshot lands. - -## destroy-needs-startDeploySnapshot-for-contiguous-seqs - -_Discovered: 2026-04-28 by implementer in pdl-10_ - -The `nextDeploySeq(cardId)` allocator only returns a real seq if there's an active `DeployProgressSnapshot` for the card (it reads `snapshot.deploymentId` to key its per-deployment counter). The destroy paths (`destroyDeployment`, `destroyAllForCard`) historically NEVER called `startDeploySnapshot` because their only wire emits were `emitLog` log lines + a final `complete` event — both of which are tolerable as `Date.now()` fallback seqs (rare, idempotent, point-in-time updates per the `seq-allocation-must-be-shared-between-wire-and-log` learning). The moment pdl-10 added per-resource `node_status` emits (queued → applying → succeeded/failed) with action='delete', the destroy narrative became multi-step and replayable, and the `Date.now()` fallback breaks the dedup-on-reconnect contract — a tab reconnecting mid-destroy would replay the persistent log and see seqs like `[1761710400000, 1761710400015, 1761710400023]` (Date.now() ms) drift against the live wire allocator that fired alongside. Fix: call `startDeploySnapshot(cardId, destroyRecord.id)` immediately after the destroyRecord is created, and `finishDeploySnapshot(cardId, ...)` before every return path (success, partial, engine-throw). Only then does `nextDeploySeq` know about the destroy and hand out contiguous integer seqs. Bonus: the snapshot also gives reconnecting tabs a memory of the per-node states for the 60s grace window, matching the apply-path UX. Generalizes: anywhere you start emitting `node_status` events for an operation, you also need to open a snapshot for that operation — the snapshot is the seq-counter's source of truth and the late-joiner's hydration source. The two surfaces are coupled even if your code path doesn't use the snapshot's `nodeStatuses` for anything else (e.g. the destroy-all path doesn't read its snapshot anywhere, but still needs to open one). - -## seq-counter-resets-per-deployment-but-slice-dedup-is-cross-deployment - -_Discovered: 2026-04-28 by critic in pdl-10 review (BLOCKER)_ - -The wire `seq` counter is keyed by `deploymentId` (`deploy-event-log.ts:nextSeqByDeployment`), so a fresh deploy / destroy / rollback resets the counter to 1. But the frontend slice's `applyNodeStatusEvent` reducer (`packages/ui/src/store/slices/deploy-slice.ts:486`) keys its dedup-by-seq guard on `node_id`, NOT on `(node_id, deploymentId)`: `if (existing && existing.last_seq >= e.seq) return;`. After a successful deploy, `nodesById['canvas-id-A'].last_seq === 9` (or whatever the last apply emit's seq was). When the user clicks Destroy, `nextDeploySeq` allocates `1, 2, 3` for the new destroy operation against the same canvas node id — and the reducer's guard sees `9 >= 1` and silently DROPS every destroy event. The destroy emits run end-to-end on the backend, the wire fires, the snapshot updates, but `nodesById` never sees the new states. This is the same shape as `ux-destroy-action-bypasses-node-status-wire` but moved one layer up: pdl-10 closes the backend-emit gap and exposes a frontend-reducer gap underneath. The fix lives in pdl-7's slice — either reset the per-node `last_seq` on a new operation (add a `clearForNewOperation` action that fires on destroy/rollback start), or key the dedup by `(deploymentId, node_id)` so different ops have independent dedup state, or stamp every `DeployNodeStatusEvent` with a `deployment_id` field and reset `last_seq` when it changes. Generalizes: any "monotonic seq for dedup" guard whose state outlives the operation that produced the seq is exactly one operation-restart away from a silent-drop bug — the seq's semantic scope and the dedup state's semantic scope must agree, otherwise the second operation never moves the consumer state. Pdl-7 + pdl-10 jointly own this; the fix needs to land before pdl-10 ships or destroy events stay invisible to the panel even with all backend work done. - -## destroyAllForCard-snapshot-leaks-on-engine-throw - -_Discovered: 2026-04-28 by critic in pdl-10 review (BLOCKER)_ - -`destroyAllForCard` opens a snapshot at line 1512 (`startDeploySnapshot(cardId, destroyRecord.id)`) but only calls `finishDeploySnapshot` on the success-path return (line 1699). The function's structure is `try { ...startSnapshot... try { ...finishSnapshot...; return; } finally { releaseTempDir; } } finally { releaseLock; }` — there is no top-level `catch` that closes the snapshot if `deployer.initialize`, `deployer.cleanup`, the `prisma.canvasDeployment.update`, or the `emitDeployEvent(complete)` throws. The next destroy click sees a stale snapshot pointing at a terminal `destroyRecord.id`, and `nextDeploySeq` keeps incrementing against a destroyed counter. Compare `destroyDeployment` (line 1808-2049) which does have the proper `try/catch/finally` with `finishDeploySnapshot(cardId, 'failed')` in the catch branch (line 2046). Generalizes: any function that opens a stateful resource (snapshot, lock, temp dir, DB row in 'running' state) needs a try/catch/finally that closes it on EVERY exit path, not just the happy-path return. The `try { ... } finally { releaseLock(); }` pattern only covers the lock; the snapshot is a separate resource with its own lifecycle and needs its own try/catch — or the whole body needs to be wrapped so a single catch closes both. The apply-path (line 632 → 1266 → 1322) is the right shape; `destroyAllForCard` is missing it. - -## deploy-service-tests-must-import-vitest-explicitly - -_Discovered: 2026-04-29 by implementer in rf-deploy-1_ - -The repo-root `vitest.config.ts` sets `globals: true`, but `services/deploy/tsconfig.json` does NOT include `@types/vitest` (or `"types": ["vitest/globals"]`) — so a fresh test file under `services/deploy/src/**/*.test.ts` that uses bare `describe / it / expect` will run fine under vitest (because the runtime exposes the globals) yet fail `pnpm --filter @ice/service-deploy typecheck` with `TS2304: Cannot find name 'expect'` and `TS2582: Cannot find name 'it'`. The convention the existing test files use is `import { describe, it, expect, vi, beforeEach } from 'vitest'` at the top of every test file — see `services/deploy/src/__tests__/deploy-event-translation.test.ts:22`. Generalizes: when adding a new test file in any package whose tsconfig doesn't pick up vitest's ambient types, mirror the existing test files' explicit imports rather than relying on `globals: true` — the runtime works either way, but the typecheck pass is the gate that catches drift. (Easy to miss when running `vitest run` alone returns green — always pair the test run with the package typecheck on a brand-new file.) - -## pre-commit-hook-auto-bumps-package-json-version - -_Discovered: 2026-04-29 by orchestrator in rf-deploy-1_ - -A pre-commit hook bumps the root `package.json` `version` field on every commit (e.g. 0.1.74 → 0.1.75 during rf-deploy-1) and stages the change into the commit. Implementers cannot opt out without `--no-verify` (which is banned by project conventions). Practical consequences: commits will always include `package.json` even when the brief lists only N source files; never assume a commit is exactly the set of files you `git add`ed. Don't try to revert the version bump after the fact — it's intentional. Generalizes: when reading commit diffs, separate the load-bearing change (the source files) from the always-present version-bump artifact; when scripting commit verification, always allow a one-line `package.json` delta in addition to the listed files. - -## deploy-service-package-has-no-test-script-use-vitest-directly - -_Discovered: 2026-04-29 by implementer in rf-deploy-1_ - -`pnpm --filter @ice/service-deploy test` is a silent no-op — the package has no `test` script (only `typecheck`). Don't be fooled by an exit-0 with empty output. The right way to run the deploy-service tests is `pnpm exec vitest run services/deploy/src` from the repo root, or `pnpm test:unit` (which scopes to the root vitest config and includes everything). Coverage is `pnpm test:coverage -- services/deploy/src/<path>` and that does work because `test:coverage` is a root-level script. Future implementer briefs touching `services/deploy/` should specify the vitest command directly rather than the `pnpm --filter <package> test` pattern that works for `@ice/core`/`@ice/ui`/`@ice/shared`. - -## vi-spyon-accumulates-across-it-blocks-without-explicit-reset - -_Discovered: 2026-04-29 by implementer in rf-deploy-3_ - -A `beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); })` is NOT enough on its own — re-calling `vi.spyOn` on the same target inside `beforeEach` returns the SAME spy and its `mock.calls` array carries over from the previous `it`. So a test that asserts `expect(warnSpy).toHaveBeenCalledTimes(1)` will see `2` (or `3` after the next sibling) and fail with `expected to be called 1 times, but got 2 times`. Two fixes work: (a) prepend `vi.restoreAllMocks()` inside `beforeEach` BEFORE the re-spy, or (b) call `warnSpy.mockClear()` at the top of each `it`. Restore is the cleaner pattern because it also tears down the mock between describe blocks. The tests in `services/deploy/src/utils/__tests__/find-source-node-id.test.ts` use the restore pattern. Generalizes: any spy on a shared global (console.\*, Date.now, fetch, process.env getters) needs an explicit per-test reset — `beforeEach` re-spying alone preserves the same Mock instance whose call counter carries over, because vitest treats successive spies on the same target as a no-op rather than a fresh wrap. - -## core-const-lifetime-varies-per-callsite-when-extracting-deployer-factory - -_Discovered: 2026-04-29 by implementer in rf-deploy-5_ - -When extracting `createDeployer(provider)` to dedupe the `if aws / else if azure / else GCP` blocks that follow `const core = await getCoreEngine()` in `services/deploy/src/services/deploy.service.ts`, do NOT blindly delete the preceding `getCoreEngine()` line at every callsite. Three of the four callsites (`destroyAllForCard`, `destroyDeployment`, drift-check) use `core` only to destructure the deployers, so the line is dead after replacement. But the apply path's `const core` (line 491 pre-rf5) also feeds `translate_card_to_graph`/`deploy_graph` which are referenced ~250 lines later, and the rollback path's `core` also feeds `MutableGraph` (line 1974) and `deploy_graph` (line 2037). Sweep-deleting `const core = await getCoreEngine()` after every switch replacement breaks those two with `core is not defined`. Verify by `grep -n "core\." deploy.service.ts | head -50` BEFORE deleting any `getCoreEngine()` call — if there are sibling references to `core.X` or destructures naming things other than the three deployer constructors, keep the binding (and just trim the deployer names from the destructure). Generalizes: when collapsing duplicated code that was destructuring different fields off a shared const, audit per-callsite whether other fields of that const are still used in the surrounding scope before deleting the const itself. - -## vi-fn-default-type-rejects-typed-callback-parameter - -_Discovered: 2026-04-29 by implementer in rf-deploy-6_ - -`const log = vi.fn();` typed by `ReturnType<typeof vi.fn>` widens to `Mock<Procedure | Constructable>`, which is NOT assignable to a specific callback signature like `(msg: string) => void`. Vitest's runtime path is happy — the mock is callable — but `pnpm --filter @ice/service-deploy typecheck` (running `tsc --noEmit`) flags every callsite that passes the bare mock to the SUT with TS2345 "provides no match for the signature". The default `vi.fn<T>()` overloads don't auto-infer from the consumer's parameter type either. Two fixes work and both are local to the test file: (a) cast the var with an intersection — `let log: ((msg: string) => void) & ReturnType<typeof vi.fn>; log = vi.fn() as any;` — or the cleaner (b) `let log: ((msg: string) => void) & ReturnType<typeof vi.fn>; log = vi.fn() as ((msg: string) => void) & ReturnType<typeof vi.fn>;`. The intersection preserves both the function callability and the `.mock.calls` / `toHaveBeenCalledWith` shape. Generalizes: any unit test that passes a `vi.fn()` mock to a SUT parameter with a NON-`any` callback type needs an explicit signature on the mock — either via the `vi.fn<Args, Return>()` generics or via an intersection type on the local var. The `deployer-factory.test.ts` pattern doesn't show this gotcha because its mocks are constructor stubs that get registered via `vi.mock(...)` and never passed as args to typed parameters. - -## vi-mock-factory-hoist-blocks-top-level-class-references - -_Discovered: 2026-04-29 by implementer in rf-deploy-8_ - -When a `vi.mock('...', () => ({ ... }))` factory needs to expose a class so the SUT's `instanceof Foo` checks survive the mock (e.g. `instanceof DeployLockError` in `deploy-lock-wrapper.ts`), the class **must be declared INSIDE the factory closure**, not at the test file's top level. Vitest hoists every `vi.mock(...)` call above all imports and other top-level statements (not just `import` lines — `class`, `let`, and `const` too), so a top-level `class MockDeployLockError extends Error {}` followed by `vi.mock('./deploy-locks.js', () => ({ DeployLockError: MockDeployLockError }))` blows up at module load with `ReferenceError: Cannot access 'MockDeployLockError' before initialization`. The reported stack frame points at the SUT's `import` line, which is misleading — the real cause is the factory running before the class binding initializes. Fix: move the class body inside the factory and re-export it through the mocked module — `vi.mock('./deploy-locks.js', () => { class MockDeployLockError extends Error { … } return { DeployLockError: MockDeployLockError, … }; })`. In the test body grab a reference via `const MockDeployLockError = (deployLocks as any).DeployLockError` after the SUT import. Generalizes: any time a `vi.mock` factory needs to reference a value defined at the top level, that value is out of scope when the factory runs. Keep factories self-contained — declare classes / helper builders / fixtures inline and surface them back to the test through the mocked module's exports. - -## emit-log-gate-must-mirror-original-truthiness-not-count - -_Discovered: 2026-04-29 by implementer in rf-deploy-10_ - -When extracting an inline emitLog that was inside `if (lastDeploy?.results)` — and the count it logs comes from a pre-filter projection (here `prevResources.filter(r => r.success).length` BEFORE the `&& res.resource_id` filter that gates `add_node`) — the natural-looking refactor `if (foundCount > 0) emitLog(...)` is NOT behavior-equivalent. Two divergence cases: (a) `results` truthy with zero successful resources → original logs "Found 0 existing resource(s)…", refactor stays silent; (b) `results` has 5 successes but only 3 with `resource_id` → original logs 5, refactor (counting added nodes) would log 3. The fix is to return TWO signals from the helper — a `hasResults` boolean (mirrors the original gate) AND a `foundCount` that mirrors the original count projection (success-only, pre-resource_id) — and let the caller pair them: `if (hasResults) emitLog(\`Found \${foundCount} ...\`)`. Generalizes: when extracting a logging callsite, the return shape of the helper has to expose both the gate predicate and the message inputs as their original projections; collapsing them into a single "did anything happen" count loses two distinct shades of behavior. Cite this when reviewing any "extract baseline / counter" refactor where the count and the gate were sibling expressions in the same `if`. - -## inline-catches-can-have-inconsistent-error-message-derivations - -_Discovered: 2026-04-29 by implementer in rf-deploy-13_ - -The two destroy loops in `deploy.service.ts` had inline `catch (err: any)` blocks that derived the error message text DIFFERENTLY across uses inside the same catch body. `destroyDeployment`'s catch did `error: err.message` (bare) for the `deleteResults.push` AND for the `emitLog` line, but `err.message || String(err)` for the `emitDestroyNodeStatus.error.message` field — three uses, two normalization strategies, in three lines of code. When you extract a per-item helper that surfaces a single normalized `error: string` (`attemptDestroy` returning `{ success: false, error: msg }` where `msg = err?.message || String(err)`), the callsite consumes the SAME string for all three downstream uses — which silently UPGRADES the deleteResults push and the log line from "bare message, may be undefined" to "always a string". For Error throws (the realistic 99.9% case) this is invisible — `.message` is non-empty so the `|| String(err)` fallback never fires. For non-Error throws (a thrown string, a thrown plain object, a thrown number) the deleteResults entry's `error` field changes from `undefined` to the stringified value and the log line changes from `'Failed to delete X: undefined'` to `'Failed to delete X: [object Object]'`. Generalizes: when an inline catch has multiple uses of `err.message` with inconsistent fallbacks, hoisting the message derivation into a helper picks one normalization for all uses — which is strictly more uniform but is technically a behavior change on the edge cases. Note it explicitly in the report (or the commit message) so the critic can verify the change is acceptable; don't pretend the helper is byte-for-byte identical when the inline code wasn't internally consistent in the first place. - -## reexport-audit-distinguish-namespace-imports-from-named-imports - -_Discovered: 2026-04-29 by implementer in rf-deploy-17_ - -When auditing whether an orchestrator-style barrel can drop a re-export, a literal grep for `import { X } from '<path>'` undercounts the consumers — `import * as foo from '<path>'` followed by `foo.X` somewhere downstream is also a real consumer of the re-export. In rf-deploy-17 the picture looked like "no external consumers" for `getNodeDeploymentOverlay` and `checkDrift` until I checked `routes/canvas-deploy.ts`, which does `import * as deployService from '../services/deploy.service'` at the top and then dispatches `deployService.checkDrift(...)` / `deployService.getNodeDeploymentOverlay(...)` from the route handlers. Those two re-exports are load-bearing precisely because the route file uses the namespace pattern, not because anyone imports the names directly. The other six re-exports (`mapStatusToOverlay`, `computeCompleteTotals`, `deriveCompleteOutcome`, `enableGcpApi`, `emitDeployEvent`, `emitLog`, `emitDestroyNodeStatus`) had ZERO callers under either pattern outside internal tests that already bound to the canonical homes — those went. Generalizes: the re-export audit must run two greps per symbol — `import { X }` AND `<namespace>.X` where `<namespace>` is whatever variable name the upstream `import * as` uses. Skipping the second grep gives false-DROP verdicts for any re-export consumed via a namespace import, which is the dominant pattern in Express route files (`import * as deployService` then `router.post(..., (req) => deployService.X(...))`). Also: tests that bind to the canonical home via `await import('../utils/<module>.js')` aren't real consumers of the re-export — they exercise the new module directly per the rf-deploy-2 / rf-deploy-15 / rf-deploy-16 pattern, so they don't count toward "keep the re-export". The re-export only stays for callers that genuinely route through the orchestrator path. - -## tree-walker-for-react-fc-tests-must-flatten-nested-children-arrays - -_Discovered: 2026-04-29 by implementer in rf-props-6_ - -When writing component tests in a node-only vitest environment (no jsdom in this monorepo), the natural strategy is invoking the `React.FC` directly to inspect its returned element tree, then walking that tree to find `<input>`/`<button>` leaves and invoking their `onChange`/`onClick` props synthetically. The walker has to flatten nested arrays in `props.children` arbitrary-depth, NOT just one level: any component whose JSX uses `value.map(...)` produces an array of elements as one child of a parent's `children` array — which means the parent's children prop is `[<header>, [<itemA>, <itemB>, <itemC>], <footer>]`, a mixed list of elements and an array. A naive `Array.isArray(children) ? children : [children]` flattens the outermost `children`, but the inner `[<itemA>, <itemB>, <itemC>]` then gets handed to a recursive `walk(child)` call which tries to read `child.props.children` on an array (TypeError: Cannot read properties of undefined (reading 'children')). The fix is to make the walker recurse into arrays explicitly before treating a node as an element: `if (Array.isArray(node)) { for (const c of node) yield* walk(c); return; }`. `React.Children.toArray(children)` would also flatten correctly, but couples the test to React's runtime helpers — preserving the explicit recurse-into-array branch keeps the test self-contained. Generalizes: any handwritten tree walker for testing React components needs to handle three node kinds — primitives (skip), arrays (recurse into each item), and elements (yield + recurse into `props.children`). The order matters: array check must come before element-property access. Same shape will recur for any field-extraction unit later in rf-props that has `.map()` in the JSX (`ListField`, `QueueListField`, and likely `EnvVarsEditor`/`ScalingSection`/`PipelineSection` once they extract). Cite this at the start of any future `**/__tests__/*.test.tsx` that uses the direct-FC-invocation strategy in a node environment. - -_Promoted to: /docs/refactoring-patterns.md_ - -## extract-pure-builders-when-testing-redux-or-effect-hooks-in-node-env - -_Discovered: 2026-04-29 by implementer in rf-props-7_ - -When extracting a custom hook in this monorepo, the test environment can't run `useEffect` (no jsdom under `renderToString`) and can't drive React state updates (no `@testing-library/react`). That leaves two paths and they cover wildly different fractions of the code: a `renderToString`-based smoke test only fires the synchronous `useState` initializer + any `useSelector`, while extracting the hook's inner branching as a pure named export gives full branch coverage on the load-bearing logic. The pattern that worked for rf-props-7 was to peel `useResourceMap`/`usePropertyIssues` into thin wrappers around `buildResourceMap(data)` / `buildPropertyIssuesMap(issues, selectedNodeId)`, then test the builders directly with vitest fixtures and the hooks via a one-shot `renderToString` smoke check. The smoke check still needs JSX (`<Provider store={...}><Probe /></Provider>`) because `react-redux`'s `Provider` types `children` as required — passing the children as the third arg to `React.createElement(Provider, { store }, child)` typechecks at runtime but TS-strict flags `Property 'children' is missing` at the props position. So: hook tests that touch redux must live in a `.tsx` file even when the hook itself is non-component code, and pure-builder extraction is the only way to get >70% function coverage in this environment. Coverage outcome on `use-resource-map.ts`: 92.3% statements / 95.83% branches / 70% functions / 91.3% lines — the function-coverage gap is exactly the unreachable `.then`/`.catch` callbacks inside `useEffect` and is structural, not a test-completeness issue. Generalizes: any future hook unit (rf-props-8 `use-drift-check`, anything in `*/hooks/`) should plan for two named exports — the hook + a pure builder — when the hook has non-trivial branching downstream of `useState`/`useSelector`. Skip the pure builder only when the hook is a one-line wrapper. - -_Promoted to: /docs/refactoring-patterns.md_ - -## capture-ref-after-render-unlocks-100pct-on-callback-returning-hooks - -_Discovered: 2026-04-29 by implementer in rf-props-8_ - -A `useCallback`-returning hook (like `useDriftCheck`) hits a different ceiling than a `useEffect`-driven hook (like `useResourceMap`): the callback body never runs during `renderToString`, but it CAN run _after_ the render completes, in plain async test code. The technique: render once via `<Provider store={...}><Probe /></Provider>`, have `Probe` write the hook's return value into a captured-ref object (`{ current?: { isLoading, checkDrift } }`), then post-render call `await captured.current.checkDrift()` and assert against `store.getState()` and a `vi.spyOn(store, 'dispatch')`. Combined with `vi.mock('../../../../shared/api/axios-instance', () => ({ default: { post: vi.fn() } }))` (note the _four_ `..` segments — the test sits in `hooks/__tests__/`, one level deeper than the source) you get a real Redux store wired to actual slice reducers, a controllable POST mock per-test (`mockResolvedValueOnce` / `mockRejectedValueOnce` / `mockReturnValueOnce(new Promise(resolve => ...))` for the synchronous-flip case), and the freedom to drive the full success/error/empty-data branches of the hook's body. Coverage on `use-drift-check.ts`: 100% statements / 100% branches / 100% functions / 100% lines — the function-coverage gap that bit rf-props-7 disappears because there's no `useEffect`-bound callback that only runs in jsdom. One subtle assertion technique that paid off: spying on `store.dispatch`, calling `dispatchSpy.mockClear()` after the render-time setup dispatches, then asserting on the _exact ordered list of action types_ dispatched during `await checkDrift()` — this catches both ordering regressions and accidental extra dispatches in one expectation. Generalizes: any future hook unit that exposes a callback (rather than running an effect on mount) should use this capture-ref + post-render-invoke pattern. Pair it with the rf-props-7 pure-builder extraction for any non-trivial inner logic that's worth covering without going through the `Provider` round-trip — `applyDriftStatus` is the rf-props-8 example, exercised with a plain `vi.fn()` dispatch. - -_Promoted to: /docs/refactoring-patterns.md_ - -## mocked-component-data-attrs-invisible-to-direct-fc-walker - -_Discovered: 2026-04-29 by implementer in rf-props-9_ - -A `vi.mock` that rewrites `IceSelect` to render as `<select data-testid="ice-select">` is invisible to the tree walker when the test invokes the parent component as a function. Direct-FC invocation returns the React-element tree with `<IceSelect>` still as an unrendered element whose `type` is the mocked function — the walker never descends into the function body, so the `data-testid="ice-select"` and the `<option>` children only exist after `renderToString` actually runs the mock. This bites the rf-props-9 pattern because `renderPropertyField` is a pure factory (no hooks), so calling it directly returns a tree that contains `<IceSelect width="160px" options={[...]}>` as a leaf — but `el.props['data-testid']` is undefined on that leaf because the mock body hasn't executed. Fix: identify mocked components by _their actual props shape_ (`width: '160px'`, `options: [...]`) rather than by the data-attributes the mock body would emit. Same goes for filtering by output: if you want to assert on the post-filter `optionDetails` that `PropertyFields` passed to `IceSelect`, read `select.props.options` directly (the unrendered prop), don't try to count `<option>` children. Generalizes: when testing components that compose mocked components and you're using direct-FC invocation rather than `renderToString`, treat the mock as a "type-shape sink" not a "rendered DOM" — find it by matching the real props the parent passed in. If you really need the mock body to execute, switch that specific test to `renderToString` and parse the HTML. - -## react-ssr-comment-markers-split-adjacent-text-substrings - -_Discovered: 2026-04-29 by implementer in rf-props-10_ - -`renderToString` from `react-dom/server` emits `<!-- -->` comment markers between adjacent text expressions inside JSX so the client can rehydrate them. JSX like `{count} {count === 1 ? 'change' : 'changes'}` renders as `1<!-- --> <!-- -->change` — three Text nodes that React keeps separable. Substring assertions like `expect(html).toContain('1 change')` fail because the literal `1 change` substring never appears in the wire output even though that's exactly what the user sees. Hit this in the rf-props-10 drift-section tests: `DriftIndicator` reads from Redux and so has to go through `renderToString` (direct-FC invocation throws without a `Provider`), and the drifted-with-N-changes branch interpolates `{count} {plural ? 'changes' : 'change'}` exactly. Fix: strip the markers with `html.replace(/<!-- -->/g, '')` before substring assertions — they carry no visible content, only rehydration metadata. Don't strip whitespace too aggressively: the _space_ between `1` and `change` is a real space character React emits, only the `<!-- -->` markers around it are noise. Generalizes: any time you're asserting on `renderToString` output with multi-segment text, run a `stripSsrMarkers` helper first. The alternative — using a regex with `[\s\S]*?` between the substrings — is more fragile because the marker count depends on JSX shape (1 marker around interpolations between two static strings, 2 markers around two adjacent interpolations). Strip-then-assert is the simpler invariant. Cite this from any future component test that needs to render Redux-connected JSX through `renderToString` — every section subcomponent extraction in rf-props will hit it the moment the JSX has more than one text expression next to each other. - -## collect-text-helper-joins-adjacent-jsx-children-with-a-separator - -_Discovered: 2026-04-29 by implementer in rf-props-11_ - -The mirror of `react-ssr-comment-markers-split-adjacent-text-substrings`, but on the _direct-FC tree-walker_ side rather than the `renderToString` side. The `collectText(tree)` helper used in rf-props-6/9/10 walks each child individually and pushes string/number primitives into a `parts: string[]` array, then returns `parts.join(' ')`. JSX like `{Math.round(opacity * 100)}%` is two adjacent children (a number `42` and a string `'%'`) on the same parent — `parts` ends up as `['42', '%']`, and `.join(' ')` produces `'42 %'`, not `'42%'`. Substring assertions like `expect(text).toContain('42%')` fail with the actual value `'Group Color Transparency 42 % Reset'` even though the user visually sees `42%` because React concatenates adjacent children without inserting any separator. Two fixes work: (a) change the join separator to `''` if you only ever care about visible text — but that breaks legitimate cross-element word boundaries (e.g. text from sibling spans should still be space-separated for readable assertions). (b) When the assertion is specifically about a single-element badge with two adjacent text children, find the badge directly via `findByPredicate` and inspect its `children` array as positional values — `expect(children[0]).toBe(42); expect(children[1]).toBe('%');`. Option (b) is what landed in rf-props-11 and stays robust. Generalizes: any `collectText`-driven assertion on a substring that crosses an interpolation/literal boundary inside the same JSX element is fragile. Either (i) restructure the JSX source so the child is a single template literal `{`${pct}%`}`, or (ii) target the parent element directly and inspect its children array. This shows up wherever percentages, units, currency suffixes, or any `{value}<unit>` pattern live — likely candidates: `ScalingSection`, `EnvVarsEditor`, anything rendering numeric values with attached symbols. Pair this with the rf-props-6 walker entry: walker shape handles arrays correctly; `collectText` formatting is the next axis to be careful about. - -## vi-hoisted-for-stable-mock-identity-in-direct-fc-tree-walker-tests - -_Discovered: 2026-04-29 by implementer in rf-props-14_ - -When extracting a section that composes multiple primitives from the same module (e.g. `ScalingSection` uses `Section`, `StepperField`, `SelectField`, `NumberField` all from `'../fields'`), the natural test pattern is to mock the module so each primitive is identifiable in the tree. The walker matches by `el.type === MockStepperField`, so the mock identity has to be _the exact same JS reference_ the rendered tree carries. `vi.mock` factories run hoisted to the top of the file before any module-level statements; trying to declare `const MockStepperField = vi.fn()` at module scope and then reference it inside the `vi.mock` factory hits a TDZ — the factory runs before the const initializer. The clean fix is `vi.hoisted`: lift the `vi.fn()` calls into a hoisted block, then re-export them in the mock factory. Two side-traps to avoid: (a) DO NOT wrap each mock primitive in another arrow inside the factory (`StepperField: (props) => MockStepperField(props)`) — that creates a fresh wrapper each render, so `el.type` is the wrapper, not `MockStepperField`, and the walker can't find anything. (b) DO NOT reach for `require('react')` inside `vi.hoisted` for JSX rendering — the `require` global isn't typed in the UI tsconfig (`Cannot find name 'require'`), and it's unnecessary anyway: direct-FC invocation never runs the mock body, only walks `el.props.children`. So the simplest mock factory is `vi.hoisted(() => ({ MockSection: vi.fn(), MockStepperField: vi.fn(), ... }))` with empty `vi.fn()` bodies — the parent FC produces `<MockSection title=""><MockStepperField label="..." /></MockSection>` as a tree of unrendered elements, and the walker descends through `MockSection.props.children` natively without needing the mock body to execute. Coverage outcome on `scaling-section.tsx` and `domain-section.tsx`: 100/100/100/100 each. Generalizes: every future section-extraction unit in rf-props (`pipeline-section`, `private-network-panel`, `service-source-section`, `source-repository-section`, `repo-deploy-list`, `deploy-history`) will compose multiple field-primitives or sibling components from the same module. Use `vi.hoisted` + raw `vi.fn()` references for the mocks; match by reference equality in the walker. Pair this with the rf-props-9 entry: that one says "find mocks by their props shape when the mock body would have rendered DOM"; this one says "find mocks by reference equality when the mock body never runs at all." The rule of thumb: if direct-FC invocation, match by reference; if `renderToString`, match by data-testid/HTML. - -_Promoted to: /docs/refactoring-patterns.md_ - -## test-prop-shape-when-extraction-preserves-an-unused-prop - -_Discovered: 2026-04-29 by implementer in rf-props-15_ - -When an extraction brief flags a prop as "preserve verbatim — currently unused but in the public shape for forward-compat" (rf-props-15: `dispatch: _dispatch` on `CustomDomainPanel`), the natural temptation is to write tests that _exercise_ the feature. But the prop is intentionally unused — there's nothing to exercise. The right test is the _opposite_ of an exercise: a guard-rail that asserts the component does NOT call the prop, across every user interaction. Two test shapes that worked: (a) `expect(dispatch).not.toHaveBeenCalled()` after a render, to catch an accidental added `dispatch(action)` call. (b) Same assertion after firing every callback in the tree (root-domain edit, route add, route delete, subdomain edit) — a regression test against future "I'll just dispatch this here" edits that would change the prop's effective shape from "unused-but-typed" to "load-bearing". This is the same pattern as testing a no-op default prop: you assert _absence of behavior_ to lock in the documented contract. Generalizes: any extraction unit whose brief includes "preserved-but-unused" prop guidance (i.e. a destructure that uses an underscore prefix like `_dispatch` or `_unused`) should ship a guard-rail test pair: a "rendering doesn't crash with this prop" test + an "every callback fired, prop never invoked" sweep. This catches the load-bearing-but-invisible class of regressions that pure type-check passes don't. Pair with the rf-props-15 behavior-risk flag (rendered-twice, identical-prop discipline): the prop-shape-guard is the unit-test counterpart of the orchestrator-side identical-callsite invariant. Both protect the same surface: stable prop identity across re-renders and across re-mounts. The orchestrator guarantees `selectedNode` reference stability; the unit test guarantees the destructure shape. Together they prevent the input-cursor-loss bug class. - -## tree-walker-must-invoke-file-private-fcs-when-extracted-component-keeps-an-inner-helper - -_Discovered: 2026-04-29 by implementer in rf-props-16_ - -The direct-FC tree-walker pattern from rf-props-6/9/10/11/12/13/14/15 descends only through `el.props.children`. That works when the extracted component renders flat JSX (mocked primitives + HTML elements). It silently breaks the moment the extracted component renders a file-private inner FC and most of the load-bearing JSX lives inside that helper's body — `PrivateNetworkPanel` is exactly this case: it returns `<div><PrivateNetworkPolicySection .../><PrivateNetworkPolicySection .../></div>`, and every radio + allowlist input + the `data-testid="pn-${direction}-..."` attributes that E2E tests reference live INSIDE `PrivateNetworkPolicySection`'s body. The standard walker yields `<PrivateNetworkPolicySection>` as a leaf and never sees the radios. Symptom: `findSections(tree).length === 0` and `findRadioByTestid(tree, ...) === undefined` even though the rendered DOM is correct. - -Fix: extend the walker to invoke any FC element it encounters that is NOT the mocked primitive. The check is `typeof el.type === 'function' && el.type !== mocks.MockSection` — if true, call `el.type(el.props)` and yield from the resulting subtree. Mocked primitives stay as leaves (matched by reference equality in the standard pattern), but file-private helpers from the source module get unrolled. The walker becomes a hybrid: a tree-walker for primitives + mocked components, an evaluator for non-mocked FCs. Don't invoke the mocks (their body is `vi.fn()` returning undefined; invoking strips structure). Don't invoke React class components or memoized FCs without a guard — `typeof el.type` would be `'object'` for those, not `'function'`. - -Generalizes to every future rf-props section extraction whose brief says "keeps inline `<HelperName>`" — the planner's blueprint flagged this for `private-network-panel` (inline `PrivateNetworkPolicySection`), `pipeline-section` (multiple inline retry-row helpers), `source-repository-section`. When you see "file-private helper" in the brief, reach for the FC-invoking walker variant up front; don't spend a debug cycle realizing the standard walker returns 0 leaves. The tradeoff vs. exporting the helper for separate testing: testing through the public surface keeps the helper genuinely private (no test-only export), and the data-testid contract is what we actually care about asserting — the helper is an implementation detail of how those testids get rendered. - -Pair this with `vi-hoisted-for-stable-mock-identity-in-direct-fc-tree-walker-tests` (the test-author still mocks `Section` by reference for leaf-detection) and with `mocked-component-leaves-are-invisible-to-direct-fc-tree-walkers` (which says: when the mock body would have rendered DOM, find mocks by props-shape). The new rule layers on top: when the extracted component composes _its own_ file-private FC, invoke it during walk; when it composes a _mocked_ FC, treat as a leaf. - -_Promoted to: /docs/refactoring-patterns.md_ - -## use-state-mock-with-mutable-ref-unlocks-direct-fc-toggle-state-tests - -_Discovered: 2026-04-29 by implementer in rf-props-17_ - -The direct-FC tree-walker pattern from rf-props-6 onward calls the React.FC as a function and walks the returned element tree. That works for stateless components, but `RepoDeployList` is the first rf-props section that uses `useState` _inside_ the body — calling the FC directly throws "useState is not a function" without a renderer context, and even mocking it naively only lets you test one state value per `vi.mock` factory hoist (the factory runs once). Fix: hoist a _mutable ref_ (`expandedIdRef = { current: null }`) into the `vi.mock('react', ...)` factory and have the mocked `useState` read from it on every call (`useState: vi.fn(() => [expandedIdRef.current, setStateSpy])`). Per-test, mutate `expandedIdRef.current` _before_ calling the component. Now you can render the same component in multiple "state worlds" (collapsed / expanded for ev-A / expanded for ev-B) within one test file, and the same `setStateSpy` captures every `setExpandedId(...)` call so click handlers can be asserted on. Pair this with the `useDispatch` mock (returns a captured `dispatchSpy`): `vi.mock('react-redux', () => ({ useDispatch: () => dispatchSpy }))`. With both hooks mocked, the FC body executes synchronously inside `RepoDeployList(props)` and the tree walker can do everything else by reference. Coverage outcome on `repo-deploy-list.tsx`: 100/100/100/100. Generalizes: every future rf-props section that uses `useState` (the blueprint flags `pipeline-section` with retry-row state, possibly `service-source-section`) should reach for the mutable-ref + `useState` mock pattern up-front. Don't try `renderToString` for state-toggle behavior — SSR doesn't re-render on dispatched setState calls, so the toggle is invisible. The `useState` mock pattern is also what unblocks testing the _click-handler argument_ (collapsed-click sets `ev.id`, expanded-click sets `null`) — those are two separate test bodies with different `expandedIdRef.current` setups. - -## dynamic-import-of-api-adapter-needs-a-direct-vi-mock-on-the-target-module - -_Discovered: 2026-04-29 by implementer in rf-props-17_ - -`RepoDeployList`'s retry click handler does `import('../../../shared/api/api-adapter').then(({ getApi }) => { getApi().pipeline.retryDeploy(ev.id).then(...) })` — a _dynamic_ import inside a closure. The natural test-author instinct is to mock `getApi` at the import-site path or stub the dynamic-import resolver. Neither is needed: vi resolves dynamic-imports through the same module-mock registry as static imports, so a single `vi.mock('../../../../../shared/api/api-adapter', () => ({ getApi: () => ({ pipeline: { retryDeploy: spy } }) }))` at the test-file's relative path covers both. The await chain inside the handler runs on microtasks, so the test driver has to `await new Promise(r => setTimeout(r, 0))` _twice_ (once for the `import()` resolution, once for the `retryDeploy(...).then(...)` chain) before asserting on the post-resolve dispatch sequence. The same pattern works for any future component that lazy-loads a module via `import('...').then(...)` to defer load-time of a heavy adapter — and several rf-props sections that wire up retry buttons or one-shot fetchers will. Cite this when the source uses a `import('...')` inside a closure rather than a top-level static import; same mock-path arithmetic, no special "esm-mock" plumbing needed. - -## jsx-html-entities-render-as-the-actual-unicode-character-not-the-escape-sequence - -_Discovered: 2026-04-29 by implementer in rf-props-18_ - -JSX written as `→` (or `&`, ` `, etc.) renders into the React element tree as the corresponding _Unicode character_, not the literal HTML entity sequence. So a JSX line like `<div>→ {branch}</div>` produces a `children` prop of `['→ ', branch]` — the right-arrow is U+2192, already decoded at parse time. This bites direct-FC tree-walker tests that try to assert on the entity literal: `expect(text).toContain('→')` always fails because the string `'→'` is never anywhere in the tree. The test must check for the actual character: `expect(text).toContain('→')` (U+2192). Same goes for `←`, `·`, `…`, etc. — match the rendered Unicode, not the entity. Hit on `service-source-section.tsx`: the linked-branch line is `<div className="...font-mono">→ {linkedBranch}</div>` and the test assertion was `text.includes('→')`. Generalizes: every future test in this codebase that asserts on a JSX-rendered HTML entity should match the Unicode character, not the entity escape — both `renderToString`-based assertions and direct-FC walker `collectText` assertions. The entity escape is purely a JSX _source-level_ convenience for embedding non-ASCII glyphs without requiring the file to be UTF-8-aware in author tooling; it never survives into the rendered tree. (If you ever DO see the literal `→` in HTML output, that means it was emitted as text rather than markup — a different bug class, e.g. a `dangerouslySetInnerHTML` with un-escaped output.) Cite this from any future rf-props section test that asserts on glyph-decorated text — pipeline rows ("•"), source labels ("→"), DNS panels ("·"), etc. - -## queued-ref-dispatch-extends-the-mutable-ref-usestate-mock-to-multi-state-fcs - -_Discovered: 2026-04-29 by implementer in rf-props-19_ - -The rf-props-17 mutable-ref `useState` mock pattern (`useState: vi.fn(() => [ref.current, setSpy])`) handles components with **one** `useState` call. `DeployHistory` is the first rf-props section with **three** `useState` calls (`history`, `expanded`, `showAll`) — and a naive single-ref mock would deal-out the same `[ref.current, setSpy]` for all three slots, mashing them together. Fix: queue them. Hoist N refs and N setter spies into `vi.hoisted`, plus a `callIdx` counter and a per-render `__resetUseState()` hook closed over the counter. The `useState` mock body looks up `dispatch[callIdx]` (a function returning `[ref.current, setSpy]` for that slot), increments `callIdx`, and returns. Every call to `renderHistory(...)` resets the counter via `__resetUseState()` so a fresh render deals slots starting at 0 again. Pair this with one-time `useEffect` mocking that fires the callback synchronously (`useEffect: vi.fn((cb, deps) => { effectCallbacks.push(cb); effectDeps.push(deps); void cb(); })`) so mount-time fetchers actually run during `Component(props)` invocation — this exposes both the captured deps array (assert `effectDeps[0]` for the dependency-list contract) and the real `setHistory(...)` call after the awaited promise resolves (test the post-resolve `setterSpy` argument). Generalizes: every future rf-props section with N≥2 `useState` calls (the blueprint flags `pipeline-section` with retry-row state, `service-source-section` if it grows env-toggle state) should reach for the queued-ref-dispatch + synchronous-`useEffect` pair up-front. Don't try `Object.assign` of a multi-key ref or a per-key dispatcher tied to call-arity heuristics — the React `useState` API is anonymous-positional, so the mock has to be too. The exact slot-by-call-index pattern is also what unblocks asserting on per-setter calls (`historySetterSpy.toHaveBeenCalledWith([])`, `showAllSetterSpy.toHaveBeenCalledWith(true)`) and on the `setExpanded((prev) => ...)` _updater function_ — capture the function via `setterSpy.mock.calls[0][0]` and run it on test-supplied input sets to verify add/remove branches. Coverage outcome on `deploy-history.tsx`: 100/100/100/100. - -_Promoted to: /docs/refactoring-patterns.md_ - -## dynamic-import-with-default-destructure-needs-the-mock-to-expose-default - -_Discovered: 2026-04-29 by implementer in rf-props-20_ - -The rf-props-17 dynamic-import-of-api-adapter learning showed that vi resolves `import('...')` through the same module-mock registry as static imports. What it didn't cover: when the source code destructures `default` off the awaited module — e.g. `PipelineSection.handleRetry` does `import('../../../../store/slices/pipeline-slice').then(({ default: _, ..._mod }) => { ... })` — the mock factory MUST include a `default` key, even if the test never reads or asserts on it. Real pipeline-slice exports `pipelineSlice.reducer` as default (`export default pipelineSlice.reducer;`), so the destructure works in production. In the test, my initial factory was `{ fetchRulesForNode, fetchEventsForNode, ... }` — no `default` — and the destructure threw at runtime: `[vitest] No "default" export is defined on the "../../../../../store/slices/pipeline-slice" mock`. The vitest error is loud and points at the exact module path, so it's a quick fix once you see it: add `default: vi.fn()` (or any value — the destructure binds it to `_` and discards it via `..._mod`) to the mock factory. Generalizes: any source code that does `import('module').then(({ default: ... }) => ...)` requires the test mock to expose `default`, even if the test ignores it. Two patterns where this comes up: (a) lazy-loading slice reducers to dispatch a thunk while keeping the slice itself out of the bundle (rf-props PipelineSection's pattern, which the planner flagged as a future cleanup candidate — it's an unnecessary double-import but verbatim-preserved during extraction), and (b) lazy-loading a default-exported React component for code-splitting (`React.lazy(() => import('./Foo')).default` is implicit; if you mock the route's lazy import, you have to expose `default`). The fix is one line in the factory; the diagnostic is the vitest "No default export is defined" error. Pair this with `dynamic-import-of-api-adapter-needs-a-direct-vi-mock-on-the-target-module` — same mocking infrastructure, just one more key to remember when the destructure pattern reaches for `default`. Cite this from any future rf-props section test where the source's dynamic-import callsite destructures off the awaited module. - -## use-memo-must-be-mocked-too-when-the-extracted-component-uses-it - -_Discovered: 2026-04-29 by implementer in rf-props-21_ - -The rf-props-17/19/20 mutable-ref `useState` mock + sync `useEffect` mock pattern handles components that use `useState` + `useEffect` only. `SourceRepositorySection` is the first rf-props section whose body also calls `useMemo(() => ..., [deps])` (for the connected-services edge walk). The standard hook mock leaves `useMemo` untouched, so the test invokes the FC directly outside a renderer context and React's `useMemo` reads `null.useMemo` from the dispatcher — `TypeError: Cannot read properties of null (reading 'useMemo')` from `react.development.js:1650:21`. Fix: extend the `vi.mock('react', ...)` factory with `useMemo: vi.fn((factory, _deps) => factory())` — invoke the factory eagerly and discard the deps array (memoization is irrelevant for synchronous test assertions). Same shape works for `useCallback` if you ever need it: `useCallback: vi.fn((cb, _deps) => cb)`. The simpler alternative — wrapping the FC in a TestProvider/render with `@testing-library/react` — defeats the direct-FC tree-walker pattern's point, which is to avoid the renderer context entirely. Generalizes: any future rf-props section that uses `useMemo` (planner flags none currently, but Layer 4 `node-properties-section` and the orchestrator slim-down may grow ones) should reach for the eager-factory mock up-front. Don't try to selectively mock useMemo (e.g., "let it pass through to the real one") — without a dispatcher, the real one always throws. The eager-factory mock has no behavior cost: every call runs the factory, which is what an un-memoized FC body would do anyway. Diagnostic: `Cannot read properties of null (reading 'useMemo')` at the line of the FC's `useMemo(...)` call — paste the same line into the mock factory and continue. Cite this from any future rf-props section test where the source body calls `useMemo` (or `useCallback`). - -## nullish-coalesce-default-in-test-helper-silently-clobbers-explicit-null-overrides - -_Discovered: 2026-04-29 by implementer in rf-props-21_ - -A `renderSection` helper that pulls defaults via `props.activeCard ?? makeCard()` looks fine at first read — `??` means "use the right side when left is null/undefined." But when a test wants to **assert** behavior under `activeCard: null` (i.e., the early-return guard inside `useMemo(() => { if (!activeCard) return [];...` ), `null ?? makeCard()` returns `makeCard()`. The test passes `activeCard: null` thinking the FC will see `null`, but the helper rewrites it to a fully-populated card, the connectedServices loop runs, and `fetchRulesForNode` fires once for `svc-1`. The failure mode is loud — "expected fn not to be called, was called once with `{cardId: 'card-1', nodeId: 'svc-1'}`" — but the diagnostic doesn't point at the helper, it points at the assertion. Fix: distinguish "no override" from "explicit null" with `Object.prototype.hasOwnProperty.call(props, 'activeCard')` (or the `'activeCard' in props` shortcut). Then `props.activeCard` is passed verbatim when present (including `null`/`undefined`), and `makeCard()` is the default only when the test author omitted the key entirely. Generalizes to every helper that takes a `Partial<Props>` for testing: any optional prop where `null` or `undefined` is a meaningful test input must NOT use `??` for the default, because `??` swallows both. The same trap exists with `||` and falsy values like `0`, `''`, `false` — but `??` is the more subtle one because most TypeScript guides recommend it as the "safer" default-assignment operator. The fix is a one-liner per prop, but you have to identify which props can accept null/undefined as load-bearing test inputs (here: `activeCard`, but also `sourceNodeId: ''` for the early-return guard). Cite this from any future rf-props section test whose component has guard clauses on optional/nullable props — and any future helper with `??`-style default assignment in test code. - -## render-helper-must-not-call-mockreturnvalue-after-test-overrides - -_Discovered: 2026-04-29 by implementer in rf-props-22_ - -A natural shape for a `renderSection` helper is to bundle "reset everything before each render" into the helper itself: `mockClear` all spies, `mockReturnValue([])` the default for `computeEdgeWarnings`, etc. The trap: `mockClear()` only wipes call history — but `mockReturnValue([])` REPLACES whatever the test body had set with `mocks.computeEdgeWarningsSpy.mockReturnValue([{...}, {...}])` two lines before calling `renderSection()`. The test sets the override, the helper clobbers it back to `[]`, the warning block doesn't render, and the assertion fails on `expect(text).toContain('warn-msg-1')` with no obvious link to the helper. The diagnostic looks like a missing-text bug; the fix is to move the per-render defaults from `renderSection` into a `beforeEach` so `mockReturnValue([])` runs ONCE per test (before the test body), and the test body's `mockReturnValueOnce(...)` or `mockReturnValue([{...}])` overrides survive to the actual `renderSection()` call. The helper should ONLY do `mockClear` (history reset), never `mockReturnValue`/`mockImplementation` (behavior reset). Generalizes: any test helper that resets mock behavior in its body races with per-test overrides set BEFORE the helper invocation. Move behavior defaults to `beforeEach`; keep helpers focused on "invoke the FC and return the tree" only. The same shape recurs for `mockImplementation` (any per-test impl override gets clobbered) and `mockResolvedValue` (any rejection-path override gets re-resolved). Cite this from any future rf-props (or general) test where a helper pulls per-render setup of mock returns/impls and per-test overrides are set inline before the render call. - -## hassource-tabs-array-orders-source-before-deploy-when-both-are-pushed - -_Discovered: 2026-04-29 by implementer in rf-props-24_ - -The properties-panel tab construction pushes tabs in a fixed source-code order: `config → scaling → domain → source → connections → deploy`. When you write a setState-during-render fallback test that drives `propsTab='config'` against a `Compute.Service` with `provider_id` set, the natural mental model is "deploy is the only tab" (you're thinking of the visible header dot). Wrong: `Compute.Service` always pushes `hasSource=true` (the formula `iceType.startsWith('Compute.') || iceType === 'Network.Gateway') && iceType !== 'Source.Repository'` is independent of whether the node has an actual `repository` field), so `visibleTabs[0].id === 'source'` even when no repo is configured. The tab list `[source, deploy]` means the fallback fires `setPropsTab('source')`, not `setPropsTab('deploy')`. To exercise zero-tab fallback (the `length > 0` guard), you can't use `Compute.Service` at all — fall back to a non-Compute, non-Source iceType like a literal `Container`. Generalizes: when a section's tab list is conditionally constructed and `Compute.*` is in the equation, always trace the `hasSource` branch first; it is the most prolific tab-pusher across all rf-props node types. The same surface bites any test asserting on tab order or "first visible tab" — read the source-code push order, don't assume header-rendering order. Cite this from any future test in rf-props (or successor refactors) that asserts on a setState-during-render fallback's argument; the `hasSource` formula is non-obvious and routinely catches reviewers off-guard. - -## canonical-home-dedup-of-local-copies-is-a-behavior-change-when-the-canonical-is-stricter - -_Discovered: 2026-04-29 by implementer in rf-props-26_ - -Two callsites had inline copies of `parseCostRange` (regex `\$(\d+)(?:[–-](\d+))?` — INTEGER-only, no commas, no decimals) and one had a local `formatCost` (`return value === 0 ? '' : '~$' + Math.round(value) + '/mo'`). Pointing them at the canonical home — `packages/ui/src/features/cost/utils/cost-calculator.ts` (`parseCostRange` regex `\$([\d,]+(?:\.\d+)?)...` with `replace(/,/g,'')`; `formatCost` with `'Free'` for 0, `'< $0.01/mo'` for tiny, `'~$X.XX/mo'` for sub-$1, `'~$Xk/mo'`for ≥1000) — looks like a pure dedup but is **strictly a behavior change** in three load-bearing places: (a)`'$1,000-2,000'` averages to 1500 instead of 1.5 (off by 1000×), (b) `'$0.50'` returns 0.5 instead of 0 (the row was previously hidden for sub-$1 totals), (c) `formatCost(0)` returns `'Free'` instead of `''`. Bullet (c) is the only one that surfaces a NEW string in the rendered output, and it is **gated** at every callsite by `totalCost > 0` — so the canonical `'Free'` return is unreachable from the live UI. Verify the gate per-callsite before declaring the dedup safe; in this case both consumers (project-overview and status-bar) had identical `> 0` guards. status-bar in particular doesn't even import `formatCost` — it inlines `~${Math.round(totalCost)}`for its own display, so the formatCost behavior change is N/A there entirely. Lock the change in with two test layers: (1) a tiny invariant suite at the canonical home covering Free/comma/decimal/zero-format edge cases (none existed before, and the canonical home is now load-bearing for cost-panel + properties + status-bar), and (2) behavior-delta tests at the consumer's test file asserting that the formerly-wrong cases (commas, decimals) now produce the strictly-more-correct rendered output, plus a regression test that the gated`formatCost(0)`-→-`'Free'` transition is still hidden. Generalizes: any "dedup the local copy to the canonical home" unit MUST diff the regex / formula / number-of-branches between the two implementations and treat any divergence as a behavior change, not a code-shape change. The diff is often subtle (`\d+`vs`[\d,]+(?:\.\d+)?`) and the planner can flag it in advance only if the broker pass surfaces both implementations side-by-side. Sequence behavior-change dedups separately from pure code-move extractions, and document the gate-by-gate insulation analysis in the commit message so future readers can audit it without re-deriving the diff. - -## export-type-from-does-not-bring-name-into-local-scope - -_Discovered: 2026-04-29 by implementer in rf-canv-1_ - -When extracting a leaf type module that the orchestrator file still uses internally, `export type { CanvasNode, ViewState, CanvasConnection } from './types';` is NOT enough. The forward keeps the name visible to _outside_ importers (the 11 consumers of `'./svg-canvas'`), but inside `svg-canvas.tsx` itself the symbol is _not_ in lexical scope — every `LocalCanvasNode = CanvasNode` alias, every `useMemo<CanvasNode[]>(...)`, every `(node: CanvasNode) => ...` signature breaks with `Cannot find name 'CanvasNode'`. The canonical fix is to pair the re-export with a sibling `import type { CanvasNode, CanvasConnection } from './types';` on the next line. This looks redundant — the same source module appears twice in two adjacent statements — but the two statements do different jobs: `export type { … } from` is a re-export (aliases the binding for downstream importers), and `import type { … } from` brings the binding into THIS module's scope for local references. Generalizes: every future rf-canv leaf-extraction unit that pulls types out of an orchestrator file with internal usages must add BOTH lines. The split also makes the orchestrator's two roles explicit — it's a backwards-compat barrel for old consumers AND a regular consumer of the new canonical home, and both consumers happen to be the same file. Skipping the import line and noticing only at typecheck wastes one cycle; planner briefs should call out "internal usages remain — add both export-type-from and import-type-from" wherever a type extraction has callers in the same file. - -## inline-classification-duplications-are-not-actually-duplicates - -_Discovered: 2026-04-29 by implementer in rf-canv-2_ - -The rf-canv-2 brief framed the work as "fold 5 inline duplicated `isGroup` / iceType classification checks into named predicates," which sounds like a pure dedup. In reality the 5 inline expressions across `svg-canvas.tsx` are NOT semantically equivalent — they each include a _different_ subset of the type/iceType axes: - -| Site | type='container' | type='group' (cast) | type='block' | iceType.startsWith('Group.') | VPC | Subnet | PrivateNetwork | Other | -| ----------------------------- | ---------------- | ------------------- | ------------ | ---------------------------- | --- | ------ | -------------- | ---------------------------------------------- | -| L414 isGroup | Y | Y | — | Y | — | — | Y | — | -| L546 container-edge filter | Y | Y | — | Y | Y | Y | Y | — | -| L1139 isGroupOrBlock | Y | — | Y | Y | — | — | — | — | -| L1488 isContainerNode | Y | Y | — | — | Y | Y | Y | — | -| L1612 (drag-highlight inline) | Y | Y | — | — | Y | Y | Y | — | -| L2647 (render isGroup) | Y | Y | — | — | Y | Y | Y | — | -| L2638 isLogNode | — | — | — | — | — | — | — | Monitoring.Log OR Observability.Logs OR Log.\* | - -L1488/L1612/L2647 ARE equivalent and fold to one `isContainerNode` predicate. But L414 and L546 each have a unique combination, and L1139 has a third unique combination (block but not group, no Network types). Trying to unify them into a single "isContainer" would silently change behavior — e.g. PrivateNetwork is treated as a container in 4 sites but NOT in L1139 (where it falls through to plain compact-height calc); type='block' is treated as a container ONLY in L1139. The brief's spec for `isLogIceType` was even narrower (`'Monitoring.Log'` only) than the inline at L2638 — adopting the strict spec would have changed render behavior. The fix is to treat each inline as a distinct predicate, name it after its semantics, and verify the truth table column-by-column before folding. Generalizes: when a planner brief says "fold N duplicated classification checks," diff the columns first — the count of named predicates almost always exceeds the count of call sites because each call site's axes differ. Add a truth-table comment in the util file when the predicate count is non-trivial. Update tests with explicit "NOT subsumed" assertions for the rows where the predicate intentionally returns false on a value some adjacent predicate returns true for; without those assertions the next refactor risks "tightening up" the predicate by adding the missing axis. - -## folded-remap-uses-two-distinct-node-arrays-on-purpose - -_Discovered: 2026-04-29 by implementer in rf-canv-3_ - -The inline `foldedRemap` block at L505-521 of `svg-canvas.tsx` looked, at first read, like a tree walk over a single node array — which made the rf-canv-3 brief's `buildFoldedRemap(canvasNodes, visibleNodes)` two-arg signature look like an over-specification. It is not. The walk uses each array for a different job: `hasCollapsedAncestor` queries `visibleNodes` (because folds operate at the visible-graph level — viewLevel filtering already removed nodes that aren't drawn at the current LOD), while the climb itself uses `canvasNodes.find` to step through the parent chain (because the climb has to _traverse_ containers that are filtered out of `visibleNodes` to reach the first ancestor that actually IS rendered). Collapsing the function to take one array and either query `canvasNodes` for both jobs (loses the visibility filter on the predicate) or query `visibleNodes` for both jobs (the climb stops too early at the first invisible-but-not-folded link) silently changes behavior. The same asymmetry shows up in the orchestrator's two descendant getters: `getDescendantIds` walks `visibleNodes` (box selection / reparenting only ever touches what the user can click), `getAllDescendantIds` walks `canvasNodes` (handleNodeMove must translate hidden L1 children with their parent or they'd visually detach). Both bind to the same pure `descendants(nodes, parentId)` walk — the array choice IS the semantic. Generalizes: when a tree walk takes "two node arrays" or "the same array twice with different filters," resist consolidating until you've diffed which array each call site reads at each step. The asymmetry is usually load-bearing. Document it in a JSDoc on the util so the next refactorer doesn't try to "simplify" the signature. - -## hit-test-loops-differ-by-iteration-direction-and-predicate-presence - -_Discovered: 2026-04-29 by implementer in rf-canv-6_ - -The rf-canv-6 brief listed 4 inline "smallest-area / hit-test" loops in `svg-canvas.tsx` and warned (per blueprint risk #2) that they might not be true duplicates. They are not — the four sites split into THREE distinct semantic patterns, not one or two: (1) **Primary L1630 (`findContainerAtPosition`)** sorts by `calculateZIndex` DESC and picks the first hit, with the predicate `isContainer(iceType) || iceType.startsWith('Group.') || iceType.startsWith('Network.')`. (2) **L1369 + L1431 (Shift-drag highlight + reparent)** linear-scan with the smallest-area tiebreaker, predicate `isContainerNode` (the node-classification predicate — _narrower_ than the primary's, since `isContainerNode` matches Network.VPC/Network.Subnet/Network.PrivateNetwork explicitly but rejects e.g. `Network.PublicEndpoint` which the primary's `iceType.startsWith('Network.')` accepts). (3) **L2032 (handleConnectionEnd) + L2754 (preview-color)** linear-scan with NO predicate at all — connection drops/previews target ANY node. L2032 picks smallest-area; L2754 iterates IN REVERSE and breaks on the first hit (a fundamentally different selection rule). - -Of the four secondary sites, only L1369 + L1431 fold cleanly into a generic `findSmallestContainerHit(nodes, x, y, predicate, excludeIds)` helper — same predicate, same selection rule, exclusion sets differ only in CONTENT (dragged-id + descendants ± current-parent). L2032 and L2754 stay inline: forcing them through the helper as `findSmallestContainerHit(..., () => true, ...)` would hide the no-predicate semantics, and L2754's reverse-iterate-break-on-first-hit is not a "smallest" pattern at all. Add an explicit `NOTE (rf-canv-6): kept inline because…` comment at each unfolded site so the next refactorer knows the divergence is intentional, not laziness. Generalizes the rf-canv-2 lesson: when a brief lists N "near-identical" sites, build a feature×site truth table FIRST (predicate, selection rule, iteration direction, exclusion shape, side-effects-in-loop) and let the table dictate how many distinct utils + how many "leave inline + flag" sites you end up with — the count is rarely 1 util ↔ N call-sites. Two utils + 2 inline holdouts was the right shape here; trying to unify into one helper would silently change behavior. - -## brief-test-spec-vs-verbatim-behavior-conflict - -_Discovered: 2026-04-29 by implementer in rf-canv-7_ - -The rf-canv-7 brief listed Test 6 as: "Edge in the canvas already has the same source+target as the new attempt → not a conflict (it's the same edge being re-validated)." The verbatim inline block in `svg-canvas.tsx` (lines 2018–2062 of the post-rf-canv-6 file, framed by the brief's original 2264–2308 from before any extractions) does NOT exclude the candidate edge from the lookup, so re-drawing the same source/target edge still matches itself as the "existing" same-special-type connection and the rule reports `conflict: true`. The brief's Test 6 spec would have required an "exclude self" predicate the inline block never had — implementing it would silently change orchestrator behavior under the same drag (re-drag-after-cancel, double-fire on the same drop, etc.) and contradict the brief's other constraint: "body verbatim from the inline block. … No behavior change. The conflict-vs-no-conflict boundary stays verbatim." The right move was to preserve the verbatim filter and write Test 6 to PIN that very fact (commented as such — "the rule does not exclude the candidate from the lookup; this is the verbatim behaviour … and is preserved intentionally"), so the next refactorer sees the divergence is documented, not accidental. Generalizes: when a brief's test-spec list and its "verbatim, no behavior change" constraint disagree, ALWAYS resolve in favor of verbatim and pin the actual behavior in a test with a comment that surfaces the disagreement. Do not "fix" the rule under cover of an extraction unit; if the rule should change, that's a separate behavior-change unit with explicit planner sign-off. Critic + ux-tester should look for test names like "preserves verbatim behaviour: …" as a signal that the implementer caught a brief↔code disagreement and chose the safe side. - -The same block also contained a vestigial-condition gotcha: the original `const otherId = e.source === serviceNodeId ? e.target : e.source === serviceNodeId ? e.source : null;` has an unreachable inner ternary arm (the second `e.source === serviceNodeId` test fires only when the first returned false, so its true-branch can never execute — the expression is equivalent to `e.source === serviceNodeId ? e.target : null`). The next-line guard `if (!otherId || (e.source !== serviceNodeId && e.target !== serviceNodeId)) return false;` is what actually filters the irrelevant edges. The extracted `findExistingSpecialConnection` drops the dead `otherId` computation and keeps only the load-bearing endpoint guard — pure dead-code elimination, no behavior change. Note for the next person who reads the lifted util and wonders why it looks "cleaner" than the legacy diff: the dead arm was the only difference; the load-bearing semantics carry over verbatim. - -## empty-map-is-not-null-in-pickPreviewColor - -_Discovered: 2026-04-29 by implementer in rf-canv-8_ - -The verbatim inline block at L2682–2698 of `svg-canvas.tsx` reads `if (connectionDragTargets) { … for (… in nodes …) { … const state = connectionDragTargets.get(node.id); previewColor = state === 'valid-target' ? '#22c55e' : '#ef4444'; … } }`. Reading the brief, the natural assumption is "no drag in progress = cyan, drag in progress = green or red." That is wrong by exactly one branch: when `connectionDragTargets` is `null`/`undefined`, the outer guard short-circuits and the loop never runs (cyan). When it is a `new Map()` (truthy but no entries), the loop runs, finds a hit, calls `.get(id)` which returns `undefined`, and the ternary `state === 'valid-target' ? green : red` falls through to **red** because `undefined !== 'valid-target'`. So an empty Map and a null Map produce _different_ preview colors over the same node. In practice the orchestrator never passes an empty Map (the source's own id is always seeded, see L1907), but the lifted util has to keep this verbatim — Test "returns red when dragTargets is empty Map and the cursor is over a node" pins the divergence. Generalizes: when extracting a guard-then-`.get(...)`-then-truthy-test pattern, build a 2×2 truth table of `{ map empty | map populated } × { cursor in node | cursor in empty space }` rather than the obvious 1D `{ drag | no drag }` axis. The diagonal corners (empty Map + hit, populated Map + miss) are easy to elide and silently change behavior. Same medicine as the rf-canv-3 lesson on "two arrays, two jobs": the asymmetry is load-bearing. - -## sibling-helpers-can-have-inverted-tie-breaks - -_Discovered: 2026-04-29 by implementer in rf-canv-9_ - -`canvas-connections.ts` (rf-canv-9) and `connection-preview.ts` (rf-canv-8) both contain dominant-axis dispatches that select horizontal vs. vertical based on the same `Math.abs(dx)` vs `Math.abs(dy)` comparison — but the comparison operator is **inverted between them**. `computePortMap.getSide` uses strict `>` (so equal-magnitude `|dx|`/`|dy|` ties fall through to the vertical branch and exit `bottom`/`top`). `computeConnectionPreviewPath` uses `>=` (so equal-magnitude ties resolve to the horizontal branch and route the bezier left/right). The two helpers look near-identical at the AST shape — same `if (Math.abs(dx) ?op? Math.abs(dy)) { ... } return ...` skeleton — and a future refactorer's first instinct will be to extract a shared `pickDominantAxis(dx, dy)` helper. Don't. Pin both tie-breaks in tests with a comment that names the sibling explicitly ("Mirror of `connection-preview.ts`'s `>=`; do NOT cross-port") so the next reader sees the asymmetry on the way in. The deeper observation: both helpers were written at different times for visually different things (port distribution vs. drag preview), and "looks like the same logic" is a TS/JS-AST-similarity that doesn't map onto the visual semantics — port distribution wants ties to go vertical because vertically-tied targets crowd the right/left side and look better fanned out top-bottom; preview wants ties to go horizontal because the user's drag cursor is more likely to be moving horizontally and the bezier should follow the dominant motion. The rule: when extracting two helpers that share an AST shape but live in different visual subsystems, do a 4-row truth table of `{ dx > 0 | dx < 0 } × { dy > 0 | dy < 0 }` (and the four ties) for **each** helper independently before considering folding. Generalizes the rf-canv-3 / rf-canv-6 / rf-canv-8 lessons: the asymmetry is load-bearing, the AST shape is misleading. - -A test-design corollary I tripped on while writing the portMap suite: when you build a fixture that places N target nodes with the intent that "all N exit the same source side," every (dx, dy) pair must individually satisfy the side-selection inequality. My first attempt at "left/right groups sort by other-Y" placed three targets at x=300 with y=0/200/400 to the right of a node at (0,0). For the third target, `|dx|=300` and `|dy|=400` — `300 > 400` is false, so it actually exits the BOTTOM side, not the right side. The test asserted count: 3 but the right-side group only had 2 entries, and vitest reported a confusing `count: 2 vs count: 3` mismatch that took a beat to map back to "one of my three test targets fell into a different side group." Fix: make the dx large enough that `|dx| > |dy|` for every target (here, dx=900 with |dy| values capped at 600). Generalizes: any `computePortMap`-style fixture has to verify the _implied side_ of each generated target before asserting indices and counts. The side selection is a hidden axis the test fixture has to control explicitly. - -## extracted-wrapper-key-must-mirror-original-closure-outer-key-chain - -_Discovered: 2026-04-29 by implementer in rf-canv-10_ - -When a per-iteration closure (like `wrapLift = (content) => isLifted ? <g key={id}>{...}</g> : isAnimating ? <g key="anim-${id}">{content}</g> : content`) gets refactored into a `<NodeLiftWrapper>` subcomponent that the call site uses inside a `sortedNodes.map(...)` body, the brief's natural-looking shape — `<NodeLiftWrapper node={node} ...>{<SvgX key={isLifted ? undefined : "${id}-lod${lod}"} .../>}</NodeLiftWrapper>` — silently elides the wrapper's OWN `key` prop. Inside the wrapper body, the inner `<g key="anim-${id}">` / `<g key={id}>` / `<g key="clipped-${id}">` keys still render verbatim as the brief asks, but those are now SOLE children of their parent — React reconciliation under a single-child parent doesn't actually consult those keys for re-mount decisions. What DOES drive re-mount in the new structure is the wrapper's outer key in the parent's children array. Without an explicit `key` prop on `<NodeLiftWrapper>`, React falls back to the array index, which means: (a) any future `sortedNodes` re-order mass-remounts every node (lod change, rename, validation status change → unnecessary unmount/mount of every concept renderer in the canvas), and (b) the original `(isLifted, parentId, isAnimating)` transitions that PREVIOUSLY swapped the outer-element key (e.g. from `${id}-lod${lod}` to `anim-${id}` when entrance fires) no longer trigger a re-mount because the wrapper instance is index-stable. The fix is to compute a `wrapperKey(innerKey)` helper at the orchestrator's per-node loop body that mirrors the original closure's outer-key priority chain — `isLifted ? node.id : node.parentId ? "clipped-${node.id}" : isAnimating ? "anim-${node.id}" : innerKey` — and pass it as the wrapper's `key` prop at every call site. The per-call-site `innerKey` differs (`${id}-lod${lod}` for most, `${id}-routes${len}` for CustomDomain, `${id}-pn${ingress}` for PrivateNetwork), so it must be a parameter, not a constant. Generalizes: every future rf-canv (or general) extraction unit that lifts a closure-returning-keyed-JSX into a subcomponent inside a `.map(...)` body MUST add an outer `key` prop to the new component, derived from whatever priority chain the original closure used for its outer element. The brief's example shape is a starting point, NOT a verbatim drop-in. Cite this from any future rf-canv unit (rf-canv-11 ParentClipDefs, rf-canv-12 NodeRendererRegistry, rf-canv-14 ConnectionPreviewOverlay, rf-canv-15 UserTrafficOverlay) where the original code returned branch-specific keyed JSX. The inner-body keys can stay verbatim per the brief's "preserve verbatim" constraint — they're harmless documentation — but they're no longer load-bearing; the wrapper key is. - -## dispatch-factory-must-return-innerkey-when-call-site-derives-outer-wrapper-key - -_Discovered: 2026-04-29 by implementer in rf-canv-12_ - -Building on the rf-canv-10 outer-wrapper-key learning: when extracting a registry-style dispatch (iceType + node.type → component) into a `renderCanvasNode(node, ctx)` factory while keeping `<NodeLiftWrapper>` at the call site, the obvious factory signature `renderCanvasNode(node, ctx): React.ReactNode` is _insufficient_. The orchestrator's wrapperKey priority chain (`isLifted ? id : parentId ? "clipped-${id}" : isAnimating ? "anim-${id}" : innerKey`) needs the per-branch `innerKey` in its FALLBACK branch — and the innerKey differs per dispatch arm (`${id}-lod${lod}` for log/group/block/resource, `${id}-routes${len}` for CustomDomain, `${id}-pn${ingress}` for PrivateNetwork). Returning only the React element forces the call site to either (a) re-derive the innerKey at the call site by reading `node.data.routes` / `node.data.ingress` (duplicates the dispatch logic), or (b) downgrade every wrapper's fallback to a single innerKey shape (silently changes reconciliation when zoom changes vs. when ingress toggles). Neither is acceptable. The right shape is a 2-tuple return: `{ element: React.ReactNode; innerKey: string }`, where the factory authoritatively names the branch's reconciliation key and the orchestrator only feeds it through the priority chain. Generalizes to any future "extract a dispatch factory but keep a wrapper at the call site" unit (rf-canv-13 connection-layer mode-switch, future deploy-rollup factories, palette-renderer dispatchers, anywhere a closure returned branch-specific keyed JSX): the factory MUST hand back any per-branch reconciliation values the wrapper's outer-key chain depends on, not just the rendered element. Briefs that show `<Wrapper key={wrapperKey(node, isLifted, isAnimating)}>{factory(node, ctx)}</Wrapper>` are wrong by exactly one parameter — the per-branch `innerKey` MUST come from the factory's return value, not from the call site's local scope, or the dispatch logic gets duplicated. Pair this with rf-canv-10 (outer key prop) and rf-canv-2 (the predicates aren't actually duplicates) — the asymmetry-is-load-bearing theme keeps recurring: registry extractions look uniform from above (one factory ↔ N branches) but the per-branch reconciliation keys differ, and those differences are not bookkeeping, they are the contract. - -## vi-hoisted-required-for-shared-mock-identities-across-many-vi-mock-calls - -_Discovered: 2026-04-29 by implementer in rf-canv-12_ - -The natural test pattern for a registry/dispatch-table extraction with N concrete dependencies (rf-canv-12 had 25 leaf `Svg*` components to mock) is to declare each mock as a top-level `const MockSvgX: React.FC = () => null` then call `vi.mock('../../nodes/x', () => ({ SvgX: MockSvgX }))` for each. Vitest hoists every `vi.mock(...)` call to the top of the module (so module imports resolve to the mock factories before any test code runs), but it does NOT hoist the `const` declarations the factories close over. Result: `ReferenceError: Cannot access 'MockSvgX' before initialization` the moment the first import-time `vi.mock` factory tries to read its `MockSvgX` reference. The fix is `vi.hoisted(() => ({ ... }))` — a sibling helper that vitest DOES hoist alongside the `vi.mock` calls, so any identities placed inside it are initialized before the factories run. The pattern that worked for rf-canv-12 was a single `const mocks = vi.hoisted(() => ({ SvgLogNode: ..., SvgGroupNode: ..., ... }))` declaring all 25 mock FCs at once, then `vi.mock('...', () => ({ SvgX: mocks.SvgX }))` for each path. After that, post-import top-level `const MockSvgGroupNode = mocks.SvgGroupNode` aliases keep the test-body assertions readable (`expect(el.type).toBe(MockSvgGroupNode)`). Generalizes: any future test that needs identity-stable mocks (mock identity matters for `toBe(...)` assertions, NOT for prop snapshots) across more than ~3 modules should reach for `vi.hoisted` from the start. The single-module pattern (`const Mock = ...; vi.mock('...', () => ({ X: Mock }))`) only works when the factory is INLINED — `vi.mock('...', () => ({ X: () => null }))` — because there's no shared identity between the test body and the factory closure to capture. The moment you want to assert by component identity, switch to `vi.hoisted`. Same medicine applies to test-doubles for utils, classes, and constants — anywhere the test wants to compare `===` against the mock value rather than just observing its effects. - -_Promoted to: /docs/refactoring-patterns.md_ - -## brief-prop-type-annotations-may-be-placeholders-not-real-codebase-types - -_Discovered: 2026-04-29 by implementer in rf-canv-15_ - -The rf-canv-15 brief specified the new component's `edgeStyle` prop type as `'default' | 'dashed' | 'thick' | string` — a stringly-typed union that does NOT match any real value in the codebase. The actual `EdgeStyle` enum, exported from `'../../../store/slices/ui-slice'`, is `'bezier' | 'straight' | 'rectangular'`. Following the brief verbatim would have produced a typed-correctly-but-wrong-domain prop — `<UserTrafficOverlay edgeStyle="dashed" />` would typecheck against the new component but would be passed straight to `<SvgConnectionPath edgeStyle: EdgeStyle>` and never match any of the three real branches. The orchestrator's call site (`edgeStyle={edgeStyle}` from `useSelector(state.ui.edgeStyle)`) passes the real `EdgeStyle` value at runtime, so the bug would be hidden behind correct values today, but a typecheck-clean code-path would invite a future refactorer to write a literal `"dashed"` and silently get bezier rendering. Fix: import `EdgeStyle` from `'../../../store/slices/ui-slice'` (same path the sibling `connection-layer.tsx` uses) and type the prop as `EdgeStyle`. Generalizes: when a brief contains an inline-string union type that looks placeholder-y (`'default' | …` or `string` as a fallback arm), grep the actual upstream callsite to find the real enum before lifting the brief's annotation. Briefs are best-effort author-summaries of the value space; for any prop fed by a redux selector or a typed adapter, the real type lives in the slice/adapter file and is the only correct choice. The rf-canv-13 ConnectionLayer prop file gets this right (`edgeStyle: EdgeStyle`) — same pattern should propagate to every future canvas-component extraction whose props include redux-sourced values. Pair with the rf-canv-12 dispatch-factory learning: registry / overlay extractions look uniform from above but their per-prop types come from disparate upstream sources, and the brief's placeholder annotations are not a substitute for the real types. - -## sibling-style-block-mount-lifecycle-changes-when-it-moves-inside-an-extracted-component - -_Discovered: 2026-04-29 by implementer in rf-canv-17_ - -The deploy-banner extraction moved both the conditional banner JSX (`{showDeployBanner && (<div>...)}`) AND a sibling `<style>{`@keyframes iceDeployPulse...`}</style>` block into the extracted `CanvasDeployBanner` component. In the original svg-canvas, the `<style>` lived OUTSIDE the conditional — it was always mounted in the canvas tree, regardless of whether the banner was visible. After extraction, since the new component returns `null` when `!showDeployBanner`, the `<style>` block now mounts and unmounts together with the banner. This is technically a behavior change (the `@keyframes iceDeployPulse` rule is no longer present in the live stylesheet outside of deploy windows) but is observably equivalent because (a) nothing else in the app references that animation name (the keyframes are only consumed by the pulse-dot inside this banner), and (b) the keyframes are re-injected before the pulse-dot's first paint each time the banner shows, so the animation always starts cleanly. If the brief had said "co-locate" without explicitly noting the lifecycle change, a reviewer could reasonably push back on this as scope-creep. Two principles that follow: (1) when extracting a sub-tree that includes a sibling `<style>` (or any always-mounted DOM injection like portals, refs, observers), check whether the original lived inside or outside the parent's conditional gate, and call out the resulting mount-lifecycle delta in the commit body explicitly; (2) the safer alternative — leaving the `<style>` in the orchestrator while moving only the conditional JSX — is correct only if the keyframes / class names need to outlive the visible window (e.g., a fade-out exit animation on a child that's already been unmounted). Here, the pulse animation is purely "during banner visibility", so co-locating is the right call. Generalizes: whenever a component extraction subsumes a `<style>` / `<link>` / `useLayoutEffect`-injected DOM artifact alongside conditionally-rendered children, write down whether the artifact's lifecycle becomes "tied to the conditional" vs "always-mounted at orchestrator-level", and prefer the former when no other consumer references the artifact. - -## browser-observer-mocks-need-stubglobal-plus-a-hoisted-callback-array - -_Discovered: 2026-04-29 by implementer in rf-canv-18_ - -Testing a hook that wraps `ResizeObserver` (or `IntersectionObserver`, `MutationObserver`, `PerformanceObserver`) in this node-only vitest environment requires a three-piece setup that the rf-props learnings don't cover end-to-end: (1) a class-shaped stub registered via `vi.stubGlobal('ResizeObserver', MockResizeObserver)` — NOT a `globalThis.ResizeObserver = ...` reassignment, which races with Vite's module worker boundary and can leave the wrong identity visible to the source-side `new ResizeObserver(...)` call; (2) a `vi.hoisted` block that owns both the captured-callback array (`observerCallbacks: ResizeObserverCallback[] = []`) AND the per-method spies (`observeSpy`, `unobserveSpy`, `disconnectSpy`) — the spies have to be on instance properties (`observe = mocks.observeSpy`) not class methods, because vitest's class-method `vi.spyOn` discovery doesn't traverse a constructor that gets called inside `useEffect`'s closure (the new instance and its prototype are fresh per test); (3) the synchronous-`useEffect` mock pattern from rf-props-19 has to additionally **stash the cleanup function** (`if (typeof cleanup === 'function') mocks.effectCleanups.push(cleanup);`) so the test can drive the disconnect branch independently of unmount — there's no DOM root to detach from in `renderToString`, so the only way to fire cleanup is to call it manually. Once those three pieces are in place, the test pattern is: render the hook via the Probe pattern, look up `mocks.observerCallbacks[0]`, build a `ResizeObserverEntry`-shaped fixture with `contentRect: { width, height, ... }` (the rest of the fields are nominally typed but unread), invoke the captured callback synthetically, and assert on the setter spy's call args. The `>0` guard on `width` and `height` is exercised by passing zero-valued entries and asserting the setter was NOT called. For the "returns the new value" path, write the setter's argument back into the mutable ref the `useState` mock reads from, then re-render the Probe — that's how this monorepo simulates React's post-setState re-render without `@testing-library/react`. Coverage outcome on `use-canvas-resize.ts`: 100/100/100/100 with 12 tests covering initial-default, observe-once, null-ref short-circuit, valid-update, post-render-return, width=0 / height=0 / both=0 guard rejection, multi-entry processing, mixed-validity-in-one-callback, cleanup-disconnects, and null-ref-no-cleanup. Generalizes: any future hook that constructs a browser observer and passes a callback (the rf-canv blueprint may extract `useCanvasMouseEvents`'s `wheel` listener registration into its own hook, future use-intersection-visibility patterns, etc.) should adopt this `stubGlobal + hoisted-callback-array + cleanup-stash` triad up-front. The `globalThis = ...` shortcut works in jsdom but is unstable in node-only vitest; pay the one-time stubGlobal setup cost. - -## rtk-store-getstate-is-frozen-use-preloadedstate-not-direct-mutation - -_Discovered: 2026-04-29 by implementer in rf-canv-19_ - -When testing a hook that reads from a Redux store via `useSelector`, the natural shape for the test harness is to `configureStore({ reducer })` then mutate the resulting state directly (`store.getState().cards.cards = seeded`) so each test can fixture different shapes without threading slice-action calls. That breaks under Redux Toolkit because RTK applies Immer's `produce` plus its `immutableCheck` middleware in development — the object returned by `getState()` is frozen, and assignment throws `TypeError: Cannot assign to read only property` at the test harness boundary. The right pattern is `configureStore({ reducer, preloadedState })` — pass the seeded state as the second arg of `configureStore` and Redux uses it as the initial state without ever needing a mutation. The harness shape that worked in rf-canv-19 is: derive each slice's default initial state by calling its reducer with `(undefined as any, { type: '@@INIT' })` once at the top of `makeStore`, then spread-merge the test's overrides into each slice's initial state, and pass the merged shape as `preloadedState`. Two side notes: (1) RTK's default middleware also includes `serializableCheck` which throws on Date/Map values some slices carry (e.g. `cards-slice.history` uses `Record<string, CardHistory>` with arrays of arrays — fine, but other slices may not be). Disable both checks for the test harness with `middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false })`. (2) The `(undefined as any, { type: '@@INIT' })` invocation works because Redux already calls every reducer with `state=undefined` at store-init to produce the initial state — the literal `'@@INIT'` action type is what reducers fall through on for that path. This is more reliable than `cardsReducer.getInitialState()` (which only exists on RTK `createSlice` reducers, not arbitrary reducer functions). Generalizes: every future rf-canv hook test that needs to seed Redux state across multiple `useSelector` calls should reach for `preloadedState` from the start — never mutate `getState()` directly. The frozen-state failure is loud (`Cannot assign to read only property`) but the diagnostic points at the test harness's assignment line, not at the missing `preloadedState`. Cite this from any future rf-canv hook test (`use-pinned-user-node`, `use-canvas-side-effects`, etc.) where the brief lists multiple distinct cards / pane configurations across tests. - -## useref-mock-with-hoisted-prefix-ref-unlocks-single-render-effect-deltas - -_Discovered: 2026-04-29 by implementer in rf-canv-19_ - -The rf-canv-18 ResizeObserver triad covers hooks that capture an external callback the test can drive. `useCanvasViewport` is different: its `useEffect` body reads `prevAutoZoomRef.current` (initialized to the current `viewport.zoom` via `useRef(viewport.zoom)`) and dispatches `scaleLayoutForZoom` only when `|viewport.zoom - prevZoom| > ZOOM_STEP * 0.5`. On the first render after mount, the ref's `.current` equals `viewport.zoom` exactly (since `useRef(initial)` initializes equal to `initial`), so the delta is 0 and the dispatch never fires — even with a synchronous-`useEffect` mock. The naive workarounds don't work either: `renderToString` is single-shot (no second render to advance the ref), and `useState`/`useRef` don't survive across separate `renderToString` calls because each call creates a fresh component instance. Fix: mock `useRef` with a hoisted `refForNextRender` container that the test pre-primes BEFORE invoking the Probe — the mock returns `{ current: refForNextRender.current }` if it has been primed, otherwise falls through to `{ current: initial }`. After the priming reads, reset `refForNextRender.current = undefined` so subsequent renders see the real initial. The test body then sets `mocks.refForNextRender.current = 1.0` and renders with `viewport.zoom = 1.5` — the effect sees `prevZoom = 1.0`, computes `delta = 0.5 > 0.025`, and fires `dispatch(scaleLayoutForZoom({ zoom: 1.5, prevZoom: 1.0 }))`. Coverage outcome: 100/100/100/100 on `use-canvas-viewport.ts`. Two side traps: (1) the hook only calls `useRef` once, so the simple "first call uses the priming, subsequent calls fall through" works. If a future hook calls `useRef` more than once in its body, switch to the queued-dispatch pattern from rf-props-19 (`useState` slot-by-call-index). (2) Don't try to make the priming permanent across multiple test renders by NOT resetting `refForNextRender` — a leaked priming value will silently corrupt the next test's baseline. The `beforeEach` block must reset it explicitly. Generalizes: any rf-canv hook test where the source code's effect-body branch depends on a `useRef` value that differs from a `useState` value should reach for the hoisted-priming `useRef` mock pattern. The blueprint flags `usePinnedUserNode` (rf-canv-21) with a `prevExposedIdsRef` that follows the same shape — pre-prime that ref to test the diff branch. Pair with the rf-canv-18 cleanup-stash pattern when the hook also needs to test cleanup, and with the rf-props-19 queued-dispatch pattern when more than one `useRef` slot is in play. - -## hook-return-shape-vs-orchestrator-callsite-the-internal-only-dep-trap - -_Discovered: 2026-04-29 by implementer in rf-canv-21_ - -When extracting a hook whose source-block is a chain of `useMemo`s where each downstream memo depends on the previous (`A → B(A) → C(A,B)`), it's tempting to copy ALL the named locals into the hook's return object verbatim — they were all visible at the orchestrator's L376–429 scope, after all. That's wrong. After extraction the orchestrator no longer reads `A` directly — it only ever read `A` to pass it as a dep into the inline `useMemo` definitions for `B` and `C`. Once those memos move INSIDE the hook, the orchestrator only needs `B` and `C` (the leaf consumers, which here are `userConnections` and `nodesWithUserNode` going into `<UserTrafficOverlay>`). `A` (the intermediate `userCanvasNode`) becomes closure-private state of the hook — necessary for the hook's own internal memo wiring but orphaned in the orchestrator's destructure. The lint signal is loud (`'userCanvasNode' is assigned a value but never used. Allowed unused vars must match /^_/u`) but only AFTER the wire-up is otherwise complete; the type-checker stays silent because the field is genuinely typed and reachable. Fix: trim the destructure to only the values the orchestrator actually threads to subcomponents (in rf-canv-21: `pinnedUserPos`, `setUserNodePos`, `userConnections`, `nodesWithUserNode` — drop `userCanvasNode`). The hook's return-type interface should still expose `userCanvasNode` so any future consumer can opt in (the `<UserTrafficOverlay>` rendering machinery already reads it indirectly via `nodesWithUserNode`, but a future overlay variant might want the raw node). Generalizes: every future hook extraction whose source-block had **chained `useMemo`s** should ask, for each named local, "does the orchestrator read this DIRECTLY for a JSX prop / further computation, or does it only read it via a `useMemo` dep that's now inside the hook?" Only the former survives the destructure. The rf-canv-22 `use-canvas-side-effects` extraction has multiple chained effects + selectors and is a likely repeat of this pattern. Pair this with the rf-canv-15 EdgeStyle-real-vs-brief-placeholder learning — both are about the same root issue: the brief sketches the hook's INTERFACE based on the source's local-symbol soup, but the actual minimum surface is narrower than the brief's enumeration. Verify by lint after the wire-up and trim back. - -## fake-timers-plus-sync-useeffect-mock-needs-pertest-toggle - -_Discovered: 2026-04-29 by implementer in rf-canv-22_ - -When a hook's `useEffect` schedules a `setTimeout`-then-cleanup pair (rf-canv-22's auto-organize: `const timer = setTimeout(() => dispatch(autoOrganizeCard(...)), 100); return () => clearTimeout(timer);`), the test harness needs three pieces working together: (1) the synchronous-`useEffect` mock from rf-canv-18 (so the effect body runs inside `renderToString` and the `setTimeout` queues against the timer engine), (2) `vi.useFakeTimers()` set up in `beforeEach` BEFORE the render so the queued `setTimeout` lands on the fake clock instead of the real one, and (3) `vi.useRealTimers()` in `afterEach` so the next test isn't poisoned by leftover fake-clock state. Without (2) the test passes spuriously (the assertion runs before the 100ms real timer fires) but the real timer keeps running and dispatches into the next test's mocked Redux store — flakiness that surfaces as "this test passes alone but fails in the suite". Without (3) any test that follows and uses `setTimeout` for setup (e.g. async-yield) hangs forever because the fake clock isn't being advanced. The "branch is gated" assertions then take the shape: render with the dispatch-firing inputs, assert `dispatch` has NOT been called yet (the timer is queued), call `vi.advanceTimersByTime(100)`, assert `dispatch` has been called once with the expected action. For the cleanup path: stash the cleanup function via the rf-canv-18 `effectCleanups` array, invoke it BEFORE advancing the timer, and assert `dispatch` is still NOT called after `vi.advanceTimersByTime(100)`. The threshold-fails branch (delta ≤ 10) is verified by advancing past the timer window and asserting no dispatch — same shape, just with no cleanup-stash invocation. One subtle point about the threshold test fixture: the rf-canv blueprint risk #7 is `currentCount - prevCount > 10` (strictly greater), so the off-by-one fixture is `prev=1, current=11` (delta=10, fails); `prev=0, current=N` always passes via the OR branch (`prevCount === 0`). Generalizes: any future rf-canv hook test where an effect schedules a `setTimeout` (the use-ghost-mode 10s auto-dismiss in rf-canv-23 follows the same shape) should set up the fake-timers triad on day one. The default error mode is silent flakiness — there's no loud diagnostic. Pair this with the rf-canv-18 cleanup-stash pattern (you need the cleanup to invoke `clearTimeout` for the cleanup-branch assertion). - -## sync-useeffect-toggle-when-mixing-pure-callback-tests-with-timer-tests - -_Discovered: 2026-04-29 by implementer in rf-canv-23_ - -The rf-canv-22 fake-timers triad assumes the synchronous-`useEffect` mock fires every render — fine when ALL tests in the file care about the timer. For `useGhostMode` (and any future hook with a mix of pure-callback tests and timer-effect tests), the always-on mock causes a different problem: tests that only exercise `handleAcceptGhost` / `handleDismissGhost` end up implicitly running the auto-dismiss `useEffect` body too, queuing a `setTimeout` that the test never advances. With empty ghosts the early `if (ghosts.length === 0) return` short-circuits cleanly, but as soon as a test pre-loads ghosts into the store (via `preloadedState` or a `setGhosts` dispatch followed by re-render to verify selector reactivity), the auto-dismiss timer DOES schedule, and the dispatch-spy now contains a `clearGhosts` call from the queued timer firing on `vi.useRealTimers()` cleanup — or worse, the `Math.max(0, 10_000 - elapsed)` clamp lands at zero and `setTimeout(cb, 0)` fires synchronously in some test runners. The fix: stash a per-test boolean toggle (`mocks.syncUseEffect.current`) inside the `vi.mock('react', ...)` factory; the `useEffect` shim either fires synchronously (timer-effect tests) or no-ops (callback-only tests). Default it to `true` in `beforeEach` to keep the rf-canv-22 default behavior; flip it to `false` at the top of pure-callback tests. The cost is one extra hoisted mock line and one extra line per pure-callback test; the benefit is dispatch-spy assertions that aren't polluted by background `clearGhosts` actions you didn't ask for. Generalizes: every future rf-canv hook test file that mixes (a) callbacks not gated on the effect with (b) timer-driven effect assertions should expose this toggle. Pair with the rf-canv-22 learning — that one is about getting the timer machinery RIGHT, this one is about turning it OFF when it would just add noise. - -## brief-import-drop-list-needs-grep-against-shift-drag-machinery - -_Discovered: 2026-04-29 by implementer in rf-canv-24_ - -The rf-canv-24 brief listed eleven imports to drop after extracting `useCanvasDrop` from `svg-canvas.tsx`: `getBlueprint`, `expandBlueprint`, `expandBlueprintToCard`, `addNodeToCard`, `setGhosts`, `canContain`, `computeCompactNodeWidth`, `computeCompactNodeHeight`, `generateGhostSuggestions`, `logDrop`, `logBlueprint`. Of those, **two are still used by orchestrator code outside `handleDrop`**: `canContain` is read at L1115 by the shift-drag-reparent containment validation (`if (!canContain(parentIceType, childIceType)) return`), and `computeCompactNodeHeight` is read at L683 (the `recalculateAncestorBounds` self-height fallback) AND at L1132 (the post-reparent expansion-fit bounds). Both are unrelated to palette drops — they live inside `useDragTargetHighlight`-style machinery that hasn't been extracted yet (queued for rf-canv-25b/26). Mechanically dropping all eleven imports per the brief breaks the orchestrator's typecheck with `Cannot find name 'canContain'` / `Cannot find name 'computeCompactNodeHeight'`. The brief did include the qualifier "Grep before deleting" but it's easy to read past — "drop unused" sounds like a single grep against `handleDrop`, not a per-symbol scan against the whole orchestrator. The right shape for an extraction unit's import-drop list is a 2-column truth table: `{ symbol: in handleDrop? in any other svg-canvas function? }` — drop only when both columns flip. For rf-canv-24 the 9 droppable imports were `getBlueprint`, `expandBlueprint`, `expandBlueprintToCard`, `addNodeToCard`, `setGhosts`, `computeCompactNodeWidth`, `generateGhostSuggestions`, `logDrop`, `logBlueprint`; the 2 retained imports were `canContain` and `computeCompactNodeHeight`. The brief's list mismatched reality on those two and reading it literally would have wedged the typecheck. Generalizes: every future rf-canv (or general) extraction-unit brief that lists imports to drop must be cross-checked with a per-symbol grep over the WHOLE source file before deletion — the brief's enumeration is a starting point, not authoritative. The shape of the gotcha mirrors the rf-canv-2 / rf-canv-6 / rf-canv-9 lessons that "near-identical" sites aren't actually duplicates — here the analog is "imports listed as droppable" aren't all actually unused, because the planner couldn't see the unrelated downstream callsite that re-uses the symbol. Cite this from rf-canv-25a/25b/26 (the `useDragTargetHighlight` and `useContainerResizing` extractions, both of which will list overlapping imports for removal) — same medicine, same per-symbol grep before deletion. - -## brief-vs-rf-canv-21-trim-rule-when-the-planner-knows-the-future-callsite - -_Discovered: 2026-04-29 by implementer in rf-canv-25a_ - -The rf-canv-21 learning (`hook-return-shape-vs-orchestrator-callsite-the-internal-only-dep-trap`) says: trim the orchestrator destructure to ONLY values the orchestrator currently reads. The rf-canv-25a brief explicitly contradicts that by asking the implementer to keep `recalculateAncestorBounds` and `calculateMinimumContainerSize` in the destructure even though TODAY (post-25a, pre-25b) the orchestrator has zero callsites for either — `handleNodeMove` and `handleToggleFold` write all their per-edge expansion math inline via `CONTAINER_PAD` etc., never going through the wrapper. Doing what the brief says produces two new `'X' is assigned a value but never used` lint warnings; doing what rf-canv-21 says drops them and forces rf-canv-25b to re-add them. The right call is to follow the brief, because the planner has knowledge the implementer doesn't: rf-canv-25b will likely refactor handleNodeMove/handleToggleFold to USE these hook-returned helpers instead of inlining `CONTAINER_PAD` arithmetic, which is the entire point of unifying the resize math. Verify the pattern is established by checking that the orchestrator already has multiple "future-consumer" destructures with the same lint warnings (rf-canv-25a baseline shows `isNodeFolded` at L252 and `calculateContainerBounds` at L386 carry the same `'X' is assigned a value but never used` lint warning, untouched by rf-canv-25a — they're being held for similar future callsites). When the brief and a prior learning conflict on destructure trimming, side with the brief if the unit is part of an explicitly-multi-step extraction (here, 25a is half of a paired 25a+25b split) AND the file already carries other unused-destructure warnings as held state. The lint signal is genuinely loud (`'recalculateAncestorBounds' is assigned a value but never used. Allowed unused vars must match /^_/u`), but it's the correct cost of preserving the destructure shape across the split; rf-canv-25b will silence it by adding the callsites. Generalizes: any time a multi-unit extraction brief asks for a destructure that contradicts rf-canv-21, check (1) is this a paired/split extraction? and (2) does the orchestrator already carry similar unused-destructure warnings? If both yes, follow the brief and accept the lint warnings as transient state. If only one, push back via the critic for clarification before committing. Pair this with the rf-canv-21 learning — they're complementary: rf-canv-21 is the default rule, this is the documented exception when the planner has paired-unit visibility. - -## min-container-floor-silently-masks-per-edge-expansion-deltas-in-tests - -_Discovered: 2026-04-29 by implementer in rf-canv-25b_ - -The rf-canv-25b extraction kept the four per-edge ancestor-expansion sites verbatim — `pw = Math.max(MIN_CONTAINER_WIDTH, pw); ph = Math.max(MIN_CONTAINER_HEIGHT, ph)` after each overflow check. While writing tests for `handleNodeMove`'s ancestor-expansion paths, the obvious fixture choice — parent at 200x200 with a 50x30 child, move the child past the left edge, assert width grew by `overflowL = PAD = 20` so width should be 220 — fails because `MIN_CONTAINER_WIDTH = 240` (and `MIN_CONTAINER_HEIGHT = 150`) and `Math.max(240, 220) = 240`. The post-clamp dispatched value silently absorbs the +20 delta into the floor, producing the misleading `expected 220, got 240` failure on what looks like correct math. The MIN values come from `@ice/constants/grid.js` (`HEADER_HEIGHT = 36`, `CONTAINER_PADDING = 20`, plus the 240/150 minimums in `canvas-constants.ts`). Generalizes: when writing tests for any ancestor-expansion / container-resize code path that ends with `Math.max(MIN_CONTAINER_*, computed)`, choose parent fixtures comfortably above the MIN floor (e.g. 400x300, not 200x200) so the asserted delta isn't masked. Read the actual MIN*CONTAINER*\* values from `@ice/constants` before sizing fixtures — don't assume "small numbers" are safe. Apply the same rule to `clampNodeToParent`, `expandToFitChildren`, `recalculateAncestorBounds` (rf-canv-4) tests, and any future per-edge expansion site (handleDragEnd's reparent block at svg-canvas L1054-1113 has the same shape and will need the same fixture sizing when extracted in rf-canv-26+). Bonus: the SAME issue applies to the `minWidth/minHeight` return from `calculateMinimumContainerSize` in rf-canv-25a — but that hook's tests use `Math.max(MIN, 250 + PAD)` in their expected values, so the test math already accounts for the floor and won't trip the gotcha. The trip point is the OPPOSITE direction — when you assume MIN is small enough to ignore, but the test parent dimensions land below MIN, the floor silently rewrites your expansion delta. Check the floor first, then pick fixture sizes; don't pick fixture sizes first and discover the floor on the assert line. - -## non-container-type-but-container-icetype-needed-to-test-cancontain-branch - -_Discovered: 2026-04-29 by implementer in rf-canv-26_ - -`handleDragEnd`'s `canContain` validator branch only runs when `bestContainer.type !== 'container'` — the gate at the top of the parent-validation block. To unit-test that branch you need a fixture that BOTH (1) gets selected as `bestContainer` by `findSmallestContainerHit` (which means `isContainerNode(node)` must return true at hit-test time), AND (2) has `node.type !== 'container'` so the validator gate fires. The first attempt — `mkNode({ type: 'block', data: { iceType: 'Compute.Service' } })` — silently fails: `isContainerNode` returns false for that fixture (no container/group type, `Compute.Service` not a container iceType), so `findSmallestContainerHit` returns null, `bestContainer` stays null, and the canContain branch never runs. The fix is to pick an iceType from the union `isContainerIceType` accepts — `'Network.VPC'`, `'Network.Subnet'`, or `'Network.PrivateNetwork'` — paired with `type: 'block'`. That combination passes the hit-test predicate but skips the `bestContainer.type === 'container'` early-return so canContain executes. Generalizes: any test that hits a `predicate(node) && node.type !== 'container'` style branch needs to inspect the predicate's full disjunction — type-only or iceType-only fixtures alone usually trip just one rail. Look at `node-classification.ts` (rf-canv-2) before picking the fixture. Same trap will surface for rf-canv-27's connection-drawing canContain check at the connection-end target validation site. - -## stateful-hook-with-callback-writes-needs-mutable-usestate-slot-mock-not-real-usestate - -_Discovered: 2026-04-29 by implementer in rf-canv-27_ - -Builds on `capture-ref-after-render-unlocks-100pct-on-callback-returning-hooks` (rf-props-8) and `use-state-mock-with-mutable-ref-unlocks-direct-fc-toggle-state-tests` (rf-props-17). When the extracted hook exposes BOTH a callback that writes state (`handleConnectionPortDown` calls `setDrawingConnection({...})`) AND a callback that reads it (`handleConnectionEnd` runs `if (!drawingConnection) return;`), the rf-props-8 capture-ref pattern with REAL `useState` is insufficient: each `renderToString(<Provider><Probe/></Provider>)` mounts a fresh component instance, so the `setDrawingConnection` write is committed against an instance the next `captureHook` call no longer holds. The next render re-initializes `useState(null)` and the read-side callback sees null forever. Symptoms: tests that worked when calling the read-callback in the SAME render as the write (rf-props-8) silently fail when the test calls write-then-render-then-read; assertions like `dispatchSpy.toHaveBeenCalled()` fail with "expected 1 times, but got 0 times" because the validator cascade short-circuits on the no-drawing guard. - -Fix: adopt the rf-canv-21 mutable-slot `useState` mock pattern even when the hook has only ONE state slot. Hoist `drawingConnectionSlot: { current: null | DrawState }`, mock `useState` to read the slot on call and have the setter write the slot directly (handle both value and updater-function forms). Tests then either (a) drive the write via `result.handleConnectionPortDown(event)` and observe the slot mutation, or (b) skip the port-down path entirely for end-of-drag tests by writing the slot directly via a `startDrag(opts)` helper. Pattern (b) keeps each test focused on exactly one validation gate without the test-author having to thread "kick off a drag, then validate" choreography through every assertion. Generalizes: every future hook extraction whose callbacks BOTH write and read `useState` slots — anywhere in the rf-canv blueprint, not just rf-canv-27 — should use the mutable-slot pattern from the start, regardless of slot count. The rf-props-8 real-`useState`-with-capture-ref pattern stays the right choice only for hooks whose callbacks do not read state they themselves wrote earlier (the typical `useDriftCheck` / `useResourceMap` shape: one-shot fire, observe the dispatched action, never re-enter). Pair this with the rf-canv-21 useState-mock learning — that one says "use the slot-mock when the hook USES `useState`"; this one adds "use it ALSO when the cross-callback read/write contract makes the test-author want to drive write-then-read in two separate `renderToString` calls." - -## series-end-cleanup-collects-the-future-callsite-bets-the-orchestrator-never-paid - -_Discovered: 2026-04-29 by implementer in rf-canv-28+29_ - -The complement to `brief-vs-rf-canv-21-trim-rule-when-the-planner-knows-the-future-callsite`: that learning said "follow the brief and accept transient unused-vars warnings when a multi-step extraction holds destructure entries for a planned future callsite." The follow-up case is what to do at the END of the series, when several of those held bets did NOT pay off: rf-canv-25a's brief asked the implementer to keep `recalculateAncestorBounds` and `calculateMinimumContainerSize` in the orchestrator destructure for "rf-canv-25b will likely refactor handleNodeMove/handleToggleFold to USE these hook-returned helpers." rf-canv-25b instead extracted `handleNodeMove`/`handleToggleFold` themselves into `useContainerMove`, so the consumers moved INSIDE a hook and the orchestrator-level destructure entries were never wired. Same shape for `isNodeFolded` (rf-canv-3) and `calculateContainerBounds` (rf-canv-4) — declared as thin-wrapper `useCallback`s that downstream effects/callbacks were expected to consume, but the consumers got extracted into hooks (rf-canv-22/25b/26) that take `visibleNodes` directly and re-derive whatever they need internally. - -Diagnostic: at series end, run ESLint and grep the orchestrator for every `'X' is assigned a value but never used` warning. Cross-check each one against the original brief that introduced it — if the brief explicitly cited a "future consumer at unit Y" and Y has now landed without consuming it, the held bet didn't pay and the entry is safe to trim. Two trim shapes: (a) destructure entries can be dropped from the orchestrator while keeping the hook's return surface intact (any future consumer can re-add them); (b) local `useCallback` wrappers and their imported aliases can be dropped entirely (the underlying util keeps existing in `../utils/`, importable directly by any future consumer). - -Generalizes to any multi-unit decomposition series: the planner's "preserve the destructure shape across the split" instruction is correct DURING the series — premature trimming would force the next unit to re-add and re-test. But the same rule reverses at series end. The final cleanup unit should explicitly walk the held-bet inventory and trim what didn't pay, because (1) every left-behind unused entry is permanent code rot once the series closes, (2) the underlying util/hook still lives at its canonical home for any future consumer, and (3) the rf-canv-21 trim rule (`hook-return-shape-vs-orchestrator-callsite-the-internal-only-dep-trap`) is the steady-state default — held-future-callsite is the exception, and the exception expires when the series ends. - -## regex-i-flag-applies-to-character-classes-not-just-the-literal - -_Discovered: 2026-04-30 by implementer in rf-pdpl-5_ - -The rf-pdpl-5 brief described `extractProjectIdFromError`'s regex `/project[=/]([a-z0-9-]+)/i` as "rejects upper-case project IDs (regex is `[a-z0-9-]`)" and asked for a test asserting `'project=FooBar'` produces no match. That description is wrong about JavaScript regex semantics: the `/i` flag applies to the **entire** pattern, including character classes. `[a-z]` under `/i` matches `[A-Za-z]`, `[0-9]` is unaffected (digits have no case), and the `-` is literal. So `'project=FooBar'` actually matches and returns `'FooBar'`; `'project=foo-BAR-baz'` matches `'foo-BAR-baz'` in one greedy go (NOT stopping at the first capital). I confirmed this experimentally with `node -e "console.log('project=FooBar'.match(/project[=/]([a-z0-9-]+)/i))"` after my first test run failed — three separate test cases I'd written to assert the (wrong) lower-case-only behaviour all returned the upper-case match. Per the `brief-test-spec-vs-verbatim-behavior-conflict` learning, I pinned the verbatim regex behaviour and added a "BRIEF↔CODE NOTE" comment in the test surfacing the disagreement so the next reader knows the divergence is deliberate. Generalizes: when a brief makes a claim about a regex's character-class behaviour ("uppercase rejected", "case-sensitive class"), independently verify by running the regex literal in a Node REPL before writing tests; the `/i` flag's reach surprises in both directions (it widens what most people think of as "narrow" classes, and conversely a class with no flag has zero connection to the surrounding flag set). The same applies to Unicode flag `/u` interactions with `\w`/`\d` and to `/s` with the dot — never trust an English description of a regex against the actual pattern. The rf-pdpl-5 unit caught the gotcha before commit because the test failure made the verbatim match output explicit; without test-first discipline, the brief's "no upper-case" assertion could have shipped as a silent behaviour change wrapped inside an "extract" diff. Cite from any future rf-pdpl unit (or any extraction unit elsewhere) where the brief's prose describes regex behaviour rather than quoting the regex literal alone. - -The final rf-canv-28+29 audit found 4 unused entries: 2 destructure (`recalculateAncestorBounds`, `calculateMinimumContainerSize` — held by rf-canv-25a brief), 2 local helpers (`isNodeFolded` from rf-canv-3, `calculateContainerBounds` from rf-canv-4). Both pairs were traceable to "future consumer" predictions that never materialized. The right time to find this is the cleanup unit, not earlier — earlier units would have churned them needlessly. - -Pair this with `brief-vs-rf-canv-21-trim-rule-when-the-planner-knows-the-future-callsite` (rf-canv-25a) — that one is "during the series, hold the bet"; this one is "at series end, audit the bets and trim the unpaid ones." Together they describe the full lifecycle of a held-for-future-callsite destructure entry: introduced by a planner with paired-unit visibility, preserved across split-units regardless of lint noise, then retired in the cleanup pass once the series closes without the predicted consumer materializing. - -## collecttext-regex-sweep-fails-because-join-erases-key-boundaries - -_Discovered: 2026-04-30 by implementer in rf-pdpl-8_ - -The natural shape for a "no unexpected translation keys" sweep in a direct-FC tree-walker test is: `collectText(tree).match(/deploy\.auth\.[A-Za-z]+/g)` and assert the resulting Set equals the expected keys. That fails in a non-obvious way when the rendered tree has multiple t() leaves at the same depth: `collectText` joins all string parts with no separator, so two adjacent leaves like `'deploy.auth.connecting'` and `'deploy.auth.browserPrompt'` become the single string `'deploy.auth.connectingdeploy.auth.browserPrompt'`. The greedy `[A-Za-z]+` then matches across what _looked like_ a `.`-separated boundary — `connecting` immediately followed by `deploy` has no `.` between them — yielding `connectingdeploy` as one capture, then the regex's next-position scan starts at `.auth.browserPrompt` (no leading `deploy.auth.` to match), so the second key is missed entirely. Symptom: `expected Set { 'deploy.auth.connectingdeploy' } to deeply equal Set { 'deploy.auth.connecting', 'deploy.auth.browserPrompt' }`. Reasonable English reading of the test code does not surface the gotcha; only running it does. Fix: don't regex-sweep concatenated text. Walk the tree per-element, collect each string-typed `children` leaf individually (`for (const el of walk(tree)) { if (typeof el.props.children === 'string') leafTexts.push(el.props.children); }`), filter to the keys you care about, and assert the Set on the per-leaf list. Generalizes: any direct-FC tree-walker test that wants to assert "the only translation keys present are X and Y" should never rely on `collectText`'s output as the source — `collectText` is for human-readable presence/absence checks (`expect(text).toContain('foo')`), not for boundary-sensitive regex sweeps. Per-leaf collection is the right shape for any "exhaustive set of t() calls" assertion. Pair this with the `vi-hoisted-for-stable-mock-identity-in-direct-fc-tree-walker-tests` and `tree-walker-must-invoke-file-private-fcs-when-extracted-component-keeps-an-inner-helper` learnings — they cover how to BUILD the walker; this one covers a load-bearing limitation of the `collectText` reduction layered on top. Cite this from any future rf-pdpl section test (and elsewhere) whose component renders 2+ adjacent translation-key strings and the test wants an "only these keys" assertion rather than a "these keys appear" one. - -## react-element-ref-is-not-on-the-public-reactelement-type - -_Discovered: 2026-04-30 by implementer in rf-pdpl-10_ - -When extracting a component that takes a forwarded ref as a prop and renders it on an HTML element (`<div ref={logEndRef} />`), the natural test pattern is "pass `React.createRef<HTMLDivElement>()` and assert reference equality on the rendered element's `ref` field." That works at runtime — React 18 stores the `ref` as a top-level field on the React element, NOT inside `el.props` (the auto-PR-eslint-rule that says "ref is a prop" is a React 19 thing) — but `tsc` rejects `el.ref` with `error TS2339: Property 'ref' does not exist on type 'ReactElement<any, string | JSXElementConstructor<any>>'`. The public `ReactElement` type intentionally hides the `ref` field because reading it post-mount is undefined behavior in app code; tests are the legitimate exception. Fix: cast through `unknown` to access it: `(el as unknown as { ref?: React.Ref<unknown> | null }).ref ?? null`. Wrap in a tiny helper (`elementRef(el)`) so the cast lives in one place rather than N test sites. Don't reach for `// @ts-expect-error` or `as any` — the `unknown`-roundtrip cast satisfies `no-explicit-any` and reads as a deliberate accessor for the internal field. Generalizes: any future direct-FC tree-walker test in this codebase that needs to verify ref-pass-through (the rf-pdpl-21 `useDeployEffects` extract will, since the ref is the load-bearing prop boundary; potentially also any future portal-target-ref tests) should use the `elementRef` helper rather than touching `el.ref` directly. The runtime side is fine; only `tsc` complains. The `props.ref` path also exists as a synonym in some React-18 paths but is not stable across all element kinds; prefer the top-level `el.ref` accessor uniformly. - -## defensive-or-fallback-after-pre-filter-is-an-unreachable-branch-95-pct-ceiling - -_Discovered: 2026-04-30 by implementer in rf-pdpl-11_ - -The DnsRecordsSection extraction kept the source's `((r.outputs as any).custom_domain_dns_records || []) as DnsRec[]` cast verbatim per RISK #7's "preserve `outputs as any` cast at util boundary" guidance. But `extractDnsResults` (the upstream filter) already requires `Array.isArray(...)` AND `.length > 0` on the same field, so by the time the cast on line 98 runs, `r.outputs.custom_domain_dns_records` is GUARANTEED to be a truthy non-empty array — the `|| []` defensive fallback is structurally unreachable. v8 coverage flags this as 90% branch coverage on the module (1 uncovered branch); no test fixture can hit it without bypassing the filter, which the test harness has no way to do because both filter and consumer share the same input. Naively chasing 100% by mocking `extractDnsResults` would test mock behavior, not the component. Two acceptable resolutions: (a) accept the ≤95% branch ceiling and document why in the PR/commit (this is what rf-pdpl-11 did — the brief explicitly anticipated "may land at 95%+"), (b) drop the `|| []` and trust the filter's invariant, which is the cleaner refactor BUT a behavior change the brief explicitly forbids on this unit. Generalizes: when an extracted component's source has a "defensive OR-fallback after upstream filter" pattern, expect the v8 coverage to land at <100% branches and call it out in the commit message. The fallback itself is load-bearing for the case where a future refactor relaxes the filter — it's coverage debt by design, not by oversight. The pattern recurs anywhere a filter+map shares an invariant: the map's per-row defensive code is unreachable but should not be removed because the invariant is upstream and could drift. - -## react-namespace-hook-access-requires-patching-default-export-too - -_Discovered: 2026-04-30 by implementer in rf-pdpl-12_ - -The rf-props-17/19 mutable-ref `useState` mock pattern (`vi.mock('react', () => ({ ...actual, useState: patchedFn }))`) ONLY patches the named exports. When the source code accesses hooks via the namespace import — `import React from 'react'; ...; React.useState(...)` — the runtime resolves `React` to the `default` export of the `react` module, NOT the namespace. The default export carries its own copy of `useState`, and unless you patch THAT too, calls like `React.useState('')` route into the real renderer-context-bound function and throw `TypeError: Cannot read properties of null (reading 'useState')`. Symptom: the test fires up the component, the FC body runs, and the very first `React.useState(...)` call hits the real React with no dispatcher. Fix: in the `vi.mock('react', ...)` factory, return BOTH `useState` (named) AND `default: { ...actualDefault, useState: patchedFn }` so the namespace and the default both see the patched version. Applies symmetrically to `useEffect`, `useMemo`, `useCallback`, `useRef`, etc. — any hook the source accesses as `React.X(...)` rather than the destructured form `import { X } from 'react'`. The destructured form is what `deploy-history.tsx` uses (it calls `useState(...)` directly), which is why the rf-props-19 tests didn't need the default-patch. The namespace form is what `destroy-confirm-modal.tsx` uses (it calls `React.useState(...)` and `React.useEffect(...)`), which is why this trap re-emerged. Note: the React types module (`@types/react`) doesn't always declare a `default` export on the namespace, so reading `actual.default` in the factory triggers `TS2339: Property 'default' does not exist`. Cast through `unknown` once: `(actual as unknown as { default?: typeof actual }).default ?? actual`. Generalizes: every future direct-FC tree-walker test where the SOURCE uses `React.X(...)` namespace access needs the dual-patch (named + default). Diagnostic: the "Cannot read properties of null (reading 'useState')" error stack points at the source line; if your mock factory only returns `{ ...actual, useState: ... }` and you see this, add the `default: { ...actualDefault, useState: ... }` block. Cite this from any future rf-pdpl section test where the source uses `React.useState`, `React.useEffect`, `React.useMemo`, `React.useCallback`, or `React.useRef` (`destroy-confirm-modal.tsx` uses the first two; the planner flags `deploy-controls`/`api-error-banner` as candidates). - -_Promoted to: /docs/refactoring-patterns.md_ - -## stubbing-window-and-keyboardevent-for-node-env-keydown-listener-tests - -_Discovered: 2026-04-30 by implementer in rf-pdpl-12_ - -The vitest default environment for this repo is `node` (per `vitest.config.ts` — no `environment: 'jsdom'` or `'happy-dom'`), so `window`, `document`, and `KeyboardEvent` are all undefined globals. When an extracted component's `useEffect` registers a `window.addEventListener('keydown', ...)` listener — DestroyConfirmModal does this for Esc-to-cancel — the test EITHER has to switch the environment (jsdom adds latency and pulls in a heavy DOM polyfill) OR stub the three globals minimally. Switching environment is the wrong call for a single suite — it's a cross-cutting config change that affects coverage isolation and adds 100ms+ to the test file. Stub instead: (a) `vi.stubGlobal('window', { addEventListener: vi.fn(...), removeEventListener: vi.fn(...), dispatchEvent: (e) => { ... } })` with a Map<string, Set<Listener>> tracker so add/remove/dispatch round-trips correctly; (b) `vi.stubGlobal('document', { body: {} })` since `createPortal(el, document.body)` evaluates the second arg even when the portal mock ignores it; (c) `vi.stubGlobal('KeyboardEvent', class StubKeyboardEvent { constructor(type, init) { this.type = type; this.key = init?.key ?? ''; } })` so the test can `dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`. The stub class only needs the fields the source's keydown handler reads — here `{ key }`. The Map-based event bus also lets the test assert that cleanup removes the listener: capture the cleanup function the `useEffect` mock returns, run it, then dispatch another keydown and verify the onCancel spy was NOT called. Generalizes: every future rf-pdpl section that uses `window.addEventListener` or `document.body` (the planner flags `deploy-controls` for cancel-fetch on outside-click and the orchestrator's keydown handlers for the panel itself) should reach for the same three-stub triple — window event-bus + document.body + KeyboardEvent class. Don't reach for jsdom unless the test needs full layout (`getBoundingClientRect`, `getComputedStyle`, etc.); a 30-line stub is dramatically faster and the assertion surface is the same. Pair this with the `react-namespace-hook-access-requires-patching-default-export-too` learning — the two together unblock testing modals/portals with `useEffect`-registered global listeners under the direct-FC tree-walker pattern in the node environment. Diagnostic: `ReferenceError: window is not defined` (or `document is not defined`, `KeyboardEvent is not defined`) at the source line; add the corresponding stub and continue. - -## react-memo-wrapper-must-be-unwrapped-via-dot-type-for-direct-fc-tree-walker - -_Discovered: 2026-04-30 by implementer in rf-pdpl-13_ - -The direct-FC tree-walker pattern (rf-props-6, rf-pdpl-7..12) invokes the extracted component as `(Component as unknown as Fn)(props)` and walks the returned tree. That works when the export is a plain function FC. For `React.memo`-wrapped components — DeployNodeRow is the first such in rf-pdpl, DeployInFlightPanel will be the next in rf-pdpl-14 — the runtime export is NOT a function: it's an object `{ $$typeof: Symbol(react.memo), type: <Inner FC>, compare }`. Calling that object directly throws `TypeError: ... is not a function`. The fix is one line in the `renderXxx(...)` helper: reach for `.type` to get the inner render and invoke that — `const Inner = (Component as unknown as { type: (p: Props) => React.ReactElement }).type; return Inner(props);`. The walker itself doesn't need changes (it already invokes anything `typeof el.type === 'function'`, and the inner FC passed to `Inner(props)` returns intrinsic elements). Bonus: this lets you pin the memo boundary as a separate test slot — `expect((Component as { $$typeof: symbol }).$$typeof.toString()).toBe('Symbol(react.memo)')` proves the wrap is intact, and the inner FC's `displayName` plus the wrapper's own `displayName` (which the source sets via `Component.displayName = '...'`) are both observable. Generalizes: any future leaf that is exported as `React.memo(({ ... }) => ...)` must use the `.type`-unwrap renderer in its test file. The brief should call out "memo'd — unwrap via .type" wherever the source has `React.memo(`. Cite alongside `react-memo-on-rollup-component-instead-of-shallowequal-on-selector` since the memo boundary is what keeps the row from re-rendering on every parent state change — both learnings reinforce that React.memo is a load-bearing structural choice, not cosmetic. - -_Promoted to: /docs/refactoring-patterns.md_ - -## vi-mock-paths-resolve-relative-to-test-file-not-source-file - -_Discovered: 2026-04-30 by implementer in rf-pdpl-14_ - -When a brief specifies "mock the imports the source uses" and lists those paths verbatim — `'../../../i18n'`, `'../../../store/slices/deploy-slice'` — it is tempting to copy them into `vi.mock(...)` calls in the test file unchanged. That is wrong: `vi.mock` resolves the first argument relative to the **test file's** location, not the source file's. So when the source lives at `packages/ui/src/features/deploy/components/deploy-in-flight-panel.tsx` (`../../../i18n` → `packages/ui/src/i18n`, correct), the test at `packages/ui/src/features/deploy/components/__tests__/deploy-in-flight-panel.test.tsx` needs ONE more `../` (`../../../../i18n` → `packages/ui/src/i18n`) to point to the same module. The diagnostic when this is wrong is subtle: vitest creates a "phantom" module at the (test-relative) path, the mock attaches to that phantom, but the source still imports the real module — so the test fails with errors from inside the real `useTranslation` (e.g. `TypeError: Cannot read properties of null (reading 'useContext')` because the real i18n hook tries to read a React context that was never wrapped by a Provider in the unit test). The fix is mechanical: every `vi.mock(path, ...)` in a `__tests__/`-folder test must have one extra `../` segment compared to the source-file's import string for that same module. Source-relative imports of mocked sibling files (e.g. `'../deploy-node-row'` from a sibling test in `__tests__/`) DO match because both source and test sit one level deep relative to a common parent. Generalizes: any `__tests__/`-folder test mocking modules at paths the source uses must add one `../` (vs the source's import string) for any path that ascends out of the `components/` folder; sibling-folder mocks like `'../deploy-node-row'` (also `'../sections/...'` if from `__tests__/`) need a hop too — `'../../deploy-node-row'`. When in doubt, derive both: use the source's import path for the _type-only_ import in the test (TS resolves paths relative to the test file too, so the path also needs `../../../../` — same logic), and use the same path for the `vi.mock` argument. They will always be identical. - -_Promoted to: /docs/refactoring-patterns.md_ - -## lucide-react-icons-are-forwardref-objects-not-fcs-for-tree-walker-predicates - -_Discovered: 2026-04-30 by implementer in rf-pdpl-14_ - -The direct-FC tree-walker (rf-props-6 / rf-pdpl-7..13 pattern) descends through React elements by checking `typeof el.type === 'function'` and invoking the FC, otherwise treating `el.type` as an intrinsic string ('div', 'span') and recursing through `props.children`. That mental model breaks for `lucide-react` icon imports — `Loader2`, `RefreshCw`, `CheckCircle`, etc. — because lucide wraps every icon in `React.forwardRef`, which produces an _object_ with `{ $$typeof: Symbol(react.forward_ref), render: <fn> }`, NOT a function. Predicates like `findByPredicate(tree, (el) => typeof el.type === 'function' && (el.props.className ?? '').includes('animate-spin'))` filter the icon out and the assertion fails with a confusing "expected 0 to be greater than or equal to 1". The walker itself yields the element fine (it's the predicate that's wrong). Fix two ways: (a) drop the `typeof el.type === 'function'` guard and predicate purely on the className, since lucide always sets the icon's class to whatever the JSX prop passed in plus internal `lucide-*` markers — `findByPredicate(tree, (el) => typeof (el.props.className) === 'string' && el.props.className.includes('animate-spin'))` works for any JSX with that className regardless of element type; (b) if you genuinely need to gate on "this is the icon", check `(el.type as { $$typeof?: symbol }).$$typeof?.toString() === 'Symbol(react.forward_ref)'` and combine with the className filter. Option (a) is enough for unit tests where the className is unique. Generalizes: every component-extraction test that asserts on a lucide icon's presence (Loader2 in DeployInFlightPanel, RefreshCw in retry buttons, X in panel close, CheckCircle in success states, AlertCircle in errors) should NOT use the FC-only predicate. Same gotcha applies to any other library that uses `React.forwardRef` for a leaf component — Radix UI primitives, react-aria components, etc. Pair with `react-memo-wrapper-must-be-unwrapped-via-dot-type-for-direct-fc-tree-walker`: memo-wrapped components ALSO have `el.type` as a non-function object, but their `$$typeof` is `Symbol(react.memo)` and the inner FC sits at `.type`. Tree-walker code already handles memo because the walker invokes the _outer_ component via the renderXxx helper (not via tree-walking), but predicates that filter on `el.type` need to know about both forwardRef and memo $$typeof variants when matching nested wrapped components. - -_Promoted to: /docs/refactoring-patterns.md_ - -## lucide-react-aliased-icons-displayname-tracks-target-not-binding - -_Discovered: 2026-04-30 by implementer in rf-pdpl-15_ - -A natural extension of the `lucide-react-icons-are-forwardref-objects-not-fcs-for-tree-walker-predicates` learning is to filter for "this is the CheckCircle icon" by `(el.type as { displayName?: string }).displayName === 'CheckCircle'`. That breaks in lucide-react v0.577 (and any version that introduces "alias re-exports") because several legacy names — `CheckCircle`, `AlertCircle`, `Edit3`, etc. — are now re-exported from a renamed underlying icon: `node_modules/lucide-react/dist/esm/icons/check-circle.js` is literally `export { default } from './circle-check-big.js';`. The `createLucideIcon('circle-check-big', ...)` call inside the target file sets `Component.displayName = toPascalCase('circle-check-big') = 'CircleCheckBig'`. So `import { CheckCircle } from 'lucide-react'` resolves to a forwardRef whose `displayName` is `CircleCheckBig`, NOT `CheckCircle`. Diagnostic: the test fails with `expected 0 to be greater than or equal to 1` from a finder that filters on `displayName === 'CheckCircle'` even though the JSX clearly renders the icon. Fix: filter by **reference equality** on `el.type` against the same imported icon (`findIconsByRef(tree, CheckCircle)` where `CheckCircle` is imported from `'lucide-react'` in the test). The forwardRef object's identity is preserved by the alias re-export — `import { CheckCircle } from 'lucide-react'` in the source and the test resolve to the same module-singleton, so reference equality holds across the source/test boundary. This is also more robust than displayName matching: future lucide renames (or the upstream "deprecate alias, keep export" cleanup) won't break the tests, and the binding name in the source is always the correct probe. Pair with the parent learning: the parent says "lucide icons are forwardRef objects, so don't gate on `typeof el.type === 'function'`"; this one adds "and don't gate on `displayName` either — use reference equality instead." Hit on `ResultsSummary` where `CheckCircle.displayName === 'CircleCheckBig'` and `AlertCircle.displayName === 'CircleAlert'`. Generalizes: any future test asserting "this exact icon was rendered" should reach for the reference-equality probe up front; any test asserting "_some_ icon was rendered" can still use the className-substring filter from the parent learning (icons always carry `lucide-${kebabCase}` markers regardless of alias). - -## prop-capturing-mock-fc-needs-drain-and-reset-for-tree-walker-tests - -_Discovered: 2026-04-30 by implementer in rf-pdpl-18_ - -When a section module renders a child component twice in the same FC body — `ConfigSection` instantiates `IceSelect` once for the project field (when `connectedProjects.length > 0`) and once unconditionally for the region field — the natural test pattern is to mock the child with an opaque marker FC that pushes its props onto a hoisted array (`mocks.iceSelectCalls.push(props)`). Then the test reads `mocks.iceSelectCalls[0]` for the project call and `[1]` for the region call (declaration order in the JSX). The trap: the direct-FC tree-walker (rf-props-6 / rf-pdpl-7..15 pattern) invokes nested FCs as a side-effect of walking, and `findByPredicate` / `collectText` walk THE WHOLE TREE every time they're called. So a test that mixes "drain IceSelect props" with "find an input element" ends up with TWO walks (one for each helper), and each walk fires the IceSelect mock again — `mocks.iceSelectCalls.length` doubles non-deterministically. Symptom: `expected 1 to be 2` or vice versa, depending on which test runs first and how many times the helpers re-walk. The standard `vi.hoisted` reset in `beforeEach` doesn't fix this — the duplicate pushes happen WITHIN one test. Fix: introduce a `drainIceSelectCalls(tree)` helper that does (a) clear the mock-call recorder, (b) run a single throwaway walk over the tree, (c) snapshot the recorder into a local array, (d) clear the recorder again so subsequent helper-walks don't pollute the snapshot. Tests then assert against the local snapshot, not the mutable recorder. This separates "the walker invokes FCs" from "we need a stable snapshot of the per-render call sequence." The same shape applies to ANY mock that captures props as a side-effect of being invoked — Radix primitives, react-aria primitives, custom forwardRef-wrapped opaques. Generalizes: every future rf-pdpl (or successor) section test that mocks a child component with a per-call props-capture pattern AND then walks the tree via `findByPredicate` / `collectText` afterwards needs the drain-and-reset wrapper. The alternative — keeping props-capture and only inspecting through tree-walking, never through the recorder — works too but loses the per-call positional identity (call 0 vs call 1 vs call 2) that drain-and-snapshot preserves. Pair this with `vi-hoisted-for-stable-mock-identity-in-direct-fc-tree-walker-tests`: the hoisted recorder gives stable identity across the file, the drain wrapper gives per-test stability against helper-walks. Diagnostic: a `length` assertion on the recorder that flips between 1 and 2 (or 2 and 4) depending on whether `findByPredicate` ran — that's the double-walk side-effect, not a flaky test. - -## redux-toolkit-unknown-action-payload-needs-double-cast-via-unknown - -_Discovered: 2026-04-30 by implementer in rf-pdpl-20_ - -When testing a callback hook that dispatches Redux Toolkit slice actions, the natural assertion shape is `(dispatchSpy.mock.calls[i][0] as { payload: string }).payload` — direct cast to a structural shape on the dispatched action. That fails under `tsc --noEmit` with `TS2352: Conversion of type 'UnknownAction' to type '{ payload: string; }' may be a mistake because neither type sufficiently overlaps with the other. Property 'payload' is missing in type 'UnknownAction' but required in type '{ payload: string; }'.` RTK 2.x's `Dispatch<UnknownAction>` types the spy parameter as `UnknownAction = { type: string; [extraProps: string]: unknown }` — the index signature deliberately does NOT include `payload` because not every action has one (the slice's reducer signature varies per action), and TS treats `payload` as a structural mismatch and refuses the cast. The `.type` field works because it's explicit on `UnknownAction`. Three workarounds, in order of cleanliness: (a) cast through `unknown`: `dispatchSpy.mock.calls[i][0] as unknown as { payload: string }` — the canonical TS escape hatch, mirroring the rf-pdpl-10 `el.ref` pattern (`(el as unknown as { ref?: ... }).ref`); (b) define a helper `function asAction<P>(call: unknown): { type: string; payload?: P } { return call as unknown as { type: string; payload?: P }; }` and call `asAction<string>(dispatchSpy.mock.calls[i][0])` — readable but adds a layer of indirection; (c) include `type` in the cast even when you don't need it: `as { type: string; payload: string }` — works because TS sees `type` as overlap, but masks the actual escape (the cast still uses the unknown round-trip TS implicitly does for the matching `type` field). Option (a) is what the rf-pdpl-20 test file landed on for ~20 cast sites. The `setImmediate` global is also missing in this monorepo's vitest config (no `@types/node` global types loaded) — for fire-and-forget promise flushes (handlePlan's background `fetchRequirements()` call), use `await new Promise<void>((resolve) => setTimeout(resolve, 0))` wrapped in a `flushMicrotasks` helper instead. Both gotchas surface together when porting the rf-props-8 capture-ref pattern to a callback hook that dispatches typed actions. Generalizes: every future hook test in this codebase that uses `vi.spyOn(store, 'dispatch')` and reads `.payload` off the captured calls must use the `unknown`-roundtrip cast or the helper. Cite from any subsequent rf-pdpl unit (especially rf-pdpl-21 `useDeployEffects` which will mock dispatches the same way). Diagnostic: TS2352 on a payload cast — add `as unknown as` between the action and the structural shape. - -## fingerprint-multi-useEffect-by-deps-array-shape-when-bundled-in-one-hook - -_Discovered: 2026-04-30 by implementer in rf-pdpl-21_ - -When extracting a multi-effect hook (rf-pdpl-21's `useDeployEffects` bundles four `useEffect` calls — auto-scroll on `[logs.length]`, provider auto-detect on `[isOpen, cardId, gcpProject, region, dispatch]`, deploy-event listener on `[isOpen, cardId]`, history-hydrate on `[cardId, dispatch]`), the rf-canv-23 sync-`useEffect` mock pattern works but each effect's body fires anonymously — the mock factory has no per-effect identity, just a flat list of `(cb, deps)` calls. The natural urge is to mock the four bodies separately by stubbing `setProvider`, `setRegion`, etc. one at a time; that's brittle (40+ tests, each re-imports the slice) and breaks if effect order shifts. The pattern that works: stash each registration as `{ cb, deps, cleanup }` into a single hoisted `mocks.effects` array, then in tests **fingerprint by deps-array shape** to find the effect under test (`effects[0]` has length 1 — that's auto-scroll; `effects[1]` has length 5 — that's provider auto-detect; etc.). Add a tiny `effectByOrder(i)` helper that throws if the slot is empty so test failures point at the registration index, not at a TypeError. The cleanup-stash is per-effect, NOT a single `effectCleanups[]` array — when only effect 4 returns a cleanup but effect 3 also returns one (the `onDeployEvent` unsubscribe), a flat array makes "find this effect's cleanup" non-trivial. Storing the cleanup inline with the registration (`effects[i].cleanup`) keeps the mapping 1:1 with the captured cb/deps. Test surface: dep-shape assertions (`expect(e.deps).toHaveLength(1)`), per-effect cb invocation (`effects[0].cb()` to re-fire after mutating `logEndRef.current`), per-effect cleanup invocation (`(effects[3].cleanup as () => void)()` to test the `cancelled = true` guard in the hydrate effect's IIFE). For the sync mock to fire effects with stable side-effects, mock React's `useEffect` BEFORE the hook import (vi.mock hoisting handles this) AND ensure the mock factory uses `await importOriginal<typeof import('react')>()` to preserve `useState`, `useRef`, `useMemo`, etc. — only `useEffect` is shimmed. The Provider+Probe+renderToString harness is otherwise identical to the rf-pdpl-20 callback-hook pattern. Diagnostic: when you add a 5th effect to the hook, the dep-shape fingerprint can collide (e.g. two `[isOpen, cardId]` effects → both length 2). Add a content check on the first dep value or move to named-export pure runners (rf-props-7 builder pattern). Generalizes: any future hook in this codebase with N≥3 `useEffect` blocks that all care about the same dispatch surface should use this fingerprint pattern rather than mocking each effect's downstream API independently. Coverage outcome on `use-deploy-effects.ts`: 100% statements / 100% branches / 100% functions / 100% lines with 48 tests — the multi-effect bundle is fully testable in node-only vitest because the effects are gated by short-circuit `return` checks (no React state-driven branching), and the async IIFE bodies fire dispatches we can observe via `dispatchSpy.mock.calls.find()`. - -## vitest-spyon-return-type-on-console-needs-loose-shape-cast-for-mock-calls-iteration - -_Discovered: 2026-04-30 by implementer in rf-pdpl-22_ - -When the test asserts on `console.log`/`console.error` calls by iterating `consoleLogSpy.mock.calls.find((c) => c[0] === '[destroy] destroyAll starting')`, the natural variable annotation `let consoleLogSpy: ReturnType<typeof vi.spyOn>` triggers `TS7006: Parameter 'c' implicitly has an 'any' type` on the find callback — the bare `ReturnType<typeof vi.spyOn>` (no generics) types `mock.calls` as `never[][]`, so the find-arg loses its tuple inference and TS falls back to `any`. The two natural-looking fixes both fail under the UI tsconfig: (a) `ReturnType<typeof vi.spyOn<Console, 'log'>>` produces `Type '"log"' does not satisfy the constraint 'never'` because vitest 4.1's overload resolution doesn't expose a 2-arg generic form usable from `typeof vi.spyOn`. (b) Importing `MockInstance` from vitest and using `MockInstance<Parameters<typeof console.log>, ReturnType<typeof console.log>>` works in some vitest versions but the parameter shape changed across vitest 1/2/4. The reliable fix is a tiny local interface — `interface ConsoleSpyLike { mock: { calls: unknown[][] }; mockRestore: () => void; }` — plus an explicit `as unknown as ConsoleSpyLike` cast on the spy. The Vitest matcher `expect(consoleErrorSpy).toHaveBeenCalledWith(...)` still works because vitest tracks spies by the underlying mock reference, not by the narrowed TS type — only the read-side iteration of `mock.calls` needs the loose-shape annotation. Diagnostic: TS7006 on a `.mock.calls.find/map/filter` callback whose param is implicit-any, where the spy's typed return is `ReturnType<typeof vi.spyOn>` without generics — the fix is the loose-shape interface, not chasing vitest's overload signatures. Generalizes: any future hook test that spies on console (or any side-effect API) and asserts on `mock.calls[i][0] === '...'` should use the same `ConsoleSpyLike` shape rather than `ReturnType<typeof vi.spyOn>`. Pair this with the rf-pdpl-20 `redux-toolkit-unknown-action-payload-needs-double-cast-via-unknown` learning — both surface together when porting the rf-props-8 capture-ref pattern to a callback hook that mixes typed Redux dispatches with console diagnostics. - -## pnpm-filter-core-test-with-path-arg-needs-root-relative-not-package-relative - -_Discovered: 2026-04-30 by implementer in rf-ctrans-1_ - -`pnpm --filter @ice/core test packages/core/src/deploy/utils/__tests__/name-utils.test.ts --run` exits with `No test files found`. Reason: the script `vitest run <path>` runs with cwd `packages/core/`, but the workspace's effective vitest `include` glob is `packages/*/src/**/*.test.{ts,tsx}` (root-level pattern). When vitest is launched from the package, the positional path arg is matched as a literal filter against the absolute test-file paths, but the package's local config doesn't override `include` — so the filter `packages/core/src/deploy/utils/__tests__/name-utils.test.ts` (a path that starts with `packages/`) doesn't match anything because the cwd-relative resolution treats it as `packages/core/packages/core/src/...` which obviously doesn't exist. Two fixes that work: (a) `pnpm vitest run --root . packages/core/src/deploy/utils/__tests__/name-utils.test.ts` from the repo root — the path is now relative to the workspace root, matches the include glob, runs. (b) `pnpm --filter @ice/core test src/deploy/utils/__tests__/name-utils.test.ts --run` — drop the `packages/core/` prefix so the path is package-relative. Option (a) is what the brief suggested and is the safer default because it works regardless of which package owns the file. The same gotcha doesn't bite `pnpm --filter @ice/ui test <path>` because @ice/ui's vitest config has its own include scoped to that package's src. Generalizes: when a brief specifies `pnpm --filter @ice/core test <path>` and you get `No test files found`, switch to root-level `pnpm vitest run --root . <path>` rather than debugging the filter — the @ice/core package's test script doesn't override include, so the path-arg semantics differ from the per-package patterns most refactor briefs were written against. - -## brief-vs-source-default-branch-discrepancy-on-get-type-map - -_Discovered: 2026-04-30 by implementer in rf-ctrans-3_ - -The rf-ctrans-3 brief stated `get_type_map`'s default branch returns `GCP_TYPE_MAP` (`per the original switch — default: return GCP_TYPE_MAP;`). The actual source at L1452 returns `{}` (empty object). Reading the brief literally and writing a test asserting `get_type_map('oracle') === GCP_TYPE_MAP` would have green-tested the wrong behavior — `translate_card_to_graph` calls `type_map[ice_type]` after the lookup, so the empty-object default produces "no mapping" (the node falls through to the unsupported-type branch and gets skipped) while a GCP-fallback default would produce a misclassified node deployed against GCP for non-GCP providers. The brief itself flagged "verify via reading the original" — that qualifier is the only thing that saved this from being a behavior-changing extract. Generalizes: every brief that claims a specific return value/default branch needs a one-line grep against the original before lifting the assertion into a test. The pattern matches the rf-canv-24 lesson about brief-listed imports needing per-symbol verification, and rf-canv-15's "stringly-typed prop" lesson about briefs that paraphrase real types loosely. Three out of three of these gotchas surface at the boundary where a brief author's summary differs from the load-bearing code detail. For rf-ctrans specifically, the planner's blueprint at `state/blueprints/rf-ctrans.md` is high quality on the structural decomposition (which functions go where, RISK flags) but the per-line behavior assertions in the unit briefs are best-effort summaries — verify before transcribing into tests. - -## graph-nodes-keyed-by-type-colon-name-not-bare-name - -_Discovered: 2026-04-30 by implementer in rf-ctrans-10_ - -`MutableGraph._nodes` is `Map<NodeId, Node>` where `NodeId` is the branded form `${input.type}:${input.name}` (assigned in `add_node` at `mutable-graph.ts` L97). The public getter `graph.nodes` returns this Map directly, so `graph.nodes.get(plainName)` will always miss — the lookup needs the full `type:name` composite. Pass 1.4 and Pass 1.45 in `card-translator.ts` both call `graph.nodes.get(name as any)` where `name` comes from `card_id_to_name` (which stores bare resource names from `add_node({...name})` at L320, not the prefixed NodeId). Net effect: in production, both passes silently no-op every iteration through the `if (!computeGraphNode) continue` guard. The `as any` cast hides the type mismatch from the compiler. Two clean alternatives exist on `MutableGraph`: `get_node_by_name(name)` (uses the internal `node_names` Map<string, NodeId>) and `update_node(id, {properties})` (the proper mutation entry point). The verbatim refactor preserves the latent bug — fixing it is out-of-scope for rf-ctrans-10..12 and belongs in a follow-up unit. Pinning the test against a `card_id_to_name` populated with the actual NodeId (the value returned by `add_node().node!.id`) is what makes the unit tests cover the mutation path; if the test mirrors the orchestrator's bug-for-bug data flow and hands in bare names, the tests pass trivially because every iteration short-circuits at the lookup. Generalizes: when extracting a function that calls `Map.get(x as any)`, grep for how `x` is produced and verify the cast hides nothing — a no-op-in-production extraction should at minimum get a `// FIXME(post-rf-ctrans):` comment in the new module so a future reader doesn't have to reverse-engineer the silence. - -## if-routeid-branch-no-fallthrough - -_Discovered: 2026-04-30 by implementer in rf-ctrans-11_ - -Pass 1.45's subdomain priority is documented in the inline comment as "1. routeId → 2. edge.subdomain → 3. blank", which reads like a fallback chain. It is not a chain — it's a mutually exclusive `if (routeId) { use route } else { use edge.subdomain }` split. When `routeId` is truthy but the route lookup misses (no matching `routes[].id`), the code does not fall through to `edge.subdomain`; it commits to empty subdomain via `route?.subdomain || ''`. Practical consequence: an edge that has both `routeId: 'route-MISSING'` and `subdomain: 'edge-sub'` set produces bare `rootDomain` (no subdomain), not `edge-sub.rootDomain`. The brief flagged this as RISK #6 — the gotcha is that "priority order" naming makes you reach for a fallthrough mental model when the code is a strict bifurcation. Pin both the success path (routeId wins over edge.subdomain when route is found) AND the route-not-found path (still empty, not fallthrough) explicitly in tests; the latter is the load-bearing assertion for anyone tempted to "fix" the missing fallthrough. Generalizes: any `if (X) {} else {}` that the surrounding comment describes as a "priority chain" deserves a regression test for the "X-truthy-but-fails-internally" path, because that branch invariant is the easiest one to silently break under refactoring. - -## stash-discards-untracked-files - -_Discovered: 2026-04-30 by implementer in rf-ctrans-11_ - -When validating a refactor with `git stash` to compare a tsc baseline before vs after edits, `git stash` (without `-u` or `--include-untracked`) only captures tracked-file modifications — newly-created (untracked) files like the new `passes/pass-1-45-domain-propagation.ts` and its test file stay in the working tree across stash/pop. If the unit edits a tracked file (card-translator.ts) AND adds new untracked files, doing `git stash && cd elsewhere && tsc-baseline-check && git stash pop` will: (a) discard the tracked-file edit, (b) keep the new untracked file in place, (c) potentially fail to pop on tsbuildinfo conflicts and leave the stash hanging. The result is a working tree that looks superficially intact (the new module + test file are still there) but the orchestrator's call site and import are silently reverted. Always grep for the new symbol's call site after a stash-pop to confirm the tracked-file edit returned. Better workflow: skip the stash dance entirely. Run `git diff packages/core/src/deploy/card-translator.ts > /tmp/edit.patch && git checkout packages/core/src/deploy/card-translator.ts && tsc 2>&1 | wc -l && git apply /tmp/edit.patch` — explicit and reversible without losing untracked files. Or just trust that pre-existing tsc errors are pre-existing and grep the error output for your own filenames. - -## test-fixture-nodeid-mapping-cascades-into-synthetic-names - -_Discovered: 2026-04-30 by implementer in rf-ctrans-12_ - -Building on `graph-nodes-keyed-by-type-colon-name-not-bare-name`: when a test fixture maps `card_id_to_name` to the full `${type}:${name}` NodeId so the in-fn `graph.nodes.get(name as any)` lookups hit, that NodeId is then read as `targetResourceName`/`forwardingResourceName` and fed into `sanitize_name(\`${forwardingResourceName}-cert\`)` and `sanitize_name(\`${targetResourceName}-backend\`)`. The colons and dots in the NodeId (`gcp.compute.globalForwardingRule:fr-1`) get stripped by sanitize_name, producing names like `gcp-compute-globalforwardingrule-fr-1-cert`instead of the expected`fr-1-cert`. Pin assertions against the actual derivation by importing `sanitize_name`into the test file and calling`sanitize_name(\`${endpointNodeKey}-cert\`)`rather than hard-coding`'fr-1-cert'`. The same applies to `deployables.find((d) => d.resource_name === ...)`checks — the resource_name written to the deployables array is whatever the function read from`card_id_to_name`, which in test mode is the full NodeId, not the bare resource name. Generalizes: when extraction tests work around a latent lookup bug by handing the function the in-graph key as the value of `card_id_to_name`, every downstream string-derivation path has to compensate too — copy your fixture's mapping into the assertion, don't hard-code names. - -## brief-import-list-may-include-transitively-referenced-types - -_Discovered: 2026-04-30 by implementer in rf-cards-1_ - -The rf-cards-1 brief specified the orchestrator's `import type { ... } from './cards/types'` line as `{ CardNode, CardEdge, CardViewport, Card, CardsState, CardSnapshot, CardHistory }`. But `CardSnapshot` and `CardHistory` are only referenced _inside_ the type definitions in `cards/types.ts` itself (`CardsState.history: Record<string, CardHistory>`, `CardHistory.past: CardSnapshot[]`) — they are NOT directly named in `cards-slice.ts` after the extraction; the orchestrator only references them transitively through `CardsState`. Including unused names in the import-type list trips this codebase's `unused-imports/no-unused-vars` lint (visible already in the rf-cards blueprint's L299 instruction to keep that exact eslint-disable comment). The fix is one line: trim the orchestrator's `import type { ... }` to only the names actually used at the syntactic level — `{ CardNode, CardEdge, CardViewport, Card, CardsState }` here. The new types module STILL exports `CardSnapshot`/`CardHistory` (marked `@internal`) for future rf-cards-* siblings (`persistence.ts`, `snapshot.ts`) that will name them directly. Generalizes: when a brief lists the orchestrator's import list, treat it as the union of "types referenced anywhere in the slice" — including transitively. The implementer's job is to subset that to "types referenced at the syntactic level inside *this file\* after the move." The brief's list is a superset, not a literal copy-paste target. Pair with the rf-canv-1 export-from-and-import-from-pattern: that one is about the canonical two-line shape; this one is about scoping the import list to actually-used names. Diagnostic: when in doubt, after the edit grep the file for each name in the import list — if a name appears 0 times outside the import statement itself, drop it from the list. - -## jsdoc-comment-block-closes-on-asterisk-slash - -_Discovered: 2026-04-30 by implementer in rf-cards-2_ - -A JSDoc block that documents migration semantics around `Cluster.*/Block.*` (legacy iceType prefixes that were rewritten to `Group.*`) parses as a syntax error: the literal `*/` inside the doc comment closes the JSDoc block early, and the rest of the comment becomes unparseable code. The vite oxc transformer's diagnostic doesn't say "comment closed early" — it points at the resumed-as-code sequence (in my case `BEFORE the` from the next line) and labels it `[PARSE_ERROR] Unexpected token`, which reads like a TypeScript bug, not a comment-closure bug. The fix is to drop the `*` that's adjacent to the `/`: write `Cluster./Block.` without the wildcard glyph, or reflow the prose to put the slash on a different line. This bites any free-form prose that happens to type a regex-like or path-like fragment containing `*/` — module names, glob patterns, comment-style examples. Diagnostic shortcut: when a parse error points at innocuous prose deep inside a JSDoc and the column number is small, scan the preceding line for `*/`. Generalizes: prose inside `/** ... */` blocks shares syntax with code; any sequence that closes the block (`*/`) or opens an inner one (`/*`) needs to be escaped, broken across lines, or rewritten. Code spans aren't a fix — backticks don't lex inside comments. - -## stacked-jsdocs-precede-only-the-immediate-next-decl - -_Discovered: 2026-04-30 by implementer in rf-cards-3_ - -The pre-extraction `cards-slice.ts` had three `/** ... */` blocks stacked on consecutive lines (L185-188, L189-193, L194-198) followed by three function declarations: `invalidateEdgeRoutesTouching` (L199), `applyEdgeRoutes` (L206), `cascadeContainerReflow` (L219). Per the JSDoc spec only the LAST stacked block (L194-198) is the doc comment for the immediately-following symbol — the earlier blocks are floating prose with no documentation linkage to anything (they happen to describe `cascadeContainerReflow` and `applyEdgeRoutes` respectively, but the parser doesn't know that). When extracting these functions to a new module, copy each JSDoc to sit immediately above the function it actually describes; preserving the original stacked layout would propagate the broken linkage forward. Diagnostic: when reading a region that has more JSDoc blocks than declarations, scan from the last `*/` UP and pair them with declarations from the FIRST one DOWN — any JSDocs that don't pair are floating prose. Generalizes: JSDoc semantics are lexically tight (the comment must immediately precede the declaration); any pre-existing layout that violates this is a code-organization bug to fix during a move, not a contract to preserve. The eslint-disable-next-line directive has the same single-target rule — it applies only to the line immediately following — and the blueprint's RISK #6 was pinning that, but stacked JSDocs above a function are a sibling problem worth flagging too. - -## reset-module-let-via-synthetic-call-not-vi-resetModules - -_Discovered: 2026-04-30 by implementer in rf-cards-5_ - -The `pushSnapshot` helper extracted to `cards/snapshot.ts` in rf-cards-5 keeps coalescing state in a module-level `let _lastSnapshotAction = ""`. Vitest tests need a clean baseline between cases or the streak from one test leaks into the next, suppressing snapshots in unrelated assertions. Two approaches: (a) `vi.resetModules()` + dynamic `await import("../snapshot")` in every test, which forces a fresh module instance per case but inflates the test code with async dance and re-import boilerplate, or (b) call the function once with a synthetic non-coalescing action like `pushSnapshot(throwawayState, "reset-coalesce")` in `beforeEach` to overwrite `_lastSnapshotAction` to a sentinel that won’t match any real action type. Approach (b) is faster, keeps the suite synchronous, and the sentinel name (`reset-coalesce`) self-documents intent. Pair it with `activeCardId: null` in the throwaway state so the reset itself is a no-op past the coalescing check (the function early-returns when no active card is found, so no fixture pollution). Generalizes: when a module-private `let` is the _mechanism_ (not an implementation detail), `vi.resetModules()` is heavy machinery; if you have any external API that touches the variable, calling that API with a known-safe input is a cheaper reset. Diagnostic: pick (a) only when the module-private state has no observable channel — e.g., a counter that never round-trips through any export; otherwise (b) is the simpler tool. - -## brief-numerics-are-approximate-source-is-canonical - -_Discovered: 2026-04-30 by implementer in rf-cards-7_ - -The rf-cards-7 brief described the `clearCardDeployOverlay` reducer as having a "20-field `fieldsToClear` array" — the test was supposed to "pin all 20 fields are cleared." The actual source list has 24 fields (`provider_id`, `deploy_status`, `deploy_progress`, `deploy_error`, `deploy_outputs`, `last_deployed_at`, `deployed_image`, `url`, `default_url`, `firebaseapp_url`, `console_url`, `site_id`, `source_repo`, `source_branch`, `republished_from_repo`, `custom_domain`, `custom_domain_url`, `custom_domain_status`, `custom_domain_dns_records`, `public_grant_failed`, `public_grant_error`, `public_grant_strategy`, `ip_address`, `IPAddress`). Same shape with `migrateCardNode`: the brief implies a single migration target, but the source has TWO migration branches — `Monitoring.Terminal` → `Monitoring.Log` (data-only, type preserved) AND `Cluster.*/Block.*` → `Group.*` with `type: 'container'` flip. Initially picked the wrong target name (`Block.Terminal`) for the migration assertion based on memory/inference. Fix: read the source migration module (`cards/migration.ts`) before writing the assertion. Generalizes: when a brief gives numeric or named specifics ("20 fields," "the migrator wires Foo to Bar"), treat them as approximate scaffolding — open the source file, count/copy the actual values, and pin THOSE in the test. The source is the contract; the brief is the high-level pointer. Same shape as the rf-cards-1 import-list learning (the brief's `import type` list is a superset, not a literal), but applied to test fixture values rather than orchestrator imports. Diagnostic: any test that pins a list of strings or a specific transformation should have its fixture values traced back to a single source-of-truth `Read` of the production module — never typed from memory. - -## relative-import-depth-must-be-recounted-when-moving-deeper - -_Discovered: 2026-04-30 by implementer in rf-cards-8_ - -The rf-cards-8 brief said "the `..` count for canvas-constants is 3 (verified by rf-cards-3)." That was true for `cards/edge-routes.ts` (depth: `packages/ui/src/store/slices/cards/edge-routes.ts` — 3 segments above to reach `src/`). But rf-cards-8 lives in `cards/reducers/node-position.ts`, which is ONE level deeper, so the correct count is 4 (`../../../../config/canvas-constants`). The first typecheck attempt failed with `TS2307: Cannot find module '../../../config/canvas-constants'` because of this off-by-one. Pattern to remember: when a sibling cites a `..`-count, that count is anchored to THAT sibling's directory depth, not yours. Anything inside `cards/reducers/` needs +1 over anything inside `cards/`. Generalizes: relative-import depths cited in briefs/learnings are anchored to the citing file's directory; when the destination directory differs (especially `reducers/` vs the parent slice folder), recount segments. Diagnostic: realpath both files and count separator-segments between the source and target to verify before pasting a path forward. Cheaper alternative — convert to a tsconfig path alias (e.g. `@ui/config/canvas-constants`) once enough modules cross multiple `..` levels; the depth-anchoring problem disappears entirely. Same shape as the rf-cards-1 transitive-import-list learning, but for path resolution rather than import lists. - -_Promoted to: /docs/refactoring-patterns.md_ - -## delete-vs-undefined-test-must-use-in-operator-not-strict-equality - -_Discovered: 2026-04-30 by implementer in rf-cards-9_ - -The rf-cards-9 brief flagged `updateCardNodeParent` MUST use `delete node.parentId` (not `node.parentId = undefined`). Pinning that distinction in a test is non-obvious: `expect(node.parentId).toBeUndefined()` passes for BOTH the `delete` and the `= undefined` shape — strict-equality undefined-checks can't tell them apart. The load-bearing assertion is `expect('parentId' in node).toBe(false)` (or equivalently `Object.prototype.hasOwnProperty.call(node, 'parentId')` returns false). Only the `delete` operator removes the key from the object's own-property list; assigning `undefined` keeps the key with an undefined value, and the `in` check fires `true` either way for inherited keys but for own keys the key being present-with-undefined-value is `true` while a deleted own key is `false`. The reason this matters in cards-slice: undo/redo deep-clones via `JSON.parse(JSON.stringify(card.nodes))`, which preserves an absent key as absent (the property simply isn't serialized) but converts `present-with-undefined` to absent ANYWAY (`JSON.stringify` drops keys whose value is `undefined`). So the runtime divergence is small post-clone, but the pre-clone in-memory shape and the public-API contract (`parentId?: string` on `CardNode`) both match the `delete` shape, and a future refactor that swaps `delete` for `= undefined` could subtly break a `Object.keys(node)` consumer or change Immer's structural-sharing behavior on the parent draft. Generalizes: any test that pins "this code path must `delete` the key, not assign undefined" should use `'<key>' in obj` (or `hasOwnProperty`) — never `obj.<key> === undefined`. Diagnostic: when reviewing a test that asserts a "no parent" or "field cleared" state, check the assertion shape — if it's a strict-equal against `undefined`, the test is silently OK with both the `delete` and the `= undefined` implementation, which means it won't catch the regression the brief was warning about. Pair with the rf-cards-7 `brief-numerics-are-approximate-source-is-canonical` learning: the brief's RISK note ("must use `delete`") is the contract; the test must pin it via the operator that actually distinguishes the two shapes. - -## vi-mock-with-mutable-result-needs-let-not-mockReturnValue - -_Discovered: 2026-04-30 by implementer in rf-cards-11_ - -When mocking a module function whose return value needs to vary per test (e.g. `autoLayout` in `import.test.ts` returning a `{ nodes, edgeRoutes: Map }` shape that each test seeds with case-specific values), the obvious `vi.fn().mockReturnValue(result)` pattern is awkward: `mockReturnValueOnce` queues one-shot returns and you have to re-call it on every test, while `mockReturnValue` is sticky and fights `mockClear()`/`mockReset()`. The cleaner pattern is to declare a module-scoped `let mockResult = ...` outside the `vi.mock(...)` factory, have the factory close over a getter that reads the current value of `mockResult`, and a separate `vi.fn()` SPY for invocation recording. Each test then re-seeds `mockResult = {...}` in its body and the next `autoLayout` call returns the freshly-seeded value with no per-test boilerplate. Important: `vi.mock` is hoisted to the top of the file before any `let` initialization, so the factory can't reference the `let` directly — it must reference it via a closure that's called at invocation time (i.e. the factory body, not the factory return). In practice the factory `() => ({ autoLayout: (...args) => { spy(...args); return mockResult; } })` works because `mockResult` is read each time `autoLayout` is called, not at mock-registration time. Pair with `vi-mock-paths-resolve-relative-to-test-file-not-source-file` (the path is anchored to test file depth, not source) and `reset-module-let-via-synthetic-call-not-vi-resetModules` (in-band reset beats `vi.resetModules()`). Diagnostic shortcut: when the same `vi.fn().mockReturnValue` line repeats in 5+ tests with slightly different shapes, the test file is begging for the let-closure pattern. Generalizes: any mock that returns structured data the test asserts ON should separate "call recording" (the spy) from "result staging" (the mutable let). - -## immer-revoked-proxy-from-spy-args-needs-deep-clone - -_Discovered: 2026-04-30 by implementer in rf-cards-12_ - -When testing a reducer that's invoked via `produce(state, draft => reducer(draft, action))` and the reducer calls a mocked dependency whose call shape you want to assert on (e.g. `autoLayout(layoutNodes, ...)` where `layoutNodes` is built from the Immer draft inside the reducer body), the `vi.fn()` spy that records the call captures references to the Immer proxies. Once `produce(...)` returns, those proxies are _revoked_ — any post-`produce` access (like `expect(layoutNodesArg).toEqual([...])` inside the test's `expect` block) throws `TypeError: Cannot perform 'has' on a proxy that has been revoked`. The diagnostic is loud and specific so it's easy to spot, but the fix isn't obvious from the error message: "deep-clone the args inside the spy capture, before the proxy is revoked." Concretely: `mockSpy(JSON.parse(JSON.stringify(nodes)), ...)` instead of `mockSpy(nodes, ...)`. The clone runs while the proxy is still live (the spy is invoked inside the reducer body, mid-`produce`), so the cloned plain objects survive past the produce callback. JSON-clone is sufficient when the args are plain data (no Date / Map / Set / RegExp / functions); for richer shapes use `structuredClone` or a tailored cloner. Pair with `vi-mock-with-mutable-result-needs-let-not-mockReturnValue` (the let-closure pattern for mock results) — the _return_ value flowing back from mock to reducer is fine because it's a fresh object the test owns, but the _args_ flowing from reducer to mock spy are the danger. Generalizes: any test that asserts on the call-args of a function called inside an Immer `produce(...)` callback must clone the args at spy-capture time, OR move the `expect` assertion _inside_ the produce callback (works but reads worse and forgoes Vitest's structured failure messages). Diagnostic shortcut: any time a vitest reducer test fails with `Cannot perform 'X' on a proxy that has been revoked`, the spy is reading args after the produce returned — clone at the spy boundary. - -## hard-coded-constant-risk-pin-needs-call-with-meaningful-input - -_Discovered: 2026-04-30 by implementer in rf-cards-13_ - -The rf-cards-13 brief flagged RISK #10: `scaleLayoutForZoom` has `scaleX = 1` / `scaleY = 1` hard-coded — a future refactor that "fixes" this by swapping in `zoom / prevZoom` would break the canvas's fixed-block-dimension contract. The naive RISK-pin shape is "assert that node.width didn't change," but that assertion is silently OK with TWO failure modes: (a) the early-return short-circuit fires before the loop ever runs (so the test passes for the WRONG reason — scale wasn't applied because the reducer bailed, not because the constants are 1); (b) the constants are correctly 1 and the loop ran. To distinguish them, the test must drive the reducer with an input that DEFINITELY enters the per-node loop: |zoom - prevZoom| ≥ 0.001 (so the epsilon guard doesn't fire), at least one top-level node (so the empty-topNodes guard doesn't fire), AND a meaningful zoom delta (e.g. 1.0 → 1.5) so a `zoom / prevZoom` refactor would scale by 1.5×, making the assertion fail loudly with `Expected 240, Received 360`. Without the meaningful delta, `1.0 → 1.0001` could pass even with the broken refactor (1.0001× ≈ unchanged for integer pixel widths). Generalizes: any "must remain hard-coded constant K" risk-pin needs an input that would produce visibly DIFFERENT output if K were dynamic — the test must distinguish "K=1 (correct)" from "guards short-circuited" from "K=dynamic (broken refactor)". Diagnostic: when writing a RISK-pin for a hard-coded constant, ask "what input would make the broken alternative produce a different observable result?" and use that input. Same shape as `delete-vs-undefined-test-must-use-in-operator-not-strict-equality` — the assertion has to actually distinguish the two implementations the brief is trying to pin. Side note: the rf-cards-13 brief itself shipped the wrong `..` count for `'../../../config/blocks'` (says 3, actual is 4 from `cards/reducers/`), but the blueprint flagged "verify path" — same shape as the rf-cards-8 learning `relative-import-depth-cited-in-brief-is-anchored-to-citing-file`, just a fresh occurrence. The fix is one segment, but it's worth re-stating: brief-cited path counts are starting points, not authoritative; recount segments before pasting. - -## reducer-bails-after-prologue-side-effect-is-still-observable - -_Discovered: 2026-04-30 by implementer in rf-cards-14_ - -`groupSelectedNodes` has TWO early-return branches that look symmetric in the action-creator contract ("no-op when X") but diverge sharply on the underlying state shape. Branch A (`nodeIds.length < 2`) returns BEFORE `pushSnapshot(state)` runs — true no-op, post-call history is identical to pre-call. Branch B (`selectedNodes.length < 2`, after the `card.nodes.filter`) returns AFTER `pushSnapshot` ran — `state.history[card.id].past` gained a new snapshot of the pre-call state even though the requested mutation never landed. The naive test shape `expect(next).toEqual(state)` would pass for branch A and FAIL for branch B with a confusing "history.c1 is undefined in expected, defined in received" mismatch, because the dispatch-layer "this action was a no-op" promise doesn't extend to coalescing prologue side effects. The right test shape pins the asymmetry explicitly: branch A asserts `next.history.c1 === undefined`; branch B asserts `next.history.c1.past.length === 1` AND the card's `nodes` array is unchanged. Generalizes: any reducer that has a `pushSnapshot(state)` (or other side-effect prologue) followed by validation logic that can early-return needs per-branch test assertions, not a blanket `next === state`. The "no-op" label in the action-creator is observably WRONG once the snapshot lands, even though the user-visible canvas state is unchanged. Diagnostic: when a reducer has shape `pushSnapshot(state); /* validate */ if (...) return; /* mutate */`, the early-return guards have to be tested individually — the `pushSnapshot`-then-bail branch cannot be folded into the "all no-ops look the same" pattern without losing coverage on the snapshot-record-but-no-change shape. Pair with `pushsnapshot-coalescing-needs-explicit-reset-between-tests` (the `_lastSnapshotAction` reset isolates streaks across tests; this learning is about asserting post-snapshot state shape WITHIN a single test). - -## prior-unit-may-leave-future-proofing-import-that-fails-lint-now - -_Discovered: 2026-04-30 by implementer in rf-fbh-2_ - -When an earlier unit in a multi-unit series extracts helpers and imports `{ TYPE, result, fail }` together with the intent that future modules will reference `TYPE`, the orchestrator's `TYPE` import becomes immediately unused as soon as the helper functions move (because `TYPE` was previously a top-level const used inside `result()` / `fail()` bodies — once those bodies move into `result-helpers.ts`, the orchestrator's reference dies with them). The prior unit's commit (rf-fbh-1, `bd050e7`) shipped this latent state because the pre-commit hook either skipped lint on that commit OR the unused-imports rule was not yet wired the same way. Running `pnpm exec eslint` in the next unit's working tree surfaces it as `'TYPE' is defined but never used (unused-imports/no-unused-imports)`. Fix is mechanical: drop `TYPE` from the import. Don't preserve "future-proofing" imports — when a future unit needs `TYPE` it can import it then; the lint rule treats unused imports as errors. Bonus issue surfaced by the same commit: the `import-x/order` warning `\`./firebase-hosting/result-helpers.js\` import should occur before type import of \`../types.js\``— the rule wants type imports last, but the prior unit had the type import first.`pnpm exec eslint --fix`reorders the imports in one shot. Generalizes: when starting a follow-up unit in any extraction series (rf-fbh-N+1, rf-cards-N+1, rf-canv-N+1, etc.), the FIRST diagnostic to run after extracting your new module and updating the orchestrator's imports is`pnpm exec eslint --fix`on the orchestrator file — it catches both (a) unused future-proofing imports the prior unit left behind and (b) import-order drift caused by adding a new value-import row. The auto-fix is safe (it only removes unused imports and reorders existing rows; no semantic change). Diagnostic shortcut: if you see lint errors on a file you didn't directly edit but only added an import to, check whether the pre-existing imports include any names you DIDN'T add — those are likely the dead imports from the prior unit. Pair with`relative-import-depth-cited-in-brief-is-anchored-to-citing-file`: both are about cleanup that prior-unit briefs implicitly defer to the next implementer. - -## git-stash-pop-conflicts-with-tsconfig-tsbuildinfo - -_Discovered: 2026-04-30 by implementer in rf-fbh-3_ - -`pnpm typecheck` writes `packages/<pkg>/tsconfig.tsbuildinfo` as a side-effect even when the build succeeds, so any in-flight stash that captured an unmodified `tsbuildinfo` will cause `git stash pop` to fail with "Your local changes to the following files would be overwritten by merge". The trap is that you might run `git stash` to verify "are these typecheck errors pre-existing?", then run `pnpm exec tsc --noEmit -p packages/core` to check baseline, and now `tsbuildinfo` has changed under your feet — `git stash pop` fails and **leaves the stash entry alive while only partially applying changes**, so the source files (firebase-hosting.ts) get reverted to baseline while the new files (tar-parser.ts + tests) survive. Recovery: `git checkout packages/core/tsconfig.tsbuildinfo` then `git stash pop`. Better: don't use stash for "is this error pre-existing?" verification at all. Cheaper alternatives: (a) `pnpm exec tsc --noEmit 2>&1 | grep <my-file>` — empty grep == none of the errors come from your files; (b) read the error file:line refs — if they're all in `src/index.ts` / `src/graph/index.ts` (which you didn't touch), they're pre-existing. Generalizes: any tool that mutates a tracked file as a build side-effect (`tsbuildinfo`, `*.tsbuildinfo`, lockfile timestamps in some setups) breaks the "stash, run baseline tool, stash pop" workflow. Don't reach for stash to verify orthogonal noise; grep the output for paths you actually edited. - -## vi-hoisted-and-vi-mock-blocks-must-not-split-import-groups - -_Discovered: 2026-04-30 by implementer in rf-fbh-5_ - -The natural shape for a test that mocks an ESM-relative module is to put `const mocks = vi.hoisted(...)` and `vi.mock("./...", ...)` right after the `import { describe, it, expect, vi } from "vitest"` line and before the imports that consume the mocked module — so a reader reads top-to-bottom and sees the mock setup adjacent to the `vitest` import. That triggers eslint `import-x/order` with `There should be no empty line between import groups` because the rule sees TWO import statements (`from "vitest"` and `from "../site-provisioner.js"`) separated by non-import code (the `vi.hoisted` block + `vi.mock` call) and treats them as splittable groups. `eslint --fix` cannot resolve this because it would have to reorder code blocks, not just imports. The fix is mechanical: put ALL imports contiguously at the top of the file (vitest, the SUT, and the type imports), then place the `vi.hoisted` declaration and `vi.mock` calls AFTER all imports. Vitest hoists both `vi.hoisted` and `vi.mock` calls above any import statement in its own pre-execution pass, so the hoisting still works correctly even though source-order has the imports first. A short comment near the hoisted block (`// Note: vitest hoists both vi.hoisted and vi.mock above any import statement, so the SUT sees the mock when its own import runs.`) saves the next reader from worrying about init order. Generalizes: any test file using `vi.mock` for relative paths in a package with the `import-x/order` lint rule (this monorepo's `@ice/core` and `@ice/ui` configs) should keep imports contiguous and put hoisted-mock blocks below — same treatment as `vi.stubGlobal` calls or any other top-of-file vitest setup. Pair with `vi-hoisted-required-for-shared-mock-identities-across-many-vi-mock-calls`: that learning covers WHY you reach for `vi.hoisted`; this one covers WHERE in the file to put it. - -_Promoted to: /docs/refactoring-patterns.md_ - -## brief-cited-event-shapes-need-source-of-truth-verification - -_Discovered: 2026-04-30 by implementer in rf-dslice-7_ - -The rf-dslice-7 brief described `applyDeployCompleteEvent` as consuming a wire event with fields like `{ deployment_id, seq, at, outcome, counts }`, mirroring the language used elsewhere in the brief about "the typed `deploy:event` channel". The actual contract in `packages/types/src/deploy-events.ts` has `DeployCompleteEvent` shaped as `{ type, card_id, outcome, totals, at, seq }` — note `card_id` (not `deployment_id`), and `totals` (not `counts`, and with two extra count buckets `queued`/`applying`). Building a fixture from the brief's mental model surfaces as `TS2352: Conversion of type ... to type 'DeployCompleteEvent' may be a mistake because neither type sufficiently overlaps with the other`, listing the missing fields. Same shape applies to `DeployNodeStatusEvent` and `DeployNodeProgressEvent` — both have `card_id`, not `deployment_id`. Fix is mechanical (rewrite the fixture to match the type), but the diagnostic is what generalizes: when writing tests for a reducer that consumes a typed wire event, EVEN IF the brief gives the field names verbatim, open the type file and copy the shape from there. The brief's field names are mnemonics, not contracts — the generated TypeScript type from `@ice/types` is the contract. Pair with `brief-numerics-are-approximate-source-is-canonical` (rf-cards-7) and `import-list-is-superset-trim-to-syntactically-used` (rf-cards-1) — same shape: brief lists are starting points, source files are authoritative. Diagnostic when TS2352 fires on an event-shape cast: the surplus fields you supplied AND the missing fields TypeScript names tell you exactly what the brief got wrong; recompose from the type file's interface, not the cast. - -## reducer-group-extraction-i18n-import-depth-from-reducers-folder - -_Discovered: 2026-04-30 by implementer in rf-dslice-4_ - -The orchestrator `slices/deploy-slice.ts` imports `t` from `'../../i18n'` (2 ups: out of `slices`, out of `store`). When extracting reducers into `slices/deploy/reducers/<group>.ts`, the new file is 2 levels deeper, so the import becomes `'../../../../i18n'` (4 ups: out of `<group>`, out of `reducers`, out of `deploy`, out of `slices`, out of `store`, landing at `src/i18n`). The cards-slice rf-cards series sets the precedent: `cards/reducers/*.ts` files all use the same `'../../../../i18n'` shape when they need i18n. The trap is mentally porting the orchestrator's `'../../i18n'` to the new file and getting `'../../i18n'` (which would resolve to `slices/i18n`, doesn't exist) or `'../../../i18n'` (resolves to `store/i18n`, also doesn't exist) — TS surfaces this as `TS2307: Cannot find module`. Same shape as the rf-cards-8 learning `relative-import-depth-must-be-recounted-when-moving-deeper`: the depth is anchored to the citing file, not the original orchestrator's location. Cheaper alternative is a tsconfig path alias (`@ui/i18n` instead of `../../../../i18n`) but the rf-cards series didn't introduce one and rf-dslice followed precedent. Diagnostic shortcut: when extracting a reducer that needs `t` or any other i18n helper, count `..` segments from the new file's directory to `src/`, then add `/i18n` — for `slices/deploy/reducers/X.ts` that's exactly 4 ups. Pair with `relative-import-depth-cited-in-brief-is-anchored-to-citing-file` (rf-cards-8) — both are about depth recounting on extraction; this one is the specific i18n example for the rf-dslice series and a future reducer-group extraction (rf-cstor, rf-other-slice, ...) that needs i18n. - -## absence-of-headers-must-be-asserted-via-init-equality-not-property-check - -_Discovered: 2026-04-30 by implementer in rf-fbh-6_ - -Testing that a `fetch(url, init)` call goes out WITHOUT auth headers (the github-downloader explicitly bypasses the auth client because codeload.github.com rejects bearer tokens with 401) is a "test the absence of a thing" assertion. The natural instinct is `expect(init?.headers?.Authorization).toBeUndefined()` — but that PASSES even if a future refactor wraps the fetch in a `withAuthHeaders()` helper that sets a different header name (`X-Auth-Token`, `Bearer`, etc.) or sets `headers: new Headers()` with default cookies. The property-check only catches the specific name you wrote, not the structural invariant. The right shape is `expect(init).toEqual({ redirect: 'follow' })` — strict-equal against the COMPLETE init shape pins the surface area to exactly the keys the source code intentionally passes. Any future addition of `headers`, `body`, `cache`, `credentials`, etc. surfaces here as a `received: { redirect: 'follow', headers: {...} }` mismatch and fails the test. Pair this with `expect((init as any)?.headers).toBeUndefined()` for an extra defensive check on the typed property — the two together mean "init is exactly { redirect: 'follow' } AND nobody snuck in a headers field via type-erasure." Generalizes: every "the function MUST NOT do X" test in this codebase should reach for an exact-shape `toEqual` (or `toMatchObject` if a partial fit is acceptable) rather than `expect(actual.x).toBeUndefined()` — the latter is a single-axis check that survives orthogonal regressions. Diagnostic: if you find yourself writing more than one `toBeUndefined()` against properties of the same call, swap to a single `toEqual` against the full expected shape — fewer assertions, broader coverage. Cite this from any future test where the source's correctness depends on the absence of something the type system doesn't enforce (auth bypass, body-omit on GET, no-cache headers, etc.). - -## fixture-hashes-must-be-derived-from-mock-transform-output-not-guessed - -_Discovered: 2026-04-30 by implementer in rf-fbh-7_ - -When testing a multi-step protocol where step N's behaviour depends on a value that step N-1 derived from a mocked transform — e.g. Firebase Hosting's `populateFiles` returns `uploadRequiredHashes`, and the publisher only fires step 3 (upload) for hashes IN that list — the test's mocked `uploadRequiredHashes` array MUST contain the EXACT hash strings the SUT will compute from the test's input bytes after the mocked `gzipSync` transform runs. Initially I declared `mockResolvedValueOnce({ ..., uploadRequiredHashes: ['sha-1101', 'sha-1102'] })` while passing `Buffer.from([0x01, 0x01])` / `Buffer.from([0x01, 0x02])` as inputs — guessing the hash strings from the input bytes alone. The mocked `gzipSync` PREPENDS `'GZ:'` (= bytes 0x47, 0x5a, 0x3a) so the actual hashes become `'sha-475a3a0101'` / `'sha-475a3a0102'`. With the mock-list mismatched, the upload step is silently skipped (the `if (!requiredSet.has(f.sha256)) continue;` branch dedups), and the test's `expect(restRequest).toHaveBeenCalledTimes(6)` fails with `got 4` because two upload calls never fired. The right shape: declare a `const HASH_A = 'sha-${gzip-output-as-hex}'` constant near `happyPath()` that EXPLICITLY reflects the mocked transform's effect, then use `HASH_A` in BOTH the mocked `uploadRequiredHashes` AND the upload-URL assertion. A docstring under HASH_A walks the byte-level derivation (`raw [0x01, 0x01] → gzip prepends 'GZ:' (0x47, 0x5a, 0x3a) → hex '475a3a0101' → 'sha-475a3a0101'`) so a future test author who tweaks the mock's transform sees what to recompute. Generalizes: any test where a mocked transform feeds downstream conditional logic needs a "single source of truth" constant that captures the transform's output — not literal-string fixtures invented separately. Diagnostic: if a sequential-call-count assertion fails by exactly the number of files passed in (e.g. expected 6, got 4 with 2 inputs), suspect that a hash/dedup branch is silently skipping calls because the mocked `requiredHashes` list doesn't match what the SUT actually computed. Pair with `vi-hoisted-required-for-shared-mock-identities-across-many-vi-mock-calls`: that learning covers identity stability of the mock fns; this one covers the VALUES the mocks return when chained transforms are involved. - -## or-chain-default-fallback-needs-its-own-test-for-100pct-branch-coverage - -_Discovered: 2026-04-30 by implementer in rf-fbh-8_ - -The DNS extractor's inner `walkRecords` reads `recordSet?.records || recordSet?.checkError?.records || []` — three branches in one expression. I shipped tests covering the first two (records present; checkError.records present) and got 96.07% branch coverage on a 100%-line, 100%-function file. The missing branch was the literal `[]` fallback — when both `records` and `checkError.records` are absent, the loop body must not run. Same pattern struck `if (ds.expectedIps)` inside Shape 3's `provisioning.dnsStatus[]` walk: testing `expectedIps: [...]` and `discoveredIps: [...]` separately covered the truthy paths but not the "neither field set" entry that exercises both falsy branches. The fix is a one-liner test per dangling branch: pass an object that hits the default, assert empty output. Generalizes: every `a || b || defaultLiteral` chain and every `if (x)` against an optional-property-on-an-untyped-bag (think `domainData: any`) needs an explicit test for the "default reached" path. The 100%-line / sub-100%-branch gap is a reliable signal — when `vitest --coverage` shows 100% lines but <100% branches with one or two specific line numbers flagged, those numbers are almost always the OR-chain tail or the `if (optional)` falsy path. Diagnostic shortcut: scan the SUT for `||` chains ending in literal defaults (`|| []`, `|| ''`, `|| 0`) and `if (x.optional)` blocks; each one needs a "neither/none set" fixture to close the branch report. Cite this any time a test file is required to hit 100% branches against an `any`-typed input bag — those are precisely the SUTs where the type system can't tell you which fields might be undefined, so each defensive default needs its own pin. - -## sed-greedy-dot-star-eats-chained-calls-on-one-line - -_Discovered: 2026-04-30 by implementer in rf-parse-1_ - -Bulk callsite-rename sed pass `s/this\.check\(\(.*\))/ps_check(this.state, \1)/g` rewrites the obvious cases correctly but silently mangles any line that has TWO+ `this.check(...)` calls joined by `||` or `&&`. On `} else if (this.check('LEFT_BRACE') || this.check('STRING') || this.check('IDENTIFIER')) {`, sed greedy-matches `.*` from the FIRST `(` to the LAST `)`, producing one mega-call: `ps_check(this.state, 'LEFT_BRACE') || this.check('STRING') || this.check('IDENTIFIER')`. The original two trailing `this.check(...)` calls survive un-rewritten and the post-replacement line typechecks as nonsense (`ps_check` now receives an OR-expression as its second arg). Symptom is loud — grep for `this.check\(` after the sweep flags the missed lines — but if you skip that grep it ships. Two prevention shapes: (a) use a non-greedy match or restrict to non-paren chars: `s/this\.check(\([^)]*\))/ps_check(this.state, \1)/g` — `[^)]*` won't span the closing `)` of the first call so chained calls each rewrite independently; (b) ALWAYS run a post-sed `grep -nE "this\\.<old-name>\\("` on the full file before declaring the sweep done, and hand-fix the residue with multi-line Edits. For rf-parse-1 the chained-call sites were three: parser.ts L455 (3-way `||` for nested-block detection), L458 (2-way `||` for label loop), and L930 (2-way `||` for type-id continuation). Generalizes to every refactor unit that does bulk `this.X(...)` → `helper(state, ...)` or any "rewrite N callsites in place" sed sweep on a logic-heavy file: switch the regex to `[^)]*` (non-paren-spanning) BEFORE the first run, and always grep for the old-name pattern after to catch holdouts. Pair with `extracting-deduped-cardstores-from-deploy-service-need-per-callsite-audit` — both are about the same root issue: bulk replacements on logic-heavy files need a per-callsite audit before commit. - -## sed-empty-arg-substitution-glues-state-to-next-token - -_Discovered: 2026-04-30 by implementer in rf-parse-2_ - -Bulk callsite-rename sed `s/this\.parse_X(\([^)]*\))/parse_X(this.state\1)/g` is correct for the rf-parse-1 chained-call gotcha (uses `[^)]*` non-greedy) AND for the typical 0-arg case (`this.parse_X()` becomes `parse_X(this.state)` because `\1` is empty so there is nothing to glue). The latent bug surfaces ONLY when a 0-arg helper sometimes takes args at a real callsite: `this.parse_X(token)` rewrites to `parse_X(this.statetoken)` — no comma between `this.state` and the original arg, because the sed body literal `parse_X(this.state\1)` did not include a comma before `\1`. For rf-parse-2 the four 0-arg helpers (`parse_identifier` x20, `parse_type_identifier` x2, `parse_string_literal` x6, `parse_boolean_literal` x2) were always called without args, so the substitution was safe. The two helpers that DO take args (`create_null_literal(this.state, \1)` and `create_span(\1)` — different signature shape) needed their own sed bodies with explicit commas. Generalizes: when a sed substitution body has an inserted state arg followed by `\1`, ALWAYS write `state, \1` (with the comma) for helpers that take any args at the callsite, and write `state\1` only for helpers that are guaranteed-zero-arg. Cheaper rule of thumb: just always use `state\1` for 0-arg helpers (the trailing `)` from `\1` empty is fine), and always use `state, \1` for N-arg helpers — never mix the two on the same regex. Pair with `sed-greedy-dot-star-eats-chained-calls-on-one-line` — both are about how innocuous-looking sed bodies break on uncommon callsite shapes; per-callsite audit (grep for `this.<old>(` AND grep for the new helper name to spot mangled args like `this.statetoken`) catches both. - -## bootstrap-fnarg-vs-direct-import-for-circular-grammar-pair - -_Discovered: 2026-04-30 by implementer in rf-parse-3_ - -When extracting recursive-descent grammar layers into separate modules and the call graph forms a cycle (e.g. `parse_postfix` → `parse_primary` → `parse_expression` → ... → `parse_postfix`), the blueprint suggested two resolutions: (a) pass the cyclic dep as a function argument (`parse_postfix(s, parse_primary)` then thread it through), or (b) direct-import the function and rely on TypeScript's lazy-evaluation of ESM cycles. Tried (b) — direct import — and it Just Works as long as both sides only reference the imported names INSIDE function bodies, never at top-level module-init. The cycle resolves at function-call time because the module-graph evaluator binds the export reference (live binding under ESM) before any function executes. Concrete shape: `parser-binary-exprs.ts` imports `parse_primary` from `parser-primary.ts`; `parser-primary.ts` imports `parse_expression`, `parse_array_expression`, etc. from `parser-binary-exprs.ts`. Both top-level `import` statements complete before either module's function bodies run. TypeScript's typecheck passes, all 932 tests in `@ice/core` (including parser tests) pass, and runtime works. Critical: this requires (1) ALL cross-module references to be inside function bodies, never module-init; (2) atomic landing of both files in one commit so there's no half-state where one side imports a non-existent function; (3) both files must be created before the parser.ts callsite-replace step (`parse_expression` from binary-exprs is needed by parser.ts callsites, and binary-exprs needs parser-primary's `parse_primary` to even compile). Generalizes: cyclical refactor pairs in TypeScript ESM are fine with direct imports if the cycle is only crossed at call time, but they MUST be staged as a single atomic commit that creates both files together — splitting them across two commits would require a stub/placeholder in the first commit. The blueprint pre-flagged this for rf-parse-3/4 specifically; we combined both into one unit (rf-parse-3 landing) rather than introducing a stub. Saves a commit and avoids stub-cleanup risk in the follow-up. - -## co-locate-mutually-recursive-helpers-to-skip-cycle-bootstrap - -_Discovered: 2026-04-30 by implementer in rf-parse-5+6_ - -The rf-parse-3+4 learning (`bootstrap-fnarg-vs-direct-import-for-circular-grammar-pair`) describes how to handle cross-module ESM cycles via direct import + atomic-landing — the cost is ~2 paragraphs of comments plus the "atomic" landing constraint. rf-parse-5 sidestepped that cost entirely by **co-locating** the mutually-recursive functions in the same file: `parse_resource_block`, `parse_data_block`, `parse_provider_block`, and `parse_block` all sit on `parser-block-body.ts`, with the first three calling `parse_block(s)` as an in-module sibling — no cross-module edge, no cycle, no ESM-init dance, no atomic-landing constraint. The blueprint's "Co-located because all four feed into `parse_block` recursion" was the right call; preserve this rule of thumb. Generalizes: when decomposing a class with `N` private methods that form a recursion subgraph (not a chain — a cluster where the leaf calls back into a sibling), put the whole cluster in one file and keep the recursion in-module. Cross-module cycles are the right tool only when the cluster is too large to live on one file (the rf-parse-3/4 expression grammar was 425 LOC across 10 functions, justifying the split); for smaller clusters (rf-parse-5's 4 functions ≈ 145 LOC, rf-parse-6's 5 statement-level functions ≈ 220 LOC and DON'T form a recursion subgraph — they each only call `parse_block(s)` from the sibling file) co-location wins on every axis: less import noise, no atomic-landing constraint, no module-init-vs-runtime ordering tax, simpler to reason about. Pair with `bootstrap-fnarg-vs-direct-import-for-circular-grammar-pair` — that learning is the FALLBACK when co-location doesn't fit; this learning is the PREFERRED shape. - -## graph-nodes-keyed-by-type-colon-name-fixed-via-get-node-by-name - -_Discovered: 2026-04-30 by implementer in bugfix-1_ - -See the original entry `graph-nodes-keyed-by-type-colon-name-not-bare-name` (above, dated 2026-04-30 from rf-ctrans-10) for the latent bug context. **Fixed in bugfix-1**: replaced the four `graph.nodes.get(name as any)` callsites with `graph.get_node_by_name(name)` across `passes/pass-1-4-repo-wiring.ts` (1), `passes/pass-1-45-domain-propagation.ts` (1), and `passes/pass-1-5-endpoint-wiring.ts` (2). The `as any` cast was hiding the bare-name-vs-NodeId mismatch from the compiler; `get_node_by_name` accepts `string` directly and resolves via the internal `node_names` Map. A 5th dependent callsite — `graph.remove_node(forwardingResourceName as any)` at pass-1-5 line 283 — also needed migrating: `remove_node` only accepts `NodeId`, so we resolve via `get_node_by_name(name)?.id` first, then call `remove_node(node.id)`. Without this 5th fix the RISK #7 atomic forwarding-rule removal would silently no-op under the new bare-name flow. Test fixtures across all three pass test files were migrated from `card_id_to_name.set(cardId, branded NodeId)` (the bug-bypass shape) to `card_id_to_name.set(cardId, bareName)` (the production shape). Cascading consequence: the assertions previously had to use `sanitize_name(\`${endpointNodeKey}-cert\`)` to match the colon-stripped NodeId-derivation form like `gcp-compute-globalforwardingrule-fr-1-cert`; post-fix, the cert name is just `fr-1-cert` and the assertions use `sanitize_name(\`${endpointNodeName}-cert\`)` with the bare-name branch — same template, different feed value, much cleaner derived names. Generalizes: when fixing a latent lookup bug, audit ALL functions called within the same flow that consume the same input shape (`forwardingResourceName`was used by both the lookup AND the`remove_node`site). Fixing only the lookup leaves the second consumer broken under the new flow. The brief listed 4 callsites; the 5th surfaced when the test fixture migration broke the RISK #7 test, which is the load-bearing signal that the fix needs broadening. Diagnostic: after fixing a`Map.get(x as any)`callsite, grep the surrounding function for any other`Map`/method call that takes the same `x` value — those need the same treatment. - -## data-heavy-shim-split-keep-helpers-with-shim-not-data - -_Discovered: 2026-04-30 by implementer in rf-data-1_ - -When splitting a data-heavy module (here: `scale-presets.ts` 1562 LOC → types/data/shim trio), the natural temptation is to move everything that imports the data INTO the data file. Resist it. Helpers that consume `SCALE_PRESETS` (here `getScalePreset` + `getAllPresetsForResource`) belong in the public shim file, not in the data file. Three reasons: (1) the shim's whole point is to be the stable import surface for `'./scale-presets.js'` consumers — keeping helpers there means the shim is structurally complete and not just an import-rewrite layer, (2) helpers live with the runtime ergonomics they expose (the shim is where you'd add a third helper, not the data file), and (3) the data file's "size exception" header reads as a real exception when the file contains ONLY data — adding helpers makes the exception leakier ("it's data, plus a few functions, plus..."). The shim file ends up at ~58 LOC: three import statements, three `export {}` re-exports, two helper functions. Sweet spot. Generalizes to any future data-table split (the in-progress `cloud-blocks` split, future `BLOCK_TEMPLATES` if that ever needs it): types in one file (cheap to import standalone), data in one file (the size-exception block), shim with helpers in the canonical-named file. The smoke test pattern that pays off: import the module both as `* as X` (to assert the namespace contains all named runtime exports) AND as named imports (to exercise the actual public surface) — catches a regression where someone forgets to re-export from the shim. The pre-existing typecheck baseline of `@ice/core` carries ~30 TS2834 import-extension errors in unrelated files (`src/index.ts`, `src/graph/*`, `src/deploy/*`, `src/importers/*`, `src/schema/embedded-schema-provider.ts`); confirm via `git stash --include-untracked && pnpm --filter @ice/core typecheck` before reporting "typecheck passes" — it doesn't and won't, the bar is "no NEW errors in your touched paths." Pre-commit only runs the version bump hook (`.git/hooks/pre-commit`) — no typecheck/lint gate — so a refactor commit lands cleanly even with the broken baseline. - -_Promoted to: /docs/refactoring-patterns.md_ - -## heredoc-content-end-includes-line-leading-whitespace - -_Discovered: 2026-04-30 by implementer in rf-lex-3_ - -The lexer's heredoc scanner has two `pos` cursors that look related but mean different things, and it's easy to mis-pin in tests. `content_start` is set BEFORE the line-loop starts (i.e. right after the opening-line newline is consumed), and `content_end` is set inside the loop to `line_start` — the position at the START of each candidate terminator line, BEFORE any leading-whitespace consumption for indented mode. The `s.pos = check_start` backtrack on a failed match restores the cursor to AFTER the indent-strip — NOT to `line_start`. So the leading whitespace of a non-terminator line IS observable in the resulting `content` slice (because content_start was set before any of these inner walks), even though the cursor itself only "sees" post-indent positions during the failed-match read-until-newline. The trailing `content.trimEnd()` is what cleans up the trailing newline + indent of the line preceding the actual terminator. Concrete trap: I wrote a RISK #7 backtrack test asserting that " EOTbar" produced literal `"EOTbar"` (no leading spaces) on the assumption that the backtrack-to-check_start meant the leading spaces were "lost." Wrong — the leading spaces are in the content slice from `content_start` onwards regardless of where the indent-strip cursor walked. The right pin for RISK #7 is that the NEXT line's terminator IS recognized correctly (which is what the cursor management actually guarantees), not that the failing line's content gets reshaped. Generalizes: when a scanner has a cursor that walks AHEAD of the recorded slice boundaries, the slice value is whatever the source contained between the boundaries, NOT what the cursor "saw" along the way. Pin tests on the slice contents directly, not on cursor-walk side effects, unless the side effect is exposed through line/column tracking on later tokens. Also: silent-EOF heredoc (RISK #9) — when I added a test for "no closing delimiter," the literal came out empty (matches `content_end == content_start` initial) AND the raw value contains the source verbatim including the unterminated `<<EOT\nlost content`. No error fires. Both shapes preserved verbatim from pre-extraction. - -## test-helper-spread-merge-drops-defaults-when-overriding-nested-shape - -_Discovered: 2026-04-30 by implementer in rf-cstor-4_ - -The natural shape for a test factory that builds a complex mock with overrides is `{ ...defaults, ...overrides }` — but when `defaults` and `overrides` both have a `key` whose value is a _nested object_, the spread is NOT a deep merge. It picks the entire `overrides.key` object and drops the keys from `defaults.key` that the test didn't customize. Concrete trap: `makeBucket({ iam: { getPolicy: vi.fn().mockResolvedValue([policy]) } })` — the test wanted to override only `getPolicy` but keep the default `setPolicy: vi.fn().mockResolvedValue(undefined)`. The spread `{ iam: { ...defaultIam, ...overrides.iam } }` works only if the test factory KNOWS to spread inside the `iam` shape; the naive `{ ...defaults, ...overrides }` at the top level produces `iam: { getPolicy: ... }` (no setPolicy at all), and downstream the SUT crashes on `bucket.iam.setPolicy is not a function`. The fix is to spread per-sub-shape explicitly: `iam: { ...iamDefault, ...(overrides.iam || {}) }`, repeated for every nested mock surface (`acl`, `acl.default`, etc.). Generalizes: any test factory that hands the SUT a nested-object mock (Cloud SDK clients, RTK store shapes, Express req/res, fetch responses with `headers`/`body` sub-objects) needs to be aware of which shapes are deep-overrideable and which are top-level-only. Diagnostic: a test that fails with `TypeError: ... is not a function` on a method the _factory's defaults_ should provide is almost always this spread-overwrite issue. The fast fix is to log `Object.keys(bucket.iam)` inside the SUT call site and check whether the default keys survived the merge. Generalizes more broadly: when reaching for `{ ...a, ...b }` think about whether `b` carries enough of the overlapping nested shape to stand alone — if not, you need explicit per-key merging. Pair with `vi-hoisted-required-for-shared-mock-identities-across-many-vi-mock-calls`: both are about test-scaffolding correctness over SUT correctness. - -## behavioral-asymmetry-between-create-and-update-paths-needs-flag-not-fork - -_Discovered: 2026-04-30 by implementer in rf-cstor-4_ - -When a refactor extracts a shared helper from two callsites that look ALMOST identical but differ in one or two specific behaviors (here: cloud-storage's create() and update() both implement IAM-grant + ACL-fallback, but update() additionally re-fetches the policy after `setPolicy` to detect silent stripping by `iam.allowedPolicyMemberDomains`), resist the temptation to fork into two separate helpers. Use a single helper with a discriminated-options flag. The rf-cstor-4 brief explicitly enshrined this as `verifyAfterWrite: boolean` — update passes `true`, create passes `false`. The flag both _names_ the asymmetry (so the caller's intent is documented in the call site) and _gates_ the behavior (so a future change can't accidentally drop the verify step from update without a CI failure). Three reasons forking is worse: (1) the two implementations drift over time as bug fixes land in one but not the other (the inline create() and update() paths in cloud-storage.ts had quietly diverged in 5 places — different log messages, slightly different access-prevention-detection string lists, an `alreadyHasAllUsers` fast-path in update but not create); (2) the tests have to cover both implementations independently which doubles maintenance; (3) reading the orchestrator, the reader can't tell at a glance whether the two paths are intentionally different or accidentally different. With the flag-based unification, the helper is the source of truth, and any preserved asymmetry is documented at exactly two places: the helper's options-type JSDoc and the orchestrator's two call sites. Diagnostic shortcut: when extracting a shared helper, do a pre-extraction read of both callsites side-by-side and write a 1–3 bullet list of every asymmetry. If you can name the difference with a noun (`verifyAfterWrite`, `bucketAlreadyExisted`, `dryRun`), it's a flag. If the difference is a structural fork (one path does X then Y, the other does Y then Z), it's two helpers. The cloud-storage case was the former — five superficial diffs that all collapsed once `verifyAfterWrite` was named. The unification also surfaced a strict improvement: the unified version uses update()'s richer flow (alreadyHasAllUsers fast-path, broader access-prevention scan), and the `verifyAfterWrite=false` arm of the helper is the new create() — strictly equivalent to or a strict superset of the previous create() inline. Generalizes: every "we have two near-duplicates of an IAM/policy/HTTP-call pattern" extraction is a candidate for this — add a flag, document the asymmetry, lock both call sites to the helper. Pair with `prior-unit-may-leave-future-proofing-import-that-fails-lint-now`: both are about cleanup that happens at the orchestrator-coordination layer. - -## use-state-mock-keep-slots-flag-for-pre-seeded-state-effect-coverage-tests - -_Discovered: 2026-04-30 by implementer in rf-rpal-9_ - -The rf-pdpl-12 / rf-pdpl-21 queued-ref-dispatch useState mock pattern (cite `react-namespace-hook-access-requires-patching-default-export-too`, `queued-ref-dispatch-extends-the-mutable-ref-usestate-mock-to-multi-state-fcs`) gives every test a fresh slot-array per render via `__resetUseState()`. That works for "render and walk" tests, but breaks for **effect-coverage tests** that need to pre-seed a slot to a non-default value to reach a branch. Concrete case: `ResourcePalette` has a useEffect that fires `setSelectedProvider(projectProvider)` only when `projectProvider` is truthy. The natural test: seed `__setState(0, 'gcp')` then re-render, capture the effect with `deps=['gcp']`, invoke its body. Trap: `renderPalette()` calls `__resetUseState()` first thing, which wipes the slots — so by the time the FC body re-reads slot 0, it sees `undefined` again, the effect's deps becomes `[null]`, and the test's `effects.find((e) => e.deps[0] === 'gcp')` returns undefined. Naively reordering "seed THEN renderPalette" doesn't fix it because renderPalette's own **resetUseState runs at the top. Fix: extend the mock so `**resetUseState({ keepSlots?: boolean })`accepts an opt-out — the call counter resets (so the next render starts at idx 0) but the slot values stick around across renders. Two-render pattern: (a)`renderPalette()`populates slots 0..N from the FC's`useState(initial)`initializers; (b)`**setState(idx, value)`overrides the slot; (c)`**resetUseState({ keepSlots: true })`resets only the per-render counter; (d) call the FC directly (not renderPalette) so the call doesn't re-wipe slots. Now the FC reads the seeded value on slot 0, the effect closure captures it in deps, and the test can find the effect by deps shape and invoke its body to drive the assertion. Generalizes: any future direct-FC test that needs to pre-seed *any* useState slot (form input, selected toggle, derived state) before walking the tree should use the keep-slots reset flag. The same applies to`useReducer`mocks if you want to pre-seed reducer state without re-firing the initializer. Coverage outcome on`resource-palette.tsx`: 76.56% → 100% lines, 53.33% → 100% functions, 78.26% → 100% statements after adding 5 effect-body tests via this pattern (the remaining branches gap of ~12% is short-circuit OR alternates inside the filter useMemo that aren't reachable without an empty COMPONENTS array). Diagnostic: a useEffect-mock test where `effects.find(...)`returns undefined despite`\_\_setState` having been called — that's the per-render slot-wipe defeating your seed; reach for the keep-slots flag. - -## vi-fn-generic-narrows-mockResolvedValueOnce-arg-to-never-on-optional-fields - -_Discovered: 2026-04-30 by implementer in rf-pset-4_ - -When you stub a provider API with `vi.fn<[ArgsTuple], Promise<{ success: boolean; graph?: { nodes?: unknown[] }; error?: string }>>()`, vitest 4.1's overload typing narrows subsequent calls to `mockResolvedValueOnce(...)` so that the argument type becomes `never` (or close to it) when the value omits an optional field — the symptom is `TS2345: Argument of type '{ success: boolean }' is not assignable to parameter of type 'never'` on a fully-valid response shape. The conditional/overload chain that picks `mockResolvedValueOnce`'s parameter against the typed `vi.fn` return ends up choosing a branch that requires every optional field be present (or explicitly `undefined`), even though the runtime accepts `{ success: false }` fine. The fix is to drop the explicit generic on `vi.fn(...)` for any stub whose typed return uses optional fields you'll vary across tests — the runtime still works (each `mockResolvedValueOnce` accepts `unknown`), and the source code's import contract still flows through the consumer's strict typing. The tests in rf-pset-4 originally typed `mocks.api.import: vi.fn<[string,string], Promise<{success: boolean; graph?: ...; error?: string}>>()` and saw two TS2345s on lines that did `mockResolvedValueOnce({ success: false })` and `mockResolvedValueOnce({ success: false, error: 'rate-limit' })`; removing the generic (so it became `vi.fn()` with vitest's default `Mock<unknown>` return) cleared both errors and didn't lose any test coverage. Cite from any future hook test (rf-pset-5+, future feature extractions) that mocks a Promise-returning API with an optional `graph` / `metadata` / `details` field — start with `vi.fn()` not `vi.fn<args, Promise<{...}>>()`. The symmetry is that explicit generics on `vi.fn` are fine for **pure-arg** signatures (fixed return shape, no optional fields) but fail on optional-field unions because vitest's typing was tightened in 4.x to flag impossible argument shapes. Diagnostic: TS2345 with "Argument of type ... is not assignable to parameter of type 'never'" on a `mockResolvedValueOnce` call — reach for `vi.fn()` (un-generic'd) at the stub declaration. Pair this with the rf-pdpl-22 `vitest-spyon-return-type-on-console-needs-loose-shape-cast-for-mock-calls-iteration` learning — both fail in the same direction (vitest's explicit generics are stricter than the runtime contracts) and the workaround is the same pattern (drop the generic, let vitest's default loose typing handle the variance). - -## tree-walker-collectText-array-children-fallback-for-jsx-button-text-after-icon - -_Discovered: 2026-04-30 by implementer in rf-pset-5_ - -The rf-rpal-8 / rf-pdpl tree-walker's `collectText` reduction collects only `props.children` values whose runtime type is `string`. That works for `<div>some text</div>` (children is a single string) and for `<>{a}{b}</>` where each leaf has `props.children: string`. It silently misses the most common JSX shape in our buttons: `<button><Icon />{t('label')}</button>`. React stores those children as an _array_ `[<Icon />, 'label']` on `button.props.children`, not as a string and not as separate child elements that the walker would re-yield. The `if (typeof c === 'string')` check sees the array, returns false, and skips the literal — leading to "expected '...' to contain 'providerSettings.connect.signInGoogle'" failures even though the button renders the text correctly. This is _not_ the same bug as rf-pdpl-15's "two adjacent leaves merge with no separator" (`collectText` _never sees_ this text in the first place, so there's nothing to merge). Fix: extend `collectText` to also iterate `Array.isArray(c)` and pull out string elements: `else if (Array.isArray(c)) { for (const item of c) { if (typeof item === 'string') s += item; } }`. Don't recurse — the walker already yields each child element when it processes the array element-by-element via `walk(node.props.children)`, but the walker yields _elements_, not the array's string members directly. The child enumeration in `walk` falls through to "if children is null, return; else walk(children)" which DOES handle string siblings via `walk` recursion (the `Array.isArray` branch), but the _yielded_ elements are only the `<Icon />` element — the bare string siblings get filtered out by `walk`'s "string -> return" guard at the top. So the array members ARE walked, but the string ones don't yield anything for `collectText` to harvest. The fix at the `collectText` reduction is the cleanest: add an `Array.isArray` branch that picks string members directly. Generalizes: every direct-FC tree-walker test that asserts on button labels rendered alongside lucide icons (`<button><Icon /> Save</button>`, `<button><RefreshCw /> {t('connecting')}</button>`) needs the array-of-string-fallback in `collectText`. Pair this with `tree-walker-must-handle-lucide-icons-as-forwardref-elements` (rf-pdpl-14) — that learning covers the icon-element predicate side; this one covers the text-extraction side. Diagnostic: `collectText(tree)` returns the parent's text but is missing the literal text right after a lucide icon inside the same button — the array branch is the missing case. - -_Promoted to: /docs/refactoring-patterns.md_ - -## twmerge-strips-text-ice-2xs-when-status-color-class-also-uses-text-prefix - -_Discovered: 2026-04-30 by implementer in rf-ppanel-3_ - -`tailwind-merge`'s default class-group resolver lumps every `text-*` utility into a single conflict family — including custom font-size scales like `text-ice-2xs` and per-status colors like `text-emerald-500`, `text-ice-text-3`. So when the source writes `cn('px-1.5 py-0.5 text-ice-2xs font-semibold rounded-full', perStatus.className)` and `perStatus.className` is `'bg-emerald-500/10 text-emerald-500'`, the merged output is `'px-1.5 py-0.5 font-semibold rounded-full bg-emerald-500/10 text-emerald-500'` — the base `text-ice-2xs` is silently dropped. Diagnostic: a test asserting `expect(cls).toContain('text-ice-2xs')` fails with "expected '...' to contain 'text-ice-2xs'" even though the literal sits in the source. Two routes: (a) drop the assertion and document the merge artifact in a comment, since the class is still in the source — twMerge collapsing it doesn't change visible behavior because the per-status color also implies its size cascade via the parent typography, OR (b) split the merge so the base classes don't share the `text-` prefix family with the dynamic palette (e.g. move `text-ice-2xs` outside the `cn()` call or migrate to a custom group via `extendTailwindMerge`). The lower-friction fix is (a) — testing the _output_ of `cn()` is testing twMerge's behavior, not the component's. Generalizes: any future test pinning custom `text-ice-*` font-size literals on a className that ALSO interpolates a per-status `text-color-*` palette should expect the size literal to NOT survive merge. Either probe a different invariant (the rounded-pill shape, the bg-\*, the per-status color) or call cn() once and walk the result. Diagnostic: an `expect(cls).toContain('text-ice-2xs')` failure where the received string contains `text-ice-text-3` or `text-emerald-500` instead — that's twMerge winning, not a missing class. Don't fight it; assert on the surviving class. - -## early-return-after-hooks-still-registers-effects-and-state-slots - -_Discovered: 2026-04-30 by implementer in rf-tgal-6_ - -A FC with the shape `const X: React.FC = () => { const [a] = useState(0); useEffect(...); ...; if (!isOpen) return null; return <JSX/>; }` registers BOTH the state slot AND the effect on every invocation, even when `isOpen` is false. This is React's "rules of hooks" — the runtime call sequence has to be stable across renders, so all hook calls happen before any conditional return. Direct-FC tree-walker tests that assert "no effects registered when the early-return branch fires" are wrong: the effects ARE in the captured `mocks.effects` array; their _bodies_ are what no-op (because the body itself short-circuits via `if (!isOpen || ...) return;`). Symptom: `expect(mocks.effects.length).toBe(0)` fails with "expected 1 to be 0" on a closed-dialog test. Two fixes: (a) re-shape the assertion to "the effect body short-circuits" — fire `mocks.effects[0].cb()` and check that no state slot mutated; (b) gate the test on whether the JSX rendered (`expect(tree).toBeNull()`) and skip the effect-count assertion entirely. Option (a) is more rigorous because it catches a regression where someone moves the `if (!isOpen) return null` ABOVE the hooks (which would break rules-of-hooks at runtime — React's dev mode catches it but the test would silently start asserting a different invariant). Generalizes: any orchestrator that has `if (!<openFlag>) return null` and registers `useState`/`useEffect`/`useMemo`/`useCallback` ABOVE the early return — `TemplateGalleryDialog`, `PipelinePanel`, the destroy modals — needs the body-short-circuit assertion shape, not an "effects.length === 0" probe. Diagnostic: orchestrator test on the closed-branch fails with `effects.length` ≠ 0; the fix is to assert the effect body's short-circuit directly. Cite from any future rf-\* unit on a Dialog/portal/panel orchestrator with an `isOpen`-style gating return. - -## tree-walker-walks-mocked-fc-output-so-data-stub-attrs-appear-on-rendered-marker-not-original-jsx - -_Discovered: 2026-04-30 by implementer in rf-tgal-6_ - -When testing an orchestrator that renders a child mocked as `<TemplateDetail data-stub="TemplateDetail">{...}</TemplateDetail>`, the rf-rpal-8 tree-walker invokes the mock FC AND walks its output. So a `findByPredicate(tree, (el) => el.props['data-stub'] === 'TemplateDetail')` will match the INNER `<div data-stub="TemplateDetail">` rendered by the mock, NOT the original `<TemplateDetail>` JSX call site. The inner div has only the props the mock copied onto it — it does NOT have `onBack`/`onUse`/`template` from the original call. So a test that does `(detail.props as { onBack: () => void }).onBack` finds `onBack === undefined` and fails with "expected 'undefined' to be 'function'". Two fixes: (a) predicate on the FC-call site directly: `el.type === <MockedComponent>` (reference equality against the imported mock — vitest's `vi.mock` returns the same module-singleton in test and source); (b) predicate on `typeof el.type === 'function'` AND a unique prop the mock copies through (e.g. `el.props.template?.id`). Option (b) is the cleaner pattern when the mocked component has a discriminating prop. The rf-rpal-8 / rf-pdpl tree-walker pattern doesn't separate "FC call site" from "FC output" — `walk` yields BOTH because it yields the original element and then descends into the FC's return value. The mocked FC's output is rendered by the same walker, so `data-stub` markers added by the mock end up as siblings of the original JSX in the iteration. Pair with `react-memo-wrapper-must-be-unwrapped-via-dot-type-for-direct-fc-tree-walker` (which also distinguishes the wrapper from the rendered tree). Generalizes: any future test that mocks a child FC with `data-stub` markers AND wants to read the original call's props should filter on `typeof el.type === 'function'` plus a content discriminator, not on the marker attribute. Diagnostic: `expected 'undefined' to be 'function'` (or any prop coming back undefined) when probing a mocked-component element via its data-stub attribute. - -_Promoted to: /docs/refactoring-patterns.md_ - -## tree-walker-findall-must-recurse-into-array-children-for-fragment-children - -_Discovered: 2026-04-30 by implementer in rf-ptree-7_ - -The rf-pdpl-7..15 tree-walker `findAll(el, pred)` typically iterates `props.children` as `Array.isArray(children) ? children : [children]` and recurses into each child via `findAll(c as React.ReactElement, pred)`. That works for tree shapes where every level is a single element with element-typed children. It breaks for components that emit a `React.Fragment` whose children are themselves an array: e.g. `{folder.expanded && <>{childFolders.map(...)}{childProjects.map(...)}</>}`. The Fragment's `props.children` arrives as a TWO-level array — the outer `[]` is the Fragment's children list, and at index `[0]` is the array returned by `childFolders.map(...)`. Treating the inner array as a single element and trying to read `(arrayValue).props?.children` yields undefined and the recursion silently stops without descending into the actual nested elements. Symptom: a test `expect(findAll(tree, el => el.type === ChildFC)).toHaveLength(N)` returns `0` and the assertion fails with no informative diagnostic — the children ARE in the tree, the walker just refuses to descend. Fix: detect arrays at the entry of `findAll` and recurse element-wise into them BEFORE the typeof-object check that yields a non-array: `if (Array.isArray(el)) { for (const c of el) out.push(...findAll(c, pred)); return out; }`. The same pattern applies to any walker (`collectText`, `findByPredicate`) that descends through `children`. Diagnostic: predicate that should match recursive children (e.g. `el.type === FolderRow` for nested folders) returns 0 results when the source has `<>{X.map(...)}{Y.map(...)}</>` even though `X.length > 0`. Generalizes: every future direct-FC tree-walker test where the source uses `<>{x.map(...)}{y.map(...)}</>` (either Fragment shorthand OR `React.Fragment` long-form) needs the array-flattening shim. The shim is two lines and harmless on element trees, so safe to inline by default in any new walker. Pair with `tree-walker-walks-mocked-fc-output-so-data-stub-attrs-appear-on-rendered-marker-not-original-jsx` (rf-tgal-6) — both surface when the test author's mental model "tree-walker descends through children" doesn't account for React.ReactNode being a _recursive type_ that includes arrays of itself. - -_Promoted to: /docs/refactoring-patterns.md_ - -## nullish-coalescing-default-shadows-explicit-null-override-in-test-capture-helpers - -_Discovered: 2026-04-30 by implementer in rf-aichat-3_ - -The rf-pdpl-20 capture-helper pattern uses `args.projectId ?? 'proj-1'` to pick a default when the test doesn't pass an override. That works for `undefined`-valued props but silently shadows `null` overrides too — `null ?? 'proj-1'` evaluates to `'proj-1'`. So a test like `captureHook({ projectId: null, store })` expecting "projectId is null" actually gets `'proj-1'`, and the assertion `expect(out).toBeUndefined()` (when the hook returns early on falsy projectId) fails with `Expected: undefined / Received: []` because the hook proceeded with `'proj-1'` and the catch-arm returned `[]`. The diagnostic surfaces as a tests-level discrepancy that LOOKS like a hook bug but is a capture-helper-defaults bug. Fix: switch from `args.projectId ?? 'proj-1'` to `'projectId' in args ? args.projectId! : 'proj-1'` so the explicit-null override is preserved through the in-check. Generalizes: any future test capture helper that uses `??` or `||` on a nullable prop must use the in-check form when the test surface needs to distinguish "not passed" from "explicitly null". The pattern most often surfaces on tests for "no-op when projectId is null" / "no-op when activeCard is null" / etc. — the early-return arms of hooks that gate on a nullable Redux selector. Diagnostic: a "no-op when X is null" test fails because the hook proceeds with the default value and one of its API calls runs unexpectedly. Cite from any subsequent rf-\* unit that adds a captureHook helper for a Redux selector boundary. - -## closure-mutation-on-mutable-message-object-makes-captured-updater-results-misleading - -_Discovered: 2026-04-30 by implementer in rf-aichat-4_ - -The chat panel's effect 4 (AI-finished handler) does: - -``` -const assistantMsg = { ..., applied: !hasOps }; -setMessages(prev => [...prev, assistantMsg]); -if (hasOps) { - const result = applyOperations(); - if (result) { - assistantMsg.applied = true; - assistantMsg.operationCount = result.executedOps; - setMessages(prev => prev.map(...)); - } -} -``` - -The `assistantMsg` reference is shared between the array appended via the first `setMessages` and the post-apply mutation. So a test that captures the first updater's output via `setMessagesSpy.mock.calls[0][0]` and inspects `out[0].applied` later will see `applied: true` (the post-mutation state) NOT `applied: false` (the value at the moment setMessages was called). It LOOKS like a refactor-introduced behavior change but it's the source's verbatim mutation pattern — the captured updater closure holds the same `assistantMsg` reference React eventually commits, by the time the test inspects it the mutation has already happened in the synchronous effect body. Two fixes: (a) accept the post-mutation state in assertions and document why ("applied=true here is post-mutation, not the initial-append state"); (b) deep-clone the captured prev-array snapshot in the spy (`setMessagesSpy = vi.fn((updater) => structuredClone(updater(prev)))`) — but that's testing the cloned-out value, not the actual committed value. Option (a) is the right call when the source's mutation pattern is what we're trying to preserve verbatim. Generalizes: any future hook test that captures a `setState`-style updater and inspects fields on the captured-output objects must check whether the source mutates those objects later in the same synchronous block. If yes, the captured-output values reflect the POST-mutation state. Diagnostic: a captured-updater test asserts `applied: false` and gets `true` when applyOperations is configured to succeed — that's the in-place mutation, not a refactor regression. - -## formatCostRaw-negative-falls-into-tiny-positive-branch - -_Discovered: 2026-04-30 by implementer in rf-cost-8_ - -`packages/ui/src/features/cost/utils/cost-calculator.ts`'s `formatCostRaw` checks `if (value < 0.01) return '< $0.01'` BEFORE the equality check `if (value === 0)`. So any negative number — like a `-50` cost delta in the environment-comparison or session-delta paths — short-circuits into the "< $0.01" branch. The first call site (session-delta in `cost-panel.tsx` and the env-comparison delta in `EnvironmentComparison`) emits "+/- < $0.01" instead of "+/-~$50" when an env is cheaper than prod or when a user removes nodes mid-session. Tests that try to assert "-$50" or "~$-50" against a negative-delta render get "< $0.01" instead. This is observable behavior that callers depend on (the source is unchanged through rf-cost) but the formatter does NOT actually represent negative values. Two fixes (out of scope for rf-cost but worth flagging for a future fix): (a) gate the tiny-positive branch on `value > 0 && value < 0.01`; (b) add a `if (value < 0) return '-$' + formatCostRaw(-value).replace(...)`short-circuit. For now: the canonical render of any negative cost via`formatCostRaw`is the literal string "< $0.01", and tests against that contract should assert that, not "-$X". Generalizes: any time you see a numeric formatter with a "very small positive" branch, write a`formatter(-X)` test before relying on it from a delta/diff code path. - -## icon-data-table-must-live-in-tsx-file-not-ts-when-stored-as-jsx-elements - -_Discovered: 2026-04-30 by implementer in rf-cost-3_ - -The first attempt at `cost/data/category-meta.ts` was a `.ts` file with `Record<string, React.ReactNode>` keyed entries like `Compute: <Server className="w-3.5 h-3.5" />`. Vitest's tsx transformer didn't run on `.ts` so the JSX in the value position was a syntax error. Renaming to `.tsx` fixed it — `.tsx` triggers the JSX transformer regardless of whether the file exports a component. Generalizes: any module that builds a lookup table whose values are `<Component .../>` literals (not `() => <Component .../>` factories) must be `.tsx`, even when the module exports no FC. The alternative — switch to factory functions or `React.createElement` calls — works in `.ts` but loses the readability advantage of declarative JSX, and is unidiomatic in this codebase. Pattern check: anywhere you see `Record<string, ReactNode>` or `Record<string, ReactElement>` in a `.ts` file, suspect a hidden compile error (or a build that swept it under the rug because the file was never imported). - -## subhook-deps-must-be-MutableRefObject-not-RefObject-when-handlers-write-back - -_Discovered: 2026-04-30 by implementer in rf-canvint-3_ - -When extracting a callback-bundle sub-hook (rf-canvint-3's `useMouseHandlers`) from an orchestrator that builds its refs via `useRef<T>(initial)`, the natural-looking dep type is `RefObject<T>` — that's what `import { type RefObject } from 'react'` exposes for "the orchestrator's ref". It's wrong. React's `RefObject<T>.current` is **`T | null` and read-only** (TS lib def L151-156); the orchestrator's `useRef<T>(initial)` actually returns **`MutableRefObject<T>`** (T, writable, never null when initialized). Typing the sub-hook's deps as `RefObject<T>` triggers a flood of `TS18047: '.current' is possibly 'null'` errors on every read AND `TS2540: Cannot assign to 'current' because it is a read-only property` on every write — including verbatim writes like `stateRef.current = freshInitialState()` and `lastMousePos.current = { x, y }` that the original closure bodies do constantly. The fix: import both `MutableRefObject` and `RefObject`, type orchestrator-owned refs as `MutableRefObject<T>` (mirrors the `useRef(initial)` return), and reserve `RefObject<T>` only for refs forwarded from external sources (svg-canvas.tsx's `useRef<SVGSVGElement>(null)` IS legitimately nullable). Document the split with an inline comment so the next refactorer sees the asymmetry. Generalizes: every future sub-hook extraction (rf-canvint-N, rf-canv-N+, anywhere a callback-bundle sub-hook reads/writes orchestrator-owned refs) must distinguish "orchestrator-built ref via useRef(initial)" from "externally-forwarded ref" at the dep-type boundary. Diagnostic: a flood of TS18047 + TS2540 on `.current` accesses inside a sub-hook whose body is verbatim from the original closure — flip the deps from `RefObject` to `MutableRefObject`. Pair with the rf-canv-1 export-from-and-import-from-pattern (the dep-type imports often get bundled with the type re-export, so the fix lives in the same import block). - -_Promoted to: /docs/refactoring-patterns.md_ - -## subhook-stateRef-cross-binding-must-be-orchestrator-owned-when-multiple-subhooks-need-it - -_Discovered: 2026-04-30 by implementer in rf-canvint-3_ - -The rf-canvint plan separated the canvas-interactions hook into a mouse-handler sub-hook (rf-canvint-3) and a keyboard-handler sub-hook (rf-canvint-4). One ref crosses the boundary: `spaceHeldRef` — the keyboard sub-hook WRITES it on `keydown`/`keyup` for Space, the mouse sub-hook READS it on `mousedown` for the Space+left-click pan branch. The natural-looking placement (matching the original code) was to define `spaceHeldRef` inline at the keyboard sub-hook's call site at the BOTTOM of the orchestrator, with the mouse sub-hook getting a closure-over reference. That breaks the moment you split the file: the mouse sub-hook's deps interface needs the ref before the keyboard sub-hook is even called, but at the textual position where mouse-handlers are wired, `spaceHeldRef` doesn't exist yet. The fix: HOIST `spaceHeldRef = useRef(false)` to the top of the orchestrator (alongside the other always-orchestrator-owned refs like `viewportRef`/`itemsRef`), thread it into BOTH sub-hooks as a normal dep, and document the cross-binding in a comment. Generalizes: any time two sibling sub-hooks share mutable state via a ref, the ref MUST live at the orchestrator (not at one sub-hook's call site), even if the original inline code structured it differently. Diagnostic: a "Cannot find name 'spaceHeldRef'" TS error during the first sub-hook extraction — that's the cross-binding asking to be hoisted. The same shape will recur in any future series that splits a single hook into N sub-hooks: catalog the cross-bindings up-front (write-to + read-from sets per ref), and any ref that appears in more than one sub-hook's set is orchestrator-owned. Pair with the brief's "Watch" list — orchestrator-owned cross-binding refs should be the FIRST thing the planner flags. - -_Promoted to: /docs/refactoring-patterns.md_ - -## sub-hook-test-needs-stub-window-and-Probe-when-effect-uses-window-listeners - -_Discovered: 2026-04-30 by implementer in rf-canvint-4_ - -When extracting a sub-hook whose `useEffect` installs `window.addEventListener('keydown'/'keyup'/'blur', ...)` (rf-canvint-4's keyboard handler), the test harness needs THREE pieces working together that don't exist in the rf-canv-18 ResizeObserver pattern: (1) a `window` global stub via `vi.stubGlobal('window', { addEventListener, removeEventListener })` because vitest's node-only mode (no jsdom) doesn't expose `window` — `vi.spyOn(window, ...)` fails with "window is not defined"; (2) stubs for the three input-element constructors `HTMLInputElement`/`HTMLTextAreaElement`/`HTMLSelectElement` because the keyboard handler does `e.target instanceof HTMLInputElement` to bypass form fields, and node-only mode doesn't have those constructors so the predicates evaluate against `undefined` and throw at runtime; (3) a `Probe` component + `renderToString` wrapper around the hook call because the sub-hook calls `useRef` for its private refs (`pressedKeysRef`, `animationFrameRef`, `isAnimatingRef`) and React's `useRef` requires a fiber context — calling the hook directly throws `TypeError: Cannot read properties of null (reading 'useRef')`. The mouse-handler sub-hook (rf-canvint-3) didn't need any of this because it's `useCallback`-only (mocked to identity, so no fiber needed) and the handlers don't touch `window` or DOM-element instanceof. Generalizes: every future sub-hook test in this codebase must inventory its source's globals + React-context dependencies BEFORE picking the harness shape. Three signals: (a) does the hook use `useRef`/`useState`/`useEffect`? → needs Probe + renderToString, even if your callbacks are pure. (b) does any callback body reference `window` / `document` / `navigator`? → needs a `vi.stubGlobal` for that surface. (c) does any callback do `instanceof X` against a DOM type? → stub `X` as a class via `vi.stubGlobal('X', class {})` so `e.target instanceof X` is testable. The diagnostic for missing (a) is a useRef null-pointer; for (b) it's "X is not defined"; for (c) it's "Right-hand side of instanceof is not callable". Pair with the rf-canv-22 fake-timers triad — that one is about timer-driven effects; this one is about window-driven effects. Both are cousins of "pick the harness based on the source's actual external surface, not on the hook's surface alone." - -## subhook-extraction-loc-budget-666-line-orchestrator-can-fit-200-loc-after-five-units - -_Discovered: 2026-04-30 by implementer in rf-canvint-5_ - -The rf-canvint series decomposed a 666-LOC `useCanvasInteractions` orchestrator into: - -- `interactions/types.ts` (105 LOC) — public + private types -- `interactions/state.ts` (67 LOC) — `INITIAL_STATE`, `freshInitialState`, `KEYBOARD_PAN_SPEED`, `snapToGrid`, `cursorForMode` -- `interactions/hit-test.ts` (102 LOC) — `screenToCanvas`, `isInItem`, `isInResizeHandle`, `findItemAtPosition` as pure functions -- `interactions/use-mouse-handlers.ts` (437 LOC) — six mouse callbacks bundled -- `interactions/use-keyboard-handlers.ts` (178 LOC) — single useEffect + window listeners + rAF loop -- `use-canvas-interactions.ts` (185 LOC) — orchestrator (refs + thin closures + sub-hook composition) - -Total: 1074 LOC across 6 files. The orchestrator drop is 666→185 (-72%); the per-sub-module sizes hit the 200–500 LOC target except for `use-mouse-handlers.ts` at 437 (one large file with six related callbacks; splitting further would force `stateRef`/`lastMousePos` Map identity through additional layers per rf-canv-2 `inline-classification-duplications-are-not-actually-duplicates`). Generalizes the rf-canv-N hook-group pattern: a 600+ LOC monolithic hook can decompose to a 5-module group via the recipe (1) types + state constants → leaf modules, (2) pure helpers → leaf module taking primitives, (3) callback bundles → sub-hook taking refs by reference, (4) effect-driven listeners → sub-hook owning private refs + reading orchestrator latest-callback refs, (5) orchestrator becomes ref provisioning + sub-hook composition + cursor-mapping + return shape. The orchestrator stays the public-import surface (re-exports types) so external consumers don't need to update imports. Cite from any future single-hook decomposition in the rf-canvint or similar series. - -## packages-web-tests-need-vitest-resolve-alias-for-ui-namespace - -_Discovered: 2026-04-30 by implementer in rf-wgal-3_ - -`packages/web/src/**` source files commonly import from `@ui/*` (e.g. `@ui/i18n`, `@ui/shared/utils/cn`, `@ui/config/templates`) — that alias is configured in `packages/web/vite.config.ts` and in `packages/web/tsconfig.json` `paths`, but the root `vitest.config.ts` did NOT have a matching `resolve.alias` map. So tsc happily compiled web source code, but `pnpm vitest run packages/web/src/...` failed at runtime with `Error: Cannot find package '@ui/...' imported from packages/web/src/...`. The fix is to add a top-level `resolve.alias` block to `vitest.config.ts` mirroring web's vite config: `'@ui': resolve(__dirname, 'packages/ui/src')` and `'@': resolve(__dirname, 'packages/web/src')`. The other packages don't use `@/` (only web does), so this doesn't clash with anything else; verify with `grep -rn "from '@/" packages/ui/src/ services/*/src/`. Diagnostic: a fresh test under `packages/web/src/` imports a source file that transitively hits `@ui/...` and vitest reports `Cannot find package '@ui/i18n'` or similar. The same pattern will recur for any future package that adds source-level path aliases — `vitest.config.ts` needs to mirror them. - -## tree-walker-mocked-fc-onclose-prop-not-readable-on-fc-element-after-walk-recursion - -_Discovered: 2026-04-30 by implementer in rf-wgal-7_ - -When testing an orchestrator that renders `<TemplateDetail template={selectedTemplate} onClose={() => setSelectedTemplate(null)} onUse={handleUseTemplate}/>`, the rf-pdpl tree-walker invokes the mock and walks the inner output. A predicate like `typeof el.type === 'function' && el.props.template?.id === 'tpl-a'` SHOULD match the original FC call site BEFORE the mock's inner `<div>` is yielded — but in practice, the FIRST element `find()` returns appears to lack the `onClose`/`onUse` props (`undefined`). I burned ~10 minutes trying to figure out whether the closure was getting stripped, the props were lost during walk, or some other nonsense. The actual fix is much simpler than chasing the FC call site: probe the inner mock-rendered `<button data-stub="close" onClick={onClose}>` directly — the mock DOES copy `onClose` to the button's `onClick`, so a `findByPredicate(tree, el => el.props['data-stub'] === 'close')` returns the button with `onClick: () => setSelectedTemplate(null)`. Asserting `typeof onClick === 'function'` is sufficient to prove the wiring exists. This pattern works any time the test mock implementation copies a callback-prop to a child element's `onClick` (which is exactly what makes the mock "press-able" in the first place). Generalizes: when a direct-FC tree-walker test fails to read a callback prop on an apparently-correct FC call site, switch the assertion to probe the rendered-mock surface that copies the callback. The mock's data-stub markers + onClick prop survive all the walker recursion because they're plain leaf elements. Pair with `tree-walker-walks-mocked-fc-output-so-data-stub-attrs-appear-on-rendered-marker-not-original-jsx` (rf-tgal-6) — both surface when the test author tries to assert on the outer FC-call site instead of the inner mock-rendered surface that's actually press-able. - -_Promoted to: /docs/refactoring-patterns.md_ - -## react-context-provider-not-typeof-function-on-react-19 - -_Discovered: 2026-04-30 by implementer in rf-accent-4_ - -A naive context-export smoke test like `expect(typeof MyContext.Provider).toBe('function')` passes on React 16/17/18 (where Provider is a forwardRef-style function component) but FAILS on React 19+ (where the Provider is an object `{ $$typeof, _context, ... }` and `typeof === 'object'`). The Consumer flips the other way (was an object, is now also an object — but with a different shape). Symptom: `expected 'object' to be 'function'` on a fresh assertion in a context-test file the moment the repo bumps from React 18 to 19. The fix: don't test the runtime shape at all — assert `expect(MyContext.Provider).toBeDefined()` plus the load-bearing default-value check via the internals symbol (`expect(typeof (MyContext as { _currentValue: unknown })._currentValue.someKey).toBe('function')`). The defaultValue field name `_currentValue` IS implementation-specific (and React's internals docs explicitly warn it could change), but it's stable across all current React majors and is the only way to read the default without entering a render dispatcher. Generalizes: every `context.test.ts` for a leaf-extracted React context should pin the public observable behavior (the hook's return value when called inside/outside a Provider) AND the default value via internals, NOT the wrapper component types. The tree-walker-via-stubbed-dispatcher pattern from this same unit is the cleanest way to drive `useContext(...)` deterministically without a renderer — patch `ReactCurrentDispatcher.current.useContext` to return either the context's `_currentValue` (no provider) or a synthetic value (matching a synthetic ancestor-provider call). Diagnostic: TSC-clean test file fails ONLY on the `typeof X.Provider` assertion after a React major bump — that's React migrating Provider/Consumer from FC-shaped to object-shaped. Drop the assertion or rewrite as `toBeDefined`. Pair with `react-namespace-hook-access-requires-patching-default-export-too` (rf-pdpl-12) — both are React-version-coupling traps surfaced when the repo upgrades. - -## vi-hoisted-must-include-large-fixture-arrays-when-vi-mock-factory-references-them - -_Discovered: 2026-04-30 by implementer in rf-accent-5_ - -When orchestrator tests stub a module-level data export (here: `vi.mock('../data/themes', () => ({ T: FIXTURE_T }))`), the natural shape is to declare `FIXTURE_T` as a top-level `const` next to the `mocks` object. That works for inline-defined hoisted state because `vi.hoisted` and `vi.mock` are co-hoisted to the top of the file, and references within their factories resolve at hoist time. But a `const FIXTURE_T = [...]` defined OUTSIDE `vi.hoisted()` is NOT hoisted — by the time `vi.mock`'s factory runs, the module-top-level binding is in the temporal dead zone and the factory throws `ReferenceError: Cannot access 'FIXTURE_T' before initialization`. The fix is to fold the fixture INTO the `vi.hoisted({ ... })` block (e.g. as `mocks.fixtureT = [...]`) and reference it via `mocks.fixtureT` from inside the factory: `vi.mock('../data/themes', () => ({ T: mocks.fixtureT }))`. Optional sugar: alias `const FIXTURE_T = mocks.fixtureT` AFTER the hoisted block to keep the test bodies readable. This is the same trap as the well-known "vi.hoisted required for shared mock identities across many vi.mock calls" learning — but specific to large fixture arrays where the test author's instinct is to declare a normal const for clarity, not realizing that any value referenced inside a `vi.mock` factory has to participate in the hoist. Generalizes: ANY non-trivial fixture (an array, a record, a builder closure) referenced inside a `vi.mock` factory belongs in `vi.hoisted({...})`. Diagnostic: vitest emits "There was an error when mocking a module" with a `ReferenceError: Cannot access 'X' before initialization` pointing into the SUT file's import line (the import runs the SUT, the SUT runs `vi.mock`, the mock factory hits the dead-zone reference) — the actual fault is the test file's outside-of-hoisted const, not the SUT. - -_Promoted to: /docs/refactoring-patterns.md_ - -## rectangular-path-zero-leg-elbow-collapses-to-duplicated-point-in-pathd - -_Discovered: 2026-04-30 by implementer in rf-conpath-7_ - -`buildRectangularPath` (rf-conpath-6) and `buildDagreRoutedPath` (rf-conpath-5) both run a per-corner chamfer pass that emits `L (before) Q (cur) (after)` triples around each interior waypoint — except when `r < 1 || lenIn < 1 || lenOut < 1`, in which case it falls through to `d += " L ${cur.x} ${cur.y}"`. The non-obvious case is a horizontal-axis edge whose two endpoints share the SAME `y` (e.g. start=(100,25), end=(200,25), midX=150). The synthesized waypoints are `[(100,25), (150,25), (150,25), (200,25)]` — the SECOND and THIRD points are byte-identical because the elbow has zero vertical leg. The chamfer math then computes `lenIn = sqrt(50² + 0²) = 50` for the first corner and `lenIn = sqrt(0² + 0²) = 0` for the second, falling through to `L cur` on BOTH (`r<1` for the first because `lenOut=0`, `lenIn<1` for the second because the inbound segment is degenerate). Resulting `pathD` is `M 100 25 L 150 25 L 150 25 L 200 25` — the duplicated `L 150 25` is intentional, the SVG renders identically to a straight line, but a test that asserts `out.pathD.toMatch(/Q 150 25 .+/)` (expecting the curve marker) FAILS because no chamfer emits. I burned a cycle on this. The right test assertion is the verbatim string with the duplicated `L`, not a regex looking for `Q`. Generalizes: rectangular-path tests where source and target are coplanar on the bend axis must pin the bytes verbatim, NOT regex on the chamfer marker — pinning the chamfer regex implicitly assumes a non-zero leg, which is fine for most fixtures but silently wrong for the coplanar case. Symptom diagnostic: `expected 'M 100 25 L 150 25 L 150 25 L 200 25' to match /Q .+/`. Cite from any future test of `buildRectangularPath` / `buildDagreRoutedPath` or the dispatcher (`computePath`) when fixtures probe the rectangular branch — half the time the fixture's intuitive endpoints are coplanar and the chamfer can't fire. - -## extracting-pure-usememo-body-as-module-helper-keeps-deps-array-honest - -_Discovered: 2026-04-30 by implementer in rf-conpath-7_ - -The orchestrator's `pathData` `useMemo` body was a 70-LOC pure function over `(connection, fromNode, toNode, sourcePortIndex, sourcePortCount, targetPortIndex, targetPortCount, edgeStyle, lod, zoom)` — no DOM, no state, no setters. The original `useMemo` deps array listed `connection.data` (NOT `connection`) plus the other 8 args. After extracting to `computePath({...args})` and rewriting the `useMemo` body as a single `() => computePath({connection, fromNode, ...})`, ESLint's `react-hooks/exhaustive-deps` flagged that the new closure captures the WHOLE `connection` object (the `args` object literal contains `connection`), not just `connection.data`. The natural fix is to swap the deps from `connection.data` to `connection`. That's actually CORRECT behavior — the original `[..., connection.data]` was under-specified, since the dispatcher reads `connection.id`, `connection.from`, and `connection.to` too (via the ports / direction code paths). The pre-extraction `useMemo` was probably "stable in practice" because every consumer mutates the whole connection object together (no one creates a CanvasConnection with the same identity but a different `.data`), but the deps array was technically lying. Lifting the body into a pure function exposes the lie because the closure over `args` becomes explicit. Generalizes: any unit that extracts a `useMemo` body to a module-level pure helper with a `{...args}` shape will trip the deps-array honesty check — that's a feature, not a bug. Use the opportunity to widen the deps to whatever the helper actually reads (usually the WHOLE prop, not a `.subfield`). The runtime cost of `useMemo` re-running when `connection` identity changes is negligible compared to the correctness win of the deps array now being accurate. Cite from any future rf-canv / rf-pdpl extraction that lifts a `useMemo` body — same pattern, same ESLint nudge, same correct-fix-is-widen-the-deps response. - -## react-children-toArray-rewrites-keys-with-dollar-prefix-direct-array-access-instead - -_Discovered: 2026-04-30 by implementer in rf-canv2-7_ - -When extracting a JSX layer into its own component (e.g. `NodesLayer`'s `sortedNodes.map(...)` block) and writing direct-FC tree-walker tests for the wrapper-key priority chain, the natural assertion shape is `React.Children.toArray(el.props.children)[0].key` — read the rendered element's outer key. That returns `'.$inner-a'` or `'.$clipped-child'`, NOT the raw `'inner-a'` / `'clipped-child'` your code emitted. `React.Children.toArray` deliberately rewrites every child's key to `'.<index>$<originalKey>'` so the result array is safe to render as siblings of the calling component (each child gets a globally-unique key under the parent's reconciliation). It's a tree-render-safety affordance, not a debug-friendly accessor. The fix: read keys directly off the JSX children array, NOT through `React.Children.toArray`: `(el.props.children as React.ReactElement[])[0].key`. The same gotcha applies to `React.Children.map` (also rewrites). Use direct array access whenever you're reading metadata (key, ref, type) off a children prop in a test — only use `React.Children.toArray` when you genuinely need the deduplicated/key-rewritten output (e.g., prop-drilling children into a Provider that re-renders them). The same direct-access pattern works for prop reads (`children[0].props.isLifted`); the rewrite only affects the `key` field. Generalizes: any future rf-canv / rf-pdpl JSX-layer extract that wants outer-key tests must avoid `React.Children.toArray` for the assertion read. Diagnostic: `expected '.$inner-a' to be 'inner-a'` — that's the dollar-prefix rewrite, swap to direct array access. Document in extracted-component test files at the read site so the next refactorer doesn't re-discover this. The rf-canv-10 wrapper-key priority chain (lifted → bare id, parentId → clipped-id, animating → anim-id, else innerKey) is exactly the kind of test surface this gotcha hits; same shape will recur for any future per-item layer-component extract. - -## ghost-overlay-asymmetric-prop-signatures-onAccept-takes-object-onDismiss-takes-id - -_Discovered: 2026-04-30 by implementer in rf-canv2-7_ - -When extracting an inline JSX overlay block into its own component (e.g. `<GhostOverlay>` lifting the ghosts.map block out of svg-canvas), the natural-looking type for the accept/dismiss callbacks is `(ghost: GhostNode) => void` for both — symmetrical signatures read cleanly. The reality: `SvgGhostNode`'s prop types are `onAccept: (ghost: GhostNode) => void` and `onDismiss: (ghostId: string) => void` — asymmetric. The accept path needs the whole node object (callers create a new card node from the ghost's `suggestedType` + `position`); the dismiss path only needs the id (the ghost-slice reducer takes `removeGhost(ghostId)` keyed by id). Typing both as `(ghost: GhostNode) => void` triggers a TS2322 from the inner SvgGhostNode call: `Type '(ghost: GhostNode) => void' is not assignable to type '(ghostId: string) => void'`. Fix: preserve the asymmetry verbatim in the extracted component's props. Cite the asymmetry in a comment at the prop-type definition so the next refactorer doesn't try to "clean up" the inconsistency. Generalizes: any future overlay/render-prop extract that thread paired callbacks must consult the inner consumer's prop types verbatim BEFORE choosing the extracted component's signature — symmetry is not a goal here, behavior preservation is. The same shape recurs in any future props-extract from svg-canvas's render block (rf-canv2-7 + downstream rf-canv2-8+ if any). Diagnostic: TS2322 on a callback-prop pass-through that "looks the same as the other callback" — check whether the inner consumer expects different signatures. - -## rf-pipe-dependency-order-vs-brief-numbering - -_Discovered: 2026-04-30 by implementer in rf-pipe-2_ - -The rf-pipe planner brief numbered units by topic affinity (rf-pipe-2 = rule-management, rf-pipe-5 = github-webhooks), but the _actual_ dependency order is the inverse for these two: rule-management imports `registerGitHubWebhook` / `unregisterGitHubWebhook`, so github-webhooks must exist as a real module first or you eat a back-import (rule-management imports back from pipeline.service, which imports forward from rule-management — a cycle that resolves at runtime but smells terrible and breaks if the orchestrator ever stops re-exporting). Resolution: extract in dependency order, not brief order. Commit messages keep the brief's unit IDs (rf-pipe-1, rf-pipe-5, rf-pipe-2, rf-pipe-3, rf-pipe-4, rf-pipe-6, rf-pipe-7) so the orchestrator can match them against the plan, but the chronological commit order reflects the dependency DAG. Generalizes: when a planner brief lists N units with an explicit topic-grouping numbering, before starting unit-2 verify whether unit-N (for N>2) is actually a _dependency_ of unit-2. If yes, run it first and let the commit message keep its assigned unit-id. The same shape will recur for any future ICE refactor where the planner's natural reading order differs from the import DAG (e.g. types modules near the end of a brief but consumed by everything earlier — pull them up; service modules near the front of a brief but with cross-cutting dependencies on a "helpers" module flagged late — pull the helpers up). - -## fetch-mock-as-vi-fn-with-arrow-typed-url-needs-no-vi-fn-default-type-workaround - -_Discovered: 2026-04-30 by implementer in rf-pipe-6_ - -The `vi-fn-default-type-rejects-typed-callback-parameter` learning warns that `vi.fn()` with no generic rejects when the first call passes a callback typed by the test author. That gotcha is specifically about _passing_ a typed callback to `vi.fn()` (e.g. `vi.fn().mockImplementation((cb: NodeStatusCallback) => ...)`). It does NOT apply to writing a fetch mock as `vi.fn(async (url: string) => { ... })` — there the typed parameter is on the _implementation function itself_, which `vi.fn` accepts via overload inference; the resulting mock has return-type inferred from the body, no generic needed. So the fetch-mock pattern in the framework-detection test reads as: - -```ts -fetchMock = vi.fn(async (url: string) => { - if (url.includes('/contents/Dockerfile')) return { ok: true, json: async () => ({...}) }; - return { ok: false, json: async () => ({}) }; -}); -vi.stubGlobal('fetch', fetchMock); -``` - -…and it typechecks cleanly without `vi.fn<typeof fetch>()` or `vi.fn<(u: string) => Promise<...>>()`. Generalizes: the `vi-fn-default-type-rejects-typed-callback-parameter` rule is narrower than its name suggests — it bites callbacks-as-arguments, not arrow-typed-url-as-implementation. When writing fetch mocks for any future pipeline-internal module that hits the GitHub Contents API (or any other URL-keyed dispatcher), reach for the inline `vi.fn(async (url: string) => {...})` shape; it gives you readable URL routing without a typing dance. - -## staged-lifecycle-extraction-needs-two-modules-when-dependency-graph-is-cyclic - -_Discovered: 2026-04-30 by implementer in rf-lstream-7b_ - -The rf-lstream brief planned a single `stream-lifecycle.ts` for the four lifecycle helpers (`stopUnderlyingStream`, `teardownStream`, `openStreamForResolved`, `restartStreamWithMode`). That looks correct on a topical reading — they're all "lifecycle" — but the import graph forces a split: `polling.ts` and `tail.ts` need `stopUnderlyingStream` from stream-lifecycle (both call it on terminal failures), and `openStreamForResolved` needs to call `startPolling` / `startTail` after registering the stream. Putting all four in one module creates a cycle (polling → stream-lifecycle → polling) that resolves at runtime — but the _test_ mocks expose the cycle: `log-stream-polling.test.ts` mocks `@ice/shared` with `getSocketServer` only; the moment polling.ts imports stream-lifecycle.ts (which also imports `@ice/db`, `@ice/service-credentials`, and the SDK loader transitively), the test runner trips on `No "requireAuth" export is defined on the "@ice/shared" mock`. The fix is a two-module split: keep the dependency-free primitives (`stopUnderlyingStream`, `teardownStream`) in stream-lifecycle.ts, lift the heavier helpers (`openStreamForResolved`, `restartStreamWithMode`) into a new `stream-open.ts` that owns the Prisma + credentials + SDK + polling/tail imports. Polling and tail tests can then mock only `@ice/shared` for their scope; orchestrator + stream-open tests mock the wider surface. Generalizes: when a refactor brief groups N helpers under one topical name (`stream-lifecycle`, `pipeline-events`, `cards-snapshot`) but a strict subset of the helpers is dependency-free while the rest pull in the world, **split by dependency surface**, not by topic. The test-mock matrix is the canary: if any sibling test would have to widen its mock factory just because the SUT now imports a primitive that lives in the same module as a heavy helper, the module is too coarse. Watch for the symptom "test that was green when its SUT imported file A becomes red when A starts importing B's siblings". Two-module solutions also follow the rf-pipe-7 dependency-order rule: extract the dependency-free primitives first, the heavy ones last; chronological commit order matches the import DAG even when topical numbering doesn't. - -## vi-mock-paths-resolve-from-test-file-not-from-sut - -_Discovered: 2026-05-01 by implementer in rf-aisvc-7_ - -When extracting a leaf module out of an orchestrator and writing a smoke test for the orchestrator that mocks the leaf, intuition says: "use the same import path the SUT uses, since `vi.mock` rewrites that resolution." That intuition is wrong. Vitest resolves `vi.mock(specifier)` from the **test file's location**, NOT from the SUT's location. So if the SUT lives at `services/ai/src/services/ai.service.ts` and imports `'./ai/provider'`, but the test lives at `services/ai/src/services/__tests__/ai.service.test.ts`, the test must call `vi.mock('../ai/provider', ...)` — the path traverses from `__tests__/` up to `services/`, then into `ai/`. Symptom: with the wrong path, the mock factory silently does NOT replace the SUT's import, the SUT loads the real leaf module, and tests fail with the real module's error message ("No AI provider configured. Set ANTHROPIC_API_KEY or ICE_AI_URL." in this case) — utterly mystifying because the mock was right there in the file. The fix is one character per path: replace `./X` with `../X`. The same effect at deeper nesting drove `services/ai/src/services/ai/__tests__/post-processing.test.ts` to use `'../../ai-audit.service'` (test → up two → over to `ai-audit.service`). Generalizes: any test file under `__tests__/` mocking sibling modules of the SUT must compute the path from the TEST file's directory, NOT copy the SUT's path verbatim. Build the path mentally as: test_dir → up to common parent → down to mocked module. When in doubt, count the `..` segments: a test in `services/__tests__/` mocking `services/ai/provider` needs ONE `..` segment (`'../ai/provider'`); a test in `services/ai/__tests__/` mocking `services/ai-audit.service` needs TWO (`'../../ai-audit.service'`). Pair this with `vi-mock-factory-hoist-blocks-top-level-class-references`: that rule covers WHEN the factory runs (early, hoisted); this rule covers WHERE the path resolves (test file, not SUT). - -_Promoted to: /docs/refactoring-patterns.md_ - -## snapshot-restore-preserves-version-not-via-on-conflict - -_Discovered: 2026-04-30 by implementer in rf-sqlite-5_ - -The SQLite state store's `restore_snapshot` looks like it should hit the -`upsert_resource` ON CONFLICT path because the prepared statement is the -same one `save_resource` uses (`ON CONFLICT(graph_id, node_id) DO UPDATE -SET ... version = version + 1`). The natural reading is "restore re-upserts -each snapshot row, so version bumps by one per resource". That's wrong. -The restore txn does `DELETE FROM resources WHERE graph_id = ?` BEFORE -the upsert loop, so when the loop runs there are no rows to conflict -against — every INSERT lands fresh and the version field is preserved -EXACTLY as it was at snapshot time. A test that asserted "version -becomes original+1 after restore" failed with the actual value being -the original; the SQL is unchanged from pre-extraction so the bug is -in the test's mental model, not the code. Generalizes: when reasoning -about ON CONFLICT semantics inside a transaction, look at what other -DML the txn does first — a DELETE that strips the conflict-target rows -turns "upsert" into "insert", and any version-tracking field is preserved -verbatim. This is the documented restore semantics (full snapshot-state -replacement, not a state-merge), so do NOT "fix" the test by adjusting -the SQL; pin the actual behaviour. Pair with snapshot/restore behaviour -in any other state store: if the store uses upserts with a version-bump -clause AND restore involves a clearing pass, the `version` field after -restore equals the snapshotted version, NOT snapshot+1. - -## algorithm-pass-grouping-needs-uniform-import-depth-tracking - -_Discovered: 2026-05-01 by implementer in rf-alay-7_ - -When an `rf-*` series produces a nested directory structure where some -modules sit at `<feature>/` and others at `<feature>/algorithms/`, the -relative-import depth to a reused `config/canvas-constants` (or any -non-package import outside the new directory) DIFFERS by one segment -between the two levels. In rf-alay, `auto-layout/visual-size.ts` uses -`'../../../config/canvas-constants'` (up to `shared/`, sideways to -`config/`) but `auto-layout/algorithms/circular.ts` requires -`'../../../../config/canvas-constants'` (one extra `..` to escape -`algorithms/`). Symptom: typecheck passes for the file at the shallower -depth, fails for the deeper one with `Cannot find module -'../../../config/canvas-constants'`. The fix is mechanical (count -segments from each module's directory upward) but easy to miss when -copy-pasting an import block from a sibling that lives one level -shallower. Generalizes: any algorithm-pass-grouping refactor that -introduces a sub-directory (`algorithms/`, `passes/`, `phases/`) for -the heavy passes while leaving the light helpers at the parent level -must adjust import paths PER FILE, not per series. When in doubt, do -the per-file `pnpm typecheck` immediately after each `Write` rather -than batching the typecheck at the end of the unit — the error fingers -the exact file and the fix is one segment. Pair with -`relative-import-depth-cited-in-brief-is-anchored-to-citing-file`: -brief-cited paths are starting points anchored to the SOURCE file, -not the destination, and need recounting when the destination's depth -changes. Diagnostic: `Cannot find module '../<n>/...'` where the -module exists at `../<n+1>/...` from the new file's directory. - -## to-camel-case-on-leading-underscore-rewrites-not-passes-through - -_Discovered: 2026-04-30 by implementer in rf-pulumi-4_ - -The `to_camel_case` helper extracted in rf-pulumi-2 looks like the -expected snake*case-to-camelCase converter, but the regex -`/*([a-z])/g`is greedier than first reading suggests: it matches`_internal`(capturing`i`), consumes the leading underscore, and -emits `Internal`. So `to_camel_case('\_internal')`is`'Internal'`, -NOT `'\_internal'`. Pre-extraction `transformValue`re-keyed every -nested object via this same helper — meaning a property bag`{ \_internal: 1 }`passed at the top level would be SKIPPED by`map_properties`(its`key.startsWith('_')`filter), but the SAME -shape inside a nested object value goes through`transform*value`which calls`to_camel_case`and produces`{ Internal: 1 }`. I wrote -a test that asserted nested `\_kept_inside`would survive verbatim -and it failed with the rewritten`KeptInside`. The verbatim -behaviour is: at the top level `map_properties`strips -underscore-prefixed keys; in nested objects`transform_value`LETS THEM THROUGH the filter (no`startsWith('*')`check) but -the to_camel_case rewrite still re-keys them, dropping the -leading underscore. Generalizes: when porting any "snake-to-camel -key rewrite" helper that uses`/_([a-z])/g`, every test fixture -with a leading-underscore key must be inspected — the regex -consumes the underscore even when the next char is a letter, -producing TitleCased output. If the consumer wants leading -underscores preserved (e.g. for "internal" markers like -`\_provider_alias`), it has to either filter the keys before -running the rewrite (the `map_properties` top-level pattern) or -switch to a regex that anchors the underscore as a non-leading -match (`/(?<!^)_([a-z])/g`). Diagnostic: a fixture asserting -`{ \_x: 1 }`round-trip fails with`expected '\_x' to deeply equal -'X'`. Pair with `sed-greedy-dot-star-eats-chained-calls-on-one-line` -— both are about regex behaviour that looks innocuous in isolation -breaking on inputs the brief-time author didn't enumerate. - -## class-private-brand-blocks-this-as-context-passthrough - -_Discovered: 2026-04-30 by implementer in rf-sched-3_ - -When extracting class methods to standalone helpers that take -`ctx: SomeContext` as their first arg (the rf-sqlite-1 + -rf-parse-1 pattern), the obvious mid-refactor move is "pass -`this` to the helper because the class fields structurally -match `SomeContext`". TypeScript rejects that with TS2345: -`Property 'foo' is private in type 'TheClass' but not in type -'SomeContext'`. The `private` modifier is a nominal brand — -even when every field name + shape lines up, the structural -assignment fails because the helper doesn't have access to the -brand. Two fixes work: (a) cast at the call site -(`this as unknown as SomeContext`) — fast, ugly, scales -poorly; (b) lift the fields onto a real -`private readonly ctx: SomeContext` field, build the ctx in -the constructor, and pass `this.ctx` to every helper — -clean, what rf-sqlite-7 did, what rf-sched-6 did. Pattern -(a) is fine as a _temporary_ stepping-stone inside the same -PR series — extract the helpers in unit N with the cast, then -pay off the cast in unit N+M when the orchestrator slim-down -lands. Generalizes: any class decomposition that wants to -delegate to standalone helpers should plan for the `ctx` -field from unit-1 instead of trying to keep separate -`private readonly` fields and pass `this`. Diagnostic: TS2345 -naming a `private` field of the orchestrator class. Pair with -`staged-lifecycle-extraction-needs-two-modules-when-dependency-graph-is-cyclic` -— both rules are about getting the surface area right BEFORE -extracting, not retrofitting after the symptoms surface. - -_Promoted to: /docs/refactoring-patterns.md_ - -## scheduler-context-pattern-fits-mutable-state-classes-better-than-pure-helpers-classes - -_Discovered: 2026-04-30 by implementer in rf-sched-4_ - -The decision tree in the rf-sched brief said: "pure (no state -read/write) → extract; reads class state only → extract with -state arg; writes class state → likely stay on class". That -heuristic is too conservative once a `ctx: SchedulerContext` -mutable handle is on the table — the rf-sqlite shape proves -that helpers writing ctx (resources_save mutates `ctx.statements` -backing store, locks_acquire mutates the lock row, etc.) all -extract cleanly because the writes go through the ctx, not -through `this.x = ...`. So for rf-sched-4 every method that -mutated `this.in_flight`, `this.handler_in_flight`, `this.results`, -`this.records[*].terminal`, `this.hard_failed`, or `this.settle_waker` -extracted to a standalone fn taking ctx; only `run()` stayed on -the class. The orchestrator shell ended up at 164 LOC (a -constructor + a run loop) — well under the 250-350 LOC -brief target, which was set assuming some mutating methods -would stay. Generalizes: when a class has a mutable bag of -state that's already structurally describable (`SqliteContext`, -`SchedulerContext`, `ParserState`), nearly every private method -can extract regardless of whether it reads or writes that state, -because the writes are mechanically `ctx.x` instead of `this.x`. -The brief's decision tree applies to classes WITHOUT the ctx -pattern (where each writer would have to take 3-5 separate -mutable refs as args). With the ctx pattern, default to -"extract everything" and reserve the class for entry-point -orchestration only. Diagnostic: if your decomposed shell -file is over 300 LOC after extracting "pure" + "state-reading" -helpers, you're probably leaving state-writing helpers on -the class that could also extract. Pair with -`class-private-brand-blocks-this-as-context-passthrough`: -both rules push toward "lift the ctx first, decompose later". - -_Promoted to: /docs/refactoring-patterns.md_ - -## ai-ops-orchestrator-keeps-the-switch-block - -_Discovered: 2026-04-30 by implementer in rf-aiop-6_ - -The operation-executor.ts orchestrator decomposed cleanly into seven helper modules (types, id-utils, position-finder, blueprint-resolver, auto-resize, node-defaults, orphan-helpers, reparent-validator) — but the central switch-on-op-type dispatch loop stayed in the orchestrator at ~250 LOC. Each `case` arm is 5-25 LOC of (1) resolve placeholder ids through idMap, (2) check the resolved node/edge exists in the live card, (3) push to skippedOps with a reason or dispatch the matching action. Trying to extract per-op handlers (`handleAddNode`, `handleAddEdge`, etc.) into separate files would have forced each handler to take `dispatch + idMap + skippedOps + getCard()` as arguments — four mutable references per call site — and the resulting "handler" would just be the case body re-wrapped in a function, with nothing pulled out. The wins came from the _opportunistic_ extractions instead: `pickNodeDefaults` collapsed a stack of nested ternaries inside one case arm, `connectOrphanHelpers` lifted a 35-LOC post-loop block, `validateReparent` lifted a 25-LOC inline gauntlet. Generalizes: when a switch-on-tag dispatch is the central pattern of a function, the cohesion the switch creates is real — leave it intact and look for the inline math/validation INSIDE the arms (or before/after the loop) instead. The diagnostic is: if extracting an arm body would just shuffle four mutable refs across a function boundary, the arm is already correctly placed. - -## node-and-edge-method-clusters-must-land-atomically - -_Discovered: 2026-04-30 by implementer in rf-mgraph-2+3_ - -The mutable-graph decomposition's plan put nodes-helpers and edges-helpers in separate units (rf-mgraph-2 and rf-mgraph-3), but the implementations have a one-way call: `nodes_remove_node` invokes `edges_remove_edge` to maintain the "removing a node also removes incident edges" invariant. Splitting into two commits would have either (a) required a stub `edges_remove_edge` in the rf-mgraph-2 commit (extra cleanup work in rf-mgraph-3), or (b) inlined the edge-removal loop into `nodes_remove_node` and deduped it later (changing behavior temporarily, which violates "verbatim" preservation). I combined them into one commit (rf-mgraph-2+3) — the file boundaries are clean, both helpers extract cleanly, and there's no stub or temporary inline. Total commit size was 6 files / ~890 lines — well within review scope. Pair with `bootstrap-fnarg-vs-direct-import-for-circular-grammar-pair` (rf-parse-3): same prescription — when extracting two helpers with a one-way or two-way call dependency, atomic landing is the right move; trying to obey the planner's per-unit boundary creates more work than it saves. Generalizes: scan extracted-helper call graphs _before_ committing to per-unit boundaries; any cross-module call from helper-A to helper-B forces them into the same commit unless you're willing to introduce a stub or temporarily change behavior. Diagnostic at brief-reading time: if the source file's class methods invoke `this.X` from inside a method that's destined for a different file, you have a cross-file call graph; check whether the call is one-way (atomic-land) or two-way (also atomic-land + co-locate by default per `decompose-recursion-cluster-co-located`). - -## ice-api-optional-method-types-need-non-null-assert-in-tests - -_Discovered: 2026-04-30 by implementer in rf-httpapi-6_ - -The `IceAPI` type declares `subscribeDeployProgress` / `subscribePipeline` / `subscribeCardPipeline` as **optional** (`?:`) properties — the desktop adapter has `window.api.subscribeDeployProgress` available, but the original type author marked them optional because not every host (Electron preload, web HTTP, test fakes) is required to provide them. As a result, `createSubscribeDeployProgress(): IceAPI['subscribeDeployProgress']` returns `((cardId: string) => () => void) | undefined`. A test that immediately destructures and calls the result triggers TS2722 / TS18048 ("possibly undefined" / "cannot invoke") even though the runtime returns a non-undefined function. Fix: append a non-null assert at the test call site (`createSubscribeDeployProgress()!`). The test could also tighten the factory's return type to `NonNullable<IceAPI['subscribeDeployProgress']>` but that drifts from the type that's actually written into the IceAPI surface — the assert at the consumer is the smaller change. Generalizes: when a factory returns a value typed by an optional property of a contract, every consumer of that factory's return value carries the optionality unless explicitly asserted away. Diagnostic: TS18048 / TS2722 inside a test that just imported a `create<X>Adapter()` fn. Pair with `vi-fn-typing-needs-explicit-callback-signature-for-typecheck-pass`: same flavor of "test file typecheck is its own surface, fix at the consumer site". - -## fake-timers-attach-rejects-before-advance - -_Discovered: 2026-04-30 by implementer in rf-lbal-1_ - -When testing a function that polls under `vi.useFakeTimers()` and eventually throws after a timeout, the natural-feeling pattern of "kick off the promise, advance time, then `await expect(promise).rejects.toThrow()`" emits an unhandled-rejection warning during the run even though the test itself passes. The reason: the rejection is created the moment vi.advanceTimersByTimeAsync flushes the timeout branch, but at that microtask there's no .catch / await listener attached yet, so Node sees an "unhandled" rejection and fires `unhandledRejection`. Then the next microtask attaches the matcher and the warning gets demoted to `PromiseRejectionHandledWarning` — but vitest still surfaces the unhandled error in the test output. Fix: attach the matcher BEFORE advancing time. Idiomatic shape: `const expectation = expect(promise).rejects.toThrow(); await vi.advanceTimersByTimeAsync(N); await expectation;`. Generalizes: any rejects-flavored matcher against a promise whose rejection is caused by fake-timer advancement needs the listener attached prior to the advance. Synchronous rejections (e.g. throws on the first await) don't have this problem because both the promise creation and the matcher attachment happen before any microtask runs. - -## react-memo-export-with-displayname-needs-eslint-displayname-rule-bypass - -_Discovered: 2026-04-30 by implementer in rf-pbrws-2_ - -When extracting a memo-wrapped FC into its own module — `const TreeItem = memo((props) => {...})` — the eslint plugin react/display-name rule fires because anonymous arrow functions inside memo() can't be inferred. The fix is the obvious `TreeItem.displayName = 'TreeItem'` AFTER the memo() call. But the displayName assignment has to come AFTER the const declaration, not as a property of the memo() return value's argument — the latter doesn't propagate through React's memo() forwarding. Generalizes: every memo-wrapped FC export in this codebase that the rule lints needs the post-declaration displayName line. Drop-in pattern: `const X = memo((props) => {...}); X.displayName = 'X';` — three lines, lint silenced, and the React DevTools picks up the correct name in the component tree. Cite from any future rf-\* unit that extracts a memo-wrapped leaf component (project-browser/tree-item.tsx is the rf-pbrws-2 example). - -## walker-yields-fc-elements-but-cannot-descend-without-recursive-invocation - -_Discovered: 2026-04-30 by implementer in rf-pbrws-2_ - -The direct-FC tree-walker pattern walks one component's returned element tree, yielding child elements where `el.type` is anything (lucide forwardRef, mock vi.fn, another React.FC). But the walker DOES NOT invoke those child FC bodies — it only descends through `el.props.children`. So when an extracted recursive component (TreeItem renders nested TreeItems for folder children) gets tested, asserting on a grandchild's text via `collectText` fails because the inner TreeItem element is a leaf in the walker's view. The right pattern is to assert on the child element's PROPS, not its rendered output: `findAllByPredicate(tree, el => el.type === TreeItem)` and then inspect `el.props.node.id` and `el.props.level`. Generalizes: any FC test where the SUT renders the SAME extracted component recursively (or composes mocked components that wouldn't run in direct-FC invocation anyway) should pivot from text-based assertions to prop-equality assertions on the child element. Pair with the `vi-hoisted-for-stable-mock-identity-in-direct-fc-tree-walker-tests` learning: that one matches mocks by reference; this one says when the recursive structure means even the SUT itself appears as a child element, treat THAT element the same way you'd treat a mocked child — match by reference, inspect props. - -## ts2834-baseline-error-moves-with-the-import - -_Discovered: 2026-04-30 by implementer in rf-esp-4_ - -The TS2834 import-extension baseline noise (29 pre-existing errors in `@ice/core`) sits on specific dynamic / barrel imports. When you EXTRACT one of those imports into a deeper subdirectory the error doesn't disappear — it moves with the import statement. Concrete example: `embedded-schema-provider.ts(220,68)` was a TS2834 baseline error pointing at the dynamic `await import("../schemas/db")`. After rf-esp-4 pulled that dynamic import into `embedded/initialization.ts` (one directory deeper, so the path became `'../../schemas/db'`), the error moved to `embedded/initialization.ts(52,64)`. The `grep -c "TS2834"` total stayed at 29. Don't be alarmed if a refactor commit "introduces" a new TS2834 location — verify the count is unchanged via `pnpm --filter @ice/core typecheck 2>&1 | grep -c TS2834` and check the moved error matches a removed one in the original file. Generalizes to every other TS2834 site in the schema/importers/index barrels: extracting an import into a subdirectory is a relocation, not a fix, and not a regression. - -## fs-existssync-is-non-configurable-under-vitest-esm - -_Discovered: 2026-04-30 by implementer in rf-esp-4_ - -`vi.spyOn(fs, 'existsSync').mockReturnValue(...)` fails under Vitest's ESM path with `TypeError: Cannot redefine property: existsSync`. The `node:fs` module's exports come back as a frozen namespace object whose properties are non-writable + non-configurable, so neither `Object.defineProperty` nor `vi.spyOn` can replace `existsSync`. The two viable patterns: (1) drive behaviour through real fs by chdir'ing into a tmp directory you control (`fs.mkdtempSync` + `process.chdir`), or (2) wrap fs into the SUT via dependency injection so tests can pass a fake. The chdir pattern is what `rf-esp-4 initialization.test.ts` and `rf-cload-2 file-validators.test.ts` use; macOS's `/var` -> `/private/var` symlink means tests should `fs.realpathSync` both sides of any path equality. Generalizes to all node-builtin namespace imports under Vitest ESM (`fs`, `os`, `path`, `crypto`): if a test wants to mock a single function out of one of these, use a tmp dir / process.chdir / make-the-thing-real instead of trying to patch the module surface. - -## one-source-of-truth-for-types-in-shim-refactors - -_Discovered: 2026-04-30 by implementer in rf-rval-1_ - -When converting an over-500-LOC class file into a shim + helper modules (the handler-domain pattern for service files, applied here to `resource-validator.ts`), the public types (here: ValidationIssue, ValidationCode, ValidationOptions, ValidationResult, ValidationSeverity) need to live in exactly ONE place — not duplicated between the shim and a helper module. The clean structure: drop the type definitions into a sibling `<name>-types.ts` file, then have the shim do `export type { X, Y, Z } from './<name>-types.js'` for external consumers. Helpers in subdirectories then import the types via the same sibling-types module without circular-importing the orchestrator. Same approach worked for `customization-loader.ts` (CustomizationFile -> scanner.ts, CustomizationError + ValidationWarning -> file-validators.ts, CustomizationPaths -> paths.ts, all re-exported from the shim). This avoids the trap of "shim re-exports a type from helper.ts which imports the type FROM the shim" cycles. Generalizes: every multi-file decomposition where types are part of the public API needs the types' canonical home decided up front. - -## dynamic-import-indirection-blocks-test-mocks - -_Discovered: 2026-04-30 by implementer in rf-aimp-3_ - -The AWS importer wraps every `@aws-sdk/client-*` import in `Function('m', 'return import(m)')(spec)`. This pattern is load-bearing: a literal `await import(spec)` would be transpiled by some bundlers into a static `require`, breaking the optional-dep guarantee for users who never need AWS. Side effect: it ALSO bypasses Vitest's module registry — `vi.mock('@aws-sdk/client-resource-explorer-2', ...)` does nothing because the resolution path goes through `Function()` rather than the bundler-instrumented import. Practical consequence for refactor tests: don't try to stub the SDK to test the success path of `discover_with_resource_explorer` / `discover_with_config`. Instead, extract the response-shape -> AWSResource conversion into pure mappers (`map_resource_explorer_hit`, `map_config_result`) that take plain objects and return `AWSResource` directly. The discover\_\*() loops then become thin paginate-and-map shells where the mapping logic is testable without any SDK present. Same pattern will apply to any future importer that uses optional-dep dynamic imports (Azure, GCP via SDK, etc.). The same gotcha applies to `init_aws_sdk` / `get_account_id` — those tests can only verify the failure path (which is what users without the SDK installed will hit anyway), not the success path. - -## same-name-local-import-and-reexport-collision - -_Discovered: 2026-04-30 by implementer in rf-aimp-4_ - -When extracting a function from an orchestrator file but keeping a local consumer in that same file, `import { X } from './extracted.js'` (for the local consumer) plus `export { X } from './extracted.js'` (for the public surface) creates two separate name bindings — TypeScript doesn't error, but the second statement appears redundant if you skim it. Cleaner pattern: alias the local import (`import { X as X_impl } from './extracted.js'`) and use `X_impl` at the call site, then keep the unaliased re-export `export { X } from './extracted.js'` as the public surface. This makes the two roles explicit — `X_impl` is the local-call-site binding, `X` is the export. Applied here in aws-importer.ts where `import_aws_to_graph` calls `aws_result_to_graph_impl(result, name)` while the file re-exports the unaliased `aws_result_to_graph` for external consumers (the index.ts barrel). Generalizes: any post-extraction file that both consumes locally AND re-exports a name needs the alias-the-local-import discipline to keep the diff readable. - -## get-critical-path-bug-preserved-as-quirk-not-fix - -_Discovered: 2026-04-30 by implementer in rf-galg-4_ - -When extracting `get_critical_path` from `graph/algorithms.ts`, -a verbatim port of the function reveals it doesn't actually -return the critical path — it returns just the start (no-deps) -node for any DAG with `depends_on` edges. Trace: the function -walks topological order to update distances, but -`topological_sort` on a `depends_on` graph emits LEAVES first -(nodes with no outgoing depends_on edges, which means no -dependencies). For a chain `a depends_on b depends_on c`, -topo order is `[c, b, a]`. When processing b, the loop -iterates `get_incoming_edges(b)` = the edge (a,b), reads -`distances.get(a) = -Infinity` (since a hasn't been processed -yet in topo order), and the new_dist `-Infinity + 1` fails the -`> current_dist` check. The chain never propagates; only c -remains at distance 0; the "max distance" walk picks c with -distance 0; reconstruction returns `[c]` because predecessors -is empty. The fix would be to walk `get_outgoing_edges` -(dependencies of current node) and read `distances.get(target)` -which has been processed earlier in topo order. But changing -this is a public-behaviour change — anything consuming -`get_critical_path` (currently nothing in core, but possibly -external) would see different output. Decision rule for -refactor work: **document the bug, don't fix it**. If the -function is genuinely useful and the fix is wanted, it -becomes a separate ticket with its own behaviour-change PR -(and possibly a feature flag during rollout). Generalizes: -when verbatim-porting an algorithm during a refactor and a -test that asserts "this should return X" fails, first run the -PRE-extraction code with the same input — if it produces the -same wrong output, you've found a pre-existing bug. The -refactor's job is verbatim preservation, not fix; the tests -must pin the actual behaviour, not the intuitive behaviour. -Diagnostic: a critical-path test asserting `path.length === 3` -fails with `expected 1 to be 3` for a 3-node chain. Pair with -the general rule that refactors preserve behaviour byte for -byte — pre-existing bugs ARE part of the contract for the -duration of the refactor. - -_Fixed: 0c44dc2_ - -## refactor-cohort-data-table-uses-376-loc-and-stays - -_Discovered: 2026-04-30 by implementer in rf-pmap-1_ - -The `pulumi/type-mapper/data.ts` extracted in rf-pmap-1 ended -up at 376 LOC — well above the 200-500 LOC ceiling documented -in `/docs/refactoring-patterns.md`. The temptation is to split -it further (per-provider sub-files, per-category sub-files). -Don't. The data is dense (~280 lookup-table entries, one -short line per entry) with NO logic; the pattern doc explicitly -calls out "Data-heavy shim split" as the exception case where -"the giant data dict (size exception, document in file -header)" is acceptable. Splitting per-provider would add -import-orchestration complexity (a `combined-type-map.ts` -that re-merges sub-tables) without buying anything: the file -is read top-to-bottom for new provider additions, and the -section comments (`// AWS EC2`, `// AWS VPC`) already give -the in-file navigation. The split would also break the simple -"one TYPE_MAP, one PROVIDER_MAP" mental model that downstream -consumers rely on. Decision rule for similar cohorts: when -the LOC budget is dominated by data, document the size -exception in the file header and stop. The 200-500 ceiling -applies to LOGIC (functions, conditionals, loops) — pure data -gets a pass when the alternative is artificial sub-division. -Generalizes: any refactor cohort touching a "lookup table + -helpers" file should treat the table as a single export and -size-cap only the helpers. Pair with the parent pattern -doc's "Data-heavy shim split" — both rules say the same thing: -data sizes don't trigger the LOC ceiling, but logic sizes do. - -## tri-state-setter-directive-pattern-for-ref-callbacks - -_Discovered: 2026-04-30 by implementer in rf-cmove_ - -When extracting a pure runner from a hook callback that conditionally -invokes a React setter (here: `setExitingGroupId` from rf-canv-25b -`useContainerMove`), the original code path had three distinct branches: -(a) call `setExitingGroupId(null)` (no parentId), (b) call -`setExitingGroupId(parent.id)` or `setExitingGroupId(null)` (parent -found, near/far edge), (c) call NEITHER branch (parentId set but parent -missing from visibleNodes — guarded by `if (parent) {}`). Initially I -returned a single `string | null` from the helper, which collapsed -branch (c) into branch (a) — silently introducing a behavior change -(setter would fire `null` on missing-parent paths instead of skipping). -Fix: return a tagged tri-state `{ call: false } | { call: true; value: -string | null }` so the orchestrator can decide whether to invoke the -setter at all. Generalizes: any pure runner extracted from a hook that -might skip a side-effect call (vs. always firing with a default value) -should use a tagged-union return rather than a sentinel `null` — `null` -is ambiguous when null IS a valid argument to the side-effect. -Diagnostic: the original code uses `if (X) { setter(...) } else if (Y) { -setter(...) }` with NO else branch — that no-else form is the -fingerprint of a tri-state; collapsing it to `setter(extract(X, Y))` -loses branch information. Cite from any future hook-extraction unit -where a callback decides whether-to-call a setter (rf-canv-26 will -likely face this with `setDragOverGroupId`). - -## awk-line-range-delete-beats-edit-tool-for-large-function-bodies - -_Discovered: 2026-04-30 by implementer in rf-deploy2-2_ - -When extracting a 500+ LOC function body to a new module, `Edit`'s -`old_string`/`new_string` is a footgun: the body has so many internal -similarities (multiple `try/catch` blocks, multiple `await -prisma.canvasDeployment.update({...})` blocks, repeated emit-event -shapes) that constructing a unique `old_string` is fragile, and any -typo silently leaves a partial body behind. The reliable shape: write -the new file from scratch, then `awk 'NR<START || NR>END' src.ts > /tmp/x -&& mv /tmp/x src.ts` to surgically delete the line range. Follow with -a small `Edit` that inserts the re-export comment + `export { ... } -from './new-module.js'` at the now-empty seam. The line numbers come -from `grep -n '^export async function'` before the awk pass. Caveat: -re-grep after every awk delete because line numbers shift; never -batch multiple awk deletes against pre-computed numbers. Generalizes: -for any line-range surgery on big files, awk + targeted re-insert -beats Edit + giant `old_string`. Edit's strength is small targeted -swaps; large-body removal is a different shape. - -## helpers-must-own-the-assembled-array-when-data-is-split-into-multiple-files - -_Discovered: 2026-04-30 by implementer in rf-hlres-8_ - -The canonical "data-heavy shim split" pattern (scale-presets, cloud-blocks) -has ONE big data file and helpers in the public shim. But when the data -splits across N files (here: 7 categories under `high-level-resources/categories/`), -the question of "where does the assembled array live" becomes -load-bearing. Naive answer: keep `HIGH_LEVEL_CATEGORIES = [compute, database, ...]` -in the public shim, helpers in `helpers.ts` that imports the array from -the shim. That creates a `helpers → shim → helpers` cycle as soon as the -shim re-exports the helpers (which it must, for public API stability). -Right answer: the helpers file owns BOTH the assembly and the helpers -that read from it. The public shim becomes a pure re-export -(`export { HIGH_LEVEL_CATEGORIES, getAll... } from './helpers.js'`). -This works because the seven category modules have no inter-dependencies -— helpers can pull them in directly. Generalizes to any future N-way -data split where helpers iterate the union (resources, blocks, presets): -prefer "helpers.ts assembles the union and exposes both" over -"shim assembles, helpers read." The cycle isn't a TypeScript build error -(it's a runtime hazard with type-only seams) but it WILL bite anything -that does a `await import('./shim.js')` cycle-aware lookup. The shim -ends up at ~40 LOC: header docstring + `export type { ... }` + `export -{ ... }`. That's the right size for a public re-export shim. - -Tactical: when the orchestrator was 6434 LOC of inline data-array -literal, splicing each category out went smoothly with awk-based -line-range deletion (reusing the rf-deploy2-2 awk pattern): - -1. `grep -nE '^ \{$|^ \},$'` to find category-level brace pairs. -2. `awk 'NR<START || NR>END'` to delete the inline literal, - replacing it on the same pass with `awk 'NR==START {print " <name>,"}'` - so the array stays valid mid-series. -3. After each splice, the inline `behavior: '...' as NodeBehavior` - casts in the extracted file need `import type { ..., NodeBehavior }` - in the new file's prelude — leaving a `NodeBehavior` import out is - the most likely typecheck failure (TS2304: Cannot find name). -4. Re-grep brace boundaries before each next splice — the line numbers - shift after every awk pass. Never batch deletes against pre-computed - numbers (same caveat as the rf-deploy2-2 learning). - -## byte-identity-snapshot-must-be-captured-pre-refactor-not-post - -_Discovered: 2026-04-30 by implementer in rf-spr2-1_ - -When extracting a large template literal into composable section -builders (system-prompt.ts → system-prompt-sections.ts), the only way -to verify the output is byte-identical is a snapshot test — but the -snapshot must be captured BEFORE the refactor, not after. Order of -operations: (1) write the snapshot test against the unrefactored -source, (2) run vitest to write the snapshot file, (3) edit the -source, (4) re-run vitest with no `-u` flag — if the comparison -passes, byte-identity holds; if it fails, the diff tells you where -the whitespace drifted. If you reverse steps 1 and 3 the test only -proves "the post-refactor output matches itself" — useless for the -verification you actually wanted. Generalizes: any refactor where -output must remain stable (prompts, generated code, fixtures) needs a -captured snapshot before the first edit. - -## svg-canvas-orchestrator-loc-budget-flexible-with-renderctx-bundling - -_Discovered: 2026-04-30 by implementer in rf-svgcv2-4_ - -After rf-canv2 already trimmed svg-canvas to 570 LOC, four more -extractions (CanvasContent for the inner transform group, three thin -hooks for mouse routing / interactions bindings / renderCtx assembly) -brought it to 453 LOC. The CanvasContent extraction was the largest -single move (570 → 490) because the inner `<g>` body was a 110-line -JSX block; the three hooks each shaved 15-20 lines but became low-LOC -focused units. The lesson: when an orchestrator looks "done" at -500-something, consider whether the JSX body has any further -sub-trees that compose well as named components; the extracted -CanvasContent is the orchestrator's single biggest dependency, and -testing it independently with the FC-walker pattern is far cheaper -than testing the orchestrator's full render tree. - -## render-ctx-record-cast-needs-double-as-unknown-as-record - -_Discovered: 2026-04-30 by implementer in rf-svgcv2-4_ - -`as Record<string, unknown>` fails TS2352 against an interface that -has no index signature (RenderCtx in this case): "Conversion ... may -be a mistake because neither type sufficiently overlaps with the -other." The two-step cast `as unknown as Record<string, unknown>` -satisfies the strict overlap check. This is the standard escape -hatch when a test wants to assert a property is absent on a typed -return shape. Pair with `expect(x).toBeUndefined()` on the cast-read -key. - -## category-bundle-split-preserve-original-array-ordering - -_Discovered: 2026-04-30 by implementer in rf-cbdat_ - -When splitting a single ordered data array (here `BLOCK_TEMPLATES: BlockTemplate[]` 16 entries → 9 per-category files), the natural assembly is `[...frontend, ...backend, ...data, ...]` but that imposes a NEW ordering that groups all-frontend-first, then all-backend-next, etc. — discarding the original file's hand-curated palette order (static-site, scalable-backend, worker, database, redis-cache, scheduled-task, api-gateway, …). Consumers that read by traversal (here: `BLOCK_CATEGORIES.filter` does NOT depend on order, but the palette UI renders in BLOCK_TEMPLATES order, and any consumer that iterates and prints would silently change). The fix is to write an explicit assembly in the orchestrator that index-picks from each bundle: - -```ts -export const BLOCK_TEMPLATES = [ - ...FRONTEND_TEMPLATES, // static-site - BACKEND_TEMPLATES[0]!, // scalable-backend - BACKEND_TEMPLATES[1]!, // worker - DATA_TEMPLATES[0]!, // database - DATA_TEMPLATES[1]!, // redis-cache - BACKEND_TEMPLATES[2]!, // scheduled-task - // ... -]; -``` - -The non-null assertions are load-bearing — TS narrows `Array[idx]` to `T | undefined` even when the array literal length is statically known. The smoke test must pin the assembled ordering against an `EXPECTED_ORDER` array of names — without that, a future maintainer who "tidies up" the orchestrator into `[...frontend, ...backend, ...data]` would land with a green typecheck and 16 templates but a re-ordered palette. Generalizes: ANY data-array split where the original order is hand-curated needs (1) explicit index-picks in the orchestrator OR (2) a single big spread + a stable-ordering smoke test that pins names against the original sequence. Bonus: if the per-category files are also alphabetical or otherwise sorted, doc the choice in the orchestrator's header comment so the next reader doesn't "fix" it back to a category-grouped spread. - -## bugfix-commits-from-refactor-quirks-need-the-fixed-line-only-when-anchor-exists - -_Discovered: 2026-04-30 by implementer in bugfix-2/3/4_ - -The bugfix sweep brief said to append `_Fixed: <commit-sha>_` to each affected learning anchor per the CLAUDE.md "only allowed edit to past `learnings.md` anchors" rule. Reality at the time of the sweep: of the three bugs (eager `require.resolve` in `get_base_db_path`, `get_incoming_edges`-walks-source-distance in `get_critical_path`, missing-lockfiles-in-`filesToCheck` in `detectJsFramework`), only ONE had a dedicated learning anchor — the rf-galg-4 quirk note `get-critical-path-bug-preserved-as-quirk-not-fix`. The base-db `require.resolve` bug was documented INLINE in the file's header comment + the test's smoke-only justification, not in a learning anchor. The pnpm-lock bug was similarly documented inline in `framework-detection.ts`'s header. Conclusion: don't fabricate `_Fixed:_` anchors against learnings that don't exist. The brief language ("append to each affected learning anchor") presumed every bug had its own anchor; in the post-extraction state, only the ones that the original implementer chose to write up as learnings do. The header-comment-only documentation pattern is fine for "we know about this, we're preserving verbatim, see also <SUT-file>:N" — but it means the future bugfix sweep has no `_Fixed:_` line to append. Generalizes: when a refactor preserves a bug verbatim, the implementer has two options for documenting the deferral: (a) inline header comment in the SUT (cheap, lives with the code, lost to future log-only readers), or (b) learning anchor (visible in the cross-cutting state file, supports the `_Fixed:_` audit trail when the bug eventually gets fixed). Option (b) is preferable for any bug whose fix-up is a real future ticket — the audit trail matters. Don't fabricate retroactive anchors; the bugfix commit message itself is sufficient when the original learning didn't exist. diff --git a/state/blueprints/rf-canv.md b/state/blueprints/rf-canv.md deleted file mode 100644 index bdc7d85a..00000000 --- a/state/blueprints/rf-canv.md +++ /dev/null @@ -1,217 +0,0 @@ -# Blueprint — `packages/ui/src/features/canvas/components/svg-canvas.tsx` - -**Source**: 3234 LOC. **Decomposer run**: 2026-04-29. -**Public-API consumers**: top-level layout slot in App.tsx (default export of `SvgCanvas`); 11+ files import `CanvasNode`/`ViewState`/`CanvasConnection` types from this file. - -## Broker prework (cross-package candidates already extracted) - -Already in the workspace — DO NOT re-implement: `useCanvasInteractions`, `useCanvasValidation`, `useComputingFlows`, `useClipboard`, `useUndoRedo`, `useExposedServices`, `calculateZIndex`, `inspectLayout`, `generateGhostSuggestions`, `canConnect` / `validateConnection` / `wouldCreateCycle` / `inferConnectionMeta` / `CATEGORY_TO_RELATIONSHIP`, `computeCompactNodeWidth/Height` (and CD / PN sizing helpers), `expandBlueprint` / `getBlueprint`, `canContain` / `isContainer`, `isTypeVisibleAtLevel` / `isEdgeVisibleAtLevel`. - -## Modules (29 total + 1 sub-split) - -### `components/types.ts` (util, ~30 LOC, lines 172–202) - -- `CanvasNode`, `ViewState`, `CanvasConnection`. Re-export from orchestrator to keep 11+ consumers quiet. - -### `utils/container-bounds.ts` (util, ~120 LOC, lines 754–878 + 944–1050 + 1183–1252 + 1697–1751) - -- `calculateContainerBounds(...)`, `expandToFitChildren(...)`, `clampNodeToParent(...)`, `CONTAINER_HEADER_H`, `CONTAINER_PAD`. Folds 4 copy-pasted "per-edge overflow expansion" blocks. - -### `utils/node-classification.ts` (util, ~45 LOC, lines 432–434 + 1506–1515 + 1628–1635 + 2641–2672) - -- `isContainerNode`, `isVpcOrSubnet`, `isPrivateNetwork`, `isLogIceType`, `isGroupContainer`. Folds 5 duplicated `isGroup` / iceType checks. - -### `utils/canvas-node-sizing.ts` (util, ~75 LOC, lines 428–474) - -- `computeVisualNodeSize(node, hasPipelineStatus)`, `toLocalCanvasNode(reduxNode, pipelineStatus)`. Wraps the compact / custom-domain / private-network width/height dispatch. - -### `utils/folded-remap.ts` (util, ~70 LOC, lines 496–532 + 725–751) - -- `buildFoldedRemap(canvasNodes, isFoldedFn)`, `descendants(...)`, `hasCollapsedAncestor(...)`, `isNodeFolded(...)`. Pure tree walks. - -### `utils/canvas-connections.ts` (util, ~140 LOC, lines 553–614 + 2073–2134) - -- `buildVisibleConnections(...)`, `computePortMap(...)`. Bundling dedupe + side-distribution. - -### `utils/connection-preview.ts` (util, ~55 LOC, lines 2937–2973) - -- `computeConnectionPreviewPath(...)`, `pickPreviewColor(...)`. Bezier control points + hit-test color. - -### `utils/drop-target.ts` (util, ~70 LOC, lines 1551–1582 + 1602–1652 + 1827–1854 + 2229–2245) - -- `findContainerAtPosition(...)`, `findSmallestContainerHit(...)`. Folds 4 near-identical "smallest hit" loops. - -### `utils/connection-special-rules.ts` (util, ~55 LOC, lines 2264–2308) - -- `findExistingSpecialConnection(...)` — "one Source.Repository / one Config.Environment per service" rule. - -### `components/canvas-renderer/node-renderer-registry.tsx` (component-factory, ~260 LOC, lines 134–165 + 2660–2932) - -- `CONCEPT_NODE_RENDERERS`, `renderCanvasNode(props)`. The iceType→component dispatch table. - -### `components/canvas-renderer/lift-wrapper.tsx` (subcomponent, ~70 LOC, lines 2675–2743) - -- `<NodeLiftWrapper>` — entrance animation + shift-drag highlight + parent-clip wrap. - -### `components/canvas-renderer/parent-clip-defs.tsx` (subcomponent, ~35 LOC, lines 2635–2657) - -- `<ParentClipDefs>` — `<defs>` block of clipPaths + shift-drag-shadow filter. - -### `components/connection-layer.tsx` (subcomponent, ~120 LOC, lines 2572–2632 + 3019–3060) - -- `<ConnectionLayer mode="background" | "highlighted">`. Replaces both connection-layer `<g>` blocks with one switched component. - -### `components/connection-preview-overlay.tsx` (subcomponent, ~55 LOC, lines 2936–2989) - -- `<ConnectionPreviewOverlay>` — inline IIFE for the in-flight connection drag preview. - -### `components/user-traffic-overlay.tsx` (subcomponent, ~50 LOC, lines 2992–3016) - -- `<UserTrafficOverlay>` — virtual user-node + its connections. - -### `components/connection-tooltip.tsx` (subcomponent, ~150 LOC, lines 3079–3225) - -- `<ConnectionTooltip>`. Heavy inline JSX; reads 7 i18n keys. - -### `components/deploy-banner.tsx` (subcomponent, ~150 LOC, lines 230–269 + 2419–2516) - -- `<CanvasDeployBanner cardId>`. Wires its own selectors; computes `deriveRollup`/`bannerActiveNode`/`bannerPct` internally. - -### `hooks/use-canvas-viewport.ts` (hook, ~75 LOC, lines 288–343 + 1784–1792) - -- `useCanvasViewport({ paneId?, cardId? })` — pane-or-card viewport, LOD threshold, `setPaneViewport`/`setCardViewport`/`setCardViewportById`, debounce + scaleLayoutForZoom. - -### `hooks/use-canvas-resize.ts` (hook, ~30 LOC, lines 382–400) - -- `useCanvasDimensions(containerRef)` — ResizeObserver effect. - -### `hooks/use-pinned-user-node.ts` (hook, ~80 LOC, lines 626–687) - -- `usePinnedUserNode(exposedServices, allCanvasNodes)`. setState + ref + memo cluster for the virtual user-node. - -### `hooks/use-container-resizing.ts` (hook, **split into 25a + 25b**, total ~480 raw / ~250 after util extraction, lines 753–1343) - -- `useContainerResizing({ visibleNodes, canvasNodes })` returning `handleNodeMove`, `handleToggleFold`, `handleNodeResize`, `recalculateAncestorBounds`, `calculateMinimumContainerSize`. **rf-canv-25a**: handleNodeResize + ancestor-bounds. **rf-canv-25b**: handleNodeMove + handleToggleFold. - -### `hooks/use-canvas-drop.ts` (hook, ~140 LOC, lines 1856–1978 + 2026–2029) - -- `useCanvasDrop(...)` — `handleDrop` + `handleDragOver`. - -### `hooks/use-ghost-mode.ts` (hook, ~50 LOC, lines 1983–2024) - -- `useGhostMode(nodes, edges)` — accept/dismiss + 10s auto-dismiss. - -### `hooks/use-connection-drawing.ts` (hook, ~260 LOC, lines 2140–2400) - -- `useConnectionDrawing(...)` — full connection-drag flow. - -### `hooks/use-drag-target-highlight.ts` (hook, ~280 raw / shrinks with drop-target util, lines 1354–1358 + 1517–1760) - -- `useDragTargetHighlight(...)` — shift-drag highlight machinery. - -### `hooks/use-canvas-side-effects.ts` (hook, ~110 LOC, lines 345–423 + 616–624 + 1366–1380 + 1483–1503) - -- Bundles install-inspector + updateInspectorState + auto-organize + logCanvasRender + per-card-pipeline subscribe + overlay-dismiss + per-card reset effects. - -### `hooks/use-rename-state.ts` (hook, ~28 LOC, lines 1362 + 1406–1422) - -- Inline-rename triplet — borderline LOC; keep for clean ownership. - -### `hooks/use-canvas-context-menu.ts` (hook, ~20 LOC, lines 1763–1776) — fold-in candidate - -### `hooks/use-validation-map.ts` (hook, ~22 LOC, lines 1465–1480) — fold-in candidate - -### `components/svg-canvas.tsx` (orchestrator, ~300 LOC final) - -- `SvgCanvas` default export. Composes hooks + section subcomponents. - -## Dependency DAG (leaves first) - -``` -LEAVES (utils, no canvas state): - components/types.ts - utils/node-classification.ts - utils/container-bounds.ts - utils/folded-remap.ts - utils/connection-preview.ts - utils/drop-target.ts - utils/connection-special-rules.ts - -LAYER 1 (utils that depend on leaves): - utils/canvas-node-sizing.ts - utils/canvas-connections.ts - -LAYER 2 (subcomponents — leaf React): - components/canvas-renderer/parent-clip-defs.tsx - components/canvas-renderer/lift-wrapper.tsx - components/connection-tooltip.tsx - components/connection-preview-overlay.tsx - components/deploy-banner.tsx - components/user-traffic-overlay.tsx - -LAYER 3 (registry + connection-layer): - components/canvas-renderer/node-renderer-registry.tsx - components/connection-layer.tsx - -LAYER 4 (hooks — pure state/effects): - hooks/use-canvas-resize.ts - hooks/use-canvas-viewport.ts - hooks/use-rename-state.ts - hooks/use-canvas-side-effects.ts - hooks/use-pinned-user-node.ts - hooks/use-ghost-mode.ts - hooks/use-canvas-drop.ts - hooks/use-container-resizing.ts (split) - hooks/use-drag-target-highlight.ts - hooks/use-connection-drawing.ts - -ROOT: - components/svg-canvas.tsx (~300 LOC) -``` - -## Behavior-risk flags - -1. **`CanvasNode`/`ViewState`/`CanvasConnection` are public-API types** — 11+ consumer files. First unit MUST add a re-export shim at the orchestrator path. -2. **`use-container-resizing.ts`** has setState-during-drag (`setExitingGroupId` inside `handleNodeMove`) coupled to `useDragTargetHighlight`'s state. Keep `setExitingGroupId` setter passed via callback, or co-own state across the two hooks. **Sub-split into 25a + 25b**. Each of the 4 expansion loops has subtly different padding semantics — DO NOT dedup wholesale. -3. **`useConnectionDrawing` reads `card` (latest Redux) inside `handleConnectionEnd`**. Keep `card` in dep array verbatim — don't switch to a ref. -4. **`<g>` wrapping at L2624–2630 conditionally adds animation wrapper with different React keys** (`anim-edge-${id}` vs `id`). Preserve key semantics or `SvgConnectionPath` re-mounts. -5. **Connection-port detection uses `target.classList.contains('connection-port')`** at L2526–2530. The `if (port)` branch MUST stay first or drag-from-port becomes pan-from-port. -6. **Non-passive wheel listener** at L1815–1825. Keep dep array on `[bindCanvas]` not `[bindCanvas.onWheel]`. -7. **`autoOrganizeCard` import-time threshold** is `> 10`. Don't change. -8. **`setOverlayDismissed(false)` setter is read but never read back**. Don't "clean up" — leave verbatim. Tag for follow-up. -9. **Inline tooltip JSX reads 7 i18n keys** — preserve order (E2E may snapshot-test). -10. **`SvgUserNode` `onPositionChange={setUserNodePos}`** writes to canvas's `userNodePos` state read by memo. Hook must return both setter and derived nodes. -11. **`CONCEPT_NODE_RENDERERS` dispatch** has 3 branches with subtly different gates. Tests MUST cover both `type:'block'` AND `type:'resource'` for the same iceType. - -## Unit ordering - -1. **rf-canv-1** — `components/types.ts` + re-export shim. -2. **rf-canv-2** — `utils/node-classification.ts`. -3. **rf-canv-3** — `utils/folded-remap.ts`. -4. **rf-canv-4** — `utils/container-bounds.ts`. -5. **rf-canv-5** — `utils/canvas-node-sizing.ts`. -6. **rf-canv-6** — `utils/drop-target.ts`. -7. **rf-canv-7** — `utils/connection-special-rules.ts`. -8. **rf-canv-8** — `utils/connection-preview.ts`. -9. **rf-canv-9** — `utils/canvas-connections.ts`. -10. **rf-canv-10** — `components/canvas-renderer/lift-wrapper.tsx`. -11. **rf-canv-11** — `components/canvas-renderer/parent-clip-defs.tsx`. -12. **rf-canv-12** — `components/canvas-renderer/node-renderer-registry.tsx` (RISK #11). -13. **rf-canv-13** — `components/connection-layer.tsx` (RISK #4). -14. **rf-canv-14** — `components/connection-preview-overlay.tsx`. -15. **rf-canv-15** — `components/user-traffic-overlay.tsx`. -16. **rf-canv-16** — `components/connection-tooltip.tsx` (RISK #9). -17. **rf-canv-17** — `components/deploy-banner.tsx`. -18. **rf-canv-18** — `hooks/use-canvas-resize.ts`. -19. **rf-canv-19** — `hooks/use-canvas-viewport.ts`. -20. **rf-canv-20** — `hooks/use-rename-state.ts`. -21. **rf-canv-21** — `hooks/use-pinned-user-node.ts` (RISK #10). -22. **rf-canv-22** — `hooks/use-canvas-side-effects.ts`. -23. **rf-canv-23** — `hooks/use-ghost-mode.ts`. -24. **rf-canv-24** — `hooks/use-canvas-drop.ts`. -25. **rf-canv-25a** — `hooks/use-container-resizing.ts` part-1 (RISK #2). -26. **rf-canv-25b** — `hooks/use-container-resizing.ts` part-2 (RISK #2). -27. **rf-canv-26** — `hooks/use-drag-target-highlight.ts`. -28. **rf-canv-27** — `hooks/use-connection-drawing.ts` (RISK #3, #5). -29. **rf-canv-28** — orchestrator slim-down to ~300 LOC. -30. **rf-canv-29** — final shim-drop / housekeeping. diff --git a/state/blueprints/rf-cards.md b/state/blueprints/rf-cards.md deleted file mode 100644 index b4b4231e..00000000 --- a/state/blueprints/rf-cards.md +++ /dev/null @@ -1,112 +0,0 @@ -# Blueprint — `packages/ui/src/store/slices/cards-slice.ts` - -**Source**: 1195 LOC. **Decomposer run**: 2026-04-30. -**Public API**: 25 action creators, 1 default reducer export, 3 selectors, 5 exported types, 1 exported helper function — see table at end. - -## Modules (16 units) - -### Layer 0 — pure utilities (no Redux imports) - -- **rf-cards-1** `cards/types.ts` (~60 LOC, L22–81) — `CardNode`, `CardEdge`, `CardViewport`, `Card`, `CardsState` (exported); `CardSnapshot`, `CardHistory`, `DEFAULT_VIEWPORT` (module-private). No imports beyond TypeScript primitives. Orchestrator re-exports all five types verbatim so the 12+ consumers importing them from `'../cards-slice'` continue to resolve. - -- **rf-cards-2** `cards/migration.ts` (~50 LOC, L104–148) — `BLOCK_TO_GROUP_TYPES` (private Set), `migrateCardNode` (private), `migrateCardNodes` (exported). Imports `CardNode` from `./types`. Orchestrator re-exports `migrateCardNodes` to preserve its external export path. The two migration branches run in fixed order (`Monitoring.Terminal` first, then `Cluster.*/Block.*`); that order must be preserved. - -- **rf-cards-3** `cards/edge-routes.ts` (~65 LOC, L280–343) — `invalidateEdgeRoutesTouching`, `applyEdgeRoutes`, `cascadeContainerReflow` (dead code but retained). Imports `CardEdge` from `./types`. The `eslint-disable-next-line unused-imports/no-unused-vars` comment on L299 must move to the line immediately before `cascadeContainerReflow` in the new file. - -### Layer 1 — slice-internal helpers (use types; no Redux imports) - -- **rf-cards-4** `cards/persistence.ts` (~80 LOC, L84–200) — `CARDS_STORAGE_KEY`, `CARDS_DATA_VERSION`, `CARDS_VERSION_KEY` (private), `loadPersistedCards` (private). Imports `CardNode`, `CardsState` from `./types`; `migrateCardNodes` from `./migration`. Both `localStorage.setItem` write paths are wrapped in separate try/catch blocks — both wrappers must be preserved. The `parsed.activeCardId === 'demo'` guard on L180 must survive the move. - -- **rf-cards-5** `cards/snapshot.ts` (~55 LOC, L202–260) — `MAX_HISTORY` (private), `_lastSnapshotAction` (module-level `let`), `COALESCE_ACTIONS` (private Set), `pushSnapshot` (private function exported for reducer modules). Imports `CardsState` from `./types`. `_lastSnapshotAction` is a module-level singleton — its coalescing behavior is preserved as long as it stays at module scope (not inside a factory or class). - -### Layer 2 — reducer groups - -Each file exports a plain object of RTK-compatible case-reducer functions, spread into `createSlice`'s `reducers` in the orchestrator. This keeps all action type strings owned by the single `createSlice` call and avoids action-creator re-export complexity. - -- **rf-cards-6** `cards/reducers/card-lifecycle.ts` (~75 LOC, L354–412 + L766–780) — `setActiveCard`, `createCard`, `deleteCard`, `renameCard`, `setCardViewport`, `setCardViewportById`. Viewport reducers are co-located here because they operate on the `Card` object (not nodes/edges). The unique-name loop in `createCard` reads `state.cards` inside an Immer draft but does not mutate during the loop — safe. - -- **rf-cards-7** `cards/reducers/node-edge-add.ts` (~100 LOC, L414–510) — `addNodeToCard`, `addEdgeToCard`, `clearCardDeployOverlay`, `updateCardEdgeData`, `reverseCardEdge`. The 20-field `fieldsToClear` array in `clearCardDeployOverlay` (L446–471) must be preserved verbatim. The spread-and-delete Immer pattern (`const next = { ...node.data }; delete next[key]; node.data = next`) is correct; a direct `delete` on the Proxy would be flagged by strict mode. - -- **rf-cards-8** `cards/reducers/node-position.ts` (~100 LOC, L512–603) — `updateCardNodePosition`, `updateCardNodePositions`, `resizeCardNode`. Imports `pushSnapshot` from `../snapshot`, `invalidateEdgeRoutesTouching` from `../edge-routes`, `CONTAINER_PADDING` / `HEADER_HEIGHT` from canvas-constants. The two-pass design in `updateCardNodePositions` (apply all, then clamp) must remain in one reducer; the `skipClamp` flag bypasses pass 2. `resizeCardNode` intentionally does NOT call `invalidateEdgeRoutesTouching`. - -- **rf-cards-9** `cards/reducers/node-data.ts` (~45 LOC, L604–643) — `toggleCardNodeFold`, `updateCardNodeParent`, `updateCardNodeData`. `toggleCardNodeFold` has no `pushSnapshot` call by design (fold is not undoable). `updateCardNodeParent` uses `delete node.parentId` (not `node.parentId = undefined`) — this must be preserved; RTK Immer serializes absent fields differently from `undefined`. - -- **rf-cards-10** `cards/reducers/node-delete-merge.ts` (~75 LOC, L644–664 + L732–764) — `deleteCardNode`, `deleteCardEdge`, `addToActiveCard`. `deleteCardNode` reassigns both `card.nodes` and `card.edges` on the Immer draft in one reducer body — this must stay in one function. `addToActiveCard` calls `migrateCardNodes` before the offset transform (L753) — migration runs first, then offset. - -- **rf-cards-11** `cards/reducers/import.ts` (~75 LOC, L664–730) — `importToActiveCard`. Own file due to embedded `autoLayout` call and two-phase mutation (replace nodes/edges, then apply edge routes). `applyEdgeRoutes` (L727) must run after `card.nodes` is reassigned (L714–725), not before. - -- **rf-cards-12** `cards/reducers/auto-organize.ts` (~205 LOC, L782–984) — `autoOrganizeCard`. Largest single reducer. The centroid-stabilize block (L938–965) shifts `edgeRoutes` by `(dx, dy)` before `applyEdgeRoutes` is called — this order is load-bearing. The `cascadeContainerReflow`/`forceResolveOverlaps` intentionally-skipped comment at L968 is operational documentation and must be kept. - -- **rf-cards-13** `cards/reducers/scale-blueprint.ts` (~65 LOC, L986–1048) — `scaleLayoutForZoom`, `expandBlueprintToCard`. `scaleX = 1` / `scaleY = 1` in `scaleLayoutForZoom` is deliberate — do not change to `zoom / prevZoom`. `expandBlueprintToCard` calls `migrateCardNode` for ingestion-path parity. - -- **rf-cards-14** `cards/reducers/undo-redo-group.ts` (~95 LOC, L1050–1141) — `undoCardChange`, `redoCardChange`, `groupSelectedNodes`. Undo/redo use `JSON.parse(JSON.stringify(...))` for deep clone inside Immer — must not be replaced with `structuredClone` or `current()`. In `groupSelectedNodes`, `card.nodes.push(groupNode)` happens before the `node.parentId` assignment loop — the group appears last in the array (renders behind children due to Z-order). - -### Final - -- **rf-cards-15** orchestrator slim-down (`cards-slice.ts` → ~300 LOC) — `import` all 9 reducer modules; spread into `createSlice`'s `reducers`; keep `initialState` assembly (`loadPersistedCards()` + `history: {}`), all re-export shims for the 5 types + `migrateCardNodes`, all 25 action-creator named exports, `export default cardsSlice.reducer`, and the 3 inline selector arrows. - -- **rf-cards-16** final housekeeping — `pnpm --filter @ice/ui typecheck`; confirm all named imports from `'../cards-slice'` resolve; remove any dead imports from the orchestrator's import block. - -## Behavior-risk flags (11 total) - -1. **Immer two-field mutation in `deleteCardNode`**: Both `card.nodes` and `card.edges` must be assigned on the same Immer draft inside one reducer body. Splitting into two dispatched actions would create a visible intermediate state on the canvas. - -2. **Two-pass position update in `updateCardNodePositions`**: Pass 1 applies all positions; pass 2 clamps children. The passes are sequential on one draft. Splitting into two dispatched actions produces a visual flash. The `skipClamp` flag skips pass 2 only — it does not affect pass 1. - -3. **`applyEdgeRoutes` ordering in `importToActiveCard`**: Must run after `card.nodes` remapping (L714–725), not before. Edge route coordinates are computed relative to the post-layout node positions. - -4. **`applyEdgeRoutes` ordering in `autoOrganizeCard`**: Must run after the centroid-stabilize `(dx, dy)` shift is applied to `edgeRoutes` (L956–963). Reversing the order misaligns routes and nodes. - -5. **`_lastSnapshotAction` coalescing state**: Module-level `let` in `cards/snapshot.ts`. Coalescing only works if the variable is a singleton across all calls in one event loop tick. Must remain at module scope, not inside a factory. - -6. **`cascadeContainerReflow` dead-code eslint-disable**: The `// eslint-disable-next-line unused-imports/no-unused-vars` comment must appear on the line immediately preceding the function in `edge-routes.ts`. The function is kept intentionally. - -7. **`clearCardDeployOverlay` field list completeness**: The 20 fields at L446–471 mirror what the deploy hydrator sets. Missing one leaves a ghost pill after destroy. Do not prune any field, including `public_grant_failed` / `public_grant_error` / `public_grant_strategy`. - -8. **Ingestion-path migration parity**: Four reducers call `migrateCardNode` or `migrateCardNodes` (`addNodeToCard`, `importToActiveCard`, `addToActiveCard`, `expandBlueprintToCard`). Any new ingestion reducer must also call the migrator. See learning `data-version-bump-migrates-not-wipes`. - -9. **`groupSelectedNodes` node insertion order**: `card.nodes.push(groupNode)` before the `parentId` loop; the group's Z-order (last in array = renders behind children) depends on it appearing after the selected nodes. - -10. **`scaleLayoutForZoom` intentional `scaleX/Y = 1`**: Do not replace with `zoom / prevZoom`. The centroid math still runs at scale=1, producing identity transforms for positions and sizes. - -11. **Selectors stay non-memoized in the orchestrator**: `selectActiveCard`, `selectCanUndo`, `selectCanRedo` are plain arrows. They must not be wrapped in `createSelector` during extraction — doing so would return new selector instances on each module load, breaking referential equality for consumers that pass the selector to `useSelector`. - -## Public API - -| Export | Kind | External consumers | -| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `CardsState` | type | `store/index.ts` (via `RootState`) | -| `Card` | type | `use-deploy-effects.ts`, `use-deploy-actions.ts`, `use-destroy-action.ts`, `use-canvas-side-effects.ts`, `project-overview.tsx` | -| `CardNode` | type | `use-canvas-drop.ts`, `use-container-move.ts`, `use-canvas-side-effects.ts`, `svg-canvas.tsx`, `use-clipboard.ts`, and 7+ others | -| `CardEdge` | type | `svg-canvas.tsx`, `use-canvas-drop.ts`, `use-connection-drawing.ts`, `use-canvas-side-effects.ts`, `edge-properties-section.tsx` | -| `CardViewport` | type | `use-canvas-viewport.ts` | -| `migrateCardNodes` | function | Internal ingestion paths; re-export shim required | -| `selectActiveCard` | selector | `svg-canvas.tsx`, `deploy-panel.tsx`, `properties-panel.tsx`, `canvas-context-menu.tsx`, `use-canvas-viewport.ts` | -| `selectCanUndo` | selector | `canvas-menu.tsx` | -| `selectCanRedo` | selector | `canvas-menu.tsx` | -| `addNodeToCard` | action | `use-canvas-drop.ts`, `use-clipboard.ts` | -| `addEdgeToCard` | action | `use-connection-drawing.ts`, `use-clipboard.ts` | -| `clearCardDeployOverlay` | action | `use-destroy-action.ts` | -| `updateCardEdgeData` | action | `edge-properties-section.tsx` | -| `reverseCardEdge` | action | `edge-menu.tsx` | -| `updateCardNodePosition` | action | `use-container-resize.ts` | -| `updateCardNodePositions` | action | `use-container-move.ts` | -| `resizeCardNode` | action | `use-container-resize.ts`, `use-container-move.ts` | -| `toggleCardNodeFold` | action | `use-container-move.ts` | -| `updateCardNodeParent` | action | `svg-canvas.tsx` (drag-end reparent) | -| `updateCardNodeData` | action | `node-properties-section.tsx`, `edge-properties-section.tsx` | -| `deleteCardNode` | action | `svg-canvas.tsx`, `node-menu.tsx`, `use-clipboard.ts` | -| `deleteCardEdge` | action | `svg-canvas.tsx`, `edge-menu.tsx`, `edge-properties-section.tsx` | -| `importToActiveCard` | action | `canvas-context-menu.tsx` | -| `addToActiveCard` | action | AI/cloud import flows | -| `setCardViewport` | action | `use-canvas-viewport.ts` | -| `setCardViewportById` | action | `use-canvas-viewport.ts` | -| `autoOrganizeCard` | action | `use-canvas-side-effects.ts`, `node-menu.tsx`, `canvas-menu.tsx` | -| `scaleLayoutForZoom` | action | `use-canvas-viewport.ts` | -| `expandBlueprintToCard` | action | `use-canvas-drop.ts`, `canvas-context-menu.tsx` | -| `undoCardChange` | action | `use-undo-redo.ts` | -| `redoCardChange` | action | `use-undo-redo.ts` | -| `groupSelectedNodes` | action | `node-menu.tsx`, `use-clipboard.ts` | -| `default` (reducer) | reducer | `store/index.ts` as `cards: cardsReducer` | - -Re-export shims needed in the orchestrator: `export type { CardNode, CardEdge, CardViewport, Card, CardsState } from './cards/types'` and `export { migrateCardNodes } from './cards/migration'`. All 25 action creators are generated by the single `createSlice` call that stays in the orchestrator — no action-creator shims required. diff --git a/state/blueprints/rf-cstor.md b/state/blueprints/rf-cstor.md deleted file mode 100644 index a1ca2009..00000000 --- a/state/blueprints/rf-cstor.md +++ /dev/null @@ -1,68 +0,0 @@ -# Blueprint — `packages/core/src/deploy/providers/gcp/handlers/cloud-storage.ts` - -**Source**: 856 LOC. **Decomposer run**: 2026-04-30. -**Public API**: `cloud_storage_handler: GCPResourceHandler` (consumed by `gcp-deployer.ts` L17). Not re-exported through any `index.ts`. - -## Modules (8 units) - -### Layer 0 — pure utils - -- **rf-cstor-1** `cloud-storage/result-helpers.ts` (~50 LOC, L11–43) — `result()`, `fail()`, `TYPE = 'gcp.storage.bucket'`. Pattern-identical to `firebase-hosting/result-helpers.ts`; do NOT merge — separate resource types, separate TYPE constants. Imports `ResourceDeployResult` from `'../../../../types.js'`. - -- **rf-cstor-2** `cloud-storage/bucket-utils.ts` (~60 LOC) — `placeholderIndexHtml(bucketName)` (RISK #1 — call-time `new Date().toISOString()`); `placeholderNotFoundHtml(bucketName)`; `resolveOutputUrl(publicAccess, grantFailed, name, indexPage)`. Pure helpers used by both create and update. Extracted from create L341–392 / L442–459 and update L686–730 / L766–774. - -### Layer 1 — bucket creation + adoption - -- **rf-cstor-3** `cloud-storage/bucket-creator.ts` (~150 LOC, L58–190) — **HIGHEST-RISK UNIT.** `createOrAdoptBucket(storage, name, createOptions, publicAccess, ctx): Promise<{ ublaForcedOn: boolean; bucketAlreadyExisted: boolean }>`. Two-tier creation retry: optimistic UBLA-off + ACL → on UBLA constraint, retry with UBLA-on; on "already exists" (409 or message probe), adopt path with metadata fetch + UBLA-disable attempt. RISK #2 (UBLA retry inner "already exists" guard 3 conditions). RISK #3 (adopted-bucket UBLA-disable must re-throw non-UBLA errors). - -### Layer 2 — public-access grant (shared between create and update) - -- **rf-cstor-4** `cloud-storage/public-access-granter.ts` (~170 LOC, create L192–325 / update L509–678) — `grantPublicAccess(bucket, name, ublaForcedOn, ctx, opts: { verifyAfterWrite: boolean }): Promise<{ strategy, failed, error, warnings }>`. Single implementation of IAM → legacy-ACL fallback used by both methods. Update calls with `verifyAfterWrite: true`; create with `false` (asymmetry preserved per source). RISK #4 (IAM merge not replace). RISK #5 (UBLA-forced + IAM-blocked dual-block short-circuits ACL). RISK #6 (ACL dual calls: `acl.default.add` + `acl.add` best-effort). RISK #7 (request policy version 3, write back with original version). - -### Layer 3 — placeholder upload - -- **rf-cstor-5** `cloud-storage/placeholder-uploader.ts` (~90 LOC, create L338–430 / update L685–764) — `uploadPlaceholders(bucket, name, publicAccess, ublaForcedOn, publicGrantStrategy, bucketAlreadyExisted, ctx): Promise<string[]>`. Skip-if-exists for index.html + 404.html (RISK #8). `predefinedAcl: 'publicRead'` only when `publicAccess && !ublaForcedOn`. ACL backfill on existing files when `bucketAlreadyExisted && publicAccess && publicGrantStrategy === 'legacy-acl'`. - -### Layer 4 — update simple-properties - -- **rf-cstor-6** `cloud-storage/bucket-updater.ts` (~40 LOC, L489–499) — `applySimpleProperties(bucket, properties): Promise<void>`. Labels + lifecycle + versioning patches; intentionally narrow. - -### Final - -- **rf-cstor-7** Orchestrator slim-down to ~240 LOC. `cloud-storage.ts` retains: imports, `create()` body (thin coordinator: resolve → buildCreateOptions inline → createOrAdoptBucket → grantPublicAccess → uploadPlaceholders → assemble), `update()` body (similar with `applySimpleProperties` first + `verifyAfterWrite: true`), `delete()` verbatim (22 LOC), `describe()` verbatim (25 LOC). - -- **rf-cstor-8** Final housekeeping. Add `cloud-storage/index.ts` barrel re-exporting `cloud_storage_handler` so `gcp-deployer.ts` import path is unchanged. Verify typecheck + coverage. - -## Behavior-risk flags (8 total) - -1. **`new Date().toISOString()` in placeholder HTML** — call-time eval; don't memoize. -2. **UBLA retry inner "already exists" guard** — must check 3 conditions (`'you already own it'` / `'already own this bucket'` / `.code === 409`). Missing one bubbles a real 409 unhandled. -3. **Adopted-bucket UBLA-disable re-throw on non-UBLA errors** — catch branch only sets `ublaForcedOn = true` when error includes UBLA constraint string. -4. **IAM policy merge, not replace** — `setPolicy` replaces; must fetch + find-or-insert `roles/storage.objectViewer` + push `allUsers` + write with original etag + version. -5. **UBLA-forced + IAM-blocked dual block short-circuits ACL** — when `ublaForcedOn` true, do NOT attempt `bucket.acl.default.add`; set `failed = true` immediately. -6. **ACL dual calls** — `bucket.acl.default.add` (default-object ACL) AND `bucket.acl.add(...).catch(() => undefined)` (bucket-level, best-effort) both required. -7. **`verifyAfterWrite` asymmetry** — update passes `true` (re-fetches policy post-write to detect silent org-policy stripping); create passes `false`. Adding verify to create changes behavior. -8. **Placeholder skip-if-exists independent guards** — `index.html` and `404.html` exists() each wrapped in `.catch(() => [false])`; skips are independent. - -## Public API - -| Export | Kind | Consumed by | Notes | -| ----------------------- | -------------------------- | --------------------- | ------------------------------------------------------------ | -| `cloud_storage_handler` | `GCPResourceHandler` const | `gcp-deployer.ts` L17 | Direct named import; not re-exported through any `index.ts`. | - -No re-export shims required at package level. The barrel `cloud-storage/index.ts` (rf-cstor-8) resolves the import path at handlers directory level, keeping `gcp-deployer.ts` unchanged. - -## Sub-module directory layout - -``` -handlers/ - cloud-storage.ts ← orchestrator (~240 LOC after rf-cstor-7) - cloud-storage/ - result-helpers.ts ← rf-cstor-1 - bucket-utils.ts ← rf-cstor-2 - bucket-creator.ts ← rf-cstor-3 ★ highest-risk - public-access-granter.ts ← rf-cstor-4 - placeholder-uploader.ts ← rf-cstor-5 - bucket-updater.ts ← rf-cstor-6 - index.ts ← rf-cstor-8 (barrel) -``` diff --git a/state/blueprints/rf-ctrans.md b/state/blueprints/rf-ctrans.md deleted file mode 100644 index 30d84f44..00000000 --- a/state/blueprints/rf-ctrans.md +++ /dev/null @@ -1,79 +0,0 @@ -# Blueprint — `packages/core/src/deploy/card-translator.ts` - -**Source**: 1585 LOC. **Decomposer run**: 2026-04-30. -**Public API**: `translate_card_to_graph` (function) + 7 exported types. Re-exported through `packages/core/src/deploy/index.ts` L39–48. Runtime callers: `services/deploy/src/services/deploy.service.ts` L75 (`planDeployment`) and L291 (`applyDeployment`). - -## Modules (13 units) - -### Layer 0 — string/name utils - -- **rf-ctrans-1** `utils/name-utils.ts` (~70 LOC, L1518–1585) — `sanitize_name`, `sanitize_label_value`, `parse_storage_gb`, `normalize_runtime`. Pure string transformers; no intra-package imports. Deepest leaves. - -- **rf-ctrans-2** `utils/stable-name.ts` (~35 LOC, L765–795) — `ENV_SHORT` const, `generate_stable_name(resource_type, node_id, project_name, environment)`. Depends on `sanitize_name` (rf-ctrans-1) and Node's `createHash`. **RISK #1**. - -### Layer 1 — provider type maps + edge helpers - -- **rf-ctrans-3** `type-maps.ts` (~135 LOC, L98–223 + L1489–1500 + L313) — `GCP_TYPE_MAP`, `AWS_TYPE_MAP`, `AZURE_TYPE_MAP`, `get_type_map(provider)`, `DESIGN_ONLY_PROVIDERS`. The `get_type_map` helper at L1489 is a pure dispatcher over these three maps and belongs here. No runtime deps. - -- **rf-ctrans-4** `edge-classifier.ts` (~60 LOC, L225–313 excl. `DESIGN_ONLY_PROVIDERS` + L1502–1517) — `UI_ONLY_TYPES`, `EXTERNAL_TYPES`, `SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS`, `hasPrivateNetworkAncestor`, `isCustomDomainStandalone`, `map_edge_relationship`. Bundles all "is this node/edge deployable?" predicates. `map_edge_relationship` moves here from L1502–1517. **RISK #2**. - -### Layer 1 — property extractors - -- **rf-ctrans-5** `extractors/compute.ts` (~90 LOC, L319–404) — `extract_cloud_run_properties`, `extract_cloud_run_job_properties`, `extract_cloud_functions_properties`, `extract_cloud_scheduler_properties`. Uses `normalize_runtime` from rf-ctrans-1. - -- **rf-ctrans-6** `extractors/database.ts` (~100 LOC, L351–372 + L433–480) — `extract_cloud_sql_properties` (uses `parse_storage_gb`), `extract_firestore_properties`, `REDIS_SIZE_MAP`, `REDIS_VALID_TIERS`, `extract_memorystore_properties`. **RISK #3**. - -- **rf-ctrans-7** `extractors/network.ts` (~115 LOC, L406–424 + L504–600) — `extract_storage_bucket_properties`, `extract_pubsub_properties`, `extract_api_gateway_properties`, `extract_load_balancer_properties`, `extract_vpc_properties`, `extract_subnet_properties`, `extract_cloud_armor_properties`. This module imports `createHash` from `'crypto'` for the CIDR auto-allocation in `extract_subnet_properties`. **RISK #4**. - -- **rf-ctrans-8** `extractors/ancillary.ts` (~115 LOC, L482–494 + L526–531 + L602–693) — `extract_secret_manager_properties`, `extract_identity_platform_properties`, `extract_bigquery_properties`, `extract_logging_properties`, `extract_vertex_ai_properties`, `extract_dataflow_properties`, `extract_discovery_engine_properties`, `extract_gke_properties`, `extract_domain_mapping_properties`, `extract_custom_domain_properties`, `extract_backend_bucket_properties`, `extract_firebase_hosting_properties`. No shared deps between these functions. - -- **rf-ctrans-9** `extractors/dispatch.ts` (~45 LOC, L699–731) — `PROPERTY_EXTRACTORS` dispatch table. Imports all extractor functions from rf-ctrans-5 through rf-ctrans-8. The orchestrator imports only this module, not the four extractor modules individually. - -### Layer 2 — translator pass helpers - -- **rf-ctrans-10** `passes/pass-1-4-repo-wiring.ts` (~65 LOC, L1029–1086) — `wire_source_repositories(edges, nodes, card_id_to_name, graph): void`. Extracts Pass 1.4. Mutates graph node properties in-place. **RISK #5**. - -- **rf-ctrans-11** `passes/pass-1-45-domain-propagation.ts` (~70 LOC, L1088–1151) — `propagate_custom_domain_hosts(edges, nodes, card_id_to_name, graph): void`. Extracts Pass 1.45. Same mutation contract as rf-ctrans-10. **RISK #6**. - -- **rf-ctrans-12** `passes/pass-1-5-endpoint-wiring.ts` (~310 LOC, L1153–1457) — `wire_public_endpoints({ edges, nodes, card_id_to_name, graph, deployables, warnings, projectName }): { deployable_count_delta: number }`. Extracts Pass 1.5. Contains the `BackendEntry` local type (promoted to module-level), the `SERVICE_BACKEND_ICE_TYPES` inner Set (stays local), synthetic SSL cert injection, forwarding-rule removal, and host-rules patching. `staticSiteToForwardingRule` map is local to this module. **RISK #7, #8** — highest-risk unit. - -### Final - -- **rf-ctrans-13** orchestrator slim-down to ~300 LOC. `card-translator.ts` retains: all 8 exported type definitions (L1–96), `translate_card_to_graph` reduced to: Pass 1 node loop (skip checks → type lookup → extractor dispatch → private-network ingress override → stable-name + label merge → `graph.add_node` + collision retry), calls to `wire_source_repositories`, `propagate_custom_domain_hosts`, `wire_public_endpoints`, Pass 2 edge loop, and the return statement. No re-export shims needed — `index.ts` continues to re-export by name from `./card-translator.js`. - -## Behavior-risk flags (9 total) - -1. **generate_stable_name hash seed**: Seed string `"${project_name}::${environment}::${node_id}"` at L781 (double-colon delimiters, exact field order) is the identity anchor for all deployed resources. Any change triggers destroy-recreate on every existing deployment. Preserve verbatim including delimiters. - -2. **map_edge_relationship default branch**: Returns `'connects_to'` for unknown/undefined relationship strings (L1516). This is not a throw; it is the resolved value for every unannotated edge. Preserve the `default: return 'connects_to'` branch verbatim. - -3. **REDIS_SIZE_MAP tier strings + REDIS_VALID_TIERS guard**: `'BASIC'` and `'STANDARD_HA'` at L449-455 are passed directly to the Memorystore API. `REDIS_VALID_TIERS` guards the `literalTier` fallback path and must stay co-located. These constants replaced a class of 400 errors from sentinel labels like `'small'`; the guard ensures those sentinels are still dropped. - -4. **extract_subnet_properties hash-CIDR allocation**: `createHash('sha256').update(node_id)` at L572, x-octet clamping `(hash[0] % 127) + 1`, y-octet `hash[1]`. Any arithmetic change shifts auto-allocated subnets on existing deployments, requiring recreation. Preserve the hash read and modulus arithmetic verbatim. - -5. **Pass 1.4 unconditional overwrite semantics**: The condition at L1081 is `if (value !== undefined && value !== '')` — unconditional overwrite, not "only if target is empty". This was an intentional fix (L1075-1078 comment). Any refactor that changes to `if (!targetProps[to])` reverts the fix. - -6. **Pass 1.45 subdomain resolution priority order**: routeId lookup → edge.data.subdomain → blank. The `if (routeId)` branch at L1139 must remain the primary path; the `else` branch is legacy back-compat. Swapping breaks existing edges created before routes existed. - -7. **Pass 1.5 triple-mutation on forwarding-rule removal**: `graph.remove_node` + `deployables.splice(idx, 1)` + `deployable_count--` at L1376-1385 must all execute together on the same code path. Partial removal causes the service to upsert a resource mapping for a node that was never deployed. - -8. **Pass 1.5 BackendEntry.sourceServiceName post-push mutation**: `be.sourceServiceName = be.targetResourceName` at L1343 mutates an already-pushed entry. The read site (outer loop, L1397) sees the mutated value. If refactored into an immutable builder, verify the read site observes the complete entry. - -9. **sanitize_label_value empty-string fallback**: The `cleaned || 'unknown'` guard at L1545 covers inputs that sanitize to an empty string (e.g. `"---"`). The fallback value `'unknown'` appears in every deployed resource's GCP labels. Changing it to any other string shifts labels on all future resources, breaking `--filter="labels.ice-source-id=unknown"` queries on pre-existing resources. - -## Public API - -Exported from `packages/core/src/deploy/card-translator.ts` and re-exported verbatim in `packages/core/src/deploy/index.ts` L39–48: - -| Export | Kind | External consumers | -| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `translate_card_to_graph` | function | `deploy.service.ts` L75, L291 | -| `CardTranslationInput` | type | `deploy.service.ts` (call-site shape) | -| `CardTranslationResult` | type | `deploy.service.ts` L102, L311 | -| `CardNodeInput` | type | `deploy.service.ts` (implicit via input mapping) | -| `CardEdgeInput` | type | `deploy.service.ts` (implicit via input mapping) | -| `DeployProvider` | type | `deploy.service.ts` options | -| `SkippedNode` | type | `deploy.service.ts` L329 | -| `DeployableNodeInfo` | type | `deploy.service.ts` L488 (via `translation.deployables`) — **not in current `index.ts` re-export list**; add it if any consumer imports it directly | - -No re-export shims are needed. The orchestrator file continues to own all 8 exports; the extracted helper modules are internal to `packages/core/src/deploy/`. diff --git a/state/blueprints/rf-deploy.md b/state/blueprints/rf-deploy.md deleted file mode 100644 index 350d7d6d..00000000 --- a/state/blueprints/rf-deploy.md +++ /dev/null @@ -1,174 +0,0 @@ -# Blueprint — `services/deploy/src/services/deploy.service.ts` - -**Source**: 2843 LOC. **Decomposer run**: 2026-04-29. -**Public-API consumers** (must keep working): `services/deploy/src/index.ts`, `services/deploy/src/routes/canvas-deploy.ts`, `services/deploy/src/services/queue.service.ts`, `services/deploy/src/services/google-verification.service.ts`. -**Existing tests**: `services/deploy/src/__tests__/{deploy-event-translation,drift-detection,rollback,build-validation}.test.ts`. - -`deploy.service.ts` keeps its 13 public exports — the file becomes a thin orchestrator that imports the modules below. All extractions are code-shape only. - -## Modules - -### `services/deploy/src/services/snapshot-persister.ts` (service-helper, ~70 LOC, lines 47–102) - -- `installSnapshotPersister(): void` -- `flushSnapshotNow(cardId: string): Promise<void>` -- deps_in: `@ice/db`, `./deploy-locks` -- deps_out: `deploy.service.ts` (init + apply finally) - -### `services/deploy/src/utils/deploy-event-formatter.ts` (util, ~35 LOC, lines 170–205) - -- `describeEventForLog(event: DeployEvent): string` -- `mapStatusToOverlay(status: DeployNodeStatus): string` -- deps_in: `@ice/types` -- deps_out: `deploy.service.ts` (re-exports `mapStatusToOverlay` for public API) -- Note: contract-coupling comment with `packages/ui/src/features/deploy/hooks/use-deploy-subscription.ts` — comment moves with the module. - -### `services/deploy/src/utils/deploy-outcome.ts` (util, ~95 LOC, lines 276–345 + 364–381) - -- `computeCompleteTotals(resources: any[] | undefined): DeployCompleteEvent['totals']` -- `deriveCompleteOutcome(resources, opts?: { cancelled?; engineSuccess? }): DeployCompleteEvent['outcome']` -- `computeDeploySummary(result: any): Record<string, number>` -- deps_in: `@ice/types` -- deps_out: `deploy.service.ts` apply / rollback / destroy-all complete-event blocks. Already covered by `deploy-event-translation.test.ts`. - -### `services/deploy/src/services/deploy-event-dispatcher.ts` (service-helper, ~120 LOC, lines 104–217 + 219–274) - -- `emitDeployEvent(cardId: string, event: DeployEvent): void` -- `emitLog(cardId, message, level?): void` -- `emitDestroyNodeStatus(cardId, payload): void` -- deps_in: `@ice/shared` wire emitters, `./deploy-event-log`, `./deploy-locks`, `../utils/deploy-event-formatter` -- deps_out: every emit callsite in deploy.service.ts (apply scheduler callbacks, destroy loops, complete events, logs) - -### `services/deploy/src/utils/project-context.ts` (util — DB-touching, ~35 LOC, lines 389–426) - -- `resolveProjectContext(cardId: string): Promise<{ projectId; projectName; environmentType }>` -- deps_in: `@ice/db` -- deps_out: `planDeployment`, `applyDeployment` runBody. - -### `services/deploy/src/services/deployer-factory.ts` (service-helper, ~30 LOC, lines 383–387 + 4 callsite duplicates) - -- `createDeployer(provider: string): Promise<any>` -- `getCoreEngine(): Promise<any>` -- deps_in: dynamic `@ice/core` -- deps_out: apply / destroy / destroyAll / rollback (4 currently-duplicated `if aws… else if azure… else GCP` blocks at 786–795, 1517–1521, 1841–1848, 2151–2158). -- Duplicate-removal exception to the ~30 LOC rule. - -### `services/deploy/src/utils/find-source-node-id.ts` (util, ~70 LOC, lines 889–953) - -- `buildResourceNameMaps(deployables): { nameToNodeId; nameToLabel; graphIdToCanvasId }` -- `makeFindSourceNodeId(args: { nameToNodeId; persistedMap }): (res) => string | undefined` -- deps_in: none (pure) -- deps_out: apply runBody (replaces inline closure 921–953 + maps 892–919). - -### `services/deploy/src/services/scheduler-callbacks.ts` (service-helper, ~110 LOC, lines 964–1072 + duplicate 1133–1178) - -- `makeSchedulerCallbacks(args: { cardId; graphIdToCanvasId; totalResources; totalsRef })` returning `{ on_node_status, on_node_progress, on_log, on_resource_result }` -- deps_in: `./deploy-event-dispatcher`, `./deploy-locks`, `../utils/deploy-event-formatter` -- deps_out: apply (replaces inline callback object) + auto-cleanup retry callbacks (second instance becomes a re-call with the same factory, with an `omit` option for the subset shape). - -### `services/deploy/src/services/quota-retry.ts` (service-helper, ~110 LOC, lines 1099–1199) — **flag for own coverage** - -- `retryAfterQuotaCleanup(args): Promise<void>` (mutates `result` in place) -- deps_in: `./orphan-cleanup.service`, `./deploy-event-dispatcher`, dynamic `getCoreEngine` -- deps_out: apply runBody (replaces 1099–1199). - -### `services/deploy/src/services/baseline-graph.ts` (service-helper, ~55 LOC, lines 824–874 + rollback duplicate 2192–2225) - -- `buildBaselineGraph(args: { cardId; environment; excludeDeploymentId? }): Promise<{ currentGraph; foundCount }>` -- deps_in: `@ice/db`, dynamic `@ice/core/graph` (`MutableGraph`) -- deps_out: apply runBody (824–874) + `rollbackDeployment` (2192–2225). -- **Param flag**: rollback's variant filters `status: 'success'` only; apply's filters `status: { in: ['success', 'partial'] }`. Pass through. - -### `services/deploy/src/services/destroy-targets.ts` (service-helper, ~110 LOC, lines 1400–1493 + 1551–1562) - -- `collectDestroyAllTargets(cardId): Promise<{ targets; provider; latestRow }>` -- `orderTargetsForDelete<T extends { type }>(targets: T[]): T[]` -- `resolveDestroyAllProject(args): string | null` -- deps_in: `@ice/db` -- deps_out: `destroyAllForCard`. - -### `services/deploy/src/services/destroy-runner.ts` (service-helper, ~140 LOC, lines 1580–1661 + 1900–2006) — **BEHAVIOR-RISK** - -- `runDestroyLoop(args): Promise<{ deleted; failed }>` -- deps_in: `@ice/db`, `./deploy-event-dispatcher` -- deps_out: `destroyDeployment` (1901–2006) + `destroyAllForCard` (1582–1661). -- **Risk**: `destroyDeployment` filters `res.success && res.provider_id`; `destroyAllForCard` iterates `targets.values()` regardless of historical success. Runner must accept a `selector` so both rules are preserved. - -### `services/deploy/src/services/deploy-lock-wrapper.ts` (service-helper, ~45 LOC, lines 567–583 + 3 duplicates) - -- `withDeployLock<T>(cardId, action, body, opts?): Promise<T>` -- deps_in: `./deploy-locks` -- deps_out: apply 568–583, destroyDeployment 1742–1752, destroyAllForCard 1391–1397, rollback 2086–2094. - -### `services/deploy/src/services/gcp-api-enabler.ts` (service-helper, ~190 LOC, lines 2637–2843) - -- `enableGcpApi(project, apiName, accessToken): Promise<boolean>` -- `autoEnableGCPApis(project, accessToken, canvasNodes, log): Promise<void>` -- consts `ICE_TYPE_API_MAP`, `BASE_APIS` -- deps_in: fetch -- deps_out: apply (818) + `google-verification.service.ts` (already imports `enableGcpApi` from `./deploy.service.js` — that import switches over). Orchestrator re-exports `enableGcpApi`. - -### `services/deploy/src/services/canvas-overlay.ts` (service-helper, ~145 LOC, lines 2319–2478) - -- `getNodeDeploymentOverlay(cardId, environment?): Promise<Record<string, any>>` -- deps_in: `@ice/db` -- deps_out: re-exported by orchestrator; `routes/canvas-deploy.ts` unchanged. - -### `services/deploy/src/services/drift.service.ts` (service-helper, ~125 LOC, lines 2480–2615) - -- `checkDrift(cardId, nodes, options?): Promise<{ driftResults; checkedAt; unsupported }>` -- deps_in: `@ice/db`, `@ice/service-credentials`, `../providers/registry`, `./deployer-factory` -- deps_out: re-exported by orchestrator; `drift-detection.test.ts` already passes through public API. - -## Dependency DAG (leaves first) - -``` -Layer 0 (pure leaves — no project deps) - utils/deploy-event-formatter.ts - utils/deploy-outcome.ts - utils/find-source-node-id.ts - -Layer 1 (DB or core only) - utils/project-context.ts - services/deployer-factory.ts - services/gcp-api-enabler.ts - services/baseline-graph.ts - services/destroy-targets.ts - -Layer 2 (depend on Layer 0) - services/snapshot-persister.ts - services/deploy-lock-wrapper.ts - services/deploy-event-dispatcher.ts - -Layer 3 (depend on Layer 2 dispatcher) - services/scheduler-callbacks.ts - services/destroy-runner.ts - services/canvas-overlay.ts - -Layer 4 (compose Layer 3) - services/quota-retry.ts - services/drift.service.ts - -Layer 5 (orchestrator) - services/deploy.service.ts → all of the above -``` - -## Unit ordering for the planner - -1. **rf-deploy-1** — `utils/deploy-event-formatter.ts` -2. **rf-deploy-2** — `utils/deploy-outcome.ts` -3. **rf-deploy-3** — `utils/find-source-node-id.ts` -4. **rf-deploy-4** — `utils/project-context.ts` -5. **rf-deploy-5** — `services/deployer-factory.ts` (dedups 4 callsites) -6. **rf-deploy-6** — `services/gcp-api-enabler.ts` (touches `google-verification.service.ts` import) -7. **rf-deploy-7** — `services/snapshot-persister.ts` -8. **rf-deploy-8** — `services/deploy-lock-wrapper.ts` (4-callsite dedup) -9. **rf-deploy-9** — `services/deploy-event-dispatcher.ts` (foundation for callback modules) -10. **rf-deploy-10** — `services/baseline-graph.ts` -11. **rf-deploy-11** — `services/destroy-targets.ts` -12. **rf-deploy-12** — `services/scheduler-callbacks.ts` -13. **rf-deploy-13** — `services/destroy-runner.ts` (BEHAVIOR-RISK) -14. **rf-deploy-14** — `services/quota-retry.ts` -15. **rf-deploy-15** — `services/canvas-overlay.ts` -16. **rf-deploy-16** — `services/drift.service.ts` -17. **rf-deploy-17** — shim-drop / orchestrator slim-down audit diff --git a/state/blueprints/rf-fbh.md b/state/blueprints/rf-fbh.md deleted file mode 100644 index e1f1dd39..00000000 --- a/state/blueprints/rf-fbh.md +++ /dev/null @@ -1,77 +0,0 @@ -# Blueprint — `packages/core/src/deploy/providers/gcp/handlers/firebase-hosting.ts` - -**Source**: 1140 LOC. **Decomposer run**: 2026-04-30. -**Public API**: `firebase_hosting_handler: GCPResourceHandler` (consumed by `gcp-deployer.ts` L21) + `FirebaseHostingDnsRecord` (exported interface; UI types its own `DnsRec` locally — server-side schema contract). Neither is re-exported through `packages/core/src/deploy/index.ts` or the GCP `index.ts`. - -## Modules (11 units) - -### Layer 0 — pure utils (no async, no ctx) - -- **rf-fbh-1** `firebase-hosting/result-helpers.ts` (~55 LOC, L40–76) — `result()`, `fail()`. The `TYPE` constant (`'gcp.firebase.hosting'`) lives here because both helpers embed it. Imports `ResourceDeployResult` from `'../../../types.js'`. Deepest leaf — zero intra-package imports. - -- **rf-fbh-2** `firebase-hosting/site-utils.ts` (~45 LOC, L78–111) — `sanitizeSiteId()`, `placeholderIndexHtml()`. Both pure. `placeholderIndexHtml` embeds `new Date().toISOString()` (RISK #1). HTML body verbatim (RISK #2). - -- **rf-fbh-3** `firebase-hosting/tar-parser.ts` (~75 LOC, L228–275) — `FileEntry` interface + `parseTar()`. Self-contained. RISK #3 (block alignment, EOF, ustar prefix concat, `Math.ceil(size/512)*512` arithmetic). - -### Layer 1 — REST transport + site provisioning - -- **rf-fbh-4** `firebase-hosting/rest-client.ts` (~65 LOC, L113–164) — `RestResponse` interface + `restRequest()`. Holds `FIREBASE_HOSTING_API` + `FIREBASE_MGMT_API` constants. RISK #4 (`validateStatus: () => true` always-true; `acceptStatuses` inclusion gate). - -- **rf-fbh-5** `firebase-hosting/site-provisioner.ts` (~80 LOC, L171–226) — `ensureFirebaseProject()`, `ensureHostingSite()`. RISK #5 (409/400 dual-meaning + message-content probe). RISK #6 (`ensureHostingSite` 409 re-fetch path). - -### Layer 2 — content pipeline - -- **rf-fbh-6** `firebase-hosting/github-downloader.ts` (~110 LOC, L282–371) — `downloadGitHubRepo()`. Imports `gunzipSync`, `parseTar`/`FileEntry`. RISK #7 (silent fallback when outputDirectory matches no files). RISK #8 (`globalThis.fetch` vs `requestRaw` dual path for codeload auth bypass). - -- **rf-fbh-7** `firebase-hosting/version-publisher.ts` (~130 LOC, L386–496) — `publishVersion()`, `publishPlaceholderVersion()`, `parseRepository()`. RISK #9 (SHA256 over GZIPPED payload). RISK #10 (5-step protocol: create → populateFiles → upload → PATCH FINALIZED → POST release; verbatim sequence). - -### Layer 2 — domain registration + DNS extraction - -- **rf-fbh-8** `firebase-hosting/dns-extractor.ts` (~110 LOC, L513–526 + L661–777) — `FirebaseHostingDnsRecord` interface (exported) + `extractDnsRecords()`. RISK #11 (four distinct API response shapes — all preserved). RISK #12 (`domainUpdateAction` per-record override). - -- **rf-fbh-9** `firebase-hosting/domain-registrar.ts` (~125 LOC, L539–659) — `registerHostingDomain()`. **HIGHEST-RISK UNIT.** RISK #13 (project-scoped path `projects/${ctx.project}/sites/${siteId}`). RISK #14 (three-tier fallback: GET → customDomains → legacy domains; each 409 re-fetches independently; legacy body shape is verbatim). - -### Final - -- **rf-fbh-10** orchestrator slim-down to ~220 LOC. `firebase-hosting.ts` retains imports + `firebase_hosting_handler` export with `create`, `update`, `delete`, `describe`. Each method body is a thin coordinator. The `delete` method's 400-means-default-site handling stays in the orchestrator. - -- **rf-fbh-11** final housekeeping. Barrel `firebase-hosting/index.ts` re-exports `firebase_hosting_handler` and `FirebaseHostingDnsRecord` so `gcp-deployer.ts`'s import path is unchanged. Verify zero regressions. - -## Behavior-risk flags (14 total) - -1. **`new Date().toISOString()` in placeholder HTML** — call-time eval, time-sensitive snapshots will fail. Don't memoize. - -2. **Placeholder HTML verbatim** — inline `<style>`, ✓ glyph (U+2713), all copy preserved byte-for-byte (Firebase hashes for dedup). - -3. **Tar parser edge cases**: (a) EOF on first zero-block (not two-block GNU); (b) octal size parsing `parseInt(sizeField, 8)`; (c) `Math.ceil(size/512)*512` for empty files (size=0 → 0); (d) `Buffer.from(data)` copy avoids retaining decompressed buffer. - -4. **`validateStatus: () => true`** — always-true means `res.ok` checks are the only guard. Don't add partial validators. - -5. **`ensureFirebaseProject` 409/400 dual-meaning** — message-content probe (`'already'`/`'ALREADY_EXISTS'`) disambiguates; pure status check would mis-classify genuine 400s. - -6. **`ensureHostingSite` adoption** — three-condition check `getRes.ok && getRes.status !== 404 && getRes.data?.name` required. - -7. **GitHub silent fallback** — when outputDirectory matches no files, falls back to repo root and warns. Non-throwing by design. - -8. **GitHub fetch auth bypass** — `globalThis.fetch` branch omits auth headers (codeload rejects them); `requestRaw` fallback preserved for envs without global fetch. - -9. **SHA256 over gzipped bytes** — Firebase requires hash of compressed payload, not raw. Hashing `f.bytes` directly fails uploads. - -10. **5-step version publish sequence** — create → populateFiles → upload → PATCH FINALIZED → POST release. Server-enforced state machine; no reorder/parallelize. - -11. **Four DNS response shapes** — `requiredDnsUpdates.*`, `dnsRecordSets`, `provisioning.dnsStatus`, `provisioning.expectedIps/dnsTokens`. All preserved; no early return. - -12. **Per-record `domainUpdateAction` override** — individual records can override set-level action. `toUpperCase()` coercion + ADD/REMOVE matching verbatim. - -13. **Project-scoped custom-domain path** — `projects/${ctx.project}/sites/${siteId}` (not bare `sites/${siteId}`). Legacy endpoint at L621 also requires project prefix. - -14. **Three-tier domain registration fallback** — GET-first (adopt) → POST customDomains → POST legacy domains. Legacy body shape (`domainRedirect.type: 'TEMPORARY'`, `provisioning.certStatus: 'CERT_PREPARING'`) verbatim. - -## Public API - -| Export | Kind | Consumed by | Notes | -| -------------------------- | -------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------- | -| `firebase_hosting_handler` | `GCPResourceHandler` const | `gcp-deployer.ts` L21 | Direct named import; not re-exported through any `index.ts`. | -| `FirebaseHostingDnsRecord` | exported interface | No direct cross-package import. UI uses its own `DnsRec` locally. | Authoritative schema for `custom_domain_dns_records`. Keep exported. | - -No re-export shims required. Orchestrator stays at `handlers/firebase-hosting.ts` (or barrel resolving same import path). diff --git a/state/blueprints/rf-lex.md b/state/blueprints/rf-lex.md deleted file mode 100644 index ae65b8bb..00000000 --- a/state/blueprints/rf-lex.md +++ /dev/null @@ -1,51 +0,0 @@ -# Blueprint — `packages/core/src/graph/parser/lexer.ts` - -**Source**: 647 LOC. **Decomposer run**: 2026-04-30. -**Public API**: `Lexer` (class), `tokenize` (factory function), `LexerError`, `LexerResult`, `LexerOptions` — all re-exported from `packages/core/src/graph/parser/index.ts`. - -**Approach**: **B — standalone functions taking a shared `LexerState` argument**, mirroring rf-parse decomposition. The `Lexer` class becomes a constructor + delegation shell. - -## Modules (5 units) - -### Layer 0 — types + navigation - -- **rf-lex-1** `lexer-state.ts` (~90 LOC) — `LexerState` interface; `make_lexer_state` factory; navigation `ls_is_at_end`, `ls_peek`, `ls_peek_next`, `ls_advance`, `ls_match`, `ls_skip_whitespace`; token-construction `ls_add_token`, `ls_add_token_with_literal`, `ls_add_error`, `ls_current_position`. Port from L547–634. **RISK #1** (block-comment newline `column = 0` then `advance()` → 1; preserve sequence). **RISK #2** (`add_error` snapshots `pos - 1`, not `pos`). - -### Layer 1 — simple scanners - -- **rf-lex-2** `lexer-scanners.ts` (~160 LOC) — `scan_number`, `scan_identifier`, `scan_line_comment`, `scan_block_comment`. Character predicates `is_digit`/`is_alpha`/`is_alphanumeric` are module-private. **RISK #3** (`scan_number._negative` unused but preserve signature). **RISK #4** (3-branch keyword dispatch: TRUE/FALSE/NULL_KEYWORD). **RISK #5** (TYPE_IDENTIFIER regex `includes('.') || /^[A-Z]/.test(value)`). **RISK #6** (block-comment nested-depth counter both directions). - -### Layer 2 — complex scanner - -- **rf-lex-3** `lexer-heredoc.ts` (~100 LOC) — **HIGHEST-RISK UNIT.** `scan_heredoc` only. Has its own internal backtrack via `this.pos = check_start`. **RISK #7** (terminator backtrack to `check_start` not `line_start`). **RISK #8** (`content_end = line_start` + later `trimEnd()`). **RISK #9** (EOF without closing delim is silent — no error). **RISK #10** (two separate newline accounting sites — opening-line + content-line — both `s.line++; s.column = 1`). - -### Final - -- **rf-lex-4** orchestrator slim-down (~110 LOC). `lexer.ts` retains types, `DEFAULT_OPTIONS`, `Lexer` class with constructor + `tokenize()` + `scan_token()` dispatch (kept here as it's pure routing), `tokenize` factory. - -- **rf-lex-5** final housekeeping. Verify no circular imports; confirm `index.ts` re-exports unchanged; `pnpm --filter @ice/core typecheck`. - -## Behavior-risk flags (10 total) - -1. **Block-comment `column = 0` not 1** — on newline inside `/*...*/`. "Correcting" to `1` drifts column tracking by +1 for all tokens after multi-line block comments. -2. **`add_error` `pos - 1` snapshot** — error token stamped after advance consumed bad char. -3. **`scan_number` unused `_negative` param** — leading `-` consumed before dispatch; signature preserved. -4. **3-branch keyword dispatch** — TRUE→BOOLEAN(true), FALSE→BOOLEAN(false), NULL_KEYWORD→NULL(null). Don't collapse. -5. **TYPE_IDENTIFIER regex** — `value.includes('.') || /^[A-Z]/.test(value)` exact form. -6. **Block-comment depth counter** — both `/*` increment and `*/` decrement load-bearing. -7. **Heredoc backtrack to `check_start`** — leading whitespace consumed for indentation check is NOT restored on terminator-match failure. -8. **`content_end = line_start` + `trimEnd()`** — content boundary set before indentation; trim applied later. -9. **EOF without closing delimiter is silent** — no `add_error` call. Don't add one. -10. **Two newline accounting sites in heredoc** — opening + content; both `s.line++; s.column = 1`. - -## Public API - -| Export | Kind | Consumed by | Notes | -| -------------- | --------- | -------------------- | --------------------------------------------------------------- | -| `Lexer` | class | `index.ts` L86, L110 | Constructor `(source, options?)` preserved. | -| `tokenize` | function | `index.ts` L70 | Factory `(source, options?) => LexerResult`. | -| `LexerError` | interface | `index.ts` L68, L89 | `{ message, position, recoverable }`. | -| `LexerResult` | interface | `index.ts` L68 | `{ tokens, errors }`. | -| `LexerOptions` | interface | `index.ts` L68, L89 | `{ file?, include_comments?, include_newlines?, max_errors? }`. | - -All 5 exports remain on `lexer.ts`. Internal modules not exported from `index.ts`. diff --git a/state/blueprints/rf-parse.md b/state/blueprints/rf-parse.md deleted file mode 100644 index 634be15c..00000000 --- a/state/blueprints/rf-parse.md +++ /dev/null @@ -1,64 +0,0 @@ -# Blueprint — `packages/core/src/graph/parser/parser.ts` - -**Source**: 1061 LOC. **Decomposer run**: 2026-04-30. -**Public API**: `Parser` (class), `parse` (factory function), `ParserError`, `ParserResult`, `ParserOptions` — all re-exported from `packages/core/src/graph/parser/index.ts`. The `parse_source` convenience function in `index.ts` constructs `new Parser(...)` directly. - -**Approach**: **B — standalone functions taking a shared `ParserState` argument**, applied to all method groups except `parse_program` / `parse_statement` (which stay in the orchestrator as a thin dispatch shell). - -Rationale over A (keep class intact): would only extract ~150 LOC of types. The real maintainability pain is the 10-level expression grammar chain + 9 block/statement parsers in one 900-LOC scroll. -Rationale over C (separate sub-parser classes): plain interface is simpler than class hierarchy; eliminates `this` aliasing and constructor overhead. - -## Modules (8 units) - -### Layer 0 — types + token navigation - -- **rf-parse-1** `parser-state.ts` (~75 LOC) — `ParserState` interface (`tokens`, `pos`, `errors`, `options`); `make_parser_state` factory; navigation helpers `ps_current`, `ps_previous`, `ps_advance`, `ps_check`, `ps_match`, `ps_consume`, `ps_is_at_end`, `ps_add_error`, `ps_synchronize`. Direct port of class methods L985–1048; `this.` → `s.`. **RISK #1** (consume no-advance on error). **RISK #2** (synchronize two exits — current keyword OR previous RIGHT_BRACE). - -- **rf-parse-2** `parser-literals.ts` (~85 LOC) — `parse_identifier`, `parse_type_identifier`, `parse_string_literal`, `parse_boolean_literal`, `create_null_literal`, `create_span` (parser-internal 2-arg variant; NOT to be confused with `ast.ts::create_span` 6-arg variant — different functions, same name). **RISK #3** (silent dot-skip in type-identifier). **RISK #4** (name collision with ast.ts). - -### Layer 1 — expression parsers - -- **rf-parse-3** `parser-binary-exprs.ts` (~210 LOC) — 10-level expression grammar chain: `parse_expression` → `parse_conditional` → `parse_or` → `parse_and` → `parse_equality` → `parse_comparison` → `parse_term` → `parse_factor` → `parse_unary` → `parse_postfix`. Imports `parse_primary` from `parser-primary.ts`. **RISK #5** (operator ternary not cast). **RISK #6** (postfix error-but-continue for non-identifier callee). **RISK #7** (precedence chain order — every level calls exactly next). - -- **rf-parse-4** `parser-primary.ts` (~215 LOC) — **HIGHEST-RISK UNIT.** `parse_primary`, `parse_array_expression`, `parse_object_expression`, `parse_for_expression`, `parse_reference`. **RISK #8** (pre-advance token snapshot). **RISK #9** (`key_expr === value_expr` map-comprehension identity — no second expression parsed after FAT_ARROW; do not add). **RISK #10** (path undefined vs `[]`). - -### Layer 2 — block + statement parsers - -- **rf-parse-5** `parser-block-body.ts` (~145 LOC) — `parse_resource_block`, `parse_data_block`, `parse_provider_block`, `parse_block`. Co-located because all four feed into `parse_block` recursion. **RISK #11** (zero-label nested-block path: LEFT_BRACE in outer condition + inner while exits immediately). - -- **rf-parse-6** `parser-statements.ts` (~225 LOC) — `parse_variable_block`, `parse_output_block`, `parse_module_block`, `parse_locals_block`, `parse_import_statement`. **RISK #12** (unknown-attribute `parse_expression()` discard advances cursor — removing causes infinite loop). **RISK #13** (output missing-value: error AND synthetic null both emitted). **RISK #14** (import statement silent token discard for non-`"as"` identifier). - -### Final - -- **rf-parse-7** orchestrator slim-down (~110 LOC). `parser.ts` retains: `ParserError` / `ParserResult` / `ParserOptions` / `DEFAULT_OPTIONS`, `Parser` class with constructor + `parse()` + `parse_program()` + `parse_statement()` (thin dispatch shell), `parse` factory function. Class keeps `this.state: ParserState` field; methods pass `this.state` to imported functions. - -- **rf-parse-8** final housekeeping. Verify no circular imports (`parser-binary-exprs` ↔ `parser-primary` cycle resolved by passing `parse_expression` as fn arg or via direct import). Confirm `index.ts` re-exports unchanged. `pnpm --filter @ice/core typecheck`. - -## Behavior-risk flags (14 total) - -1. **`ps_consume` no-advance** — on mismatch, calls `add_error` then returns `current()` WITHOUT advancing. Cursor stalls so caller decides recovery. -2. **`ps_synchronize` two exits** — advances at least once, then exits on (a) statement keyword at `current()` OR (b) RIGHT_BRACE at `previous()`. Both checks load-bearing. -3. **`parse_type_identifier` silent dot-skip** — after `.` if neither IDENTIFIER nor TYPE_IDENTIFIER follows, no error/no advance. Preserve. -4. **`create_span` name collision** — parser-internal 2-arg vs ast.ts 6-arg. Different functions, same name. Don't merge. -5. **`parse_equality` operator ternary** — explicit `=== '==' ? '==' : '!='` not cast. Preserve. -6. **`parse_postfix` error-but-continue** — non-identifier callee: `add_error` fires but FunctionCall node still constructed. No break/skip. -7. **Precedence chain order** — 10-level chain encodes operator precedence. Every level must call next. -8. **`parse_primary` pre-advance snapshot** — `const token = current()` then `match(...)` advances. All reads use snapshot. -9. **`parse_for_expression` key/value identity** — when FAT_ARROW matched, `key_expr === value_expr` (same object reference); no second expression parsed. -10. **`parse_reference` path undefined** — `path.length > 0 ? path : undefined` returns undefined, not `[]`. -11. **`parse_block` zero-label nested-block** — LEFT_BRACE in outer condition + inner while exits immediately when neither STRING nor IDENTIFIER. Both conditions load-bearing. -12. **Unknown-attribute `parse_expression()` discard** — advances cursor past unknown values; removing causes infinite loop in outer while. -13. **`parse_output_block` missing value** — both `add_error` AND `create_null_literal(start)` emitted. Error not suppressed by recovery. -14. **`parse_import_statement` silent discard** — non-`"as"` identifier after path is consumed by `match('IDENTIFIER')` and dropped. No error, no backtrack. - -## Public API - -| Export | Kind | Consumed by | Notes | -| --------------- | --------- | ------------------------------------------------- | ------------------------------------------------------ | -| `Parser` | class | `index.ts` L86 (`parse_source`); direct consumers | Constructor `(tokens, options?)` preserved. | -| `parse` | function | `index.ts` L74 | Factory `(tokens, options?) => ParserResult`. | -| `ParserError` | interface | `index.ts` L73 | `{ message, position, token? }`. | -| `ParserResult` | interface | `index.ts` L73, L96 | `{ program: Program \| null, errors: ParserError[] }`. | -| `ParserOptions` | interface | `index.ts` L73, L108 | `{ max_errors?, error_recovery? }`. | - -All 5 exports remain on `parser.ts`. No re-export shims required. Internal modules (`parser-state.ts`, etc.) are not exported from `index.ts`. diff --git a/state/blueprints/rf-pdpl.md b/state/blueprints/rf-pdpl.md deleted file mode 100644 index bf89c3f2..00000000 --- a/state/blueprints/rf-pdpl.md +++ /dev/null @@ -1,67 +0,0 @@ -# Blueprint — `packages/ui/src/features/deploy/components/deploy-panel.tsx` - -**Source**: 2229 LOC. **Decomposer run**: 2026-04-29. -**Public API**: single named export `DeployPanel`; no internal shims needed. - -## Modules (24 units) - -### Layer 0 — utils - -- **rf-pdpl-1** `utils/provider-regions.ts` (~40 LOC, L71–138) — `PROVIDER_REGIONS`, `PROVIDER_LABELS`, `PROVIDER_PROJECT_LABELS`, `detectDominantProvider(nodes)`. -- **rf-pdpl-2** `utils/open-external-url.ts` (~10 LOC, L1481–1484) — `openExternalUrl(url)`. 8 callsites. -- **rf-pdpl-3** `utils/dns-records.ts` (~45 LOC, L664–714) — `extractDnsResults`, `splitDnsByAction`, `DnsRec` type. -- **rf-pdpl-4** `utils/results-summary-text.ts` (~35 LOC, L1996–2018) — `buildResultsSummaryText`, `summaryCounts`. **RISK #9**: preserve ✓/✗ glyphs. -- **rf-pdpl-5** `utils/error-classification.ts` (~50 LOC, L1652–1718) — `classifyDeployError`, `collectApiEnableUrls`, `extractProjectIdFromError`, `QUOTA_PATTERN`. **RISK #10**: preserve regex verbatim. - -### Layer 1 — leaf subcomponents - -- **rf-pdpl-6** `components/status-badge.tsx` (~50 LOC, L1223–1270). **RISK #8**: returns null for unknown statuses (load-bearing). -- **rf-pdpl-7** `components/plan-preview.tsx` (~80 LOC, L1399–1476). Co-locates ChangeRow. -- **rf-pdpl-8** `components/sections/auth-banner.tsx` (~25 LOC, L626–635). -- **rf-pdpl-9** `components/sections/deployed-resources-list.tsx` (~25 LOC, L600–624). -- **rf-pdpl-10** `components/sections/log-panel.tsx` (~25 LOC, L768–781). **RISK #6**: ref must come from useDeployEffects. -- **rf-pdpl-11** `components/sections/dns-records-section.tsx` (~110 LOC, L660–765). **RISK #7**: keep `outputs as any` cast at util boundary. -- **rf-pdpl-12** `components/destroy-confirm-modal.tsx` (~135 LOC, L1836–1980). **RISK #11**: createPortal + Esc listener owned by modal. -- **rf-pdpl-13** `components/deploy-node-row.tsx` (~85 LOC, L1133–1221). **RISK #3**: React.memo boundary — separate module from DeployInFlightPanel. - -### Layer 2 — composing - -- **rf-pdpl-14** `components/deploy-in-flight-panel.tsx` (~70 LOC, L1043–1131). React.memo + useMemo on deriveRollup. -- **rf-pdpl-15** `components/results-summary.tsx` (~245 LOC, L1982–2229). Largest module; flag if >280 during impl. -- **rf-pdpl-16** `components/banners/quota-error-banner.tsx` (~140 LOC, L1496–1635). 4-state machine. - -### Layer 3 — composing banners + section with state - -- **rf-pdpl-17** `components/banners/api-error-banner.tsx` (~135 LOC, L1637–1832). Switches on classifyDeployError. -- **rf-pdpl-18** `components/sections/config-section.tsx` (~125 LOC, L1272–1397). **RISK #5**: parallel network paths with orchestrator (intentional). -- **rf-pdpl-19** `components/deploy-controls.tsx` (~165 LOC, L805–960). Footer buttons + cancel-fetch. - -### Layer 4 — hooks (Redux + side-effects) - -- **rf-pdpl-20** `hooks/use-deploy-actions.ts` (~210 LOC, L233–528). **RISK #2**: retry-after-auth re-dispatches startPlanning/startDeploying — keep verbatim. -- **rf-pdpl-21** `hooks/use-deploy-effects.ts` (~140 LOC, L165–229 + 303–377). **RISK #1**: 4 effects in one hook with overlapping deps; preserve order. -- **rf-pdpl-22** `hooks/use-destroy-action.ts` (~80 LOC, L967–1027). **RISK #4**: startDestroying BEFORE await — order is observable to canvas overlay. - -### Final - -- **rf-pdpl-23** orchestrator slim-down to ~250–300 LOC. -- **rf-pdpl-24** final housekeeping (no public-API shims needed). - -## Behavior-risk flags (12 total) - -1. **useDeployEffects 4-effect bundle**: keep in one hook with comments verbatim (the "Don't gate on slice status here" comment at L303 is load-bearing). -2. **use-deploy-actions retry-after-auth**: handlePlan/handleDeploy re-dispatch start\* before retry. Don't pull retry into a helper. -3. **DeployNodeRow React.memo boundary**: separate module from DeployInFlightPanel; collapsing re-renders every row. -4. **use-destroy-action ordering**: startDestroying → await API → clearCardDeployOverlay → setDeployedResources([]) → resetDeploy(). Don't reorder. -5. **ConfigSection parallel network**: provider.isConnected runs both in orchestrator (auto-fill once) and ConfigSection (refresh on change). Keep both. -6. **logEndRef auto-scroll**: hook must stay unconditional (not gated by `if (!isOpen) return null`). -7. **DnsRecordsSection `outputs as any` cast**: keep at util boundary; switching to type guard changes runtime. -8. **StatusBadge null fallthrough**: returns null for unknown statuses (Redux transient state). Load-bearing. -9. **buildResultsSummaryText glyphs**: ✓/✗ Unicode preserved — E2E clipboard snapshots may match. -10. **classifyDeployError regex**: single regex with capture groups; OR-joined includes() drops semantics. -11. **DestroyConfirmModal createPortal + Esc**: listener must attach inside modal, not parent. -12. **gcpNodes alias**: don't rename in a single unit — flag for follow-up. - -## Public API - -Single named export `DeployPanel` from `packages/ui/src/features/deploy/components/deploy-panel.tsx`. Re-exported via `packages/ui/src/features/deploy/index.ts`. Mounted by `packages/ui/src/shared/components/main-layout.tsx`. diff --git a/state/blueprints/rf-props.md b/state/blueprints/rf-props.md deleted file mode 100644 index cf173dd7..00000000 --- a/state/blueprints/rf-props.md +++ /dev/null @@ -1,222 +0,0 @@ -# Blueprint — `packages/ui/src/features/properties/components/properties-panel.tsx` - -**Source**: 3268 LOC. **Decomposer run**: 2026-04-29. -**Public-API consumer**: top-level layout slot in App.tsx (default export of `PropertiesPanel`). Re-exports stay; only the internal composition changes. - -## Broker prework (cross-package dedups already flagged) - -- **`parseCostRange` + `formatCost`** already exist canonically at `packages/ui/src/features/cost/utils/cost-calculator.ts` (registry anchors `parse-cost-range`, `format-cost`). Three call sites (this file at L114-125 + `shared/components/status-bar.tsx` + the canonical home). The canonical version handles `Free` / commas / decimals; the local version doesn't and `formatCost` returns `''` for zero where canonical returns `'Free'`. **Treat as a behavior-change-risk dedup unit at the very end of the rf-props series, not as a copy-and-rename during extraction.** -- **`normalizeSubdomain`** exists at `custom-domain/index.tsx:107` and inline twice in this file. Reconcile to one canonical home; flag for critic that the edge-subdomain version's truncation order is strictly dominant. -- **`parseQueue`** exists at `message-queue/index.tsx:34` with a _different shape_ (`QueueView` vs `QueueSpec`). Don't merge. - -## Modules - -### `packages/ui/src/features/properties/utils/queue-spec.ts` (util, ~22 LOC, lines 402–421) - -- `interface QueueSpec`, `parseQueue(raw): QueueSpec`, `stringifyQueue(q): string` - -### `packages/ui/src/features/properties/utils/normalize-subdomain.ts` (util, ~28 LOC, lines 972–993 + 1763–1774) - -- `normalizeSubdomain(raw): string`, `validateSubdomain(s): string | null` -- Pick the edge-subdomain order (truncate-after-trim) — strictly dominant. - -### `packages/ui/src/features/properties/utils/edge-warnings.ts` (util, ~30 LOC, lines 880–895) - -- `computeEdgeWarnings(srcIceType, tgtIceType, t): Array<{ level; message; suggestion? }>` - -### `packages/ui/src/features/properties/utils/format-age.ts` (util, ~12 LOC, lines 2272–2280 + 3099–3107 — duplicate-removal) - -- `formatAge(date): string` - -### `packages/ui/src/features/properties/utils/deploy-history-format.ts` (util, ~60 LOC, lines 2877–2949) - -- `ACTION_LABELS`, `ACTION_COLORS`, `formatDeployRow(d)` - -### `packages/ui/src/features/properties/components/fields/index.tsx` (component-bundle, ~280 LOC, lines 291–579) - -Single file with multiple named exports — NOT a barrel. The no-barrel rule forbids re-export hubs, not co-located primitives. - -- `Section`, `TextField`, `NumberField`, `SelectField`, `ListField`, `QueueListField`, `StepperField`, `PropertyLabel`, `CustomValueInput` - -### `packages/ui/src/features/properties/components/fields/render-property-field.tsx` (component-factory, ~175 LOC, lines 52–110 + 581–755) - -- `renderPropertyField(prop, value, onChange, nodeData?)`, `PropertyFields` component -- Types: `HighLevelProperty`, `OptionDetail`, `CustomInputConfig`, `ResourceDef`, `ResourceCategory`, `ProviderImpl` - -### `packages/ui/src/features/properties/hooks/use-resource-map.ts` (hook, ~32 LOC, lines 772–796) - -- `useResourceMap(): Map<string, ResourceDef>`. Silent-on-fail effect — preserve. - -### `packages/ui/src/features/properties/hooks/use-property-issues.ts` (hook, ~18 LOC, lines 806–815) — fold-in candidate - -- `usePropertyIssues(selectedNodeId): Map | undefined`. Lean fold-in into `use-resource-map`. - -### `packages/ui/src/features/properties/hooks/use-active-env-name.ts` (hook, ~12 LOC, lines 818–821) — inline candidate - -- `useActiveEnvName(projectId): string`. Lean inline if not duplicated. - -### `packages/ui/src/features/properties/hooks/use-drift-check.ts` (hook, ~35 LOC, lines 251–269) - -- `useDriftCheck(cardId, nodes): { isLoading; checkDrift }` - -### `packages/ui/src/features/properties/components/sections/drift.tsx` (subcomponent, ~100 LOC, lines 185–289) - -- `DriftIndicator`, `DriftCheckButton` - -### `packages/ui/src/features/properties/components/sections/group-color-picker.tsx` (subcomponent, ~50 LOC, lines 129–181) - -- `GroupColorPicker` - -### `packages/ui/src/features/properties/components/sections/edge-properties-section.tsx` (subcomponent, ~245 LOC, lines 870–1111) - -- `EdgePropertiesSection`. Stays Redux-coupled. - -### `packages/ui/src/features/properties/components/sections/scaling-section.tsx` (subcomponent, ~50 LOC, lines 1410–1451) - -- `ScalingSection` - -### `packages/ui/src/features/properties/components/sections/domain-section.tsx` (subcomponent, ~35 LOC, lines 1454–1483) - -- `PublicEndpointDomainSection` - -### `packages/ui/src/features/properties/components/sections/custom-domain-panel.tsx` (subcomponent, ~260 LOC, lines 1726–1987) - -- `CustomDomainPanel`, `interface CustomDomainRoute`. Candidate for follow-up split (RoutesList + DnsRecordsList) — defer. - -### `packages/ui/src/features/properties/components/sections/private-network-panel.tsx` (subcomponent, ~175 LOC, lines 1989–2165) - -- `PrivateNetworkPanel`. Keeps inline `PrivateNetworkPolicySection`. **Preserve `data-testid="pn-${direction}-..."` attributes.** - -### `packages/ui/src/features/properties/components/sections/connection-card.tsx` (subcomponent, ~75 LOC, lines 3014–3087) - -- `ConnectionCard` - -### `packages/ui/src/features/properties/components/sections/env-vars-editor.tsx` (subcomponent, ~60 LOC, lines 3209–3268) - -- `EnvVarsEditor` - -### `packages/ui/src/features/properties/components/sections/pipeline-section.tsx` (subcomponent, ~300 LOC, lines 2169–2466) - -- `PipelineSection`. Three dynamic imports — relative paths change after move, test must cover. - -### `packages/ui/src/features/properties/components/sections/service-source-section.tsx` (subcomponent, ~56 LOC, lines 2470–2523) - -- `ServiceSourceSection` - -### `packages/ui/src/features/properties/components/sections/source-repository-section.tsx` (subcomponent, ~350 LOC, lines 2527–2873) - -- `SourceRepositorySection` - -### `packages/ui/src/features/properties/components/sections/repo-deploy-list.tsx` (subcomponent, ~120 LOC, lines 3091–3207) - -- `RepoDeployList` - -### `packages/ui/src/features/properties/components/sections/deploy-history.tsx` (subcomponent, ~130 LOC, lines 2877–3012) - -- `DeployHistory` - -### `packages/ui/src/features/properties/components/sections/project-overview.tsx` (subcomponent, ~75 LOC, lines 858–867 + 1644–1707) - -- `ProjectOverview` - -### `packages/ui/src/features/properties/components/sections/node-properties-section.tsx` (subcomponent, ~530 LOC, lines 1113–1641) — **split into two units** - -- `NodePropertiesSection` — tab-router + per-tab dispatch. Split per the planner's call: (a) tab-router shell with setState-during-render fallback intact, (b) per-tab body extraction. - -### `packages/ui/src/features/properties/components/properties-panel.tsx` (orchestrator, ~120 LOC final) - -- Default `PropertiesPanel` only. Composes the three top-level branches: edge / node / project-overview. Default export preserved. - -## Dependency DAG (leaves first) - -``` -LEAVES: - utils/queue-spec.ts - utils/normalize-subdomain.ts - utils/edge-warnings.ts - utils/format-age.ts - utils/deploy-history-format.ts - -LAYER 1: - components/fields/index.tsx ← queue-spec - hooks/use-resource-map.ts - hooks/use-property-issues.ts (or fold in) - hooks/use-active-env-name.ts (or inline) - hooks/use-drift-check.ts - -LAYER 2: - components/fields/render-property-field.tsx ← fields - sections/drift.tsx ← use-drift-check - sections/group-color-picker.tsx - sections/connection-card.tsx - sections/env-vars-editor.tsx ← fields - sections/scaling-section.tsx ← fields - sections/domain-section.tsx ← fields - sections/custom-domain-panel.tsx ← fields, normalize-subdomain - sections/private-network-panel.tsx ← fields - sections/repo-deploy-list.tsx ← fields, format-age - sections/service-source-section.tsx ← fields - sections/deploy-history.tsx ← fields, deploy-history-format - sections/pipeline-section.tsx ← fields, format-age - -LAYER 3: - sections/source-repository-section.tsx ← fields, repo-deploy-list, pipeline-section - sections/edge-properties-section.tsx ← fields, edge-warnings, normalize-subdomain - sections/project-overview.tsx ← fields + canonical cost-calculator - -LAYER 4: - sections/node-properties-section.tsx ← every Layer 1-3 section + render-property-field - -ROOT: - properties-panel.tsx ← edge-section, node-section, project-overview, hooks -``` - -## Behavior-risk flags - -1. **parseCostRange/formatCost dedup is a behavior change**, not pure code-shape. Local strictly less capable than canonical. Sequence as a **separate unit at the END** of the rf-props series, after sections land. Critic must flag a behavior-equivalence diff. - -2. **NodePropertiesSection (~530 LOC) has setState-during-render** at L1280–1284 (`setPropsTab(visibleTabs[0].id)` during render — React tolerates). Split into tab-router shell + per-tab body in TWO units. Preserve the setState's exact JSX position. - -3. **PipelineSection + SourceRepositorySection use dynamic `import('../../../store/...')`**. Relative paths change after extraction. Static typecheck won't catch wrong relative paths inside string literals — test-author must cover the dynamic-import path. - -4. **renderPropertyField wraps each field with `data-prop-key={prop.name}`** (E2E selectors depend on this). Preserve verbatim. - -5. **CustomDomainPanel is rendered TWICE** (domain tab + config tab). Identical props in both call sites; any selector reshuffle that gives different `selectedNode` references would re-mount and lose `<input>` cursor position during typing. - -6. **useResourceMap silently swallows fetch errors** (load-bearing for offline/dev-server). Document with a comment; don't add error handling. - -7. **PrivateNetworkPolicySection has `data-testid="pn-${direction}-..."`** referenced from E2E. Preserve verbatim. - -8. **normalizeSubdomain inline-twice with subtly different ordering**. Edge version (truncate-after-trim) strictly dominates. Pick edge order; document why in the new util. - -9. **PipelineSection `handleRetry` has unnecessary double-dynamic-import** at L2260–2269. Don't clean up in extraction — flag follow-up. - -## Unit ordering for the planner - -1. **rf-props-1** — `utils/queue-spec.ts` -2. **rf-props-2** — `utils/normalize-subdomain.ts` -3. **rf-props-3** — `utils/edge-warnings.ts` -4. **rf-props-4** — `utils/format-age.ts` -5. **rf-props-5** — `utils/deploy-history-format.ts` -6. **rf-props-6** — `components/fields/index.tsx` (the field primitives bundle) -7. **rf-props-7** — `hooks/use-resource-map.ts` (+ fold-in `use-property-issues.ts`) -8. **rf-props-8** — `hooks/use-drift-check.ts` -9. **rf-props-9** — `components/fields/render-property-field.tsx` -10. **rf-props-10** — `sections/drift.tsx` -11. **rf-props-11** — `sections/group-color-picker.tsx` -12. **rf-props-12** — `sections/connection-card.tsx` -13. **rf-props-13** — `sections/env-vars-editor.tsx` -14. **rf-props-14** — `sections/scaling-section.tsx` + `sections/domain-section.tsx` (both small; same brief) -15. **rf-props-15** — `sections/custom-domain-panel.tsx` (BEHAVIOR-RISK: rendered twice) -16. **rf-props-16** — `sections/private-network-panel.tsx` -17. **rf-props-17** — `sections/repo-deploy-list.tsx` -18. **rf-props-18** — `sections/service-source-section.tsx` -19. **rf-props-19** — `sections/deploy-history.tsx` -20. **rf-props-20** — `sections/pipeline-section.tsx` (BEHAVIOR-RISK: dynamic imports) -21. **rf-props-21** — `sections/source-repository-section.tsx` -22. **rf-props-22** — `sections/edge-properties-section.tsx` -23. **rf-props-23** — `sections/project-overview.tsx` -24. **rf-props-24a** — `sections/node-properties-section.tsx` shell + tab-router (BEHAVIOR-RISK) -25. **rf-props-24b** — per-tab body extraction -26. **rf-props-25** — orchestrator slim-down (the final compose-and-route shell) -27. **rf-props-26** — `parseCostRange`/`formatCost` cross-file dedup to canonical home (BEHAVIOR-CHANGE — separate unit at the end) diff --git a/state/blueprints/tour.md b/state/blueprints/tour.md deleted file mode 100644 index 011c0b77..00000000 --- a/state/blueprints/tour.md +++ /dev/null @@ -1,1121 +0,0 @@ -# Tour engine blueprint (v1) - -In-house, JSON-driven, multi-step guided tour engine for ICE. Lives at -`packages/ui/src/features/tour/`. Runs alongside the existing 3-step -credential wizard at `packages/ui/src/features/onboarding/` (which stays -as-is — it collects data, tours teach surface). - -The blueprint is unit-by-unit so an implementer can land one piece at a -time. Per repo discipline, every source file targets 200–500 LOC; over -500 needs further splitting (see `feedback_200_loc_ceiling`). - -## 1. Architecture - -### 1.1 Folder layout - -``` -packages/ui/src/features/tour/ -├── index.ts Barrel: TourRunner, useTour, registerTour, types -├── tour.types.ts ~80 LOC. Tour, TourStep, Placement, TourEvent. -├── components/ -│ ├── tour-runner.tsx ~180 LOC. Mount component, listens to slice, -│ │ owns lifecycle effects (route, resolve, focus). -│ ├── tour-overlay.tsx ~140 LOC. Spotlight + click-shield. Uses -│ │ `box-shadow: 0 0 0 9999px rgba(0,0,0,.55)` on -│ │ a fixed div sized to the resolved target rect. -│ ├── tour-popover.tsx ~220 LOC. Tooltip card (Radix Popover wrapper -│ │ — see unit tour-2). Title, body, step counter, -│ │ prev/next/skip, focus trap, role="dialog". -│ └── __tests__/ -│ ├── tour-runner.test.tsx -│ ├── tour-overlay.test.tsx -│ └── tour-popover.test.tsx -├── hooks/ -│ ├── use-tour.ts ~80 LOC. Public hook: start / stop / skip / -│ │ advance / previous + selectors. -│ ├── use-target-resolver.ts ~140 LOC. rAF + MutationObserver retry loop -│ │ with budget. Returns { rect, element, status }. -│ ├── use-element-position.ts ~120 LOC. ResizeObserver + scroll/resize -│ │ listener that re-reads getBoundingClientRect. -│ ├── use-tour-keyboard.ts ~60 LOC. Esc / →/Enter / ←. window.keydown. -│ ├── use-tour-route.ts ~80 LOC. react-router-dom's useNavigate. Waits -│ │ for pathname match before resolving target. -│ └── __tests__/... -├── store/ -│ ├── tour-slice.ts ~150 LOC. RTK slice + persistence thunks. -│ └── __tests__/tour-slice.test.ts -├── config/ -│ ├── tours.ts ~40 LOC. `export const tours: Tour[] = [...]`. -│ ├── canvas-tour.ts ~80 LOC. Sample tour definition. -│ ├── palette-tour.ts ~60 LOC. Sample tour definition. -│ └── __tests__/tours.test.ts -└── utils/ - ├── tour-registry.ts ~70 LOC. Map<string, Tour>. registerTour(), - │ getTour(id), allTours(). - ├── focus-trap.ts ~80 LOC. Pure focus-cycle helper for popover. - ├── target-rect.ts ~50 LOC. clampRectToViewport, expandPad, etc. - └── __tests__/... -``` - -Approx total ~1,600 LOC of source + tests, every file inside the LOC band. - -### 1.2 Anchors - -Existing IDs stay verbatim. New anchors are added with `data-tour-id` — -**deliberately separate from `data-testid`** so test contracts and tour -contracts can evolve independently. Verified anchors today: - -| ID | File | Status | -| -------------------------------- | ------------------------------------------------------------------------- | --------------------------------------- | -| `#ice-canvas-svg` | `features/canvas/components/svg-canvas.tsx:385` | exists | -| `#ice-ai-panel` | `features/ai/components/ai-chat-panel.tsx:126` | exists | -| `#ice-ai-input-message` | same | exists | -| `#ice-ai-btn-send` | same | exists | -| `#ice-palette-panel` | `features/palette/components/resource-palette.tsx:142` | exists | -| `#ice-palette-search-input` | `features/palette/sections/blocks-section.tsx:74` | exists | -| `#ice-palette-provider-select` | same:79 | exists | -| `#ice-properties-panel` | `features/properties/components/sections/node-properties-section.tsx:149` | exists (also `project-overview.tsx:63`) | -| `#ice-properties-node-name` | `features/properties/components/sections/node-identity-card.tsx:42` | exists | -| `#ice-folder-btn-create-project` | `packages/web/src/pages/folder-view.tsx:96` | exists | - -Anchors that need adding (separate units, all `data-tour-id="..."` since -none of these surfaces have established id patterns yet): - -| `data-tour-id` | File | Unit | -| ------------------------------------------------------------------------------------------------ | ----------------------------------------------- | ------ | -| `app-settings-tab-ai`, `app-settings-btn-save` | `packages/web/src/pages/app-settings.tsx` | tour-9 | -| `wizard-btn-next`, `wizard-btn-back`, `wizard-step-N` | `features/wizard/components/project-wizard.tsx` | tour-9 | -| `cost-panel-root`, `cost-panel-tier-slider` | `features/cost/components/cost-panel.tsx` | tour-9 | -| `sidebar-strip-cost`, `sidebar-strip-ai`, `sidebar-strip-properties`, `sidebar-strip-validation` | right-sidebar strip toggles | tour-9 | - -## 2. Public API surface - -### 2.1 Mount point - -`<TourRunner />` mounts **once**, in `packages/web/src/app/app.tsx`, -inside `BrowserRouter` and `LocaleProvider` (so it can use `useNavigate` -and `useTranslation`) but outside any `<Routes>` element so it survives -route changes: - -```tsx -<BrowserRouter> - <TourRunner /> {/* NEW */} - <Routes>...</Routes> -</BrowserRouter> -``` - -It is a sibling of `<Routes>`, not a wrapper, so the popover and overlay -portal at `document.body` regardless of which route is active. - -### 2.2 `useTour()` hook - -```ts -interface UseTour { - activeTourId: string | null; - stepIdx: number; // 0-based - totalSteps: number; - isFirst: boolean; - isLast: boolean; - isCompleted: (id: string) => boolean; - start: (tourId: string) => void; - advance: () => void; // → stepIdx + 1, finishes if last - previous: () => void; // → stepIdx − 1, no-op at 0 - skip: () => void; // → mark completed, close - stop: () => void; // → close without marking completed -} - -export function useTour(): UseTour; -``` - -Internally a thin selector + dispatcher around `tour-slice`. - -### 2.3 Type shapes - -```ts -// tour.types.ts - -export type Placement = 'top' | 'bottom' | 'left' | 'right' | 'auto'; - -export interface TourStepActions { - /** Override default "Next" label. i18n key, runs through t(). */ - nextLabel?: string; - /** Override default "Back" label. */ - backLabel?: string; - /** Hide the skip button on this step (e.g. terminal step). */ - hideSkip?: boolean; -} - -export interface TourStep { - id: string; // unique within tour - /** CSS selector (e.g. '#ice-canvas-svg', '[data-tour-id="..."]') OR - * a thunk returning the live element. Selector preferred — JSON-friendly. */ - target: string | (() => Element | null); - /** i18n key, evaluated through t(). */ - title: string; - /** Either an i18n key or a ReactNode. */ - body: string | React.ReactNode; - placement?: Placement; // default 'auto' - /** Optional padding around the target rect for the spotlight. Default 8. */ - pad?: number; - /** Route to navigate to before resolving target. Skipped if pathname - * already starts with this. Compared as-is (no params). */ - route?: string; - /** Runs after navigation completed AND target resolved AND placed. */ - onEnter?: (ctx: TourLifecycleCtx) => void | Promise<void>; - /** Runs before stepIdx changes (or close). */ - onExit?: (ctx: TourLifecycleCtx) => void | Promise<void>; - /** If returns false the step is skipped (auto-advance). */ - condition?: (ctx: TourLifecycleCtx) => boolean; - actions?: TourStepActions; -} - -export interface Tour { - id: string; // 'canvas-tour', 'palette-tour', ... - /** i18n key for tour-level title (used in registry UI / restart menu). */ - title: string; - steps: TourStep[]; - /** Auto-fire predicate. If returns true the tour starts on app boot. */ - autoStart?: (s: AutoStartCtx) => boolean; - /** When true, the engine will NOT mark this tour completed on skip - * (rare, e.g. tutorial-mode tours that the user re-runs intentionally). */ - manualOnly?: boolean; -} - -export interface TourLifecycleCtx { - tourId: string; - stepId: string; - stepIdx: number; - dispatch: AppDispatch; - navigate: NavigateFunction; -} - -export interface AutoStartCtx { - user: User | null; // from account-slice - completedTours: string[]; // from tour-slice + persistence - pathname: string; -} -``` - -### 2.4 Registration - -Two paths, both supported: - -1. **Static config (preferred for in-tree tours).** Each tour exports - from `config/<name>-tour.ts`. The barrel `config/tours.ts` collects - them into `export const tours: Tour[]`. `<TourRunner />` calls - `registerTour(t)` for each at mount. - -2. **Dynamic registration (for plugins/integrations later).** - `registerTour(tour: Tour)` and `unregisterTour(id: string)` are - exported from the barrel. The registry is a module-scoped - `Map<string, Tour>`. - -Tour ids and step ids are validated at register time — duplicates throw -in dev (`process.env.NODE_ENV !== 'production'`), warn-only in prod. - -## 3. Engine behavior - -### 3.1 Spotlight technique - -A single fixed `<div>` portal'd to `document.body`, sized and positioned -to the resolved target rect (plus `pad` from the step). CSS: - -```css -position: fixed; -border-radius: 8px; -pointer-events: none; -box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55); -transition: - top 180ms, - left 180ms, - width 180ms, - height 180ms; -``` - -Why box-shadow over SVG mask: zero DOM nodes per dim region, animates on -the GPU, and the page underneath stays interactive on the inner rect. -Click-shield: a separate full-viewport invisible div listens for clicks -outside the rect → `dispatch(skipTour())`. - -### 3.2 Element resolution - -`use-target-resolver.ts` resolves a `target` (selector or thunk) with a -retry budget. Required because targets in lazy-mounted panels (AI chat, -properties) may not be in the DOM when the step activates. - -``` -budget = 30 frames (~500 ms at 60Hz) -``` - -Implementation: rAF loop. Every frame, run `document.querySelector(...)` -or thunk, if found return rect; if budget exhausted return -`{ status: 'missing' }`. A `MutationObserver` on `document.body` is -attached **only after** the first 6 frames fail, to avoid the hot-path -cost on common cases. Status flows back through Redux — -`'resolving' | 'placed' | 'missing'`. - -If `'missing'` after budget: log dev warning, dispatch `skipStep()` so -the tour continues (configurable per step via `step.required`, default -false). - -### 3.3 Resize / scroll handling - -`use-element-position.ts` wires the placed-target rect to live updates: - -- `ResizeObserver(target)` and `ResizeObserver(document.documentElement)` - → re-read rect. -- `window.addEventListener('scroll', ..., { capture: true, passive: true })` - to catch scrolls inside any scroll container. -- `IntersectionObserver(target)` with `threshold: [0, 0.5, 1]`. When - `intersectionRatio < 0.5`, the engine calls - `target.scrollIntoView({ behavior: 'smooth', block: 'center' })` once, - **trailing-edge debounced** at 250 ms — each under-0.5 event resets - the pending timer, and the call fires once 250ms after the LAST - under-0.5 event in the window. - -All listeners are torn down on step exit. Cleanup is the most common -focus-trap leak risk — pin a test for "previous step's listeners are -removed on advance" (see test plan in tour-4). - -### 3.4 Route navigation - -The router is `react-router-dom@6.30.3` (`BrowserRouter`, `useNavigate`, -`useLocation`). `use-tour-route.ts`: - -``` -on step enter, if step.route is set and !pathname.startsWith(step.route): - navigate(step.route) - await pathname change (subscribe to useLocation, resolve when new - pathname startsWith step.route) -then resolve target -``` - -The "wait for pathname change" piece is handled inside the `TourRunner` -effect, gated by a state machine field `phase: 'navigating' | 'resolving' -| 'placed'`. This is necessary because navigation is async — calling -`navigate()` does not synchronously change `useLocation()`. - -### 3.5 Step lifecycle - -``` -advance() / start() → - dispatch(setStep(idx)) - phase = 'navigating' - if step.route: navigate(step.route); wait for pathname match - phase = 'resolving' - resolve target (rAF + retry, see 3.2) - if missing: skipStep() (or surface error if step.required) - phase = 'placed' - await step.onEnter?.(ctx) ← runs ONCE; errors are caught + logged - attach overlay + popover - attach keyboard + position listeners - -previous() / advance(next) → - await step.onExit?.(ctx) - detach listeners - detach overlay + popover - proceed to new step -``` - -`onEnter` and `onExit` await both Promise and void returns. Errors -caught and logged via `console.warn` — they never abort the tour. - -### 3.6 Keyboard - -`use-tour-keyboard.ts` registers `window.keydown` (capture-phase) only -while a tour is active: - -| Key | Effect | -| ----------------------- | ----------------------------------------------------------------- | -| `Escape` | `dispatch(stopTour())` (does NOT mark completed; user can resume) | -| `ArrowRight` or `Enter` | `advance()` | -| `ArrowLeft` | `previous()` | -| `Tab` / `Shift+Tab` | handled by focus trap (see 3.7) | - -`Enter` is suppressed when the active element is a form input — the -user is typing inside the highlighted UI (palette search, property -field), not advancing the tour. Detected via -`document.activeElement?.tagName in {INPUT, TEXTAREA}`. - -### 3.7 Accessibility - -- Popover root: `role="dialog"`, `aria-modal="false"` (the rest of the - page is intentionally interactive — this is a coachmark, not a modal), - `aria-labelledby="tour-popover-title"`. -- Focus enters the popover when a step opens; on close, focus restores - to whatever element was focused before `start()`. -- `utils/focus-trap.ts` keeps `Tab` cycling inside the popover when the - user is interacting with popover-internal controls. Tab-out from the - popover lets focus reach the spotlit element (this is intentional — - tutorials sometimes want users to type into the highlighted field). -- Reduced motion: `useReducedMotion()` already exists at - `packages/ui/src/shared/hooks/use-reduced-motion.ts`. When `true`, the - spotlight transitions are disabled and `scrollIntoView` uses - `behavior: 'auto'`. -- `aria-live="polite"` on the step counter so screen readers announce - step changes. - -## 4. State - -### 4.1 New slice: `tour-slice.ts` - -Lives at `packages/ui/src/features/tour/store/tour-slice.ts` (NOT under -`packages/ui/src/store/slices/` — feature-local slice, follows the -pattern set by deploy-slice's reducers/ subfolder being feature-local -file-organization). Registered in the root store's `configureStore` call -exactly once. - -```ts -interface TourState { - activeTourId: string | null; - stepIdx: number; // 0-based - phase: 'idle' | 'navigating' | 'resolving' | 'placed' | 'missing'; - completedTours: string[]; // ids in completion order - /** Per-tour skipped-step counts, for telemetry. Optional. */ - perTour: Record<string, { stepsAdvanced: number; skipped: boolean }>; - /** Hydrated from User.completedTours on first profile fetch. */ - hydrated: boolean; -} -``` - -Reducers: `startTour`, `setStep`, `setPhase`, `markCompleted`, -`stopTour`, `hydrateFromUser`, `resetTour`. - -Action logger middleware prefix: add `'tour/'` to -`LOGGED_ACTION_PREFIXES` in `packages/ui/src/store/index.ts`. - -### 4.2 Persistence — three-tier - -1. **localStorage fast path.** Key `ice-completed-tours` (JSON array). - Read synchronously in slice initialState computation so the engine - knows to suppress auto-fired tours before the profile API resolves. -2. **API authoritative.** New `User.completed_tours` column on the - Prisma model — `String?` storing a JSON-encoded array (sqlite-friendly, - no array type). Hydrated via `fetchProfile` (account-slice already - fetches; extend its select). On `markCompleted`, slice dispatches a - thunk that PUTs `/api/onboarding/completed-tours/:id` (new route). -3. **Slice merges both** in `hydrateFromUser`: union of localStorage + - server, server wins on conflict, write merged set back to localStorage. - -The Prisma migration is its own unit (tour-7) — separate from the slice -(tour-6) because it touches `services/iam` and the database. The route -addition lives in `services/iam/src/routes/onboarding.ts` (extends the -existing onboarding router rather than introducing a new one — same -auth middleware, same shape). - -### 4.3 Auto-fire trigger logic - -Default policy: - -| Tour | Trigger | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `canvas-tour` | After wizard completes AND first project canvas is open AND `!completedTours.includes('canvas-tour')`. Fires on the next `placed` route — i.e. canvas mounted. | -| `palette-tour` | First time the palette panel is opened AND `canvas-tour` is completed. | -| `ai-tour` | First time `#ice-ai-panel` is opened AND `palette-tour` is completed. | -| `cost-tour` | Manual only (Help menu → "Show me around → Cost") in v1. | - -Auto-fire is evaluated by `<TourRunner />` on every `useLocation()` -change AND on `account.user` mutation. The logic is centralized in -`use-tour-autostart.ts` (called from `TourRunner`). - -Recommendation, **flag this for orchestrator confirmation**: in v1 -auto-fire is OFF by default and tours are launched only via: - -1. A `tour=<id>` URL param on first project (e.g. wizard's "Finish & - tour me around" button appends `?tour=canvas-tour` to the redirect). -2. The Help menu (existing AppBar slot). - -This keeps v1 from accidentally surprising users who already know the -app. v2 can flip to predicate-based auto-fire once the engine has run in -production for a release. See unit tour-13. - -## 5. Configuration model - -### 5.1 Sample tour: `canvas-tour.ts` - -```ts -import type { Tour } from '../tour.types'; - -export const canvasTour: Tour = { - id: 'canvas-tour', - title: 'tour.canvas.title', // "Welcome to your canvas" - steps: [ - { - id: 'canvas-overview', - target: '#ice-canvas-svg', - title: 'tour.canvas.overview.title', - body: 'tour.canvas.overview.body', - placement: 'auto', - pad: 16, - }, - { - id: 'palette-intro', - target: '#ice-palette-panel', - title: 'tour.canvas.palette.title', - body: 'tour.canvas.palette.body', - placement: 'right', - }, - { - id: 'palette-search', - target: '#ice-palette-search-input', - title: 'tour.canvas.search.title', - body: 'tour.canvas.search.body', - placement: 'right', - pad: 4, - }, - { - id: 'properties-intro', - target: '#ice-properties-panel', - title: 'tour.canvas.properties.title', - body: 'tour.canvas.properties.body', - placement: 'left', - }, - { - id: 'ai-intro', - target: '#ice-ai-panel', - title: 'tour.canvas.ai.title', - body: 'tour.canvas.ai.body', - placement: 'left', - // Open the AI panel on enter so it's actually visible. - onEnter: ({ dispatch }) => { - dispatch({ type: 'ui/openSidebarPanel', payload: 'ai' }); - }, - actions: { hideSkip: true, nextLabel: 'tour.actions.finish' }, - }, - ], -}; -``` - -### 5.2 i18n keys - -Add a new top-level `tour` namespace in -`packages/ui/src/i18n/en.json` and the parallel zh.json. Skeleton: - -```json -"tour": { - "actions": { - "next": "Next", - "back": "Back", - "skip": "Skip tour", - "finish": "Got it" - }, - "canvas": { - "title": "Welcome to your canvas", - "overview": { "title": "...", "body": "..." }, - "palette": { "title": "...", "body": "..." }, - "search": { "title": "...", "body": "..." }, - "properties": { "title": "...", "body": "..." }, - "ai": { "title": "...", "body": "..." } - } -} -``` - -`TranslationKey` is autogenerated via `NestedKeyOf<TranslationData>` — -new keys are typed automatically once the json file is updated. Tests -in `tour-popover.test.tsx` mock `useTranslation` with a passthrough -identity (already a documented pattern; see -`vi-mock-paths-resolve-relative-to-test-file-not-source-file` learning -for the relative-path gotcha — popover tests will need -`'../../../../i18n'` from `__tests__/tour-popover.test.tsx`). - -## 6. Per-unit plan - -Order is leaves-first so the engine runs end-to-end as early as -possible. Implementer can split tour-3 across two days if needed; every -other unit is a single sitting. - ---- - -### tour-1 — Types + barrel skeleton - -**Files** - -- `packages/ui/src/features/tour/tour.types.ts` (new) -- `packages/ui/src/features/tour/index.ts` (new) -- `packages/ui/src/features/tour/utils/tour-registry.ts` (new) -- `packages/ui/src/features/tour/utils/__tests__/tour-registry.test.ts` (new) - -**Contract.** Defines `Tour`, `TourStep`, `Placement`, `TourLifecycleCtx`, -`AutoStartCtx`. Registry is a module-scoped `Map<string, Tour>` with -`registerTour(t)` (throws on duplicate id in dev, warn in prod), -`getTour(id)`, `unregisterTour(id)`, `allTours()`. Barrel re-exports -public surface only — internal hooks are NOT exported. - -**Tests (≥10).** - -- registerTour adds to map; getTour returns it. -- duplicate registerTour throws in `NODE_ENV !== 'production'`. -- duplicate registerTour warns + overwrites in production. -- unregisterTour removes; subsequent getTour returns undefined. -- allTours returns array snapshot, not the live map (mutation-safe). -- registerTour validates step ids are unique within a tour, throws if not. -- registerTour rejects empty steps array. - -**Risks.** None — pure module. - -**Deps.** None. - ---- - -### tour-2 — Wrapped Popover primitive - -**Files** - -- `packages/ui/src/shared/components/ui/popover.tsx` (new) -- `packages/ui/src/shared/components/ui/index.ts` (modified — add export) -- `packages/ui/src/shared/components/ui/__tests__/popover.test.tsx` (new) - -**Contract.** Wraps `@radix-ui/react-popover` (already in -`packages/ui/package.json`) following the same shape as `dialog.tsx` / -`tooltip.tsx`: `Popover`, `PopoverTrigger`, `PopoverContent`, -`PopoverAnchor`, `PopoverPortal`. Tailwind classes for -`bg-ice-raised`, `border-ice-border`, `text-ice-text-1`, animations -mirroring the tooltip. NOT tour-specific. - -**Tests (≥6).** Tree-walker: PopoverContent renders with class string -including expected tokens; Anchor passes `ref` through; PopoverPortal -respects portalled markup (mock createPortal). - -**Risks.** None — primitive wrapper. - -**Deps.** None. (Pulled out as a standalone unit so other features can -reuse it. The tour engine will use the lower-level Radix primitive -directly via `PopoverAnchor` for arbitrary-element anchoring, but the -wrapped pieces still apply for the popover content shell.) - ---- - -### tour-3 — Target resolver hook - -**Files** - -- `packages/ui/src/features/tour/hooks/use-target-resolver.ts` (new) -- `packages/ui/src/features/tour/utils/target-rect.ts` (new) -- `packages/ui/src/features/tour/hooks/__tests__/use-target-resolver.test.ts` (new) -- `packages/ui/src/features/tour/utils/__tests__/target-rect.test.ts` (new) - -**Contract.** `useTargetResolver(target: string | (() => Element | null), { budget = 30, padding = 0 }) → { status: 'idle' | 'resolving' | 'placed' | 'missing', element: Element | null, rect: DOMRect | null }`. -rAF retry loop with budget. After 6 failed frames, attaches a -`MutationObserver` on `document.body { childList: true, subtree: true }` -which retriggers a single rAF check on each mutation batch (debounced). -On unmount or target change, all timers/observers torn down. - -`target-rect.ts` exports `expandRect(rect, pad)`, `clampRectToViewport(rect)`. - -**Tests (≥18).** - -- selector resolves on frame 0 → status 'placed' with rect. -- thunk resolves on frame 0 → same. -- target absent for 30 frames → 'missing'. -- target absent for 5 frames then appears via Mutation → 'placed'. -- changing the target prop tears down old observer. -- unmount tears down both rAF + MutationObserver. -- expandRect adds pad to all sides; clampRectToViewport clips negatives. -- rect re-read on every successful frame (live drag scenario). - -**Risks.** - -- rAF + MutationObserver in node-env vitest. Use the - `stubbing-window-and-keyboardevent-for-node-env-keydown-listener-tests` - pattern: `vi.stubGlobal('window', { requestAnimationFrame, cancelAnimationFrame, MutationObserver })`. -- MutationObserver constructor needs to be a class with `observe`, - `disconnect`, `takeRecords` — stub minimally. - -**Deps.** None (pure hook + util). - ---- - -### tour-4 — Element-position hook - -**Files** - -- `packages/ui/src/features/tour/hooks/use-element-position.ts` (new) -- `packages/ui/src/features/tour/hooks/__tests__/use-element-position.test.ts` (new) - -**Contract.** `useElementPosition(element: Element | null, { observeViewport = true }) → DOMRect | null`. Returns the live rect, updated via: - -- `ResizeObserver(element)` on the target. -- `ResizeObserver(document.documentElement)` on the viewport (when `observeViewport`). -- `window.addEventListener('scroll', _, { capture: true, passive: true })` so scrolls inside any container trigger an update. -- `IntersectionObserver(element, { threshold: [0, 0.5, 1] })` — when ratio drops below 0.5, calls `element.scrollIntoView({ behavior, block: 'center' })`. Debounced 250 ms. - -**Tests (≥14).** - -- Initial rect on mount. -- ResizeObserver fires → rect updated. -- Scroll listener fires → rect updated. -- IntersectionObserver `< 0.5` ratio → scrollIntoView called once - (debounced — multiple ratios under 0.5 within 250ms call it once). -- `useReducedMotion() === true` → `behavior: 'auto'`. -- Element changes from non-null to null → all listeners torn down. -- Unmount tears down every listener. - -**Risks.** Listener leak on element change is the highest-risk bug. -Pin "swap target then unmount → 0 listeners" explicitly. - -**Deps.** None. - ---- - -### tour-5 — Focus trap util - -**Files** - -- `packages/ui/src/features/tour/utils/focus-trap.ts` (new) -- `packages/ui/src/features/tour/utils/__tests__/focus-trap.test.ts` (new) - -**Contract.** `installFocusTrap(container: HTMLElement, { initialFocus?: HTMLElement, returnFocus?: HTMLElement }) → () => void`. -Listens for keydown Tab on the container, queries focusable selectors -(`a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])` plus filter out `aria-hidden="true"` and `hidden`), -cycles focus inside. On install, focuses `initialFocus` or first focusable. -On uninstall, restores focus to `returnFocus`. - -Important: the trap is **soft** — it does NOT block focus from leaving -when the user clicks outside. Only `Tab`/`Shift+Tab` cycle. This lets -users intentionally tab into the spotlit page element. - -**Tests (≥8).** - -- Tab on last focusable → wraps to first. -- Shift+Tab on first → wraps to last. -- Initial focus respects `initialFocus`. -- Uninstall restores focus to `returnFocus`. -- Empty container (no focusables) → no-op, no throw. -- Reinstall on changed container is safe. - -**Risks.** Stash event listeners with named handlers so uninstall is -exact. Don't re-attach on re-render; the popover unit owns the lifecycle. - -**Deps.** None. - ---- - -### tour-6 — Tour slice - -**Files** - -- `packages/ui/src/features/tour/store/tour-slice.ts` (new) -- `packages/ui/src/features/tour/store/__tests__/tour-slice.test.ts` (new) -- `packages/ui/src/store/index.ts` (modified — register reducer + - add `'tour/'` to `LOGGED_ACTION_PREFIXES`) -- `packages/ui/src/features/tour/hooks/use-tour.ts` (new) -- `packages/ui/src/features/tour/hooks/__tests__/use-tour.test.ts` (new) - -**Contract.** Slice as defined in §4.1. `start(tourId)` resets stepIdx -to 0 and validates the tour is registered. `advance()` increments -stepIdx; if it equals `totalSteps` the slice transitions to `markCompleted` - -- closes. `stopTour()` closes without marking completed. - `hydrateFromUser({ completedTours: string[] })` merges with localStorage. - -`use-tour.ts` is the public consumer hook — selectors + dispatchers, -typed. The async persistence thunk (`persistCompletedTour`) lives in -the slice file. - -**Tests (≥30).** - -- start sets activeTourId, stepIdx=0, phase=navigating. -- start with unregistered tour throws (or no-ops with warn — implementer pick). -- advance: not-last step → stepIdx + 1, phase=navigating. -- advance: last step → markCompleted dispatched, slice closes. -- previous: stepIdx > 0 → stepIdx − 1. -- previous: stepIdx === 0 → no-op. -- skip: closes + adds to completedTours. -- stop: closes, completedTours unchanged. -- hydrateFromUser merges arrays + dedupes; later set wins. -- localStorage fast-path read in initialState (mock localStorage). -- localStorage write on markCompleted. -- isCompleted selector + totalSteps selector. - -**Risks.** - -- RTK 2 unknown-action-payload double-cast in tests - (`redux-toolkit-unknown-action-payload-needs-double-cast-via-unknown`). -- localStorage writes must be guarded by `try { ... } catch` (matches - pattern in `onboarding-checklist.tsx:32-38`). - -**Deps.** tour-1. - ---- - -### tour-7 — User.completed_tours migration + API route - -**Files** - -- `packages/db/prisma/schema.prisma` (modified — add `completed_tours String?` to User) -- `packages/db/prisma/migrations/<ts>_add_user_completed_tours/migration.sql` (new) -- `services/iam/src/routes/onboarding.ts` (modified — add PUT `/api/onboarding/completed-tours/:id`) -- `services/iam/src/routes/__tests__/onboarding.test.ts` (modified — extend) - -**Contract.** New User column `completed_tours` (sqlite-friendly, -`String?` storing a JSON-encoded `string[]`). Default empty. Existing -`/status` route extends its `select` to include `completed_tours`. New -`PUT /api/onboarding/completed-tours/:id` appends an id (idempotent — -no-op if already present), returns the updated array. `requireAuth` -applies, same as the rest of the router. - -**Tests (≥6).** - -- /status returns `completed_tours: []` for fresh user. -- PUT /completed-tours/:id appends; second PUT same id is idempotent. -- Returns 401 when unauthenticated. -- Concurrency: parallel PUTs of two ids both land (unique merge in - service code; SQLite update-if doesn't lock the read). - -**Risks.** - -- SQLite has no array column, so we serialize JSON in/out at the route - layer. Document this as a learning if it bites. -- Migration must NOT default to a non-empty value — fresh installs - should see no completed tours. - -**Deps.** None functionally — but landing this BEFORE tour-6 means the -slice can read the real shape. If sequencing is awkward, land tour-6 -first with a stub thunk, then wire the real PUT in tour-7. - ---- - -### tour-8 — Tour overlay component - -**Files** - -- `packages/ui/src/features/tour/components/tour-overlay.tsx` (new) -- `packages/ui/src/features/tour/components/__tests__/tour-overlay.test.tsx` (new) - -**Contract.** `<TourOverlay rect={DOMRect | null} pad={number} onSkip={() => void} />`. -Renders a fixed div spotlight (box-shadow technique) AND a click-shield -sibling that catches outside-clicks → `onSkip()`. Pure presentational — -takes rect as a prop (the runner owns rect derivation). - -**Tests (≥10).** - -- Renders nothing when rect is null. -- Spotlight div top/left/width/height match rect + pad. -- Click on shield → onSkip. -- Click on spotlight inner → does NOT call onSkip (pointer-events: none - on shield within rect bounds). -- `useReducedMotion()` true → no transition class. - -**Risks.** Box-shadow technique fails if the target is in a -`position: fixed` ancestor whose own `transform` creates a stacking -context — shadow renders relative to that, not the viewport. In v1 the -plan accepts this limitation (anchors are app-level: canvas, panels). -Document as v1-out-of-scope (§7). - -**Deps.** tour-1. - ---- - -### tour-9 — Add `data-tour-id` anchors - -**Files (all modified, no new files)** - -- `packages/web/src/pages/app-settings.tsx` — add `data-tour-id="app-settings-tab-ai"` to AI tab trigger and `data-tour-id="app-settings-btn-save"` to save button. -- `packages/ui/src/features/wizard/components/project-wizard.tsx` — add `data-tour-id="wizard-btn-next"`, `wizard-btn-back`, `wizard-step-${idx+1}`. -- `packages/ui/src/features/cost/components/cost-panel.tsx` — `data-tour-id="cost-panel-root"` on container, `cost-panel-tier-slider` on the slider input. -- The right-sidebar strip toggle file (verify path during the unit; - candidates in `packages/ui/src/shared/components/sidebar-strip.tsx` - per the existing primitives) — `data-tour-id="sidebar-strip-cost"`, - `sidebar-strip-ai`, `sidebar-strip-properties`, `sidebar-strip-validation`. - -**Contract.** Pure attribute additions. Zero behavior change. NOT -substituting for `data-testid` — both attributes coexist where -applicable. `data-tour-id` selectors used in tour configs only. - -**Tests.** Each touched file's existing test file gets one extra -assertion: "expected `data-tour-id="..."` attribute present on -\<element\>". No new test files. Touched files (5) — 5 extra assertions. - -**Risks.** `feedback_validated_blocks` doesn't apply (no behavior -change to GitHub Repo / Custom Domain / Static Site / Private Network -blocks). Per `feedback_no_canvas_inputs`, no input goes onto the -canvas — none of these anchors live on canvas blocks anyway. - -**Deps.** None. Can land in parallel with the engine work, but in -practice land it just before tour-12 so the configured tours have real -selectors to point at. - ---- - -### tour-10 — Tour popover component - -**Files** - -- `packages/ui/src/features/tour/components/tour-popover.tsx` (new) -- `packages/ui/src/features/tour/components/__tests__/tour-popover.test.tsx` (new) - -**Contract.** `<TourPopover step={TourStep} stepIdx={number} totalSteps={number} placement={Placement} anchor={Element} onAdvance onPrevious onSkip onClose />`. -Renders the Radix Popover (from tour-2) anchored to `anchor` via -`PopoverAnchor`. Title from `t(step.title)`, body from `t(step.body)` -or `step.body` directly when ReactNode. Footer: step counter -("3 of 5"), Back, Skip, Next. Focus trap installed on mount via tour-5 -util. `role="dialog"`, `aria-labelledby`, `aria-describedby`. - -`placement: 'auto'` → compute best side from anchor rect vs. viewport. -Use a tiny utility (~25 LOC) inside the component file or `utils/auto-placement.ts` -if it grows: prefer the side with most space, fall back top → bottom → -right → left. - -**Tests (≥18).** - -- Renders title + body + counter + buttons. -- isFirst → no Back button. -- step.actions.hideSkip → no Skip. -- step.actions.nextLabel/backLabel respected. -- isLast → Next button label is `t('tour.actions.finish')` unless - overridden. -- onAdvance/onPrevious/onSkip wired to clicks. -- ReactNode body bypasses t(). -- Focus moves to the popover on mount. -- Tab cycles inside; Shift+Tab from first → last. -- Auto-placement picks `right` when target on left edge; etc. -- Reduced-motion → no transition class. - -**Risks.** - -- Radix Popover focus-management vs. our focus trap: configure - `<PopoverContent onOpenAutoFocus>` and `onCloseAutoFocus` to no-op - so our trap owns focus. Otherwise Radix steals first focus. -- Test mock-path depth: `__tests__/tour-popover.test.tsx` → - `vi.mock('../../../../i18n', ...)` (one extra `../` per - `vi-mock-paths-resolve-relative-to-test-file-not-source-file`). - -**Deps.** tour-1, tour-2, tour-5. - ---- - -### tour-11 — Keyboard + route hooks - -**Files** - -- `packages/ui/src/features/tour/hooks/use-tour-keyboard.ts` (new) -- `packages/ui/src/features/tour/hooks/use-tour-route.ts` (new) -- `packages/ui/src/features/tour/hooks/__tests__/use-tour-keyboard.test.ts` (new) -- `packages/ui/src/features/tour/hooks/__tests__/use-tour-route.test.ts` (new) - -**Contract.** -`use-tour-keyboard(active: boolean, { onAdvance, onPrevious, onSkip })`. -Attaches `window.addEventListener('keydown', ...)` capture-phase only -when `active`. Detaches on `active === false`. Suppresses -ArrowRight/Enter when `document.activeElement` is INPUT/TEXTAREA. - -`use-tour-route(targetRoute: string | undefined)` → `{ phase: 'idle' | 'navigating' | 'arrived', navigate(): void }`. Watches `useLocation()` and resolves `arrived` when pathname matches. - -**Tests (≥16).** - -- ArrowRight → onAdvance. -- ArrowRight while focused on INPUT → no advance. -- ArrowLeft → onPrevious. -- Enter → onAdvance; Enter on INPUT → no advance. -- Escape → onSkip. -- listener detached when `active=false`. -- targetRoute undefined → phase='arrived' immediately. -- targetRoute set + pathname mismatch → phase='navigating'. -- pathname changes to match → phase='arrived'. - -**Risks.** Per -`stubbing-window-and-keyboardevent-for-node-env-keydown-listener-tests`, -stub `window.addEventListener` etc. for node-env. KeyboardEvent stub -class needs `key` field at minimum. - -**Deps.** None for keyboard hook; `useLocation`/`useNavigate` from -react-router-dom for route hook (mock via `vi.mock`). - ---- - -### tour-12 — TourRunner + tour configs - -**Files** - -- `packages/ui/src/features/tour/components/tour-runner.tsx` (new) -- `packages/ui/src/features/tour/components/__tests__/tour-runner.test.tsx` (new) -- `packages/ui/src/features/tour/config/tours.ts` (new) -- `packages/ui/src/features/tour/config/canvas-tour.ts` (new) -- `packages/ui/src/features/tour/config/palette-tour.ts` (new) -- `packages/ui/src/features/tour/config/__tests__/tours.test.ts` (new) -- `packages/ui/src/i18n/en.json` (modified — add `tour` namespace) -- `packages/ui/src/i18n/zh.json` (modified — same keys, English placeholder copy is acceptable; translation pass is a separate task) -- `packages/web/src/app/app.tsx` (modified — mount `<TourRunner />` inside `<BrowserRouter>`) - -**Contract.** Top-level coordinator: - -- On mount, calls `registerTour(t)` for each tour in `tours.ts`. -- Subscribes to `tour-slice`. When `activeTourId` flips on: - - read step, dispatch `setPhase('navigating')`. - - if step.route, navigate; await pathname match. - - dispatch `setPhase('resolving')`, run useTargetResolver. - - on resolve, dispatch `setPhase('placed')`, run `step.onEnter`. - - render `<TourOverlay>` + `<TourPopover>` portals, wire keyboard. -- On step change, runs `step.onExit`, tears down listeners, repeats. -- On stop/skip/complete, restores focus, removes overlay, clears state. - -This file is the runner + the controller — no Redux logic in the -overlay/popover children. Keep `tour-runner.tsx` ≤ 240 LOC; if it grows, -extract a `use-tour-runner.ts` hook. - -**Tests (≥22).** - -- registerTour called for each config on mount. -- start('canvas-tour') → phase progresses idle → navigating → resolving → placed. -- step.route triggers navigate. -- step.onEnter awaited before listeners attach. -- step.onExit awaited before next step. -- skip/stop/Escape → focus restored. -- Multiple tours can coexist in registry; only one active at a time. -- Resize/scroll on placed step → popover repositions (asserted via - rect prop changes on TourPopover mock). - -**Risks.** - -- Focus restoration: stash `document.activeElement` at `start`; restore - in cleanup. `document.activeElement` may be null in tests — guard. -- React StrictMode double-effect: ensure registerTour is idempotent - (already required by tour-1) — duplicate-register in dev is the test - case. - -**Deps.** tour-1, tour-3, tour-4, tour-5, tour-6, tour-8, tour-10, tour-11. - ---- - -### tour-13 — Auto-fire + Help-menu launcher - -**Files** - -- `packages/ui/src/features/tour/hooks/use-tour-autostart.ts` (new) -- `packages/ui/src/features/tour/hooks/__tests__/use-tour-autostart.test.ts` (new) -- `packages/ui/src/shared/components/app-bar.tsx` (modified — Help menu - gets a "Show me around" submenu with one entry per registered tour) -- `packages/ui/src/shared/components/__tests__/app-bar.test.tsx` (modified) - -**Contract.** v1 default policy: - -- `use-tour-autostart` reads `?tour=<id>` from `useLocation().search`. - If present AND tour is registered AND not in `completedTours`, call - `start(id)` once, then strip the param via `navigate(pathname, { replace: true })`. -- All other tours are launched via the Help menu only. - -The hook lives in TourRunner. The Help-menu addition wires -`useTour().start(id)` per entry. - -**Tests (≥10).** - -- ?tour=canvas-tour → start dispatched, query param stripped. -- ?tour=unknown → ignored (warn in dev). -- Already in completedTours → ignored. -- Help-menu click on tour entry → start dispatched. - -**Risks.** Tour `?tour=` param interferes with deep-linking — strip on -fire. Help-menu UI requires a small decision on placement; defer to the -existing AppBar conventions (the Help item already has a subtree). - -**Deps.** tour-12. - ---- - -### tour-14 — Replace `OnboardingChecklist` with tour entry points - -**Files** - -- `packages/ui/src/features/onboarding/components/onboarding-checklist.tsx` (modified) -- `packages/ui/src/features/onboarding/components/__tests__/onboarding-checklist.test.tsx` (modified) -- `packages/ui/src/features/onboarding/components/onboarding-page.tsx` (modified — Finish button gets `?tour=canvas-tour` redirect) - -**Contract.** - -- The "create-and-start" finish on `onboarding-page.tsx` already navigates - to the project URL — append `?tour=canvas-tour` so first-launch users - see the canvas tour automatically. -- `OnboardingChecklist`'s items get a subtle "Show me how" link next to - each item that fires the corresponding tour. Existing checklist - behavior unchanged otherwise. - -**Tests.** Extend existing test files; don't add new ones. ≥4 new -assertions. - -**Risks.** Don't break the existing wizard flow. Per the brief, the -wizard stays intact — this unit is wiring only. - -**Deps.** tour-12, tour-13. - ---- - -## 7. Out of scope for v1 - -- **Branching tours.** All tours are linear sequences. Conditional - branches based on user choices are v2. -- **Embedded forms inside tour steps.** A step shows copy + buttons. - Forms live in the surfaces being taught, not the tour popover. -- **Voiceover / video / animated transitions inside the popover.** - Static copy + arrows only. -- **Cross-edition tours.** Cloud / multi-tenant editions can re-use - the engine but ship their own configs; the engine itself is - edition-agnostic. -- **Tour authoring UI.** Tours live in code, not in the database. -- **Progress sync across devices in real time.** `completed_tours` - persists per user but doesn't push — refresh required to see - another device's completions. -- **Targets inside transformed ancestors.** Box-shadow spotlight - doesn't render correctly when an ancestor has `transform: ...` and - creates a new stacking context. Documented limitation; targets - defined today are all top-level. -- **Telemetry pipe.** `perTour.stepsAdvanced` is captured in slice - state for future telemetry but no analytics emit happens in v1. - -## 8. Risks / open questions for orchestrator - -1. **Auto-fire policy.** §4.3 recommends OFF by default in v1 - (manual + `?tour=...` param only). Confirm — alternate is to enable - auto-fire after wizard completion for the canvas tour. - -2. **Slice location.** The blueprint proposes `features/tour/store/tour-slice.ts` - (feature-local). The repo convention to date is - `packages/ui/src/store/slices/<name>-slice.ts` (centralized). Either - works. Centralized is more consistent; feature-local keeps the tour - feature self-contained for plugin-style extraction. Recommend - feature-local. Confirm before tour-6 lands. - -3. **DB migration shape.** `User.completed_tours` is JSON-string in - sqlite. Alternative: a separate `UserTourCompletion` table with - `(user_id, tour_id, completed_at)`. Stronger query story, more code. - Recommend JSON-string for v1 simplicity. Confirm before tour-7. - -4. **Help-menu placement.** Tour-13 adds a "Show me around" submenu to - the existing AppBar Help entry. Need to confirm where in the existing - menu structure it lands (or a new top-level entry). - -5. **Reduced-motion default.** v1 honors `useReducedMotion()` (the hook - already exists). Confirm spotlight transitions and `scrollIntoView` - both fall back to instant — that is the plan. - -6. **i18n placeholder copy for zh.** Plan adds keys to en.json; zh.json - gets the same keys with English placeholder copy until a translation - pass. Acceptable per repo's existing zh.json style? (skim of the file - suggests yes.) Confirm. - -7. **`step.required` field.** §3.2 mentions `required` on `TourStep` - for "fail tour if target missing" semantics. The type shape in §2.3 - doesn't list it — leave OFF for v1, default to "skip-on-missing" so - tours never get stuck. Add field if/when a tour needs it. - -## 9. Sequencing summary - -``` -tour-1 types + registry -tour-2 Popover primitive (parallel with tour-1) -tour-3 target resolver -tour-4 element position (parallel with tour-3) -tour-5 focus trap -tour-6 tour slice + useTour (depends on tour-1) -tour-7 Prisma migration + API route (parallel with tour-6) -tour-8 TourOverlay (depends on tour-1) -tour-9 data-tour-id anchors (any time) -tour-10 TourPopover (depends on tour-1, tour-2, tour-5) -tour-11 keyboard + route hooks -tour-12 TourRunner + configs + en.json (depends on 1,3,4,5,6,8,10,11) -tour-13 auto-fire + Help menu (depends on tour-12) -tour-14 wire onboarding entry points (depends on tour-12, tour-13) -``` - -Earliest end-to-end working engine: after tour-12 (canvas-tour -launchable via `?tour=canvas-tour`). tour-13 and tour-14 are wiring -units that can be paused without blocking the engine itself. diff --git a/state/decisions.md b/state/decisions.md deleted file mode 100644 index 46d76ce4..00000000 --- a/state/decisions.md +++ /dev/null @@ -1,164 +0,0 @@ -# Decisions log - -Append-only log of architectural and process decisions for the multi-agent ICE workflow. - -**Rules** - -- New decisions: append a dated entry. Never edit past entries. -- Supersede an old decision by adding a new entry that references it under "Related". -- The only allowed edit to a past entry elsewhere in `state/` is appending a `_Promoted to: /docs/<path>_` line on a learning that's been promoted. That rule does not apply to entries in this file — `decisions.md` entries are never edited. - -(Note: state directory moved from `.claude/state/` to `state/` on 2026-04-29 — see the dated entry below for context. Past entries' prose mentions of `.claude/state/` are historical and stay verbatim per append-only.) - ---- - -## 2026-04-27 — Adopt persistent state system - -**Context.** The multi-agent ICE workflow (planner, implementer, critic, ux-tester) needs cross-session memory. Without it, each agent starts cold and re-derives the same conclusions, the orchestrator can't see what's in flight, and post-mortem learnings vanish at the end of a session. - -**Decision.** Adopt a three-file markdown state system under `.claude/state/`: - -- `decisions.md` — append-only log of architectural and process choices. -- `progress.md` — living document, owned exclusively by the orchestrator (main session). -- `learnings.md` — append-only log of non-obvious gotchas and patterns. - -State files live in `.claude/state/` (agent-managed operational state) and are cross-linked from `/docs/refactoring-patterns.md` (human entry point). Stabilized learnings — cited 3+ times or generalizing beyond one unit — get promoted into `/docs` as proper documentation, and the original learning entry is annotated with a `_Promoted to:_` back-reference. - -**Alternatives considered.** - -- _Single `state.json`._ Rejected. Markdown reads well in diffs, tolerates partial writes, and surfaces in `git blame`. JSON encourages whole-file overwrites; we want append-only. -- _Per-agent memory frontmatter inside each `.claude/agents/_.md`.\* Rejected. Couples state to the agent definition (so editing an agent's role would churn its memory), prevents cross-agent reads (the critic should see what the implementer learned), and fragments the orchestrator's view. -- _State in `/docs/state/`._ Rejected. `/docs` is human-authored documentation that ships with the repo; mixing agent-managed operational state into it muddies that contract. We cross-link from `/docs/refactoring-patterns.md` instead of co-locating. - -**Consequences.** - -- Every agent reads `decisions.md` and `learnings.md` before acting (see learning `read-state-first`). -- The orchestrator owns `progress.md` exclusively. Subagents never write to it. -- Quarterly compaction: cluster duplicates in `learnings.md`, archive the prior version to `.claude/state/archive/learnings-YYYY-Qn.md`. -- Stabilized learnings get promoted to `/docs`; the original entry gets a `_Promoted to:_` line appended (the only legal post-hoc edit). - -**Related.** `state/learnings.md` (compacted patterns archive) - ---- - -## 2026-04-28 — Parallel deploy scheduler with per-node live status - -**Context.** The deploy apply phase walks the topologically-sorted plan node-by-node sequentially (`packages/core/src/deploy/deploy-engine.ts:67-169`). The `parallelism: 10` field in `DeployOptions` is set in `DEFAULT_OPTIONS` but never read anywhere in the engine — sequential is the only path. Cloud SQL takes 10+ minutes per instance and Memorystore Redis 3-5 minutes; a 12-node fan-out card serializes into 30+ minutes when 15 minutes is achievable with parallelism. The UI's only progress signal is a single percentage that resets per resource, giving observers the impression that progress is going backwards. Per-handler milestone reporting exists for exactly one handler (`load-balancer.ts` via `ctx.on_step`) but the contract isn't used elsewhere. - -**Decision.** Replace the sequential apply walk with a bounded worker-pool scheduler over the per-node DAG. - -1. **Pool size default 6**, configurable. Per-handler caps for quota-sensitive resource types: `gcp.sql.* = 1`, `gcp.redis.* = 1` (Cloud SQL has a 1-create-per-project-per-minute soft quota and IP-range allocation that fails when two creates race; same for Memorystore Redis). -2. **Failure isolation:** a node failing cancels only its descendants; siblings and unrelated branches continue. The existing `continue_on_error: true` callsite default is preserved. -3. **Per-node lifecycle events** (`queued | applying | succeeded | failed | skipped | cancelled-due-to-dep`) emitted over the existing `deploy:<cardId>` Socket.IO room as `node_status` / `node_progress` events. -4. **No backwards-compat window for the legacy `type: 'progress'` aggregate.** Cut cleanly. ICE is pre-1.0; no external listeners depend on it. The new node-status stream replaces it; the rollup ("X of N succeeded, K in flight, F failed") is computed client-side from `nodesById`. -5. **Frontend rendering:** deploy panel renders one row per node with simultaneous `applying` indicators; canvas overlay reflects each block's individual state. Redux state extends `deploy-slice` with a `nodesById` map keyed by canvas node id. -6. **Stable correlation by canvas node id**, sourced from `translation.deployables`, replacing the fragile `findSourceNodeId` name-suffix-stripping in the service layer. - -The apply-engine in `packages/core/src/apply/` (which has plan-execution-layer batching via `Promise.all`) is **not** adopted as-is because it waits for the slowest node in each layer before starting the next — a work-stealing pool over the DAG is strictly better. The two engines (`deploy-engine.ts` for parallel deploy, `apply-engine.ts` for the older plan/apply path) coexist; reconciliation is a separate refactor. - -**Alternatives considered.** - -- _Adopt `apply-engine.ts`'s execution-layer batching._ Rejected. Layer-batched `Promise.all` waits for the slowest node in layer N before starting layer N+1, even when a fast node in N+1 has only one dep already finished. Work-stealing over the DAG is strictly better. -- _New socket room `deploy:<deployId>`._ Rejected. The existing `deploy:<cardId>` is what the canvas hydration is shaped around (per-card lifecycle, per-card snapshot in `deploy-locks.ts`). The deployId is unknown to clients before the HTTP roundtrip. -- _Keep emitting legacy `type: 'progress'` for one release._ Rejected per user direction. ICE is pre-1.0; no external clients to protect; cleaner cut. -- _Single global concurrency limit, no per-handler caps._ Rejected. GCP quotas (Cloud SQL 1/min, Memorystore IP-range races) make >1 concurrent unsafe for those resource types specifically. -- _Fail-fast on first error._ Rejected per user direction (failure isolates per branch). The partial-success rollup gives users the actionable diff: which resources succeeded, which failed. - -**Consequences.** - -- The deploy path returns identical `DeployResult` shapes (no API break for callers); only timing and event surface change. -- The misleading per-resource progress percentage is gone — replaced by an honest "X of N terminal" rollup. Implicitly fixes the long-standing UI wart where the bar bounces 59% → 0% → 0% as the engine moves between resources. -- Future adopt-vs-already-exists work slots into the per-handler `create()` wrapper without touching the scheduler. The `applying` event fires at scheduler dispatch time, not handler call time, so a future adopt-detection wrapper can emit a `node.progress` like "checking for existing resource" before the actual create. -- Per-handler caps for SQL and Redis mean a card with 3 SQL instances still serializes those three creations (correct given GCP behavior). Documented as a knob in `/docs/architecture/core-engine.md`. -- Cancellation behavior (existing `abort_signal`): in-flight nodes finish naturally; not-yet-applying nodes flip to `cancelled-due-to-dep`. -- Two engines coexist; reconciliation is a separate refactor. - -**Related.** Plan units pdl-1 through pdl-9. [`packages/core/src/deploy/deploy-engine.ts`](../../packages/core/src/deploy/deploy-engine.ts) (current sequential apply, primary refactor target). [`packages/core/src/apply/apply-engine.ts`](../../packages/core/src/apply/apply-engine.ts) (reference, not adopted). [`packages/core/src/deploy/providers/gcp/types.ts`](../../packages/core/src/deploy/providers/gcp/types.ts) (`GCPHandlerContext.on_step` — existing milestone hook to be used by all slow handlers in pdl-3). Adopt-resource (issue #4) — out of scope, hooks reserved. - ---- - -## 2026-04-29 — Refactor initiative: monster-file decomposition with three new agents - -**Context.** The ICE codebase has ~30 source files over 500 LOC and four over 2000 LOC (`properties-panel.tsx` 3268, `svg-canvas.tsx` 3234, `deploy.service.ts` 2843, `deploy-panel.tsx` 2229), with another six in the 1000–1600 LOC band. Coverage tooling is installed only in `packages/core` and there is no threshold enforcement at the workspace level. Refactoring at this scale via the existing four-agent loop alone risks duplicate utilities and uneven test coverage as code is moved across packages. - -**Decision.** Extend the existing `planner / implementer / critic / ux-tester` loop with three additive agents and two new state files: - -- **decomposer** — analyzes one large file, produces a semantic split blueprint (utils / hooks / components / subcomponents). Does not edit code. -- **util-broker** — owns `.claude/state/shared-modules.md`, validates each blueprint against existing exports across the workspace, flags duplicates before they land. -- **test-author** — brings each extracted module to ≥90% statement + ≥90% branch coverage; documents structural exceptions in `learnings.md`. -- New state file **`.claude/state/refactor-targets.md`** (orchestrator-owned, living document) — per-file queue with current LOC, current coverage, units open/done, shim-drop status. -- New state file **`.claude/state/shared-modules.md`** (util-broker-owned, append-only) — exported-module registry. - -Workflow per file: orchestrator picks target → decomposer drafts blueprint → util-broker validates against registry → planner orders units leaves-first → for each unit (implementer extracts behind a re-export shim → test-author hits coverage → critic verifies public-API equivalence + coverage delta ≥ 0). The **ux-tester step is intentionally skipped from the per-unit refactor cadence** — behavior preservation is enforced by the tests + critic API-equivalence check, and headed-browser cycles are too slow for the cadence. The orchestrator may dispatch a one-off ux-tester smoke if the critic flags behavior risk on a particular unit. Each file ends with an explicit shim-drop unit. - -**Alternatives considered.** - -- _Replace the existing four agents with refactor-specialised ones._ Rejected. The current loop already works for the parallel-deploy initiative; replacing it would lose the planner / critic / ux-tester convention. Additive is strictly safer. -- _Single "refactorer" agent that does decomposition, extraction, and tests in one pass._ Rejected. Too much surface area per agent; the tight feedback loop between decomposer and util-broker is what kills duplicate utils, and merging them would lose that. -- _Coverage threshold ratchet to 90/80 across the whole repo on day one._ Rejected. Disruptive; some packages don't even have a coverage tool installed yet. Per-package baseline-on-touch with a global gate that forbids regression is the staged version. -- _ux-tester runs per refactor unit._ Rejected per orchestrator direction (2026-04-29). Headed-browser cycles are too slow for per-unit cadence and refactor units do not change behavior. The general UI-testing rule still applies for behavior changes. - -**Consequences.** - -- Phase 0 (coverage tooling at root + initial registry seed) is a hard prerequisite before any refactor unit dispatches. -- Re-export shims live at the original path until a file's final shim-drop unit; this keeps each commit's blast radius small but adds a discipline cost (shims must be tracked). -- `progress.md` In flight list grows by one initiative; per-file progress goes into `refactor-targets.md` so `progress.md` stays scannable. -- `learnings.md` will accumulate coverage-exception entries for modules that legitimately can't hit 90%. -- ux-tester smoke runs become exception-driven for refactor work — the critic must explicitly call for one when behavior risk is suspected. - -**Related.** [`/CLAUDE.md`](../../CLAUDE.md), [`state/refactor-targets.md`](refactor-targets.md), [`state/shared-modules.md`](shared-modules.md), [`.claude/agents/decomposer.md`](../.claude/agents/decomposer.md), [`.claude/agents/util-broker.md`](../.claude/agents/util-broker.md), [`.claude/agents/test-author.md`](../.claude/agents/test-author.md). - ---- - -## 2026-04-29 — Move state directory from `.claude/state/` to `state/` - -**Context.** Operating Claude Code on this repo with default permission settings, every write under `.claude/` triggers an interactive permission prompt. The state files (`decisions.md`, `learnings.md`, `progress.md`, `refactor-targets.md`, `shared-modules.md`, `blueprints/*`, `archive/*`) are touched on every refactor unit — append a learning, update progress, etc. — so the prompts compound into significant friction during long refactor runs. - -**Decision.** Move the operational state directory from `.claude/state/` to `state/` at the project root. The agent definitions stay in `.claude/agents/` because that path is how Claude Code's harness discovers subagents — moving them would break agent dispatch. CLAUDE.md, `docs/agents.md`, and every agent definition file get bulk-updated to reference the new path. - -**Alternatives considered.** - -- _Keep state under `.claude/state/` and add a permission allowlist for it._ Rejected. Allowlists work per-tool but require ongoing maintenance (every new state file under the tree needs the entry refreshed); moving the directory once is structurally cleaner. -- _Move state to `docs/state/`._ Rejected. `docs/` is human-authored documentation that ships with the repo; mixing agent-managed operational state into it muddies that contract (same reasoning as the original 2026-04-27 decision). -- _Move state to `.claude-state/` (sibling of `.claude/`)._ Rejected. The leading dot would still imply hidden / tooling-managed; explicit `state/` at the root makes the directory's purpose obvious in `ls`. - -**Consequences.** - -- All references in CLAUDE.md, `docs/agents.md`, and `.claude/agents/*.md` updated to `state/`. -- Past entries in `decisions.md` and `learnings.md` (append-only) keep their historical `.claude/state/` prose mentions verbatim — those describe the path at the time of writing. The meta-rules section at the top of `decisions.md` is updated since it's normative meta-text, not an entry. -- `git mv` preserves blame on the moved files. -- The agent .md files themselves stay in `.claude/agents/` for harness discovery; only `state/` was moved. - -**Related.** Supersedes the path choice in the [`2026-04-27 — Adopt persistent state system`](#2026-04-27--adopt-persistent-state-system) decision. - ---- - -## 2026-05-02 — Merge `refactoring` branch as a single PR - -**Context.** The `refactoring` branch carries 509 commits ahead of `main`: the parallel-deploy initiative (pdl-1..10), the workspace-wide LOC discipline initiative (Phase 1 / 2 / 3 / Final round, 73 files refactored, ~7,500 new tests added, 4 latent bugs fixed). Branch is internally coherent — every commit ships with tests; the orchestrator pattern (planner / implementer / critic / ux-tester) and the per-unit cadence enforced behavior preservation throughout. ICE is pre-1.0; no external API stability obligations gate the cut. - -**Decision.** Merge the `refactoring` branch into `main` as **one squash-or-merge PR**, not split per phase. - -Rationale: - -- The two initiatives (parallel-deploy + LOC discipline) are interleaved in the commit graph; phase-PR splits would require cherry-picking and lose accurate `git blame`. -- Each refactor unit was already gated by a critic pass + test deltas; per-unit reviewability already exists in the commit messages and per-unit blueprints (`state/blueprints/rf-*.md`). -- A single merge preserves chronological order in `main`'s history and is the cheapest path to closing the long-running branch. -- Pre-1.0 status means no semver / changelog burden tied to the cut shape. -- No active feature work depends on a phase-by-phase cut; the deferred follow-ups (current In flight) layer cleanly on top of `main` post-merge. - -**Alternatives considered.** - -- _Split into Phase-1 / Phase-2 / Phase-3 / Final-round / bugfixes PRs._ Rejected. The refactor units were dispatched by file, not by phase boundary; many commits touch utilities shared across phase boundaries. Splitting would require synthetic boundaries and lose the per-unit shim-drop sequencing. Review burden is the same total but more painful when interleaved. -- _Merge straight to `main` without a PR._ Rejected. Even though ICE is pre-1.0, the volume warrants a PR record for `main` history searchability — title + body capture the high-level summary that 509 individual commit messages don't. -- _Keep cooking on `refactoring` and merge at end of Q2-2026._ Rejected. The branch is a closed initiative, not work-in-progress; further commits would be the deferred follow-ups (current In flight) and those are independent enough to ship as their own PRs after the merge. -- _Merge commit vs squash._ No preference recorded — let the human PR reviewer pick. A merge commit preserves the per-unit history (richer `git log`); a squash collapses 509 → 1 (cleaner mainline graph). Both are acceptable. - -**Consequences.** - -- After merge, the deferred follow-ups (pdl-11, rollupPercentage extraction, nodesById warm-seed, dead snapshot fields, data.status fallback, 3 cross-package dedups) ship as their own PRs against the new `main`. -- `state/refactor-targets.md` becomes historical reference — the queue is empty other than the 4 documented data-leaf exceptions. -- `state/learnings.md` Q2-2026 quarterly compaction (separate work) lands either on `refactoring` pre-merge or on `main` post-merge — both are fine, no ordering constraint. -- The pre-commit hook auto-bumps `package.json` version on every commit, so the cut version on `main` will be wherever the bump lands. Not load-bearing. - -**Related.** [`state/progress.md`](progress.md) (Archive entries for both initiatives), `state/learnings.md` (the playbook distilled from the LOC initiative; doc-form deprecated). diff --git a/state/findings.md b/state/findings.md deleted file mode 100644 index 06b081f8..00000000 --- a/state/findings.md +++ /dev/null @@ -1,374 +0,0 @@ -# Findings — coverage sweep (2026-05-02) - -Triage of bugs, dead code, and architecture surprises surfaced while -bringing workspace coverage from 8,261 → 13,231 tests. Each entry cites -its origin commit so the fix can land on the same branch. - -**Status update (2026-05-02 PM)** — all 30 Tier 1, all 16 Tier 2, -AND all 8 Tier 3 findings have landed on `refactoring`; commits not -yet pushed to remote per user request. Each fix has its own commit -with an inverted regression test (where applicable — Tier 3 entries -are mostly documentation-only because they're judgment calls). - -**Tier 3 (closed 2026-05-02 PM)** — #47 (diff null↔empty equivalence), -#48 (diff nested `_`-skip), #49 (diff array-of-objects positional — -documented), #50 (graph-slice empty-container hide — documented as -dormant), #51 (ui-slice panes[0]? fallbacks — documented as -dormant), #52 (team-page null-org guard — documented as dormant), -#53 (createProvider sync/async divergence — documented), #54 -(NullProvider streamChat throws on first iteration). - -**Tier 2 (closed 2026-05-02 PM)** — #31 (svg-connection-path arrows), -#32 (use-canvas-mouse-events orphan), #33 (desktop tsc artifacts), -#34 (terraform/pulumi dead error branch), #35 (architecture-rules -incoming Map — already done with #19), #36 (deploy/structure-rules -redundant nodeType), #37 (deploy-rules tautology), #38 (connection- -rules unreachable label fallback), #39 (property-rules unreachable -guard), #40 (mock-provider 'deleted' branch), #41 (provider-registry -duplicate-warn), #42 (provider-registry health-check lazy doc), -#43 (canvas/cards try/catch), #44 (project-members shared -middleware), #45 (templates/validate.ts unit-testable), #46 -(requireProjectAccess single query). - -**Sweep complete: 54 / 54 findings closed.** Branch `refactoring` -has 50+ commits ahead of `main` across this multi-day session, -none pushed yet. - -## Tier 1 — Security / correctness bugs (fix soon) - -### Auth / authorization - -✅ **All 30 Tier 1 findings fixed in this sweep:** -#1 (JWT_SECRET gate), #2 (requireProjectAccess fail-open), -#3 (refresh-token over-deletion), #4 (web/app.tsx trust model), -#5 (invite-accept email-bind + URL-encode), #6 (last-owner guards), -#7 (canvas getOrgId body fallback), #8 (webhooks idempotency hang), -#9 (unified-type-resolver Result shape), #10 (FORCE_NEW_PROPERTIES), -#11 (azure type-mapper static_site), #12 (use-ai-command SSE), -#13 (deleteEnvironment in-flight check), #14 (createEnvironment errors), -#15 (bootstrapProductionEnvironment mismatch), #16 (validateCanvas validatedBy), -#17 (OpenAICompatProvider.healthCheck auth), #18 (chat finishReason), -#19 (architecture-rules Redis), #20 (connection-rules phantom cycle), -#21 (audit silent fire-and-forget), #22 (canvas-intent SSE error), -#23 (apply-engine AbortSignal — between layers + between batches, -in-flight ops not interrupted, `cancelled` flag on result), -#24 (apply-engine success-vs-error), #25 (apply-engine replace skip warn), -#26 (GCP CLEAN_PROPERTY_EXTRACTORS), #27 (GCP importer access-denied), -#28 (use-clipboard cut), #29 (environments thunk guard), #30 (setActive guards). - -Tier 1 closed. Next: Tier 2 cleanup pass. - -1. **JWT_SECRET falls back to literal `'test-secret'`** when - `NODE_ENV === 'test'`. Any process started with that env (CI, dev - scripts, accidentally-staged) signs production-shaped tokens with - a known string. _Origin_: `services/iam/src/routes/auth.ts:15-21`. - _Fix_: tighten the gate (`import.meta.vitest` or a dedicated - `IS_VITEST` env, not `NODE_ENV`). - -2. **`requireProjectAccess` fails open on unknown `minRole`**. - `(ROLE_LEVEL[pm.role] || 0) < (ROLE_LEVEL[minRole] || 0)` - collapses an unknown minRole to 0; a viewer (level 1) then - satisfies it. Route effectively becomes auth-required-only if the - minRole type ever loosens. _Origin_: - `packages/shared/src/auth/middleware.ts`. _Fix_: throw at - handler-build time if minRole isn't in `ROLE_LEVEL`. - -3. **Refresh-token reuse-detection over-deletes the family** when a - row is missing — a stolen token can log out the legitimate user. - _Origin_: `services/iam/src/services/auth.service.ts` BE-3. - _Fix_: scope deletion to the suspected token only; mark the - family as compromised separately. - -4. **`web/app.tsx` has NO auth gating at the App shell** — routes - render unconditionally. `/templates`, `/settings`, `/onboarding` - are reachable without a token. Onboarding redirect (the only - client-side gate) lives inside `DynamicContent`, not at App. - _Origin_: `packages/web/src/app/app.tsx`. _Fix_: gate at the App - shell or document client-side trust model. - -5. **`invite-accept.tsx` auth check is the SOLE barrier**. The - redirect-token round-trip (`navigate('/login?redirect=/invite/ -<token>')`) is the only thing protecting the accept-invite - endpoint. Token isn't URL-encoded. _Origin_: - `packages/web/src/pages/invite-accept.tsx`. _Fix_: server-side - validation on `/users/invite/accept`, plus `encodeURIComponent` - on the token in the redirect. - -6. **No `cannot-remove-last-admin` guard at any layer**. UI hides - nothing extra; project-members route just dispatches the delete. - _Origin_: `services/canvas/src/routes/project-members.ts`, - `packages/ui/src/features/account/components/team-page.tsx`. - _Fix_: add a count-of-admins check at the service or DB-trigger - layer. - -7. **`canvas/canvas.ts /projects` list trusts body - `organisationId` fallback** when JWT is missing. Mild - org-spoofing surface. _Origin_: - `services/canvas/src/routes/canvas.ts:getOrgId`. _Fix_: drop the - body fallback or document why it's intentional. - -### Correctness - -8. **`webhooks.ts:57` async catch handler hangs the request**. - Express 4 has no default async-error handler — non-P2002 prisma - errors leave the request hanging until the client times out. - _Origin_: `services/deploy/src/routes/webhooks.ts:57`. _Fix_: - wrap in the same try/catch shape used at lines 85-96. - -9. **`unified-type-resolver` only populates type maps for the legacy - `{ data: { schemas: [...] } }` payload shape**. The Result-shaped - response that `query()` actually returns gets silently swallowed. - If the schema provider was migrated to Result shape, the - resolver is no-op. _Origin_: - `packages/core/src/schema/unified-type-resolver.ts`. _Fix_: - handle both shapes or normalize on the provider side. - -10. **`FORCE_NEW_PROPERTIES` table is partially dead** in - `core/src/plan/diff.ts`. `normalize_resource_type` rewrites - every `_` to `.` before lookup, but several map keys _contain_ - `_` (e.g. `azure.compute.virtual_machine`, - `gcp.sql.database_instance`, `azure.storage.storage_account`). - Those entries are silently unreachable; destructive changes to - those resources won't trigger the destroy/recreate flow. - _Origin_: `packages/core/src/plan/diff.ts`. _Fix_: rewrite the - keys to use `.` consistently, or skip normalization on the - map-lookup path. - -11. **`azure-importer/type-mapper.ts:40` dead key**. - `'microsoft.web/staticSites'` (capital S) is in TYPE*MAP, but - `get_ice_type` lowercases input before lookup. Microsoft.Web/ - staticSites falls through to the synthesized - `azure.web.staticsites` instead of intended - `azure.web.static_site`. \_Origin*: - `packages/core/src/importers/azure/type-mapper.ts`. _Fix_: - lowercase the key, or skip lowercasing on lookup. - -12. **`use-ai-command` SSE parser resets `eventType`/`eventData` - per `read()` call** — multi-chunk-spanning SSE events silently - drop. _Origin_: `packages/ui/src/features/ai/hooks/ -use-ai-command.ts`. _Fix_: persist parser state across reads. - -13. **`canvas/environment.service.deleteEnvironment` doesn't check - in-flight deploys**. Relies on FK cascade only. _Origin_: - `services/canvas/src/services/environment.service.ts`. - _Fix_: query active deployment rows + reject delete. - -14. **`canvas/environment.service.createEnvironment` swallows ALL - trigger-rule errors** (incl. auth/permission failures from - `deploymentRule.create`). User gets a green path even when - zero rules cloned. _Origin_: same file. _Fix_: rethrow - non-best-effort errors; downgrade to warning only for - expected-skip cases. - -15. **`canvas/environment.service.bootstrapProductionEnvironment` - short-circuits on existing-prod without comparing inputs**. - Stale callsite that thinks it's seeding gets back the old env - unchanged. _Origin_: same file. _Fix_: compare requested - name/userId/cardId; if they don't match, throw or update. - -16. **`canvas/canvas-validation.service.validateCanvas` swallows - core-engine import failures with `valid: true`**. Frontend - can't distinguish "engine down" from "canvas truly clean". - _Origin_: `services/canvas/src/services/canvas-validation. -service.ts`. _Fix_: return `{ valid: true, validatedBy: -'engine' | 'skipped' }` so the frontend can see the - distinction. - -17. **`OpenAICompatProvider.healthCheck` swallows ALL errors** from - `/health` and silently falls through to `/v1/models`. A 401 on - `/health` is treated identically to "endpoint missing". _Origin_: - `packages/ai/src/providers/openai-compat.ts`. _Fix_: distinguish - network errors from auth failures; surface auth. - -18. **`OpenAICompatProvider.chat` always reports `finishReason: -'stop'`** — discards the wire-level finish reason. _Origin_: - same file. _Fix_: thread the wire's finish reason through. - -19. **`architecture-rules.ts` else-if classifier shadowing**. - `Database.Redis` matches `isDatabase` first (Database. prefix), - so it lands in `databases`, never in `caches`. The - `MULTI_DB_NO_CACHE` suppression branch is unreachable for any - real Redis node. _Origin_: - `packages/core/src/validation/architecture-rules.ts:62-65`. - _Fix_: order isCache before isDatabase, or have isDatabase - exclude Redis explicitly. - -20. **`connection-rules.ts` cycle detector includes dangling - targets** — phantom-cycle reports `a → ghost → a` when an edge - has a non-existent target. _Origin_: - `packages/core/src/validation/connection-rules.ts:135`. _Fix_: - filter dataEdges by node-existence, or move cycle-detect above - the dangling-edge skip. - -21. **`ai/audit.service.writeAuditEntry` silent fire-and-forget** — - audit-log DB outage drops entries with zero observability. - _Origin_: `services/ai/src/services/ai-audit.service.ts`. - _Fix_: at minimum `console.error` on rejection; better, wire - into the deploy log channel. - -22. **`canvas-intent` SSE error-after-headers risk** — error catch - fires `res.status(500).json(...)` without guarding on - `res.headersSent`. _Origin_: `services/ai/src/routes/ai.ts`. - _Fix_: `if (res.headersSent) return; else res.status(500)...`. - -23. **`apply-engine.ts` no abort_signal / cancellation support**. - Legacy engine; mid-flight cancel is ignored. Rollback callers - run to layer completion regardless. _Origin_: - `packages/core/src/apply/apply-engine.ts`. _Fix_: thread an - `AbortSignal` through; check between layers. - -24. **`apply-engine.ts` success-vs-error mismatch**. Handler - returning `{ success: false }` with no `error` field results in - summary "1 failed" while overall result says success=true. - _Origin_: same file. _Fix_: derive overall success from the - summary not from `errors.length`. - -25. **`apply-engine.ts` replace path silently skips destroy** when - `current_state` is missing — different from the scheduler's - stricter destroy/create choreography. _Origin_: same file. - _Fix_: log a warning; skip only when explicitly "create-only" - semantics requested. - -26. **GCP `CLEAN_PROPERTY_EXTRACTORS` dead-eyed fallbacks**. - `props.name || extractName(props.name as string)` — when - `props.name` is undefined, `extractName(undefined)` returns - undefined too. The "extract from URL" path never fires for - missing-name cases. _Origin_: - `packages/core/src/importers/gcp/type-mapper.ts`. _Fix_: - `extractName(props.self_link)` — pass the URL field - explicitly. - -27. **GCP importer access-denied "success"**. Reports `success: -true` if ALL errors are ACCESS*DENIED — may mask real - permission misconfigurations. \_Origin*: - `packages/core/src/importers/gcp/gcp-importer.ts`. _Fix_: - distinguish "no permission" (warning, partial success) from - "all permissions denied" (error). - -### State / data integrity - -28. **`use-clipboard` cut-path data-loss risk**. `writeText` reject - - sessionStorage fallback also fails → nodes are STILL deleted. - _Origin_: `packages/ui/src/shared/hooks/use-clipboard.ts`. - _Fix_: gate the delete on a successful write of either path. - -29. **`environments.tsx` `is_protected` only checked at UI render**. - Manual delete dispatch could bypass. _Origin_: - `packages/web/src/pages/project/environments.tsx` + the - `deleteEnvironment` thunk. _Fix_: thunk-level check. - -30. **`projects-slice.setActiveProject` and - `environments-slice.setActiveEnvironment` don't validate id** - — set even for unknown ids. _Origin_: - `packages/ui/src/store/slices/{projects,environments}-slice.ts`. - _Fix_: gate on existence in state. - -## Tier 2 — Dead code / cleanup - -31. **`svg-connection-path.tsx` arrow markup is dead**. - `hasArrow = false` is hardcoded at line 125 with comment - "Arrows removed" — the entire `<marker>` block (lines 278-288) - and `markerEnd` truthy branch (line 318) is unreachable. - -32. **`use-canvas-mouse-events.ts` is orphan code**. No consumers - in the workspace; only mentioned in a JSDoc reference in - `canvas-constants.ts`. 399 LOC of dead React-hook plumbing. - _Action_: delete the file + the JSDoc reference. (Tests can be - deleted with it.) - -33. **Stale `index.js` / `index.d.ts` artifacts** under - `apps/desktop/src/{main,preload}/` from a past `tsc --emit` - run. They're git-tracked and collide with Vite's `.js`-over- - `.ts` resolver. _Action_: remove from the repo + add to - `.gitignore`. - -34. **Same dead error branch in terraform/pulumi converters**: - `errors.push(result.error)` in `export_graph` is structurally - unreachable. `node_to_resource` always co-emits - `unmapped: true` with any error. _Action_: drop the redundant - branch or assert it as a contract violation. - -35. **`architecture-rules.ts:38,44,46` unused `incoming` Map** — - built but never read. _Action_: drop. - -36. **`deploy-rules.ts:175` redundant nodeType check** — - `node.type === 'container' || 'group'` after `isContainer(...)` - already returns true for those values. Same shape at - `structure-rules.ts:128`. _Action_: drop the redundant check. - -37. **`deploy-rules.ts:225` tautology** — `length > 0 ? '...' : -undefined` inside `if (length > 0)`. _Action_: drop the - ternary. - -38. **`connection-rules.ts:70-71` unreachable label fallback** — - `'Source' / 'Target'` arms after `iceType.split('.').pop()` - only fire for iceType `'.'`. _Action_: drop. - -39. **`property-rules.ts:237` unreachable defensive guard** — - caller already gates on `prop.customInput` truthy. _Action_: - drop. - -40. **`mock-provider default_state_generator 'deleted'` branch is - dead code** — destroy doesn't call the generator through the - public ProviderClient interface. _Action_: drop the branch or - document it as a future hook. - -41. **`provider-registry.register()` silently overrides on - duplicate key**. _Fix_: `console.warn` on collision (or throw - in dev mode). - -42. **`provider-registry.health_check_all()` only checks - instantiated clients** — registered-but-unused providers are - invisible. _Fix_: instantiate-on-first-health-check, or - document the lazy semantics. - -43. **`canvas/canvas.ts cards/{create,delete}` lack try/catch** — - outliers among the file's other handlers; unhandled-rejection - becomes 500 envelope-less. _Fix_: wrap consistently. - -44. **`canvas/project-members.ts` re-implements project-owner - gating** via `hasProjectAccess(req.userId, projectId, 'owner')` - instead of using shared `requireProjectAccess('owner')` - middleware. _Fix_: switch to the middleware. - -45. **`templates/validate.ts` calls `process.exit(1)` at - module-import time** — script that's not unit-testable as-is. - _Fix_: extract `runValidation(...)` and keep a thin - `if (require.main === module)` driver. - -46. **`requireProjectAccess` does 2 prisma round-trips** despite - BE-10 comment claiming single query. _Fix_: fold the org-member - lookup into the project's `select` clause. - -## Tier 3 — Subtle / debatable - -47. **`core/diff.ts` null vs `[]` / `null` vs `{}` treated as - different** — false-positive vector for drift detection when - providers return `null` for omitted lists. - -48. **`core/diff.ts` `_`-prefix internal-skip is top-level only** — - nested `_` keys diff in detailed mode. - -49. **`core/diff.ts` arrays of objects compared positionally not by - id** — reordering produces a parent-path drift record. - -50. **`graph-slice.ts:181` empty-container hide branch unreachable** - through current callers (all use viewLevel=2). - -51. **`ui-slice.ts:332/350` defensive `panes[0]?` fallback - unreachable** — initialized non-empty and `closeSplit` keeps - ≥1 pane. - -52. **`team-page.ts:86` defensive null-org guard unreachable** — - role `<select>` only renders when isAdmin, which derives from - selectedOrg.role. - -53. **`createProvider` (sync) vs `createProviderAsync` produce - different providers** for the same config. _Action_: document - or normalize. - -54. **`NullProvider.streamChat` yields one `undefined` chunk before - throwing** — a consumer not checking chunk.content silently - processes an undefined token. Code blames eslint - require-yield. _Action_: throw immediately, suppress the - eslint rule on that one function. diff --git a/state/learnings.md b/state/learnings.md deleted file mode 100644 index 9ea1c022..00000000 --- a/state/learnings.md +++ /dev/null @@ -1,1110 +0,0 @@ -# Learnings - -Append-only. Each entry has a kebab-case `##` anchor, a `_Discovered_` line, and one paragraph. - -**Rules** - -- New learnings: append. Never edit past entries. -- The one allowed edit to a past entry is appending a `_Promoted to: /docs/<path>_` line once the learning has stabilized — cited 3+ times, or generalizes beyond one unit — and has been written up in `/docs`. -- To supersede or contradict a past learning, append a new entry that references the old anchor. - ---- - -## read-state-first - -_Discovered: 2026-04-27 by orchestrator in unit setup_ - -Every agent reads `.claude/state/decisions.md` and `.claude/state/learnings.md` before acting on a brief. Without this, agents redo investigations the rest of the workflow has already settled, miss explicit decisions about how to approach a class of problem, and rediscover the same gotchas the critic flagged last week. Reading state is the cheapest step in the loop and the highest-leverage; skip it and the rest of the loop wastes effort. - -## destroy-needs-terminal-state - -_Discovered: 2026-04-27 by orchestrator in deploy-panel destroy fix_ - -The destroy onConfirm handler used to dispatch `appendLog(...)` then `resetDeploy()` in the same tick — `resetDeploy` wipes `state.logs` and sets status `'idle'`, so a fast destroy looked silently inert. Fix: dedicated `destroyed` `DeployStatus` + `destroySuccess` reducer that flips to it and pushes a final summary log. Generalizes: any terminal-state action that clears its own log is indistinguishable from failure; always land on a non-idle status long enough for the human to see what happened. - -## one-status-source-deploy-status - -_Discovered: 2026-04-27 by orchestrator in compact-node status unification_ - -The compact-node status pill used to read `(data.deploy_status as string) || (data.status as string) || ''`. The fallback turned `data.status` (seeded `'active'` by templates/blueprints/WAF defaults/svg-canvas drop handlers/cards-slice/drift checker) into the de-facto source. Fix: drop the legacy fallback in `compact-node/index.tsx`, stop seeding `status: 'active'` at every node-creation site. Generalizes: when two parallel state fields exist for the same UX concept, the fallback turns one of them into the de-facto source no matter how careful the writer side is. Pick one and delete the others. - -## svg-canvas-isLogNode-precedes-renderer-map - -_Discovered: 2026-04-27 by implementer in LT-1-consolidate-icetypes_ - -`svg-canvas.tsx`'s dispatcher loop has two parallel routing layers for the same iceType: an early `isLogNode` short-circuit (~L2715) that always wins, AND a `CONCEPT_NODE_RENDERERS` map (~L135) consulted later. So when LT-1 added `Monitoring.Log` to `isLogNode`, the existing `'Monitoring.Log': SvgObservabilityNode` map entry became dead code. Generalizes: any iceType special-cased in an early branch should NOT also live in the catch-all map — invitation to subtle behavior splits. The "what counts as a log node" set is also duplicated across `expand-blueprint.ts:173`, `svg-canvas.tsx:2634`, and `gcp/type-mapper.ts:87`; LT-5 should export a single shared `LOG_ICE_TYPES` set from `@ice/constants`. - -## data-version-bump-migrates-not-wipes - -_Discovered: 2026-04-27 by critic in LT-1-consolidate-icetypes review_ - -The cards-slice load path treats a version bump as cause to wipe `localStorage` and start fresh, then runs `migrateCardNodes` only on already-current-version payloads. Result: a v5 → v6 bump deletes the user's canvas instead of migrating. Same shape recurs in any reducer that accepts external nodes/edges (`importToActiveCard`, `addToActiveCard`, `addNodeToCard`) — backend-saved canvases, AI tool-use writes, clipboard imports all bypass the localStorage migrator. Generalizes: write the migration as a pure function over the payload and call it from every ingestion site. - -## deploy-service-package-name-is-service-deploy - -_Discovered: 2026-04-27 by implementer in LT-2-filter-resolver_ - -The deploy service's `package.json` name is `@ice/service-deploy`, not `@ice/deploy`. `pnpm --filter @ice/deploy typecheck` silently no-ops (filter matches zero packages, exit 0). Always verify the filter target exists by reading `services/<name>/package.json` first; `pnpm -r typecheck` from the repo root is a safer fallback when uncertain. - -## google-cloud-logging-getentries-not-entries-list - -_Discovered: 2026-04-27 by implementer in LT-3-log-stream-service_ - -The `@google-cloud/logging` Node SDK's surface is `Logging.getEntries(opts)` and `Logging.tailEntries(opts)` — NOT the REST API names `entries.list` / `tailLogEntries`. Each `Entry` carries envelope fields under `entry.metadata` and payload under `entry.data` (string for textPayload, object for jsonPayload). `tailEntries`'s `data` event delivers a `TailEntriesResponse` with `entries: Entry[]`, not a single Entry. Don't map REST shape to SDK shape one-to-one — read the `.d.ts` first. Also: the IAM probe in tests inflates the `getEntries` call counter by one; mock the probe as an explicit early branch (`if (call === 1) return [[]]`). - -## google-cloud-logging-loaded-via-load-sdk-from-core - -_Discovered: 2026-04-27 by implementer in LT-3-log-stream-service_ - -`@google-cloud/logging` is declared in `packages/core/package.json` and loaded via the dynamic-import wrapper `load_sdk(module_name)` in `packages/core/src/deploy/providers/gcp/sdk-loader.ts`. The deploy service does NOT have it as a direct dep — re-export `load_sdk` from `packages/core/src/deploy/index.ts` and use `(await core.load_sdk('@google-cloud/logging')).Logging` to construct. Generalizes: any GCP SDK referenced from a service outside `packages/core` should go through `load_sdk`. - -## auth-derived-orgid-must-not-trust-body - -_Discovered: 2026-04-27 by implementer in LT-4-routes-and-socket_ - -If a route spreads `req.body` into a service call that takes `organisationId`, a client can spoof `organisationId: 'evil'` and route the credential lookup to a different tenant. Mitigation: build the args object explicitly with `{ ...validatedBody, organisationId: req.organisationId }` AFTER body validation so auth-derived value always wins. Generalizes: any service-layer function whose argument record mixes client-controlled and auth-derived fields needs an explicit assembly step at the route boundary. - -## supertest-not-in-monorepo-use-fetch-against-app-listen - -_Discovered: 2026-04-27 by implementer in LT-4-routes-and-socket_ - -Repo has zero supertest. For HTTP-level Express router tests: `express() + app.use('/path', router) + app.listen(0, '127.0.0.1', ...)` (port 0 = ephemeral), capture `server.address().port`, then `fetch(\`http://127.0.0.1:${port}\`)`. Node 22's built-in `fetch` plus `http.Server` is enough — no extra deps. Cleanup in `afterEach` via `server.close(...)`. - -## frontend-cannot-import-from-services - -_Discovered: 2026-04-27 by implementer in LT-5-frontend-wiring_ - -The frontend (packages/ui) cannot import types from services/ — workspace topology has no path. For shared API contracts: (a) inline-mirror in the consuming slice (cheap, accept drift), (b) lift to `packages/types/src/<domain>.ts` (canonical for cross-package types), or (c) keep two parallel definitions with a runtime decode/validate at the boundary. One consumer = mirror; two+ = promote. - -## socket-room-and-http-lifecycle-are-two-cleanups - -_Discovered: 2026-04-27 by implementer in LT-5-frontend-wiring_ - -The Log Terminal subscription has TWO independent server-side resources to release on unmount: Socket.IO room membership AND the polling/tail loop opened by HTTP `/subscribe`. Skipping room emit leaves handler-closure leaks; skipping HTTP unsubscribe leaks a 60s polling loop hammering Cloud Logging quota. Cleanup order: stop listeners → leave room → POST unsubscribe → dispatch teardown. Third edge: user unmounts WHILE initial `/subscribe` POST is in flight — the cancelled-flag still completes and creates a server-side stream. Fix: in the cancelled branch, fire a best-effort `unsubscribe(result.subscriptionId)`. - -## properties-panel-section-nodeId-vs-selectedNode-prop-shape - -_Discovered: 2026-04-27 by implementer in LT-6-properties-section_ - -Per-iceType branches in properties-panel.tsx thread `selectedNode={...}` + `updateNodeField(field, value)` callback. `MonitoringLogSection` instead takes a single `nodeId: string` prop and re-resolves both cards and logs slices through Redux, dispatching `updateCardNodeData` directly — because it's the first per-iceType section reading from a slice OTHER than `cards`. Generalizes: per-iceType section reading outside cards → use `nodeId` prop shape; section that only mutates `cards` → keep `selectedNode + updateNodeField`. - -## ux-log-terminal-pitfalls - -_Discovered: 2026-04-27 by ux-tester in LT-9-ux-test/reverify_ - -Five sibling Log Terminal correctness gaps: (a) on-canvas `LogHeader`'s "LIVE" badge reads only local `isAutoScroll`, never `status` from `useLogStream` — pre-deploy/connecting/error states say "LIVE" on canvas while properties pill is correct. Generalizes: two surfaces showing the same state must read from the same source. (b) Store-level persistence subscriber's `cardHash(card)` excludes `node.data.*`; `streamingMode` flip never persists. Generalizes: content-hash skip that excludes a mutation surface is a silent data-loss path. (c) `api.logs.unsubscribe` POSTs `{ subscriptionId }` only — the route's middleware demands `projectId`/`cardId` → every unsubscribe returns 400, polling loop leaks. Generalizes: cleanup endpoints are still authenticated endpoints. (d) `logs:resumed` handler unconditionally promotes to `'streaming'`; backend tail-reconnect retries even when SDK unavailable → "LIVE" badge despite `source.state === 'pre-deploy'`. Fix: gate on the same "only promote from connecting" check `appendEntry` uses. (e) 12 subscribe→unsubscribe pairs in 2s after one click — `useEffect` deps include `sourceNodeIdOverride` from non-memoized `node.data` read, fresh reference per render. Generalizes: useEffect with object-shape deps in a hot render path silently DDoSes its own backend. - -## use-selector-primitive-projection-vs-derived - -_Discovered: 2026-04-27 by implementer in LT-9-bugfix-2_ - -When a hook's `useEffect` dep is "derived from a Redux blob" (e.g. `node.data.streamingMode`), do the projection INSIDE `useSelector`, not OUTSIDE it. Returning the parent `node` and reading `node?.data?.streamingMode` downstream forces the consumer to re-render on every cards-slice publish (Object.is on the parent fails). Project to the primitive inside the selector so `useSelector` can short-circuit. Add a runtime `typeof value === 'string'` guard inside the selector to close gaps with type-system optimism during init. Generalizes: useSelector projects; component code consumes the projection. - -## debounced-persist-creates-stale-backend-reads - -_Discovered: 2026-04-27 by implementer in LT-bugfix-stale-edges_ - -The canvas's persistence subscriber debounces saves by 2000ms. When a backend route resolves anything off the persisted card row, it sees state two ticks ago — a fast "draw edge → click block" reads pre-edge data and the source resolver returns `none`. Fix: pass the live frontend state explicitly in the request body (e.g. `candidateSources`) and have the backend prefer it; keep Prisma read as a fallback. Generalizes: any backend route that joins on a row written by a debounced client-side persist subscriber is a correctness footgun. Lift the inputs into the request body, or flush the persist before the route fires. - -## one-shot-resolution-needs-state-trigger - -_Discovered: 2026-04-27 by implementer in LT-bugfix-postdeploy-resubscribe_ - -A subscription that resolves once at subscribe time is fine ONLY if every condition the resolver checks is also captured in the effect's deps. `useLogStream`'s `candidateFingerprint` projected `<sourceId>><iceType>` — enough for "edge added/removed" but NOT for "user deployed → `data.deploy_status = 'active'`". The deps ignored `deploy_status`, so the placeholder said "Deploy this environment to start streaming logs" hours after a successful deploy. Fix: extend projection to `<sourceId>><iceType>><deployStatus>`. Generalizes: any "subscribe-once + resolve-once" hook must include EVERY field the resolver inspects in its deps fingerprint. - -## ux-real-deploy-needs-clean-gcp-precondition - -_Discovered: 2026-04-27 by ux-tester in LT-10-real-deploy-blocked_ - -Before kicking off any "real cloud" UX run, list every relevant resource (`gcloud run services list`, `gcloud sql instances list`, etc.) and verify the canvas is `idle`. The LT-10 attempt found the project already had 1 Cloud SQL + 6 Redis + a "Deploying… 23%" badge from a prior abandoned session — pushing fresh Deploy on top would double the leak (~$250/mo in active leak) and corrupt the timing measurement. Generalizes: real-deploy UX runs need a `gcloud`-based pre-flight that fails fast if the project isn't clean. Cleanup is the orchestrator's job, not the ux-tester's. - -## ux-deploy-real-cloud-pitfalls - -_Discovered: 2026-04-27 by ux-tester in LT-10-deploy-attempts_ - -Three sibling traps from real-cloud deploy UX runs: (a) "already exists" is treated EITHER as adopt-gracefully OR hard-fail across consecutive runs against the same partial state — UI must call out which path was taken, silence reads as "engine broken"; (b) the bottom-of-canvas progress pill X% is per-resource, not overall, so it resets to 0% on each resource transition (looks like the deploy stalled) — show step N/M or weight by total work, or drop the canvas pill; (c) when an orchestrator's "world is in state X" brief contradicts a `gcloud` pre-flight, trust `gcloud`. Generalizes: any agent acting on "world is in state X" from another agent must independently verify when cost-of-wrong is real-world side-effects. - -## scheduler-ready-list-must-reserve-per-handler-cap - -_Discovered: 2026-04-28 by implementer in pdl-1_ - -`ParallelChangeScheduler.collect_ready` reserved against global `pool_size` but called `can_take_slot` which read `this.handler_in_flight` — incremented LATER in `dispatch`. With three `gcp.sql.databaseInstance` siblings and cap=1, the first iteration saw `handler_in_flight === 0` for all three and returned them all. Fix: track BOTH `pool_reserved` and `handler_reserved` as local Maps inside `collect_ready` itself. Generalizes: any "two-phase scheduling" loop where dispatch mutates the bookkeeping the collect phase reads MUST do its own within-phase reservations. - -## scheduler-resource-name-vs-graph-node-id-vs-canvas-node-id - -_Discovered: 2026-04-28 by implementer in pdl-1_ - -Three identifiers travel through the deploy stack: (1) **canvas node id** (user-facing block id from `cards-slice.nodes[i].id`), (2) **graph node id** (`${type}:${name}` from `MutableGraph.add_node`), (3) **resource name** (sanitized hash-suffixed cloud-resource name). The brief said "`change.id` traces to `deployables.node_id`" but the actual chain: `change.id == graph_node_id`, NOT canvas node id. The mapping `graph_node_id → canvas_node_id` lives in `card-translator.ts`'s `deployables[]`. Inside the scheduler we emit `node_id = graph_node_id`; pdl-4 (service layer) translates to canvas id. Generalizes: when three identifiers exist for the "same thing", each layer's events carry the most-stable id available; the boundary translates. - -## cloud-build-helper-substep-shares-outer-index - -_Discovered: 2026-04-28 by implementer in pdl-3_ - -The `on_step` contract is "1-based, monotonic, never exceeds total". When a slow handler (cloud-run) delegates a multi-minute sub-operation (cloud-build), the naive "let the helper emit its own indices" blows the contract. Fix: keep ALL build-helper sub-states at the SAME outer index (`(_inner, label) => reportStep(2, label)`) so the consumer sees the label refresh in place. Test detail: the cloud-build-helper sleeps `BUILD_POLL_INTERVAL_MS = 10_000`; switch from `vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 20 })` to plain `vi.useFakeTimers()` + explicit `await vi.advanceTimersByTimeAsync(15_000)` to keep tests fast. Generalizes: any nested-handler call where the inner work is one logical step should pin its sub-states to the outer index. - -## socket-service-module-scoped-io-needs-vi-resetmodules-per-test - -_Discovered: 2026-04-28 by implementer in pdl-2_ - -`packages/shared/src/socket/service.ts` keeps the Socket.IO server in a module-scoped `let _io` written once by `setupSocketService`. That makes the module stateful across tests in the same file — a test that sets up the server leaks `_io` into the next test. Each `it` should `vi.resetModules()` then `await import('../socket/service.js')` to get a pristine module, with `vi.restoreAllMocks()` in `afterEach`. Generalizes: any module owning mutable singleton state needs `vi.resetModules()` between tests in the same file. Smoking-gun symptom: "test A passes alone, B passes alone, both pass in either order, but the third added later flakes." - -## seq-allocation-must-be-shared-between-wire-and-log - -_Discovered: 2026-04-28 by implementer in pdl-4_ - -The wire contract requires every `DeployEvent` to carry a monotonic `seq` so reconnecting clients can dedupe. Two separate counters (one for live emit, one for persistent log) drift: the same logical event ends up with seq=N on the wire and N+1 in the DB row. Fix: split into `nextDeploySeq(cardId)` allocator + `recordDeployEvent(cardId, seq, type, payload)` consumer; allocate first, set on `event.seq`, fire wire helper, THEN pass same seq to recordDeployEvent. For events outside an active deploy, `nextDeploySeq` returns null and we fall back to `Date.now()`. Generalizes: any "live emit + persistent log" pair where consumers reconcile across both sides must share the sequence number from a single allocator. - -## graph-id-vs-canvas-id-translation-is-service-layer-job - -_Discovered: 2026-04-28 by implementer in pdl-4_ - -The scheduler's `NodeStatusEvent.node_id` carries `${type}:${name}` (graph node id), NOT canvas node id, despite the original JSDoc. The wire contract requires the CANVAS id (frontend keys `nodesById` on it). Translation happens exactly once at the service layer, against a `graphIdToCanvasId` map built ONCE per deploy from `translation.deployables[]`. On a missing translation, DROP the wire emit + warn — emitting with sentinel id silently miscorrelates status to the wrong block. Destroy/rollback don't have the map (walk historical deployments) so they emit log lines instead. Generalizes: when three identifier spaces flow through a layered system, every wire-contract field has ONE definition of which space's id it carries; the service layer is the right place to translate. - -## point-types-at-source-not-dist-in-workspace-packages - -_Discovered: 2026-04-28 by orchestrator in pdl-4 critic-fix pass_ - -`@ice/core`'s `package.json` `types` field pointed at `./dist/index.d.ts` while every other workspace package (`@ice/blocks`, `@ice/db`, `@ice/templates`, `@ice/shared`, `@ice/types`, `@ice/ui`, all six services) points at source. Consumers use `moduleResolution: bundler` which doesn't enforce node16's TS2834 file-extension rule, so the 29 pre-existing core errors don't propagate. After repointing `types: ./src/index.ts`, the local mirror in deploy.service.ts could be dropped and cross-package imports work. Generalizes: when a TS workspace package needs to expose a type to peers, point `types` at source; the dist-d.ts pattern only makes sense for published packages outside the monorepo. - -## wire-contract-pdl-pitfalls - -_Discovered: 2026-04-28 by critic in pdl-4 review_ - -Two sibling wire-contract gotchas: (a) `BlockRequirementStatus` is uniquely keyed on `(card_id, node_id, environment, requirement_id)` but `DeployRequirementVerifiedEvent` initially carried only `card_id` + `requirement` — frontend reducer can't disambiguate "the cert flipped" between two custom-domain blocks on the same canvas. Fix: every key field on the wire, plus optional `details`. (b) Two incompatible `seq` schemes on single `deploy:event` channel — deploy-tape uses small monotonic ints, requirement-poller uses `Date.now()`. Fix: JSDoc spells out the scheme on the offending field — "route by `event.type` first, then sort within each scheme". Generalizes: when freezing a wire contract for a composite-key row, every key field must be on the wire; when one channel carries multiple event types with different sequencing, expose the discrimination at the type level. - -## frontend-channel-flip-needs-eager-init-callsite-sweep - -_Discovered: 2026-04-28 by implementer in pdl-7_ - -A "channel rename" is really a triple-rename: the constant, the listener method, AND every callsite. Renaming the API method (`onDeployEvent`) and the interface gets you typecheck enforcement on most callsites, but TypeScript is happy with `api.onDeployProgress` returning `undefined` for an optional method on the interface — it silently breaks the eager-init handshake-warming loop. There were three callsites in pdl-7 (eager-init, live listener, deploy-panel `requirement_verified` watcher); missing any one would have been a real regression. Keep the unsubscribe room emit (`subscribeDeployProgress`) named the same — that's the room-join, not the event listener. - -## test-the-channel-name-constant-not-the-string - -_Discovered: 2026-04-28 by implementer in pdl-7_ - -The pdl-7 channel-flip test asserts `expect(channel).toBe(DEPLOY_EVENT_CHANNEL)` — importing the same constant from `@ice/types` that the http-api-adapter does. Writing the literal `'deploy:event'` would compile and pass today, but a future rename would silently green-light backend/frontend disagreement. Generalizes: when a constant exists to give two sides of an interface a single source of truth, the test for that interface must import the constant too. - -## pdl-7-wire-contract-trims-downstream-ui - -_Discovered: 2026-04-28 by critic in pdl-7 review_ - -Three sibling regressions from dropping `DeployResult.results` and `error` from `DeployCompleteEvent` while preserving `outcome` + `totals`: (a) async-path `state.results` stays at wire-mirror only — output-derived UI (DNS records, Cloud Run URL, custom domain pill, api_enable_url CTAs) goes blank until reload. Fix: dispatch `hydrateDeployFromHistory({...})` in the `complete` handler. (b) `state.error` is null after wire-only completion → `<ApiErrorBanner error={deploy.error}>` renders empty even on outcome='failure'. Same fix (DB row carries `error: string`). (c) `mapWireStatusToOverlay` produces `'queued'`/`'skipped'` strings that don't exist as keys in `STATUS_COLORS` (fall back to green/active) AND parallel server-side `mapStatusToOverlay` collapses `queued | applying → 'deploying'` (blue) — same node renders inconsistently across paths. Fix: add `'queued': '#f59e0b'` + `'skipped': '#94a3b8'` keys; align both mapping functions. Generalizes: when freezing a wire contract that drops fields downstream UI consumed unchanged, explicitly add a re-fetch path; every overlay-string mapping value must exist in the consumer's table; every parallel mapping must produce identical strings. - -## react-memo-on-rollup-component-instead-of-shallowequal-on-selector - -_Discovered: 2026-04-28 by implementer in pdl-5_ - -The wire-event path produces a new `nodesById` reference per reducer write (10+ events/sec during deploy). Adding a separate `useSelector(s => s.deploy.nodesById)` with `shallowEqual` would cost an extra subscription without changing parent re-render frequency (parent already selects whole slice). The cheaper fix: keep prop-drilling `nodesById` from the existing whole-slice selector into a `React.memo`-wrapped child (`DeployInFlightPanel`), and run `useMemo([nodesById])` for the rollup INSIDE the child. The memo invalidates only on actual reference change. For independent components (canvas banner, status-bar) with their own subscriptions, DO use `shallowEqual`. Generalizes: a `useSelector` returning a non-primitive blob is acceptable WHEN downstream consumers are `React.memo`-wrapped; pick by which side of the tree owns the blob. - -## destroy-status-and-action-aware-row-labels - -_Discovered: 2026-04-28 by implementer/critic in pdl-5_ - -Two destroy-path gaps: (a) the legacy deploy panel's progress UI gated `status === 'deploying'` only — destroy went through with no live progress. Fix: widen to `(status === 'deploying' || status === 'destroying')`. (b) `node.status` carries lifecycle but NOT action; the same wire shape covers create AND delete, so a destroy-applying row showed "DEPLOY", destroy-succeeded showed "LIVE" — contradiction. Fix: thread `node.action` into badge label override, swap `applying → DESTROY`, `succeeded → GONE` when `action === 'delete'`. Generalizes: audit every `status === 'X'` gate against every status the backend emits; per-row UI rendering events from a multi-action backend must compose its label from `(action, status)`, not status alone. - -## ux-pdl-smoke-test-pitfalls - -_Discovered: 2026-04-28 by ux-tester in pdl-smoke-test_ - -Three sibling smoke-test traps: (a) newly-dropped blocks have no `provider` on `node.data`; deploy panel filters them out as "skipped — non-GCP" even when project provider is GCP. Fix options: default at drop, treat absent as matching, or surface as panel dropdown. Generalizes: any per-node setting whose absence routes to "skipped" must default at creation OR render "needs config". (b) Static Site has a hard pre-deploy requirement (GitHub repo) — bad pick for "minimum viable canvas" smoke tests. Storage.Bucket × N is better (no requirement). Generalizes: prefer block types whose handlers don't fan out into requirements. (c) 3 buckets destroyed cloud-side, but `nodesById` cleared to count=0 immediately and stayed empty — destroy wire events don't land. Generalizes: when a wire-contract claim is made about parity between two operations, the smoke test must drive both ends and confirm; passing the create side alone is necessary but not sufficient. Also: deploy panel keeps stale requirement cards around when the underlying block is deleted (requirement reactivity is per-card, lazy). - -## pdl-10-destroy-snapshot-and-dedup-traps - -_Discovered: 2026-04-28 by implementer/critic in pdl-10_ - -Three bugs that surface together when destroy starts emitting per-resource `node_status` events: (a) `nextDeploySeq(cardId)` returns null without an active snapshot, so destroy emits fall back to `Date.now()` ms — breaks dedup-on-reconnect. Fix: `startDeploySnapshot(cardId, destroyRecord.id)` after creation, `finishDeploySnapshot` before every return. (b) Frontend slice keys dedup on `node_id` only; after deploy, `last_seq=9`, destroy allocates fresh seq=1, reducer sees `9 >= 1` → silently DROPS every destroy event. Fix: reset `last_seq` on new operation, or key dedup by `(deploymentId, node_id)`, or stamp every event with `deployment_id`. (c) `destroyAllForCard` opens a snapshot but lacks top-level catch to close it — engine throw leaves stale snapshot pointing at terminal `destroyRecord.id`. Compare `destroyDeployment`'s proper try/catch/finally with `finishDeploySnapshot('failed')` in catch. Generalizes: anywhere you emit `node_status` events you also need a snapshot; any "monotonic seq for dedup" guard outliving the operation needs scope-agreement; any stateful resource needs try/catch/finally on EVERY exit path. - -## deploy-service-test-script-and-typecheck-traps - -_Discovered: 2026-04-29 by implementer in rf-deploy-1_ - -Three workspace ergonomic gotchas in `services/deploy`: (a) repo-root vitest sets `globals: true` but `services/deploy/tsconfig.json` doesn't include `@types/vitest` — bare `describe/it/expect` runs but fails typecheck. Convention: `import { describe, it, expect, vi, beforeEach } from 'vitest'` at top of every test file. Always pair test run with package typecheck on brand-new files. (b) `pnpm --filter @ice/service-deploy test` is a silent no-op — the package has no `test` script. Run via `pnpm exec vitest run services/deploy/src` or `pnpm test:unit`. Coverage: `pnpm test:coverage -- services/deploy/src/<path>`. (c) Pre-commit hook bumps root `package.json` `version` on every commit and stages the change; cannot opt out without `--no-verify` (banned). Commits always include `package.json`; never assume commit == files you `git add`ed. - -## vi-spyon-accumulates-across-it-blocks-without-explicit-reset - -_Discovered: 2026-04-29 by implementer in rf-deploy-3_ - -A `beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); })` is NOT enough — re-calling `vi.spyOn` on the same target returns the SAME spy and its `mock.calls` carries over. Fix: `vi.restoreAllMocks()` inside `beforeEach` BEFORE the re-spy, or `warnSpy.mockClear()` at the top of each `it`. Restore is cleaner because it tears down the mock between describe blocks too. Generalizes: any spy on a shared global (console.\*, Date.now, fetch) needs explicit per-test reset. - -## core-const-lifetime-varies-per-callsite-when-extracting-deployer-factory - -_Discovered: 2026-04-29 by implementer in rf-deploy-5_ - -When extracting `createDeployer(provider)` to dedupe `if aws / else if azure / else GCP` blocks following `const core = await getCoreEngine()`, do NOT blindly delete the preceding `getCoreEngine()` line at every callsite. Three of four use `core` only for deployer destructure (line dies after replacement), but the apply path also feeds `translate_card_to_graph`/`deploy_graph` ~250 lines later, and rollback uses `core` for `MutableGraph`. Verify with `grep -n "core\." deploy.service.ts | head -50` BEFORE deleting any `getCoreEngine()` call. Generalizes: when collapsing duplicated code that destructured different fields off a shared const, audit per-callsite before deleting the const. - -## vi-fn-default-type-rejects-typed-callback-parameter - -_Discovered: 2026-04-29 by implementer in rf-deploy-6_ - -`const log = vi.fn();` widens to `Mock<Procedure | Constructable>`, NOT assignable to a specific callback signature like `(msg: string) => void`. Vitest runtime is happy; `tsc --noEmit` flags TS2345 at every callsite. Fix: intersection type — `let log: ((msg: string) => void) & ReturnType<typeof vi.fn>; log = vi.fn() as ((msg: string) => void) & ReturnType<typeof vi.fn>;`. Generalizes: any unit test passing a `vi.fn()` mock to a SUT parameter with non-`any` callback type needs explicit signature on the mock — via generics or intersection. - -## vi-mock-factory-hoist-blocks-top-level-class-references - -_Discovered: 2026-04-29 by implementer in rf-deploy-8_ - -When a `vi.mock(...)` factory needs to expose a class so SUT's `instanceof Foo` checks survive the mock, the class **must be declared INSIDE the factory closure**, not at the test file's top level. Vitest hoists `vi.mock` above all top-level statements (not just imports — `class`, `let`, `const` too), so a top-level `class MockDeployLockError extends Error {}` blows up at module load with `ReferenceError: Cannot access ... before initialization`. The reported stack frame points at the SUT's import line — misleading. Fix: move class body inside factory and re-export through the mocked module. Grab via `const MockDeployLockError = (deployLocks as any).DeployLockError` after SUT import. - -## emit-log-gate-must-mirror-original-truthiness-not-count - -_Discovered: 2026-04-29 by implementer in rf-deploy-10_ - -When extracting an inline emitLog inside `if (lastDeploy?.results)` where the count comes from a pre-filter projection (`prevResources.filter(r => r.success).length` BEFORE the `&& res.resource_id` filter), the natural-looking refactor `if (foundCount > 0) emitLog(...)` is NOT behavior-equivalent. Two divergence cases: (a) `results` truthy with zero successes — original logs "Found 0 existing resource(s)…", refactor stays silent; (b) original counts 5 successes but refactor counts 3 added nodes. Fix: return TWO signals — `hasResults` boolean AND a `foundCount` mirroring the original count projection. Generalizes: when extracting a logging callsite, the helper has to expose both the gate predicate and the message inputs as their original projections. - -## inline-catches-can-have-inconsistent-error-message-derivations - -_Discovered: 2026-04-29 by implementer in rf-deploy-13_ - -`destroyDeployment`'s catch did `error: err.message` (bare) for `deleteResults.push` AND `emitLog`, but `err.message || String(err)` for `emitDestroyNodeStatus.error.message` — three uses, two normalizations, three lines. Extracting to a per-item helper that surfaces a single normalized `error: string` silently UPGRADES the deleteResults push and the log line to "always a string". For Error throws this is invisible; for non-Error throws (thrown string/object/number) the deleteResults entry's `error` field changes from `undefined` to stringified value. Generalizes: when an inline catch has multiple uses of `err.message` with inconsistent fallbacks, hoisting picks one normalization for all uses — note explicitly in the report. - -## reexport-audit-distinguish-namespace-imports-from-named-imports - -_Discovered: 2026-04-29 by implementer in rf-deploy-17_ - -A literal grep for `import { X } from '<path>'` undercounts consumers — `import * as foo from '<path>'` then `foo.X` downstream is also a real consumer. In rf-deploy-17, `routes/canvas-deploy.ts` did `import * as deployService from '../services/deploy.service'` then dispatched `deployService.checkDrift(...)` — those re-exports were load-bearing precisely because of the namespace pattern. Always run TWO greps per symbol: `import { X }` AND `<namespace>.X`. Tests binding to canonical home via `await import('../utils/<module>.js')` don't count toward "keep the re-export"; the re-export only stays for callers genuinely routing through the orchestrator path. - -## tree-walker-for-react-fc-tests-must-flatten-nested-children-arrays - -_Discovered: 2026-04-29 by implementer in rf-props-6_ - -When writing component tests in node-only vitest (no jsdom), invoking the `React.FC` directly to inspect its returned element tree, the walker has to flatten nested arrays in `props.children` arbitrary-depth. Any component using `value.map(...)` produces an array as one child of a parent's `children` array — so children is `[<header>, [<itemA>, <itemB>, <itemC>], <footer>]`. A naive `Array.isArray(children) ? children : [children]` flattens the outer level; the inner array then gets handed to recursive `walk(child)` which tries `.props.children` on an array (TypeError). Fix: make the walker recurse into arrays explicitly before treating a node as an element: `if (Array.isArray(node)) { for (const c of node) yield* walk(c); return; }`. Order matters: array check before element-property access. - -_Promoted to: /docs/refactoring-patterns.md_ - -## extract-pure-builders-when-testing-redux-or-effect-hooks-in-node-env - -_Discovered: 2026-04-29 by implementer in rf-props-7_ - -When extracting a custom hook in this monorepo, the test environment can't run `useEffect` (no jsdom under `renderToString`) and can't drive state updates (no `@testing-library/react`). A `renderToString`-based smoke test only fires the synchronous `useState` initializer + any `useSelector`; extracting the hook's inner branching as a pure named export gives full branch coverage on the load-bearing logic. Pattern: peel `useResourceMap`/`usePropertyIssues` into thin wrappers around `buildResourceMap(data)` / `buildPropertyIssuesMap(issues, selectedNodeId)`, test the builders directly. Hook tests touching redux must live in `.tsx` (Provider's children prop is required). Generalizes: any future hook with non-trivial branching downstream of useState/useSelector should plan for two named exports — the hook + a pure builder. - -_Promoted to: /docs/refactoring-patterns.md_ - -## capture-ref-after-render-unlocks-100pct-on-callback-returning-hooks - -_Discovered: 2026-04-29 by implementer in rf-props-8_ - -A `useCallback`-returning hook hits a different ceiling than a `useEffect`-driven hook: callback body never runs during `renderToString` but CAN run after, in plain async test code. Render once via `<Provider><Probe /></Provider>`; `Probe` writes the hook's return value into a captured-ref object; post-render call `await captured.current.checkDrift()` and assert against `store.getState()` and `vi.spyOn(store, 'dispatch')`. Combined with `vi.mock('../../../../shared/api/axios-instance', ...)` (note four `..` segments) you get a real Redux store wired to actual reducers, controllable POST mock per-test, and freedom to drive full success/error/empty branches. Coverage on callback hooks: 100/100/100/100. Spy on `store.dispatch`, `mockClear()` after render-time setup, assert on the exact ordered list of action types — catches both ordering regressions and accidental extra dispatches. - -_Promoted to: /docs/refactoring-patterns.md_ - -## vi-hoisted-for-stable-mock-identity-in-direct-fc-tree-walker-tests - -_Discovered: 2026-04-29 by implementer in rf-props-14_ - -When extracting a section that composes multiple primitives from the same module, mock the module so each primitive is identifiable by reference (`el.type === MockStepperField`). `vi.mock` factories run hoisted before module-level statements; declaring `const MockStepperField = vi.fn()` at module scope hits a TDZ. Use `vi.hoisted`: `vi.hoisted(() => ({ MockSection: vi.fn(), MockStepperField: vi.fn(), ... }))`. Side traps to avoid: (a) DO NOT wrap each mock in another arrow inside the factory — creates a fresh wrapper each render, walker can't find anything; (b) DO NOT reach for `require('react')` inside `vi.hoisted` — direct-FC invocation never runs the mock body anyway. Empty `vi.fn()` bodies are sufficient — the walker descends through `MockSection.props.children` natively. - -_Promoted to: /docs/refactoring-patterns.md_ - -## tree-walker-must-invoke-file-private-fcs-when-extracted-component-keeps-an-inner-helper - -_Discovered: 2026-04-29 by implementer in rf-props-16_ - -The standard direct-FC walker descends only through `el.props.children`. That breaks the moment the extracted component renders a file-private inner FC and most load-bearing JSX lives inside that helper's body — `PrivateNetworkPanel` returns `<div><PrivateNetworkPolicySection .../><PrivateNetworkPolicySection .../></div>` and every radio + allowlist lives inside the inner FC. The walker yields `<PrivateNetworkPolicySection>` as a leaf and never sees the radios. - -Fix: extend the walker to invoke any FC element it encounters that is NOT the mocked primitive. The check is `typeof el.type === 'function' && el.type !== mocks.MockSection` — if true, call `el.type(el.props)` and yield from the resulting subtree. Mocked primitives stay as leaves (matched by reference equality), but file-private helpers from the source module get unrolled. The walker becomes a hybrid: a tree-walker for primitives + mocked components, an evaluator for non-mocked FCs. Don't invoke React class components or memoized FCs without a guard — `typeof el.type` would be `'object'` for those. - -Pair with `vi-hoisted-for-stable-mock-identity-in-direct-fc-tree-walker-tests` (still mock primitives by reference) and `mocked-component-data-attrs-invisible-to-direct-fc-walker` (find mocks by props-shape when their body would have rendered DOM). - -_Promoted to: /docs/refactoring-patterns.md_ - -## use-state-mock-with-mutable-ref-unlocks-direct-fc-toggle-state-tests - -_Discovered: 2026-04-29 by implementer in rf-props-17_ - -For a FC using `useState`, calling without renderer context throws. Mocking naively only lets you test one state value per factory hoist. Fix: hoist a _mutable ref_ (`expandedIdRef = { current: null }`) into the `vi.mock('react', ...)` factory and have the mocked `useState` read from it on every call: `useState: vi.fn(() => [expandedIdRef.current, setStateSpy])`. Per-test, mutate `expandedIdRef.current` _before_ calling the component. Same `setStateSpy` captures every `setExpandedId(...)` call. Pair with `useDispatch` mock returning a captured `dispatchSpy`. Sub-pattern (rf-rpal-9 `keepSlots` flag): for effect-coverage tests needing to pre-seed across renders, extend `__resetUseState({ keepSlots?: boolean })` — call counter resets but slot values stick. - -## dynamic-import-of-api-adapter-needs-a-direct-vi-mock-on-the-target-module - -_Discovered: 2026-04-29 by implementer in rf-props-17_ - -A _dynamic_ `import('../../../shared/api/api-adapter').then(({ getApi }) => ...)` inside a closure is mocked exactly the same way as static imports — vi resolves dynamic-imports through the same module-mock registry. A single `vi.mock('../../../../../shared/api/api-adapter', () => ({ getApi: () => ({ ... }) }))` at the test-file's relative path covers both. The await chain inside the handler runs on microtasks, so `await new Promise(r => setTimeout(r, 0))` _twice_ (once for `import()` resolution, once for the `.then(...)` chain) before asserting. Sub-rule: when source destructures `default` off the awaited dynamic-import (`import('...').then(({ default: _, ..._mod }) => ...)`), the mock factory MUST include `default` key, even if the test never reads it. - -## queued-ref-dispatch-extends-the-mutable-ref-usestate-mock-to-multi-state-fcs - -_Discovered: 2026-04-29 by implementer in rf-props-19_ - -The single-ref `useState` mock handles components with **one** `useState` call. For N≥2 calls, queue them: hoist N refs and N setter spies into `vi.hoisted`, plus a `callIdx` counter and a per-render `__resetUseState()` hook closed over the counter. The `useState` mock looks up `dispatch[callIdx]` (a function returning `[ref.current, setSpy]` for that slot), increments, returns. Every `renderHistory(...)` resets the counter so a fresh render deals slots starting at 0. Pair with one-time `useEffect` mocking that fires the callback synchronously: `useEffect: vi.fn((cb, deps) => { effectCallbacks.push(cb); effectDeps.push(deps); void cb(); })`. The slot-by-call-index pattern unblocks asserting on per-setter calls and on `setExpanded((prev) => ...)` updater functions — capture via `setterSpy.mock.calls[0][0]` and run on test inputs. - -_Promoted to: /docs/refactoring-patterns.md_ - -## use-memo-must-be-mocked-too-when-the-extracted-component-uses-it - -_Discovered: 2026-04-29 by implementer in rf-props-21_ - -When the extracted FC body calls `useMemo(() => ..., [deps])`, the standard hook mock leaves `useMemo` untouched and React's `useMemo` reads `null.useMemo` → TypeError. Fix: extend the `vi.mock('react', ...)` factory with `useMemo: vi.fn((factory, _deps) => factory())`. Same shape for `useCallback`: `useCallback: vi.fn((cb, _deps) => cb)`. The eager-factory mock has no behavior cost — every call runs the factory, which is what an un-memoized FC body would do anyway. - -## test-helper-defaults-traps-coalesce-and-spread - -_Discovered: 2026-04-29 by implementer in rf-props-21/22, rf-cstor-4, rf-aichat-3_ - -Three sibling test-helper traps: (a) `props.activeCard ?? makeCard()` swallows explicit `null` overrides intended to test guard branches — fix with `'activeCard' in props ? props.activeCard : makeCard()`. Same with `||` and falsy values. (b) Helper that bundles "reset everything" with `mockClear` AND `mockReturnValue([])` REPLACES whatever the test set with `mockReturnValueOnce(...)` two lines before. Fix: helper does ONLY `mockClear`; move behavior defaults to `beforeEach`. (c) `{ ...defaults, ...overrides }` is NOT a deep merge — `defaults.iam = { getPolicy, setPolicy }` overridden by `overrides.iam = { getPolicy: vi.fn() }` drops `setPolicy`, SUT crashes. Fix: spread per-sub-shape explicitly. Generalizes: helpers handing nested-object mocks need awareness of which shapes are deep-overrideable; `??`/`||` defaults swallow null/falsy overrides. - -## canonical-home-dedup-of-local-copies-is-a-behavior-change-when-the-canonical-is-stricter - -_Discovered: 2026-04-29 by implementer in rf-props-26_ - -Two callsites had inline `parseCostRange` (regex `\$(\d+)(?:[–-](\d+))?` — INTEGER-only) and one had local `formatCost` (`return value === 0 ? '' : '~$' + Math.round(value) + '/mo'`). Pointing them at the canonical home (`packages/ui/src/features/cost/utils/cost-calculator.ts` with regex `\$([\d,]+(?:\.\d+)?)`, `replace(/,/g,'')`, formatCost returning `'Free'`/`'< $0.01/mo'`/etc.) is **strictly a behavior change**: (a) `'$1,000-2,000'` averages to 1500 not 1.5 (off by 1000×), (b) `'$0.50'` returns 0.5 not 0, (c) `formatCost(0)` returns `'Free'` not `''`. Bullet (c) is gated at every callsite by `totalCost > 0` so `'Free'` is unreachable. Verify the gate per-callsite before declaring dedup safe. Lock with: invariant tests at the canonical home + behavior-delta tests at the consumer + regression test that gated transitions are still hidden. Generalizes: any "dedup the local copy to the canonical home" unit MUST diff regex/formula between implementations and treat divergence as behavior change. - -## export-type-from-does-not-bring-name-into-local-scope - -_Discovered: 2026-04-29 by implementer in rf-canv-1_ - -When extracting a leaf type module that the orchestrator file still uses internally, `export type { CanvasNode } from './types';` is NOT enough. The forward keeps the name visible to outside importers, but inside `svg-canvas.tsx` itself the symbol is not in lexical scope — every internal alias breaks with `Cannot find name`. Fix: pair the re-export with a sibling `import type { CanvasNode } from './types';`. Looks redundant but does different jobs: re-export aliases for downstream importers; import brings the binding into THIS module's scope. - -## inline-classification-duplications-are-not-actually-duplicates - -_Discovered: 2026-04-29 by implementer in rf-canv-2/6/8/9_ - -When a brief lists N "near-identical" sites for dedup, build a feature×site truth table FIRST (predicate, selection rule, iteration direction, exclusion shape, side-effects-in-loop) — let the table dictate how many distinct utils + how many "leave inline + flag" sites you end up with. The count is rarely 1↔N. rf-canv-2 listed 5 inline `isGroup`/iceType checks but L1488/L1612/L2647 ARE equivalent (fold to `isContainerNode`) while L414, L546, L1139 each have unique axis combinations. rf-canv-6 listed 4 hit-test loops resolving to 3 distinct patterns (smallest+predicate, no-predicate-smallest, no-predicate-reverse-iterate). rf-canv-8 `pickPreviewColor`'s `if (connectionDragTargets)` short-circuit produces different colors for null Map vs empty Map. rf-canv-9's `computePortMap.getSide` uses `>` while sibling `computeConnectionPreviewPath` uses `>=` (inverted tie-break, AST-similar but visually distinct). Pin verbatim with "Mirror of X; do NOT cross-port" comments where divergence is load-bearing. Generalizes: AST-similarity is misleading; the asymmetry is load-bearing. - -## brief-test-spec-vs-verbatim-behavior-conflict - -_Discovered: 2026-04-29 by implementer in rf-canv-7_ - -The rf-canv-7 brief listed Test 6 as: "Edge already has same source+target → not a conflict." The verbatim inline block does NOT exclude the candidate edge from the lookup, so re-drawing the same edge still matches itself as conflict. The brief's spec would have required an "exclude self" predicate the inline block never had — implementing it would silently change behavior under same-drag scenarios. Right move: preserve verbatim filter, write Test 6 to PIN that fact (commented "preserves verbatim behaviour … intentionally"). Generalizes: when a brief's test-spec list and its "verbatim, no behavior change" constraint disagree, ALWAYS resolve in favor of verbatim and pin the actual behavior in a test with a comment surfacing the disagreement. Critic should look for test names like "preserves verbatim behaviour: …". - -## extracted-wrapper-key-must-mirror-original-closure-outer-key-chain - -_Discovered: 2026-04-29 by implementer in rf-canv-10_ - -When a per-iteration closure (`wrapLift = (content) => isLifted ? <g key={id}>{...}</g> : isAnimating ? <g key="anim-${id}">{content}</g> : content`) gets refactored into a `<NodeLiftWrapper>` subcomponent inside `sortedNodes.map(...)`, the brief's natural shape `<NodeLiftWrapper node={...}>{<SvgX key={...}/>}</NodeLiftWrapper>` silently elides the wrapper's OWN `key` prop. Inner-element keys still render verbatim but are now SOLE children — React reconciliation under a single-child parent doesn't consult those keys. Without explicit `key` on `<NodeLiftWrapper>`, React falls back to array index → mass-remounts on `sortedNodes` reorder. Fix: compute `wrapperKey(innerKey)` at the orchestrator's per-node loop mirroring the original priority chain (`isLifted ? id : parentId ? "clipped-${id}" : isAnimating ? "anim-${id}" : innerKey`) and pass as wrapper's `key`. Per-call-site `innerKey` differs; must be a parameter. Generalizes: every extraction that lifts a closure-returning-keyed-JSX into a subcomponent inside `.map(...)` MUST add an outer `key` prop derived from the original closure's priority chain. - -## dispatch-factory-must-return-innerkey-when-call-site-derives-outer-wrapper-key - -_Discovered: 2026-04-29 by implementer in rf-canv-12_ - -Building on `extracted-wrapper-key-must-mirror-original-closure-outer-key-chain`: when extracting a registry-style dispatch (iceType + node.type → component) into `renderCanvasNode(node, ctx)` while keeping `<NodeLiftWrapper>` at the call site, the obvious factory signature returning `React.ReactNode` is _insufficient_. The wrapperKey priority chain needs the per-branch `innerKey` in its FALLBACK branch — and the innerKey differs per dispatch arm. Right shape: 2-tuple return `{ element, innerKey }`, where the factory authoritatively names the branch's reconciliation key. Generalizes: any "extract a dispatch factory but keep a wrapper at the call site" — the factory MUST hand back any per-branch reconciliation values the wrapper's outer-key chain depends on. - -## vi-hoisted-required-for-shared-mock-identities-across-many-vi-mock-calls - -_Discovered: 2026-04-29 by implementer in rf-canv-12_ - -The natural test pattern for a registry/dispatch-table extraction with N concrete dependencies (rf-canv-12 had 25 leaf `Svg*` components) is to declare each as a top-level `const MockSvgX: React.FC = () => null` then `vi.mock('../../nodes/x', () => ({ SvgX: MockSvgX }))`. Vitest hoists every `vi.mock(...)` to the top, but does NOT hoist the `const` declarations — `ReferenceError: Cannot access 'MockSvgX' before initialization`. Fix: `vi.hoisted(() => ({ ... }))` is hoisted alongside `vi.mock` calls. Pattern: a single `const mocks = vi.hoisted(() => ({ SvgLogNode: ..., SvgGroupNode: ..., ... }))` declaring all 25 mock FCs, then `vi.mock('...', () => ({ SvgX: mocks.SvgX }))`. Post-import `const MockSvgGroupNode = mocks.SvgGroupNode` aliases keep test bodies readable. Generalizes: any test needing identity-stable mocks across more than ~3 modules should reach for `vi.hoisted` from the start. - -_Promoted to: /docs/refactoring-patterns.md_ - -## brief-prop-type-annotations-may-be-placeholders-not-real-codebase-types - -_Discovered: 2026-04-29 by implementer in rf-canv-15_ - -The rf-canv-15 brief specified `edgeStyle: 'default' | 'dashed' | 'thick' | string` — does NOT match any real value. The actual `EdgeStyle` enum from `'../../../store/slices/ui-slice'` is `'bezier' | 'straight' | 'rectangular'`. Following the brief verbatim would produce a typed-correctly-but-wrong-domain prop. Fix: import `EdgeStyle` from the slice. Generalizes: when a brief contains an inline-string union that looks placeholder-y, grep the actual upstream callsite to find the real enum. Briefs are best-effort summaries of the value space; for any prop fed by a redux selector, the real type lives in the slice. Same shape as `brief-numerics-are-approximate-source-is-canonical` and `brief-import-list-may-include-transitively-referenced-types`. - -## browser-observer-mocks-need-stubglobal-plus-a-hoisted-callback-array - -_Discovered: 2026-04-29 by implementer in rf-canv-18_ - -Testing a hook wrapping `ResizeObserver`/`IntersectionObserver`/etc. in node-only vitest needs three pieces: (1) class-shaped stub via `vi.stubGlobal('ResizeObserver', MockResizeObserver)` — NOT `globalThis.ResizeObserver = ...` reassignment which races with Vite's module worker; (2) `vi.hoisted` block owning both captured-callback array AND per-method spies — spies on instance properties (`observe = mocks.observeSpy`), not class methods (vitest's `vi.spyOn` doesn't traverse a constructor called inside `useEffect`); (3) the synchronous-`useEffect` mock additionally stashes the cleanup function (`if (typeof cleanup === 'function') mocks.effectCleanups.push(cleanup);`) so the test can drive disconnect independently of unmount. - -Pattern: render via Probe, look up `mocks.observerCallbacks[0]`, build a `ResizeObserverEntry`-shaped fixture with `contentRect: { width, height }`, invoke synthetically, assert on the setter spy. The `>0` guard exercised by zero-valued entries. For "returns the new value" path: write the setter's argument back into the mutable ref the `useState` mock reads from, then re-render. Don't reach for jsdom — a 30-line stub is dramatically faster. - -## rtk-store-getstate-is-frozen-use-preloadedstate-not-direct-mutation - -_Discovered: 2026-04-29 by implementer in rf-canv-19_ - -The natural shape for a hook-test harness — `configureStore({ reducer })` then mutate `store.getState().cards.cards = seeded` — fails under RTK's `immutableCheck` middleware: `TypeError: Cannot assign to read only property`. Right pattern: `configureStore({ reducer, preloadedState })`. Derive each slice's default initial state by calling its reducer with `(undefined as any, { type: '@@INIT' })` once at the top of `makeStore`, spread-merge test overrides, pass merged shape as preloadedState. Disable both checks for the harness: `middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false })`. The `(undefined, '@@INIT')` invocation is more reliable than `cardsReducer.getInitialState()` (RTK-only). Pair with `useref-mock-with-hoisted-prefix-ref-unlocks-single-render-effect-deltas`: when a hook's effect-body branch depends on a `useRef` differing from `useState`, mock `useRef` with a hoisted `refForNextRender` container the test pre-primes BEFORE invoking the Probe. - -## hook-return-shape-vs-orchestrator-callsite-the-internal-only-dep-trap - -_Discovered: 2026-04-29 by implementer in rf-canv-21_ - -When extracting a hook whose source-block is a chain of `useMemo`s where each downstream depends on the previous (`A → B(A) → C(A,B)`), trim the orchestrator destructure to ONLY values the orchestrator currently reads. After extraction the orchestrator no longer reads `A` directly — it only ever read `A` to pass it as a dep into the inline `useMemo`s for `B` and `C`. Once those memos move INSIDE the hook, `A` becomes closure-private state. Lint signal: `'X' is assigned a value but never used`, only AFTER wire-up. Generalizes: every hook extraction whose source-block had **chained `useMemo`s** should ask for each named local: "does the orchestrator read this DIRECTLY for a JSX prop / further computation, or only via a `useMemo` dep that's now inside the hook?" Only the former survives the destructure. Verify by lint after wire-up and trim back. Counter-rule below: `brief-vs-rf-canv-21-trim-rule-when-the-planner-knows-the-future-callsite`. - -## fake-timers-plus-sync-useeffect-mock-needs-pertest-toggle - -_Discovered: 2026-04-29 by implementer in rf-canv-22_ - -When a hook's `useEffect` schedules `setTimeout`-then-cleanup (rf-canv-22's auto-organize: `const timer = setTimeout(..., 100); return () => clearTimeout(timer);`), the harness needs THREE pieces: (1) synchronous-`useEffect` mock so effect body runs and queues against timer engine, (2) `vi.useFakeTimers()` set up in `beforeEach` BEFORE render so queued setTimeout lands on fake clock, (3) `vi.useRealTimers()` in `afterEach` so next test isn't poisoned. Without (2) test passes spuriously (assertion runs before real 100ms timer); without (3) any test that follows hangs. Branch-gated assertions: render → assert dispatch NOT called → `vi.advanceTimersByTime(100)` → assert dispatch called. For mixing pure-callback tests with timer tests, stash a per-test boolean `mocks.syncUseEffect.current` so callback-only tests can flip the effect mock to no-op (default true in `beforeEach`). - -## brief-import-drop-list-needs-per-symbol-grep - -_Discovered: 2026-04-29 by implementer in rf-canv-24_ - -The rf-canv-24 brief listed 11 imports to drop after extracting `useCanvasDrop`. TWO are still used outside `handleDrop`: `canContain` (shift-drag-reparent) and `computeCompactNodeHeight` (recalculateAncestorBounds, post-reparent expansion). Mechanically dropping breaks typecheck. Right shape: 2-column truth table `{ symbol: in extracted block? in any other function? }` — drop only when both flip. Generalizes: every extraction-unit brief listing imports to drop must be cross-checked with per-symbol grep over the WHOLE source file before deletion. - -## brief-vs-rf-canv-21-trim-rule-when-the-planner-knows-the-future-callsite - -_Discovered: 2026-04-29 by implementer in rf-canv-25a/28+29_ - -The rf-canv-21 trim rule says: drop destructure entries the orchestrator doesn't use. The rf-canv-25a brief explicitly contradicts: keep `recalculateAncestorBounds` etc. in the destructure even though TODAY zero callsites — planner has paired-unit visibility, rf-canv-25b will use them. When brief and prior learning conflict on destructure trimming, side with brief if (1) part of an explicitly-multi-step extraction AND (2) file already carries other unused-destructure warnings as held state — both YES → follow brief, accept transient lint. **Series-end cleanup**: at the END of a series, audit held bets that didn't pay (rf-canv-25b extracted handleNodeMove into a hook so the destructure entries never wired). Run ESLint, grep for unused-warnings, cross-check against introducing brief — if "future consumer at unit Y" landed without consuming, drop. Generalizes: planner's "preserve destructure shape across the split" is correct DURING the series; the rule reverses at series end. - -## stateful-hook-with-callback-writes-needs-mutable-usestate-slot-mock-not-real-usestate - -_Discovered: 2026-04-29 by implementer in rf-canv-27_ - -Builds on `capture-ref-after-render-unlocks-100pct-on-callback-returning-hooks` and `use-state-mock-with-mutable-ref-unlocks-direct-fc-toggle-state-tests`. When the extracted hook exposes BOTH a callback that writes state AND a callback that reads it (`handleConnectionPortDown` calls `setDrawingConnection({...})`, `handleConnectionEnd` runs `if (!drawingConnection) return;`), the rf-props-8 capture-ref pattern with REAL `useState` is insufficient: each `renderToString` mounts a fresh component instance, so the write is committed against an instance the next captureHook no longer holds. Adopt the mutable-slot `useState` mock pattern even with ONE state slot. Hoist `drawingConnectionSlot: { current: null | DrawState }`, mock `useState` to read on call and have setter write directly. Tests either drive the write via `result.handleConnectionPortDown(event)` or skip the port-down path entirely by writing the slot directly via a `startDrag(opts)` helper. Generalizes: any future hook whose callbacks BOTH write and read `useState` slots — use mutable-slot from the start regardless of slot count. - -## regex-i-flag-applies-to-character-classes-not-just-the-literal - -_Discovered: 2026-04-30 by implementer in rf-pdpl-5_ - -The rf-pdpl-5 brief described `extractProjectIdFromError`'s regex `/project[=/]([a-z0-9-]+)/i` as "rejects upper-case project IDs". Wrong: `/i` applies to the **entire** pattern, including character classes. `[a-z]` under `/i` matches `[A-Za-z]`. So `'project=FooBar'` actually returns `'FooBar'`; `'project=foo-BAR-baz'` matches `'foo-BAR-baz'` greedy. Per `brief-test-spec-vs-verbatim-behavior-conflict`, pin verbatim behavior with a "BRIEF↔CODE NOTE" comment. Generalizes: when a brief makes a claim about a regex's character-class behaviour, independently verify by running the literal in a Node REPL before writing tests; the `/i` flag's reach surprises in both directions. Same applies to `/u` with `\w`/`\d` and `/s` with the dot. - -## react-namespace-hook-access-requires-patching-default-export-too - -_Discovered: 2026-04-30 by implementer in rf-pdpl-12_ - -The mutable-ref `useState` mock pattern (`vi.mock('react', () => ({ ...actual, useState: patchedFn }))`) ONLY patches named exports. When source accesses hooks via namespace import — `import React from 'react'; ...; React.useState(...)` — the runtime resolves `React` to the `default` export, which carries its own copy of `useState`. Calls route into the real renderer-context-bound function and throw `TypeError: Cannot read properties of null (reading 'useState')`. Fix: in the `vi.mock('react', ...)` factory, return BOTH `useState` (named) AND `default: { ...actualDefault, useState: patchedFn }`. Applies symmetrically to `useEffect`, `useMemo`, `useCallback`, `useRef`. The `@types/react` namespace doesn't always declare `default`; cast through `unknown`: `(actual as unknown as { default?: typeof actual }).default ?? actual`. Diagnostic stack points at source line; if your factory only returns `{ ...actual, useState: ... }` and you see this, add the `default:` block. - -_Promoted to: /docs/refactoring-patterns.md_ - -## stubbing-window-and-keyboardevent-for-node-env-keydown-listener-tests - -_Discovered: 2026-04-30 by implementer in rf-pdpl-12_ - -Vitest default env is `node` — `window`, `document`, `KeyboardEvent` undefined. When extracted component's `useEffect` registers `window.addEventListener('keydown', ...)`, stub instead of switching env (jsdom adds 100ms+ and pulls a heavy polyfill): (a) `vi.stubGlobal('window', { addEventListener, removeEventListener, dispatchEvent })` with a Map<string, Set<Listener>> tracker so add/remove/dispatch round-trips; (b) `vi.stubGlobal('document', { body: {} })` since `createPortal(el, document.body)` evaluates the second arg even when portal mock ignores it; (c) `vi.stubGlobal('KeyboardEvent', class StubKeyboardEvent { constructor(type, init) { this.type = type; this.key = init?.key ?? ''; } })`. Stub class needs only fields the source's keydown handler reads. - -## react-memo-wrapper-must-be-unwrapped-via-dot-type-for-direct-fc-tree-walker - -_Discovered: 2026-04-30 by implementer in rf-pdpl-13_ - -The direct-FC tree-walker invokes the extracted component as `(Component as unknown as Fn)(props)`. For plain function FCs that's fine. For `React.memo`-wrapped components, the runtime export is an object `{ $$typeof: Symbol(react.memo), type: <Inner FC>, compare }` — calling it throws `TypeError: ... is not a function`. Fix: reach for `.type` to get the inner render — `const Inner = (Component as unknown as { type: (p: Props) => React.ReactElement }).type; return Inner(props);`. The walker itself doesn't need changes. Bonus: pin the memo boundary as a separate slot — `expect((Component as { $$typeof: symbol }).$$typeof.toString()).toBe('Symbol(react.memo)')`. The brief should call out "memo'd — unwrap via .type" wherever source has `React.memo(`. - -_Promoted to: /docs/refactoring-patterns.md_ - -## vi-mock-paths-resolve-relative-to-test-file-not-source-file - -_Discovered: 2026-04-30 by implementer in rf-pdpl-14_ - -When a brief specifies "mock the imports the source uses" and lists those paths verbatim — `'../../../i18n'` — it is tempting to copy them unchanged. That is wrong: `vi.mock` resolves relative to the **test file's** location, not the source file's. So if the source lives at `packages/ui/src/features/deploy/components/deploy-in-flight-panel.tsx` (`../../../i18n` → `packages/ui/src/i18n`), the test at `packages/ui/src/features/deploy/components/__tests__/deploy-in-flight-panel.test.tsx` needs ONE more `../`: `'../../../../i18n'`. The diagnostic when this is wrong is subtle: vitest creates a "phantom" module at the (test-relative) path, the mock attaches to that phantom, but the source still imports the real module — test fails with errors from inside the real `useTranslation` (e.g. `TypeError: Cannot read properties of null (reading 'useContext')`). Fix is mechanical: every `vi.mock(path, ...)` in a `__tests__/`-folder test must have one extra `../` segment compared to the source-file's import string for that same module. - -_Promoted to: /docs/refactoring-patterns.md_ - -## lucide-react-icons-are-forwardref-objects-not-fcs-for-tree-walker-predicates - -_Discovered: 2026-04-30 by implementer in rf-pdpl-14_ - -The direct-FC tree-walker descends through React elements by checking `typeof el.type === 'function'`. That breaks for `lucide-react` icon imports — `Loader2`, `RefreshCw`, etc. — because lucide wraps every icon in `React.forwardRef`, producing an _object_ `{ $$typeof: Symbol(react.forward_ref), render: <fn> }`, NOT a function. Predicates like `findByPredicate(tree, (el) => typeof el.type === 'function' && (el.props.className ?? '').includes('animate-spin'))` filter the icon out. Fix two ways: (a) drop `typeof === 'function'` guard, predicate purely on className — `findByPredicate(tree, (el) => typeof (el.props.className) === 'string' && el.props.className.includes('animate-spin'))`; (b) check `(el.type as { $$typeof?: symbol }).$$typeof?.toString() === 'Symbol(react.forward_ref)'`. Same gotcha for any forwardRef library (Radix UI primitives, react-aria). Sub-rule: `displayName` won't help either — lucide v0.577+ aliases legacy names (`CheckCircle.displayName === 'CircleCheckBig'` because `check-circle.js` re-exports from `circle-check-big.js`). Filter by **reference equality** on `el.type` against the imported icon — module-singleton identity holds across source/test boundary. - -_Promoted to: /docs/refactoring-patterns.md_ - -## prop-capturing-mock-fc-needs-drain-and-reset-for-tree-walker-tests - -_Discovered: 2026-04-30 by implementer in rf-pdpl-18_ - -When a section module renders a child component twice in the same FC body, the natural pattern mocks the child as an opaque marker FC pushing props onto a hoisted array. Tests read `mocks.iceSelectCalls[0]` for first call, `[1]` for second. Trap: the tree-walker invokes nested FCs as a side-effect of walking, and `findByPredicate` / `collectText` walk THE WHOLE TREE every call. Two walks → mock fires twice → `iceSelectCalls.length` doubles non-deterministically. `vi.hoisted` reset in `beforeEach` doesn't fix it (duplicate pushes happen WITHIN one test). Fix: a `drainIceSelectCalls(tree)` helper that (a) clears the recorder, (b) runs ONE throwaway walk, (c) snapshots into a local array, (d) clears the recorder again. Tests assert against the local snapshot. Generalizes: every test mocking a child component with per-call props-capture AND then tree-walking afterwards needs the drain-and-reset wrapper. - -## redux-toolkit-unknown-action-payload-needs-double-cast-via-unknown - -_Discovered: 2026-04-30 by implementer in rf-pdpl-20_ - -When testing a callback hook that dispatches RTK slice actions, `(dispatchSpy.mock.calls[i][0] as { payload: string }).payload` fails with TS2352. RTK 2.x's `Dispatch<UnknownAction>` types the spy parameter as `UnknownAction = { type: string; [extraProps: string]: unknown }` — index signature deliberately doesn't include `payload`. Workarounds: (a) cast through `unknown`: `dispatchSpy.mock.calls[i][0] as unknown as { payload: string }`; (b) helper `function asAction<P>(call: unknown): { type: string; payload?: P } { return call as unknown as ...; }`. The `setImmediate` global is also missing in this monorepo's vitest config — for fire-and-forget promise flushes, use `await new Promise<void>((resolve) => setTimeout(resolve, 0))` wrapped in a `flushMicrotasks` helper. Sub-rule (`vitest-spyon-return-type-on-console-needs-loose-shape-cast-for-mock-calls-iteration`): `let consoleLogSpy: ReturnType<typeof vi.spyOn>` triggers TS7006 on `.mock.calls.find(c => c[0] === '...')`. Fix: tiny local interface `interface ConsoleSpyLike { mock: { calls: unknown[][] }; mockRestore: () => void; }` with `as unknown as ConsoleSpyLike` cast. Same flavor as `vi-fn-default-type-rejects-typed-callback-parameter` and `vi-fn-generic-narrows-mockResolvedValueOnce-arg-to-never-on-optional-fields`: vitest's explicit generics are stricter than runtime. - -## fingerprint-multi-useEffect-by-deps-array-shape-when-bundled-in-one-hook - -_Discovered: 2026-04-30 by implementer in rf-pdpl-21_ - -When extracting a multi-effect hook (rf-pdpl-21's `useDeployEffects` bundles four `useEffect` calls), the natural urge is to mock the four bodies separately by stubbing downstream APIs one at a time — brittle. Stash each registration as `{ cb, deps, cleanup }` into a single hoisted `mocks.effects` array, then in tests **fingerprint by deps-array shape** to find the effect under test (`effects[0]` length 1 → auto-scroll; `effects[1]` length 5 → provider auto-detect; etc.). Add `effectByOrder(i)` helper that throws on empty slot. Cleanup-stash is per-effect inline (`effects[i].cleanup`), NOT a flat `effectCleanups[]`. The Provider+Probe+renderToString harness is otherwise identical. When you add a 5th effect that collides on dep-shape (`[isOpen, cardId]` × 2), add a content check or move to named-export pure runners. Coverage on `use-deploy-effects.ts`: 100/100/100/100 with 48 tests. - -## pnpm-filter-core-test-with-path-arg-needs-root-relative-not-package-relative - -_Discovered: 2026-04-30 by implementer in rf-ctrans-1_ - -`pnpm --filter @ice/core test packages/core/src/<path>.test.ts --run` exits with `No test files found`. Reason: the script runs `vitest run <path>` with cwd `packages/core/`, but workspace's vitest `include` glob is `packages/*/src/**/*.test.{ts,tsx}` (root-level pattern). Two fixes: (a) `pnpm vitest run --root . packages/core/src/<path>.test.ts` from repo root; (b) `pnpm --filter @ice/core test src/<path>.test.ts --run` (drop the `packages/core/` prefix). When you get `No test files found`, switch to root-level `pnpm vitest run --root .` rather than debugging the filter. - -## graph-nodes-keyed-by-type-colon-name-not-bare-name - -_Discovered: 2026-04-30 by implementer in rf-ctrans-10_ - -`MutableGraph._nodes` is `Map<NodeId, Node>` where `NodeId` is the branded form `${input.type}:${input.name}`. The public getter returns this Map directly, so `graph.nodes.get(plainName)` will always miss. Pass 1.4 and 1.45 in `card-translator.ts` both call `graph.nodes.get(name as any)` where `name` comes from `card_id_to_name` (storing bare resource names). Net: in production both passes silently no-op. The `as any` cast hides the type mismatch. Fixed in bugfix-1 via `graph.get_node_by_name(name)` migration across pass-1-4, pass-1-45, pass-1-5; `remove_node` consumer also migrated (it only accepts NodeId, so resolve via `get_node_by_name(name)?.id` first). Test fixtures migrated from bug-bypass `card_id_to_name.set(cardId, branded NodeId)` to production-shape `card_id_to_name.set(cardId, bareName)`. Generalizes: when fixing a latent lookup bug, audit ALL functions in the same flow consuming the same input shape — fixing only the lookup leaves the second consumer broken. Diagnostic: after fixing a `Map.get(x as any)` callsite, grep for any other `Map`/method call taking the same `x`. - -## brief-numerics-are-approximate-source-is-canonical - -_Discovered: 2026-04-30 by implementer in rf-cards-7_ - -Briefs that give numeric or named specifics ("20-field array", "the migrator wires Foo to Bar") are best-effort summaries. Real `clearCardDeployOverlay` had 24 fields, not 20; `migrateCardNode` has TWO migration branches not one (`Monitoring.Terminal → Monitoring.Log` data-only AND `Cluster.*/Block.* → Group.*` with type flip). Generalizes: when a brief gives specifics, treat as scaffolding — open the source, count/copy actual values, pin THOSE in tests. Same shape as `brief-import-list-may-include-transitively-referenced-types`, `brief-prop-type-annotations-may-be-placeholders-not-real-codebase-types`, `brief-cited-event-shapes-need-source-of-truth-verification`, `brief-vs-source-default-branch-discrepancy-on-get-type-map`: brief lists/types/numerics are starting points, source files are authoritative. - -## relative-import-depth-must-be-recounted-when-moving-deeper - -_Discovered: 2026-04-30 by implementer in rf-cards-8_ - -The rf-cards-8 brief said "the `..` count for canvas-constants is 3 (verified by rf-cards-3)." True for `cards/edge-routes.ts` (3 segments to `src/`). But rf-cards-8 lives in `cards/reducers/node-position.ts`, ONE level deeper, so correct count is 4 (`../../../../config/canvas-constants`). When a sibling cites a `..`-count, that count is anchored to THAT sibling's directory depth, not yours. Generalizes: relative-import depths cited in briefs/learnings are anchored to citing file's directory; when the destination differs, recount segments. Cheaper alternative — convert to a tsconfig path alias (`@ui/config/canvas-constants`) once enough modules cross multiple `..` levels. Same shape recurs at `algorithm-pass-grouping-needs-uniform-import-depth-tracking` and `reducer-group-extraction-i18n-import-depth-from-reducers-folder`. - -_Promoted to: /docs/refactoring-patterns.md_ - -## delete-vs-undefined-test-must-use-in-operator-not-strict-equality - -_Discovered: 2026-04-30 by implementer in rf-cards-9_ - -When pinning that code MUST use `delete node.parentId` (not `node.parentId = undefined`), `expect(node.parentId).toBeUndefined()` passes for BOTH shapes — strict-equality undefined-checks can't tell them apart. Load-bearing assertion: `expect('parentId' in node).toBe(false)` (or `Object.prototype.hasOwnProperty.call(node, 'parentId')`). Only `delete` removes the key from the own-property list; `= undefined` keeps the key with undefined value. Generalizes: any test pinning "must `delete` the key, not assign undefined" must use `'<key>' in obj` — never `obj.<key> === undefined`. Same shape as `hard-coded-constant-risk-pin-needs-call-with-meaningful-input`: assertion has to actually distinguish the two implementations the brief is trying to pin. - -## immer-revoked-proxy-from-spy-args-needs-deep-clone - -_Discovered: 2026-04-30 by implementer in rf-cards-12_ - -When testing a reducer invoked via `produce(state, draft => reducer(draft, action))` where the reducer calls a mocked dependency, the `vi.fn()` spy captures references to Immer proxies. Once `produce(...)` returns, those proxies are _revoked_ — post-`produce` access throws `TypeError: Cannot perform 'has' on a proxy that has been revoked`. Fix: deep-clone args inside the spy capture: `mockSpy(JSON.parse(JSON.stringify(nodes)), ...)`. The clone runs while the proxy is still live (spy invoked inside reducer body, mid-`produce`). For richer shapes use `structuredClone`. Generalizes: any test asserting on call-args of a function called inside an Immer `produce(...)` callback must clone args at spy-capture time, OR move the `expect` inside the produce callback. - -## vi-hoisted-and-vi-mock-blocks-must-not-split-import-groups - -_Discovered: 2026-04-30 by implementer in rf-fbh-5_ - -The natural shape — put `const mocks = vi.hoisted(...)` and `vi.mock("./...", ...)` between `import { ... } from "vitest"` and other imports — triggers eslint `import-x/order` "no empty line between import groups". `eslint --fix` cannot resolve this. Fix: ALL imports contiguously at the top, then `vi.hoisted` and `vi.mock` calls AFTER. Vitest hoists both above any import statement in its pre-execution pass, so hoisting still works. A short comment near the hoisted block saves the next reader from worrying about init order. - -_Promoted to: /docs/refactoring-patterns.md_ - -## git-stash-and-tsbuildinfo-and-prior-unit-lint-traps - -_Discovered: 2026-04-30 by implementer in rf-fbh-2/3_ - -Three workflow gotchas around using stash/lint to verify "is this pre-existing?": (a) earlier units may ship "future-proofing" imports that become immediately unused — pre-commit hook only runs version-bump, no lint gate. Run `pnpm exec eslint --fix` on the orchestrator FIRST when starting any follow-up unit. (b) `pnpm typecheck` writes `tsconfig.tsbuildinfo` as a side-effect, so `git stash → pnpm typecheck → git stash pop` fails with "local changes would be overwritten" and partially applies (source files reverted, new untracked files survive). Recovery: `git checkout <pkg>/tsconfig.tsbuildinfo` then `git stash pop`. Better: skip stash entirely — `pnpm exec tsc --noEmit 2>&1 | grep <my-file>` (empty grep == no new errors). (c) `git stash` without `-u` discards untracked files from the work tree only when the conflict path includes them; check that newly-created files survived the stash dance. - -## brief-cited-event-shapes-need-source-of-truth-verification - -_Discovered: 2026-04-30 by implementer in rf-dslice-7_ - -Briefs describing wire events with field names like `{ deployment_id, seq, at, outcome, counts }` are mnemonics, not contracts. Actual `DeployCompleteEvent` from `packages/types/src/deploy-events.ts` uses `card_id` (not `deployment_id`) and `totals` (not `counts`, with extra `queued`/`applying` buckets). Same applies to `DeployNodeStatusEvent` and `DeployNodeProgressEvent`. Building a fixture from the brief surfaces as TS2352. Generalizes: when writing tests for a reducer consuming a typed wire event, EVEN IF the brief gives field names verbatim, open the type file. The generated TypeScript type from `@ice/types` is the contract. - -## or-chain-default-fallback-needs-its-own-test-for-100pct-branch-coverage - -_Discovered: 2026-04-30 by implementer in rf-fbh-8_ - -The DNS extractor's `recordSet?.records || recordSet?.checkError?.records || []` — three branches in one expression. Testing the first two yields 96.07% branches on a 100%-line, 100%-function file. Missing branch: literal `[]` fallback when both are absent. Same pattern strikes `if (ds.expectedIps)` against optional-property-on-untyped-bag. Fix: one-liner test per dangling branch passing an object that hits the default. Generalizes: every `a || b || defaultLiteral` chain and every `if (x.optional)` against an `any`-typed bag needs an explicit "default reached" test. 100%-line / sub-100%-branch with one or two specific line numbers flagged is a reliable signal pointing at OR-chain tails or `if (optional)` falsy paths. - -## sed-greedy-dot-star-eats-chained-calls-on-one-line - -_Discovered: 2026-04-30 by implementer in rf-parse-1_ - -Bulk callsite-rename `s/this\.check\(\(.*\))/ps_check(this.state, \1)/g` rewrites obvious cases but silently mangles lines with TWO+ chained calls (`this.check('A') || this.check('B')`). Sed greedy-matches `.*` from FIRST `(` to LAST `)`. Two prevention shapes: (a) `s/this\.check(\([^)]*\))/ps_check(this.state, \1)/g` — `[^)]*` won't span the closing `)` so chained calls each rewrite independently; (b) ALWAYS post-sed grep for the old-name pattern before declaring done. Sub-rule (`sed-empty-arg-substitution-glues-state-to-next-token`): `parse_X(this.state\1)` body works for 0-arg cases but `this.parse_X(token)` rewrites to `parse_X(this.statetoken)` — no comma. Always `state, \1` for N-arg helpers, `state\1` for guaranteed-0-arg. - -## bootstrap-fnarg-vs-direct-import-for-circular-grammar-pair - -_Discovered: 2026-04-30 by implementer in rf-parse-3_ - -When extracting recursive-descent grammar layers and the call graph forms a cycle (parse_postfix → parse_primary → parse_expression → ... → parse_postfix), direct-import + lazy-evaluation of ESM cycles works as long as: (1) ALL cross-module references are inside function bodies, never at top-level module-init; (2) atomic landing of both files in one commit; (3) both files created before the parser.ts callsite-replace step. TypeScript's typecheck passes, runtime works, all tests pass. Companion rule (`co-locate-mutually-recursive-helpers-to-skip-cycle-bootstrap`): when the recursion cluster is small (rf-parse-5's 4 functions ≈ 145 LOC), co-locate in one file — no cross-module edge, no cycle, no atomic-landing constraint. Cross-module cycles are right only when the cluster is too large to live on one file. - -## data-heavy-shim-split-keep-helpers-with-shim-not-data - -_Discovered: 2026-04-30 by implementer in rf-data-1_ - -When splitting a data-heavy module (scale-presets.ts 1562 LOC → types/data/shim trio), helpers that consume the data belong in the public shim file, NOT in the data file. Three reasons: (1) shim is the stable import surface — helpers there means the shim is structurally complete; (2) helpers live with runtime ergonomics; (3) data file's "size exception" header reads as a real exception only when the file contains ONLY data. Shim file ends up at ~58 LOC. Pre-existing typecheck baseline of `@ice/core` carries ~30 TS2834 errors in unrelated files; bar is "no NEW errors in your touched paths". Pre-commit only runs version-bump hook; no typecheck/lint gate. - -_Promoted to: /docs/refactoring-patterns.md_ - -## class-private-brand-blocks-this-as-context-passthrough - -_Discovered: 2026-04-30 by implementer in rf-sched-3_ - -When extracting class methods to standalone helpers taking `ctx: SomeContext`, "pass `this` to the helper because the class fields structurally match" rejects with TS2345: `Property 'foo' is private in type 'TheClass' but not in type 'SomeContext'`. The `private` modifier is a nominal brand. Two fixes: (a) cast at call site (`this as unknown as SomeContext`) — fast, ugly; (b) lift the fields onto a real `private readonly ctx: SomeContext` field, build in constructor, pass `this.ctx` everywhere — clean. Pattern (a) is fine as a temporary stepping-stone inside the same PR series. Generalizes: any class decomposition delegating to standalone helpers should plan for the `ctx` field from unit-1. - -_Promoted to: /docs/refactoring-patterns.md_ - -## scheduler-context-pattern-fits-mutable-state-classes-better-than-pure-helpers-classes - -_Discovered: 2026-04-30 by implementer in rf-sched-4_ - -The conservative decision tree "pure → extract; reads class state only → extract with state arg; writes class state → likely stay" is too conservative once a `ctx: SchedulerContext` mutable handle is on the table — the rf-sqlite shape proves helpers writing ctx (resources_save mutates `ctx.statements`, locks_acquire mutates the lock row) all extract cleanly because writes go through ctx, not `this.x`. For rf-sched-4 every method that mutated `this.in_flight`, `this.handler_in_flight`, etc. extracted to a standalone fn taking ctx; only `run()` stayed on the class. Generalizes: when a class has a mutable state bag already structurally describable, nearly every private method can extract regardless of read/write. With ctx pattern, default to "extract everything" and reserve the class for entry-point orchestration. - -_Promoted to: /docs/refactoring-patterns.md_ - -## tree-walker-collectText-array-children-fallback-for-jsx-button-text-after-icon - -_Discovered: 2026-04-30 by implementer in rf-pset-5_ - -The `collectText` reduction collects only `props.children` whose runtime type is `string`. That misses the most common JSX shape: `<button><Icon />{t('label')}</button>`. React stores those as an array `[<Icon />, 'label']`, not as a string. Fix: extend `collectText` to also iterate `Array.isArray(c)` and pull string elements: `else if (Array.isArray(c)) { for (const item of c) { if (typeof item === 'string') s += item; } }`. Don't recurse — walker already yields children element-by-element via `walk`. This is _not_ the same bug as `react-ssr-comment-markers-split-adjacent-text-substrings` (`collectText` _never sees_ this text in the first place). Diagnostic: `collectText(tree)` returns parent text but is missing the literal text right after a lucide icon inside the same button. - -_Promoted to: /docs/refactoring-patterns.md_ - -## tree-walker-walks-mocked-fc-output-so-data-stub-attrs-appear-on-rendered-marker-not-original-jsx - -_Discovered: 2026-04-30 by implementer in rf-tgal-6_ - -When testing an orchestrator that renders a child mocked as `<TemplateDetail data-stub="TemplateDetail">{...}</TemplateDetail>`, the rf-rpal-8 tree-walker invokes the mock FC AND walks its output. So a `findByPredicate(tree, (el) => el.props['data-stub'] === 'TemplateDetail')` will match the INNER `<div data-stub="TemplateDetail">` rendered by the mock, NOT the original `<TemplateDetail>` JSX call site. The inner div has only the props the mock copied onto it — it does NOT have `onBack`/`onUse`/`template` from the original call. So a test that does `(detail.props as { onBack: () => void }).onBack` finds `onBack === undefined` and fails with "expected 'undefined' to be 'function'". Two fixes: (a) predicate on the FC-call site directly: `el.type === <MockedComponent>` (reference equality against the imported mock — vitest's `vi.mock` returns the same module-singleton in test and source); (b) predicate on `typeof el.type === 'function'` AND a unique prop the mock copies through (e.g. `el.props.template?.id`). Option (b) is the cleaner pattern when the mocked component has a discriminating prop. The rf-rpal-8 / rf-pdpl tree-walker pattern doesn't separate "FC call site" from "FC output" — `walk` yields BOTH because it yields the original element and then descends into the FC's return value. The mocked FC's output is rendered by the same walker, so `data-stub` markers added by the mock end up as siblings of the original JSX in the iteration. Pair with `react-memo-wrapper-must-be-unwrapped-via-dot-type-for-direct-fc-tree-walker` (which also distinguishes the wrapper from the rendered tree). Generalizes: any future test that mocks a child FC with `data-stub` markers AND wants to read the original call's props should filter on `typeof el.type === 'function'` plus a content discriminator, not on the marker attribute. Diagnostic: `expected 'undefined' to be 'function'` (or any prop coming back undefined) when probing a mocked-component element via its data-stub attribute. - -_Promoted to: /docs/refactoring-patterns.md_ - -## tree-walker-mocked-fc-onclose-prop-not-readable-on-fc-element-after-walk-recursion - -_Discovered: 2026-04-30 by implementer in rf-wgal-7_ - -When testing an orchestrator that renders `<TemplateDetail template={selectedTemplate} onClose={() => setSelectedTemplate(null)} onUse={handleUseTemplate}/>`, the rf-pdpl tree-walker invokes the mock and walks the inner output. A predicate like `typeof el.type === 'function' && el.props.template?.id === 'tpl-a'` SHOULD match the original FC call site BEFORE the mock's inner `<div>` is yielded — but in practice, the FIRST element `find()` returns appears to lack the `onClose`/`onUse` props (`undefined`). I burned ~10 minutes trying to figure out whether the closure was getting stripped, the props were lost during walk, or some other nonsense. The actual fix is much simpler than chasing the FC call site: probe the inner mock-rendered `<button data-stub="close" onClick={onClose}>` directly — the mock DOES copy `onClose` to the button's `onClick`, so a `findByPredicate(tree, el => el.props['data-stub'] === 'close')` returns the button with `onClick: () => setSelectedTemplate(null)`. Asserting `typeof onClick === 'function'` is sufficient to prove the wiring exists. This pattern works any time the test mock implementation copies a callback-prop to a child element's `onClick` (which is exactly what makes the mock "press-able" in the first place). Generalizes: when a direct-FC tree-walker test fails to read a callback prop on an apparently-correct FC call site, switch the assertion to probe the rendered-mock surface that copies the callback. The mock's data-stub markers + onClick prop survive all the walker recursion because they're plain leaf elements. Pair with `tree-walker-walks-mocked-fc-output-so-data-stub-attrs-appear-on-rendered-marker-not-original-jsx` (rf-tgal-6) — both surface when the test author tries to assert on the outer FC-call site instead of the inner mock-rendered surface that's actually press-able. - -_Promoted to: /docs/refactoring-patterns.md_ - -## tree-walker-findall-must-recurse-into-array-children-for-fragment-children - -_Discovered: 2026-04-30 by implementer in rf-ptree-7_ - -The walker's `findAll(el, pred)` typically iterates `props.children` as `Array.isArray(children) ? children : [children]` and recurses into each child. That breaks for components emitting a `React.Fragment` whose children are themselves an array: `{folder.expanded && <>{childFolders.map(...)}{childProjects.map(...)}</>}`. The Fragment's `props.children` arrives as a TWO-level array — outer is Fragment children list, at index `[0]` is the `childFolders.map(...)` array. Treating the inner array as a single element yields undefined. Fix: detect arrays at entry of `findAll` and recurse element-wise: `if (Array.isArray(el)) { for (const c of el) out.push(...findAll(c, pred)); return out; }`. Same pattern for any walker (`collectText`, `findByPredicate`). Generalizes: every direct-FC tree-walker test where the source uses `<>{x.map(...)}{y.map(...)}</>` needs the array-flattening shim. Two lines and harmless on element trees, so safe to inline by default. - -_Promoted to: /docs/refactoring-patterns.md_ - -## subhook-deps-must-be-MutableRefObject-not-RefObject-when-handlers-write-back - -_Discovered: 2026-04-30 by implementer in rf-canvint-3_ - -When extracting a callback-bundle sub-hook from an orchestrator that builds refs via `useRef<T>(initial)`, the natural-looking dep type is `RefObject<T>`. It's wrong. React's `RefObject<T>.current` is **`T | null` and read-only** (TS lib def L151-156); the orchestrator's `useRef<T>(initial)` actually returns **`MutableRefObject<T>`** (T, writable, never null when initialized). Typing sub-hook's deps as `RefObject<T>` triggers TS18047 "possibly null" on every read AND TS2540 "Cannot assign to 'current'" on every write — including verbatim writes the original closure bodies do constantly. Fix: import both `MutableRefObject` and `RefObject`; type orchestrator-owned refs as `MutableRefObject<T>`; reserve `RefObject<T>` only for refs forwarded from external sources. - -_Promoted to: /docs/refactoring-patterns.md_ - -## subhook-stateRef-cross-binding-must-be-orchestrator-owned-when-multiple-subhooks-need-it - -_Discovered: 2026-04-30 by implementer in rf-canvint-3_ - -The rf-canvint plan separated canvas-interactions into mouse-handler (3) and keyboard-handler (4) sub-hooks. One ref crosses the boundary: `spaceHeldRef` — keyboard sub-hook WRITES on keydown/keyup, mouse sub-hook READS on mousedown for the Space+left-click pan branch. Defining `spaceHeldRef` inline at the keyboard sub-hook's call site at the bottom of the orchestrator breaks the moment you split the file. Fix: HOIST `spaceHeldRef = useRef(false)` to the top of the orchestrator alongside other always-orchestrator-owned refs, thread into BOTH sub-hooks. Generalizes: any time two sibling sub-hooks share mutable state via a ref, the ref MUST live at the orchestrator. Cross-binding refs should be the FIRST thing the planner flags. - -_Promoted to: /docs/refactoring-patterns.md_ - -## sub-hook-test-needs-stub-window-and-Probe-when-effect-uses-window-listeners - -_Discovered: 2026-04-30 by implementer in rf-canvint-4_ - -When extracting a sub-hook whose `useEffect` installs `window.addEventListener('keydown'/'keyup'/'blur', ...)`, the harness needs THREE pieces: (1) `window` global stub via `vi.stubGlobal('window', { addEventListener, removeEventListener })`; (2) stubs for input-element constructors (`HTMLInputElement`/`HTMLTextAreaElement`/`HTMLSelectElement`) because handler does `e.target instanceof HTMLInputElement`; (3) Probe + `renderToString` because sub-hook calls `useRef` for private refs requiring fiber context. Three signals: (a) hook uses `useRef`/`useState`/`useEffect`? → needs Probe + renderToString; (b) callback references `window`/`document`/`navigator`? → needs `vi.stubGlobal`; (c) callback does `instanceof X` against DOM type? → stub `X` as a class. Diagnostics: useRef null-pointer / "X is not defined" / "Right-hand side of instanceof is not callable". - -## vi-mock-paths-resolve-from-test-file-not-from-sut - -_Discovered: 2026-05-01 by implementer in rf-aisvc-7_ - -When extracting a leaf module out of an orchestrator and writing a smoke test for the orchestrator that mocks the leaf, intuition says use the same import path the SUT uses. Wrong. Vitest resolves `vi.mock(specifier)` from the **test file's** location, NOT the SUT's. So if SUT lives at `services/ai/src/services/ai.service.ts` and imports `'./ai/provider'`, but test lives at `services/ai/src/services/__tests__/ai.service.test.ts`, the test must call `vi.mock('../ai/provider', ...)`. Symptom: with wrong path, mock factory silently does NOT replace the import, SUT loads real leaf module, tests fail with the real module's error. Fix is one character: replace `./X` with `../X`. Build the path mentally: test_dir → up to common parent → down to mocked module. Pair with `vi-mock-factory-hoist-blocks-top-level-class-references`. - -_Promoted to: /docs/refactoring-patterns.md_ - -## pushSnapshot-prologue-side-effect-still-observable-on-bail - -_Discovered: 2026-04-30 by implementer in rf-cards-14_ - -`groupSelectedNodes` has TWO early-return branches that look symmetric but diverge sharply on state shape. Branch A (`nodeIds.length < 2`) returns BEFORE `pushSnapshot(state)` — true no-op. Branch B (`selectedNodes.length < 2`, after the filter) returns AFTER `pushSnapshot` ran — `state.history[card.id].past` gained a snapshot of pre-call state even though the requested mutation never landed. The naive `expect(next).toEqual(state)` would pass for A and fail for B with a confusing history mismatch. Right shape: branch A asserts `next.history.c1 === undefined`; branch B asserts `next.history.c1.past.length === 1` AND nodes unchanged. Generalizes: any reducer with `pushSnapshot(state); /* validate */ if (...) return; /* mutate */` shape needs per-branch test assertions. - -## hard-coded-constant-risk-pin-needs-call-with-meaningful-input - -_Discovered: 2026-04-30 by implementer in rf-cards-13_ - -The naive RISK-pin shape "assert that node.width didn't change" is silently OK with TWO failure modes: (a) the early-return short-circuit fires before the loop runs (test passes for the WRONG reason); (b) the constants are correctly 1 and the loop ran. To distinguish, drive the reducer with input that DEFINITELY enters the per-node loop AND a meaningful zoom delta (1.0 → 1.5) so a `zoom / prevZoom` refactor would scale by 1.5×, making the assertion fail loudly with `Expected 240, Received 360`. Generalizes: any "must remain hard-coded constant K" risk-pin needs an input that would produce visibly DIFFERENT output if K were dynamic. Same shape as `delete-vs-undefined-test-must-use-in-operator-not-strict-equality`. - -## behavioral-asymmetry-between-create-and-update-paths-needs-flag-not-fork - -_Discovered: 2026-04-30 by implementer in rf-cstor-4_ - -When extracting a shared helper from two callsites that differ in a few specific behaviors (cloud-storage's create() and update() both implement IAM-grant + ACL-fallback; update() additionally re-fetches the policy after `setPolicy` to detect silent stripping), resist forking. Use a single helper with discriminated-options flag: `verifyAfterWrite: boolean` — update passes true, create passes false. The flag _names_ the asymmetry (caller's intent documented) and _gates_ the behavior. Three reasons forking is worse: (1) implementations drift; (2) tests have to cover both; (3) reading the orchestrator, the reader can't tell if differences are intentional. Diagnostic: when extracting, do a pre-extraction read of both callsites side-by-side, write a 1-3 bullet list of every asymmetry — if you can name the difference with a noun, it's a flag; if structural, two helpers. - -## early-return-after-hooks-still-registers-effects-and-state-slots - -_Discovered: 2026-04-30 by implementer in rf-tgal-6_ - -A FC `() => { const [a] = useState(0); useEffect(...); if (!isOpen) return null; return <JSX/>; }` registers BOTH state slot AND effect on every invocation, even when `isOpen` is false. This is React's "rules of hooks". Direct-FC tests asserting "no effects registered when early-return fires" are wrong: effects ARE in `mocks.effects`; their _bodies_ short-circuit. Two fixes: (a) re-shape assertion to "effect body short-circuits" — fire `mocks.effects[0].cb()` and check no state slot mutated; (b) gate test on whether JSX rendered (`expect(tree).toBeNull()`) and skip effect-count assertion. Generalizes: any orchestrator with `if (!<openFlag>) return null` registering hooks ABOVE the early return needs body-short-circuit assertion shape. - -## icon-data-table-must-live-in-tsx-file-not-ts-when-stored-as-jsx-elements - -_Discovered: 2026-04-30 by implementer in rf-cost-3_ - -`.ts` doesn't trigger the JSX transformer; `.tsx` does. A `Record<string, React.ReactNode>` keyed entries like `Compute: <Server className="w-3.5 h-3.5" />` in a `.ts` file is a syntax error. Renaming to `.tsx` fixes it — `.tsx` triggers JSX regardless of whether the module exports a component. Pattern check: anywhere you see `Record<string, ReactNode>` in `.ts`, suspect a hidden compile error. - -## vi-fn-generic-narrows-mockResolvedValueOnce-arg-to-never-on-optional-fields - -_Discovered: 2026-04-30 by implementer in rf-pset-4_ - -When stubbing a provider API with `vi.fn<[ArgsTuple], Promise<{ success: boolean; graph?: { nodes?: unknown[] }; error?: string }>>()`, vitest 4.1's overload typing narrows subsequent `mockResolvedValueOnce(...)` so argument type becomes `never` when value omits an optional field — `TS2345` on a fully-valid response shape. Fix: drop the explicit generic on `vi.fn(...)` for any stub whose typed return uses optional fields you'll vary across tests. Runtime works (each `mockResolvedValueOnce` accepts `unknown`); source code's import contract still flows. Generalizes: explicit generics on `vi.fn` are fine for pure-arg signatures but fail on optional-field unions. - -## fs-existssync-is-non-configurable-under-vitest-esm - -_Discovered: 2026-04-30 by implementer in rf-esp-4_ - -`vi.spyOn(fs, 'existsSync').mockReturnValue(...)` fails under Vitest's ESM with `TypeError: Cannot redefine property: existsSync`. The `node:fs` module's exports are a frozen namespace whose properties are non-writable + non-configurable. Two viable patterns: (1) drive behaviour through real fs via `fs.mkdtempSync` + `process.chdir`; (2) wrap fs into the SUT via dependency injection. Chdir pattern is what `rf-esp-4` and `rf-cload-2` use; macOS `/var` → `/private/var` symlink means tests should `fs.realpathSync` both sides. Same applies to all node-builtin namespace imports under Vitest ESM (`fs`, `os`, `path`, `crypto`). - -## dynamic-import-indirection-blocks-test-mocks - -_Discovered: 2026-04-30 by implementer in rf-aimp-3_ - -The AWS importer wraps every `@aws-sdk/client-*` import in `Function('m', 'return import(m)')(spec)`. Load-bearing pattern — a literal `await import(spec)` would be transpiled into a static `require`, breaking optional-dep guarantee. Side effect: bypasses Vitest's module registry — `vi.mock('@aws-sdk/client-resource-explorer-2', ...)` does nothing. Don't try to stub the SDK; extract response-shape → AWSResource conversion into pure mappers. The discover\_\*() loops become thin paginate-and-map shells where mapping is testable without any SDK. - -## get-critical-path-bug-preserved-as-quirk-not-fix - -_Discovered: 2026-04-30 by implementer in rf-galg-4_ - -When extracting `get_critical_path` from `graph/algorithms.ts`, -a verbatim port of the function reveals it doesn't actually -return the critical path — it returns just the start (no-deps) -node for any DAG with `depends_on` edges. Trace: the function -walks topological order to update distances, but -`topological_sort` on a `depends_on` graph emits LEAVES first -(nodes with no outgoing depends_on edges, which means no -dependencies). For a chain `a depends_on b depends_on c`, -topo order is `[c, b, a]`. When processing b, the loop -iterates `get_incoming_edges(b)` = the edge (a,b), reads -`distances.get(a) = -Infinity` (since a hasn't been processed -yet in topo order), and the new_dist `-Infinity + 1` fails the -`> current_dist` check. The chain never propagates; only c -remains at distance 0; the "max distance" walk picks c with -distance 0; reconstruction returns `[c]` because predecessors -is empty. The fix would be to walk `get_outgoing_edges` -(dependencies of current node) and read `distances.get(target)` -which has been processed earlier in topo order. But changing -this is a public-behaviour change — anything consuming -`get_critical_path` (currently nothing in core, but possibly -external) would see different output. Decision rule for -refactor work: **document the bug, don't fix it**. If the -function is genuinely useful and the fix is wanted, it -becomes a separate ticket with its own behaviour-change PR -(and possibly a feature flag during rollout). Generalizes: -when verbatim-porting an algorithm during a refactor and a -test that asserts "this should return X" fails, first run the -PRE-extraction code with the same input — if it produces the -same wrong output, you've found a pre-existing bug. The -refactor's job is verbatim preservation, not fix; the tests -must pin the actual behaviour, not the intuitive behaviour. -Diagnostic: a critical-path test asserting `path.length === 3` -fails with `expected 1 to be 3` for a 3-node chain. Pair with -the general rule that refactors preserve behaviour byte for -byte — pre-existing bugs ARE part of the contract for the -duration of the refactor. - -_Fixed: 0c44dc2_ - -## tri-state-setter-directive-pattern-for-ref-callbacks - -_Discovered: 2026-04-30 by implementer in rf-cmove_ - -When extracting a pure runner from a hook callback that conditionally invokes a React setter (rf-canv-25b `useContainerMove`'s `setExitingGroupId`), the original code path had three distinct branches: (a) call `setExitingGroupId(null)`, (b) call `setExitingGroupId(parent.id)` or `setExitingGroupId(null)`, (c) call NEITHER (parentId set but parent missing — guarded by `if (parent) {}`). Returning a single `string | null` from the helper collapses (c) into (a) — silently introducing a behavior change. Fix: tagged tri-state `{ call: false } | { call: true; value: string | null }` so the orchestrator can decide whether to invoke the setter. Generalizes: any pure runner extracted from a hook that might skip a side-effect call should use a tagged-union return, not a sentinel `null`. Diagnostic: original code uses `if (X) { setter(...) } else if (Y) { setter(...) }` with NO else. - -## byte-identity-snapshot-must-be-captured-pre-refactor-not-post - -_Discovered: 2026-04-30 by implementer in rf-spr2-1_ - -When extracting a large template literal into composable section builders, the only way to verify byte-identical output is a snapshot test — but the snapshot must be captured BEFORE the refactor. Order: (1) write snapshot test against unrefactored source, (2) run vitest to write snapshot, (3) edit source, (4) re-run with no `-u` flag — pass = byte-identity holds. If you reverse 1 and 3 the test only proves "post-refactor matches itself." Generalizes: any refactor where output must remain stable (prompts, generated code, fixtures) needs captured snapshot before first edit. - -## category-bundle-split-preserve-original-array-ordering - -_Discovered: 2026-04-30 by implementer in rf-cbdat_ - -When splitting a single ordered data array (16 entries → 9 per-category files), the natural assembly `[...frontend, ...backend, ...data, ...]` imposes a NEW ordering grouping all-frontend-first — discarding hand-curated order. Consumers iterating the array (palette UI) silently change. Fix: explicit assembly with index-picks (`...FRONTEND_TEMPLATES, BACKEND_TEMPLATES[0]!, BACKEND_TEMPLATES[1]!, DATA_TEMPLATES[0]!, ...`). Non-null assertions are load-bearing — TS narrows `Array[idx]` to `T | undefined` even when length is statically known. Smoke test must pin assembled ordering against `EXPECTED_ORDER` of names. Generalizes: ANY data-array split where original order is hand-curated needs explicit index-picks OR a single big spread + stable-ordering smoke test. - -## vi-hoisted-must-include-large-fixture-arrays-when-vi-mock-factory-references-them - -_Discovered: 2026-04-30 by implementer in rf-accent-5_ - -When orchestrator tests stub a module-level data export (here: `vi.mock('../data/themes', () => ({ T: FIXTURE_T }))`), the natural shape is to declare `FIXTURE_T` as a top-level `const` next to the `mocks` object. That works for inline-defined hoisted state because `vi.hoisted` and `vi.mock` are co-hoisted to the top of the file, and references within their factories resolve at hoist time. But a `const FIXTURE_T = [...]` defined OUTSIDE `vi.hoisted()` is NOT hoisted — by the time `vi.mock`'s factory runs, the module-top-level binding is in the temporal dead zone and the factory throws `ReferenceError: Cannot access 'FIXTURE_T' before initialization`. The fix is to fold the fixture INTO the `vi.hoisted({ ... })` block (e.g. as `mocks.fixtureT = [...]`) and reference it via `mocks.fixtureT` from inside the factory: `vi.mock('../data/themes', () => ({ T: mocks.fixtureT }))`. Optional sugar: alias `const FIXTURE_T = mocks.fixtureT` AFTER the hoisted block to keep the test bodies readable. This is the same trap as the well-known "vi.hoisted required for shared mock identities across many vi.mock calls" learning — but specific to large fixture arrays where the test author's instinct is to declare a normal const for clarity, not realizing that any value referenced inside a `vi.mock` factory has to participate in the hoist. Generalizes: ANY non-trivial fixture (an array, a record, a builder closure) referenced inside a `vi.mock` factory belongs in `vi.hoisted({...})`. Diagnostic: vitest emits "There was an error when mocking a module" with a `ReferenceError: Cannot access 'X' before initialization` pointing into the SUT file's import line (the import runs the SUT, the SUT runs `vi.mock`, the mock factory hits the dead-zone reference) — the actual fault is the test file's outside-of-hoisted const, not the SUT. - -_Promoted to: /docs/refactoring-patterns.md_ - -## bugfix-commits-from-refactor-quirks-need-the-fixed-line-only-when-anchor-exists - -_Discovered: 2026-04-30 by implementer in bugfix-2/3/4_ - -The bugfix sweep brief said to append `_Fixed: <commit-sha>_` to each affected learning anchor per the CLAUDE.md "only allowed edit" rule. Reality: of three bugs (eager `require.resolve` in `get_base_db_path`, `get_incoming_edges`-walks-source-distance in `get_critical_path`, missing-lockfiles-in-`filesToCheck` in `detectJsFramework`), only ONE had a dedicated learning anchor — the rf-galg-4 quirk note. The other two were documented INLINE in file headers. Conclusion: don't fabricate `_Fixed:_` anchors against learnings that don't exist. Generalizes: when a refactor preserves a bug verbatim, the implementer has two options: (a) inline header comment in the SUT (cheap, lives with code, lost to log-only readers); (b) learning anchor (visible, supports `_Fixed:_` audit trail). Option (b) is preferable for any bug whose fix-up is a real future ticket. - -## vitest-4-strict-mock-surface-and-throwing-factory-needs-isolated-file - -_Discovered: 2026-05-02 by test-author in services/engine-coverage_ - -Two related Vitest 4 gotchas surfaced together when bringing `services/engine` (lazy-imports `@ice/core` via `_core = await import('@ice/core')`) to ≥90% coverage. (a) **Strict mock surface**: vitest 4 errors with `[vitest] No "X" export is defined on the "@ice/core" mock` even when the SUT uses optional chaining (`core.getAllHighLevelResources?.()`). The check is at access-time on the namespace, not at call-time. Fix: hoist a `vi.mock` whose factory exposes ALL accessed exports via property getters reading from a `vi.hoisted({ coreImpl: {} })` bag, then per-test do `h.coreImpl = { ... }` instead of `vi.doMock` per test. Bare `vi.doMock` in `beforeEach` may not re-register cleanly across `resetModules`, surfacing real package data when the mock silently disappears. (b) **Throwing-factory isolation**: testing the SUT's catch arm (`try { _core = await import('@ice/core') } catch { _core = stub }`) requires the import to reject. A hoisted `vi.mock(..., () => { throw ... })` in the same file as happy-path tests poisons every downstream test — the throw persists because `vi.mock` is not unmockable mid-file. Fix: put the load-failure assertion in a sibling test file (e.g. `<svc>.import-failure.test.ts`) with its own throwing `vi.mock` factory and a single `it` block. Generalizes: any defensive catch-on-import branch needs a dedicated test file in vitest 4; combining it with property-getter hoisted mocks is the cleanest path for testing services that lazy-import a workspace package. - -## hook-singleton-store-needs-getter-mock-not-snapshot-mock - -_Discovered: 2026-05-02 by test-author in 5-hooks-coverage_ - -Hooks that read the global store via `import { store } from '../../../store'` AND use it as a non-React side channel (`store.getState()` inside `useCallback`) fight the per-test pattern of building a fresh `configureStore` for each test. A naive `vi.mock('../../../store', () => ({ store: configureStore(...) }))` either runs once at module load (single-shot) or overrides `useDispatch`/`useSelector` resolution. Fix: hoist a `mocks.storeRef = { current: null }`, then mock with a property getter — `vi.mock('../../../store', async (orig) => { const a = await orig(); return { ...a, get store() { return mocks.storeRef.current ?? a.store; } }; })`. Per-test set `mocks.storeRef.current = makeStore()` before `captureHook`. The Provider wraps the same store for `useDispatch`/`useSelector`; the getter routes the side-channel `store.getState()` to the same instance. Combined with `preloadedState` (vs. dispatching `cards/createCard` which uses real-time IDs and ignores the test card shape) you get a deterministic seed. Same pattern works for any singleton imported from `../../../store` AND consumed via React context — single point of override. Generalizes to: any hook test where the SUT mixes `useSelector` (Provider-served) with a direct `store.getState()` import (module-served). - -## defensive-null-org-guards-unreachable-when-isadmin-derives-from-org-role - -_Discovered: 2026-05-02 by test-author in team-page-coverage_ - -In `team-page.tsx`, two action handlers (`handleRoleChange`, `handleRemoveUser`) start with `if (!selectedOrg) return;` defensive guards. The branches LOOK testable but are unreachable through the UI: the handlers are wired to controls (the role <select>, the Trash2 button) that only render when `isAdmin === true`, which is itself derived as `callerRole === 'owner' || callerRole === 'admin'` where `callerRole = selectedOrg?.role?.toLowerCase()`. So `isAdmin=true` requires `selectedOrg` to be truthy. The closure-captured `selectedOrg` inside each handler binds at render time and never re-reads the redux store, so post-render mutations of `mocks.state.account.selectedOrg` do not influence the closure. Branch coverage will sit at 98% with line 86 (the `handleRoleChange` early return) uncovered; that is structural, not a test gap. The `handleRemoveUser` line is half-covered (the right-hand side of `||` for `confirm:false` is reachable, the left-hand `!selectedOrg` is not). Generalizes: any "permission-gated handler" pattern where (a) the handler is unmemoized and (b) the gate predicate derives from the same state the handler defends against — the defensive guard is dead code under the closed-form invariants of the slice. Treat as a finding for the critic, not a test gap. Same shape will recur in other admin-gated components (e.g. project-collaborators, create-team-modal). Counter-pattern that would be testable: if the handler were `useCallback`-wrapped with `[selectedOrg]` deps and the trigger were retained across re-renders, mutating the slice between renders could expose the guard. Currently this code does not have that shape. - -## file-private-fc-direct-invocation-via-el-type-for-unreachable-branches - -_Discovered: 2026-05-02 by test-author in 4-ui-coverage_ - -In `app-bar.tsx`, the file-private `BarBtn` and `BarImgBtn` helpers each have branches (`disabled && '...'`, `if (!tip) return btn;`, `tip || ''` fallback for an `<img alt>`) that are unreachable from the AppBar JSX — every callsite passes `tip`, none pass `disabled`. Direct-FC tree-walker tests get to ~86% branch coverage and stop. The helpers cannot be exported (no source change rule), but they CAN be invoked directly because the walker yields `<BarBtn>`/`<BarImgBtn>` elements during traversal of the AppBar tree, and `el.type` is the FC reference. Pattern: `findFirst(tree, predicate)` to locate any `<BarBtn>` JSX site (e.g. by `typeof el.type === 'function'` plus a discriminating prop like `icon`+`onClick`), then call `(el.type as Fn)({ icon: () => null, onClick: vi.fn() /* tip omitted */ })` and walk THAT result to assert the branch shape. The discriminator-by-prop is the cleanest filter — keys exist that other walked elements do not (e.g. `BarBtn` has `icon`, `BarImgBtn` has `src`). Brings branch coverage from 86.66% to 100% on app-bar without touching source. Generalizes: any orchestrator with file-private branching helpers exposed only through JSX call sites — tree-walker traversal hands you the FC reference for free; reuse it as a callable to drive the unreached props. - -## same-module-helper-error-branch-is-structurally-dead - -_Discovered: 2026-05-02 by test-author in pulumi-terraform-aws-coverage_ - -Both `export/pulumi/converter.ts` and `export/terraform/converter.ts` have an `export_graph` loop that conditionally pushes to `errors[]` when `result.success === false && !result.unmapped`. The branch is structurally unreachable in current code shape: the only error-emitter (`node_to_resource`, defined in the same module) ALWAYS sets `unmapped: true` when emitting an error. There is no path that returns `{ success: false, error: 'x', unmapped: false }`. Because `node_to_resource` is a same-module function, `vi.doMock('./converter.js', ...)` does not rebind the `export_graph` reference (TS module-private bindings, not via the namespace import). The branch ceiling is therefore: 86.66% (pulumi) / 91.17% (terraform) — terraform clears 90% because its `fallback_type_mapping` ALWAYS returns a string, so the unmapped branch is exercised via mocked `type-mapping.js`, while pulumi's reaches the unmapped branch naturally (no mock needed) and the dead branch is the SAME `errors.push` line. Generalizes: any helper-error-branch pattern where the helper is in-module and the discriminator (`unmapped`) is always co-emitted with the error — the orthogonal branch is dead. Treat as finding for the critic, not test gap. Counter-pattern that would make this testable: extract `node_to_resource` to a sibling module so `vi.doMock` rebinds the import in `export_graph`. With the helper in-module, the branch can only be reached by mutating source. - -## function-constructor-stub-intercepts-bypass-bundler-imports - -_Discovered: 2026-05-02 by test-author in azure-importer coverage_ - -The Azure / AWS importers and gcp/sdk-loader use `Function('m', 'return import(m)')(specifier)` to dynamically load optional SDKs in a way that bypasses the Vitest module registry — `vi.mock`, `vi.doMock`, and module-spec interception all miss it. The hook that DOES work is replacing `globalThis.Function` itself for the test: a stub that recognizes `args[0] === 'm' && args[1].includes('return import')` returns a controllable resolver (`(name) => Promise.resolve(fakeRegistry[name])`), and falls through to the original Function constructor for everything else. Restore in `afterEach`. This pattern lets you exercise full success paths (mocked SDK returns canned data, pagination via `skipToken`, error throws from `client.resources()`) and full error paths (auth-shape rejections that bubble up through the wrap to hit the action-truthy branch in classifyAzureError consumers) — bringing azure-importer.ts from 0% to 100% statements / 98.78% branches. Generalizes: any module that gates a third-party dep behind `Function('m', 'return import(m)')` is testable through Function-constructor stubbing — but only this pattern, not `await import(spec)` (which Vitest's registry handles natively). - -## defensive-double-fallback-leaves-unreachable-branches - -_Discovered: 2026-05-02 by test-author in azure-importer coverage_ - -`azure-importer.ts` line 122 reads `tags: resource.tags || {}` while the upstream discovery loop at line 325 has already done `tags: item.tags || {}`. By the time line 122 runs, `resource.tags` is always at least `{}`, so the `||` short-circuits the same way every time — the `{}` branch is dead defensive code. Branch coverage will plateau at ~99% with this single branch reported uncovered. The fix is a one-line source change (drop the `|| {}` at line 122, or drop it at line 325 — pick one). The same shape is likely in other importers that normalize at discovery AND at conversion. Treat as a finding for the critic, not a test gap. Type-mapper has a related dead-code finding: the `'microsoft.web/staticSites'` TYPE_MAP key has a capital S, but `get_ice_type` lowercases input before lookup — the key never matches and the intended `azure.web.static_site` mapping falls through to the synthesized `azure.web.staticsites` fallback. The table key should be lowercased like every other entry. - -## function-ctor-stub-needs-class-not-vifn-for-new-callsites - -_Discovered: 2026-05-02 by test-author in gcp-importer coverage_ - -Follow-up to `function-constructor-stub-intercepts-bypass-bundler-imports`. When the SUT calls `new compute.InstancesClient(options)` (every GCP service in `packages/core/src/importers/gcp/services/*.ts` does this for 1+ client constructors), the fake module returned by the stubbed `Function` MUST expose real classes. `vi.fn().mockImplementation((...args) => ({ list: vi.fn() }))` looks correct but the underlying mock implementation is an arrow function — arrow functions cannot be invoked with `new`, the SUT's catch wraps "X is not a constructor" into the friendly INIT_ERROR message, and all coverage of the success branch is lost (looks like the stub didn't fire even though it did). Pattern: hand-write a `class FakeInstancesClient { constructor(opts) { recordCalls.push(opts); this.list = async () => [[]]; } }` per client and put those classes in the fake module's namespace. This was the difference between 85% and 100% coverage on `compute.ts`. Same shape in `storage.ts` (`new Storage(options)`) and `asset-inventory.ts` (`new AssetServiceClient(options)`). Generalizes: any test that intercepts dynamic `import()` to swap a third-party SDK whose surface is constructor-based — use real classes, not `vi.fn().mockImplementation`. A second GCP-only gotcha: the `MutableGraph` API exposes labels under `node.metadata.labels`, NOT `node.labels` (similar to edges via `edge.metadata.labels`). Tests using `get_node_by_name(...)?.labels.foo` will silently return undefined and look like missing functionality. Read `packages/core/src/types/graph.ts` first. - -## validation-rules-have-elseif-classifier-shadowing - -_Discovered: 2026-05-02 by test-author in validation/architecture-rules coverage_ - -`packages/core/src/validation/architecture-rules.ts:62-65` classifies nodes via an `else if` chain — `isFrontend → isBackend → isDatabase → isCache`. `isDatabase('Database.Redis')` returns true (matches `Database.` prefix), so a Redis node lands in the `databases` bucket and never reaches the `caches` else-arm. The MULTI_DB_NO_CACHE rule's only suppression condition (`caches.length === 0`) therefore cannot fire from a real Redis node — only from an iceType that matches `isCache` but NOT `isDatabase` (e.g. a hypothetical `Cache.Memcache`). No such iceType exists in the production tree, so the suppression branch is structurally unreachable from real graphs. Treat as a critic finding: either reorder the chain to put `isCache` before `isDatabase`, or have `isDatabase` exclude Redis explicitly. - -Companion finding: `architecture-rules.ts:38,44,46` builds an `incoming` Map that is never read anywhere in the function body — pure dead variable. Drop it and the post-loop assignments to it. Companion: `connection-rules.ts:70-71` has a `'Source'` / `'Target'` label fallback after `iceType.split('.').pop()` that is only reachable when the iceType string is exactly `'.'` (split → `['','']`, pop → `''`). Real iceTypes always include either no dot or at least one non-empty segment, so the third arm is dead in production. Companion: `deploy-rules.ts:175` re-checks `node.type === 'container' || 'group'` after `isContainer(iceType, node.type)` already returns true for those nodeType values — same in `structure-rules.ts:128`. Companion: `deploy-rules.ts:225` has `supportedProviders.length > 0 ? '...' : undefined` inside an `if (supportedProviders.length > 0)` block — tautology. Branch ceiling for the validation directory at 90/90 target was 99.3% statements / 97.91% branches; the remaining gaps are all dead branches above. Generalizes: classifier predicates that overlap (e.g. `isDatabase` and `isCache`) need to be ordered so that the more-specific predicate runs first when used in an `else if` chain — otherwise the broader predicate consumes nodes the narrower one was meant to count. - -## script-style-module-needs-process-exit-stub-and-bare-relative-mock-target - -_Discovered: 2026-05-02 by test-author in templates+shared coverage_ - -`packages/templates/src/validate.ts` is a script — top-level `for (const t of ALL_TEMPLATES)` loop, terminal `process.exit(1)` on errors. To get full branch coverage on its rule helpers without forking the source, the test file must (a) `vi.spyOn(process, 'exit').mockImplementation(...)` so the test runner doesn't crash, (b) `vi.spyOn(console, 'log').mockImplementation(...)` to inspect rule output, (c) mock `ALL_TEMPLATES` via a hoisted-bag getter so each `it` swaps the input set, then `vi.resetModules()` + `await import('../validate')` per test. The non-obvious gotcha: the SUT does `import { ALL_TEMPLATES } from '.';` and the test file lives in `src/__tests__/`. Vitest resolves `vi.mock(spec, ...)` paths relative to the TEST FILE, not the source file — so `vi.mock('.', () => ...)` resolves to `src/__tests__/index.ts` (which doesn't exist) and silently does nothing, allowing the real registry to leak in. Diagnostic: tests start emitting [R1:blueprint] errors against real templates like `secure-api`, `budget-webapp`, `saas-multi-tenant` — the mock isn't firing. Fix: target the path the SUT's bare specifier resolves to, expressed relative to the test file: `vi.mock('../index', () => ...)`. Pair with: vi.mock factory returning a property getter (`get ALL_TEMPLATES() { return h.templates; }`) so per-test mutation of the hoisted bag is visible without re-mocking. Same shape as `vi-mock-paths-resolve-from-test-file-not-from-sut` and `vi-mock-paths-resolve-relative-to-test-file-not-source-file`, but specific to bare-relative `from '.'` imports in script-style modules. Generalizes: any future script-style module (`process.exit`-terminating, top-level work) is testable through resetModules + getter-mocks for its data inputs + spy-stubbed process.exit; the resolution rule for vi.mock specs is invariant — relative to test, not source. - -## electron-main-needs-deferred-whenReady-plus-microtask-drain-and-stale-js-disambiguation - -_Discovered: 2026-05-02 by test-author in apps/desktop coverage_ - -Bringing `apps/desktop/src/main/index.ts` to 100/100 surfaced four interlocking gotchas worth memorializing as a single anchor because they ALL fire on the same SUT shape (Electron main process bootstrap): - -(a) **Deferred `app.whenReady()` + nested-await drain**: the SUT's bootstrap is `app.whenReady().then(async () => { ...; await startEmbeddedBackend(); createMainWindow(); setupAutoUpdater(); })`. To drive coverage you must mock `whenReady` to return a deferred Promise, resolve it in the test body, then drain microtasks ENOUGH times for every nested await to land. Three drains (`await Promise.resolve()` x 3) is too few — 32 drains paired with `await vi.advanceTimersByTimeAsync(0)` reliably reach `setupAutoUpdater()`. Diagnostic: `h.bag.windows.length === 1` (only splash) means the chain stalled at `await import('@ice/gateway')`; `autoUpdaterListeners` empty AND windows=2 means it stalled between `createMainWindow()` and `setupAutoUpdater()`. Pattern: `for (let i = 0; i < 32; i++) { await Promise.resolve(); await vi.advanceTimersByTimeAsync(0); }` after `deferred.resolve()`. - -(b) **Module.\_resolveFilename monkey-patch**: SUT does `(Module as any)._resolveFilename = function(...)`. Naively mocking `module` with `vi.fn()` is a trap because the SUT REPLACES the function with a plain closure on first boot — subsequent `mockClear()` calls in `resetBag()` throw `mockClear is not a function`. Fix: keep the mock object stable (`{ default: { _resolveFilename: origFn } }`), and in `resetBag()` REASSIGN `_resolveFilename` to a fresh closure rather than calling `mockClear()`. Read the patched resolver post-boot via `(h.moduleMod as any).default._resolveFilename`. - -(c) **fs.existsSync mock ordering**: the SUT calls `existsSync` with multiple distinct paths (splash, icons, prisma targetDir, prisma resolved candidate `default.js`/`index.js`, asar paths). A naive mock that uses `p.includes('node_modules/.prisma/client')` as the FIRST guard incorrectly captures resolver candidates like `/userData/node_modules/.prisma/client/default.js`, returning the targetDir flag (false) and short-circuiting the resolver to the original path. Order the guards specific-first: `endsWith('.js')` for resolver candidates → asar prefix → exact-match targetDir → splash → icons → false fallback. - -(d) **Throwing `vi.mock('@ice/gateway')` factory needs a sibling test file**: the gateway-import-failure branch (`try { await import('@ice/gateway') } catch ...`) requires the dynamic import to reject. A throwing factory in the same file as happy-path tests is cached by vitest 4 and poisons all downstream `await import(...)` calls. Same pattern as `services/engine` (anchor `vitest-4-strict-mock-surface…`): put the failure assertion in `index.gateway-import-failure.test.ts`, with its own throwing `vi.mock` factory and a single `it` block. Vitest wraps the factory throw with its own message — assert on the SUT's catch-arm preamble (`'[desktop] Gateway start error:'`), not the literal error text. - -Side gotcha: `apps/desktop/src/{main,preload}/` ships with stale `index.js`/`index.d.ts` artifacts checked into git (a prior `tsc --emit` run). Vite's resolver picks `.js` over `.ts` when both exist, instrumenting the wrong file for v8 coverage (you'll see `index.ts` line ranges flagged as 100% uncovered even when tests pass). Workaround without source change: dynamic-import the `.ts` extension via a string variable to dodge tsc's `allowImportingTsExtensions` rule — `const sutPath = '../index.ts'; await import(sutPath);`. Cleaner long-term fix is for the planner/critic to drop the stale artifacts from git; the workaround is a test-file-only escape hatch. - -Generalizes: any future Electron main-process file (one bootstrap chain, multiple electron/electron-toolkit/electron-updater/Module monkey-patches behind one `app.whenReady().then(async)`) follows the same template. The four-gotcha checklist is the playbook; budget ~2x the LOC of the SUT in test infra (this SUT is 321 LOC, the test files plus failure-sibling are ~1100 LOC combined including the FakeBrowserWindow class and the hoisted-bag scaffolding). - -## v8-coverage-zeros-pure-barrel-files-as-zero-of-zero - -_Discovered: 2026-05-02 by test-author in shared/api coverage_ - -A pure barrel file consisting only of `export ... from '...'` re-exports compiles to a module record with no executable statements once tsc/Vite emits it — the runtime just rebinds the named exports onto the importing module. v8 coverage reports such a file as `0 / 0 / 0 / 0` for stmt/branch/func/line and the table cell prints `0%` even when a test imports the barrel and asserts every export. The denominator is zero: there is nothing to cover. Don't chase this — treat `0/0/0/0` on a re-export-only file as "fully exercised" provided the barrel test asserts each re-exported binding (`expect(typeof mod.foo).toBe('function')`) so a future deletion or rename breaks a test. Specifically observed in `packages/ui/src/shared/api/index.ts`. Sub-rule for reviewers: confirm the file truly has no executable statements before accepting; a single `const X = ...;` or top-level `if/?:` flips the file out of the 0/0 shape and the coverage minimum applies again. - -## import-meta-env-DEV-is-vite-build-time-inlined-and-untestable-from-test-side - -_Discovered: 2026-05-02 by test-author in shared/utils/action-logger coverage_ - -A SUT shaped like `(typeof import.meta !== 'undefined' && import.meta.env?.DEV) || localStorage.getItem(...) === 'true'` (the gate in `packages/ui/src/shared/utils/action-logger.ts`) cannot have its DEV-falsy branch reached from a Vitest test. Vitest defaults `import.meta.env.DEV = true` at build time, the value is INLINED into each module's own `import.meta` binding, AND `vi.stubEnv('DEV', ...)`, `vi.stubEnv('MODE', 'production')`, and direct `(import.meta as unknown as {env:{DEV:boolean}}).env.DEV = false` from the test file all fail to flip the SUT module's binding (each module gets its own `import.meta`). Confirmed empirically with a sibling helper module — mutation in the test file does not propagate. Consequence: the `||` short-circuit takes the DEV-true path on every test run, making the right-hand `localStorage.getItem(...)` arm and the `try { ... } catch { _enabled = false; }` arm structurally unreachable. Action-logger's branch coverage caps at ~85% (1 uncovered catch arm out of ~13 branches) for this reason. Two viable workarounds NOT taken here because both alter the SUT: (a) refactor SUT to read DEV from a function-local indirection (`getDevFlag()`) injectable for tests; (b) move the gate into a small wrapper module the test can `vi.mock`. Generalizes: any SUT consulting `import.meta.env.DEV` in a top-level expression (not via a function indirection) accepts a structural coverage gap on the DEV-falsy branch; document it inline and surface to the critic. Sub-rule: a sibling test file with its own throwing `vi.mock(...)` or env override doesn't help either — both run in the same Vite build and inherit DEV=true. - -## sut-shaped-checkflow-cache-hit-needs-shared-child-via-multiple-contains-edges - -_Discovered: 2026-05-02 by test-author in shared/utils/auto-layout coverage_ - -`dagreTreeLayout`'s `checkFlowSubtree` and `repackIsolatedTopLevel`'s `checkFlow` both have `if (cached !== undefined) return cached;` cache-hit branches. Naturally these never fire when `buildHierarchy` produces a tree with single-parent edges — each id is visited at most once. The fixture that exercises the cache hit is TWO `contains` edges pointing at the SAME child (`p1 → shared`, `p2 → shared`): `buildHierarchy` appends `shared` to BOTH parents' children lists, so when the recursive descent visits the second parent's subtree, `shared`'s flow flag is already cached. Pin via two separate top-level fake-containers and a shared kid; assert the layout call doesn't throw (and optionally that the cache-hit code path is taken via line counters in coverage). Generalizes: any "memoize subtree property" recursion in a graph with single-parent invariants needs a multi-parent fixture to hit the cache-hit branch — single-parent trees never re-enter a node. - -## expand-blueprint-schema-driven-fallback-branches-need-fixture-not-real-schema - -_Discovered: 2026-05-02 by test-author in packages/blocks coverage_ - -Three branches in `expand-blueprint.ts` are unreachable from the public API given the current shape of `HIGH_LEVEL_CATEGORIES`: - -1. `(hasPipeline ? PIPELINE_ROW_H : 0)` (line ~49 in `computeCompactNodeHeight`) — `expandBlueprint` always invokes `computeCompactNodeHeight(data, false)` and never threads `hasPipeline=true`. The `true` branch is dead from this caller. -2. `prop.default ? providerOptions.find(...) : undefined` (line ~144) — every `select`+`optionDetails` entry in `HIGH_LEVEL_CATEGORIES` ships with a non-empty `default`. The `: undefined` fallback only fires for a property without a default. -3. `(prop.default as string) ?? prop.options[0]!` (line ~159) — same shape: every `select`+`options` entry in `HIGH_LEVEL_CATEGORIES` ships with a `default`, so the `??` right operand is dead. - -Reachable only by hijacking the schema (mocking `@ice/core/resources` to return a fixture without `default`) — but this couples the `expand-blueprint` test to `getResourceProperties`'s import shape, which the test specifically avoids by relying on the real schema. The 100% statements / 97.29% branches / 100% functions outcome is the structural ceiling. Generalizes: any pure function that branches on an OPTIONAL field in a single-source-of-truth data table accepts a structural coverage gap on the "field absent" branches as long as every existing record in the table sets the field. Document the gap, point to the schema invariant, and don't introduce a fake-schema mock just for the branch counter. - -## import-meta-env-as-any-cast-defeats-vite-transform - -_Discovered: 2026-05-03 by test-author in shared/hooks coverage sweep_ - -`use-gcp-oauth.ts:59` reads `(import.meta as any).env?.VITE_GOOGLE_CLIENT_ID`. Vite's transform layer pattern-matches `import.meta.env.X` AST shapes for compile-time inlining; the `as any` cast wraps `import.meta` in a TS assertion node that the transform doesn't recognize. Result: at runtime the SUT goes through vite-node's env Proxy — which Vitest replaces with one that reads from `process.env`. So the test side `(import.meta as any).env.X` returns the value just fine. But the SUT's `__vite_ssr_import_meta__.env?.X` returns `undefined` regardless of `vi.stubEnv`, `process.env.X = "..."`, or direct mutation of `import.meta.env.X` — the proxy backing differs between modules in vite-node 8.x. Net effect: any branch past `if (!clientId) return;` is structurally unreachable under vitest. Coverage exception: 41.93/17.39/50/41.93 on `use-gcp-oauth.ts` — only the initial state and the two early-return guards execute. Generalizes: `(import.meta as any).env?.X` is a test-hostile pattern; use the bare `import.meta.env.X` form and hand the cast to the consumer if the type is awkward. Don't introduce env-replacement plumbing in tests; flag the pattern for the source author instead. Same constraint applies to any module that wraps `import.meta` in a cast or local variable before the property access — vite-plugin-define only matches `MemberExpression(MemberExpression(Identifier(import.meta), Identifier(env)), Identifier(X))` literally. - -## position-finder-fallback-after-below-the-lowest-is-structurally-unreachable - -_Discovered: 2026-05-02 by test-author in features/ai+features/properties coverage sweep_ - -`packages/ui/src/features/ai/services/ai-ops/position-finder.ts:116` (the `return { x: 100, y: maxBottom };` after the candidate loop) is dead code given the algorithm above it. The candidate list always starts with `{ x: 100, y: maxBottom }` where `maxBottom = max over n of (n.position.y + (n.height || NODE_HEIGHT) + NODE_GAP_Y)` (NODE_GAP_Y = 36). The overlap check calls "no overlap" when `y >= n.position.y + nh + 12`. For each existing node, `maxBottom >= n.y + nh + 36 > n.y + nh + 12` — so the first candidate's y check ALWAYS reports no-overlap. The for-loop returns at iteration 0; line 116 never fires. Coverage ceiling: 98.11/85.71/100/97.67 with line 116 listed as uncovered. Don't write a giant-overlapping-node test to chase it — the giant node still positions below the lowest with a margin, and you can't squeeze maxBottom below a node's y+nh+12 by construction. Generalizes: any position-finder whose first candidate is `{ ..., maxOf(existingY + existingH + bigGap) }` and whose overlap test uses `smallGap < bigGap` has a structurally-unreachable trailing fallback; flag for the source author rather than adding contrived input. - -## usestate-slot-counter-must-reset-per-render-not-per-test-file - -_Discovered: 2026-05-02 by test-author in svg-node-cluster coverage_ - -When the SUT calls `useState(false)` multiple times (e.g. `isHovered`, `folded`, `scrollOffset`, `isAutoScroll`, `copiedLine` in svg-log-node), the natural urge is to use `mocks.state.stateValues.length` as the slot index, then write the value back at that index. This breaks because the second `useState` call sees length already incremented (from the first call's writeback) and lands on idx=2 instead of idx=1. Fix: separate the slot-counter from the value-array — keep `stateCounter: number` and `pinnedSlots: unknown[]` as two independent fields, increment `stateCounter` on every call, and look up via `idx in pinnedSlots` (NOT `pinnedSlots[idx] !== undefined` — that misclassifies legitimate `false` pins). Reset `stateCounter = 0` in BOTH `beforeEach` AND in the test's `renderXX()` helper (so two renders in the same test don't drift the counter). Pair with `vi.fn()` setters per slot stored in `stateSetters[idx]` so tests can assert `expect(mocks.state.stateSetters[1]).toHaveBeenCalledWith(true)` against the specific slot. Generalizes: any FC with N `useState` calls of the same initial-value type needs slot-by-position pinning (not by initial-value matching) — five `useState(false)` calls collide if you pin via `typeof initial === 'boolean'` alone. - -## icon-prop-as-react-element-collides-with-tree-walker-find-by-type - -_Discovered: 2026-05-02 by test-author in canvas/components+\_shared coverage sweep_ - -`BlockSidebar` accepts an `icon: ReactNode` prop and renders it inside the type-tile slot. Tests that probe per-slot content via `findByType(tree, 'span')` (looking for the resource-name short-name span) hit a subtle failure: a test fixture passing `icon: React.createElement('span', null)` puts a `<span>` into the tree as a sibling of the slot spans. The tree-walker yields the icon span FIRST in DFS order, so `spans[0]` is the icon, not the resource-name slot — the assertion `(spans[0].props as { children }).children === 'RDS'` fails because the icon span has no children. Fix: pass a non-tag icon fixture that the FC's actual JSX never reuses — `React.createElement('svg', { 'data-stub': 'icon' })` is safe because BlockSidebar's slots only emit `<div>` / `<span>` / `<img>`. Same pattern recurs anywhere a leaf-FC takes a ReactNode prop and the test discriminates downstream content by tag name. Generalizes: when a test fixture supplies a ReactNode prop, choose a tag the SUT never renders elsewhere; otherwise switch the predicate to a content discriminator (`el.props.children === 'RDS'`) instead of `el.type === 'span'`. Sub-rule: for `card-shell.tsx`'s `icon: LucideIcon` prop (a function-typed prop, not a ReactNode), passing `(() => null) as unknown as LucideIcon` works AND the test can probe via `findByType(tree, FakeIcon)` because the FC itself is the type — module-singleton identity holds. - -## structurally-unreachable-rhs-of-nullish-coalesce-inside-truthy-gated-block - -_Discovered: 2026-05-02 by test-author in canvas/components+\_shared coverage sweep_ - -`block-sidebar.tsx:121` is `title={serviceName ?? undefined}` — but the line lives INSIDE `{shortName && (<>...</>)}` where `shortName = serviceName ? shortResourceName(serviceName) : null`. So entering the conditional block requires `shortName` truthy, which requires `serviceName` truthy AND `shortResourceName(serviceName)` to return a truthy string. Once inside, `serviceName ?? undefined` always resolves to the LHS (truthy serviceName). The RHS `undefined` branch is structurally unreachable — coverage caps at 94.44% branches with that one branch reported uncovered. Don't write a `serviceName === ''` test to chase it: empty-string `serviceName` would make `shortName` empty (falsy under `&&`), so the conditional gate fires first and we never reach the title prop site. Generalizes: any `?? undefined` (or `?? null`) defensive fallback inside a conditional whose gate already requires the LHS truthy is dead branch coverage. Treat as a finding for the critic — the safer `||` here doesn't change behavior because `serviceName` is a string in this path; dropping the `?? undefined` entirely is fine. Sub-rule: pair with `or-chain-default-fallback-needs-its-own-test-for-100pct-branch-coverage` — same shape (RHS-of-OR-or-NULLISH unreachable when LHS is gate-truthy), but in that case the LHS could be `0` or `''` while still passing the gate, so the RHS IS reachable. Pin the gate's invariants before deciding the branch is unreachable. - -## redux-store-subscriber-defensive-guards-need-replacereducer-injection - -_Discovered: 2026-05-02 by test-author in store/index-coverage_ - -The Redux store factory in `packages/ui/src/store/index.ts` registers two subscribers (card persistence + UI persistence). The card subscriber has three defensive early-return branches that the slice reducers structurally guarantee will never fire through the public dispatch surface: (a) `if (!card) return ''` inside `cardHash` — only called from inside `setTimeout` after `if (!activeCard) return;` lands, so `card` is always non-null; (b) `card.nodes || []` and `card.edges || []` — every `cardsSlice.createCard` reducer initializes both arrays, and no reducer deletes them; (c) `if (!activeCard) return;` — `setActiveCard` reducer guards `state.cards.some((c) => c.id === payload)` before assigning `state.activeCardId`, and `deleteCard` auto-clears it. Single-public-dispatch tests cap at ~86% branches. - -To recover (b) and (c), use `store.replaceReducer` to inject a minimal test-only `cards` reducer that responds to a `__test__/divergeCardsState`-shaped action and returns `{ activeCardId: 'phantom', cards: [] }` (or `{ activeCardId: 'sparse', cards: [{ id: 'sparse' }] }` for the missing-nodes/edges fallback). Compose with passthrough reducers (`(s = currentState[k]) => s`) for every other slice key so the rest of the store stays intact. Pattern brings the file from 86% → 96% branches; the remaining `if (!card) return ''` is unreachable even via `replaceReducer` because nothing in the test path hands a null card to `cardHash` directly. Generalizes: any closed-state invariant defended by a slice reducer can be bypassed with `replaceReducer` for coverage purposes — this is a test-only escape hatch, not a runtime path. - -Sub-pattern (in-flight backend save): the `if (_backendSaveInFlight) return;` guard is reachable but ONLY if the second dispatch produces a hash-different state. `cardHash` includes node count, first/last node id, last node x position, and a fingerprint of every `node.data` blob — but NOT card name. So `renameCard` between two `setActiveCard` calls won't trigger a re-save (hash is identical, the earlier `if (hash === _lastSavedHash) return;` swallows it). Use `addNodeToCard` instead (changes node count → new hash). Pair with a never-resolving `vi.fn().mockImplementationOnce(() => new Promise(...))` for the first `graphSave` so `_backendSaveInFlight` stays true into the second tick. - -Sub-pattern (vi.advanceTimersByTimeAsync resolves with 0, not undefined in vitest 4): `await expect(vi.advanceTimersByTimeAsync(N)).resolves.toBeUndefined()` fails because the resolved value is `0`. Use `let threw = null; try { await vi.advanceTimersByTimeAsync(N); } catch (e) { threw = e; } expect(threw).toBeNull();` instead. Same for any timer-driven swallow-error tests in vitest 4. - -## tree-walker-mocks-must-import-the-mocked-fn-ref-not-string-tags - -_Discovered: 2026-05-02 by test-author in pbrws-orchestrator-coverage_ - -When testing an orchestrator that delegates to extracted leaf components (`PanelHeader`, `TreeItem`, `FolderRow`, etc.), it's tempting to `vi.mock` the leaves with shim FCs that return `{ type: 'PanelHeader', props }` so the tree walker can `.find(el => el.type === 'PanelHeader')`. This DOES NOT work in React: when the orchestrator's JSX `<PanelHeader>` runs through `React.createElement(PanelHeader, ...)`, the resulting element has `type: <the-mocked-FC-reference>`, not whatever the FC happens to return. Walker yields the parent element with `type === MockFC`, never visits the mock's return value because React calls the FC at render time only. Fix: hoist the mock FCs into `vi.hoisted` (`MockPanelHeader: vi.fn((p) => p)`), import the named ref AFTER the mock (`import { PanelHeader } from '...'` resolves to the mock), then compare via `el.type === PanelHeader`. The walker then matches against the canonical reference. - -The named-fn pattern also lets the mock surface props verbatim (`(p) => p` returns the props object so `findFirst(tree, el => el.type === PanelHeader)!.props.search.onChange('bar')` drives the actual orchestrator wiring). Identity equality holds across the module boundary because `vi.mock(spec, factory)` registers the factory's return as the resolved module — both `import { PanelHeader }` from the orchestrator and from the test file see the same object. - -Sub-rule (state-driven render tests): if an orchestrator owns N `useState` calls plus delegates to hooks, mock `react.useState` with a slot-by-call-index dispatch table (cite: `mock-react-usestate-slot-by-call-index-with-mutable-refs`) AND mock the hooks themselves to return predictable shapes. `vi.hoisted` mutable refs let beforeEach reset slot values without re-mocking — but mutate the inner objects (`mocks.data.items = []`), don't replace the reference (`mocks.data = { items: [] }`), or the captured factory closures lose visibility. - -## split-then-coalesce-defensive-rhs-unreachable-after-length-gate - -_Discovered: 2026-05-03 by test-author in format-parser-coverage_ - -`format-parser.ts`'s `parse_reference_string` does `const parts = ref.split('.');` then early-returns if `parts.length < 2` and otherwise dispatches per `parts[0]`. Inside each switch arm, the source defends with `parts[1] ?? ''` (var/local/module/path branches at line 407), `parts[1] ?? ''` and `parts[2] ?? ''` (data branch at lines 414/415), and `parts[0] ?? ''` plus `parts[1] ?? ''` (resource default at lines 426/427). Every one of these RHS `''` arms is structurally unreachable: `String.prototype.split('.')` always returns at least one element, the `parts.length < 2` gate guarantees parts[0] AND parts[1] are defined past the early return, and parts[2] (data branch) lands as empty string but defined when input is `'data.x'`. v8 reports 5 uncovered branches at lines 407, 414, 426, 427 — the structural branch ceiling is 92.3% on this file, and exceeding it requires source change. Treat as a finding for the critic. Counter-pattern that would lift the ceiling: drop the `?? ''` defensive fallback (TS noUncheckedIndexedAccess can be loosened locally with non-null assertions where the gate proves presence) — but that's a source change, not a test gap. Same shape as `or-chain-default-fallback-needs-its-own-test-for-100pct-branch-coverage` and `structurally-unreachable-rhs-of-nullish-coalesce-inside-truthy-gated-block`, but here the gate is `parts.length < 2` rather than a truthy check. - -## aws-deployer-init-outer-catch-is-structurally-unreachable - -_Discovered: 2026-05-03 by test-author in aws-deployer + sdk-loader coverage_ - -`aws-deployer.ts`'s `initialize()` (lines 20-54) wraps three sibling per-client try/catch arms inside a single outer try/catch that re-throws as `Failed to initialize AWS SDK: <message>`. The outer catch is structurally unreachable: (a) the only statements outside the inner trys are pure `const X = '<literal>'` declarations, which cannot throw; (b) every inner `try { await Function('m', 'return import(m)')(spec); new ec2.EC2Client({region}); } catch {}` swallows ALL throws — whether the dynamic import rejects, `Function('m', ...)` itself throws synchronously, or the SDK constructor throws. Result: the structural ceiling for `aws-deployer.ts` is 99.29% statements / 97.77% branches (line 50 `throw new Error(...)` plus its `error instanceof Error ? error.message : String(error)` ternary). Treat as a critic finding — either drop the outer try/catch (the per-arm trys already provide complete error containment) or extract per-arm helpers that can throw before the outer wrapper sees them. Counter-example: `azure-deployer.ts` reaches the outer catch in tests because its FIRST identity-load call sits OUTSIDE the per-client trys (the credential is shared); AWS has no shared pre-load, so its outer catch is dead by construction. `sdk-loader.ts` does NOT have this issue: every `load_sdk` returns null on rejection internally, and `initialize_gcp_clients` doesn't re-wrap them — it reaches 100% statements / 100% branches naturally. Generalizes: any "outer wrapper try around N self-contained try/catch arms whose only co-located statements are literal assignments" is dead-by-construction. - -## tour-1-process-env-NODE_ENV-needs-narrow-ambient-declare-in-ui-package - -_Discovered: 2026-05-08 by implementer in tour-1_ - -The UI package (`packages/ui`) has `@types/react` but NOT `@types/node` in its devDeps. Any source file referencing `process.env.NODE_ENV` directly fails `tsc --noEmit` with `error TS2580: Cannot find name 'process'. Do you need to install type definitions for node?`. Three rejected fixes: (a) adding `@types/node` to the UI package — bloats `node_modules` and pulls Node ambient globals (`global`, `Buffer`) into a browser-targeted package's namespace; (b) using `import.meta.env.DEV` as a substitute — per `import-meta-env-DEV-is-vite-build-time-inlined-and-untestable-from-test-side`, that's untestable from the vitest side because Vite inlines DEV=true and `vi.stubEnv` doesn't reach the SUT module's `import.meta` binding; (c) hoisting the gate into a wrapper module — over-engineered for one boolean. Adopted fix: a narrow ambient declaration at top of the consuming `.ts` file, `declare const process: { env: { NODE_ENV?: string } };`. Vite statically replaces `process.env.NODE_ENV` at build time (documented Vite behavior — confirmed in production bundles), and Vitest exposes the real Node `process` at test time, so both runtime paths satisfy the signature. The declaration scopes the ambient to ONE file rather than polluting the whole package's globals. Use this pattern any time UI-package code needs the dev/prod runtime gate without import-meta acrobatics. - -## tour-4-multi-listener-hook-test-needs-per-render-useref-slot-index - -_Discovered: 2026-05-08 by implementer in tour-4_ - -`useElementPosition` calls `useReducedMotion()` AND a private `useRef(reducedMotion)` to expose freshest reduced-motion value to the IntersectionObserver closure. The standard hook-test harness mocks `useState`/`useEffect` synchronously — but a `useRef` mock that just allocates a fresh `{ current }` per call breaks across re-renders (each `renderToString` is a new component instance, so the ref's `current` resets). Fix: maintain a hoisted `refSlots: Array<{ current: unknown }>` and a `refSlotIndex: { i: 0 }` counter; mock `useRef` to return `refSlots[i++]`, allocating a slot only on first encounter, and reset `refSlotIndex.i = 0` in the test's `renderHook` helper before each render. This preserves ref identity across renders without leaking between tests (slots cleared in `beforeEach`). The element-swap test then works because the ref slot persists from render-1 to render-2 the way React's ref persists. Diagnostic if you skip this: the second render's reduced-motion read sees the wrong (initial) value, or `reducedMotionRef.current = ...` writes to a stale slot — the IO callback sees `false` even after `mocks.reducedMotionRef.current = true`. Same harness shape as `useref-mock-with-hoisted-prefix-ref-unlocks-single-render-effect-deltas`, generalized: any hook with `useRef` slot count > 0 needs ordered slot allocation, NOT per-call fresh objects. - -## tour-4-classic-debounce-vs-leading-edge-disambiguates-via-test-cases - -_Discovered: 2026-05-08 by implementer in tour-4_ - -The brief for `useElementPosition` said: "Use setTimeout. Reset on each call ONLY if the previous timer hasn't fired yet (i.e. classic debounce)." That phrasing is ambiguous — "classic debounce" resets the timer every call (trailing edge fires once after the last call), but "from the FIRST under-0.5 event in a window" sounds leading-edge. Disambiguator: the test cases. "Multiple <0.5 within 250ms call it once" passes for BOTH leading and trailing, but "after 250ms idle the next <0.5 calls scrollIntoView again" requires the timer to be one-shot per window, which trailing-edge satisfies cleanly: each `clearTimeout` + new `setTimeout` reset preserves the "fires once 250ms after the last under-0.5 event" semantics, and once it fires `hideTimer = null` so the next event schedules fresh. Generalizes: when a brief uses both "throttle" and "debounce" language, write the test cases first, then pick whichever implementation passes them. Don't try to satisfy ambiguous prose verbatim. - -## tour-3-raf-id-stale-after-callback-fires-blocks-rescheduling - -_Discovered: 2026-05-08 by implementer in tour-3_ - -A retry-loop hook that uses rAF as its scheduler must clear `handles.rafId` to `null` AT THE TOP of the rAF callback body, not just on tear-down. Reason: when the rAF tick fires, the id we recorded when calling `requestAnimationFrame(tick)` is now stale (the callback consumed it), but it's still sitting in `handles.rafId`. If the bottom of `tick` gates "schedule the next rAF" with `if (handles.rafId === null)` — to dedupe with a parallel observer-driven path that may also schedule — the gate fails because the stale id is still there. Symptom: the loop runs frame 1, then freezes in `'resolving'`, never increments the frame counter past 1 even though the budget is 30. Same trap exists for `setTimeout`-based schedulers. Diagnostic: tests that drive the rAF queue manually pass for the first frame and fail for every subsequent frame with status stuck on `'resolving'` — there's no visible error, the loop simply doesn't progress. Generalizes: every "fire-once" id stored on a handle (rAF, setTimeout, observer callbacks that re-arm rAFs, etc.) needs an explicit "I've been consumed" reset point in the callback body itself, not at the call site. Don't rely on `cancelAnimationFrame` to reset the id either — `cancelAnimationFrame` is only called on tear-down, not after a normal callback fires. - -## tour-5-fake-dom-children-must-carry-html-surface-at-construction - -_Discovered: 2026-05-08 by implementer in tour-5_ - -When building a node-only-vitest mini-DOM harness for a util that calls `container.querySelectorAll(...)` then iterates the result with `el.getAttribute(...)`, the harness's children MUST carry the HTMLElement-like surface at construction time, not via a wrapper function that returns a fresh object. The seductive-but-wrong shape is `function asHTMLElement(el: FakeElement): HTMLElement { return Object.assign({ ...el }, { getAttribute, ... }); }` — when only the test's outer references go through `asHTMLElement`, the children inside the container's `children: FakeElement[]` are still raw FakeElements without methods. The source's `querySelectorAll` returns raw children; the loop hits `el.getAttribute is not a function`. Fix: attach methods inside `makeEl` itself via `Object.assign(el, { getAttribute, hasAttribute, focus, addEventListener, removeEventListener })`, so every FakeElement is born HTMLElement-shaped. The wrapper is then just `(el) => el as unknown as HTMLElement` — a cast, no rewriting. Diagnostic if you skip this: `getFocusableElements` works in the install-time path (because tests cast that container) but fails inside the `getFocusableElements` body when iterating raw children. Generalizes: any fake-DOM harness where the SUT performs a tree walk (querySelector, parentElement, children) needs surface attached at element-creation time; harnesses where the SUT only ever touches the wrapped reference can get away with a wrapper. Pattern fits the canvas-context-menu test's flat element-tree assertion approach but extends it to objects exposed via `Array.from(NodeList)`. - -## tour-7-prisma-generate-vs-migrate-distinction - -_Discovered: 2026-05-08 by implementer in tour-7_ - -When a unit adds a Prisma column but the brief bans `prisma migrate` (because the orchestrator owns DB state), the typecheck on the consuming service still needs the regenerated client to know about the new field. `prisma generate` is a separate command that only rewrites the type definitions in `node_modules/.pnpm/.../@prisma/client/` and does NOT touch the database — so it is safe to run even when migrations are deferred. Run `pnpm --filter @ice/db generate` after editing `schema.prisma`, then the service typecheck (`pnpm --filter @ice/service-iam typecheck`) sees `completed_tours` in the User select-shape. Skipping `generate` produces a TS2353 "object literal may only specify known properties" error pointing at the new column in the route's `select: { ... }`. Generalizes: `migrate` writes to the DB, `generate` rewrites types — they are independent. Tests at the route layer mock `@ice/db` so they pass without `generate`, but typecheck does not. - -## tour-7-json-string-column-needs-route-layer-parsing - -_Discovered: 2026-05-08 by implementer in tour-7_ - -SQLite has no array type, so `User.completed_tours` is `String?` storing a JSON-encoded `string[]`. The discipline that keeps callers sane is: never expose the raw column outside the route file. Add a tiny `parseCompletedTours(raw)` helper that returns `[]` for null, empty string, malformed JSON, and non-array JSON values — the slice/UI sees `string[]` and only `string[]`. The PUT route reads → parses → mutates → JSON.stringify → writes. The GET route reads → parses → returns `completedTours` (camelCase) in the response, never the raw `completed_tours` column. Two read-time edge cases that surprised me: a non-array JSON value (e.g. `'"canvas-tour"'`) and malformed JSON (e.g. `'{not json'`) both deserve the `[]` fallback, and the very next PUT writes a valid JSON array — so a corrupt write self-heals on the next idempotent append. Generalizes: any "JSON-in-a-string-column" pattern needs a single read-side parser that's tolerant of nulls, malformed JSON, and shape mismatches; the write side is always `JSON.stringify(validatedShape)`. - -## tour-8-jsdom-portal-needs-fresh-root-per-test-and-act-from-react - -_Discovered: 2026-05-08 by implementer in tour-8_ - -Three intertwined gotchas when the first jsdom test under `// @vitest-environment jsdom` lands in this repo (the prior tour-3..6 tests all ran under node fake-DOM, so this was the cleanroom landing): (1) `jsdom` is not in `devDependencies` until you add it — `pnpm add -D -w jsdom` once, then the directive at the top of the test file activates it for that file only without slowing the rest of the suite. (2) `act` import path: React 19 (which this repo uses) re-exports `act` from `react` itself, NOT from `react-dom/test-utils` (deprecated). Importing it as `import { act } from 'react'` is the right call; `react-dom/test-utils` would still work but logs a deprecation warning under jsdom — silently passing through to console.error which can fail strict test runs. (3) `createPortal` + reused `document.body` between tests: tests that portal MUST clean `document.body.innerHTML` in `afterEach` AND unmount the React root explicitly (`root.unmount()` inside `act`). Skipping either one means the next test's query selector finds the previous test's overlay and assertions about "renders nothing when rect is null" fail mysteriously because the prior test left a spotlight in `document.body`. The pattern that works: per-test `container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);` in `beforeEach`, then `unmount(); document.body.innerHTML = ''` in `afterEach`. This shape will replicate across tour-10 (TourPopover) and tour-12 (TourRunner). - -## tour-6-localstorage-fastpath-needs-vi-resetmodules-to-re-evaluate-initialstate - -_Discovered: 2026-05-08 by implementer in tour-6_ - -The slice's `initialState` reads localStorage at module-load time so the engine can suppress auto-fired tours before the profile API resolves. To exercise the fast-path branch in a test (seed with stored ids, expect those ids in `init().completedTours`), restubbing `localStorage` AFTER the test file's top-level `import` of the slice is too late — the slice has already evaluated `readCompletedFromStorage()` against the empty pre-test stub and frozen `initialState`. The fix is `vi.resetModules()` followed by an `await import('../tour-slice')` — that forces the slice file to re-evaluate, picking up the freshly stubbed `localStorage`. Symptom if you skip this: `init().completedTours` is `[]` even though `localStorage.getItem(KEY)` returns the seeded JSON inside the same test. Pair with: `vi.stubGlobal('localStorage', ...)` (the standard fake-DOM pattern), and run the reset-and-reimport pattern in EACH fast-path test (parse-error fallback, non-array fallback, filter-non-strings, normal seeded path) so each evaluates against its own initial storage shape. Generalizes: any slice/util that does IO at top-level for an `initialState` computation needs `vi.resetModules` between tests that vary that IO. - -## tour-6-react-usecallback-stub-keeps-hook-driveable-without-renderer - -_Discovered: 2026-05-08 by implementer in tour-6_ - -The `useTour` hook composes ~6 `useCallback` wrappers around dispatchers. Driving the hook from a node-env unit test (no `@testing-library/react`, no fiber tree) collides with React's `useCallback` runtime check that requires an active renderer (`Cannot read properties of null (reading 'useCallback')`). Swapping to a manual closure or moving to jsdom would defeat the point of unit-testing the hook outside a React tree. The minimal escape: `vi.mock('react', () => ({ useCallback: <T>(fn: T): T => fn }))` — the mock turns `useCallback` into identity, so each call to the hook creates fresh closures (which is what the test wants — assertions are dispatch-call shape, not referential stability). `useDispatch` and `useSelector` get the standard `vi.mock('react-redux', ...)` treatment. This pattern lets a hook test stay in node-env even when the source uses `useCallback`; it's strictly cheaper than jsdom + a renderer for slice-orchestration tests where state changes between calls are driven by mutating the `useSelector` stub's return value. Caveat: the mock blocks `useEffect`, `useMemo`, `useState`, etc. — only safe when the hook uses ONLY `useCallback` + the redux hooks. If the hook later adds `useEffect`, expand the react mock or move to jsdom. - -## tour-9-derive-data-tour-id-from-existing-tab-id-instead-of-new-prop - -_Discovered: 2026-05-08 by implementer in tour-9_ - -The right-sidebar strip toggles each needed a `data-tour-id`, but the actual `<button>` DOM is owned by `SidebarStrip` (a generic shared component) — the parent `main-layout.tsx` only passes a `SidebarStripTab[]` data array. The naive paths both violate planner constraints: (a) "DO NOT add a new `data-*` prop type to a shared component" rules out extending `SidebarStripTab` with a `dataTourId` field; (b) "choose the inner element that DOES render to a real DOM node" suggests parent ownership, but the parent here owns NO inner DOM. The escape: derive the attribute from a value that's already on the data — `data-tour-id={\`sidebar-strip-${tab.id}\`}`. Tab ids are short, namespaced ("cost", "ai", "properties", "validation"), and stable; the resulting tour-ids match the planner's expected anchors exactly. Bonus: every other tab (left strip's "project", "blocks", "templates") gets the same treatment for free, which is a feature, not pollution — they're naturally namespaced under `sidebar-strip-\*`. Generalizes: when a tour anchor is needed on a child rendered by a shared component, look for an existing identifier on the data prop you can derive from, before reaching for a new prop or a wrapper element. - -## tour-11-fake-window-dispatch-helper-must-itself-enforce-capture-before-bubble-ordering - -_Discovered: 2026-05-08 by implementer in tour-11_ - -Stubbing `window.addEventListener` for a node-env keydown test typically uses a Map<eventType, Set<listener>> bus + a `dispatch(ev)` helper that iterates listeners in registration order. That works for tests that don't care about phases — but if the SUT registers with `{ capture: true }` and a test wants to verify "tour handler runs before bubbling listeners", the harness MUST itself partition by capture flag. Real DOM runs ALL capture listeners (in registration order) before ANY bubble listeners. A registration-order-only iteration silently passes when the capture listener happens to be registered first, then silently fails (or worse, gives a false-positive pass) when test setup reorders. The fix: store `{ cb, capture }` per listener and split `entries.filter(e => e.capture)` then `entries.filter(e => !e.capture)` before iterating. Pair this with `KeyboardEvent` stub class carrying a writable `defaultPrevented` field (the SUT calls `preventDefault()` and tests want to assert it). Sub-rule for vitest+TS strict-mode: `vi.fn()` returns `Mock<Procedure | Constructable>` which is NOT directly assignable to `() => void` props — annotate the spy as `vi.fn<() => void>()` (parameterized mock) so the TS error "Mock<Procedure | Constructable> is not assignable to type () => void" doesn't bubble up at typecheck time even though tests pass at runtime. Generalizes: any future hook that uses `{ capture: true }` listeners must have its test harness partition by phase, not just registration order. - -## tour-11-array-push-return-value-makes-vi-fn-typed-callback-mismatch - -_Discovered: 2026-05-08 by implementer in tour-11_ - -When using `vi.fn(() => callOrder.push('label'))` to record a sequence of handler invocations, the inferred return type is `number` (Array.prototype.push returns the new length), NOT `void`. Under TS strict mode this leaks: `Mock<() => number>` is not assignable to `Mock<() => void>` if the test field is typed `() => void`. Tests pass at runtime (the runtime spy is happy returning a number even when the prop type is void), but `tsc --noEmit` flags the mismatch. Fix: wrap the push in a block-bodied arrow with no implicit return — `vi.fn<() => void>(() => { callOrder.push('label'); })`. Same shape applies to `array.unshift`, `Map.set`, `Set.add` — all return non-void. The general rule: when a `vi.fn()` is destined for a `() => void` slot, parameterize the mock's signature explicitly AND ensure the body has no implicit return, OR the TS error fires only after a clean typecheck (long after the runtime test green-lights). - -## tour-10-radix-popper-virtualref-must-be-identity-stable-or-ooms - -_Discovered: 2026-05-08 by implementer in tour-10_ - -`PopperAnchor` from `@radix-ui/react-popper@1.2.8` runs an unguarded `useEffect` (no dep array) that reads `virtualRef?.current` on every render and dispatches `onAnchorChange(setAnchor)` on the parent `Popper` whenever identity differs from the prior tick (see `node_modules/.pnpm/.../@radix-ui/react-popper/dist/index.mjs` L43-L48). If the consumer hands a fresh measurable each render — e.g. `useMemo(() => ({ get current() { return { getBoundingClientRect: () => anchor.getBoundingClientRect() } } }), [anchor])` — `current` returns a brand-new object on every read, so the effect ALWAYS sees a new anchor, ALWAYS calls `onAnchorChange`, the parent `setState` triggers a re-render, the effect runs again — infinite render loop, JS heap OOM in ~30s under jsdom (`FATAL ERROR: Reached heap limit Allocation failed`). Symptom: vitest worker exits with `JavaScript heap out of memory` and `Worker exited unexpectedly`, no test output, just the V8 stack trace. Fix: keep ONE `useRef` whose `.current` is a single measurable object, with the `getBoundingClientRect` closure reading the live anchor through a `latestAnchorRef` indirection (so the closure is stable but always reads the freshest element). Concretely: `const latestAnchorRef = useRef(anchor); latestAnchorRef.current = anchor; const virtualRef = useRef<{ getBoundingClientRect: () => DOMRect }>({ getBoundingClientRect: () => latestAnchorRef.current!.getBoundingClientRect() });`. The same shape replicates for any future Radix Popper consumer using `virtualRef` (Tooltip, HoverCard, Menu — all share the popper layer). Generalizes: any time you adapt a "live element" to a Radix `virtualRef`, the ref AND its `current` must be stable across renders; channel mutability via a separate ref-of-ref. A `useMemo` with a getter is NOT stable. - -## tour-12-strictmode-register-guard-via-module-scoped-set - -_Discovered: 2026-05-08 by implementer in tour-12_ - -The tour registry's `registerTour` THROWS on duplicate ids in dev (intentional — bugs in tour-1 should surface), but React.StrictMode mounts every effect twice, so a naive `useEffect(() => { for (const t of tours) registerTour(t); }, [])` crashes in dev on the second mount even though both mounts came from the same component lifecycle. The pattern that survives StrictMode: a MODULE-SCOPED `Set<string>` that tracks "ids this process has already registered", checked before each `registerTour` call. Two-tier guard handles every path: (1) if `registeredIds.has(t.id)` → skip; (2) else if `getTour(t.id)` (registry already has it from a prior mount lifecycle) → skip + add to local set; (3) else → registerTour(t) wrapped in try/catch (a malformed config still must not crash the runner). Plus an exported `__clearTourRunnerRegisteredIds()` test-only escape hatch (matching `clearRegistry()` from `utils/tour-registry`) — without it tests can't simulate clean runtime mounts because the module-scoped set persists across vitest's module reuse. Generalizes to any "register once" pattern that lives in a module-scoped registry: never assume effect-once semantics; pair the registry with a parallel "have-registered" set that mirrors the registry's natural cardinality. - -## tour-12-jsdom-needs-resize-and-intersection-observer-stubs - -_Discovered: 2026-05-08 by implementer in tour-12_ - -jsdom does not ship `ResizeObserver` or `IntersectionObserver`. The element-position hook (tour-4) constructs both unconditionally on placed steps, so any TourRunner test that drives a tour past `phase='placed'` throws `ReferenceError: ResizeObserver is not defined` from inside React's commit phase — surfaces as `commitHookEffectListMount` in the stack trace. The minimum stub: a no-op class with `observe`, `unobserve`, `disconnect`, `takeRecords` methods, registered via `vi.stubGlobal('ResizeObserver', Stub)` + `vi.stubGlobal('IntersectionObserver', Stub)` in `beforeEach`, paired with `vi.unstubAllGlobals()` in `afterEach`. Don't skimp on `takeRecords` — `MutationObserver`-flavored callers expect it on its peers too. Generalizes: anytime a test crosses through `commit` for a hook that uses native browser observer APIs, stub them upfront in the test file's `beforeEach` rather than per-test, because failure surfaces inside React internals (not in user code) and the call site is ambiguous from the trace. - -## tour-12-jsdom-raf-budget-needs-real-elapsed-time-not-microtask-flush - -_Discovered: 2026-05-08 by implementer in tour-12_ - -The target resolver hook (tour-3) has a 30-frame rAF budget (~500ms at 60Hz). In jsdom, `requestAnimationFrame` is polyfilled atop `setTimeout(~16ms)`, so to drive the resolver to completion in a test you can't just `await Promise.resolve()` — you have to ELAPSE WALL-CLOCK TIME. The pattern that works under React 19 + jsdom + vi: `await act(async () => { await new Promise((r) => setTimeout(r, 20)); })` per iteration, looped enough times to cover the budget plus React's commit settling. For "expected resolver landed" tests, ~2-3 iterations (≈40-60ms) is plenty. For "expected resolver gave up" tests (status==='missing' after 30 frames), the budget is ~500ms wall-clock — loop 25-50 times with break-on-success guards to avoid wasting time once the assertion is satisfied. Don't reach for `vi.useFakeTimers` — the observer + portal + Radix interactions don't compose cleanly with fake timers; real timers + act are the correct primitive here. Generalizes: any test that drives a hook with a rAF retry budget needs real-time loops, not just microtask flushes — and bound the loop with a "did the state I'm waiting for arrive yet?" guard so happy-path tests don't pay the worst-case latency. - -## tour-13-empty-tour-param-must-be-noop-not-strip - -_Discovered: 2026-05-08 by implementer in tour-13_ - -`URLSearchParams.get('tour')` distinguishes `null` (key absent) from `''` (key present, empty value, e.g. `?tour=`). The autostart hook conflates both as "no tour requested" by treating `!id` (which is `false` for the empty string) as a no-op. The subtle gotcha: if you THEN call `URLSearchParams.delete('tour') + toString()` on `?tour=`, the result is the empty string, so `lastLocation.search` becomes `''`. A test that pastes `/p?tour=` expecting "search left as-is" will fail with "expected '' got ''" — because the implementation re-wrote the URL to a search-less variant via `navigate(...)`. Solution: the empty-id branch must short-circuit BEFORE the navigate-to-strip path, AND the test must assert the empty-id case preserves the original search verbatim. The hook does this by returning early when `readTourParam(search) === null` — but only after also clearing the `lastHandled` ref so a future re-paste of the same id re-fires. Generalizes: any URL-param hook that walks `URLSearchParams` should branch on `getParam !== null && getParam !== ''` separately from "param valid and ready to dispatch", because the strip path will rewrite the URL even when the dispatch path is gated. - -## tour-13-strictmode-double-effect-needs-pathname-plus-tour-tuple-key - -_Discovered: 2026-05-08 by implementer in tour-13_ - -A naive `useEffect` listening on `[location.search]` re-fires under React.StrictMode (every effect runs twice in dev) and dispatches `start(id)` twice — the second dispatch resets the slice's `stepIdx` to 0 even after the first `advance()` already moved past step 1. The fix is a `useRef<{ pathname, tour } | null>` that records the LAST handled tuple; if the next effect run sees the same `(pathname, tour)`, it skips. Critically, the ref must be CLEARED when the param vanishes (e.g. after the strip), else a later re-paste of the same id won't re-fire — the ref still holds the old tuple. Pattern: `if (id === null) { lastHandled.current = null; return; }`. Test signal: advance the tour to stepIdx=1 in act(); dispatch a no-op redux action to force a render; assert stepIdx STILL equals 1. If StrictMode gating is missing, the tour silently re-starts and stepIdx flips back to 0 — visible only via this stepIdx-conservation check. Generalizes: any effect that dispatches a side-effect from a URL/state param needs a per-tuple gate keyed on `(scope, value)` AND a clear-on-empty step so re-issuing the same value from a fresh edit re-fires. - -## tour-12-entering-phase-required-because-render-gate-must-wait-for-onenter - -_Discovered: 2026-05-08 by implementer in tour-12-followup_ - -Blueprint §3.5 spelled the lifecycle as `phase = 'placed' → await step.onEnter(...)`, but that ordering paints the overlay/popover BEFORE onEnter runs — fine for trivial onEnters but latently broken for any onEnter that mutates layout (open a sidebar, scroll, focus a child). The correct shape is a separate `'entering'` phase between `'resolving'` and `'placed'`: the runner moves to `'entering'` after the resolver lands, awaits `onExit(prev)` then `onEnter(curr)` in that order, THEN dispatches `setPhase('placed')` which is the existing render gate. Without the extra phase you have to either (a) hold the render gate behind a separate ref (parallel state to the slice — easy to drift) or (b) collapse onEnter into a synchronous-only hook (breaks the public Promise contract). Adding a phase is the cheapest correct option. Two tests pin the contract: (1) "overlay does NOT render until onEnter resolves" — set up an unresolving Promise and assert mocks.overlayProps.length stays 0 while phase is 'entering', then resolve and assert it goes positive; (2) "advancing during onEnter cancels the pending placement" — capture the resolver, dispatch advance to step-2 mid-await, then resolve step-1's onEnter; assert the popover ends up showing step-2 (NOT step-1 — the stale setPhase('placed') for step-1 must be dropped). Cancellation pattern: a `cancelled` flag flipped in the effect cleanup AND a `settled` flag set just before the final `setPhase('placed')` dispatch — the cleanup re-arms the entered-step ref ONLY when `!settled`, so successful entries persist their guard across the inevitable phase-change-driven re-render of the same effect. Test for phase ordering uses a `store.subscribe` log rather than polling between act-blocks — React batches the navigating→resolving→entering chain tightly under act, so post-act snapshots can miss intermediate phases entirely. Generalizes: any "render after async work completes" gate is best represented as its own phase value rather than a parallel ref/state, because the slice is the natural ordering oracle for tests. - -## i18n-wrap-brief-listed-string-may-not-match-source-literal-or-key-value - -_Discovered: 2026-05-18 by implementer in db-blocks-i18n-wire_ - -When a brief instructs "wrap `'Foo'` with `t('some.key')`", verify three things separately: (1) the EXACT literal in source — sometimes the source already uses an abbreviated/different form (`'Postgres'` instead of `'PostgreSQL'`); (2) the i18n key's resolved value in en.json — `canvas.blocks.titles.postgres` resolves to `"PostgreSQL"`, not `"Postgres"`; (3) what the test files assert against — the postgres test asserts the title fallback === `'Postgres'` literally. If you wrap the source's `'Postgres'` literal with `t('canvas.blocks.titles.postgres')`, the displayed string flips to `"PostgreSQL"` and the test breaks. The brief's "Do NOT introduce new behavior" overrides the literal-by-literal wrap instruction when those two collide. Safe rule: only wrap when source-literal === key-resolved-value. Otherwise skip + flag in report so the planner can decide if the i18n key should change OR the test assertion should update. Sub-rule: the standalone `t()` returns the English value by default (default locale 'en'), so tests asserting on English literals pass without test mock changes IF and only if the i18n value matches the source literal exactly. For DB block renderers (mongodb/mysql/postgres/redis-cache) the source files do NOT use `useTranslation()` — only the standalone `t()` — so no `useContext` mock is needed in tests. The mock-i18n-import-depth pattern from `i18n-mock-import-paths-resolve-relative-to-test-file-not-source-file` only applies when the source uses the hook variant. - -## imported-t-shadowed-by-existing-parameter-named-t-in-helpers-ts - -_Discovered: 2026-05-18 by implementer in network-source-blocks-i18n-wire_ - -`packages/ui/src/features/canvas/components/nodes/compact-node/helpers.ts` exports `truncate(t: string, n: number)` where the parameter is literally named `t`. Adding `import { t } from '../../../../../i18n'` at the top creates a shadowing situation: inside `truncate`'s body, `t` refers to the parameter (the string-to-truncate), not the translation function. This is SAFE today because `truncate` never invokes the imported `t()` — but it is a latent footgun: a future edit that adds `t('...')` inside `truncate` would silently dispatch against the string parameter (which is callable only if it has a `.call` method, otherwise TypeError at runtime). Two safe options when wiring i18n into a file with this shape: (a) rename the parameter (`truncate(text, n)`) — minimal-delta but technically a refactor; (b) leave the parameter name and rely on the fact that the existing function body never uses the imported `t()`. Chose (b) here to honor "do not refactor beyond translation." Pin: any future edit inside `truncate` that wants to call the translation function MUST use a renamed re-import or a per-call `import` alias. Smoking gun for the bug: `TypeError: t is not a function` (or silently returning the input string if the parameter happens to have a `.call` member). Generalizes: when adding a top-level named import to a file, grep the entire file for any local symbol with the same name (parameters, locals, type aliases) — if a collision exists in a scope that DOESN'T use the import today, document it; if it would use the import, pick a different alias (`import { t as translate } from '...'`). - -## module-level-options-array-with-t-must-move-inside-component-for-locale-reactivity - -_Discovered: 2026-05-18 by implementer in properties-panel-i18n-group-a_ - -`packages/ui/src/features/properties/components/fields/index.tsx` had module-scope constants `TASK_FREQUENCY_OPTIONS` (12 strings) and `ACTION_TYPE_OPTIONS` (label + description per entry) consumed by `TaskListField`'s `IceSelect` dropdowns. Wrapping the strings with `t('canvas.properties.fields.freqEvery5')` at the module top-level "works" at first paint but the array literal is evaluated exactly ONCE during module init — `_activeLocale` is whatever it was when the bundle loaded (usually 'en'). Later `setLocale('zh')` calls update `_activeT` but the already-frozen array still holds the English strings. Fix: move both constants INSIDE the FC body so they re-evaluate on every render. Trade-off: per-render allocation of a 12-element array — negligible vs. the cost of the IceSelect re-render that already happens. `HTTP_METHOD_OPTIONS` stays module-level because HTTP verbs are universal — not translated. Companion gotcha that's NOT this brief's problem but is now exposed: `task.frequency` persists as a string in the saved card (e.g. `'Daily at midnight'`); in 'zh' locale, the dropdown options become `['每 5 分钟', ...]` and the stored value won't match any option, so `IceSelect` with `allowEmpty={false}` shows the value as-is but with no highlighted option. Same shape applies to ANY persisted display-string field wired through i18n — the fix is a separate value/label split where the persisted value is a stable key (e.g. `'every_5_minutes'`) and the displayed label is derived from `t(...)`. Generalizes: when wiring i18n into module-scope arrays/constants that components consume, ALWAYS hoist the array into the consuming component body (or a `useMemo` keyed on locale) — otherwise the wrap looks done but only works for the initial locale. - -## i18n-wrap-blocked-when-test-mocks-t-and-asserts-on-english-literal - -_Discovered: 2026-05-18 by implementer in properties-panel-i18n-group-b_ - -Several `__tests__/` files under `packages/ui/src/features/properties/components/sections/` mock the i18n module with `vi.mock('../../../../../i18n', () => ({ t: vi.fn((key: string) => 't:' + key) }))`. The mock converts `t('foo.bar')` into a key-prefixed sentinel `'t:foo.bar'`, but the test assertions sometimes target the unwrapped English literal that the source emits today, e.g. `(el.props as any).children === 'Subdomain'`, `props.placeholder).toBe('e.g. 5432')`, `text).toContain('1 error')`, `(s.props as { title: string }).title === 'Triggers · staging'`. Wrapping these literals with `t('canvas.properties.edge.subdomainLabel')` flips the displayed value to `'t:canvas.properties.edge.subdomainLabel'` under the mock — and the assertion fails. The brief's "default locale 'en' so wraps stay green" assumes the test uses the REAL `t()`; when the test stubs `t` to prefix keys, that guidance inverts — wrapping BREAKS the test. Fix when the brief forbids touching tests: skip the wrap and flag in the report. Concretely skipped sites in this unit: `edge-properties-section.tsx` Subdomain label + subdomain/port placeholders + "(no domain set)" preview + "node" iceType fallback; `node-properties-section.tsx` validation banner "{n} error/warning" pluralization + "+N more" footer; `source-repository-section.tsx` `Triggers · {env}` title; `deploy-history.tsx` "Show all N deploys" button. Tests in this codebase use two i18n-mock shapes: (a) `t:<key>` prefix sentinel (used in most section-extraction tests for clarity of which key feeds which assertion) and (b) no mock at all (real `t()` resolves against `en.json`). Shape (b) is safe to wrap against; shape (a) requires either skipping the wrap OR updating the test — when the brief forbids the latter, skipping is the only path. Generalizes: before wrapping a literal with `t()`, grep the corresponding `__tests__/` file for that literal AND `vi.mock.*i18n`; if both exist AND the brief forbids test edits, the wrap is blocked. - -## blocks-section-getproviders-cannot-be-wrapped-in-usememo - -_Discovered: 2026-05-19 by implementer in palette-data-module-level-t-refactor_ - -`packages/ui/src/features/palette/__tests__/blocks-section.test.tsx` is a direct-FC tree-walker test that calls `BlocksSection` as a plain function — no `react-test-renderer`, no `react-dom`. When the component uses `useMemo(() => getProviders(t), [t])`, the real React's `useMemo` hits `ReactCurrentDispatcher.current` which is `null` outside a render cycle — and the entire test file crashes with `Cannot read properties of null (reading 'useMemo')`. The "obvious" fix is to mock `useMemo` in that test file's `vi.mock('react', ...)`, but companion tests in the same folder (e.g. `resource-palette.test.tsx`) already do that — adding a fresh `useMemo` mock to `blocks-section.test.tsx` doubles the maintenance surface. Cheaper fix: call the locale-reactive getter directly (`const providers = getProviders(t);`) when the data is small (≤4 entries) and call-site re-render frequency is already gated by something else. Generalizes: when wiring a locale-reactive getter into a component already covered by direct-FC tree-walker tests, prefer a plain call over `useMemo` UNLESS the getter does meaningful work AND the test infra mocks `useMemo` already. Smoke test: read the test file's react mock — if `useMemo` isn't in the override list, your `useMemo` call will crash the whole spec file at import time, not just the asserting test. - -## provider-flags-tests-need-paired-ice-constants-and-ice-templates-stubs - -_Discovered: 2026-05-19 by implementer in fix-59-failing-unit-tests_ - -When PROVIDER_FLAGS gates all non-GCP providers off (current default), unit tests that fix provider-related rendering or behavior fail in two layers that you have to stub in tandem. Layer 1: `@ice/constants` exports (`isProviderEnabled`, `isCategoryEnabledForProvider`, `ENABLED_PROVIDERS`, `isIceTypeEnabledForProvider`). Stub with `vi.mock('@ice/constants', async (importOriginal) => ({ ...(await importOriginal()), isProviderEnabled: () => true }))` so live flags don't leak in. Layer 2: `@ice/templates` re-exports `getEnabledProvidersForTemplate(tpl)` which transitively calls `getBlueprint(block, provider)` against the SAME flags — even after stubbing `@ice/constants` for the importing module, the helper still runs against the real flags inside `@ice/templates` because vi.mock is per-importer. Fix: in template-card / template-detail / template-gallery tests, ADDITIONALLY stub `@ice/templates.getEnabledProvidersForTemplate` to return `tpl.providers ?? []`. Bonus gotcha: node-menu uses `vi.mock('@ice/constants', () => ({ CLOUD_PROVIDERS: [...] }))` WITHOUT `importOriginal` — that wipes out every other export the source uses (`ENABLED_PROVIDERS`, `getCategoryForIceType`, `isCategoryEnabledForProvider`), so the source crashes at first use. Either always pass through with importOriginal, or stub every named export the source touches. Smoke test before assuming flag-only fix: `grep -rn '@ice/constants\|@ice/templates' <test-file>` and verify the stubbed export list matches what `<source-file>` imports. diff --git a/state/progress.md b/state/progress.md deleted file mode 100644 index 403d9cd5..00000000 --- a/state/progress.md +++ /dev/null @@ -1,230 +0,0 @@ -# Progress - -Living document. **Owned exclusively by the orchestrator (main session).** Subagents do not write to this file. - -## In flight - -_(none)_ - -## Done this week - -- **2026-05-02 — Parallel-deploy + dedup follow-ups (carry-over from pdl + rf-0c) — COMPLETE.** Six deferred items from the parallel-deploy initiative and the LOC-discipline initiative landed in one batch: - - ✅ **pdl-11** canvas-block-default-provider — `useCanvasDrop` reads `state.deploy.provider` and threads it into both `getBlueprint` / `expandBlueprint` (when palette didn't pin one) and into `newNodeData.provider` for resource drops. Group drops are unchanged. logBlueprint still records the _palette_ provider (analytics tracks user intent). 21 → 23 tests in `use-canvas-drop.test.tsx`. Commit `b062b6c`. - - ✅ **rollupPercentage extraction** — `deriveRollupPercentage(rollup)` extracted next to `deriveRollup` in `store/slices/deploy/derive.ts`; three identical inline copies (deploy-in-flight-panel, deploy-banner, status-bar) collapsed to a single import. 14 → 19 tests in `derive.test.ts` (+5 cases: empty / full / cap-at-99 boundary / rounding / defensive zero-total). Commit `6e637e9`. - - ✅ **Phase 2 `nodesById` warm-seed** — `useDeploySubscription` Phase 2 now dispatches synthetic `node_status` (and `node_progress` when step descriptor present) events for each node in `snapshot.nodeStatuses`, with seq=0 so any live event with seq>0 dedup-wins. New `overlayToWireStatus` helper inverts `mapWireStatusToOverlay` (returns null for non-wire overlay strings). 14 → 17 tests in `use-deploy-subscription.test.ts` (+3 inverse-map cases). Commit `47ffecd`. - - ✅ **DeployProgressSnapshot dead fields** — dropped `progress` / `currentResource` / `currentStep` from the interface (deploy-locks.ts), the `startDeploySnapshot` / `finishDeploySnapshot` writers, the `scheduler-callbacks.ts` writes (totals.completed.count bump preserved), the `canvas-deploy.ts` /current/:cardId fallback, and the now-orphan `updateDeploySnapshot` helper. 32 → 30 tests in `scheduler-callbacks.test.ts` (caps-99 + only-currentResource cases dropped — they tested the dead-field semantics specifically). Commit `63fb76b`. - - ✅ **Drop `data.status` legacy fallback** — per learning `one-status-source-deploy-status`. Reader fallback at `compact-node/index.tsx:81` removed; writer-side sweep removed `status: 'active'` seeds across `packages/blocks/src/{aws,azure,gcp}/security/waf.ts`, `expand-blueprint.ts`, `expand-template.ts`, `use-canvas-drop.ts`, `cards/reducers/undo-redo-group.ts`. Drift-checker writes (`use-drift-check.ts`) re-routed to `deploy_status`. 8261 unit tests passing. Commit `5bef36f`. - - ✅ **rf-0c cross-package dedup** — (a) `mapStatusToOverlay` + `overlayToWireStatus` hoisted to `@ice/types` next to `DeployNodeStatus`; service-side and UI-side modules re-export to preserve imports. New `DeployOverlayStatus` type. (b) Network-container set unified via `@ice/constants:NETWORK_CONTAINER_TYPES` — both `isContainer` predicates (`@ice/types/connection-rules/predicates.ts` and `@ice/core/validation/classifiers.ts`) now read from the canonical list, so a new container type flips both predicates in lockstep. The wider classifier-predicate dedup is deferred — `@ice/core` deliberately does not depend on `@ice/types`, and crossing that boundary is a larger architectural change than rf-0c warrants. Commits `6e053af`, `e40cdcc`. -- **2026-05-02 — Concepts palette: Auth + Data Warehouse + Search shipped.** Three blocks originally deferred from the 23-block cut on 2026-04-14 are now built. New `auth` high-level resource added to `core/src/resources/high-level-resources/categories/security.ts` (Cognito / Firebase Auth / Entra ID); `data-warehouse` and `search-engine` resources already existed in `database.ts`. Three concept blueprints + info content + family registrations under `packages/blocks/src/common/concepts/{auth,data-warehouse,search-engine}/`. Palette grows 25 → 28; new "Analytics" group between Data and Messaging in `CONCEPT_BLUEPRINTS`. SaaS-key path (Clerk/Auth0 in Secret Store) and library path (NextAuth/Lucia + Postgres) still work — the new Auth block is for users who explicitly want managed identity. 8261 unit tests passing; 0 typecheck regressions. Commits `614bd80`, `7d291ff`. -- **2026-05-02 — Quarterly compaction of `learnings.md` (Q2-2026).** 204 → 113 anchors (-44%); 1676 → 780 lines (-53%). Pre-compaction snapshot archived to `state/archive/learnings-2026-Q2.md`. All 25 must-preserve anchors retained verbatim (24 `_Promoted to: /docs/refactoring-patterns.md_` + the `read-state-first` anchor cited from `decisions.md` and `CLAUDE.md`). All `_Promoted to:_` and `_Fixed:_` trailers preserved. Representative cluster merges: `ux-log-terminal-pitfalls` (5 sub-anchors), `ux-deploy-real-cloud-pitfalls` (3), `pdl-7-wire-contract-trims-downstream-ui` (3), `pdl-10-destroy-snapshot-and-dedup-traps` (3), `inline-classification-duplications-are-not-actually-duplicates` (4 rf-canv siblings), `test-helper-defaults-traps-coalesce-and-spread` (3), `brief-numerics-are-approximate-source-is-canonical` (3 brief-vs-source variants). Commits `8ef2e25`, `763457a`. -- **2026-05-02 — Merge story decision for the refactoring branch.** Decision recorded in `decisions.md` (2026-05-02 entry): merge as a single PR rather than splitting per phase. 511 commits ahead of `main`, ~1,724 files touched, +251,866 / −50,621 LOC. PR body prepared at `/tmp/refactoring-pr-body.md` for use after `gh auth login -h github.com`. Pre-PR sanity: typecheck clean across all 22 packages except for the documented 25 TS2834 baseline errors in `packages/core` barrel files (pre-existing); 1 typecheck error in `apply-pipeline-helpers.test.ts` fixed inline (cast `source_node_id` reads on the inline result fixture). Commits `78bbd83`, `7e7b7b0`. -- **2026-05-02 — Housekeeping pass on `progress.md`.** Reduced 322 → 216 lines. The In flight section's pdl-1..10 status block + 7 rf-\* per-file refactor subsections (all complete) moved verbatim under Archive so anchor and commit references stayed searchable. Commit `c2c7b2f`. -- **2026-04-27 LT-1 through LT-9** — Consolidated `Monitoring.Terminal` into `Monitoring.Log`; built the live Cloud Logging stream backend (filter resolver + log-stream service + routes + Socket.IO room) and frontend (`logs-slice` + `useLogStream` hook + properties section + canvas placeholder). 365+ tests added. Real-deploy verification deferred behind the parallel-deploy work because the deploy engine was too fragile for clean iteration. See `decisions.md` 2026-04-27 entry. -- **2026-04-28 pdl-1** — Parallel scheduler in deploy engine. Commit `c60bd1b`. -- **2026-04-28 pdl-2** — Per-node deploy event types (`packages/types/src/deploy-events.ts`) + 5 typed wire emitters in `@ice/shared` socket service; legacy `emitDeployProgress` removed. 8 new tests. Critic APPROVE WITH NITS (deferred to pdl-4/pdl-7). -- **2026-04-28 pdl-3** — `ctx.on_step` wired into 6 GCP handlers + cloud-build-helper signature change. 12 new tests. Critic APPROVE WITH NITS (deferred update-path follow-ups). -- **2026-04-28 pdl-4** — Service-layer migrated to the new typed wire contract. Graph→canvas id translation lives at the deploy-service boundary. `DeployRequirementVerifiedEvent` widened in pdl-2's contract to carry `node_id`/`environment`/`details`. 19 new tests; total 79 in `@ice/service-deploy`. All four typechecks green. - -- **2026-04-27 LT-1 through LT-9** — Consolidated `Monitoring.Terminal` into `Monitoring.Log`; built the live Cloud Logging stream backend (filter resolver + log-stream service + routes + Socket.IO room) and frontend (`logs-slice` + `useLogStream` hook + properties section + canvas placeholder). 365+ tests added. Real-deploy verification deferred behind the parallel-deploy work because the deploy engine was too fragile for clean iteration. See `decisions.md` 2026-04-27 entry. -- **2026-04-28 pdl-1** — Parallel scheduler in deploy engine. Commit `c60bd1b`. -- **2026-04-28 pdl-2** — Per-node deploy event types (`packages/types/src/deploy-events.ts`) + 5 typed wire emitters in `@ice/shared` socket service; legacy `emitDeployProgress` removed. 8 new tests. Critic APPROVE WITH NITS (deferred to pdl-4/pdl-7). -- **2026-04-28 pdl-3** — `ctx.on_step` wired into 6 GCP handlers + cloud-build-helper signature change. 12 new tests. Critic APPROVE WITH NITS (deferred update-path follow-ups). -- **2026-04-28 pdl-4** — Service-layer migrated to the new typed wire contract. Graph→canvas id translation lives at the deploy-service boundary. `DeployRequirementVerifiedEvent` widened in pdl-2's contract to carry `node_id`/`environment`/`details`. 19 new tests; total 79 in `@ice/service-deploy`. All four typechecks green. -- **2026-04-28 pdl-7** — Frontend Redux `nodesById` state + channel name flipped to `DEPLOY_EVENT_CHANNEL`. The deploy-panel black-hole window from pdl-4 is closed. 38 new tests; 306 total in `@ice/ui` + `@ice/service-deploy`. All five typechecks green. -- **2026-04-28 pdl-6** — Canvas overlay badges for queued/skipped/cancelled wire states. 8 new tests. Mostly cosmetic; no critic dispatch. -- **2026-04-28 pdl-5** — Deploy panel rewrite. Per-node live list from `nodesById`, action-aware destroy labels, honest progress rollup. Legacy `progress`/`currentResource`/`currentStep` fields dropped. 9 new tests; 244 total in `@ice/ui`. -- **2026-04-28 pdl-8 + pdl-9** — Cross-unit seq-roundtrip test (wire ↔ persistent log) + `/docs/architecture/core-engine.md` and `/docs/architecture/frontend.md` updates for the parallel scheduler architecture. 477 tests passing across the monorepo. -- **2026-04-28 pdl-10** — Destroy parity. Both destroy paths now emit `node_status` events with `action: 'delete'`; frontend slice now action-aware-dedups so destroy events land in `nodesById`. Closes the smoke-test regression `ux-destroy-action-bypasses-node-status-wire`. 487 tests passing (+10 net). - -## Blocked - -_(no blockers)_ - -## Archive - -### 2026-04-28 → 2026-04-29 — Parallel deploy scheduler initiative — COMPLETE through pdl-10 - -The deploy engine was refactored from sequential apply to a parallel work-stealing scheduler with live per-node statuses. Architectural decisions are in `decisions.md` under **"2026-04-28 — Parallel deploy scheduler with per-node live status"**. - -**Status (all complete):** - -- ✅ **pdl-1 consolidate scheduler** — `packages/core/src/deploy/scheduler.ts` + tests. Pool size 6 default; per-handler caps `gcp.sql.*=1`, `gcp.redis.*=1`. Failure isolates to descendants. New `NodeStatusEvent` / `NodeProgressEvent` types + callbacks on `DeployOptions`. 17 new tests; 331 in `packages/`. Commit `c60bd1b`. -- ✅ **pdl-2 per-node event types** — `packages/types/src/deploy-events.ts` (locked contract: discriminated union + `DEPLOY_EVENT_CHANNEL`) + 5 typed wire helpers in `packages/shared/src/socket/service.ts`. Legacy `emitDeployProgress` removed cleanly. 8 tests. Commit included channel-constant test learning anchor `socket-service-module-scoped-io-needs-vi-resetmodules-per-test`. -- ✅ **pdl-3 GCP handler milestones** — `ctx.on_step` wired into cloud-sql (2), memorystore (2), cloud-run service+job (4), cloud-functions (2), api-gateway (1 or 3 with openapi_spec), gke (2). `cloud-build-helper.ts` now takes `reportStep(index, label)`; cloud-run pins all build sub-states to outer index 2 so the bar holds while labels refresh. 12 tests. Learning anchor `cloud-build-helper-substep-shares-outer-index`. -- ✅ **pdl-4 service-layer wiring** — local `emitDeployProgress` shadow replaced with typed `emitDeployEvent` dispatcher; `graphIdToCanvasId` translation map built from `translation.deployables[]`; scheduler callbacks wired with translation; legacy `on_progress` aggregate dropped. 50 callsites migrated in `deploy.service.ts`, 3 in `queue.service.ts`, 1 in `requirement-poller.service.ts`. Single-counter seq via `nextDeploySeq(cardId)` + per-emit allocation. 19 tests. Learning anchors: `seq-allocation-must-be-shared-between-wire-and-log`, `graph-id-vs-canvas-id-translation-is-service-layer-job`, `point-types-at-source-not-dist-in-workspace-packages`, `requirement-verified-needs-full-tenancy-key-on-the-wire`, `seq-schemes-on-shared-channel-need-jsdoc-discrimination`. -- ✅ **pdl-5 deploy panel rewrite** — Per-node live list driven by `nodesById` from pdl-7, with simultaneous-applying indicators and an honest `terminal/total` rollup that fixes the long-standing bouncing-bar bug. `currentResource`/`progress`/`currentStep` removed from `DeployState`. New `deriveRollup` and `orderNodesForPanel` helpers. svg-canvas banner + status-bar pill rewired via `shallowEqual` selectors. 9 tests. Learning anchors: `react-memo-on-rollup-component-instead-of-shallowequal-on-selector`, `destroy-status-also-emits-node-events`, `ux-row-labels-need-action-aware-substitution`. Deferred: rollupPercentage extraction (#2/#4) and Phase 2 nodesById warm-seed (#7) — both now in current In flight. -- ✅ **pdl-6 per-block canvas overlay** — `getDeployBadge` extracted to `compact-node/helpers.ts` covering all 6 wire overlay strings. New badges for `queued` / `skipped` / `cancelled`; `skipped`/`cancelled` blocks render at `opacity: 0.6`. 8 tests. -- ✅ **pdl-7 Redux nodesById state** — `deploy-slice` extended with `nodesById: Record<string, NodeDeployState>`; three new typed reducers with seq-based dedup. Frontend listener flipped to `DEPLOY_EVENT_CHANNEL`. API method renamed `onDeployProgress → onDeployEvent`. Hook's `applyDeployEvent` rewritten as `switch (event.type)` over typed `DeployEvent` union. 38 tests across 3 new test files; 227 → 306 total. Learning anchors: `frontend-channel-flip-needs-eager-init-callsite-sweep`, `test-the-channel-name-constant-not-the-string`, `complete-event-without-results-needs-post-complete-fetch`, `deploy-overlay-mapping-must-match-status-colors-keyset`, `complete-event-must-thread-error-message`. -- ✅ **pdl-8 tests** — gap-fill from critic findings: seq-roundtrip integration test in `deploy-event-translation.test.ts` asserting every wire-emit seq lands on the persistent log row with the same value. -- ✅ **pdl-9 docs** — `/docs/architecture/core-engine.md` Apply paragraph rewritten to describe the parallel scheduler + per-handler caps + failure isolation; new "Live event wire contract" section documents the `DeployEvent` discriminated union and the three id namespaces. `/docs/architecture/frontend.md` deploy-slice description updated. -- ✅ **pdl-10 destroy parity** — `destroyDeployment` and `destroyAllForCard` emit `node_status` events with `action: 'delete'`. Built per-resource canvas-node-id correlation from `res.source_node_id` (destroyDeployment) and `prisma.deployedResourceMapping` rows + historical fallback (destroyAllForCard). Both paths now open + close `startDeploySnapshot`/`finishDeploySnapshot` so `nextDeploySeq` allocates contiguous integers. Action-aware dedup + reset on `queued`-after-terminal added to slice. Snapshot lifecycle wrap with try/catch + defensive prisma update. 10 net new tests; 487 total. - -**UX smoke test (2026-04-28).** ux-tester drove the parallel-deploy work end-to-end against `lc-ice` (pre-flight clean after orchestrator deleted leaked `ice-full-sta-prod-bucket-3bf3f9d3`). Canvas: 3× `Storage.Bucket` blocks. Result: - -- ✅ Headline bouncing-bar bug GONE. Bar moved 0% → 67% → 100% monotonically over an 8.2s deploy. Per-node list rendered with QUEUED → DEPLOY → LIVE pills. 1-in-flight + 2-done observed at one tick; the two completed buckets clocked 2.4s and 2.6s — proof of pdl-1's parallel pool actually running concurrent applies. -- ✅ Wire contract behaves correctly on apply path. `state.deploy.nodesById` populates from `'deploy:event'` channel; canvas badge agrees with deploy panel row for the same node; post-success summary shows `outputs` / `provider_id` / URLs. -- ✅ No legacy `'deploy:progress'` listener anywhere; `state.deploy.nodesById` exists; idle panel renders correctly. -- ❌ Destroy bypassed the node_status wire — addressed by pdl-10. - -**Deferred follow-ups from this initiative (now in current In flight):** pdl-11 canvas-block-default-provider, rollupPercentage extraction, Phase 2 nodesById warm-seed, DeployProgressSnapshot dead fields, drop `data.status` legacy fallback. - -### 2026-04-29 → 2026-05-02 — Workspace-wide LOC discipline initiative — COMPLETE - -Multi-day effort to bring every actionable source file in the monorepo within the 200-500 LOC range. ~470 commits, ~7,500 new tests, 73 files refactored, 4 latent bugs fixed. - -**Phase 1 — 5 monster files (>2000 LOC each)**: - -- `services/deploy/src/services/deploy.service.ts` (2843 → 1572 → eventually 107 in final round) -- `packages/ui/src/features/properties/components/properties-panel.tsx` (3268 → 94) -- `packages/ui/src/features/canvas/components/svg-canvas.tsx` (3234 → 909 → eventually 453) -- `packages/ui/src/features/deploy/components/deploy-panel.tsx` (2229 → 262) -- `packages/core/src/deploy/card-translator.ts` (1585 → 401) - -**Phase 2 cohorts** (1000-2000 LOC files): - -- 5 files via various patterns (cards-slice, firebase-hosting, parser, lexer, deploy-slice, cloud-storage) -- 12 UI components via rf-pdpl section pattern -- 12 code-heavy files (sqlite-state-store, auto-layout, scheduler, mutable-graph, ai.service, pulumi-exporter, operation-executor, pipeline.service, log-stream.service, etc.) -- 3 data-heavy splits (scale-presets, cloud-blocks, dev-accent-picker themes) -- 3 tail files (connection-rules, ast, http-api-adapter) - -**Phase 3** — 18 files in 500-600 LOC band, 6 cohorts (rf-fbh handlers, rf-pdpl sections, rf-deploy method-grouping, importers, rf-pulumi/rf-parse, mixed). Eliminated all "slightly over" files. - -**Final round** — 8 documented exceptions decomposed (excluding generated `resource-types.ts` per user instruction). Reduced 12,754 → 1,082 orchestrator LOC across remaining files. - -**Bug fixes** (2026-05-02): - -- `bugfix-1` — graph-nodes-keyed-by-type-colon-name (5 callsites switched to `graph.get_node_by_name`) -- `bugfix-2` — `get_base_db_path` lazy `require.resolve` -- `bugfix-3` — `get_critical_path` distance propagation -- `bugfix-4` — `detectJsFramework` pnpm-lock detection - -**Documentation**: - -- `/docs/refactoring-patterns.md` — 6 proven decomposition patterns + test patterns + gotchas, distilled from the initiative -- 24 stabilized learnings promoted from `state/learnings.md` to the new doc - -**Final state of the codebase**: - -- Every actionable file within 200-500 LOC band. -- 4 documented data-leaf exceptions remain (high-level-resources category files, all carrying SIZE EXCEPTION headers). -- 1 generated file excluded per user instruction (`resource-types.ts`). -- 0 known latent bugs. -- 162 fine-grained learning anchors retained in `state/learnings.md` for future reference. - -The codebase decomposition initiative is complete. Future contributors have: - -- A documented set of patterns to follow (`/docs/refactoring-patterns.md`). -- A clean LOC discipline across the workspace. -- ~7,500 tests covering every extracted module. -- A concrete audit trail in `refactor-targets.md` of every file touched. - -#### Per-file detail trail (rf-\* refactor initiative) - -Detailed unit-by-unit history. Preserved verbatim because individual learning anchors and commit hashes are referenced from `learnings.md`, `decisions.md`, and `/docs/refactoring-patterns.md`. The `blueprints/rf-*.md` files capture the architecture per series; this section captures the unit-by-unit narration. - -**rf-deploy — `deploy.service.ts` (2843 → 1572 → eventually 107 in final round)** - -- ✅ **rf-deploy-1** `utils/deploy-event-formatter.ts` — `describeEventForLog` + `mapStatusToOverlay`. 13 tests, 100% / 100%. Commit `7669b9c`. -- ✅ **rf-deploy-2** `utils/deploy-outcome.ts` — `computeCompleteTotals` + `deriveCompleteOutcome` + `computeDeploySummary`. 29 tests. Commit `2d8ded8`. -- ✅ **rf-deploy-3** `utils/find-source-node-id.ts` — `buildResourceNameMaps` + `makeFindSourceNodeId`. 20 tests. Commit `c10b0df`. Learning `vi-spyon-accumulates-across-it-blocks-without-explicit-reset`. -- ✅ **rf-deploy-4** `utils/project-context.ts` — `resolveProjectContext`. 6 tests. Commit `59afd32`. -- ✅ **rf-deploy-5** `services/deployer-factory.ts` — `createDeployer` + `getCoreEngine`. 6 tests. Commit `dd95e8f`. Learning `core-const-lifetime-varies-per-callsite-when-extracting-deployer-factory`. -- ✅ **rf-deploy-6** `services/gcp-api-enabler.ts` — `ICE_TYPE_API_MAP` + `BASE_APIS` + `enableGcpApi` + `autoEnableGCPApis`. 20 tests. Commit `70674e4`. Learning `vi-fn-default-type-rejects-typed-callback-parameter`. -- ✅ **rf-deploy-7** `services/snapshot-persister.ts` — `installSnapshotPersister` + `flushSnapshotNow`. 10 tests. Commit `6556ae4`. -- ✅ **rf-deploy-8** `services/deploy-lock-wrapper.ts` — `acquireWriteLock` (3 callsites deduped). 6 tests. Commit `e52ebbf`. Learning `vi-mock-factory-hoist-blocks-top-level-class-references`. -- ✅ **rf-deploy-9** `services/deploy-event-dispatcher.ts` — `emitDeployEvent` + `emitLog` + `emitDestroyNodeStatus`. 19 tests. Commit `d92630e`. -- ✅ **rf-deploy-10** `services/baseline-graph.ts` — `buildBaselineGraph`. 11 tests. Commit `ecd31c1`. Learning `emit-log-gate-must-mirror-original-truthiness-not-count`. -- ✅ **rf-deploy-11** `services/destroy-targets.ts` — `collectDestroyAllTargets` + `orderTargetsForDelete` + `resolveDestroyAllProject`. 27 tests. Commit `7cf988d`. -- ✅ **rf-deploy-12** `services/scheduler-callbacks.ts` — `makeSchedulerCallbacks`. 32 tests. Commit `a4f287b`. -- ✅ **rf-deploy-13** `services/destroy-runner.ts` — `attemptDestroy` + `emitDestroyLifecycle`. 21 tests. Commit `dbc7313`. Learning `inline-catches-can-have-inconsistent-error-message-derivations`. -- ✅ **rf-deploy-14** `services/quota-retry.ts` — `retryAfterQuotaCleanup` + `hasQuotaFailure`. 26 tests. Commit `1fa9198`. -- ✅ **rf-deploy-15** `services/canvas-overlay.ts` — `getNodeDeploymentOverlay`. 36 tests. Commit `9b37da3`. -- ✅ **rf-deploy-16** `services/drift.service.ts` — `checkDrift`. 23 tests. Commit `982d3b2`. -- ✅ **rf-deploy-17** cleanup — 7 re-exports dropped, 2 imports trimmed, drift test migrated. 392 tests passing in service-deploy. Commit `1e43f7e`. Learning `reexport-audit-distinguish-namespace-imports-from-named-imports`. - -**rf-props — `properties-panel.tsx` (3268 → 94)** - -26-unit blueprint at [`blueprints/rf-props.md`](blueprints/rf-props.md). 5 leaf utils → fields bundle + 4 hooks → 14 section subcomponents → orchestrator + 1 cross-file dedup. Behavior-risk flags: custom-domain-panel rendered twice (rf-props-15), dynamic imports with shifting relative paths (rf-props-20), setState-during-render fallback (rf-props-24a/b). - -- ✅ **rf-props-1..5** utils — `queue-spec` / `normalize-subdomain` / `edge-warnings` / `format-age` / `deploy-history-format`. Commits `5629b1b` / `88d383f` / `d356718` / `47a91f8` / `093bda5`. -- ✅ **rf-props-6** `components/fields/index.tsx` — Section + 8 input components. Commit `112b8d9`. Learning `tree-walker-for-react-fc-tests-must-flatten-nested-children-arrays`. -- ✅ **rf-props-7** `hooks/use-resource-map.ts` — `useResourceMap` + `usePropertyIssues`. Commit `28bacc2`. Learning `extract-pure-builders-when-testing-redux-or-effect-hooks-in-node-env`. -- ✅ **rf-props-8** `hooks/use-drift-check.ts`. Commit `206ca4d`. Learning `capture-ref-after-render-unlocks-100pct-on-callback-returning-hooks`. -- ✅ **rf-props-9** `components/fields/render-property-field.tsx` — orchestrator + canonical home for resource-def types. **312 LOC removed.** Commit `12507f5`. Learning `mocked-component-data-attrs-invisible-to-direct-fc-walker`. -- ✅ **rf-props-10..14** sections — `drift` / `group-color-picker` / `connection-card` / `env-vars-editor` / `scaling+domain`. Commits `ca54041` / `b11e275` / `dbd6dc7` / `efa8340` / `7748b32`. Learnings `react-ssr-comment-markers-split-adjacent-text-substrings`, `collect-text-helper-joins-adjacent-jsx-children-with-a-separator`, `vi-hoisted-for-stable-mock-identity-in-direct-fc-tree-walker-tests`. -- ✅ **rf-props-15** `sections/custom-domain-panel.tsx` — BEHAVIOR-RISK; both callsites preserved byte-identical. Commit `b1fd1e0`. Learning `test-prop-shape-when-extraction-preserves-an-unused-prop`. -- ✅ **rf-props-16** `sections/private-network-panel.tsx`. Commit `4648712`. Learning `tree-walker-must-invoke-file-private-fcs-when-extracted-component-keeps-an-inner-helper`. -- ✅ **rf-props-17** `sections/repo-deploy-list.tsx`. Commit `29c4731`. Learnings `use-state-mock-with-mutable-ref-unlocks-direct-fc-toggle-state-tests`, `dynamic-import-of-api-adapter-needs-a-direct-vi-mock-on-the-target-module`. -- ✅ **rf-props-18..19** `service-source-section` / `deploy-history`. Commits `6645227` / `9efc48b`. Learnings `jsx-html-entities-render-as-the-actual-unicode-character-not-the-escape-sequence`, `queued-ref-dispatch-extends-the-mutable-ref-usestate-mock-to-multi-state-fcs`. -- ✅ **rf-props-20** `sections/pipeline-section.tsx` — BEHAVIOR-RISK dynamic-import paths. Commit `3d781f5`. Learning `dynamic-import-with-default-destructure-needs-the-mock-to-expose-default`. -- ✅ **rf-props-21** `sections/source-repository-section.tsx`. Commit `6cc2dae`. Learnings `use-memo-must-be-mocked-too-when-the-extracted-component-uses-it`, `nullish-coalesce-default-in-test-helper-silently-clobbers-explicit-null-overrides`. -- ✅ **rf-props-22** `sections/edge-properties-section.tsx`. Commit `ed2193c`. Learning `render-helper-must-not-call-mockreturnvalue-after-test-overrides`. -- ✅ **rf-props-23** `sections/project-overview.tsx`. Commit `00caaa2`. -- ✅ **rf-props-24** `sections/node-properties-section.tsx`. Commit `6dfd890`. -- ✅ **rf-props-26** cost-utils dedup — BEHAVIOR-CHANGE. Local `parseCostRange` / `formatCost` replaced with imports from canonical `packages/ui/src/features/cost/utils/cost-calculator.ts`. 19 tests at canonical home + 3 behavior-delta tests in consumer. 1464 unit tests passing. Learning `canonical-home-dedup-of-local-copies-is-a-behavior-change-when-the-canonical-is-stricter`. - -**rf-canv — `svg-canvas.tsx` (3234 → 909, then 570 → 453 in final round)** - -28-unit blueprint at [`blueprints/rf-canv.md`](blueprints/rf-canv.md). 8 utils + 14 hooks + 8 subcomponents + 1 dispatch registry. Behavior risks pinned with tests: predicate broadening (rf-canv-2/6), wrapper key reconciliation (rf-canv-10), dispatch-factory innerKey shape (rf-canv-12), edgeStyle real type (rf-canv-15), RTK frozen-state test pattern (rf-canv-19). Key commits `7d2f1e6` (rf-canv-1) through `ec17722` (cleanup) and `fac4fb7` (rf-canv-28+29). - -**rf-pdpl — `deploy-panel.tsx` (2229 → 262)** - -24-unit blueprint at [`blueprints/rf-pdpl.md`](blueprints/rf-pdpl.md). 5 leaf utils → 8 leaf subcomponents → 3 composing → 3 hooks (Redux + side-effects) → orchestrator. 12 behavior-risk flags including: useDeployEffects 4-effect bundle, retry-after-auth re-dispatch ordering, React.memo boundary on DeployNodeRow, startDestroying-before-await ordering, classifyDeployError single-regex preservation, ✓/✗ glyphs, createPortal+Esc listener owned by destroy-confirm modal. - -- ✅ **rf-pdpl-1..5** utils — `provider-regions` / `open-external-url` / `dns-records` / `results-summary-text` / `error-classification`. Commits `382b13c` / `f540531` / `090cf3d` / `d7828e5` / `545ba78`. Learning `regex-i-flag-applies-to-character-classes-not-just-the-literal`. -- ✅ **rf-pdpl-6..13** subcomponents — `status-badge` / `plan-preview` / `auth-banner` / `deployed-resources-list` / `log-panel` / `dns-records-section` / `destroy-confirm-modal` / `deploy-node-row`. Commits `ca5610b` / `95ca71d` / `510c3fa` / `7e4c2e3` / `a4cd9b0` / `b66becb`+`deaa096` / `8d4b0b5` / `2e01211`. Learnings `collecttext-regex-sweep-fails-because-join-erases-key-boundaries`, `react-element-ref-is-not-on-the-public-reactelement-type`, `defensive-or-fallback-after-pre-filter-is-an-unreachable-branch-95-pct-ceiling`, `react-namespace-hook-access-requires-patching-default-export-too`, `stubbing-window-and-keyboardevent-for-node-env-keydown-listener-tests`, `react-memo-wrapper-must-be-unwrapped-via-dot-type-for-direct-fc-tree-walker`. -- ✅ **rf-pdpl-14..16** composing — `deploy-in-flight-panel` / `results-summary` / `quota-error-banner`. Commits `5277103` / `69e7f0e` / `2c9943b`. Learnings `vi-mock-paths-resolve-relative-to-test-file-not-source-file`, `lucide-react-icons-are-forwardref-objects-not-fcs-for-tree-walker-predicates`, `lucide-react-aliased-icons-displayname-tracks-target-not-binding`. -- ✅ **rf-pdpl-17..19** banners + section + controls — `api-error-banner` / `config-section` / `deploy-controls`. Commits `24b1617` / `e1a3bea` / `6b3f6ae`. Learning `prop-capturing-mock-fc-needs-drain-and-reset-for-tree-walker-tests`. -- ✅ **rf-pdpl-20..22** hooks — `use-deploy-actions` / `use-deploy-effects` / `use-destroy-action`. Commits `0192cf5` / `f3a760c` / `969a6c4`. Learnings `redux-toolkit-unknown-action-payload-needs-double-cast-via-unknown`, `fingerprint-multi-useEffect-by-deps-array-shape-when-bundled-in-one-hook`, `vitest-spyon-return-type-on-console-needs-loose-shape-cast-for-mock-calls-iteration`. -- ✅ **rf-pdpl-23+24** orchestrator + housekeeping. RISK #12 (`gcpNodes` alias) deferred per blueprint as a follow-up rename. - -**rf-ctrans — `card-translator.ts` (1585 → 401)** - -12-unit blueprint at [`blueprints/rf-ctrans.md`](blueprints/rf-ctrans.md). 2 utils → 5 type-maps/extractors/dispatch → 3 passes → orchestrator. 9 behavior-risks pinned including: generate_stable_name hash seed (RISK #1), map_edge_relationship default branch, REDIS_SIZE_MAP tier strings, extract_subnet_properties hash-CIDR, Pass 1.4 unconditional overwrite, Pass 1.45 subdomain priority, Pass 1.5 forwarding-rule triple-mutation + BackendEntry post-push. - -- ✅ **rf-ctrans-1** `utils/name-utils.ts`. Commit `bc4a55b`. Learning `pnpm-filter-core-test-with-path-arg-needs-root-relative-not-package-relative`. -- ✅ **rf-ctrans-2** `utils/stable-name.ts`. Commit `28dd0e5`. -- ✅ **rf-ctrans-3** `type-maps.ts`. Commit `dd33334`. Learning `brief-vs-source-default-branch-discrepancy-on-get-type-map`. -- ✅ **rf-ctrans-4** `edge-classifier.ts`. Commit `1872670`. -- ✅ **rf-ctrans-5..8** extractors — `compute` / `database` / `network` / `ancillary`. Commits `56302cc` / `62c5d93` / `80f90d1` / `a5d9f58`. -- ✅ **rf-ctrans-9** `extractors/dispatch.ts`. Commit `3b4e35e`. -- ✅ **rf-ctrans-10** `passes/pass-1-4-repo-wiring.ts`. Commit `2f2c252`. Learning `graph-nodes-keyed-by-type-colon-name-not-bare-name` (latent bug, fixed in bugfix-1). -- ✅ **rf-ctrans-11** `passes/pass-1-45-domain-propagation.ts`. Commit `a5f9228`. Learnings `if-routeid-branch-no-fallthrough`, `stash-discards-untracked-files`. -- ✅ **rf-ctrans-12** `passes/pass-1-5-endpoint-wiring.ts`. Commit `3a6b088`. Learning `test-fixture-nodeid-mapping-cascades-into-synthetic-names`. -- ✅ **rf-ctrans-13** orchestrator absorbed into housekeeping. - -**rf-cards — `cards-slice.ts` (1195 → 162)** - -16-unit blueprint at [`blueprints/rf-cards.md`](blueprints/rf-cards.md). 5 utils → 9 reducer groups → orchestrator. 11 behavior-risks: Immer two-field atomicity, two-pass position update, applyEdgeRoutes ordering, \_lastSnapshotAction module-level coalescing, cascadeContainerReflow eslint-disable, clearCardDeployOverlay 24-field completeness, ingestion-path migration parity, groupSelectedNodes Z-order, scaleLayoutForZoom intentional `scaleX/Y = 1`, JSON deep-clone, non-memoized inline selectors. - -- ✅ **rf-cards-1..3** utils — `types` / `migration` / `edge-routes`. Commits `4659ec2` / `47c0953` / `7f9173b`. Learnings `brief-import-list-may-include-transitively-referenced-types`, `jsdoc-comment-block-closes-on-asterisk-slash`, `stacked-jsdocs-precede-only-the-immediate-next-decl`. -- ✅ **rf-cards-4..5** helpers — `persistence` / `snapshot`. Commits `bec0639` / `fe79306`. Learning `reset-module-let-via-synthetic-call-not-vi-resetModules`. -- ✅ **rf-cards-6..14** reducer groups — `card-lifecycle` / `node-edge-add` / `node-position` / `node-data` / `node-delete-merge` / `import` / `auto-organize` / `scale-blueprint` / `undo-redo-group`. Commits `68bd112` / `e948336` / `3c7d72d` / `d4d7224` / `4863356` / `dcd57a8` / `4ff90d5` / `dc9295b` / `ff39d4b`+`6a9a95a`. Learnings `brief-numerics-are-approximate-source-is-canonical`, `relative-import-depth-must-be-recounted-when-moving-deeper`, `delete-vs-undefined-test-must-use-in-operator-not-strict-equality`, `vi-mock-with-mutable-result-needs-let-not-mockReturnValue`, `immer-revoked-proxy-from-spy-args-needs-deep-clone`, `hard-coded-constant-risk-pin-needs-call-with-meaningful-input`, `reducer-bails-after-prologue-side-effect-is-still-observable`. - -**rf-fbh — `firebase-hosting.ts` (1140 → 422)** - -11-unit blueprint at [`blueprints/rf-fbh.md`](blueprints/rf-fbh.md). 3 utils + 2 transport/site + 4 workflow modules + orchestrator. 14 behavior-risks: placeholder HTML verbatim with U+2713 glyph, tar parser block alignment + octal sizes, REST `validateStatus: () => true`, Firebase project 409/400 dual-meaning, GitHub fetch auth bypass, SHA256 over GZIPPED payload, 5-step version publish sequence, four DNS response shapes, project-scoped custom-domain path, three-tier domain registration fallback. - -- ✅ **rf-fbh-1..3** utils — `result-helpers` / `site-utils` / `tar-parser`. Commits `bd050e7` / `55d9a5f` / `ddeee52`. Learnings `prior-unit-may-leave-future-proofing-import-that-fails-lint-now`, `git-stash-pop-conflicts-with-tsconfig-tsbuildinfo`. -- ✅ **rf-fbh-4..5** transport+provisioning — `rest-client` / `site-provisioner`. Commits `5fb42a9` / `4687242`. Learning `vi-hoisted-and-vi-mock-blocks-must-not-split-import-groups`. -- ✅ **rf-fbh-6..9** workflow — `github-downloader` / `version-publisher` / `dns-extractor` / `domain-registrar`. Commits `2a39fe8`+`1998b83`+`12387f4` / `05854b9` / `56f9d0d`+`69deb91` / `38e3d22`. Learnings `absence-of-headers-must-be-asserted-via-init-equality-not-property-check`, `fixture-hashes-must-be-derived-from-mock-transform-output-not-guessed`, `or-chain-default-fallback-needs-its-own-test-for-100pct-branch-coverage`. -- ✅ **rf-fbh-10+11** orchestrator + housekeeping absorbed. - -**rf-parse — `parser.ts` (1061 → 184)** - -8-unit blueprint at [`blueprints/rf-parse.md`](blueprints/rf-parse.md). Approach B chosen: standalone functions taking `ParserState` interface; `Parser` class becomes constructor + delegation shell. 14 behavior-risks pinned including: ps_consume no-advance, synchronize two exits, type-identifier silent dot-skip, create_span name collision, parse_equality operator ternary, parse_postfix error-but-continue, 10-level precedence chain order, parse_primary pre-advance snapshot, **parse_for_expression key/value identity** (highest-risk), parse_reference path undefined, parse_block zero-label nested, unknown-attribute discard advances cursor, output_block emits both error AND null literal, import_statement non-`"as"` silent discard. - -- ✅ **rf-parse-1** `parser-state.ts` — `ParserState` interface + 9 ps\_\* navigation helpers. 31 tests. 147 callsites replaced. Commit `92778f7`. Learning `sed-greedy-dot-star-eats-chained-calls-on-one-line`. -- ✅ **rf-parse-2** `parser-literals.ts` — 6 helpers. 23 tests. 67 callsites. Commit `2f7a3f7`. Learning `sed-empty-arg-substitution-glues-state-to-next-token`. -- ✅ **rf-parse-3+4** `parser-binary-exprs.ts` + `parser-primary.ts` — combined atomically due to circular import resolved at function-call time. 94 tests. Commit `667df94`. Learning `bootstrap-fnarg-vs-direct-import-for-circular-grammar-pair`. -- ✅ **rf-parse-5+6** `parser-block-body.ts` + `parser-statements.ts` — 4 block parsers + 5 statement parsers. 31 tests. Commit `2ee35e5`. Learning `co-locate-mutually-recursive-helpers-to-skip-cycle-bootstrap`. -- ✅ **rf-parse-7+8** orchestrator absorbed into housekeeping. Class is dispatch shell: constructor + `parse()` + `parse_program()` + `parse_statement()`. - -**Phase 1 / 2 / 3 / Final-round detail trail.** Per-file LOC deltas, commits, and learning anchors are tracked in `state/refactor-targets.md` (the per-file scoreboard) and the individual `state/blueprints/rf-*.md` files. `git log` per series gives unit-by-unit detail. - -### 2026-04-29 — refactor agents and state scaffolds - -- ✅ **rf-0a agent + state scaffolds** — `.claude/agents/{decomposer,util-broker,test-author}.md` and `state/{refactor-targets,shared-modules}.md` written. Decisions entry appended. -- ✅ **rf-0b coverage tooling (root only)** — `@vitest/coverage-v8 4.1.5` installed at root; root `vitest.config.ts` configured with v8 provider, json-summary + html reporters; `pnpm test:coverage` script wired. CI gate deferred. -- ✅ **rf-0c registry seed** — util-broker indexed 198 entries to `state/shared-modules.md`. Three cross-package duplicates flagged: (1) iceType classifier set across `@ice/types` / `@ice/core` / `@ice/ui`; (2) `mapStatusToOverlay` (server) vs `mapWireStatusToOverlay` (UI); (3) `isContainer` (`@ice/types`) vs `is_container_type` (`@ice/core`). All three now in current In flight. diff --git a/state/refactor-targets.md b/state/refactor-targets.md deleted file mode 100644 index a90fe82a..00000000 --- a/state/refactor-targets.md +++ /dev/null @@ -1,211 +0,0 @@ -# Refactor targets - -Living document, **owned exclusively by the orchestrator**. Tracks which large files are queued for decomposition, their current state, and per-file refactor progress. Per-unit cadence stays in `progress.md`; this file is the per-file scoreboard. - -**File size range 200–500 LOC**: Per orchestrator direction (2026-04-29), source files should fit within 200–500 LOC. Over 500 needs splitting; under 200 is fine when meaningfully scoped (don't fragment for fragmentation's sake). Test files are exempt. - -## Excluded (generated or build artifacts; never refactor) - -- `packages/core/src/schemas/generated/resource-types.ts` (≈4.4M LOC, generated by `tools/build-schema-db.ts`) -- `apps/desktop/resources/prisma-client/index.d.ts` (Prisma-generated) -- `apps/desktop/release/**` (Electron build artifacts) -- Anything under `dist/`, `node_modules/`, `.schema-cache/`, `.playwright-mcp/`. - -## Queue - -| File | LOC | Coverage (line/branch) | Status | Units done | Shim dropped | -| ------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | -| ~~`services/deploy/src/services/deploy.service.ts`~~ | 2843 → **1572** | new modules at 100% / 94–100% | **DONE** (17 units, see [blueprint](blueprints/rf-deploy.md)) | 17 | n/a (re-exports kept for namespace-import surface) | -| ~~`packages/ui/src/features/properties/components/properties-panel.tsx`~~ | 3268 → **94** | new modules at 95–100% | **DONE** (25 units, see [blueprint](blueprints/rf-props.md)) | 25 (rf-props-1..24, rf-props-26; rf-props-25 absorbed into 24) | — | -| ~~`packages/ui/src/features/canvas/components/svg-canvas.tsx`~~ | 3234 → **909** | new modules at 92–100% | **DONE** (28+1 units, see [blueprint](blueprints/rf-canv.md)) | 29 | — | -| ~~`packages/ui/src/features/deploy/components/deploy-panel.tsx`~~ | 2229 → **262** | new modules at 90–100% | **DONE** (22 units, see [blueprint](blueprints/rf-pdpl.md)) | 22 (rf-pdpl-1..22; rf-pdpl-23 absorbed into rf-pdpl-24 housekeeping) | — | -| ~~`packages/core/src/deploy/card-translator.ts`~~ | 1585 → **401** | new modules at 100% | **DONE** (12 units, see [blueprint](blueprints/rf-ctrans.md)) | 12 (rf-ctrans-1..12; rf-ctrans-13 absorbed since orchestrator already in target range) | — | -| ~~`packages/core/src/resources/scale-presets.ts`~~ | 1562 → **58 (shim) + 64 (types) + 1482 (data)** | new modules at 100% on shim/types | **DONE** (rf-data-1, data-heavy split) | 1 | data file is documented size exception | -| ~~`packages/core/src/resources/cloud-blocks.ts`~~ | 1315 → **141 (shim) + 222 (types) + 1009 (data)** | new modules at 100% on shim/types | **DONE** (rf-data-2, data-heavy split) | 1 | data file is documented size exception | -| ~~`packages/ui/src/store/slices/cards-slice.ts`~~ | 1195 → **162** | new modules at 100% | **DONE** (14 units, see [blueprint](blueprints/rf-cards.md)) | 14 (rf-cards-1..14; rf-cards-15+16 absorbed into rf-cards-14 housekeeping) | — | -| ~~`packages/core/src/deploy/providers/gcp/handlers/firebase-hosting.ts`~~ | 1140 → **422** | new modules at 92–100% | **DONE** (9 units, see [blueprint](blueprints/rf-fbh.md)) | 9 (rf-fbh-1..9; rf-fbh-10+11 absorbed into housekeeping) | — | -| ~~`packages/core/src/graph/parser/parser.ts`~~ | 1061 → **184** | new modules at 100% | **DONE** (6 units, see [blueprint](blueprints/rf-parse.md)) | 6 (rf-parse-1..2 + 3+4 combined + 5+6 combined; rf-parse-7+8 absorbed into housekeeping) | — | -| ~~`packages/core/src/graph/parser/lexer.ts`~~ | 647 → **316** | new modules at 100% | **DONE** (5 units, see [blueprint](blueprints/rf-lex.md)) | 5 (rf-lex-1..5) | — | -| ~~`packages/core/src/deploy/providers/gcp/handlers/cloud-storage.ts`~~ | 856 → **267** | new modules at 100% | **DONE** (8 units, see [blueprint](blueprints/rf-cstor.md)) | 8 (rf-cstor-1..8; orchestrator file kept as sibling — no barrel needed, matching firebase-hosting precedent) | — | - -`packages/core/src/resources/high-level-resources.ts` (6434 LOC) is intentionally not in the active queue — it is data-heavy and decomposing it before its consumers are split would be wasted work. Revisit after the consuming files land. - -## Phase 2 queue — 600-1000 LOC band (audited 2026-05-01) - -After Phase 1 cleared 5 monster files (deploy-panel + card-translator + cards-slice + firebase-hosting + parser), this audit catalogues the next cohort. Workspace-wide search excluded `node_modules`, `dist`, `release`, `__tests__`, `.d.ts`, `.test.{ts,tsx}`, and the schema-cache. - -### Code-heavy (priority candidates) - -| File | LOC | Notes | -| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------- | -| `services/ai/src/services/ai.service.ts` | 994 | AI service — likely many handler methods, good decomposition target | -| `packages/core/src/state/sqlite-state-store.ts` | 946 | SQLite persistence layer — likely query/migration/CRUD seams | -| `packages/ui/src/shared/utils/auto-layout.ts` | 941 | Dagre wrapper + container resolution — multiple algorithm passes | -| `packages/ui/src/store/slices/deploy-slice.ts` | 918 | RTK slice — apply rf-cards reducer-group pattern | -| `services/deploy/src/services/pipeline.service.ts` | 880 | Pre-existing follow-up split (already noted) | -| `services/deploy/src/services/log-stream.service.ts` | 869 | Pre-existing follow-up split (already noted) | -| ~~`packages/core/src/deploy/providers/gcp/handlers/cloud-storage.ts`~~ | 856 → 267 | **DONE** (rf-cstor, 8 units) | -| `packages/core/src/deploy/scheduler.ts` | 694 | pdl-1 scheduler — likely self-contained, may not need splitting if cohesive | -| `packages/core/src/export/pulumi-exporter.ts` | 660 | Pulumi export logic | -| `packages/ui/src/features/ai/services/operation-executor.ts` | 659 | AI operation dispatcher | -| `packages/core/src/graph/mutable-graph.ts` | 657 | Graph mutation API — methods may group naturally | - -### UI components - -| File | LOC | Notes | -| -------------------------------------------------------------------- | --- | ----------------------------------------------------------------- | -| `packages/ui/src/features/palette/components/resource-palette.tsx` | 962 | Resource browser tabs; section-based decomposition | -| `packages/ui/src/features/canvas/components/svg-canvas.tsx` | 909 | Already refactored 3234→909; can push further toward target range | -| `packages/ui/src/shared/components/dev-accent-picker.tsx` | 826 | Dev tool — lower priority | -| `packages/web/src/pages/template-gallery.tsx` | 800 | Page route | -| `packages/ui/src/shared/components/provider-settings.tsx` | 784 | Settings dialog — multiple sections | -| `packages/ui/src/features/templates/components/template-gallery.tsx` | 753 | Templates UI | -| `packages/ui/src/features/pipeline/components/pipeline-panel.tsx` | 724 | Pipeline panel | -| `packages/ui/src/features/canvas/components/svg-connection-path.tsx` | 710 | Edge rendering | -| `packages/ui/src/features/palette/components/project-tree.tsx` | 707 | Tree view | -| `packages/ui/src/features/ai/components/ai-chat-panel.tsx` | 688 | Chat UI | -| `packages/ui/src/features/cost/components/cost-panel.tsx` | 678 | Cost analysis panel | -| `packages/ui/src/features/canvas/hooks/use-canvas-interactions.ts` | 666 | Big interaction hook — apply rf-canv hook-group pattern | - -### Data-shape / schema (likely deferred) - -| File | LOC | Notes | -| ---------------------------------------- | --- | ------------------------------------------------------------ | -| `packages/types/src/connection-rules.ts` | 763 | Connection-rule data — may be data-heavy like scale-presets | -| `packages/core/src/graph/parser/ast.ts` | 701 | AST type definitions — types-only files don't need splitting | - -### Other - -| File | LOC | Notes | -| ------------------------------------------------ | --- | -------------------------------------------------------- | -| `packages/ui/src/shared/api/http-api-adapter.ts` | 612 | API client — many endpoint methods, likely natural seams | - -**Recommended next cohort** (in priority order, smallest decomposition risk first): `lexer.ts` (rf-parse pattern proven), `deploy-slice.ts` (rf-cards pattern proven), `cloud-storage.ts` (rf-fbh pattern proven), then `ai.service.ts` / `sqlite-state-store.ts` / `auto-layout.ts` for novel-pattern work. - -## In flight - -_(none — `parser.ts` finished 2026-04-30; all code-heavy queue files done. Data-heavy `scale-presets.ts` (1562) and `cloud-blocks.ts` (1315) deferred — splitting fragments without code-quality gain.)_ - -## Final round (2026-05-01) — exceptions decomposed - -After Phase 3 cleared all 18 actionable files, the user requested decomposing the remaining "exceptions" too (excluding only generated files). - -### Final round results - -| File | Before | After | Series | Commits | -| ------------------------------------------------------------------------ | ------ | -------------- | ---------------------------------------------------- | ------- | -| ~~`services/deploy/src/services/deploy.service.ts`~~ | 1572 | **107 (shim)** | rf-deploy2 (split apply/destroy/destroyAll/rollback) | 8 | -| ~~`packages/core/src/resources/high-level-resources.ts`~~ | 6434 | **47 (shim)** | rf-hlres (split by 7 categories) | 10 | -| ~~`packages/ui/src/features/canvas/components/svg-canvas.tsx`~~ | 570 | **453** | rf-svgcv2 (extract canvas-content + 3 hooks) | 5 | -| ~~`services/ai/src/services/ai/system-prompt.ts`~~ | 516 | **203** | rf-spr2 (split prompt into 7 section builders) | 1 | -| ~~`packages/core/src/graph/parser/ast/types.ts`~~ | 581 | **70 (shim)** | rf-asttyp (split by AST node category) | 1 | -| ~~`packages/core/src/resources/scale-presets-data.ts`~~ | 1482 | **46 (shim)** | rf-spdat (split by 7 categories) | 1 | -| ~~`packages/core/src/resources/cloud-blocks-data.ts`~~ | 1009 | **126 (shim)** | rf-cbdat (split by 9 categories) | 1 | -| ~~`packages/ui/src/shared/components/dev-accent-picker/data/themes.ts`~~ | 590 | **30 (shim)** | rf-thmdat (split into 3 groups of 4 themes) | 1 | - -**Final round totals**: 12,754 → 1,082 LOC orchestrators (-91%), +180 tests, 28 commits. - -### Files over 500 LOC remaining - -| File | LOC | Status | -| --------------------------------------------------------------------------- | ---- | ------------------------------------------ | -| `packages/core/src/schemas/generated/resource-types.ts` | 4.4M | Generated — excluded per user | -| `packages/core/src/resources/high-level-resources/categories/database.ts` | 2187 | Data-heavy sub-module (rf-hlres exception) | -| `packages/core/src/resources/high-level-resources/categories/compute.ts` | 1799 | Data-heavy sub-module | -| `packages/core/src/resources/high-level-resources/categories/messaging.ts` | 862 | Data-heavy sub-module | -| `packages/core/src/resources/high-level-resources/categories/networking.ts` | 579 | Data-heavy sub-module | - -The remaining 4 category files are pure resource-definition data (20+ resources × ~80 LOC of properties each). Further fragmentation would split related resources into one-resource-per-file, hurting discoverability without improving maintainability. Each carries a `SIZE EXCEPTION` docstring header. - -### Phase 3 — DONE 2026-05-01 (18 files, 6 cohorts, 64 commits, +1356 tests) - -All 18 files audited at the start of Phase 3 are now within ceiling. Remaining files over 500 LOC are documented exceptions only. - -| File | LOC delta | Cohort | Commits | -| ----------------------------------------------------------------------------------------- | ------------------- | ------ | ------- | -| ~~`packages/core/src/deploy/providers/gcp/handlers/cloud-run.ts`~~ | 530 → **219** | C1 | 3 | -| ~~`packages/core/src/deploy/providers/gcp/handlers/load-balancer.ts`~~ | 527 → **263** | C1 | 3 | -| ~~`packages/ui/src/shared/components/inline-table-view.tsx`~~ | 509 → **210** | C1 | 3 | -| ~~`packages/ui/src/features/project-browser/components/project-browser.tsx`~~ | 584 → **140** | C2 | 3 | -| ~~`packages/ui/src/features/environments/components/environment-tab-bar.tsx`~~ | 554 → **268** | C2 | 3 | -| ~~`packages/ui/src/features/properties/components/sections/node-properties-section.tsx`~~ | 576 → **418** | C2 | 4 | -| ~~`packages/core/src/schema/embedded-schema-provider.ts`~~ | 591 → **284** | C3 | 4 | -| ~~`packages/core/src/schema/resource-validator.ts`~~ | 543 → **157** | C3 | 3 | -| ~~`packages/core/src/schema/customization-loader.ts`~~ | 521 → **204** | C3 | 3 | -| ~~`packages/core/src/importers/pulumi/state-importer.ts`~~ | 564 → **278** | C4 | 3 | -| ~~`packages/core/src/importers/terraform/state-importer.ts`~~ | 547 → **268** | C4 | 3 | -| ~~`packages/core/src/importers/aws/aws-importer.ts`~~ | 533 → **250** | C4 | 4 | -| ~~`packages/core/src/export/terraform-exporter.ts`~~ | 558 → **102** | C5 | 7 | -| ~~`packages/core/src/importers/pulumi/type-mapper.ts`~~ | 527 → **42 (shim)** | C5 | 4 | -| ~~`packages/core/src/graph/algorithms.ts`~~ | 586 → **51 (shim)** | C5 | 5 | -| ~~`packages/ui/src/features/canvas/hooks/use-container-move.ts`~~ | 564 → **189** | C6 | 1 | -| ~~`packages/core/src/graph/validator/validators.ts`~~ | 524 → **68 (shim)** | C6 | 1 | -| ~~`packages/core/src/errors/import-errors.ts`~~ | 507 → **30 (shim)** | C6 | 1 | - -**Phase 3 totals**: 9756 → 3441 LOC (-65%), +1356 tests, 64 commits. - -Three pre-existing bugs surfaced during Phase 3 (preserved verbatim per refactor discipline): - -- `customization-loader.ts::get_base_db_path` — eager `require.resolve` evaluation throws when `@ice-engine/schemas` not installed (rf-cload-3). -- `algorithms.ts::get_critical_path` — distance never propagates beyond start node due to topo-order/incoming-edge mismatch (rf-galg-4). -- `pipeline.service.ts::detectJsFramework` (Phase 2) — pnpm-lock detection always falls through to npm because lockfiles aren't added to detectedFiles. Already noted in rf-pipe-6. - -Files in the 200–500 range continue to fit the ceiling and don't need follow-up splits. - -## Done - -### `properties-panel.tsx` — 25 units, 2026-04-29 - -3268 → **94 LOC** (−3174, −97%). The orchestrator is now a thin compose-and-route shell that dispatches to one of three section subcomponents (`ProjectOverview` / `EdgePropertiesSection` / `NodePropertiesSection`) plus the panel header. Five leaf utils, one fields bundle, four hooks, and 13 section subcomponents extracted; one final cross-file dedup unit pointed `parseCostRange` / `formatCost` consumers at the canonical home. - -New modules: - -- `utils/queue-spec.ts` (rf-props-1) -- `utils/normalize-subdomain.ts` (rf-props-2) -- `utils/edge-warnings.ts` (rf-props-3) -- `utils/format-age.ts` (rf-props-4) -- `utils/deploy-history-format.ts` (rf-props-5) -- `components/fields/index.tsx` + `components/fields/render-property-field.tsx` (rf-props-6, rf-props-9) -- `hooks/use-resource-map.ts` + `hooks/use-drift-check.ts` (rf-props-7, rf-props-8) -- `sections/drift.tsx` (rf-props-10) -- `sections/group-color-picker.tsx` (rf-props-11) -- `sections/connection-card.tsx` (rf-props-12) -- `sections/env-vars-editor.tsx` (rf-props-13) -- `sections/scaling-section.tsx` + `sections/domain-section.tsx` (rf-props-14) -- `sections/custom-domain-panel.tsx` (rf-props-15; behavior-risk dual-render) -- `sections/private-network-panel.tsx` (rf-props-16) -- `sections/repo-deploy-list.tsx` (rf-props-17) -- `sections/service-source-section.tsx` (rf-props-18) -- `sections/deploy-history.tsx` (rf-props-19) -- `sections/pipeline-section.tsx` (rf-props-20; behavior-risk dynamic-import paths) -- `sections/source-repository-section.tsx` (rf-props-21) -- `sections/edge-properties-section.tsx` (rf-props-22) -- `sections/project-overview.tsx` (rf-props-23) -- `sections/node-properties-section.tsx` (rf-props-24; tab-router shell + body, keeps setState-during-render fallback intact) -- rf-props-26: `parseCostRange` / `formatCost` cross-file dedup to canonical `features/cost/utils/cost-calculator.ts` (behavior-change — `'Free'` / commas / decimals now parsed correctly; `formatCost(0) → 'Free'` insulated by `totalCost > 0` gate at every callsite). 19 new tests at the canonical home + 3 behavior-delta tests at the consumer; 1464 unit tests passing across the monorepo. - -Commits: `5629b1b`, `88d383f`, `d356718`, `47a91f8`, `093bda5`, `112b8d9`, `28bacc2`, `206ca4d`, `12507f5`, `ca54041`, `b11e275`, `dbc7313`, `efa8340`, `7748b32`, `b1fd1e0`, `4648712`, `29c4731`, `6645227`, `9efc48b`, `3d781f5`, `6cc2dae`, `ed2193c`, `00caaa2`, `6dfd890`, plus the rf-props-26 commit. rf-props-25 (orchestrator slim-down) was absorbed into rf-props-24 because the orchestrator was already minimal after the section extractions landed. Coverage on new modules: 95–100% statement / 85–100% branch. - -### `deploy.service.ts` — 17 units, 2026-04-29 - -2843 → 1572 LOC (−1271, −45%). 16 new modules extracted across `services/deploy/src/utils/` (5) and `services/deploy/src/services/` (11): - -- `utils/deploy-event-formatter.ts` (rf-deploy-1) -- `utils/deploy-outcome.ts` (rf-deploy-2) -- `utils/find-source-node-id.ts` (rf-deploy-3) -- `utils/project-context.ts` (rf-deploy-4) -- `services/deployer-factory.ts` (rf-deploy-5; deduped 4 callsites) -- `services/gcp-api-enabler.ts` (rf-deploy-6) -- `services/snapshot-persister.ts` (rf-deploy-7) -- `services/deploy-lock-wrapper.ts` (rf-deploy-8; deduped 3 callsites) -- `services/deploy-event-dispatcher.ts` (rf-deploy-9; foundation for downstream units) -- `services/baseline-graph.ts` (rf-deploy-10; preserved divergent statusFilter sets) -- `services/destroy-targets.ts` (rf-deploy-11) -- `services/scheduler-callbacks.ts` (rf-deploy-12; primary + retry shapes via options) -- `services/destroy-runner.ts` (rf-deploy-13; per-item helpers, loops kept separate by design) -- `services/quota-retry.ts` (rf-deploy-14) -- `services/canvas-overlay.ts` (rf-deploy-15) -- `services/drift.service.ts` (rf-deploy-16) -- rf-deploy-17 cleanup: 7 re-exports dropped, 2 imports trimmed, drift test migrated to canonical home. - -Commits: `7669b9c`, `2d8ded8`, `c10b0df`, `59afd32`, `dd95e8f`, `70674e4`, `6556ae4`, `e52ebbf`, `d92630e`, `ecd31c1`, `7cf988d`, `a4f287b`, `dbc7313`, `1fa9198`, `9b37da3`, `982d3b2`, `1e43f7e`. Tests in `@ice/service-deploy`: ~85 → **392 passing + 7 skipped** (~+310 new). Coverage on new modules: 100% statement / 94–100% branch. diff --git a/state/shared-modules.md b/state/shared-modules.md deleted file mode 100644 index 055729de..00000000 --- a/state/shared-modules.md +++ /dev/null @@ -1,1641 +0,0 @@ -# Shared modules - -**Owner**: util-broker. **Append-only** — never edit past entries. - -This is a registry of every exported util, hook, or helper across the workspace, used to short-circuit duplication during refactoring. Each entry: kebab-case `##` anchor, `_Indexed: YYYY-MM-DD by util-broker_` line, signature, path, one-line purpose. - -The util-broker rescans before each refactor unit and appends any new entries. Cross-package duplicates discovered during a scan are also recorded so the planner can schedule dedup units against them. - ---- - -## cn - -_Indexed: 2026-04-29 by util-broker_ - -`cn(...inputs: ClassValue[]): string` -Path: `packages/ui/src/shared/utils/cn.ts` -Purpose: clsx + tailwind-merge wrapper for conditional Tailwind class names. - -## to-slug - -_Indexed: 2026-04-29 by util-broker_ - -`toSlug(name: string): string` -Path: `packages/ui/src/shared/utils/slug.ts` -Purpose: Lowercase + non-alphanumeric → `-` slug; falls back to `'org'` on empty. - -## log-canvas-render - -_Indexed: 2026-04-29 by util-broker_ - -`logCanvasRender(data: { nodeCount; edgeCount; visibleCount; viewLevel: number }): void` -Path: `packages/ui/src/shared/utils/debug-logger.ts` -Purpose: Gated `console.debug` for canvas render cycles (toggled by `localStorage['ice-debug']`). - -## log-blueprint - -_Indexed: 2026-04-29 by util-broker_ - -`logBlueprint(data: { type; provider?; childCount; containerWidth; containerHeight }): void` -Path: `packages/ui/src/shared/utils/debug-logger.ts` -Purpose: Gated debug log for blueprint expansion events. - -## log-drop - -_Indexed: 2026-04-29 by util-broker_ - -`logDrop(data: { position; targetContainer?; nodeType }): void` -Path: `packages/ui/src/shared/utils/debug-logger.ts` -Purpose: Gated debug log for palette drop events. - -## log-api-call - -_Indexed: 2026-04-29 by util-broker_ - -`logApiCall(method: string, path: string, body?: unknown): void` -Path: `packages/ui/src/shared/utils/action-logger.ts` -Purpose: Pushes structured API request event to `window.__ICE_ACTION_LOG__` (E2E test buffer). - -## log-api-response - -_Indexed: 2026-04-29 by util-broker_ - -`logApiResponse(method, path: string, status: number, data: unknown, duration_ms: number): void` -Path: `packages/ui/src/shared/utils/action-logger.ts` -Purpose: Pushes structured API response event to action-log buffer; auto-classifies 4xx+ as `api_error`. - -## log-state-change - -_Indexed: 2026-04-29 by util-broker_ - -`logStateChange(actionType: string, payload?: unknown): void` -Path: `packages/ui/src/shared/utils/action-logger.ts` -Purpose: Records Redux dispatch as structured action-log event. - -## auto-layout - -_Indexed: 2026-04-29 by util-broker_ - -`autoLayout(nodes: LayoutNode[], edges: LayoutEdge[], options?: LayoutOptions): LayoutResult` -Path: `packages/ui/src/shared/utils/auto-layout.ts` -Purpose: Dagre-based hierarchical tree layout with circular fallback; recursively sizes nested containers. - -## calculate-z-index - -_Indexed: 2026-04-29 by util-broker_ - -`calculateZIndex(iceType: string, depth?: number): number` -Path: `packages/ui/src/shared/utils/auto-layout.ts` -Purpose: Resolves SVG paint-order z-index from iceType + nesting depth (VPC=0, Subnet=10, Group=15, container=20, leaf=100). - -## force-resolve-overlaps - -_Indexed: 2026-04-29 by util-broker_ - -`forceResolveOverlaps<T extends ForceBody>(allNodes: T[], gap?: number, ticks?: number, strength?: number): void` -Path: `packages/ui/src/shared/utils/auto-layout.ts` -Purpose: Velocity-damped sim that pushes overlapping top-level nodes apart; mutates x/y in place. - -## is-api-not-enabled-error - -_Indexed: 2026-04-29 by util-broker_ - -`isApiNotEnabledError(error: string): boolean` -Path: `packages/ui/src/shared/utils/gcp-errors.ts` -Purpose: Pattern-matches GCP error strings for the "API not enabled / disabled" case. - -## extract-api-name - -_Indexed: 2026-04-29 by util-broker_ - -`extractApiName(errorOrUrl: string): string | null` -Path: `packages/ui/src/shared/utils/gcp-errors.ts` -Purpose: Pulls the GCP API service name (e.g. `compute.googleapis.com`) out of an error or enable URL. - -## extract-api-enable-url - -_Indexed: 2026-04-29 by util-broker_ - -`extractApiEnableUrl(error: string): string | null` -Path: `packages/ui/src/shared/utils/gcp-errors.ts` -Purpose: Returns the Cloud Console enable URL embedded in (or constructable from) a GCP error. - -## build-api-enable-url - -_Indexed: 2026-04-29 by util-broker_ - -`buildApiEnableUrl(apiName: string, project?: string): string` -Path: `packages/ui/src/shared/utils/gcp-errors.ts` -Purpose: Constructs `https://console.cloud.google.com/apis/api/<api>/overview` (optionally with `?project=`). - -## inspect-layout - -_Indexed: 2026-04-29 by util-broker_ - -`inspectLayout(state: InspectState, opts?: InspectOptions): InspectResult` -Path: `packages/ui/src/shared/utils/layout-inspector.ts` -Purpose: Browser-console layout inspector — dumps node positions, gaps, overlaps, and container fit. - -## update-inspector-state - -_Indexed: 2026-04-29 by util-broker_ - -`updateInspectorState(state: InspectState): void` -Path: `packages/ui/src/shared/utils/layout-inspector.ts` -Purpose: Refreshes the cached state used by `window.__iceInspect()`; called on every canvas render. - -## install-inspector - -_Indexed: 2026-04-29 by util-broker_ - -`installInspector(): void` -Path: `packages/ui/src/shared/utils/layout-inspector.ts` -Purpose: Binds `window.__iceInspect` / `__iceInspectVerbose` for manual canvas inspection. - -## use-system-stats - -_Indexed: 2026-04-29 by util-broker_ - -`useSystemStats(intervalMs?: number): { ram: number; cpu: number } | null` -Path: `packages/ui/src/shared/hooks/use-system-stats.ts` -Purpose: Polls `/system/stats` on an interval; returns RAM/CPU or null. - -## compute-candidate-fingerprint - -_Indexed: 2026-04-29 by util-broker_ - -`computeCandidateFingerprint(edges, nodes, terminalNodeId: string): string` -Path: `packages/ui/src/shared/hooks/use-log-stream.ts` -Purpose: Stable string projection of inbound log-source candidates; used as effect dep so subscribe re-runs on deploy_status flip. - -## use-log-stream - -_Indexed: 2026-04-29 by util-broker_ - -`useLogStream(terminalNodeId: string): { status; entries; source; lastError }` -Path: `packages/ui/src/shared/hooks/use-log-stream.ts` -Purpose: Owns the full subscribe → join → listen → unsubscribe lifecycle for a Cloud Logging terminal node. - -## use-gcp-oauth - -_Indexed: 2026-04-29 by util-broker_ - -`useGCPOAuth(onSuccess: () => void): { connecting; error; connect }` -Path: `packages/ui/src/shared/hooks/use-gcp-oauth.ts` -Purpose: Google Identity Services authorization-code flow → POST code to backend → onSuccess callback. - -## use-resolve-path - -_Indexed: 2026-04-29 by util-broker_ - -`useResolvePath(allSegments: string[]): ResolvedPath` -Path: `packages/ui/src/shared/hooks/use-resolve-path.ts` -Purpose: Resolves URL path segments → folder/project IDs + breadcrumbs for both community and platform editions. - -## use-clipboard - -_Indexed: 2026-04-29 by util-broker_ - -`useClipboard(): void` -Path: `packages/ui/src/shared/hooks/use-clipboard.ts` -Purpose: Wires Ctrl+C/X/V/G keyboard shortcuts to canvas node copy/cut/paste/group via Redux. - -## use-reduced-motion - -_Indexed: 2026-04-29 by util-broker_ - -`useReducedMotion(): boolean` -Path: `packages/ui/src/shared/hooks/use-reduced-motion.ts` -Purpose: Tracks `prefers-reduced-motion` media query with live updates. - -## use-exposed-services - -_Indexed: 2026-04-29 by util-broker_ - -`useExposedServices(visibleNodes, edges, allNodes?): { nodeIds: string[]; userIconPosition }` -Path: `packages/ui/src/shared/hooks/use-exposed-services.ts` -Purpose: Detects true public entry-point nodes (WAF/LB/CDN/Gateway sources) for the canvas user-icon overlay. - -## use-menu-actions - -_Indexed: 2026-04-29 by util-broker_ - -`useMenuActions(): void` -Path: `packages/ui/src/shared/hooks/use-menu-actions.ts` -Purpose: Subscribes to Electron menu events and dispatches matching Redux actions (new/open/save/undo/redo/zoom/etc). - -## theme-provider - -_Indexed: 2026-04-29 by util-broker_ - -`<ThemeProvider>` + `useTheme(): ThemeContextValue` -Path: `packages/ui/src/shared/hooks/use-theme.tsx` -Purpose: Light/dark mode + font-size context with localStorage persistence and `prefers-color-scheme` follow. - -## use-undo-redo - -_Indexed: 2026-04-29 by util-broker_ - -`useUndoRedo(): void` -Path: `packages/ui/src/shared/hooks/use-undo-redo.ts` -Purpose: Wires Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y to `undoCardChange` / `redoCardChange`. - -## encrypt-credentials - -_Indexed: 2026-04-29 by util-broker_ - -`encryptCredentials(data: Record<string, string>): string` / `decryptCredentials(encrypted: string): Record<string, string>` -Path: `packages/shared/src/crypto/index.ts` -Purpose: AES-256-GCM authenticated encryption for provider credential blobs (Node `crypto`). - -## encrypt-string - -_Indexed: 2026-04-29 by util-broker_ - -`encryptString(value: string): string` / `decryptString(encrypted: string): string` -Path: `packages/shared/src/crypto/index.ts` -Purpose: Single-string variant of credential encryption — used for GitHub tokens. - -## require-auth - -_Indexed: 2026-04-29 by util-broker_ - -`requireAuth(req: AuthRequest, res: Response, next: NextFunction): Response | void` -Path: `packages/shared/src/auth/middleware.ts` -Purpose: Express middleware — JWT validation, with desktop-mode bypass when a local user is set. - -## require-project-access - -_Indexed: 2026-04-29 by util-broker_ - -`requireProjectAccess(minRole: 'viewer' | 'editor' | 'owner'): RequestHandler` -Path: `packages/shared/src/auth/middleware.ts` -Purpose: Express middleware factory — checks org-admin OR project-member role ≥ `minRole` for `projectId`/`cardId`. - -## require-org-role - -_Indexed: 2026-04-29 by util-broker_ - -`requireOrgRole(...allowedRoles: string[]): RequestHandler` -Path: `packages/shared/src/auth/middleware.ts` -Purpose: Express middleware factory — checks org-membership role for org-scoped routes. - -## generate-token - -_Indexed: 2026-04-29 by util-broker_ - -`generateToken(userId, organisationId: string): string` / `generateRefreshToken(...): string` -Path: `packages/shared/src/auth/middleware.ts` -Purpose: Sign 1h access JWT or 30d refresh JWT (with `jti`). - -## set-desktop-user - -_Indexed: 2026-04-29 by util-broker_ - -`setDesktopUser(userId, orgId: string): void` / `isDesktopMode(): { userId; orgId } | null` -Path: `packages/shared/src/auth/middleware.ts` -Purpose: Toggle/inspect community-edition auth bypass (auto-seeded local user). - -## setup-socket-service - -_Indexed: 2026-04-29 by util-broker_ - -`setupSocketService(io: SocketServer): void` / `getSocketServer(): SocketServer | null` -Path: `packages/shared/src/socket/service.ts` -Purpose: Boots Socket.IO with JWT/desktop-mode auth and registers room subscribe/unsubscribe handlers. - -## emit-deploy-node-status - -_Indexed: 2026-04-29 by util-broker_ - -`emitDeployNodeStatus(cardId: string, event: DeployNodeStatusEvent): void` -Path: `packages/shared/src/socket/service.ts` -Purpose: Type-narrowed emitter for one deploy event variant; pushes to `deploy:<cardId>` room over `DEPLOY_EVENT_CHANNEL`. - -## emit-deploy-node-progress - -_Indexed: 2026-04-29 by util-broker_ - -`emitDeployNodeProgress(cardId: string, event: DeployNodeProgressEvent): void` -Path: `packages/shared/src/socket/service.ts` -Purpose: Type-narrowed emitter for per-node progress percentages. - -## emit-deploy-complete - -_Indexed: 2026-04-29 by util-broker_ - -`emitDeployComplete(cardId: string, event: DeployCompleteEvent): void` -Path: `packages/shared/src/socket/service.ts` -Purpose: Type-narrowed emitter for end-of-deploy summary. - -## emit-deploy-log - -_Indexed: 2026-04-29 by util-broker_ - -`emitDeployLog(cardId: string, event: DeployLogEvent): void` -Path: `packages/shared/src/socket/service.ts` -Purpose: Type-narrowed emitter for streaming deploy logs. - -## emit-deploy-requirement-verified - -_Indexed: 2026-04-29 by util-broker_ - -`emitDeployRequirementVerified(cardId: string, event: DeployRequirementVerifiedEvent): void` -Path: `packages/shared/src/socket/service.ts` -Purpose: Type-narrowed emitter for requirement-resolved events (DNS/SSL/etc). - -## emit-canvas-update - -_Indexed: 2026-04-29 by util-broker_ - -`emitCanvasUpdate(projectId: string, event: any): void` -Path: `packages/shared/src/socket/service.ts` -Purpose: Broadcasts canvas mutation to `canvas:<projectId>` collaboration room. - -## emit-pipeline-update - -_Indexed: 2026-04-29 by util-broker_ - -`emitPipelineUpdate(nodeId: string, event: PipelineStatusUpdate): void` -Path: `packages/shared/src/socket/service.ts` -Purpose: Pushes per-node pipeline status (full logs) to viewers of one node's pipeline panel. - -## emit-card-pipeline-update - -_Indexed: 2026-04-29 by util-broker_ - -`emitCardPipelineUpdate(cardId: string, event: CardPipelineUpdate): void` -Path: `packages/shared/src/socket/service.ts` -Purpose: Lightweight per-card pipeline status (no logs) for canvas badge updates. - -## is-database - -_Indexed: 2026-04-29 by util-broker_ - -`isDatabase(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True if iceType matches `Database.*` or known DB engines (Postgres/MySQL/etc). - -## is-cache - -_Indexed: 2026-04-29 by util-broker_ - -`isCache(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True if iceType references Redis/Cache/Memcache. - -## is-queue - -_Indexed: 2026-04-29 by util-broker_ - -`isQueue(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True if iceType is `Messaging.*` or known queue/event service. - -## is-storage - -_Indexed: 2026-04-29 by util-broker_ - -`isStorage(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True if iceType is `Storage.*` or known object-store service. - -## is-backend - -_Indexed: 2026-04-29 by util-broker_ - -`isBackend(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True if iceType is `Compute.*` or matches Backend/Container/Worker/Function. - -## is-frontend - -_Indexed: 2026-04-29 by util-broker_ - -`isFrontend(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for StaticSite/SSRSite/Frontend iceTypes. - -## is-gateway - -_Indexed: 2026-04-29 by util-broker_ - -`isGateway(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for Gateway/LoadBalancer/CDN entry-point types. - -## is-auth - -_Indexed: 2026-04-29 by util-broker_ - -`isAuth(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for Auth/IAM/Identity iceTypes. - -## is-secrets - -_Indexed: 2026-04-29 by util-broker_ - -`isSecrets(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for Secret/Vault iceTypes. - -## is-monitoring - -_Indexed: 2026-04-29 by util-broker_ - -`isMonitoring(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for Log/Monitor/Observability iceTypes. - -## is-search - -_Indexed: 2026-04-29 by util-broker_ - -`isSearch(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for Search/Elasticsearch/OpenSearch iceTypes. - -## is-data-warehouse - -_Indexed: 2026-04-29 by util-broker_ - -`isDataWarehouse(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for Warehouse/BigQuery/Redshift/Snowflake iceTypes. - -## is-vector-db - -_Indexed: 2026-04-29 by util-broker_ - -`isVectorDb(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for VectorDB/Pinecone/Weaviate iceTypes. - -## is-llm - -_Indexed: 2026-04-29 by util-broker_ - -`isLLM(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for LLM/AIGateway/AIModel iceTypes. - -## is-repo - -_Indexed: 2026-04-29 by util-broker_ - -`isRepo(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for Source.Repository / GitHub source-of-code iceTypes. - -## is-env-config - -_Indexed: 2026-04-29 by util-broker_ - -`isEnvConfig(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for Config.Env / EnvConfig iceTypes. - -## is-domain - -_Indexed: 2026-04-29 by util-broker_ - -`isDomain(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for CustomDomain/DNS iceTypes. - -## is-custom-domain - -_Indexed: 2026-04-29 by util-broker_ - -`isCustomDomain(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True specifically for `Network.CustomDomain`. - -## is-private-network - -_Indexed: 2026-04-29 by util-broker_ - -`isPrivateNetwork(t: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True for `Network.PrivateNetwork` (high-level VPC). - -## is-container - -_Indexed: 2026-04-29 by util-broker_ - -`isContainer(iceType: string, nodeType?: string): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True if iceType is a layout container (VPC/Subnet/Group/PrivateNetwork) — used by canvas + auto-layout. - -## is-inside-container - -_Indexed: 2026-04-29 by util-broker_ - -`isInsideContainer(nodeId: string, allNodes: NodeForConnectionCheck[]): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: Walks parentId chain to determine if a node lives inside any container ancestor. - -## get-default-port - -_Indexed: 2026-04-29 by util-broker_ - -`getDefaultPort(iceType: string): number | undefined` -Path: `packages/types/src/connection-rules.ts` -Purpose: Default TCP port for a given iceType (5432 for Postgres, 6379 for Redis, etc). - -## get-env-var-name - -_Indexed: 2026-04-29 by util-broker_ - -`getEnvVarName(iceType: string): string | undefined` -Path: `packages/types/src/connection-rules.ts` -Purpose: Conventional env-var name a service exports for downstream consumers (`DATABASE_URL`, `REDIS_URL`, etc). - -## can-connect - -_Indexed: 2026-04-29 by util-broker_ - -`canConnect(srcIceType, tgtIceType: string, srcNodeId?, tgtNodeId?, allNodes?): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True if `CONNECTION_RULES` allows the source→target pair (with optional containment context). - -## find-connection-rule - -_Indexed: 2026-04-29 by util-broker_ - -`findConnectionRule(srcIceType, tgtIceType: string): ConnectionRule | null` -Path: `packages/types/src/connection-rules.ts` -Purpose: Returns the single matching `CONNECTION_RULES` entry (or null) for an src→tgt pair. - -## get-valid-target-ids - -_Indexed: 2026-04-29 by util-broker_ - -`getValidTargetIds(srcId: string, allNodes, allEdges): string[]` -Path: `packages/types/src/connection-rules.ts` -Purpose: Returns IDs of every node a `srcId` could legally connect to right now. - -## infer-connection-meta - -_Indexed: 2026-04-29 by util-broker_ - -`inferConnectionMeta(src, tgt: string): ConnectionMeta` -Path: `packages/types/src/connection-rules.ts` -Purpose: Resolves category/color/lineStyle/port/envVar for an edge between two iceTypes. - -## validate-connection - -_Indexed: 2026-04-29 by util-broker_ - -`validateConnection(src, tgt: string, ...): ConnectionWarning[]` -Path: `packages/types/src/connection-rules.ts` -Purpose: Returns the list of warnings/errors for a proposed edge. - -## would-create-cycle - -_Indexed: 2026-04-29 by util-broker_ - -`wouldCreateCycle(src, tgt: string, allEdges): boolean` -Path: `packages/types/src/connection-rules.ts` -Purpose: True if adding src→tgt would close a cycle in the existing edge graph. - -## generate-ai-connection-prompt - -_Indexed: 2026-04-29 by util-broker_ - -`generateAiConnectionPrompt(): string` -Path: `packages/types/src/connection-rules.ts` -Purpose: Renders `CONNECTION_RULES` as a system-prompt fragment for the AI assistant. - -## is-node-status-event - -_Indexed: 2026-04-29 by util-broker_ - -`isNodeStatusEvent(e: DeployEvent): e is DeployNodeStatusEvent` -Path: `packages/types/src/deploy-events.ts` -Purpose: Discriminated-union narrowing for `node_status` deploy events. - -## is-node-progress-event - -_Indexed: 2026-04-29 by util-broker_ - -`isNodeProgressEvent(e: DeployEvent): e is DeployNodeProgressEvent` -Path: `packages/types/src/deploy-events.ts` -Purpose: Discriminated-union narrowing for `node_progress` deploy events. - -## is-deploy-log-event - -_Indexed: 2026-04-29 by util-broker_ - -`isDeployLogEvent(e: DeployEvent): e is DeployLogEvent` -Path: `packages/types/src/deploy-events.ts` -Purpose: Discriminated-union narrowing for streaming `log` deploy events. - -## is-deploy-complete-event - -_Indexed: 2026-04-29 by util-broker_ - -`isDeployCompleteEvent(e: DeployEvent): e is DeployCompleteEvent` -Path: `packages/types/src/deploy-events.ts` -Purpose: Discriminated-union narrowing for `complete` deploy events. - -## is-requirement-verified-event - -_Indexed: 2026-04-29 by util-broker_ - -`isRequirementVerifiedEvent(e: DeployEvent): e is DeployRequirementVerifiedEvent` -Path: `packages/types/src/deploy-events.ts` -Purpose: Discriminated-union narrowing for `requirement_verified` events. - -## is-terminal-node-status - -_Indexed: 2026-04-29 by util-broker_ - -`isTerminalNodeStatus(s: DeployNodeStatus): boolean` -Path: `packages/types/src/deploy-events.ts` -Purpose: True if a node-status is end-of-life (succeeded/failed/skipped/cancelled). - -## success - -_Indexed: 2026-04-29 by util-broker_ - -`success<T>(value: T): Success<T>` / `failure<E>(error: E): Failure<E>` -Path: `packages/core/src/types/result.ts` -Purpose: Constructors for the `Result<T, E>` discriminated union. - -## is-success - -_Indexed: 2026-04-29 by util-broker_ - -`is_success<T, E>(result: Result<T, E>): result is Success<T>` / `is_failure(...): result is Failure<E>` -Path: `packages/core/src/types/result.ts` -Purpose: Type guards for the `Result` union. - -## unwrap - -_Indexed: 2026-04-29 by util-broker_ - -`unwrap<T, E extends Error>(result): T` / `unwrap_or(result, default)` / `unwrap_or_else(result, fn)` / `unwrap_error(result): E` -Path: `packages/core/src/types/result.ts` -Purpose: Extract value (throw on failure) or apply default/error mapping. - -## map-result - -_Indexed: 2026-04-29 by util-broker_ - -`map<T, U, E>(result, fn): Result<U, E>` / `map_error(result, fn)` / `flat_map(result, fn)` / `or_else(result, fn)` -Path: `packages/core/src/types/result.ts` -Purpose: Functor/monad combinators over `Result`. - -## all-results - -_Indexed: 2026-04-29 by util-broker_ - -`all<T, E>(results: Result<T, E>[]): Result<T[], E>` / `any(results): Result<T, E>` / `partition(results): { successes; failures }` -Path: `packages/core/src/types/result.ts` -Purpose: Aggregate combinators for arrays of `Result`. - -## from-try - -_Indexed: 2026-04-29 by util-broker_ - -`from_try<T, E>(fn: () => T, error_mapper?): Result<T, E>` / `from_nullable(value, error): Result<T, E>` -Path: `packages/core/src/types/result.ts` -Purpose: Convert thrown exceptions or nullable values into `Result`. - -## ice-error-classes - -_Indexed: 2026-04-29 by util-broker_ - -`class IceError extends Error` + subclasses: `ValidationError`, `GraphError`, `NodeNotFoundError`, `CycleDetectedError`, `ProviderError`, `AuthenticationError`, `RateLimitError`, `DeploymentError`, `SecurityError`, `InternalError`, `NotImplementedError` -Path: `packages/core/src/types/errors.ts` -Purpose: Tagged-error hierarchy for the engine — every public function returns one of these via `Result`. - -## is-ice-error - -_Indexed: 2026-04-29 by util-broker_ - -`is_ice_error(error: unknown): error is IceError` -Path: `packages/core/src/types/errors.ts` -Purpose: Type guard for engine-tagged errors. - -## is-retryable - -_Indexed: 2026-04-29 by util-broker_ - -`is_retryable(error: unknown): boolean` -Path: `packages/core/src/types/errors.ts` -Purpose: True for transient errors (rate-limit, network, retryable provider faults). - -## wrap-error - -_Indexed: 2026-04-29 by util-broker_ - -`wrap_error(error: unknown, message?: string): IceError` -Path: `packages/core/src/types/errors.ts` -Purpose: Promotes any thrown value into an `IceError`, preserving the original cause. - -## create-node-id - -_Indexed: 2026-04-29 by util-broker_ - -`create_node_id(id: string): NodeId` / `create_edge_id(id): EdgeId` / `create_graph_id(id): GraphId` -Path: `packages/core/src/types/graph.ts` -Purpose: Brand constructors for graph identifier types. - -## create-provider-id - -_Indexed: 2026-04-29 by util-broker_ - -`create_provider_id(provider: ProviderId): string` -Path: `packages/core/src/types/providers.ts` -Purpose: Brand constructor for provider IDs. - -## create-deployment-id - -_Indexed: 2026-04-29 by util-broker_ - -`create_deployment_id(id: string): DeploymentId` -Path: `packages/core/src/types/deployment.ts` -Purpose: Brand constructor for deployment IDs. - -## topological-sort - -_Indexed: 2026-04-29 by util-broker_ - -`topological_sort(graph: MutableGraph): TopologicalSortResult` / `reverse_topological_sort(...)` -Path: `packages/core/src/graph/algorithms.ts` -Purpose: Kahn's-algorithm sort with cycle detection; reverse for teardown order. - -## has-cycle - -_Indexed: 2026-04-29 by util-broker_ - -`has_cycle(graph: MutableGraph): boolean` / `find_cycles(graph): NodeId[][]` -Path: `packages/core/src/graph/algorithms.ts` -Purpose: Cycle existence + enumeration over a graph. - -## find-paths - -_Indexed: 2026-04-29 by util-broker_ - -`find_all_paths(graph, start, end, max_paths?): NodeId[][]` / `find_shortest_path(graph, start, end): NodeId[] | null` -Path: `packages/core/src/graph/algorithms.ts` -Purpose: Path-finding between two nodes (DFS enumerate / BFS shortest). - -## find-connected-components - -_Indexed: 2026-04-29 by util-broker_ - -`find_connected_components(graph): NodeId[][]` / `find_strongly_connected_components(graph): NodeId[][]` -Path: `packages/core/src/graph/algorithms.ts` -Purpose: Connected / strongly-connected component partitioning. - -## get-execution-layers - -_Indexed: 2026-04-29 by util-broker_ - -`get_execution_layers(graph): NodeId[][]` / `get_critical_path(graph): NodeId[]` -Path: `packages/core/src/graph/algorithms.ts` -Purpose: Parallel-execution layering and longest-path critical chain for deploy scheduling. - -## calculate-graph-metrics - -_Indexed: 2026-04-29 by util-broker_ - -`calculate_metrics(graph: MutableGraph): GraphMetrics` -Path: `packages/core/src/graph/algorithms.ts` -Purpose: Aggregates node/edge counts, depth, breadth, density. - -## mutable-graph - -_Indexed: 2026-04-29 by util-broker_ - -`class MutableGraph implements Graph` + `create_mutable_graph(...)` -Path: `packages/core/src/graph/mutable-graph.ts` -Purpose: In-memory mutable graph implementation; the engine's primary graph data type. - -## tokenize - -_Indexed: 2026-04-29 by util-broker_ - -`tokenize(source: string, options?: Partial<LexerOptions>): LexerResult` + `class Lexer` -Path: `packages/core/src/graph/parser/lexer.ts` -Purpose: Lexer for the ICE graph DSL. - -## parse - -_Indexed: 2026-04-29 by util-broker_ - -`parse(tokens: Token[], options?: Partial<ParserOptions>): ParserResult` + `class Parser` -Path: `packages/core/src/graph/parser/parser.ts` -Purpose: Parser → AST for the ICE graph DSL. - -## parse-json - -_Indexed: 2026-04-29 by util-broker_ - -`parse_json(input: string, file?: string): FormatParserResult` -Path: `packages/core/src/graph/parser/format-parser.ts` -Purpose: JSON-format graph parser; produces a `MutableGraph`. - -## classify-resource - -_Indexed: 2026-04-29 by util-broker_ - -`classify_resource(resourceType: string): NodeCategory` -Path: `packages/core/src/graph/classifier/category-classifier.ts` -Purpose: Maps a resource type to a NodeCategory (compute/data/network/etc). - -## is-category-visible-at-level - -_Indexed: 2026-04-29 by util-broker_ - -`is_category_visible_at_level(category: NodeCategory, level: 1 | 2 | 3): boolean` / `is_resource_visible_at_level(...)` -Path: `packages/core/src/graph/classifier/category-classifier.ts` -Purpose: Level-of-detail visibility per category — drives canvas zoom-tier hiding. - -## is-container-type - -_Indexed: 2026-04-29 by util-broker_ - -`is_container_type(resourceType: string): boolean` -Path: `packages/core/src/graph/classifier/category-classifier.ts` -Purpose: True for resource types that act as layout containers (alternate of `@ice/types/isContainer`). - -## get-types-by-category - -_Indexed: 2026-04-29 by util-broker_ - -`get_types_by_category(category: NodeCategory): string[]` -Path: `packages/core/src/graph/classifier/category-classifier.ts` -Purpose: Reverse lookup — all resource types in a category. - -## relationship-inferrer - -_Indexed: 2026-04-29 by util-broker_ - -`class RelationshipInferrer` + `create_relationship_inferrer(...)` + `infer_relationships(...)` -Path: `packages/core/src/graph/inference/relationship-inferrer.ts` -Purpose: Inspects nodes and proposes implicit `connects_to`/`depends_on` edges. - -## graph-validators - -_Indexed: 2026-04-29 by util-broker_ - -Built-in `Validator` classes: `CycleValidator`, `ReferenceValidator`, `NamingValidator`, `ConnectivityValidator`, `TypeValidator`, `PropertyValidator`, `SensitiveDataValidator`, `BestPracticesValidator` -Path: `packages/core/src/graph/validator/validators.ts` -Purpose: Pluggable validators run by `GraphValidator.validate()`. - -## create-builtin-validators - -_Indexed: 2026-04-29 by util-broker_ - -`create_builtin_validators(schema_provider?): Validator[]` -Path: `packages/core/src/graph/validator/validators.ts` -Purpose: Default validator stack for typical graph validation. - -## graph-validator - -_Indexed: 2026-04-29 by util-broker_ - -`class GraphValidator` + `class ValidationContext` + `create_graph_validator()` + `create_validator(...)` -Path: `packages/core/src/graph/validator/base-validator.ts` -Purpose: Orchestrator that runs `Validator[]` against a graph. - -## diff-properties - -_Indexed: 2026-04-29 by util-broker_ - -`diff_properties(desired, current): PropertyChange[]` / `deep_equal(a, b): boolean` -Path: `packages/core/src/plan/diff.ts` -Purpose: Property-level diff between desired vs current resource state. - -## is-destructive-change - -_Indexed: 2026-04-29 by util-broker_ - -`is_destructive_change(resource_type: string, changes: PropertyChange[]): boolean` -Path: `packages/core/src/plan/diff.ts` -Purpose: True if a change set requires replace-not-update (e.g. region change). - -## summarize-changes - -_Indexed: 2026-04-29 by util-broker_ - -`summarize_changes(changes: PropertyChange[]): string` / `format_property_change(change): string` -Path: `packages/core/src/plan/diff.ts` -Purpose: Human-readable diff summaries for the Plan view. - -## diff-graphs - -_Indexed: 2026-04-29 by util-broker_ - -`diff_graphs(desired: Graph, current: Graph, provider: string, options?: DiffOptions): DiffResult` -Path: `packages/core/src/diff/diff.ts` -Purpose: Whole-graph diff (added/removed/changed nodes + edges) for plan generation. - -## format-plan - -_Indexed: 2026-04-29 by util-broker_ - -`format_plan(result: DiffResult): string` -Path: `packages/core/src/diff/diff.ts` -Purpose: Renders a plan diff as a human-readable summary. - -## create-plan - -_Indexed: 2026-04-29 by util-broker_ - -`create_plan(...): DeploymentPlan` -Path: `packages/core/src/plan/plan-engine.ts` -Purpose: Build a deployment plan from desired/current graphs + provider. - -## plan-has-changes - -_Indexed: 2026-04-29 by util-broker_ - -`plan_has_changes(plan): boolean` / `plan_has_destructive_changes(plan): boolean` -Path: `packages/core/src/plan/plan-engine.ts` -Purpose: Quick predicates over a `DeploymentPlan`. - -## get-changes-by-action - -_Indexed: 2026-04-29 by util-broker_ - -`get_changes_by_action(plan, action): PlannedChange[]` / `get_plan_execution_layers(plan): PlannedChange[][]` -Path: `packages/core/src/plan/plan-engine.ts` -Purpose: Filter / layer plan changes for parallel deploy. - -## serialize-plan - -_Indexed: 2026-04-29 by util-broker_ - -`serialize_plan(plan): string` / `deserialize_plan(json): DeploymentPlan` -Path: `packages/core/src/plan/plan-engine.ts` -Purpose: JSON round-trip for persisted plans. - -## apply-succeeded - -_Indexed: 2026-04-29 by util-broker_ - -`apply_succeeded(result: ApplyResult): boolean` / `get_failed_resources(result)` / `get_successful_resources(result)` -Path: `packages/core/src/apply/apply-engine.ts` -Purpose: Predicates and partitioners over an `ApplyResult`. - -## compute-derived - -_Indexed: 2026-04-29 by util-broker_ - -`computeDerived(nodes: PropagationNode[], edges: PropagationEdge[]): PatchSet` -Path: `packages/core/src/compute/compute-derived.ts` -Purpose: Reactive property propagation — derives node patches from `PROPAGATION_RULES` + `AGGREGATE_RULES`. - -## diff-patches - -_Indexed: 2026-04-29 by util-broker_ - -`diffPatches(currentNodes, currentEdges, patchSet: PatchSet): PatchSet` -Path: `packages/core/src/compute/compute-derived.ts` -Purpose: Filters a desired-patch-set down to only patches that materially change current values. - -## validate-canvas - -_Indexed: 2026-04-29 by util-broker_ - -`validateCanvas(nodes, edges, ctx?): CanvasIssue[]` / `validateNode(node, ctx?): CanvasIssue[]` -Path: `packages/core/src/validation/canvas-validator.ts` -Purpose: Top-level canvas validation — runs property/structure/architecture/deploy rules. - -## validate-template - -_Indexed: 2026-04-29 by util-broker_ - -`validateTemplate(template: TemplateInput): CanvasIssue[]` -Path: `packages/core/src/validation/template-validator.ts` -Purpose: Validates a template/blueprint definition. - -## validate-connections - -_Indexed: 2026-04-29 by util-broker_ - -`validateConnections(...): CanvasIssue[]` -Path: `packages/core/src/validation/connection-rules.ts` -Purpose: Edge validation against `CONNECTION_RULES`. - -## validate-properties - -_Indexed: 2026-04-29 by util-broker_ - -`validateProperties(nodes, ctx): CanvasIssue[]` -Path: `packages/core/src/validation/property-rules.ts` -Purpose: Per-node property validation (required fields, enum membership, etc). - -## validate-structure - -_Indexed: 2026-04-29 by util-broker_ - -`validateStructure(nodes, edges): CanvasIssue[]` -Path: `packages/core/src/validation/structure-rules.ts` -Purpose: Graph-shape validation (orphans, dangling refs, cycles). - -## validate-architecture - -_Indexed: 2026-04-29 by util-broker_ - -`validateArchitecture(...): CanvasIssue[]` -Path: `packages/core/src/validation/architecture-rules.ts` -Purpose: Pattern-level checks (e.g. backend missing DB, public service missing WAF). - -## validate-deployability - -_Indexed: 2026-04-29 by util-broker_ - -`validateDeployability(...): CanvasIssue[]` -Path: `packages/core/src/validation/deploy-rules.ts` -Purpose: Pre-deploy invariant checks (creds present, regions consistent). - -## get-resource-for-ice-type - -_Indexed: 2026-04-29 by util-broker_ - -`getResourceForIceType(iceType: string): HighLevelResource | undefined` / `getPropertiesForIceType(iceType): HighLevelProperty[]` / `getSupportedProviders(iceType): string[]` / `isKnownIceType(iceType): boolean` -Path: `packages/core/src/validation/schema-bridge.ts` -Purpose: Lookups from the high-level resource catalog used in validators. - -## classify-gcp-error - -_Indexed: 2026-04-29 by util-broker_ - -`classifyGCPError(...)` / `classifyAWSError(...)` / `classifyAzureError(...)` -Path: `packages/core/src/errors/import-errors.ts` -Purpose: Tags raw provider import errors into normalized categories. - -## sqlite-state-store - -_Indexed: 2026-04-29 by util-broker_ - -`class SqliteStateStore` + `create_sqlite_state_store(options?)` + `create_memory_state_store()` -Path: `packages/core/src/state/sqlite-state-store.ts` -Purpose: Default deploy-state persistence backend (file-backed or in-memory for tests). - -## embedded-schema-provider - -_Indexed: 2026-04-29 by util-broker_ - -`class EmbeddedSchemaProvider` + `create_embedded_schema_provider(db_path?)` + `create_embedded_schema_provider_with_registry(...)` -Path: `packages/core/src/schema/embedded-schema-provider.ts` -Purpose: Reads the bundled SQLite schema DB; the engine's default `GraphSchemaProvider`. - -## unified-type-resolver - -_Indexed: 2026-04-29 by util-broker_ - -`class UnifiedTypeResolver` + `get_type_resolver()` + `create_type_resolver(schema_provider?)` -Path: `packages/core/src/schema/unified-type-resolver.ts` -Purpose: Resolves raw resource types → unified iceType across providers. - -## type-mapper - -_Indexed: 2026-04-29 by util-broker_ - -`class TypeMapper` + `create_type_mapper(schema_provider)` -Path: `packages/core/src/schema/type-mapper.ts` -Purpose: Bidirectional mapping between iceTypes and provider-specific resource types. - -## resource-validator - -_Indexed: 2026-04-29 by util-broker_ - -`class ResourceValidator` + `create_resource_validator(schema_provider)` -Path: `packages/core/src/schema/resource-validator.ts` -Purpose: Validates a resource's properties against its schema definition. - -## customization-loader - -_Indexed: 2026-04-29 by util-broker_ - -`class CustomizationLoader` + `create_customization_loader(project_root?)` + `get_base_db_path(): string` -Path: `packages/core/src/schema/customization-loader.ts` -Purpose: Loads project-local schema customizations layered on top of the bundled DB. - -## create-ice-type - -_Indexed: 2026-04-29 by util-broker_ - -`create_ice_type(type: string): IceType` -Path: `packages/core/src/schema/schema-provider.ts` -Purpose: Brand constructor for `IceType` strings. - -## get-cloud-provider - -_Indexed: 2026-04-29 by util-broker_ - -`getCloudProvider(id: string): CloudProviderMeta | undefined` / `getAllCloudProviders(): CloudProviderMeta[]` / `getCloudProviderColor(id)` / `getCloudProviderShortName(id)` -Path: `packages/core/src/resources/cloud-providers.ts` -Purpose: Provider catalog lookup (color, short name, metadata). - -## get-block-template - -_Indexed: 2026-04-29 by util-broker_ - -`getBlockTemplate(name: string): BlockTemplate | undefined` / `createBlockFromTemplate(...)` -Path: `packages/core/src/resources/cloud-blocks.ts` -Purpose: Lookup and instantiation of canonical block templates. - -## get-block-type-tag - -_Indexed: 2026-04-29 by util-broker_ - -`getBlockTypeTag(type: BlockType): { label: string; color: string }` -Path: `packages/core/src/resources/cloud-blocks.ts` -Purpose: UI tag (label + color) for a block type. - -## get-provider-icon - -_Indexed: 2026-04-29 by util-broker_ - -`getProviderIcon(provider: CloudProvider): string` -Path: `packages/core/src/resources/cloud-blocks.ts` -Purpose: Returns the icon-asset path for a provider. - -## format-uptime - -_Indexed: 2026-04-29 by util-broker_ - -`formatUptime(deployedAt?: string): string` -Path: `packages/core/src/resources/cloud-blocks.ts` -Purpose: Renders an ISO timestamp as a relative "5m ago / 3h ago" uptime string. - -## get-scale-preset - -_Indexed: 2026-04-29 by util-broker_ - -`getScalePreset(resourceId: string, tier: ScaleTier, provider: string): Record<string, unknown>` / `getAllPresetsForResource(resourceId)` -Path: `packages/core/src/resources/scale-presets.ts` -Purpose: Returns provider-specific defaults for a resource at a given scale tier. - -## get-all-high-level-resources - -_Indexed: 2026-04-29 by util-broker_ - -`getAllHighLevelResources(): HighLevelResource[]` / `getHighLevelResourcesForPalette()` / `filterResourcesByProvider(provider): HighLevelResource[]` -Path: `packages/core/src/resources/high-level-resources.ts` -Purpose: Lookups against the high-level resource catalog (used by palette + validators). - -## get-behavior-label - -_Indexed: 2026-04-29 by util-broker_ - -`getBehaviorLabel(behavior: NodeBehavior): string` / `getBehaviorColor(behavior): string` -Path: `packages/core/src/resources/high-level-resources.ts` -Purpose: UX rendering helpers for `NodeBehavior` enum. - -## get-gcp-cloud-asset-types - -_Indexed: 2026-04-29 by util-broker_ - -`getGCPCloudAssetTypes(): string[]` / `cloudAssetToHighLevelType(cloudAssetType: string): string | null` -Path: `packages/core/src/resources/high-level-resources.ts` -Purpose: GCP Cloud Asset Inventory ↔ ICE high-level type translation for the importer. - -## create-blueprint-from-resource - -_Indexed: 2026-04-29 by util-broker_ - -`createBlueprintFromResource(resourceId: string, overrides: BlueprintOverrides): GeneratedBlueprint` -Path: `packages/core/src/resources/blueprint-factory.ts` -Purpose: Spins up a deployable blueprint from a resource id + overrides. - -## enrich-graph-with-state - -_Indexed: 2026-04-29 by util-broker_ - -`enrich_graph_with_state(graph: Graph, state: Map<string, StoredResourceEntry>): Map<string, string>` -Path: `packages/core/src/deploy/state-bridge.ts` -Purpose: Merges persisted state into a graph; returns a node→provider-id map. - -## wrap-on-progress-for-node-progress - -_Indexed: 2026-04-29 by util-broker_ - -`wrap_on_progress_for_node_progress(...)` -Path: `packages/core/src/deploy/scheduler.ts` -Purpose: Adapts a generic `on_progress` callback into the per-node progress event shape. - -## create-deploy-state-adapter - -_Indexed: 2026-04-29 by util-broker_ - -`create_deploy_state_adapter(store: SqliteStateStore, graph_id: string): DeployStateStore` -Path: `packages/core/src/deploy/state-store-adapter.ts` -Purpose: Adapts the generic `SqliteStateStore` into the `DeployStateStore` interface scoped to one graph. - -## translate-card-to-graph - -_Indexed: 2026-04-29 by util-broker_ - -`translate_card_to_graph(input: CardTranslationInput): CardTranslationResult` -Path: `packages/core/src/deploy/card-translator.ts` -Purpose: Converts a Redux canvas card (`nodes`/`edges`) into an engine-ready `Graph`. - -## apply-environment-overrides - -_Indexed: 2026-04-29 by util-broker_ - -`apply_environment_overrides(graph, env): Graph` / `get_environment_label(env: EnvironmentType): string` / `get_cost_multiplier(env): number` -Path: `packages/core/src/deploy/environment-config.ts` -Purpose: Applies env-specific replicas/scale overrides; UX helpers for env type. - -## expand-blueprint - -_Indexed: 2026-04-29 by util-broker_ - -`expandBlueprint(blueprint: BlockBlueprint, options: ExpandBlueprintOptions): ExpandedBlueprint` -Path: `packages/blocks/src/expand-blueprint.ts` -Purpose: Materializes a block blueprint (multi-resource composite) into nodes + edges with concrete defaults. - -## expand-composed-template - -_Indexed: 2026-04-29 by util-broker_ - -`expandComposedTemplate(...)` -Path: `packages/templates/src/expand-template.ts` -Purpose: Expands a multi-block project template into a fully-wired canvas card. - -## parse-cost-range - -_Indexed: 2026-04-29 by util-broker_ - -`parseCostRange(cost: string): number` -Path: `packages/ui/src/features/cost/utils/cost-calculator.ts` -Purpose: Parses a `"$5/mo"` or `"$5–$10/mo"` cost string into a numeric midpoint. - -## format-cost - -_Indexed: 2026-04-29 by util-broker_ - -`formatCost(value: number): string` / `formatCostRaw(value: number): string` -Path: `packages/ui/src/features/cost/utils/cost-calculator.ts` -Purpose: Currency formatting for cost panels (with/without `/mo` suffix). - -## get-node-cost-info - -_Indexed: 2026-04-29 by util-broker_ - -`getNodeCostInfo(node, resourceMap, scaleTier): NodeCostInfo` -Path: `packages/ui/src/features/cost/utils/cost-calculator.ts` -Purpose: Resolves a node's monthly cost + scaling range from the resource catalog. - -## compute-cost-summary - -_Indexed: 2026-04-29 by util-broker_ - -`computeCostSummary(nodes: CardNode[], resourceMap: ResourceMap, scaleTier: ScaleTier): CostSummary` -Path: `packages/ui/src/features/cost/utils/cost-calculator.ts` -Purpose: Aggregates per-category totals + scaling envelope across all canvas nodes. - -## estimate-data-transfer-cost - -_Indexed: 2026-04-29 by util-broker_ - -`estimateDataTransferCost(provider: string, trafficTierIndex: number): DataTransferEstimate` -Path: `packages/ui/src/features/cost/utils/provider-pricing.ts` -Purpose: Estimates monthly egress cost for a provider at a given traffic tier. - -## compare-provider-costs - -_Indexed: 2026-04-29 by util-broker_ - -`compareProviderCosts(...): ProviderCostComparison[]` -Path: `packages/ui/src/features/cost/utils/provider-pricing.ts` -Purpose: Cross-provider cost comparison for the canvas. - -## count-traffic-connections - -_Indexed: 2026-04-29 by util-broker_ - -`countTrafficConnections(nodes: CardNode[], edges: CardEdge[]): Map<string, number>` -Path: `packages/ui/src/features/cost/utils/provider-pricing.ts` -Purpose: Counts traffic-category edges entering each node. - -## use-cost-calculation - -_Indexed: 2026-04-29 by util-broker_ - -`useCostCalculation(trafficTierIndex: number): CostCalculationResult` -Path: `packages/ui/src/features/cost/hooks/use-cost-calculation.ts` -Purpose: Loads resource definitions once + re-derives cost summary from the active card. - -## generate-ghost-suggestions - -_Indexed: 2026-04-29 by util-broker_ - -`generateGhostSuggestions(droppedNode: CardNode, existingNodes, existingEdges): GhostNode[]` -Path: `packages/ui/src/features/canvas/utils/ghost-suggestions.ts` -Purpose: Returns up to 3 ghost suggestions to render after a palette drop. - -## analyze-canvas-patterns - -_Indexed: 2026-04-29 by util-broker_ - -`analyzeCanvasPatterns(nodes, edges): CanvasSuggestion[]` -Path: `packages/ui/src/features/canvas/utils/connection-rules.ts` -Purpose: Heuristic missing-connection hints (e.g. backend without DB) for the canvas overlay. - -## use-canvas-utils - -_Indexed: 2026-04-29 by util-broker_ - -`useCanvasUtils(svgRef: RefObject<SVGSVGElement>, viewState: ViewState): { screenToCanvas; canvasToScreen; isPointInElement; distance }` -Path: `packages/ui/src/features/canvas/hooks/use-canvas-utils.ts` -Purpose: Screen↔world coordinate conversion + bounds and distance helpers. - -## use-canvas-mouse-events - -_Indexed: 2026-04-29 by util-broker_ - -`useCanvasMouseEvents(props: UseCanvasMouseEventsProps): { ... }` -Path: `packages/ui/src/features/canvas/hooks/use-canvas-mouse-events.ts` -Purpose: Mouse handlers for canvas pan/zoom/drag/resize/select/connect. - -## use-canvas-validation - -_Indexed: 2026-04-29 by util-broker_ - -`useCanvasValidation(): void` -Path: `packages/ui/src/features/canvas/hooks/use-canvas-validation.ts` -Purpose: Debounced canvas validation runner that dispatches results into Redux. - -## use-computing-flows - -_Indexed: 2026-04-29 by util-broker_ - -`useComputingFlows(): void` -Path: `packages/ui/src/features/canvas/hooks/use-computing-flows.ts` -Purpose: Reactive property propagation (`computeDerived` → diff → dispatch) for the active card. - -## use-canvas-interactions - -_Indexed: 2026-04-29 by util-broker_ - -`useCanvasInteractions({ ... }): { ... }` -Path: `packages/ui/src/features/canvas/hooks/use-canvas-interactions.ts` -Purpose: Higher-level canvas interaction state machine (mode: pan/drag/resize/box-select). - -## suggest-patterns - -_Indexed: 2026-04-29 by util-broker_ - -`suggestPatterns(nodes: CanvasNode[], _edges: CanvasEdge[]): PatternSuggestion[]` -Path: `packages/ui/src/features/ai/utils/suggest-patterns.ts` -Purpose: Returns 3 contextual architecture suggestions for the AI chat empty state. - -## serialize-canvas - -_Indexed: 2026-04-29 by util-broker_ - -`serializeCanvas(state: RootState): SerializedCanvas` -Path: `packages/ui/src/features/ai/utils/serialize-canvas.ts` -Purpose: Compact JSON projection of the active card for AI context (strips pixel-level detail). - -## use-ai-command - -_Indexed: 2026-04-29 by util-broker_ - -`useAiCommand(): { sendIntent; isProcessing; ... }` -Path: `packages/ui/src/features/ai/hooks/use-ai-command.ts` -Purpose: Streams AI ops via SSE and applies them to the canvas. - -## analyze-pre-deploy - -_Indexed: 2026-04-29 by util-broker_ - -`analyzePreDeploy(nodes: CardNode[], edges: CardEdge[]): PreDeployAnalysis` -Path: `packages/ui/src/features/deploy/utils/predeploy-analysis.ts` -Purpose: Returns the security warnings snapshot rendered between Plan and Apply. - -## analyze-security-warnings - -_Indexed: 2026-04-29 by util-broker_ - -`analyzeSecurityWarnings(nodes: CardNode[], edges: CardEdge[]): PreDeployWarning[]` -Path: `packages/ui/src/features/deploy/utils/security-rules.ts` -Purpose: Deterministic security-rule scanner (public DB, no WAF, missing secrets, etc). - -## map-wire-status-to-overlay - -_Indexed: 2026-04-29 by util-broker_ - -`mapWireStatusToOverlay(status: DeployNodeStatus): string` -Path: `packages/ui/src/features/deploy/hooks/use-deploy-subscription.ts` -Purpose: Translates wire-format deploy status → canvas overlay status string. - -## apply-deploy-event - -_Indexed: 2026-04-29 by util-broker_ - -`applyDeployEvent(dispatch, event: DeployEvent, cardId: string): void` -Path: `packages/ui/src/features/deploy/hooks/use-deploy-subscription.ts` -Purpose: Routes a typed `DeployEvent` to the right slice reducer + canvas-data overlay. - -## use-deploy-subscription - -_Indexed: 2026-04-29 by util-broker_ - -`useDeploySubscription(cardId: string | undefined): void` -Path: `packages/ui/src/features/deploy/hooks/use-deploy-subscription.ts` -Purpose: App-level hook that subscribes to deploy socket room and hydrates Redux for the active card. - -## use-wizard-state - -_Indexed: 2026-04-29 by util-broker_ - -`useWizardState(): { state; validation; setStep; setProjectName; ... }` -Path: `packages/ui/src/features/wizard/hooks/use-wizard-state.ts` -Purpose: Local state machine for the new-project wizard (step navigation + validation). - -## resolve-log-filter - -_Indexed: 2026-04-29 by util-broker_ - -`resolveLogFilter(ctx: SourceContext): ResolvedFilter | null` -Path: `services/deploy/src/services/log-stream/filter-resolver.ts` -Purpose: Resolves a log-source node into a Cloud Logging filter expression. - -## gcp-credential-resolver - -_Indexed: 2026-04-29 by util-broker_ - -`gcpCredentialResolver: CredentialResolver` -Path: `services/deploy/src/providers/gcp/credential-resolver.ts` -Purpose: GCP-specific resolver that fetches encrypted creds from DB and returns a usable client. - -## aws-credential-resolver - -_Indexed: 2026-04-29 by util-broker_ - -`awsCredentialResolver: CredentialResolver` -Path: `services/deploy/src/providers/aws/credential-resolver.ts` -Purpose: AWS-specific resolver — symmetric to the GCP one. - -## acquire-deploy-lock - -_Indexed: 2026-04-29 by util-broker_ - -`acquireDeployLock(...): boolean` / `cancelDeploy(cardId): boolean` / `isDeployInFlight(cardId, op): boolean` -Path: `services/deploy/src/services/deploy-locks.ts` -Purpose: Per-card deploy lock manager — prevents concurrent operations on the same card. - -## register-temp-dir - -_Indexed: 2026-04-29 by util-broker_ - -`registerTempDir(dir): void` / `releaseTempDir(dir?): void` / `cleanupAllTempDirs(): void` -Path: `services/deploy/src/services/deploy-locks.ts` -Purpose: Tracks temp build dirs so we can clean them on deploy cancel/exit. - -## start-deploy-snapshot - -_Indexed: 2026-04-29 by util-broker_ - -`startDeploySnapshot(cardId, deploymentId?)` / `updateDeploySnapshotNode(...)` / `finishDeploySnapshot(cardId, status)` / `getDeploySnapshot(cardId)` / `clearDeploySnapshot(cardId)` -Path: `services/deploy/src/services/deploy-locks.ts` -Purpose: In-memory deploy progress snapshots — read by the `/canvas/deploy/current` route to hydrate clients mid-deploy. - -## set-snapshot-persister - -_Indexed: 2026-04-29 by util-broker_ - -`setSnapshotPersister(fn: SnapshotPersister | null): void` -Path: `services/deploy/src/services/deploy-locks.ts` -Purpose: Injects an optional persistence fn so snapshots survive process restarts. - -## next-deploy-seq - -_Indexed: 2026-04-29 by util-broker_ - -`nextDeploySeq(cardId: string): number | null` / `recordDeployEvent(cardId, seq, type, payload)` / `forgetDeploymentSeq(deploymentId)` -Path: `services/deploy/src/services/deploy-event-log.ts` -Purpose: Monotonic deploy event sequence counter + JSONL append-only log per deploy. - -## map-status-to-overlay - -_Indexed: 2026-04-29 by util-broker_ - -`mapStatusToOverlay(status: DeployNodeStatus): string` -Path: `services/deploy/src/services/deploy.service.ts` -Purpose: Server-side wire status → overlay string (mirror of UI `mapWireStatusToOverlay`). - -## compute-complete-totals - -_Indexed: 2026-04-29 by util-broker_ - -`computeCompleteTotals(resources: any[] | undefined): DeployCompleteEvent['totals']` / `deriveCompleteOutcome(...)` -Path: `services/deploy/src/services/deploy.service.ts` -Purpose: Builds the totals + outcome fields on a `DeployCompleteEvent`. - -## request-deploy-cancel - -_Indexed: 2026-04-29 by util-broker_ - -`requestDeployCancel(cardId: string): boolean` / `getCurrentDeploySnapshot(cardId): DeployProgressSnapshot | undefined` -Path: `services/deploy/src/services/deploy.service.ts` -Purpose: Public service-layer entry points for cancel + snapshot read. - -## start-cron-jobs - -_Indexed: 2026-04-29 by util-broker_ - -`startCronJobs(): void` -Path: `services/deploy/src/services/cron.service.ts` -Purpose: Boots scheduled jobs (deploy retries, snapshot cleanup, etc). - -## get-deploy-queue - -_Indexed: 2026-04-29 by util-broker_ - -`getDeployQueue(): any` / `startDeployWorker(): void` -Path: `services/deploy/src/services/queue.service.ts` -Purpose: BullMQ deploy queue accessor + worker bootstrap. - -## get-active-subscriptions - -_Indexed: 2026-04-29 by util-broker_ - -`getActiveSubscriptions(): ReadonlyMap<string, SubscribeArgs>` -Path: `services/deploy/src/services/log-stream.service.ts` -Purpose: Returns the active Cloud Logging subscriptions (used by reconnection / health checks). - -## start-requirement-poller - -_Indexed: 2026-04-29 by util-broker_ - -`startRequirementPoller(): void` / `stopRequirementPoller(): void` -Path: `services/deploy/src/services/requirement-poller.service.ts` -Purpose: Boots/stops the post-deploy requirement-verification poller (DNS, SSL, etc). - -## cleanup-build - -_Indexed: 2026-04-29 by util-broker_ - -`cleanupBuild(buildDir: string): void` -Path: `services/deploy/src/services/build.service.ts` -Purpose: Removes a build's temp directory after a deploy finishes. - -## create-audit-entry - -_Indexed: 2026-04-29 by util-broker_ - -`createAuditEntry(intent: string, canvas: any): AuditEntry` / `finalizeAuditEntry(...)` / `writeAuditEntry(entry: AuditEntry): void` -Path: `services/ai/src/services/ai-audit.service.ts` -Purpose: Records AI-generated canvas operations to a JSONL audit log for replay/analysis. - ---- - -## Cross-package duplicates flagged - -### iceType classifier set (HIGH IMPACT) - -The classifier suite (`isDatabase`, `isCache`, `isQueue`, `isStorage`, `isBackend`, `isFrontend`, `isGateway`, `isAuth`, `isSecrets`, `isMonitoring`, `isSearch`, `isVectorDb`, `isLLM`, `isRepo`, `isEnvConfig`, `isDomain`, `isContainer`) exists in two places with **deliberately-duplicated** implementations: - -- `packages/types/src/connection-rules.ts` — canonical -- `packages/core/src/validation/classifiers.ts` — local copy because `@ice/core` uses `NodeNext` resolution while `@ice/types` uses `bundler`, so re-exports don't cross cleanly (per the file's own header). - -Plus a partial third copy in `packages/ui/src/features/deploy/utils/security-rules.ts` (private functions: `isDatabase`, `isStorage`, `isGateway`, `isService`, `isAuth`, `isSecret`, `isMonitoring`, `isVpc`, `isSubnet`, `isPrivateNetwork`, `isVpcLike`). - -**Risk**: silent divergence — adding a new iceType to one copy (e.g. a new database engine) doesn't propagate. The header comment claims tests will catch divergence, but only the connection-validation tests do; cost calculator, security rules, and validation rules each fan out their own dependencies. - -**Suggested dedup**: pick one canonical home (`@ice/types`), fix the moduleResolution mismatch (or expose via a small `@ice/classifiers` package that both resolutions accept), and drop the copies. - -### `validateConnection` (canvas) vs `validateConnections` (engine) - -- `packages/ui/src/features/canvas/utils/connection-rules.ts` re-exports `validateConnection` from `@ice/types` (single connection check). -- `packages/core/src/validation/connection-rules.ts` exports `validateConnections` (full canvas pass). - -Different signatures, different intent — **not a true duplicate**, but the naming is collision-prone (one is a singular check, one is a plural pass). Worth aliasing one of them on import for clarity. - -### `mapStatusToOverlay` vs `mapWireStatusToOverlay` - -- `services/deploy/src/services/deploy.service.ts` — `mapStatusToOverlay` -- `packages/ui/src/features/deploy/hooks/use-deploy-subscription.ts` — `mapWireStatusToOverlay` - -Same function, mirrored across the wire boundary. Canonical home should be `@ice/types/deploy-events.ts` next to `DeployNodeStatus`. Both implementations are tiny and identical in intent; one drift point. - -### `isContainer` (canvas containment) vs `is_container_type` (engine) - -- `packages/types/src/connection-rules.ts` — `isContainer(iceType, nodeType?)` -- `packages/core/src/graph/classifier/category-classifier.ts` — `is_container_type(resourceType)` - -Same predicate, different naming convention (camel vs snake). Both check whether a node acts as a layout container. **High confusion risk** for new contributors. - -### `parseCostRange` parity check - -`parseCostRange` lives only in `packages/ui/src/features/cost/utils/cost-calculator.ts`, but the engine ingests cost strings independently in `services/deploy/src/services/requirements.service.ts` (untracked here — out of scope for this scan). **Worth re-checking** if a future scan picks up cost parsing on the server side. - -### Top 3 most concerning by impact - -1. **iceType classifier set** — cross-cut: catalog, palette, validation, security rules, cost. Two-and-a-bit copies; tests catch one of three drift modes. -2. **`mapStatusToOverlay` / `mapWireStatusToOverlay`** — wire-protocol mirror. Two implementations on opposite sides of the socket; if the wire enum ever gains a state, both must change in lockstep. -3. **`isContainer` / `is_container_type`** — naming collision across `@ice/types` and `@ice/core`; same intent, different style. New contributors reach for whichever they grep first. From 11361ae3a8c0ae667cc1d14a20c5fa202f1d355b Mon Sep 17 00:00:00 2001 From: Julia Kafarska <julia.kafarska@gmail.com> Date: Mon, 25 May 2026 13:22:15 +0200 Subject: [PATCH 51/52] docs(readme): link AWS rollout state + connections-to-cloud page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - root README: sharpen "AWS — in progress" to mention the handler / extractor count and link the staged-rollout table in the AWS README. - docs/README: add connections-to-cloud to the architecture mermaid and to the contributors table. --- README.md | 2 +- docs/README.md | 18 ++++++++++-------- package.json | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a4721776..0ef875c8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Full guide: [docs/getting-started.md](docs/getting-started.md). ## Providers at a glance - 🟢 **Google Cloud - stable(ish).** 20 service handlers, 45+ importers, full create / update / destroy. -- 🟡 **AWS - in progress.** +- 🟡 **AWS - in progress.** 17 service handlers + 20 extractors; staged rollout via feature flags — see [`packages/core/src/deploy/providers/aws/README.md`](packages/core/src/deploy/providers/aws/README.md) for the per-category state. - 🟡 **Azure - in progress.** - ⚪ **IBM Cloud - planned.** - ⚪ **Kubernetes - planned.** diff --git a/docs/README.md b/docs/README.md index afcbe613..bb96a512 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ flowchart LR Arch -.-> A4[database] Arch -.-> A5[desktop] Arch -.-> A6[ai-assistant] + Arch -.-> A7[connections-to-cloud] Ref -.-> Rf1[blocks] Ref -.-> Rf2[extending-providers] @@ -56,14 +57,15 @@ You want to run ICE for a team (self-hosted). You want to read the code, fix bugs, or add features. The canonical contributor doc is [`../CONTRIBUTING.md`](../CONTRIBUTING.md) — pages below complement it. -| Page | What it covers | -| ------------------------------------------------------------------- | ------------------------------------------------- | -| [Testing](testing.md) | Unit · integration · E2E · GCP scenario dashboard | -| [Architecture → core engine](architecture/core-engine.md) | Graph, schemas, plan/apply, scheduler, importers | -| [Architecture → frontend](architecture/frontend.md) | React, Redux slices, SVG canvas, feature folders | -| [Architecture → services](architecture/services.md) | The six backend services composed by the gateway | -| [Reference → blocks](reference/blocks.md) | Concept palette + per-provider variants | -| [Reference → extending providers](reference/extending-providers.md) | How to add a new cloud provider | +| Page | What it covers | +| --------------------------------------------------------------------------- | ----------------------------------------------------------- | +| [Testing](testing.md) | Unit · integration · E2E · GCP scenario dashboard | +| [Architecture → core engine](architecture/core-engine.md) | Graph, schemas, plan/apply, scheduler, importers | +| [Architecture → frontend](architecture/frontend.md) | React, Redux slices, SVG canvas, feature folders | +| [Architecture → services](architecture/services.md) | The six backend services composed by the gateway | +| [Architecture → connections to cloud](architecture/connections-to-cloud.md) | How a canvas edge becomes env vars, IAM, and network policy | +| [Reference → blocks](reference/blocks.md) | Concept palette + per-provider variants | +| [Reference → extending providers](reference/extending-providers.md) | How to add a new cloud provider | ## How these docs are maintained diff --git a/package.json b/package.json index 3b07d7c6..9b4f400d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.773", + "version": "0.1.774", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", From 1638f09451335986c25f524241d49aae94aba7cd Mon Sep 17 00:00:00 2001 From: Julia Kafarska <julia.kafarska@gmail.com> Date: Mon, 25 May 2026 13:37:45 +0200 Subject: [PATCH 52/52] docs(aws): reflect handler buildout + staged rollout - provider-status: AWS matrix entry now lists the 17 handlers + 20 extractors and the per-category feature-flag state instead of the stale "EC2 / S3 / Lambda only" copy. - deploying-to-aws: drop the dead reference to "provider-status.md - to be added" (the doc exists now), fix the broken handlers-source link (handlers live at packages/core/src/deploy/providers/aws/, not packages/providers/aws/), fix the broken architecture.md anchor. - Add a quirks section pointing operators at the AWS README for S3 account-id suffix, CloudFront us-east-1 cert pin, ECS auto-cluster, RDS password gate + provisioning poll, Lambda auto-build, FIFO suffix. Update the "known gaps" list to match the deferred items in the AWS README (VPC blocks, CodeBuild, update paths, LocalStack). --- docs/deploying-to-aws.md | 33 +++++++++++++++++++++++++-------- docs/provider-status.md | 28 +++++++++++++++------------- package.json | 2 +- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/docs/deploying-to-aws.md b/docs/deploying-to-aws.md index 545214ae..a1f6b2aa 100644 --- a/docs/deploying-to-aws.md +++ b/docs/deploying-to-aws.md @@ -25,18 +25,35 @@ Same flow as [deploying-to-gcp.md](deploying-to-gcp.md) - drag blocks, connect t ## What works today -The block categories listed in the provider matrix (`docs/provider-status.md` - to be added) are the source of truth. As of this release, the AWS handler set covers compute, storage, basic networking, and managed databases. Anything outside that set will either no-op or surface an "unsupported on AWS" error in the plan modal. +17 service handlers + 20 extractors live in [`packages/core/src/deploy/providers/aws/`](../packages/core/src/deploy/providers/aws/). The categories exposed to the palette / plan modal are gated by feature flags — see the **Rollout state** table in [`providers/aws/README.md`](../packages/core/src/deploy/providers/aws/README.md) for the per-category truth source. Today: Storage (S3), Messaging (SQS, SNS, EventBridge), Cache (ElastiCache), Monitoring (CloudWatch Logs), Security (Secrets Manager), Source, and Config are on. Compute (ECS), Frontend, Scheduler, Network, Database (RDS / DynamoDB / DocDB), AI, and Analytics are gated until their concrete unblockers ship. + +For the source-of-truth provider matrix across all clouds, see [provider-status.md](provider-status.md). + +## AWS-specific quirks + +The deployer handles several AWS-specific gotchas silently. The full list lives in [`providers/aws/README.md`](../packages/core/src/deploy/providers/aws/README.md); highlights: + +- **S3 bucket names** get a `-{accountId}` suffix because S3 names are globally unique. +- **CloudFront ACM certs** are pinned to `us-east-1` regardless of deploy region. +- **ECS auto-provisions** a default cluster + task execution role on first deploy. Subnets and security groups are still operator-supplied today; canvas VPC blocks for AWS are deferred. +- **RDS / DocDB / Redshift** refuse to ship without a `master_user_password` — wire a `Security.Secret` or set the property explicitly. +- **RDS provisioning** takes 5–10 minutes; the handler polls `DescribeDBInstances` and reports progress via `ctx.on_step`. +- **Lambda auto-build** clones a connected `Source.Repository`, runs `npm install`, zips, and uploads to `ice-bootstrap-{accountId}-{region}` — needs local `git` / `npm` / `zip` on the deploy host. AWS CodeBuild integration is deferred. +- **SQS / SNS FIFO** queues + topics get the required `.fifo` suffix automatically. ## Known gaps vs. GCP -- No live cost estimate parity for several AWS-specific services. -- The importer (`Import → From AWS`) is not implemented yet. -- Some block types render on the canvas but have no AWS handler - they'll show a yellow "no provider for AWS" pip during plan. +- No importer (`Import → From AWS`) — manual canvas only. +- VPC-aware canvas blocks for ECS subnets/security groups not yet wired. +- Update paths for CloudFront / Cognito / DocDB / Redshift are create-only. +- Tests use mocked AWS SDKs only — no LocalStack integration tests yet. +- Cost estimate parity is sparser than GCP. -If you hit a gap that matters to you, please file a feature request - AWS parity is high-priority on the [ROADMAP](../ROADMAP.md) and contributions are welcome (see [contributing.md](contributing.md)). +If you hit a gap that matters to you, please file a feature request — AWS parity is high-priority on the [ROADMAP](../ROADMAP.md) and contributions are welcome (see [contributing.md](contributing.md)). ## See also -- [deploying-to-gcp.md](deploying-to-gcp.md) - the canonical end-to-end tutorial. -- [architecture.md](architecture.md) - how plan / apply work. -- [`packages/providers/aws/src/handlers/`](../packages/providers/aws/src/handlers/) - per-service handler source. +- [deploying-to-gcp.md](deploying-to-gcp.md) — the canonical end-to-end tutorial. +- [architecture/README.md](architecture/README.md) — how plan / apply work. +- [`providers/aws/README.md`](../packages/core/src/deploy/providers/aws/README.md) — operator notes covering every AWS quirk and the rollout-state table. +- [`packages/core/src/deploy/providers/aws/handlers/`](../packages/core/src/deploy/providers/aws/handlers/) — per-service handler source. diff --git a/docs/provider-status.md b/docs/provider-status.md index ba5a8abc..b78ea411 100644 --- a/docs/provider-status.md +++ b/docs/provider-status.md @@ -12,23 +12,25 @@ Where each provider sits today. The source of truth is `PROVIDER_READINESS` in ` ## Current matrix (v0.1) -| Provider | Status | What works | -| ----------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **GCP** | stable | 20+ handlers: Cloud Run (services + jobs), Cloud Functions, GKE, Cloud SQL, Firestore, Memorystore Redis, Cloud Storage, Pub/Sub, Cloud Scheduler, Vertex AI, Discovery Engine, BigQuery, Secret Manager, Identity Platform, API Gateway, Load Balancer, Domain Mapping, Cloud Logging. Full importer via Cloud Asset Inventory. | -| **AWS** | experimental | EC2 instance, S3 bucket, Lambda function. Importer not implemented. No auto-enable for required services. Most other resource categories surface as "unsupported on AWS" in the plan modal. | -| **Azure** | experimental | Virtual Machine, Storage Account, Web App. Importer not implemented. Most other resource categories surface as "unsupported on Azure". | -| **Kubernetes** | design-only | 13 blocks render on canvas. Deployer is not wired. | -| **Alibaba Cloud** | design-only | Blocks render. Deployer is the next item after AWS/Azure parity. | -| **Oracle Cloud** | design-only | Block stubs. No deployer. | -| **DigitalOcean** | design-only | Block stubs. No deployer. | +| Provider | Status | What works | +| ----------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **GCP** | stable | 20+ handlers: Cloud Run (services + jobs), Cloud Functions, GKE, Cloud SQL, Firestore, Memorystore Redis, Cloud Storage, Pub/Sub, Cloud Scheduler, Vertex AI, Discovery Engine, BigQuery, Secret Manager, Identity Platform, API Gateway, Load Balancer, Domain Mapping, Cloud Logging. Full importer via Cloud Asset Inventory. | +| **AWS** | experimental | 17 handlers + 20 extractors: S3 (account-id suffix), Lambda (auto-build from `Source.Repository`), ECS (auto-cluster + task role), RDS (provisioning poll), DynamoDB, ElastiCache, DocDB, CloudFront (us-east-1 ACM cert), API Gateway, ELBv2, SQS/SNS (FIFO suffix), EventBridge, Cognito, OpenSearch, Bedrock, SageMaker, Redshift, CloudWatch Logs, Secrets Manager. Staged rollout via feature flags — Storage / Messaging / Cache / Monitoring / Security / Source / Config categories are on; Compute / Frontend / Scheduler / Network / Database / AI / Analytics are gated until concrete unblockers ship (VPC blocks for ECS, ACM cert validation flow, update paths). See [`packages/core/src/deploy/providers/aws/README.md`](../packages/core/src/deploy/providers/aws/README.md) for the per-category state. Importer not implemented. | +| **Azure** | experimental | Virtual Machine, Storage Account, Web App. Importer not implemented. Most other resource categories surface as "unsupported on Azure". | +| **Kubernetes** | design-only | 13 blocks render on canvas. Deployer is not wired. | +| **Alibaba Cloud** | design-only | Blocks render. Deployer is the next item after AWS/Azure parity. | +| **Oracle Cloud** | design-only | Block stubs. No deployer. | +| **DigitalOcean** | design-only | Block stubs. No deployer. | ## What "experimental" looks like in practice -For an AWS deploy of a canvas that uses Static Site + Custom Domain: +For an AWS deploy of a canvas: -- The plan modal will show creates for `aws.s3.bucket` and `aws.lambda.function` if those blocks are present. -- Anything outside the supported set (e.g., `aws.rds.instance`, `aws.cloudfront.distribution`, networking constructs) will surface in the plan as **unsupported** rather than create. -- Apply runs only against the supported types. Partial-success result with an explicit "this block has no AWS handler yet" log line. +- Blocks in the enabled categories (Storage / Messaging / Cache / Monitoring / Security / Source / Config) plan and apply normally — S3, SQS, SNS, ElastiCache, Secrets Manager, CloudWatch Logs. +- Blocks in gated categories are hidden from the palette when the project's provider is AWS (Compute, Frontend, Scheduler, Network, Database, AI, Analytics). Their handlers exist but aren't exposed yet — flip a category in `PROVIDER_FLAGS.aws.categories` once its unblocker lands. +- RDS / DocDB / Redshift refuse to create without a `master_user_password`. Wire a `Security.Secret` or set the property explicitly. +- CloudFront / Cognito / DocDB / Redshift are create-only today — no update path. +- Lambda auto-build needs local `git` / `npm` / `zip` on the deploy host. This is the same loop you'd hit on Azure for anything past VM / Storage / Web App. diff --git a/package.json b/package.json index 9b4f400d..b3e24548 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.774", + "version": "0.1.775", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module",