| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import picomatch from "picomatch"; |
| 4 | |
| 5 | /** Selection applies after the whole stack declaration evaluates. */ |
| 6 | export interface ResourceSelection { |
| 7 | /** Exact FQNs, unique logical IDs, or FQN globs; dependencies are included. */ |
| 8 | readonly include?: ReadonlyArray<string>; |
| 9 | /** Hard exclusions. A selected node depending on an excluded node fails planning. */ |
| 10 | readonly exclude?: ReadonlyArray<string>; |
| 11 | } |
| 12 | |
| 13 | /** Required filters return no output; possibly supplied filters make output optional. */ |
| 14 | export type SelectionOutput<A, Options> = Options extends |
| 15 | | { include: ReadonlyArray<string> } |
| 16 | | { exclude: ReadonlyArray<string> } |
| 17 | ? undefined |
| 18 | : Options extends undefined |
| 19 | ? A |
| 20 | : Extract< |
| 21 | Options[keyof Options & keyof ResourceSelection], |
| 22 | ReadonlyArray<string> |
| 23 | > extends never |
| 24 | ? A |
| 25 | : A | undefined; |
| 26 | |
| 27 | export class InvalidResourceSelection extends Data.TaggedError( |
| 28 | "InvalidResourceSelection", |
| 29 | )<{ message: string }> {} |
| 30 | |
| 31 | export class UnsafeSelectionBoundary extends Data.TaggedError( |
| 32 | "UnsafeSelectionBoundary", |
| 33 | )<{ message: string }> {} |
| 34 | |
| 35 | /** Resolve and close the selection before accessing state or providers. */ |
| 36 | export const selectResources = < |
| 37 | Node extends { FQN: string; LogicalId: string }, |
| 38 | >( |
| 39 | declared: ReadonlyArray<Node>, |
| 40 | upstream: (node: Node) => ReadonlyArray<string>, |
| 41 | options: ResourceSelection, |
| 42 | ) => |
| 43 | Effect.gen(function* () { |
| 44 | if (options.include === undefined && options.exclude === undefined) { |
| 45 | return undefined; |
| 46 | } |
| 47 | const byFqn = new Map(declared.map((node) => [node.FQN, node])); |
| 48 | const available = declared |
| 49 | .map((node) => node.FQN) |
| 50 | .sort() |
| 51 | .join(", "); |
| 52 | const invalid = (message: string) => |
| 53 | Effect.die( |
| 54 | new InvalidResourceSelection({ |
| 55 | message: `${message}. Available resources and Actions: ${available || "(none)"}`, |
| 56 | }), |
| 57 | ); |
| 58 | const resolve = ( |
| 59 | kind: "include" | "exclude", |
| 60 | patterns: ReadonlyArray<string>, |
| 61 | ) => |
| 62 | Effect.gen(function* () { |
| 63 | if (patterns.length === 0) |
| 64 | return yield* invalid(`${kind} must not be empty`); |
| 65 | const matches = new Map<string, string>(); |
| 66 | for (const pattern of new Set(patterns)) { |
| 67 | if (pattern.trim() === "") |
| 68 | return yield* invalid(`${kind} pattern must not be empty`); |
| 69 | const exact = byFqn.get(pattern); |
| 70 | let nodes: ReadonlyArray<Node>; |
| 71 | if (exact) { |
| 72 | nodes = [exact]; |
| 73 | } else { |
| 74 | const scanned = picomatch.scan(pattern); |
| 75 | const parsed = yield* Effect.try(() => |
| 76 | picomatch.parse(pattern), |
| 77 | ).pipe( |
| 78 | Effect.catch(() => |
| 79 | invalid(`Invalid ${kind} pattern '${pattern}'`), |
| 80 | ), |
| 81 | ); |
| 82 | if ( |
| 83 | scanned.negated || |
| 84 | scanned.negatedExtglob || |
| 85 | parsed.negated || |
| 86 | parsed.negatedExtglob |
| 87 | ) { |
| 88 | return yield* invalid( |
| 89 | `Negated pattern '${pattern}' is not supported; use --exclude instead`, |
| 90 | ); |
| 91 | } |
| 92 | const matcher = yield* Effect.try(() => |
| 93 | picomatch(pattern, { |
| 94 | dot: true, |
| 95 | strictBrackets: true, |
| 96 | nonegate: true, |
| 97 | }), |
| 98 | ).pipe( |
| 99 | Effect.catch(() => |
| 100 | invalid(`Invalid ${kind} pattern '${pattern}'`), |
| 101 | ), |
| 102 | ); |
| 103 | const glob = scanned.isGlob; |
| 104 | nodes = declared.filter((node) => |
| 105 | glob ? matcher(node.FQN) : node.LogicalId === pattern, |
| 106 | ); |
| 107 | if (!glob && nodes.length > 1) { |
| 108 | return yield* invalid( |
| 109 | `Ambiguous ${kind} '${pattern}'; use an FQN: ${nodes |
| 110 | .map((node) => node.FQN) |
| 111 | .sort() |
| 112 | .join(", ")}`, |
| 113 | ); |
| 114 | } |
| 115 | } |
| 116 | if (nodes.length === 0) { |
| 117 | if (kind === "include") |
| 118 | return yield* invalid( |
| 119 | `Unknown or unmatched include '${pattern}'`, |
| 120 | ); |
| 121 | yield* Effect.logWarning( |
| 122 | `Exclusion '${pattern}' matched no declared resources or Actions.`, |
| 123 | ); |
| 124 | } |
| 125 | for (const node of nodes) { |
| 126 | if (!matches.has(node.FQN)) matches.set(node.FQN, pattern); |
| 127 | } |
| 128 | } |
| 129 | return matches; |
| 130 | }); |
| 131 | const included = |
| 132 | options.include === undefined |
| 133 | ? new Map(declared.map((node) => [node.FQN, node.FQN])) |
| 134 | : yield* resolve("include", options.include); |
| 135 | const excluded = |
| 136 | options.exclude === undefined |
| 137 | ? new Map<string, string>() |
| 138 | : yield* resolve("exclude", options.exclude); |
| 139 | const selected = new Set( |
| 140 | [...included.keys()].filter((fqn) => !excluded.has(fqn)), |
| 141 | ); |
| 142 | if (selected.size === 0) |
| 143 | return yield* invalid("Resource selection is empty"); |
| 144 | const chains = new Map([...selected].map((fqn) => [fqn, [fqn]])); |
| 145 | // Set iteration visits additions and terminates on dependency cycles. |
| 146 | for (const fqn of selected) { |
| 147 | for (const dependency of upstream(byFqn.get(fqn)!)) { |
| 148 | const chain = [...chains.get(fqn)!, dependency]; |
| 149 | if (excluded.has(dependency)) { |
| 150 | return yield* invalid( |
| 151 | `Dependency ${chain.join(" -> ")} is blocked by exclude pattern '${excluded.get(dependency)}'`, |
| 152 | ); |
| 153 | } |
| 154 | if (!byFqn.has(dependency)) { |
| 155 | return yield* invalid( |
| 156 | `Dependency ${chain.join(" -> ")} is undeclared`, |
| 157 | ); |
| 158 | } |
| 159 | if (!selected.has(dependency)) { |
| 160 | selected.add(dependency); |
| 161 | chains.set(dependency, chain); |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | return selected; |
| 166 | }); |