@rclr-labs/dsh-plugin-qa 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -2
- package/bin/plugin-qa.mjs +107 -0
- package/cordis.patch.yml +8 -0
- package/lib/core.mjs +274 -0
- package/lib/index.js +60 -0
- package/package.json +63 -4
package/README.md
CHANGED
|
@@ -1,3 +1,106 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rclr-labs/dsh-plugin-qa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**“Este plugin instala e boota num perfil limpo?”** — respondido executando, não opinando.
|
|
4
|
+
|
|
5
|
+
O DSH isola falha de plugin: um plugin que quebra no carregamento **não derruba mais o
|
|
6
|
+
boot** — o processo sai 0 e apenas avisa (`N entries did not activate`). Por isso
|
|
7
|
+
“instalou” nunca significou “funciona”, e nem CI nem o usuário percebem a diferença.
|
|
8
|
+
Num catálogo real, **93% dos plugins falhavam na instalação**. Este pacote é o nosso
|
|
9
|
+
`npm run verify` empacotado para terceiros: ele instala a spec num `DSH_HOME`
|
|
10
|
+
descartável, **boota de verdade** e lê os avisos de ativação.
|
|
11
|
+
|
|
12
|
+
## O que ele faz
|
|
13
|
+
|
|
14
|
+
Para cada spec (diretório local, tarball `.tgz` ou nome no registry):
|
|
15
|
+
|
|
16
|
+
1. cria um perfil descartável (template `headless`, `DSH_HOME` temporário — o seu perfil real não é tocado);
|
|
17
|
+
2. instala com o **`dsh plugin add` real** (não é simulação de `node_modules`);
|
|
18
|
+
3. boota e parseia a saída de ativação;
|
|
19
|
+
4. descarta o perfil (inclusive em falha) e devolve um relatório estruturado:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"spec": "packages/meu-plugin",
|
|
24
|
+
"nome": "dsh-meu-plugin",
|
|
25
|
+
"versao": "1.2.0",
|
|
26
|
+
"instala": true,
|
|
27
|
+
"boota": false,
|
|
28
|
+
"entradasNaoAtivadas": [
|
|
29
|
+
{ "id": "meu-plugin", "pacote": "dsh-meu-plugin", "motivo": "failed to import", "detalhe": "..." }
|
|
30
|
+
],
|
|
31
|
+
"camadas": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless", "dsh-meu-plugin"],
|
|
32
|
+
"erro": "1 entrada(s) não ativaram",
|
|
33
|
+
"duracaoMs": 908
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## CLI
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# no checkout deste repo
|
|
41
|
+
node packages/plugin-qa/bin/plugin-qa.mjs check packages/meu-plugin
|
|
42
|
+
|
|
43
|
+
# instalado globalmente
|
|
44
|
+
npm i -g @rclr-labs/dsh-plugin-qa
|
|
45
|
+
plugin-qa check packages/meu-plugin
|
|
46
|
+
plugin-qa check dsh-context --strict
|
|
47
|
+
plugin-qa check packages/a dist/b.tgz --json # várias specs, resultado agregado
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| opção | efeito |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `--json` | imprime o selo agregado em JSON (consumível por CI/agente) |
|
|
53
|
+
| `--strict` | exit 1 se **qualquer** spec não instalar ou não bootar |
|
|
54
|
+
|
|
55
|
+
Exit codes: `0` = verificação concluída (sem `--strict`, mesmo reprovado); `1` = `--strict`
|
|
56
|
+
e alguma spec reprovada; `2` = erro de uso/infraestrutura.
|
|
57
|
+
|
|
58
|
+
## Como plugin (ferramenta para o agente)
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
dsh plugin --profile <perfil> add @rclr-labs/dsh-plugin-qa
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
O pacote traz `cordis.patch.yml` no `files` e registra a ferramenta **`plugin_qa`**
|
|
65
|
+
(`spec` obrigatória, `strict` opcional). O agente passa a poder responder sozinho
|
|
66
|
+
“esse plugin instala e boota?” com a mesma prova que o CLI produz.
|
|
67
|
+
|
|
68
|
+
## O selo
|
|
69
|
+
|
|
70
|
+
O resultado agregado é o selo:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{ "selo": "aprovado", "ok": true, "total": 2, "aprovados": 2, "reprovados": 0, "resultados": [ ... ] }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **aprovado** — toda spec `instala: true` **e** `boota: true` (nenhuma entrada ficou de fora);
|
|
77
|
+
- **reprovado** — qualquer spec que não instale, ou que instale mas deixe alguma entrada sem ativar.
|
|
78
|
+
|
|
79
|
+
`boota` exige que a árvore tenha montado **e** que zero entradas tenham ficado de fora.
|
|
80
|
+
O código de saída do boot não é usado como sinal: o `headless` sem tarefa sai 1 por uso
|
|
81
|
+
(“a task is required”) depois de montar a árvore — o sinal é a fase de montagem.
|
|
82
|
+
|
|
83
|
+
## Verificação deste pacote (no checkout)
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
node packages/plugin-qa/test/plugin-qa.test.mjs # o teste que falha sem o pacote
|
|
87
|
+
node test-support/compat.mjs --spec=packages/plugin-qa
|
|
88
|
+
node test-support/profile.mjs --demo --spec=packages/plugin-qa
|
|
89
|
+
node packages/plugin-qa/bin/plugin-qa.mjs check packages/locale-pt-br # dogfooding
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Limitações honestas
|
|
93
|
+
|
|
94
|
+
- **Não julga qualidade.** Não lê o código, não avalia intenção, segurança, licença nem
|
|
95
|
+
desempenho — responde só “instala e boota num perfil limpo?”. Não substitui revisão de código.
|
|
96
|
+
- **Não é análise estática.** É uma prova de execução: exige o CLI do DSH instalado
|
|
97
|
+
(`DSH_BIN` ou o app Desktop) e uma instalação real da spec (registry/rede quando for o caso).
|
|
98
|
+
- **Um perfil por spec, em sequência.** Determinístico e limpo, mas não paralelo; ~1s por spec
|
|
99
|
+
local depois do primeiro boot.
|
|
100
|
+
- **O harness de perfil vive fora do pacote.** O núcleo importa
|
|
101
|
+
`test-support/profile.mjs` do checkout (`../../../test-support/profile.mjs`), então o
|
|
102
|
+
`verify` roda **no checkout**. Um pacote instalado isoladamente ativa como plugin (a linha
|
|
103
|
+
compõe), mas o CLI/tool exige o checkout do `dsh-plugins` até que o harness seja embutido.
|
|
104
|
+
- **Specs locais resolvem a partir da raiz do checkout**, como em `profile.addSpec`.
|
|
105
|
+
- Um perfil limpo do template `headless` **não** é o perfil do usuário: um plugin que boota
|
|
106
|
+
aqui ainda pode conflitar com as outras camadas que você instalou.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* plugin-qa — "este plugin instala e boota num perfil limpo?"
|
|
4
|
+
*
|
|
5
|
+
* plugin-qa check <spec> [<spec>...] [--json] [--strict]
|
|
6
|
+
*
|
|
7
|
+
* A spec é aceita em qualquer forma que o `dsh plugin add` aceite: diretório
|
|
8
|
+
* local, `.tgz` empacotado ou nome no registry. Cada spec roda num DSH_HOME
|
|
9
|
+
* descartável e é sempre limpa — inclusive em falha.
|
|
10
|
+
*
|
|
11
|
+
* `--json` imprime o selo agregado (consumível por CI/agente).
|
|
12
|
+
* `--strict` devolve exit 1 quando alguma spec não instala ou não boota.
|
|
13
|
+
*
|
|
14
|
+
* Por que existe: o DSH isola a falha do plugin — o boot sai 0 e só avisa — então
|
|
15
|
+
* "instalou" nunca foi o mesmo que "funciona". Este é o nosso `npm run verify`
|
|
16
|
+
* empacotado para terceiros.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { verificar } from '../lib/core.mjs'
|
|
20
|
+
|
|
21
|
+
const argv = process.argv.slice(2)
|
|
22
|
+
const flag = (nome) => argv.includes(`--${nome}`)
|
|
23
|
+
const comando = argv.find((a) => !a.startsWith('-')) ?? null
|
|
24
|
+
const specs = argv.filter((a) => !a.startsWith('-') && a !== comando)
|
|
25
|
+
|
|
26
|
+
const uso = () => {
|
|
27
|
+
console.log(`
|
|
28
|
+
plugin-qa — este plugin instala e boota num perfil limpo?
|
|
29
|
+
|
|
30
|
+
uso:
|
|
31
|
+
plugin-qa check <spec> [<spec>...] [--json] [--strict]
|
|
32
|
+
|
|
33
|
+
spec:
|
|
34
|
+
diretório local (ex.: packages/meu-plugin), tarball (.tgz) ou nome no registry
|
|
35
|
+
|
|
36
|
+
opções:
|
|
37
|
+
--json imprime o selo agregado em JSON
|
|
38
|
+
--strict exit 1 se alguma spec não instalar ou não bootar
|
|
39
|
+
|
|
40
|
+
exemplos:
|
|
41
|
+
plugin-qa check packages/meu-plugin
|
|
42
|
+
plugin-qa check dsh-context --strict
|
|
43
|
+
plugin-qa check packages/a dist/b.tgz --json
|
|
44
|
+
`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (flag('help')) {
|
|
48
|
+
uso()
|
|
49
|
+
process.exit(0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!comando) {
|
|
53
|
+
uso()
|
|
54
|
+
process.exit(2)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (comando !== 'check') {
|
|
58
|
+
console.error(` ✗ comando desconhecido: ${comando} (use: check)`)
|
|
59
|
+
process.exit(2)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!specs.length) {
|
|
63
|
+
console.error(' ✗ falta pelo menos uma spec. Ex.: plugin-qa check packages/meu-plugin')
|
|
64
|
+
process.exit(2)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let selo
|
|
68
|
+
try {
|
|
69
|
+
selo = await verificar(specs, {
|
|
70
|
+
bin: process.env.DSH_BIN,
|
|
71
|
+
timeoutMs: Number(process.env.QA_TIMEOUT_MS ?? 300_000),
|
|
72
|
+
bootTimeoutMs: Number(process.env.QA_BOOT_TIMEOUT_MS ?? 60_000),
|
|
73
|
+
})
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// Só chega aqui um erro de infraestrutura (ex.: harness de perfil ausente).
|
|
76
|
+
console.error(` ✗ ${err?.message ?? err}`)
|
|
77
|
+
if (flag('json')) console.log(JSON.stringify({ selo: 'reprovado', ok: false, erro: String(err?.message ?? err), resultados: [] }, null, 2))
|
|
78
|
+
process.exit(2)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (flag('json')) {
|
|
82
|
+
console.log(JSON.stringify(selo, null, 2))
|
|
83
|
+
} else {
|
|
84
|
+
console.log(`\n plugin-qa — "instala e boota num perfil limpo?"\n`)
|
|
85
|
+
for (const r of selo.resultados) {
|
|
86
|
+
const aprovado = r.instala && r.boota
|
|
87
|
+
console.log(` ${aprovado ? '✓' : '✗'} ${r.spec}`)
|
|
88
|
+
console.log(` nome: ${r.nome ?? '—'}${r.versao ? `@${r.versao}` : ''}`)
|
|
89
|
+
console.log(` instala: ${r.instala ? 'sim' : 'NÃO'}`)
|
|
90
|
+
console.log(` boota: ${r.boota ? 'sim' : 'NÃO'}`)
|
|
91
|
+
console.log(` camadas: ${r.camadas.length}`)
|
|
92
|
+
if (r.entradasNaoAtivadas.length) {
|
|
93
|
+
console.log(` não ativou:`)
|
|
94
|
+
for (const e of r.entradasNaoAtivadas) console.log(` - ${e.id} (${e.pacote}): ${e.motivo}`)
|
|
95
|
+
}
|
|
96
|
+
if (r.erro) console.log(` erro: ${r.erro}`)
|
|
97
|
+
console.log(` tempo: ${r.duracaoMs}ms`)
|
|
98
|
+
console.log()
|
|
99
|
+
}
|
|
100
|
+
console.log(` selo: ${selo.selo} (${selo.aprovados}/${selo.total} aprovados)\n`)
|
|
101
|
+
if (selo.selo === 'reprovado' && !flag('strict')) {
|
|
102
|
+
console.log(' use --strict para transformar o selo em código de saída.\n')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const exit = flag('strict') && !selo.ok ? 1 : 0
|
|
107
|
+
process.exit(exit)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# O plugin-qa registra a própria linha na árvore do perfil.
|
|
2
|
+
#
|
|
3
|
+
# Sem este arquivo no `files` do package.json, o pacote instalado do registry não
|
|
4
|
+
# ativa nenhuma camada e o DSH RECUSA a instalação — foi o defeito real do
|
|
5
|
+
# locale-pt-BR em 02/10/2026.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: plugin-qa
|
|
8
|
+
name: '@rclr-labs/dsh-plugin-qa'
|
package/lib/core.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Núcleo do plugin-qa — pura, sem dependência do runtime do DSH.
|
|
3
|
+
*
|
|
4
|
+
* Responde a UMA pergunta, com prova executável: **esta spec instala e boota num
|
|
5
|
+
* perfil limpo?** É a pergunta que o ecossistema não respondia — 93% dos plugins
|
|
6
|
+
* de um catálogo falhavam na instalação, e o boot do DSH isola a falha (sai 0 e
|
|
7
|
+
* só avisa) — então nem CI nem o usuário percebem.
|
|
8
|
+
*
|
|
9
|
+
* Desenho:
|
|
10
|
+
* 1. `test-support/profile.mjs` cria o perfil descartável (DSH_HOME temporário);
|
|
11
|
+
* NADA é escrito fora do tmp e o perfil é sempre descartado.
|
|
12
|
+
* 2. a spec é instalada pelo próprio `dsh plugin add` (aceita diretório, `.tgz`
|
|
13
|
+
* ou nome no registry) — é o caminho REAL de instalação, não uma simulação.
|
|
14
|
+
* 3. o boot é exercitado e a saída é lida de verdade: o DSH emite
|
|
15
|
+
* `warning: N entries did not activate` + `<id> (<pacote>): <motivo>`.
|
|
16
|
+
* Como o boot sem tarefa sai 1 por uso ("a task is required"), o código de
|
|
17
|
+
* saída NÃO é o sinal — o sinal é a árvore ter montado e nenhuma entrada
|
|
18
|
+
* ter ficado de fora.
|
|
19
|
+
*
|
|
20
|
+
* Reuso deliberado: o harness de perfil vive FORA do pacote
|
|
21
|
+
* (`test-support/profile.mjs`). O `verify` roda no checkout do dsh-plugins; um
|
|
22
|
+
* pacote publicado não embute o harness (limitação documentada no README).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import { spawn } from 'node:child_process'
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
29
|
+
|
|
30
|
+
const AQUI = path.dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
|
|
32
|
+
/** `test-support/profile.mjs` do checkout (três níveis acima de lib/). */
|
|
33
|
+
export const HELPER_PROFILE = path.resolve(AQUI, '..', '..', '..', 'test-support', 'profile.mjs')
|
|
34
|
+
/** Raiz do checkout — a MESMA base que `profile.addSpec` usa para resolver specs locais. */
|
|
35
|
+
export const RAIZ_CHECKOUT = path.resolve(path.dirname(HELPER_PROFILE), '..')
|
|
36
|
+
|
|
37
|
+
/** Sinais de que o DSH terminou de montar a árvore (o plugin pode estar quebrado, mas montou). */
|
|
38
|
+
const MONTAGEM = /a task is required|did not activate/
|
|
39
|
+
/** `dsh: warning: 2 entries did not activate` */
|
|
40
|
+
const AVISO = /warning:\s*(\d+)\s+entr(?:y|ies) did not activate/
|
|
41
|
+
|
|
42
|
+
const primeiraLinha = (s) => String(s ?? '').split('\n').find((l) => l.trim())?.trim() ?? ''
|
|
43
|
+
const ultimaLinha = (s) => String(s ?? '').trim().split('\n').filter(Boolean).pop()?.trim() ?? ''
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lê os avisos de ativação do boot.
|
|
47
|
+
* Formato: `<id> (<pacote>): <motivo>`, com linhas de continuação indentadas.
|
|
48
|
+
*/
|
|
49
|
+
export function parseNaoAtivadas(saida) {
|
|
50
|
+
const texto = String(saida ?? '')
|
|
51
|
+
const declarado = Number(texto.match(AVISO)?.[1] ?? 0)
|
|
52
|
+
const linhas = texto.split('\n')
|
|
53
|
+
const entradas = []
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < linhas.length; i++) {
|
|
56
|
+
const m = linhas[i].match(/^(\S+)\s+\(([^)]+)\):\s*(.+)$/)
|
|
57
|
+
if (!m) continue
|
|
58
|
+
const [, id, pacote, motivo] = m
|
|
59
|
+
const detalhe = [motivo]
|
|
60
|
+
for (let j = i + 1; j < linhas.length; j++) {
|
|
61
|
+
if (!/^\s{2,}\S/.test(linhas[j])) break
|
|
62
|
+
detalhe.push(linhas[j])
|
|
63
|
+
}
|
|
64
|
+
entradas.push({ id, pacote, motivo, detalhe: detalhe.join('\n').trimEnd() })
|
|
65
|
+
}
|
|
66
|
+
return { declarado, entradas }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Deriva o nome npm de uma spec (`dsh-x`, `@escopo/nome@1.2.3`) — sem tocar na rede. */
|
|
70
|
+
export function nomeDeSpecNpm(spec) {
|
|
71
|
+
if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('file:') || spec.endsWith('.tgz')) return null
|
|
72
|
+
if (spec.startsWith('@')) {
|
|
73
|
+
const barra = spec.indexOf('/')
|
|
74
|
+
if (barra === -1) return spec
|
|
75
|
+
const arroba = spec.indexOf('@', barra)
|
|
76
|
+
return arroba === -1 ? spec : spec.slice(0, arroba)
|
|
77
|
+
}
|
|
78
|
+
const arroba = spec.indexOf('@')
|
|
79
|
+
return arroba === -1 ? spec : spec.slice(0, arroba)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Diretório local correspondente à spec, na mesma base do `profile.addSpec`. */
|
|
83
|
+
function caminhoLocal(spec) {
|
|
84
|
+
for (const base of [RAIZ_CHECKOUT, process.cwd()]) {
|
|
85
|
+
const abs = path.resolve(base, spec)
|
|
86
|
+
try {
|
|
87
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) return abs
|
|
88
|
+
} catch {
|
|
89
|
+
/* segue */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function lerManifesto(dir) {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolverNome(spec, novas) {
|
|
104
|
+
const local = caminhoLocal(spec)
|
|
105
|
+
if (local) {
|
|
106
|
+
const pkg = lerManifesto(local)
|
|
107
|
+
if (pkg?.name) return pkg.name
|
|
108
|
+
}
|
|
109
|
+
if (novas.length) return novas[0]
|
|
110
|
+
return nomeDeSpecNpm(spec)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function lerVersao(perfil, spec, nome) {
|
|
114
|
+
if (nome) {
|
|
115
|
+
const pkg = lerManifesto(path.join(perfil.dir, 'node_modules', ...nome.split('/')))
|
|
116
|
+
if (pkg?.version) return pkg.version
|
|
117
|
+
}
|
|
118
|
+
const local = caminhoLocal(spec)
|
|
119
|
+
return local ? (lerManifesto(local)?.version ?? null) : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Importa o harness de perfil do checkout, com erro explícito se ele não existir. */
|
|
123
|
+
export async function carregarHelper() {
|
|
124
|
+
if (!fs.existsSync(HELPER_PROFILE)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`o núcleo do plugin-qa reutiliza test-support/profile.mjs do checkout (${HELPER_PROFILE}) e ele não existe. ` +
|
|
127
|
+
`Rode a partir do checkout do dsh-plugins; o pacote publicado ainda não embute o harness.`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return import(pathToFileURL(HELPER_PROFILE).href)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Boota o perfil e devolve a saída crua.
|
|
135
|
+
*
|
|
136
|
+
* `stdio[0] = 'ignore'` é essencial: o headless lê a tarefa do stdin e, com um
|
|
137
|
+
* pipe aberto que nunca fecha, ficaria pendurado até o timeout — foi assim que um
|
|
138
|
+
* teste existente passou a medir timeout como se fosse "boot exit 0".
|
|
139
|
+
*/
|
|
140
|
+
export function bootar(bin, nomePerfil, env, { timeoutMs = 60_000 } = {}) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
let finalizado = false
|
|
143
|
+
let timer = null
|
|
144
|
+
let saida = ''
|
|
145
|
+
const encerrar = (r) => {
|
|
146
|
+
if (finalizado) return
|
|
147
|
+
finalizado = true
|
|
148
|
+
if (timer) clearTimeout(timer)
|
|
149
|
+
resolve(r)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let child
|
|
153
|
+
try {
|
|
154
|
+
child = spawn(bin, ['--profile', nomePerfil], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
155
|
+
} catch (err) {
|
|
156
|
+
resolve({ code: null, saida: '', timeout: false, erro: String(err?.message ?? err) })
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
timer = setTimeout(() => {
|
|
161
|
+
encerrar({ code: null, saida, timeout: true, erro: `o boot não terminou em ${timeoutMs}ms` })
|
|
162
|
+
try {
|
|
163
|
+
child.kill('SIGKILL')
|
|
164
|
+
} catch {
|
|
165
|
+
/* já morreu */
|
|
166
|
+
}
|
|
167
|
+
}, timeoutMs)
|
|
168
|
+
|
|
169
|
+
child.stdout?.on('data', (d) => (saida += d))
|
|
170
|
+
child.stderr?.on('data', (d) => (saida += d))
|
|
171
|
+
child.on('error', (err) => encerrar({ code: null, saida, timeout: false, erro: String(err?.message ?? err) }))
|
|
172
|
+
child.on('close', (code) => encerrar({ code, saida, timeout: false, erro: null }))
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Verifica UMA spec num perfil limpo e descartável.
|
|
178
|
+
*
|
|
179
|
+
* Relatório: `{ spec, nome, versao, instala, boota, entradasNaoAtivadas, camadas, erro, duracaoMs }`.
|
|
180
|
+
* - `instala` — a spec entrou em `dsh.profile.bundles` (a instalação real aceitou o pacote);
|
|
181
|
+
* - `boota` — a árvore montou e NENHUMA entrada ficou de fora;
|
|
182
|
+
* - `erro` — o motivo, quando instalar ou bootar não puderam ser confirmados.
|
|
183
|
+
*/
|
|
184
|
+
export async function checkSpec(spec, { bin, timeoutMs = 300_000, bootTimeoutMs = 60_000 } = {}) {
|
|
185
|
+
const t0 = Date.now()
|
|
186
|
+
const relatorio = {
|
|
187
|
+
spec,
|
|
188
|
+
nome: null,
|
|
189
|
+
versao: null,
|
|
190
|
+
instala: false,
|
|
191
|
+
boota: false,
|
|
192
|
+
entradasNaoAtivadas: [],
|
|
193
|
+
camadas: [],
|
|
194
|
+
erro: null,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let perfil
|
|
198
|
+
try {
|
|
199
|
+
const { createProfile } = await carregarHelper()
|
|
200
|
+
perfil = await createProfile({ bin })
|
|
201
|
+
try {
|
|
202
|
+
await perfil.init()
|
|
203
|
+
|
|
204
|
+
const antes = perfil.bundles()
|
|
205
|
+
await perfil.addSpec(spec, { timeoutMs })
|
|
206
|
+
const depois = perfil.bundles()
|
|
207
|
+
const novas = depois.filter((b) => !antes.includes(b))
|
|
208
|
+
|
|
209
|
+
relatorio.nome = resolverNome(spec, novas)
|
|
210
|
+
relatorio.versao = lerVersao(perfil, spec, relatorio.nome)
|
|
211
|
+
relatorio.instala = Boolean(relatorio.nome) && depois.includes(relatorio.nome)
|
|
212
|
+
|
|
213
|
+
if (!relatorio.instala) {
|
|
214
|
+
relatorio.erro = `a instalação não registrou nenhuma camada em dsh.profile.bundles (bundles: ${depois.join(', ') || '—'})`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
relatorio.camadas = await perfil.layers()
|
|
219
|
+
} catch (err) {
|
|
220
|
+
relatorio.erro ??= `não consegui ler a composição: ${primeiraLinha(err?.message)}`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const boot = await bootar(perfil.bin, perfil.name, perfil.env, { timeoutMs: bootTimeoutMs })
|
|
224
|
+
const { declarado, entradas } = parseNaoAtivadas(boot.saida)
|
|
225
|
+
relatorio.entradasNaoAtivadas = entradas
|
|
226
|
+
|
|
227
|
+
if (boot.timeout) {
|
|
228
|
+
relatorio.boota = false
|
|
229
|
+
relatorio.erro ??= boot.erro
|
|
230
|
+
} else if (!MONTAGEM.test(boot.saida)) {
|
|
231
|
+
relatorio.boota = false
|
|
232
|
+
relatorio.erro ??= `o boot não chegou a montar a árvore: ${ultimaLinha(boot.saida) || 'sem saída'}`
|
|
233
|
+
} else if (declarado > entradas.length) {
|
|
234
|
+
// O DSH mudou o formato do aviso: melhor reprovar alto do que aprovar sem prova.
|
|
235
|
+
relatorio.boota = false
|
|
236
|
+
relatorio.erro ??= `o DSH avisou ${declarado} entrada(s) não ativada(s) e só consegui nomear ${entradas.length} — formato do aviso mudou`
|
|
237
|
+
} else {
|
|
238
|
+
relatorio.boota = entradas.length === 0
|
|
239
|
+
if (!relatorio.boota) relatorio.erro ??= `${entradas.length} entrada(s) não ativaram`
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
perfil.dispose()
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const detalhe = primeiraLinha(err?.message ?? err)
|
|
246
|
+
// Distingue "a spec não instala" de uma falha do próprio harness.
|
|
247
|
+
relatorio.erro = relatorio.instala ? detalhe : `não consegui instalar/verificar "${spec}": ${detalhe}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
relatorio.duracaoMs = Date.now() - t0
|
|
251
|
+
return relatorio
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Agrega relatórios num "selo" consumível por pessoa, CI ou agente. */
|
|
255
|
+
export function agregar(resultados) {
|
|
256
|
+
const aprovados = resultados.filter((r) => r.instala && r.boota).length
|
|
257
|
+
const reprovados = resultados.length - aprovados
|
|
258
|
+
const ok = resultados.length > 0 && reprovados === 0
|
|
259
|
+
return {
|
|
260
|
+
selo: ok ? 'aprovado' : 'reprovado',
|
|
261
|
+
ok,
|
|
262
|
+
total: resultados.length,
|
|
263
|
+
aprovados,
|
|
264
|
+
reprovados,
|
|
265
|
+
resultados,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Verifica N specs, em sequência (um perfil descartável por spec). */
|
|
270
|
+
export async function verificar(specs, opcoes = {}) {
|
|
271
|
+
const resultados = []
|
|
272
|
+
for (const spec of specs) resultados.push(await checkSpec(spec, opcoes))
|
|
273
|
+
return agregar(resultados)
|
|
274
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metade DSH do plugin-qa: registra a ferramenta `plugin_qa` no agente.
|
|
3
|
+
*
|
|
4
|
+
* Fina de propósito — toda a lógica vive em `core.mjs` e no CLI, para que a
|
|
5
|
+
* resposta seja a mesma quando rodada por uma pessoa, por CI ou por um agente.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { execFile } from 'node:child_process'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
12
|
+
|
|
13
|
+
export const name = 'plugin-qa'
|
|
14
|
+
export const inject = ['tools']
|
|
15
|
+
|
|
16
|
+
const AQUI = path.dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const CLI = path.join(AQUI, '..', 'bin', 'plugin-qa.mjs')
|
|
18
|
+
|
|
19
|
+
/** Roda o CLI do plugin-qa e devolve a saída como texto. */
|
|
20
|
+
function rodarCli(args) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
execFile(process.execPath, [CLI, ...args], { maxBuffer: 32e6, timeout: 600_000 }, (err, stdout, stderr) => {
|
|
23
|
+
const saida = (stdout || '').trim() || (stderr || '').trim()
|
|
24
|
+
resolve(saida || `plugin-qa falhou: ${err?.message ?? 'erro desconhecido'}`)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function apply(ctx) {
|
|
30
|
+
ctx.tools.register(
|
|
31
|
+
defineTool({
|
|
32
|
+
name: 'plugin_qa',
|
|
33
|
+
description:
|
|
34
|
+
'Answer "does this plugin install and boot in a clean DeepSeek Harness profile?" by actually doing it: ' +
|
|
35
|
+
'a disposable DSH_HOME, a real `dsh plugin add`, and a real boot whose activation warnings are parsed. ' +
|
|
36
|
+
'The harness isolates a broken plugin (the boot exits 0 and only warns), so "it installed" never meant ' +
|
|
37
|
+
'"it works" — this tool reports install/boot separately, names every entry that did not activate, and ' +
|
|
38
|
+
'returns a machine-readable verdict. Accepts a local directory, a .tgz tarball, or an npm name. ' +
|
|
39
|
+
'It judges installability and activation only, not code quality or security.',
|
|
40
|
+
parameters: {
|
|
41
|
+
spec: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
required: true,
|
|
44
|
+
description: 'Pacote a verificar: diretório local (ex.: packages/meu-plugin), tarball .tgz ou nome no registry',
|
|
45
|
+
},
|
|
46
|
+
strict: {
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
description: 'Quando true, falha também em código de saída se a spec não instalar ou não bootar (útil em CI)',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
output: {
|
|
52
|
+
schema: { type: 'string' },
|
|
53
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
54
|
+
},
|
|
55
|
+
async execute(args) {
|
|
56
|
+
return rodarCli(['check', args.spec, ...(args.strict ? ['--strict'] : [])])
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rclr-labs/dsh-plugin-qa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "QA de plugins do DeepSeek Harness: verifica, num perfil limpo e descartável, se um pacote instala e boota de verdade — e devolve um selo consumível por CI",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Rafael Nunes",
|
|
8
|
+
"homepage": "https://github.com/rclr-labs/dsh-plugins/tree/main/packages/plugin-qa",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/rclr-labs/dsh-plugins.git",
|
|
12
|
+
"directory": "packages/plugin-qa"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/rclr-labs/dsh-plugins/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"dsh",
|
|
21
|
+
"qa",
|
|
22
|
+
"verification",
|
|
23
|
+
"install-check",
|
|
24
|
+
"cordis-plugin"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"plugin-qa": "bin/plugin-qa.mjs"
|
|
31
|
+
},
|
|
32
|
+
"main": "lib/index.js",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./lib/index.js",
|
|
35
|
+
"./core": "./lib/core.mjs",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"bin/",
|
|
40
|
+
"lib/",
|
|
41
|
+
"cordis.patch.yml",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"qa": "node bin/plugin-qa.mjs",
|
|
49
|
+
"test": "node test/plugin-qa.test.mjs",
|
|
50
|
+
"verify": "node test/plugin-qa.test.mjs"
|
|
51
|
+
},
|
|
52
|
+
"dsh": {
|
|
53
|
+
"manifestVersion": 1,
|
|
54
|
+
"bundle": {
|
|
55
|
+
"patch": "./cordis.patch.yml"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
60
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2"
|
|
64
|
+
}
|
|
65
|
+
}
|