@pathbit/agentdevkit 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.pbai4devs/COMPATIBILIDADE.md +4 -2
  2. package/.pbai4devs/INSTRUCTIONS.md +4 -0
  3. package/.pbai4devs/commands/sincronizar/COMMAND.md +4 -3
  4. package/.pbai4devs/rules/documentacao/RULE.md +4 -0
  5. package/.pbai4devs/rules/qualidade/RULE.md +4 -2
  6. package/.pbai4devs/scripts/README.md +3 -3
  7. package/.pbai4devs/scripts/inspecionar-projeto/README.md +31 -0
  8. package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +142 -1
  9. package/.pbai4devs/scripts/inspecionar-projeto/tests/TEST.mjs +168 -1
  10. package/.pbai4devs/scripts/lib/native-paths.mjs +35 -0
  11. package/.pbai4devs/scripts/sincronizar-agentes/README.md +3 -0
  12. package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +73 -29
  13. package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +190 -0
  14. package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +8 -2
  15. package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +28 -28
  16. package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +56 -8
  17. package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +92 -1
  18. package/.pbai4devs/skills/produto/criar-design-doc/SKILL.md +2 -0
  19. package/.pbai4devs/skills/produto/criar-prd/SKILL.md +2 -0
  20. package/.pbai4devs/skills/produto/criar-ux-ui/SKILL.md +2 -0
  21. package/.pbai4devs/skills/projeto/atualizar-documentacao/SKILL.md +18 -4
  22. package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +2 -0
  23. package/.pbai4devs/skills/projeto/criar-recurso/SKILL.md +1 -0
  24. package/.pbai4devs/skills/projeto/sincronizar-agentes/SKILL.md +3 -2
  25. package/.pbai4devs/skills/specs/criar-spec/SKILL.md +1 -0
  26. package/.pbai4devs/skills/specs/gerenciar-roadmap/SKILL.md +2 -1
  27. package/.pbai4devs/templates/README.md +4 -1
  28. package/.pbai4devs/templates/configuracao/TEMPLATE.md +6 -0
  29. package/CHANGELOG.md +10 -0
  30. package/README.en.md +3 -2
  31. package/README.es.md +3 -2
  32. package/README.md +3 -2
  33. package/docs/README.md +1 -0
  34. package/docs/guides/README.md +1 -0
  35. package/docs/guides/cli.md +2 -0
  36. package/docs/guides/documentation.md +63 -0
  37. package/docs/guides/installation.md +12 -5
  38. package/docs/guides/resources.md +6 -5
  39. package/docs/guides/troubleshooting.md +6 -6
  40. package/docs/guides/workflow.md +2 -0
  41. package/docs/maintainers/packages.md +1 -1
  42. package/docs/product/design.md +12 -0
  43. package/docs/product/prd.md +4 -1
  44. package/docs/product/roadmap.md +2 -0
  45. package/docs/specs/README.md +1 -0
  46. package/docs/specs/v0.0.9/README.md +9 -0
  47. package/docs/specs/v0.0.9/SPEC.md +68 -0
  48. package/package.json +3 -2
  49. package/src/cli.mjs +3 -0
  50. package/src/i18n.mjs +3 -0
  51. package/src/install.mjs +84 -16
@@ -87,8 +87,10 @@ remoção em fevereiro de 2027. No Copilot, use skills para rotinas invocáveis.
87
87
 
88
88
  O instalador gera a seleção e a política em `espelhos.conf`; `sincronizar`
89
89
  reconstrói os apontamentos somente com Node e os arquivos do hub instalado.
90
- `--check` não altera arquivos. `--force` faz backup ignorado antes de substituir
91
- conteúdo próprio que conflite com caminhos gerenciados.
90
+ `--check` não altera arquivos. `--force` faz backup ignorado ao reparar
91
+ conflitos gerenciados. Recursos e links humanos dos clientes são preservados;
92
+ uma rota preservada não recebe apontamentos novos. No Zod, `.codex/skills`
93
+ continua apontando para suas skills e o Codex recebe o kit em `.agents/skills`.
92
94
 
93
95
  As fontes confirmam formatos, não execução de cada cliente. Valide descoberta,
94
96
  invocação e resultado no cliente escolhido antes de considerar uma integração
@@ -6,6 +6,10 @@ description: Ordem de trabalho e manutenção dos agentes.
6
6
 
7
7
  Peça a skill pelo nome ou solicite a leitura de seu `SKILL.md`.
8
8
 
9
+ Antes de trabalhar na documentação, use `atualizar-documentacao` para inventariar
10
+ e ler os arquivos existentes. Preserve seus caminhos e conteúdo; complemente
11
+ lacunas e organize por índices, sem substituir documentos pelos modelos.
12
+
9
13
  Antes de instalar ou preparar ferramentas, pergunte quais clientes o usuário
