@pathbit/agentdevkit 0.0.23 → 0.0.25

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.
@@ -0,0 +1,251 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createHash } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { synchronize } from '../SCRIPT.mjs';
10
+ import { entrypointReference, mergeManagedContent } from '../../lib/managed-content.mjs';
11
+ import { mergeTomlContent } from '../../lib/managed-toml.mjs';
12
+
13
+ const catalogs = ['catalog.json', 'skills.json', 'agents.json', 'rules.json'].map(name => `.pbai4devs/${name}`);
14
+ const state = '.pbai4devs/state/derived.json';
15
+ const original = '.pbai4devs/skills/demo/SKILL.md';
16
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
17
+ const get = (root, path) => readFileSync(join(root, path));
18
+ const put = (root, path, bytes) => { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), bytes); };
19
+ const gitEnvironment = root => ({ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))), GIT_CONFIG_GLOBAL: join(root, '.git-config-ausente'), GIT_CONFIG_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0' });
20
+ const sync = (root, options = {}) => {
21
+ // synchronize is synchronous and launches Git internally. Isolate only for
22
+ // this call so an inherited GIT_INDEX_FILE/GIT_DIR cannot touch another repo.
23
+ const saved = Object.fromEntries(Object.entries(process.env).filter(([key]) => /^GIT_/i.test(key)));
24
+ for (const key of Object.keys(saved)) delete process.env[key];
25
+ Object.assign(process.env, { GIT_CONFIG_GLOBAL: join(root, '.git-config-ausente'), GIT_CONFIG_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0' });
26
+ try { return synchronize({ root, localIgnores: false, generateEntrypoints: false, output() {}, ...options }); }
27
+ finally {
28
+ for (const key of Object.keys(process.env)) if (/^GIT_/i.test(key)) delete process.env[key];
29
+ Object.assign(process.env, saved);
30
+ }
31
+ };
32
+ function git(root, ...args) {
33
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', env: gitEnvironment(root) });
34
+ assert.equal(result.status, 0, result.stderr);
35
+ }
36
+ function fixture(t) {
37
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'adk-catalog-ownership-')));
38
+ t.after(() => rmSync(root, { recursive: true, force: true }));
39
+ git(root, 'init', '-q');
40
+ put(root, '.gitignore', 'node_modules/\n');
41
+ put(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\n');
42
+ put(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Cache\n/*\n!/.gitignore\n{{ALLOWLIST}}\n');
43
+ put(root, '.pbai4devs/templates/gitignore/clients.json', '{}\n');
44
+ put(root, '.pbai4devs/templates/gitignore/shared.ignore', '# Shared\n/tools/*/.local/\n');
45
+ put(root, original, '---\nname: demo\ndescription: Demo.\n---\n\n# Demo\n');
46
+ git(root, 'add', '-f', '.gitignore');
47
+ return root;
48
+ }
49
+
50
+ for (const path of catalogs) for (const force of [false, true]) {
51
+ test(`catálogo editado permanece intacto na sincronização direta: ${path}, force=${force}`, t => {
52
+ const root = fixture(t);
53
+ assert.equal(sync(root).exitCode, 0);
54
+ put(root, path, '{"human":"preservar"}\n');
55
+ git(root, 'add', '-f', '--', path);
56
+ const before = Object.fromEntries([path, state, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
57
+ assert.notEqual(sync(root, { check: true, force }).exitCode, 0);
58
+ assert.throws(() => sync(root, { force }), /Conteudo proprio preservado/);
59
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
60
+ });
61
+ }
62
+
63
+ test('catálogo anterior idêntico não é assumido como próprio sem estado', t => {
64
+ const root = fixture(t);
65
+ assert.equal(sync(root).exitCode, 0);
66
+ const recorded = JSON.parse(get(root, state));
67
+ delete recorded.catalogs;
68
+ put(root, state, `${JSON.stringify(recorded, null, 2)}\n`);
69
+ const before = Object.fromEntries([...catalogs, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
70
+ const result = sync(root, { force: true });
71
+ for (const path of catalogs) assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-catalog'), path);
72
+ assert.equal(JSON.parse(get(root, state)).catalogs, undefined);
73
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
74
+ put(root, original, '---\nname: demo\ndescription: Mudança humana.\n---\n\n# Demo\n');
75
+ assert.throws(() => sync(root, { force: true }), /Conteudo proprio preservado/);
76
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
77
+ });
78
+
79
+ test('sincronizações diretas sucessivas registram hashes dos catálogos criados', t => {
80
+ const root = fixture(t);
81
+ assert.equal(sync(root).exitCode, 0);
82
+ for (const path of catalogs) assert.equal(JSON.parse(get(root, state)).catalogs[path], digest(get(root, path)), path);
83
+ put(root, original, '---\nname: demo\ndescription: Nova descrição.\n---\n\n# Demo\n');
84
+ assert.equal(sync(root).exitCode, 0);
85
+ for (const path of catalogs) assert.equal(JSON.parse(get(root, state)).catalogs[path], digest(get(root, path)), path);
86
+ assert.equal(sync(root, { check: true }).exitCode, 0);
87
+ });
88
+
89
+ test('registro de catálogo desconhecido ou hash inválido bloqueia antes de alterar arquivos', t => {
90
+ const root = fixture(t);
91
+ assert.equal(sync(root).exitCode, 0);
92
+ const registry = JSON.parse(get(root, state));
93
+ registry.catalogs['.pbai4devs/other.json'] = 'a'.repeat(64);
94
+ put(root, state, JSON.stringify(registry));
95
+ const before = Object.fromEntries([...catalogs, state, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
96
+ assert.throws(() => sync(root, { force: true }), /Registro de catalogos derivados invalido/);
97
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
98
+ assert.ok(existsSync(join(root, original)));
99
+ });
100
+
101
+ for (const path of ['.pbai4devs/.gitignore', '.claude/.gitignore']) for (const force of [false, true]) {
102
+ test(`bloco ignore editado permanece intacto: ${path}, force=${force}`, t => {
103
+ const root = fixture(t);
104
+ assert.equal(sync(root).exitCode, 0);
105
+ const content = get(root, path).toString('utf8').replace('# BEGIN AGENT IGNORE', '# BEGIN AGENT IGNORE\n# Regra humana\n!/conteudo-humano/');
106
+ put(root, path, content);
107
+ git(root, 'add', '-f', '--', path);
108
+ const before = Object.fromEntries([path, state, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
109
+ assert.notEqual(sync(root, { check: true, force }).exitCode, 0);
110
+ assert.throws(() => sync(root, { force }), /Conteudo proprio preservado/);
111
+ for (const [name, bytes] of Object.entries(before)) assert.deepEqual(get(root, name), bytes, name);
112
+ });
113
+ }
114
+
115
+ for (const path of ['.pbai4devs/.gitignore', '.claude/.gitignore']) {
116
+ test(`bloco ignore igual sem comprovante não é adotado: ${path}`, t => {
117
+ const root = fixture(t);
118
+ assert.equal(sync(root).exitCode, 0);
119
+ const registry = JSON.parse(get(root, state));
120
+ delete registry.blocks[path];
121
+ put(root, state, JSON.stringify(registry, null, 2) + '\n');
122
+ const originalIgnore = get(root, path);
123
+ const result = sync(root, { force: true });
124
+ assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-ignore-block'));
125
+ assert.equal(JSON.parse(get(root, state)).blocks?.[path], undefined);
126
+ assert.deepEqual(get(root, path), originalIgnore);
127
+ const template = path.startsWith('.pbai4devs/') ? 'shared.ignore' : 'agents.ignore';
128
+ const templatePath = `.pbai4devs/templates/gitignore/${template}`;
129
+ put(root, templatePath, Buffer.concat([get(root, templatePath), Buffer.from('/novo-item/\n')]));
130
+ assert.throws(() => sync(root, { force: true }), /Conteudo proprio preservado/);
131
+ assert.deepEqual(get(root, path), originalIgnore);
132
+ });
133
+ }
134
+
135
+ test('bloco idêntico em AGENTS.md sem registro não é adotado', t => {
136
+ const root = fixture(t), path = 'AGENTS.md';
137
+ put(root, '.pbai4devs/templates/entrypoints/AGENTS.md', '# Instruções\n');
138
+ const content = mergeManagedContent('# Instruções humanas\n', entrypointReference(root, path)).text;
139
+ put(root, path, content);
140
+ const result = sync(root, { force: true, generateEntrypoints: true });
141
+ assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-managed-block'));
142
+ assert.equal(get(root, path).toString('utf8'), content);
143
+ const registry = JSON.parse(get(root, state));
144
+ assert.equal(registry.blocks?.[path], undefined);
145
+ assert.equal(registry.entries?.[path], undefined);
146
+ assert.equal(sync(root, { force: true, generateEntrypoints: true }).exitCode, 0);
147
+ assert.equal(get(root, path).toString('utf8'), content);
148
+ });
149
+
150
+ for (const kind of ['markdown', 'toml']) test(`bloco nativo ${kind} idêntico sem registro não é adotado`, t => {
151
+ const root = fixture(t);
152
+ const path = kind === 'markdown' ? '.claude/skills/demo/SKILL.md' : '.gemini/commands/demo.toml';
153
+ let content;
154
+ if (kind === 'markdown') {
155
+ content = mergeManagedContent('# Instruções humanas\n', 'Leia e siga [origem](../../../.pbai4devs/skills/demo/SKILL.md).').text;
156
+ } else {
157
+ put(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\nagente|.gemini|plano\n');
158
+ put(root, '.pbai4devs/commands/demo/COMMAND.md', '---\ndescription: Demo.\n---\n\n# Demo\n');
159
+ content = mergeTomlContent('description = "Humano"\nprompt = "Meu prompt"\n', '@{.pbai4devs/commands/demo/COMMAND.md}').text;
160
+ }
161
+ put(root, path, content);
162
+ const result = sync(root, { force: true });
163
+ assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-managed-block'));
164
+ assert.equal(get(root, path).toString('utf8'), content);
165
+ const registry = JSON.parse(get(root, state));
166
+ assert.equal(registry.blocks?.[path], undefined);
167
+ assert.equal(registry.files?.[path], undefined);
168
+ assert.equal(sync(root, { force: true }).exitCode, 0);
169
+ assert.equal(get(root, path).toString('utf8'), content);
170
+ });
171
+
172
+ for (const force of [false, true]) test(`configuração humana não é alterada nem adotada: force=${force}`, t => {
173
+ const root = fixture(t), path = '.pbai4devs/espelhos.conf';
174
+ const identical = get(root, path);
175
+ const result = sync(root, { force });
176
+ assert.ok(result.preserved.some(entry => entry.path === path && entry.reason === 'existing-configuration'));
177
+ assert.equal(JSON.parse(get(root, state)).config, undefined);
178
+ assert.deepEqual(get(root, path), identical);
179
+ put(root, path, Buffer.concat([identical, Buffer.from('documento|manual-humano\n')]));
180
+ git(root, 'add', '-f', '--', path);
181
+ const before = Object.fromEntries([path, state, '.gitignore', '.git/index'].map(name => [name, get(root, name)]));
182
+ assert.notEqual(sync(root, { check: true, force }).exitCode, 0);
183
+ assert.throws(() => sync(root, { force }), /Conteudo proprio preservado/);
184
+ for (const [name, content] of Object.entries(before)) assert.deepEqual(get(root, name), content, name);
185
+ });
186
+
187
+ test('overlay de configuração inválido não autoriza escrita', t => {
188
+ const root = fixture(t);
189
+ assert.equal(sync(root).exitCode, 0);
190
+ const registry = JSON.parse(get(root, state));
191
+ registry.config = 'nao-e-sha256';
192
+ put(root, state, JSON.stringify(registry));
193
+ const before = get(root, state);
194
+ assert.throws(() => sync(root, { force: true }), /Registro de configuracao derivada invalido/);
195
+ assert.deepEqual(get(root, state), before);
196
+ });
197
+
198
+ test('CLI reconhece catálogos da sincronização direta e preserva atualização de ignore', t => {
199
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'adk-cli-sync-ownership-')));
200
+ t.after(() => rmSync(root, { recursive: true, force: true }));
201
+ git(root, 'init', '-q');
202
+ put(root, '.gitignore', 'node_modules/\n');
203
+ put(root, 'AGENTS.md', '# Instruções existentes\n');
204
+ put(root, 'staged.txt', 'Conteúdo staged humano.\n');
205
+ git(root, 'add', '-f', '.gitignore', 'AGENTS.md', 'staged.txt');
206
+ const initial = Object.fromEntries(['.gitignore', '.git/index', 'staged.txt'].map(path => [path, get(root, path)]));
207
+ const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../bin/agentdevkit.mjs');
208
+ function runCli(...args) {
209
+ const result = spawnSync(process.execPath, [cliPath, ...args, '--target', root, '--json'], {
210
+ cwd: root, env: gitEnvironment(root), encoding: 'utf8', timeout: 90000, maxBuffer: 16 * 1024 * 1024,
211
+ });
212
+ let output; try { output = JSON.parse(result.stdout); } catch { assert.fail(`Resposta CLI inválida: ${result.stderr} ${result.stdout}`); }
213
+ assert.equal(result.status, 0, `${args.join(' ')}: ${result.stderr} ${result.stdout}`);
214
+ return output;
215
+ }
216
+ function direct() {
217
+ const result = spawnSync(process.execPath, [join(root, '.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs')], {
218
+ cwd: root, env: gitEnvironment(root), encoding: 'utf8', timeout: 90000,
219
+ });
220
+ assert.equal(result.status, 0, result.stderr || result.stdout);
221
+ }
222
+ runCli('init', '--agents', 'codex');
223
+ assert.ok(get(root, 'AGENTS.md').toString('utf8').startsWith('# Instruções existentes\n'));
224
+ put(root, '.pbai4devs/skills/projeto/human-extra/SKILL.md', '---\nname: human-extra\ndescription: Minha skill.\n---\n\n# Minha skill\n');
225
+ direct(); direct();
226
+ assert.equal(runCli('doctor').ok, true);
227
+ assert.equal(runCli('sync', '--check').ok, true);
228
+ assert.equal(runCli('update').ok, true);
229
+ assert.equal(runCli('sync', '--check').ok, true);
230
+ // Simule uma versão legada que registrou legitimamente este manifesto.
231
+ const config = '.pbai4devs/espelhos.conf', installed = '.pbai4devs/state/agentdevkit.json';
232
+ put(root, config, Buffer.concat([get(root, config), Buffer.from('documento|legado-comprovado\n')]));
233
+ const previous = JSON.parse(get(root, installed));
234
+ previous.files[config] = digest(get(root, config));
235
+ put(root, installed, JSON.stringify(previous, null, 2) + '\n');
236
+ direct();
237
+ assert.doesNotMatch(get(root, config).toString('utf8'), /documento\|legado-comprovado/);
238
+ assert.equal(JSON.parse(get(root, state)).config, digest(get(root, config)));
239
+ assert.equal(runCli('doctor').ok, true);
240
+ assert.equal(runCli('sync', '--check').ok, true);
241
+ assert.equal(runCli('update').ok, true);
242
+ const template = '.pbai4devs/templates/gitignore/shared.ignore';
243
+ const humanTemplate = Buffer.concat([get(root, template), Buffer.from('\n/artefato-humano/\n')]);
244
+ put(root, template, humanTemplate);
245
+ direct();
246
+ assert.match(get(root, '.pbai4devs/.gitignore').toString('utf8'), /\/artefato-humano\//);
247
+ assert.equal(runCli('update').ok, true);
248
+ assert.deepEqual(get(root, template), humanTemplate);
249
+ assert.equal(runCli('sync', '--check').ok, true);
250
+ for (const [path, content] of Object.entries(initial)) assert.deepEqual(get(root, path), content, path);
251
+ });
@@ -14,6 +14,7 @@ import './HUMAN_OWNERSHIP.mjs';
14
14
  import './COEXISTENCE.mjs';
15
15
  import './MANAGED_CONTENT.mjs';
16
16
  import './PRESERVATION.mjs';
17
+ import './CATALOG_OWNERSHIP.mjs';
17
18
  import { validateCatalog as validateExternalCatalog } from '../../gerenciar-skills-externas/SCRIPT.mjs';
18
19
  import { stateDirectory } from '../../lib/state-path.mjs';
19
20
 
@@ -276,21 +277,22 @@ test('gera gitignore canonico e de clientes; registro gitignore nao espelha recu
276
277
  assertCheckReadonly(root, {}, 0);
277
278
  });
278
279
 
279
- test('repara somente bloco gitignore e force salva o original sem perder regras externas', t => {
280
+ test('preserva bloco gitignore editado e atualiza bloco conhecido sem perder regras externas', t => {
280
281
  const root = fixture(t, true); run(root);
281
282
  const prefix = '# Regras do usuario\r\n/private-cache/\r\n';
282
283
  const suffix = '# Excecao local\r\n!keep.log\r\n';
284
+ const original = read(root, '.codex/.gitignore');
283
285
  const old = `${prefix}${ignoreBegin}\r\n/custom-inside/\r\n${ignoreEnd}\r\n${suffix}`;
284
286
  write(root, '.codex/.gitignore', old);
285
287
  assertCheckReadonly(root); assertCheckReadonly(root, { force: true });
286
- const result = run(root, { force: true });
287
- assert.equal(readFileSync(join(result.backupRoot, '.codex/.gitignore'), 'utf8'), old);
288
- const repaired = readFileSync(join(root, '.codex/.gitignore'), 'utf8');
289
- assert.ok(repaired.startsWith(prefix)); assert.ok(repaired.endsWith(suffix));
290
- assert.ok(!repaired.includes('/custom-inside/')); assert.ok(repaired.includes('/cache/'));
288
+ assert.throws(() => run(root, { force: true }), /Conteudo proprio preservado/);
289
+ assert.equal(read(root, '.codex/.gitignore'), old);
290
+ write(root, '.codex/.gitignore', `${prefix}${original}${suffix}`);
291
291
  write(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Novo padrao\n{{ALLOWLIST}}\n/cache/\n/session/\n');
292
292
  assertCheckReadonly(root);
293
- run(root);
293
+ const result = run(root, { force: true });
294
+ assert.equal(readFileSync(join(result.backupRoot, '.codex/.gitignore'), 'utf8'), `${prefix}${original}${suffix}`);
295
+ assert.ok(read(root, '.codex/.gitignore').startsWith(prefix));
294
296
  assert.ok(read(root, '.codex/.gitignore').includes('/session/'));
295
297
  assert.ok(readFileSync(join(root, '.codex/.gitignore'), 'utf8').endsWith(suffix));
296
298
  assertCheckReadonly(root, {}, 0);
@@ -374,6 +376,9 @@ test('registros são derivados, determinísticos e --check nunca os corrige', t
374
376
  run(root);
375
377
  write(root, '.pbai4devs/skills.json', '{"incorreto":true}\n');
376
378
  assertCheckReadonly(root);
379
+ assert.throws(() => run(root, { force: true }), /Conteudo proprio preservado/);
380
+ assert.equal(read(root, '.pbai4devs/skills.json'), '{"incorreto":true}\n');
381
+ rmSync(join(root, '.pbai4devs/skills.json'));
377
382
  run(root);
378
383
  assert.ok(Array.isArray(JSON.parse(read(root, '.pbai4devs/skills.json')).entries));
379
384
  rmSync(join(root, '.pbai4devs/adapters/antigravity/agents/revisor.md'));
@@ -455,7 +460,7 @@ test('atualiza fontes, adiciona clientes, remove recursos e migra agrupado para
455
460
  assertCheckReadonly(root, {}, 0);
456
461
  });
457
462
 
458
- test('remove linhas legadas e preserva arquivos sem prova de propriedade', t => {
463
+ test('preserva linhas legadas humanas sem prova de propriedade', t => {
459
464
  const root = fixture(t);
460
465
  run(root);
461
466
  write(root, 'docs/produto/prd/demo/PRD.md', '# Produto real\n');
@@ -463,13 +468,26 @@ test('remove linhas legadas e preserva arquivos sem prova de propriedade', t =>
463
468
  write(root, '.claude/manual/DOC.md', pointer('../../manual/guia%20com%20espa%C3%A7o/DOC.md'));
464
469
  write(root, '.github/prompts/missing.prompt.md', pointer());
465
470
  write(root, '.pbai4devs/espelhos.conf', read(root, '.pbai4devs/espelhos.conf') + 'documento|manual\n' + 'nativo|.github/prompts/missing.prompt.md|.pbai4devs/commands/missing/COMMAND.md\n');
471
+ const config = read(root, '.pbai4devs/espelhos.conf');
466
472
  assertCheckReadonly(root);
467
- run(root);
468
- assert.doesNotMatch(read(root, '.pbai4devs/espelhos.conf'), /documento\||missing/);
473
+ assert.throws(() => run(root, { force: true }), /Conteudo proprio preservado/);
474
+ assert.equal(read(root, '.pbai4devs/espelhos.conf'), config);
469
475
  assert.equal(existsSync(join(root, '.claude/manual/DOC.md')), true);
470
476
  assert.equal(existsSync(join(root, '.github/prompts/missing.prompt.md')), true);
471
477
  assert.ok(existsSync(join(root, 'docs/produto/prd/demo/PRD.md')));
472
478
  assert.ok(existsSync(join(root, 'manual/guia com espaço/DOC.md')));
479
+ assertCheckReadonly(root);
480
+ });
481
+
482
+ test('limpa configuração legada comprovadamente gerenciada e registra novo hash', t => {
483
+ const root = fixture(t);
484
+ const path = '.pbai4devs/espelhos.conf';
485
+ write(root, path, read(root, path) + 'documento|manual\n');
486
+ recordOwned(root, [path]);
487
+ run(root);
488
+ assert.doesNotMatch(read(root, path), /documento\|/);
489
+ const derived = JSON.parse(read(root, '.pbai4devs/state/derived.json'));
490
+ assert.equal(derived.config, createHash('sha256').update(readFileSync(join(root, path))).digest('hex'));
473
491
  assertCheckReadonly(root, {}, 0);
474
492
  });
475
493
 
@@ -745,13 +763,15 @@ test('criar editar e remover recursos atualiza todos os tipos e os índices nati
745
763
  assertCheckReadonly(root, {}, 0);
746
764
  });
747
765
 
748
- test('JSON derivado corrompido é detectado e reparado com backup sem editar fontes', t => {
766
+ test('JSON derivado alterado é preservado até remoção deliberada pelo usuário', t => {
749
767
  const root = completeCatalogFixture(t);
750
768
  run(root);
751
769
  for (const path of ['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']) write(root, path, '{corrompido');
752
770
  assertCheckReadonly(root, { force: true });
753
- const result = run(root, { force: true });
754
- for (const path of ['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']) assert.equal(read(result.backupRoot, path), '{corrompido');
771
+ assert.throws(() => run(root, { force: true }), /Conteudo proprio preservado/);
772
+ for (const path of ['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']) assert.equal(read(root, path), '{corrompido');
773
+ for (const path of ['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']) rmSync(join(root, path));
774
+ run(root);
755
775
  assert.equal(JSON.parse(read(root, '.pbai4devs/catalog.json')).schemaVersion, 1);
756
776
  assertCheckReadonly(root, {}, 0);
757
777
  });
@@ -29,21 +29,30 @@ execuções e projetos anteriores não são substituídos nem removidos. Cada
29
29
  exemplo recebe Git local, sem commits nem identidade alterada. O diretório
30
30
  gerado contém um ignore próprio, inclusive em projetos consumidores. Se a
31
31
  preparação falhar, o índice da nova execução permanece em `preparing`.
32
+ O gerador verifica links e identidade da saída antes e depois das etapas, mas
33
+ isso não é uma proteção atômica. Um processo concorrente com permissão para
34
+ trocar diretórios ainda pode redirecionar uma escrita entre as verificações;
35
+ execute-o em uma área sem esse acesso concorrente.
32
36
  O hub `.pbai4devs` e os clientes registrados são copiados, incluindo comandos
33
37
  nativos em TOML. Sem manifesto local, os templates definem os clientes em
34
38
  memória, respeitando a seleção salva. `.vscode` exige registro. Apenas as entradas
35
39
  `AGENTS.md`, `CLAUDE.md` e `GEMINI.md` presentes na origem são incluídas na raiz.
36
40
  Os templates substituem somente entradas com blocos gerenciados válidos;
37
41
  entradas próprias ou editadas são preservadas. Sem estado de instalação,
38
- o mantenedor usa `.pbai4devs/templates/entrypoints/`, com fallback para a raiz.
42
+ as entradas existentes são copiadas sem presumir propriedade do kit.
39
43
  Os testes resolvem cada apontamento pelo formato declarado do cliente e recurso.
40
44
 
41
- As cópias geram os derivados locais antes dos testes, sem alterar a origem.
45
+ As cópias guardam os catálogos originais em `.agent-tests/testar-maturidade/original-catalogs/`
46
+ e geram catálogos operacionais novos, sem alterar os arquivos da origem.
47
+ Arquivos da origem substituídos pela fixture ficam em `original-files/` nessa
48
+ mesma pasta. O relatório identifica cada cópia: o cenário não é um teste do
49
+ projeto original no local.
42
50
  Não copiam o código do produto em `bin/`, `src/`, `scripts/` ou `tests/`,
43
51
  nem essas pastas na localização antiga dentro do hub.
44
52
  Executam sincronização/reparo/idempotência, catálogo, verificação de
45
53
  recursos, diagnóstico, inspeção e gates negativos/positivo. Em dois casos,
46
- criação/edição/renomeação/remoção e corrupção dos índices exercitam o ciclo todo.
54
+ criação, edição, renomeação, remoção e preservação de índices alterados
55
+ exercitam o ciclo todo.
47
56
  Aplicações executam testes HTTP reais em
48
57
  portas efêmeras, encerradas ao fim. Legado e monorepo preservam seus arquivos.
49
58
  Ferramentas copiadas ficam sem downloads: o check negativo é esperado.
@@ -15,6 +15,7 @@ const assets = join(dirname(fileURLToPath(import.meta.url)), 'assets');
15
15
  const owner = 'testar-maturidade';
16
16
  const marker = '.maturity-case.json';
17
17
  const suiteMarker = '.maturity-suite.json';
18
+ const generatedCatalogs = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']);
18
19
  const outputIgnore = '# Exemplos gerados permanecem locais.\n*\n';
19
20
  const defaultOutput = root => join(root, 'examples/generated');
20
21
  const prepareResources = root => {
@@ -58,6 +59,7 @@ function createChild(root, parent, name, { allowExisting = false } = {}) {
58
59
  noLinks(root, join(parent, name));
59
60
  const output = stat(join(parent, name));
60
61
  if (!output?.isDirectory() || output.isSymbolicLink()) fail('Diretório de saída inválido.');
62
+ return output;
61
63
  }
62
64
  function ensureDirectories(root, target) {
63
65
  let current = root;
@@ -144,7 +146,7 @@ function copyAllowed(relativePath, directory = false) {
144
146
  return true;
145
147
  }
146
148
 
147
- export function copyAgentStructure(sourceRoot, destination) {
149
+ export function copyAgentStructure(sourceRoot, destination, { archiveCatalogs = false, archiveOverrides = new Set(), archiveEntrypoints = false } = {}) {
148
150
  const roots = ['.pbai4devs', ...registeredRoots(sourceRoot)];
149
151
  const stateFile = join(sourceRoot, stateDirectory(sourceRoot), 'agentdevkit.json');
150
152
  const stateInfo = stat(stateFile);
@@ -152,7 +154,7 @@ export function copyAgentStructure(sourceRoot, destination) {
152
154
  const installation = stateInfo ? json(stateFile) : null;
153
155
  if (stateInfo && (!installation || !installation.blocks || typeof installation.blocks !== 'object' || Array.isArray(installation.blocks))) fail('Estado da instalação deve registrar os blocos gerenciados.');
154
156
  function managedEntry(name) {
155
- if (!installation) return true;
157
+ if (!installation) return false;
156
158
  const expected = installation.blocks[name];
157
159
  if (typeof expected !== 'string') return false;
158
160
  const content = readFileSync(join(sourceRoot, name), 'utf8');
@@ -161,7 +163,7 @@ export function copyAgentStructure(sourceRoot, destination) {
161
163
  const start = content.indexOf(begin), finish = content.indexOf(end);
162
164
  return finish > start && digest(content.slice(start, finish + end.length)) === expected;
163
165
  }
164
- const files = []; const entrypoints = {}; let skipped = 0;
166
+ const files = []; const entrypoints = {}; const catalogSnapshots = []; const overriddenSnapshots = []; const seenSources = new Set(); let skipped = 0;
165
167
  function copy(file, targetFile = file) {
166
168
  const source = join(sourceRoot, file);
167
169
  const item = stat(source); if (!item) return;
@@ -171,6 +173,14 @@ export function copyAgentStructure(sourceRoot, destination) {
171
173
  mkdirSync(join(destination, targetFile), { recursive: true });
172
174
  for (const name of readdirSync(source).sort()) copy(join(file, name), join(targetFile, name));
173
175
  } else if (item.isFile()) {
176
+ seenSources.add(portable(file));
177
+ if (archiveCatalogs && generatedCatalogs.has(portable(file))) {
178
+ targetFile = `.agent-tests/testar-maturidade/original-catalogs/${basename(file)}`;
179
+ catalogSnapshots.push({ source: portable(file), snapshot: targetFile });
180
+ } else if (archiveOverrides.has(portable(file))) {
181
+ targetFile = `.agent-tests/testar-maturidade/original-files/${portable(file)}`;
182
+ overriddenSnapshots.push({ source: portable(file), snapshot: targetFile });
183
+ }
174
184
  const target = join(destination, targetFile); mkdirSync(dirname(target), { recursive: true }); copyFileSync(source, target); files.push(portable(targetFile));
175
185
  } else fail(`Arquivo não regular na origem: ${file}`);
176
186
  }
@@ -183,11 +193,29 @@ export function copyAgentStructure(sourceRoot, destination) {
183
193
  const selected = managedEntry(name) && stat(join(sourceRoot, template)) ? template : name;
184
194
  const info = stat(join(sourceRoot, selected));
185
195
  if (info && !info.isFile()) fail(`Entrada de agente deve ser arquivo regular: ${selected}`);
196
+ if (archiveEntrypoints && selected !== name) {
197
+ const snapshot = `.agent-tests/testar-maturidade/original-files/${name}`;
198
+ copy(name, snapshot); overriddenSnapshots.push({ source: name, snapshot });
199
+ }
186
200
  copy(selected, name);
187
201
  if (info) entrypoints[name] = selected;
188
202
  }
203
+ // A fixture can create files belonging to an unselected client; archive
204
+ // those source files as well, without traversing symbolic-link ancestors.
205
+ for (const path of archiveOverrides) if (!seenSources.has(path)) {
206
+ noLinks(sourceRoot, join(sourceRoot, path));
207
+ copy(path);
208
+ }
189
209
  const sourceDigest = digest(files.map(file => `${file}\0${digest(readFileSync(join(destination, file)))}`).join('\n'));
190
- return { roots, copiedFiles: files.length, skipped, files, sourceDigest, entrypoints, toolDefinitions: files.filter(file => /^\.pbai4devs\/tools\/[^/]+\/TOOL\.json$/.test(file)).length };
210
+ return { roots, copiedFiles: files.length, skipped, files, sourceDigest, entrypoints, catalogSnapshots, overriddenSnapshots, toolDefinitions: files.filter(file => /^\.pbai4devs\/tools\/[^/]+\/TOOL\.json$/.test(file)).length };
211
+ }
212
+
213
+ function fixtureOverrides(scenario, sourceRoot) {
214
+ const paths = ['README.md', '.pbai4devs/.gitignore', ...registeredRoots(sourceRoot).map(root => `${root}/.gitignore`), '.pbai4devs/state/configuracao/ESTADO.md', '.pbai4devs/estado/configuracao/ESTADO.md'];
215
+ if (scenario.planning !== 'none' || scenario.app) paths.push('.pbai4devs/state/README.md', '.pbai4devs/estado/README.md');
216
+ if (scenario.configured) paths.push('.dockerignore', '.env.example', 'Makefile');
217
+ if (scenario.layout === 'legacy') paths.push('Makefile', '.github/workflows/legacy.yml', '.vscode/settings.json');
218
+ return new Set(paths);
191
219
  }
192
220
 
193
221
  function planning(root, scenario) {
@@ -299,33 +327,41 @@ export function prepareExamples({ sourceRoot = projectRoot, outputRoot, scenario
299
327
  const parent = outputRoot === undefined ? base : dirname(base);
300
328
  ensureDirectories(sourceRoot, parent);
301
329
  const name = outputRoot === undefined ? `maturity-${randomUUID()}` : basename(base);
302
- createChild(sourceRoot, parent, name);
330
+ const created = createChild(sourceRoot, parent, name);
303
331
  outputRoot = join(parent, name);
304
- write(outputRoot, suiteMarker, JSON.stringify({ owner, version: 1 }, null, 2) + '\n');
305
- write(outputRoot, '.gitignore', outputIgnore);
306
- writeIndex(outputRoot, [], 'preparing');
332
+ const canonicalOutput = realpathSync(outputRoot);
333
+ const verifyOutput = (path = outputRoot) => {
334
+ noLinks(sourceRoot, path);
335
+ const current = stat(outputRoot);
336
+ if (!current?.isDirectory() || current.dev !== created.dev || current.ino !== created.ino || realpathSync(outputRoot) !== canonicalOutput) fail('Diretório de saída alterado durante a execução.');
337
+ };
338
+ const guarded = (path, operation) => { verifyOutput(path); const value = operation(); verifyOutput(path); return value; };
339
+ guarded(outputRoot, () => write(outputRoot, suiteMarker, JSON.stringify({ owner, version: 1 }, null, 2) + '\n'));
340
+ guarded(outputRoot, () => write(outputRoot, '.gitignore', outputIgnore));
341
+ guarded(outputRoot, () => writeIndex(outputRoot, [], 'preparing'));
307
342
  const examples = [];
308
343
  for (const scenario of scenarios) {
309
344
  const destination = join(outputRoot, scenario.id);
310
- mkdirSync(destination);
311
- write(destination, marker, JSON.stringify({ owner, version: 1, id: scenario.id }, null, 2) + '\n');
312
- const copied = copyAgentStructure(sourceRoot, destination);
313
- planning(destination, scenario);
314
- const app = application(destination, scenario);
315
- write(destination, 'README.md', document(scenario.title, `${scenario.note}\n\nEste é um exemplo gerado. Estados de aprovação/publicação são dados de teste.\nRevisão do gate é simulação contratual, não revisão humana.\n\nOs scripts dos agentes foram copiados, sem pacotes ou credenciais conhecidas.\nO filtro não detecta segredos em conteúdo arbitrário.\n${app.tests.length ? `\nPara repetir os testes: \`${scenario.layout === 'legacy' ? 'npm run test:legacy' : 'npm test'}\`.\nO harness executa os testes Node diretamente, sem instalar dependências.\nO teste abre HTTP em porta efêmera e encerra o servidor.\n` : '\nNão há aplicação; a prova real exercita os scripts copiados.\n'}`, `fase: ${scenario.expected}\n`));
316
- renderResources(destination);
317
- if (initializeGit) gitInit(destination);
345
+ guarded(outputRoot, () => mkdirSync(destination));
346
+ guarded(destination, () => write(destination, marker, JSON.stringify({ owner, version: 1, id: scenario.id }, null, 2) + '\n'));
347
+ const copied = guarded(destination, () => copyAgentStructure(sourceRoot, destination, { archiveCatalogs: true, archiveOverrides: fixtureOverrides(scenario, sourceRoot), archiveEntrypoints: true }));
348
+ guarded(destination, () => planning(destination, scenario));
349
+ const app = guarded(destination, () => application(destination, scenario));
350
+ guarded(destination, () => write(destination, 'README.md', document(scenario.title, `${scenario.note}\n\nEste é um exemplo gerado, não um teste no projeto original. Estados de aprovação/publicação são dados de teste.\nRevisão do gate é simulação contratual, não revisão humana.\nCatálogos originais e arquivos substituídos pela fixture ficam em \`.agent-tests/testar-maturidade/\`; os catálogos operacionais são regenerados.\n\nOs scripts dos agentes foram copiados, sem pacotes ou credenciais conhecidas.\nO filtro não detecta segredos em conteúdo arbitrário.\n${app.tests.length ? `\nPara repetir os testes: \`${scenario.layout === 'legacy' ? 'npm run test:legacy' : 'npm test'}\`.\nO harness executa os testes Node diretamente, sem instalar dependências.\nO teste abre HTTP em porta efêmera e encerra o servidor.\n` : '\nNão há aplicação; a prova real exercita os scripts copiados.\n'}`, `fase: ${scenario.expected}\n`)));
351
+ guarded(destination, () => renderResources(destination));
352
+ if (initializeGit) guarded(destination, () => gitInit(destination));
318
353
  examples.push({ ...scenario, root: destination, copied, application: app });
319
354
  }
320
- write(outputRoot, 'REPORT.json', JSON.stringify({ owner, version: 1, status: 'prepared', scenarios: [...ids], results: [] }, null, 2) + '\n');
321
- writeIndex(outputRoot, examples, 'prepared');
355
+ guarded(outputRoot, () => write(outputRoot, 'REPORT.json', JSON.stringify({ owner, version: 1, status: 'prepared', scenarios: [...ids], copies: examples.map(example => ({ id: example.id, catalogSnapshots: example.copied.catalogSnapshots, overriddenSnapshots: example.copied.overriddenSnapshots })), results: [] }, null, 2) + '\n'));
356
+ guarded(outputRoot, () => writeIndex(outputRoot, examples, 'prepared'));
357
+ Object.defineProperty(examples, 'verifyOutput', { value: verifyOutput });
322
358
  return examples;
323
359
  }
324
360
 
325
361
  function writeIndex(outputRoot, examples, status) {
326
362
  const rows = examples.map(item => `| [${item.id}](${item.id}/README.md) | ${item.expected} | ${item.status || 'prepared'} |`).join('\n');
327
363
  const progress = status === 'preparing' ? '\nPreparação em andamento nesta execução.\n' : '';
328
- write(outputRoot, 'README.md', document('Exemplos de maturidade', `Projetos sintéticos com cópias locais da estrutura de agentes.\nNenhum resultado certifica produção, revisão humana ou outros sistemas operacionais.\n${progress}\n| Cenário | Nível esperado | Resultado |\n| --- | --- | --- |${rows ? '\n' + rows : ''}\n\nRelatório: \`REPORT.json\`. Logs e evidências por projeto ficam em \`.agent-tests/\`.\nCada execução usa uma pasta nova e preserva as anteriores.\n`, `status: ${status}\n`));
364
+ write(outputRoot, 'README.md', document('Exemplos de maturidade', `Projetos sintéticos com cópias locais da estrutura de agentes; não são testes no projeto original.\nCatálogos operacionais são regenerados e suas cópias originais ficam em cada \`.agent-tests/testar-maturidade/\`.\nNenhum resultado certifica produção, revisão humana ou outros sistemas operacionais.\n${progress}\n| Cenário | Nível esperado | Resultado |\n| --- | --- | --- |${rows ? '\n' + rows : ''}\n\nRelatório: \`REPORT.json\`. Logs e evidências por projeto ficam em \`.agent-tests/\`.\nCada execução usa uma pasta nova e preserva as anteriores.\n`, `status: ${status}\n`));
329
365
  }
330
366
 
331
367
  function hashes(root, files) { return Object.fromEntries(files.map(file => [file, digest(readFileSync(join(root, file)))])); }
@@ -399,13 +435,14 @@ function resourceLifecycle(example, run) {
399
435
  if (readFileSync(join(root, catalogPath), 'utf8') !== before.catalog || readFileSync(join(root, registryPath), 'utf8') !== before.registry) fail('O ciclo não restaurou os catálogos originais.');
400
436
  const corrupted = '{"tampered":true}\n'; write(root, catalogPath, corrupted);
401
437
  run(sync, ['--check'], 1, 'catalog-drift-detected');
402
- const repair = run(sync, ['--force'], 0, 'catalog-drift-repaired');
403
- const backup = repair.output.match(/Backup local: (.+)/)?.[1]?.trim();
404
- if (!backup || readFileSync(join(root, backup, catalogPath), 'utf8') !== corrupted) fail('Correção forçada não preservou backup do JSON alterado.');
438
+ const blocked = run(sync, ['--force'], 2, 'catalog-drift-preserved');
439
+ if (!blocked.output.includes('Conteudo proprio preservado')) fail('A colisão não informou a preservação do catálogo.');
440
+ if (readFileSync(join(root, catalogPath), 'utf8') !== corrupted) fail('Sincronização alterou o JSON sintético sem ownership.');
441
+ write(root, catalogPath, before.catalog); // Restore only the content this fixture deliberately changed.
405
442
  rmSync(join(root, catalogPath)); run(sync, ['--check'], 1, 'catalog-missing-detected');
406
443
  run(sync, [], 0, 'catalog-missing-repaired'); run(sync, ['--check'], 0, 'lifecycle-clean');
407
444
  if (readFileSync(join(root, catalogPath), 'utf8') !== before.catalog || readFileSync(join(root, registryPath), 'utf8') !== before.registry) fail('Catálogos não foram restaurados após corrupção/remoção.');
408
- return { status: 'passed', operations: ['create', 'edit-description', 'rename', 'delete', 'corrupt-json', 'remove-json'], clients: rows.length, backup, sourceRestored: true };
445
+ return { status: 'passed', operations: ['create', 'edit-description', 'rename', 'delete', 'protect-modified-json', 'remove-json'], clients: rows.length, sourceRestored: true };
409
446
  }
410
447
 
411
448
  function reviewContract(example, run, proof) {
@@ -444,7 +481,7 @@ function reviewContract(example, run, proof) {
444
481
  export function testExample(example) {
445
482
  const commands = []; const logRoot = '.agent-tests/maturity';
446
483
  const run = runner(example.root, logRoot, commands);
447
- const result = { id: example.id, expected: example.expected, status: 'failed', copied: { files: example.copied.copiedFiles, roots: example.copied.roots, skipped: example.copied.skipped, sourceDigest: example.copied.sourceDigest, entrypoints: example.copied.entrypoints }, commands, limits: ['Executado somente no SO/runtime deste host.', 'Estado de produto e revisão do gate são dados sintéticos.', 'Build Docker, UI e implantação não são exercitados.', 'Cópia exclui credenciais por nomes conhecidos, sem varredura semântica de segredos.'] };
484
+ const result = { id: example.id, expected: example.expected, status: 'failed', copied: { files: example.copied.copiedFiles, roots: example.copied.roots, skipped: example.copied.skipped, sourceDigest: example.copied.sourceDigest, entrypoints: example.copied.entrypoints, catalogSnapshots: example.copied.catalogSnapshots, overriddenSnapshots: example.copied.overriddenSnapshots }, commands, limits: ['Cenário sintético: não testa o projeto original no local.', 'Executado somente no SO/runtime deste host.', 'Estado de produto e revisão do gate são dados sintéticos.', 'Build Docker, UI e implantação não são exercitados.', 'Cópia exclui credenciais por nomes conhecidos, sem varredura semântica de segredos.'] };
448
485
  const preserved = hashes(example.root, example.application.protected);
449
486
  try {
450
487
  const sync = '.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs';
@@ -493,11 +530,14 @@ export function testExample(example) {
493
530
 
494
531
  export function runExamples(options = {}) {
495
532
  const examples = prepareExamples(options);
496
- const results = examples.map(example => { const result = testExample(example); console.log(`${example.id}=${result.status}${result.error ? ': ' + result.error : ''}`); return result; });
533
+ const results = examples.map(example => { examples.verifyOutput(example.root); const result = testExample(example); examples.verifyOutput(example.root); console.log(`${example.id}=${result.status}${result.error ? ': ' + result.error : ''}`); return result; });
497
534
  const outputRoot = dirname(examples[0].root);
498
535
  const report = { owner, version: 1, status: results.every(item => item.status === 'passed') ? 'passed' : 'failed', platform: process.platform, arch: process.arch, node: process.version, results, limits: ['Provas reais somente das operações descritas e APIs locais.', 'Pareceres do gate são simulações explícitas, sem revisão humana.', 'Não certifica produção nem sistemas operacionais não executados.'] };
536
+ examples.verifyOutput();
499
537
  write(outputRoot, 'REPORT.json', JSON.stringify(report, null, 2) + '\n');
538
+ examples.verifyOutput();
500
539
  writeIndex(outputRoot, examples.map(example => ({ ...example, status: results.find(result => result.id === example.id).status })), report.status);
540
+ examples.verifyOutput();
501
541
  return { outputRoot, report };
502
542
  }
503
543