@smthrs/plugin 1.0.0-rc.7 → 1.0.0-rc.9

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.
@@ -7,7 +7,11 @@ sidebar:
7
7
 
8
8
  ## Install the package
9
9
 
10
- Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
10
+ ```bash
11
+ npm install @smthrs/plugin@next effect
12
+ ```
13
+
14
+ The `next` tag carries the 1.0 release candidates.
11
15
 
12
16
  Install `effect` explicitly, in the range `^4.0.4`. The kernel depends on it, but your
13
17
  plugins import `Effect`, `Layer`, and `Option` directly, so `effect` belongs in
@@ -16,7 +16,8 @@ defines the plugins and runs the host. There are no services to provide.
16
16
  ## Prerequisites
17
17
 
18
18
  - Node.js 26.4.0 or later.
19
- - A TypeScript project with the package installed. Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
19
+ - A TypeScript project with the package installed:
20
+ `npm install @smthrs/plugin@next effect`.
20
21
 
21
22
  ## Declare the host's hook catalog
22
23
 
@@ -45,7 +45,8 @@ At startup, `Options.hooks` labelled `config` with a kind other than
45
45
  `waterfall`, or `configResolved` with a kind other than `parallel`; the path
46
46
  names the entry, such as `$options.hooks.config`. At dispatch, a method was
47
47
  called for a hook the catalog declares with a different kind, which only a cast
48
- around the typed dispatcher can reach. In both cases no handler ran.
48
+ around the typed dispatcher can reach, or for a hook a hand-built `Resolved`
49
+ gave handlers without declaring it in `kinds`. In every case no handler ran.
49
50
 
50
51
  **What to change.** Make the catalog agree with the hook interface: spread
51
52
  `engineHooks` for the shared hooks and write each of your own hooks with the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@smthrs/plugin",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.7",
4
+ "version": "1.0.0-rc.9",
5
5
  "license": "MIT",
6
6
  "smthrs": {
7
7
  "group": "agent"
@@ -145,8 +145,8 @@
145
145
  "tag": "next"
146
146
  },
147
147
  "dependencies": {
148
- "@smthrs/canonical": "1.0.0-rc.7",
149
- "@smthrs/flow": "1.0.0-rc.7"
148
+ "@smthrs/canonical": "1.0.0-rc.9",
149
+ "@smthrs/flow": "1.0.0-rc.9"
150
150
  },
151
151
  "devDependencies": {
152
152
  "@effect/language-service": "0.87.2",
@@ -171,7 +171,7 @@
171
171
  "effect": "^4.0.4"
172
172
  },
