@softize/opus 22.0.0 → 22.2.0

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 (33) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bin/cli.mjs +18 -1
  3. package/bin/lib/check.mjs +28 -2
  4. package/bin/lib/gen-manifest.mjs +2 -0
  5. package/bin/lib/gen-runner.mjs +5 -0
  6. package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
  7. package/docs/adr/0041-actions-declare-effect-and-personal-data.md +139 -0
  8. package/docs/protocol.md +15 -2
  9. package/package.json +5 -1
  10. package/registry/skills/create-opus-action/SKILL.md +7 -2
  11. package/registry/skills/create-opus-action/references/contract-and-binding.md +11 -4
  12. package/registry/skills/create-opus-action/references/evaluations.md +3 -0
  13. package/registry/skills/create-opus-action/scripts/scaffold.mjs +6 -4
  14. package/registry/skills/test-opus-action/SKILL.md +2 -2
  15. package/registry/skills/test-opus-action/references/harness.md +2 -2
  16. package/registry/templates/app/src/App.tsx +1 -1
  17. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
  18. package/registry/templates/app/src/domains/tasks/actions/list.ts +5 -5
  19. package/registry/templates/app/vite.config.ts +3 -2
  20. package/src/core/action-nature.ts +40 -0
  21. package/src/core/audit.ts +12 -4
  22. package/src/core/domain.ts +2 -2
  23. package/src/core/index.ts +2 -0
  24. package/src/core/runtime.ts +56 -1
  25. package/src/core/types.ts +46 -20
  26. package/src/design/index.ts +179 -0
  27. package/src/mcp/index.ts +6 -0
  28. package/src/testing/fake.ts +2 -2
  29. package/src/testing/index.ts +7 -3
  30. package/src/ui/docs/content/actions.md +79 -44
  31. package/src/ui/docs/content/mcp.md +6 -0
  32. package/src/ui/docs/content/testing.md +9 -25
  33. package/src/vite/design.ts +133 -23
@@ -206,9 +206,13 @@ export interface TestableAction<In, Out, ParsedIn = In> {
206
206
  authorize?: AuthorizeSpec<ParsedIn>
207
207
  input: Schema<In, ParsedIn>
208
208
  output: Schema<Out> | Schema<unknown, Out>
209
- /** Opcional: contrato puro (sem handler) executa via `mockHandler` em modo design. */
209
+ /** Opcional: contrato puro (sem handler) só executa pelo `mockHandler` obsoleto. */
210
210
  handler?: HandlerFn<ParsedIn, Out>
211
- /** Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime. */
211
+ /**
212
+ * Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime.
213
+ *
214
+ * @deprecated Desde a 22.1.0 (ADR 0037); teste a action ligada ao handler.
215
+ */
212
216
  mockHandler?: HandlerFn<ParsedIn, Out>
213
217
  }
214
218
 
@@ -329,7 +333,7 @@ export async function runAction<In, Out, ParsedIn = In>(
329
333
  throw error({
330
334
  code: 'testing.no_handler',
331
335
  category: 'internal',
332
- message: `Action "${action.name}" não tem handler executável — contrato puro roda em modo design (OPUS_MODE=design) com mockHandler; fora dele, teste o ActionDef do bindAction`,
336
+ message: `Action "${action.name}" não tem handler executável: teste o ActionDef produzido pelo bindAction. Os dados do modo design são gerados pelo plugin opusDesign() e não são comportamento da action.`,
333
337
  })
334
338
  }
335
339
  const raw = await handlerToRun(ctx, parsed, loaded)
