@softize/opus 24.1.0 → 25.1.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 +121 -0
- package/README.md +1 -1
- package/bin/cli.mjs +9 -0
- package/bin/lib/check.mjs +390 -1
- package/bin/lib/copy.mjs +39 -2
- package/bin/lib/gen-runner.mjs +25 -12
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
- package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
- package/docs/data-layer.md +31 -3
- package/docs/protocol.md +98 -2
- package/docs/releasing.md +8 -2
- package/docs/stacking-scale.md +53 -0
- package/package.json +6 -1
- package/src/ai/drivers/anthropic.ts +3 -0
- package/src/core/domain.ts +189 -7
- package/src/core/index.ts +19 -2
- package/src/core/retention.ts +493 -0
- package/src/core/runtime.ts +93 -29
- package/src/core/system-actor.ts +33 -0
- package/src/core/types.ts +12 -0
- package/src/data/drivers/kysely.ts +47 -5
- package/src/data/index.ts +24 -6
- package/src/mcp/index.ts +3 -2
- package/src/schema/entity.ts +30 -9
- package/src/schema/index.ts +4 -0
- package/src/schema/openapi.ts +4 -1
- package/src/ui/components/primitives/dialog.tsx +285 -26
- package/src/ui/components/primitives/drawer.tsx +104 -13
- package/src/ui/components/primitives/floating-layer.tsx +79 -0
- package/src/ui/components/primitives/menu.tsx +17 -2
- package/src/ui/components/primitives/popover.tsx +6 -1
- package/src/ui/components/primitives/tooltip.tsx +6 -1
- package/src/ui/docs/content/cli.md +5 -3
- package/src/ui/docs/content/data.md +42 -0
- package/src/ui/docs/content/dialog.md +3 -0
- package/src/ui/docs/content/drawer.md +3 -0
- package/src/ui/docs/content/floating-layer.md +178 -0
- package/src/ui/docs/content/scheduler.md +4 -0
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/lib/dismiss-on-iframe.ts +4 -1
- package/src/ui/lib/modal-layers.ts +547 -0
- package/src/ui/meta.ts +6 -0
- package/src/ui/react.tsx +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,127 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 25.1.0 — 2026-10-03
|
|
11
|
+
|
|
12
|
+
`FloatingLayer`, de `@softize/opus/ui/react`, é uma camada que continua utilizável por cima de
|
|
13
|
+
qualquer `Dialog` ou `Drawer` modal, como o botão e o painel de um assistente (ADR 0052). Quem está
|
|
14
|
+
preenchendo um formulário em um modal pode usar a camada sem fechar o modal nem perder o que digitou.
|
|
15
|
+
Os filhos vão para um hospedeiro fixo no `body`, `div[data-slot="floating-layer"]`, e cada um se
|
|
16
|
+
posiciona contra a janela. Com um `Dialog` ou `Drawer` aberto:
|
|
17
|
+
|
|
18
|
+
- clicar na camada não fecha o modal, mesmo que o elemento clicado suma no próprio clique, e o véu
|
|
19
|
+
continua fechando, respeitando `closeDisabled`;
|
|
20
|
+
- o foco pode ir para a camada por clique ou por código sem ser puxado de volta, e o Tab passa do
|
|
21
|
+
último elemento do modal para a camada e dela de volta ao primeiro; quando o elemento focado da
|
|
22
|
+
camada some, o foco volta ao campo do modal em que a pessoa estava, e um modal que fecha com a
|
|
23
|
+
pessoa na camada não leva o foco ao gatilho dele;
|
|
24
|
+
- o Esc age onde a pessoa está: na camada, nenhum modal fecha e `onEscapeKeyDown` é chamado uma vez
|
|
25
|
+
por tecla; no modal, o modal fecha;
|
|
26
|
+
- a camada rola e continua visível ao leitor de tela, enquanto a página atrás segue travada e
|
|
27
|
+
escondida.
|
|
28
|
+
|
|
29
|
+
O hospedeiro publica o `Dialog` ou `Drawer` do topo em `data-modal` (`none`, `dialog` ou `drawer`) e o
|
|
30
|
+
lado do Drawer em `data-modal-side`, para o consumidor tirar o painel de cima do modal. O Opus não
|
|
31
|
+
desloca nem redimensiona modais. Os patterns construídos sobre `Dialog` e `Drawer`, como
|
|
32
|
+
`ActionFormDialog`, `ActionListDialog`, `Presentation` e `CommandDialog`, convivem com a camada sem
|
|
33
|
+
mudança.
|
|
34
|
+
|
|
35
|
+
Outras mudanças:
|
|
36
|
+
|
|
37
|
+
- Popover, Menu, Tooltip, Select, Dialog e Drawer abertos de dentro de uma `FloatingLayer` vão para o
|
|
38
|
+
hospedeiro dela, e não para o `body`; fora da camada, nada muda;
|
|
39
|
+
- `Dialog mode="alert"`, que também serve às confirmações do `DialogHost`, passa à faixa `z-70`,
|
|
40
|
+
acima da camada (`z-60`) e dos demais modais (`z-50`), e cobre a camada até a resposta, inclusive
|
|
41
|
+
enquanto um `Dialog` aberto de dentro dele estiver aberto. Popover, Menu e Tooltip do Opus abertos
|
|
42
|
+
de dentro de um alerta acompanham a faixa dele. Dois efeitos para conferir no seu código: um
|
|
43
|
+
elemento próprio com `z-index` entre 51 e 69, que antes ficava acima do alerta, agora fica abaixo
|
|
44
|
+
dele; e uma sobreposição de outra biblioteca com `z-50`, aberta de dentro de um alerta, fica
|
|
45
|
+
abaixo do véu dele. Durante a animação de saída, o alerta deixa de receber clique. A escala está
|
|
46
|
+
em `docs/stacking-scale.md`;
|
|
47
|
+
- `DialogPortal` e `MenuPortal` sem `container` usam o hospedeiro da camada quando abertos de
|
|
48
|
+
dentro dela;
|
|
49
|
+
- a `ref` de `DialogContent` e `DrawerContent` continua recebendo a limpeza das callback refs do
|
|
50
|
+
React 19, agora composta com a ref interna da camada;
|
|
51
|
+
- o pacote ganha testes em navegador real (Chromium, pelo modo navegador do Vitest com Playwright)
|
|
52
|
+
para o foco da camada: `pnpm test:browser` roda à parte do `pnpm test` e entra no gate de release.
|
|
53
|
+
|
|
54
|
+
## 25.0.0 — 2026-10-02
|
|
55
|
+
|
|
56
|
+
A exclusão lógica passa a declarar quando o registro excluído sai de vez (ADR 0051). `softDelete`
|
|
57
|
+
aceita a retenção com prazo — `retainDays`, `timezone` e os textos `label` e `description` da action
|
|
58
|
+
de expurgo, com `schedule`, `batchSize` e `maxDurationMs` opcionais — ou `{ retain: 'forever',
|
|
59
|
+
reason }`. Para cada entidade com `retainDays`, o domínio que a lista em `entities` ganha a action
|
|
60
|
+
`<caminho-do-domínio>.<entidade-em-kebab>.purge-deleted` e a rotina `<action>.daily`, que roda às
|
|
61
|
+
3h30 no fuso declarado, como o sistema. A action aceita `olderThanDays` entre o `retainDays` e
|
|
62
|
+
3650 e nunca expurga antes do prazo; só o sistema a executa, e ela fica fora do REST e da IA. Com
|
|
63
|
+
`kyselyData`, o handler padrão apaga em lotes, das linhas mais antigas para as mais novas, até
|
|
64
|
+
acabar ou esgotar `maxDurationMs`. Quando apagar a linha não basta, `bindPurge`, de
|
|
65
|
+
`@softize/opus/data`, liga um handler próprio, registrado em `purges` no `defineDomain`; ele
|
|
66
|
+
precisa respeitar o `cutoff` que recebe, e um lote que repete ids ou excede o `batchSize` interrompe
|
|
67
|
+
o expurgo com `purge.no_progress`. Cada lote que apaga algo registra `opus.purge` no log, com os ids
|
|
68
|
+
removidos em blocos de no máximo 500, e a execução termina com `opus.purge.total`, que traz a
|
|
69
|
+
quantidade, os lotes e se restou trabalho. Os schemas da action gerada são Standard Schema com o
|
|
70
|
+
JSON Schema anexado, sem zod: `@softize/opus` continua carregando sem zod instalado, e o manifest, o
|
|
71
|
+
OpenAPI e o MCP leem esse JSON Schema antes de recorrer ao zod.
|
|
72
|
+
|
|
73
|
+
Outras mudanças:
|
|
74
|
+
|
|
75
|
+
- `hasSoftDelete(entity)`, de `@softize/opus/schema`, reconhece as três formas de `softDelete`.
|
|
76
|
+
`entityColumns`, o tipo `EntityRow` e o `kyselyRepo` passam por ela; um teste que comparava
|
|
77
|
+
`entity.softDelete === true` deve usá-la;
|
|
78
|
+
- `defineEntity` recusa retenção inválida com `entity.invalid_soft_delete`: `retainDays` que não é
|
|
79
|
+
inteiro de 1 a 3650, `batchSize` acima de 10.000, fuso IANA inválido, texto vazio ou
|
|
80
|
+
`retain: 'forever'` sem `reason`;
|
|
81
|
+
- `defineDomain` passa a recusar schedules com o mesmo nome na árvore (`domain.duplicate_schedule`),
|
|
82
|
+
como já fazia com actions; antes, o conflito só aparecia no `register`;
|
|
83
|
+
- `FlattenedDomain` ganha `purges`, e `DataAdapter` ganha o método opcional `purgeDeleted`, que o
|
|
84
|
+
driver Kysely implementa;
|
|
85
|
+
- `opus gen` projeta a action e a rotina de expurgo no manifest: rode-o para que o `manifest:check`
|
|
86
|
+
volte a passar;
|
|
87
|
+
- `opus copy` lê o `label` e a `description` da retenção no `defineEntity`: rode-o para atualizar
|
|
88
|
+
o inventário;
|
|
89
|
+
- `docs/protocol.md` deixa de prometer uma opção para incluir excluídos na leitura, que não existe.
|
|
90
|
+
|
|
91
|
+
### Breaking
|
|
92
|
+
|
|
93
|
+
**`softDelete: true` reprova no `opus check`.** A regra `soft-delete-retention` reprova a exclusão
|
|
94
|
+
lógica sem retenção, `retain: 'forever'` sem `reason`, `retainDays` sem `timezone`, `label` ou
|
|
95
|
+
`description` ou que não é inteiro positivo, `batchSize` acima de 10.000, entidade com `retainDays`
|
|
96
|
+
fora de qualquer `entities` de domínio, `bindPurge` de entidade sem `retainDays` e `bindPurge` fora
|
|
97
|
+
de qualquer `purges`.
|
|
98
|
+
Migração: em cada `softDelete: true`, declare a retenção ou o motivo para guardar:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
softDelete: {
|
|
102
|
+
retainDays: 90,
|
|
103
|
+
timezone: 'America/Sao_Paulo',
|
|
104
|
+
label: 'Expurgar relatórios excluídos',
|
|
105
|
+
description: 'Apaga de vez os relatórios excluídos há mais de 90 dias.',
|
|
106
|
+
}
|
|
107
|
+
// ou
|
|
108
|
+
softDelete: { retain: 'forever', reason: 'Exigência legal de guarda do histórico.' }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Se o domínio já tem um expurgo escrito à mão, remova a action e o schedule próprios ao adotar
|
|
112
|
+
`retainDays`: o nome gerado pode coincidir com o declarado (`domain.duplicate_action`). Para manter
|
|
113
|
+
a ordem própria do que se apaga, mova o corpo para um `bindPurge`.
|
|
114
|
+
|
|
115
|
+
**Entidade com `retainDays` exige quem apague e quem agende.** Sem `bindPurge` no domínio e sem um
|
|
116
|
+
`DataAdapter` com o handler padrão (`kyselyData`), o `start()` recusa com
|
|
117
|
+
`runtime.purge_handler_missing`, nomeando a entidade, antes de inicializar os adapters. Como toda
|
|
118
|
+
rotina, a de expurgo exige um `SchedulerAdapter`; sem ele, o `start()` falha com
|
|
119
|
+
`runtime.scheduler_required`. A mesma entidade com retenção em dois domínios, ou num domínio e num
|
|
120
|
+
subdomínio dele, falha com `domain.entity_registered_twice`, assim como duas declarações com
|
|
121
|
+
retenção da mesma tabela. Migração: configure `data` e
|
|
122
|
+
`scheduler` no runtime que registra o domínio, ou registre o `bindPurge`; mantenha cada entidade
|
|
123
|
+
com retenção num único domínio.
|
|
124
|
+
|
|
125
|
+
**Chave estrangeira restritiva para o expurgo.** O schema do Opus não declara cascata. Se uma chave
|
|
126
|
+
estrangeira recusar a exclusão, a execução falha com o erro do banco no log e na auditoria, e nada
|
|
127
|
+
daquele lote é apagado. Como a linha travada está entre as mais antigas, ela volta ao primeiro lote
|
|
128
|
+
em toda execução: o expurgo da entidade fica parado até a chave ser ajustada. Migração: antes de declarar `retainDays`, confira as chaves que
|
|
129
|
+
apontam para a tabela; use cascata na DDL ou um `bindPurge` que apague os dependentes primeiro.
|
|
130
|
+
|
|
10
131
|
## 24.1.0 — 2026-10-02
|
|
11
132
|
|
|
12
133
|
O `Markdown` deixa de carregar imagem com endereço absoluto (`https:`, `http:` ou `//host`). No
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ Um pacote (`@softize/opus`), uma versão. Cada categoria abaixo é um **subpath
|
|
|
28
28
|
| `@softize/opus/server` | `/fastify`, `/node` | Transporte HTTP e montagem de endpoints |
|
|
29
29
|
| `@softize/opus/client` | `/fetch` | Adapter de cliente para invocar actions |
|
|
30
30
|
| `@softize/opus/ui` | `/react`, `/meta`, `/docs` | Componentes, Provider e hooks (`useAction`, `useListAction`) |
|
|
31
|
-
| `@softize/opus/data` | `/kysely`, `/readonly-pool` | Adapter de dados (`ctx.db`, `ctx.repo`) |
|
|
31
|
+
| `@softize/opus/data` | `/kysely`, `/readonly-pool` | Adapter de dados (`ctx.db`, `ctx.repo`) e expurgo próprio (`bindPurge`) |
|
|
32
32
|
| `@softize/opus/auth` | `/jwt`, `/better-auth` | Adapter de autenticação (`ctx.user`, `ctx.can`) |
|
|
33
33
|
| `@softize/opus/audit` | `/console`, `/pg` | Destinos de auditoria |
|
|
34
34
|
| `@softize/opus/log` | `/pino` | Adapter de log (console é o default no core) |
|
package/bin/cli.mjs
CHANGED
|
@@ -446,6 +446,15 @@ Regras dos Produtos de Dados (defineDataProduct):
|
|
|
446
446
|
data-product-* — declaração exportada, id e versão válidos, sem duplicata,
|
|
447
447
|
interfaces e entidades que existem no domínio
|
|
448
448
|
|
|
449
|
+
Regras da exclusão lógica (defineEntity, ADR 0051):
|
|
450
|
+
soft-delete-retention — a exclusão lógica declara retenção ou motivo: reprova
|
|
451
|
+
\`softDelete: true\`, \`retain: 'forever'\` sem \`reason\`,
|
|
452
|
+
\`retainDays\` sem \`timezone\`, \`label\` ou \`description\`
|
|
453
|
+
ou que não é inteiro positivo, \`batchSize\` acima de 10.000,
|
|
454
|
+
entidade com \`retainDays\` fora de qualquer \`entities\` de
|
|
455
|
+
domínio, \`bindPurge\` de entidade sem \`retainDays\` e
|
|
456
|
+
\`bindPurge\` fora de \`purges\`
|
|
457
|
+
|
|
449
458
|
Exit ≠ 0 se houver violação. Sem nenhuma action: projeto marcado (opus.json) passa
|
|
450
459
|
vacuamente; sem marcador, falha — gate vazio não é aprovação. Default dir: cwd.
|
|
451
460
|
`)
|
package/bin/lib/check.mjs
CHANGED
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
* ignored-by-kind — `emits`/`background` em action list/view: o runtime e o manifest
|
|
16
16
|
* só os aplicam em simple/form. No bindAction, o kind vem do
|
|
17
17
|
* contrato pelo mesmo join por identificador exportado.
|
|
18
|
+
* soft-delete-retention — toda exclusão lógica declara retenção ou motivo para guardar
|
|
19
|
+
* (ADR 0051): reprova `softDelete: true`, `retain: 'forever'` sem
|
|
20
|
+
* `reason`, `retainDays` incompleto ou inválido, entidade com
|
|
21
|
+
* `retainDays` fora de qualquer `entities` de domínio, `bindPurge`
|
|
22
|
+
* de entidade sem `retainDays` e `bindPurge` fora de qualquer
|
|
23
|
+
* `purges`. A entidade e o `bindPurge` se ligam ao domínio pelo
|
|
24
|
+
* identificador exportado; um `entities` ou `purges` que o check não
|
|
25
|
+
* consegue ler (spread, variável) suspende a regra de ausência
|
|
26
|
+
* correspondente, sem falso positivo.
|
|
18
27
|
*
|
|
19
28
|
* A checagem cross-file de registro no runtime (graph) fica pra v2 (introspecção/MCP).
|
|
20
29
|
* Saída: findings + exit ≠ 0 se houver erro. Zero actions num diretório NÃO-marcado
|
|
@@ -467,6 +476,377 @@ const UI_LEGACY_VARIANTS = new Map([
|
|
|
467
476
|
/** Os três nomes que denotam action/contrato — o arquivo sem nenhum deles é pulado. */
|
|
468
477
|
export const ACTION_MARKERS = ["defineAction", "defineContract", "bindAction"];
|
|
469
478
|
export const DATA_PRODUCT_MARKERS = ["defineDataProduct", "defineEntity"];
|
|
479
|
+
export const RETENTION_MARKERS = ["defineDomain", "bindPurge"];
|
|
480
|
+
|
|
481
|
+
/** Teto do `retainDays`, o mesmo do `olderThanDays` da action de expurgo (src/core/retention.ts). */
|
|
482
|
+
const MAX_RETAIN_DAYS = 3650;
|
|
483
|
+
/** Teto do `batchSize` (PURGE_MAX_BATCH_SIZE em src/core/retention.ts). */
|
|
484
|
+
const MAX_PURGE_BATCH_SIZE = 10000;
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Extrai a retenção das entidades, os `entities`/`purges` dos domínios e os `bindPurge` de um
|
|
488
|
+
* source (ADR 0051). Puro/sintático — a ligação entre arquivos fica no `checkProject`.
|
|
489
|
+
*/
|
|
490
|
+
export function parseRetention(fileName, sourceText) {
|
|
491
|
+
const sf = ts.createSourceFile(
|
|
492
|
+
fileName,
|
|
493
|
+
sourceText,
|
|
494
|
+
ts.ScriptTarget.Latest,
|
|
495
|
+
true,
|
|
496
|
+
ts.ScriptKind.TS,
|
|
497
|
+
);
|
|
498
|
+
const entities = [];
|
|
499
|
+
const domains = [];
|
|
500
|
+
const purges = [];
|
|
501
|
+
const lineOf = (node) =>
|
|
502
|
+
sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
503
|
+
// `import { ReportEntity as Report }`: a referência no domínio usa o nome local; a ligação
|
|
504
|
+
// com a declaração precisa do nome exportado.
|
|
505
|
+
const aliases = new Map();
|
|
506
|
+
for (const statement of sf.statements) {
|
|
507
|
+
const bindings = ts.isImportDeclaration(statement)
|
|
508
|
+
? statement.importClause?.namedBindings
|
|
509
|
+
: undefined;
|
|
510
|
+
if (bindings === undefined || !ts.isNamedImports(bindings)) continue;
|
|
511
|
+
for (const element of bindings.elements) {
|
|
512
|
+
if (element.propertyName !== undefined)
|
|
513
|
+
aliases.set(element.name.text, element.propertyName.text);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const refName = (node) => {
|
|
517
|
+
if (ts.isIdentifier(node)) return aliases.get(node.text) ?? node.text;
|
|
518
|
+
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
519
|
+
return null;
|
|
520
|
+
};
|
|
521
|
+
const isCallTo = (node, name) =>
|
|
522
|
+
ts.isCallExpression(node) &&
|
|
523
|
+
ts.isIdentifier(node.expression) &&
|
|
524
|
+
node.expression.text === name;
|
|
525
|
+
const prop = (obj, key) =>
|
|
526
|
+
obj.properties.find(
|
|
527
|
+
(item) =>
|
|
528
|
+
(ts.isPropertyAssignment(item) ||
|
|
529
|
+
ts.isShorthandPropertyAssignment(item)) &&
|
|
530
|
+
item.name?.getText(sf) === key,
|
|
531
|
+
);
|
|
532
|
+
const constIdent = (call) => {
|
|
533
|
+
const declaration = call.parent;
|
|
534
|
+
return declaration &&
|
|
535
|
+
ts.isVariableDeclaration(declaration) &&
|
|
536
|
+
ts.isIdentifier(declaration.name)
|
|
537
|
+
? declaration.name.text
|
|
538
|
+
: null;
|
|
539
|
+
};
|
|
540
|
+
const isExported = (call) => {
|
|
541
|
+
const declaration = call.parent;
|
|
542
|
+
const statement =
|
|
543
|
+
declaration && ts.isVariableDeclaration(declaration)
|
|
544
|
+
? declaration.parent?.parent
|
|
545
|
+
: undefined;
|
|
546
|
+
return (
|
|
547
|
+
statement !== undefined &&
|
|
548
|
+
ts.isVariableStatement(statement) &&
|
|
549
|
+
(statement.modifiers ?? []).some(
|
|
550
|
+
(item) => item.kind === ts.SyntaxKind.ExportKeyword,
|
|
551
|
+
)
|
|
552
|
+
);
|
|
553
|
+
};
|
|
554
|
+
const inlineEntities = new Set();
|
|
555
|
+
const inlinePurges = new Set();
|
|
556
|
+
|
|
557
|
+
/** Lê a forma de `softDelete` sem executar: literal reconhecido ou `opaque`. */
|
|
558
|
+
const softDeleteOf = (obj) => {
|
|
559
|
+
const entry = prop(obj, "softDelete");
|
|
560
|
+
if (entry === undefined) return { form: "none" };
|
|
561
|
+
if (!ts.isPropertyAssignment(entry)) return { form: "opaque" };
|
|
562
|
+
const value = entry.initializer;
|
|
563
|
+
if (value.kind === ts.SyntaxKind.TrueKeyword) return { form: "true" };
|
|
564
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword) return { form: "none" };
|
|
565
|
+
if (!ts.isObjectLiteralExpression(value)) return { form: "opaque" };
|
|
566
|
+
const keys = new Set(
|
|
567
|
+
value.properties.map((item) => item.name?.getText(sf)).filter(Boolean),
|
|
568
|
+
);
|
|
569
|
+
// Um spread pode trazer qualquer chave: o que não está literal no objeto não é ausência.
|
|
570
|
+
const spread = value.properties.some(ts.isSpreadAssignment);
|
|
571
|
+
const literalText = (key) => {
|
|
572
|
+
const item = prop(value, key);
|
|
573
|
+
if (item === undefined) return undefined;
|
|
574
|
+
if (!ts.isPropertyAssignment(item)) return null;
|
|
575
|
+
const init = item.initializer;
|
|
576
|
+
return ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)
|
|
577
|
+
? init.text
|
|
578
|
+
: null;
|
|
579
|
+
};
|
|
580
|
+
if (keys.has("retainDays")) {
|
|
581
|
+
const batchProp = prop(value, "batchSize");
|
|
582
|
+
const batchSize =
|
|
583
|
+
batchProp !== undefined &&
|
|
584
|
+
ts.isPropertyAssignment(batchProp) &&
|
|
585
|
+
ts.isNumericLiteral(batchProp.initializer)
|
|
586
|
+
? Number(batchProp.initializer.text)
|
|
587
|
+
: null;
|
|
588
|
+
const days = prop(value, "retainDays");
|
|
589
|
+
let retainDays = null;
|
|
590
|
+
if (ts.isPropertyAssignment(days)) {
|
|
591
|
+
const init = days.initializer;
|
|
592
|
+
if (ts.isNumericLiteral(init)) retainDays = Number(init.text);
|
|
593
|
+
else if (
|
|
594
|
+
ts.isPrefixUnaryExpression(init) &&
|
|
595
|
+
init.operator === ts.SyntaxKind.MinusToken &&
|
|
596
|
+
ts.isNumericLiteral(init.operand)
|
|
597
|
+
)
|
|
598
|
+
retainDays = -Number(init.operand.text);
|
|
599
|
+
else if (ts.isStringLiteral(init)) retainDays = Number.NaN;
|
|
600
|
+
}
|
|
601
|
+
const missing = ["timezone", "label", "description"].filter((key) => {
|
|
602
|
+
if (!keys.has(key)) return !spread;
|
|
603
|
+
// Texto literal vazio conta como ausente; expressão não literal passa (o runtime confere).
|
|
604
|
+
return literalText(key) === "";
|
|
605
|
+
});
|
|
606
|
+
return {
|
|
607
|
+
form: "days",
|
|
608
|
+
retainDays,
|
|
609
|
+
batchSize,
|
|
610
|
+
missing,
|
|
611
|
+
conflicting: keys.has("retain"),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
if (keys.has("retain")) {
|
|
615
|
+
// Expressão não literal (`null`) conta como presente; o runtime confere o valor.
|
|
616
|
+
const reason = literalText("reason");
|
|
617
|
+
return {
|
|
618
|
+
form: "forever",
|
|
619
|
+
reasonMissing:
|
|
620
|
+
(reason === undefined && !spread) ||
|
|
621
|
+
(typeof reason === "string" && reason.trim() === ""),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
return { form: spread ? "opaque" : "invalid" };
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
/** `entities` e `purges` de um objeto de domínio (defineDomain ou subdomínio inline). */
|
|
628
|
+
const readDomain = (obj) => {
|
|
629
|
+
const domain = {
|
|
630
|
+
entityRefs: [],
|
|
631
|
+
entitiesOpaque: false,
|
|
632
|
+
purgeRefs: [],
|
|
633
|
+
purgesOpaque: false,
|
|
634
|
+
line: lineOf(obj),
|
|
635
|
+
};
|
|
636
|
+
const entitiesProp = prop(obj, "entities");
|
|
637
|
+
if (entitiesProp !== undefined) {
|
|
638
|
+
const value = ts.isPropertyAssignment(entitiesProp)
|
|
639
|
+
? entitiesProp.initializer
|
|
640
|
+
: null;
|
|
641
|
+
if (value === null || !ts.isObjectLiteralExpression(value)) {
|
|
642
|
+
domain.entitiesOpaque = true;
|
|
643
|
+
} else {
|
|
644
|
+
for (const item of value.properties) {
|
|
645
|
+
if (ts.isShorthandPropertyAssignment(item)) {
|
|
646
|
+
domain.entityRefs.push(refName(item.name));
|
|
647
|
+
} else if (ts.isPropertyAssignment(item)) {
|
|
648
|
+
const init = item.initializer;
|
|
649
|
+
if (isCallTo(init, "defineEntity")) inlineEntities.add(init);
|
|
650
|
+
else {
|
|
651
|
+
const name = refName(init);
|
|
652
|
+
if (name === null) domain.entitiesOpaque = true;
|
|
653
|
+
else domain.entityRefs.push(name);
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
domain.entitiesOpaque = true;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const purgesProp = prop(obj, "purges");
|
|
662
|
+
if (purgesProp !== undefined) {
|
|
663
|
+
const value = ts.isPropertyAssignment(purgesProp)
|
|
664
|
+
? purgesProp.initializer
|
|
665
|
+
: null;
|
|
666
|
+
if (value === null || !ts.isArrayLiteralExpression(value)) {
|
|
667
|
+
domain.purgesOpaque = true;
|
|
668
|
+
} else {
|
|
669
|
+
for (const element of value.elements) {
|
|
670
|
+
if (isCallTo(element, "bindPurge")) inlinePurges.add(element);
|
|
671
|
+
else {
|
|
672
|
+
const name = refName(element);
|
|
673
|
+
if (name === null) domain.purgesOpaque = true;
|
|
674
|
+
else domain.purgeRefs.push(name);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
domains.push(domain);
|
|
680
|
+
const subdomains = prop(obj, "subdomains");
|
|
681
|
+
if (
|
|
682
|
+
subdomains !== undefined &&
|
|
683
|
+
ts.isPropertyAssignment(subdomains) &&
|
|
684
|
+
ts.isArrayLiteralExpression(subdomains.initializer)
|
|
685
|
+
) {
|
|
686
|
+
// Subdomínio em `defineDomain(...)` é visitado como chamada própria; o literal, aqui.
|
|
687
|
+
for (const element of subdomains.initializer.elements) {
|
|
688
|
+
if (ts.isObjectLiteralExpression(element)) readDomain(element);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const entityCalls = [];
|
|
694
|
+
const purgeCalls = [];
|
|
695
|
+
const visit = (node) => {
|
|
696
|
+
if (
|
|
697
|
+
ts.isCallExpression(node) &&
|
|
698
|
+
ts.isIdentifier(node.expression) &&
|
|
699
|
+
node.arguments.length > 0
|
|
700
|
+
) {
|
|
701
|
+
const callee = node.expression.text;
|
|
702
|
+
const first = node.arguments[0];
|
|
703
|
+
if (callee === "defineEntity" && ts.isObjectLiteralExpression(first))
|
|
704
|
+
entityCalls.push(node);
|
|
705
|
+
else if (callee === "defineDomain" && ts.isObjectLiteralExpression(first))
|
|
706
|
+
readDomain(first);
|
|
707
|
+
else if (callee === "bindPurge") purgeCalls.push(node);
|
|
708
|
+
}
|
|
709
|
+
ts.forEachChild(node, visit);
|
|
710
|
+
};
|
|
711
|
+
visit(sf);
|
|
712
|
+
|
|
713
|
+
for (const call of entityCalls) {
|
|
714
|
+
const obj = call.arguments[0];
|
|
715
|
+
const nameProp = prop(obj, "name");
|
|
716
|
+
entities.push({
|
|
717
|
+
ident: constIdent(call),
|
|
718
|
+
name:
|
|
719
|
+
nameProp &&
|
|
720
|
+
ts.isPropertyAssignment(nameProp) &&
|
|
721
|
+
ts.isStringLiteral(nameProp.initializer)
|
|
722
|
+
? nameProp.initializer.text
|
|
723
|
+
: null,
|
|
724
|
+
softDelete: softDeleteOf(obj),
|
|
725
|
+
inlineInDomain: inlineEntities.has(call),
|
|
726
|
+
line: lineOf(call),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
for (const call of purgeCalls) {
|
|
730
|
+
purges.push({
|
|
731
|
+
ident: constIdent(call),
|
|
732
|
+
exported: isExported(call),
|
|
733
|
+
entityRef: refName(call.arguments[0]),
|
|
734
|
+
inlineInPurges: inlinePurges.has(call),
|
|
735
|
+
line: lineOf(call),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return { entities, domains, purges };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Regra `soft-delete-retention` (ADR 0051) sobre o projeto inteiro: a forma da declaração, por
|
|
743
|
+
* entidade, e a ligação entidade ↔ domínio ↔ `bindPurge`, pelo identificador exportado.
|
|
744
|
+
*/
|
|
745
|
+
function checkRetention(retention) {
|
|
746
|
+
const findings = [];
|
|
747
|
+
const finding = (file, line, subject, message) =>
|
|
748
|
+
findings.push({
|
|
749
|
+
rule: "soft-delete-retention",
|
|
750
|
+
level: "error",
|
|
751
|
+
action: subject,
|
|
752
|
+
line,
|
|
753
|
+
file,
|
|
754
|
+
message,
|
|
755
|
+
});
|
|
756
|
+
const domains = retention.flatMap(({ parsed }) => parsed.domains);
|
|
757
|
+
const entitiesOpaque = domains.some((domain) => domain.entitiesOpaque);
|
|
758
|
+
const purgesOpaque = domains.some((domain) => domain.purgesOpaque);
|
|
759
|
+
const entityRefs = new Set(domains.flatMap((domain) => domain.entityRefs));
|
|
760
|
+
const purgeRefs = new Set(domains.flatMap((domain) => domain.purgeRefs));
|
|
761
|
+
const entitiesByIdent = new Map(); // ident → entity | 'ambiguous'
|
|
762
|
+
|
|
763
|
+
for (const { file, parsed } of retention) {
|
|
764
|
+
for (const entity of parsed.entities) {
|
|
765
|
+
if (entity.ident !== null) {
|
|
766
|
+
entitiesByIdent.set(
|
|
767
|
+
entity.ident,
|
|
768
|
+
entitiesByIdent.has(entity.ident) ? "ambiguous" : entity,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const subject = entity.name ?? entity.ident ?? "(entidade sem name)";
|
|
772
|
+
const at = (message) => finding(file, entity.line, subject, message);
|
|
773
|
+
const { softDelete } = entity;
|
|
774
|
+
if (softDelete.form === "true") {
|
|
775
|
+
at(
|
|
776
|
+
"`softDelete: true` não declara por quanto tempo o registro excluído fica guardado. Declare `retainDays`, com `timezone`, `label` e `description`, ou `{ retain: 'forever', reason }`.",
|
|
777
|
+
);
|
|
778
|
+
} else if (softDelete.form === "invalid") {
|
|
779
|
+
at(
|
|
780
|
+
"`softDelete` em objeto precisa de `retainDays` ou de `retain: 'forever'` com `reason`.",
|
|
781
|
+
);
|
|
782
|
+
} else if (softDelete.form === "forever") {
|
|
783
|
+
if (softDelete.reasonMissing)
|
|
784
|
+
at(
|
|
785
|
+
"`retain: 'forever'` precisa de `reason` com o motivo de guardar o registro excluído para sempre.",
|
|
786
|
+
);
|
|
787
|
+
} else if (softDelete.form === "days") {
|
|
788
|
+
if (softDelete.conflicting)
|
|
789
|
+
at(
|
|
790
|
+
"`retainDays` e `retain: 'forever'` são formas excludentes; declare só uma.",
|
|
791
|
+
);
|
|
792
|
+
if (softDelete.missing.length > 0)
|
|
793
|
+
at(
|
|
794
|
+
`\`retainDays\` precisa de ${softDelete.missing.map((key) => `\`${key}\``).join(", ")}: o fuso da rotina e os textos completos da action de expurgo.`,
|
|
795
|
+
);
|
|
796
|
+
const days = softDelete.retainDays;
|
|
797
|
+
if (days !== null && (!Number.isInteger(days) || days < 1))
|
|
798
|
+
at("`retainDays` precisa ser um inteiro positivo.");
|
|
799
|
+
else if (days !== null && days > MAX_RETAIN_DAYS)
|
|
800
|
+
at(`\`retainDays\` aceita no máximo ${MAX_RETAIN_DAYS} dias.`);
|
|
801
|
+
const batch = softDelete.batchSize;
|
|
802
|
+
if (batch !== null && (!Number.isInteger(batch) || batch < 1))
|
|
803
|
+
at("`batchSize` precisa ser um inteiro positivo.");
|
|
804
|
+
else if (batch !== null && batch > MAX_PURGE_BATCH_SIZE)
|
|
805
|
+
at(`\`batchSize\` aceita no máximo ${MAX_PURGE_BATCH_SIZE} linhas por lote.`);
|
|
806
|
+
const inDomain =
|
|
807
|
+
entity.inlineInDomain ||
|
|
808
|
+
(entity.ident !== null && entityRefs.has(entity.ident));
|
|
809
|
+
if (!inDomain && !entitiesOpaque)
|
|
810
|
+
at(
|
|
811
|
+
"Entidade com `retainDays` fora de qualquer `entities` de domínio: o expurgo nasce no domínio que lista a entidade. Registre-a em `entities` no `defineDomain`.",
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
for (const { file, parsed } of retention) {
|
|
818
|
+
for (const purge of parsed.purges) {
|
|
819
|
+
const subject = `bindPurge(${purge.entityRef ?? "?"})`;
|
|
820
|
+
const entity =
|
|
821
|
+
purge.entityRef === null ? undefined : entitiesByIdent.get(purge.entityRef);
|
|
822
|
+
if (
|
|
823
|
+
entity !== undefined &&
|
|
824
|
+
entity !== "ambiguous" &&
|
|
825
|
+
entity.softDelete.form !== "days" &&
|
|
826
|
+
entity.softDelete.form !== "opaque"
|
|
827
|
+
) {
|
|
828
|
+
finding(
|
|
829
|
+
file,
|
|
830
|
+
purge.line,
|
|
831
|
+
subject,
|
|
832
|
+
"`bindPurge` para uma entidade sem `retainDays`: sem prazo, não há expurgo para o handler executar.",
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
const registered =
|
|
836
|
+
purge.inlineInPurges ||
|
|
837
|
+
(purge.ident !== null && purgeRefs.has(purge.ident));
|
|
838
|
+
if (!registered && !purgesOpaque) {
|
|
839
|
+
finding(
|
|
840
|
+
file,
|
|
841
|
+
purge.line,
|
|
842
|
+
subject,
|
|
843
|
+
"`bindPurge` fora de qualquer `purges` de domínio: o runtime usaria o handler padrão em silêncio. Registre-o em `purges` no `defineDomain` que lista a entidade.",
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return findings;
|
|
849
|
+
}
|
|
470
850
|
|
|
471
851
|
/** Extrai Produtos de Dados e entities literais para validar a linhagem sem executar o app. */
|
|
472
852
|
export function parseDataProducts(fileName, sourceText) {
|
|
@@ -745,6 +1125,7 @@ export function checkProject(sources) {
|
|
|
745
1125
|
const productDeclarations = [];
|
|
746
1126
|
const entityNames = new Set();
|
|
747
1127
|
const actionNames = new Set();
|
|
1128
|
+
const retention = [];
|
|
748
1129
|
// Natureza da action (ADR 0041): declarações ausentes são contadas, não reprovadas, na minor.
|
|
749
1130
|
let missingEffect = 0;
|
|
750
1131
|
let missingPersonalData = 0;
|
|
@@ -754,6 +1135,12 @@ export function checkProject(sources) {
|
|
|
754
1135
|
for (const entity of declarations.entities) entityNames.add(entity);
|
|
755
1136
|
for (const product of declarations.products)
|
|
756
1137
|
productDeclarations.push({ product, file });
|
|
1138
|
+
if (
|
|
1139
|
+
["defineEntity", ...RETENTION_MARKERS].some((marker) =>
|
|
1140
|
+
text.includes(marker),
|
|
1141
|
+
)
|
|
1142
|
+
)
|
|
1143
|
+
retention.push({ file, parsed: parseRetention(file, text) });
|
|
757
1144
|
for (const item of parseActions(file, text)) {
|
|
758
1145
|
if (item.form === "bindAction") {
|
|
759
1146
|
actions += 1;
|
|
@@ -878,6 +1265,8 @@ export function checkProject(sources) {
|
|
|
878
1265
|
}
|
|
879
1266
|
}
|
|
880
1267
|
|
|
1268
|
+
findings.push(...checkRetention(retention));
|
|
1269
|
+
|
|
881
1270
|
return {
|
|
882
1271
|
actions,
|
|
883
1272
|
contracts,
|
|
@@ -2147,7 +2536,7 @@ export async function scanDir(rootDir) {
|
|
|
2147
2536
|
for (const file of files) {
|
|
2148
2537
|
const text = readProjectFile(rootDir, path.relative(rootDir, file)).content;
|
|
2149
2538
|
if (
|
|
2150
|
-
![...ACTION_MARKERS, ...DATA_PRODUCT_MARKERS].some((m) =>
|
|
2539
|
+
![...ACTION_MARKERS, ...DATA_PRODUCT_MARKERS, ...RETENTION_MARKERS].some((m) =>
|
|
2151
2540
|
text.includes(m),
|
|
2152
2541
|
)
|
|
2153
2542
|
)
|
package/bin/lib/copy.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Inventário semântico de copy dos contratos e dicionários Opus.
|
|
3
3
|
*
|
|
4
|
-
* O Opus conhece o papel dos textos declarados em defineAction/defineContract
|
|
4
|
+
* O Opus conhece o papel dos textos declarados em defineAction/defineContract, t.dict e na
|
|
5
|
+
* retenção de defineEntity (o `label` e a `description` da action de expurgo derivada);
|
|
5
6
|
* a @softize/base continua dona da política e da validação editorial. Este módulo
|
|
6
7
|
* apenas projeta o protocolo JSON v2 sem executar código do consumidor.
|
|
7
8
|
*/
|
|
@@ -78,6 +79,8 @@ const AUDIT_REFERENCE =
|
|
|
78
79
|
/^(?:https?:\/\/\S+|[a-z][a-z0-9-]*:\S+|(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._#/-]+|[A-Za-z0-9._-]+\.(?:md|json|ya?ml)(?:#[^\s]+)?)$/u
|
|
79
80
|
const CONTRACT_MODULES = new Set(['@softize/opus', '@softize/opus/core'])
|
|
80
81
|
const CONTRACT_FACTORIES = new Set(['defineAction', 'defineContract'])
|
|
82
|
+
// A retenção da exclusão lógica declara os textos da action de expurgo que o Opus deriva (ADR 0051).
|
|
83
|
+
const ENTITY_MODULES = new Set(['@softize/opus/schema'])
|
|
81
84
|
const PRESENTATION_MODULES = new Set(['@softize/opus', '@softize/opus/presentation'])
|
|
82
85
|
const SCHEMA_ZOD_MODULES = new Set(['@softize/opus/schema/zod'])
|
|
83
86
|
const ACTION_BINDING_KEYS = new Set(['authorize', 'background', 'emits', 'handler', 'idempotency', 'loads'])
|
|
@@ -1407,6 +1410,7 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1407
1410
|
let hasContract = false
|
|
1408
1411
|
let hasDictionary = false
|
|
1409
1412
|
let hasPresentation = false
|
|
1413
|
+
let hasEntity = false
|
|
1410
1414
|
const hasUiImport = sourceFile.statements.some(
|
|
1411
1415
|
(statement) =>
|
|
1412
1416
|
ts.isImportDeclaration(statement) &&
|
|
@@ -1599,6 +1603,33 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1599
1603
|
return name !== null && CONTRACT_FACTORIES.has(name) ? name : null
|
|
1600
1604
|
}
|
|
1601
1605
|
|
|
1606
|
+
function entityFactory(expression) {
|
|
1607
|
+
return importedMember(expression, (module) => ENTITY_MODULES.has(module)) === 'defineEntity'
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* `softDelete` com `retainDays` traz o título e a descrição, completos e literais, da action de
|
|
1612
|
+
* expurgo que o `flattenDomain` deriva (ADR 0051). `true`, `false` e `retain: 'forever'` não
|
|
1613
|
+
* têm texto para pessoas.
|
|
1614
|
+
*/
|
|
1615
|
+
function extractEntity(object) {
|
|
1616
|
+
const resolved = property(object, 'softDelete', sourceFile, checker)
|
|
1617
|
+
if (resolved.opaque !== undefined) {
|
|
1618
|
+
diagnostics.push(diagnostic(file, sourceFile, resolved.opaque, 'softDelete', 'structure'))
|
|
1619
|
+
return
|
|
1620
|
+
}
|
|
1621
|
+
if (resolved.candidate === undefined) return
|
|
1622
|
+
const valueNode = propertyValue(resolved.candidate)
|
|
1623
|
+
const value = resolveBinding(valueNode, checker)
|
|
1624
|
+
if (value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword) return
|
|
1625
|
+
if (!ts.isObjectLiteralExpression(value)) {
|
|
1626
|
+
diagnostics.push(diagnostic(file, sourceFile, valueNode, 'softDelete', 'structure'))
|
|
1627
|
+
return
|
|
1628
|
+
}
|
|
1629
|
+
addProperty(value, 'label', 'button', 'softDelete.')
|
|
1630
|
+
addProperty(value, 'description', 'description', 'softDelete.')
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1602
1633
|
function presentationFactory(expression) {
|
|
1603
1634
|
return importedMember(expression, (module) => PRESENTATION_MODULES.has(module)) === 'definePresentation'
|
|
1604
1635
|
}
|
|
@@ -2582,6 +2613,12 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
2582
2613
|
else diagnostics.push(diagnostic(file, sourceFile, node.arguments[0], `${factory}(...)`, 'structure'))
|
|
2583
2614
|
}
|
|
2584
2615
|
}
|
|
2616
|
+
if (ts.isCallExpression(node) && entityFactory(node.expression) && node.arguments.length > 0) {
|
|
2617
|
+
hasEntity = true
|
|
2618
|
+
const object = resolveBinding(node.arguments[0], checker)
|
|
2619
|
+
if (ts.isObjectLiteralExpression(object)) extractEntity(object)
|
|
2620
|
+
else diagnostics.push(diagnostic(file, sourceFile, node.arguments[0], 'defineEntity(...)', 'structure'))
|
|
2621
|
+
}
|
|
2585
2622
|
if (ts.isCallExpression(node) && presentationFactory(node.expression) && node.arguments.length > 0) {
|
|
2586
2623
|
hasPresentation = true
|
|
2587
2624
|
const object = resolveBinding(node.arguments[0], checker)
|
|
@@ -2599,7 +2636,7 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
2599
2636
|
|
|
2600
2637
|
return {
|
|
2601
2638
|
hasContract,
|
|
2602
|
-
hasCopySurface: hasContract || hasDictionary || hasPresentation || hasUiImport,
|
|
2639
|
+
hasCopySurface: hasContract || hasDictionary || hasPresentation || hasEntity || hasUiImport,
|
|
2603
2640
|
entries,
|
|
2604
2641
|
diagnostics,
|
|
2605
2642
|
}
|