SLOPSHOPPER

addon-studio

Plugin Sankhya Addon Studio 2.0 (Wildfly/EJB + SDK JAPE) com 26 skills focadas e 6 sub-agents. Skills = fonte de verdade da API do SDK, validadas contra os…

newspinnerguardstatuspromptprocess
★ 5v3.4.5MITupdated 2026-10-09snk-devcenter/addon-studio/plugins/addon-studio
A shopper browsing a rack in a slop shop
README

Addon Studio

release license

Plugin para desenvolver addons Sankhya Addon Studio 2.0 com Claude Code ou Codex CLI.

São 26 skills e 6 agents especializados em WildFly/EJB, Java 8 e SDK JAPE. As instruções foram validadas contra os jars reais do SDK (studio-annotations e sdk-sankhya) para evitar APIs inventadas, JPA no lugar de JAPE, SQL incompatível ou código fora do padrão da plataforma.

Navegação

Instalação

Os instaladores configuram o marketplace, instalam ou atualizam o plugin e deixam os agents disponíveis no escopo do usuário. Eles não instalam agents dentro do projeto.

Pré-requisito: tenha o claude ou o codex instalado e disponível no PATH.

Claude Code

Linux e macOS
curl -fsSL https://github.com/snk-devcenter/addon-studio/releases/latest/download/addon-studio-install.sh | sh -s -- --claude
Windows PowerShell
& ([scriptblock]::Create((Invoke-RestMethod -Uri 'https://github.com/snk-devcenter/addon-studio/releases/latest/download/addon-studio-install.ps1'))) -Claude

O instalador registra o marketplace snk-devcenter e instala addon-studio@snk-devcenter no escopo do usuário. Skills, agents e hooks são carregados pelo próprio plugin.

Skills e agents só entram no contexto em projeto Addon Studio, reconhecido pelo plugin Gradle br.com.sankhya.addonstudio no build.gradle do diretório da sessão ou de um diretório acima. Nos demais repositórios o plugin fica fora do caminho.

/plugin marketplace add snk-devcenter/addon-studio
/plugin install addon-studio@snk-devcenter

Codex CLI

Linux e macOS
curl -fsSL https://github.com/snk-devcenter/addon-studio/releases/latest/download/addon-studio-install.sh | sh -s -- --codex
Windows PowerShell
& ([scriptblock]::Create((Invoke-RestMethod -Uri 'https://github.com/snk-devcenter/addon-studio/releases/latest/download/addon-studio-install.ps1'))) -Codex

O instalador registra o marketplace, instala addon-studio@snk-devcenter e copia os seis agents para o escopo pessoal do Codex:

  • Linux e macOS: ~/.codex/agents/
  • Windows: $HOME\.codex\agents\
  • Com CODEX_HOME: $CODEX_HOME/agents/

Agents já personalizados são preservados. Use --force no Linux/macOS ou -Force no PowerShell somente quando quiser substituí-los pela versão da release.

Primeiros passos

Abra uma nova sessão da CLI depois da instalação.

Claude Code

Na raiz de um projeto Addon Studio existente, execute:

/addon-studio:init

O comando valida o build.gradle, copia as regras permanentes para docs/ADDON.md e garante o import @docs/ADDON.md no CLAUDE.md. A operação é idempotente e preserva as customizações do projeto.

Depois, trabalhe normalmente:

Crie a entidade, o dbscript e o dicionário para a tabela PRXXYZCAB.

Codex CLI

As skills ficam disponíveis automaticamente após a instalação. Você pode descrevê-las em linguagem natural ou invocá-las pelo nome com $:

$addon-studio:entity crie a entidade da tabela PRXXYZCAB

Para usar um especialista, peça pelo nome:

Use o entity-architect para modelar o CRUD completo desta tabela.

Os agents permanecem no diretório pessoal do Codex; nenhum TOML é copiado para o projeto.

Como usar

Linguagem natural

As skills são selecionadas pelo contexto do pedido. Exemplos:

PedidoSkills principais
“Crie entidade, migration e tela cadastral para esta tabela”entity, database, data-dictionary
“Exponha este cadastro por REST com DTO e validação”controller, mapstruct, controller-advice
“Consuma esta API externa com autenticação”retrofit, dependency-injection
“Execute este processamento toda madrugada”job
“Preencha este campo quando o registro for salvo”listener
“Crie uma tela HTML5 que chama o endpoint do addon”sankhya-js
“Escreva testes para este service”test
“Diagnostique este erro de deploy ou Guice”build, dependency-injection

Invocação explícita

No Claude Code, use /:

/addon-studio:controller
/addon-studio:repository
/addon-studio:test

No Codex CLI, use $:

$addon-studio:controller
$addon-studio:repository
$addon-studio:test

Skills

Fundamentos e projeto

SkillResponsabilidade
initPrepara docs/ADDON.md e CLAUDE.md para uso com Claude Code
buildBuild e deploy local com Gradle
encodingAuditoria e conversão de fontes para ISO-8859-1

Dados e persistência

SkillResponsabilidade
entityEntidades @JapeEntity, chaves e relacionamentos
repositoryJapeRepository, critérios, queries nativas e paginação
databaseDbscripts versionados para Oracle e SQL Server
data-dictionaryTelas cadastrais geradas pelo dicionário de dados
merge-on-rootCampos novos em entidade nativa via tabela de extensão 1:1
macrosSQL portável com MacroTranslator

Backend e integrações

