@softize/opus 11.1.1 → 12.0.1

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 (44) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +31 -1
  3. package/bin/cli.mjs +97 -27
  4. package/bin/lib/check.mjs +55 -43
  5. package/bin/lib/copy.mjs +2202 -0
  6. package/bin/lib/create.mjs +227 -39
  7. package/bin/lib/db-migrate-runner.mjs +9 -6
  8. package/bin/lib/db-project-path.mjs +20 -0
  9. package/bin/lib/db-scaffold-runner.mjs +23 -8
  10. package/bin/lib/db.mjs +6 -4
  11. package/bin/lib/gen.mjs +60 -29
  12. package/bin/lib/init.mjs +212 -56
  13. package/bin/lib/introspect.mjs +3 -2
  14. package/bin/lib/materialize.mjs +623 -97
  15. package/bin/lib/postinstall.mjs +6 -5
  16. package/bin/lib/validate-skill.mjs +502 -30
  17. package/docs/code-style.md +142 -7
  18. package/docs/consumer-upgrade-propagation.md +4 -3
  19. package/docs/releasing.md +28 -17
  20. package/package.json +6 -1
  21. package/registry/git/pre-push.d/00-opus-copy +14 -0
  22. package/registry/git/pre-push.d/opus +7 -21
  23. package/registry/git/run-opus-pre-push.mjs +141 -0
  24. package/registry/hooks/opus-check-on-stop.mjs +13 -31
  25. package/registry/instructions/opus.md +11 -5
  26. package/registry/skills/build-opus-ui/SKILL.md +5 -4
  27. package/registry/skills/create-opus-action/SKILL.md +4 -4
  28. package/registry/skills/implement-opus-change/SKILL.md +7 -5
  29. package/registry/skills/upgrade-opus/SKILL.md +8 -4
  30. package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
  31. package/registry/templates/app/package.json +4 -0
  32. package/registry/templates/app/pnpm-workspace.yaml +3 -2
  33. package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
  34. package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
  35. package/src/ui/components/patterns/split.tsx +66 -24
  36. package/src/ui/docs/content/cli.md +8 -7
  37. package/src/ui/docs/content/communication.md +79 -126
  38. package/src/ui/docs/content/getting-started.md +29 -16
  39. package/src/ui/docs/content/split.md +43 -3
  40. package/src/ui/react.tsx +1 -1
  41. package/registry/skills/write-product-communication/SKILL.md +0 -28
  42. package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
  43. package/registry/templates/app/_npmrc +0 -1
  44. package/registry/templates/monorepo/_npmrc +0 -1
