@smthrs/plugin 0.0.0-stage → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +86 -2
  4. package/dist/cjs/Config.d.ts +129 -0
  5. package/dist/cjs/Config.d.ts.map +1 -0
  6. package/dist/cjs/Config.js +115 -0
  7. package/dist/cjs/Config.js.map +7 -0
  8. package/dist/cjs/Hooks.d.ts +176 -0
  9. package/dist/cjs/Hooks.d.ts.map +1 -0
  10. package/dist/cjs/Hooks.js +34 -0
  11. package/dist/cjs/Hooks.js.map +7 -0
  12. package/dist/cjs/Kernel.d.ts +81 -0
  13. package/dist/cjs/Kernel.d.ts.map +1 -0
  14. package/dist/cjs/Kernel.js +57 -0
  15. package/dist/cjs/Kernel.js.map +7 -0
  16. package/dist/cjs/Plugin.d.ts +71 -0
  17. package/dist/cjs/Plugin.d.ts.map +1 -0
  18. package/dist/cjs/Plugin.js +25 -0
  19. package/dist/cjs/Plugin.js.map +7 -0
  20. package/dist/cjs/PluginError.d.ts +50 -0
  21. package/dist/cjs/PluginError.d.ts.map +1 -0
  22. package/dist/cjs/PluginError.js +58 -0
  23. package/dist/cjs/PluginError.js.map +7 -0
  24. package/dist/cjs/Plugins.d.ts +97 -0
  25. package/dist/cjs/Plugins.d.ts.map +1 -0
  26. package/dist/cjs/Plugins.js +160 -0
  27. package/dist/cjs/Plugins.js.map +7 -0
  28. package/dist/cjs/Resolve.d.ts +128 -0
  29. package/dist/cjs/Resolve.d.ts.map +1 -0
  30. package/dist/cjs/Resolve.js +343 -0
  31. package/dist/cjs/Resolve.js.map +7 -0
  32. package/dist/cjs/index.d.ts +83 -0
  33. package/dist/cjs/index.d.ts.map +1 -0
  34. package/dist/cjs/index.js +45 -0
  35. package/dist/cjs/index.js.map +7 -0
  36. package/dist/cjs/internal/Boundary.d.ts +96 -0
  37. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  38. package/dist/cjs/internal/Boundary.js +129 -0
  39. package/dist/cjs/internal/Boundary.js.map +7 -0
  40. package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
  41. package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
  42. package/dist/cjs/internal/mergePluginLayers.js +57 -0
  43. package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
  44. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +19 -0
  45. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  46. package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
  47. package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
  48. package/dist/cjs/package.json +1 -0
  49. package/dist/esm/Config.d.ts +129 -0
  50. package/dist/esm/Config.d.ts.map +1 -0
  51. package/dist/esm/Config.js +160 -0
  52. package/dist/esm/Config.js.map +1 -0
  53. package/dist/esm/Hooks.d.ts +176 -0
  54. package/dist/esm/Hooks.d.ts.map +1 -0
  55. package/dist/esm/Hooks.js +56 -0
  56. package/dist/esm/Hooks.js.map +1 -0
  57. package/dist/esm/Kernel.d.ts +81 -0
  58. package/dist/esm/Kernel.d.ts.map +1 -0
  59. package/dist/esm/Kernel.js +58 -0
  60. package/dist/esm/Kernel.js.map +1 -0
  61. package/dist/esm/Plugin.d.ts +71 -0
  62. package/dist/esm/Plugin.d.ts.map +1 -0
  63. package/dist/esm/Plugin.js +20 -0
  64. package/dist/esm/Plugin.js.map +1 -0
  65. package/dist/esm/PluginError.d.ts +50 -0
  66. package/dist/esm/PluginError.d.ts.map +1 -0
  67. package/dist/esm/PluginError.js +53 -0
  68. package/dist/esm/PluginError.js.map +1 -0
  69. package/dist/esm/Plugins.d.ts +97 -0
  70. package/dist/esm/Plugins.d.ts.map +1 -0
  71. package/dist/esm/Plugins.js +178 -0
  72. package/dist/esm/Plugins.js.map +1 -0
  73. package/dist/esm/Resolve.d.ts +128 -0
  74. package/dist/esm/Resolve.d.ts.map +1 -0
  75. package/dist/esm/Resolve.js +367 -0
  76. package/dist/esm/Resolve.js.map +1 -0
  77. package/dist/esm/index.d.ts +83 -0
  78. package/dist/esm/index.d.ts.map +1 -0
  79. package/dist/esm/index.js +48 -0
  80. package/dist/esm/index.js.map +1 -0
  81. package/dist/esm/internal/Boundary.d.ts +96 -0
  82. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  83. package/dist/esm/internal/Boundary.js +162 -0
  84. package/dist/esm/internal/Boundary.js.map +1 -0
  85. package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
  86. package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
  87. package/dist/esm/internal/mergePluginLayers.js +41 -0
  88. package/dist/esm/internal/mergePluginLayers.js.map +1 -0
  89. package/dist/esm/internal/snapshotCacheEnvironment.d.ts +19 -0
  90. package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  91. package/dist/esm/internal/snapshotCacheEnvironment.js +63 -0
  92. package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
  93. package/docs/README.md +131 -0
  94. package/docs/api.md +522 -0
  95. package/docs/concepts/configuration.md +111 -0
  96. package/docs/concepts/hook-kinds.md +107 -0
  97. package/docs/concepts/resolution.md +132 -0
  98. package/docs/guides/cache-identity.md +106 -0
  99. package/docs/guides/contribute-services.md +84 -0
  100. package/docs/guides/host-your-own-hooks.md +129 -0
  101. package/docs/guides/order-handlers.md +76 -0
  102. package/docs/guides/select-plugins.md +72 -0
  103. package/docs/guides/testing.md +149 -0
  104. package/docs/installation.md +71 -0
  105. package/docs/quickstart.md +151 -0
  106. package/docs/troubleshooting.md +179 -0
  107. package/package.json +179 -3
  108. package/src/Config.ts +207 -0
  109. package/src/Hooks.ts +212 -0
  110. package/src/Kernel.ts +119 -0
  111. package/src/Plugin.ts +80 -0
  112. package/src/PluginError.ts +62 -0
  113. package/src/Plugins.ts +254 -0
  114. package/src/Resolve.ts +535 -0
  115. package/src/index.ts +91 -0
  116. package/src/internal/Boundary.ts +252 -0
  117. package/src/internal/mergePluginLayers.ts +57 -0
  118. package/src/internal/snapshotCacheEnvironment.ts +77 -0
