1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import picomatch from "picomatch";
4
5/** Selection applies after the whole stack declaration evaluates. */
6export 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. */
14export 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
27export class InvalidResourceSelection extends Data.TaggedError(
28 "InvalidResourceSelection",
29)<{ message: string }> {}
30
31export class UnsafeSelectionBoundary extends Data.TaggedError(
32 "UnsafeSelectionBoundary",
33)<{ message: string }> {}
34
35/** Resolve and close the selection before accessing state or providers. */
36export 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 });