@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -20,10 +20,20 @@ import {
20
20
  import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
21
21
  import type { TaskExecutor } from '../exec/index.js'
22
22
  import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
23
- import { maskedLine, nearest, type PluginSecrets, SecretBroker, UserError } from '../util/index.js'
23
+ import {
24
+ errorCode,
25
+ maskedLine,
26
+ nearest,
27
+ type PluginSecrets,
28
+ SecretBroker,
29
+ thrownMessage,
30
+ ui,
31
+ UserError,
32
+ } from '../util/index.js'
24
33
  import type { ProjectMeta } from '../workspace/index.js'
25
34
  import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
26
35
  import type { CiContext } from './run-context.js'
36
+ import type { CacheFacts, TaskArtifacts } from './task-artifacts.js'
27
37
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
28
38
 
29
39
  /**
@@ -59,7 +69,8 @@ export interface VxPlugin {
59
69
 
60
70
  /**
61
71
  * The workspace config, before anything is derived from it (concurrency,
62
- * cacheDir, timeout, …). `plugins` is already fixed by the time this runs.
72
+ * cacheDir, timeout, …). `plugins` is already fixed by the time this runs:
73
+ * an edit to it is refused.
63
74
  * Core re-validates after each plugin, as it does a user's file.
64
75
  * `ctx.setBranch` names what the CI knows of the branch: its cache scope
65
76
  * and the commit it is compared with.
@@ -77,7 +88,7 @@ export interface VxPlugin {
77
88
  * (with no config; named by `name`, else its path). A project named by
78
89
  * its path takes `name`; one its config names keeps that name, and a
79
90
  * different name from an earlier plugin is refused, as is a directory
80
- * outside the root. Two projects of one name are refused once the stage
91
+ * outside the root and the name `//` away from it. Two projects of one name are refused once the stage
81
92
  * ends. `deps` and `reach` from every plugin union then; a target that is
82
93
  * no project is refused.
83
94
  */
@@ -92,7 +103,7 @@ export interface VxPlugin {
92
103
  * applied on top of it live. A project with NO config file (a
93
104
  * `discover` plugin added it) is loaded too, as `{ tasks: {} }`, so a
94
105
  * plugin can give it tasks; with no `project` plugin such a project is
95
- * never visited.
106
+ * never visited. Its `name` is fixed by then: a change is refused.
96
107
  */
97
108
  project?(config: ProjectConfig, ctx: ProjectHookContext): void | Promise<void>
98
109
 
@@ -153,13 +164,13 @@ export interface VxPlugin {
153
164
  * on this machine, with the tasks running here right now. Return `false`
154
165
  * to hold it until something finishes (it is asked again then). Asked
155
166
  * many times per run, so it must be cheap and synchronous; a throw, or
156
- * an answer that is a Promise, is reported once and the plugin admits
167
+ * an answer that is a Promise or not a boolean or undefined, is reported once and the plugin admits
157
168
  * from then on — a policy never
158
169
  * breaks a run. Restore-tier hits, tasks on an executor pool and groups
159
170
  * hold no local resources: never asked, never in `ctx.running`. When
160
171
  * several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
161
172
  * packs memory or CPU learns or declares the numbers itself
162
- * (`@vzn/vx-schedule-history` packs what past executions used).
173
+ * (`@vzn/vx-history` packs what past executions used).
163
174
  */
164
175
  admit?(task: TaskNode, ctx: AdmitContext): boolean
165
176
 
@@ -222,8 +233,10 @@ export interface VxPlugin {
222
233
  * (the contract `run({ log })` takes), or `undefined` to leave core's
223
234
  * renderer in place. The first plugin, in declaration order, that
224
235
  * returns one renders the run; the rest are not asked. Not consulted
225
- * when an embedder passed its own `log`. A logger that throws hands the
226
- * rest of the run back to core's renderer with one warning.
236
+ * when an embedder passed its own `log`. A logger that throws or rejects
237
+ * hands the rest of the run back to core's renderer with one warning; a
238
+ * hook that does not answer within the teardown bound (3 s) is passed
239
+ * over with one.
227
240
  */
228
241
  output?(ctx: OutputContext): Logger | undefined | Promise<Logger | undefined>
229
242
 
@@ -239,8 +252,10 @@ export interface VxPlugin {
239
252
  setup?(ctx: PluginSetupContext): void | Promise<void>
240
253
 
241
254
  /**
242
- * Optional teardown at end-of-run (flush a sink, close a socket). Errors
243
- * are logged, never thrown. A run that ran to its end passes its
255
+ * Optional teardown at end-of-run (flush a sink, close a socket), in
256
+ * declaration order, a plugin with `cache` after every other: the
257
+ * others may still write through its remote. Errors are logged, never
258
+ * thrown. A run that ran to its end passes its
244
259
  * `RunReport`; every other exit (a refused setup, a stop) passes none.
245
260
  */
246
261
  teardown?(run?: RunReport): void | Promise<void>
@@ -334,6 +349,20 @@ export interface CommandContext extends BaseContext {
334
349
  * workspace's cache plugins supply, under the run's scope. Opened on first use.
335
350
  */
336
351
  readonly cache: PluginCache
352
+ /**
353
+ * Task entries by key in the workspace's cache, under a run's policy and
354
+ * scope (a laptop writes no remote unless the workspace's policy names
355
+ * it): what a verb that runs a run's tasks on this machine saves and
356
+ * restores through (`TaskArtifacts`). Opened on first use.
357
+ */
358
+ readonly artifacts: TaskArtifacts
359
+ /**
360
+ * `artifacts` read as another machine's run would read them: under its
361
+ * refs, with the access its remotes lent (`CacheFacts`, asked on each
362
+ * request, so a token it lends again replaces the last). Opened on first
363
+ * use; until `facts` answers, under this machine's own.
364
+ */
365
+ artifactsFor(facts: () => CacheFacts | undefined): TaskArtifacts
337
366
  }
338
367
 
339
368
  /** `config` runs before the cache dir is known — it may be what the hook changes. */
@@ -343,17 +372,22 @@ export interface WorkspaceHookContext {
343
372
  }
344
373
 
345
374
  /**
346
- * What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
375
+ * What a CI knows of the run's branch (2026-10-17): `scope`, the
347
376
  * cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
348
377
  * beats the one git names), and `base`, the commit the branch is compared
349
378
  * with (a pull request's base), for a bare `--affected`. `trusted`: the
350
- * run has no scope, whatever git names (a merge queue's run, owner
351
- * 2026-10-18). The scope keys plugin entries only. A later plugin's field wins.
379
+ * run has no scope, whatever git names (a merge queue's run,
380
+ * 2026-10-18). The scope keys plugin entries only. `refs`: the refs a
381
+ * remote reads, in order (`CacheContext.refs`; 2026-10-25: a pull
382
+ * request its head branch, then its base, then the default branch; a
383
+ * merge queue or train only its base); unset, git's. A later plugin's
384
+ * field wins.
352
385
  */
353
386
  export interface BranchFacts {
354
387
  scope?: string
355
388
  base?: string
356
389
  trusted?: boolean
390
+ refs?: readonly string[]
357
391
  }
358
392
 
359
393
  export interface ConfigContext extends WorkspaceHookContext {
@@ -437,7 +471,7 @@ export interface FingerprintClaim {
437
471
  * by `--affected` with the file's bytes at the base ref and in the
438
472
  * working tree (`null` where it does not exist). Return `undefined` to
439
473
  * select every project — the answer a claimant gives when it cannot
440
- * tell.
474
+ * tell. A name that is not one of `ctx.projects` is refused.
441
475
  */
442
476
  affected(
443
477
  change: FingerprintChange,
@@ -449,9 +483,14 @@ export interface FingerprintClaim {
449
483
  * path and hashes nowhere, or a `file:` dependency's directory. A change
450
484
  * to one, or under one, asks `affected` about the file naming it, with
451
485
  * `change.extras`. Without it, a `bun.lock` patch edit selects every
452
- * project.
486
+ * project. Asked on every `--affected` diff, so a claimant that memoises
487
+ * its parse answers from `ctx.cacheDir`.
453
488
  */
454
- extraFiles?(file: string, bytes: Uint8Array): readonly string[]
489
+ extraFiles?(
490
+ file: string,
491
+ bytes: Uint8Array,
492
+ ctx: FingerprintContext,
493
+ ): readonly string[] | Promise<readonly string[]>
455
494
  }
456
495
 
457
496
  export interface FingerprintChange {
@@ -492,14 +531,33 @@ export interface ScheduleHookContext extends BaseContext {
492
531
  readonly localCache: Cache
493
532
  /** The run's branch (`currentBranch`), the timings a forecast reads first: pass it to `LocalHistoryProvider`. */
494
533
  readonly branch: string | null
534
+ /** The refs the run reads, in order (`CacheContext.refs`): a remote's timings are read by them too. */
535
+ readonly refs: readonly string[]
495
536
  /** This plugin's entries in the workspace's cache, as `setup` has them. */
496
537
  readonly cache: PluginCache
497
538
  /**
498
539
  * Replace this plugin's weights while the run goes on: the tasks not yet
499
540
  * started re-rank at once. For weights that arrive late (timings fetched
500
541
  * from a remote). Ignored in a plan and once the run's tasks are done.
542
+ * Given a promise, the weights it resolves to, if any, when it does:
543
+ * `refining()` waits for it meanwhile.
544
+ */
545
+ refine(
546
+ weights: ReadonlyMap<string, number> | Promise<ReadonlyMap<string, number> | undefined>,
547
+ ): void
548
+ /**
549
+ * Settles once every refine promised so far (a promise to `refine`) has
550
+ * landed or failed, its listeners called: a plugin that sizes the run by
551
+ * the fetched data waits for it here rather than guessing without it.
552
+ */
553
+ refining(): Promise<void>
554
+ /**
555
+ * Calls `listener` each time another plugin `refine`s: the late data it
556
+ * waited for (timings fetched from a remote) has landed in history, so a
557
+ * plugin that read history in `schedule` may read it again. Never called
558
+ * in a plan.
501
559
  */
502
- refine(weights: ReadonlyMap<string, number>): void
560
+ onRefine(listener: () => void): void
503
561
  }
504
562
 
505
563
  /** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
@@ -515,6 +573,13 @@ export interface CacheContext extends BaseContext {
515
573
  * keys or scopes its entries by them; core keys task entries by none.
516
574
  */
517
575
  readonly refs: readonly string[]
576
+ /**
577
+ * The token a run lent this plugin's remote (`RemoteCacheLayer.lend`),
578
+ * on a machine that runs that run's tasks (`CommandContext.artifactsFor`);
579
+ * undefined anywhere else. Read it per request: a run lends again before
580
+ * a token ends. A plugin uses it only when it has no access of its own.
581
+ */
582
+ readonly lent?: () => string | undefined
518
583
  }
519
584
 
520
585
  export interface OutputContext extends BaseContext {
@@ -527,6 +592,14 @@ export interface OutputContext extends BaseContext {
527
592
  export interface ExecutorContext extends BaseContext {
528
593
  /** The run's worker count — an executor that paces itself reads it here. */
529
594
  readonly concurrency: number
595
+ /** Task entries by key in the run's cache, for a task another machine ran (`TaskArtifacts`). */
596
+ readonly artifacts: TaskArtifacts
597
+ /**
598
+ * What a machine that runs this run's tasks reads the cache under
599
+ * (`CacheFacts`): the run's refs, and each remote's lent access, asked
600
+ * now. Ask again before `expiresAt`.
601
+ */
602
+ cacheFacts(): Promise<CacheFacts>
530
603
  }
531
604
 
532
605
  export interface PluginContext extends PluginSetupContext {
@@ -569,7 +642,7 @@ export interface PluginHookHandlers {
569
642
  }
570
643
 
571
644
  export interface Plugin {
572
- /** Logged on errors; the convention is `'org/plugin-name'`. */
645
+ /** The plugin's package name (`definePlugin` sets it), logged on errors. */
573
646
  readonly name: string
574
647
  /**
575
648
  * Called once at the start of every run. Register hooks via
@@ -598,10 +671,15 @@ export interface PluginOrigin {
598
671
 
599
672
  const packageNameByDir = new Map<string, string>()
600
673
 
601
- /** The name of the nearest `package.json` above `dir` — the one that owns it. */
674
+ /** The name of the nearest named `package.json` above `dir` — the one that owns it. */
602
675
  function pluginPackageName(dir: string): string {
603
676
  const memo = packageNameByDir.get(dir)
604
677
  if (memo !== undefined) return memo
678
+ // A package.json with no `name` below the package root is no package:
679
+ // dual builds (tshy) write `{ "type": "module" }` into `dist/esm/`. The
680
+ // walk passes it, as Vite's package lookup does; with no named one
681
+ // above, the nearest is refused.
682
+ let nameless: string | undefined
605
683
  for (let d = dir; ;) {
606
684
  let text: string | undefined
607
685
  try {
@@ -622,18 +700,22 @@ function pluginPackageName(dir: string): string {
622
700
  )
623
701
  }
624
702
  const name = pkg?.name
625
- if (typeof name !== 'string' || name.length === 0) {
703
+ if (name === undefined) nameless ??= path.join(d, 'package.json')
704
+ else if (typeof name !== 'string' || name.length === 0) {
626
705
  throw new UserError(
627
706
  `definePlugin: ${path.join(d, 'package.json')} has no name — a plugin is a package, and its name is the package's`,
628
707
  )
708
+ } else {
709
+ packageNameByDir.set(dir, name)
710
+ return name
629
711
  }
630
- packageNameByDir.set(dir, name)
631
- return name
632
712
  }
633
713
  const parent = path.dirname(d)
634
714
  if (parent === d) {
635
715
  throw new UserError(
636
- `definePlugin: no package.json above ${dir} — a plugin is a package, and its name is the package's`,
716
+ nameless === undefined
717
+ ? `definePlugin: no package.json above ${dir} — a plugin is a package, and its name is the package's`
718
+ : `definePlugin: ${nameless} has no name — a plugin is a package, and its name is the package's`,
637
719
  )
638
720
  }
639
721
  d = parent
@@ -643,10 +725,24 @@ function pluginPackageName(dir: string): string {
643
725
  /**
644
726
  * The one way to make a plugin: `definePlugin(import.meta, { ...hooks })`.
645
727
  * The name is read from the package the calling module belongs to — the
646
- * nearest `package.json` above it — and stamped where the workspace loader
728
+ * nearest `package.json` above it that has a name — and stamped where the workspace loader
647
729
  * checks for it, so a plugin cannot be named anything but its package.
648
730
  */
649
731
  export function definePlugin(origin: PluginOrigin, hooks: PluginHooks): VxPlugin {
732
+ // The hooks are copied by spread, which takes own fields only: a class
733
+ // instance's methods stayed on its prototype and the plugin loaded
734
+ // without them, saying nothing.
735
+ const proto: unknown =
736
+ hooks !== null && typeof hooks === 'object' ? Object.getPrototypeOf(hooks) : undefined
737
+ if (proto !== Object.prototype && proto !== null) {
738
+ const got =
739
+ hooks === null || typeof hooks !== 'object'
740
+ ? String(hooks)
741
+ : `a ${(hooks as object).constructor?.name || 'class instance'}`
742
+ throw new UserError(
743
+ `definePlugin: the hooks must be a plain object ({ setup() { … } }), got ${got}`,
744
+ )
745
+ }
650
746
  if ('name' in hooks) {
651
747
  throw new UserError(`definePlugin: a plugin's name is its package name — drop the 'name' field`)
652
748
  }
@@ -668,9 +764,11 @@ type OptionKind<V> = [V] extends [string]
668
764
  ? 'boolean'
669
765
  : [V] extends [(...args: never[]) => unknown]
670
766
  ? 'function'
671
- : [V] extends [object]
672
- ? 'object'
673
- : 'any'
767
+ : [V] extends [readonly unknown[]]
768
+ ? 'array'
769
+ : [V] extends [object]
770
+ ? 'object'
771
+ : 'any'
674
772
 
675
773
  /**
676
774
  * Every option a factory takes, each with the one kind its type allows
@@ -702,7 +800,8 @@ export function refuseUnknownOptions<T>(
702
800
  const known = kinds as Readonly<Record<string, string>>
703
801
  const names = Object.keys(known)
704
802
  for (const [key, value] of Object.entries(options)) {
705
- const kind = known[key]
803
+ // Own keys only: the record's prototype answers `constructor`.
804
+ const kind = Object.hasOwn(known, key) ? known[key] : undefined
706
805
  if (kind === undefined) {
707
806
  const near = nearest(key, names)
708
807
  throw new UserError(
@@ -714,12 +813,14 @@ export function refuseUnknownOptions<T>(
714
813
  const ok =
715
814
  kind === 'object'
716
815
  ? value !== null && typeof value === 'object' && !Array.isArray(value)
717
- : typeof value === kind
816
+ : kind === 'array'
817
+ ? Array.isArray(value)
818
+ : typeof value === kind
718
819
  if (!ok) {
719
820
  const got =
720
821
  typeof value === 'function' ? 'a function' : (JSON.stringify(value) ?? String(value))
721
822
  throw new UserError(
722
- `${factory} option "${key}" must be ${kind === 'object' ? 'an' : 'a'} ${kind}, got ${got}`,
823
+ `${factory} option "${key}" must be ${kind === 'object' || kind === 'array' ? 'an' : 'a'} ${kind}, got ${got}`,
723
824
  )
724
825
  }
725
826
  }
@@ -731,7 +832,7 @@ export interface InstallPluginsArgs {
731
832
  cacheDir: string
732
833
  bus: EventBus
733
834
  /**
734
- * Where plugin-throw warnings go. Defaults to console.error; passing
835
+ * Where plugin-throw warnings go. Defaults to `ui.say` on stderr; passing
735
836
  * a callback lets the orchestrator funnel into the framed-output
736
837
  * `run:status` channel.
737
838
  */
@@ -757,8 +858,9 @@ export class PluginSetupError extends UserError {
757
858
  constructor(
758
859
  readonly plugin: Plugin,
759
860
  message: string,
861
+ code = 'VX_E_REFUSED',
760
862
  ) {
761
- super(message)
863
+ super(message, code)
762
864
  }
763
865
  }
764
866
 
@@ -774,7 +876,7 @@ export class PluginSetupError extends UserError {
774
876
  */
775
877
  export async function installPlugins(args: InstallPluginsArgs): Promise<() => void> {
776
878
  const { plugins, bus, workspaceRoot, cacheDir } = args
777
- const warn = args.warn ?? ((m) => console.error(m))
879
+ const warn = args.warn ?? ((m) => ui.say('warn', m))
778
880
  const secrets = args.secrets ?? new SecretBroker(args.ci ?? false, warn)
779
881
  const disposers: Array<() => void> = []
780
882
  // By object, not name: one package can export two plugins.
@@ -796,7 +898,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
796
898
  if (disabled.has(plugin)) return
797
899
  disabled.add(plugin)
798
900
  warn(
799
- `[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
901
+ `[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${thrownMessage(err)}`,
800
902
  )
801
903
  }
802
904
  const ctx: PluginContext = {
@@ -813,6 +915,11 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
813
915
  bus: {
814
916
  emit: (event) => bus.emit(event),
815
917
  subscribe(subscriber) {
918
+ if (typeof subscriber !== 'function') {
919
+ throw new Error(
920
+ `ctx.bus.subscribe: the subscriber is ${subscriber === null ? 'null' : typeof subscriber}, not a function`,
921
+ )
922
+ }
816
923
  // Isolated like an `on` handler: the bus itself swallows a throw.
817
924
  const dispose = bus.subscribe((event) => {
818
925
  if (disabled.has(plugin)) return
@@ -837,6 +944,12 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
837
944
  `ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
838
945
  )
839
946
  }
947
+ // Taken, it threw at the first event and switched the whole plugin off.
948
+ if (typeof handler !== 'function') {
949
+ throw new Error(
950
+ `ctx.on: the handler for '${hook}' is ${handler === null ? 'null' : typeof handler}, not a function`,
951
+ )
952
+ }
840
953
  const onFail = (err: unknown): void => fail(hook, err)
841
954
  const dispose = bus.subscribe((event) => {
842
955
  if (disabled.has(plugin)) return
@@ -893,9 +1006,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
893
1006
  throw new PluginSetupError(
894
1007
  plugin,
895
1008
  // Masked at the source, as plugin-host.ts's `safe()` does (L-11).
896
- maskedLine(
897
- `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
898
- ),
1009
+ maskedLine(`plugin '${plugin.name}' failed in setup: ${thrownMessage(err)}`),
1010
+ // The plugin's own code survives, as `safe()` keeps it.
1011
+ errorCode(err) === 'VX_E_INTERNAL' ? 'VX_E_REFUSED' : errorCode(err),
899
1012
  )
900
1013
  }
901
1014
  }