SkillResponsabilidade
controllerEndpoints REST, DTOs, validação e transações
controller-adviceTratamento global de exceções e respostas HTTP
mapstructConversão entre DTOs e entidades
dependency-injectionWiring Guice, módulos, providers e escopos
retrofitClientes HTTP com Retrofit, Moshi e OkHttp
type-adapterSerialização JSON global de tipos
valueParâmetros Sankhya, @Value e feature flags
sankhya-utilsUtilitários nativos de com.sankhya.util

Eventos e automação

SkillResponsabilidade
action-buttonBotões de ação com AcaoRotinaJava
business-ruleRegras do barramento comercial
callbackHooks de confirmação e faturamento de documento
listenerEventos CRUD de persistência
before-load-listenerFiltros transversais antes de consultas JAPE
jobProcessamentos agendados com CRON

Frontend e qualidade

SkillResponsabilidade
sankhya-jsTelas HTML5 em AngularJS sobre sankhya-js
jspTelas .jsp do add-on com a taglib sankhyaUtil
testTestes JUnit 5 e Mockito para addons

Agents

Os seis agents cobrem tarefas maiores que atravessam várias skills.

AgentQuando usar
addon-reviewerRevisão pré-commit de compatibilidade, encoding e padrões do plugin
entity-architectModelagem conjunta de entidade, dbscript e dicionário
controller-designerEndpoint completo com DTOs, mapper e tratamento de erros
test-writerSuíte de testes ou cobertura de vários arquivos
troubleshooterDiagnóstico de causa-raiz ainda incerta
dbscript-builderMigration isolada para Oracle e SQL Server

No Claude Code, os agents fazem parte do plugin e aparecem em /agents. No Codex, o instalador usa os TOMLs da mesma release e os coloca em ~/.codex/agents/; peça a delegação explicitamente pelo nome.

Regras do Addon Studio

O plugin mantém estas restrições em todas as implementações:

  • Java 8 estrito.
  • JAPE SDK em vez de JPA genérico.
  • Lombok, Guice e MapStruct conforme os padrões do Addon Studio.
  • Fontes Java, XML, Kotlin e properties em ISO-8859-1.
  • Dbscripts compatíveis com Oracle e SQL Server.
  • Nomenclatura parametrizada pelo prefixo e módulo do projeto.
  • Arquitetura de pacotes e camadas definida pelo projeto, não pelo plugin.

Convenção de nomenclatura

O plugin detecta o padrão existente. Quando não houver referência suficiente, pergunta o prefixo (<PRX>) e o código do módulo (<MOD3>) antes de gerar artefatos.

ArtefatoPadrãoExemplo
Tabela do addon<PRX><MOD3><CTX>PRXXYZCAB
Nome da entidade JAPE<Prx><Mod><Ctx>PrxXyzCabecalho
Tabela de extensão de entidade nativa (merge-on-root)<PRX><MOD3><CTX>, mesma PK da nativaPRXXYZIPA

Proteção de encoding

O hook de pós-edição converte .java, .xml, .kt e .properties para ISO-8859-1. Se o conteúdo já tiver sido corrompido na leitura, o hook interrompe a conversão e pede a restauração do trecho para evitar perda silenciosa.

Atualização

Execute novamente o instalador do seu provider e sistema operacional. Ele atualiza o marketplace, o plugin e, no Codex, os agents pessoais.

No Claude Code, também é possível atualizar manualmente:

/plugin update addon-studio@snk-devcenter

Após atualizar no Claude Code, execute /addon-studio:init em cada projeto que precise receber a versão nova de docs/ADDON.md.

No Codex, rode /addon-studio:init em cada projeto: sem docs/ADDON.md as regras universais do plugin não entram no contexto (o mod que faz esse piso só roda no Claude Code). Customizações locais dos TOMLs continuam preservadas. Use --force ou -Force apenas para substituí-las.

Estrutura do repositório

