@softize/opus 28.0.0 → 30.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/bin/cli.mjs +68 -3
  3. package/bin/lib/check.mjs +86 -10
  4. package/bin/lib/copy.mjs +505 -67
  5. package/bin/lib/materialize.mjs +99 -1
  6. package/docs/adr/0028-schedules-run-actions-as-the-system-by-action-opt-in.md +1 -1
  7. package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
  8. package/docs/adr/0045-surface-replaces-content-and-card.md +3 -0
  9. package/docs/adr/0061-claude-md-imports-agents-md.md +41 -0
  10. package/docs/adr/0062-listas-contidas-e-texto-flexivel.md +68 -0
  11. package/docs/adr/0063-metadados-das-superficies.md +44 -0
  12. package/docs/adr/README.md +6 -0
  13. package/docs/code-style.md +47 -8
  14. package/docs/protocol.md +1 -1
  15. package/docs/releasing.md +7 -0
  16. package/package.json +1 -1
  17. package/registry/instructions/opus.md +3 -1
  18. package/registry/skills/build-opus-ui/SKILL.md +6 -4
  19. package/registry/skills/build-opus-ui/references/evaluations.md +10 -1
  20. package/registry/skills/build-opus-ui/references/ui-patterns.md +10 -2
  21. package/registry/skills/maintain-opus-docs/SKILL.md +2 -1
  22. package/registry/skills/maintain-opus-docs/references/editorial-standard.md +9 -0
  23. package/registry/skills/maintain-opus-docs/references/evaluations.md +8 -0
  24. package/registry/templates/app/pnpm-workspace.yaml +1 -1
  25. package/src/core/system-actor.ts +1 -1
  26. package/src/core/types.ts +1 -1
  27. package/src/ui/components/patterns/list.tsx +19 -10
  28. package/src/ui/components/patterns/page.tsx +27 -0
  29. package/src/ui/components/patterns/structural-header.tsx +25 -3
  30. package/src/ui/components/patterns/surface.tsx +36 -8
  31. package/src/ui/components/primitives/dialog.tsx +74 -42
  32. package/src/ui/components/primitives/drawer.tsx +69 -42
  33. package/src/ui/components/primitives/table.tsx +3 -3
  34. package/src/ui/docs/content/action-list.md +15 -8
  35. package/src/ui/docs/content/actions.md +1 -1
  36. package/src/ui/docs/content/auth.md +3 -3
  37. package/src/ui/docs/content/cli.md +6 -0
  38. package/src/ui/docs/content/communication.md +10 -8
  39. package/src/ui/docs/content/dialog.md +39 -0
  40. package/src/ui/docs/content/drawer.md +36 -0
  41. package/src/ui/docs/content/events.md +13 -13
  42. package/src/ui/docs/content/installation.md +4 -0
  43. package/src/ui/docs/content/log.md +1 -1
  44. package/src/ui/docs/content/page.md +30 -1
  45. package/src/ui/docs/content/queue.md +2 -2
  46. package/src/ui/docs/content/router.md +1 -1
  47. package/src/ui/docs/content/scheduler.md +15 -15
  48. package/src/ui/docs/content/surface.md +41 -8
  49. package/src/ui/docs/content/table.md +7 -0
  50. package/src/ui/docs/content/testing.md +4 -4
  51. package/src/ui/react.tsx +4 -0
  52. 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
- * `elemento.chave` fora de posição de atribuição. Strings são imutáveis, então uma leitura de
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 `elemento.chave` (ou a chave destruturada) para o texto literal de cada elemento
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 applyDynamicMetadata(config, entries, diagnostics) {
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
- !Number.isInteger(declaration.line) ||
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 seletor, kind, reason e reference válidos` +
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
- const matches = diagnostics
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
- if (matches.length !== 1) {
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(matches[0].diagnosticIndex)
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
- line: declaration.line,
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
- entries.push({
2959
- source: declaration.source,
2960
- line: declaration.line,
2961
- role: declaration.role,
2962
- text: declaration.text,
2963
- dynamic: {
2964
- kind: declaration.kind,
2965
- field: declaration.field,
2966
- reference: normalizedReference,
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
- surfaces.push(surface)
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
- typeof exemption.source !== 'string' ||
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}] deve selecionar exatamente um texto; encontrou ${matches.length}.`,
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 target = entries[matches[0].index]
3015
- target.exemptions ??= []
3016
- if (target.exemptions.some((item) => item.ruleId === exemption.ruleId)) {
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
- target.exemptions.push({
3021
- ruleId: exemption.ruleId,
3022
- kind: exemption.kind.trim(),
3023
- reason: exemption.reason.trim(),
3024
- reference: exemption.reference.trim(),
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
- typeof transform.source !== 'string' ||
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(selectorDiagnostic(`copy.transforms[${index}] tem seletor ou transform inválido.`))
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}] deve selecionar exatamente um texto; encontrou ${matches.length}.`,
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 = matches[0].index
3059
- if (transformed.has(entryIndex)) {
3060
- diagnostics.push(selectorDiagnostic(`copy.transforms[${index}] tenta sobrescrever um texto já transformado.`))
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
- export function extractCopyInventory(root) {
3071
- const target = path.resolve(root)
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(config, entries, diagnostics)
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
  }