@@ -0,0 +1,2202 @@
1
+ /**
2
+ * Inventário semântico de copy dos contratos Opus.
3
+ *
4
+ * O Opus conhece o papel dos textos declarados em defineAction/defineContract;
5
+ * a @softize/base continua dona da política e da validação editorial. Este módulo
6
+ * apenas projeta o protocolo JSON v2 sem executar código do consumidor.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto'
10
+ import { readFileSync } from 'node:fs'
11
+ import path from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import {
14
+ ensureProjectDirectory,
15
+ projectPathErrorMessage,
16
+ readProjectDirectory,
17
+ readProjectFile,
18
+ removeProjectFileIfUnchanged,
19
+ safeProjectPath,
20
+ writeProjectFileAtomically,
21
+ } from '@softize/base/project-path'
22
+ import ts from 'typescript'
23
+
24
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
25
+ const PACKAGE = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'))
26
+
27
+ export const COPY_INVENTORY_SCHEMA_VERSION = 2
28
+ export const DEFAULT_COPY_INVENTORY = '.base/copy-inventory.json'
29
+ const COPY_COVERAGE_PROTOCOL = 'source-manifest-v1'
30
+
31
+ const SOURCE_EXTENSIONS = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx'])
32
+ const IGNORED_DIRECTORIES = new Set([
33
+ '.base',
34
+ '.agents',
35
+ '.claude',
36
+ '.git',
37
+ '.github',
38
+ '.githooks',
39
+ '.maestro',
40
+ '.next',
41
+ '.opus',
42
+ 'build',
43
+ 'coverage',
44
+ 'dist',
45
+ 'fixtures',
46
+ 'node_modules',
47
+ 'test',
48
+ 'tests',
49
+ '__tests__',
50
+ ])
51
+ const IGNORED_FILE = /(?:\.d|\.spec|\.test|\.stories)\.[cm]?[jt]sx?$/u
52
+ // O Opus garante somente o namespace e o shape do identificador. O catálogo de regras,
53
+ // inclusive adições futuras, pertence à versão instalada da Base.
54
+ const COPY_RULE_ID = /^COPY\d{3}$/u
55
+ const CONTRACT_MODULES = new Set(['@softize/opus', '@softize/opus/core'])
56
+ const CONTRACT_FACTORIES = new Set(['defineAction', 'defineContract'])
57
+ const ACTION_BINDING_KEYS = new Set(['authorize', 'background', 'emits', 'handler', 'idempotency', 'loads'])
58
+
59
+ // Somente exports Opus cujo papel textual é estável por contrato do componente.
60
+ // Tags nativas, wrappers locais e bibliotecas de terceiros ficam deliberadamente fora.
61
+ const JSX_CHILD_ROLES = new Map([
62
+ ['ActionForm', 'message'],
63
+ ['ActionFormCard', 'message'],
64
+ ['ActionFormDialog', 'message'],
65
+ ['AlertDescription', 'message'],
66
+ ['AlertDialogAction', 'button'],
67
+ ['AlertDialogCancel', 'button'],
68
+ ['AlertDialogDescription', 'dialog-body'],
69
+ ['AlertDialogTitle', 'title'],
70
+ ['AlertTitle', 'title'],
71
+ ['Badge', 'badge'],
72
+ ['BreadcrumbLink', 'breadcrumb'],
73
+ ['BreadcrumbPage', 'breadcrumb'],
74
+ ['Button', 'button'],
75
+ ['CardDescription', 'description'],
76
+ ['CardTitle', 'title'],
77
+ ['CommandEmpty', 'empty-state'],
78
+ ['CommandItem', 'menu-item'],
79
+ ['DialogDescription', 'dialog-body'],
80
+ ['DialogTitle', 'title'],
81
+ ['DrawerDescription', 'dialog-body'],
82
+ ['DrawerTitle', 'title'],
83
+ ['EmptyDescription', 'empty-state'],
84
+ ['EmptyTitle', 'title'],
85
+ ['FieldDescription', 'helper-text'],
86
+ ['FieldError', 'error'],
87
+ ['FieldLabel', 'label'],
88
+ ['FieldLegend', 'heading'],
89
+ ['FieldTitle', 'heading'],
90
+ ['ItemDescription', 'description'],
91
+ ['ItemTitle', 'title'],
92
+ ['Label', 'label'],
93
+ ['MenuCheckboxItem', 'menu-item'],
94
+ ['MenuItem', 'menu-item'],
95
+ ['MenuLabel', 'label'],
96
+ ['MenuRadioItem', 'menu-item'],
97
+ ['PopoverDescription', 'description'],
98
+ ['PopoverTitle', 'title'],
99
+ ['TableCaption', 'description'],
100
+ ['TableHead', 'heading'],
101
+ ['TabsTrigger', 'tab'],
102
+ ])
103
+
104
+ const OPTIONAL_CHILD_COMPONENTS = new Set(['ActionForm', 'ActionFormCard', 'ActionFormDialog'])
105
+ const JSX_ACTION_CONSUMERS = new Set([
106
+ 'ActionForm', 'ActionFormCard', 'ActionFormDialog', 'ActionList', 'ActionListDialog', 'ActionTrigger', 'ActionView',
107
+ ])
108
+ const ACTION_HOOK_CONSUMERS = new Set([
109
+ 'useAction', 'useFormAction', 'useListAction', 'useLookupAction', 'useTriggerAction', 'useViewAction',
110
+ ])
111
+ const OPAQUE_SLOT_PROPS = new Map([
112
+ ['ActionForm', ['body', 'footer']],
113
+ ['ActionFormDialog', ['body', 'footer']],
114
+ ])
115
+
116
+ const JSX_PROP_ROLES = new Map([
117
+ ['ActionForm', new Map([['submitLabel', 'button'], ['cancelLabel', 'button']])],
118
+ ['ActionFormCard', new Map([['title', 'title'], ['description', 'description'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
119
+ ['ActionFormDialog', new Map([['title', 'title'], ['description', 'dialog-body'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
120
+ // `itemLabel` identifica o dado alvo (por exemplo, `customer.name`); não é
121
+ // microcopy estável e, por isso, não pertence ao inventário editorial.
122
+ ['ActionTrigger', new Map([['label', 'button']])],
123
+ ['Alert', new Map([['title', 'title'], ['description', 'message']])],
124
+ ['CommandInput', new Map([['placeholder', 'placeholder']])],
125
+ ['DataState', new Map([['emptyText', 'empty-state'], ['errorText', 'error']])],
126
+ ['Input', new Map([['placeholder', 'placeholder']])],
127
+ ['InputGroupInput', new Map([['placeholder', 'placeholder']])],
128
+ ['InputGroupTextarea', new Map([['placeholder', 'placeholder']])],
129
+ ['Page', new Map([['title', 'title'], ['description', 'description']])],
130
+ ['Select', new Map([
131
+ ['placeholder', 'placeholder'],
132
+ ['searchPlaceholder', 'placeholder'],
133
+ ['emptyText', 'empty-state'],
134
+ ])],
135
+ ['Textarea', new Map([['placeholder', 'placeholder']])],
136
+ ])
137
+
138
+ const JSX_OBJECT_PROP_ROLES = new Map([
139
+ ['ActionTrigger', new Map([
140
+ ['confirm', new Map([
141
+ ['title', 'title'],
142
+ ['description', 'dialog-body'],
143
+ ['actionLabel', 'button'],
144
+ ['cancelLabel', 'button'],
145
+ ])],
146
+ ])],
147
+ ])
148
+
149
+ const JSX_ARRAY_PROP_ROLES = new Map([
150
+ ['Select', new Map([
151
+ ['options', new Map([
152
+ ['label', 'menu-item'],
153
+ ['hint', 'label'],
154
+ ['triggerLabel', 'label'],
155
+ ['group', 'heading'],
156
+ ['content', 'menu-item'],
157
+ ])],
158
+ ])],
159
+ ])
160
+
161
+ // Prop de classe que alcança cada superfície textual. A lista acompanha a implementação
162
+ // real dos componentes: `className` do ActionFormCard vai para o form, enquanto
163
+ // `cardClassName` envolve o título; o menu do Select custom vive em portal e não herda a
164
+ // classe do gatilho.
165
+ const JSX_PROP_CLASS = new Map([
166
+ ['ActionForm', new Map([['submitLabel', 'className'], ['cancelLabel', 'className']])],
167
+ ['ActionFormCard', new Map([
168
+ ['title', 'cardClassName'], ['description', 'cardClassName'],
169
+ ['submitLabel', 'className'], ['cancelLabel', 'className'],
170
+ ])],
171
+ ['ActionTrigger', new Map([['label', 'className']])],
172
+ ['Alert', new Map([['title', 'className'], ['description', 'className']])],
173
+ ['CommandInput', new Map([['placeholder', 'className']])],
174
+ ['Input', new Map([['placeholder', 'className']])],
175
+ ['InputGroupInput', new Map([['placeholder', 'className']])],
176
+ ['InputGroupTextarea', new Map([['placeholder', 'className']])],
177
+ ['Page', new Map([['title', 'className'], ['description', 'className']])],
178
+ ['Select', new Map([['placeholder', 'className']])],
179
+ ['Textarea', new Map([['placeholder', 'className']])],
180
+ ])
181
+
182
+ // Somente componentes cuja API repassa `style` até o nó visual dessa copy. Page,
183
+ // Select e os wrappers de form não expõem style; inferir ali criaria uma garantia falsa.
184
+ const JSX_PROP_STYLE = new Map([
185
+ ['ActionTrigger', new Map([['label', 'style']])],
186
+ ['Alert', new Map([['title', 'style'], ['description', 'style']])],
187
+ ['CommandInput', new Map([['placeholder', 'style']])],
188
+ ['Input', new Map([['placeholder', 'style']])],
189
+ ['InputGroupInput', new Map([['placeholder', 'style']])],
190
+ ['InputGroupTextarea', new Map([['placeholder', 'style']])],
191
+ ['Textarea', new Map([['placeholder', 'style']])],
192
+ ])
193
+
194
+ const JSX_PORTAL_BOUNDARIES = new Set([
195
+ 'AlertDialogContent', 'DialogContent', 'DrawerContent', 'MenuContent', 'MenuSubContent',
196
+ 'PopoverContent', 'TooltipContent',
197
+ ])
198
+
199
+ const digest = (content) => createHash('sha256').update(content).digest('hex')
200
+ const portable = (value) => value.split(path.sep).join('/')
201
+
202
+ function safeInventoryPath(root, inventory) {
203
+ try {
204
+ return safeProjectPath(root, inventory).path
205
+ } catch (error) {
206
+ throw new Error(`base.json: copy.inventory inseguro: ${projectPathErrorMessage(error)}`)
207
+ }
208
+ }
209
+
210
+ function uiModule(value) {
211
+ return value === '@softize/opus/ui' || value.startsWith('@softize/opus/ui/')
212
+ }
213
+
214
+ /**
215
+ * Cria apenas o binder/type-checker de um source virtual. Não resolve módulos nem carrega
216
+ * libs: precisamos da identidade lexical das declarações, não de executar ou tipar o app.
217
+ */
218
+ function parseSource(file, sourceText) {
219
+ const virtual = path.resolve('/__opus_copy__', portable(file))
220
+ const parsed = ts.createSourceFile(virtual, sourceText, ts.ScriptTarget.Latest, true)
221
+ const host = {
222
+ getSourceFile: (name) => (name === virtual ? parsed : undefined),
223
+ getDefaultLibFileName: () => '',
224
+ writeFile: () => {},
225
+ getCurrentDirectory: () => path.dirname(virtual),
226
+ getDirectories: () => [],
227
+ fileExists: (name) => name === virtual,
228
+ readFile: (name) => (name === virtual ? sourceText : undefined),
229
+ getCanonicalFileName: (name) => name,
230
+ useCaseSensitiveFileNames: () => true,
231
+ getNewLine: () => '\n',
232
+ }
233
+ const program = ts.createProgram({
234
+ rootNames: [virtual],
235
+ options: { allowJs: true, jsx: ts.JsxEmit.Preserve, noLib: true, noResolve: true },
236
+ host,
237
+ })
238
+ return { sourceFile: program.getSourceFile(virtual) ?? parsed, checker: program.getTypeChecker() }
239
+ }
240
+
241
+ function importDeclaration(node) {
242
+ let current = node
243
+ while (current !== undefined && !ts.isSourceFile(current)) {
244
+ if (ts.isImportDeclaration(current)) return current
245
+ current = current.parent
246
+ }
247
+ return null
248
+ }
249
+
250
+ function importBinding(identifier, checker) {
251
+ const symbol = checker.getSymbolAtLocation(identifier)
252
+ const declaration = symbol?.declarations?.find(
253
+ (item) => ts.isImportSpecifier(item) || ts.isNamespaceImport(item) || ts.isImportClause(item),
254
+ )
255
+ if (declaration === undefined) return null
256
+ const statement = importDeclaration(declaration)
257
+ if (statement === null || !ts.isStringLiteral(statement.moduleSpecifier)) return null
258
+ if (
259
+ (ts.isImportSpecifier(declaration) && declaration.isTypeOnly) ||
260
+ (statement.importClause?.isTypeOnly ?? false)
261
+ ) return null
262
+ if (ts.isImportSpecifier(declaration)) {
263
+ return { kind: 'named', imported: declaration.propertyName?.text ?? declaration.name.text, module: statement.moduleSpecifier.text }
264
+ }
265
+ if (ts.isNamespaceImport(declaration)) {
266
+ return { kind: 'namespace', module: statement.moduleSpecifier.text }
267
+ }
268
+ return { kind: 'default', imported: 'default', module: statement.moduleSpecifier.text }
269
+ }
270
+
271
+ function propertyValue(candidate) {
272
+ return ts.isPropertyAssignment(candidate) ? candidate.initializer : candidate.name
273
+ }
274
+
275
+ function unwrap(node) {
276
+ let current = node
277
+ while (
278
+ ts.isAsExpression(current) ||
279
+ ts.isSatisfiesExpression(current) ||
280
+ ts.isTypeAssertionExpression(current) ||
281
+ ts.isParenthesizedExpression(current) ||
282
+ ts.isNonNullExpression(current)
283
+ ) {
284
+ current = current.expression
285
+ }
286
+ return current
287
+ }
288
+
289
+ function bindingSymbol(identifier, checker) {
290
+ return ts.isShorthandPropertyAssignment(identifier.parent)
291
+ ? checker.getShorthandAssignmentValueSymbol(identifier.parent)
292
+ : checker.getSymbolAtLocation(identifier)
293
+ }
294
+
295
+ function sameBinding(identifier, symbol, checker) {
296
+ const candidate = bindingSymbol(identifier, checker)
297
+ if (candidate === symbol) return true
298
+ if (candidate !== undefined && (candidate.flags & ts.SymbolFlags.Alias) !== 0) {
299
+ try {
300
+ return checker.getAliasedSymbol(candidate) === symbol
301
+ } catch {
302
+ return false
303
+ }
304
+ }
305
+ return false
306
+ }
307
+
308
+ function constInitializer(identifier, checker) {
309
+ const symbol = bindingSymbol(identifier, checker)
310
+ const declaration = symbol?.declarations?.find(
311
+ (item) =>
312
+ ts.isVariableDeclaration(item) &&
313
+ ts.isIdentifier(item.name) &&
314
+ item.initializer !== undefined &&
315
+ ts.isVariableDeclarationList(item.parent) &&
316
+ (item.parent.flags & ts.NodeFlags.Const) !== 0,
317
+ )
318
+ return declaration === undefined ? null : { symbol, declaration, initializer: declaration.initializer }
319
+ }
320
+
321
+ function assignmentTarget(node) {
322
+ const parent = node.parent
323
+ return (
324
+ (ts.isBinaryExpression(parent) && parent.left === node && ts.isAssignmentOperator(parent.operatorToken.kind)) ||
325
+ ((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
326
+ [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(parent.operator)) ||
327
+ (ts.isDeleteExpression(parent) && parent.expression === node)
328
+ )
329
+ }
330
+
331
+ function containingVariableStatement(node) {
332
+ let current = node
333
+ while (current !== undefined && !ts.isSourceFile(current)) {
334
+ if (ts.isVariableStatement(current)) return current
335
+ current = current.parent
336
+ }
337
+ return null
338
+ }
339
+
340
+ function declarationIdentifier(node, symbol) {
341
+ return symbol.declarations?.some((declaration) => declaration === node || declaration.name === node) ?? false
342
+ }
343
+
344
+ function bindingNames(name, checker) {
345
+ const names = []
346
+ const collect = (current) => {
347
+ if (ts.isIdentifier(current)) names.push(current)
348
+ else for (const element of current.elements) if (!ts.isOmittedExpression(element)) collect(element.name)
349
+ }
350
+ collect(name)
351
+ return names
352
+ .map((identifier) => ({ name: identifier, symbol: bindingSymbol(identifier, checker) }))
353
+ .filter((item) => item.symbol !== undefined)
354
+ }
355
+
356
+ function transparentCarrier(node) {
357
+ let current = node
358
+ while (
359
+ ts.isParenthesizedExpression(current.parent) || ts.isAsExpression(current.parent) ||
360
+ ts.isSatisfiesExpression(current.parent) || ts.isNonNullExpression(current.parent) ||
361
+ ts.isTypeAssertionExpression(current.parent)
362
+ ) current = current.parent
363
+ return current
364
+ }
365
+
366
+ function importedMemberName(node, checker, modulePredicate) {
367
+ const value = resolveBinding(node, checker)
368
+ if (ts.isIdentifier(value)) {
369
+ const binding = importBinding(value, checker)
370
+ if (binding?.kind === 'named' && modulePredicate(binding.module)) return binding.imported
371
+ return destructuredNamespaceMember(value, checker, modulePredicate)
372
+ }
373
+ if (ts.isPropertyAccessExpression(value) || ts.isElementAccessExpression(value)) {
374
+ const owner = resolveBinding(value.expression, checker)
375
+ if (!ts.isIdentifier(owner)) return null
376
+ const binding = importBinding(owner, checker)
377
+ if (binding?.kind !== 'namespace' || !modulePredicate(binding.module)) return null
378
+ if (ts.isPropertyAccessExpression(value)) return value.name.text
379
+ return value.argumentExpression === undefined ? null : staticText(value.argumentExpression, checker)?.text ?? null
380
+ }
381
+ return null
382
+ }
383
+
384
+ function knownContractFactory(call, checker) {
385
+ const name = importedMemberName(call.expression, checker, (module) => CONTRACT_MODULES.has(module))
386
+ return name !== null && CONTRACT_FACTORIES.has(name)
387
+ }
388
+
389
+ function knownBindAction(call, checker) {
390
+ return importedMemberName(call.expression, checker, (module) => CONTRACT_MODULES.has(module)) === 'bindAction'
391
+ }
392
+
393
+ function safeBindActionBinding(call, checker) {
394
+ if (call.arguments.length < 2) return false
395
+ const binding = unwrap(call.arguments[1])
396
+ if (!ts.isObjectLiteralExpression(binding)) return false
397
+ return binding.properties.every((item) => {
398
+ if (
399
+ !ts.isPropertyAssignment(item) && !ts.isShorthandPropertyAssignment(item) &&
400
+ !ts.isMethodDeclaration(item)
401
+ ) return false
402
+ const name = item.name === undefined ? null : propertyName(item.name, item.getSourceFile(), checker)
403
+ return name !== null && ACTION_BINDING_KEYS.has(name)
404
+ })
405
+ }
406
+
407
+ function jsxOpeningFromAttribute(attribute) {
408
+ const opening = attribute.parent?.parent
409
+ return ts.isJsxOpeningElement(opening) || ts.isJsxSelfClosingElement(opening) ? opening : null
410
+ }
411
+
412
+ function knownJsxAggregateConsumer(expression, checker, mode) {
413
+ const jsx = expression.parent
414
+ if (!ts.isJsxExpression(jsx)) return false
415
+ if (ts.isJsxAttribute(jsx.parent)) {
416
+ const opening = jsxOpeningFromAttribute(jsx.parent)
417
+ if (opening === null) return false
418
+ const prop = jsx.parent.name.getText(expression.getSourceFile())
419
+ const component = importedMemberName(opening.tagName, checker, uiModule)
420
+ if (component === null) {
421
+ return ts.isIdentifier(opening.tagName) && /^[a-z]/u.test(opening.tagName.text) &&
422
+ mode === 'aggregate' && ['style', 'dangerouslySetInnerHTML'].includes(prop)
423
+ }
424
+ if (mode === 'factory-result' || mode === 'bound-result') {
425
+ return prop === 'action' && JSX_ACTION_CONSUMERS.has(component)
426
+ }
427
+ return (
428
+ JSX_PROP_ROLES.get(component)?.has(prop) === true ||
429
+ JSX_OBJECT_PROP_ROLES.get(component)?.has(prop) === true ||
430
+ JSX_ARRAY_PROP_ROLES.get(component)?.has(prop) === true ||
431
+ [...(JSX_PROP_CLASS.get(component)?.values() ?? [])].includes(prop) ||
432
+ [...(JSX_PROP_STYLE.get(component)?.values() ?? [])].includes(prop) ||
433
+ (JSX_CHILD_ROLES.has(component) && ['className', 'style'].includes(prop))
434
+ )
435
+ }
436
+ const element = jsx.parent
437
+ if (!ts.isJsxElement(element)) return false
438
+ const component = importedMemberName(element.openingElement.tagName, checker, uiModule)
439
+ if (component === null) {
440
+ return mode === 'aggregate' && ts.isIdentifier(element.openingElement.tagName) && /^[a-z]/u.test(element.openingElement.tagName.text)
441
+ }
442
+ return mode === 'aggregate' && JSX_CHILD_ROLES.has(component)
443
+ }
444
+
445
+ function exportedUse(node) {
446
+ const carrier = transparentCarrier(node)
447
+ return ts.isExportSpecifier(carrier.parent) || ts.isExportAssignment(carrier.parent)
448
+ }
449
+
450
+ function stableFactoryResult(call, checker, stack) {
451
+ return stableExpressionUse(call, checker, 'factory-result', stack)
452
+ }
453
+
454
+ function stableBoundResult(call, checker, stack) {
455
+ return stableExpressionUse(call, checker, 'bound-result', stack)
456
+ }
457
+
458
+ function stableComposition(node, checker, stack) {
459
+ let current = transparentCarrier(node)
460
+ for (;;) {
461
+ const parent = current.parent
462
+ if (
463
+ (ts.isPropertyAssignment(parent) && parent.initializer === current) ||
464
+ ts.isShorthandPropertyAssignment(parent) || ts.isSpreadAssignment(parent) ||
465
+ ts.isSpreadElement(parent) || ts.isArrayLiteralExpression(parent) ||
466
+ ts.isObjectLiteralExpression(parent)
467
+ ) {
468
+ current = parent
469
+ continue
470
+ }
471
+ return stableExpressionUse(current, checker, 'aggregate', stack)
472
+ }
473
+ }
474
+
475
+ function stableExpressionUse(node, checker, mode, stack) {
476
+ let carrier = transparentCarrier(node)
477
+ let sharedProperty = false
478
+ while (
479
+ (ts.isPropertyAccessExpression(carrier.parent) || ts.isElementAccessExpression(carrier.parent)) &&
480
+ carrier.parent.expression === carrier
481
+ ) {
482
+ sharedProperty = true
483
+ carrier = transparentCarrier(carrier.parent)
484
+ }
485
+ // defineContract retorna a própria entrada e bindAction faz clone raso. Qualquer
486
+ // subobjeto alcançado pelo resultado continua compartilhado com a copy declarada.
487
+ const consumerMode = sharedProperty && (mode === 'factory-result' || mode === 'bound-result')
488
+ ? 'aggregate'
489
+ : mode
490
+ const parent = carrier.parent
491
+
492
+ if (assignmentTarget(carrier)) return false
493
+ if (ts.isVariableDeclaration(parent) && parent.initializer === carrier) {
494
+ if (!ts.isVariableDeclarationList(parent.parent) || (parent.parent.flags & ts.NodeFlags.Const) === 0) return false
495
+ const aliases = bindingNames(parent.name, checker)
496
+ return aliases.length > 0 && aliases.every((alias) => stableReferences(alias, checker, consumerMode, stack))
497
+ }
498
+ if (
499
+ ts.isShorthandPropertyAssignment(parent) ||
500
+ (ts.isPropertyAssignment(parent) && parent.initializer === carrier) ||
501
+ ts.isSpreadAssignment(parent) || ts.isSpreadElement(parent) || ts.isArrayLiteralExpression(parent)
502
+ ) return consumerMode === 'aggregate' && stableComposition(carrier, checker, stack)
503
+ if (ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === carrier)) {
504
+ if (consumerMode === 'aggregate') return knownContractFactory(parent, checker) && stableFactoryResult(parent, checker, stack)
505
+ if (
506
+ consumerMode === 'factory-result' && parent.arguments[0] === carrier &&
507
+ knownBindAction(parent, checker) && safeBindActionBinding(parent, checker)
508
+ ) return stableBoundResult(parent, checker, stack)
509
+ const consumer = importedMemberName(parent.expression, checker, uiModule)
510
+ return (consumerMode === 'factory-result' || consumerMode === 'bound-result') &&
511
+ parent.arguments[0] === carrier && consumer !== null && ACTION_HOOK_CONSUMERS.has(consumer)
512
+ }
513
+ if (ts.isJsxExpression(parent)) return knownJsxAggregateConsumer(carrier, checker, consumerMode)
514
+ if (ts.isExpressionStatement(parent)) return true
515
+ if ((consumerMode === 'factory-result' || consumerMode === 'bound-result') && exportedUse(carrier)) return true
516
+ if ((ts.isTypeOfExpression(parent) || ts.isVoidExpression(parent)) && parent.expression === carrier) return true
517
+ return false
518
+ }
519
+
520
+ function stableReferences(binding, checker, mode, stack) {
521
+ if (stack.has(binding.symbol)) return false
522
+ const nextStack = new Set([...stack, binding.symbol])
523
+ const statement = containingVariableStatement(binding.name)
524
+ if (
525
+ mode === 'aggregate' &&
526
+ statement?.modifiers?.some((item) => item.kind === ts.SyntaxKind.ExportKeyword)
527
+ ) return false
528
+
529
+ let stable = true
530
+ const visit = (node) => {
531
+ if (!stable) return
532
+ if (
533
+ ts.isIdentifier(node) && sameBinding(node, binding.symbol, checker) &&
534
+ !declarationIdentifier(node, binding.symbol)
535
+ ) stable = stableExpressionUse(node, checker, mode, nextStack)
536
+ ts.forEachChild(node, visit)
537
+ }
538
+ visit(binding.name.getSourceFile())
539
+ return stable
540
+ }
541
+
542
+ /**
543
+ * Objetos e arrays `const` continuam mutáveis. A extração só faz fold quando cada uso
544
+ * está numa allowlist observável: composição `const` igualmente estável, factory Opus
545
+ * cujo resultado não sofre mutação/escape, ou prop/children conhecidos de DOM/Opus.
546
+ */
547
+ function stableAggregateBinding(binding, checker, stack = new Set()) {
548
+ const initial = unwrap(binding.initializer)
549
+ if (!ts.isObjectLiteralExpression(initial) && !ts.isArrayLiteralExpression(initial)) return true
550
+ return stableReferences({ name: binding.declaration.name, symbol: binding.symbol }, checker, 'aggregate', stack)
551
+ }
552
+
553
+ /** Avalia somente bindings `const` da declaração lexical correta; nunca executa o source. */
554
+ function resolveBinding(node, checker, seen = new Set()) {
555
+ const value = unwrap(node)
556
+ if (ts.isIdentifier(value)) {
557
+ const binding = constInitializer(value, checker)
558
+ if (binding === null || seen.has(binding.symbol) || !stableAggregateBinding(binding, checker)) return value
559
+ return resolveBinding(binding.initializer, checker, new Set([...seen, binding.symbol]))
560
+ }
561
+ if (ts.isPropertyAccessExpression(value) || ts.isElementAccessExpression(value)) {
562
+ const owner = resolveBinding(value.expression, checker, seen)
563
+ const key = ts.isPropertyAccessExpression(value)
564
+ ? value.name.text
565
+ : value.argumentExpression === undefined ? null : staticText(value.argumentExpression, checker)?.text ?? null
566
+ if (key === null) return value
567
+ if (ts.isObjectLiteralExpression(owner)) {
568
+ const resolved = property(owner, key, value.getSourceFile(), checker)
569
+ if (resolved.opaque === undefined && resolved.candidate !== undefined) {
570
+ return resolveBinding(propertyValue(resolved.candidate), checker, seen)
571
+ }
572
+ }
573
+ if (ts.isArrayLiteralExpression(owner) && /^\d+$/u.test(key)) {
574
+ const target = Number(key)
575
+ const flattened = []
576
+ for (const element of owner.elements) {
577
+ if (!ts.isSpreadElement(element)) flattened.push(element)
578
+ else {
579
+ const spread = resolveBinding(element.expression, checker, seen)
580
+ if (!ts.isArrayLiteralExpression(spread)) return value
581
+ flattened.push(...spread.elements)
582
+ }
583
+ }
584
+ if (target < flattened.length && !ts.isSpreadElement(flattened[target])) {
585
+ return resolveBinding(flattened[target], checker, seen)
586
+ }
587
+ }
588
+ }
589
+ return value
590
+ }
591
+
592
+ function staticText(node, checker) {
593
+ const value = resolveBinding(node, checker)
594
+ if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) {
595
+ return { text: value.text, node: value }
596
+ }
597
+ if (
598
+ ts.isBinaryExpression(value) &&
599
+ value.operatorToken.kind === ts.SyntaxKind.PlusToken
600
+ ) {
601
+ const left = staticText(value.left, checker)
602
+ const right = staticText(value.right, checker)
603
+ if (left !== null && right !== null) return { text: left.text + right.text, node: value }
604
+ }
605
+ return null
606
+ }
607
+
608
+ const JSX_ENTITY_CACHE = new Map()
609
+
610
+ /**
611
+ * Usa o próprio transform JSX do TypeScript como tabela de entidades. Assim a extração
612
+ * acompanha exatamente o conjunto completo aceito pelo parser/emit instalado, sem uma
613
+ * allowlist parcial que possa divergir do texto entregue ao React.
614
+ */
615
+ function namedJsxEntity(entity) {
616
+ if (JSX_ENTITY_CACHE.has(entity)) return JSX_ENTITY_CACHE.get(entity)
617
+ const output = ts.transpileModule(`const __softize = <i>${entity}</i>`, {
618
+ compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022 },
619
+ }).outputText
620
+ const emitted = ts.createSourceFile('__softize-entity.js', output, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS)
621
+ let decoded = null
622
+ const visit = (node) => {
623
+ if (
624
+ decoded === null &&
625
+ ts.isCallExpression(node) &&
626
+ node.arguments.length >= 3 &&
627
+ ts.isStringLiteral(node.arguments[2])
628
+ ) decoded = node.arguments[2].text
629
+ ts.forEachChild(node, visit)
630
+ }
631
+ visit(emitted)
632
+ const result = decoded !== null && decoded !== entity ? decoded : null
633
+ JSX_ENTITY_CACHE.set(entity, result)
634
+ return result
635
+ }
636
+
637
+ /**
638
+ * Decodifica somente entidades completas que o JSX instalado realmente transforma.
639
+ * Entidade desconhecida ou código Unicode inválido é opaco: inventariar o source cru
640
+ * como se fosse a copy renderizada criaria uma garantia falsa.
641
+ */
642
+ function decodeJsxEntities(text) {
643
+ let valid = true
644
+ const decoded = text.replace(/&(?:#([^;\s<>]+)|([a-z][a-z0-9]*));/giu, (entity, numeric) => {
645
+ if (numeric !== undefined) {
646
+ const hexadecimal = /^[xX][0-9a-f]+$/u.test(numeric)
647
+ const decimal = /^\d+$/u.test(numeric)
648
+ const point = hexadecimal
649
+ ? Number.parseInt(numeric.slice(1), 16)
650
+ : decimal ? Number.parseInt(numeric, 10) : Number.NaN
651
+ if (Number.isInteger(point) && point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)) {
652
+ return String.fromCodePoint(point)
653
+ }
654
+ valid = false
655
+ return entity
656
+ }
657
+ const value = namedJsxEntity(entity)
658
+ if (value !== null) return value
659
+ valid = false
660
+ return entity
661
+ })
662
+ for (const match of text.matchAll(/&(#(?:[xX][0-9a-fA-F]+|\d+)|[A-Za-z][A-Za-z0-9]*)(?![A-Za-z0-9;])/gu)) {
663
+ if (match[1].startsWith('#') || namedJsxEntity(`&${match[1]};`) !== null) valid = false
664
+ }
665
+ return valid ? decoded : null
666
+ }
667
+
668
+ function constVariableDeclaration(node) {
669
+ if (!ts.isVariableDeclaration(node) || node.initializer === undefined) return false
670
+ return ts.isVariableDeclarationList(node.parent) && (node.parent.flags & ts.NodeFlags.Const) !== 0
671
+ }
672
+
673
+ /** Resolve `const { Button: B } = Opus` sem aceitar destructuring mutável ou homônimo local. */
674
+ function destructuredNamespaceMember(identifier, checker, modulePredicate) {
675
+ const symbol = checker.getSymbolAtLocation(identifier)
676
+ const declaration = symbol?.declarations?.find(
677
+ (item) => ts.isBindingElement(item) && ts.isIdentifier(item.name) && item.name.text === identifier.text,
678
+ )
679
+ if (
680
+ declaration === undefined ||
681
+ declaration.dotDotDotToken !== undefined ||
682
+ !ts.isObjectBindingPattern(declaration.parent) ||
683
+ !constVariableDeclaration(declaration.parent.parent)
684
+ ) return null
685
+
686
+ const memberNode = declaration.propertyName ?? declaration.name
687
+ const member = propertyName(memberNode, identifier.getSourceFile(), checker)
688
+ if (member === null) return null
689
+ const owner = resolveBinding(declaration.parent.parent.initializer, checker)
690
+ if (!ts.isIdentifier(owner)) return null
691
+ const binding = importBinding(owner, checker)
692
+ return binding?.kind === 'namespace' && modulePredicate(binding.module) ? member : null
693
+ }
694
+
695
+ function propertyName(node, sourceFile, checker) {
696
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return node.text
697
+ if (ts.isComputedPropertyName(node)) return staticText(node.expression, checker)?.text ?? null
698
+ void sourceFile
699
+ return null
700
+ }
701
+
702
+ /**
703
+ * Resolve uma chave conforme a ordem real do objeto. Um spread/computed opaco só deixa
704
+ * de importar quando uma propriedade explícita posterior prova o valor final da chave.
705
+ */
706
+ function property(object, name, sourceFile, checker, seen = new Set()) {
707
+ if (seen.has(object)) return { candidate: undefined, opaque: object }
708
+ const nextSeen = new Set([...seen, object])
709
+ let candidate
710
+ let opaque
711
+ for (const item of object.properties) {
712
+ if (ts.isSpreadAssignment(item)) {
713
+ const spread = resolveBinding(item.expression, checker)
714
+ if (!ts.isObjectLiteralExpression(spread)) {
715
+ candidate = undefined
716
+ opaque = item
717
+ continue
718
+ }
719
+ const nested = property(spread, name, sourceFile, checker, nextSeen)
720
+ if (nested.opaque !== undefined) {
721
+ candidate = undefined
722
+ opaque = nested.opaque
723
+ } else if (nested.candidate !== undefined) {
724
+ candidate = nested.candidate
725
+ opaque = undefined
726
+ }
727
+ continue
728
+ }
729
+ const itemName = item.name === undefined ? null : propertyName(item.name, sourceFile, checker)
730
+ if (itemName === null) {
731
+ candidate = undefined
732
+ opaque = item
733
+ continue
734
+ }
735
+ if (itemName !== name) continue
736
+ if (ts.isPropertyAssignment(item) || ts.isShorthandPropertyAssignment(item)) {
737
+ candidate = item
738
+ opaque = undefined
739
+ } else {
740
+ candidate = undefined
741
+ opaque = item
742
+ }
743
+ }
744
+ return { candidate, opaque }
745
+ }
746
+
747
+ /** Enumera propriedades nomeadas; spreads estáticos são mesclados na ordem do runtime. */
748
+ function properties(object, sourceFile, checker, seen = new Set()) {
749
+ if (seen.has(object)) {
750
+ return {
751
+ items: new Map(),
752
+ opaque: [object],
753
+ unknownOpaque: [object],
754
+ namedOpaque: new Map(),
755
+ }
756
+ }
757
+ const nextSeen = new Set([...seen, object])
758
+ const items = new Map()
759
+ const unknownOpaque = []
760
+ const namedOpaque = new Map()
761
+ for (const item of object.properties) {
762
+ if (ts.isSpreadAssignment(item)) {
763
+ const spread = resolveBinding(item.expression, checker)
764
+ if (!ts.isObjectLiteralExpression(spread)) {
765
+ // Um spread opaco pode acrescentar qualquer chave; nenhuma lista finita de
766
+ // overrides posteriores prova que o mapa completo ficou conhecido.
767
+ unknownOpaque.push(item)
768
+ continue
769
+ }
770
+ const nested = properties(spread, sourceFile, checker, nextSeen)
771
+ unknownOpaque.push(...nested.unknownOpaque)
772
+ for (const [name, candidate] of nested.items) {
773
+ items.set(name, candidate)
774
+ namedOpaque.delete(name)
775
+ }
776
+ for (const [name, opaque] of nested.namedOpaque) {
777
+ items.delete(name)
778
+ namedOpaque.set(name, opaque)
779
+ }
780
+ continue
781
+ }
782
+ const name = item.name === undefined ? null : propertyName(item.name, sourceFile, checker)
783
+ if (name === null) {
784
+ unknownOpaque.push(item)
785
+ continue
786
+ }
787
+ if (ts.isPropertyAssignment(item) || ts.isShorthandPropertyAssignment(item)) {
788
+ items.set(name, item)
789
+ namedOpaque.delete(name)
790
+ } else {
791
+ items.delete(name)
792
+ namedOpaque.set(name, item)
793
+ }
794
+ }
795
+ return {
796
+ items,
797
+ opaque: [...unknownOpaque, ...namedOpaque.values()],
798
+ unknownOpaque,
799
+ namedOpaque,
800
+ }
801
+ }
802
+
803
+ function i18nText(node, sourceFile, checker) {
804
+ const value = resolveBinding(node, checker)
805
+ const direct = staticText(value, checker)
806
+ if (direct !== null) return direct
807
+ if (!ts.isObjectLiteralExpression(value)) return null
808
+ const fallback = property(value, 'default', sourceFile, checker)
809
+ return fallback.opaque !== undefined || fallback.candidate === undefined
810
+ ? null
811
+ : staticText(propertyValue(fallback.candidate), checker)
812
+ }
813
+
814
+ function objects(node, checker, seen = new Set()) {
815
+ const value = resolveBinding(node, checker)
816
+ if (ts.isObjectLiteralExpression(value)) return { values: [value], opaque: [] }
817
+ if (ts.isArrayLiteralExpression(value)) {
818
+ if (seen.has(value)) return { values: [], opaque: [value] }
819
+ const values = []
820
+ const opaque = []
821
+ const nextSeen = new Set([...seen, value])
822
+ for (const element of value.elements) {
823
+ if (ts.isSpreadElement(element)) {
824
+ const nested = objects(element.expression, checker, nextSeen)
825
+ values.push(...nested.values)
826
+ opaque.push(...nested.opaque)
827
+ continue
828
+ }
829
+ const item = resolveBinding(element, checker)
830
+ if (ts.isObjectLiteralExpression(item)) values.push(item)
831
+ else opaque.push(element)
832
+ }
833
+ return { values, opaque }
834
+ }
835
+ return { values: [], opaque: [node] }
836
+ }
837
+
838
+ function lineOf(sourceFile, node) {
839
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
840
+ }
841
+
842
+ function diagnostic(file, sourceFile, node, field) {
843
+ return {
844
+ source: file,
845
+ line: lineOf(sourceFile, node),
846
+ message:
847
+ `não foi possível extrair \`${field}\` estaticamente; use texto literal, concatenação ` +
848
+ 'de literais, constante local ou I18nRef com `default` literal.',
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Extrai as superfícies humanas de um source isolado.
854
+ *
855
+ * O retorno distingue arquivo sem contrato de contrato sem copy. Essa diferença é
856
+ * necessária para hashear todo contrato e detectar a adição posterior de um texto.
857
+ */
858
+ export function extractCopyFromSource(file, sourceText) {
859
+ const { sourceFile, checker } = parseSource(file, sourceText)
860
+ const entries = []
861
+ const diagnostics = []
862
+ let hasContract = false
863
+ const hasUiImport = sourceFile.statements.some(
864
+ (statement) =>
865
+ ts.isImportDeclaration(statement) &&
866
+ ts.isStringLiteral(statement.moduleSpecifier) &&
867
+ uiModule(statement.moduleSpecifier.text) &&
868
+ statement.importClause !== undefined &&
869
+ !statement.importClause.isTypeOnly,
870
+ )
871
+
872
+ const add = (node, role, field) => {
873
+ const extracted = i18nText(node, sourceFile, checker)
874
+ if (extracted === null) diagnostics.push(diagnostic(file, sourceFile, node, field))
875
+ else {
876
+ const entry = { source: file, line: lineOf(sourceFile, extracted.node), role, text: extracted.text }
877
+ entries.push(entry)
878
+ return entry
879
+ }
880
+ return null
881
+ }
882
+
883
+ const addProperty = (object, name, role, prefix = '') => {
884
+ const resolved = property(object, name, sourceFile, checker)
885
+ if (resolved.opaque !== undefined) {
886
+ diagnostics.push(diagnostic(file, sourceFile, resolved.opaque, `${prefix}${name}`))
887
+ } else if (resolved.candidate !== undefined) {
888
+ add(propertyValue(resolved.candidate), role, `${prefix}${name}`)
889
+ }
890
+ }
891
+
892
+ const nestedObject = (object, name, visitor, prefix = '') => {
893
+ const resolved = property(object, name, sourceFile, checker)
894
+ if (resolved.opaque !== undefined) {
895
+ diagnostics.push(diagnostic(file, sourceFile, resolved.opaque, `${prefix}${name}`))
896
+ return
897
+ }
898
+ if (resolved.candidate === undefined) return
899
+ const candidateValue = propertyValue(resolved.candidate)
900
+ const value = resolveBinding(candidateValue, checker)
901
+ if (ts.isObjectLiteralExpression(value)) {
902
+ visitor(value, `${prefix}${name}.`)
903
+ return
904
+ }
905
+ if (ts.isArrayLiteralExpression(value)) {
906
+ const listed = objects(value, checker)
907
+ for (const [index, item] of listed.values.entries()) visitor(item, `${prefix}${name}[${index}].`)
908
+ for (const item of listed.opaque) diagnostics.push(diagnostic(file, sourceFile, item, `${prefix}${name}`))
909
+ return
910
+ }
911
+ diagnostics.push(diagnostic(file, sourceFile, candidateValue, `${prefix}${name}`))
912
+ }
913
+
914
+ const namedObjects = (object, name, visitor) => {
915
+ nestedObject(object, name, (container, prefix) => {
916
+ const listed = properties(container, sourceFile, checker)
917
+ for (const item of listed.opaque) diagnostics.push(diagnostic(file, sourceFile, item, prefix.slice(0, -1)))
918
+ for (const [itemName, item] of listed.items) {
919
+ const listedValues = objects(propertyValue(item), checker)
920
+ for (const opaque of listedValues.opaque) diagnostics.push(diagnostic(file, sourceFile, opaque, `${prefix}${itemName}`))
921
+ if (listedValues.values.length === 0 && listedValues.opaque.length === 0) {
922
+ diagnostics.push(diagnostic(file, sourceFile, propertyValue(item), `${prefix}${itemName}`))
923
+ }
924
+ for (const value of listedValues.values) visitor(value, `${prefix}${itemName}.`)
925
+ }
926
+ })
927
+ }
928
+
929
+ const optionItems = (object, prefix) => {
930
+ const kind = property(object, 'kind', sourceFile, checker)
931
+ if (kind.opaque !== undefined) {
932
+ diagnostics.push(diagnostic(file, sourceFile, kind.opaque, `${prefix}kind`))
933
+ return
934
+ }
935
+ if (kind.candidate === undefined) return
936
+ const kindText = staticText(propertyValue(kind.candidate), checker)?.text
937
+ if (kindText === undefined) {
938
+ diagnostics.push(diagnostic(file, sourceFile, propertyValue(kind.candidate), `${prefix}kind`))
939
+ return
940
+ }
941
+ if (kindText !== 'static') return
942
+ nestedObject(object, 'items', (item, itemPrefix) => addProperty(item, 'label', 'menu-item', itemPrefix), prefix)
943
+ }
944
+
945
+ const field = (object, prefix) => {
946
+ addProperty(object, 'label', 'label', prefix)
947
+ addProperty(object, 'placeholder', 'placeholder', prefix)
948
+ addProperty(object, 'hint', 'helper-text', prefix)
949
+ addProperty(object, 'help', 'helper-text', prefix)
950
+ nestedObject(object, 'options', optionItems, prefix)
951
+ }
952
+
953
+ const filter = (object, prefix) => {
954
+ addProperty(object, 'label', 'label', prefix)
955
+ addProperty(object, 'placeholder', 'placeholder', prefix)
956
+ nestedObject(object, 'options', optionItems, prefix)
957
+ }
958
+
959
+ function extractContract(object) {
960
+ addProperty(object, 'label', 'button')
961
+ addProperty(object, 'title', 'title')
962
+ addProperty(object, 'summary', 'description')
963
+ addProperty(object, 'description', 'description')
964
+
965
+ nestedObject(object, 'messages', (messages, prefix) => {
966
+ addProperty(messages, 'success', 'success', prefix)
967
+ addProperty(messages, 'error', 'error', prefix)
968
+ addProperty(messages, 'confirmation', 'message', prefix)
969
+ })
970
+ nestedObject(object, 'confirm', (confirm, prefix) => {
971
+ addProperty(confirm, 'title', 'title', prefix)
972
+ addProperty(confirm, 'message', 'dialog-body', prefix)
973
+ addProperty(confirm, 'confirmLabel', 'button', prefix)
974
+ addProperty(confirm, 'cancelLabel', 'button', prefix)
975
+ })
976
+ namedObjects(object, 'fields', field)
977
+ namedObjects(object, 'filters', filter)
978
+ nestedObject(object, 'columns', (column, prefix) => addProperty(column, 'label', 'heading', prefix))
979
+ nestedObject(object, 'periods', (period, prefix) => addProperty(period, 'label', 'tab', prefix))
980
+ nestedObject(object, 'text', (text, prefix) => addProperty(text, 'placeholder', 'placeholder', prefix))
981
+ namedObjects(object, 'expand', (expand, prefix) => addProperty(expand, 'description', 'description', prefix))
982
+ nestedObject(object, 'examples', (example, prefix) => {
983
+ addProperty(example, 'name', 'title', prefix)
984
+ addProperty(example, 'description', 'description', prefix)
985
+ })
986
+ nestedObject(object, 'errors', (error, prefix) => addProperty(error, 'description', 'error', prefix))
987
+ }
988
+
989
+ function importedMember(node, modulePredicate) {
990
+ return importedMemberName(node, checker, modulePredicate)
991
+ }
992
+
993
+ function contractFactory(expression) {
994
+ const name = importedMember(expression, (module) => CONTRACT_MODULES.has(module))
995
+ return name !== null && CONTRACT_FACTORIES.has(name) ? name : null
996
+ }
997
+
998
+ function opusComponent(tagName) {
999
+ return importedMember(tagName, uiModule)
1000
+ }
1001
+
1002
+ function attribute(attributes, name) {
1003
+ let candidate
1004
+ let opaque
1005
+ for (const item of attributes.properties) {
1006
+ if (ts.isJsxAttribute(item)) {
1007
+ if (item.name.getText(sourceFile) === name) {
1008
+ candidate = { kind: 'jsx', item }
1009
+ opaque = undefined
1010
+ }
1011
+ continue
1012
+ }
1013
+ const spread = resolveBinding(item.expression, checker)
1014
+ if (!ts.isObjectLiteralExpression(spread)) {
1015
+ candidate = undefined
1016
+ opaque = item
1017
+ continue
1018
+ }
1019
+ const nested = property(spread, name, sourceFile, checker)
1020
+ if (nested.opaque !== undefined) {
1021
+ candidate = undefined
1022
+ opaque = nested.opaque
1023
+ } else if (nested.candidate !== undefined) {
1024
+ candidate = { kind: 'value', item: propertyValue(nested.candidate) }
1025
+ opaque = undefined
1026
+ }
1027
+ }
1028
+ return { candidate, opaque }
1029
+ }
1030
+
1031
+ function attributeValue(resolved) {
1032
+ if (resolved.opaque !== undefined || resolved.candidate === undefined) return null
1033
+ if (resolved.candidate.kind === 'value') return resolved.candidate.item
1034
+ const item = resolved.candidate.item
1035
+ if (item.initializer === undefined) return null
1036
+ if (ts.isStringLiteral(item.initializer)) return item.initializer
1037
+ if (ts.isJsxExpression(item.initializer) && item.initializer.expression !== undefined) {
1038
+ return item.initializer.expression
1039
+ }
1040
+ return null
1041
+ }
1042
+
1043
+ function attributeText(resolved) {
1044
+ if (
1045
+ resolved.opaque === undefined &&
1046
+ resolved.candidate?.kind === 'jsx' &&
1047
+ ts.isStringLiteral(resolved.candidate.item.initializer)
1048
+ ) {
1049
+ const text = decodeJsxEntities(resolved.candidate.item.initializer.text)
1050
+ if (text === null) return null
1051
+ return {
1052
+ text,
1053
+ node: resolved.candidate.item.initializer,
1054
+ }
1055
+ }
1056
+ const value = attributeValue(resolved)
1057
+ return value === null ? null : staticText(value, checker)
1058
+ }
1059
+
1060
+ function attributeBoolean(resolved) {
1061
+ if (resolved.opaque !== undefined || resolved.candidate === undefined) return null
1062
+ if (resolved.candidate.kind === 'jsx' && resolved.candidate.item.initializer === undefined) return true
1063
+ const raw = attributeValue(resolved)
1064
+ if (raw === null) return null
1065
+ const value = resolveBinding(raw, checker)
1066
+ if (value.kind === ts.SyntaxKind.TrueKeyword) return true
1067
+ if (value.kind === ts.SyntaxKind.FalseKeyword) return false
1068
+ const text = staticText(value, checker)?.text
1069
+ return text === 'true' ? true : text === 'false' ? false : null
1070
+ }
1071
+
1072
+ const transformDiagnostics = new Set()
1073
+
1074
+ function transformDiagnostic(node, field) {
1075
+ const key = `${node.pos}:${node.end}:${field}`
1076
+ if (transformDiagnostics.has(key)) return
1077
+ transformDiagnostics.add(key)
1078
+ diagnostics.push(diagnostic(file, sourceFile, node, field))
1079
+ }
1080
+
1081
+ function tailwindTransform(token) {
1082
+ let normalized = token
1083
+ let important = false
1084
+ if (normalized.startsWith('!')) {
1085
+ important = true
1086
+ normalized = normalized.slice(1)
1087
+ }
1088
+ let bracketDepth = 0
1089
+ let separator = -1
1090
+ for (let index = 0; index < normalized.length; index += 1) {
1091
+ if (normalized[index] === '[') bracketDepth += 1
1092
+ else if (normalized[index] === ']') bracketDepth = Math.max(0, bracketDepth - 1)
1093
+ else if (normalized[index] === ':' && bracketDepth === 0) separator = index
1094
+ }
1095
+ let utility = normalized.slice(separator + 1)
1096
+ if (utility.startsWith('!')) {
1097
+ important = true
1098
+ utility = utility.slice(1)
1099
+ }
1100
+ if (utility.endsWith('!')) {
1101
+ important = true
1102
+ utility = utility.slice(0, -1)
1103
+ }
1104
+ if (utility === 'uppercase' || utility === '[text-transform:uppercase]') {
1105
+ return { kind: 'uppercase', conditional: separator !== -1, important }
1106
+ }
1107
+ if (
1108
+ utility === 'normal-case' ||
1109
+ utility === 'lowercase' ||
1110
+ utility === 'capitalize' ||
1111
+ /^\[text-transform:(?:none|lowercase|capitalize)\]$/u.test(utility)
1112
+ ) return { kind: 'other', conditional: separator !== -1, important }
1113
+ return null
1114
+ }
1115
+
1116
+ function classTransformState(attributes, name, field, diagnose = true) {
1117
+ if (name === null) return { opaque: false, transforms: [] }
1118
+ const resolved = attribute(attributes, name)
1119
+ if (resolved.opaque !== undefined) {
1120
+ if (diagnose) transformDiagnostic(resolved.opaque, field)
1121
+ return { opaque: true, transforms: [] }
1122
+ }
1123
+ if (resolved.candidate === undefined) return { opaque: false, transforms: [] }
1124
+ const text = attributeText(resolved)
1125
+ if (text === null) {
1126
+ if (diagnose) transformDiagnostic(attributeValue(resolved) ?? attributes, field)
1127
+ return { opaque: true, transforms: [] }
1128
+ }
1129
+ return {
1130
+ opaque: false,
1131
+ transforms: text.text.split(/\s+/u).filter(Boolean).map(tailwindTransform).filter((item) => item !== null),
1132
+ }
1133
+ }
1134
+
1135
+ function styleTransformState(attributes, name, field, diagnose = true) {
1136
+ if (name === null) return { kind: 'absent' }
1137
+ const style = attribute(attributes, name)
1138
+ if (style.opaque !== undefined) {
1139
+ if (diagnose) transformDiagnostic(style.opaque, field)
1140
+ return { kind: 'opaque' }
1141
+ }
1142
+ if (style.candidate === undefined) return { kind: 'absent' }
1143
+ const raw = attributeValue(style)
1144
+ const object = raw === null ? null : resolveBinding(raw, checker)
1145
+ if (!ts.isObjectLiteralExpression(object)) {
1146
+ if (diagnose) transformDiagnostic(raw ?? attributes, field)
1147
+ return { kind: 'opaque' }
1148
+ }
1149
+ const textTransform = property(object, 'textTransform', sourceFile, checker)
1150
+ if (textTransform.opaque !== undefined) {
1151
+ if (diagnose) transformDiagnostic(textTransform.opaque, `${field}.textTransform`)
1152
+ return { kind: 'opaque' }
1153
+ }
1154
+ if (textTransform.candidate === undefined) return { kind: 'absent' }
1155
+ const value = staticText(propertyValue(textTransform.candidate), checker)?.text
1156
+ if (value === 'uppercase') return { kind: 'uppercase' }
1157
+ if (['none', 'lowercase', 'capitalize'].includes(value)) return { kind: 'other' }
1158
+ if (diagnose) transformDiagnostic(propertyValue(textTransform.candidate), `${field}.textTransform`)
1159
+ return { kind: 'opaque' }
1160
+ }
1161
+
1162
+ function transformFacts(transforms) {
1163
+ return {
1164
+ importantUppercase: transforms.some((item) => item.important && item.kind === 'uppercase'),
1165
+ importantOtherAlways: transforms.some((item) => item.important && item.kind === 'other' && !item.conditional),
1166
+ uppercase: transforms.some((item) => item.kind === 'uppercase'),
1167
+ otherAlways: transforms.some((item) => item.kind === 'other' && !item.conditional),
1168
+ }
1169
+ }
1170
+
1171
+ /** Retorna se a superfície pode aparecer em uppercase em qualquer estado/viewport. */
1172
+ function visualTransform(attributes, className, styleName, inheritedUppercase, field) {
1173
+ const classes = classTransformState(attributes, className, `${field}.${className}`)
1174
+ const facts = transformFacts(classes.transforms)
1175
+ const style = styleTransformState(attributes, styleName, `${field}.${styleName}`)
1176
+
1177
+ // Utility `!important` vence style inline normal. Se a classe é dinâmica, ela
1178
+ // também pode conter important; portanto style conhecido não a neutraliza.
1179
+ if (classes.opaque) return { opaque: true, uppercase: false }
1180
+ if (facts.importantUppercase) return { opaque: false, uppercase: true }
1181
+ if (facts.importantOtherAlways) return { opaque: false, uppercase: false }
1182
+ if (style.kind === 'opaque') return { opaque: true, uppercase: false }
1183
+ if (style.kind === 'uppercase') return { opaque: false, uppercase: true }
1184
+ if (style.kind === 'other') return { opaque: false, uppercase: false }
1185
+ if (facts.uppercase) return { opaque: false, uppercase: true }
1186
+ if (facts.otherAlways) return { opaque: false, uppercase: false }
1187
+ return { opaque: false, uppercase: inheritedUppercase }
1188
+ }
1189
+
1190
+ function localTransformOverridesInheritance(attributes, className, styleName) {
1191
+ const classes = classTransformState(attributes, className, '', false)
1192
+ if (classes.opaque) return false
1193
+ const facts = transformFacts(classes.transforms)
1194
+ if (facts.importantUppercase || facts.importantOtherAlways) return true
1195
+ const style = styleTransformState(attributes, styleName, '', false)
1196
+ if (style.kind === 'uppercase' || style.kind === 'other') return true
1197
+ if (style.kind === 'opaque') return false
1198
+ return classes.transforms.some((item) => !item.conditional)
1199
+ }
1200
+
1201
+ function intrinsicElement(tagName) {
1202
+ return ts.isIdentifier(tagName) && /^[a-z]/u.test(tagName.text)
1203
+ }
1204
+
1205
+ function transformableElement(opening, component) {
1206
+ if (component !== null) return true
1207
+ return intrinsicElement(opening.tagName)
1208
+ }
1209
+
1210
+ function ancestorTransform(element) {
1211
+ const ancestors = []
1212
+ for (let parent = element.parent; parent !== undefined; parent = parent.parent) {
1213
+ if (ts.isJsxElement(parent)) ancestors.push(parent.openingElement)
1214
+ }
1215
+ let result = { opaque: false, uppercase: false }
1216
+ for (const opening of ancestors.reverse()) {
1217
+ const component = opusComponent(opening.tagName)
1218
+ if (component !== null && JSX_PORTAL_BOUNDARIES.has(component)) result = { opaque: false, uppercase: false }
1219
+ if (component === 'ActionFormDialog' || component === 'Select' || component === 'ActionTrigger') continue
1220
+ if (component === 'ActionFormCard') {
1221
+ result = visualTransform(opening.attributes, 'cardClassName', null, result.uppercase, '<ActionFormCard>')
1222
+ if (result.opaque) return result
1223
+ result = visualTransform(opening.attributes, 'className', null, result.uppercase, '<ActionFormCard>')
1224
+ } else if (transformableElement(opening, component)) {
1225
+ const acceptsStyle = component === null || !['ActionForm', 'Page'].includes(component)
1226
+ result = visualTransform(opening.attributes, 'className', acceptsStyle ? 'style' : null, result.uppercase, `<${component ?? opening.tagName.getText(sourceFile)}>`)
1227
+ }
1228
+ if (result.opaque) return result
1229
+ }
1230
+ return result
1231
+ }
1232
+
1233
+ function noRenderedValue(node) {
1234
+ const value = resolveBinding(node, checker)
1235
+ if (
1236
+ value.kind === ts.SyntaxKind.NullKeyword ||
1237
+ value.kind === ts.SyntaxKind.TrueKeyword ||
1238
+ value.kind === ts.SyntaxKind.FalseKeyword ||
1239
+ ts.isVoidExpression(value)
1240
+ ) return true
1241
+ if (ts.isIdentifier(value) && value.text === 'undefined') {
1242
+ return checker.getSymbolAtLocation(value) === undefined
1243
+ }
1244
+ return false
1245
+ }
1246
+
1247
+ function numericLiteral(node) {
1248
+ if (ts.isNumericLiteral(node)) return { value: Number(node.text), text: node.text }
1249
+ if (
1250
+ ts.isPrefixUnaryExpression(node) &&
1251
+ (node.operator === ts.SyntaxKind.PlusToken || node.operator === ts.SyntaxKind.MinusToken) &&
1252
+ ts.isNumericLiteral(node.operand)
1253
+ ) {
1254
+ const value = Number(node.operand.text) * (node.operator === ts.SyntaxKind.MinusToken ? -1 : 1)
1255
+ return { value, text: Object.is(value, -0) ? '0' : String(value) }
1256
+ }
1257
+ return null
1258
+ }
1259
+
1260
+ function staticTruthiness(node) {
1261
+ const value = resolveBinding(node, checker)
1262
+ if (value.kind === ts.SyntaxKind.TrueKeyword) return true
1263
+ if (value.kind === ts.SyntaxKind.FalseKeyword || value.kind === ts.SyntaxKind.NullKeyword || ts.isVoidExpression(value)) return false
1264
+ if (ts.isIdentifier(value) && value.text === 'undefined' && checker.getSymbolAtLocation(value) === undefined) return false
1265
+ const numeric = numericLiteral(value)
1266
+ if (numeric !== null) return numeric.value !== 0 && !Number.isNaN(numeric.value)
1267
+ const text = staticText(value, checker)
1268
+ if (text !== null) return text.text !== ''
1269
+ if (ts.isObjectLiteralExpression(value) || ts.isArrayLiteralExpression(value) || ts.isArrowFunction(value) || ts.isFunctionExpression(value)) return true
1270
+ return null
1271
+ }
1272
+
1273
+ function mergeChildResults(results) {
1274
+ const rendered = []
1275
+ for (const result of results) {
1276
+ if (result.kind === 'opaque') return result
1277
+ if (result.kind !== 'decorative') rendered.push(result)
1278
+ }
1279
+ if (rendered.length === 0) return { kind: 'decorative' }
1280
+ // Inserir espaço cegamente entre fragmentos muda copy como
1281
+ // `Excluir <strong>agora</strong>!`. Sem modelar o layout/nome acessível completo,
1282
+ // múltiplos fragmentos permanecem opacos; uma única expressão (inclusive uma
1283
+ // união finita de branches) é inequívoca.
1284
+ if (rendered.length > 1) return { kind: 'opaque', node: rendered[1].node ?? rendered[1].values[0].node }
1285
+ return rendered[0]
1286
+ }
1287
+
1288
+ function alternateTexts(...results) {
1289
+ const values = []
1290
+ for (const result of results) {
1291
+ if (result.kind === 'text') values.push(result)
1292
+ else if (result.kind === 'alternatives') values.push(...result.values)
1293
+ else return null
1294
+ }
1295
+ const unique = new Map()
1296
+ for (const value of values) {
1297
+ const previous = unique.get(value.text)
1298
+ unique.set(value.text, previous === undefined ? value : { ...previous, uppercase: previous.uppercase || value.uppercase })
1299
+ }
1300
+ const alternatives = [...unique.values()]
1301
+ if (alternatives.length === 1) return alternatives[0]
1302
+ return { kind: 'alternatives', values: alternatives, node: alternatives[0].node }
1303
+ }
1304
+
1305
+ function childExpression(expression, inheritedUppercase = false, hiddenAncestor = false) {
1306
+ const value = resolveBinding(expression, checker)
1307
+ if (noRenderedValue(value)) return { kind: 'decorative' }
1308
+ const text = staticText(value, checker)
1309
+ if (text !== null) {
1310
+ return text.text.trim() === ''
1311
+ ? { kind: 'decorative' }
1312
+ : { kind: 'text', ...text, uppercase: inheritedUppercase }
1313
+ }
1314
+ if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) {
1315
+ return childNode(value, inheritedUppercase, hiddenAncestor)
1316
+ }
1317
+ if (ts.isArrayLiteralExpression(value)) {
1318
+ return mergeChildResults(value.elements.map((element) => childExpression(element, inheritedUppercase, hiddenAncestor)))
1319
+ }
1320
+ if (ts.isConditionalExpression(value)) {
1321
+ const condition = resolveBinding(value.condition, checker)
1322
+ const truthy = staticTruthiness(condition)
1323
+ if (truthy === true) {
1324
+ return childExpression(value.whenTrue, inheritedUppercase, hiddenAncestor)
1325
+ }
1326
+ if (truthy === false) {
1327
+ return childExpression(value.whenFalse, inheritedUppercase, hiddenAncestor)
1328
+ }
1329
+ const yes = childExpression(value.whenTrue, inheritedUppercase, hiddenAncestor)
1330
+ const no = childExpression(value.whenFalse, inheritedUppercase, hiddenAncestor)
1331
+ if (yes.kind === 'decorative' && no.kind === 'decorative') return { kind: 'decorative' }
1332
+ if (yes.kind === 'text' && no.kind === 'decorative') return yes
1333
+ if (no.kind === 'text' && yes.kind === 'decorative') return no
1334
+ const alternatives = alternateTexts(yes, no)
1335
+ if (alternatives !== null) return alternatives
1336
+ }
1337
+ if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
1338
+ const condition = resolveBinding(value.left, checker)
1339
+ const truthy = staticTruthiness(condition)
1340
+ if (truthy === true) return childExpression(value.right, inheritedUppercase, hiddenAncestor)
1341
+ const numeric = numericLiteral(condition)
1342
+ if (numeric !== null) {
1343
+ if (numeric.value === 0 || Number.isNaN(numeric.value)) {
1344
+ return { kind: 'text', text: numeric.text, node: condition, uppercase: inheritedUppercase }
1345
+ }
1346
+ }
1347
+ if (truthy === false) return { kind: 'decorative' }
1348
+ return { kind: 'opaque', node: condition }
1349
+ }
1350
+ return { kind: 'opaque', node: value }
1351
+ }
1352
+
1353
+ function childNode(child, inheritedUppercase = false, hiddenAncestor = false) {
1354
+ if (ts.isJsxText(child)) {
1355
+ const decoded = decodeJsxEntities(child.text)
1356
+ if (decoded === null) return { kind: 'opaque', node: child }
1357
+ const text = decoded.replace(/\s+/gu, ' ').trim()
1358
+ return text === '' ? { kind: 'decorative' } : { kind: 'text', text, node: child, uppercase: inheritedUppercase }
1359
+ }
1360
+ if (ts.isJsxExpression(child)) {
1361
+ return child.expression === undefined
1362
+ ? { kind: 'decorative' }
1363
+ : childExpression(child.expression, inheritedUppercase, hiddenAncestor)
1364
+ }
1365
+ if (ts.isJsxFragment(child)) return childList(child.children, inheritedUppercase, hiddenAncestor)
1366
+ if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
1367
+ const opening = ts.isJsxElement(child) ? child.openingElement : child
1368
+ const component = opusComponent(opening.tagName)
1369
+ const provenElement = transformableElement(opening, component)
1370
+ const hidden = attribute(opening.attributes, 'aria-hidden')
1371
+ if (hidden.opaque !== undefined) return { kind: 'opaque', node: hidden.opaque }
1372
+ const hiddenValue = attributeBoolean(hidden)
1373
+ if (hidden.candidate !== undefined && hiddenValue === null) return { kind: 'opaque', node: attributeValue(hidden) ?? opening }
1374
+ // `aria-hidden` removes a subtree from the accessibility tree, not from the
1375
+ // rendered UI. It is therefore enough to classify a leaf icon as decorative,
1376
+ // but it must never hide visual text from the copy inventory.
1377
+ const subtreeHidden = hiddenAncestor || hiddenValue === true
1378
+ const dangerous = attribute(opening.attributes, 'dangerouslySetInnerHTML')
1379
+ if (dangerous.opaque !== undefined || dangerous.candidate !== undefined) {
1380
+ return { kind: 'opaque', node: dangerous.opaque ?? attributeValue(dangerous) ?? opening }
1381
+ }
1382
+ // Props de um componente local/terceiro não provam nada sobre o DOM que ele
1383
+ // renderiza. Preserve a herança conhecida e recurse seus children, mas não use
1384
+ // className/style/aria-hidden locais para cancelar ou inventar transformação.
1385
+ if (!provenElement) {
1386
+ if (subtreeHidden) {
1387
+ if (ts.isJsxSelfClosingElement(child)) return { kind: 'decorative' }
1388
+ if (!child.children.some(hasRuntimeChild)) return { kind: 'decorative' }
1389
+ return childList(child.children, inheritedUppercase, true)
1390
+ }
1391
+ if (ts.isJsxSelfClosingElement(child)) return { kind: 'opaque', node: child }
1392
+ if (!child.children.some(hasRuntimeChild)) return { kind: 'opaque', node: child }
1393
+ return childList(child.children, inheritedUppercase, false)
1394
+ }
1395
+ if (subtreeHidden) {
1396
+ if (ts.isJsxSelfClosingElement(child)) return { kind: 'decorative' }
1397
+ if (!child.children.some(hasRuntimeChild)) return { kind: 'decorative' }
1398
+ const transformed = visualTransform(opening.attributes, 'className', 'style', inheritedUppercase, opening.tagName.getText(sourceFile))
1399
+ if (transformed.opaque) return { kind: 'opaque', node: attributeValue(attribute(opening.attributes, 'className')) ?? opening }
1400
+ return childList(child.children, transformed.uppercase, true)
1401
+ }
1402
+ if (ts.isJsxSelfClosingElement(child)) return { kind: 'opaque', node: child }
1403
+ if (!child.children.some(hasRuntimeChild)) return { kind: 'opaque', node: child }
1404
+ const transformed = visualTransform(opening.attributes, 'className', 'style', inheritedUppercase, opening.tagName.getText(sourceFile))
1405
+ if (transformed.opaque) return { kind: 'opaque', node: attributeValue(attribute(opening.attributes, 'className')) ?? opening }
1406
+ return childList(child.children, transformed.uppercase, false)
1407
+ }
1408
+ return { kind: 'opaque', node: child }
1409
+ }
1410
+
1411
+ function childList(children, inheritedUppercase = false, hiddenAncestor = false) {
1412
+ return mergeChildResults(children.map((child) => childNode(child, inheritedUppercase, hiddenAncestor)))
1413
+ }
1414
+
1415
+ function hasRuntimeChild(child) {
1416
+ if (ts.isJsxText(child)) return child.text.replace(/\s+/gu, ' ').trim() !== ''
1417
+ if (ts.isJsxExpression(child)) return child.expression !== undefined
1418
+ return true
1419
+ }
1420
+
1421
+ function addVisualObjectProperty(object, name, role, prefix, inheritedUppercase = false) {
1422
+ const resolved = property(object, name, sourceFile, checker)
1423
+ if (resolved.opaque !== undefined) {
1424
+ diagnostics.push(diagnostic(file, sourceFile, resolved.opaque, `${prefix}${name}`))
1425
+ return null
1426
+ }
1427
+ if (resolved.candidate === undefined) return null
1428
+ const raw = propertyValue(resolved.candidate)
1429
+ const value = resolveBinding(raw, checker)
1430
+ if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value) || ts.isArrayLiteralExpression(value)) {
1431
+ const child = childExpression(value, inheritedUppercase)
1432
+ if (child.kind === 'opaque') diagnostics.push(diagnostic(file, sourceFile, child.node, `${prefix}${name}`))
1433
+ const texts = child.kind === 'text' ? [child] : child.kind === 'alternatives' ? child.values : []
1434
+ for (const text of texts) {
1435
+ const entry = { source: file, line: lineOf(sourceFile, text.node), role, text: text.text }
1436
+ if (text.uppercase) entry.transform = 'uppercase'
1437
+ entries.push(entry)
1438
+ }
1439
+ return texts[0] ?? null
1440
+ }
1441
+ const entry = add(raw, role, `${prefix}${name}`)
1442
+ if (entry !== null && inheritedUppercase) entry.transform = 'uppercase'
1443
+ return entry
1444
+ }
1445
+
1446
+ function actionTriggerIconState(opening) {
1447
+ const icon = attribute(opening.attributes, 'icon')
1448
+ if (icon.opaque !== undefined) return { kind: 'unknown', node: icon.opaque }
1449
+ if (icon.candidate === undefined) return { kind: 'absent' }
1450
+ const raw = attributeValue(icon)
1451
+ // O componente usa `icon !== undefined`: null/false também ativam o tooltip, e o
1452
+ // rótulo deixa de ser descendente do botão. Só `undefined` comprovado mantém texto.
1453
+ if (raw === null) return { kind: 'present' }
1454
+ const value = resolveBinding(raw, checker)
1455
+ if (ts.isVoidExpression(value)) return { kind: 'absent' }
1456
+ if (ts.isIdentifier(value) && value.text === 'undefined' && checker.getSymbolAtLocation(value) === undefined) {
1457
+ return { kind: 'absent' }
1458
+ }
1459
+ if (ts.isConditionalExpression(value)) {
1460
+ const yes = actionTriggerIconValueState(value.whenTrue)
1461
+ const no = actionTriggerIconValueState(value.whenFalse)
1462
+ if (yes === no && yes !== 'unknown') return { kind: yes }
1463
+ }
1464
+ if (actionTriggerIconValueState(value) === 'present') return { kind: 'present' }
1465
+ return { kind: 'unknown', node: value }
1466
+ }
1467
+
1468
+ function actionTriggerIconValueState(node) {
1469
+ const value = resolveBinding(node, checker)
1470
+ if (ts.isVoidExpression(value)) return 'absent'
1471
+ if (ts.isIdentifier(value) && value.text === 'undefined' && checker.getSymbolAtLocation(value) === undefined) return 'absent'
1472
+ if (
1473
+ value.kind === ts.SyntaxKind.NullKeyword ||
1474
+ value.kind === ts.SyntaxKind.TrueKeyword ||
1475
+ value.kind === ts.SyntaxKind.FalseKeyword ||
1476
+ ts.isStringLiteralLike(value) ||
1477
+ ts.isNumericLiteral(value) ||
1478
+ ts.isBigIntLiteral(value) ||
1479
+ ts.isObjectLiteralExpression(value) ||
1480
+ ts.isArrayLiteralExpression(value) ||
1481
+ ts.isJsxElement(value) ||
1482
+ ts.isJsxSelfClosingElement(value) ||
1483
+ ts.isJsxFragment(value) ||
1484
+ ts.isArrowFunction(value) ||
1485
+ ts.isFunctionExpression(value)
1486
+ ) return 'present'
1487
+ return 'unknown'
1488
+ }
1489
+
1490
+ function actionTriggerLabelTransform(opening, inheritedUppercase = false) {
1491
+ const icon = actionTriggerIconState(opening)
1492
+ if (icon.kind === 'present') return { opaque: false, uppercase: false }
1493
+ const transformed = visualTransform(opening.attributes, 'className', 'style', inheritedUppercase, '<ActionTrigger>')
1494
+ if (icon.kind === 'absent' || transformed.opaque || !transformed.uppercase) return transformed
1495
+ // O label pode estar no botão (herda a classe) ou no tooltip (portal). Sem resolver
1496
+ // o discriminador, marcar uppercase seria uma garantia falsa; o gate pede explicitação.
1497
+ transformDiagnostic(icon.node, '<ActionTrigger>.icon')
1498
+ return { opaque: true, uppercase: false }
1499
+ }
1500
+
1501
+ function selectMode(opening, component) {
1502
+ if (component !== 'Select') return null
1503
+ const native = attribute(opening.attributes, 'native')
1504
+ if (native.opaque !== undefined) {
1505
+ diagnostics.push(diagnostic(file, sourceFile, native.opaque, '<Select>.native'))
1506
+ return 'opaque'
1507
+ }
1508
+ if (native.candidate === undefined) return 'custom'
1509
+ const value = attributeBoolean(native)
1510
+ if (value === null) {
1511
+ diagnostics.push(diagnostic(file, sourceFile, attributeValue(native) ?? opening, '<Select>.native'))
1512
+ return 'opaque'
1513
+ }
1514
+ return value ? 'native' : 'custom'
1515
+ }
1516
+
1517
+ function extractJsx(element) {
1518
+ const opening = ts.isJsxElement(element) ? element.openingElement : element
1519
+ const component = opusComponent(opening.tagName)
1520
+ if (component === null) return
1521
+ let inherited
1522
+ const inheritedFor = (className, styleName, portal = false) => {
1523
+ if (portal || localTransformOverridesInheritance(opening.attributes, className, styleName)) return false
1524
+ inherited ??= ancestorTransform(element)
1525
+ return inherited.opaque ? false : inherited.uppercase
1526
+ }
1527
+ const actionFormCardBodyTransform = () => {
1528
+ // O form é descendente do Card. Uma transformação incondicional no próprio form
1529
+ // corta toda herança anterior; caso contrário, cardClassName participa primeiro.
1530
+ if (localTransformOverridesInheritance(opening.attributes, 'className', null)) {
1531
+ return visualTransform(opening.attributes, 'className', null, false, '<ActionFormCard>')
1532
+ }
1533
+ const card = visualTransform(
1534
+ opening.attributes,
1535
+ 'cardClassName',
1536
+ null,
1537
+ inheritedFor('cardClassName', null),
1538
+ '<ActionFormCard>',
1539
+ )
1540
+ if (card.opaque) return card
1541
+ return visualTransform(opening.attributes, 'className', null, card.uppercase, '<ActionFormCard>')
1542
+ }
1543
+ const mode = selectMode(opening, component)
1544
+
1545
+ for (const name of OPAQUE_SLOT_PROPS.get(component) ?? []) {
1546
+ const slot = attribute(opening.attributes, name)
1547
+ if (slot.opaque !== undefined) diagnostics.push(diagnostic(file, sourceFile, slot.opaque, `<${component}>.${name}`))
1548
+ else if (slot.candidate !== undefined) {
1549
+ const raw = attributeValue(slot)
1550
+ if (raw === null || !noRenderedValue(raw)) diagnostics.push(diagnostic(file, sourceFile, raw ?? opening, `<${component}>.${name}`))
1551
+ }
1552
+ }
1553
+
1554
+ const props = JSX_PROP_ROLES.get(component)
1555
+ for (const [name, role] of props ?? []) {
1556
+ if (component === 'Select' && mode === 'native' && name !== 'placeholder') continue
1557
+ const item = attribute(opening.attributes, name)
1558
+ if (item.opaque !== undefined) diagnostics.push(diagnostic(file, sourceFile, item.opaque, `<${component}>.${name}`))
1559
+ else if (item.candidate === undefined) continue
1560
+ else {
1561
+ const classProp = JSX_PROP_CLASS.get(component)?.get(name)
1562
+ const styleProp = JSX_PROP_STYLE.get(component)?.get(name) ?? null
1563
+ const inheritedForSurface = component === 'ActionFormCard' && classProp === 'className'
1564
+ ? false
1565
+ : inheritedFor(
1566
+ classProp ?? null,
1567
+ styleProp,
1568
+ component === 'ActionFormDialog' || (component === 'Select' && name !== 'placeholder'),
1569
+ )
1570
+ const transformed = component === 'ActionTrigger' && name === 'label'
1571
+ ? actionTriggerLabelTransform(opening, inheritedForSurface)
1572
+ : component === 'ActionFormCard' && classProp === 'className'
1573
+ ? actionFormCardBodyTransform()
1574
+ : classProp === undefined
1575
+ ? { opaque: false, uppercase: inheritedForSurface }
1576
+ : visualTransform(
1577
+ opening.attributes,
1578
+ classProp,
1579
+ styleProp,
1580
+ inheritedForSurface,
1581
+ `<${component}>`,
1582
+ )
1583
+ const raw = attributeValue(item)
1584
+ const resolved = raw === null ? null : resolveBinding(raw, checker)
1585
+ if (resolved !== null && (ts.isJsxElement(resolved) || ts.isJsxSelfClosingElement(resolved) || ts.isJsxFragment(resolved) || ts.isArrayLiteralExpression(resolved))) {
1586
+ const child = childExpression(resolved, transformed.uppercase)
1587
+ if (child.kind === 'opaque') diagnostics.push(diagnostic(file, sourceFile, child.node, `<${component}>.${name}`))
1588
+ else {
1589
+ const texts = child.kind === 'text' ? [child] : child.kind === 'alternatives' ? child.values : []
1590
+ for (const text of texts) {
1591
+ const entry = { source: file, line: lineOf(sourceFile, text.node), role, text: text.text }
1592
+ if (text.uppercase) entry.transform = 'uppercase'
1593
+ entries.push(entry)
1594
+ }
1595
+ }
1596
+ } else {
1597
+ const value = attributeText(item)
1598
+ if (value === null) diagnostics.push(diagnostic(file, sourceFile, raw ?? opening, `<${component}>.${name}`))
1599
+ else {
1600
+ const entry = { source: file, line: lineOf(sourceFile, value.node), role, text: value.text }
1601
+ if (transformed.uppercase) entry.transform = 'uppercase'
1602
+ entries.push(entry)
1603
+ }
1604
+ }
1605
+ }
1606
+ }
1607
+
1608
+ if (component === 'ActionTrigger') {
1609
+ const label = attribute(opening.attributes, 'label')
1610
+ if (label.candidate === undefined && label.opaque === undefined) {
1611
+ const transformed = actionTriggerLabelTransform(
1612
+ opening,
1613
+ inheritedFor('className', 'style'),
1614
+ )
1615
+ if (transformed.uppercase) {
1616
+ diagnostics.push(diagnostic(file, sourceFile, opening, '<ActionTrigger>.label herdado do contrato sob uppercase'))
1617
+ }
1618
+ }
1619
+ }
1620
+
1621
+ for (const [name, nestedRoles] of JSX_OBJECT_PROP_ROLES.get(component) ?? []) {
1622
+ const item = attribute(opening.attributes, name)
1623
+ if (item.opaque !== undefined) diagnostics.push(diagnostic(file, sourceFile, item.opaque, `<${component}>.${name}`))
1624
+ else if (item.candidate !== undefined) {
1625
+ const raw = attributeValue(item)
1626
+ const object = raw === null ? null : resolveBinding(raw, checker)
1627
+ if (object?.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(object) && object.text === 'undefined')) continue
1628
+ if (!ts.isObjectLiteralExpression(object)) diagnostics.push(diagnostic(file, sourceFile, raw ?? opening, `<${component}>.${name}`))
1629
+ else for (const [nestedName, role] of nestedRoles) addProperty(object, nestedName, role, `<${component}>.${name}.`)
1630
+ }
1631
+ }
1632
+
1633
+ for (const [name, optionRoles] of JSX_ARRAY_PROP_ROLES.get(component) ?? []) {
1634
+ const item = attribute(opening.attributes, name)
1635
+ if (item.opaque !== undefined) diagnostics.push(diagnostic(file, sourceFile, item.opaque, `<${component}>.${name}`))
1636
+ else if (item.candidate !== undefined) {
1637
+ const raw = attributeValue(item)
1638
+ const listed = raw === null ? { values: [], opaque: [opening] } : objects(raw, checker)
1639
+ for (const opaque of listed.opaque) diagnostics.push(diagnostic(file, sourceFile, opaque, `<${component}>.${name}`))
1640
+ for (const [index, option] of listed.values.entries()) {
1641
+ for (const [optionName, role] of optionRoles) {
1642
+ if (mode === 'native' && !['label', 'group'].includes(optionName)) continue
1643
+ const affectsTrigger = optionName === 'label' || (mode === 'custom' && optionName === 'triggerLabel')
1644
+ const affectsNative = mode === 'native' && (optionName === 'label' || optionName === 'group')
1645
+ const transformed = affectsTrigger || affectsNative
1646
+ ? visualTransform(
1647
+ opening.attributes,
1648
+ 'className',
1649
+ null,
1650
+ inheritedFor('className', null),
1651
+ `<${component}>`,
1652
+ )
1653
+ : { opaque: false, uppercase: false }
1654
+ addVisualObjectProperty(
1655
+ option,
1656
+ optionName,
1657
+ role,
1658
+ `<${component}>.${name}[${index}].`,
1659
+ transformed.uppercase,
1660
+ )
1661
+ }
1662
+ }
1663
+ }
1664
+ }
1665
+
1666
+ const ariaLabelAttribute = attribute(opening.attributes, 'aria-label')
1667
+ let hasStaticAriaLabel = false
1668
+ if (ariaLabelAttribute.opaque !== undefined) {
1669
+ diagnostics.push(diagnostic(file, sourceFile, ariaLabelAttribute.opaque, `<${component}>.aria-label`))
1670
+ } else if (ariaLabelAttribute.candidate !== undefined) {
1671
+ const ariaLabel = attributeText(ariaLabelAttribute)
1672
+ if (ariaLabel === null) diagnostics.push(diagnostic(file, sourceFile, attributeValue(ariaLabelAttribute) ?? opening, `<${component}>.aria-label`))
1673
+ if (ariaLabel !== null) {
1674
+ hasStaticAriaLabel = true
1675
+ entries.push({ source: file, line: lineOf(sourceFile, ariaLabel.node), role: 'label', text: ariaLabel.text })
1676
+ }
1677
+ }
1678
+
1679
+ const childRole = JSX_CHILD_ROLES.get(component)
1680
+ if (childRole !== undefined) {
1681
+ const childValue = (inheritedUppercase) => {
1682
+ // Quebras/indentação puras não viram `children` no JSX emitido e, portanto,
1683
+ // não sobrescrevem uma prop `children="..."` explícita.
1684
+ if (ts.isJsxElement(element) && element.children.some(hasRuntimeChild)) {
1685
+ if (
1686
+ OPTIONAL_CHILD_COMPONENTS.has(component) &&
1687
+ element.children.some((item) => ts.isJsxElement(item) || ts.isJsxSelfClosingElement(item) || ts.isJsxFragment(item))
1688
+ ) return { kind: 'opaque', node: element.children.find((item) => ts.isJsxElement(item) || ts.isJsxSelfClosingElement(item) || ts.isJsxFragment(item)) }
1689
+ return childList(element.children, inheritedUppercase)
1690
+ }
1691
+ const children = attribute(opening.attributes, 'children')
1692
+ if (children.opaque !== undefined) return { kind: 'opaque', node: children.opaque }
1693
+ if (children.candidate === undefined) return { kind: 'decorative' }
1694
+ const raw = attributeValue(children)
1695
+ return raw === null ? { kind: 'opaque', node: opening } : childExpression(raw, inheritedUppercase)
1696
+ }
1697
+ const inheritedUppercase = component === 'ActionFormDialog'
1698
+ ? false
1699
+ : component === 'ActionFormCard'
1700
+ ? actionFormCardBodyTransform().uppercase
1701
+ : inheritedFor('className', 'style')
1702
+ let value = childValue(inheritedUppercase)
1703
+ if (value.kind === 'text' || value.kind === 'alternatives') {
1704
+ const transformed = component === 'ActionFormDialog'
1705
+ ? { opaque: false, uppercase: false }
1706
+ : component === 'ActionFormCard'
1707
+ ? actionFormCardBodyTransform()
1708
+ : visualTransform(opening.attributes, 'className', 'style', inheritedUppercase, `<${component}>`)
1709
+ value = transformed.opaque ? { kind: 'opaque', node: attributeValue(attribute(opening.attributes, 'className')) ?? opening } : childValue(transformed.uppercase)
1710
+ }
1711
+ if (value.kind === 'opaque') diagnostics.push(diagnostic(file, sourceFile, value.node, `<${component}> children`))
1712
+ else if (value.kind === 'decorative') {
1713
+ if (!hasStaticAriaLabel && !OPTIONAL_CHILD_COMPONENTS.has(component)) diagnostics.push(diagnostic(file, sourceFile, element, `<${component}> children`))
1714
+ } else {
1715
+ const texts = value.kind === 'text' ? [value] : value.values
1716
+ for (const text of texts) {
1717
+ const entry = { source: file, line: lineOf(sourceFile, text.node), role: childRole, text: text.text }
1718
+ if (text.uppercase) entry.transform = 'uppercase'
1719
+ entries.push(entry)
1720
+ }
1721
+ }
1722
+ }
1723
+ }
1724
+
1725
+ function visit(node) {
1726
+ const factory = ts.isCallExpression(node) ? contractFactory(node.expression) : null
1727
+ if (ts.isCallExpression(node) && factory !== null && node.arguments.length > 0) {
1728
+ hasContract = true
1729
+ if (!stableFactoryResult(node, checker, new Set())) {
1730
+ diagnostics.push(diagnostic(file, sourceFile, node, `${factory}(...) result`))
1731
+ } else {
1732
+ const object = resolveBinding(node.arguments[0], checker)
1733
+ if (ts.isObjectLiteralExpression(object)) extractContract(object)
1734
+ else diagnostics.push(diagnostic(file, sourceFile, node.arguments[0], `${factory}(...)`))
1735
+ }
1736
+ }
1737
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) extractJsx(node)
1738
+ ts.forEachChild(node, visit)
1739
+ }
1740
+ visit(sourceFile)
1741
+
1742
+ return { hasContract, hasCopySurface: hasContract || hasUiImport, entries, diagnostics }
1743
+ }
1744
+
1745
+ function globRegex(pattern) {
1746
+ const escaped = portable(pattern)
1747
+ .split('**')
1748
+ .map((part) => part.split('*').map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*'))
1749
+ .join('.*')
1750
+ return new RegExp(`^${escaped}$`)
1751
+ }
1752
+
1753
+ function sourceFiles(root, excluded = []) {
1754
+ const output = []
1755
+ const diagnostics = []
1756
+ const walk = (directory = '.') => {
1757
+ let listing
1758
+ try {
1759
+ listing = readProjectDirectory(root, directory)
1760
+ } catch (error) {
1761
+ diagnostics.push({
1762
+ source: portable(directory),
1763
+ line: 1,
1764
+ message: `diretório de source inseguro ou instável: ${projectPathErrorMessage(error)}`,
1765
+ })
1766
+ return
1767
+ }
1768
+ for (const item of listing.entries.sort((a, b) => a.name.localeCompare(b.name))) {
1769
+ const target = path.join(directory, item.name)
1770
+ const source = portable(target === '.' ? item.name : target)
1771
+ if (IGNORED_DIRECTORIES.has(item.name)) continue
1772
+ if (item.isSymbolicLink()) {
1773
+ if (!excluded.some((pattern) => globRegex(pattern).test(source))) {
1774
+ diagnostics.push({
1775
+ source,
1776
+ line: 1,
1777
+ message: 'link simbólico dentro do escopo percorrido pode ocultar um diretório de sources; remova-o ou exclua o caminho explicitamente em copy.exclude.',
1778
+ })
1779
+ }
1780
+ continue
1781
+ }
1782
+ if (item.isDirectory()) {
1783
+ let inspected
1784
+ try {
1785
+ inspected = safeProjectPath(root, target, { mustExist: true })
1786
+ } catch (error) {
1787
+ diagnostics.push({ source, line: 1, message: `diretório de source inseguro: ${projectPathErrorMessage(error)}` })
1788
+ continue
1789
+ }
1790
+ if (inspected.kind !== 'directory') {
1791
+ diagnostics.push({ source, line: 1, message: 'entrada de source deixou de ser diretório durante a descoberta.' })
1792
+ continue
1793
+ }
1794
+ walk(target)
1795
+ } else if (
1796
+ item.isFile() &&
1797
+ SOURCE_EXTENSIONS.has(path.extname(item.name)) &&
1798
+ !IGNORED_FILE.test(item.name) &&
1799
+ !excluded.some((pattern) => globRegex(pattern).test(source))
1800
+ ) {
1801
+ try {
1802
+ const inspected = safeProjectPath(root, target, { mustExist: true })
1803
+ if (inspected.kind !== 'file') {
1804
+ diagnostics.push({ source, line: 1, message: 'source deixou de ser arquivo regular durante a descoberta.' })
1805
+ } else {
1806
+ output.push(source)
1807
+ }
1808
+ } catch (error) {
1809
+ diagnostics.push({ source, line: 1, message: `source inseguro: ${projectPathErrorMessage(error)}` })
1810
+ }
1811
+ }
1812
+ }
1813
+ }
1814
+ walk()
1815
+ return { files: output, diagnostics }
1816
+ }
1817
+
1818
+ export function copyInventoryPath(root) {
1819
+ return copyConfiguration(root).inventoryPath
1820
+ }
1821
+
1822
+ function copyConfiguration(root) {
1823
+ let file
1824
+ try {
1825
+ file = readProjectFile(root, 'base.json', { allowMissing: true })
1826
+ } catch (error) {
1827
+ throw new Error(`base.json: ${projectPathErrorMessage(error)}`)
1828
+ }
1829
+ if (!file.exists) {
1830
+ const inventoryPath = safeInventoryPath(root, DEFAULT_COPY_INVENTORY)
1831
+ return {
1832
+ configFile: file,
1833
+ inventory: portable(path.relative(safeProjectPath(root, '.').root, inventoryPath)),
1834
+ inventoryPath,
1835
+ exemptions: [],
1836
+ transforms: [],
1837
+ exclude: [],
1838
+ }
1839
+ }
1840
+ let config
1841
+ try {
1842
+ config = JSON.parse(file.content).copy
1843
+ } catch {
1844
+ throw new Error('base.json não é JSON válido.')
1845
+ }
1846
+ const inventory = config?.inventory ?? DEFAULT_COPY_INVENTORY
1847
+ if (typeof inventory !== 'string' || inventory.trim() === '') {
1848
+ throw new Error('base.json: copy.inventory deve ser um caminho não vazio.')
1849
+ }
1850
+ const target = safeInventoryPath(root, inventory)
1851
+ return {
1852
+ configFile: file,
1853
+ inventory: portable(path.relative(safeProjectPath(root, '.').root, target)),
1854
+ inventoryPath: target,
1855
+ exemptions: config?.exemptions ?? [],
1856
+ transforms: config?.transforms ?? [],
1857
+ exclude: config?.exclude ?? [],
1858
+ }
1859
+ }
1860
+
1861
+ function selectorDiagnostic(message) {
1862
+ return { source: 'base.json', line: 1, message }
1863
+ }
1864
+
1865
+ function sameProjectFile(left, right) {
1866
+ return (
1867
+ left.exists === right.exists &&
1868
+ (!left.exists ||
1869
+ (left.identity.dev === right.identity.dev &&
1870
+ left.identity.ino === right.identity.ino &&
1871
+ left.mode === right.mode &&
1872
+ left.modifiedAtMs === right.modifiedAtMs &&
1873
+ left.content === right.content))
1874
+ )
1875
+ }
1876
+
1877
+ function verifyConfiguration(root, expected) {
1878
+ let current
1879
+ try {
1880
+ current = readProjectFile(root, 'base.json', { allowMissing: true })
1881
+ } catch (error) {
1882
+ throw new Error(`base.json: ${projectPathErrorMessage(error)}`)
1883
+ }
1884
+ if (!sameProjectFile(expected, current)) throw new Error('base.json mudou durante a extração de copy.')
1885
+ }
1886
+
1887
+ function verifyCopyInputs(root, consistency) {
1888
+ verifyConfiguration(root, consistency.configFile)
1889
+ const discovered = sourceFiles(root, consistency.excluded)
1890
+ if (discovered.diagnostics.length > 0) {
1891
+ const first = discovered.diagnostics[0]
1892
+ throw new Error(`${first.source}: ${first.message}`)
1893
+ }
1894
+ if (JSON.stringify(discovered.files) !== JSON.stringify(consistency.discoveredSources)) {
1895
+ throw new Error('o conjunto de sources mudou durante a geração do inventário de copy.')
1896
+ }
1897
+ for (const snapshot of consistency.sourceSnapshots) {
1898
+ const current = readProjectFile(root, snapshot.source)
1899
+ if (!sameProjectFile(snapshot.file, current)) {
1900
+ throw new Error(`${snapshot.source}: source mudou durante a geração do inventário de copy.`)
1901
+ }
1902
+ }
1903
+ verifyConfiguration(root, consistency.configFile)
1904
+ }
1905
+
1906
+ function findEntry(entries, selector) {
1907
+ return entries
1908
+ .map((entry, index) => ({ entry, index }))
1909
+ .filter(({ entry }) =>
1910
+ entry.source === selector.source &&
1911
+ entry.line === selector.line &&
1912
+ entry.role === selector.role &&
1913
+ (selector.text === undefined || entry.text === selector.text),
1914
+ )
1915
+ }
1916
+
1917
+ function applyProjectMetadata(config, entries, diagnostics) {
1918
+ if (!Array.isArray(config.exemptions)) {
1919
+ diagnostics.push(selectorDiagnostic('copy.exemptions deve ser uma lista.'))
1920
+ } else {
1921
+ for (const [index, exemption] of config.exemptions.entries()) {
1922
+ if (
1923
+ exemption === null ||
1924
+ typeof exemption !== 'object' ||
1925
+ typeof exemption.source !== 'string' ||
1926
+ !Number.isInteger(exemption.line) ||
1927
+ typeof exemption.role !== 'string' ||
1928
+ typeof exemption.ruleId !== 'string' ||
1929
+ !COPY_RULE_ID.test(exemption.ruleId) ||
1930
+ typeof exemption.kind !== 'string' ||
1931
+ exemption.kind.trim() === '' ||
1932
+ typeof exemption.reason !== 'string' ||
1933
+ exemption.reason.trim().length < 12 ||
1934
+ typeof exemption.reference !== 'string' ||
1935
+ exemption.reference.trim() === ''
1936
+ ) {
1937
+ diagnostics.push(selectorDiagnostic(`copy.exemptions[${index}] exige seletor, ruleId, kind, reason e reference válidos.`))
1938
+ continue
1939
+ }
1940
+ const matches = findEntry(entries, exemption)
1941
+ if (matches.length !== 1) {
1942
+ diagnostics.push(selectorDiagnostic(`copy.exemptions[${index}] deve selecionar exatamente um texto; encontrou ${matches.length}.`))
1943
+ continue
1944
+ }
1945
+ const target = entries[matches[0].index]
1946
+ target.exemptions ??= []
1947
+ if (target.exemptions.some((item) => item.ruleId === exemption.ruleId)) {
1948
+ diagnostics.push(selectorDiagnostic(`copy.exemptions[${index}] repete ${exemption.ruleId} no mesmo texto.`))
1949
+ continue
1950
+ }
1951
+ target.exemptions.push({
1952
+ ruleId: exemption.ruleId,
1953
+ kind: exemption.kind.trim(),
1954
+ reason: exemption.reason.trim(),
1955
+ reference: exemption.reference.trim(),
1956
+ })
1957
+ }
1958
+ }
1959
+
1960
+ if (!Array.isArray(config.transforms)) {
1961
+ diagnostics.push(selectorDiagnostic('copy.transforms deve ser uma lista.'))
1962
+ } else {
1963
+ // Metadata declarativa complementa o que o extrator não consegue observar; ela
1964
+ // nunca pode enfraquecer um `uppercase` comprovado no JSX.
1965
+ const transformed = new Set(
1966
+ entries
1967
+ .map((entry, index) => (entry.transform === undefined ? null : index))
1968
+ .filter((index) => index !== null),
1969
+ )
1970
+ for (const [index, transform] of config.transforms.entries()) {
1971
+ if (
1972
+ transform === null ||
1973
+ typeof transform !== 'object' ||
1974
+ typeof transform.source !== 'string' ||
1975
+ !Number.isInteger(transform.line) ||
1976
+ typeof transform.role !== 'string' ||
1977
+ !['none', 'lowercase', 'uppercase'].includes(transform.transform)
1978
+ ) {
1979
+ diagnostics.push(selectorDiagnostic(`copy.transforms[${index}] tem seletor ou transform inválido.`))
1980
+ continue
1981
+ }
1982
+ const matches = findEntry(entries, transform)
1983
+ if (matches.length !== 1) {
1984
+ diagnostics.push(selectorDiagnostic(`copy.transforms[${index}] deve selecionar exatamente um texto; encontrou ${matches.length}.`))
1985
+ continue
1986
+ }
1987
+ const entryIndex = matches[0].index
1988
+ if (transformed.has(entryIndex)) {
1989
+ diagnostics.push(selectorDiagnostic(`copy.transforms[${index}] tenta sobrescrever um texto já transformado.`))
1990
+ continue
1991
+ }
1992
+ transformed.add(entryIndex)
1993
+ entries[entryIndex].transform = transform.transform
1994
+ }
1995
+ }
1996
+ }
1997
+
1998
+ export function extractCopyInventory(root) {
1999
+ const target = path.resolve(root)
2000
+ const config = copyConfiguration(target)
2001
+ const sources = []
2002
+ const sourceSnapshots = []
2003
+ const entries = []
2004
+ const diagnostics = []
2005
+
2006
+ if (!Array.isArray(config.exclude) || config.exclude.some((pattern) => typeof pattern !== 'string' || pattern.trim() === '')) {
2007
+ diagnostics.push(selectorDiagnostic('copy.exclude deve ser uma lista de globs não vazios.'))
2008
+ }
2009
+ const excluded = Array.isArray(config.exclude) ? config.exclude.filter((pattern) => typeof pattern === 'string') : []
2010
+ const discovered = sourceFiles(target, excluded)
2011
+ diagnostics.push(...discovered.diagnostics)
2012
+ for (const source of discovered.files) {
2013
+ let file
2014
+ try {
2015
+ file = readProjectFile(target, source)
2016
+ } catch (error) {
2017
+ diagnostics.push({ source, line: 1, message: `source inseguro ou instável: ${projectPathErrorMessage(error)}` })
2018
+ continue
2019
+ }
2020
+ const content = file.content
2021
+ const extracted = extractCopyFromSource(source, content)
2022
+ // `sources` é o manifesto observável do escopo realmente submetido ao parser,
2023
+ // inclusive quando um arquivo não contém contrato/allowlist ou produz zero copy.
2024
+ sources.push({ path: source, sha256: digest(content) })
2025
+ sourceSnapshots.push({ source, file })
2026
+ entries.push(...extracted.entries)
2027
+ diagnostics.push(...extracted.diagnostics)
2028
+ }
2029
+ for (const snapshot of sourceSnapshots) {
2030
+ try {
2031
+ const current = readProjectFile(target, snapshot.source)
2032
+ if (!sameProjectFile(snapshot.file, current)) {
2033
+ diagnostics.push({ source: snapshot.source, line: 1, message: 'source mudou durante a extração de copy.' })
2034
+ }
2035
+ } catch (error) {
2036
+ diagnostics.push({
2037
+ source: snapshot.source,
2038
+ line: 1,
2039
+ message: `source mudou ou se tornou inseguro durante a extração: ${projectPathErrorMessage(error)}`,
2040
+ })
2041
+ }
2042
+ }
2043
+ try {
2044
+ verifyConfiguration(target, config.configFile)
2045
+ } catch (error) {
2046
+ diagnostics.push(selectorDiagnostic(error.message))
2047
+ }
2048
+ if (sources.length === 0) {
2049
+ diagnostics.push(selectorDiagnostic(
2050
+ 'nenhum arquivo JavaScript/TypeScript elegível foi analisado; o inventário v2 exige ao menos uma fonte rastreável.',
2051
+ ))
2052
+ }
2053
+ applyProjectMetadata(config, entries, diagnostics)
2054
+
2055
+ const coveredSources = new Set(entries.map((entry) => entry.source)).size
2056
+
2057
+ const result = {
2058
+ inventory: {
2059
+ schemaVersion: COPY_INVENTORY_SCHEMA_VERSION,
2060
+ generator: { name: PACKAGE.name, version: PACKAGE.version },
2061
+ coverage: {
2062
+ protocol: COPY_COVERAGE_PROTOCOL,
2063
+ analyzedFiles: sources.length,
2064
+ coveredSources,
2065
+ extractedEntries: entries.length,
2066
+ },
2067
+ sources,
2068
+ entries,
2069
+ },
2070
+ diagnostics,
2071
+ }
2072
+ Object.defineProperties(result, {
2073
+ configuration: { value: config },
2074
+ consistency: {
2075
+ value: {
2076
+ configFile: config.configFile,
2077
+ discoveredSources: [...discovered.files],
2078
+ excluded: [...excluded],
2079
+ sourceSnapshots,
2080
+ },
2081
+ },
2082
+ })
2083
+ return result
2084
+ }
2085
+
2086
+ export function renderCopyInventory(inventory) {
2087
+ return `${JSON.stringify(inventory, null, 2)}\n`
2088
+ }
2089
+
2090
+ function ensureCopyParents(root, destination) {
2091
+ const pending = []
2092
+ let parent = path.dirname(destination)
2093
+ while (parent !== '.') {
2094
+ pending.unshift(parent)
2095
+ const next = path.dirname(parent)
2096
+ if (next === parent) throw new Error('copy.inventory deve ser relativo ao projeto.')
2097
+ parent = next
2098
+ }
2099
+ for (const directory of pending) ensureProjectDirectory(root, directory)
2100
+ }
2101
+
2102
+ /** Grava no mesmo diretório e renomeia; uma falha nunca deixa JSON parcial. */
2103
+ export function writeCopyInventory(root) {
2104
+ const targetRoot = path.resolve(root)
2105
+ const result = extractCopyInventory(targetRoot)
2106
+ if (result.diagnostics.length > 0) return { ok: false, changed: false, ...result }
2107
+
2108
+ const destination = result.configuration.inventory
2109
+ const target = result.configuration.inventoryPath
2110
+ const rendered = renderCopyInventory(result.inventory)
2111
+ let current
2112
+ try {
2113
+ current = readProjectFile(targetRoot, destination, { allowMissing: true })
2114
+ verifyCopyInputs(targetRoot, result.consistency)
2115
+ } catch (error) {
2116
+ throw new Error(`copy.inventory: ${projectPathErrorMessage(error) === 'operação de arquivo recusada por segurança.' ? error.message : projectPathErrorMessage(error)}`)
2117
+ }
2118
+ if (current.exists && current.content === rendered) {
2119
+ return { ok: true, changed: false, path: target, ...result }
2120
+ }
2121
+
2122
+ let written = null
2123
+ try {
2124
+ ensureCopyParents(targetRoot, destination)
2125
+ writeProjectFileAtomically(
2126
+ targetRoot,
2127
+ destination,
2128
+ rendered,
2129
+ { exists: current.exists, content: current.content },
2130
+ )
2131
+ written = readProjectFile(targetRoot, destination)
2132
+ verifyCopyInputs(targetRoot, result.consistency)
2133
+ } catch (error) {
2134
+ if (written !== null) {
2135
+ try {
2136
+ if (current.exists) {
2137
+ const rollbackCurrent = readProjectFile(targetRoot, destination)
2138
+ if (!sameProjectFile(written, rollbackCurrent)) {
2139
+ throw new Error('inventário mudou antes da reversão.')
2140
+ }
2141
+ writeProjectFileAtomically(
2142
+ targetRoot,
2143
+ destination,
2144
+ current.content,
2145
+ { exists: true, content: written.content },
2146
+ { mode: current.mode },
2147
+ )
2148
+ } else {
2149
+ removeProjectFileIfUnchanged(targetRoot, destination, written)
2150
+ }
2151
+ } catch (rollbackError) {
2152
+ throw new Error(
2153
+ `copy.inventory: as entradas mudaram e a reversão segura falhou: ${projectPathErrorMessage(rollbackError) === 'operação de arquivo recusada por segurança.' ? rollbackError.message : projectPathErrorMessage(rollbackError)}`,
2154
+ )
2155
+ }
2156
+ }
2157
+ throw new Error(`copy.inventory: ${projectPathErrorMessage(error) === 'operação de arquivo recusada por segurança.' ? error.message : projectPathErrorMessage(error)}`)
2158
+ }
2159
+ return { ok: true, changed: true, path: target, ...result }
2160
+ }
2161
+
2162
+ export function checkCopyInventory(root) {
2163
+ const targetRoot = path.resolve(root)
2164
+ const result = extractCopyInventory(targetRoot)
2165
+ if (result.diagnostics.length > 0) return { ok: false, reason: 'unsupported', ...result }
2166
+ const destination = result.configuration.inventory
2167
+ const target = result.configuration.inventoryPath
2168
+ let current
2169
+ try {
2170
+ current = readProjectFile(targetRoot, destination, { allowMissing: true })
2171
+ } catch (error) {
2172
+ return {
2173
+ ...result,
2174
+ ok: false,
2175
+ reason: 'unsafe',
2176
+ path: target,
2177
+ diagnostics: [
2178
+ ...result.diagnostics,
2179
+ selectorDiagnostic(`copy.inventory inseguro ou instável: ${projectPathErrorMessage(error) === 'operação de arquivo recusada por segurança.' ? error.message : projectPathErrorMessage(error)}`),
2180
+ ],
2181
+ }
2182
+ }
2183
+ if (!current.exists) return { ok: false, reason: 'missing', path: target, ...result }
2184
+ const expected = renderCopyInventory(result.inventory)
2185
+ const actual = current.content
2186
+ if (actual !== expected) return { ok: false, reason: 'stale', path: target, ...result }
2187
+ try {
2188
+ verifyCopyInputs(targetRoot, result.consistency)
2189
+ } catch (error) {
2190
+ return {
2191
+ ...result,
2192
+ ok: false,
2193
+ reason: 'unsafe',
2194
+ path: target,
2195
+ diagnostics: [
2196
+ ...result.diagnostics,
2197
+ selectorDiagnostic(`sources ou configuração mudaram antes da confirmação final: ${projectPathErrorMessage(error) === 'operação de arquivo recusada por segurança.' ? error.message : projectPathErrorMessage(error)}`),
2198
+ ],
2199
+ }
2200
+ }
2201
+ return { ok: true, reason: 'current', path: target, ...result }
2202
+ }