.
├── .claude-plugin/marketplace.json
├── plugins/addon-studio/
│   ├── .claude-plugin/plugin.json
│   ├── agents/
│   │   ├── *.md               # agents do Claude Code
│   │   └── codex/*.toml       # agents do Codex
│   ├── hooks/
│   └── skills/                # compartilhadas pelos dois providers
└── scripts/                   # instalação e artefatos de release

O repositório funciona como marketplace e como fonte do plugin para os dois providers. Novos plugins podem ser adicionados em plugins/<nome>/.

Contribuição

As regras de contribuição e release estão em CLAUDE.md. Em resumo:

  • Crie a branch a partir de main usando feat/, fix/ ou docs/.
  • Use Conventional Commits.
  • Registre a mudança em [Não publicado] no CHANGELOG.md.
  • Não altere a versão em PRs; o bump acontece no corte da release.

O projeto usa SemVer. Consulte o histórico no changelog e os artefatos nas releases.

Licença

MIT — Copyright (c) 2026 DevCenter Squad.

Source 6 files
hooks/index.ts 13 lines
1import type { Register } from 'claude-code'
2import { register as registerEncoding } from './encoding.ts'
3import { register as registerProjectScope } from './project-scope.ts'
4import { register as registerSessionRules } from './session-rules.ts'
5import { register as registerSourceLint } from './source-lint.ts'
6
7export const register: Register = (on, options) => {
8  registerEncoding(on, options)
9  registerProjectScope(on, options)
10  registerSessionRules(on, options)
11  registerSourceLint(on, options)
12}
13
hooks/encoding.ts 113 lines
1import type { Register, EngineInterface, HookFailure, ToolCallResult } from 'claude-code'
2import { isInAddonProject, parentOf } from './commons.ts'
3
4// Converte arquivo-fonte de addon Sankhya para ISO-8859-1 depois de Write/Edit, e
5// entrega Read/Edit sobre UTF-8. As tools decodificam o arquivo como UTF-8: num arquivo
6// em ISO-8859-1, o Edit regravaria cada acento do trecho não editado como U+FFFD (#45).
7const SOURCE_EXTENSION = /\.(java|xml|kt|properties)$/
8const REPLACEMENT_CHARACTER = '�'
9const UTF8_BOM_LENGTH = 3
10const FROM_CHAR_CODE_CHUNK = 8192
11
12const readIfExists = ($: EngineInterface) => async (path: string) =>
13  (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
14
15// Sem o filtro de projeto o hook converteria .java de qualquer projeto da máquina.
16const isAddonSource = async ($: EngineInterface, filePath: string) =>
17  SOURCE_EXTENSION.test(filePath) && (await $.fs.exists(filePath)) && (await isInAddonProject(parentOf(filePath), readIfExists($)))
18
19const readBytes = async ($: EngineInterface, filePath: string) => {
20  const { base64 } = await $.fs.read(filePath, { as: 'bytes' })
21  return Uint8Array.from(atob(base64), c => c.charCodeAt(0))
22}
23
24const isSameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((byte, i) => byte === b[i])
25
26// O texto, quando os bytes são UTF-8 válido; undefined quando não são (arquivo em ISO-8859-1).
27const decodeUtf8 = (bytes: Uint8Array) => {
28  const text = new TextDecoder().decode(bytes)
29  const encoded = new TextEncoder().encode(text)
30  const isValid = isSameBytes(encoded, bytes) || isSameBytes(encoded, bytes.subarray(UTF8_BOM_LENGTH))
31  return isValid ? text : undefined
32}
33
34// Byte a byte: em ISO-8859-1 cada byte é o code point de mesmo valor. TextDecoder('latin1')
35// não serve, o WHATWG o trata como windows-1252 e troca a faixa 0x80-0x9F.
36const decodeLatin1 = (bytes: Uint8Array) => {
37  let text = ''
38  for (let i = 0; i < bytes.length; i += FROM_CHAR_CODE_CHUNK) {
39    text += String.fromCharCode(...bytes.subarray(i, i + FROM_CHAR_CODE_CHUNK))
40  }
41  return text
42}
43
44// $.fs.write só grava UTF-8: os bytes ISO-8859-1 saem do iconv, que vem com glibc, macOS e o
45// Git Bash que o Claude Code exige no Windows. Node não serve: o instalador nativo não o traz.
46// //TRANSLIT aproxima o que não existe em Latin-1 (— vira -).
47const writeLatin1 = ($: EngineInterface, filePath: string, text: string) =>
48  $.process.run(['sh', '-c', 'iconv -f UTF-8 -t ISO-8859-1//TRANSLIT > "$1"', 'sh', filePath], { stdin: text })
49
50// Devolve o aviso para o modelo, quando há um.
51const toIso = async ($: EngineInterface, filePath: string) => {
52  if (!(await isAddonSource($, filePath))) return undefined
53  const text = decodeUtf8(await readBytes($, filePath))
54  if (text === undefined) return undefined
55
56  // O byte original já não existe: converter só trocaria U+FFFD por '?' e esconderia a perda.
57  if (text.includes(REPLACEMENT_CHARACTER)) {
58    return `encoding: "${filePath}" contem U+FFFD -- acento perdido ao ler arquivo ISO-8859-1 como UTF-8. Arquivo NAO convertido: restaure o trecho acentuado (git diff / git checkout -- "${filePath}") e reaplique a edicao.`
59  }
60
61  $.ui.status('Convertendo encoding para ISO-8859-1...')
62  const written = await writeLatin1($, filePath, text).finally(() => $.ui.status(undefined))
63  if (written.exitCode === 0) return undefined
64
65  // O redirecionamento já truncou o arquivo: o conteúdo volta em UTF-8.
66  await $.fs.write(filePath, text)
67  return `encoding: conversao para ISO-8859-1 falhou em "${filePath}" -- arquivo mantido em UTF-8. ${written.stderr}`
68}
69
70// true quando converteu: Read só devolve para ISO-8859-1 o arquivo que ele mesmo trocou.
71const toUtf8 = async ($: EngineInterface, filePath: string) => {
72  if (!(await isAddonSource($, filePath))) return false
73  const bytes = await readBytes($, filePath)
74  if (decodeUtf8(bytes) !== undefined) return false
75  await $.fs.write(filePath, decodeLatin1(bytes))
76  return true
77}
78
79const withWarning = (ran: ToolCallResult, warning: string | undefined) =>
80  warning === undefined || ran.deny !== undefined ? ran : { ...ran, context: [...(ran.context ?? []), warning] }
81
82const hasSucceeded = (ran: ToolCallResult) => ran.deny === undefined && !ran.isError
83
84const aroundUtf8 = async (
85  $: EngineInterface,
86  filePath: string,
87  runTool: () => Promise<ToolCallResult>,
88  needsIso: (ran: ToolCallResult, wasConverted: boolean) => boolean,
89) => {
90  const wasConverted = await toUtf8($, filePath)
91  const ran = await runTool()
92  return needsIso(ran, wasConverted) ? withWarning(ran, await toIso($, filePath)) : ran
93}
94
95const reportFailure = (filePath: string, error: HookFailure, ran: ToolCallResult) =>
96  withWarning(
97    ran,
98    `encoding: hook falhou em "${filePath}" (${error.message ?? error.kind}) -- confira o encoding com a skill encoding.`,
99  )
100
101export const register: Register = on => {
102  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
103    const ran = await next(e)
104    return hasSucceeded(ran) ? withWarning(ran, await toIso($, e.file_path)) : ran
105  }).catch(async ($, e, next) => reportFailure(e.file_path, next.error, await next(e)))
106  on('tool.call', { tool: 'Edit' }, ($, e, next) =>
107    aroundUtf8($, e.file_path, () => next(e), (ran, wasConverted) => wasConverted || hasSucceeded(ran)),
108  ).catch(async ($, e, next) => reportFailure(e.file_path, next.error, await next(e)))
109  on('tool.call', { tool: 'Read' }, ($, e, next) =>
110    aroundUtf8($, e.file_path, () => next(e), (_ran, wasConverted) => wasConverted),
111  ).catch(async ($, e, next) => reportFailure(e.file_path, next.error, await next(e)))
112}
113
hooks/project-scope.ts 52 lines
1import type { Register, EngineInterface } from 'claude-code'
2import { isInAddonProject } from './commons.ts'
3
4// Fora de projeto Addon Studio o plugin some do contexto: listagem de skills, sub-agents,
5// Skill tool e menu `/`. Permite instalar o plugin no escopo de usuário sem que as
6// descriptions disputem o disparo em projeto que não é Sankhya.
7
8const readIfExists = ($: EngineInterface) => async (path: string) =>
9  (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
10
11const isOutsideAddonProject = async ($: EngineInterface) => !(await isInAddonProject(await $.session.cwd(), readIfExists($)))
12
13const INDICATOR = 'addon-studio'
14
15const ownPrefix = ($: EngineInterface) => `${$.plugin.name}:`
16
17// Uma linha por skill: as descriptions do plugin são escalares YAML de uma linha.
18const withoutOwnSkills = (listing: string, prefix: string) =>
19  listing
20    .split('\n')
21    .filter(line => !line.startsWith(`- ${prefix}`))
22    .join('\n')
23
24export const register: Register = on => {
25  // Indicador discreto (cinza, no fim da linha de dica do prompt): `$.ui.status` sai como aviso.
26  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
27    if (await isOutsideAddonProject($)) return next(e)
28    const tail = e.props.tail === undefined ? INDICATOR : `${e.props.tail} · ${INDICATOR}`
29    return next({ ...e, props: { ...e.props, tail } })
30  })
31
32  on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
33    if (!(await isOutsideAddonProject($))) return next(e)
34    return next({ ...e, text: withoutOwnSkills(e.text, ownPrefix($)) })
35  })
36
37  on('agent.offer', async ($, e, next) => {
38    if (!e.agent.startsWith(ownPrefix($)) || !(await isOutsideAddonProject($))) return next(e)
39    return { isOffered: false }
40  })
41
42  on('command.describe', async ($, e, next) => {
43    if (!e.command.startsWith(ownPrefix($)) || !(await isOutsideAddonProject($))) return next(e)
44    return next({ ...e, isHidden: true })
45  })
46
47  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
48    if (!e.skill.startsWith(ownPrefix($)) || !(await isOutsideAddonProject($))) return next(e)
49    return { deny: `${e.skill}: skill de projeto Sankhya Addon Studio, e este diretório não aplica o plugin Gradle br.com.sankhya.addonstudio.` }
50  })
51}
52
hooks/session-rules.ts 32 lines
1import type { Register, EngineInterface } from 'claude-code'
2import { findAddonRoot } from './commons.ts'
3
4// Piso para projeto que nunca rodou `/addon-studio:init`: sem isso, as regras universais
5// (Java 8 estrito, ISO-8859-1, JAPE, Guice) não entram no contexto por caminho nenhum.
6// Fonte única: o mesmo ADDON.md que o `init` copia.
7const RULES_BLOCK = 'addonStudioRules'
8const INIT_NOTE =
9  '[Injetado pelo plugin addon-studio: este projeto nao tem docs/ADDON.md. Rode /addon-studio:init para fixar estas regras no projeto.]\n\n'
10
11const readIfExists = ($: EngineInterface) => async (path: string) =>
12  (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
13
14// Com o init, o ADDON.md do projeto já está no contexto via CLAUDE.md, que o Claude Code
15// carrega também dos diretórios acima do cwd.
16const hasRunInit = async ($: EngineInterface, root: string) =>
17  (await $.fs.exists(`${root}/docs/ADDON.md`)) && ((await readIfExists($)(`${root}/CLAUDE.md`))?.includes('@docs/ADDON.md') ?? false)
18
19const needsRules = async ($: EngineInterface) => {
20  const root = await findAddonRoot(await $.session.cwd(), readIfExists($))
21  return root !== undefined && !(await hasRunInit($, root))
22}
23
24export const register: Register = on => {
25  on('prompt.context', async ($, e, next) => {
26    const context = await next(e)
27    if (!(await needsRules($))) return context
28    const rules = await $.fs.read(`${$.plugin.root}/skills/init/assets/ADDON.md`)
29    return { ...context, blocks: [...context.blocks, { name: RULES_BLOCK, text: INIT_NOTE + rules }] }
30  })
31}
32
hooks/source-lint.ts 292 lines
1import type { Register, EngineInterface, HookFailure, ToolCallResult } from 'claude-code'
2import { isInAddonProject, parentOf } from './commons.ts'
3
4// Avisa o modelo, sem bloquear, quando o código gravado em projeto Addon Studio viola regra
5// documentada nas skills. Toda regra cita a origem: regra sem origem nas skills não entra.
6// No Edit só o new_string é verificado, para não repetir violação que já estava no arquivo.
7
8type Rule = {
9  pattern: RegExp
10  message: string
11  source: string
12  // A regra só vale quando o texto verificado também casa isto (ex.: é um @Controller).
13  onlyWith?: RegExp
14}
15
16const JAVA_FILE = /\.java$/
17// encoding/SKILL.md restringe o cabeçalho obrigatório ao XML de dicionário e dbscripts.
18const HEADER_XML_FILE = /[\\/](dbscripts|datadictionary)[\\/][^\\/]*\.xml$/
19const XML_DECLARATION_WITH_ENCODING = /^\s*<\?xml[^?]*\bencoding\s*=/i
20
21const ADDON = 'init/assets/ADDON.md'
22const USE_LOG = 'use `@Log` Lombok + `java.util.logging`'
23const USE_GUICE_INJECT = 'use `com.google.inject.Inject`'
24
25// A regra Java 8 estrito do ADDON.md proíbe o que não existe no Java 8; a lista dela é exemplo, não o limite.
26// Fica de fora o símbolo Java 9+ cujo nome também existe no Java 8 ou em lib comum
27// (`Optional.isEmpty`, `.or(`, `.lines()`, `.transferTo(`, `getFirst()`, `.reversed()`).
28const notInJava8 = (pattern: RegExp, symbol: string, version: number, instead?: string): Rule => ({
29  pattern,
30  message: `${symbol} é Java ${version}+ (projeto é Java 8 estrito)${instead === undefined ? '' : ` — use ${instead}`}`,
31  source: ADDON,
32})
33
34const JAVA_8_RULES: Rule[] = [
35  notInJava8(/\bvar\s+[A-Za-z_$][\w$]*\s*[=:,)]/g, '`var`', 10),
36  notInJava8(/\b(?:List|Set|Map)\.(?:of|copyOf)\s*\(/g, '`List`/`Set`/`Map` `.of`/`.copyOf`', 9, '`Arrays.asList`/`Collections.unmodifiable*`'),
37  notInJava8(/\bMap\.(?:ofEntries|entry)\s*\(/g, '`Map.ofEntries`/`Map.entry`', 9, '`new AbstractMap.SimpleEntry<>(k, v)`'),
38  notInJava8(/\.isBlank\(\s*\)/g, '`String.isBlank()`', 11, '`trim().isEmpty()`'),
39  notInJava8(/\.strip(?:Leading|Trailing|Indent)?\(\s*\)/g, '`String.strip*()`', 11, '`trim()`'),
40  notInJava8(/(?<!\b(?:StringUtils|Strings))\.repeat\s*\(/g, '`String.repeat`', 11),
41  notInJava8(/\.(?:indent|formatted)\s*\(|\.translateEscapes\(\s*\)/g, '`String.indent`/`formatted`/`translateEscapes`', 12, '`String.format`'),
42  notInJava8(/(?<!\bCollectors)\.toList\(\s*\)/g, '`Stream.toList()`', 16, '`collect(Collectors.toList())`'),
43  notInJava8(/\.(?:takeWhile|dropWhile)\s*\(|\bStream\.ofNullable\s*\(/g, '`takeWhile`/`dropWhile`/`Stream.ofNullable`', 9),
44  notInJava8(/\.mapMulti\s*\(/g, '`Stream.mapMulti`', 16),
45  notInJava8(/\btoUnmodifiable(?:List|Set|Map)\s*\(|\bCollectors\.(?:filtering|flatMapping|teeing)\s*\(/g, '`Collectors` `toUnmodifiable*`/`filtering`/`flatMapping`/`teeing`', 9),
46  notInJava8(/\.orElseThrow\(\s*\)/g, '`orElseThrow()` sem argumento', 10, '`orElseThrow(Supplier)`'),
47  notInJava8(/\.ifPresentOrElse\s*\(/g, '`Optional.ifPresentOrElse`', 9),
48  notInJava8(/\bPredicate\.not\s*\(/g, '`Predicate.not`', 11),
49  notInJava8(/\bObjects\.(?:requireNonNullElse(?:Get)?|checkIndex|checkFromToIndex|checkFromIndexSize)\s*\(/g, '`Objects.requireNonNullElse`/`check*Index`', 9),
50  notInJava8(/\bFiles\.(?:readString|writeString)\s*\(|\bPath\.of\s*\(/g, '`Files.readString`/`writeString`/`Path.of`', 11, '`Paths.get` e `Files.readAllBytes`/`write`'),
51  notInJava8(/\.readAllBytes\(\s*\)|\.readNBytes\s*\(/g, '`InputStream.readAllBytes()`/`readNBytes`', 9),
52  notInJava8(/\.(?:orTimeout|completeOnTimeout)\s*\(|\bCompletableFuture\.(?:failedFuture|delayedExecutor|completedStage|failedStage)\s*\(/g, '`CompletableFuture` timeout/`failedFuture`', 9),
53  notInJava8(/\bMath\.clamp\s*\(/g, '`Math.clamp`', 21),
54  notInJava8(/\b(?:ProcessHandle|StackWalker|VarHandle|HexFormat)\b|^[ \t]*import\s+java\.net\.http\./gm, '`ProcessHandle`/`StackWalker`/`VarHandle`/`HexFormat`/`java.net.http`', 9),
55  notInJava8(/\bThread\.(?:ofVirtual|ofPlatform|startVirtualThread)\s*\(|\bnewVirtualThreadPerTaskExecutor\s*\(/g, 'virtual thread', 21),
56  notInJava8(/@Deprecated\s*\(/g, '`@Deprecated(since/forRemoval)`', 9, '`@Deprecated` sem argumento'),
57  notInJava8(/\bnew\s+[\w$.]+\s*<>\s*\((?:[^()]|\([^()]*\))*\)\s*\{/g, 'diamond `<>` em classe anônima', 9, 'o tipo explícito em `new X<Tipo>() {`'),
58  notInJava8(/\btry\s*\(\s*[\w$.]+\s*[;)]/g, 'try-with-resources com variável já declarada', 9, '`try (Tipo nome = ...)`'),
59  notInJava8(/\bcase\b[^:;{}]*->|\bdefault\s*->/g, '`switch` com `->`', 14, '`case X:` com `break`'),
60  notInJava8(/(?<![.\w$])yield\s+[\w$"'(-]/g, '`yield` em switch', 14),
61  notInJava8(/\binstanceof\s+(?:final\s+)?[\w$.]+(?:<[^>]*>)?(?:\[\])*(?:\s+[A-Za-z_$][\w$]*\b|\s*\()/g, 'pattern matching em `instanceof`', 16, 'cast explícito depois do `instanceof`'),
62  notInJava8(/\brecord\s+[A-Z][\w$]*\s*[(<]/g, '`record`', 16, 'classe com Lombok `@Data`'),
63  notInJava8(/\b(?:non-)?sealed\s+(?:abstract\s+|static\s+)*(?:class|interface)\b/g, '`sealed`', 17),
64  notInJava8(/"""/g, 'text block', 15),
65  notInJava8(/^[ \t]*(?:open\s+)?module\s+[\w.]+\s*\{/gm, '`module-info`', 9),
66]
67
68const JAVA_RULES: Rule[] = [
69  ...JAVA_8_RULES,
70  {
71    pattern: /^[ \t]*import\s+(?:javax|jakarta)\.persistence\./gm,
72    message: 'JPA padrão — use `@JapeEntity` e as anotações de `br.com.sankhya.studio.persistence`',
73    source: ADDON,
74  },
75  {
76    pattern: /\b(?:JapeWrapper|EntityFacade)\b/g,
77    onlyWith: /@Controller\b/,
78    message: '`JapeWrapper`/`EntityFacade` direto em controller — use interface estendendo `JapeRepository`',
79    source: ADDON,
80  },
81  { pattern: /^[ \t]*import\s+javax\.inject\./gm, message: `\`javax.inject\` — ${USE_GUICE_INJECT}`, source: `${ADDON}, dependency-injection/SKILL.md` },
82  { pattern: /^[ \t]*import\s+org\.slf4j\./gm, message: `SLF4J — ${USE_LOG}`, source: ADDON },
83  { pattern: /@Slf4j\b/g, message: `SLF4J — ${USE_LOG}`, source: ADDON },
84  { pattern: /\bSystem\.out\b/g, message: `\`System.out\` — ${USE_LOG}`, source: `${ADDON}, job/SKILL.md` },
85  {
86    pattern: /\bthrow\s+new\s+RuntimeException\s*\(/g,
87    message: '`RuntimeException` cru — lance exceção tipada estendendo `RuntimeException` com mensagem de negócio',
88    source: `${ADDON}, listener/SKILL.md`,
89  },
90  {
91    pattern: /@Service\b/g,
92    message: '`@Service` é legado — endpoint é `@Controller`, service de negócio é `@Component`',
93    source: 'controller/SKILL.md',
94  },
95  {
96    pattern: /@Component\b/g,
97    onlyWith: /@(?:Controller|Repository)\b/,
98    message: '`@Controller`/`@Repository` já são gerenciados — não adicione `@Component`',
99    source: 'dependency-injection/SKILL.md, controller/SKILL.md',
100  },
101  {
102    pattern: /\bTxType\.SUPPORTS\b/g,
103    message: '`TxType.SUPPORTS` não existe — omita `@Transactional` (o método herda `Supports` da classe)',
104    source: 'controller/SKILL.md',
105  },
106  {
107    pattern: /^[ \t]*import\s+[\w.]+\.transaction\.Transactional\s*;/gm,
108    message: 'import errado de `@Transactional` — use `br.com.sankhya.studio.persistence.Transactional`',
109    source: 'job/SKILL.md',
110  },
111  {
112    pattern: /^[ \t]*import\s+[\w.]+\.stereotypes\.Job\s*;/gm,
113    message: 'import errado de `@Job` — use `br.com.sankhya.studio.annotations.Job`',
114    source: 'job/SKILL.md',
115  },
116  {
117    pattern: /\bimplements\s+(?:[\w.<>]+\s*,\s*)*IJob\b/g,
118    message: '`IJob` é classe abstrata — use `extends IJob`',
119    source: 'job/SKILL.md',
120  },
121  { pattern: /@Job\s*\([^)]*\bname\s*=/g, message: '`@Job(name = ...)` — use `@Job(serviceName = ...)`', source: 'job/SKILL.md' },
122  {
123    pattern: /\bString\s+getScheduleConfigHook\s*\(/g,
124    message: 'frequência vem de `getScheduleConfig()` — `getScheduleConfigHook()` é obsoleto',
125    source: 'job/SKILL.md',
126  },
127  {
128    pattern: /\bTransactionType\.REQUIRES_NEW\b/g,
129    message: '`TransactionType.REQUIRES_NEW` não existe — use `AUTOMATIC` ou `MANUAL`',
130    source: 'action-button/SKILL.md',
131  },
132  { pattern: /\bFieldType\.CHECKBOX\b/g, message: '`FieldType.CHECKBOX` não existe — use `FieldType.BOOLEAN`', source: 'action-button/SKILL.md' },
133  {
134    pattern: /\bRefreshTypeEnum\.(?:ALL|ITEM)\b/g,
135    message: '`RefreshTypeEnum.ALL`/`ITEM` não existem — use `ALL_ITEMS`/`NONE_ITEM`',
136    source: 'action-button/SKILL.md',
137  },
138  {
139    pattern: /@Callback\s*\((?=[^)]*\bAFTER\b)(?=[^)]*\bPROCESS_BILLING\b)/g,
140    message: '`PROCESS_BILLING` só existe com `BEFORE`',
141    source: 'callback/SKILL.md',
142  },
143  {
144    pattern: /@ExceptionHandler\s*\(\s*(?:value\s*=\s*)?\{?\s*Exception\.class\s*\}?\s*\)/g,
145    message: '`@ExceptionHandler(Exception.class)` pega-tudo — declare exceções específicas',
146    source: 'controller-advice/SKILL.md',
147  },
148  {
149    pattern: /@ExceptionHandler\s*\(\s*(?:value\s*=\s*)?\{\s*\}\s*\)/g,
150    message: '`@ExceptionHandler({})` vazio — declare ao menos uma classe',
151    source: 'controller-advice/SKILL.md',
152  },
153  {
154    pattern: /\bfindByPK\s*\((?:[^()]|\([^()]*\))*\)\s*\.\s*(?:orElseThrow|map)\s*\(/g,
155    message: '`findByPK` retorna `T` nullable, não `Optional` — use null-check',
156    source: 'repository/SKILL.md',
157  },
158  { pattern: /@Delete\b/g, message: '`@Delete` descontinuada — use `@Modifying` + `@NativeQuery`', source: 'repository/SKILL.md' },
159  {
160    pattern: /@Modifying\b[^;{}]*?\bint\s+[\w$]+\s*\(/g,
161    message: '`@Modifying` retornando `int` — use `void` ou `Boolean`',
162    source: 'repository/SKILL.md',
163  },
164  {
165    pattern: /^[ \t]*import\s+br\.com\.sankhya\.sdk\.data\.repository\.NativeQuery\s*;/gm,
166    message: 'import errado de `@NativeQuery` — use `br.com.sankhya.studio.persistence.NativeQuery`',
167    source: 'repository/SKILL.md',
168  },
169  {
170    pattern: /\bPageable\.of\s*\(/g,
171    message: '`Pageable` não tem factory — use `PageRequest.of(...)`',
172    source: 'repository/SKILL.md',
173  },
174  {
175    pattern: /\.getTotal(?:Elements|Pages)\s*\(/g,
176    message: '`Page<T>` não tem total — use `hasNext()`/`isLast()` ou `COUNT` próprio',
177    source: 'repository/SKILL.md',
178  },
179  {
180    pattern: /^[ \t]*import\s+br\.com\.sankhya\.jape\.util\.JdbcWrapper\s*;/gm,
181    message: 'import errado — use `br.com.sankhya.jape.dao.JdbcWrapper`',
182    source: 'listener/SKILL.md',
183  },
184  {
185    pattern: /\bDynamicVO\s+[\w$]+\s*=\s*[\w$.]+\.getVo\s*\(\s*\)/g,
186    message: '`getVo()` sem cast — use `(DynamicVO) event.getVo()`',
187    source: 'listener/SKILL.md',
188  },
189  {
190    pattern: /@Value\s*\([^)]*\)\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:(?:private|protected|public|static)\s+)*final\s+(?!class\b)/g,
191    message: 'campo `final` com `@Value` — remova o `final`',
192    source: 'value/SKILL.md',
193  },
194  {
195    pattern: /@Value\s*\((?=[^)]*\bvalue\s*=)(?=[^)]*\bparam\s*=)/g,
196    message: '`@Value` com `value` e `param` juntos — use só `param`',
197    source: 'value/SKILL.md',
198  },
199  {
200    pattern: /@Value\s*\((?=[^)]*\bgroup\s*=)(?=[^)]*\b(?:ENV_VAR|SYSTEM_PROPERTY)\b)/g,
201    message: '`group` só funciona com `SANKHYA_PARAM`',
202    source: 'value/SKILL.md',
203  },
204]
205
206const XML_RULES: Rule[] = [
207  {
208    pattern: /<\?xml[^?]*\bencoding\s*=\s*["'](?!ISO-8859-1["'])/gi,
209    message: 'cabeçalho XML com encoding diferente — use `<?xml version="1.0" encoding="ISO-8859-1" ?>`',
210    source: 'encoding/SKILL.md',
211  },
212]
213
214type Finding = { line: number; message: string; source: string }
215
216const missingXmlHeader: Finding = {
217  line: 1,
218  message: 'XML sem cabeçalho obrigatório `<?xml version="1.0" encoding="ISO-8859-1" ?>`',
219  source: 'encoding/SKILL.md',
220}
221
222// Troca o conteúdo de comentários e literais por espaço, mantendo as quebras de linha, para o
223// regex não casar texto que não é código. A abertura do text block fica: a regra dele é o `"""`.
224const blankNonCode = (source: string) => {
225  const blank = (text: string) => text.replace(/[^\n]/g, ' ')
226  return source.replace(
227    /\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)|"""[\s\S]*?(?:"""|$)|"(?:\\.|[^"\\\n])*"?|'(?:\\.|[^'\\\n])*'?/g,
228    token => {
229      if (token.startsWith('"""')) return `"""${blank(token.slice(3))}`
230      if (token.startsWith('"') || token.startsWith("'")) return token[0] + blank(token.slice(1))
231      return blank(token)
232    },
233  )
234}
235
236const lineAt = (text: string, index: number) => text.slice(0, index).split('\n').length
237
238const violations = (text: string, rules: Rule[]): Finding[] =>
239  rules
240    .filter(rule => rule.onlyWith === undefined || rule.onlyWith.test(text))
241    .flatMap(({ pattern, message, source }) => [...text.matchAll(pattern)].map(match => ({ line: lineAt(text, match.index), message, source })))
242    .sort((a, b) => a.line - b.line)
243
244const javaViolations = (text: string) => violations(blankNonCode(text), JAVA_RULES)
245
246const writeViolations = (filePath: string, content: string) => {
247  if (JAVA_FILE.test(filePath)) return javaViolations(content)
248  if (!HEADER_XML_FILE.test(filePath)) return []
249  const header = XML_DECLARATION_WITH_ENCODING.test(content) ? [] : [missingXmlHeader]
250  return [...header, ...violations(content, XML_RULES)]
251}
252
253const editViolations = (filePath: string, newString: string) => {
254  if (JAVA_FILE.test(filePath)) return javaViolations(newString)
255  return HEADER_XML_FILE.test(filePath) ? violations(newString, XML_RULES) : []
256}
257
258const report = (where: string, found: Finding[]) =>
259  [
260    `source-lint: ${where} viola regra das skills do addon-studio (aviso; corrija se a violação for sua):`,
261    ...found.map(({ line, message, source }) => `- linha ${line}: ${message} [${source}]`),
262  ].join('\n')
263
264const readIfExists = ($: EngineInterface) => async (path: string) =>
265  (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
266
267const lintAfter = async ($: EngineInterface, ran: ToolCallResult, filePath: string, lint: () => Finding[], where: string) => {
268  if (ran.deny !== undefined || ran.isError) return ran
269  const found = lint()
270  if (found.length === 0) return ran
271  if (!(await isInAddonProject(parentOf(filePath), readIfExists($)))) return ran
272  return { ...ran, context: [...(ran.context ?? []), report(where, found)] }
273}
274
275// O arquivo já foi gravado quando o lint roda: falha dele vira aviso, não derruba a tool.
276const reportFailure = (filePath: string, error: HookFailure, ran: ToolCallResult) =>
277  ran.deny !== undefined
278    ? ran
279    : {
280        ...ran,
281        context: [...(ran.context ?? []), `source-lint: hook falhou em "${filePath}" (${error.message ?? error.kind}) -- arquivo gravado sem verificação das regras das skills.`],
282      }
283
284export const register: Register = on => {
285  on('tool.call', { tool: 'Write' }, async ($, e, next) =>
286    lintAfter($, await next(e), e.file_path, () => writeViolations(e.file_path, e.content), `"${e.file_path}"`),
287  ).catch(async ($, e, next) => reportFailure(e.file_path, next.error, await next(e)))
288  on('tool.call', { tool: 'Edit' }, async ($, e, next) =>
289    lintAfter($, await next(e), e.file_path, () => editViolations(e.file_path, e.new_string), `o trecho novo (new_string) de "${e.file_path}"`),
290  ).catch(async ($, e, next) => reportFailure(e.file_path, next.error, await next(e)))
291}
292
hooks/commons.ts 25 lines
1const ADDON_GRADLE_PLUGIN = 'br.com.sankhya.addonstudio'
2const BUILD_FILES = ['build.gradle', 'build.gradle.kts']
3
4export const parentOf = (path: string) => path.replace(/[\\/]+[^\\/]*$/, '')
5
6// Conteúdo do arquivo, ou undefined quando ele não existe. Recebido de quem chama porque
7// `$` não atravessa import: o engine só o segue em função declarada no próprio arquivo.
8export type ReadIfExists = (path: string) => Promise<string | undefined>
9
10// O módulo -vc não aplica o plugin Gradle, a raiz sim: por isso a subida até a raiz.
11export const findAddonRoot = async (startDir: string, readIfExists: ReadIfExists) => {
12  for (let dir = startDir; dir !== ''; ) {
13    for (const name of BUILD_FILES) {
14      if ((await readIfExists(`${dir}/${name}`))?.includes(ADDON_GRADLE_PLUGIN)) return dir
15    }
16    const parent = parentOf(dir)
17    if (parent === dir) return undefined
18    dir = parent
19  }
20  return undefined
21}
22
23export const isInAddonProject = async (startDir: string, readIfExists: ReadIfExists) =>
24  (await findAddonRoot(startDir, readIfExists)) !== undefined
25