@@ -132,7 +132,7 @@ nessa estrutura. Os grupos seguem esta sequência; dentro de cada grupo, a ordem
132
132
  ```text
133
133
  name, kind
134
134
  → label, title, summary, description, icon, color, messages, tags, examples, errors
135
- → input, output
135
+ → input, output, effect, personalData
136
136
  → public, requires, authorize, loads
137
137
  → handler, mockHandler
138
138
  → audit, confirm, invalidates, rateLimit, idempotency
@@ -146,60 +146,97 @@ Declare `input` e `output` com `z.object` e use os tipos lógicos do catálogo e
146
146
  validações: `z.object({ tag: t.slug().zod(), contato: t.email().zod() })`. O `.zod()` devolve o
147
147
  schema zod de cada tipo lógico (slug, email, phone, money…), importado de `@softize/opus/schema/zod`.
148
148
 
149
+ ## Declarar o que a action faz com os dados
150
+
151
+ Dois fatos sobre a action valem para qualquer consumidor, da interface à IA, e ficam declarados no
152
+ contrato:
153
+
154
+ - **`effect`** diz se a action só lê (`'read'`) ou altera estado (`'write'`). `list` e `view` já são
155
+ leitura, porque são servidas por `GET`, e não precisam declarar. `simple` e `form` declaram,
156
+ porque o `kind` não diz isso: uma análise sob demanda costuma ser `simple` e só ler.
157
+ - **`personalData`** diz se a resposta contém dados que identificam pessoas, como nome, documento,
158
+ contato, placa ou texto livre sobre alguém. `false` também é uma declaração.
159
+
160
+ ```ts
161
+ export const salesPerformanceContract = defineContract({
162
+ name: 'sales.performance',
163
+ kind: 'simple',
164
+ input: z.object({ period: z.string() }),
165
+ output: z.object({ total: z.number() }),
166
+ effect: 'read',
167
+ personalData: false,
168
+ })
169
+ ```
170
+
171
+ Com `personalData: true`, toda execução bem-sucedida fica marcada no registro de auditoria com a
172
+ quantidade de registros devolvidos, inclusive quando a action declara `audit: false`; nesse caso o
173
+ registro é mínimo, sem entrada nem saída. No modo design, a resposta simulada não é marcada. Um
174
+ host de IA pode usar `effect` para decidir o que oferecer ao modelo sem procedimento, e o MCP publica
175
+ a anotação `readOnlyHint`. O `opus check` avisa quantas actions ainda não declaram os dois fatos; uma
176
+ major futura passará a exigi-los.
177
+
149
178
  A autorização é fechada por padrão: sem `public: true`, a action exige usuário autenticado; com
150
179
  `authorize`, exige também a permissão declarada. `automatable: true` permite que um
151
180
  [schedule](/docs/scheduler) execute a action como o sistema, sem abri-la a chamadores anônimos.
152
181
 
153
182
  ## Prototipar com dados simulados
154
183
 
155
- Para desenhar a interface antes de o backend existir, declare um `mockHandler` no contrato. Com
156
- `OPUS_MODE=design`, o runtime executa o `mockHandler` no lugar do `handler`; sem ele, usa o
157
- `handler` real. Como o mock fica no contrato, a mesma declaração serve ao design e, depois, ao
158
- handler real, e o mock não precisa acessar `ctx.db`.
184
+ Para desenhar a interface antes de o backend existir, rode o app em modo design. Nesse modo, toda
185
+ action responde sem que o contrato declare nada: a resposta é gerada pelo `fake` a partir do
186
+ `output` do contrato (ADR 0037).
187
+
188
+ - A mesma requisição recebe sempre a mesma resposta.
189
+ - Uma `list` devolve três páginas do tamanho pedido em `limit` (20 quando a entrada não pede, até
190
+ 100) e respeita a paginação declarada. Busca e filtros não filtram o dado gerado.
191
+ - Quando a entrada e a saída têm `id`, a saída repete o `id` da entrada, desde que o schema da saída
192
+ aceite esse valor. Assim, abrir um item da lista mostra o mesmo identificador.
159
193
 
160
- `fake` e `fakeMany` geram dados determinísticos a partir do próprio schema (ver
161
- [Testes de action](/docs/testing)). Em `list`, o `mockHandler` devolve a página inteira, com
162
- `items`, `cursor` e `total`:
194
+ O dado gerado tem o formato certo, mas não segue a regra de negócio: os valores de um enum são
195
+ sorteados, e uma listagem e um detalhe só coincidem no `id`. Quando o design precisa de coerência,
196
+ como um item em cada situação ou algo criado que aparece na lista, escreva um cenário. Ele fica fora
197
+ do contrato e é ligado à action pelo nome:
163
198
 
164
199
  ```ts