10
14
  usa, se não houver escolha no pedido ou no estado do kit. A CLI aceita
11
15
  `--agents codex,claude`; `agents` consulta ou altera a seleção depois.
@@ -8,9 +8,10 @@ Entrada: sem argumentos usa `--force` para reparar; `--check` só confere.
8
8
  Pré-requisitos: Node.js 24+ e manifesto `.pbai4devs/espelhos.conf` válido.
9
9
 
10
10
  Siga [sincronizar-agentes](../../skills/projeto/sincronizar-agentes/SKILL.md)
11
- e execute o script correspondente pelo seletor. A reconstrução pode sobrescrever
12
- apontamentos e remover extras dos destinos gerenciados; preserva os arquivos
13
- nativos fora desse escopo e mantém backup local do conteúdo próprio removido.
11
+ e execute o script correspondente pelo seletor. A reconstrução repara
12
+ apontamentos e remove saídas obsoletas cuja propriedade do kit foi comprovada,
13
+ com backup local. Recursos, configurações e links humanos são preservados,
14
+ inclusive quando seus nomes coincidem com os destinos do kit.
14
15
  Após reparar, execute também `--check`.
15
16
 
16
17
  Respeite os clientes registrados no manifesto. Para mudar a seleção salva,
@@ -5,6 +5,10 @@ description: Documentação e revisão fazem parte de toda entrega implementada.
5
5
 
6
6
  # Documentação da entrega
7
7
 
8
+ Antes de criar, organizar ou alterar documentação, use `atualizar-documentacao`:
9
+ inventarie e leia o que já existe. Reutilize caminhos e formatos;
10
+ complemente lacunas sem apagar, mover ou substituir arquivos por templates.
11
+
8
12
  Após implementar, siga `atualizar-documentacao` e o comando `revisar-entrega`
9
13
  antes de commit ou merge. Use os modelos da fase atual; registre decisões sem
10
14
  impacto com justificativa e resolva os achados da revisão independente.
@@ -15,8 +15,10 @@ Use `verificar-trabalho` para provar as mudanças. Alterações web exigem testa
15
15
  o fluxo real no navegador; desktop exige abrir e testar a aplicação. Código
16
16
  revisado, build ou fixture isolada não substituem essa prova funcional.
17
17
 
18
- Comece arquivos Markdown com frontmatter YAML adequado ao tipo e título H1;
19
- preserve os metadados exigidos pela ferramenta.
18
+ Comece novos arquivos Markdown com frontmatter YAML adequado ao tipo e título
19
+ H1; preserve os metadados exigidos pela ferramenta. Em documentos humanos
20
+ existentes, mantenha o formato e o conteúdo: não os reformate apenas para
21
+ impor esse padrão.
20
22
 
21
23
  Ao criar ou atualizar `.gitignore`, mantenha o bloco do
22
24
  [modelo de gitignore](../../templates/gitignore/TEMPLATE.md)
@@ -59,9 +59,9 @@ recursos, com frontmatter YAML sem corpo. Caminhos são relativos à raiz.
59
59
  Somente os quatro arquivos principais são fontes; não registre documentos.
60
60
  Use `gitignore|pasta` para gerar apenas o `.gitignore`, como em `.vscode`.
61
61
 
62
- `sincronizar-agentes --force` reconstrói os destinos e limpa auxiliares,
63
- preservando configurações e workflows fora do escopo gerenciado. Conteúdo
64
- próprio substituído recebe backup em `.agent-tests/`; `--check` não altera nada.
62
+ `sincronizar-agentes --force` repara os destinos gerenciados com backup em
63
+ `.agent-tests/`. Recursos e configurações humanos permanecem intactos, inclusive
64
+ quando ocupam o caminho de um apontamento. `--check` não altera arquivos.
65
65
  O sincronizador também gera o catálogo completo e os índices auxiliares a partir
66
66
  das fontes. Helpers e testes constam como suporte, sem fingir comandos próprios.
67
67
  Ele protege os derivados contra versionamento e preserva regras humanas.
