@pathbit/agentdevkit 0.0.17 → 0.0.19

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.
@@ -42,7 +42,7 @@ test('sem Git preserva ignore e informa pendência; após git init protege outpu
42
42
  git(root, ['add', '--all']);
43
43
  const tracked = git(root, ['ls-files', '-z']).split('\0').filter(Boolean);
44
44
  for (const path of outputs) assert.equal(tracked.includes(path), false, path);
45
- for (const path of ['.github/workflows/ci.yml', '.github/ISSUE_TEMPLATE/bug.yml', '.vscode/settings.json', '.github/skills/human/SKILL.md']) assert.ok(tracked.includes(path), path);
45
+ for (const path of ['.github/workflows/ci.yml', '.github/ISSUE_TEMPLATE/bug.yml', '.vscode/settings.json', '.github/skills/human/SKILL.md']) assert.equal(tracked.includes(path), false, path);
46
46
  });
47
47
 
48
48
  test('prefixos e projetos distintos preservam blocos locais independentes', t => {
@@ -58,13 +58,49 @@ test('prefixos e projetos distintos preservam blocos locais independentes', t =>
58
58
  assert.match(content, /first\\ space/); assert.match(content, /second/);
59
59
  });
60
60
 
61
- test('check identifica negação humana e outputs rastreados sem alterar o índice', t => {
61
+ test('projeto aninhado sob ancestral ignorado falha antes de alterar índices ou excludes', t => {
62
+ const root = fixture(t); git(root, ['init', '-q']);
63
+ put(root, '.gitignore', '/.github/\n');
64
+ const child = join(root, '.github/project');
65
+ put(child, '.gitignore', '!/.claude/\n');
66
+ put(child, '.claude/custom.md', 'instruções próprias\n');
67
+ const exclude = readFileSync(join(root, '.git/info/exclude'));
68
+ const index = git(root, ['ls-files', '--stage', '-z']);
69
+ const plan = planLocalIgnore(child, { outputs: ['.claude/custom.md'] });
70
+ assert.ok(checkLocalIgnore(plan).missing.some(value => value.includes('Ancestral Git ignorado: .github/')));
71
+ assert.throws(() => applyLocalIgnore(plan), /Ancestral Git ignorado/);
72
+ assert.deepEqual(readFileSync(join(root, '.git/info/exclude')), exclude);
73
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), index);
74
+ assert.equal(readFileSync(join(child, '.claude/custom.md'), 'utf8'), 'instruções próprias\n');
75
+ put(root, '.gitignore', '/.github/\n!/.github/\n');
76
+ const opened = planLocalIgnore(child, { outputs: ['.claude/custom.md'] });
77
+ applyLocalIgnore(opened);
78
+ assert.equal(checkLocalIgnore(planLocalIgnore(child, { outputs: ['.claude/custom.md'] })).ok, true);
79
+ assert.equal(spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', '.github/project/.claude/custom.md']).status, 1);
80
+ });
81
+
82
+ test('opt-in aninhado respeita também excludesFile configurado pelo usuário', t => {
83
+ const root = fixture(t); git(root, ['init', '-q']);
84
+ put(root, 'global-ignore', '/.private/\n');
85
+ git(root, ['config', '--local', 'core.excludesFile', join(root, 'global-ignore')]);
86
+ const child = join(root, '.private/project');
87
+ put(child, '.gitignore', '!/.claude/\n');
88
+ put(child, '.claude/custom.md', 'instruções próprias\n');
89
+ const before = readFileSync(join(root, '.git/info/exclude'));
90
+ const plan = planLocalIgnore(child);
91
+ assert.ok(checkLocalIgnore(plan).missing.some(value => value.includes('Ancestral Git ignorado: .private/')));
92
+ assert.throws(() => applyLocalIgnore(plan), /Ancestral Git ignorado/);
93
+ assert.deepEqual(readFileSync(join(root, '.git/info/exclude')), before);
94
+ assert.equal(readFileSync(join(child, '.claude/custom.md'), 'utf8'), 'instruções próprias\n');
95
+ });
96
+
97
+ test('check identifica negação humana sem alterar nem diagnosticar conteúdo rastreado legítimo', t => {
62
98
  const root = fixture(t), outputs = ['generated.md']; git(root, ['init', '-q']);
63
99
  put(root, 'generated.md', 'gerado'); git(root, ['add', 'generated.md']);
64
100
  applyLocalIgnore(planLocalIgnore(root, { outputs }));
65
101
  put(root, '.gitignore', '!/generated.md\n');
66
102
  const result = checkLocalIgnore(planLocalIgnore(root, { outputs }));
67
- assert.equal(result.ok, false); assert.deepEqual(result.missing, outputs); assert.deepEqual(result.trackedOutputs, outputs);
103
+ assert.equal(result.ok, false); assert.deepEqual(result.missing, outputs); assert.deepEqual(result.trackedOutputs, []);
68
104
  assert.equal(git(root, ['ls-files', 'generated.md']).trim(), 'generated.md');
69
105
  });
70
106
 
@@ -89,7 +125,7 @@ test('exceções de templates preservam o ignore raiz e protegem dados privados'
89
125
  assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.CACHE/private.json']).includes('.cursor/.CACHE/private.json'));
90
126
  assert.ok(git(root, ['check-ignore', '--no-index', '.cursor/.cache/private.json']).includes('.cursor/.cache/private.json'));
91
127
  const visible = spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', '.cursor/human.md']);
92
- assert.equal(visible.status, 1);
128
+ assert.equal(visible.status, 0);
93
129
  applyLocalIgnore(planLocalIgnore(root));
94
130
  assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), updated);
95
131
  put(root, '.gitignore', updated + '# edição humana posterior\n');
@@ -134,12 +170,12 @@ test('negação humana posterior é informada sem reescrever a raiz', t => {
134
170
  const root = fixture(t); git(root, ['init', '-q']);
135
171
  put(root, '.gitignore', '# humano\n'); applyLocalIgnore(planLocalIgnore(root));
136
172
  const path = join(root, '.gitignore');
137
- writeFileSync(path, readFileSync(path, 'utf8') + '!/.cursor/.env.production\n');
173
+ writeFileSync(path, readFileSync(path, 'utf8') + '!/.cursor/\n!/.cursor/.env.production\n');
138
174
  put(root, '.cursor/.env.production', 'privado');
139
175
  const plan = planLocalIgnore(root);
140
176
  assert.ok(checkLocalIgnore(plan).missing.includes('.cursor/.env.production'));
141
177
  assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
142
- assert.equal(readFileSync(path, 'utf8'), '# humano\n!/.cursor/.env.production\n');
178
+ assert.equal(readFileSync(path, 'utf8'), '# humano\n!/.cursor/\n!/.cursor/.env.production\n');
143
179
  });
144
180
 
145
181
  test('planejamento sem Git não modifica ignore nem edição humana posterior', t => {
@@ -193,11 +229,13 @@ test('desindexação não descarta edição humana staged com workingtree restau
193
229
  assert.equal(readFileSync(join(root, 'generated.md'), 'utf8'), 'gerado\n');
194
230
  });
195
231
 
196
- test('configurações humanas dos clientes continuam versionáveis', t => {
232
+ test('configurações humanas podem ser versionadas após exceção explícita', t => {
197
233
  const root = fixture(t); git(root, ['init', '-q']);
198
234
  applyLocalIgnore(planLocalIgnore(root));
199
235
  put(root, '.codex/config.toml', 'local'); put(root, '.claude/settings.json', 'local');
200
236
  put(root, '.github/settings.json', 'fonte'); put(root, '.vscode/settings.json', 'fonte');
237
+ put(root, '.gitignore', '!/.codex/\n!/.claude/\n!/.github/\n!/.vscode/\n');
238
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
201
239
  git(root, ['add', '--all']);
202
240
  const tracked = git(root, ['ls-files', '-z']).split('\0');
203
241
  assert.equal(tracked.includes('.codex/config.toml'), true);
@@ -205,6 +243,53 @@ test('configurações humanas dos clientes continuam versionáveis', t => {
205
243
  assert.ok(tracked.includes('.github/settings.json')); assert.ok(tracked.includes('.vscode/settings.json'));
206
244
  });
207
245
 
246
+ test('exceções de raiz usam precedência real e mantêm credenciais ignoradas', t => {
247
+ const root = fixture(t); git(root, ['init', '-q']);
248
+ put(root, '.gitignore', '!/.claude/\n!/.github/\n');
249
+ put(root, '.claude/skills/team/SKILL.md', 'skill humana\n');
250
+ put(root, '.claude/auth.json', 'segredo');
251
+ put(root, '.github/workflows/ci.yml', 'workflow humano\n');
252
+ put(root, '.vscode/settings.json', 'configuração humana\n');
253
+ const original = readFileSync(join(root, '.gitignore'));
254
+ const plan = planLocalIgnore(root, { outputs: ['.claude/skills/team/SKILL.md', '.github/workflows/ci.yml'] });
255
+ assert.ok(plan.optedRoots.includes('.claude'));
256
+ assert.ok(plan.optedRoots.includes('.github'));
257
+ applyLocalIgnore(plan);
258
+ assert.equal(checkLocalIgnore(planLocalIgnore(root)).ok, true);
259
+ for (const path of ['.claude/skills/team/SKILL.md', '.github/workflows/ci.yml']) {
260
+ assert.equal(spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', path]).status, 1, path);
261
+ }
262
+ for (const path of ['.claude/auth.json', '.vscode/settings.json']) {
263
+ assert.equal(spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', path]).status, 0, path);
264
+ }
265
+ assert.deepEqual(readFileSync(join(root, '.gitignore')), original);
266
+ put(root, '.gitignore', '!/.claude/\n/.claude/\n');
267
+ const overridden = planLocalIgnore(root);
268
+ assert.equal(overridden.optedRoots.includes('.claude'), false);
269
+ assert.equal(spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '-q', '.claude/skills/team/SKILL.md']).status, 0);
270
+ put(root, '.gitignore', '!/.claude/\n!/.claude/**\n');
271
+ assert.throws(() => applyLocalIgnore(planLocalIgnore(root)), /Regras humanas/);
272
+ });
273
+
274
+ test('derivados e entradas legítimas já rastreados permanecem no índice com ou sem opt-in', t => {
275
+ const root = fixture(t); git(root, ['init', '-q']);
276
+ put(root, '.claude/skills/team/SKILL.md', 'skill rastreada\n');
277
+ put(root, '.github/workflows/ci.yml', 'workflow rastreado\n');
278
+ put(root, 'AGENTS.md', 'instruções rastreadas\n');
279
+ git(root, ['add', '--all']);
280
+ const before = git(root, ['ls-files', '--stage', '-z']);
281
+ const outputs = ['.claude/skills/team/SKILL.md', '.github/workflows/ci.yml', 'AGENTS.md'];
282
+ applyLocalIgnore(planLocalIgnore(root, { outputs }));
283
+ assert.equal(checkLocalIgnore(planLocalIgnore(root, { outputs })).ok, true);
284
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), before);
285
+ put(root, '.gitignore', '!/.claude/\n');
286
+ const opted = planLocalIgnore(root, { outputs });
287
+ assert.ok(opted.optedRoots.includes('.claude'));
288
+ applyLocalIgnore(opted);
289
+ assert.equal(checkLocalIgnore(planLocalIgnore(root, { outputs })).ok, true);
290
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), before);
291
+ });
292
+
208
293
  test('artefatos vêm da política compartilhada e rastreados são informados', t => {
209
294
  const root = fixture(t); git(root, ['init', '-q']);
210
295
  put(root, '.agent-tests/human.txt', 'evidência humana'); git(root, ['add', '-f', '.agent-tests/human.txt']);
@@ -271,12 +356,13 @@ test('probes reconhecem exceções humanas para nomes reais de Playwright', asyn
271
356
  });
272
357
  });
273
358
 
274
- test('milhares de caminhos usam stdin e não ampliam metadata de área já ignorada', t => {
359
+ test('milhares de caminhos usam stdin sem ampliar metadata de área já ignorada', t => {
275
360
  const root = fixture(t); git(root, ['init', '-q']); put(root, '.gitignore', '/ignored/\n');
276
361
  const before = readFileSync(join(root, '.git/info/exclude'), 'utf8');
277
362
  const target = join(root, 'ignored'); mkdirSync(target);
278
- const outputs = Array.from({ length: 1500 }, (_, index) => `.github/skills/generated-${index}-${'x'.repeat(45)}/SKILL.md`);
363
+ const outputs = Array.from({ length: 1500 }, (_, index) => `generated-${index}-${'x'.repeat(45)}.md`);
279
364
  const plan = planLocalIgnore(target, { outputs });
365
+ assert.ok(plan.paths.length >= 1500);
280
366
  assert.deepEqual(plan.edits, []); assert.equal(checkLocalIgnore(plan).ok, true);
281
367
  applyLocalIgnore(plan); assert.equal(readFileSync(join(root, '.git/info/exclude'), 'utf8'), before);
282
368
  assert.deepEqual(planUntrack(target, Object.fromEntries(outputs.map(path => [path, 'a'.repeat(64)]))).paths, []);
@@ -341,16 +427,17 @@ test('cliente não selecionado com symlink próprio não é atravessado pelo pro
341
427
  assert.deepEqual(readdirSync(outside), before);
342
428
  });
343
429
 
344
- test('preflight dispensa somente regras de bloco substituído com hash válido', t => {
430
+ test('preflight valida hash do bloco substituído sem impor exclusão sobre cliente optado', t => {
345
431
  const root = fixture(t); git(root, ['init', '-q']);
346
432
  const block = '# BEGIN AGENT IGNORE\r\n!/generated.md\r\n# END AGENT IGNORE';
433
+ put(root, '.gitignore', '!/.github/\n');
347
434
  put(root, '.github/.gitignore', `# Humano\r\n${block}\r\n!/human.md\r\n`);
348
435
  const plan = planLocalIgnore(root, { outputs: ['.github/generated.md', '.github/human.md'] });
349
436
  const replacement = { path: join(root, '.github/.gitignore'), firstLine: 2, lastLine: 4, sha256: createHash('sha256').update(block).digest('hex') };
350
- assert.deepEqual(checkLocalIgnore(plan).missing, ['.github/generated.md', '.github/human.md']);
351
- assert.deepEqual(checkLocalIgnore(plan, { replacedBlocks: [replacement] }).missing, ['.github/human.md']);
437
+ assert.deepEqual(checkLocalIgnore(plan).missing, []);
438
+ assert.deepEqual(checkLocalIgnore(plan, { replacedBlocks: [replacement] }).missing, []);
352
439
  assert.throws(() => checkLocalIgnore(plan, { replacedBlocks: [{ ...replacement, sha256: '0'.repeat(64) }] }), /Bloco de ignore mudou/);
353
- assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
440
+ assert.doesNotThrow(() => applyLocalIgnore(plan));
354
441
  assert.equal(readFileSync(join(root, '.github/.gitignore'), 'utf8'), `# Humano\r\n${block}\r\n!/human.md\r\n`);
355
442
  });
356
443
 
@@ -490,14 +577,14 @@ test('dependências já ignoradas não causam conflito, mas ignores visíveis s
490
577
  assert.ok(checkLocalIgnore(planLocalIgnore(root)).missing.some(value => value.includes('!.playwright-*/')));
491
578
  });
492
579
 
493
- test('bloco antigo de outro caminho não oculta arquivos humanos silenciosamente', t => {
580
+ test('bloco de outra worktree não bloqueia a política local de clientes', t => {
494
581
  const root = fixture(t); git(root, ['init', '-q']);
495
582
  const old = '# BEGIN AGENTDEVKIT LOCAL 0123456789abcdef\n/.claude/\n# END AGENTDEVKIT LOCAL 0123456789abcdef\n';
496
583
  put(root, '.git/info/exclude', old);
497
584
  const plan = planLocalIgnore(root);
498
- assert.ok(checkLocalIgnore(plan).missing.includes('.claude/.agentdevkit-human-probe.md'));
499
- assert.throws(() => applyLocalIgnore(plan), /Regras humanas/);
500
- assert.equal(readFileSync(join(root, '.git/info/exclude'), 'utf8'), old);
585
+ assert.equal(checkLocalIgnore(plan).missing.length, 0);
586
+ assert.doesNotThrow(() => applyLocalIgnore(plan));
587
+ assert.ok(readFileSync(join(root, '.git/info/exclude'), 'utf8').startsWith(old));
501
588
  });
502
589
 
503
590
  test('artefatos já versionados são informados sem alterar o índice', t => {
@@ -555,6 +642,57 @@ test('escrita de exclusões preserva permissões e detecta alteração concorren
555
642
  assert.equal(statSync(exclude).mode & 0o777, 0o640);
556
643
  });
557
644
 
645
+ test('alteração no exclude durante escrita temporária é preservada antes da promoção', t => {
646
+ const root = fixture(t); git(root, ['init', '-q']);
647
+ const file = join(root, '.git/info/exclude'), before = readFileSync(file, 'utf8');
648
+ const plan = planLocalIgnore(root), index = git(root, ['ls-files', '--stage', '-z']);
649
+ const write = fs.writeFileSync; let injected = false;
650
+ try {
651
+ fs.writeFileSync = (path, content, options) => {
652
+ const value = write(path, content, options);
653
+ if (!injected && String(path).startsWith(`${file}.agentdevkit-`)) {
654
+ injected = true;
655
+ write(file, `${before}\n# regra humana concorrente\n`);
656
+ }
657
+ return value;
658
+ };
659
+ syncBuiltinESMExports();
660
+ assert.throws(() => applyLocalIgnore(plan), /Exclusao alterada antes de publicar/);
661
+ } finally { fs.writeFileSync = write; syncBuiltinESMExports(); }
662
+ assert.equal(injected, true);
663
+ assert.equal(readFileSync(file, 'utf8'), `${before}\n# regra humana concorrente\n`);
664
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), index);
665
+ });
666
+
667
+ test('alteração no exclude durante rollback não é substituída', t => {
668
+ const root = fixture(t); git(root, ['init', '-q']);
669
+ const file = join(root, '.git/info/exclude'), plan = planLocalIgnore(root);
670
+ const index = git(root, ['ls-files', '--stage', '-z']);
671
+ const write = fs.writeFileSync, remove = fs.rmSync;
672
+ let failed = false, injected = false;
673
+ try {
674
+ fs.rmSync = (path, options) => {
675
+ if (!failed && String(path).startsWith(`${file}.agentdevkit-`) && !existsSync(path)) {
676
+ failed = true; throw new Error('falha após promoção');
677
+ }
678
+ return remove(path, options);
679
+ };
680
+ fs.writeFileSync = (path, content, options) => {
681
+ const value = write(path, content, options);
682
+ if (failed && !injected && String(path).startsWith(`${file}.agentdevkit-`)) {
683
+ injected = true;
684
+ write(file, `${readFileSync(file, 'utf8')}\n# regra humana no rollback\n`);
685
+ }
686
+ return value;
687
+ };
688
+ syncBuiltinESMExports();
689
+ assert.throws(() => applyLocalIgnore(plan), /rollback incompleto.*conteudo preservado/s);
690
+ } finally { fs.rmSync = remove; fs.writeFileSync = write; syncBuiltinESMExports(); }
691
+ assert.equal(injected, true);
692
+ assert.match(readFileSync(file, 'utf8'), /# regra humana no rollback/);
693
+ assert.equal(git(root, ['ls-files', '--stage', '-z']), index);
694
+ });
695
+
558
696
  test('ignore raiz mantém modo e bytes antes e depois de git init', { skip: process.platform === 'win32' }, t => {
559
697
  const root = fixture(t), ignore = join(root, '.gitignore');
560
698
  put(root, '.gitignore', '# humano\n'); chmodSync(ignore, 0o600);
@@ -0,0 +1,306 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { tmpdir } from 'node:os';
8
+ import { synchronize } from '../SCRIPT.mjs';
9
+ import { indexContentMatches } from '../../lib/gitignore.mjs';
10
+
11
+ const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
12
+ const original = '.pbai4devs/skills/projeto/criar-skill/SKILL.md';
13
+ const pointer = '.claude/skills/criar-skill/SKILL.md';
14
+ const file = (root, relative) => join(root, relative);
15
+ const put = (root, relative, content) => { mkdirSync(dirname(file(root, relative)), { recursive: true }); writeFileSync(file(root, relative), content); };
16
+ function git(root, ...args) {
17
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
18
+ assert.equal(result.status, 0, `${args.join(' ')}: ${result.stderr}`);
19
+ return result.stdout;
20
+ }
21
+ function fixture(t, withGit = true) {
22
+ mkdirSync(join(repository, '.agent-tests'), { recursive: true });
23
+ const root = mkdtempSync(join(withGit ? join(repository, '.agent-tests') : tmpdir(), 'sync-preservation-'));
24
+ t.after(() => rmSync(root, { recursive: true, force: true }));
25
+ if (withGit) git(root, 'init', '--quiet');
26
+ populate(root);
27
+ return root;
28
+ }
29
+ function populate(root) {
30
+ put(root, '.gitignore', '# Human ignore\nnode_modules/\n');
31
+ put(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Cache\n/*\n!/.gitignore\n{{ALLOWLIST}}\n/cache/\n*.log\n');
32
+ put(root, '.pbai4devs/templates/gitignore/clients.json', '{}\n');
33
+ put(root, '.pbai4devs/templates/gitignore/shared.ignore', '# Shared\n/tools/*/.local/\n');
34
+ put(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\n');
35
+ put(root, original, '---\nname: criar-skill\ndescription: Criar recurso.\n---\n\n# Criar skill\n');
36
+ assert.equal(run(root).exitCode, 0);
37
+ assert.ok(existsSync(file(root, pointer)));
38
+ }
39
+ function run(root, options = {}) {
40
+ return synchronize({ root, localIgnores: false, generateEntrypoints: false, output() {}, ...options });
41
+ }
42
+ function withoutGit(t, callback) {
43
+ const emptyPath = mkdtempSync(join(tmpdir(), 'sync-empty-path-'));
44
+ t.after(() => rmSync(emptyPath, { recursive: true, force: true }));
45
+ const keys = Object.keys(process.env).filter(key => key.toLowerCase() === 'path');
46
+ const previous = new Map(keys.map(key => [key, process.env[key]]));
47
+ if (!keys.length) keys.push('PATH');
48
+ try {
49
+ for (const key of keys) process.env[key] = emptyPath;
50
+ return callback();
51
+ } finally {
52
+ for (const key of keys) {
53
+ if (previous.has(key)) process.env[key] = previous.get(key);
54
+ else delete process.env[key];
55
+ }
56
+ }
57
+ }
58
+
59
+ for (const [withGit, force] of [[true, false], [true, true], [false, false], [false, true]]) {
60
+ test(`remoção obsoleta preserva pasta vazia humana (git=${withGit}, force=${force})`, t => {
61
+ const root = fixture(t, withGit), human = '.claude/skills/criar-skill/Equipe-Vazia';
62
+ mkdirSync(file(root, human));
63
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
64
+ const index = withGit ? git(root, 'ls-files', '--stage', '-z') : null;
65
+ const ignore = readFileSync(file(root, '.gitignore'));
66
+ rmSync(dirname(file(root, original)), { recursive: true });
67
+ const check = run(root, { check: true, force });
68
+ assert.notEqual(check.exitCode, 0);
69
+ assert.ok(existsSync(file(root, pointer)));
70
+ assert.ok(existsSync(file(root, human)));
71
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
72
+ run(root, { force });
73
+ assert.equal(existsSync(file(root, pointer)), false, 'apontamento obsoleto comprovado é removido');
74
+ assert.ok(existsSync(file(root, human)), 'pasta vazia própria permanece');
75
+ assert.deepEqual(readFileSync(file(root, '.gitignore')), ignore);
76
+ if (withGit) assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
77
+ });
78
+ }
79
+
80
+ for (const force of [false, true]) {
81
+ test(`índice com conteúdo humano divergente bloqueia remoção (force=${force})`, t => {
82
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
83
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nConteúdo humano staged.\n')]));
84
+ git(root, 'add', '-f', '--', pointer);
85
+ const index = git(root, 'ls-files', '--stage', '-z');
86
+ put(root, pointer, generated);
87
+ rmSync(dirname(file(root, original)), { recursive: true });
88
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
89
+ const check = run(root, { check: true, force });
90
+ assert.notEqual(check.exitCode, 0);
91
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
92
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
93
+ assert.throws(() => run(root, { force }), /conflitos|indice|índice/i);
94
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
95
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
96
+ });
97
+ }
98
+
99
+ for (const force of [false, true]) {
100
+ test(`índice divergente impede atualização de apontamento ativo (force=${force})`, t => {
101
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
102
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
103
+ git(root, 'add', '-f', '--', pointer);
104
+ const index = git(root, 'ls-files', '--stage', '-z');
105
+ put(root, pointer, generated);
106
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
107
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
108
+ assert.notEqual(run(root, { check: true, force }).exitCode, 0);
109
+ assert.throws(() => run(root, { force }), /indice/i);
110
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
111
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
112
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
113
+ });
114
+ }
115
+
116
+ for (const flag of ['--assume-unchanged', '--skip-worktree']) {
117
+ test(`índice divergente continua protegido com ${flag}`, t => {
118
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
119
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
120
+ git(root, 'add', '-f', '--', pointer);
121
+ put(root, pointer, generated);
122
+ git(root, 'update-index', flag, '--', pointer);
123
+ const index = git(root, 'ls-files', '--stage', '-z');
124
+ const flags = git(root, 'ls-files', '-v', '--', pointer);
125
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
126
+ assert.notEqual(run(root, { check: true, force: true }).exitCode, 0);
127
+ assert.throws(() => run(root, { force: true }), /indice/i);
128
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
129
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
130
+ assert.equal(git(root, 'ls-files', '-v', '--', pointer), flags);
131
+ });
132
+ }
133
+
134
+ test('índice com alias de caixa bloqueia alteração conservadoramente em qualquer sistema', t => {
135
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
136
+ const alias = pointer.replace('/skills/', '/SKILLS/');
137
+ put(root, alias, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
138
+ git(root, 'add', '-f', '--', alias);
139
+ const index = git(root, 'ls-files', '--stage', '-z');
140
+ put(root, pointer, generated);
141
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
142
+ const aliasBytes = readFileSync(file(root, alias));
143
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
144
+ const ignore = readFileSync(file(root, '.gitignore'));
145
+ const preserved = () => {
146
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
147
+ assert.deepEqual(readFileSync(file(root, alias)), aliasBytes);
148
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
149
+ assert.deepEqual(readFileSync(file(root, '.gitignore')), ignore);
150
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
151
+ };
152
+ let check, error;
153
+ try { check = run(root, { check: true, force: true }); } catch (failure) { error = failure; }
154
+ if (error) assert.match(error.message, /maiusculas\/minusculas/i);
155
+ else assert.notEqual(check.exitCode, 0);
156
+ preserved();
157
+ assert.throws(() => run(root, { force: true }), /indice|maiusculas\/minusculas/i);
158
+ preserved();
159
+ });
160
+
161
+ test('comparação de índice entende CRLF autorizado e recusa filtros, binários e divergência', t => {
162
+ const root = fixture(t), path = 'arquivo.txt';
163
+ const crlf = Buffer.from('texto\r\noutra linha\r\n'), lf = Buffer.from('texto\noutra linha\n');
164
+ git(root, 'config', 'core.autocrlf', 'false');
165
+ git(root, 'config', 'core.attributesfile', file(root, 'atributos-ausentes'));
166
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
167
+ git(root, 'config', 'core.autocrlf', 'true');
168
+ assert.equal(indexContentMatches(root, path, crlf, lf), true);
169
+ assert.equal(indexContentMatches(root, path, crlf, Buffer.from('texto humano\noutra linha\n')), false);
170
+ assert.equal(indexContentMatches(root, path, Buffer.from([0, 13, 10]), Buffer.from([0, 10])), false);
171
+ assert.equal(indexContentMatches(root, path, Buffer.from('um\r\ndois\r'), Buffer.from('um\ndois\r')), false);
172
+ assert.equal(indexContentMatches(root, path, Buffer.from('\ufefftexto\r\n'), Buffer.from('texto\n')), false);
173
+ assert.equal(indexContentMatches(root, path, Buffer.from('\ufefftexto\r\n'), Buffer.from('\ufefftexto\n')), true);
174
+ put(root, '.gitattributes', '*.txt -text\n');
175
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
176
+ put(root, '.gitattributes', '*.txt text filter=nao-executar\n');
177
+ git(root, 'config', 'filter.nao-executar.clean', 'exit 1');
178
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
179
+ put(root, '.gitattributes', '*.txt text working-tree-encoding=UTF-16\n');
180
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
181
+ put(root, '.gitattributes', '*.txt text ident\n');
182
+ assert.equal(indexContentMatches(root, path, crlf, lf), false);
183
+ put(root, '.gitattributes', '*.txt text eol=crlf\n');
184
+ git(root, 'config', 'core.autocrlf', 'false');
185
+ assert.equal(indexContentMatches(root, path, crlf, lf), true);
186
+ });
187
+
188
+ test('sync atualiza apontamento staged equivalente em repositório com autocrlf', t => {
189
+ const root = fixture(t), generated = readFileSync(file(root, pointer), 'utf8').replaceAll('\n', '\r\n');
190
+ git(root, 'config', 'core.autocrlf', 'true');
191
+ put(root, pointer, generated);
192
+ git(root, 'add', '-f', '--', pointer);
193
+ const index = git(root, 'ls-files', '--stage', '-z');
194
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
195
+ assert.equal(run(root, { force: true }).exitCode, 0);
196
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
197
+ });
198
+
199
+ for (const target of ['.pbai4devs/state/derived.json', '.claude/.gitignore']) {
200
+ test(`índice divergente impede atualização de ${target}`, t => {
201
+ const root = fixture(t), generated = readFileSync(file(root, target));
202
+ put(root, target, Buffer.concat([generated, Buffer.from('\nAlteração humana staged.\n')]));
203
+ git(root, 'add', '-f', '--', target);
204
+ const index = git(root, 'ls-files', '--stage', '-z');
205
+ put(root, target, generated);
206
+ if (target.endsWith('derived.json')) {
207
+ put(root, original, '---\nname: criar-skill\ndescription: Mudança real.\n---\n\n# Criar skill\n');
208
+ } else {
209
+ put(root, '.pbai4devs/templates/gitignore/agents.ignore', '# Cache atualizado\n/*\n!/.gitignore\n{{ALLOWLIST}}\n/cache/\n*.log\n');
210
+ }
211
+ assert.notEqual(run(root, { check: true, force: true }).exitCode, 0);
212
+ assert.throws(() => run(root, { force: true }), /indice/i);
213
+ assert.deepEqual(readFileSync(file(root, target)), generated);
214
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
215
+ });
216
+ }
217
+
218
+ for (const options of [{ check: true }, { force: true }]) {
219
+ test(`Git indisponível bloqueia limpeza em repositório (check=${Boolean(options.check)}, force=${Boolean(options.force)})`, t => {
220
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
221
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nConteúdo humano staged.\n')]));
222
+ git(root, 'add', '-f', '--', pointer);
223
+ const index = git(root, 'ls-files', '--stage', '-z');
224
+ put(root, pointer, generated);
225
+ rmSync(dirname(file(root, original)), { recursive: true });
226
+ const state = readFileSync(file(root, '.pbai4devs/state/derived.json'));
227
+ withoutGit(t, () => assert.throws(() => run(root, options), /consultar o Git.*preservar/i));
228
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
229
+ assert.deepEqual(readFileSync(file(root, '.pbai4devs/state/derived.json')), state);
230
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
231
+ });
232
+ }
233
+
234
+ test('projeto realmente sem Git pode limpar apontamento próprio sem executável Git', t => {
235
+ const root = fixture(t, false), human = '.claude/skills/criar-skill/equipe-vazia';
236
+ mkdirSync(file(root, human));
237
+ rmSync(dirname(file(root, original)), { recursive: true });
238
+ withoutGit(t, () => assert.equal(run(root, { force: true }).exitCode, 0));
239
+ assert.equal(existsSync(file(root, pointer)), false);
240
+ assert.ok(existsSync(file(root, human)));
241
+ });
242
+
243
+ test('preserva diretório preexistente sem prova de propriedade após remover único apontamento', t => {
244
+ const root = fixture(t), directory = dirname(file(root, pointer));
245
+ rmSync(dirname(file(root, original)), { recursive: true });
246
+ assert.equal(run(root, { force: true }).exitCode, 0);
247
+ assert.equal(existsSync(file(root, pointer)), false);
248
+ assert.ok(existsSync(directory));
249
+ });
250
+
251
+ test('alteração concorrente no índice bloqueia limpeza antes de qualquer exclusão', t => {
252
+ const root = fixture(t), generated = readFileSync(file(root, pointer));
253
+ rmSync(dirname(file(root, original)), { recursive: true });
254
+ let staged;
255
+ assert.throws(() => synchronize({ root, localIgnores: false, generateEntrypoints: false, force: true,
256
+ output() {
257
+ if (staged !== undefined) return;
258
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nStaged pela equipe.\n')]));
259
+ git(root, 'add', '-f', '--', pointer);
260
+ staged = git(root, 'ls-files', '--stage', '-z');
261
+ put(root, pointer, generated);
262
+ },
263
+ }), /indice git mudou/i);
264
+ assert.notEqual(staged, undefined);
265
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
266
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), staged);
267
+ });
268
+
269
+ test('índice divergente é protegido quando o projeto fica em subpasta do repositório Git', t => {
270
+ const outer = mkdtempSync(join(tmpdir(), 'sync-parent-git-'));
271
+ t.after(() => rmSync(outer, { recursive: true, force: true }));
272
+ git(outer, 'init', '--quiet');
273
+ const root = join(outer, 'projeto'); mkdirSync(root);
274
+ populate(root);
275
+ const generated = readFileSync(file(root, pointer));
276
+ put(root, pointer, Buffer.concat([generated, Buffer.from('\nStaged pela equipe.\n')]));
277
+ git(root, 'add', '-f', '--', pointer);
278
+ const index = git(outer, 'ls-files', '--stage', '-z');
279
+ put(root, pointer, generated);
280
+ rmSync(dirname(file(root, original)), { recursive: true });
281
+ assert.throws(() => run(root, { force: true }), /indice/i);
282
+ assert.deepEqual(readFileSync(file(root, pointer)), generated);
283
+ assert.equal(git(outer, 'ls-files', '--stage', '-z'), index);
284
+ });
285
+
286
+ test('conteúdo humano na pasta e apontamento gerenciado editado sobrevivem à limpeza forçada', t => {
287
+ const root = fixture(t), human = '.claude/skills/criar-skill/notas-da-equipe.md';
288
+ const notes = '# Referência própria\n', edited = Buffer.concat([readFileSync(file(root, pointer)), Buffer.from('\nNota própria.\n')]);
289
+ put(root, human, notes);
290
+ put(root, pointer, edited);
291
+ rmSync(dirname(file(root, original)), { recursive: true });
292
+ run(root, { force: true });
293
+ assert.equal(readFileSync(file(root, human), 'utf8'), notes);
294
+ assert.deepEqual(readFileSync(file(root, pointer)), edited);
295
+ });
296
+
297
+ test('colisão com arquivo humano ancestral não é removida por --force', t => {
298
+ const root = fixture(t), ancestor = '.claude/skills/equipe-custom';
299
+ const human = 'Arquivo da equipe; não substituir.\n';
300
+ put(root, ancestor, human);
301
+ put(root, '.pbai4devs/skills/projeto/equipe-custom/SKILL.md', '---\nname: equipe-custom\ndescription: Exemplo.\n---\n\n# Equipe\n');
302
+ const index = git(root, 'ls-files', '--stage', '-z');
303
+ try { run(root, { force: true }); } catch (error) { assert.match(error.message, /conflito|gerenciado|arquivo|diretor/i); }
304
+ assert.equal(readFileSync(file(root, ancestor), 'utf8'), human);
305
+ assert.equal(git(root, 'ls-files', '--stage', '-z'), index);
306
+ });
@@ -13,6 +13,7 @@ import './LOCAL_IGNORE.mjs';
13
13
  import './HUMAN_OWNERSHIP.mjs';
14
14
  import './COEXISTENCE.mjs';
15
15
  import './MANAGED_CONTENT.mjs';
16
+ import './PRESERVATION.mjs';
16
17
  import { validateCatalog as validateExternalCatalog } from '../../gerenciar-skills-externas/SCRIPT.mjs';
17
18
  import { stateDirectory } from '../../lib/state-path.mjs';
18
19
 
@@ -140,7 +141,12 @@ test('sincroniza somente principais, preserva metadados e usa referências UTF-8
140
141
  assert.match(read(root, '.kimi-code/agents/revisor.md'), /tools: \[Read, Grep, Glob\]/);
141
142
  assert.ok(existsSync(join(root, '.claude/skills/projeto/demo/SKILL.md')));
142
143
  for (const client of ['.claude', '.opencode', '.github', '.codex', '.cursor', '.kimi-code', '.vscode', '.agents', '.agent', '.gemini']) {
143
- for (const kind of ['templates', 'scripts', 'tools', 'external-skills', 'state', 'estado', 'tests', 'docs', 'adapters', 'extra', 'README.md', 'INSTRUCTIONS.md']) assert.equal(existsSync(join(root, client, kind)), false, `${client}/${kind}`);
144
+ for (const kind of ['templates', 'scripts', 'tools', 'external-skills', 'state', 'estado', 'tests', 'docs', 'adapters', 'extra', 'README.md', 'INSTRUCTIONS.md']) {
145
+ if (client === '.claude' && ['templates', 'scripts', 'tools', 'external-skills', 'state', 'tests', 'docs', 'adapters'].includes(kind)) {
146
+ assert.equal(existsSync(join(root, client, kind, 'demo/REFERENCE.md')), false);
147
+ assert.ok(existsSync(join(root, client, kind)), 'diretório sem prova de propriedade permanece');
148
+ } else assert.equal(existsSync(join(root, client, kind)), false, `${client}/${kind}`);
149
+ }
144
150
  }
145
151
  assert.deepEqual(JSON.parse(read(root, '.pbai4devs/agents.json')).entries, [{ path: '.pbai4devs/agents/revisor/AGENT.md' }]);
146
152
  assert.ok(JSON.parse(read(root, '.pbai4devs/skills.json')).entries.some(entry => entry.path === '.pbai4devs/skills/grupo com espaço/outro'));
@@ -441,7 +447,8 @@ test('atualiza fontes, adiciona clientes, remove recursos e migra agrupado para
441
447
  rmSync(join(root, '.pbai4devs/rules/example/RULE.md'));
442
448
  write(root, '.pbai4devs/espelhos.conf', read(root, '.pbai4devs/espelhos.conf').replace('agente|.claude\n', 'agente|.claude|plano\n'));
443
449
  run(root);
444
- assert.equal(existsSync(join(root, '.claude/skills/projeto')), false);
450
+ assert.equal(existsSync(join(root, '.claude/skills/projeto/demo/SKILL.md')), false);
451
+ assert.ok(existsSync(join(root, '.claude/skills/projeto')));
445
452
  assert.ok(existsSync(join(root, '.claude/skills/demo/SKILL.md')));
446
453
  assert.equal(existsSync(join(root, '.codex/rules/example.md')), false);
447
454
  assert.deepEqual(JSON.parse(read(root, '.pbai4devs/rules.json')).entries, []);
@@ -882,7 +889,8 @@ test('manifestos Antigravity acompanham skills, regras e agentes agrupados', t =
882
889
  rmSync(join(root, '.pbai4devs/skills/projeto/demo'), { recursive: true });
883
890
  run(root, { generateEntrypoints: true });
884
891
  assert.deepEqual(JSON.parse(read(root, '.agents/skills.json')).entries, [{ path: '.agents/skills/projeto/new-skill' }]);
885
- assert.equal(existsSync(join(root, '.agents/skills/projeto/demo')), false);
892
+ assert.equal(existsSync(join(root, '.agents/skills/projeto/demo/SKILL.md')), false);
893
+ assert.ok(existsSync(join(root, '.agents/skills/projeto/demo')));
886
894
  assertCheckReadonly(root, { generateEntrypoints: true }, 0);
887
895
  });
888
896