165
- import { bindAction, defineContract } from '@softize/opus/core'
166
- import { entityRowSchema } from '@softize/opus/schema'
167
- import { fake, fakeMany } from '@softize/opus/testing'
168
- import { z } from 'zod'
169
- import { EventEntity } from '../entity.ts'
200
+ import { defineDesignScenario } from '@softize/opus/design'
201
+ import { eventGet, eventList } from '../domains/event/contract.ts'
202
+
203
+ // Valor fixo: o caso mais comum.
204
+ export const eventsInEachState = defineDesignScenario(eventList, {
205
+ items: [
206
+ { id: 'e1', title: 'Revisão agendada', status: 'scheduled' },
207
+ { id: 'e2', title: 'Revisão perdida', status: 'lost' },
208
+ ],
209
+ cursor: { next: null },
210
+ total: 2,
211
+ })
170
212
 
171
- // `entityRowSchema` deriva da entidade o schema de uma linha.
172
- const eventRowSchema = entityRowSchema(EventEntity)
213
+ // Função: quando a resposta depende da entrada.
214
+ export const eventDetail = defineDesignScenario(eventGet, (input) => ({
215
+ id: input.id,
216
+ title: 'Revisão agendada',
217
+ status: 'scheduled',
218
+ }))
219
+ ```
173
220
 
174
- export const eventGet = defineContract({
175
- name: 'event.get',
176
- kind: 'view',
177
- input: z.object({ id: z.string().uuid() }),
178
- output: eventRowSchema,
179
- mockHandler: () => fake(eventRowSchema),
180
- })
221
+ A resposta é tipada pela entrada e pela saída do contrato; em `list`, é a página inteira. Informe ao
222
+ plugin onde os cenários estão, com caminhos ou globs:
223
+ `opusDesign({ scenarios: 'design/scenarios/*.ts' })`. O cenário vence a resposta gerada. Um cenário
224
+ de uma action que não existe, ou dois cenários para a mesma action, impedem o runtime de design de
225
+ subir, e o erro lista os nomes.
181
226
 
182
- export const eventList = defineContract({
183
- name: 'event.list',
184
- kind: 'list',
185
- input: z.object({}),
186
- output: eventRowSchema,
187
- paginate: 'cursor',
188
- mockHandler: async () => ({ items: fakeMany(eventRowSchema, 20), cursor: { next: null }, total: 20 }),
189
- })
227
+ O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
228
+ aplicação: podem ficar numa pasta ignorada pelo git e ser escritos durante a sessão de design,
229
+ quando forem necessários.
190
230
 
191
- // Depois, o servidor liga o handler real ao mesmo contrato.
192
- export const eventGetImpl = bindAction(eventGet, {
193
- handler: async (ctx, input) =>
194
- ctx.db.selectFrom('events').selectAll().where('id', '=', input.id).executeTakeFirstOrThrow(),
195
- })
196
- ```
231
+ `mockHandler` está obsoleto desde a 22.1.0 e sai na próxima major. Enquanto existir, ele ainda
232
+ responde no modo design, abaixo do cenário e acima da resposta gerada, e o `opus check` conta
233
+ quantas actions ainda o declaram. Para migrar, apague o `mockHandler`; se a resposta dele importar
234
+ para o design, transforme-a em cenário.
197
235
 
