@pathbit/agentdevkit 0.0.7 → 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.
- package/.pbai4devs/COMPATIBILIDADE.md +4 -2
- package/.pbai4devs/INSTRUCTIONS.md +4 -0
- package/.pbai4devs/commands/sincronizar/COMMAND.md +4 -3
- package/.pbai4devs/rules/documentacao/RULE.md +4 -0
- package/.pbai4devs/rules/qualidade/RULE.md +4 -2
- package/.pbai4devs/scripts/README.md +3 -3
- package/.pbai4devs/scripts/inspecionar-projeto/README.md +31 -0
- package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +142 -1
- package/.pbai4devs/scripts/inspecionar-projeto/tests/TEST.mjs +168 -1
- package/.pbai4devs/scripts/lib/gitignore.mjs +84 -15
- package/.pbai4devs/scripts/lib/native-paths.mjs +35 -0
- package/.pbai4devs/scripts/preparar-ferramentas/SCRIPT.mjs +41 -20
- package/.pbai4devs/scripts/preparar-ferramentas/tests/TEST.mjs +81 -0
- package/.pbai4devs/scripts/sincronizar-agentes/README.md +3 -0
- package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +193 -49
- package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +190 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +151 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +145 -1
- package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +30 -29
- package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +56 -8
- package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +92 -1
- package/.pbai4devs/skills/produto/criar-design-doc/SKILL.md +2 -0
- package/.pbai4devs/skills/produto/criar-prd/SKILL.md +2 -0
- package/.pbai4devs/skills/produto/criar-ux-ui/SKILL.md +2 -0
- package/.pbai4devs/skills/projeto/atualizar-documentacao/SKILL.md +18 -4
- package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +2 -0
- package/.pbai4devs/skills/projeto/criar-recurso/SKILL.md +1 -0
- package/.pbai4devs/skills/projeto/sincronizar-agentes/SKILL.md +3 -2
- package/.pbai4devs/skills/specs/criar-spec/SKILL.md +1 -0
- package/.pbai4devs/skills/specs/gerenciar-roadmap/SKILL.md +2 -1
- package/.pbai4devs/templates/README.md +4 -1
- package/.pbai4devs/templates/configuracao/TEMPLATE.md +6 -0
- package/CHANGELOG.md +19 -0
- package/README.en.md +3 -2
- package/README.es.md +3 -2
- package/README.md +3 -2
- package/docs/README.md +1 -0
- package/docs/guides/README.md +1 -0
- package/docs/guides/cli.md +11 -0
- package/docs/guides/documentation.md +63 -0
- package/docs/guides/installation.md +18 -5
- package/docs/guides/resources.md +6 -5
- package/docs/guides/tools-and-testing.md +2 -0
- package/docs/guides/troubleshooting.md +6 -6
- package/docs/guides/workflow.md +2 -0
- package/docs/maintainers/packages.md +1 -1
- package/docs/product/design.md +12 -0
- package/docs/product/prd.md +4 -1
- package/docs/product/roadmap.md +4 -0
- package/docs/specs/README.md +2 -0
- package/docs/specs/v0.0.8/README.md +10 -0
- package/docs/specs/v0.0.8/SPEC.md +62 -0
- package/docs/specs/v0.0.9/README.md +9 -0
- package/docs/specs/v0.0.9/SPEC.md +68 -0
- package/package.json +3 -2
- package/src/cli.mjs +13 -4
- package/src/i18n.mjs +3 -0
- package/src/install.mjs +145 -38
|
@@ -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
|
|
91
|
-
|
|
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
|
|
12
|
-
apontamentos e
|
|
13
|
-
|
|
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
|
|
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`
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
2
|
+
import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
@@ -63,6 +63,7 @@ export function renderClientIgnore(template, settings = {}) {
|
|
|
63
63
|
|
|
64
64
|
const localStat = file => { try { return lstatSync(file); } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return null; throw error; } };
|
|
65
65
|
const localRead = file => localStat(file) ? readFileSync(file, 'utf8') : null;
|
|
66
|
+
const localMode = file => { const item = localStat(file); return item ? item.mode & 0o777 : null; };
|
|
66
67
|
const portable = path => path.split(sep).join('/');
|
|
67
68
|
|
|
68
69
|
function canonicalExisting(path) {
|
|
@@ -144,6 +145,61 @@ function artifactProbes(root, patterns) {
|
|
|
144
145
|
return [...probes];
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
const matchesComponent = (part, pattern) => posix.matchesGlob(`x${part}`, `x${pattern.replace(/[{}()]/g, '\\$&')}`);
|
|
149
|
+
|
|
150
|
+
function matchesParts(parts, pattern) {
|
|
151
|
+
const cache = new Map();
|
|
152
|
+
function match(part, glob) {
|
|
153
|
+
const key = `${part}:${glob}`; if (cache.has(key)) return cache.get(key);
|
|
154
|
+
const expected = pattern[glob];
|
|
155
|
+
const result = glob === pattern.length ? part === parts.length
|
|
156
|
+
: expected === '**' ? match(part, glob + 1) || part < parts.length && match(part + 1, glob)
|
|
157
|
+
: part < parts.length && matchesComponent(parts[part], expected) && match(part + 1, glob + 1);
|
|
158
|
+
cache.set(key, result); return result;
|
|
159
|
+
}
|
|
160
|
+
return match(0, 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function artifactPath(path, patterns, directory = false) {
|
|
164
|
+
const parts = path.split('/');
|
|
165
|
+
return patterns.some(pattern => {
|
|
166
|
+
const rule = pattern.slice(3).replace(/\/$/, '');
|
|
167
|
+
return parts.some((part, index) => (index < parts.length - 1 || directory || !pattern.endsWith('/'))
|
|
168
|
+
&& (rule.includes('/') ? matchesParts(parts.slice(0, index + 1), ['**', ...rule.split('/')])
|
|
169
|
+
: matchesComponent(part, rule)));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function visibleArtifactProbes(root, location, patterns) {
|
|
174
|
+
const listed = gitCommand(location.top, ['--literal-pathspecs', '-c', 'core.excludesFile=', 'ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', location.prefix ? `${location.prefix}/` : '.']);
|
|
175
|
+
if (listed.error || listed.status !== 0) throw new Error(listed.error?.message || listed.stderr || 'Falha ao conferir artefatos existentes.');
|
|
176
|
+
const paths = listed.stdout.split('\0').filter(Boolean), prefix = location.prefix ? `${location.prefix}/` : '';
|
|
177
|
+
const probes = new Set(paths.filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length)).filter(path => artifactPath(path, patterns)));
|
|
178
|
+
const ignores = new Set(paths.filter(path => path.startsWith(prefix) && (path === '.gitignore' || path.endsWith('/.gitignore'))));
|
|
179
|
+
let ancestor = root;
|
|
180
|
+
while (true) {
|
|
181
|
+
ignores.add(portable(relative(location.top, join(ancestor, '.gitignore'))));
|
|
182
|
+
if (ancestor === location.top) break;
|
|
183
|
+
ancestor = dirname(ancestor);
|
|
184
|
+
}
|
|
185
|
+
for (const path of ignores) {
|
|
186
|
+
const file = join(location.top, path), item = localStat(file);
|
|
187
|
+
if (!item?.isFile() || item.isSymbolicLink()) continue;
|
|
188
|
+
// Git resolves precedence; these literals only add witnesses for named exceptions.
|
|
189
|
+
for (const line of readFileSync(checkedLocalPath(file), 'utf8').split(/\r?\n/)) {
|
|
190
|
+
if (!line.startsWith('!') || line.length === 1) continue;
|
|
191
|
+
const rule = line.slice(1).replace(/(?<!\\) +$/, ''), directory = rule.endsWith('/') || rule.endsWith('/**');
|
|
192
|
+
const literal = rule.replace(/(?:\/\*\*)+\/?$/, '').replace(/\/$/, '').replace(/^\//, '');
|
|
193
|
+
if (/(^|[^\\])[*?\[]/.test(literal)) continue;
|
|
194
|
+
const ruleBase = !rule.startsWith('/') && !literal.includes('/') && relative(dirname(file), root).split(sep)[0] !== '..' ? root : dirname(file);
|
|
195
|
+
const candidate = portable(relative(root, resolve(ruleBase, literal.replace(/\\(.)/g, '$1'))));
|
|
196
|
+
if (!candidate || candidate === '..' || candidate.startsWith('../') || !artifactPath(candidate, patterns, directory)) continue;
|
|
197
|
+
probes.add(checkedOutput(directory ? `${candidate}/.agentdevkit-artifact-probe` : candidate));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return [...probes];
|
|
201
|
+
}
|
|
202
|
+
|
|
147
203
|
function localIgnoreSource(location, source) {
|
|
148
204
|
const path = resolve(location.top, source);
|
|
149
205
|
if (path === location.exclude) return true;
|
|
@@ -204,9 +260,9 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
|
|
|
204
260
|
const body = fixed.split(/\r?\n/).map(line => location?.prefix && line.startsWith('/') ? `/${escapePattern(location.prefix)}${line}` : line).join('\n');
|
|
205
261
|
const target = location?.exclude ?? checkedLocalPath(join(root, '.gitignore'));
|
|
206
262
|
const before = localRead(target), after = localBlock(before, first, last, body);
|
|
207
|
-
const edits = before === after ? [] : [{ path: target, before, after }];
|
|
263
|
+
const edits = before === after ? [] : [{ path: target, before, after, beforeMode: localMode(target) }];
|
|
208
264
|
const probes = [...paths, ...rootProbes(root, roots)];
|
|
209
|
-
const artifactPaths = artifactProbes(root, artifacts);
|
|
265
|
+
const artifactPaths = [...new Set([...artifactProbes(root, artifacts), ...(location ? visibleArtifactProbes(root, location, artifacts) : [])])];
|
|
210
266
|
const wanted = new Set(probes.map(path => location?.prefix ? `${location.prefix}/${path}` : path));
|
|
211
267
|
if (location && !before?.includes(first) && !localRead(join(root, '.gitignore'))?.includes(first)) {
|
|
212
268
|
const ignored = ignoreMatches(location, [...probes, ...artifactPaths]);
|
|
@@ -215,10 +271,10 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
|
|
|
215
271
|
if (location) {
|
|
216
272
|
const fallback = checkedLocalPath(join(root, '.gitignore'));
|
|
217
273
|
const old = localRead(fallback), next = localBlock(old, first, last, null);
|
|
218
|
-
if (old !== null && old !== next) edits.push({ path: fallback, before: old, after: next });
|
|
274
|
+
if (old !== null && old !== next) edits.push({ path: fallback, before: old, after: next, beforeMode: localMode(fallback) });
|
|
219
275
|
}
|
|
220
276
|
const tracked = location ? gitCommand(location.top, ['ls-files', '-z']).stdout.split('\0').filter(path => wanted.has(path)).map(path => location.prefix ? path.slice(location.prefix.length + 1) : path) : [];
|
|
221
|
-
return { root, status: location ? 'git' : 'fallback', location, paths, roots, artifactProbes: artifactPaths, edits, trackedOutputs: tracked, first, last };
|
|
277
|
+
return { root, status: location ? 'git' : 'fallback', location, paths, roots, artifactPatterns: artifacts, artifactProbes: artifactPaths, edits, trackedOutputs: tracked, first, last };
|
|
222
278
|
}
|
|
223
279
|
|
|
224
280
|
export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
|
|
@@ -235,7 +291,8 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
|
|
|
235
291
|
return { ...item, path };
|
|
236
292
|
});
|
|
237
293
|
if (plan.location && (plan.paths.length || plan.artifactProbes?.length)) {
|
|
238
|
-
const probes = [...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || [])
|
|
294
|
+
const probes = [...new Set([...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || []),
|
|
295
|
+
...(plan.artifactPatterns ? visibleArtifactProbes(plan.root, plan.location, plan.artifactPatterns) : [])])];
|
|
239
296
|
const { paths: relativePaths, matches } = ignoreMatches(plan.location, probes);
|
|
240
297
|
for (let index = 0; index < relativePaths.length; index++) {
|
|
241
298
|
const match = matches.get(relativePaths[index]);
|
|
@@ -252,10 +309,21 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
|
|
|
252
309
|
export function assertLocalIgnoreUnchanged(plan) {
|
|
253
310
|
for (const edit of plan.edits) {
|
|
254
311
|
checkedLocalPath(edit.path);
|
|
255
|
-
if (localRead(edit.path) !== edit.before) throw new Error(`Exclusao alterada apos planejar: ${edit.path}`);
|
|
312
|
+
if (localRead(edit.path) !== edit.before || Object.hasOwn(edit, 'beforeMode') && localMode(edit.path) !== edit.beforeMode) throw new Error(`Exclusao alterada apos planejar: ${edit.path}`);
|
|
256
313
|
}
|
|
257
314
|
}
|
|
258
315
|
|
|
316
|
+
function atomicLocalWrite(path, content, mode, published) {
|
|
317
|
+
const temporary = `${path}.agentdevkit-${randomUUID()}`;
|
|
318
|
+
try {
|
|
319
|
+
writeFileSync(temporary, content, { flag: 'wx' });
|
|
320
|
+
if (mode !== null && mode !== undefined) chmodSync(temporary, mode);
|
|
321
|
+
const writtenMode = localMode(temporary);
|
|
322
|
+
renameSync(temporary, path);
|
|
323
|
+
published?.(writtenMode);
|
|
324
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
325
|
+
}
|
|
326
|
+
|
|
259
327
|
export function applyLocalIgnore(plan) {
|
|
260
328
|
assertLocalIgnoreUnchanged(plan);
|
|
261
329
|
const before = checkLocalIgnore(plan);
|
|
@@ -264,18 +332,19 @@ export function applyLocalIgnore(plan) {
|
|
|
264
332
|
try {
|
|
265
333
|
for (const edit of plan.edits) {
|
|
266
334
|
mkdirSync(dirname(edit.path), { recursive: true });
|
|
267
|
-
|
|
268
|
-
try { writeFileSync(temporary, edit.after, { flag: 'wx' }); renameSync(temporary, edit.path); }
|
|
269
|
-
finally { rmSync(temporary, { force: true }); }
|
|
270
|
-
written.push(edit);
|
|
335
|
+
atomicLocalWrite(edit.path, edit.after, edit.beforeMode, mode => written.push({ edit, mode }));
|
|
271
336
|
}
|
|
272
337
|
const verified = checkLocalIgnore({ ...plan, edits: [], trackedOutputs: [] });
|
|
273
338
|
if (verified.missing.length) throw new Error(`Regras humanas reabrem derivados locais: ${verified.missing.join(', ')}`);
|
|
274
339
|
} catch (error) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
340
|
+
const rollbackErrors = [];
|
|
341
|
+
for (const { edit, mode } of written.reverse()) {
|
|
342
|
+
try {
|
|
343
|
+
if (localRead(edit.path) !== edit.after || localMode(edit.path) !== mode) continue;
|
|
344
|
+
if (edit.before === null) rmSync(edit.path, { force: true }); else atomicLocalWrite(edit.path, edit.before, edit.beforeMode);
|
|
345
|
+
} catch (failure) { rollbackErrors.push(failure); }
|
|
278
346
|
}
|
|
347
|
+
if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error });
|
|
279
348
|
throw error;
|
|
280
349
|
}
|
|
281
350
|
return { status: plan.status, changed: plan.edits.length, trackedOutputs: plan.trackedOutputs };
|