@softize/opus 12.10.0 → 13.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.
- package/CHANGELOG.md +56 -0
- package/bin/lib/check.mjs +1098 -310
- package/bin/lib/copy.mjs +12 -5
- package/docs/adr/0003-dictionary-presentation-is-declared.md +3 -0
- package/docs/adr/0004-page-content-state-is-composed.md +65 -0
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +180 -0
- package/docs/adr/0006-semantic-context-precedes-visual-variant.md +182 -0
- package/docs/adr/0007-toast-actions-form-an-ordered-collection.md +63 -0
- package/docs/adr/0008-hierarchical-navigation-is-composed-at-the-consumer-boundary.md +71 -0
- package/docs/radius-scale.md +1 -1
- package/package.json +1 -1
- package/registry/instructions/opus.md +5 -0
- package/registry/skills/build-opus-ui/SKILL.md +27 -16
- package/registry/skills/build-opus-ui/references/evaluations.md +16 -5
- package/registry/skills/build-opus-ui/references/ui-patterns.md +38 -15
- package/registry/skills/maintain-opus-docs/SKILL.md +83 -0
- package/registry/skills/maintain-opus-docs/agents/openai.yaml +4 -0
- package/registry/skills/maintain-opus-docs/references/editorial-standard.md +85 -0
- package/registry/skills/maintain-opus-docs/references/evaluations.md +34 -0
- package/registry/skills/maintain-opus-docs/scripts/audit-docs.mjs +81 -0
- package/registry/skills/model-opus-dictionary/SKILL.md +4 -2
- package/registry/skills/model-opus-dictionary/references/evaluations.md +4 -3
- package/registry/templates/app/src/App.tsx +1 -1
- package/src/core/dictionary.ts +52 -14
- package/src/core/index.ts +10 -0
- package/src/core/ui-context.ts +29 -0
- package/src/schema/drivers/zod.ts +17 -8
- package/src/ui/components/patterns/action-form-card.tsx +18 -12
- package/src/ui/components/patterns/confirm.tsx +163 -40
- package/src/ui/components/patterns/content-header.tsx +335 -61
- package/src/ui/components/patterns/data-state.tsx +23 -10
- package/src/ui/components/patterns/list.tsx +1097 -783
- package/src/ui/components/patterns/page-state.tsx +115 -0
- package/src/ui/components/patterns/page.tsx +231 -41
- package/src/ui/components/patterns/sidebar.tsx +357 -83
- package/src/ui/components/patterns/trigger.tsx +37 -30
- package/src/ui/components/patterns/view.tsx +7 -11
- package/src/ui/components/primitives/alert.tsx +298 -110
- package/src/ui/components/primitives/ask.tsx +2 -1
- package/src/ui/components/primitives/badge.tsx +91 -30
- package/src/ui/components/primitives/button.tsx +99 -60
- package/src/ui/components/primitives/calendar.tsx +39 -39
- package/src/ui/components/primitives/card.tsx +96 -23
- package/src/ui/components/primitives/detail.tsx +2 -2
- package/src/ui/components/primitives/dialog.tsx +196 -39
- package/src/ui/components/primitives/dictionary-value.tsx +9 -14
- package/src/ui/components/primitives/dot.tsx +74 -21
- package/src/ui/components/primitives/drawer.tsx +40 -24
- package/src/ui/components/primitives/empty.tsx +3 -3
- package/src/ui/components/primitives/item.tsx +135 -79
- package/src/ui/components/primitives/menu.tsx +11 -3
- package/src/ui/components/primitives/metric-card.tsx +133 -0
- package/src/ui/components/primitives/sonner.tsx +187 -8
- package/src/ui/components/primitives/table.tsx +2 -2
- package/src/ui/docs/DocBrowser.tsx +104 -25
- package/src/ui/docs/changelog.tsx +1 -1
- package/src/ui/docs/content/accordion.md +22 -16
- package/src/ui/docs/content/action-form-card.md +8 -8
- package/src/ui/docs/content/action-form-dialog.md +9 -9
- package/src/ui/docs/content/action-form.md +28 -34
- package/src/ui/docs/content/action-list-dialog.md +11 -6
- package/src/ui/docs/content/action-list.md +64 -39
- package/src/ui/docs/content/action-trigger.md +21 -14
- package/src/ui/docs/content/action-view.md +8 -8
- package/src/ui/docs/content/actions.md +9 -9
- package/src/ui/docs/content/ai.md +3 -3
- package/src/ui/docs/content/alert.md +54 -28
- package/src/ui/docs/content/aspect-ratio.md +4 -4
- package/src/ui/docs/content/audit.md +2 -2
- package/src/ui/docs/content/auth.md +3 -3
- package/src/ui/docs/content/avatar.md +34 -14
- package/src/ui/docs/content/badge.md +21 -22
- package/src/ui/docs/content/breadcrumb.md +13 -8
- package/src/ui/docs/content/button.md +93 -15
- package/src/ui/docs/content/calendar.md +5 -5
- package/src/ui/docs/content/card.md +6 -6
- package/src/ui/docs/content/carousel.md +16 -11
- package/src/ui/docs/content/chat.md +3 -3
- package/src/ui/docs/content/checkbox.md +7 -7
- package/src/ui/docs/content/cli.md +5 -5
- package/src/ui/docs/content/collapsible.md +8 -8
- package/src/ui/docs/content/command.md +16 -8
- package/src/ui/docs/content/composer.md +2 -2
- package/src/ui/docs/content/content.md +44 -0
- package/src/ui/docs/content/copyable.md +4 -3
- package/src/ui/docs/content/customization.md +7 -7
- package/src/ui/docs/content/cycle.md +3 -3
- package/src/ui/docs/content/data-state.md +11 -12
- package/src/ui/docs/content/data.md +26 -33
- package/src/ui/docs/content/detail.md +8 -5
- package/src/ui/docs/content/dialog.md +339 -31
- package/src/ui/docs/content/dictionary-value.md +19 -18
- package/src/ui/docs/content/dock.md +3 -3
- package/src/ui/docs/content/dot.md +7 -7
- package/src/ui/docs/content/drawer.md +32 -16
- package/src/ui/docs/content/empty-value.md +2 -2
- package/src/ui/docs/content/empty.md +19 -12
- package/src/ui/docs/content/events.md +4 -4
- package/src/ui/docs/content/field.md +34 -12
- package/src/ui/docs/content/getting-started.md +1 -1
- package/src/ui/docs/content/icon-picker.md +8 -4
- package/src/ui/docs/content/input-otp.md +20 -12
- package/src/ui/docs/content/input.md +121 -9
- package/src/ui/docs/content/item.md +64 -24
- package/src/ui/docs/content/kbd.md +19 -11
- package/src/ui/docs/content/label.md +5 -3
- package/src/ui/docs/content/log.md +4 -4
- package/src/ui/docs/content/markdown.md +7 -6
- package/src/ui/docs/content/mcp.md +13 -15
- package/src/ui/docs/content/menu.md +36 -17
- package/src/ui/docs/content/metric-card.md +41 -0
- package/src/ui/docs/content/observability.md +2 -2
- package/src/ui/docs/content/page.md +93 -10
- package/src/ui/docs/content/pagination.md +22 -17
- package/src/ui/docs/content/popover.md +16 -8
- package/src/ui/docs/content/progress.md +7 -5
- package/src/ui/docs/content/queue.md +5 -5
- package/src/ui/docs/content/radio-group.md +20 -12
- package/src/ui/docs/content/router.md +11 -6
- package/src/ui/docs/content/scheduler.md +4 -5
- package/src/ui/docs/content/scroll-area.md +12 -7
- package/src/ui/docs/content/select.md +42 -29
- package/src/ui/docs/content/semantic-context.md +63 -0
- package/src/ui/docs/content/separator.md +5 -5
- package/src/ui/docs/content/sidebar.md +325 -56
- package/src/ui/docs/content/skeleton.md +5 -4
- package/src/ui/docs/content/slider.md +8 -7
- package/src/ui/docs/content/spinner.md +8 -8
- package/src/ui/docs/content/split.md +8 -5
- package/src/ui/docs/content/storage.md +6 -8
- package/src/ui/docs/content/switch.md +8 -7
- package/src/ui/docs/content/table.md +16 -6
- package/src/ui/docs/content/tabs.md +28 -14
- package/src/ui/docs/content/testing.md +9 -11
- package/src/ui/docs/content/textarea.md +5 -4
- package/src/ui/docs/content/toast.md +47 -13
- package/src/ui/docs/content/toggle.md +75 -7
- package/src/ui/docs/content/tokens.md +31 -3
- package/src/ui/docs/content/tooltip.md +19 -11
- package/src/ui/docs/content/truncate.md +7 -8
- package/src/ui/docs/content/ui.md +10 -9
- package/src/ui/docs/content/upgrading.md +7 -8
- package/src/ui/docs/doc-client.tsx +2 -2
- package/src/ui/docs/registry.tsx +580 -229
- package/src/ui/lib/semantic-context.ts +30 -0
- package/src/ui/meta.ts +278 -286
- package/src/ui/react.tsx +377 -111
- package/src/ui/theme.css +116 -0
- package/src/ui/components/primitives/alert-dialog.tsx +0 -190
- package/src/ui/docs/content/alert-dialog.md +0 -73
- package/src/ui/docs/content/button-group.md +0 -71
- package/src/ui/docs/content/confirm.md +0 -120
- package/src/ui/docs/content/input-group.md +0 -78
- package/src/ui/docs/content/toggle-group.md +0 -81
package/bin/lib/check.mjs
CHANGED
|
@@ -21,66 +21,282 @@
|
|
|
21
21
|
* É a régua do reviewer e a métrica de padrão.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import path from
|
|
25
|
-
import ts from
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import ts from "typescript";
|
|
26
26
|
import {
|
|
27
27
|
canonicalProjectDirectory,
|
|
28
28
|
readProjectDirectory,
|
|
29
29
|
readProjectFile,
|
|
30
30
|
safeProjectPath,
|
|
31
|
-
} from
|
|
31
|
+
} from "@softize/base/project-path";
|
|
32
32
|
|
|
33
33
|
// Ordem canônica (grupos do ActionBase em src/core/types.ts). Campos kind-específicos
|
|
34
34
|
// (fields/paginate/filters/expand/background/emits/successStatus…) NÃO entram no mapa —
|
|
35
35
|
// são ignorados na checagem de ordem (não geram falso-positivo).
|
|
36
36
|
const FIELD_GROUP = {
|
|
37
|
-
name: 0,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
name: 0,
|
|
38
|
+
kind: 0,
|
|
39
|
+
label: 1,
|
|
40
|
+
title: 1,
|
|
41
|
+
summary: 1,
|
|
42
|
+
description: 1,
|
|
43
|
+
icon: 1,
|
|
44
|
+
color: 1,
|
|
45
|
+
messages: 1,
|
|
46
|
+
tags: 1,
|
|
47
|
+
examples: 1,
|
|
48
|
+
errors: 1,
|
|
49
|
+
input: 2,
|
|
50
|
+
output: 2,
|
|
51
|
+
public: 3,
|
|
52
|
+
requires: 3,
|
|
53
|
+
authorize: 3,
|
|
54
|
+
loads: 3,
|
|
55
|
+
handler: 4,
|
|
56
|
+
mockHandler: 4,
|
|
57
|
+
audit: 5,
|
|
58
|
+
confirm: 5,
|
|
59
|
+
invalidates: 5,
|
|
60
|
+
rateLimit: 5,
|
|
61
|
+
idempotency: 5,
|
|
62
|
+
ai: 6,
|
|
63
|
+
automatable: 6,
|
|
64
|
+
internal: 6,
|
|
65
|
+
};
|
|
45
66
|
|
|
46
67
|
// <resource>.<verb> com namespacing opcional: <resource>(.<sub>)*.<verb> — ≥2 segmentos,
|
|
47
68
|
// minúsculo+hífen. Ex.: client.list, user.set-password, ticket.sla.check.
|
|
48
|
-
const SEG =
|
|
49
|
-
const ACTION_NAME_RE = new RegExp(`^${SEG}(\\.${SEG})+$`)
|
|
50
|
-
const KINDS = new Set([
|
|
69
|
+
const SEG = "[a-z][a-z0-9]*(-[a-z0-9]+)*";
|
|
70
|
+
const ACTION_NAME_RE = new RegExp(`^${SEG}(\\.${SEG})+$`);
|
|
71
|
+
const KINDS = new Set(["simple", "form", "list", "view"]);
|
|
51
72
|
|
|
52
73
|
// Tokens públicos removidos cuja ausência no CSS não quebra typecheck/build: sem uma
|
|
53
74
|
// migração executável, o consumidor perde borda/sombra em silêncio. O check fica no Opus
|
|
54
75
|
// (dono do contrato visual), não no Maestro. Uma ocorrência por token/arquivo basta.
|
|
55
76
|
const REMOVED_UI_TOKENS = new Map([
|
|
56
|
-
[
|
|
57
|
-
[
|
|
58
|
-
[
|
|
59
|
-
[
|
|
60
|
-
[
|
|
61
|
-
[
|
|
62
|
-
[
|
|
63
|
-
[
|
|
64
|
-
[
|
|
65
|
-
[
|
|
66
|
-
[
|
|
67
|
-
[
|
|
68
|
-
[
|
|
69
|
-
[
|
|
70
|
-
[
|
|
71
|
-
[
|
|
72
|
-
])
|
|
77
|
+
["ring-edge", "use `border border-border` para a aresta da superfície"],
|
|
78
|
+
["border-edge", "use `border-border`"],
|
|
79
|
+
["shadow-card", "use `shadow-sm`"],
|
|
80
|
+
["shadow-popover", "use `shadow-md`"],
|
|
81
|
+
["shadow-dialog", "use `shadow-lg`"],
|
|
82
|
+
["rounded-card", "use `rounded-xl`"],
|
|
83
|
+
["rounded-popover", "use `rounded-md`"],
|
|
84
|
+
["rounded-dialog", "use `rounded-xl`"],
|
|
85
|
+
["--edge-value", "remova o override; a aresta agora usa `--border`"],
|
|
86
|
+
["--color-edge", "remova o token; a aresta agora usa `--border`"],
|
|
87
|
+
["--elevation-card", "use/ajuste `--shadow-sm`"],
|
|
88
|
+
["--elevation-popover", "use/ajuste `--shadow-md`"],
|
|
89
|
+
["--elevation-dialog", "use/ajuste `--shadow-lg`"],
|
|
90
|
+
["--radius-card", "use/ajuste `--radius-xl`"],
|
|
91
|
+
["--radius-popover", "use/ajuste `--radius-md`"],
|
|
92
|
+
["--radius-dialog", "use/ajuste `--radius-xl`"],
|
|
93
|
+
]);
|
|
73
94
|
|
|
74
95
|
// Superfícies com foreground próprio formam um par indivisível. Exigir as duas
|
|
75
96
|
// utilities no mesmo fragmento de classes torna a regra local, revisável e evita que a
|
|
76
97
|
// igualdade acidental com `--foreground` esconda um tema quebrado.
|
|
77
98
|
const UI_SURFACE_PAIRS = new Map([
|
|
78
|
-
[
|
|
79
|
-
[
|
|
80
|
-
])
|
|
99
|
+
["bg-card", "text-card-foreground"],
|
|
100
|
+
["bg-popover", "text-popover-foreground"],
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
// Anatomia pública das superfícies compostas (ADR 0005). O lint olha somente nomes
|
|
104
|
+
// importados de @softize/opus/ui/react, evitando atribuir regras da biblioteca a componentes
|
|
105
|
+
// locais homônimos.
|
|
106
|
+
const UI_STRUCTURAL_PARENTS = new Map([
|
|
107
|
+
["PageHeader", new Set(["Page"])],
|
|
108
|
+
["PageBody", new Set(["Page"])],
|
|
109
|
+
["PageTitle", new Set(["PageHeader"])],
|
|
110
|
+
["PageDescription", new Set(["PageHeader"])],
|
|
111
|
+
["PageMeta", new Set(["PageHeader"])],
|
|
112
|
+
["PageActions", new Set(["PageHeader"])],
|
|
113
|
+
["ContentHeader", new Set(["Content"])],
|
|
114
|
+
["ContentBody", new Set(["Content"])],
|
|
115
|
+
["ContentTitle", new Set(["ContentHeader"])],
|
|
116
|
+
["ContentDescription", new Set(["ContentHeader"])],
|
|
117
|
+
["ContentMeta", new Set(["ContentHeader"])],
|
|
118
|
+
["ContentActions", new Set(["ContentHeader"])],
|
|
119
|
+
["CardHeader", new Set(["Card"])],
|
|
120
|
+
["CardBody", new Set(["Card"])],
|
|
121
|
+
["CardFooter", new Set(["Card"])],
|
|
122
|
+
["CardTitle", new Set(["CardHeader"])],
|
|
123
|
+
["CardDescription", new Set(["CardHeader"])],
|
|
124
|
+
["CardAction", new Set(["CardHeader"])],
|
|
125
|
+
["DialogHeader", new Set(["DialogContent"])],
|
|
126
|
+
["DialogBody", new Set(["DialogContent"])],
|
|
127
|
+
["DialogFooter", new Set(["DialogContent"])],
|
|
128
|
+
["DialogMedia", new Set(["DialogHeader"])],
|
|
129
|
+
["DialogTitle", new Set(["DialogHeader"])],
|
|
130
|
+
["DialogDescription", new Set(["DialogHeader"])],
|
|
131
|
+
["DrawerHeader", new Set(["DrawerContent"])],
|
|
132
|
+
["DrawerBody", new Set(["DrawerContent"])],
|
|
133
|
+
["DrawerFooter", new Set(["DrawerContent"])],
|
|
134
|
+
["DrawerTitle", new Set(["DrawerHeader"])],
|
|
135
|
+
["DrawerDescription", new Set(["DrawerHeader"])],
|
|
136
|
+
["PaneHeader", new Set(["Pane", "Sidebar"])],
|
|
137
|
+
["PaneBody", new Set(["Pane", "Sidebar"])],
|
|
138
|
+
["PaneFooter", new Set(["Pane", "Sidebar"])],
|
|
139
|
+
["AlertMedia", new Set(["Alert"])],
|
|
140
|
+
["AlertHeader", new Set(["Alert"])],
|
|
141
|
+
["AlertActions", new Set(["Alert"])],
|
|
142
|
+
["AlertTitle", new Set(["AlertHeader"])],
|
|
143
|
+
["AlertDescription", new Set(["AlertHeader"])],
|
|
144
|
+
["ItemMedia", new Set(["Item"])],
|
|
145
|
+
["ItemHeader", new Set(["Item"])],
|
|
146
|
+
["ItemBody", new Set(["Item"])],
|
|
147
|
+
["ItemContent", new Set(["Item"])],
|
|
148
|
+
["ItemActions", new Set(["Item"])],
|
|
149
|
+
["ItemFooter", new Set(["Item"])],
|
|
150
|
+
["ItemTitle", new Set(["ItemHeader"])],
|
|
151
|
+
["ItemDescription", new Set(["ItemHeader"])],
|
|
152
|
+
["EmptyHeader", new Set(["Empty"])],
|
|
153
|
+
["EmptyActions", new Set(["Empty"])],
|
|
154
|
+
["EmptyContent", new Set(["Empty"])],
|
|
155
|
+
["EmptyMedia", new Set(["EmptyHeader"])],
|
|
156
|
+
["EmptyTitle", new Set(["EmptyHeader"])],
|
|
157
|
+
["EmptyDescription", new Set(["EmptyHeader"])],
|
|
158
|
+
["PopoverHeader", new Set(["PopoverContent"])],
|
|
159
|
+
["PopoverTitle", new Set(["PopoverHeader"])],
|
|
160
|
+
["PopoverDescription", new Set(["PopoverHeader"])],
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const UI_STRUCTURAL_ROOTS = new Map([
|
|
164
|
+
[
|
|
165
|
+
"Page",
|
|
166
|
+
{
|
|
167
|
+
header: "PageHeader",
|
|
168
|
+
body: "PageBody",
|
|
169
|
+
shorthand: new Set(["title", "description", "count", "actions"]),
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
[
|
|
173
|
+
"Content",
|
|
174
|
+
{
|
|
175
|
+
header: "ContentHeader",
|
|
176
|
+
body: "ContentBody",
|
|
177
|
+
shorthand: new Set(["title", "description", "meta", "actions"]),
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const UI_STRICT_DIRECT_COMPONENTS = new Set([
|
|
183
|
+
"PageHeader",
|
|
184
|
+
"PageBody",
|
|
185
|
+
"PageTitle",
|
|
186
|
+
"PageDescription",
|
|
187
|
+
"PageMeta",
|
|
188
|
+
"PageActions",
|
|
189
|
+
"ContentHeader",
|
|
190
|
+
"ContentBody",
|
|
191
|
+
"ContentTitle",
|
|
192
|
+
"ContentDescription",
|
|
193
|
+
"ContentMeta",
|
|
194
|
+
"ContentActions",
|
|
195
|
+
"AlertMedia",
|
|
196
|
+
"AlertHeader",
|
|
197
|
+
"AlertActions",
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const UI_STRUCTURAL_HEADERS = new Map([
|
|
201
|
+
[
|
|
202
|
+
"PageHeader",
|
|
203
|
+
{
|
|
204
|
+
title: "PageTitle",
|
|
205
|
+
optional: new Set(["PageDescription", "PageMeta", "PageActions"]),
|
|
206
|
+
shorthand: new Set(),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
[
|
|
210
|
+
"ContentHeader",
|
|
211
|
+
{
|
|
212
|
+
title: "ContentTitle",
|
|
213
|
+
optional: new Set([
|
|
214
|
+
"ContentDescription",
|
|
215
|
+
"ContentMeta",
|
|
216
|
+
"ContentActions",
|
|
217
|
+
]),
|
|
218
|
+
shorthand: new Set(["title", "description", "meta", "actions"]),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
[
|
|
222
|
+
"AlertHeader",
|
|
223
|
+
{
|
|
224
|
+
title: "AlertTitle",
|
|
225
|
+
optional: new Set(["AlertDescription"]),
|
|
226
|
+
shorthand: new Set(),
|
|
227
|
+
titleRequired: false,
|
|
228
|
+
allowOpaque: true,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
[
|
|
232
|
+
"ItemHeader",
|
|
233
|
+
{
|
|
234
|
+
title: "ItemTitle",
|
|
235
|
+
optional: new Set(["ItemDescription"]),
|
|
236
|
+
shorthand: new Set(),
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
// API semântica canônica (ADR 0006). Os aliases continuam no runtime somente para que uma
|
|
242
|
+
// atualização não quebre o app antes da migração; código-fonte novo deve separar significado
|
|
243
|
+
// (`context`) de tratamento visual (`variant`).
|
|
244
|
+
const UI_LEGACY_VARIANTS = new Map([
|
|
245
|
+
["Button", new Set(["default", "secondary", "destructive"])],
|
|
246
|
+
[
|
|
247
|
+
"Badge",
|
|
248
|
+
new Set([
|
|
249
|
+
"default",
|
|
250
|
+
"secondary",
|
|
251
|
+
"destructive",
|
|
252
|
+
"info",
|
|
253
|
+
"success",
|
|
254
|
+
"warning",
|
|
255
|
+
"danger",
|
|
256
|
+
]),
|
|
257
|
+
],
|
|
258
|
+
[
|
|
259
|
+
"DictionaryValue",
|
|
260
|
+
new Set([
|
|
261
|
+
"default",
|
|
262
|
+
"secondary",
|
|
263
|
+
"destructive",
|
|
264
|
+
"info",
|
|
265
|
+
"success",
|
|
266
|
+
"warning",
|
|
267
|
+
"danger",
|
|
268
|
+
]),
|
|
269
|
+
],
|
|
270
|
+
[
|
|
271
|
+
"Alert",
|
|
272
|
+
new Set([
|
|
273
|
+
"default",
|
|
274
|
+
"destructive",
|
|
275
|
+
"info",
|
|
276
|
+
"success",
|
|
277
|
+
"warning",
|
|
278
|
+
"danger",
|
|
279
|
+
]),
|
|
280
|
+
],
|
|
281
|
+
[
|
|
282
|
+
"Dot",
|
|
283
|
+
new Set([
|
|
284
|
+
"default",
|
|
285
|
+
"secondary",
|
|
286
|
+
"destructive",
|
|
287
|
+
"info",
|
|
288
|
+
"success",
|
|
289
|
+
"warning",
|
|
290
|
+
"danger",
|
|
291
|
+
]),
|
|
292
|
+
],
|
|
293
|
+
["MetricCard", new Set()],
|
|
294
|
+
["MenuItem", new Set(["default", "destructive"])],
|
|
295
|
+
["ActionTrigger", new Set(["default", "secondary", "destructive"])],
|
|
296
|
+
]);
|
|
81
297
|
|
|
82
298
|
/** Os três nomes que denotam action/contrato — o arquivo sem nenhum deles é pulado. */
|
|
83
|
-
export const ACTION_MARKERS = [
|
|
299
|
+
export const ACTION_MARKERS = ["defineAction", "defineContract", "bindAction"];
|
|
84
300
|
|
|
85
301
|
/**
|
|
86
302
|
* Extrai `defineAction`/`defineContract`/`bindAction` de um source. Puro/sintático —
|
|
@@ -88,71 +304,86 @@ export const ACTION_MARKERS = ['defineAction', 'defineContract', 'bindAction']
|
|
|
88
304
|
* da const, se houver) e, no bindAction, `contractRef` (o identificador do 1º arg).
|
|
89
305
|
*/
|
|
90
306
|
export function parseActions(fileName, sourceText) {
|
|
91
|
-
const sf = ts.createSourceFile(
|
|
92
|
-
|
|
307
|
+
const sf = ts.createSourceFile(
|
|
308
|
+
fileName,
|
|
309
|
+
sourceText,
|
|
310
|
+
ts.ScriptTarget.Latest,
|
|
311
|
+
true,
|
|
312
|
+
ts.ScriptKind.TS,
|
|
313
|
+
);
|
|
314
|
+
const actions = [];
|
|
93
315
|
|
|
94
|
-
const lineOf = (node) =>
|
|
316
|
+
const lineOf = (node) =>
|
|
317
|
+
sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
95
318
|
|
|
96
319
|
function constIdent(callNode) {
|
|
97
|
-
const decl = callNode.parent
|
|
98
|
-
if (!decl || !ts.isVariableDeclaration(decl)) return null
|
|
99
|
-
return ts.isIdentifier(decl.name) ? decl.name.text : null
|
|
320
|
+
const decl = callNode.parent;
|
|
321
|
+
if (!decl || !ts.isVariableDeclaration(decl)) return null;
|
|
322
|
+
return ts.isIdentifier(decl.name) ? decl.name.text : null;
|
|
100
323
|
}
|
|
101
324
|
|
|
102
325
|
function isExported(callNode) {
|
|
103
326
|
// call → VariableDeclaration → VariableDeclarationList → VariableStatement (com export)
|
|
104
|
-
const decl = callNode.parent
|
|
105
|
-
if (!decl || !ts.isVariableDeclaration(decl)) return false
|
|
106
|
-
const list = decl.parent
|
|
107
|
-
const stmt = list && list.parent
|
|
108
|
-
if (!stmt || !ts.isVariableStatement(stmt)) return false
|
|
109
|
-
return (stmt.modifiers ?? []).some(
|
|
327
|
+
const decl = callNode.parent;
|
|
328
|
+
if (!decl || !ts.isVariableDeclaration(decl)) return false;
|
|
329
|
+
const list = decl.parent;
|
|
330
|
+
const stmt = list && list.parent;
|
|
331
|
+
if (!stmt || !ts.isVariableStatement(stmt)) return false;
|
|
332
|
+
return (stmt.modifiers ?? []).some(
|
|
333
|
+
(m) => m.kind === ts.SyntaxKind.ExportKeyword,
|
|
334
|
+
);
|
|
110
335
|
}
|
|
111
336
|
|
|
112
337
|
function strProp(obj, key) {
|
|
113
338
|
const p = obj.properties.find(
|
|
114
|
-
(pr) =>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
339
|
+
(pr) =>
|
|
340
|
+
ts.isPropertyAssignment(pr) && pr.name && pr.name.getText(sf) === key,
|
|
341
|
+
);
|
|
342
|
+
if (!p || !ts.isStringLiteral(p.initializer)) return null;
|
|
343
|
+
return p.initializer.text;
|
|
118
344
|
}
|
|
119
345
|
|
|
120
346
|
function objKeys(obj) {
|
|
121
347
|
return obj.properties
|
|
122
|
-
.filter(
|
|
123
|
-
|
|
124
|
-
|
|
348
|
+
.filter(
|
|
349
|
+
(p) =>
|
|
350
|
+
ts.isPropertyAssignment(p) ||
|
|
351
|
+
ts.isMethodDeclaration(p) ||
|
|
352
|
+
ts.isShorthandPropertyAssignment(p),
|
|
353
|
+
)
|
|
354
|
+
.map((p) => (p.name ? p.name.getText(sf) : ""))
|
|
355
|
+
.filter(Boolean);
|
|
125
356
|
}
|
|
126
357
|
|
|
127
358
|
function visit(node) {
|
|
128
359
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
129
|
-
const callee = node.expression.text
|
|
360
|
+
const callee = node.expression.text;
|
|
130
361
|
|
|
131
362
|
if (
|
|
132
|
-
(callee ===
|
|
363
|
+
(callee === "defineAction" || callee === "defineContract") &&
|
|
133
364
|
node.arguments.length > 0 &&
|
|
134
365
|
ts.isObjectLiteralExpression(node.arguments[0])
|
|
135
366
|
) {
|
|
136
|
-
const obj = node.arguments[0]
|
|
367
|
+
const obj = node.arguments[0];
|
|
137
368
|
actions.push({
|
|
138
369
|
form: callee,
|
|
139
|
-
name: strProp(obj,
|
|
140
|
-
kind: strProp(obj,
|
|
370
|
+
name: strProp(obj, "name"),
|
|
371
|
+
kind: strProp(obj, "kind"),
|
|
141
372
|
keys: objKeys(obj),
|
|
142
373
|
ident: constIdent(node),
|
|
143
374
|
exported: isExported(node),
|
|
144
375
|
line: lineOf(node),
|
|
145
|
-
})
|
|
376
|
+
});
|
|
146
377
|
}
|
|
147
378
|
|
|
148
379
|
if (
|
|
149
|
-
callee ===
|
|
380
|
+
callee === "bindAction" &&
|
|
150
381
|
node.arguments.length >= 2 &&
|
|
151
382
|
ts.isObjectLiteralExpression(node.arguments[1])
|
|
152
383
|
) {
|
|
153
|
-
const ref = node.arguments[0]
|
|
384
|
+
const ref = node.arguments[0];
|
|
154
385
|
actions.push({
|
|
155
|
-
form:
|
|
386
|
+
form: "bindAction",
|
|
156
387
|
name: null,
|
|
157
388
|
kind: null,
|
|
158
389
|
keys: objKeys(node.arguments[1]),
|
|
@@ -160,67 +391,90 @@ export function parseActions(fileName, sourceText) {
|
|
|
160
391
|
contractRef: ts.isIdentifier(ref) ? ref.text : ref.getText(sf),
|
|
161
392
|
exported: isExported(node),
|
|
162
393
|
line: lineOf(node),
|
|
163
|
-
})
|
|
394
|
+
});
|
|
164
395
|
}
|
|
165
396
|
}
|
|
166
|
-
ts.forEachChild(node, visit)
|
|
397
|
+
ts.forEachChild(node, visit);
|
|
167
398
|
}
|
|
168
|
-
visit(sf)
|
|
169
|
-
return actions
|
|
399
|
+
visit(sf);
|
|
400
|
+
return actions;
|
|
170
401
|
}
|
|
171
402
|
|
|
172
403
|
/** Aplica as regras locais (por arquivo) a um item → findings. */
|
|
173
404
|
export function lintAction(a) {
|
|
174
|
-
const out = []
|
|
175
|
-
const label =
|
|
176
|
-
|
|
405
|
+
const out = [];
|
|
406
|
+
const label =
|
|
407
|
+
a.name ??
|
|
408
|
+
(a.form === "bindAction" ? `bindAction(${a.contractRef})` : "(sem name)");
|
|
409
|
+
const at = (rule, message) =>
|
|
410
|
+
out.push({ rule, level: "error", action: label, line: a.line, message });
|
|
177
411
|
|
|
178
|
-
if (a.form ===
|
|
412
|
+
if (a.form === "bindAction") {
|
|
179
413
|
// O binding não repete name/kind/ordem — quem carrega isso é o contrato.
|
|
180
|
-
if (!a.exported)
|
|
181
|
-
|
|
414
|
+
if (!a.exported)
|
|
415
|
+
at(
|
|
416
|
+
"export",
|
|
417
|
+
"bindAction precisa ser `export const` (senão não dá pra registrar)",
|
|
418
|
+
);
|
|
419
|
+
return out;
|
|
182
420
|
}
|
|
183
421
|
|
|
184
|
-
if (!a.name) at(
|
|
185
|
-
else if (!ACTION_NAME_RE.test(a.name))
|
|
422
|
+
if (!a.name) at("action-name", `${a.form} sem \`name\``);
|
|
423
|
+
else if (!ACTION_NAME_RE.test(a.name))
|
|
424
|
+
at(
|
|
425
|
+
"action-name",
|
|
426
|
+
`name "${a.name}" não é <resource>.<verb> (minúsculo, ponto)`,
|
|
427
|
+
);
|
|
186
428
|
|
|
187
|
-
if (!a.kind) at(
|
|
188
|
-
else if (!KINDS.has(a.kind))
|
|
429
|
+
if (!a.kind) at("kind", `${a.form} sem \`kind\``);
|
|
430
|
+
else if (!KINDS.has(a.kind))
|
|
431
|
+
at("kind", `kind "${a.kind}" inválido (use simple|form|list|view)`);
|
|
189
432
|
|
|
190
433
|
// field-order: os campos conhecidos devem ter group-index não-decrescente
|
|
191
|
-
let lastGroup = -1
|
|
192
|
-
let lastKey =
|
|
434
|
+
let lastGroup = -1;
|
|
435
|
+
let lastKey = "";
|
|
193
436
|
for (const k of a.keys) {
|
|
194
|
-
const g = FIELD_GROUP[k]
|
|
195
|
-
if (g === undefined) continue // kind-específico/desconhecido → lenient
|
|
437
|
+
const g = FIELD_GROUP[k];
|
|
438
|
+
if (g === undefined) continue; // kind-específico/desconhecido → lenient
|
|
196
439
|
if (g < lastGroup) {
|
|
197
|
-
at(
|
|
198
|
-
|
|
440
|
+
at(
|
|
441
|
+
"field-order",
|
|
442
|
+
`campo "${k}" fora de ordem (depois de "${lastKey}") — siga identidade→docs→input/output→authorize→handler→comportamento`,
|
|
443
|
+
);
|
|
444
|
+
break;
|
|
199
445
|
}
|
|
200
|
-
lastGroup = g
|
|
201
|
-
lastKey = k
|
|
446
|
+
lastGroup = g;
|
|
447
|
+
lastKey = k;
|
|
202
448
|
}
|
|
203
449
|
|
|
204
|
-
if (!a.exported)
|
|
450
|
+
if (!a.exported)
|
|
451
|
+
at(
|
|
452
|
+
"export",
|
|
453
|
+
`${a.form} precisa ser \`export const\` (senão não dá pra registrar)`,
|
|
454
|
+
);
|
|
205
455
|
|
|
206
456
|
// requires sem authorize: no defineAction o par mora no mesmo objeto — acusa direto.
|
|
207
457
|
// No defineContract a regra é cross-file (o bind pode trazer o authorize row-level);
|
|
208
458
|
// fica pro join do checkProject.
|
|
209
|
-
if (
|
|
459
|
+
if (
|
|
460
|
+
a.form === "defineAction" &&
|
|
461
|
+
a.keys.includes("requires") &&
|
|
462
|
+
!a.keys.includes("authorize")
|
|
463
|
+
) {
|
|
210
464
|
at(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
465
|
+
"requires-sem-authorize",
|
|
466
|
+
"`requires` é declarativo (o runtime não o executa) — sem `authorize`, a action fica ABERTA. Declare o authorize.",
|
|
467
|
+
);
|
|
214
468
|
}
|
|
215
469
|
|
|
216
|
-
return out
|
|
470
|
+
return out;
|
|
217
471
|
}
|
|
218
472
|
|
|
219
473
|
/** Checa um source isolado → { actions, findings }. Regras cross-file: checkProject. */
|
|
220
474
|
export function checkSource(fileName, sourceText) {
|
|
221
|
-
const actions = parseActions(fileName, sourceText)
|
|
222
|
-
const findings = actions.flatMap(lintAction)
|
|
223
|
-
return { actions, findings }
|
|
475
|
+
const actions = parseActions(fileName, sourceText);
|
|
476
|
+
const findings = actions.flatMap(lintAction);
|
|
477
|
+
return { actions, findings };
|
|
224
478
|
}
|
|
225
479
|
|
|
226
480
|
/**
|
|
@@ -231,343 +485,877 @@ export function checkSource(fileName, sourceText) {
|
|
|
231
485
|
* Referência não encontrada ou identificador duplicado não acusa.
|
|
232
486
|
*/
|
|
233
487
|
export function checkProject(sources) {
|
|
234
|
-
let actions = 0
|
|
235
|
-
let contracts = 0
|
|
236
|
-
const findings = []
|
|
237
|
-
const contractsByIdent = new Map() // ident → item | 'ambiguous'
|
|
238
|
-
const binds = [] // { item, file }
|
|
488
|
+
let actions = 0;
|
|
489
|
+
let contracts = 0;
|
|
490
|
+
const findings = [];
|
|
491
|
+
const contractsByIdent = new Map(); // ident → item | 'ambiguous'
|
|
492
|
+
const binds = []; // { item, file }
|
|
239
493
|
|
|
240
494
|
for (const { file, text } of sources) {
|
|
241
495
|
for (const item of parseActions(file, text)) {
|
|
242
|
-
if (item.form ===
|
|
243
|
-
actions += 1
|
|
244
|
-
binds.push({ item, file })
|
|
245
|
-
} else if (item.form ===
|
|
246
|
-
contracts += 1
|
|
496
|
+
if (item.form === "bindAction") {
|
|
497
|
+
actions += 1;
|
|
498
|
+
binds.push({ item, file });
|
|
499
|
+
} else if (item.form === "defineContract") {
|
|
500
|
+
contracts += 1;
|
|
247
501
|
if (item.ident !== null) {
|
|
248
|
-
contractsByIdent.set(
|
|
502
|
+
contractsByIdent.set(
|
|
503
|
+
item.ident,
|
|
504
|
+
contractsByIdent.has(item.ident) ? "ambiguous" : item,
|
|
505
|
+
);
|
|
249
506
|
}
|
|
250
507
|
} else {
|
|
251
|
-
actions += 1
|
|
508
|
+
actions += 1;
|
|
252
509
|
}
|
|
253
|
-
for (const f of lintAction(item)) findings.push({ ...f, file })
|
|
510
|
+
for (const f of lintAction(item)) findings.push({ ...f, file });
|
|
254
511
|
}
|
|
255
512
|
}
|
|
256
513
|
|
|
257
514
|
for (const { item, file } of binds) {
|
|
258
|
-
const contract = contractsByIdent.get(item.contractRef)
|
|
259
|
-
if (contract === undefined || contract ===
|
|
260
|
-
const protectedSomewhere =
|
|
261
|
-
|
|
515
|
+
const contract = contractsByIdent.get(item.contractRef);
|
|
516
|
+
if (contract === undefined || contract === "ambiguous") continue;
|
|
517
|
+
const protectedSomewhere =
|
|
518
|
+
contract.keys.includes("authorize") || item.keys.includes("authorize");
|
|
519
|
+
if (contract.keys.includes("requires") && !protectedSomewhere) {
|
|
262
520
|
findings.push({
|
|
263
|
-
rule:
|
|
264
|
-
level:
|
|
521
|
+
rule: "requires-sem-authorize",
|
|
522
|
+
level: "error",
|
|
265
523
|
action: contract.name ?? item.contractRef,
|
|
266
524
|
line: item.line,
|
|
267
525
|
file,
|
|
268
526
|
message:
|
|
269
|
-
|
|
270
|
-
})
|
|
527
|
+
"`requires` do contrato é declarativo (o runtime não o executa) — sem `authorize` no contrato ou no binding, a action fica ABERTA.",
|
|
528
|
+
});
|
|
271
529
|
}
|
|
272
530
|
}
|
|
273
531
|
|
|
274
|
-
return { actions, contracts, findings }
|
|
532
|
+
return { actions, contracts, findings };
|
|
275
533
|
}
|
|
276
534
|
|
|
277
535
|
/** Migrações de UI que falhariam apenas visualmente. Linhas que são só comentário não
|
|
278
536
|
* contam; documentação vive fora do sourceRoot e também não entra no walk. */
|
|
279
537
|
function stripComments(text) {
|
|
280
|
-
let output =
|
|
281
|
-
let state =
|
|
282
|
-
let escaped = false
|
|
538
|
+
let output = "";
|
|
539
|
+
let state = "code";
|
|
540
|
+
let escaped = false;
|
|
283
541
|
for (let index = 0; index < text.length; index += 1) {
|
|
284
|
-
const char = text[index]
|
|
285
|
-
const next = text[index + 1]
|
|
286
|
-
if (state ===
|
|
287
|
-
if (char ===
|
|
288
|
-
|
|
542
|
+
const char = text[index];
|
|
543
|
+
const next = text[index + 1];
|
|
544
|
+
if (state === "line-comment") {
|
|
545
|
+
if (char === "\n") {
|
|
546
|
+
state = "code";
|
|
547
|
+
output += char;
|
|
548
|
+
} else output += " ";
|
|
549
|
+
continue;
|
|
289
550
|
}
|
|
290
|
-
if (state ===
|
|
291
|
-
if (char ===
|
|
292
|
-
|
|
293
|
-
|
|
551
|
+
if (state === "block-comment") {
|
|
552
|
+
if (char === "*" && next === "/") {
|
|
553
|
+
output += " ";
|
|
554
|
+
index += 1;
|
|
555
|
+
state = "code";
|
|
556
|
+
} else output += char === "\n" ? "\n" : " ";
|
|
557
|
+
continue;
|
|
294
558
|
}
|
|
295
|
-
if (state ===
|
|
296
|
-
if (char ===
|
|
297
|
-
output +=
|
|
298
|
-
|
|
299
|
-
|
|
559
|
+
if (state === "html-comment") {
|
|
560
|
+
if (char === "-" && text.slice(index, index + 3) === "-->") {
|
|
561
|
+
output += " ";
|
|
562
|
+
index += 2;
|
|
563
|
+
state = "code";
|
|
564
|
+
} else output += char === "\n" ? "\n" : " ";
|
|
565
|
+
continue;
|
|
300
566
|
}
|
|
301
|
-
if (state ===
|
|
302
|
-
output +=
|
|
567
|
+
if (state === "code" && text.slice(index, index + 4) === "<!--") {
|
|
568
|
+
output += " ";
|
|
569
|
+
index += 3;
|
|
570
|
+
state = "html-comment";
|
|
571
|
+
continue;
|
|
303
572
|
}
|
|
304
|
-
if (state ===
|
|
305
|
-
output +=
|
|
573
|
+
if (state === "code" && char === "/" && next === "/") {
|
|
574
|
+
output += " ";
|
|
575
|
+
index += 1;
|
|
576
|
+
state = "line-comment";
|
|
577
|
+
continue;
|
|
306
578
|
}
|
|
307
|
-
if (state ===
|
|
308
|
-
output +=
|
|
579
|
+
if (state === "code" && char === "/" && next === "*") {
|
|
580
|
+
output += " ";
|
|
581
|
+
index += 1;
|
|
582
|
+
state = "block-comment";
|
|
583
|
+
continue;
|
|
309
584
|
}
|
|
310
|
-
if (state ===
|
|
311
|
-
state = char;
|
|
585
|
+
if (state === "code" && (char === "'" || char === '"' || char === "`")) {
|
|
586
|
+
state = char;
|
|
587
|
+
output += char;
|
|
588
|
+
escaped = false;
|
|
589
|
+
continue;
|
|
312
590
|
}
|
|
313
|
-
if (state !==
|
|
314
|
-
output += char
|
|
315
|
-
if (escaped) escaped = false
|
|
316
|
-
else if (char ===
|
|
317
|
-
else if (char === state) state =
|
|
318
|
-
continue
|
|
591
|
+
if (state !== "code") {
|
|
592
|
+
output += char;
|
|
593
|
+
if (escaped) escaped = false;
|
|
594
|
+
else if (char === "\\") escaped = true;
|
|
595
|
+
else if (char === state) state = "code";
|
|
596
|
+
continue;
|
|
319
597
|
}
|
|
320
|
-
output += char
|
|
598
|
+
output += char;
|
|
321
599
|
}
|
|
322
|
-
return output
|
|
600
|
+
return output;
|
|
323
601
|
}
|
|
324
602
|
|
|
325
603
|
function quotedValues(text) {
|
|
326
|
-
const values = []
|
|
327
|
-
let line = 1
|
|
604
|
+
const values = [];
|
|
605
|
+
let line = 1;
|
|
328
606
|
const startsRegex = (index) => {
|
|
329
|
-
const before = text.slice(0, index).trimEnd()
|
|
330
|
-
if (before ===
|
|
331
|
-
const previous = before.at(-1)
|
|
332
|
-
return
|
|
333
|
-
|
|
334
|
-
|
|
607
|
+
const before = text.slice(0, index).trimEnd();
|
|
608
|
+
if (before === "") return true;
|
|
609
|
+
const previous = before.at(-1);
|
|
610
|
+
return (
|
|
611
|
+
/[=(:,![{;?&|+*%^~<>-]/.test(previous) ||
|
|
612
|
+
/\b(?:return|case|throw|typeof|instanceof|in|of|yield|await)\s*$/.test(
|
|
613
|
+
before,
|
|
614
|
+
)
|
|
615
|
+
);
|
|
616
|
+
};
|
|
335
617
|
for (let index = 0; index < text.length; index += 1) {
|
|
336
|
-
const quote = text[index]
|
|
337
|
-
if (quote ===
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
618
|
+
const quote = text[index];
|
|
619
|
+
if (quote === "\n") {
|
|
620
|
+
line += 1;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (quote === "/" && startsRegex(index)) {
|
|
624
|
+
let escaped = false;
|
|
625
|
+
let inCharacterClass = false;
|
|
341
626
|
for (index += 1; index < text.length; index += 1) {
|
|
342
|
-
const char = text[index]
|
|
343
|
-
if (char ===
|
|
344
|
-
if (escaped) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (char ===
|
|
627
|
+
const char = text[index];
|
|
628
|
+
if (char === "\n") line += 1;
|
|
629
|
+
if (escaped) {
|
|
630
|
+
escaped = false;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (char === "\\") {
|
|
634
|
+
escaped = true;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (char === "[") {
|
|
638
|
+
inCharacterClass = true;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (char === "]") {
|
|
642
|
+
inCharacterClass = false;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (char === "/" && !inCharacterClass) break;
|
|
349
646
|
}
|
|
350
|
-
while (/[a-z]/i.test(text[index + 1] ??
|
|
351
|
-
continue
|
|
647
|
+
while (/[a-z]/i.test(text[index + 1] ?? "")) index += 1;
|
|
648
|
+
continue;
|
|
352
649
|
}
|
|
353
|
-
if (quote !== "'" && quote !== '"' && quote !==
|
|
354
|
-
let value =
|
|
355
|
-
let escaped = false
|
|
356
|
-
const startLine = line
|
|
357
|
-
const startIndex = index
|
|
650
|
+
if (quote !== "'" && quote !== '"' && quote !== "`") continue;
|
|
651
|
+
let value = "";
|
|
652
|
+
let escaped = false;
|
|
653
|
+
const startLine = line;
|
|
654
|
+
const startIndex = index;
|
|
358
655
|
for (index += 1; index < text.length; index += 1) {
|
|
359
|
-
const char = text[index]
|
|
360
|
-
if (char ===
|
|
361
|
-
if (escaped) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
656
|
+
const char = text[index];
|
|
657
|
+
if (char === "\n") line += 1;
|
|
658
|
+
if (escaped) {
|
|
659
|
+
value += char;
|
|
660
|
+
escaped = false;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (char === "\\") {
|
|
664
|
+
value += char;
|
|
665
|
+
escaped = true;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (char === quote) break;
|
|
669
|
+
value += char;
|
|
365
670
|
}
|
|
366
|
-
values.push({ value, startLine, startIndex, endIndex: index })
|
|
671
|
+
values.push({ value, startLine, startIndex, endIndex: index });
|
|
367
672
|
}
|
|
368
|
-
return values
|
|
673
|
+
return values;
|
|
369
674
|
}
|
|
370
675
|
|
|
371
|
-
const SIMPLE_TAILWIND_CLASSES = new Set([
|
|
676
|
+
const SIMPLE_TAILWIND_CLASSES = new Set([
|
|
677
|
+
"flex",
|
|
678
|
+
"grid",
|
|
679
|
+
"block",
|
|
680
|
+
"inline",
|
|
681
|
+
"hidden",
|
|
682
|
+
"border",
|
|
683
|
+
"shadow",
|
|
684
|
+
"ring",
|
|
685
|
+
]);
|
|
372
686
|
|
|
373
687
|
function looksLikeUtilityValue(value, token, matchesToken) {
|
|
374
|
-
if (!matchesToken(value, token)) return false
|
|
375
|
-
const parts = value.trim().split(/\s+/).filter(Boolean)
|
|
376
|
-
return
|
|
377
|
-
|
|
378
|
-
|
|
688
|
+
if (!matchesToken(value, token)) return false;
|
|
689
|
+
const parts = value.trim().split(/\s+/).filter(Boolean);
|
|
690
|
+
return (
|
|
691
|
+
parts.length > 1 &&
|
|
692
|
+
parts.every(
|
|
693
|
+
(part) =>
|
|
694
|
+
matchesToken(part, token) ||
|
|
695
|
+
SIMPLE_TAILWIND_CLASSES.has(part) ||
|
|
696
|
+
/[-:[\]/!@.%]/.test(part),
|
|
697
|
+
)
|
|
698
|
+
);
|
|
379
699
|
}
|
|
380
700
|
|
|
381
701
|
function isInsideUtilityHelper(beforeValue) {
|
|
382
|
-
const matches = [...beforeValue.matchAll(/\b(?:cn|cva|clsx|twMerge)\s*\(/g)]
|
|
702
|
+
const matches = [...beforeValue.matchAll(/\b(?:cn|cva|clsx|twMerge)\s*\(/g)];
|
|
383
703
|
for (const match of matches.reverse()) {
|
|
384
|
-
const call = beforeValue.slice(match.index)
|
|
385
|
-
let depth = 0
|
|
386
|
-
let quote = null
|
|
387
|
-
let escaped = false
|
|
704
|
+
const call = beforeValue.slice(match.index);
|
|
705
|
+
let depth = 0;
|
|
706
|
+
let quote = null;
|
|
707
|
+
let escaped = false;
|
|
388
708
|
for (const char of call) {
|
|
389
709
|
if (quote !== null) {
|
|
390
|
-
if (escaped) escaped = false
|
|
391
|
-
else if (char ===
|
|
392
|
-
else if (char === quote) quote = null
|
|
393
|
-
continue
|
|
710
|
+
if (escaped) escaped = false;
|
|
711
|
+
else if (char === "\\") escaped = true;
|
|
712
|
+
else if (char === quote) quote = null;
|
|
713
|
+
continue;
|
|
394
714
|
}
|
|
395
|
-
if (char === "'" || char === '"' || char ===
|
|
396
|
-
|
|
397
|
-
|
|
715
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
716
|
+
quote = char;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (char === "(") depth += 1;
|
|
720
|
+
else if (char === ")") depth -= 1;
|
|
721
|
+
}
|
|
722
|
+
if (depth > 0) return true;
|
|
723
|
+
}
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function uiImports(sourceFile) {
|
|
728
|
+
const imports = new Map();
|
|
729
|
+
for (const statement of sourceFile.statements) {
|
|
730
|
+
if (
|
|
731
|
+
!ts.isImportDeclaration(statement) ||
|
|
732
|
+
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
733
|
+
statement.moduleSpecifier.text !== "@softize/opus/ui/react"
|
|
734
|
+
)
|
|
735
|
+
continue;
|
|
736
|
+
const bindings = statement.importClause?.namedBindings;
|
|
737
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
738
|
+
for (const element of bindings.elements) {
|
|
739
|
+
imports.set(
|
|
740
|
+
element.name.text,
|
|
741
|
+
element.propertyName?.text ?? element.name.text,
|
|
742
|
+
);
|
|
398
743
|
}
|
|
399
|
-
if (depth > 0) return true
|
|
400
744
|
}
|
|
401
|
-
return
|
|
745
|
+
return imports;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function jsxLocalName(node) {
|
|
749
|
+
const tag = ts.isJsxElement(node)
|
|
750
|
+
? node.openingElement.tagName
|
|
751
|
+
: node.tagName;
|
|
752
|
+
return ts.isIdentifier(tag) ? tag.text : null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function jsxOpening(node) {
|
|
756
|
+
return ts.isJsxElement(node) ? node.openingElement : node;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function directJsxParentName(node, imports) {
|
|
760
|
+
let parent = node.parent;
|
|
761
|
+
while (
|
|
762
|
+
ts.isParenthesizedExpression(parent) ||
|
|
763
|
+
ts.isConditionalExpression(parent) ||
|
|
764
|
+
ts.isBinaryExpression(parent) ||
|
|
765
|
+
ts.isJsxExpression(parent)
|
|
766
|
+
) {
|
|
767
|
+
parent = parent.parent;
|
|
768
|
+
}
|
|
769
|
+
if (!ts.isJsxElement(parent)) return null;
|
|
770
|
+
const local = jsxLocalName(parent);
|
|
771
|
+
return local === null ? null : (imports.get(local) ?? null);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function structuralJsxAncestorName(node, imports) {
|
|
775
|
+
let current = node.parent;
|
|
776
|
+
while (current !== undefined) {
|
|
777
|
+
if (ts.isJsxElement(current) || ts.isJsxSelfClosingElement(current)) {
|
|
778
|
+
const local = jsxLocalName(current);
|
|
779
|
+
const imported = local === null ? undefined : imports.get(local);
|
|
780
|
+
if (
|
|
781
|
+
imported !== undefined &&
|
|
782
|
+
(UI_STRUCTURAL_ROOTS.has(imported) ||
|
|
783
|
+
UI_STRUCTURAL_PARENTS.has(imported) ||
|
|
784
|
+
[...UI_STRUCTURAL_PARENTS.values()].some((parents) =>
|
|
785
|
+
parents.has(imported),
|
|
786
|
+
))
|
|
787
|
+
)
|
|
788
|
+
return imported;
|
|
789
|
+
}
|
|
790
|
+
current = current.parent;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function directStructuralChildren(node, imports) {
|
|
796
|
+
if (!ts.isJsxElement(node)) return { names: [], hasOpaque: false };
|
|
797
|
+
const names = [];
|
|
798
|
+
let hasOpaque = false;
|
|
799
|
+
for (const child of node.children) {
|
|
800
|
+
if (ts.isJsxText(child) && child.text.trim() === "") continue;
|
|
801
|
+
if (ts.isJsxExpression(child) && child.expression === undefined) continue;
|
|
802
|
+
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
|
|
803
|
+
const local = jsxLocalName(child);
|
|
804
|
+
const imported = local === null ? null : imports.get(local);
|
|
805
|
+
if (imported !== undefined) names.push(imported);
|
|
806
|
+
else hasOpaque = true;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
hasOpaque = true;
|
|
810
|
+
}
|
|
811
|
+
return { names, hasOpaque };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Verifica a anatomia JSX pública definida na ADR 0005. */
|
|
815
|
+
export function checkUiStructure(file, text) {
|
|
816
|
+
if (!/\.[cm]?[jt]sx$/.test(file)) return [];
|
|
817
|
+
const sourceFile = ts.createSourceFile(
|
|
818
|
+
file,
|
|
819
|
+
text,
|
|
820
|
+
ts.ScriptTarget.Latest,
|
|
821
|
+
true,
|
|
822
|
+
ts.ScriptKind.TSX,
|
|
823
|
+
);
|
|
824
|
+
const imports = uiImports(sourceFile);
|
|
825
|
+
if (imports.size === 0) return [];
|
|
826
|
+
const findings = [];
|
|
827
|
+
const lineOf = (node) =>
|
|
828
|
+
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line +
|
|
829
|
+
1;
|
|
830
|
+
const add = (node, action, message, rule = "ui-structure") =>
|
|
831
|
+
findings.push({
|
|
832
|
+
rule,
|
|
833
|
+
level: "error",
|
|
834
|
+
action,
|
|
835
|
+
line: lineOf(node),
|
|
836
|
+
file,
|
|
837
|
+
message,
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
const visit = (node) => {
|
|
841
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
842
|
+
const local = jsxLocalName(node);
|
|
843
|
+
const component = local === null ? null : imports.get(local);
|
|
844
|
+
const parents =
|
|
845
|
+
component === null ? undefined : UI_STRUCTURAL_PARENTS.get(component);
|
|
846
|
+
if (parents !== undefined) {
|
|
847
|
+
const strict = UI_STRICT_DIRECT_COMPONENTS.has(component);
|
|
848
|
+
const parent = strict
|
|
849
|
+
? directJsxParentName(node, imports)
|
|
850
|
+
: structuralJsxAncestorName(node, imports);
|
|
851
|
+
// Slots das famílias históricas podem ser a raiz de um componente wrapper. Quando há
|
|
852
|
+
// uma superfície estrutural visível no mesmo JSX, porém, ela precisa ser a família certa.
|
|
853
|
+
if (
|
|
854
|
+
(strict || parent !== null) &&
|
|
855
|
+
(parent === null || !parents.has(parent))
|
|
856
|
+
) {
|
|
857
|
+
add(
|
|
858
|
+
node,
|
|
859
|
+
component,
|
|
860
|
+
`${component} deve estar ${strict ? "como filho direto" : "dentro"} de ${[...parents].join(" ou ")}.`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const header =
|
|
866
|
+
component === null ? undefined : UI_STRUCTURAL_HEADERS.get(component);
|
|
867
|
+
if (header !== undefined) {
|
|
868
|
+
const opening = jsxOpening(node);
|
|
869
|
+
const attributes = new Set(
|
|
870
|
+
opening.attributes.properties.flatMap((attribute) =>
|
|
871
|
+
ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name)
|
|
872
|
+
? [attribute.name.text]
|
|
873
|
+
: [],
|
|
874
|
+
),
|
|
875
|
+
);
|
|
876
|
+
const shorthand = [...header.shorthand].some((attribute) =>
|
|
877
|
+
attributes.has(attribute),
|
|
878
|
+
);
|
|
879
|
+
const children = directStructuralChildren(node, imports);
|
|
880
|
+
const structural =
|
|
881
|
+
children.names.includes(header.title) ||
|
|
882
|
+
children.names.some((name) => header.optional.has(name));
|
|
883
|
+
if (shorthand && structural) {
|
|
884
|
+
add(
|
|
885
|
+
node,
|
|
886
|
+
component,
|
|
887
|
+
`${component} não permite misturar propriedades de shorthand com seus slots explícitos.`,
|
|
888
|
+
"ui-structure-mode",
|
|
889
|
+
);
|
|
890
|
+
} else if (!shorthand) {
|
|
891
|
+
const titles = children.names.filter(
|
|
892
|
+
(name) => name === header.title,
|
|
893
|
+
).length;
|
|
894
|
+
const unexpected = children.names.some(
|
|
895
|
+
(name) => name !== header.title && !header.optional.has(name),
|
|
896
|
+
);
|
|
897
|
+
const duplicatedOptional = [...header.optional].some(
|
|
898
|
+
(name) =>
|
|
899
|
+
children.names.filter((child) => child === name).length > 1,
|
|
900
|
+
);
|
|
901
|
+
const titleValid =
|
|
902
|
+
header.titleRequired === false ? titles <= 1 : titles === 1;
|
|
903
|
+
const recognizedContent =
|
|
904
|
+
titles +
|
|
905
|
+
children.names.filter((name) => header.optional.has(name)).length;
|
|
906
|
+
if (
|
|
907
|
+
!titleValid ||
|
|
908
|
+
(header.titleRequired === false &&
|
|
909
|
+
recognizedContent === 0 &&
|
|
910
|
+
!(header.allowOpaque && children.hasOpaque)) ||
|
|
911
|
+
unexpected ||
|
|
912
|
+
duplicatedOptional ||
|
|
913
|
+
(children.hasOpaque && !header.allowOpaque)
|
|
914
|
+
) {
|
|
915
|
+
add(
|
|
916
|
+
node,
|
|
917
|
+
component,
|
|
918
|
+
`${component} explícito ${header.titleRequired === false ? `aceita no máximo um ${header.title}` : `exige um ${header.title}`} e aceita no máximo um de cada slot opcional como filhos diretos.`,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const root =
|
|
925
|
+
component === null ? undefined : UI_STRUCTURAL_ROOTS.get(component);
|
|
926
|
+
if (root !== undefined) {
|
|
927
|
+
const opening = jsxOpening(node);
|
|
928
|
+
const attributes = new Set(
|
|
929
|
+
opening.attributes.properties.flatMap((attribute) =>
|
|
930
|
+
ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name)
|
|
931
|
+
? [attribute.name.text]
|
|
932
|
+
: [],
|
|
933
|
+
),
|
|
934
|
+
);
|
|
935
|
+
const shorthand = [...root.shorthand].some((attribute) =>
|
|
936
|
+
attributes.has(attribute),
|
|
937
|
+
);
|
|
938
|
+
const children = directStructuralChildren(node, imports);
|
|
939
|
+
const structural =
|
|
940
|
+
children.names.includes(root.header) ||
|
|
941
|
+
children.names.includes(root.body);
|
|
942
|
+
if (shorthand && structural) {
|
|
943
|
+
add(
|
|
944
|
+
node,
|
|
945
|
+
component,
|
|
946
|
+
`${component} não permite misturar propriedades de shorthand com ${root.header}/${root.body}.`,
|
|
947
|
+
"ui-structure-mode",
|
|
948
|
+
);
|
|
949
|
+
} else if (!shorthand) {
|
|
950
|
+
const headers = children.names.filter(
|
|
951
|
+
(name) => name === root.header,
|
|
952
|
+
).length;
|
|
953
|
+
const bodies = children.names.filter(
|
|
954
|
+
(name) => name === root.body,
|
|
955
|
+
).length;
|
|
956
|
+
const unexpected = children.names.some(
|
|
957
|
+
(name) => name !== root.header && name !== root.body,
|
|
958
|
+
);
|
|
959
|
+
if (
|
|
960
|
+
headers !== 1 ||
|
|
961
|
+
bodies !== 1 ||
|
|
962
|
+
unexpected ||
|
|
963
|
+
children.hasOpaque
|
|
964
|
+
) {
|
|
965
|
+
add(
|
|
966
|
+
node,
|
|
967
|
+
component,
|
|
968
|
+
`${component} explícito exige exatamente um ${root.header} e um ${root.body} como filhos diretos.`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
ts.forEachChild(node, visit);
|
|
975
|
+
};
|
|
976
|
+
visit(sourceFile);
|
|
977
|
+
return findings;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function jsxAttribute(opening, name) {
|
|
981
|
+
return opening.attributes.properties.find(
|
|
982
|
+
(candidate) =>
|
|
983
|
+
ts.isJsxAttribute(candidate) &&
|
|
984
|
+
ts.isIdentifier(candidate.name) &&
|
|
985
|
+
candidate.name.text === name,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function jsxStringAttributeValues(opening, name) {
|
|
990
|
+
const attribute = jsxAttribute(opening, name);
|
|
991
|
+
if (!attribute || !ts.isJsxAttribute(attribute) || !attribute.initializer)
|
|
992
|
+
return [];
|
|
993
|
+
if (ts.isStringLiteral(attribute.initializer))
|
|
994
|
+
return [attribute.initializer.text];
|
|
995
|
+
if (!ts.isJsxExpression(attribute.initializer)) return [];
|
|
996
|
+
const values = [];
|
|
997
|
+
const collect = (node) => {
|
|
998
|
+
if (ts.isStringLiteral(node)) values.push(node.text);
|
|
999
|
+
else ts.forEachChild(node, collect);
|
|
1000
|
+
};
|
|
1001
|
+
if (attribute.initializer.expression)
|
|
1002
|
+
collect(attribute.initializer.expression);
|
|
1003
|
+
return [...new Set(values)];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Reprova aliases de migração da API semântica e `tone` em novos dicionários. */
|
|
1007
|
+
export function checkUiSemantics(file, text) {
|
|
1008
|
+
if (!/\.[cm]?[jt]sx?$/.test(file)) return [];
|
|
1009
|
+
const sourceFile = ts.createSourceFile(
|
|
1010
|
+
file,
|
|
1011
|
+
text,
|
|
1012
|
+
ts.ScriptTarget.Latest,
|
|
1013
|
+
true,
|
|
1014
|
+
file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
1015
|
+
);
|
|
1016
|
+
const imports = uiImports(sourceFile);
|
|
1017
|
+
const schemaAliases = new Set();
|
|
1018
|
+
const variableInitializers = new Map();
|
|
1019
|
+
for (const statement of sourceFile.statements) {
|
|
1020
|
+
if (ts.isVariableStatement(statement)) {
|
|
1021
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1022
|
+
if (ts.isIdentifier(declaration.name) && declaration.initializer)
|
|
1023
|
+
variableInitializers.set(
|
|
1024
|
+
declaration.name.text,
|
|
1025
|
+
declaration.initializer,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (
|
|
1030
|
+
!ts.isImportDeclaration(statement) ||
|
|
1031
|
+
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
1032
|
+
statement.moduleSpecifier.text !== "@softize/opus/schema/zod"
|
|
1033
|
+
)
|
|
1034
|
+
continue;
|
|
1035
|
+
const bindings = statement.importClause?.namedBindings;
|
|
1036
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
1037
|
+
for (const element of bindings.elements) {
|
|
1038
|
+
if ((element.propertyName?.text ?? element.name.text) === "t")
|
|
1039
|
+
schemaAliases.add(element.name.text);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const findings = [];
|
|
1044
|
+
const lineOf = (node) =>
|
|
1045
|
+
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line +
|
|
1046
|
+
1;
|
|
1047
|
+
const add = (node, action, message) =>
|
|
1048
|
+
findings.push({
|
|
1049
|
+
rule: "ui-semantic-api",
|
|
1050
|
+
level: "error",
|
|
1051
|
+
action,
|
|
1052
|
+
line: lineOf(node),
|
|
1053
|
+
file,
|
|
1054
|
+
message,
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
const visit = (node) => {
|
|
1058
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
1059
|
+
const local = jsxLocalName(node);
|
|
1060
|
+
const component = local === null ? null : imports.get(local);
|
|
1061
|
+
if (component !== null && UI_LEGACY_VARIANTS.has(component)) {
|
|
1062
|
+
const opening = jsxOpening(node);
|
|
1063
|
+
if (jsxAttribute(opening, "tone") !== undefined) {
|
|
1064
|
+
add(
|
|
1065
|
+
opening,
|
|
1066
|
+
`${component}.tone`,
|
|
1067
|
+
`${component} usa \`context\` para significado semântico; \`tone\` é apenas um alias temporário.`,
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
const legacyVariants = jsxStringAttributeValues(
|
|
1071
|
+
opening,
|
|
1072
|
+
"variant",
|
|
1073
|
+
).filter((variant) => UI_LEGACY_VARIANTS.get(component).has(variant));
|
|
1074
|
+
for (const variant of legacyVariants) {
|
|
1075
|
+
add(
|
|
1076
|
+
opening,
|
|
1077
|
+
`${component}.variant=${variant}`,
|
|
1078
|
+
"Separe o significado em `context` e use `variant` somente para tratamento visual.",
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (
|
|
1085
|
+
ts.isCallExpression(node) &&
|
|
1086
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
1087
|
+
schemaAliases.has(node.expression.expression.getText(sourceFile)) &&
|
|
1088
|
+
node.expression.name.text === "dict"
|
|
1089
|
+
) {
|
|
1090
|
+
const argument = node.arguments[0];
|
|
1091
|
+
const entries =
|
|
1092
|
+
argument && ts.isIdentifier(argument)
|
|
1093
|
+
? variableInitializers.get(argument.text)
|
|
1094
|
+
: argument;
|
|
1095
|
+
if (entries) {
|
|
1096
|
+
const inspectEntry = (candidate) => {
|
|
1097
|
+
if (
|
|
1098
|
+
ts.isPropertyAssignment(candidate) &&
|
|
1099
|
+
candidate.name.getText(sourceFile).replaceAll(/["']/g, "") ===
|
|
1100
|
+
"tone"
|
|
1101
|
+
) {
|
|
1102
|
+
add(
|
|
1103
|
+
candidate,
|
|
1104
|
+
"t.dict.tone",
|
|
1105
|
+
"Entradas de status e estágio usam `context`; `tone` é apenas um alias temporário.",
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
ts.forEachChild(candidate, inspectEntry);
|
|
1109
|
+
};
|
|
1110
|
+
inspectEntry(entries);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
ts.forEachChild(node, visit);
|
|
1114
|
+
};
|
|
1115
|
+
visit(sourceFile);
|
|
1116
|
+
return findings;
|
|
402
1117
|
}
|
|
403
1118
|
|
|
404
1119
|
export function checkUiMigrations(file, text) {
|
|
405
|
-
const findings = []
|
|
406
|
-
const seen = new Set()
|
|
1120
|
+
const findings = [];
|
|
1121
|
+
const seen = new Set();
|
|
407
1122
|
const matchesToken = (line, token) => {
|
|
408
|
-
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g,
|
|
409
|
-
return new RegExp(`(^|[^A-Za-z0-9_-])${escaped}(?=$|[^A-Za-z0-9_-])`).test(
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
1123
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1124
|
+
return new RegExp(`(^|[^A-Za-z0-9_-])${escaped}(?=$|[^A-Za-z0-9_-])`).test(
|
|
1125
|
+
line,
|
|
1126
|
+
);
|
|
1127
|
+
};
|
|
1128
|
+
const extension = path.extname(file);
|
|
1129
|
+
const source = stripComments(text);
|
|
413
1130
|
const addFinding = (token, replacement, line) => {
|
|
414
|
-
seen.add(token)
|
|
1131
|
+
seen.add(token);
|
|
415
1132
|
findings.push({
|
|
416
|
-
rule:
|
|
417
|
-
level:
|
|
1133
|
+
rule: "removed-ui-token",
|
|
1134
|
+
level: "error",
|
|
418
1135
|
action: token,
|
|
419
1136
|
line,
|
|
420
1137
|
file,
|
|
421
1138
|
message: `token removido — ${replacement}.`,
|
|
422
|
-
})
|
|
423
|
-
}
|
|
1139
|
+
});
|
|
1140
|
+
};
|
|
424
1141
|
for (const [index, raw] of source.split(/\r?\n/).entries()) {
|
|
425
|
-
const line = raw.trim()
|
|
426
|
-
if (line ===
|
|
1142
|
+
const line = raw.trim();
|
|
1143
|
+
if (line === "") continue;
|
|
427
1144
|
for (const [token, replacement] of REMOVED_UI_TOKENS) {
|
|
428
|
-
if (seen.has(token)) continue
|
|
429
|
-
const inStylesheet = extension ===
|
|
430
|
-
const inSvelteDirective =
|
|
431
|
-
|
|
1145
|
+
if (seen.has(token)) continue;
|
|
1146
|
+
const inStylesheet = extension === ".css" && matchesToken(line, token);
|
|
1147
|
+
const inSvelteDirective =
|
|
1148
|
+
extension === ".svelte" &&
|
|
1149
|
+
new RegExp(
|
|
1150
|
+
`\\bclass:${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=[=\\s]|$)`,
|
|
1151
|
+
).test(line);
|
|
1152
|
+
if (inStylesheet || inSvelteDirective)
|
|
1153
|
+
addFinding(token, replacement, index + 1);
|
|
432
1154
|
}
|
|
433
1155
|
}
|
|
434
|
-
for (const { value, startLine, startIndex, endIndex } of quotedValues(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
const
|
|
444
|
-
const
|
|
445
|
-
|
|
1156
|
+
for (const { value, startLine, startIndex, endIndex } of quotedValues(
|
|
1157
|
+
source,
|
|
1158
|
+
)) {
|
|
1159
|
+
const beforeValue = source.slice(Math.max(0, startIndex - 500), startIndex);
|
|
1160
|
+
const classAttribute = /\bclass(?:Name)?\s*=\s*\{?\s*$/.test(beforeValue);
|
|
1161
|
+
const classExpression = /\bclass(?:Name)?\s*=\s*\{[^{}]*$/.test(
|
|
1162
|
+
beforeValue,
|
|
1163
|
+
);
|
|
1164
|
+
const utilityHelper = isInsideUtilityHelper(beforeValue);
|
|
1165
|
+
const propertyKey = /^\s*:/.test(source.slice(endIndex + 1, endIndex + 12));
|
|
1166
|
+
const classBinding =
|
|
1167
|
+
/\b(?:class(?:Name|Names)?|classes|\w+(?:Class|Classes))\s*=\s*[^;\n]*$/.test(
|
|
1168
|
+
beforeValue,
|
|
1169
|
+
);
|
|
1170
|
+
const objectProperty =
|
|
1171
|
+
/\b(?!aria(?:Label|Description)\b|label\b|title\b|description\b|text\b|message\b|note\b|content\b)[A-Za-z_$][\w$-]*\s*:\s*$/.test(
|
|
1172
|
+
beforeValue,
|
|
1173
|
+
);
|
|
1174
|
+
const classListCall =
|
|
1175
|
+
/\bclassList\.(?:add|remove|toggle|replace|contains)\s*\([^)]*$/.test(
|
|
1176
|
+
beforeValue,
|
|
1177
|
+
);
|
|
1178
|
+
const stylePropertyCall = /\b(?:style\.)?setProperty\s*\([^)]*$/.test(
|
|
1179
|
+
beforeValue,
|
|
1180
|
+
);
|
|
1181
|
+
const utilityContext =
|
|
1182
|
+
classAttribute ||
|
|
1183
|
+
classExpression ||
|
|
1184
|
+
utilityHelper ||
|
|
1185
|
+
classBinding ||
|
|
1186
|
+
objectProperty ||
|
|
1187
|
+
classListCall;
|
|
1188
|
+
const utilities = value.trim().split(/\s+/).filter(Boolean);
|
|
446
1189
|
for (const [background, foreground] of UI_SURFACE_PAIRS) {
|
|
447
|
-
const pairKey = `surface:${background}
|
|
448
|
-
const hasUtility = (expected) =>
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
1190
|
+
const pairKey = `surface:${background}`;
|
|
1191
|
+
const hasUtility = (expected) =>
|
|
1192
|
+
utilities.some((utility) => {
|
|
1193
|
+
const normalized = utility.replace(/^!/, "").replace(/!$/, "");
|
|
1194
|
+
return (
|
|
1195
|
+
normalized === expected || normalized.startsWith(`${expected}/`)
|
|
1196
|
+
);
|
|
1197
|
+
});
|
|
1198
|
+
const hasBackground = hasUtility(background);
|
|
1199
|
+
const hasForeground = hasUtility(foreground);
|
|
1200
|
+
if (
|
|
1201
|
+
!seen.has(pairKey) &&
|
|
1202
|
+
utilityContext &&
|
|
1203
|
+
hasBackground &&
|
|
1204
|
+
!hasForeground
|
|
1205
|
+
) {
|
|
1206
|
+
seen.add(pairKey);
|
|
456
1207
|
findings.push({
|
|
457
|
-
rule:
|
|
458
|
-
level:
|
|
1208
|
+
rule: "unpaired-ui-surface",
|
|
1209
|
+
level: "error",
|
|
459
1210
|
action: background,
|
|
460
1211
|
line: startLine,
|
|
461
1212
|
file,
|
|
462
1213
|
message: `superfície sem o foreground correspondente — use \`${background} ${foreground}\` no mesmo fragmento de classes.`,
|
|
463
|
-
})
|
|
1214
|
+
});
|
|
464
1215
|
}
|
|
465
1216
|
}
|
|
466
1217
|
for (const [token, replacement] of REMOVED_UI_TOKENS) {
|
|
467
|
-
const usedAsUtility =
|
|
468
|
-
|
|
469
|
-
(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
1218
|
+
const usedAsUtility =
|
|
1219
|
+
matchesToken(value, token) &&
|
|
1220
|
+
(classAttribute ||
|
|
1221
|
+
classExpression ||
|
|
1222
|
+
utilityHelper ||
|
|
1223
|
+
classBinding ||
|
|
1224
|
+
objectProperty ||
|
|
1225
|
+
(classListCall && !token.startsWith("--")) ||
|
|
1226
|
+
(stylePropertyCall && token.startsWith("--")) ||
|
|
1227
|
+
(propertyKey && token.startsWith("--")) ||
|
|
1228
|
+
looksLikeUtilityValue(value, token, matchesToken));
|
|
1229
|
+
if (seen.has(token) || !usedAsUtility) continue;
|
|
1230
|
+
const tokenIndex = value.indexOf(token);
|
|
1231
|
+
const tokenLine =
|
|
1232
|
+
startLine + value.slice(0, tokenIndex).split("\n").length - 1;
|
|
1233
|
+
addFinding(token, replacement, tokenLine);
|
|
478
1234
|
}
|
|
479
1235
|
}
|
|
480
|
-
|
|
1236
|
+
findings.push(...checkUiStructure(file, text));
|
|
1237
|
+
findings.push(...checkUiSemantics(file, text));
|
|
1238
|
+
return findings.sort((left, right) => left.line - right.line);
|
|
481
1239
|
}
|
|
482
1240
|
|
|
483
|
-
const SKIP_DIRS = new Set([
|
|
484
|
-
|
|
485
|
-
|
|
1241
|
+
const SKIP_DIRS = new Set([
|
|
1242
|
+
"node_modules",
|
|
1243
|
+
"dist",
|
|
1244
|
+
"build",
|
|
1245
|
+
".git",
|
|
1246
|
+
".next",
|
|
1247
|
+
"coverage",
|
|
1248
|
+
]);
|
|
1249
|
+
const UI_SKIP_DIRS = new Set([
|
|
1250
|
+
...SKIP_DIRS,
|
|
1251
|
+
"__tests__",
|
|
1252
|
+
"__fixtures__",
|
|
1253
|
+
"fixtures",
|
|
1254
|
+
"test",
|
|
1255
|
+
"tests",
|
|
1256
|
+
]);
|
|
1257
|
+
const UI_EXTENSIONS = new Set([
|
|
1258
|
+
".ts",
|
|
1259
|
+
".tsx",
|
|
1260
|
+
".mts",
|
|
1261
|
+
".cts",
|
|
1262
|
+
".js",
|
|
1263
|
+
".jsx",
|
|
1264
|
+
".mjs",
|
|
1265
|
+
".cjs",
|
|
1266
|
+
".css",
|
|
1267
|
+
".html",
|
|
1268
|
+
".svelte",
|
|
1269
|
+
".vue",
|
|
1270
|
+
]);
|
|
486
1271
|
|
|
487
1272
|
export async function walkTsFiles(dir, acc = []) {
|
|
488
|
-
const root = canonicalProjectDirectory(dir)
|
|
489
|
-
const walk = (local =
|
|
490
|
-
const entries = readProjectDirectory(root, local).entries
|
|
1273
|
+
const root = canonicalProjectDirectory(dir);
|
|
1274
|
+
const walk = (local = ".") => {
|
|
1275
|
+
const entries = readProjectDirectory(root, local).entries;
|
|
491
1276
|
for (const e of entries) {
|
|
492
|
-
if (e.name.startsWith(
|
|
493
|
-
if (SKIP_DIRS.has(e.name)) continue
|
|
494
|
-
const relative = path.join(local, e.name)
|
|
495
|
-
const full = path.join(root, relative)
|
|
1277
|
+
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
1278
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
1279
|
+
const relative = path.join(local, e.name);
|
|
1280
|
+
const full = path.join(root, relative);
|
|
496
1281
|
if (e.isSymbolicLink()) {
|
|
497
1282
|
// Dirent não revela se o link aponta para arquivo ou diretório. Ignorá-lo pela
|
|
498
1283
|
// extensão permitiria ocultar uma árvore de sources sob um nome de asset.
|
|
499
|
-
safeProjectPath(root, relative, { mustExist: true })
|
|
1284
|
+
safeProjectPath(root, relative, { mustExist: true });
|
|
500
1285
|
}
|
|
501
1286
|
if (e.isDirectory()) {
|
|
502
|
-
safeProjectPath(root, relative, { mustExist: true })
|
|
503
|
-
walk(relative)
|
|
1287
|
+
safeProjectPath(root, relative, { mustExist: true });
|
|
1288
|
+
walk(relative);
|
|
504
1289
|
} else if (
|
|
505
|
-
(e.name.endsWith(
|
|
506
|
-
!e.name.endsWith(
|
|
507
|
-
!e.name.endsWith(
|
|
1290
|
+
(e.name.endsWith(".ts") || e.name.endsWith(".tsx")) &&
|
|
1291
|
+
!e.name.endsWith(".d.ts") &&
|
|
1292
|
+
!e.name.endsWith(".test.ts")
|
|
508
1293
|
) {
|
|
509
|
-
safeProjectPath(root, relative, { mustExist: true })
|
|
510
|
-
acc.push(full)
|
|
1294
|
+
safeProjectPath(root, relative, { mustExist: true });
|
|
1295
|
+
acc.push(full);
|
|
511
1296
|
}
|
|
512
1297
|
}
|
|
513
|
-
}
|
|
514
|
-
walk()
|
|
515
|
-
return acc
|
|
1298
|
+
};
|
|
1299
|
+
walk();
|
|
1300
|
+
return acc;
|
|
516
1301
|
}
|
|
517
1302
|
|
|
518
1303
|
async function walkUiFiles(dir, acc = []) {
|
|
519
|
-
const root = canonicalProjectDirectory(dir)
|
|
520
|
-
const walk = (local =
|
|
521
|
-
const entries = readProjectDirectory(root, local).entries
|
|
1304
|
+
const root = canonicalProjectDirectory(dir);
|
|
1305
|
+
const walk = (local = ".") => {
|
|
1306
|
+
const entries = readProjectDirectory(root, local).entries;
|
|
522
1307
|
for (const e of entries) {
|
|
523
|
-
if (e.name.startsWith(
|
|
524
|
-
if (UI_SKIP_DIRS.has(e.name)) continue
|
|
525
|
-
const relative = path.join(local, e.name)
|
|
526
|
-
const full = path.join(root, relative)
|
|
1308
|
+
if (e.name.startsWith(".")) continue;
|
|
1309
|
+
if (UI_SKIP_DIRS.has(e.name)) continue;
|
|
1310
|
+
const relative = path.join(local, e.name);
|
|
1311
|
+
const full = path.join(root, relative);
|
|
527
1312
|
if (e.isSymbolicLink()) {
|
|
528
1313
|
// Um link com nome de asset ainda pode esconder um diretório com UI analisável.
|
|
529
|
-
safeProjectPath(root, relative, { mustExist: true })
|
|
1314
|
+
safeProjectPath(root, relative, { mustExist: true });
|
|
530
1315
|
}
|
|
531
1316
|
if (e.isDirectory()) {
|
|
532
|
-
safeProjectPath(root, relative, { mustExist: true })
|
|
533
|
-
walk(relative)
|
|
534
|
-
} else if (
|
|
535
|
-
|
|
536
|
-
|
|
1317
|
+
safeProjectPath(root, relative, { mustExist: true });
|
|
1318
|
+
walk(relative);
|
|
1319
|
+
} else if (
|
|
1320
|
+
UI_EXTENSIONS.has(path.extname(e.name)) &&
|
|
1321
|
+
!/\.(?:test|spec)\.[^.]+$/.test(e.name)
|
|
1322
|
+
) {
|
|
1323
|
+
safeProjectPath(root, relative, { mustExist: true });
|
|
1324
|
+
acc.push(full);
|
|
537
1325
|
}
|
|
538
1326
|
}
|
|
539
|
-
}
|
|
540
|
-
walk()
|
|
541
|
-
return acc
|
|
1327
|
+
};
|
|
1328
|
+
walk();
|
|
1329
|
+
return acc;
|
|
542
1330
|
}
|
|
543
1331
|
|
|
544
1332
|
/** Escaneia um diretório → { files, actions, contracts, findings, hasMarker }.
|
|
545
1333
|
* `hasMarker` = o diretório é um projeto opus (tem opus.json na raiz). */
|
|
546
1334
|
export async function scanDir(rootDir) {
|
|
547
|
-
rootDir = canonicalProjectDirectory(rootDir)
|
|
548
|
-
const files = await walkTsFiles(rootDir)
|
|
549
|
-
const sources = []
|
|
1335
|
+
rootDir = canonicalProjectDirectory(rootDir);
|
|
1336
|
+
const files = await walkTsFiles(rootDir);
|
|
1337
|
+
const sources = [];
|
|
550
1338
|
for (const file of files) {
|
|
551
|
-
const text = readProjectFile(rootDir, path.relative(rootDir, file)).content
|
|
552
|
-
if (!ACTION_MARKERS.some((m) => text.includes(m))) continue
|
|
553
|
-
sources.push({ file: path.relative(rootDir, file), text })
|
|
1339
|
+
const text = readProjectFile(rootDir, path.relative(rootDir, file)).content;
|
|
1340
|
+
if (!ACTION_MARKERS.some((m) => text.includes(m))) continue;
|
|
1341
|
+
sources.push({ file: path.relative(rootDir, file), text });
|
|
554
1342
|
}
|
|
555
|
-
const checked = checkProject(sources)
|
|
556
|
-
const uiFindings = []
|
|
1343
|
+
const checked = checkProject(sources);
|
|
1344
|
+
const uiFindings = [];
|
|
557
1345
|
// UI pode morar em src/, app/, pages/ ou na raiz. O walk cobre o projeto inteiro e
|
|
558
1346
|
// exclui dependências, builds, testes e fixtures para não interpretar exemplos como uso.
|
|
559
1347
|
for (const file of await walkUiFiles(rootDir)) {
|
|
560
|
-
const text = readProjectFile(rootDir, path.relative(rootDir, file)).content
|
|
561
|
-
uiFindings.push(...checkUiMigrations(path.relative(rootDir, file), text))
|
|
1348
|
+
const text = readProjectFile(rootDir, path.relative(rootDir, file)).content;
|
|
1349
|
+
uiFindings.push(...checkUiMigrations(path.relative(rootDir, file), text));
|
|
562
1350
|
}
|
|
563
|
-
const hasMarker = safeProjectPath(rootDir,
|
|
1351
|
+
const hasMarker = safeProjectPath(rootDir, "opus.json").exists;
|
|
564
1352
|
return {
|
|
565
1353
|
files: files.length,
|
|
566
1354
|
actions: checked.actions,
|
|
567
1355
|
contracts: checked.contracts,
|
|
568
1356
|
findings: [...checked.findings, ...uiFindings],
|
|
569
1357
|
hasMarker,
|
|
570
|
-
}
|
|
1358
|
+
};
|
|
571
1359
|
}
|
|
572
1360
|
|
|
573
1361
|
/**
|
|
@@ -579,11 +1367,11 @@ export async function scanDir(rootDir) {
|
|
|
579
1367
|
*/
|
|
580
1368
|
export function emptyScanVerdict({ hasMarker, contracts }) {
|
|
581
1369
|
if (hasMarker) {
|
|
582
|
-
return { ok: true, message:
|
|
1370
|
+
return { ok: true, message: "projeto opus sem actions — nada a checar." };
|
|
583
1371
|
}
|
|
584
1372
|
const hint =
|
|
585
1373
|
contracts > 0
|
|
586
1374
|
? `${contracts} contrato(s) (defineContract) sem bindAction no diretório — o check roda na raiz do app, onde os binds moram.`
|
|
587
|
-
: `procuro defineAction, defineContract e bindAction — confira o diretório
|
|
588
|
-
return { ok: false, message: hint }
|
|
1375
|
+
: `procuro defineAction, defineContract e bindAction — confira o diretório.`;
|
|
1376
|
+
return { ok: false, message: hint };
|
|
589
1377
|
}
|