198
- O `mockHandler` roda no servidor, não no navegador. O plugin `opusDesign()`, de
199
- `@softize/opus/vite`, monta o runtime do Opus dentro do dev server e responde `/api` no próprio
200
- processo. No modo design, qualquer `server.proxy` para um backend externo fica desligado: um prefixo
201
- que ia para o proxy e não tem action correspondente responde 503 em envelope, sem alcançar o
202
- backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
236
+ O plugin `opusDesign()`, de `@softize/opus/vite`, monta o runtime do Opus dentro do dev server e
237
+ responde `/api` no próprio processo. No modo design, qualquer `server.proxy` para um backend externo
238
+ fica desligado: um prefixo que ia para o proxy e não tem action correspondente responde 503 em
239
+ envelope, sem alcançar o backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
203
240
 
204
241
  ```ts
205
242
  // vite.config.ts — o plugin só carrega no modo design, por import dinâmico.
@@ -233,9 +270,7 @@ nem reiniciar o dev server. Quando uma action não é encontrada, o plugin recon
233
270
  vez por ciclo de mudança e tenta de novo; se a action continuar ausente, a resposta indica o
234
271
  `entry` a conferir.
235
272
 
236
- Como o contrato vai para os dois lados, o `mockHandler` também entra no bundle da web, onde nunca é
237
- chamado. Não o condicione com `import.meta.env` no contrato: essa variável não existe no Node e
238
- quebraria o servidor.
273
+ Os cenários não entram no bundle da web: eles ficam fora do contrato, e só o plugin os carrega.
239
274
 
240
275
  ## Consumir o contrato na interface
241
276
 
@@ -56,6 +56,12 @@ entrada. Em `_meta`, cada tool traz os
56
56
  [Produtos de Dados](/docs/data-products) relacionados: `com.softize.opus/data-products` lista os
57
57
  identificadores e `com.softize.opus/data-product-labels`, o nome legível de cada um.
58
58
 
59
+ Quando a action declara o que faz com os dados, a tool traz também as anotações padrão do MCP:
60
+ `readOnlyHint` vem de `effect`. Numa action que escreve, `destructiveHint` vai sempre explícito, a
61
+ partir de `ai.destructive`, porque o MCP trata a ausência numa escrita como destrutiva.
62
+ Em `_meta`, `com.softize.opus/effect` repete o efeito e `com.softize.opus/personal-data` diz se a
63
+ resposta contém dado pessoal. Veja [Actions](/docs/actions).
64
+
59
65
  `CallTool` recusa com erro qualquer nome fora dessa lista; os demais seguem por `runtime.execute`.
60
66
  A exposição declarada em `ai` decide o que o cliente MCP pode executar, e a autorização do contexto
61
67
  continua valendo sobre ela. Para um acesso somente de leitura, exponha apenas actions de leitura.
@@ -15,8 +15,8 @@ pulada, porque o schema descreve o item e o handler devolve a página. Falhas us
15
15
  `ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated`,
16
16
  `auth.system_forbidden` e `auth.forbidden`.
17
17
 
18
- Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e só
19
- executa em modo design, descrito abaixo.
18
+ Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e
19
+ falha com `testing.no_handler`.
20
20
 
21
21
  ```ts
22
22
  import { runAction } from '@softize/opus/testing'
