@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.
- package/CHANGELOG.md +63 -1
- package/README.md +31 -1
- package/bin/cli.mjs +97 -27
- package/bin/lib/check.mjs +55 -43
- package/bin/lib/copy.mjs +2202 -0
- package/bin/lib/create.mjs +227 -39
- package/bin/lib/db-migrate-runner.mjs +9 -6
- package/bin/lib/db-project-path.mjs +20 -0
- package/bin/lib/db-scaffold-runner.mjs +23 -8
- package/bin/lib/db.mjs +6 -4
- package/bin/lib/gen.mjs +60 -29
- package/bin/lib/init.mjs +212 -56
- package/bin/lib/introspect.mjs +3 -2
- package/bin/lib/materialize.mjs +623 -97
- package/bin/lib/postinstall.mjs +6 -5
- package/bin/lib/validate-skill.mjs +502 -30
- package/docs/code-style.md +142 -7
- package/docs/consumer-upgrade-propagation.md +4 -3
- package/docs/releasing.md +28 -17
- package/package.json +6 -1
- package/registry/git/pre-push.d/00-opus-copy +14 -0
- package/registry/git/pre-push.d/opus +7 -21
- package/registry/git/run-opus-pre-push.mjs +141 -0
- package/registry/hooks/opus-check-on-stop.mjs +13 -31
- package/registry/instructions/opus.md +11 -5
- package/registry/skills/build-opus-ui/SKILL.md +5 -4
- package/registry/skills/create-opus-action/SKILL.md +4 -4
- package/registry/skills/implement-opus-change/SKILL.md +7 -5
- package/registry/skills/upgrade-opus/SKILL.md +8 -4
- package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
- package/registry/templates/app/package.json +4 -0
- package/registry/templates/app/pnpm-workspace.yaml +3 -2
- package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
- package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
- package/src/ui/components/patterns/split.tsx +66 -24
- package/src/ui/docs/content/cli.md +8 -7
- package/src/ui/docs/content/communication.md +79 -126
- package/src/ui/docs/content/getting-started.md +29 -16
- package/src/ui/docs/content/split.md +43 -3
- package/src/ui/react.tsx +1 -1
- package/registry/skills/write-product-communication/SKILL.md +0 -28
- package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
- package/registry/templates/app/_npmrc +0 -1
- package/registry/templates/monorepo/_npmrc +0 -1
package/bin/lib/copy.mjs
ADDED
|
@@ -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
|
+
}
|