@@ -0,0 +1,31 @@
1
+ ---
2
+ description: Inventário somente leitura da estrutura e documentação do projeto.
3
+ ---
4
+
5
+ # Inspecionar projeto
6
+
7
+ ```sh
8
+ node .pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs
9
+ ```
10
+
11
+ O JSON identifica tecnologia, comandos, estrutura e documentos existentes.
12
+ `documentation.files` mantém caminhos e caixa, papel provável, tamanho e hash
13
+ quando a leitura couber nos limites. Referências válidas do estado aparecem em
14
+ `configured`; nomes sugerem papéis, mas não comprovam conteúdo ou aprovação.
15
+
16
+ Links simbólicos são listados como opacos, sem ler seus destinos. Conteúdo dos
17
+ documentos e valores de configuração não aparecem no relatório. `scan` e
18
+ `warnings` informam limites e leitura incompleta; `scan.complete` resume essas
19
+ condições. Uma pasta simbólica como `docs` também aparece, sem presumir que
20
+ esteja vazia. Links não classificados recebem `role: unclassified`; enquanto
21
+ existir uma fronteira não inspecionada, `scan.complete` será falso. Não use
22
+ inventário incompleto como prova de ausência de documentos.
23
+
24
+ `exclusions` descreve pastas de clientes, artefatos e caches fora do inventário.
25
+ Documentos humanos em `.github`, exemplos, wiki e RFCs são incluídos; os
26
+ apontamentos gerados em `.github/skills`, agentes e comandos ficam de fora.
27
+
28
+ O script não cria nem altera arquivos. Leia os documentos relevantes antes de
29
+ planejar complementos; preserve sua estrutura e use templates só para lacunas.
30
+ `--check` confere a estrutura técnica. `--root` aceita somente as fixtures
31
+ documentadas pelo próprio comando, sem ampliar o acesso a outros projetos.
@@ -2,6 +2,7 @@
2
2
  // Inventario somente leitura; nomes e caminhos, nunca valores de configuracao.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { requireNode, isMain } from '../lib/runtime.mjs';
7
8
 
@@ -92,7 +93,146 @@ function maturity(root, report) {
92
93
  return { level, planning, configuration, released, signals: { application: report.mode === 'aplicacao-detectada', structure: report.contract.complete }, limits: ['Classificacao descritiva; status em arquivos nao substitui aprovacao, testes ou provas de funcionamento.'] };
93
94
  }
94
95
 
