@softize/opus 12.6.2 → 12.7.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.
- package/CHANGELOG.md +26 -1
- package/README.md +20 -2
- package/bin/cli.mjs +10 -1
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +29 -1
- package/bin/lib/seed-runner.mjs +152 -0
- package/bin/lib/seed.mjs +229 -0
- package/docs/adr/0002-structured-seeds-are-declared-and-bound.md +130 -0
- package/docs/protocol.md +10 -1
- package/docs/seeds.md +132 -0
- package/package.json +5 -1
- package/registry/instructions/opus.md +4 -0
- package/registry/skills/apply-opus-seed/SKILL.md +45 -0
- package/registry/skills/apply-opus-seed/agents/openai.yaml +4 -0
- package/registry/skills/apply-opus-seed/references/evaluations.md +8 -0
- package/registry/skills/build-opus-ui/SKILL.md +12 -6
- package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +26 -0
- package/registry/skills/create-opus-seed/SKILL.md +55 -0
- package/registry/skills/create-opus-seed/agents/openai.yaml +4 -0
- package/registry/skills/create-opus-seed/references/contract.md +16 -0
- package/registry/skills/create-opus-seed/references/evaluations.md +8 -0
- package/registry/skills/create-opus-seed/scripts/scaffold.mjs +78 -0
- package/registry/skills/implement-opus-change/SKILL.md +3 -1
- package/src/core/index.ts +2 -0
- package/src/core/types.ts +16 -1
- package/src/seed/index.ts +391 -0
- package/src/ui/components/patterns/form.tsx +131 -19
- package/src/ui/components/primitives/detail.tsx +113 -0
- package/src/ui/docs/content/action-form.md +10 -0
- package/src/ui/docs/content/cli.md +17 -0
- package/src/ui/docs/content/detail.md +38 -0
- package/src/ui/docs/registry.tsx +2 -0
- package/src/ui/drivers/react.tsx +3 -2
- package/src/ui/lib/object-schema.ts +36 -0
- package/src/ui/meta.ts +7 -1
- package/src/ui/react.tsx +9 -0
|
@@ -5,6 +5,7 @@ description: Implementa uma mudança em projeto baseado no Opus preservando cont
|
|
|
5
5
|
<!-- softize-skill-route: $create-opus-action -->
|
|
6
6
|
<!-- softize-skill-route: $test-opus-action -->
|
|
7
7
|
<!-- softize-skill-route: $build-opus-ui -->
|
|
8
|
+
<!-- softize-skill-route: $create-opus-seed -->
|
|
8
9
|
|
|
9
10
|
# Implementar mudança Opus
|
|
10
11
|
|
|
@@ -25,7 +26,8 @@ dependências server-only, bindings registrados, testes e gates verdes.
|
|
|
25
26
|
2. Modelar primeiro o contrato observável: nome, descrição, input, output, erros e metadata.
|
|
26
27
|
3. Manter código compartilhável fora de banco, segredo, filesystem e drivers server-only.
|
|
27
28
|
4. Implementar o binding e registrar o `ActionDef` no domínio/runtime conforme a topologia local.
|
|
28
|
-
5. Carregar e seguir `$create-opus-action`, `$test-opus-action
|
|
29
|
+
5. Carregar e seguir `$create-opus-action`, `$test-opus-action`, `$build-opus-ui` ou
|
|
30
|
+
`$create-opus-seed` antes
|
|
29
31
|
do passo correspondente quando a mudança entrar nesses workflows especializados.
|
|
30
32
|
6. Atualizar manifest, docs geradas e exemplos somente pelos comandos do repo.
|
|
31
33
|
|
package/src/core/index.ts
CHANGED
package/src/core/types.ts
CHANGED
|
@@ -529,6 +529,21 @@ export type OptionsSpec =
|
|
|
529
529
|
| { kind: 'dictionary'; ref: string }
|
|
530
530
|
| { kind: 'lookup'; source: string; depends?: string[] }
|
|
531
531
|
|
|
532
|
+
/** Overrides declarativos nativos reconhecidos pelo ActionForm. */
|
|
533
|
+
export type BuiltInFieldWidget =
|
|
534
|
+
| 'textarea'
|
|
535
|
+
| 'code'
|
|
536
|
+
| 'lines'
|
|
537
|
+
| 'refItems'
|
|
538
|
+
| 'icon'
|
|
539
|
+
| 'toggle-group'
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Widget nativo ou identificador próprio de outro renderer.
|
|
543
|
+
* A interseção preserva autocomplete dos nativos sem quebrar widgets customizados existentes.
|
|
544
|
+
*/
|
|
545
|
+
export type FieldWidget = BuiltInFieldWidget | (string & { readonly __opusCustomWidget?: never })
|
|
546
|
+
|
|
532
547
|
/**
|
|
533
548
|
* Descrição de campo em `FormAction.fields`. Drive UI rendering + AI tool
|
|
534
549
|
* description per-field.
|
|
@@ -551,7 +566,7 @@ export interface FieldSpec {
|
|
|
551
566
|
group?: string
|
|
552
567
|
order?: number
|
|
553
568
|
|
|
554
|
-
widget?:
|
|
569
|
+
widget?: FieldWidget
|
|
555
570
|
options?: OptionsSpec
|
|
556
571
|
|
|
557
572
|
aiDescription?: string
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeds estruturados são operações de projeto, não actions do runtime.
|
|
3
|
+
*
|
|
4
|
+
* `defineSeed` mantém a declaração pura e compartilhável. `bindSeed` liga a
|
|
5
|
+
* implementação server-only que a CLI carrega pelo `opus.config.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SeedProfile {
|
|
9
|
+
/** Situação que este perfil representa para quem vai aplicá-lo. */
|
|
10
|
+
description: string
|
|
11
|
+
/** Métricas que `verify` precisa encontrar após uma aplicação bem-sucedida. */
|
|
12
|
+
expected?: Readonly<Record<string, number>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SeedSafety {
|
|
16
|
+
/** Escopos de dados em que o seed pode abrir conexão. */
|
|
17
|
+
scopes: readonly SeedScope[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SeedScope = 'local' | 'test' | 'isolated-preview'
|
|
21
|
+
|
|
22
|
+
export interface SeedDefinition {
|
|
23
|
+
/** Identidade estável, em segmentos lowercase separados por ponto. */
|
|
24
|
+
name: string
|
|
25
|
+
/** Versão positiva do dataset e de suas invariantes. */
|
|
26
|
+
version: number
|
|
27
|
+
description: string
|
|
28
|
+
profiles: Readonly<Record<string, SeedProfile>>
|
|
29
|
+
defaultProfile: string
|
|
30
|
+
dependsOn?: readonly string[]
|
|
31
|
+
safety: SeedSafety
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SeedContext<Database = unknown> {
|
|
35
|
+
database: Database | null
|
|
36
|
+
profile: string
|
|
37
|
+
scope: SeedScope
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SeedTargetContext<Database = unknown> {
|
|
41
|
+
database: Database | null
|
|
42
|
+
scope: SeedScope
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Atesta que a conexão aberta corresponde ao escopo solicitado, antes de qualquer binding.
|
|
47
|
+
* Deve retornar `true` explicitamente; ausência de retorno e valores falsos falham fechado.
|
|
48
|
+
*/
|
|
49
|
+
export type SeedTargetAssertion<Database = unknown> = (
|
|
50
|
+
context: SeedTargetContext<Database>,
|
|
51
|
+
) => boolean | Promise<boolean>
|
|
52
|
+
|
|
53
|
+
export interface SeedPlan {
|
|
54
|
+
summary: string
|
|
55
|
+
operations: readonly string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SeedReport {
|
|
59
|
+
summary: string
|
|
60
|
+
metrics: Readonly<Record<string, number>>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SeedBinding<Database = unknown> {
|
|
64
|
+
plan(context: SeedContext<Database>): SeedPlan | Promise<SeedPlan>
|
|
65
|
+
apply(context: SeedContext<Database>): SeedReport | Promise<SeedReport>
|
|
66
|
+
verify(context: SeedContext<Database>): SeedReport | Promise<SeedReport>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface BoundSeed<Database = unknown> extends SeedDefinition {
|
|
70
|
+
readonly binding: SeedBinding<Database>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type SeedRegistryItem = SeedDefinition | BoundSeed
|
|
74
|
+
|
|
75
|
+
export interface SeedDiagnostic {
|
|
76
|
+
code:
|
|
77
|
+
| 'seed.duplicate'
|
|
78
|
+
| 'seed.unbound'
|
|
79
|
+
| 'seed.missing_dependency'
|
|
80
|
+
| 'seed.cyclic_dependency'
|
|
81
|
+
message: string
|
|
82
|
+
seed: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SeedRegistryCheck {
|
|
86
|
+
ok: boolean
|
|
87
|
+
diagnostics: readonly SeedDiagnostic[]
|
|
88
|
+
/** Ordem topológica; dependências aparecem antes de seus consumidores. */
|
|
89
|
+
ordered: readonly BoundSeed[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SEED_NAME_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/
|
|
93
|
+
const PROFILE_RE = /^[a-z][a-z0-9-]*$/
|
|
94
|
+
const SCOPE_RE = /^[a-z][a-z0-9-]*$/
|
|
95
|
+
const SEED_SCOPES = new Set<SeedScope>(['local', 'test', 'isolated-preview'])
|
|
96
|
+
|
|
97
|
+
export function defineSeed<const Definition extends SeedDefinition>(
|
|
98
|
+
definition: Definition,
|
|
99
|
+
): Definition {
|
|
100
|
+
validateDefinition(definition)
|
|
101
|
+
return definition
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Projeta somente os metadados públicos do seed.
|
|
106
|
+
*
|
|
107
|
+
* A projeção é deliberadamente profunda: objetos recebidos de TypeScript ou
|
|
108
|
+
* JavaScript podem carregar campos adicionais, mas manifestos e respostas da
|
|
109
|
+
* CLI nunca devem reproduzi-los por espalhamento ou referência.
|
|
110
|
+
*/
|
|
111
|
+
export function publicSeedDefinition(seed: SeedDefinition): SeedDefinition {
|
|
112
|
+
validateDefinition(seed)
|
|
113
|
+
return {
|
|
114
|
+
name: seed.name,
|
|
115
|
+
version: seed.version,
|
|
116
|
+
description: seed.description,
|
|
117
|
+
profiles: Object.fromEntries(
|
|
118
|
+
Object.entries(seed.profiles).map(([name, profile]) => [
|
|
119
|
+
name,
|
|
120
|
+
{
|
|
121
|
+
description: profile.description,
|
|
122
|
+
...(profile.expected === undefined ? {} : { expected: { ...profile.expected } }),
|
|
123
|
+
},
|
|
124
|
+
]),
|
|
125
|
+
),
|
|
126
|
+
defaultProfile: seed.defaultProfile,
|
|
127
|
+
...(seed.dependsOn === undefined ? {} : { dependsOn: [...seed.dependsOn] }),
|
|
128
|
+
safety: { scopes: [...seed.safety.scopes] },
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function bindSeed<Database = unknown>(
|
|
133
|
+
definition: SeedDefinition,
|
|
134
|
+
binding: SeedBinding<Database>,
|
|
135
|
+
): BoundSeed<Database> {
|
|
136
|
+
validateDefinition(definition)
|
|
137
|
+
validateBinding(binding)
|
|
138
|
+
return { ...definition, binding }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isSeedDefinition(value: unknown): value is SeedDefinition {
|
|
142
|
+
try {
|
|
143
|
+
validateDefinition(value)
|
|
144
|
+
return true
|
|
145
|
+
} catch {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isBoundSeed(value: unknown): value is BoundSeed {
|
|
151
|
+
if (!isSeedDefinition(value)) return false
|
|
152
|
+
try {
|
|
153
|
+
validateBinding((value as Partial<BoundSeed>).binding)
|
|
154
|
+
return true
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function checkSeedRegistry(items: readonly SeedRegistryItem[]): SeedRegistryCheck {
|
|
161
|
+
const diagnostics: SeedDiagnostic[] = []
|
|
162
|
+
const byName = new Map<string, BoundSeed>()
|
|
163
|
+
const seen = new Set<string>()
|
|
164
|
+
|
|
165
|
+
for (const item of items) {
|
|
166
|
+
validateDefinition(item)
|
|
167
|
+
if (seen.has(item.name)) {
|
|
168
|
+
diagnostics.push({
|
|
169
|
+
code: 'seed.duplicate',
|
|
170
|
+
seed: item.name,
|
|
171
|
+
message: `Seed duplicado: ${item.name}`,
|
|
172
|
+
})
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
seen.add(item.name)
|
|
176
|
+
if (!isBoundSeed(item)) {
|
|
177
|
+
diagnostics.push({
|
|
178
|
+
code: 'seed.unbound',
|
|
179
|
+
seed: item.name,
|
|
180
|
+
message: `Seed ${item.name} precisa de plan, apply e verify via bindSeed`,
|
|
181
|
+
})
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
byName.set(item.name, item)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const item of items) {
|
|
188
|
+
for (const dependency of item.dependsOn ?? []) {
|
|
189
|
+
if (!seen.has(dependency)) {
|
|
190
|
+
diagnostics.push({
|
|
191
|
+
code: 'seed.missing_dependency',
|
|
192
|
+
seed: item.name,
|
|
193
|
+
message: `Seed ${item.name} depende de ${dependency}, que não está registrado`,
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const ordered: BoundSeed[] = []
|
|
200
|
+
const visiting = new Set<string>()
|
|
201
|
+
const visited = new Set<string>()
|
|
202
|
+
const cycles = new Set<string>()
|
|
203
|
+
|
|
204
|
+
const visit = (name: string, path: readonly string[]): void => {
|
|
205
|
+
if (visited.has(name)) return
|
|
206
|
+
if (visiting.has(name)) {
|
|
207
|
+
const start = path.indexOf(name)
|
|
208
|
+
const cycle = [...path.slice(start), name]
|
|
209
|
+
const key = cycle.join(' -> ')
|
|
210
|
+
if (!cycles.has(key)) {
|
|
211
|
+
cycles.add(key)
|
|
212
|
+
diagnostics.push({
|
|
213
|
+
code: 'seed.cyclic_dependency',
|
|
214
|
+
seed: name,
|
|
215
|
+
message: `Dependência cíclica entre seeds: ${key}`,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const seed = byName.get(name)
|
|
221
|
+
if (seed === undefined) return
|
|
222
|
+
visiting.add(name)
|
|
223
|
+
for (const dependency of seed.dependsOn ?? []) visit(dependency, [...path, name])
|
|
224
|
+
visiting.delete(name)
|
|
225
|
+
visited.add(name)
|
|
226
|
+
ordered.push(seed)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const name of byName.keys()) visit(name, [])
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
ok: diagnostics.length === 0,
|
|
233
|
+
diagnostics,
|
|
234
|
+
ordered: diagnostics.some((diagnostic) => diagnostic.code === 'seed.cyclic_dependency')
|
|
235
|
+
? []
|
|
236
|
+
: ordered,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function resolveSeedProfile(seed: SeedDefinition, requested?: string): string {
|
|
241
|
+
const profile = requested ?? seed.defaultProfile
|
|
242
|
+
if (!Object.hasOwn(seed.profiles, profile)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Perfil ${profile} não existe no seed ${seed.name}; disponíveis: ${Object.keys(seed.profiles).join(', ')}`,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
return profile
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function assertSeedExecutionAllowed(
|
|
251
|
+
seed: SeedDefinition,
|
|
252
|
+
scope: string | undefined,
|
|
253
|
+
environment = runtimeNodeEnvironment(),
|
|
254
|
+
): asserts scope is SeedScope {
|
|
255
|
+
const normalizedEnvironment = environment?.trim().toLowerCase()
|
|
256
|
+
if (normalizedEnvironment !== 'development' && normalizedEnvironment !== 'test') {
|
|
257
|
+
throw new Error('Seeds exigem NODE_ENV=development ou NODE_ENV=test antes de abrir conexão')
|
|
258
|
+
}
|
|
259
|
+
if (scope === undefined || scope.trim().length === 0) {
|
|
260
|
+
throw new Error('Informe --scope ou OPUS_SEED_SCOPE antes de abrir o banco')
|
|
261
|
+
}
|
|
262
|
+
if (!seed.safety.scopes.some((allowedScope) => allowedScope === scope)) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Escopo ${scope} não é permitido para ${seed.name}; permitidos: ${seed.safety.scopes.join(', ')}`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function runtimeNodeEnvironment(): string | undefined {
|
|
270
|
+
return (
|
|
271
|
+
globalThis as typeof globalThis & {
|
|
272
|
+
process?: { env?: { NODE_ENV?: string } }
|
|
273
|
+
}
|
|
274
|
+
).process?.env?.NODE_ENV
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function validateSeedPlan(value: unknown): SeedPlan {
|
|
278
|
+
if (!isRecord(value) || !isNonEmptyString(value.summary) || !Array.isArray(value.operations)) {
|
|
279
|
+
throw new Error('plan precisa retornar { summary, operations }')
|
|
280
|
+
}
|
|
281
|
+
if (!value.operations.every(isNonEmptyString)) {
|
|
282
|
+
throw new Error('plan.operations precisa conter somente descrições não vazias')
|
|
283
|
+
}
|
|
284
|
+
return value as unknown as SeedPlan
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function validateSeedReport(value: unknown): SeedReport {
|
|
288
|
+
if (!isRecord(value) || !isNonEmptyString(value.summary) || !isMetrics(value.metrics)) {
|
|
289
|
+
throw new Error('apply/verify precisa retornar { summary, metrics } com valores numéricos finitos')
|
|
290
|
+
}
|
|
291
|
+
return value as unknown as SeedReport
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function assertSeedExpectedMetrics(
|
|
295
|
+
seed: SeedDefinition,
|
|
296
|
+
profile: string,
|
|
297
|
+
report: SeedReport,
|
|
298
|
+
): void {
|
|
299
|
+
const expected = seed.profiles[profile]?.expected
|
|
300
|
+
if (expected === undefined) return
|
|
301
|
+
const mismatches = Object.entries(expected).flatMap(([metric, wanted]) => {
|
|
302
|
+
const actual = report.metrics[metric]
|
|
303
|
+
return actual === wanted ? [] : [`${metric}: esperado ${wanted}, encontrado ${String(actual)}`]
|
|
304
|
+
})
|
|
305
|
+
if (mismatches.length > 0) {
|
|
306
|
+
throw new Error(`Verificação de ${seed.name} falhou — ${mismatches.join('; ')}`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function validateDefinition(value: unknown): asserts value is SeedDefinition {
|
|
311
|
+
if (!isRecord(value)) throw new Error('Seed precisa ser um objeto')
|
|
312
|
+
if (typeof value.name !== 'string' || !SEED_NAME_RE.test(value.name)) {
|
|
313
|
+
throw new Error('Seed name deve usar ao menos dois segmentos lowercase, como customers.scenarios')
|
|
314
|
+
}
|
|
315
|
+
if (!Number.isInteger(value.version) || Number(value.version) <= 0) {
|
|
316
|
+
throw new Error(`Seed ${value.name} precisa de version inteira positiva`)
|
|
317
|
+
}
|
|
318
|
+
if (!isNonEmptyString(value.description)) {
|
|
319
|
+
throw new Error(`Seed ${value.name} precisa de description`)
|
|
320
|
+
}
|
|
321
|
+
if (!isRecord(value.profiles) || Object.keys(value.profiles).length === 0) {
|
|
322
|
+
throw new Error(`Seed ${value.name} precisa de ao menos um profile`)
|
|
323
|
+
}
|
|
324
|
+
for (const [profileName, profile] of Object.entries(value.profiles)) {
|
|
325
|
+
if (!PROFILE_RE.test(profileName)) {
|
|
326
|
+
throw new Error(`Profile ${profileName} de ${value.name} possui nome inválido`)
|
|
327
|
+
}
|
|
328
|
+
if (!isRecord(profile) || !isNonEmptyString(profile.description)) {
|
|
329
|
+
throw new Error(`Profile ${profileName} de ${value.name} precisa de description`)
|
|
330
|
+
}
|
|
331
|
+
if (profile.expected !== undefined && !isMetrics(profile.expected)) {
|
|
332
|
+
throw new Error(`Profile ${profileName} de ${value.name} possui métricas esperadas inválidas`)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (typeof value.defaultProfile !== 'string' || !Object.hasOwn(value.profiles, value.defaultProfile)) {
|
|
336
|
+
throw new Error(`defaultProfile de ${value.name} precisa apontar para um profile existente`)
|
|
337
|
+
}
|
|
338
|
+
if (value.dependsOn !== undefined) {
|
|
339
|
+
if (!Array.isArray(value.dependsOn)) throw new Error(`dependsOn de ${value.name} precisa ser uma lista`)
|
|
340
|
+
const dependencies = value.dependsOn
|
|
341
|
+
const unique = new Set(dependencies)
|
|
342
|
+
if (
|
|
343
|
+
dependencies.some((dependency) => typeof dependency !== 'string' || !SEED_NAME_RE.test(dependency)) ||
|
|
344
|
+
unique.size !== dependencies.length ||
|
|
345
|
+
unique.has(value.name)
|
|
346
|
+
) {
|
|
347
|
+
throw new Error(`dependsOn de ${value.name} possui dependência inválida, repetida ou autorreferente`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!isRecord(value.safety) || !Array.isArray(value.safety.scopes) || value.safety.scopes.length === 0) {
|
|
351
|
+
throw new Error(`Seed ${value.name} precisa declarar safety.scopes`)
|
|
352
|
+
}
|
|
353
|
+
const scopes = value.safety.scopes
|
|
354
|
+
if (
|
|
355
|
+
scopes.some((scope) => typeof scope !== 'string' || !SCOPE_RE.test(scope)) ||
|
|
356
|
+
new Set(scopes).size !== scopes.length ||
|
|
357
|
+
scopes.some((scope) => !SEED_SCOPES.has(scope as SeedScope))
|
|
358
|
+
) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`safety.scopes de ${value.name} aceita somente local, test e isolated-preview, sem repetições`,
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function validateBinding(value: unknown): asserts value is SeedBinding {
|
|
366
|
+
if (
|
|
367
|
+
!isRecord(value) ||
|
|
368
|
+
typeof value.plan !== 'function' ||
|
|
369
|
+
typeof value.apply !== 'function' ||
|
|
370
|
+
typeof value.verify !== 'function'
|
|
371
|
+
) {
|
|
372
|
+
throw new Error('bindSeed exige as funções plan, apply e verify')
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isMetrics(value: unknown): value is Readonly<Record<string, number>> {
|
|
377
|
+
return (
|
|
378
|
+
isRecord(value) &&
|
|
379
|
+
Object.entries(value).every(
|
|
380
|
+
([name, metric]) => isNonEmptyString(name) && typeof metric === 'number' && Number.isFinite(metric),
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
386
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
390
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
391
|
+
}
|
|
@@ -17,12 +17,18 @@
|
|
|
17
17
|
* inline; toast em sucesso/erro. Emite `data-action="<action.name>"` na raiz (selector E2E).
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { createContext, useContext } from 'react'
|
|
20
|
+
import { createContext, useContext, useEffect } from 'react'
|
|
21
21
|
import type { UseFormReturn } from 'react-hook-form'
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
getLogicalType,
|
|
24
|
+
type FieldWidget,
|
|
25
|
+
type FormContract,
|
|
26
|
+
type OptionsSpec,
|
|
27
|
+
} from '../../../core/index.ts'
|
|
23
28
|
import { useDicts, useFormAction, type DictLike } from '../../drivers/react.tsx'
|
|
24
29
|
import { z, type ZodTypeAny } from 'zod'
|
|
25
30
|
import { cn } from '../../lib/cn.ts'
|
|
31
|
+
import { objectSchemaShape } from '../../lib/object-schema.ts'
|
|
26
32
|
import { toast } from '../primitives/sonner.tsx'
|
|
27
33
|
import { Button } from '../primitives/button.tsx'
|
|
28
34
|
import { Input } from '../primitives/input.tsx'
|
|
@@ -32,6 +38,7 @@ import { IconPicker } from '../primitives/icon-picker.tsx'
|
|
|
32
38
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
|
|
33
39
|
import { Info } from 'lucide-react'
|
|
34
40
|
import { Select, type SelectOption } from '../primitives/select.tsx'
|
|
41
|
+
import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
|
|
35
42
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
|
|
36
43
|
|
|
37
44
|
// =============================================================================
|
|
@@ -44,6 +51,7 @@ type FieldKind =
|
|
|
44
51
|
| { kind: 'checkbox'; required: boolean }
|
|
45
52
|
| { kind: 'select'; required: boolean; options: string[] }
|
|
46
53
|
| { kind: 'multiselect'; required: boolean; options: string[] }
|
|
54
|
+
| { kind: 'toggle-group'; required: boolean; multiple: boolean; options: string[] }
|
|
47
55
|
| { kind: 'lines'; required: boolean }
|
|
48
56
|
| { kind: 'refItems'; required: boolean }
|
|
49
57
|
| { kind: 'icon'; required: boolean }
|
|
@@ -111,8 +119,9 @@ interface FieldSpec {
|
|
|
111
119
|
* 'lines' (z.array(z.string()) num textarea, um item por linha), 'refItems'
|
|
112
120
|
* (z.array(z.object({ref, text})) — linhas de referência + texto; as opções do
|
|
113
121
|
* `ref` vêm de fieldOptions[campo]) e 'icon' (string com o nome kebab-case da
|
|
114
|
-
* paleta da casa — renderiza o <IconPicker>)
|
|
115
|
-
|
|
122
|
+
* paleta da casa — renderiza o <IconPicker>) e 'toggle-group' para escolhas
|
|
123
|
+
* declarativas com opções ricas. */
|
|
124
|
+
widget?: FieldWidget
|
|
116
125
|
/** Renderiza o campo só quando true pro input atual (ex.: clientId só se !staff).
|
|
117
126
|
* No modo COMPOSIÇÃO o condicional pode (e deve) ser JSX de quem diagrama. */
|
|
118
127
|
showWhen?: (input: Record<string, unknown>) => boolean
|
|
@@ -194,14 +203,16 @@ function LabelHelp({ help }: { help: string | undefined }): React.ReactElement |
|
|
|
194
203
|
)
|
|
195
204
|
}
|
|
196
205
|
|
|
197
|
-
/**
|
|
198
|
-
* aria-hidden: leitor de tela valida pelo erro do resolver, não pelo símbolo. */
|
|
206
|
+
/** Marca de obrigatório na label — derivada do Zod (campo sem optional/default). */
|
|
199
207
|
function RequiredMark({ required }: { required: boolean }): React.ReactElement | null {
|
|
200
208
|
if (!required) return null
|
|
201
209
|
return (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
<>
|
|
211
|
+
<span aria-hidden className="ml-0.5 text-destructive">
|
|
212
|
+
*
|
|
213
|
+
</span>
|
|
214
|
+
<span className="sr-only"> (obrigatório)</span>
|
|
215
|
+
</>
|
|
205
216
|
)
|
|
206
217
|
}
|
|
207
218
|
|
|
@@ -264,7 +275,30 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
264
275
|
// Assina TODOS os valores: alimenta o showWhen e os widgets controlados.
|
|
265
276
|
const formValues = form.watch()
|
|
266
277
|
|
|
278
|
+
const focusControl = (): void => {
|
|
279
|
+
const control = document.getElementById(name)
|
|
280
|
+
if (control?.dataset.slot === 'toggle-group') {
|
|
281
|
+
control.querySelector<HTMLElement>('[data-slot="toggle-group-item"]:not([disabled])')?.focus()
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
control?.focus()
|
|
285
|
+
}
|
|
286
|
+
|
|
267
287
|
const spec: FieldSpec = fields[name] ?? {}
|
|
288
|
+
const fieldError = form.formState.errors[name]
|
|
289
|
+
const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
|
|
290
|
+
const descriptionId = spec.hint !== undefined ? `${name}-description` : undefined
|
|
291
|
+
const errorId = errorMessage !== undefined ? `${name}-error` : undefined
|
|
292
|
+
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (errorMessage === undefined) return
|
|
295
|
+
const control = document.getElementById(name)
|
|
296
|
+
const firstInvalidToggle = control
|
|
297
|
+
?.closest('form')
|
|
298
|
+
?.querySelector<HTMLElement>('[data-slot="toggle-group"][aria-invalid="true"]')
|
|
299
|
+
if (firstInvalidToggle?.id === name) focusControl()
|
|
300
|
+
}, [errorMessage, name])
|
|
301
|
+
|
|
268
302
|
const fieldSchema = shape[name]
|
|
269
303
|
if (fieldSchema === undefined) return null
|
|
270
304
|
if (typeof spec.showWhen === 'function' && !spec.showWhen(formValues)) return null
|
|
@@ -280,12 +314,17 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
280
314
|
? { kind: 'refItems', required: inferred.required }
|
|
281
315
|
: spec.widget === 'icon'
|
|
282
316
|
? { kind: 'icon', required: inferred.required }
|
|
283
|
-
:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
317
|
+
: spec.widget === 'toggle-group'
|
|
318
|
+
? {
|
|
319
|
+
kind: 'toggle-group',
|
|
320
|
+
required: inferred.required,
|
|
321
|
+
multiple: inferred.kind === 'multiselect',
|
|
322
|
+
options:
|
|
323
|
+
inferred.kind === 'select' || inferred.kind === 'multiselect'
|
|
324
|
+
? inferred.options
|
|
325
|
+
: [],
|
|
326
|
+
}
|
|
327
|
+
: inferred
|
|
289
328
|
// Precedência das opções: prop do campo > fieldOptions do form > spec.options
|
|
290
329
|
// (dictionary via provider, static direto) > meta do t.dict no schema
|
|
291
330
|
// (zero-config) > chaves cruas do z.enum.
|
|
@@ -295,7 +334,9 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
295
334
|
const options: SelectOption[] =
|
|
296
335
|
declaredOptions ??
|
|
297
336
|
dictMetaOptions(fieldSchema) ??
|
|
298
|
-
(fieldKind.kind === 'select' ||
|
|
337
|
+
(fieldKind.kind === 'select' ||
|
|
338
|
+
fieldKind.kind === 'multiselect' ||
|
|
339
|
+
fieldKind.kind === 'toggle-group'
|
|
299
340
|
? fieldKind.options.map((o) => ({ value: o, label: o }))
|
|
300
341
|
: [])
|
|
301
342
|
// Campo texto COM opções declaradas (runtime ou spec) → single-select por-id.
|
|
@@ -343,7 +384,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
343
384
|
aria-invalid={errorMessage !== undefined}
|
|
344
385
|
aria-describedby={describedBy}
|
|
345
386
|
>
|
|
346
|
-
<FieldLabel htmlFor={name} className="items-center gap-1.5">
|
|
387
|
+
<FieldLabel id={`${name}-label`} htmlFor={name} onClick={focusControl} className="items-center gap-1.5">
|
|
347
388
|
<span>
|
|
348
389
|
{spec.label ?? name}
|
|
349
390
|
<RequiredMark required={fieldKind.required} />
|
|
@@ -351,7 +392,78 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
351
392
|
<LabelHelp help={spec.help} />
|
|
352
393
|
</FieldLabel>
|
|
353
394
|
|
|
354
|
-
{effectiveKind.kind === '
|
|
395
|
+
{effectiveKind.kind === 'toggle-group' ? (
|
|
396
|
+
effectiveKind.multiple ? (
|
|
397
|
+
<ToggleGroup
|
|
398
|
+
type="multiple"
|
|
399
|
+
id={name}
|
|
400
|
+
value={(formValues[name] as string[] | undefined) ?? []}
|
|
401
|
+
onValueChange={(value) => setValue(value)}
|
|
402
|
+
variant="outline"
|
|
403
|
+
spacing={2}
|
|
404
|
+
aria-labelledby={`${name}-label`}
|
|
405
|
+
aria-invalid={errorMessage !== undefined}
|
|
406
|
+
aria-describedby={describedBy}
|
|
407
|
+
className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] items-stretch gap-2"
|
|
408
|
+
>
|
|
409
|
+
{options.map((option) => (
|
|
410
|
+
<ToggleGroupItem
|
|
411
|
+
key={option.value}
|
|
412
|
+
value={option.value}
|
|
413
|
+
disabled={option.disabled}
|
|
414
|
+
aria-label={option.label}
|
|
415
|
+
className="h-auto min-h-14 w-full cursor-pointer justify-start whitespace-normal p-3 text-left data-[state=on]:border-primary data-[state=on]:bg-primary/5 data-[state=on]:shadow-sm"
|
|
416
|
+
>
|
|
417
|
+
<span className="min-w-0">
|
|
418
|
+
<span className="block">{option.content ?? option.label}</span>
|
|
419
|
+
{option.hint !== undefined && (
|
|
420
|
+
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
|
421
|
+
{option.hint}
|
|
422
|
+
</span>
|
|
423
|
+
)}
|
|
424
|
+
</span>
|
|
425
|
+
</ToggleGroupItem>
|
|
426
|
+
))}
|
|
427
|
+
</ToggleGroup>
|
|
428
|
+
) : (
|
|
429
|
+
<ToggleGroup
|
|
430
|
+
type="single"
|
|
431
|
+
id={name}
|
|
432
|
+
value={(formValues[name] as string | undefined) ?? ''}
|
|
433
|
+
onValueChange={(value) => {
|
|
434
|
+
if (value !== '' || !effectiveKind.required) {
|
|
435
|
+
setValue(value === '' ? undefined : value)
|
|
436
|
+
}
|
|
437
|
+
}}
|
|
438
|
+
variant="outline"
|
|
439
|
+
spacing={2}
|
|
440
|
+
aria-labelledby={`${name}-label`}
|
|
441
|
+
aria-required={effectiveKind.required}
|
|
442
|
+
aria-invalid={errorMessage !== undefined}
|
|
443
|
+
aria-describedby={describedBy}
|
|
444
|
+
className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] items-stretch gap-2"
|
|
445
|
+
>
|
|
446
|
+
{options.map((option) => (
|
|
447
|
+
<ToggleGroupItem
|
|
448
|
+
key={option.value}
|
|
449
|
+
value={option.value}
|
|
450
|
+
disabled={option.disabled}
|
|
451
|
+
aria-label={option.label}
|
|
452
|
+
className="h-auto min-h-14 w-full cursor-pointer justify-start whitespace-normal p-3 text-left data-[state=on]:border-primary data-[state=on]:bg-primary/5 data-[state=on]:shadow-sm"
|
|
453
|
+
>
|
|
454
|
+
<span className="min-w-0">
|
|
455
|
+
<span className="block">{option.content ?? option.label}</span>
|
|
456
|
+
{option.hint !== undefined && (
|
|
457
|
+
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
|
458
|
+
{option.hint}
|
|
459
|
+
</span>
|
|
460
|
+
)}
|
|
461
|
+
</span>
|
|
462
|
+
</ToggleGroupItem>
|
|
463
|
+
))}
|
|
464
|
+
</ToggleGroup>
|
|
465
|
+
)
|
|
466
|
+
) : effectiveKind.kind === 'multiselect' ? (
|
|
355
467
|
<Select
|
|
356
468
|
multiple
|
|
357
469
|
searchable
|
|
@@ -543,7 +655,7 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
543
655
|
},
|
|
544
656
|
})
|
|
545
657
|
|
|
546
|
-
const shape = (action.input
|
|
658
|
+
const shape = (objectSchemaShape(action.input) ?? {}) as Record<string, ZodTypeAny | undefined>
|
|
547
659
|
const fields = action.fields as Record<string, FieldSpec>
|
|
548
660
|
|
|
549
661
|
// Corpo e rodapé como SLOTS: por padrão renderizam inline; o ActionFormDialog injeta
|