package/src/Resolve.ts ADDED
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Bounded plugin resolution: admit, filter, validate, order, and snapshot.
3
+ *
4
+ * The resulting catalog owns every record it exposes. Dispatch never reads a
5
+ * caller-owned map, plugin record, hook object, config value, or cache identity.
6
+ *
7
+ * @since 1.0.0-rc.0
8
+ */
9
+
10
+ import * as ImmutableMap from "@smthrs/canonical/ReadonlyMap"
11
+ import type { Action } from "@smthrs/flow"
12
+ import * as Effect from "effect/Effect"
13
+ import * as Layer from "effect/Layer"
14
+ import type { FlowsConfig } from "./Config.ts"
15
+ import * as Config from "./Config.ts"
16
+ import type { HookCatalog, HookKind, HookObject } from "./Hooks.ts"
17
+ import { engineHooks, handlerOf, orderOf } from "./Hooks.ts"
18
+ import type { FlowsHooks } from "./index.ts"
19
+ import * as Boundary from "./internal/Boundary.ts"
20
+ import { mergePluginLayers } from "./internal/mergePluginLayers.ts"
21
+ import { snapshotCacheEnvironment } from "./internal/snapshotCacheEnvironment.ts"
22
+ import type { Apply, FlowsPlugin, PluginInput } from "./Plugin.ts"
23
+ import { PluginError, type PluginErrorCode } from "./PluginError.ts"
24
+
25
+ /**
26
+ * Maximum nested preset-array depth accepted at startup.
27
+ *
28
+ * @category limits
29
+ * @since 1.0.0-rc.0
30
+ */
31
+ export const maximumPluginDepth = 64
32
+
33
+ /**
34
+ * Maximum plugins accepted by one kernel.
35
+ *
36
+ * @category limits
37
+ * @since 1.0.0-rc.0
38
+ */
39
+ export const maximumPlugins = 256
40
+
41
+ /**
42
+ * Maximum aggregate hook handlers accepted by one kernel.
43
+ *
44
+ * @category limits
45
+ * @since 1.0.0-rc.0
46
+ */
47
+ export const maximumHandlers = 1_024
48
+
49
+ /**
50
+ * Maximum input nodes, including arrays and omitted entries.
51
+ *
52
+ * @category limits
53
+ * @since 1.0.0-rc.0
54
+ */
55
+ export const maximumPluginInputNodes = 4_096
56
+
57
+ /**
58
+ * Maximum UTF-16 length of one plugin or hook name.
59
+ *
60
+ * @category limits
61
+ * @since 1.0.0-rc.0
62
+ */
63
+ export const maximumPluginNameLength = 256
64
+
65
+ /**
66
+ * Default fiber bound for parallel observers.
67
+ *
68
+ * @category limits
69
+ * @since 1.0.0-rc.0
70
+ */
71
+ export const defaultParallelConcurrency = 16
72
+
73
+ /**
74
+ * Maximum configurable parallel-observer bound.
75
+ *
76
+ * @category limits
77
+ * @since 1.0.0-rc.0
78
+ */
79
+ export const maximumParallelConcurrency = 256
80
+
81
+ /**
82
+ * One resolved handler with immutable attribution.
83
+ *
84
+ * @category models
85
+ * @since 1.0.0-rc.0
86
+ */
87
+ export interface HandlerRecord {
88
+ readonly plugin: string
89
+ readonly hook: string
90
+ readonly handler: (...args: Array<any>) => unknown
91
+ }
92
+
93
+ /**
94
+ * The immutable output of plugin resolution.
95
+ *
96
+ * @category models
97
+ * @since 1.0.0-rc.0
98
+ */
99
+ export interface Resolved<H = FlowsHooks> {
100
+ readonly plugins: ReadonlyArray<FlowsPlugin<H>>
101
+ readonly handlers: ReadonlyMap<string, ReadonlyArray<HandlerRecord>>
102
+ /** The frozen runtime catalog this resolution admitted. Dispatch refuses a method whose kind differs from it. */
103
+ readonly kinds: Readonly<Record<string, HookKind>>
104
+ readonly parallelConcurrency: number
105
+ readonly cacheEnvironment?: Action.CacheEnvironment | undefined
106
+ }
107
+
108
+ /**
109
+ * Options for {@link resolve}.
110
+ *
111
+ * @category models
112
+ * @since 1.0.0-rc.0
113
+ */
114
+ export interface Options<H = FlowsHooks> {
115
+ /** Pre-resolution config tested by `apply` predicates. */
116
+ readonly config?: FlowsConfig | undefined
117
+ /** Host whose literal `apply` selectors are active. Defaults to `"engine"`. */
118
+ readonly target?: "engine" | "harness" | undefined
119
+ /**
120
+ * Hook names recognized by this host, each with the kind `H` declares for
121
+ * it. The shared `config` and `configResolved` hooks are fixed to
122
+ * `waterfall` and `parallel`; another kind fails with `hook_kind_mismatch`.
123
+ */
124
+ readonly hooks?: HookCatalog<H> | undefined
125
+ /** Complete composition identity for sealed activity keys. Requires every selected plugin to have a version. */
126
+ readonly cacheEnvironment?: Action.CacheEnvironment | undefined
127
+ /** Maximum observers run at once. Defaults to 16 and cannot exceed 256. */
128
+ readonly parallelConcurrency?: number | undefined
129
+ }
130
+
131
+ interface RawOptions {
132
+ readonly config: unknown
133
+ readonly target: unknown
134
+ readonly hooks: unknown
135
+ readonly cacheEnvironment: unknown
136
+ readonly parallelConcurrency: unknown
137
+ }
138
+
139
+ const failure = (
140
+ code: PluginErrorCode,
141
+ message: string,
142
+ path: string,
143
+ fields: { readonly plugin?: string; readonly hook?: string } = {}
144
+ ): PluginError => new PluginError({ code, message, path, ...fields })
145
+
146
+ const invalidPlugin = (
147
+ path: string,
148
+ complaint: string,
149
+ fields: { readonly plugin?: string; readonly hook?: string } = {}
150
+ ): PluginError => failure("invalid_plugin", `plugin input ${complaint}`, path, fields)
151
+
152
+ // Bound before quoting or scanning caller-controlled keys. Keep Boundary's
153
+ // identifier/dot and JSON-quoted/bracket path convention.
154
+ const diagnosticKey = (key: string): string => key.length > 64 ? `${key.slice(0, 64)}...` : key
155
+
156
+ const childPath = (path: string, key: string): string => {
157
+ const bounded = diagnosticKey(key)
158
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(bounded) ? `${path}.${bounded}` : `${path}[${JSON.stringify(bounded)}]`
159
+ }
160
+
161
+ const ownData = (
162
+ input: unknown,
163
+ path: string,
164
+ allowed?: ReadonlySet<string>
165
+ ): Readonly<Record<string, unknown>> => {
166
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
167
+ throw invalidPlugin(path, "must be an ordinary record")
168
+ }
169
+ const prototype = Object.getPrototypeOf(input)
170
+ if (prototype !== Object.prototype && prototype !== null) throw invalidPlugin(path, "must be an ordinary record")
171
+ const output: Record<string, unknown> = {}
172
+ for (const key of Reflect.ownKeys(input)) {
173
+ if (typeof key !== "string") throw invalidPlugin(path, "must not contain symbol keys")
174
+ if (allowed !== undefined && !allowed.has(key)) {
175
+ throw invalidPlugin(childPath(path, key), "contains an unknown property")
176
+ }
177
+ const descriptor = Object.getOwnPropertyDescriptor(input, key)
178
+ if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
179
+ throw invalidPlugin(childPath(path, key), "must be an enumerable data property")
180
+ }
181
+ Object.defineProperty(output, key, { value: descriptor.value, enumerable: true })
182
+ }
183
+ return output
184
+ }
185
+
186
+ const snapshotOptions = (input: unknown): RawOptions => {
187
+ const values = ownData(
188
+ input,
189
+ "$options",
190
+ new Set(["config", "target", "hooks", "cacheEnvironment", "parallelConcurrency"])
191
+ )
192
+ return {
193
+ config: values.config,
194
+ target: values.target,
195
+ hooks: values.hooks,
196
+ cacheEnvironment: values.cacheEnvironment,
197
+ parallelConcurrency: values.parallelConcurrency
198
+ }
199
+ }
200
+
201
+ const validName = (value: unknown): value is string =>
202
+ typeof value === "string" && value.length > 0 && value.length <= maximumPluginNameLength &&
203
+ Boundary.isWellFormedText(value) && !/^\s*$/u.test(value) && !/\p{Cc}/u.test(value)
204
+
205
+ const snapshotCatalog = (input: unknown): Readonly<Record<string, HookKind>> => {
206
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
207
+ throw invalidPlugin("$options.hooks", "must be an ordinary hook catalog")
208
+ }
209
+ const prototype = Object.getPrototypeOf(input)
210
+ if (prototype !== Object.prototype && prototype !== null) {
211
+ throw invalidPlugin("$options.hooks", "must be an ordinary hook catalog")
212
+ }
213
+ const output: Record<string, HookKind> = {}
214
+ for (const key of Reflect.ownKeys(input)) {
215
+ if (typeof key !== "string" || !validName(key)) {
216
+ throw invalidPlugin("$options.hooks", "contains an invalid hook name")
217
+ }
218
+ const descriptor = Object.getOwnPropertyDescriptor(input, key)
219
+ if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
220
+ throw invalidPlugin(childPath("$options.hooks", key), "must be an enumerable data property")
221
+ }
222
+ if (!(["sequential", "parallel", "first", "waterfall"] as ReadonlyArray<unknown>).includes(descriptor.value)) {
223
+ throw invalidPlugin(childPath("$options.hooks", key), "contains an invalid hook kind")
224
+ }
225
+ Object.defineProperty(output, key, { value: descriptor.value, enumerable: true })
226
+ }
227
+ for (const hook of Object.keys(engineHooks) as ReadonlyArray<keyof typeof engineHooks>) {
228
+ if (Object.hasOwn(output, hook) && output[hook] !== engineHooks[hook]) {
229
+ throw failure(
230
+ "hook_kind_mismatch",
231
+ `hook catalog declares shared hook ${JSON.stringify(hook)} as ${
232
+ JSON.stringify(output[hook])
233
+ }, the kernel dispatches it as ${JSON.stringify(engineHooks[hook])}`,
234
+ childPath("$options.hooks", hook),
235
+ { hook }
236
+ )
237
+ }
238
+ }
239
+ return Object.freeze(output)
240
+ }
241
+
242
+ const snapshotHook = (
243
+ entry: unknown,
244
+ path: string,
245
+ plugin: string,
246
+ hook: string
247
+ ): ((...args: Array<any>) => unknown) | HookObject<(...args: Array<any>) => unknown> => {
248
+ if (typeof entry === "function") return entry as (...args: Array<any>) => unknown
249
+ if (entry === undefined) {
250
+ throw invalidPlugin(path, "must be a function or a hook object", { plugin, hook })
251
+ }
252
+ const values = ownData(entry, path, new Set(["order", "handler"]))
253
+ if (typeof values.handler !== "function") {
254
+ throw invalidPlugin(`${path}.handler`, "must be a function", { plugin, hook })
255
+ }
256
+ if (values.order !== undefined && values.order !== "pre" && values.order !== "post") {
257
+ throw invalidPlugin(`${path}.order`, "must be pre or post", { plugin, hook })
258
+ }
259
+ return Object.freeze({
260
+ ...(values.order === undefined ? {} : { order: values.order }),
261
+ handler: values.handler
262
+ }) as HookObject<(...args: Array<any>) => unknown>
263
+ }
264
+
265
+ const snapshotPlugin = <H>(
266
+ input: unknown,
267
+ path: string
268
+ ): FlowsPlugin<H> => {
269
+ const values = ownData(input, path, new Set(["name", "version", "enforce", "apply", "layer", "hooks"]))
270
+ if (!validName(values.name)) throw invalidPlugin(`${path}.name`, "requires a bounded non-empty name")
271
+ const name = values.name
272
+ if (values.version !== undefined && !validName(values.version)) {
273
+ throw invalidPlugin(`${path}.version`, "requires a bounded non-empty version", { plugin: name })
274
+ }
275
+ if (values.enforce !== undefined && values.enforce !== "pre" && values.enforce !== "post") {
276
+ throw invalidPlugin(`${path}.enforce`, "must be pre or post", { plugin: name })
277
+ }
278
+ if (
279
+ values.apply !== undefined && values.apply !== "engine" && values.apply !== "harness" &&
280
+ typeof values.apply !== "function"
281
+ ) throw invalidPlugin(`${path}.apply`, "must be engine, harness, or a predicate", { plugin: name })
282
+ if (values.layer !== undefined && !Layer.isLayer(values.layer)) {
283
+ throw invalidPlugin(`${path}.layer`, "must be an Effect Layer", { plugin: name })
284
+ }
285
+
286
+ let hooks: Readonly<Record<string, unknown>> | undefined
287
+ if (values.hooks !== undefined) {
288
+ const rawHooks = ownData(values.hooks, `${path}.hooks`)
289
+ const snapshot: Record<string, unknown> = Object.create(null)
290
+ for (const [index, hook] of Object.keys(rawHooks).entries()) {
291
+ if (!validName(hook)) {
292
+ throw invalidPlugin(`${path}.hooks[${index}]`, "contains an invalid hook name")
293
+ }
294
+ const entry = rawHooks[hook]
295
+ Object.defineProperty(snapshot, hook, {
296
+ value: snapshotHook(entry, childPath(`${path}.hooks`, hook), name, hook),
297
+ enumerable: true
298
+ })
299
+ }
300
+ hooks = Object.freeze(snapshot)
301
+ }
302
+
303
+ return Object.freeze({
304
+ name,
305
+ ...(values.version === undefined ? {} : { version: values.version }),
306
+ ...(values.enforce === undefined ? {} : { enforce: values.enforce }),
307
+ ...(values.apply === undefined ? {} : { apply: values.apply as Apply }),
308
+ ...(values.layer === undefined ? {} : { layer: values.layer as Layer.Layer<never, any, any> }),
309
+ ...(hooks === undefined ? {} : { hooks: hooks as Partial<H> })
310
+ })
311
+ }
312
+
313
+ interface AdmittedPlugin<H> {
314
+ readonly plugin: FlowsPlugin<H>
315
+ readonly path: string
316
+ }
317
+
318
+ const flatten = <H>(input: unknown): ReadonlyArray<AdmittedPlugin<H>> => {
319
+ const output: Array<AdmittedPlugin<H>> = []
320
+ const arrays = new WeakSet<object>()
321
+ const stack: Array<{ readonly value: unknown; readonly path: string; readonly depth: number }> = [
322
+ { value: input, path: "$", depth: 0 }
323
+ ]
324
+ let nodes = 0
325
+ while (stack.length > 0) {
326
+ const current = stack.pop()!
327
+ nodes += 1
328
+ if (current.value === false || current.value === null || current.value === undefined) continue
329
+ if (Array.isArray(current.value)) {
330
+ if (current.depth >= maximumPluginDepth) {
331
+ throw failure("resource_limit", `plugin input exceeds the depth limit of ${maximumPluginDepth}`, current.path)
332
+ }
333
+ if (arrays.has(current.value)) throw invalidPlugin(current.path, "contains a cycle or repeated preset array")
334
+ arrays.add(current.value)
335
+ const length = current.value.length
336
+ // Every queued frame and array element costs a node, including omitted
337
+ // entries. Refuse before enumerating keys, descriptors, or child frames.
338
+ if (length > maximumPluginInputNodes - nodes - stack.length) {
339
+ throw failure("resource_limit", `plugin input exceeds the ${maximumPluginInputNodes}-node limit`, current.path)
340
+ }
341
+ const keys = Reflect.ownKeys(current.value)
342
+ if (keys.length !== length + 1 || !keys.includes("length")) {
343
+ throw invalidPlugin(current.path, "must be a dense preset array with no extra properties")
344
+ }
345
+ for (let index = length - 1; index >= 0; index--) {
346
+ const descriptor = Object.getOwnPropertyDescriptor(current.value, String(index))
347
+ if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
348
+ throw invalidPlugin(`${current.path}[${index}]`, "must be an enumerable data property")
349
+ }
350
+ stack.push({ value: descriptor.value, path: `${current.path}[${index}]`, depth: current.depth + 1 })
351
+ }
352
+ continue
353
+ }
354
+ output.push(Object.freeze({ plugin: snapshotPlugin<H>(current.value, current.path), path: current.path }))
355
+ if (output.length > maximumPlugins) {
356
+ throw failure("resource_limit", `plugin input exceeds the ${maximumPlugins}-plugin limit`, current.path)
357
+ }
358
+ }
359
+ return output
360
+ }
361
+
362
+ const included = <H>(
363
+ plugin: FlowsPlugin<H>,
364
+ config: FlowsConfig,
365
+ target: "engine" | "harness"
366
+ ): Effect.Effect<boolean, PluginError> => {
367
+ const apply = plugin.apply
368
+ if (apply === undefined) return Effect.succeed(true)
369
+ if (typeof apply !== "function") return Effect.succeed(apply === target)
370
+ return Effect.try({
371
+ try: () => apply(config),
372
+ catch: () =>
373
+ failure("apply_failed", `plugin "${plugin.name}" apply predicate failed`, "$.apply", {
374
+ plugin: plugin.name
375
+ })
376
+ }).pipe(
377
+ Effect.flatMap((result) =>
378
+ typeof result === "boolean"
379
+ ? Effect.succeed(result)
380
+ : Effect.fail(invalidPlugin("$.apply", "predicate must return a boolean", { plugin: plugin.name }))
381
+ )
382
+ )
383
+ }
384
+
385
+ const rank = (enforce: "pre" | "post" | undefined): number => (enforce === "pre" ? 0 : enforce === "post" ? 2 : 1)
386
+
387
+ /**
388
+ * Resolves a plugin preset into an immutable, resource-bounded catalog.
389
+ *
390
+ * @category constructors
391
+ * @since 1.0.0-rc.0
392
+ */
393
+ export const resolve = <H = FlowsHooks>(
394
+ input: PluginInput<NoInfer<H>>,
395
+ options: Options<NoInfer<H>> = {},
396
+ configOverride?: FlowsConfig
397
+ ): Effect.Effect<Resolved<H>, PluginError> =>
398
+ Effect.gen(function*() {
399
+ const raw = yield* Effect.try({
400
+ try: () => snapshotOptions(options),
401
+ catch: (cause) =>
402
+ cause instanceof PluginError
403
+ ? cause
404
+ : invalidPlugin("$options", "could not be inspected without executing user code")
405
+ })
406
+ if (configOverride !== undefined && raw.config !== undefined) {
407
+ return yield* Effect.fail(invalidPlugin(
408
+ "$options.config",
409
+ "cannot declare config in options alongside the positional override"
410
+ ))
411
+ }
412
+ if (raw.target !== undefined && raw.target !== "engine" && raw.target !== "harness") {
413
+ return yield* Effect.fail(invalidPlugin("$options.target", "must be engine or harness"))
414
+ }
415
+ if (
416
+ raw.parallelConcurrency !== undefined &&
417
+ (typeof raw.parallelConcurrency !== "number" || !Number.isSafeInteger(raw.parallelConcurrency) ||
418
+ raw.parallelConcurrency <= 0 ||
419
+ raw.parallelConcurrency > maximumParallelConcurrency)
420
+ ) {
421
+ return yield* Effect.fail(failure(
422
+ "resource_limit",
423
+ `parallel concurrency must be between 1 and ${maximumParallelConcurrency}`,
424
+ "$options.parallelConcurrency"
425
+ ))
426
+ }
427
+ const parallelConcurrency = raw.parallelConcurrency === undefined
428
+ ? defaultParallelConcurrency
429
+ : raw.parallelConcurrency
430
+ const config = yield* Config.snapshot(configOverride ?? raw.config ?? {})
431
+ const known = yield* Effect.try({
432
+ try: () => snapshotCatalog(raw.hooks ?? engineHooks),
433
+ catch: (cause) =>
434
+ cause instanceof PluginError
435
+ ? cause
436
+ : invalidPlugin("$options.hooks", "could not be inspected without executing user code")
437
+ })
438
+ const admitted = yield* Effect.try({
439
+ try: () => flatten<H>(input),
440
+ catch: (cause) =>
441
+ cause instanceof PluginError
442
+ ? cause
443
+ : invalidPlugin("$", "could not be inspected without executing user code")
444
+ })
445
+ const target = raw.target ?? "engine"
446
+ const selected = yield* Effect.filter(admitted, ({ plugin }) => included(plugin, config, target))
447
+
448
+ let handlerCount = 0
449
+ for (const { path, plugin } of selected) {
450
+ const pluginHooks = plugin.hooks as Readonly<Record<string, unknown>> | undefined
451
+ if (pluginHooks === undefined) continue
452
+ for (const hook of Object.keys(pluginHooks)) {
453
+ if (!Object.hasOwn(known, hook)) {
454
+ return yield* Effect.fail(failure(
455
+ "unknown_hook",
456
+ `plugin ${JSON.stringify(plugin.name)} declares unknown hook ${JSON.stringify(diagnosticKey(hook))}`,
457
+ childPath(`${path}.hooks`, hook),
458
+ { plugin: plugin.name, hook }
459
+ ))
460
+ }
461
+ handlerCount += 1
462
+ if (handlerCount > maximumHandlers) {
463
+ return yield* Effect.fail(failure(
464
+ "resource_limit",
465
+ `plugin handlers exceed the limit of ${maximumHandlers}`,
466
+ childPath(`${path}.hooks`, hook)
467
+ ))
468
+ }
469
+ }
470
+ }
471
+
472
+ const seen = new Set<string>()
473
+ for (const { plugin } of selected) {
474
+ if (seen.has(plugin.name)) {
475
+ return yield* Effect.fail(failure(
476
+ "duplicate_name",
477
+ `duplicate plugin name "${plugin.name}"`,
478
+ "$.name",
479
+ { plugin: plugin.name }
480
+ ))
481
+ }
482
+ seen.add(plugin.name)
483
+ }
484
+
485
+ const plugins = Object.freeze(
486
+ selected
487
+ .map(({ plugin }, index) => ({ plugin, index }))
488
+ .sort((left, right) => rank(left.plugin.enforce) - rank(right.plugin.enforce) || left.index - right.index)
489
+ .map(({ plugin }) => plugin)
490
+ )
491
+ const entries: Array<readonly [string, ReadonlyArray<HandlerRecord>]> = []
492
+ for (const hook of Object.keys(known)) {
493
+ const records: Array<{
494
+ readonly record: HandlerRecord
495
+ readonly enforceRank: number
496
+ readonly orderRank: number
497
+ readonly index: number
498
+ }> = []
499
+ for (const [index, plugin] of plugins.entries()) {
500
+ const pluginHooks = plugin.hooks as Readonly<Record<string, unknown>> | undefined
501
+ if (pluginHooks === undefined || !Object.hasOwn(pluginHooks, hook)) continue
502
+ const entry = pluginHooks[hook]
503
+ const record = Object.freeze({ plugin: plugin.name, hook, handler: handlerOf(entry) })
504
+ records.push({ record, enforceRank: rank(plugin.enforce), orderRank: rank(orderOf(entry)), index })
505
+ }
506
+ // Vite's rule, verbatim: the per-hook `order` is the outer partition and
507
+ // the enforce-sorted plugin list supplies the stable order inside it, so a
508
+ // hook marked `order: "pre"` runs ahead of every normal-order handler even
509
+ // when its plugin is `enforce: "post"`.
510
+ const ordered = records
511
+ .sort((left, right) =>
512
+ left.orderRank - right.orderRank || left.enforceRank - right.enforceRank || left.index - right.index
513
+ )
514
+ .map(({ record }) => record)
515
+ if (ordered.length > 0) entries.push([hook, Object.freeze(ordered)])
516
+ }
517
+ const cacheEnvironment = yield* snapshotCacheEnvironment(raw.cacheEnvironment, plugins)
518
+ return Object.freeze({
519
+ plugins,
520
+ handlers: ImmutableMap.make(entries),
521
+ kinds: known,
522
+ parallelConcurrency,
523
+ ...(cacheEnvironment === undefined ? {} : { cacheEnvironment })
524
+ })
525
+ })
526
+
527
+ /**
528
+ * Merges every resolved plugin layer left-to-right and supplies the detached
529
+ * cache environment captured during resolution.
530
+ *
531
+ * @category combinators
532
+ * @since 1.0.0-rc.0
533
+ */
534
+ export const layer = <H>(resolved: Resolved<H>): Layer.Layer<any, PluginError, any> =>
535
+ mergePluginLayers(resolved.plugins, resolved.cacheEnvironment)
package/src/index.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The bounded cell-host plugin kernel: typed hooks, resolution and ordering,
3
+ * and the config pipeline.
4
+ *
5
+ * The public package contract is documented at
6
+ * {@link https://smithers.sh/docs/reference/api/plugin}, with the shipped host catalog in
7
+ * `packages/smithers/agent/src/CellPlugin.ts`. Durable
8
+ * engine extension remains dependency injection; this package does not expose
9
+ * an engine-wide lifecycle registry.
10
+ *
11
+ * @since 1.0.0-rc.0
12
+ */
13
+
14
+ import type * as Effect from "effect/Effect"
15
+ import type { FlowsConfig, ResolvedConfig } from "./Config.ts"
16
+ import type { ParallelHook, WaterfallHook } from "./Hooks.ts"
17
+
18
+ /**
19
+ * The shared kernel's base hook catalog.
20
+ *
21
+ * Declared here, in the package entry point, so that the documented
22
+ * augmentation specifier works: an interface can only be augmented in the
23
+ * module that declares it:
24
+ *
25
+ * ```ts
26
+ * declare module "@smthrs/plugin" {
27
+ * interface FlowsHooks {
28
+ * toolCall: SequentialHook<(ctx: ToolCallContext) => Effect.Effect<Option.Option<ToolOverride>>>
29
+ * }
30
+ * }
31
+ * ```
32
+ *
33
+ * Closed for dispatch, open for augmentation: the kernel dispatches only the
34
+ * config lifecycle below; a host supplies and dispatches its own catalog over
35
+ * the same augmented interface.
36
+ *
37
+ * Both startup hooks are context-free: `Kernel.make` runs them before any
38
+ * plugin layer exists and supplies no services of its own, so a handler that
39
+ * needs a service provides it inside the hook. A host whose startup hooks do
40
+ * require services declares a separate hook interface, and `Kernel.make` then
41
+ * surfaces those requirements as `Kernel.StartupContext<H>`.
42
+ *
43
+ * @category models
44
+ * @since 1.0.0-rc.0
45
+ */
46
+ export interface FlowsHooks {
47
+ readonly config: WaterfallHook<(config: FlowsConfig) => Effect.Effect<Partial<FlowsConfig> | void, any, never>>
48
+ readonly configResolved: ParallelHook<(config: ResolvedConfig) => Effect.Effect<void, any, never>>
49
+ }
50
+
51
+ /**
52
+ * @since 1.0.0-rc.0
53
+ * @category config
54
+ */
55
+ export * as Config from "./Config.ts"
56
+
57
+ /**
58
+ * @since 1.0.0-rc.0
59
+ * @category hooks
60
+ */
61
+ export * from "./Hooks.ts"
62
+
63
+ /**
64
+ * @since 1.0.0-rc.0
65
+ * @category startup
66
+ */
67
+ export * as Kernel from "./Kernel.ts"
68
+
69
+ /**
70
+ * @since 1.0.0-rc.0
71
+ * @category models
72
+ */
73
+ export * from "./Plugin.ts"
74
+
75
+ /**
76
+ * @since 1.0.0-rc.0
77
+ * @category errors
78
+ */
79
+ export * from "./PluginError.ts"
80
+
81
+ /**
82
+ * @since 1.0.0-rc.0
83
+ * @category dispatch
84
+ */
85
+ export * as Plugins from "./Plugins.ts"
86
+
87
+ /**
88
+ * @since 1.0.0-rc.0
89
+ * @category resolution
90
+ */
91
+ export * as Resolve from "./Resolve.ts"