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