@softize/opus 27.1.0 → 29.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -0
- package/bin/cli.mjs +68 -3
- package/bin/lib/check.mjs +139 -30
- package/bin/lib/copy.mjs +505 -67
- package/bin/lib/materialize.mjs +99 -1
- package/docs/adr/0015-action-size-follows-interaction-density.md +2 -0
- package/docs/adr/0028-schedules-run-actions-as-the-system-by-action-opt-in.md +1 -1
- package/docs/adr/0029-page-header-is-the-single-page-introduction.md +3 -1
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
- package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +161 -0
- package/docs/adr/0061-claude-md-imports-agents-md.md +41 -0
- package/docs/adr/README.md +4 -0
- package/docs/code-style.md +47 -8
- package/docs/protocol.md +1 -1
- package/docs/releasing.md +7 -0
- package/package.json +1 -1
- package/registry/instructions/opus.md +3 -1
- package/registry/skills/build-opus-ui/SKILL.md +16 -14
- package/registry/skills/build-opus-ui/references/evaluations.md +10 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -15
- package/registry/skills/maintain-opus-docs/SKILL.md +2 -1
- package/registry/skills/maintain-opus-docs/references/editorial-standard.md +9 -0
- package/registry/skills/maintain-opus-docs/references/evaluations.md +8 -0
- package/registry/templates/app/pnpm-workspace.yaml +1 -1
- package/src/core/system-actor.ts +1 -1
- package/src/ui/components/patterns/list.tsx +63 -54
- package/src/ui/components/patterns/page.tsx +146 -38
- package/src/ui/components/patterns/presentation.tsx +44 -59
- package/src/ui/components/patterns/structural-header.tsx +42 -6
- package/src/ui/components/patterns/surface-anatomy.ts +52 -22
- package/src/ui/components/patterns/surface.tsx +2 -3
- package/src/ui/components/primitives/control.ts +2 -2
- package/src/ui/components/primitives/dialog.tsx +7 -2
- package/src/ui/components/primitives/drawer.tsx +7 -2
- package/src/ui/components/primitives/icon-picker.tsx +271 -34
- package/src/ui/components/primitives/pagination.tsx +5 -4
- package/src/ui/components/primitives/tabs.tsx +15 -2
- package/src/ui/components/primitives/toggle.tsx +1 -1
- package/src/ui/docs/content/action-list.md +12 -3
- package/src/ui/docs/content/actions.md +1 -1
- package/src/ui/docs/content/auth.md +3 -3
- package/src/ui/docs/content/button.md +7 -7
- package/src/ui/docs/content/cli.md +6 -0
- package/src/ui/docs/content/communication.md +10 -8
- package/src/ui/docs/content/compositions.md +35 -23
- package/src/ui/docs/content/dialog.md +18 -6
- package/src/ui/docs/content/drawer.md +17 -5
- package/src/ui/docs/content/events.md +13 -13
- package/src/ui/docs/content/icon-picker.md +37 -19
- package/src/ui/docs/content/input.md +1 -1
- package/src/ui/docs/content/installation.md +4 -0
- package/src/ui/docs/content/log.md +1 -1
- package/src/ui/docs/content/page.md +169 -69
- package/src/ui/docs/content/pagination.md +7 -8
- package/src/ui/docs/content/presentation.md +4 -3
- package/src/ui/docs/content/queue.md +2 -2
- package/src/ui/docs/content/router.md +1 -1
- package/src/ui/docs/content/scheduler.md +15 -15
- package/src/ui/docs/content/surface.md +8 -5
- package/src/ui/docs/content/tabs.md +12 -4
- package/src/ui/docs/content/testing.md +4 -4
- package/src/ui/meta.ts +1 -1
- package/src/ui/react.tsx +9 -1
- package/src/vite/design.ts +1 -1
package/bin/lib/copy.mjs
CHANGED
|
@@ -54,6 +54,7 @@ const IGNORED_FILE = /(?:\.d|\.spec|\.test|\.stories)\.[cm]?[jt]sx?$/u
|
|
|
54
54
|
// inclusive adições futuras, pertence à versão instalada da Base.
|
|
55
55
|
const COPY_RULE_ID = /^COPY\d{3}$/u
|
|
56
56
|
const COPY_DYNAMIC_KINDS = new Set(['message-template', 'runtime-data'])
|
|
57
|
+
const COPY_EXPRESSION_LIMIT = 200
|
|
57
58
|
const COPY_ROLES = new Set([
|
|
58
59
|
'badge',
|
|
59
60
|
'breadcrumb',
|
|
@@ -914,7 +915,7 @@ function stableReferences(binding, checker, mode, stack) {
|
|
|
914
915
|
|
|
915
916
|
/**
|
|
916
917
|
* Um parâmetro de callback (ou variável de `for…of`) é somente leitura quando cada uso é
|
|
917
|
-
* `
|
|
918
|
+
* `element.key` fora de posição de atribuição. Strings são imutáveis, então uma leitura de
|
|
918
919
|
* propriedade nunca altera o rótulo literal; qualquer outro uso (spread, argumento, retorno,
|
|
919
920
|
* atribuição) poderia mutar ou fazer o objeto escapar e é rejeitado. Destructuring copia os
|
|
920
921
|
* valores de primeiro nível e, por isso, também não alcança o literal.
|
|
@@ -1048,7 +1049,7 @@ function elementSource(identifier, checker) {
|
|
|
1048
1049
|
}
|
|
1049
1050
|
|
|
1050
1051
|
/**
|
|
1051
|
-
* Resolve `
|
|
1052
|
+
* Resolve `element.key` (ou a chave destruturada) para o texto literal de cada elemento
|
|
1052
1053
|
* do array de origem. Um único elemento opaco ou sem texto estático torna o conjunto todo
|
|
1053
1054
|
* inextraível: o inventário não pode listar só parte do que a superfície renderiza.
|
|
1054
1055
|
*/
|
|
@@ -1387,15 +1388,37 @@ function lineOf(sourceFile, node) {
|
|
|
1387
1388
|
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
|
|
1388
1389
|
}
|
|
1389
1390
|
|
|
1391
|
+
/**
|
|
1392
|
+
* Trecho que identifica o diagnóstico em `copy.dynamic` sem depender da linha. Espaços são
|
|
1393
|
+
* normalizados para a âncora sobreviver a reformatação; trechos longos são cortados de forma
|
|
1394
|
+
* determinística, porque o seletor só precisa distinguir, não reproduzir, o código.
|
|
1395
|
+
*/
|
|
1396
|
+
function anchorExpression(sourceFile, node) {
|
|
1397
|
+
const text = node
|
|
1398
|
+
.getText(sourceFile)
|
|
1399
|
+
.replace(/\s+/gu, ' ')
|
|
1400
|
+
.replace(/,(\s*)(?=[)\]}])/gu, '$1')
|
|
1401
|
+
.trim()
|
|
1402
|
+
return text.length > COPY_EXPRESSION_LIMIT ? `${text.slice(0, COPY_EXPRESSION_LIMIT)}…` : text
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1390
1405
|
function diagnostic(file, sourceFile, node, field, category = 'content') {
|
|
1406
|
+
const expression = anchorExpression(sourceFile, node)
|
|
1407
|
+
// Só diagnóstico de conteúdo é declarável em copy.dynamic; o trecho é a âncora da declaração.
|
|
1408
|
+
const declarable =
|
|
1409
|
+
category === 'content'
|
|
1410
|
+
? ` Se o valor vem da aplicação, declare-o em copy.dynamic com field "${field}" e expression ${JSON.stringify(expression)}.`
|
|
1411
|
+
: ''
|
|
1391
1412
|
return {
|
|
1392
1413
|
source: file,
|
|
1393
1414
|
line: lineOf(sourceFile, node),
|
|
1394
1415
|
field,
|
|
1416
|
+
expression,
|
|
1395
1417
|
category,
|
|
1396
1418
|
message:
|
|
1397
1419
|
`não foi possível extrair \`${field}\` estaticamente; use texto literal, concatenação ` +
|
|
1398
|
-
'de literais, constante local ou I18nRef com `default` literal.'
|
|
1420
|
+
'de literais, constante local ou I18nRef com `default` literal.' +
|
|
1421
|
+
declarable,
|
|
1399
1422
|
}
|
|
1400
1423
|
}
|
|
1401
1424
|
|
|
@@ -2796,8 +2819,8 @@ function copyConfiguration(root) {
|
|
|
2796
2819
|
}
|
|
2797
2820
|
}
|
|
2798
2821
|
|
|
2799
|
-
function selectorDiagnostic(message) {
|
|
2800
|
-
return { source: 'base.json', line: 1, message }
|
|
2822
|
+
function selectorDiagnostic(message, selector) {
|
|
2823
|
+
return selector === undefined ? { source: 'base.json', line: 1, message } : { source: 'base.json', line: 1, message, selector }
|
|
2801
2824
|
}
|
|
2802
2825
|
|
|
2803
2826
|
function sameProjectFile(left, right) {
|
|
@@ -2841,18 +2864,50 @@ function verifyCopyInputs(root, consistency) {
|
|
|
2841
2864
|
verifyConfiguration(root, consistency.configFile)
|
|
2842
2865
|
}
|
|
2843
2866
|
|
|
2867
|
+
/**
|
|
2868
|
+
* Seletores de `copy.exemptions` e `copy.transforms`. Sem `line`, o seletor é pelo conteúdo
|
|
2869
|
+
* (`source` + `role` + `text`) e vale para toda ocorrência desse texto com esse papel no
|
|
2870
|
+
* arquivo; com `line`, é o formato anterior, que seleciona um único texto naquela linha.
|
|
2871
|
+
*/
|
|
2844
2872
|
function findEntry(entries, selector) {
|
|
2845
2873
|
return entries
|
|
2846
2874
|
.map((entry, index) => ({ entry, index }))
|
|
2847
2875
|
.filter(
|
|
2848
2876
|
({ entry }) =>
|
|
2849
2877
|
entry.source === selector.source &&
|
|
2850
|
-
entry.line === selector.line &&
|
|
2878
|
+
(selector.line === undefined || entry.line === selector.line) &&
|
|
2851
2879
|
entry.role === selector.role &&
|
|
2852
2880
|
(selector.text === undefined || entry.text === selector.text),
|
|
2853
2881
|
)
|
|
2854
2882
|
}
|
|
2855
2883
|
|
|
2884
|
+
function validTextSelector(selector) {
|
|
2885
|
+
if (typeof selector.source !== 'string' || typeof selector.role !== 'string') return false
|
|
2886
|
+
if (selector.line === undefined) return typeof selector.text === 'string' && selector.text !== ''
|
|
2887
|
+
return Number.isInteger(selector.line) && (selector.text === undefined || typeof selector.text === 'string')
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function describeTextSelector(selector) {
|
|
2891
|
+
const text = selector.text === undefined ? '' : ` "${selector.text}"`
|
|
2892
|
+
return selector.line === undefined
|
|
2893
|
+
? `o texto${text} (${selector.role}) em ${selector.source}`
|
|
2894
|
+
: `o texto${text} (${selector.role}) na linha ${selector.line} de ${selector.source}`
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
function missingTextSelector(list, index, selector, matches) {
|
|
2898
|
+
if (selector.line === undefined) {
|
|
2899
|
+
return selectorDiagnostic(
|
|
2900
|
+
`copy.${list}[${index}] não encontra ${describeTextSelector(selector)}; o texto mudou ou saiu do arquivo — atualize ou remova a declaração.`,
|
|
2901
|
+
{ list, index },
|
|
2902
|
+
)
|
|
2903
|
+
}
|
|
2904
|
+
return selectorDiagnostic(
|
|
2905
|
+
`copy.${list}[${index}] deve selecionar exatamente um texto; encontrou ${matches.length} para ${describeTextSelector(selector)}. ` +
|
|
2906
|
+
'A âncora por linha quebra a cada edição acima dela: rode `opus copy --reanchor` para ancorar pelo texto.',
|
|
2907
|
+
{ list, index },
|
|
2908
|
+
)
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2856
2911
|
function canonicalSource(value) {
|
|
2857
2912
|
return (
|
|
2858
2913
|
typeof value === 'string' &&
|
|
@@ -2870,18 +2925,54 @@ function canonicalSource(value) {
|
|
|
2870
2925
|
)
|
|
2871
2926
|
}
|
|
2872
2927
|
|
|
2873
|
-
function
|
|
2928
|
+
function validExpression(value) {
|
|
2929
|
+
return (
|
|
2930
|
+
typeof value === 'string' &&
|
|
2931
|
+
value !== '' &&
|
|
2932
|
+
value === value.trim() &&
|
|
2933
|
+
!/\s{2,}|[\r\n\t]/u.test(value) &&
|
|
2934
|
+
value.length <= COPY_EXPRESSION_LIMIT + 1
|
|
2935
|
+
)
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
function describeDynamicSelector(declaration) {
|
|
2939
|
+
return declaration.expression === undefined
|
|
2940
|
+
? `\`${declaration.field}\` na linha ${declaration.line} de ${declaration.source}`
|
|
2941
|
+
: `\`${declaration.field}\` com o trecho \`${declaration.expression}\` em ${declaration.source}`
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
function pendingDynamicHint(candidates) {
|
|
2945
|
+
if (candidates.length === 0) {
|
|
2946
|
+
return ' Nenhum diagnóstico desse campo está pendente no arquivo; se o código passou a ser estático, remova a declaração.'
|
|
2947
|
+
}
|
|
2948
|
+
const listed = candidates
|
|
2949
|
+
.slice(0, 5)
|
|
2950
|
+
.map(({ item }) => `\`${item.expression}\` (linha ${item.line})`)
|
|
2951
|
+
.join(', ')
|
|
2952
|
+
const more = candidates.length > 5 ? ` e mais ${candidates.length - 5}` : ''
|
|
2953
|
+
return ` Diagnósticos desse campo ainda sem declaração: ${listed}${more}.`
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
/**
|
|
2957
|
+
* `copy.dynamic` seleciona diagnósticos de conteúdo. O seletor por `expression` sobrevive a
|
|
2958
|
+
* edições em outras linhas e cobre toda ocorrência do mesmo trecho no mesmo campo do arquivo;
|
|
2959
|
+
* o seletor por `line` (formato anterior) cobre exatamente um diagnóstico. Os seletores por
|
|
2960
|
+
* linha consomem primeiro, para que uma declaração pontual não seja engolida por uma geral.
|
|
2961
|
+
*/
|
|
2962
|
+
function applyDynamicMetadata(config, entries, diagnostics, analyzed) {
|
|
2874
2963
|
const surfaces = []
|
|
2875
2964
|
if (!Array.isArray(config.dynamic)) {
|
|
2876
2965
|
diagnostics.push(selectorDiagnostic('copy.dynamic deve ser uma lista.'))
|
|
2877
2966
|
return surfaces
|
|
2878
2967
|
}
|
|
2879
2968
|
const consumed = new Set()
|
|
2969
|
+
const valid = []
|
|
2880
2970
|
for (const [index, declaration] of config.dynamic.entries()) {
|
|
2881
2971
|
const template = declaration?.kind === 'message-template'
|
|
2882
2972
|
const allowedFields = new Set([
|
|
2883
2973
|
'source',
|
|
2884
2974
|
'line',
|
|
2975
|
+
'expression',
|
|
2885
2976
|
'field',
|
|
2886
2977
|
'kind',
|
|
2887
2978
|
'reason',
|
|
@@ -2890,14 +2981,17 @@ function applyDynamicMetadata(config, entries, diagnostics) {
|
|
|
2890
2981
|
])
|
|
2891
2982
|
const normalizedReason = typeof declaration?.reason === 'string' ? declaration.reason.trim() : ''
|
|
2892
2983
|
const normalizedReference = typeof declaration?.reference === 'string' ? declaration.reference.trim() : ''
|
|
2984
|
+
const byLine = declaration?.line !== undefined
|
|
2985
|
+
const byExpression = declaration?.expression !== undefined
|
|
2893
2986
|
if (
|
|
2894
2987
|
declaration === null ||
|
|
2895
2988
|
typeof declaration !== 'object' ||
|
|
2896
2989
|
Array.isArray(declaration) ||
|
|
2897
2990
|
Object.keys(declaration).some((field) => !allowedFields.has(field)) ||
|
|
2898
2991
|
!canonicalSource(declaration.source) ||
|
|
2899
|
-
|
|
2900
|
-
declaration.line < 1 ||
|
|
2992
|
+
byLine === byExpression ||
|
|
2993
|
+
(byLine && (!Number.isInteger(declaration.line) || declaration.line < 1)) ||
|
|
2994
|
+
(byExpression && !validExpression(declaration.expression)) ||
|
|
2901
2995
|
typeof declaration.field !== 'string' ||
|
|
2902
2996
|
declaration.field.trim() === '' ||
|
|
2903
2997
|
!COPY_DYNAMIC_KINDS.has(declaration.kind) ||
|
|
@@ -2919,34 +3013,58 @@ function applyDynamicMetadata(config, entries, diagnostics) {
|
|
|
2919
3013
|
) {
|
|
2920
3014
|
diagnostics.push(
|
|
2921
3015
|
selectorDiagnostic(
|
|
2922
|
-
`copy.dynamic[${index}] exige
|
|
3016
|
+
`copy.dynamic[${index}] exige source, expression (ou line, no formato anterior), field, kind, reason e reference válidos` +
|
|
2923
3017
|
(template ? ', além de text e role para message-template.' : '.'),
|
|
3018
|
+
{ list: 'dynamic', index },
|
|
2924
3019
|
),
|
|
2925
3020
|
)
|
|
2926
3021
|
continue
|
|
2927
3022
|
}
|
|
2928
|
-
|
|
3023
|
+
valid.push({ index, declaration, template, normalizedReason, normalizedReference })
|
|
3024
|
+
}
|
|
3025
|
+
const ordered = [
|
|
3026
|
+
...valid.filter(({ declaration }) => declaration.expression === undefined),
|
|
3027
|
+
...valid.filter(({ declaration }) => declaration.expression !== undefined),
|
|
3028
|
+
]
|
|
3029
|
+
const accepted = new Map()
|
|
3030
|
+
for (const { index, declaration, template, normalizedReason, normalizedReference } of ordered) {
|
|
3031
|
+
const candidates = diagnostics
|
|
2929
3032
|
.map((item, diagnosticIndex) => ({ item, diagnosticIndex }))
|
|
2930
3033
|
.filter(
|
|
2931
3034
|
({ item, diagnosticIndex }) =>
|
|
2932
3035
|
!consumed.has(diagnosticIndex) &&
|
|
2933
3036
|
item.category === 'content' &&
|
|
2934
3037
|
item.source === declaration.source &&
|
|
2935
|
-
item.line === declaration.line &&
|
|
2936
3038
|
item.field === declaration.field,
|
|
2937
3039
|
)
|
|
2938
|
-
|
|
3040
|
+
const matches =
|
|
3041
|
+
declaration.expression === undefined
|
|
3042
|
+
? candidates.filter(({ item }) => item.line === declaration.line)
|
|
3043
|
+
: candidates.filter(({ item }) => item.expression === declaration.expression)
|
|
3044
|
+
if (matches.length === 0 || (declaration.expression === undefined && matches.length > 1)) {
|
|
3045
|
+
const reanchor =
|
|
3046
|
+
declaration.expression === undefined
|
|
3047
|
+
? ' A âncora por linha quebra a cada edição acima dela: rode `opus copy --reanchor` para ancorar pelo trecho.'
|
|
3048
|
+
: ''
|
|
2939
3049
|
diagnostics.push(
|
|
2940
3050
|
selectorDiagnostic(
|
|
2941
|
-
`copy.dynamic[${index}] deve selecionar exatamente um diagnóstico de extração; encontrou ${matches.length}
|
|
3051
|
+
`copy.dynamic[${index}] deve selecionar ${declaration.expression === undefined ? 'exatamente' : 'ao menos'} um diagnóstico de extração; encontrou ${matches.length} para ${describeDynamicSelector(declaration)}.` +
|
|
3052
|
+
(analyzed !== undefined && !analyzed.has(declaration.source)
|
|
3053
|
+
? ' O arquivo não existe ou está fora do escopo do inventário; atualize o caminho ou remova a declaração.'
|
|
3054
|
+
: pendingDynamicHint(candidates)) +
|
|
3055
|
+
reanchor,
|
|
3056
|
+
{ list: 'dynamic', index },
|
|
2942
3057
|
),
|
|
2943
3058
|
)
|
|
2944
3059
|
continue
|
|
2945
3060
|
}
|
|
2946
|
-
consumed.add(
|
|
3061
|
+
for (const { diagnosticIndex } of matches) consumed.add(diagnosticIndex)
|
|
3062
|
+
const lines = matches.map(({ item }) => item.line).sort((left, right) => left - right)
|
|
2947
3063
|
const surface = {
|
|
2948
3064
|
source: declaration.source,
|
|
2949
|
-
|
|
3065
|
+
...(declaration.expression === undefined
|
|
3066
|
+
? { line: declaration.line }
|
|
3067
|
+
: { expression: declaration.expression, lines }),
|
|
2950
3068
|
field: declaration.field,
|
|
2951
3069
|
kind: declaration.kind,
|
|
2952
3070
|
reason: normalizedReason,
|
|
@@ -2955,20 +3073,24 @@ function applyDynamicMetadata(config, entries, diagnostics) {
|
|
|
2955
3073
|
if (template) {
|
|
2956
3074
|
surface.role = declaration.role
|
|
2957
3075
|
surface.text = declaration.text
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
3076
|
+
for (const line of lines) {
|
|
3077
|
+
entries.push({
|
|
3078
|
+
source: declaration.source,
|
|
3079
|
+
line,
|
|
3080
|
+
role: declaration.role,
|
|
3081
|
+
text: declaration.text,
|
|
3082
|
+
dynamic: {
|
|
3083
|
+
kind: declaration.kind,
|
|
3084
|
+
field: declaration.field,
|
|
3085
|
+
reference: normalizedReference,
|
|
3086
|
+
},
|
|
3087
|
+
})
|
|
3088
|
+
}
|
|
2969
3089
|
}
|
|
2970
|
-
|
|
3090
|
+
accepted.set(index, surface)
|
|
2971
3091
|
}
|
|
3092
|
+
// A ordem do inventário segue a do base.json, não a ordem de consumo.
|
|
3093
|
+
for (const { index } of valid) if (accepted.has(index)) surfaces.push(accepted.get(index))
|
|
2972
3094
|
if (consumed.size > 0) {
|
|
2973
3095
|
const remaining = diagnostics.filter((_, index) => !consumed.has(index))
|
|
2974
3096
|
diagnostics.splice(0, diagnostics.length, ...remaining)
|
|
@@ -2976,8 +3098,8 @@ function applyDynamicMetadata(config, entries, diagnostics) {
|
|
|
2976
3098
|
return surfaces
|
|
2977
3099
|
}
|
|
2978
3100
|
|
|
2979
|
-
function applyProjectMetadata(config, entries, diagnostics) {
|
|
2980
|
-
const dynamicSurfaces = applyDynamicMetadata(config, entries, diagnostics)
|
|
3101
|
+
function applyProjectMetadata(config, entries, diagnostics, analyzed) {
|
|
3102
|
+
const dynamicSurfaces = applyDynamicMetadata(config, entries, diagnostics, analyzed)
|
|
2981
3103
|
if (!Array.isArray(config.exemptions)) {
|
|
2982
3104
|
diagnostics.push(selectorDiagnostic('copy.exemptions deve ser uma lista.'))
|
|
2983
3105
|
} else {
|
|
@@ -2985,9 +3107,7 @@ function applyProjectMetadata(config, entries, diagnostics) {
|
|
|
2985
3107
|
if (
|
|
2986
3108
|
exemption === null ||
|
|
2987
3109
|
typeof exemption !== 'object' ||
|
|
2988
|
-
|
|
2989
|
-
!Number.isInteger(exemption.line) ||
|
|
2990
|
-
typeof exemption.role !== 'string' ||
|
|
3110
|
+
!validTextSelector(exemption) ||
|
|
2991
3111
|
typeof exemption.ruleId !== 'string' ||
|
|
2992
3112
|
!COPY_RULE_ID.test(exemption.ruleId) ||
|
|
2993
3113
|
typeof exemption.kind !== 'string' ||
|
|
@@ -2997,32 +3117,38 @@ function applyProjectMetadata(config, entries, diagnostics) {
|
|
|
2997
3117
|
typeof exemption.reference !== 'string' ||
|
|
2998
3118
|
exemption.reference.trim() === ''
|
|
2999
3119
|
) {
|
|
3000
|
-
diagnostics.push(
|
|
3001
|
-
selectorDiagnostic(`copy.exemptions[${index}] exige seletor, ruleId, kind, reason e reference válidos.`),
|
|
3002
|
-
)
|
|
3003
|
-
continue
|
|
3004
|
-
}
|
|
3005
|
-
const matches = findEntry(entries, exemption)
|
|
3006
|
-
if (matches.length !== 1) {
|
|
3007
3120
|
diagnostics.push(
|
|
3008
3121
|
selectorDiagnostic(
|
|
3009
|
-
`copy.exemptions[${index}]
|
|
3122
|
+
`copy.exemptions[${index}] exige source, role, text (ou line, no formato anterior), ruleId, kind, reason e reference válidos.`,
|
|
3123
|
+
{ list: 'exemptions', index },
|
|
3010
3124
|
),
|
|
3011
3125
|
)
|
|
3012
3126
|
continue
|
|
3013
3127
|
}
|
|
3014
|
-
const
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
diagnostics.push(selectorDiagnostic(`copy.exemptions[${index}] repete ${exemption.ruleId} no mesmo texto.`))
|
|
3128
|
+
const matches = findEntry(entries, exemption)
|
|
3129
|
+
if (matches.length === 0 || (exemption.line !== undefined && matches.length > 1)) {
|
|
3130
|
+
diagnostics.push(missingTextSelector('exemptions', index, exemption, matches))
|
|
3018
3131
|
continue
|
|
3019
3132
|
}
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3133
|
+
for (const { index: entryIndex } of matches) {
|
|
3134
|
+
const target = entries[entryIndex]
|
|
3135
|
+
target.exemptions ??= []
|
|
3136
|
+
if (target.exemptions.some((item) => item.ruleId === exemption.ruleId)) {
|
|
3137
|
+
diagnostics.push(
|
|
3138
|
+
selectorDiagnostic(
|
|
3139
|
+
`copy.exemptions[${index}] repete ${exemption.ruleId} no texto "${target.text}" da linha ${target.line} de ${target.source}.`,
|
|
3140
|
+
{ list: 'exemptions', index },
|
|
3141
|
+
),
|
|
3142
|
+
)
|
|
3143
|
+
continue
|
|
3144
|
+
}
|
|
3145
|
+
target.exemptions.push({
|
|
3146
|
+
ruleId: exemption.ruleId,
|
|
3147
|
+
kind: exemption.kind.trim(),
|
|
3148
|
+
reason: exemption.reason.trim(),
|
|
3149
|
+
reference: exemption.reference.trim(),
|
|
3150
|
+
})
|
|
3151
|
+
}
|
|
3026
3152
|
}
|
|
3027
3153
|
}
|
|
3028
3154
|
|
|
@@ -3038,38 +3164,43 @@ function applyProjectMetadata(config, entries, diagnostics) {
|
|
|
3038
3164
|
if (
|
|
3039
3165
|
transform === null ||
|
|
3040
3166
|
typeof transform !== 'object' ||
|
|
3041
|
-
|
|
3042
|
-
!Number.isInteger(transform.line) ||
|
|
3043
|
-
typeof transform.role !== 'string' ||
|
|
3167
|
+
!validTextSelector(transform) ||
|
|
3044
3168
|
!['none', 'lowercase', 'uppercase'].includes(transform.transform)
|
|
3045
3169
|
) {
|
|
3046
|
-
diagnostics.push(
|
|
3170
|
+
diagnostics.push(
|
|
3171
|
+
selectorDiagnostic(`copy.transforms[${index}] tem seletor ou transform inválido.`, {
|
|
3172
|
+
list: 'transforms',
|
|
3173
|
+
index,
|
|
3174
|
+
}),
|
|
3175
|
+
)
|
|
3047
3176
|
continue
|
|
3048
3177
|
}
|
|
3049
3178
|
const matches = findEntry(entries, transform)
|
|
3050
|
-
if (matches.length !== 1) {
|
|
3179
|
+
if (matches.length === 0 || (transform.line !== undefined && matches.length > 1)) {
|
|
3180
|
+
diagnostics.push(missingTextSelector('transforms', index, transform, matches))
|
|
3181
|
+
continue
|
|
3182
|
+
}
|
|
3183
|
+
const overriding = matches.find(({ index: entryIndex }) => transformed.has(entryIndex))
|
|
3184
|
+
if (overriding !== undefined) {
|
|
3051
3185
|
diagnostics.push(
|
|
3052
3186
|
selectorDiagnostic(
|
|
3053
|
-
`copy.transforms[${index}]
|
|
3187
|
+
`copy.transforms[${index}] tenta sobrescrever um texto já transformado (linha ${overriding.entry.line} de ${overriding.entry.source}).`,
|
|
3188
|
+
{ list: 'transforms', index },
|
|
3054
3189
|
),
|
|
3055
3190
|
)
|
|
3056
3191
|
continue
|
|
3057
3192
|
}
|
|
3058
|
-
const entryIndex
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
continue
|
|
3193
|
+
for (const { index: entryIndex } of matches) {
|
|
3194
|
+
transformed.add(entryIndex)
|
|
3195
|
+
entries[entryIndex].transform = transform.transform
|
|
3062
3196
|
}
|
|
3063
|
-
transformed.add(entryIndex)
|
|
3064
|
-
entries[entryIndex].transform = transform.transform
|
|
3065
3197
|
}
|
|
3066
3198
|
}
|
|
3067
3199
|
return dynamicSurfaces
|
|
3068
3200
|
}
|
|
3069
3201
|
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
const config = copyConfiguration(target)
|
|
3202
|
+
/** Lê e extrai cada source do escopo, sem aplicar as declarações do `base.json`. */
|
|
3203
|
+
function collectCopySources(target, config) {
|
|
3073
3204
|
const sources = []
|
|
3074
3205
|
const sourceSnapshots = []
|
|
3075
3206
|
const entries = []
|
|
@@ -3123,6 +3254,13 @@ export function extractCopyInventory(root) {
|
|
|
3123
3254
|
})
|
|
3124
3255
|
}
|
|
3125
3256
|
}
|
|
3257
|
+
return { sources, sourceSnapshots, entries, diagnostics, discovered, excluded }
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
export function extractCopyInventory(root) {
|
|
3261
|
+
const target = path.resolve(root)
|
|
3262
|
+
const config = copyConfiguration(target)
|
|
3263
|
+
const { sources, sourceSnapshots, entries, diagnostics, discovered, excluded } = collectCopySources(target, config)
|
|
3126
3264
|
try {
|
|
3127
3265
|
verifyConfiguration(target, config.configFile)
|
|
3128
3266
|
} catch (error) {
|
|
@@ -3135,7 +3273,12 @@ export function extractCopyInventory(root) {
|
|
|
3135
3273
|
),
|
|
3136
3274
|
)
|
|
3137
3275
|
}
|
|
3138
|
-
const dynamicSurfaces = applyProjectMetadata(
|
|
3276
|
+
const dynamicSurfaces = applyProjectMetadata(
|
|
3277
|
+
config,
|
|
3278
|
+
entries,
|
|
3279
|
+
diagnostics,
|
|
3280
|
+
new Set(sources.map((source) => source.path)),
|
|
3281
|
+
)
|
|
3139
3282
|
|
|
3140
3283
|
const coveredSources = new Set(entries.map((entry) => entry.source)).size
|
|
3141
3284
|
|
|
@@ -3165,10 +3308,305 @@ export function extractCopyInventory(root) {
|
|
|
3165
3308
|
sourceSnapshots,
|
|
3166
3309
|
},
|
|
3167
3310
|
},
|
|
3311
|
+
legacySelectors: { value: legacySelectorCount(config) },
|
|
3168
3312
|
})
|
|
3169
3313
|
return result
|
|
3170
3314
|
}
|
|
3171
3315
|
|
|
3316
|
+
function legacySelectorCount(config) {
|
|
3317
|
+
return ['dynamic', 'exemptions', 'transforms']
|
|
3318
|
+
.flatMap((list) => (Array.isArray(config[list]) ? config[list] : []))
|
|
3319
|
+
.filter((selector) => selector !== null && typeof selector === 'object' && selector.line !== undefined).length
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
/** Troca a chave `line` pelas chaves da âncora, preservando a posição dela na declaração. */
|
|
3323
|
+
function withAnchor(selector, anchor) {
|
|
3324
|
+
const output = {}
|
|
3325
|
+
for (const [key, value] of Object.entries(selector)) {
|
|
3326
|
+
if (key === 'line') Object.assign(output, anchor)
|
|
3327
|
+
else if (!(key in anchor)) output[key] = value
|
|
3328
|
+
}
|
|
3329
|
+
return output
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
function sameDeclaration(left, right) {
|
|
3333
|
+
return JSON.stringify(Object.entries(left).sort()) === JSON.stringify(Object.entries(right).sort())
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
function contentKey(list, selector) {
|
|
3337
|
+
return list === 'dynamic'
|
|
3338
|
+
? `${selector.source}\u0000${selector.field}\u0000${selector.expression}`
|
|
3339
|
+
: `${selector.source}\u0000${selector.role}\u0000${selector.text}\u0000${list === 'exemptions' ? selector.ruleId : ''}`
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
/**
|
|
3343
|
+
* Propõe a âncora de conteúdo de cada seletor por linha, por grupo: em `copy.dynamic`, arquivo e
|
|
3344
|
+
* campo; em `copy.exemptions` e `copy.transforms`, arquivo, papel e texto (e a regra, nas
|
|
3345
|
+
* exceções). Uma âncora por conteúdo cobre todos os alvos do grupo, então só é proposta quando o
|
|
3346
|
+
* grupo tem exatamente uma declaração por alvo: com alvo sobrando, a conversão ampliaria a
|
|
3347
|
+
* declaração para um alvo que ninguém classificou. Dentro do grupo, declarações e alvos são
|
|
3348
|
+
* pareados em ordem de linha; `to` é a linha atual do alvo pareado e serve para manter o
|
|
3349
|
+
* seletor por linha, corrigido, quando a conversão não é segura.
|
|
3350
|
+
*/
|
|
3351
|
+
function reanchorProposals(lists, collected) {
|
|
3352
|
+
const proposals = { dynamic: new Map(), exemptions: new Map(), transforms: new Map() }
|
|
3353
|
+
const unresolved = []
|
|
3354
|
+
const legacy = (selector) => selector !== null && typeof selector === 'object' && Number.isInteger(selector.line)
|
|
3355
|
+
const pair = (list, indexes, targets, line) => {
|
|
3356
|
+
const sorted = [...indexes].sort((left, right) => lists[list][left].line - lists[list][right].line)
|
|
3357
|
+
for (const [position, index] of sorted.entries()) {
|
|
3358
|
+
const target = targets[position]
|
|
3359
|
+
proposals[list].set(index, {
|
|
3360
|
+
anchor: list === 'dynamic' ? { expression: target.expression } : { text: target.text },
|
|
3361
|
+
from: lists[list][index].line,
|
|
3362
|
+
to: line(target),
|
|
3363
|
+
realigned: line(target) !== lists[list][index].line,
|
|
3364
|
+
})
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
// copy.dynamic. Parear o grupo inteiro, e não só as declarações quebradas, evita trocar motivos
|
|
3369
|
+
// entre vizinhos que por coincidência caíram na linha um do outro.
|
|
3370
|
+
const content = collected.diagnostics.filter((item) => item.category === 'content')
|
|
3371
|
+
const declared = new Set(
|
|
3372
|
+
lists.dynamic
|
|
3373
|
+
.filter((item) => item !== null && typeof item === 'object' && typeof item.expression === 'string')
|
|
3374
|
+
.map((item) => contentKey('dynamic', item)),
|
|
3375
|
+
)
|
|
3376
|
+
const dynamicGroups = new Map()
|
|
3377
|
+
for (const [index, declaration] of lists.dynamic.entries()) {
|
|
3378
|
+
if (!legacy(declaration)) continue
|
|
3379
|
+
const key = `${declaration.source}\u0000${declaration.field}`
|
|
3380
|
+
if (!dynamicGroups.has(key)) dynamicGroups.set(key, [])
|
|
3381
|
+
dynamicGroups.get(key).push(index)
|
|
3382
|
+
}
|
|
3383
|
+
for (const indexes of dynamicGroups.values()) {
|
|
3384
|
+
const first = lists.dynamic[indexes[0]]
|
|
3385
|
+
const free = content
|
|
3386
|
+
.filter(
|
|
3387
|
+
(item) =>
|
|
3388
|
+
item.source === first.source &&
|
|
3389
|
+
item.field === first.field &&
|
|
3390
|
+
!declared.has(contentKey('dynamic', item)),
|
|
3391
|
+
)
|
|
3392
|
+
.sort((left, right) => left.line - right.line)
|
|
3393
|
+
if (free.length !== indexes.length) {
|
|
3394
|
+
for (const index of indexes) {
|
|
3395
|
+
unresolved.push({
|
|
3396
|
+
list: 'dynamic',
|
|
3397
|
+
index,
|
|
3398
|
+
reason: `${indexes.length} declaração(ões) por linha e ${free.length} diagnóstico(s) sem declaração de \`${first.field}\` em ${first.source}`,
|
|
3399
|
+
})
|
|
3400
|
+
}
|
|
3401
|
+
continue
|
|
3402
|
+
}
|
|
3403
|
+
pair('dynamic', indexes, free, (item) => item.line)
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
// copy.exemptions e copy.transforms. O texto pode vir de um `message-template`, que só existe
|
|
3407
|
+
// depois de aplicar copy.dynamic; por isso os textos são lidos com as propostas acima aplicadas.
|
|
3408
|
+
const entries = collected.entries.map((entry) => ({ ...entry }))
|
|
3409
|
+
applyDynamicMetadata(
|
|
3410
|
+
{
|
|
3411
|
+
dynamic: lists.dynamic.map((selector, index) => {
|
|
3412
|
+
const proposal = proposals.dynamic.get(index)
|
|
3413
|
+
return proposal === undefined ? selector : withAnchor(selector, proposal.anchor)
|
|
3414
|
+
}),
|
|
3415
|
+
},
|
|
3416
|
+
entries,
|
|
3417
|
+
collected.diagnostics.map((item) => ({ ...item })),
|
|
3418
|
+
)
|
|
3419
|
+
for (const list of ['exemptions', 'transforms']) {
|
|
3420
|
+
const groups = new Map()
|
|
3421
|
+
for (const [index, selector] of lists[list].entries()) {
|
|
3422
|
+
if (!legacy(selector)) continue
|
|
3423
|
+
let text = selector.text
|
|
3424
|
+
if (typeof text !== 'string' || text === '') {
|
|
3425
|
+
const atLine = findEntry(entries, selector)
|
|
3426
|
+
if (atLine.length !== 1) {
|
|
3427
|
+
unresolved.push({ list, index, reason: `${atLine.length} texto(s) correspondem a ${describeTextSelector(selector)}` })
|
|
3428
|
+
continue
|
|
3429
|
+
}
|
|
3430
|
+
text = atLine[0].entry.text
|
|
3431
|
+
}
|
|
3432
|
+
const key = `${selector.source}\u0000${selector.role}\u0000${text}\u0000${list === 'exemptions' ? selector.ruleId : ''}`
|
|
3433
|
+
if (!groups.has(key)) groups.set(key, { text, indexes: [] })
|
|
3434
|
+
groups.get(key).indexes.push(index)
|
|
3435
|
+
}
|
|
3436
|
+
for (const { text, indexes } of groups.values()) {
|
|
3437
|
+
const first = lists[list][indexes[0]]
|
|
3438
|
+
const matches = findEntry(entries, { source: first.source, role: first.role, text })
|
|
3439
|
+
.map(({ entry }) => entry)
|
|
3440
|
+
.sort((left, right) => left.line - right.line)
|
|
3441
|
+
if (matches.length !== indexes.length) {
|
|
3442
|
+
for (const index of indexes) {
|
|
3443
|
+
unresolved.push({
|
|
3444
|
+
list,
|
|
3445
|
+
index,
|
|
3446
|
+
reason:
|
|
3447
|
+
`o texto "${text}" (${first.role}) aparece ${matches.length} vez(es) em ${first.source} e tem ` +
|
|
3448
|
+
`${indexes.length} declaração(ões) por linha; a âncora por texto mudaria o alcance da declaração`,
|
|
3449
|
+
})
|
|
3450
|
+
}
|
|
3451
|
+
continue
|
|
3452
|
+
}
|
|
3453
|
+
pair(list, indexes, matches, (entry) => entry.line)
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
return { proposals, unresolved }
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
/**
|
|
3460
|
+
* Aplica as propostas às listas. Declarações que viram o mesmo seletor de conteúdo se fundem
|
|
3461
|
+
* quando são idênticas; quando divergem em motivo, tipo ou referência, ficam por linha, já
|
|
3462
|
+
* corrigida, porque um seletor de conteúdo cobriria as duas com um único motivo. Devolve
|
|
3463
|
+
* `null` quando precisou rebaixar alguma proposta, para a chamada recalcular.
|
|
3464
|
+
*/
|
|
3465
|
+
function buildReanchored(lists, proposals, unresolved) {
|
|
3466
|
+
const next = {}
|
|
3467
|
+
const origin = {}
|
|
3468
|
+
const merged = []
|
|
3469
|
+
for (const list of Object.keys(lists)) {
|
|
3470
|
+
const effective = lists[list].map((selector, index) => {
|
|
3471
|
+
const proposal = proposals[list].get(index)
|
|
3472
|
+
if (proposal === undefined) return selector
|
|
3473
|
+
return withAnchor(selector, proposal.keepLine ? { line: proposal.to } : proposal.anchor)
|
|
3474
|
+
})
|
|
3475
|
+
const seen = new Map()
|
|
3476
|
+
const dropped = new Set()
|
|
3477
|
+
let demoted = false
|
|
3478
|
+
for (const [index, selector] of effective.entries()) {
|
|
3479
|
+
if (selector === null || typeof selector !== 'object' || selector.line !== undefined) continue
|
|
3480
|
+
const key = contentKey(list, selector)
|
|
3481
|
+
if (!seen.has(key)) {
|
|
3482
|
+
seen.set(key, index)
|
|
3483
|
+
continue
|
|
3484
|
+
}
|
|
3485
|
+
const first = seen.get(key)
|
|
3486
|
+
if (sameDeclaration(effective[first], selector)) {
|
|
3487
|
+
// Só uma declaração convertida aqui é descartada; duplicata já escrita assim fica.
|
|
3488
|
+
if (proposals[list].has(index)) dropped.add(index)
|
|
3489
|
+
else if (proposals[list].has(first)) {
|
|
3490
|
+
dropped.add(first)
|
|
3491
|
+
seen.set(key, index)
|
|
3492
|
+
}
|
|
3493
|
+
continue
|
|
3494
|
+
}
|
|
3495
|
+
for (const conflicting of [first, index]) {
|
|
3496
|
+
const proposal = proposals[list].get(conflicting)
|
|
3497
|
+
if (proposal === undefined || proposal.keepLine) continue
|
|
3498
|
+
const reason = 'outra declaração seleciona o mesmo conteúdo com motivo, tipo ou referência diferentes'
|
|
3499
|
+
if (proposal.to === undefined) {
|
|
3500
|
+
proposals[list].delete(conflicting)
|
|
3501
|
+
unresolved.push({ list, index: conflicting, reason })
|
|
3502
|
+
} else proposal.keepLine = reason
|
|
3503
|
+
demoted = true
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
if (demoted) return null
|
|
3507
|
+
next[list] = effective.filter((_, index) => !dropped.has(index))
|
|
3508
|
+
origin[list] = effective.map((_, index) => index).filter((index) => !dropped.has(index))
|
|
3509
|
+
for (const index of dropped) merged.push({ list, index })
|
|
3510
|
+
}
|
|
3511
|
+
return { next, origin, merged }
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
/**
|
|
3515
|
+
* Converte os seletores por linha de `copy.dynamic`, `copy.exemptions` e `copy.transforms`
|
|
3516
|
+
* para seletores por conteúdo e grava o `base.json` atomicamente. Só converte um grupo que tem
|
|
3517
|
+
* uma declaração por alvo (`reanchorProposals`), então a conversão não muda o alcance de
|
|
3518
|
+
* nenhuma declaração; as que mudaram de linha são separadas no relatório para revisão humana.
|
|
3519
|
+
* Os seletores resultantes precisam passar na extração do `opus copy`: uma conversão que
|
|
3520
|
+
* reprovaria fica por linha corrigida ou, sem linha segura, intacta. A política da Base não é
|
|
3521
|
+
* avaliada aqui; `base copy check` continua sendo o gate dela.
|
|
3522
|
+
*/
|
|
3523
|
+
export function reanchorCopySelectors(root) {
|
|
3524
|
+
const target = path.resolve(root)
|
|
3525
|
+
const config = copyConfiguration(target)
|
|
3526
|
+
const report = { converted: [], realigned: [], relined: [], merged: [], unresolved: [] }
|
|
3527
|
+
if (!config.configFile.exists) return { ok: true, changed: false, report }
|
|
3528
|
+
const collected = collectCopySources(target, config)
|
|
3529
|
+
const blocking = collected.diagnostics.filter((item) => item.category === undefined)
|
|
3530
|
+
if (blocking.length > 0) return { ok: false, changed: false, report, diagnostics: blocking }
|
|
3531
|
+
|
|
3532
|
+
const lists = {
|
|
3533
|
+
dynamic: Array.isArray(config.dynamic) ? config.dynamic : [],
|
|
3534
|
+
exemptions: Array.isArray(config.exemptions) ? config.exemptions : [],
|
|
3535
|
+
transforms: Array.isArray(config.transforms) ? config.transforms : [],
|
|
3536
|
+
}
|
|
3537
|
+
const { proposals, unresolved } = reanchorProposals(lists, collected)
|
|
3538
|
+
report.unresolved.push(...unresolved)
|
|
3539
|
+
|
|
3540
|
+
let built = null
|
|
3541
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
3542
|
+
built = buildReanchored(lists, proposals, report.unresolved)
|
|
3543
|
+
if (built === null) continue
|
|
3544
|
+
const entries = collected.entries.map((entry) => ({ ...entry, exemptions: entry.exemptions?.map((item) => ({ ...item })) }))
|
|
3545
|
+
const diagnostics = collected.diagnostics.map((item) => ({ ...item }))
|
|
3546
|
+
applyProjectMetadata(
|
|
3547
|
+
{ ...config, ...built.next },
|
|
3548
|
+
entries,
|
|
3549
|
+
diagnostics,
|
|
3550
|
+
new Set(collected.sources.map((source) => source.path)),
|
|
3551
|
+
)
|
|
3552
|
+
let demoted = false
|
|
3553
|
+
for (const { selector } of diagnostics) {
|
|
3554
|
+
if (selector === undefined) continue
|
|
3555
|
+
const original = built.origin[selector.list]?.[selector.index]
|
|
3556
|
+
const proposal = original === undefined ? undefined : proposals[selector.list].get(original)
|
|
3557
|
+
if (proposal === undefined) continue
|
|
3558
|
+
if (!proposal.keepLine && proposal.to !== undefined) proposal.keepLine = 'a âncora por conteúdo reprovaria no gate'
|
|
3559
|
+
else {
|
|
3560
|
+
proposals[selector.list].delete(original)
|
|
3561
|
+
report.unresolved.push({ list: selector.list, index: original, reason: 'nenhuma âncora segura passa no gate' })
|
|
3562
|
+
}
|
|
3563
|
+
demoted = true
|
|
3564
|
+
}
|
|
3565
|
+
if (!demoted) break
|
|
3566
|
+
built = null
|
|
3567
|
+
}
|
|
3568
|
+
if (built === null) throw new Error('copy --reanchor não convergiu; base.json não foi alterado.')
|
|
3569
|
+
|
|
3570
|
+
report.merged.push(...built.merged)
|
|
3571
|
+
for (const list of Object.keys(lists)) {
|
|
3572
|
+
for (const [index, proposal] of proposals[list]) {
|
|
3573
|
+
if (built.merged.some((item) => item.list === list && item.index === index)) continue
|
|
3574
|
+
const item = { list, index, selector: lists[list][index], anchor: proposal.anchor, from: proposal.from, to: proposal.to }
|
|
3575
|
+
if (proposal.keepLine) {
|
|
3576
|
+
if (proposal.to !== proposal.from) report.relined.push({ ...item, reason: proposal.keepLine })
|
|
3577
|
+
else report.unresolved.push({ list, index, reason: proposal.keepLine })
|
|
3578
|
+
} else if (proposal.realigned) report.realigned.push(item)
|
|
3579
|
+
else report.converted.push(item)
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
const changed = Object.keys(lists).some(
|
|
3583
|
+
(list) => JSON.stringify(built.next[list]) !== JSON.stringify(lists[list]),
|
|
3584
|
+
)
|
|
3585
|
+
if (!changed) return { ok: true, changed: false, report }
|
|
3586
|
+
|
|
3587
|
+
const raw = JSON.parse(config.configFile.content)
|
|
3588
|
+
raw.copy = { ...raw.copy }
|
|
3589
|
+
for (const list of Object.keys(lists)) if (Array.isArray(raw.copy[list])) raw.copy[list] = built.next[list]
|
|
3590
|
+
const indent = /^\{\r?\n([ \t]+)/u.exec(config.configFile.content)?.[1] ?? 2
|
|
3591
|
+
const rendered = withLineEndingsOf(
|
|
3592
|
+
config.configFile.content,
|
|
3593
|
+
`${JSON.stringify(raw, null, indent)}${config.configFile.content.endsWith('\n') ? '\n' : ''}`,
|
|
3594
|
+
)
|
|
3595
|
+
verifyConfiguration(target, config.configFile)
|
|
3596
|
+
writeProjectFileAtomically(
|
|
3597
|
+
target,
|
|
3598
|
+
'base.json',
|
|
3599
|
+
rendered,
|
|
3600
|
+
{ exists: true, content: config.configFile.content },
|
|
3601
|
+
{ mode: config.configFile.mode },
|
|
3602
|
+
)
|
|
3603
|
+
return { ok: true, changed: true, report }
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
function withLineEndingsOf(original, rendered) {
|
|
3607
|
+
return original.includes('\r\n') ? rendered.replace(/\n/gu, '\r\n') : rendered
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3172
3610
|
export function renderCopyInventory(inventory) {
|
|
3173
3611
|
return `${JSON.stringify(inventory, null, 2)}\n`
|
|
3174
3612
|
}
|