95
- export function inspectProject(root = repository, { check = false } = {}) {
96
+ const documentRoles = new Set(['readme', 'contribuicao', 'changelog', 'seguranca', 'adrs', 'prd', 'ux_ui', 'design', 'roadmap', 'releases', 'specs', 'tarefas']);
97
+ const documentLimits = { maxDepth: 12, maxEntries: 25000, maxFiles: 2000, maxFileBytes: 2 * 1024 * 1024, maxTotalBytes: 16 * 1024 * 1024 };
98
+ const documentClients = [...defaultAgents, '.zcode', '.zai', '.kimi', '.kimi-code', '.minimax', '.mimocode', '.dsh'];
99
+ const documentName = name => /\.(?:md|mdx|rst|adoc)$/i.test(name) || /^(?:readme|contributing|changelog|history|security|licen[cs]e|copying|notice|authors|code_of_conduct|architecture|design|roadmap)(?:\.txt)?$/i.test(name);
100
+ const documentRole = file => {
101
+ const name = path.posix.basename(file).toLowerCase();
102
+ if (/^readme(?:\.|$)/.test(name)) return 'readme';
103
+ if (/^(?:agents|claude|gemini|copilot-instructions)\.md$/.test(name)) return 'instructions';
104
+ if (/^(?:licen[cs]e|copying|notice)(?:\.|$)/.test(name)) return 'license';
105
+ if (/^code_of_conduct(?:\.|$)/.test(name)) return 'conduct';
106
+ if (/^contribut/.test(name)) return 'contribution';
107
+ if (/^security(?:\.|$)/.test(name)) return 'security';
108
+ if (/^(?:changelog|history)(?:\.|$)/.test(name)) return 'changelog';
109
+ if (/(^|\/)(?:adr|adrs|decisions|rfcs)(\/|$)/i.test(file) || /^adr[-_.]/.test(name)) return 'decision';
110
+ if (/(^|\/)(?:prd|product|requisitos)(?:\.|\/|$)/i.test(file)) return 'product';
111
+ if (/(?:architecture|arquitetura|design)(?:\.|\/|$)/i.test(file)) return 'design';
112
+ if (/roadmap/i.test(name)) return 'roadmap';
113
+ if (/(^|\/)(?:spec|specs|specifications)(\/|$)/i.test(file)) return 'spec';
114
+ return 'document';
115
+ };
116
+
117
+ // Names, kinds and bounded hashes only. A guessed role is never an approval.
118
+ export function inspectDocumentation(root, limits = {}) {
119
+ root = fs.realpathSync(path.resolve(root));
120
+ const bounds = { ...documentLimits, ...limits };
121
+ for (const [key, value] of Object.entries(bounds)) {
122
+ if (!(key in documentLimits) || !Number.isSafeInteger(value) || value < 0 || value > documentLimits[key]) throw new Error('Limite documental invalido.');
123
+ }
124
+ const ignored = new Set([...defaultExcluded, ...documentClients, ...agentRoots(root)].filter(name => !['examples', '.github'].includes(name)).map(name => name.toLowerCase()));
125
+ const excludedPaths = ['examples/generated', ...['skills', 'agents', 'commands', 'rules', 'instructions', 'prompts'].map(name => `.github/${name}`)];
126
+ const files = new Map();
127
+ const configured = [];
128
+ const warnings = [];
129
+ const scan = { ...bounds, entries: 0, bytesRead: 0, bytesHashed: 0, truncated: false };
130
+ const warn = (file, reason) => warnings.push({ file, reason });
131
+ const excludedDocument = file => file.split('/').some(name => ignored.has(name.toLowerCase()) || /^\.?playwright/i.test(name))
132
+ || excludedPaths.some(prefix => file.toLowerCase() === prefix || file.toLowerCase().startsWith(prefix + '/'));
133
+ const safeDocument = value => typeof value === 'string' && value.length > 0 && !path.isAbsolute(value) && !/[\\\x00-\x1f\x7f:]/.test(value)
134
+ && !value.split('/').some(part => !part || part === '.' || part === '..');
135
+ const locate = file => {
136
+ let current = root;
137
+ const parts = file.split('/');
138
+ for (const [index, part] of parts.entries()) {
139
+ current = path.join(current, part);
140
+ let item;
141
+ try { item = fs.lstatSync(current); }
142
+ catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return { kind: 'missing' }; throw error; }
143
+ if (item.isSymbolicLink()) return { kind: 'symbolic-link', boundary: parts.slice(0, index + 1).join('/'), item };
144
+ if (index < parts.length - 1 && !item.isDirectory()) return { kind: 'missing' };
145
+ if (index === parts.length - 1) return { kind: item.isFile() ? 'file' : item.isDirectory() ? 'directory' : 'unsupported', item };
146
+ }
147
+ };
148
+ function boundedHash(file, expected) {
149
+ if (expected.size > bounds.maxFileBytes || expected.size > bounds.maxTotalBytes - scan.bytesRead) {
150
+ scan.truncated = true; warn(file, 'hash-byte-limit'); return;
151
+ }
152
+ let descriptor;
153
+ try {
154
+ descriptor = fs.openSync(path.join(root, file), fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
155
+ const before = fs.fstatSync(descriptor);
156
+ if (!before.isFile() || before.dev !== expected.dev || before.ino !== expected.ino || before.size !== expected.size
157
+ || before.mtimeMs !== expected.mtimeMs || before.ctimeMs !== expected.ctimeMs) { warn(file, 'changed-during-read'); return; }
158
+ const buffer = Buffer.alloc(Math.min(before.size + 1, bounds.maxTotalBytes - scan.bytesRead));
159
+ let length = 0;
160
+ while (length < buffer.length) {
161
+ const read = fs.readSync(descriptor, buffer, length, buffer.length - length, length);
162
+ if (!read) break;
163
+ length += read;
164
+ scan.bytesRead += read;
165
+ }
166
+ const after = fs.fstatSync(descriptor);
167
+ if (length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) {
168
+ warn(file, 'changed-during-read'); return;
169
+ }
170
+ scan.bytesHashed += length;
171
+ return createHash('sha256').update(buffer.subarray(0, length)).digest('hex');
172
+ } catch (error) { warn(file, error.code === 'ELOOP' ? 'symbolic-link-during-read' : 'unreadable-document'); }
173
+ finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
174
+ }
175
+ function add(file, found, role) {
176
+ if (files.has(file)) {
177
+ if (role) { const entry = files.get(file); entry.origin = 'configured'; entry.configuredAs = [...new Set([...(entry.configuredAs || []), role])].sort(); }
178
+ return;
179
+ }
180
+ if (files.size >= bounds.maxFiles) { scan.truncated = true; warn(file, 'file-limit'); return; }
181
+ const inferredRole = found.kind === 'symbolic-link' && !documentName(path.posix.basename(file)) ? 'unclassified' : documentRole(file);
182
+ const entry = { path: file, kind: found.kind, role: inferredRole, origin: role ? 'configured' : 'inferred', bytes: found.item.size };
183
+ if (role) entry.configuredAs = [role];
184
+ if (found.kind === 'symbolic-link') warn(file, 'symbolic-link-not-inspected');
185
+ if (found.kind === 'file') {
186
+ const digest = boundedHash(file, found.item);
187
+ if (digest) entry.sha256 = digest;
188
+ }
189
+ files.set(file, entry);
190
+ }
191
+ function walk(directory = '', depth = 0, role) {
192
+ if (directory) {
193
+ const found = locate(directory);
194
+ if (found.kind !== 'directory') { if (found.kind === 'symbolic-link') add(found.boundary, found, role); return; }
195
+ }
196
+ let children;
197
+ try { children = fs.readdirSync(path.join(root, directory), { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); }
198
+ catch { warn(directory || '.', 'unreadable-directory'); return; }
199
+ // Discard a listing if the directory became a link while being inspected.
200
+ if (directory && locate(directory).kind !== 'directory') { warn(directory, 'changed-during-read'); return; }
201
+ for (const child of children) {
202
+ if (scan.entries >= bounds.maxEntries || files.size >= bounds.maxFiles) { scan.truncated = true; return; }
203
+ scan.entries++;
204
+ const file = directory ? `${directory}/${child.name}` : child.name;
205
+ if (excludedDocument(file)) continue;
206
+ const found = locate(file);
207
+ if (found.kind === 'directory') {
208
+ if (depth < bounds.maxDepth) walk(file, depth + 1, role);
209
+ else { scan.truncated = true; warn(file, 'depth-limit'); }
210
+ } else if (found.kind === 'file' && documentName(child.name)) add(file, found, role);
211
+ else if (found.kind === 'symbolic-link') add(found.boundary, found, role);
212
+ }
213
+ }
214
+ const declared = metadata(root, '.pbai4devs/estado/configuracao/ESTADO.md').documents;
215
+ for (const [role, file] of Object.entries(declared)) {
216
+ if (!documentRoles.has(role)) continue;
217
+ if (!safeDocument(file)) { if (file !== null) warn(null, 'invalid-configured-path'); continue; }
218
+ if (excludedDocument(file)) { configured.push({ role, path: file, kind: 'excluded' }); continue; }
219
+ const found = locate(file);
220
+ configured.push({ role, path: file, kind: found.kind });
221
+ if (found.kind === 'directory') walk(file, 0, role);
222
+ else if (found.kind === 'file' && documentName(path.posix.basename(file))) add(file, found, role);
223
+ else if (found.kind === 'file') warn(file, 'configured-path-not-document');
224
+ else if (found.kind === 'symbolic-link') add(found.boundary, found, role);
225
+ }
226
+ walk();
227
+ return {
228
+ files: [...files.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
229
+ configured, scan: { ...scan, complete: !scan.truncated && warnings.length === 0 }, warnings,
230
+ exclusions: { directories: [...ignored].sort(), paths: excludedPaths, prefixes: ['playwright', '.playwright'] },
231
+ limits: ['Papeis inferidos por nome; confirme o conteudo antes de propor alteracoes.', 'Links simbolicos sao opacos; destinos nao sao lidos.', 'O inventario nao cria documentos nem comprova aprovacao ou completude.'],
232
+ };
233
+ }
234
+
235
+ export function inspectProject(root = repository, { check = false, documentationLimits } = {}) {
96
236
  requireNode();
97
237
  root = fs.realpathSync(path.resolve(root));
98
238
  if (!fs.statSync(root).isDirectory()) throw new Error('A origem deve ser um diretorio.');
@@ -160,6 +300,7 @@ export function inspectProject(root = repository, { check = false } = {}) {
160
300
  report.languages = [...languages].sort();
161
301
  report.contract = { checked: check, complete: report.structure.missing.length === 0 };
162
302
  report.maturity = maturity(root, report);
303
+ report.documentation = inspectDocumentation(root, documentationLimits);
163
304
  return report;
164
305
  }
165
306
 
@@ -1,9 +1,11 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import test from 'node:test';
6
7
  import { projectRoot, requireNode } from '../../lib/runtime.mjs';
8
+ import { inspectDocumentation } from '../SCRIPT.mjs';
7
9
 
8
10
  requireNode();
9
11
  const script = join(projectRoot, '.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs');
@@ -159,3 +161,168 @@ test('status arbitrários e referências fora da raiz não expõem dados da conf
159
161
  assert.equal(result.report.maturity.planning.ready, false);
160
162
  assert.ok(!result.stdout.includes(secret));
161
163
  });
164
+
165
+ test('inventário documental vazio não cria arquivos nem presume aprovação', t => {
166
+ const { root, run } = fixture(t);
167
+ const result = run();
168
+ assert.equal(result.status, 0, result.stderr);
169
+ assert.deepEqual(result.report.documentation.files, []);
170
+ assert.deepEqual(result.report.documentation.configured, []);
171
+ assert.equal(result.report.documentation.scan.truncated, false);
172
+ assert.equal(result.report.documentation.scan.complete, true);
173
+ assert.deepEqual(readdirSync(root), []);
174
+ assert.match(result.report.documentation.limits.at(-1), /nao cria documentos/);
175
+ });
176
+
177
+ test('descobre documentação real sem impor caixa, pasta docs ou frontmatter', t => {
178
+ const { root, file, run } = fixture(t);
179
+ const content = 'DOC_CONTENT_MUST_NOT_BE_EMITTED_182711';
180
+ for (const name of ['Readme.md', 'CONTRIBUTING.rst', 'SECURITY.adoc', 'History.md', 'ARCHITECTURE.md', 'AGENTS.md', 'LICENSE', 'examples/README', '_examples/server/README.md', 'wiki/guide.mdx', 'rfcs/001-change.md', 'packages/docs/content/api.mdx', '.github/CONTRIBUTING.md', '.github/SECURITY.md', '.github/copilot-instructions.md']) file(name, content);
181
+ for (const name of ['node_modules/lib/README.md', '.pbai4devs/README.md', '.claude/skills/private/SKILL.md', '.github/skills/demo/SKILL.md', '.github/agents/revisor.agent.md', 'examples/generated/fixture/README.md', '.PLAYWRIGHT-MCP/README.md', 'tmp/README.md']) file(name, content);
182
+ const result = run();
183
+ assert.equal(result.status, 0, result.stderr);
184
+ const docs = result.report.documentation;
185
+ assert.equal(docs.files.length, 15);
186
+ assert.equal(docs.files.find(item => item.path === 'Readme.md').role, 'readme');
187
+ assert.equal(docs.files.find(item => item.path === 'rfcs/001-change.md').role, 'decision');
188
+ assert.equal(docs.files.find(item => item.path === 'ARCHITECTURE.md').role, 'design');
189
+ assert.equal(docs.files.find(item => item.path === 'AGENTS.md').role, 'instructions');
190
+ assert.equal(docs.files.find(item => item.path === '.github/copilot-instructions.md').role, 'instructions');
191
+ assert.equal(docs.files.find(item => item.path === '.github/CONTRIBUTING.md').role, 'contribution');
192
+ assert.equal(docs.files.find(item => item.path === 'LICENSE').role, 'license');
193
+ assert.ok(docs.exclusions.paths.includes('.github/skills'));
194
+ assert.equal(docs.files.find(item => item.path === 'wiki/guide.mdx').sha256, createHash('sha256').update(content).digest('hex'));
195
+ assert.ok(docs.files.every(item => item.origin === 'inferred' && item.kind === 'file'));
196
+ assert.ok(!result.stdout.includes(content));
197
+ assert.equal(readFileSync(join(root, 'Readme.md'), 'utf8'), content);
198
+ });
199
+
200
+ test('caminhos cadastrados têm proveniência sem assumir status ou ler arquivos de configuração', t => {
201
+ const { file, run } = fixture(t);
202
+ file('.pbai4devs/estado/configuracao/ESTADO.md', '---\ndocumentos:\n design: architecture/current.md\n specs: specifications\n prd: planning/absent.md\n readme: ../private-secret.md\n seguranca: .env\n---\n# Estado\n');
203
+ file('architecture/current.md', '# Existing architecture\n');
204
+ file('specifications/current.mdx', '# Increment\n');
205
+ file('.env', 'DO_NOT_EMIT_OR_HASH_THIS_SECRET');
206
+ const result = run();
207
+ assert.equal(result.status, 0, result.stderr);
208
+ const docs = result.report.documentation;
209
+ assert.deepEqual(docs.files.find(item => item.path === 'architecture/current.md').configuredAs, ['design']);
210
+ assert.equal(docs.files.find(item => item.path === 'specifications/current.mdx').origin, 'configured');
211
+ assert.ok(docs.configured.some(item => item.role === 'prd' && item.kind === 'missing'));
212
+ assert.ok(docs.warnings.some(item => item.reason === 'invalid-configured-path'));
213
+ assert.ok(docs.warnings.some(item => item.reason === 'configured-path-not-document'));
214
+ assert.ok(!docs.files.some(item => item.path === '.env'));
215
+ assert.ok(!result.stdout.includes('private-secret') && !result.stdout.includes('DO_NOT_EMIT'));
216
+ assert.equal(docs.files.some(item => Object.hasOwn(item, 'status')), false);
217
+ });
218
+
219
+ test('documentos simbólicos permanecem opacos sem ler destinos internos, externos ou ausentes', t => {
220
+ const { root, file, run } = fixture(t);
221
+ const outside = mkdtempSync(join(projectRoot, '.agent-tests', 'outside-docs-'));
222
+ t.after(() => rmSync(outside, { recursive: true, force: true }));
223
+ file('docs/real.md', '# Actual document\n');
224
+ writeFileSync(join(outside, 'private.md'), 'OUTSIDE_SECRET_MUST_NOT_APPEAR');
225
+ try {
226
+ symlinkSync('docs/real.md', join(root, 'README.md'));
227
+ symlinkSync(join(outside, 'private.md'), join(root, 'CLAUDE.md'));
228
+ symlinkSync('absent.md', join(root, 'missing.md'));
229
+ symlinkSync(outside, join(root, 'external'), process.platform === 'win32' ? 'junction' : 'dir');
230
+ } catch (error) {
231
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES'].includes(error.code)) throw error;
232
+ t.skip('Links simbólicos indisponíveis sem permissão do Windows.'); return;
233
+ }
234
+ file('.pbai4devs/estado/configuracao/ESTADO.md', '---\ndocumentos:\n design: external/private.md\n---\n# State\n');
235
+ const result = run();
236
+ assert.equal(result.status, 0, result.stderr);
237
+ const docs = result.report.documentation;
238
+ for (const name of ['README.md', 'CLAUDE.md', 'missing.md', 'external']) {
239
+ const item = docs.files.find(item => item.path === name);
240
+ assert.equal(item?.kind, 'symbolic-link', name);
241
+ assert.equal(item.sha256, undefined);
242
+ }
243
+ assert.ok(!docs.files.some(item => item.path === 'external/private.md'));
244
+ assert.ok(!result.stdout.includes(outside) && !result.stdout.includes('OUTSIDE_SECRET'));
245
+ assert.equal(docs.scan.complete, false);
246
+ assert.ok(docs.warnings.some(item => item.file === 'external' && item.reason === 'symbolic-link-not-inspected'));
247
+ assert.equal(readFileSync(join(outside, 'private.md'), 'utf8'), 'OUTSIDE_SECRET_MUST_NOT_APPEAR');
248
+ });
249
+
250
+ test('pastas documentais simbólicas sem cadastro não ocultam uma leitura incompleta', t => {
251
+ const { root, run } = fixture(t);
252
+ const outside = mkdtempSync(join(projectRoot, '.agent-tests', 'outside-doc-tree-'));
253
+ t.after(() => rmSync(outside, { recursive: true, force: true }));
254
+ const privateContent = 'UNREAD_EXTERNAL_DOCUMENT_719329';
255
+ writeFileSync(join(outside, 'private.md'), privateContent);
256
+ try {
257
+ for (const name of ['docs', 'wiki', 'rfcs', 'examples', 'custom-docs']) symlinkSync(outside, join(root, name), process.platform === 'win32' ? 'junction' : 'dir');
258
+ symlinkSync('absent-directory', join(root, 'missing-directory'), process.platform === 'win32' ? 'junction' : 'dir');
259
+ } catch (error) {
260
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES'].includes(error.code)) throw error;
261
+ t.skip('Links simbólicos indisponíveis sem permissão do Windows.'); return;
262
+ }
263
+ const result = run();
264
+ assert.equal(result.status, 0, result.stderr);
265
+ const docs = result.report.documentation;
266
+ assert.equal(docs.scan.complete, false);
267
+ assert.equal(docs.scan.bytesRead, 0);
268
+ assert.deepEqual(docs.configured, []);
269
+ assert.deepEqual(docs.files.map(item => item.path), ['custom-docs', 'docs', 'examples', 'missing-directory', 'rfcs', 'wiki']);
270
+ assert.ok(docs.files.every(item => item.kind === 'symbolic-link' && item.role === 'unclassified' && !item.sha256));
271
+ assert.equal(docs.warnings.filter(item => item.reason === 'symbolic-link-not-inspected').length, 6);
272
+ assert.ok(!result.stdout.includes(outside) && !result.stdout.includes(privateContent));
273
+ assert.equal(readFileSync(join(outside, 'private.md'), 'utf8'), privateContent);
274
+ const limited = inspectDocumentation(root, { maxFiles: 1 });
275
+ assert.equal(limited.files.length, 1);
276
+ assert.equal(limited.scan.truncated, true);
277
+ assert.equal(limited.scan.complete, false);
278
+ });
279
+
280
+ test('limites documentais são explícitos e não retornam inventário parcial como completo', t => {
281
+ const { root, file } = fixture(t);
282
+ file('a.md', '123'); file('b.md', '456'); file('deep/nested/c.md', '789');
283
+ const files = inspectDocumentation(root, { maxFiles: 1 });
284
+ assert.equal(files.files.length, 1); assert.equal(files.scan.truncated, true);
285
+ assert.equal(files.scan.complete, false);
286
+ const entries = inspectDocumentation(root, { maxEntries: 1 });
287
+ assert.equal(entries.scan.entries, 1); assert.equal(entries.scan.truncated, true);
288
+ const depth = inspectDocumentation(root, { maxDepth: 0 });
289
+ assert.ok(!depth.files.some(item => item.path.startsWith('deep/')));
290
+ assert.ok(depth.warnings.some(item => item.reason === 'depth-limit'));
291
+ const bytes = inspectDocumentation(root, { maxFileBytes: 2 });
292
+ assert.equal(bytes.scan.bytesHashed, 0); assert.equal(bytes.scan.truncated, true);
293
+ assert.ok(bytes.files.every(item => !item.sha256));
294
+ const total = inspectDocumentation(root, { maxTotalBytes: 4 });
295
+ assert.ok(total.scan.bytesRead <= 4); assert.ok(total.scan.bytesHashed <= 4);
296
+ assert.equal(total.scan.truncated, true);
297
+ for (const limits of [{ maxFiles: -1 }, { maxEntries: 25001 }, { unknown: 1 }]) assert.throws(() => inspectDocumentation(root, limits), /Limite documental/);
298
+ });
299
+
300
+ test('inventário repetido preserva bytes, modos, caminhos e índice Git', t => {
301
+ const { root, file, run } = fixture(t);
302
+ file('README.md', '# Existing\n'); file('docs/guide.md', 'No imposed frontmatter\n');
303
+ const git = (...args) => {
304
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
305
+ assert.equal(result.status, 0, result.stderr); return result.stdout;
306
+ };
307
+ git('init', '--quiet', '--template='); git('add', '--', 'README.md');
308
+ const snapshot = () => {
309
+ const files = {};
310
+ const visit = (folder = '') => {
311
+ for (const name of readdirSync(join(root, folder)).sort()) {
312
+ if (name === '.git') continue;
313
+ const relative = folder ? `${folder}/${name}` : name;
314
+ const info = lstatSync(join(root, relative));
315
+ files[relative] = { mode: info.mode, kind: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symbolic-link' : 'file' };
316
+ if (info.isSymbolicLink()) files[relative].target = readlinkSync(join(root, relative));
317
+ else if (info.isDirectory()) visit(relative);
318
+ else files[relative].sha256 = createHash('sha256').update(readFileSync(join(root, relative))).digest('hex');
319
+ }
320
+ };
321
+ visit(); return { files, index: git('ls-files', '--stage'), staged: git('diff', '--cached', '--binary'), config: readFileSync(join(root, '.git/config'), 'utf8') };
322
+ };
323
+ const before = snapshot();
324
+ const first = run(), second = run();
325
+ assert.equal(first.status, 0, first.stderr); assert.equal(second.status, 0, second.stderr);
326
+ assert.deepEqual(first.report.documentation, second.report.documentation);
327
+ assert.deepEqual(snapshot(), before);
328
+ });
@@ -0,0 +1,35 @@
1
+ import { lstatSync, readdirSync } from 'node:fs';
2
+ import { isAbsolute, join } from 'node:path';
3
+
4
+ // Inspect native paths without opening a symlink target. Callers restrict this
5
+ // exception to user-owned adapter paths; canonical kit files stay strict.
6
+ export function nativePathBoundary(root, path, ownedPaths = [], { preserveRootAliases = false } = {}) {
7
+ if (typeof path !== 'string' || !path || isAbsolute(path) || /[|\\\x00-\x1f\x7f:]/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) {
8
+ throw Object.assign(new Error(`Caminho invalido: ${path}`), { code: 'E_PATH' });
9
+ }
10
+ const parts = path.split('/');
11
+ let current = root;
12
+ for (const [index, part] of parts.entries()) {
13
+ try {
14
+ const aliases = lstatSync(current).isDirectory() ? readdirSync(current).filter(name => name.toLowerCase() === part.toLowerCase() && name !== part).sort() : [];
15
+ if (aliases.length) {
16
+ const rootEntry = parts.length === 1 && ['README.md', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md'].includes(path);
17
+ const owned = [...ownedPaths].some(value => value.toLowerCase() === path.toLowerCase() || value.toLowerCase().startsWith(`${path.toLowerCase()}/`));
18
+ if (preserveRootAliases && rootEntry && !owned) return { path: aliases[0], reason: 'case-variant' };
19
+ throw Object.assign(new Error(`Caminhos diferem somente por maiusculas/minusculas: ${path}`), { code: 'E_PATH' });
20
+ }
21
+ } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return null; throw error; }
22
+ current = join(current, part);
23
+ let info;
24
+ try { info = lstatSync(current); }
25
+ catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return null; throw error; }
26
+ const reason = info.isSymbolicLink() ? 'symbolic-link' : index < parts.length - 1 && !info.isDirectory() ? 'non-directory' : null;
27
+ if (!reason) continue;
28
+ const boundary = parts.slice(0, index + 1).join('/');
29
+ if ([...ownedPaths].some(value => value === boundary || value.startsWith(`${boundary}/`))) {
30
+ throw Object.assign(new Error(`${reason === 'symbolic-link' ? 'Link simbolico' : 'Ancestral nao e diretorio'} em caminho gerenciado: ${boundary}`), { code: reason === 'symbolic-link' ? 'E_SYMLINK' : 'E_PATH' });
31
+ }
32
+ return { path: boundary, reason };
33
+ }
34
+ return null;
35
+ }
@@ -12,6 +12,9 @@ node --test .pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs
12
12
 
13
13
  `--check` só confere; a execução normal atualiza os recursos. `--force` salva
14
14
  conflitos em `.agent-tests/` antes de reparar os caminhos gerenciados.
15
+ Recursos nativos humanos e seus links permanecem intactos, inclusive com
16
+ `--force`. O retorno inclui `preserved`; esses caminhos não contam como
17
+ derivados pendentes nem passam a pertencer ao kit.
15
18
 
16
19
  Metadados vêm da fonte ou do YAML nativo declarado no manifesto. Regras exigem
17
20
  `trigger` válido; `glob` exige `globs`. Origens nativas precisam pertencer ao