@softize/opus 12.9.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/bin/lib/check.mjs +1103 -310
  3. package/bin/lib/copy.mjs +74 -6
  4. package/docs/adr/0003-dictionary-presentation-is-declared.md +3 -0
  5. package/docs/adr/0004-page-content-state-is-composed.md +65 -0
  6. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +97 -0
  7. package/docs/adr/0006-semantic-context-precedes-visual-variant.md +182 -0
  8. package/docs/code-style.md +6 -2
  9. package/package.json +1 -1
  10. package/registry/instructions/opus.md +5 -0
  11. package/registry/skills/build-opus-ui/SKILL.md +27 -16
  12. package/registry/skills/build-opus-ui/references/evaluations.md +16 -5
  13. package/registry/skills/build-opus-ui/references/ui-patterns.md +38 -15
  14. package/registry/skills/model-opus-dictionary/SKILL.md +4 -2
  15. package/registry/skills/model-opus-dictionary/references/evaluations.md +4 -3
  16. package/registry/templates/app/src/App.tsx +1 -1
  17. package/src/core/dictionary.ts +52 -14
  18. package/src/core/index.ts +10 -0
  19. package/src/core/ui-context.ts +29 -0
  20. package/src/schema/drivers/zod.ts +34 -19
  21. package/src/ui/components/patterns/action-form-card.tsx +18 -12
  22. package/src/ui/components/patterns/confirm.tsx +26 -3
  23. package/src/ui/components/patterns/content-header.tsx +335 -61
  24. package/src/ui/components/patterns/data-state.tsx +23 -10
  25. package/src/ui/components/patterns/form.tsx +1 -1
  26. package/src/ui/components/patterns/list.tsx +1096 -777
  27. package/src/ui/components/patterns/page-state.tsx +115 -0
  28. package/src/ui/components/patterns/page.tsx +231 -41
  29. package/src/ui/components/patterns/sidebar.tsx +354 -80
  30. package/src/ui/components/patterns/trigger.tsx +13 -9
  31. package/src/ui/components/patterns/view.tsx +7 -11
  32. package/src/ui/components/primitives/alert-dialog.tsx +7 -5
  33. package/src/ui/components/primitives/alert.tsx +298 -80
  34. package/src/ui/components/primitives/ask.tsx +2 -1
  35. package/src/ui/components/primitives/badge.tsx +91 -30
  36. package/src/ui/components/primitives/button.tsx +99 -60
  37. package/src/ui/components/primitives/calendar.tsx +39 -39
  38. package/src/ui/components/primitives/card.tsx +96 -23
  39. package/src/ui/components/primitives/detail.tsx +2 -2
  40. package/src/ui/components/primitives/dictionary-value.tsx +9 -14
  41. package/src/ui/components/primitives/dot.tsx +74 -21
  42. package/src/ui/components/primitives/drawer.tsx +33 -20
  43. package/src/ui/components/primitives/field.tsx +4 -4
  44. package/src/ui/components/primitives/item.tsx +137 -81
  45. package/src/ui/components/primitives/menu.tsx +11 -3
  46. package/src/ui/components/primitives/metric-card.tsx +133 -0
  47. package/src/ui/components/primitives/table.tsx +2 -2
  48. package/src/ui/components/primitives/tooltip.tsx +1 -1
  49. package/src/ui/docs/DocBrowser.tsx +3 -3
  50. package/src/ui/docs/changelog.tsx +1 -1
  51. package/src/ui/docs/content/action-form-card.md +1 -1
  52. package/src/ui/docs/content/alert-dialog.md +8 -8
  53. package/src/ui/docs/content/alert.md +54 -23
  54. package/src/ui/docs/content/badge.md +18 -19
  55. package/src/ui/docs/content/button.md +12 -9
  56. package/src/ui/docs/content/card.md +5 -5
  57. package/src/ui/docs/content/content.md +44 -0
  58. package/src/ui/docs/content/customization.md +2 -2
  59. package/src/ui/docs/content/detail.md +5 -2
  60. package/src/ui/docs/content/dialog.md +2 -2
  61. package/src/ui/docs/content/dictionary-value.md +11 -10
  62. package/src/ui/docs/content/dot.md +7 -7
  63. package/src/ui/docs/content/drawer.md +6 -3
  64. package/src/ui/docs/content/field.md +1 -1
  65. package/src/ui/docs/content/input-group.md +3 -2
  66. package/src/ui/docs/content/item.md +47 -21
  67. package/src/ui/docs/content/menu.md +5 -4
  68. package/src/ui/docs/content/metric-card.md +41 -0
  69. package/src/ui/docs/content/page-state.md +45 -0
  70. package/src/ui/docs/content/page.md +48 -10
  71. package/src/ui/docs/content/semantic-context.md +63 -0
  72. package/src/ui/docs/content/sidebar.md +4 -4
  73. package/src/ui/docs/content/skeleton.md +2 -2
  74. package/src/ui/docs/content/table.md +3 -3
  75. package/src/ui/docs/content/tokens.md +28 -0
  76. package/src/ui/docs/content/tooltip.md +15 -1
  77. package/src/ui/docs/doc-client.tsx +2 -2
  78. package/src/ui/docs/registry.tsx +596 -228
  79. package/src/ui/lib/semantic-context.ts +30 -0
  80. package/src/ui/meta.ts +292 -270
  81. package/src/ui/react.tsx +378 -111
  82. 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 'node:path'
