@softize/opus 13.0.0 → 14.0.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 (164) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/PROMOTED.md +46 -0
  3. package/README.md +28 -19
  4. package/bin/cli.mjs +87 -216
  5. package/bin/lib/cli-shared.mjs +131 -0
  6. package/bin/lib/copy.mjs +276 -6
  7. package/bin/lib/db.mjs +16 -74
  8. package/bin/lib/gen-openapi.mjs +3 -3
  9. package/bin/lib/gen-runner.mjs +1 -1
  10. package/bin/lib/gen.mjs +14 -69
  11. package/bin/lib/mcp.mjs +3 -1
  12. package/bin/lib/seed.mjs +5 -62
  13. package/docs/code-style.md +4 -1
  14. package/docs/ownership-vs-shadcn-lock.md +2 -3
  15. package/docs/protocol.md +7 -7
  16. package/docs/releasing.md +8 -2
  17. package/package.json +7 -3
  18. package/registry/instructions/opus.md +3 -3
  19. package/registry/templates/app/package.json +1 -1
  20. package/registry/templates/app/src/App.tsx +11 -6
  21. package/registry/templates/app/src/main.tsx +4 -4
  22. package/src/audit/drivers/console.ts +1 -0
  23. package/src/auth/drivers/better-auth.ts +1 -0
  24. package/src/auth/drivers/jwt.ts +1 -0
  25. package/src/cache/drivers/memory.ts +1 -0
  26. package/src/client/drivers/fetch.ts +2 -1
  27. package/src/core/actions.ts +6 -1
  28. package/src/core/audit.ts +9 -3
  29. package/src/core/contracts.ts +7 -0
  30. package/src/core/domain.ts +1 -1
  31. package/src/core/errors.ts +18 -15
  32. package/src/core/index.ts +4 -2
  33. package/src/core/package-version.ts +26 -0
  34. package/src/core/reactions.ts +1 -1
  35. package/src/core/runtime.ts +33 -23
  36. package/src/core/schedules.ts +1 -1
  37. package/src/core/types.ts +5 -6
  38. package/src/dsl/eval.ts +2 -2
  39. package/src/dsl/kysely.ts +2 -2
  40. package/src/dsl/loads.ts +1 -1
  41. package/src/dsl/parser.ts +5 -5
  42. package/src/events/drivers/mitt.ts +1 -0
  43. package/src/mcp/index.ts +2 -1
  44. package/src/observability/drivers/opentelemetry.ts +1 -0
  45. package/src/queue/drivers/bullmq.ts +3 -3
  46. package/src/scheduler/drivers/node-cron.ts +3 -2
  47. package/src/scheduler/every.ts +7 -7
  48. package/src/schema/openapi.ts +3 -3
  49. package/src/seed/index.ts +29 -0
  50. package/src/server/drivers/fastify.ts +5 -2
  51. package/src/server/drivers/node.ts +9 -6
  52. package/src/server/index.ts +3 -1
  53. package/src/storage/drivers/fs.ts +1 -0
  54. package/src/testing/index.ts +3 -3
  55. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  56. package/src/ui/components/patterns/confirm.tsx +2 -31
  57. package/src/ui/components/patterns/content-header.tsx +44 -137
  58. package/src/ui/components/patterns/data-state.tsx +42 -68
  59. package/src/ui/components/patterns/dock.tsx +20 -3
  60. package/src/ui/components/patterns/form.tsx +26 -22
  61. package/src/ui/components/patterns/list.tsx +18 -12
  62. package/src/ui/components/patterns/page-state.tsx +39 -51
  63. package/src/ui/components/patterns/page.tsx +37 -54
  64. package/src/ui/components/patterns/sidebar.tsx +17 -6
  65. package/src/ui/components/patterns/state-surface.tsx +148 -0
  66. package/src/ui/components/patterns/surface-header.tsx +119 -0
  67. package/src/ui/components/patterns/trigger.tsx +21 -25
  68. package/src/ui/components/patterns/view.tsx +29 -22
  69. package/src/ui/components/primitives/alert.tsx +1 -27
  70. package/src/ui/components/primitives/ask.tsx +3 -3
  71. package/src/ui/components/primitives/avatar.tsx +15 -5
  72. package/src/ui/components/primitives/badge.tsx +5 -41
  73. package/src/ui/components/primitives/breadcrumb.tsx +2 -2
  74. package/src/ui/components/primitives/button.tsx +39 -30
  75. package/src/ui/components/primitives/calendar.tsx +28 -2
  76. package/src/ui/components/primitives/carousel.tsx +3 -3
  77. package/src/ui/components/primitives/chat.tsx +1 -1
  78. package/src/ui/components/primitives/checkbox.tsx +1 -1
  79. package/src/ui/components/primitives/command.tsx +2 -2
  80. package/src/ui/components/primitives/control.ts +72 -0
  81. package/src/ui/components/primitives/copyable.tsx +1 -1
  82. package/src/ui/components/primitives/dialog.tsx +12 -7
  83. package/src/ui/components/primitives/dot.tsx +1 -25
  84. package/src/ui/components/primitives/drawer.tsx +10 -3
  85. package/src/ui/components/primitives/field.tsx +3 -3
  86. package/src/ui/components/primitives/icon-picker.tsx +3 -1
  87. package/src/ui/components/primitives/input-group.tsx +12 -9
  88. package/src/ui/components/primitives/input-otp.tsx +1 -1
  89. package/src/ui/components/primitives/input.tsx +2 -2
  90. package/src/ui/components/primitives/item.tsx +3 -1
  91. package/src/ui/components/primitives/menu.tsx +1 -7
  92. package/src/ui/components/primitives/pagination.tsx +16 -8
  93. package/src/ui/components/primitives/progress.tsx +32 -3
  94. package/src/ui/components/primitives/radio-group.tsx +1 -1
  95. package/src/ui/components/primitives/resizable.tsx +3 -1
  96. package/src/ui/components/primitives/select.tsx +6 -6
  97. package/src/ui/components/primitives/slider.tsx +5 -1
  98. package/src/ui/components/primitives/sonner.tsx +3 -0
  99. package/src/ui/components/primitives/spinner.tsx +13 -16
  100. package/src/ui/components/primitives/switch.tsx +5 -1
  101. package/src/ui/components/primitives/tabs.tsx +6 -3
  102. package/src/ui/components/primitives/textarea.tsx +1 -1
  103. package/src/ui/components/primitives/toggle.tsx +9 -4
  104. package/src/ui/components/primitives/tooltip.tsx +1 -0
  105. package/src/ui/docs/changelog.tsx +1 -1
  106. package/src/ui/docs/content/action-form.md +36 -3
  107. package/src/ui/docs/content/action-list-dialog.md +2 -2
  108. package/src/ui/docs/content/action-list.md +13 -2
  109. package/src/ui/docs/content/action-trigger.md +12 -5
  110. package/src/ui/docs/content/action-view.md +11 -3
  111. package/src/ui/docs/content/ask.md +11 -0
  112. package/src/ui/docs/content/avatar.md +7 -3
  113. package/src/ui/docs/content/button.md +30 -14
  114. package/src/ui/docs/content/calendar.md +13 -0
  115. package/src/ui/docs/content/card.md +26 -0
  116. package/src/ui/docs/content/chat.md +20 -0
  117. package/src/ui/docs/content/cli.md +71 -19
  118. package/src/ui/docs/content/communication.md +36 -0
  119. package/src/ui/docs/content/composer.md +15 -0
  120. package/src/ui/docs/content/content.md +17 -1
  121. package/src/ui/docs/content/copyable.md +8 -0
  122. package/src/ui/docs/content/data-state.md +17 -13
  123. package/src/ui/docs/content/detail.md +19 -1
  124. package/src/ui/docs/content/dialog.md +1 -4
  125. package/src/ui/docs/content/dictionary-value.md +9 -2
  126. package/src/ui/docs/content/dock.md +8 -0
  127. package/src/ui/docs/content/dot.md +8 -0
  128. package/src/ui/docs/content/empty.md +2 -2
  129. package/src/ui/docs/content/getting-started.md +2 -2
  130. package/src/ui/docs/content/icon-picker.md +11 -0
  131. package/src/ui/docs/content/input.md +1 -1
  132. package/src/ui/docs/content/item.md +1 -1
  133. package/src/ui/docs/content/label.md +7 -0
  134. package/src/ui/docs/content/menu.md +6 -0
  135. package/src/ui/docs/content/metric-card.md +13 -0
  136. package/src/ui/docs/content/page.md +20 -4
  137. package/src/ui/docs/content/pagination.md +11 -9
  138. package/src/ui/docs/content/popover.md +6 -0
  139. package/src/ui/docs/content/progress.md +8 -11
  140. package/src/ui/docs/content/select.md +5 -5
  141. package/src/ui/docs/content/semantic-context.md +5 -4
  142. package/src/ui/docs/content/sidebar.md +13 -47
  143. package/src/ui/docs/content/skeleton.md +6 -0
  144. package/src/ui/docs/content/spinner.md +9 -6
  145. package/src/ui/docs/content/split.md +21 -0
  146. package/src/ui/docs/content/switch.md +1 -1
  147. package/src/ui/docs/content/tabs.md +1 -1
  148. package/src/ui/docs/content/textarea.md +7 -0
  149. package/src/ui/docs/content/toggle.md +1 -1
  150. package/src/ui/docs/content/tokens.md +4 -4
  151. package/src/ui/docs/content/truncate.md +8 -0
  152. package/src/ui/docs/content/ui.md +14 -0
  153. package/src/ui/docs/doc-client.tsx +5 -5
  154. package/src/ui/docs/doc.tsx +26 -14
  155. package/src/ui/docs/registry.tsx +5 -5
  156. package/src/ui/docs/standalone.tsx +2 -2
  157. package/src/ui/drivers/react.tsx +12 -12
  158. package/src/ui/lib/action-errors.ts +45 -0
  159. package/src/ui/lib/zod-pt-br.ts +31 -4
  160. package/src/ui/meta.ts +8 -8
  161. package/src/ui/react.tsx +10 -16
  162. package/src/ui/theme.css +10 -8
  163. package/src/vite/design.ts +6 -18
  164. package/src/ui/components/patterns/shell-nav.tsx +0 -147
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Utilitários compartilhados pelos comandos do CLI `opus`.
3
+ *
4
+ * Concentra o que mais de um comando repetia: log colorido no terminal, teste de
5
+ * existência de arquivo, resolução do `opus.config.ts` do consumer e o spawn do `tsx`
6
+ * que carrega esse config TypeScript num runner isolado (`gen`, `db` e `seed` usam a
7
+ * mesma infra: o runner emite uma linha JSON na última linha de stdout).
8
+ */
9
+
10
+ import { execFile } from 'node:child_process'
11
+ import { promises as fs } from 'node:fs'
12
+ import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { promisify } from 'node:util'
15
+
16
+ import { canonicalProjectDirectory, safeProjectPath } from '@softize/base/project-path'
17
+
18
+ const execFileAsync = promisify(execFile)
19
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
20
+
21
+ export const CONFIG_FILE = 'opus.config.ts'
22
+
23
+ // =============================================================================
24
+ // Log
25
+ // =============================================================================
26
+
27
+ const COLORS = {
28
+ info: '\x1b[36m',
29
+ success: '\x1b[32m',
30
+ error: '\x1b[31m',
31
+ warn: '\x1b[33m',
32
+ dim: '\x1b[2m',
33
+ }
34
+ const RESET = '\x1b[0m'
35
+
36
+ /** Escreve uma linha colorida em stdout. Nível desconhecido sai sem cor. */
37
+ export function log(level, message) {
38
+ console.log(`${COLORS[level] ?? ''}${message}${RESET}`)
39
+ }
40
+
41
+ // =============================================================================
42
+ // FS
43
+ // =============================================================================
44
+
45
+ export async function fileExists(file) {
46
+ try {
47
+ await fs.access(file)
48
+ return true
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ // =============================================================================
55
+ // Config do consumer
56
+ // =============================================================================
57
+
58
+ /**
59
+ * Resolve o `opus.config.ts` a partir de `flags.config` (default: `./opus.config.ts`),
60
+ * contido no projeto. Lança `Error` com uma mensagem única quando o arquivo não existe;
61
+ * cada comando decide o canal (texto ou JSON) e o código de saída.
62
+ */
63
+ export function resolveConfig(flags, cwd = process.cwd()) {
64
+ const root = canonicalProjectDirectory(cwd)
65
+ const config = safeProjectPath(root, flags.config ?? CONFIG_FILE)
66
+ if (!config.exists) {
67
+ throw new Error(
68
+ `${CONFIG_FILE} não encontrado em ${config.path}. ` +
69
+ 'Passe o caminho com --config <path> ou crie o arquivo na raiz do projeto.',
70
+ )
71
+ }
72
+ return { cwd: root, configPath: config.path }
73
+ }
74
+
75
+ // =============================================================================
76
+ // Runner via tsx
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Binário do tsx: o empacotado em `<package>/node_modules/.bin/tsx` quando existe
81
+ * (independe do PATH do consumer); senão confia no `tsx` do PATH.
82
+ */
83
+ export async function resolveTsxBin() {
84
+ const local = path.join(PACKAGE_ROOT, 'node_modules', '.bin', 'tsx')
85
+ return (await fileExists(local)) ? local : 'tsx'
86
+ }
87
+
88
+ /**
89
+ * Executa `tsx <runnerPath> ...args` capturando stdout/stderr em utf8 e herdando o
90
+ * ambiente. Rejeita como o `execFile` (o erro traz `stdout` e `stderr`) quando o
91
+ * processo falha.
92
+ */
93
+ export async function spawnTsx(runnerPath, args, { maxBuffer = 16 * 1024 * 1024 } = {}) {
94
+ const command = await resolveTsxBin()
95
+ return execFileAsync(command, [runnerPath, ...args], {
96
+ encoding: 'utf8',
97
+ maxBuffer,
98
+ env: { ...process.env },
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Última linha de stdout que parece um objeto JSON. Logs do consumer durante o
104
+ * import do config vão para o início; o runner emite o payload por último.
105
+ */
106
+ export function lastJsonLine(stdout) {
107
+ const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
108
+ for (let index = lines.length - 1; index >= 0; index--) {
109
+ const line = lines[index].trim()
110
+ if (line.startsWith('{') && line.endsWith('}')) return line
111
+ }
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * Roda um runner que responde `{ ok, ... }` em JSON. Nunca rejeita: falha de spawn ou
117
+ * ausência de payload viram `{ ok: false, error }` para o comando reportar.
118
+ */
119
+ export async function runJsonRunner(runnerPath, args, options) {
120
+ try {
121
+ const { stdout } = await spawnTsx(runnerPath, args, options)
122
+ const line = lastJsonLine(stdout)
123
+ return line === null ? { ok: false, error: 'runner não emitiu JSON' } : JSON.parse(line)
124
+ } catch (error) {
125
+ const detail =
126
+ typeof error.stderr === 'string' && error.stderr.trim().length > 0
127
+ ? error.stderr.trim()
128
+ : error.message
129
+ return { ok: false, error: detail }
130
+ }
131
+ }
package/bin/lib/copy.mjs CHANGED
@@ -107,8 +107,16 @@ const JSX_CHILD_ROLES = new Map([
107
107
  ['TableCaption', 'description'],
108
108
  ['TableHead', 'heading'],
109
109
  ['TabsTrigger', 'tab'],
110
+ // A Base não tem papel `tooltip`. O tooltip nomeia um controle icon-only, logo é um
111
+ // fragmento (sem ponto final), não uma frase; `label` é o papel que reflete isso.
112
+ ['TooltipContent', 'label'],
110
113
  ])
111
114
 
115
+ // Componentes cuja copy entra pelas próprias props e que, como filhos, não acrescentam
116
+ // texto ao pai: `<FieldLabel>Nome<LabelHelp help="…" /></FieldLabel>` inventaria `Nome`
117
+ // como label e a ajuda como helper-text, sem tornar o filho opaco.
118
+ const SELF_CONTAINED_CHILD_COMPONENTS = new Set(['LabelHelp'])
119
+
112
120
  const OPTIONAL_CHILD_COMPONENTS = new Set(['ActionForm', 'ActionFormCard', 'ActionFormDialog'])
113
121
  const JSX_ACTION_CONSUMERS = new Set([
114
122
  'ActionForm', 'ActionFormCard', 'ActionFormDialog', 'ActionList', 'ActionListDialog', 'ActionTrigger', 'ActionView',
@@ -125,6 +133,14 @@ const JSX_PROP_ROLES = new Map([
125
133
  ['ActionForm', new Map([['submitLabel', 'button'], ['cancelLabel', 'button']])],
126
134
  ['ActionFormCard', new Map([['title', 'title'], ['description', 'description'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
127
135
  ['ActionFormDialog', new Map([['title', 'title'], ['description', 'dialog-body'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
136
+ // Estados unificados (14.0): a mesma tríade de mensagens em ActionList, ActionListDialog,
137
+ // DataState e PageState; ActionView só tem o vazio.
138
+ ['ActionList', new Map([['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button']])],
139
+ ['ActionListDialog', new Map([
140
+ ['title', 'title'], ['description', 'dialog-body'],
141
+ ['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button'],
142
+ ])],
143
+ ['ActionView', new Map([['emptyMessage', 'empty-state']])],
128
144
  // `itemLabel` identifica o dado alvo (por exemplo, `customer.name`); não é
129
145
  // microcopy estável e, por isso, não pertence ao inventário editorial.
130
146
  ['ActionTrigger', new Map([['label', 'button']])],
@@ -132,7 +148,7 @@ const JSX_PROP_ROLES = new Map([
132
148
  ['CommandInput', new Map([['placeholder', 'placeholder']])],
133
149
  ['Content', new Map([['title', 'title'], ['description', 'description']])],
134
150
  ['ContentHeader', new Map([['title', 'title'], ['description', 'description']])],
135
- ['DataState', new Map([['emptyText', 'empty-state'], ['errorText', 'error']])],
151
+ ['DataState', new Map([['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button']])],
136
152
  // A Dock nomeia a barra e cada ação por prop. Sem estas linhas, a copy sairia do inventário
137
153
  // exatamente quando uma superfície migra de <Button aria-label> para <DockAction label>.
138
154
  ['Dock', new Map([['label', 'label']])],
@@ -140,8 +156,14 @@ const JSX_PROP_ROLES = new Map([
140
156
  ['Input', new Map([['placeholder', 'placeholder']])],
141
157
  ['InputGroupInput', new Map([['placeholder', 'placeholder']])],
142
158
  ['InputGroupTextarea', new Map([['placeholder', 'placeholder']])],
159
+ // A ajuda vive num tooltip (portal): não herda transformação nem classe do label.
160
+ ['LabelHelp', new Map([['help', 'helper-text']])],
143
161
  ['MetricCard', new Map([['label', 'label'], ['description', 'description']])],
144
162
  ['Page', new Map([['title', 'title'], ['description', 'description']])],
163
+ ['PageState', new Map([
164
+ ['title', 'title'], ['description', 'description'],
165
+ ['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button'],
166
+ ])],
145
167
  ['Select', new Map([
146
168
  ['placeholder', 'placeholder'],
147
169
  ['searchPlaceholder', 'placeholder'],
@@ -215,6 +237,36 @@ const JSX_PORTAL_BOUNDARIES = new Set([
215
237
  'PopoverContent', 'TooltipContent',
216
238
  ])
217
239
 
240
+ // Respostas imperativas `dialog.*` da UI Opus. O DialogHost renderiza em portal com classes
241
+ // próprias; nenhuma transformação do chamador alcança o texto.
242
+ const DIALOG_METHOD_ROLES = new Map([
243
+ ['alert', new Map([['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'], ['action', 'button']])],
244
+ ['confirm', new Map([
245
+ ['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'],
246
+ ['action', 'button'], ['cancel', 'button'],
247
+ ])],
248
+ ['prompt', new Map([
249
+ ['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'],
250
+ ['action', 'button'], ['cancel', 'button'], ['placeholder', 'placeholder'],
251
+ ])],
252
+ ['choose', new Map([['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body']])],
253
+ ])
254
+
255
+ // Métodos de array que não mutam o receptor. Os que devolvem os próprios elementos (num
256
+ // array novo ou avulsos) exigem que o resultado também seja usado de forma estável; os
257
+ // demais só produzem primitivos, novos objetos ou nada.
258
+ const ARRAY_RETURNING_ARRAY_METHODS = new Set(['concat', 'filter', 'flat', 'slice', 'toReversed', 'toSorted'])
259
+ const ELEMENT_PICKING_ARRAY_METHODS = new Set(['at', 'find', 'findLast'])
260
+ const ELEMENT_ITERATING_ARRAY_METHODS = new Set([
261
+ 'every', 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'flatMap', 'forEach', 'map', 'some',
262
+ ])
263
+ const READ_ONLY_ARRAY_METHODS = new Set([
264
+ ...ARRAY_RETURNING_ARRAY_METHODS, ...ELEMENT_PICKING_ARRAY_METHODS, ...ELEMENT_ITERATING_ARRAY_METHODS,
265
+ 'includes', 'indexOf', 'join', 'lastIndexOf',
266
+ ])
267
+ // Cadeia que preserva os elementos entre o array literal e a iteração que os lê.
268
+ const ELEMENT_PRESERVING_ARRAY_METHODS = new Set(['filter', 'slice', 'toReversed', 'toSorted'])
269
+
218
270
  const digest = (content) => createHash('sha256').update(content).digest('hex')
219
271
  const portable = (value) => value.split(path.sep).join('/')
220
272
 
@@ -421,6 +473,17 @@ function knownBindAction(call, checker) {
421
473
  return importedMemberName(call.expression, checker, (module) => CONTRACT_MODULES.has(module)) === 'bindAction'
422
474
  }
423
475
 
476
+ /** Reconhece `dialog.confirm({...})` e `Opus.dialog.alert({...})` importados da UI Opus. */
477
+ function knownDialogCall(call, checker) {
478
+ const callee = unwrap(call.expression)
479
+ if (
480
+ ts.isPropertyAccessExpression(callee) &&
481
+ DIALOG_METHOD_ROLES.has(callee.name.text) &&
482
+ importedMemberName(callee.expression, checker, uiModule) === 'dialog'
483
+ ) return { method: callee.name.text, field: `dialog.${callee.name.text}()` }
484
+ return null
485
+ }
486
+
424
487
  function safeBindActionBinding(call, checker) {
425
488
  if (call.arguments.length < 2) return false
426
489
  const binding = unwrap(call.arguments[1])
@@ -521,6 +584,16 @@ function stableExpressionUse(node, checker, mode, stack) {
521
584
  const parent = carrier.parent
522
585
 
523
586
  if (assignmentTarget(carrier)) return false
587
+ if (consumerMode === 'aggregate' && ts.isCallExpression(parent) && parent.expression === carrier) {
588
+ const access = unwrap(carrier)
589
+ return (
590
+ ts.isPropertyAccessExpression(access) && arrayValued(access.expression, checker) &&
591
+ readOnlyArrayMethodUse(access.name.text, parent, checker, stack)
592
+ )
593
+ }
594
+ if (ts.isForOfStatement(parent) && parent.expression === carrier) {
595
+ return consumerMode === 'aggregate' && arrayValued(carrier, checker) && readOnlyForOfBinding(parent, checker)
596
+ }
524
597
  if (ts.isVariableDeclaration(parent) && parent.initializer === carrier) {
525
598
  if (!ts.isVariableDeclarationList(parent.parent) || (parent.parent.flags & ts.NodeFlags.Const) === 0) return false
526
599
  const aliases = bindingNames(parent.name, checker)
@@ -534,6 +607,8 @@ function stableExpressionUse(node, checker, mode, stack) {
534
607
  if (ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === carrier)) {
535
608
  if (consumerMode === 'aggregate') {
536
609
  if (knownDictionaryFactory(parent, checker)) return true
610
+ // O DialogHost copia as opções (`{ ...options }`) e só as lê; nada muta o literal.
611
+ if (parent.arguments[0] === carrier && knownDialogCall(parent, checker) !== null) return true
537
612
  return knownContractFactory(parent, checker) && stableFactoryResult(parent, checker, stack)
538
613
  }
539
614
  if (
@@ -573,6 +648,166 @@ function stableReferences(binding, checker, mode, stack) {
573
648
  return stable
574
649
  }
575
650
 
651
+ /**
652
+ * Um parâmetro de callback (ou variável de `for…of`) é somente leitura quando cada uso é
653
+ * `elemento.chave` fora de posição de atribuição. Strings são imutáveis, então uma leitura de
654
+ * propriedade nunca altera o rótulo literal; qualquer outro uso (spread, argumento, retorno,
655
+ * atribuição) poderia mutar ou fazer o objeto escapar e é rejeitado. Destructuring copia os
656
+ * valores de primeiro nível e, por isso, também não alcança o literal.
657
+ */
658
+ function readOnlyElementBinding(name, checker) {
659
+ if (ts.isObjectBindingPattern(name)) return true
660
+ if (!ts.isIdentifier(name)) return false
661
+ const symbol = bindingSymbol(name, checker)
662
+ if (symbol === undefined) return false
663
+ let readOnly = true
664
+ const visit = (node) => {
665
+ if (!readOnly) return
666
+ if (ts.isIdentifier(node) && sameBinding(node, symbol, checker) && !declarationIdentifier(node, symbol)) {
667
+ const carrier = transparentCarrier(node)
668
+ const access = carrier.parent
669
+ readOnly =
670
+ (ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access)) &&
671
+ access.expression === carrier && !assignmentTarget(access)
672
+ }
673
+ ts.forEachChild(node, visit)
674
+ }
675
+ visit(name.getSourceFile())
676
+ return readOnly
677
+ }
678
+
679
+ function readOnlyCallback(callback, checker) {
680
+ const value = callback === undefined ? null : unwrap(callback)
681
+ if (value === null || (!ts.isArrowFunction(value) && !ts.isFunctionExpression(value))) return false
682
+ // O terceiro parâmetro expõe o próprio array ao callback; índice e `thisArg` não.
683
+ if (value.parameters.length > 2) return false
684
+ const element = value.parameters[0]
685
+ if (element === undefined) return true
686
+ if (element.dotDotDotToken !== undefined || element.initializer !== undefined) return false
687
+ return readOnlyElementBinding(element.name, checker)
688
+ }
689
+
690
+ function readOnlyArrayMethodUse(method, call, checker, stack) {
691
+ if (!READ_ONLY_ARRAY_METHODS.has(method)) return false
692
+ if (ELEMENT_ITERATING_ARRAY_METHODS.has(method) && !readOnlyCallback(call.arguments[0], checker)) return false
693
+ if (ARRAY_RETURNING_ARRAY_METHODS.has(method) || ELEMENT_PICKING_ARRAY_METHODS.has(method)) {
694
+ return stableExpressionUse(call, checker, 'aggregate', stack)
695
+ }
696
+ return true
697
+ }
698
+
699
+ /**
700
+ * A semântica somente leitura de `map`, `filter`… vale para arrays. Um objeto literal pode
701
+ * declarar um método com o mesmo nome e mutar o que quiser; por isso o receptor precisa ser
702
+ * um `const` com array literal ou o resultado de um método que devolve array sobre ele.
703
+ */
704
+ function arrayValued(node, checker) {
705
+ const value = unwrap(node)
706
+ if (ts.isIdentifier(value)) {
707
+ const binding = constInitializer(value, checker)
708
+ return binding !== null && ts.isArrayLiteralExpression(unwrap(binding.initializer))
709
+ }
710
+ if (ts.isCallExpression(value)) {
711
+ const callee = unwrap(value.expression)
712
+ return ts.isPropertyAccessExpression(callee) && ARRAY_RETURNING_ARRAY_METHODS.has(callee.name.text) &&
713
+ arrayValued(callee.expression, checker)
714
+ }
715
+ return ts.isArrayLiteralExpression(value)
716
+ }
717
+
718
+ function readOnlyForOfBinding(statement, checker) {
719
+ const initializer = statement.initializer
720
+ if (!ts.isVariableDeclarationList(initializer) || initializer.declarations.length !== 1) return false
721
+ return readOnlyElementBinding(initializer.declarations[0].name, checker)
722
+ }
723
+
724
+ /**
725
+ * Localiza o array literal do qual um identificador é elemento: parâmetro de callback de
726
+ * `map`/`forEach`/`filter`… ou variável de `for…of`, possivelmente atrás de `filter`,
727
+ * `slice`, `toSorted` e `toReversed`, que preservam os elementos. Só arrays literais no
728
+ * mesmo arquivo entram; qualquer outra origem devolve null.
729
+ */
730
+ function elementSource(identifier, checker) {
731
+ const symbol = bindingSymbol(identifier, checker)
732
+ const declaration = symbol?.declarations?.find(
733
+ (item) => ts.isParameter(item) || ts.isBindingElement(item) || ts.isVariableDeclaration(item),
734
+ )
735
+ if (declaration === undefined) return null
736
+
737
+ let key = null
738
+ let binding = declaration
739
+ if (ts.isBindingElement(declaration)) {
740
+ if (declaration.dotDotDotToken !== undefined || declaration.initializer !== undefined) return null
741
+ const pattern = declaration.parent
742
+ if (!ts.isObjectBindingPattern(pattern) || (!ts.isParameter(pattern.parent) && !ts.isVariableDeclaration(pattern.parent))) return null
743
+ const keyNode = declaration.propertyName ?? declaration.name
744
+ if (!ts.isIdentifier(keyNode) && !ts.isStringLiteral(keyNode)) return null
745
+ key = keyNode.text
746
+ binding = pattern.parent
747
+ }
748
+
749
+ let receiver
750
+ if (ts.isParameter(binding)) {
751
+ if (binding.dotDotDotToken !== undefined || binding.initializer !== undefined) return null
752
+ const callback = binding.parent
753
+ if ((!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) || callback.parameters[0] !== binding) return null
754
+ const call = transparentCarrier(callback).parent
755
+ if (!ts.isCallExpression(call) || call.arguments[0] !== transparentCarrier(callback)) return null
756
+ const callee = unwrap(call.expression)
757
+ if (!ts.isPropertyAccessExpression(callee) || !ELEMENT_ITERATING_ARRAY_METHODS.has(callee.name.text)) return null
758
+ receiver = callee.expression
759
+ } else if (ts.isVariableDeclaration(binding)) {
760
+ const list = binding.parent
761
+ if (!ts.isVariableDeclarationList(list) || !ts.isForOfStatement(list.parent) || list.parent.initializer !== list) return null
762
+ receiver = list.parent.expression
763
+ } else return null
764
+
765
+ let source = unwrap(receiver)
766
+ while (
767
+ ts.isCallExpression(source) && ts.isPropertyAccessExpression(unwrap(source.expression)) &&
768
+ ELEMENT_PRESERVING_ARRAY_METHODS.has(unwrap(source.expression).name.text)
769
+ ) source = unwrap(unwrap(source.expression).expression)
770
+ const array = resolveBinding(source, checker)
771
+ return ts.isArrayLiteralExpression(array) ? { array, key } : null
772
+ }
773
+
774
+ /**
775
+ * Resolve `elemento.chave` (ou a chave destruturada) para o texto literal de cada elemento
776
+ * do array de origem. Um único elemento opaco ou sem texto estático torna o conjunto todo
777
+ * inextraível: o inventário não pode listar só parte do que a superfície renderiza.
778
+ */
779
+ function elementTexts(node, checker) {
780
+ const value = unwrap(node)
781
+ let identifier
782
+ let key = null
783
+ if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(unwrap(value.expression))) {
784
+ identifier = unwrap(value.expression)
785
+ key = value.name.text
786
+ } else if (ts.isElementAccessExpression(value) && ts.isIdentifier(unwrap(value.expression))) {
787
+ identifier = unwrap(value.expression)
788
+ key = value.argumentExpression === undefined ? null : staticText(value.argumentExpression, checker)?.text ?? null
789
+ } else if (ts.isIdentifier(value)) {
790
+ identifier = value
791
+ } else return null
792
+ const source = elementSource(identifier, checker)
793
+ if (source === null) return null
794
+ if (ts.isIdentifier(value)) key = source.key
795
+ else if (source.key !== null) return null
796
+ if (key === null) return null
797
+
798
+ const listed = objects(source.array, checker)
799
+ if (listed.opaque.length > 0 || listed.values.length === 0) return null
800
+ const texts = []
801
+ for (const element of listed.values) {
802
+ const resolved = property(element, key, element.getSourceFile(), checker)
803
+ if (resolved.opaque !== undefined || resolved.candidate === undefined) return null
804
+ const text = staticText(propertyValue(resolved.candidate), checker)
805
+ if (text === null) return null
806
+ texts.push(text)
807
+ }
808
+ return texts
809
+ }
810
+
576
811
  /**
577
812
  * Objetos e arrays `const` continuam mutáveis. A extração só faz fold quando cada uso
578
813
  * está numa allowlist observável: composição `const` igualmente estável, factory Opus
@@ -982,7 +1217,6 @@ export function extractCopyFromSource(file, sourceText) {
982
1217
  const field = (object, prefix) => {
983
1218
  addProperty(object, 'label', 'label', prefix)
984
1219
  addProperty(object, 'placeholder', 'placeholder', prefix)
985
- addProperty(object, 'hint', 'helper-text', prefix)
986
1220
  addProperty(object, 'help', 'helper-text', prefix)
987
1221
  nestedObject(object, 'options', optionItems, prefix)
988
1222
  }
@@ -1384,6 +1618,11 @@ export function extractCopyFromSource(file, sourceText) {
1384
1618
  ? { kind: 'decorative' }
1385
1619
  : { kind: 'text', ...text, uppercase: inheritedUppercase }
1386
1620
  }
1621
+ const listed = elementTexts(value, checker)
1622
+ if (listed !== null) {
1623
+ const values = listed.map((item) => ({ kind: 'text', ...item, uppercase: inheritedUppercase }))
1624
+ return values.length === 1 ? values[0] : { kind: 'alternatives', values, node: values[0].node }
1625
+ }
1387
1626
  if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) {
1388
1627
  return childNode(value, inheritedUppercase, hiddenAncestor)
1389
1628
  }
@@ -1439,6 +1678,9 @@ export function extractCopyFromSource(file, sourceText) {
1439
1678
  if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
1440
1679
  const opening = ts.isJsxElement(child) ? child.openingElement : child
1441
1680
  const component = opusComponent(opening.tagName)
1681
+ if (component !== null && SELF_CONTAINED_CHILD_COMPONENTS.has(component) && ts.isJsxSelfClosingElement(child)) {
1682
+ return { kind: 'decorative' }
1683
+ }
1442
1684
  const provenElement = transformableElement(opening, component)
1443
1685
  const hidden = attribute(opening.attributes, 'aria-hidden')
1444
1686
  if (hidden.opaque !== undefined) return { kind: 'opaque', node: hidden.opaque }
@@ -1593,7 +1835,10 @@ export function extractCopyFromSource(file, sourceText) {
1593
1835
  if (component === null) return
1594
1836
  let inherited
1595
1837
  const inheritedFor = (className, styleName, portal = false) => {
1596
- if (portal || localTransformOverridesInheritance(opening.attributes, className, styleName)) return false
1838
+ // Um componente que renderiza em portal (TooltipContent) não é descendente visual
1839
+ // dos ancestrais no JSX; a transformação deles não alcança o texto.
1840
+ if (portal || JSX_PORTAL_BOUNDARIES.has(component)) return false
1841
+ if (localTransformOverridesInheritance(opening.attributes, className, styleName)) return false
1597
1842
  inherited ??= ancestorTransform(element)
1598
1843
  return inherited.opaque ? false : inherited.uppercase
1599
1844
  }
@@ -1676,7 +1921,11 @@ export function extractCopyFromSource(file, sourceText) {
1676
1921
  }
1677
1922
  } else {
1678
1923
  const value = raw === null ? null : attributeText(item)
1679
- if (value === null) {
1924
+ // `title={group.label}` dentro de `GROUPS.map(...)`: cada literal do array local
1925
+ // vira um texto na própria linha, em vez de um diagnóstico sobre a prop.
1926
+ const listed = value === null && raw !== null ? elementTexts(raw, checker) : null
1927
+ const texts = value !== null ? [value] : listed ?? []
1928
+ if (texts.length === 0) {
1680
1929
  diagnostics.push(diagnostic(
1681
1930
  file,
1682
1931
  sourceFile,
@@ -1685,8 +1934,8 @@ export function extractCopyFromSource(file, sourceText) {
1685
1934
  raw === null ? 'missing-content' : 'content',
1686
1935
  ))
1687
1936
  }
1688
- else {
1689
- const entry = { source: file, line: lineOf(sourceFile, value.node), role, text: value.text }
1937
+ for (const text of texts) {
1938
+ const entry = { source: file, line: lineOf(sourceFile, text.node), role, text: text.text }
1690
1939
  if (transformed.uppercase) entry.transform = 'uppercase'
1691
1940
  entries.push(entry)
1692
1941
  }
@@ -1838,7 +2087,28 @@ export function extractCopyFromSource(file, sourceText) {
1838
2087
  }
1839
2088
  }
1840
2089
 
2090
+ /**
2091
+ * `dialog.confirm({ title, description, action })` é copy tão editorial quanto a de um
2092
+ * `<ActionFormDialog>`; a chamada só é imperativa. Um template com interpolação
2093
+ * (`description: describe(name)`) cai no diagnóstico `content` normal, declarável em
2094
+ * `copy.dynamic` como `message-template`.
2095
+ */
2096
+ function extractDialog(call, { method, field }) {
2097
+ const raw = call.arguments[0]
2098
+ const options = raw === undefined ? null : resolveBinding(raw, checker)
2099
+ if (options === null || !ts.isObjectLiteralExpression(options)) {
2100
+ diagnostics.push(diagnostic(file, sourceFile, raw ?? call, `${field} options`, 'structure'))
2101
+ return
2102
+ }
2103
+ for (const [name, role] of DIALOG_METHOD_ROLES.get(method)) addVisualObjectProperty(options, name, role, `${field}.`)
2104
+ if (method === 'choose') {
2105
+ nestedObject(options, 'actions', (action, prefix) => addVisualObjectProperty(action, 'label', 'button', prefix), `${field}.`)
2106
+ }
2107
+ }
2108
+
1841
2109
  function visit(node) {
2110
+ const dialogCall = ts.isCallExpression(node) ? knownDialogCall(node, checker) : null
2111
+ if (dialogCall !== null) extractDialog(node, dialogCall)
1842
2112
  const factory = ts.isCallExpression(node) ? contractFactory(node.expression) : null
1843
2113
  if (ts.isCallExpression(node) && factory !== null && node.arguments.length > 0) {
1844
2114
  hasContract = true
package/bin/lib/db.mjs CHANGED
@@ -10,31 +10,12 @@
10
10
  * Ver `docs/data-layer.md`.
11
11
  */
12
12
 
13
- import { promises as fs } from 'node:fs'
14
13
  import path from 'node:path'
15
14
  import { fileURLToPath } from 'node:url'
16
- import { execFile } from 'node:child_process'
17
- import { promisify } from 'node:util'
18
- import { canonicalProjectDirectory, safeProjectPath } from '@softize/base/project-path'
19
15
 
20
- const execFileAsync = promisify(execFile)
16
+ import { log, resolveConfig, runJsonRunner } from './cli-shared.mjs'
21
17
 
22
- const __filename = fileURLToPath(import.meta.url)
23
- const __dirname = path.dirname(__filename)
24
- const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
25
-
26
- const COLORS = {
27
- info: '\x1b[36m',
28
- success: '\x1b[32m',
29
- error: '\x1b[31m',
30
- warn: '\x1b[33m',
31
- dim: '\x1b[2m',
32
- }
33
- const RESET = '\x1b[0m'
34
-
35
- function log(level, msg) {
36
- console.log(`${COLORS[level] ?? ''}${msg}${RESET}`)
37
- }
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
38
19
 
39
20
  // =============================================================================
40
21
  // Dispatch
@@ -67,17 +48,13 @@ export async function cmdDb(rest, flags) {
67
48
  // Config
68
49
  // =============================================================================
69
50
 
70
- async function resolveConfig(flags) {
71
- const cwd = canonicalProjectDirectory(process.cwd())
72
- const configRel = flags.config ?? 'opus.config.ts'
73
- const config = safeProjectPath(cwd, configRel)
74
- const configPath = config.path
75
- if (!config.exists) {
76
- log('error', `opus.config.ts não encontrado em ${configPath}`)
77
- log('dim', ' Passa o path via --config <path> ou cria um na raiz do projeto.')
51
+ function resolveConfigOrExit(flags) {
52
+ try {
53
+ return resolveConfig(flags)
54
+ } catch (error) {
55
+ log('error', error instanceof Error ? error.message : String(error))
78
56
  process.exit(1)
79
57
  }
80
- return { cwd, configPath }
81
58
  }
82
59
 
83
60
  // =============================================================================
@@ -85,7 +62,7 @@ async function resolveConfig(flags) {
85
62
  // =============================================================================
86
63
 
87
64
  async function cmdDbCheck(flags) {
88
- const { cwd, configPath } = await resolveConfig(flags)
65
+ const { cwd, configPath } = resolveConfigOrExit(flags)
89
66
  log('info', `→ drift-check via ${path.relative(cwd, configPath)}...`)
90
67
 
91
68
  const result = await runRunner('db-check-runner.mjs', configPath)
@@ -128,7 +105,7 @@ async function cmdDbMigrate(sub2, flags) {
128
105
  log('dim', ' Rollback = editar o schema e re-rodar `opus db migrate`; catástrofe = snapshot pré-deploy.')
129
106
  process.exit(1)
130
107
  }
131
- const { cwd, configPath } = await resolveConfig(flags)
108
+ const { cwd, configPath } = resolveConfigOrExit(flags)
132
109
  log('info', `→ db migrate via ${path.relative(cwd, configPath)}...`)
133
110
 
134
111
  const result = await runRunner('db-migrate-runner.mjs', configPath)
@@ -163,7 +140,7 @@ async function cmdDbMigrate(sub2, flags) {
163
140
  // =============================================================================
164
141
 
165
142
  async function cmdDbScaffold(flags) {
166
- const { cwd, configPath } = await resolveConfig(flags)
143
+ const { cwd, configPath } = resolveConfigOrExit(flags)
167
144
  log('info', `→ db scaffold via ${path.relative(cwd, configPath)}...`)
168
145
 
169
146
  const result = await runRunner('db-scaffold-runner.mjs', configPath)
@@ -188,46 +165,8 @@ async function cmdDbScaffold(flags) {
188
165
  // Runner spawn (via tsx)
189
166
  // =============================================================================
190
167
 
191
- async function runRunner(runnerFile, configPath, extraArgs = []) {
192
- const tsxBin = path.join(PACKAGE_ROOT, 'node_modules', '.bin', 'tsx')
193
- const hasLocalTsx = await fileExists(tsxBin)
194
- const runnerPath = path.join(__dirname, runnerFile)
195
- const cmd = hasLocalTsx ? tsxBin : 'tsx'
196
-
197
- try {
198
- const { stdout } = await execFileAsync(cmd, [runnerPath, configPath, ...extraArgs], {
199
- encoding: 'utf8',
200
- maxBuffer: 16 * 1024 * 1024,
201
- env: { ...process.env },
202
- })
203
- const line = lastJsonLine(stdout)
204
- if (line === null) return { ok: false, error: 'runner não emitiu JSON' }
205
- return JSON.parse(line)
206
- } catch (err) {
207
- const detail =
208
- typeof err.stderr === 'string' && err.stderr.trim().length > 0
209
- ? err.stderr.trim()
210
- : err.message
211
- return { ok: false, error: detail }
212
- }
213
- }
214
-
215
- function lastJsonLine(stdout) {
216
- const lines = stdout.split('\n').filter((l) => l.trim().length > 0)
217
- for (let i = lines.length - 1; i >= 0; i--) {
218
- const trimmed = lines[i].trim()
219
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed
220
- }
221
- return null
222
- }
223
-
224
- async function fileExists(p) {
225
- try {
226
- await fs.access(p)
227
- return true
228
- } catch {
229
- return false
230
- }
168
+ function runRunner(runnerFile, configPath, extraArgs = []) {
169
+ return runJsonRunner(path.join(__dirname, runnerFile), [configPath, ...extraArgs])
231
170
  }
232
171
 
233
172
  // =============================================================================
@@ -245,9 +184,12 @@ export function helpDb() {
245
184
  re-rodável) e roda o drift-check na sequência. Exit ≠ 0 se divergir.
246
185
  db scaffold Gera um rascunho kysely a partir do diff — REFERÊNCIA pra escrever o
247
186
  SQL no schema (a verdade é o script; revise à mão).
187
+ db migrate down Aposentado: encerra com erro. O schema é um script idempotente sem
188
+ histórico a reverter; rollback = editar o script e rodar db migrate.
248
189
 
249
190
  Flags:
250
191
  --config <path> Caminho interno ao projeto para opus.config.ts. Default: ./opus.config.ts
192
+ --help, -h Mostra esta mensagem
251
193
 
252
194
  O opus.config.ts precisa expor, pros comandos db:
253
195
  database: () => Kysely factory LAZY do banco (gen não a chama)
@@ -258,6 +200,6 @@ O opus.config.ts precisa expor, pros comandos db:
258
200
  Exemplos:
259
201
  npx @softize/opus db check
260
202
  npx @softize/opus db migrate
261
- npx @softize/opus db migrate down --config ./apps/api/opus.config.ts
203
+ npx @softize/opus db scaffold --config ./apps/api/opus.config.ts
262
204
  `)
263
205
  }