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…

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.
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.
curl -fsSL https://github.com/snk-devcenter/addon-studio/releases/latest/download/addon-studio-install.sh | sh -s -- --claude
& ([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
curl -fsSL https://github.com/snk-devcenter/addon-studio/releases/latest/download/addon-studio-install.sh | sh -s -- --codex
& ([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:
~/.codex/agents/$HOME\.codex\agents\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.
Abra uma nova sessão da CLI depois da instalação.
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.
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.
As skills são selecionadas pelo contexto do pedido. Exemplos:
| Pedido | Skills 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 |
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
| Skill | Responsabilidade |
|---|---|
init | Prepara docs/ADDON.md e CLAUDE.md para uso com Claude Code |
build | Build e deploy local com Gradle |
encoding | Auditoria e conversão de fontes para ISO-8859-1 |
| Skill | Responsabilidade |
|---|---|
entity | Entidades @JapeEntity, chaves e relacionamentos |
repository | JapeRepository, critérios, queries nativas e paginação |
database | Dbscripts versionados para Oracle e SQL Server |
data-dictionary | Telas cadastrais geradas pelo dicionário de dados |
merge-on-root | Campos novos em entidade nativa via tabela de extensão 1:1 |
macros | SQL portável com MacroTranslator |
| Skill | Responsabilidade |
|---|---|
controller | Endpoints REST, DTOs, validação e transações |
controller-advice | Tratamento global de exceções e respostas HTTP |
mapstruct | Conversão entre DTOs e entidades |
dependency-injection | Wiring Guice, módulos, providers e escopos |
retrofit | Clientes HTTP com Retrofit, Moshi e OkHttp |
type-adapter | Serialização JSON global de tipos |
value | Parâmetros Sankhya, @Value e feature flags |
sankhya-utils | Utilitários nativos de com.sankhya.util |
| Skill | Responsabilidade |
|---|---|
action-button | Botões de ação com AcaoRotinaJava |
business-rule | Regras do barramento comercial |
callback | Hooks de confirmação e faturamento de documento |
listener | Eventos CRUD de persistência |
before-load-listener | Filtros transversais antes de consultas JAPE |
job | Processamentos agendados com CRON |
| Skill | Responsabilidade |
|---|---|
sankhya-js | Telas HTML5 em AngularJS sobre sankhya-js |
jsp | Telas .jsp do add-on com a taglib sankhyaUtil |
test | Testes JUnit 5 e Mockito para addons |
Os seis agents cobrem tarefas maiores que atravessam várias skills.
| Agent | Quando usar |
|---|---|
addon-reviewer | Revisão pré-commit de compatibilidade, encoding e padrões do plugin |
entity-architect | Modelagem conjunta de entidade, dbscript e dicionário |
controller-designer | Endpoint completo com DTOs, mapper e tratamento de erros |
test-writer | Suíte de testes ou cobertura de vários arquivos |
troubleshooter | Diagnóstico de causa-raiz ainda incerta |
dbscript-builder | Migration 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.
O plugin mantém estas restrições em todas as implementações:
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.
| Artefato | Padrão | Exemplo |
|---|---|---|
| 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 nativa | PRXXYZIPA |
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.
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.
.
├── .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>/.
As regras de contribuição e release estão em CLAUDE.md. Em resumo:
main usando feat/, fix/ ou docs/.[Não publicado] no CHANGELOG.md.O projeto usa SemVer. Consulte o histórico no changelog e os artefatos nas releases.
MIT — Copyright (c) 2026 DevCenter Squad.
hooks/index.ts 13 lines1import 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}
13hooks/encoding.ts 113 lines1import 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}
113hooks/project-scope.ts 52 lines1import 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}
52hooks/session-rules.ts 32 lines1import 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}
32hooks/source-lint.ts 292 lines1import 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}
292hooks/commons.ts 25 lines1const 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