@@ -86,29 +86,13 @@ const { output, emitted, logged } = await runAction(taskDone, { id: '1' }, { use
86
86
  expect(emitted).toEqual([{ event: 'task.done', data: { id: '1' } }])
87
87
  ```
88
88
 
89
- ## Testar em modo design
89
+ ## Dados do modo design
90
90
 
91
- Com `OPUS_MODE=design`, `runAction` executa o `mockHandler` quando ele existe, como o runtime do
92
- modo design. Nesse modo, o contrato puro também é testável. Fora dele, um contrato sem handler falha
93
- com `testing.no_handler`.
94
-
95
- ```ts
96
- import { afterEach, it } from 'vitest'
97
- import { runAction } from '@softize/opus/testing'
98
- import { Task, taskList } from './list.ts'
99
-
100
- afterEach(() => {
101
- // A variável vale para o processo inteiro; limpe para não afetar outros testes.
102
- delete process.env['OPUS_MODE']
103
- })
104
-
105
- it('o mock devolve itens válidos para o schema', async () => {
106
- process.env['OPUS_MODE'] = 'design'
107
- const { output } = await runAction(taskList, {}, { user: { id: 'u1' } })
108
- // `list` não valida a saída no pipeline: confira item a item.
109
- for (const item of output.items) expect(Task.safeParse(item).success).toBe(true)
110
- })
111
- ```
91
+ Os dados que o modo design mostra não são comportamento da action e não precisam de teste de
92
+ unidade: o plugin `opusDesign()` os gera a partir do contrato (ver
93
+ [Actions](/docs/actions)). Enquanto o `mockHandler` existir, obsoleto desde a 22.1.0,
94
+ `runAction` com `OPUS_MODE=design` ainda o executa, como o runtime. Teste a action ligada ao
95
+ handler em vez dele.
112
96
 
113
97
  ## memStorage
114
98
 
@@ -126,7 +110,7 @@ expect(storage.keys()).toEqual(['notas/9.pdf'])
126
110
  ## fake — dados gerados pelo schema
127
111
 
128
112
  `fake(schema)` e `fakeMany(schema, n)` geram dados sintéticos determinísticos que satisfazem o
129
- schema zod. Servem ao `mockHandler` do modo design e a fixtures de teste. A mesma `seed` produz a
113
+ schema zod. Servem à resposta gerada do modo design e a fixtures de teste. A mesma `seed` produz a
130
114
  mesma saída, sem `Math.random`. Os valores seguem as validações do zod (email, uuid, url, datetime)
131
115
  e o nome do campo (email, id, `*At` como data, telefone, CPF/CNPJ, nome…), com conteúdo em pt-BR.
132
116
 
@@ -6,7 +6,7 @@
6
6
  * Antes, o dev server proxeava `/api` pra um backend real — o que vaza escrita
7
7
  * pra prod e exige infra de pé pra desenhar tela. Com o plugin, `vite --mode
8
8
  * design` monta o runtime (driver `nodeServer`) no próprio dev server: os
9
- * `mockHandler` dos contratos respondem (`OPUS_MODE=design`), qualquer proxy
9
+ * actions respondem pelos dados de design (ADR 0037), qualquer proxy
10
10
  * configurado é DESLIGADO (prefixo ex-proxy sem cobertura responde 503 em
11
11
  * envelope, nunca vaza), e o Maestro sonda `GET /__opus/design` pra saber que
12
12
  * o preview está montado.
@@ -44,6 +44,7 @@ import {
44
44
  isDomainConfig,
45
45
  isReaction,
46
46
  isSchedule,
47
+ type ActionContext,
47
48
  type ActionDef,
48
49
  type AuthAdapter,
49
50
  type ErrorInput,
@@ -53,6 +54,7 @@ import {
53
54
  type User,
54
55
  } from '../core/index.ts'
55
56
  import { readPackageVersion } from '../core/package-version.ts'
57
+ import { designResponse, isDesignScenario, type DesignScenario } from '../design/index.ts'
56
58
  import { mittEvents } from '../events/drivers/mitt.ts'
57
59
  import { nodeServer, type NodeServerHandle } from '../server/drivers/node.ts'
58
60
  import { serializeResult } from '../server/index.ts'
@@ -84,6 +86,14 @@ export interface OpusDesignOptions {
84
86
  */
85
87
  entry?: string | string[]
86
88
 
89
+ /**
90
+ * Cenários de design (ADR 0037): módulos que exportam `defineDesignScenario(contrato, resposta)`.
91
+ * Aceita caminhos e globs, como `entry`. O cenário vence o `mockHandler` e a resposta padrão
92
+ * gerada pelo `fake`; cenário de action inexistente impede o boot, com a lista no erro.
93
+ * Sem default: o projeto decide onde os cenários ficam e se são versionados.
94
+ */
95
+ scenarios?: string | string[]
96
+
87
97
  /** Prefixo do path. Default '/api'. */
88
98
  apiPrefix?: string
89
99
 
@@ -145,10 +155,14 @@ const PROBE_PATH = '/__opus/design'
145
155
  // deixá-los chegar no handler (moram fora do apiPrefix).
146
156
  const RUNTIME_ENDPOINT_PATHS = new Set(['/health', '/ready', '/openapi.json'])
147
157
 
158
+ /** De onde vem a resposta de cada action no modo design, na ordem de precedência. */
159
+ type DesignSource = 'scenario' | 'mock' | 'handler' | 'generated'
160
+
148
161
  interface DesignState {
149
162
  handle: NodeServerHandle
150
163
  runtime: Runtime
151
164
  actions: ActionDef[]
165
+ sources: Map<string, DesignSource>
152
166
  }
153
167
 
154
168
  export function opusDesign(options: OpusDesignOptions = {}): Plugin {
@@ -156,6 +170,12 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
156
170
  const entries = Array.isArray(options.entry)
157
171
  ? options.entry
158
172
  : [options.entry ?? './opus.config.ts']
173
+ const scenarioEntries =
174
+ options.scenarios === undefined
175
+ ? []
176
+ : Array.isArray(options.scenarios)
177
+ ? options.scenarios
178
+ : [options.scenarios]
159
179
  const devBackend = options.devBackend === true
160
180
  // Mais-específico primeiro: prefixo mais longo vence.
161
181
  const stubEntries = Object.entries(options.stubs ?? {}).sort(
@@ -194,8 +214,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
194
214
  exProxyPrefixes = Object.keys(config.server?.proxy ?? {})
195
215
  // 2) Mata o proxy: fecha o vazamento preview→prod pra QUALQUER prefixo.
196
216
  if (config.server?.proxy !== undefined) config.server.proxy = undefined
197
- // 3) O execute() roteia handler×mockHandler lendo OPUS_MODE (isOpusDesignMode);
198
- // com o runtime local, o preview fica autossuficiente.
217
+ // 3) O execute() roteia pro mockHandler lendo OPUS_MODE (isOpusDesignMode); o boot
218
+ // liga em cada action a resposta de design, e o preview fica autossuficiente.
199
219
  process.env.OPUS_MODE = 'design'
200
220
  },
201
221
 
@@ -215,7 +235,14 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
215
235
  registrables.push(...(await loadEntry(server, entry)))
216
236
  }
217
237
  }
218
- const { actions, dropped } = collectActions(registrables)
238
+ const collected = collectActions(registrables)
239
+ const { dropped } = collected
240
+ const scenarios = isDesign
241
+ ? await loadScenarios(server, scenarioEntries, collected.actions)
242
+ : new Map<string, DesignScenario>()
243
+ const { actions, sources } = isDesign
244
+ ? prepareDesignActions(collected.actions, scenarios)
245
+ : { actions: collected.actions, sources: new Map<string, DesignSource>() }
219
246
 
220
247
  const handle = nodeServer({ apiPrefix })
221
248
  const runtime = createRuntime({
@@ -223,8 +250,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
223
250
  auth: designAuth({ user: options.user, tenantId: options.tenantId }),
224
251
  eventBus: mittEvents(),
225
252
  storage: memStorage(),
226
- // SEM `data` de propósito: design não toca ctx.db — handler real sem
227
- // mock falhando é o sinal certo de que falta mockar.
253
+ // SEM `data` de propósito: design não toca ctx.db. Uma action ligada a handler real
254
+ // que dependa do banco falha aqui; o caminho é um cenário (ADR 0037).
228
255
  ...options.runtime,
229
256
  config: { env: 'development', dev: true, ...options.runtime?.config },
230
257
  })
@@ -236,19 +263,9 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
236
263
  `[opus-design] ${dropped} reaction(s)/schedule(s) ignorada(s) — o runtime de design é request/response.`,
237
264
  )
238
265
  }
239
- if (isDesign) {
240
- for (const action of actions) {
241
- if (action.mockHandler === undefined) {
242
- logger.warn(
243
- `[opus-design] Action "${action.name}" sem mockHandler — em design cai no handler real e pode falhar sem adapters.`,
244
- )
245
- }
246
- }
247
- }
248
-
249
266
  await runtime.start()
250
267
  handle.adapter.mountEndpoints({ health: true, ready: true, openapi: true })
251
- return { handle, runtime, actions }
268
+ return { handle, runtime, actions, sources }
252
269
  }
253
270
 
254
271
  // Promise-mutex: requests em voo aguardam o MESMO boot; rebuild (stale)
@@ -285,8 +302,9 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
285
302
  // add/unlink: QUALQUER arquivo sob o root (fora node_modules/.git) —
286
303
  // arquivo novo nunca importado não está no graph, e é exatamente ele
287
304
  // que um entry glob precisa enxergar. Over-invalidation é barata.
305
+ const watchedEntries = [...entries, ...scenarioEntries]
288
306
  const entryPaths = new Set(
289
- entries
307
+ watchedEntries
290
308
  .filter((entry) => !isGlobPattern(entry))
291
309
  .map((entry) => path.resolve(server.config.root, entry)),
292
310
  )
@@ -294,7 +312,7 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
294
312
  // pode apontar pra FORA dele (ex.: '../../services/*/contract.ts', caso GB):
295
313
  // mandamos o chokidar observar o PREFIXO ESTÁTICO de cada glob — o mesmo que
296
314
  // o expandGlob anda, nunca o mundo — pra change/unlink lá também invalidar.
297
- const globSpecs = entries
315
+ const globSpecs = watchedEntries
298
316
  .filter(isGlobPattern)
299
317
  .map((pattern) => globSpec(server.config.root, pattern))
300
318
  for (const { prefixDir } of globSpecs) server.watcher.add(prefixDir)
@@ -341,12 +359,21 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
341
359
  }
342
360
  try {
343
361
  const state = await ensureBoot()
344
- const unmocked = state.actions.filter((a) => a.mockHandler === undefined)
362
+ const named = (source: DesignSource): string[] =>
363
+ state.actions.filter((a) => state.sources.get(a.name) === source).map((a) => a.name)
364
+ const scenarios = named('scenario')
365
+ const mocks = named('mock')
345
366
  payload.runtime = {
346
367
  prefix: apiPrefix,
347
368
  actions: state.actions.length,
348
- mocked: state.actions.length - unmocked.length,
349
- unmocked: unmocked.map((a) => a.name),
369
+ // `mocked`/`unmocked` mantêm o formato anterior da sonda: resposta escrita à mão
370
+ // (cenário ou mockHandler) contra o resto, que responde por handler real ou `fake`.
371
+ mocked: scenarios.length + mocks.length,
372
+ unmocked: state.actions
373
+ .filter((a) => !['scenario', 'mock'].includes(state.sources.get(a.name) ?? ''))
374
+ .map((a) => a.name),
375
+ scenarios,
376
+ generated: named('generated'),
350
377
  }
351
378
  } catch (err) {
352
379
  payload.error = String(err)
@@ -605,6 +632,89 @@ function registrablesOfModule(mod: Record<string, unknown>): unknown[] {
605
632
  return [...out]
606
633
  }
607
634
 
635
+ // =============================================================================
636
+ // Dados de design (ADR 0037)
637
+ // =============================================================================
638
+
639
+ /**
640
+ * Carrega os cenários dos módulos casados por `scenarios`. Cenário de action inexistente ou dois
641
+ * cenários para a mesma action impedem o boot: o preview mostra o erro em vez de silenciar.
642
+ */
643
+ async function loadScenarios(
644
+ server: ViteDevServer,
645
+ patterns: string[],
646
+ actions: ActionDef[],
647
+ ): Promise<Map<string, DesignScenario>> {
648
+ const scenarios = new Map<string, DesignScenario>()
649
+ const known = new Set(actions.map((action) => action.name))
650
+ const unknown: string[] = []
651
+ const duplicated: string[] = []
652
+ for (const pattern of patterns) {
653
+ const files = isGlobPattern(pattern) ? expandGlob(server.config.root, pattern) : [normalizeEntryUrl(pattern)]
654
+ for (const file of files) {
655
+ const mod = (await server.ssrLoadModule(file)) as Record<string, unknown>
656
+ for (const scenario of scenariosOfModule(mod)) {
657
+ if (!known.has(scenario.action)) unknown.push(scenario.action)
658
+ else if (scenarios.has(scenario.action) && scenarios.get(scenario.action) !== scenario) {
659
+ duplicated.push(scenario.action)
660
+ } else scenarios.set(scenario.action, scenario)
661
+ }
662
+ }
663
+ }
664
+ if (unknown.length > 0 || duplicated.length > 0) {
665
+ const names = (list: string[]): string => [...new Set(list)].join(', ')
666
+ const problems = [
667
+ unknown.length > 0 ? `sem action registrada: ${names(unknown)}` : '',
668
+ duplicated.length > 0 ? `mais de um cenário para: ${names(duplicated)}` : '',
669
+ ].filter(Boolean)
670
+ throw error({
671
+ code: 'design.invalid_scenario',
672
+ category: 'internal',
673
+ message: `Cenários de design inválidos (${problems.join('; ')}).`,
674
+ })
675
+ }
676
+ return scenarios
677
+ }
678
+
679
+ function scenariosOfModule(mod: Record<string, unknown>): DesignScenario[] {
680
+ const out = new Set<DesignScenario>()
681
+ const add = (value: unknown): void => {
682
+ if (Array.isArray(value)) value.forEach(add)
683
+ else if (isDesignScenario(value)) out.add(value)
684
+ }
685
+ Object.values(mod).forEach(add)
686
+ return [...out]
687
+ }
688
+
689
+ /**
690
+ * Liga a cada action a resposta de design, nesta precedência: cenário, `mockHandler` declarado,
691
+ * handler real (`bindAction`) e, por fim, a resposta padrão gerada pelo `fake`.
692
+ */
693
+ function prepareDesignActions(
694
+ actions: ActionDef[],
695
+ scenarios: Map<string, DesignScenario>,
696
+ ): { actions: ActionDef[]; sources: Map<string, DesignSource> } {
697
+ const sources = new Map<string, DesignSource>()
698
+ const prepared = actions.map((action): ActionDef => {
699
+ const scenario = scenarios.get(action.name)
700
+ if (scenario !== undefined) {
701
+ sources.set(action.name, 'scenario')
702
+ return { ...action, mockHandler: (ctx: ActionContext, input: unknown) => scenario.respond(input, ctx) } as ActionDef
703
+ }
704
+ if (action.mockHandler !== undefined) {
705
+ sources.set(action.name, 'mock')
706
+ return action
707
+ }
708
+ if (typeof (action as { handler?: unknown }).handler === 'function') {
709
+ sources.set(action.name, 'handler')
710
+ return action
711
+ }
712
+ sources.set(action.name, 'generated')
713
+ return { ...action, mockHandler: async (_ctx: ActionContext, input: unknown) => designResponse(action, input) } as ActionDef
714
+ })
715
+ return { actions: prepared, sources }
716
+ }
717
+
608
718
  /** Achata os registráveis em actions; reactions/schedules ficam de fora (contados). */
609
719
  function collectActions(items: unknown[]): { actions: ActionDef[]; dropped: number } {
610
720
  const actions: ActionDef[] = []
@@ -621,7 +731,7 @@ function collectActions(items: unknown[]): { actions: ActionDef[]; dropped: numb
621
731
  dropped += 1
622
732
  continue
623
733
  }
624
- // ActionDef ou contrato (sem handler): em design o mockHandler responde.
734
+ // ActionDef ou contrato (sem handler): em design, a resposta de design responde.
625
735
  actions.push(item as ActionDef)
626
736
  }
627
737
  return { actions, dropped }