25
- import ts from 'typescript'
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 '@softize/base/project-path'
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, kind: 0,
38
- label: 1, title: 1, summary: 1, description: 1, icon: 1, color: 1, messages: 1, tags: 1, examples: 1, errors: 1,
39
- input: 2, output: 2,
40
- public: 3, requires: 3, authorize: 3, loads: 3,
41
- handler: 4, mockHandler: 4,
42
- audit: 5, confirm: 5, invalidates: 5, rateLimit: 5, idempotency: 5,
43
- ai: 6, automatable: 6, internal: 6,
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 = '[a-z][a-z0-9]*(-[a-z0-9]+)*'
49
- const ACTION_NAME_RE = new RegExp(`^${SEG}(\\.${SEG})+$`)
50
- const KINDS = new Set(['simple', 'form', 'list', 'view'])
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
- ['ring-edge', 'use `border border-border` para a aresta da superfície'],
57
- ['border-edge', 'use `border-border`'],
58
- ['shadow-card', 'use `shadow-sm`'],
59
- ['shadow-popover', 'use `shadow-md`'],
60
- ['shadow-dialog', 'use `shadow-lg`'],
61
- ['rounded-card', 'use `rounded-xl`'],
62
- ['rounded-popover', 'use `rounded-md`'],
63
- ['rounded-dialog', 'use `rounded-xl`'],
64
- ['--edge-value', 'remova o override; a aresta agora usa `--border`'],
65
- ['--color-edge', 'remova o token; a aresta agora usa `--border`'],
66
- ['--elevation-card', 'use/ajuste `--shadow-sm`'],
67
- ['--elevation-popover', 'use/ajuste `--shadow-md`'],
68
- ['--elevation-dialog', 'use/ajuste `--shadow-lg`'],
69
- ['--radius-card', 'use/ajuste `--radius-xl`'],
70
- ['--radius-popover', 'use/ajuste `--radius-md`'],
71
- ['--radius-dialog', 'use/ajuste `--radius-xl`'],
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
- ['bg-card', 'text-card-foreground'],
79
- ['bg-popover', 'text-popover-foreground'],
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 = ['defineAction', 'defineContract', 'bindAction']
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(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
92
- const actions = []
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) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
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((m) => m.kind === ts.SyntaxKind.ExportKeyword)
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) => ts.isPropertyAssignment(pr) && pr.name && pr.name.getText(sf) === key,
115
- )
116
- if (!p || !ts.isStringLiteral(p.initializer)) return null
117
- return p.initializer.text
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((p) => ts.isPropertyAssignment(p) || ts.isMethodDeclaration(p) || ts.isShorthandPropertyAssignment(p))
123
- .map((p) => (p.name ? p.name.getText(sf) : ''))
124
- .filter(Boolean)
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 === 'defineAction' || callee === 'defineContract') &&
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, 'name'),
140
- kind: strProp(obj, 'kind'),
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 === 'bindAction' &&
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: 'bindAction',
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 = a.name ?? (a.form === 'bindAction' ? `bindAction(${a.contractRef})` : '(sem name)')
176
- const at = (rule, message) => out.push({ rule, level: 'error', action: label, line: a.line, message })
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 === 'bindAction') {
417
+ if (a.form === "bindAction") {
179
418
  // O binding não repete name/kind/ordem — quem carrega isso é o contrato.
180
- if (!a.exported) at('export', 'bindAction precisa ser `export const` (senão não dá pra registrar)')
181
- return out
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('action-name', `${a.form} sem \`name\``)
185
- else if (!ACTION_NAME_RE.test(a.name)) at('action-name', `name "${a.name}" não é <resource>.<verb> (minúsculo, ponto)`)
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('kind', `${a.form} sem \`kind\``)
188
- else if (!KINDS.has(a.kind)) at('kind', `kind "${a.kind}" inválido (use simple|form|list|view)`)
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('field-order', `campo "${k}" fora de ordem (depois de "${lastKey}") — siga identidade→docs→input/output→authorize→handler→comportamento`)
198
- break
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) at('export', `${a.form} precisa ser \`export const\` (senão não dá pra registrar)`)
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 (a.form === 'defineAction' && a.keys.includes('requires') && !a.keys.includes('authorize')) {
464
+ if (
465
+ a.form === "defineAction" &&
466
+ a.keys.includes("requires") &&
467
+ !a.keys.includes("authorize")
468
+ ) {
210
469
  at(
211
- 'requires-sem-authorize',
212
- '`requires` é declarativo (o runtime não o executa) — sem `authorize`, a action fica ABERTA. Declare o authorize.',
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 === 'bindAction') {
243
- actions += 1
244
- binds.push({ item, file })
245
- } else if (item.form === 'defineContract') {
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(item.ident, contractsByIdent.has(item.ident) ? 'ambiguous' : item)
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 === 'ambiguous') continue
260
- const protectedSomewhere = contract.keys.includes('authorize') || item.keys.includes('authorize')
261
- if (contract.keys.includes('requires') && !protectedSomewhere) {
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: 'requires-sem-authorize',
264
- level: 'error',
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
- '`requires` do contrato é declarativo (o runtime não o executa) — sem `authorize` no contrato ou no binding, a action fica ABERTA.',
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 = 'code'
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 === 'line-comment') {
287
- if (char === '\n') { state = 'code'; output += char } else output += ' '
288
- continue
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 === 'block-comment') {
291
- if (char === '*' && next === '/') { output += ' '; index += 1; state = 'code' }
292
- else output += char === '\n' ? '\n' : ' '
293
- continue
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 === 'html-comment') {
296
- if (char === '-' && text.slice(index, index + 3) === '-->') {
297
- output += ' '; index += 2; state = 'code'
298
- } else output += char === '\n' ? '\n' : ' '
299
- continue
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 === 'code' && text.slice(index, index + 4) === '<!--') {
302
- output += ' '; index += 3; state = 'html-comment'; continue
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 === 'code' && char === '/' && next === '/') {
305
- output += ' '; index += 1; state = 'line-comment'; continue
578
+ if (state === "code" && char === "/" && next === "/") {
579
+ output += " ";
580
+ index += 1;
581
+ state = "line-comment";
582
+ continue;
306
583
  }
307
- if (state === 'code' && char === '/' && next === '*') {
308
- output += ' '; index += 1; state = 'block-comment'; continue
584
+ if (state === "code" && char === "/" && next === "*") {
585
+ output += " ";
586
+ index += 1;
587
+ state = "block-comment";
588
+ continue;
309
589
  }
310
- if (state === 'code' && (char === "'" || char === '"' || char === '`')) {
311
- state = char; output += char; escaped = false; continue
590
+ if (state === "code" && (char === "'" || char === '"' || char === "`")) {
591
+ state = char;
592
+ output += char;
593
+ escaped = false;
594
+ continue;
312
595
  }
313
- if (state !== 'code') {
314
- output += char
315
- if (escaped) escaped = false
316
- else if (char === '\\') escaped = true
317
- else if (char === state) state = 'code'
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 === '') return true
331
- const previous = before.at(-1)
332
- return /[=(:,![{;?&|+*%^~<>-]/.test(previous) ||
333
- /\b(?:return|case|throw|typeof|instanceof|in|of|yield|await)\s*$/.test(before)
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 === '\n') { line += 1; continue }
338
- if (quote === '/' && startsRegex(index)) {
339
- let escaped = false
340
- let inCharacterClass = false
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 === '\n') line += 1
344
- if (escaped) { escaped = false; continue }
345
- if (char === '\\') { escaped = true; continue }
346
- if (char === '[') { inCharacterClass = true; continue }
347
- if (char === ']') { inCharacterClass = false; continue }
348
- if (char === '/' && !inCharacterClass) break
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] ?? '')) index += 1
351
- continue
652
+ while (/[a-z]/i.test(text[index + 1] ?? "")) index += 1;
653
+ continue;
352
654
  }
353
- if (quote !== "'" && quote !== '"' && quote !== '`') continue
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 === '\n') line += 1
361
- if (escaped) { value += char; escaped = false; continue }
362
- if (char === '\\') { value += char; escaped = true; continue }
363
- if (char === quote) break
364
- value += char
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(['flex', 'grid', 'block', 'inline', 'hidden', 'border', 'shadow', 'ring'])
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 parts.length > 1 && parts.every((part) =>
377
- matchesToken(part, token) || SIMPLE_TAILWIND_CLASSES.has(part) || /[-:[\]/!@.%]/.test(part),
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 === '\\') escaped = true
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 === '`') { quote = char; continue }
396
- if (char === '(') depth += 1
397
- else if (char === ')') depth -= 1
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 false
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(line)
410
- }
411
- const extension = path.extname(file)
412
- const source = stripComments(text)
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: 'removed-ui-token',
417
- level: 'error',
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 === '') continue
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 === '.css' && matchesToken(line, token)
430
- const inSvelteDirective = extension === '.svelte' && new RegExp(`\\bclass:${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[=\\s]|$)`).test(line)
431
- if (inStylesheet || inSvelteDirective) addFinding(token, replacement, index + 1)
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(source)) {
435
- const beforeValue = source.slice(Math.max(0, startIndex - 500), startIndex)
436
- const classAttribute = /\bclass(?:Name)?\s*=\s*\{?\s*$/.test(beforeValue)
437
- const classExpression = /\bclass(?:Name)?\s*=\s*\{[^{}]*$/.test(beforeValue)
438
- const utilityHelper = isInsideUtilityHelper(beforeValue)
439
- const propertyKey = /^\s*:/.test(source.slice(endIndex + 1, endIndex + 12))
440
- const classBinding = /\b(?:class(?:Name|Names)?|classes|\w+(?:Class|Classes))\s*=\s*[^;\n]*$/.test(beforeValue)
441
- const objectProperty = /\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(beforeValue)
442
- const classListCall = /\bclassList\.(?:add|remove|toggle|replace|contains)\s*\([^)]*$/.test(beforeValue)
443
- const stylePropertyCall = /\b(?:style\.)?setProperty\s*\([^)]*$/.test(beforeValue)
444
- const utilityContext = classAttribute || classExpression || utilityHelper || classBinding || objectProperty || classListCall
445
- const utilities = value.trim().split(/\s+/).filter(Boolean)
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) => utilities.some((utility) => {
449
- const normalized = utility.replace(/^!/, '').replace(/!$/, '')
450
- return normalized === expected || normalized.startsWith(`${expected}/`)
451
- })
452
- const hasBackground = hasUtility(background)
453
- const hasForeground = hasUtility(foreground)
454
- if (!seen.has(pairKey) && utilityContext && hasBackground && !hasForeground) {
455
- seen.add(pairKey)
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: 'unpaired-ui-surface',
458
- level: 'error',
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 = matchesToken(value, token) && (
468
- classAttribute || classExpression || utilityHelper || classBinding || objectProperty ||
469
- (classListCall && !token.startsWith('--')) ||
470
- (stylePropertyCall && token.startsWith('--')) ||
471
- (propertyKey && token.startsWith('--')) ||
472
- looksLikeUtilityValue(value, token, matchesToken)
473
- )
474
- if (seen.has(token) || !usedAsUtility) continue
475
- const tokenIndex = value.indexOf(token)
476
- const tokenLine = startLine + value.slice(0, tokenIndex).split('\n').length - 1
477
- addFinding(token, replacement, tokenLine)
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
- return findings.sort((left, right) => left.line - right.line)
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(['node_modules', 'dist', 'build', '.git', '.next', 'coverage'])
484
- const UI_SKIP_DIRS = new Set([...SKIP_DIRS, '__tests__', '__fixtures__', 'fixtures', 'test', 'tests'])
485
- const UI_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html', '.svelte', '.vue'])
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('.') && e.name !== '.') continue
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('.ts') || e.name.endsWith('.tsx')) &&
506
- !e.name.endsWith('.d.ts') &&
507
- !e.name.endsWith('.test.ts')
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('.')) continue
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 (UI_EXTENSIONS.has(path.extname(e.name)) && !/\.(?:test|spec)\.[^.]+$/.test(e.name)) {
535
- safeProjectPath(root, relative, { mustExist: true })
536
- acc.push(full)
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, 'opus.json').exists
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: 'projeto opus sem actions — nada a checar.' }
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
  }