173
173
  "scripts": {
174
- "lint": "eslint src --max-warnings=0 && dprint check",
174
+ "lint": "eslint src examples --max-warnings=0 && dprint check",
175
175
  "format": "dprint fmt",
176
176
  "build": "node scripts/build.mjs",
177
177
  "check": "tsc -b tsconfig.json && tsc -p tsconfig.test.json --noEmit",
package/src/Plugins.ts CHANGED
@@ -32,8 +32,23 @@ const empty: ReadonlyArray<HandlerRecord> = Object.freeze([])
32
32
  // The typed catalog and the runtime catalog can only drift when a host casts
33
33
  // around `KeysOfKind`; refuse before any handler runs rather than execute a
34
34
  // hook under semantics its declaration never promised.
35
- const kindMismatch = (resolved: Resolved<unknown>, hook: string, kind: HookKind): PluginError | undefined => {
36
- if (!Object.hasOwn(resolved.kinds, hook)) return undefined
35
+ // A hook the catalog does not declare dispatches as empty, unless a
36
+ // hand-built resolution registered handlers for it: those have no declared
37
+ // kind to honor, so they are refused too.
38
+ const kindMismatch = (
39
+ resolved: Resolved<unknown>,
40
+ hook: string,
41
+ kind: HookKind,
42
+ handlers: ReadonlyArray<HandlerRecord>
43
+ ): PluginError | undefined => {
44
+ if (!Object.hasOwn(resolved.kinds, hook)) {
45
+ if (handlers.length === 0) return undefined
46
+ return new PluginError({
47
+ code: "hook_kind_mismatch",
48
+ message: `hook "${hook}" has handlers but is not declared in the runtime catalog`,
49
+ hook
50
+ })
51
+ }
37
52
  const declared = resolved.kinds[hook]
38
53
  if (declared === kind) return undefined
39
54
  return new PluginError({
@@ -125,7 +140,7 @@ export const make = <H = FlowsHooks>(resolved: Resolved<H>): Service<H> => {
125
140
 
126
141
  const sequential = ((hook: string, ...args: Array<unknown>) =>
127
142
  Effect.gen(function*() {
128
- const mismatch = kindMismatch(resolved, hook, "sequential")
143
+ const mismatch = kindMismatch(resolved, hook, "sequential", handlers(hook))
129
144
  if (mismatch !== undefined) return yield* Effect.fail(mismatch)
130
145
  const results: Array<unknown> = []
131
146
  for (const record of handlers(hook)) {
@@ -135,7 +150,7 @@ export const make = <H = FlowsHooks>(resolved: Resolved<H>): Service<H> => {
135
150
  })) as Service<H>["sequential"]
136
151
 
137
152
  const parallel = ((hook: string, ...args: Array<unknown>) => {
138
- const mismatch = kindMismatch(resolved, hook, "parallel")
153
+ const mismatch = kindMismatch(resolved, hook, "parallel", handlers(hook))
139
154
  if (mismatch !== undefined) return Effect.succeed([mismatch])
140
155
  return Effect.forEach(
141
156
  handlers(hook),
@@ -149,7 +164,7 @@ export const make = <H = FlowsHooks>(resolved: Resolved<H>): Service<H> => {
149
164
 
150
165
  const first = ((hook: string, ...args: Array<unknown>) =>
151
166
  Effect.gen(function*() {
152
- const mismatch = kindMismatch(resolved, hook, "first")
167
+ const mismatch = kindMismatch(resolved, hook, "first", handlers(hook))
153
168
  if (mismatch !== undefined) return yield* Effect.fail(mismatch)
154
169
  for (const record of handlers(hook)) {
155
170
  const result = yield* runHandler(record, args)
@@ -171,7 +186,7 @@ export const make = <H = FlowsHooks>(resolved: Resolved<H>): Service<H> => {
171
186
  const waterfall =
172
187
  ((hook: string, initial: unknown, merge: (previous: unknown, patch: unknown) => unknown) =>
173
188
  Effect.gen(function*() {
174
- const mismatch = kindMismatch(resolved, hook, "waterfall")
189
+ const mismatch = kindMismatch(resolved, hook, "waterfall", handlers(hook))
175
190
  if (mismatch !== undefined) return yield* Effect.fail(mismatch)
176
191
  let value = initial
177
192
  for (const record of handlers(hook)) {
@@ -186,13 +201,15 @@ export const make = <H = FlowsHooks>(resolved: Resolved<H>): Service<H> => {
186
201
  message: cause.message,
187
202
  plugin: record.plugin,
188
203
  hook: record.hook,
189
- path: cause.path
204
+ path: cause.path,
205
+ cause
190
206
  })
191
207
  : new PluginError({
192
208
  code: "config_invalid",
193
209
  message: `waterfall hook "${record.hook}" in plugin "${record.plugin}" returned an invalid patch`,
194
210
  plugin: record.plugin,
195
- hook: record.hook
211
+ hook: record.hook,
212
+ cause
196
213
  })
197
214
  })
198
215
  }
package/src/Resolve.ts CHANGED
@@ -161,22 +161,25 @@ const childPath = (path: string, key: string): string => {
161
161
  const ownData = (
162
162
  input: unknown,
163
163
  path: string,
164
- allowed?: ReadonlySet<string>
164
+ allowed?: ReadonlySet<string>,
165
+ fields: { readonly plugin?: string; readonly hook?: string } = {}
165
166
  ): Readonly<Record<string, unknown>> => {
166
167
  if (typeof input !== "object" || input === null || Array.isArray(input)) {
167
- throw invalidPlugin(path, "must be an ordinary record")
168
+ throw invalidPlugin(path, "must be an ordinary record", fields)
168
169
  }
169
170
  const prototype = Object.getPrototypeOf(input)
170
- if (prototype !== Object.prototype && prototype !== null) throw invalidPlugin(path, "must be an ordinary record")
171
+ if (prototype !== Object.prototype && prototype !== null) {
172
+ throw invalidPlugin(path, "must be an ordinary record", fields)
173
+ }
171
174
  const output: Record<string, unknown> = {}
172
175
  for (const key of Reflect.ownKeys(input)) {
173
- if (typeof key !== "string") throw invalidPlugin(path, "must not contain symbol keys")
176
+ if (typeof key !== "string") throw invalidPlugin(path, "must not contain symbol keys", fields)
174
177
  if (allowed !== undefined && !allowed.has(key)) {
175
- throw invalidPlugin(childPath(path, key), "contains an unknown property")
178
+ throw invalidPlugin(childPath(path, key), "contains an unknown property", fields)
176
179
  }
177
180
  const descriptor = Object.getOwnPropertyDescriptor(input, key)
178
181
  if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
179
- throw invalidPlugin(childPath(path, key), "must be an enumerable data property")
182
+ throw invalidPlugin(childPath(path, key), "must be an enumerable data property", fields)
180
183
  }
181
184
  Object.defineProperty(output, key, { value: descriptor.value, enumerable: true })
182
185
  }
@@ -249,7 +252,7 @@ const snapshotHook = (
249
252
  if (entry === undefined) {
250
253
  throw invalidPlugin(path, "must be a function or a hook object", { plugin, hook })
251
254
  }
252
- const values = ownData(entry, path, new Set(["order", "handler"]))
255
+ const values = ownData(entry, path, new Set(["order", "handler"]), { plugin, hook })
253
256
  if (typeof values.handler !== "function") {
254
257
  throw invalidPlugin(`${path}.handler`, "must be a function", { plugin, hook })
255
258
  }
@@ -285,11 +288,11 @@ const snapshotPlugin = <H>(
285
288
 
286
289
  let hooks: Readonly<Record<string, unknown>> | undefined
287
290
  if (values.hooks !== undefined) {
288
- const rawHooks = ownData(values.hooks, `${path}.hooks`)
291
+ const rawHooks = ownData(values.hooks, `${path}.hooks`, undefined, { plugin: name })
289
292
  const snapshot: Record<string, unknown> = Object.create(null)
290
293
  for (const [index, hook] of Object.keys(rawHooks).entries()) {
291
294
  if (!validName(hook)) {
292
- throw invalidPlugin(`${path}.hooks[${index}]`, "contains an invalid hook name")
295
+ throw invalidPlugin(`${path}.hooks[${index}]`, "contains an invalid hook name", { plugin: name })
293
296
  }
294
297
  const entry = rawHooks[hook]
295
298
  Object.defineProperty(snapshot, hook, {
@@ -360,7 +363,7 @@ const flatten = <H>(input: unknown): ReadonlyArray<AdmittedPlugin<H>> => {
360
363
  }
361
364
 
362
365
  const included = <H>(
363
- plugin: FlowsPlugin<H>,
366
+ { path, plugin }: AdmittedPlugin<H>,
364
367
  config: FlowsConfig,
365
368
  target: "engine" | "harness"
366
369
  ): Effect.Effect<boolean, PluginError> => {
@@ -370,14 +373,14 @@ const included = <H>(
370
373
  return Effect.try({
371
374
  try: () => apply(config),
372
375
  catch: () =>
373
- failure("apply_failed", `plugin "${plugin.name}" apply predicate failed`, "$.apply", {
376
+ failure("apply_failed", `plugin "${plugin.name}" apply predicate failed`, `${path}.apply`, {
374
377
  plugin: plugin.name
375
378
  })
376
379
  }).pipe(
377
380
  Effect.flatMap((result) =>
378
381
  typeof result === "boolean"
379
382
  ? Effect.succeed(result)
380
- : Effect.fail(invalidPlugin("$.apply", "predicate must return a boolean", { plugin: plugin.name }))
383
+ : Effect.fail(invalidPlugin(`${path}.apply`, "predicate must return a boolean", { plugin: plugin.name }))
381
384
  )
382
385
  )
383
386
  }
@@ -443,7 +446,7 @@ export const resolve = <H = FlowsHooks>(
443
446
  : invalidPlugin("$", "could not be inspected without executing user code")
444
447
  })
445
448
  const target = raw.target ?? "engine"
446
- const selected = yield* Effect.filter(admitted, ({ plugin }) => included(plugin, config, target))
449
+ const selected = yield* Effect.filter(admitted, (entry) => included(entry, config, target))
447
450
 
448
451
  let handlerCount = 0
449
452
  for (const { path, plugin } of selected) {
@@ -470,24 +473,25 @@ export const resolve = <H = FlowsHooks>(
470
473
  }
471
474
 
472
475
  const seen = new Set<string>()
473
- for (const { plugin } of selected) {
476
+ for (const { path, plugin } of selected) {
474
477
  if (seen.has(plugin.name)) {
475
478
  return yield* Effect.fail(failure(
476
479
  "duplicate_name",
477
480
  `duplicate plugin name "${plugin.name}"`,
478
- "$.name",
481
+ `${path}.name`,
479
482
  { plugin: plugin.name }
480
483
  ))
481
484
  }
482
485
  seen.add(plugin.name)
483
486
  }
484
487
 
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
- )
488
+ const orderedEntries = selected
489
+ .map((entry, index) => ({ entry, index }))
490
+ .sort((left, right) =>
491
+ rank(left.entry.plugin.enforce) - rank(right.entry.plugin.enforce) || left.index - right.index
492
+ )
493
+ .map(({ entry }) => entry)
494
+ const plugins = Object.freeze(orderedEntries.map(({ plugin }) => plugin))
491
495
  const entries: Array<readonly [string, ReadonlyArray<HandlerRecord>]> = []
492
496
  for (const hook of Object.keys(known)) {
493
497
  const records: Array<{
@@ -514,7 +518,7 @@ export const resolve = <H = FlowsHooks>(
514
518
  .map(({ record }) => record)
515
519
  if (ordered.length > 0) entries.push([hook, Object.freeze(ordered)])
516
520
  }
517
- const cacheEnvironment = yield* snapshotCacheEnvironment(raw.cacheEnvironment, plugins)
521
+ const cacheEnvironment = yield* snapshotCacheEnvironment(raw.cacheEnvironment, orderedEntries)
518
522
  return Object.freeze({
519
523
  plugins,
520
524
  handlers: ImmutableMap.make(entries),
@@ -18,24 +18,25 @@ const escapePluginIdentityPart = (value: string): string =>
18
18
 
19
19
  /**
20
20
  * Admits a caller cache environment and prepends one escaped `name@version`
21
- * identity per plugin, in resolved order. An absent input stays absent.
21
+ * identity per plugin, in resolved order. An absent input stays absent. A
22
+ * versionless plugin is refused at the dollar-path it was admitted from.
22
23
  *
23
24
  * @private
24
25
  * @since 1.0.0-rc.0
25
26
  */
26
27
  export const snapshotCacheEnvironment = <H>(
27
28
  input: unknown,
28
- plugins: ReadonlyArray<FlowsPlugin<H>>
29
+ plugins: ReadonlyArray<{ readonly plugin: FlowsPlugin<H>; readonly path: string }>
29
30
  ): Effect.Effect<Action.CacheEnvironment | undefined, PluginError> => {
30
31
  if (input === undefined) return Effect.succeed(undefined)
31
32
  const pluginIdentities: Array<string> = []
32
- for (const plugin of plugins) {
33
+ for (const { path, plugin } of plugins) {
33
34
  if (plugin.version === undefined) {
34
35
  return Effect.fail(
35
36
  new PluginError({
36
37
  code: "cache_environment_invalid",
37
38
  message: `cache environment requires a version for plugin "${plugin.name}"`,
38
- path: "$.version",
39
+ path: `${path}.version`,
39
40
  plugin: plugin.name
40
41
  })
41
42
  )