@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.
Files changed (58) hide show
  1. package/.pbai4devs/COMPATIBILIDADE.md +4 -2
  2. package/.pbai4devs/INSTRUCTIONS.md +4 -0
  3. package/.pbai4devs/commands/sincronizar/COMMAND.md +4 -3
  4. package/.pbai4devs/rules/documentacao/RULE.md +4 -0
  5. package/.pbai4devs/rules/qualidade/RULE.md +4 -2
  6. package/.pbai4devs/scripts/README.md +3 -3
  7. package/.pbai4devs/scripts/inspecionar-projeto/README.md +31 -0
  8. package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +142 -1
  9. package/.pbai4devs/scripts/inspecionar-projeto/tests/TEST.mjs +168 -1
  10. package/.pbai4devs/scripts/lib/gitignore.mjs +84 -15
  11. package/.pbai4devs/scripts/lib/native-paths.mjs +35 -0
  12. package/.pbai4devs/scripts/preparar-ferramentas/SCRIPT.mjs +41 -20
  13. package/.pbai4devs/scripts/preparar-ferramentas/tests/TEST.mjs +81 -0
  14. package/.pbai4devs/scripts/sincronizar-agentes/README.md +3 -0
  15. package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +193 -49
  16. package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +190 -0
  17. package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +151 -0
  18. package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +145 -1
  19. package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +30 -29
  20. package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +56 -8
  21. package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +92 -1
  22. package/.pbai4devs/skills/produto/criar-design-doc/SKILL.md +2 -0
  23. package/.pbai4devs/skills/produto/criar-prd/SKILL.md +2 -0
  24. package/.pbai4devs/skills/produto/criar-ux-ui/SKILL.md +2 -0
  25. package/.pbai4devs/skills/projeto/atualizar-documentacao/SKILL.md +18 -4
  26. package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +2 -0
  27. package/.pbai4devs/skills/projeto/criar-recurso/SKILL.md +1 -0
  28. package/.pbai4devs/skills/projeto/sincronizar-agentes/SKILL.md +3 -2
  29. package/.pbai4devs/skills/specs/criar-spec/SKILL.md +1 -0
  30. package/.pbai4devs/skills/specs/gerenciar-roadmap/SKILL.md +2 -1
  31. package/.pbai4devs/templates/README.md +4 -1
  32. package/.pbai4devs/templates/configuracao/TEMPLATE.md +6 -0
  33. package/CHANGELOG.md +19 -0
  34. package/README.en.md +3 -2
  35. package/README.es.md +3 -2
  36. package/README.md +3 -2
  37. package/docs/README.md +1 -0
  38. package/docs/guides/README.md +1 -0
  39. package/docs/guides/cli.md +11 -0
  40. package/docs/guides/documentation.md +63 -0
  41. package/docs/guides/installation.md +18 -5
  42. package/docs/guides/resources.md +6 -5
  43. package/docs/guides/tools-and-testing.md +2 -0
  44. package/docs/guides/troubleshooting.md +6 -6
  45. package/docs/guides/workflow.md +2 -0
  46. package/docs/maintainers/packages.md +1 -1
  47. package/docs/product/design.md +12 -0
  48. package/docs/product/prd.md +4 -1
  49. package/docs/product/roadmap.md +4 -0
  50. package/docs/specs/README.md +2 -0
  51. package/docs/specs/v0.0.8/README.md +10 -0
  52. package/docs/specs/v0.0.8/SPEC.md +62 -0
  53. package/docs/specs/v0.0.9/README.md +9 -0
  54. package/docs/specs/v0.0.9/SPEC.md +68 -0
  55. package/package.json +3 -2
  56. package/src/cli.mjs +13 -4
  57. package/src/i18n.mjs +3 -0
  58. package/src/install.mjs +145 -38
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // Planeja em memória; somente os caminhos gerenciados pelo manifesto são alterados.
3
- import { cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, posix, resolve } from 'node:path';
5
5
  import { randomUUID, createHash } from 'node:crypto';
6
6
  import { requireNode, projectRoot, isMain } from '../lib/runtime.mjs';
7
7
  import { buildCatalog } from '../lib/catalog.mjs';
8
8
  import { resourcePath } from '../lib/resource-path.mjs';
9
9
  import { adapterManifest, resolveAdapters } from '../lib/adapters.mjs';
10
- import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
10
+ import { nativePathBoundary } from '../lib/native-paths.mjs';
11
+ import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
11
12
 
12
13
  const marker = '<!-- gerado: sincronizar-agentes -->';
13
14
  const tomlMarker = '# gerado: sincronizar-agentes';
@@ -90,6 +91,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
90
91
  return current;
91
92
  }
92
93
  function scan(value, { markdownOnly = false, prune = false } = {}) {
94
+ if (preserveBoundary(value)) return [];
93
95
  const path = safe(value);
94
96
  const item = stat(path);
95
97
  if (!item) return [];
@@ -99,6 +101,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
99
101
  function visit(folder) {
100
102
  for (const name of readdirSync(safe(folder)).sort(compare)) {
101
103
  const child = `${folder}/${name}`;
104
+ if (preserveBoundary(child)) continue;
102
105
  const info = stat(safe(child));
103
106
  if (prune && isGeneratedOutput(child, info.isDirectory())) continue;
104
107
  if (info.isDirectory()) {
@@ -113,9 +116,13 @@ export function synchronize({ root = projectRoot, check = false, force = false,
113
116
  }
114
117
  const referenceFile = value => {
115
118
  const info = stat(safe(value));
116
- return Boolean(info?.isFile() && info.size <= 1024 * 1024 && isReference(read(absolute(value))));
119
+ if (!info?.isFile() || info.size > 1024 * 1024) return false;
120
+ const bytes = readFileSync(absolute(value)), text = normalize(bytes.toString('utf8'));
121
+ const recorded = installed?.files?.[value] === createHash('sha256').update(bytes).digest('hex');
122
+ const marked = text.split('\n').includes(marker) || text.startsWith(tomlMarker + '\n');
123
+ return isReference(text) && (marked || recorded);
117
124
  };
118
- const onlyReferences = value => scan(value).every(file => /\.(md|mdc|toml)$/.test(file) && referenceFile(file));
125
+ const onlyReferences = value => { const files = scan(value); return files.length > 0 && ![...preserved.keys()].some(path => path === value || path.startsWith(`${value}/`)) && files.every(file => /\.(md|mdc|toml)$/.test(file) && referenceFile(file)); };
119
126
  const protectedPath = value => /^(workflows(?:\/|$)|settings\.json$|extensions\.json$)/.test(value.slice(value.indexOf('/') + 1).toLowerCase());
120
127
  const agents = new Map();
121
128
  const formats = new Map();
@@ -129,10 +136,41 @@ export function synchronize({ root = projectRoot, check = false, force = false,
129
136
  const installed = stat(safe(stateFile)) ? JSON.parse(read(safe(stateFile))) : null;
130
137
  const configText = configExists ? read(safe(config)) : adapterManifest(root, { clients: installed?.clients });
131
138
  const rows = configText.split('\n');
139
+ const entryRegistry = '.pbai4devs/estado/derived.json';
140
+ const previousEntries = stat(safe(entryRegistry)) ? JSON.parse(read(safe(entryRegistry))) : { schemaVersion: 1, entries: {} };
141
+ if (previousEntries?.schemaVersion !== 1 || !previousEntries.entries || typeof previousEntries.entries !== 'object' || Array.isArray(previousEntries.entries) || Object.entries(previousEntries.entries).some(([name, hash]) => !['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'].includes(name) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de entradas derivadas invalido.');
142
+ const previousOutputs = previousEntries.outputs ?? {};
143
+ if (!previousOutputs || typeof previousOutputs !== 'object' || Array.isArray(previousOutputs) || Object.entries(previousOutputs).some(([path, hash]) => !/^\.[A-Za-z0-9][A-Za-z0-9._-]*\/(skills|rules|agents)\.json$/.test(path) || reserved.has(path.split('/')[0].toLowerCase()) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de saidas derivadas invalido.');
144
+ const ownedPaths = new Set([...Object.keys(installed?.files || {}), ...Object.keys(installed?.blocks || {}), ...Object.keys(previousEntries.entries), ...Object.keys(previousOutputs)]);
145
+ const nativeRoots = new Set(rows.map(row => row.split('|')).filter(([type]) => ['agente', 'gitignore'].includes(type)).map(([, target]) => target));
146
+ const preserved = new Map();
147
+ const remember = (path, reason = 'user-owned') => { preserved.set(path, { path, reason }); return true; };
148
+ function preserveBoundary(value) {
149
+ if (!nativeRoots.has(value.split('/')[0]) && !['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', 'README.md'].includes(value)) return false;
150
+ const boundary = nativePathBoundary(root, value, ownedPaths, { preserveRootAliases: true });
151
+ return boundary ? remember(boundary.path, boundary.reason) : false;
152
+ }
153
+ function claimed(value) {
154
+ if (ownedPaths.has(value)) return true;
155
+ const info = stat(safe(value));
156
+ if (!info?.isFile() || info.size > 1024 * 1024) return false;
157
+ const text = read(safe(value));
158
+ return text.split('\n').includes(marker) || text.startsWith(tomlMarker + '\n');
159
+ }
160
+ function preserveUnknown(value) {
161
+ if (preserveBoundary(value)) return true;
162
+ const info = stat(safe(value));
163
+ return info && !referenceFile(value) && !claimed(value) ? remember(value) : false;
164
+ }
165
+ function preserveNativeType(value, info, type) {
166
+ if (!info || info[type]()) return false;
167
+ if ([...ownedPaths].some(path => path === value || path.startsWith(`${value}/`))) fail(`Tipo alterado em caminho gerenciado: ${value}`);
168
+ return remember(value, info.isDirectory() ? 'existing-directory' : info.isFile() ? 'existing-file' : 'existing-other');
169
+ }
132
170
  function ignoreRoot(target) {
133
171
  if (!/^\.[^/]+$/.test(target) || reserved.has(target.toLowerCase())) fail(`Pasta de agente invalida: ${target}`);
134
- const info = stat(absolute(target));
135
- if (info && !info.isDirectory()) fail(`Pasta de agente nao e diretorio: ${target}`);
172
+ const info = preserveBoundary(target) ? null : stat(safe(target));
173
+ preserveNativeType(target, info, 'isDirectory');
136
174
  for (const known of ignoreRoots) if (known !== target && known.toLowerCase() === target.toLowerCase()) fail('Raizes de agentes diferem somente por maiusculas/minusculas.');
137
175
  ignoreRoots.add(target);
138
176
  }
@@ -141,7 +179,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
141
179
  const fields = row.split('|');
142
180
  if (fields.length < 2 || fields.length > 4) fail('Campos invalidos no manifesto.');
143
181
  const [type, target, source = '', metadata = ''] = fields;
144
- safe(target);
182
+ if (!preserveBoundary(target)) safe(target);
145
183
  if (type === 'agente') {
146
184
  if (fields.length > 3) fail(`Pasta de agente invalida: ${target}`);
147
185
  ignoreRoot(target);
@@ -187,6 +225,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
187
225
  const entries = new Map();
188
226
  const folded = new Map();
189
227
  function add(target, source, metadata = '') {
228
+ if (preserveUnknown(target)) return;
190
229
  safe(target);
191
230
  const alias = folded.get(target.toLowerCase());
192
231
  if (alias && alias !== target) fail(`Destinos diferem somente por maiusculas/minusculas: ${target}`);
@@ -236,13 +275,15 @@ export function synchronize({ root = projectRoot, check = false, force = false,
236
275
  if (custom) backup.add(value);
237
276
  }
238
277
  function cleanScope(value) {
278
+ if (preserveBoundary(value)) return;
239
279
  if (!stat(safe(value))) return;
240
- if (force && value.split('/')[1] === 'external-skills') planRemove(value, true);
241
- else if (onlyReferences(value)) planRemove(value);
242
- else if (force) planRemove(value, true);
243
- else conflicts.add(value);
280
+ if (stat(safe(value)).isDirectory() && !scan(value).length) return;
281
+ if (onlyReferences(value)) planRemove(value);
282
+ else if (claimed(value)) { if (force) planRemove(value, true); else conflicts.add(value); }
283
+ else remember(value);
244
284
  }
245
285
  const registries = new Map();
286
+ const discoveryOutputs = {};
246
287
  for (const [target, content] of built.outputs) {
247
288
  const info = stat(safe(target));
248
289
  if (info && !info.isFile()) fail(`Registro nao e arquivo: ${target}`);
@@ -254,22 +295,22 @@ export function synchronize({ root = projectRoot, check = false, force = false,
254
295
  for (const agent of discoveries) {
255
296
  for (const kind of ['skills', 'rules', 'agents']) {
256
297
  const manifest = `${agent}/${kind}.json`;
298
+ if (preserveBoundary(manifest)) continue;
257
299
  const paths = [...entries].filter(([target, value]) => target.startsWith(`${agent}/`) && sources.get(value.source) === kind)
258
300
  .map(([target]) => kind === 'skills' ? posix.dirname(target) : target).sort(compare);
259
301
  const content = JSON.stringify({ entries: paths.map(path => ({ path })) }, null, 2) + '\n';
260
302
  const info = stat(safe(manifest));
303
+ if (preserveNativeType(manifest, info, 'isFile')) continue;
261
304
  if (info && !info.isFile()) fail(`Registro nao e arquivo: ${manifest}`);
305
+ if (info && !ownedPaths.has(manifest)) { remember(manifest); continue; }
306
+ const current = info ? readFileSync(absolute(manifest)) : null;
307
+ const priorHashes = [previousOutputs[manifest], installed?.files?.[manifest]];
308
+ const unchanged = current === null || priorHashes.includes(createHash('sha256').update(current).digest('hex'));
309
+ if (!unchanged && !force) conflicts.add(manifest);
310
+ if (!unchanged && force) backup.add(manifest);
311
+ const nextHash = createHash('sha256').update(content).digest('hex');
312
+ if (installed?.files?.[manifest] !== nextHash) discoveryOutputs[manifest] = nextHash;
262
313
  if (!info || readFileSync(absolute(manifest), 'utf8') !== content) {
263
- let managedManifest = false;
264
- if (info) {
265
- try {
266
- const previous = JSON.parse(read(absolute(manifest)));
267
- managedManifest = Object.keys(previous).length === 1 && Array.isArray(previous.entries) && previous.entries.every(entry =>
268
- entry && Object.keys(entry).length === 1 && typeof entry.path === 'string' && entry.path.startsWith(`${agent}/${kind}/`) && !entry.path.split('/').some(part => !part || part === '..' || part === '.')
269
- );
270
- } catch { /* Um manifesto customizado ou corrompido requer --force e backup. */ }
271
- }
272
- if (info && !managedManifest && !force) conflicts.add(manifest);
273
314
  if (info && force) backup.add(manifest);
274
315
  registries.set(manifest, content);
275
316
  }
@@ -284,11 +325,14 @@ export function synchronize({ root = projectRoot, check = false, force = false,
284
325
  ...sorted(ignoreRoots).map(folder => [`${folder}/.gitignore`, '.pbai4devs/templates/gitignore/agents.ignore']),
285
326
  ]);
286
327
  for (const [target, template] of ignoreTemplates) {
328
+ if (preserveBoundary(target)) continue;
287
329
  if (!stat(safe(template))?.isFile()) fail(`Template .gitignore ausente ou invalido: ${template}`);
288
330
  const info = stat(safe(target));
331
+ if (target !== '.pbai4devs/.gitignore' && preserveNativeType(target, info, 'isFile')) continue;
289
332
  if (info && !info.isFile()) fail(`.gitignore nao e arquivo: ${target}`);
290
333
  let merged;
291
334
  const current = info ? readFileSync(absolute(target), 'utf8') : '';
335
+ if (target !== '.pbai4devs/.gitignore' && info && !ownedPaths.has(target) && !current.split(/\r?\n/).some(line => [ignoreBegin, ignoreEnd].includes(line))) { remember(target); continue; }
292
336
  try {
293
337
  let patterns = readFileSync(absolute(template), 'utf8');
294
338
  if (target !== '.pbai4devs/.gitignore') patterns = renderClientIgnore(patterns, ignoreClients[posix.dirname(target)]);
@@ -344,18 +388,20 @@ export function synchronize({ root = projectRoot, check = false, force = false,
344
388
  for (const kind of [...auxiliary, 'README.md', 'INSTRUCTIONS.md']) cleanScope(`${agent}/${kind}`);
345
389
  for (const kind of Object.keys(principals)) {
346
390
  const scope = `${agent}/${kind}`;
391
+ if (preserveBoundary(scope)) continue;
347
392
  const info = stat(safe(scope));
348
393
  if (!info) continue;
349
394
  const files = scan(scope);
350
- if (!info.isDirectory()) { if (force) planRemove(scope, true); else conflicts.add(scope); continue; }
395
+ if (!info.isDirectory()) { if (claimed(scope)) { if (force) planRemove(scope, true); else conflicts.add(scope); } else remember(scope); continue; }
351
396
  for (const file of files) if (!expected.has(file)) {
352
397
  if (referenceFile(file)) planRemove(file);
353
- else if (force) planRemove(file, true);
354
- else conflicts.add(file);
398
+ else if (claimed(file)) { if (force) planRemove(file, true); else conflicts.add(file); }
399
+ else remember(file);
355
400
  }
356
401
  function directories(value) {
357
402
  for (const name of readdirSync(safe(value))) {
358
403
  const child = `${value}/${name}`;
404
+ if (preserveBoundary(child)) continue;
359
405
  if (!stat(safe(child))?.isDirectory()) continue;
360
406
  if (![...expected.keys()].some(target => target.startsWith(child + '/'))) cleanScope(child);
361
407
  directories(child);
@@ -372,18 +418,19 @@ export function synchronize({ root = projectRoot, check = false, force = false,
372
418
  }
373
419
  for (const { target } of missing) cleanScope(target);
374
420
  const rootEntries = new Map();
375
- const entryRegistry = '.pbai4devs/estado/derived.json';
376
421
  if (generateEntrypoints) {
377
- const previous = stat(safe(entryRegistry)) ? JSON.parse(read(safe(entryRegistry))) : { schemaVersion: 1, entries: {} };
378
- if (previous?.schemaVersion !== 1 || !previous.entries || typeof previous.entries !== 'object' || Array.isArray(previous.entries) || Object.entries(previous.entries).some(([name, hash]) => !['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'].includes(name) || !/^[a-f0-9]{64}$/.test(hash))) fail('Registro de entradas derivadas invalido.');
422
+ const previous = previousEntries;
379
423
  const next = { schemaVersion: 1, entries: {} };
424
+ if (Object.keys(discoveryOutputs).length) next.outputs = discoveryOutputs;
380
425
  for (const name of resolveAdapters(installed?.clients).entrypoints) {
381
426
  const template = `.pbai4devs/templates/entrypoints/${name}`;
382
427
  if (!stat(safe(template))?.isFile()) continue;
428
+ if (preserveBoundary(name)) continue;
383
429
  const info = stat(safe(name));
430
+ if (preserveNativeType(name, info, 'isFile')) continue;
384
431
  const current = info?.isFile() ? readFileSync(absolute(name)) : null;
385
432
  const previousHash = previous.entries[name] ?? installed?.files?.[name];
386
- if (info && (!current || createHash('sha256').update(current).digest('hex') !== previousHash)) continue;
433
+ if (info && (!current || createHash('sha256').update(current).digest('hex') !== previousHash)) { remember(name); continue; }
387
434
  if (current && createHash('sha256').update(current).digest('hex') === installed?.files?.[name]) {
388
435
  rootEntries.set(name, current.toString('utf8'));
389
436
  continue;
@@ -394,32 +441,90 @@ export function synchronize({ root = projectRoot, check = false, force = false,
394
441
  rootEntries.set(name, content);
395
442
  }
396
443
  const content = JSON.stringify(next, null, 2) + '\n';
397
- if ((Object.keys(next.entries).length || stat(safe(entryRegistry))) && (!stat(safe(entryRegistry)) || readFileSync(absolute(entryRegistry), 'utf8') !== content)) registries.set(entryRegistry, content);
444
+ if ((Object.keys(next.entries).length || Object.keys(discoveryOutputs).length || stat(safe(entryRegistry))) && (!stat(safe(entryRegistry)) || readFileSync(absolute(entryRegistry), 'utf8') !== content)) registries.set(entryRegistry, content);
398
445
  }
399
446
  const ownedOutputs = sorted([...targets, ...built.outputs.keys(), '.pbai4devs/espelhos.conf', ...rootEntries.keys(), ...(generateEntrypoints && (stat(safe(entryRegistry)) || registries.has(entryRegistry)) ? [entryRegistry] : []),
400
447
  ...[...discoveries].flatMap(agent => ['skills', 'rules', 'agents'].map(kind => `${agent}/${kind}.json`)),
401
448
  ...[...ignoreTemplates.keys()].filter(target => {
402
449
  if (target === '.pbai4devs/.gitignore') return false;
450
+ if (preserved.has(target) || preserveBoundary(target)) return false;
403
451
  const content = ignoreFiles.get(target) ?? readFileSync(absolute(target), 'utf8');
404
452
  const start = content.indexOf(ignoreBegin), finish = content.indexOf(ignoreEnd);
405
453
  return start >= 0 && finish > start && !(content.slice(0, start) + content.slice(finish + ignoreEnd.length)).trim();
406
454
  }),
407
- ]);
455
+ ]).filter(path => ![...preserved.keys()].some(value => path === value || path.startsWith(`${value}/`)));
408
456
  const localPlan = localIgnores ? planLocalIgnore(root, { outputs: ownedOutputs }) : null;
409
457
  const localStatus = localPlan ? checkLocalIgnore(localPlan) : { changed: 0, missing: [], trackedOutputs: [] };
410
458
  const removals = topPaths(remove);
411
459
  const backups = topPaths(backup);
412
460
  const rootEntryChanges = [...rootEntries].filter(([name, content]) => !stat(absolute(name)) || readFileSync(absolute(name), 'utf8') !== content);
461
+ const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
462
+ const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
463
+ const configChanged = !configExists || configText !== finalConfig;
464
+ function capture(value) {
465
+ const file = safe(value), info = stat(file);
466
+ if (!info) return null;
467
+ if (info.isFile()) return { mode: info.mode & 0o777, data: readFileSync(file) };
468
+ if (!info.isDirectory()) fail(`Tipo de arquivo nao suportado: ${value}`);
469
+ return { mode: info.mode & 0o777, children: Object.fromEntries(readdirSync(file).sort(compare).map(name => [name, capture(`${value}/${name}`)])) };
470
+ }
471
+ function fingerprint(value) {
472
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
473
+ }
474
+ const transactionRoots = topPaths([...removals, ...changed, ...registries.keys(), ...ignoreFiles.keys(), ...rootEntryChanges.map(([name]) => name), ...(configChanged ? [config] : [])]);
475
+ const originals = new Map(transactionRoots.map(value => [value, capture(value)]));
476
+ const currentHashes = new Map([...originals].map(([value, content]) => [value, fingerprint(content)]));
477
+ const originalDirectories = new Set();
478
+ for (const value of transactionRoots) {
479
+ let parent = posix.dirname(value);
480
+ while (parent !== '.') { if (stat(safe(parent))?.isDirectory()) originalDirectories.add(parent); parent = posix.dirname(parent); }
481
+ }
482
+ const touched = new Set(), createdDirectories = new Set(), restoredDirectories = new Set();
483
+ const prunedDirectories = new Map();
484
+ let restoring = false;
413
485
  const summary = { changed: changed.length + rootEntryChanges.length, stale: removals.length, conflicts: conflicts.size, missing: missing.length, legacy: legacy.length, registries: registries.size, gitignores: ignoreFiles.size, manifests: configExists ? 0 : 1, localIgnores: localStatus.changed + localStatus.missing.length + localStatus.trackedOutputs.length };
414
486
  const dirty = Object.values(summary).some(Boolean);
415
487
  output(`${summary.changed} desatualizadas; ${summary.stale} obsoletas; ${summary.conflicts} conflitos; ${summary.missing} origens nativas ausentes; ${summary.legacy} documentos antigos; ${summary.registries} indices derivados; ${summary.gitignores} gitignores; ${summary.localIgnores} exclusoes locais.`);
416
- if (check) return { ...summary, ownedOutputs, exitCode: dirty ? 1 : 0 };
488
+ const preservedPaths = sorted(preserved.keys()).map(path => preserved.get(path));
489
+ if (check) return { ...summary, ownedOutputs, preserved: preservedPaths, exitCode: dirty ? 1 : 0 };
417
490
  if (conflicts.size) fail(`Use --force para reconstruir somente os caminhos gerenciados: ${sorted(conflicts).join(', ')}`);
491
+ if (localStatus.missing.length) fail(`Regras humanas reabrem derivados locais: ${localStatus.missing.join(', ')}`);
492
+ if (localPlan) assertLocalIgnoreUnchanged(localPlan);
493
+ function ensureDirectory(value) {
494
+ let parent = posix.dirname(value);
495
+ const missing = [];
496
+ while (parent !== '.' && !stat(safe(parent))) { missing.push(parent); parent = posix.dirname(parent); }
497
+ for (const directory of missing.reverse()) {
498
+ mkdirSync(safe(directory)); createdDirectories.add(directory);
499
+ if (restoring) restoredDirectories.add(directory);
500
+ }
501
+ }
502
+ function mutate(value, action, text) {
503
+ const boundary = transactionRoots.find(root => value === root || value.startsWith(root + '/'));
504
+ if (!boundary || fingerprint(capture(boundary)) !== currentHashes.get(boundary)) fail(`Arquivo mudou apos planejar: ${value}`);
505
+ const beforeHash = currentHashes.get(boundary);
506
+ touched.add(boundary);
507
+ let failure;
508
+ try { action(); } catch (error) { failure = error; }
509
+ let afterHash;
510
+ try { afterHash = fingerprint(capture(boundary)); }
511
+ catch (error) { currentHashes.set(boundary, 'conteudo-concorrente'); throw failure || error; }
512
+ const matches = text === undefined || (text === null ? !stat(safe(value)) : stat(safe(value))?.isFile() && readFileSync(absolute(value)).equals(Buffer.from(text)));
513
+ if (!matches && afterHash !== beforeHash) {
514
+ currentHashes.set(boundary, 'conteudo-concorrente');
515
+ if (failure) throw failure;
516
+ fail(`Mudanca concorrente preservada: ${value}`);
517
+ }
518
+ currentHashes.set(boundary, afterHash);
519
+ if (failure) throw failure;
520
+ if (!matches) fail(`Arquivo nao corresponde a gravacao planejada: ${value}`);
521
+ }
418
522
  function atomic(value, text) {
419
523
  const target = safe(value);
420
- mkdirSync(dirname(target), { recursive: true });
524
+ const mode = stat(target)?.isFile() ? stat(target).mode & 0o777 : undefined;
525
+ ensureDirectory(value);
421
526
  const temporary = `${target}.sync.${randomUUID()}`;
422
- try { writeFileSync(temporary, text, { flag: 'wx' }); renameSync(temporary, target); }
527
+ try { writeFileSync(temporary, text, { flag: 'wx' }); if (mode !== undefined) chmodSync(temporary, mode); renameSync(temporary, target); }
423
528
  finally { rmSync(temporary, { force: true }); }
424
529
  }
425
530
  let backupRoot;
@@ -433,22 +538,61 @@ export function synchronize({ root = projectRoot, check = false, force = false,
433
538
  }
434
539
  output(`Backup local: ${posix.join('.agent-tests', backupRoot.slice(backupRoot.lastIndexOf('/') + 1).split('\\').at(-1))}`);
435
540
  }
436
- for (const value of removals) rmSync(safe(value), { recursive: true, force: true });
437
- for (const value of changed) atomic(value, expected.get(value));
438
- for (const [value, text] of registries) atomic(value, text);
439
- for (const [value, text] of ignoreFiles) atomic(value, text);
440
- for (const [value, text] of rootEntryChanges) atomic(value, text);
441
- if (localPlan) applyLocalIgnore(localPlan);
442
- function pruneEmpty(value) {
443
- if (!stat(safe(value))?.isDirectory()) return;
444
- for (const name of readdirSync(absolute(value))) if (stat(safe(`${value}/${name}`))?.isDirectory()) pruneEmpty(`${value}/${name}`);
445
- if (!readdirSync(absolute(value)).length) rmdirSync(absolute(value));
446
- }
447
- for (const agent of agents.keys()) for (const kind of [...Object.keys(principals), 'instructions', 'prompts']) pruneEmpty(`${agent}/${kind}`);
448
- const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
449
- const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
450
- if (!configExists || configText !== finalConfig) atomic(config, finalConfig);
451
- return { ...summary, ownedOutputs, exitCode: 0, ...(backupRoot ? { backupRoot } : {}) };
541
+ function restore(value, content) {
542
+ rmSync(safe(value), { recursive: true, force: true });
543
+ if (!content) return;
544
+ ensureDirectory(value);
545
+ if (content.data) writeFileSync(safe(value), content.data);
546
+ else {
547
+ mkdirSync(safe(value));
548
+ for (const [name, child] of Object.entries(content.children)) restore(`${value}/${name}`, child);
549
+ }
550
+ chmodSync(safe(value), content.mode);
551
+ }
552
+ try {
553
+ if (localPlan) assertLocalIgnoreUnchanged(localPlan);
554
+ for (const value of removals) mutate(value, () => rmSync(safe(value), { recursive: true, force: true }), null);
555
+ for (const value of changed) mutate(value, () => atomic(value, expected.get(value)), expected.get(value));
556
+ for (const [value, text] of registries) mutate(value, () => atomic(value, text), text);
557
+ for (const [value, text] of ignoreFiles) mutate(value, () => atomic(value, text), text);
558
+ for (const [value, text] of rootEntryChanges) mutate(value, () => atomic(value, text), text);
559
+ if (configChanged) mutate(config, () => atomic(config, finalConfig), finalConfig);
560
+ // Só os ancestrais de remoções conhecidas são candidatos à limpeza.
561
+ for (const value of removals) {
562
+ let parent = posix.dirname(value);
563
+ const boundary = value.split('/').slice(0, 2).join('/');
564
+ while (parent.startsWith(boundary + '/') || parent === boundary) {
565
+ const info = stat(safe(parent));
566
+ if (!info?.isDirectory() || readdirSync(absolute(parent)).length) break;
567
+ rmdirSync(safe(parent)); prunedDirectories.set(parent, info.mode & 0o777); parent = posix.dirname(parent);
568
+ }
569
+ }
570
+ if (localPlan) applyLocalIgnore(localPlan);
571
+ } catch (error) {
572
+ const failures = [];
573
+ restoring = true;
574
+ for (const value of [...touched].reverse()) {
575
+ try {
576
+ if (fingerprint(capture(value)) !== currentHashes.get(value)) fail(`Mudanca concorrente preservada: ${value}`);
577
+ restore(value, originals.get(value));
578
+ } catch (failure) { failures.push(failure); }
579
+ }
580
+ for (const [directory, mode] of [...prunedDirectories].sort(([left], [right]) => right.length - left.length)) {
581
+ try {
582
+ if (!stat(safe(directory))) ensureDirectory(`${directory}/.restore-probe`);
583
+ const info = stat(safe(directory));
584
+ if (!info?.isDirectory() || !restoredDirectories.has(directory) && (info.mode & 0o777) !== mode) fail(`Diretorio concorrente preservado: ${directory}`);
585
+ chmodSync(safe(directory), mode);
586
+ } catch (failure) { failures.push(failure); }
587
+ }
588
+ for (const directory of sorted(createdDirectories).sort((a, b) => b.length - a.length)) {
589
+ try { if (!originalDirectories.has(directory) && stat(safe(directory))?.isDirectory() && !readdirSync(absolute(directory)).length) rmdirSync(safe(directory)); }
590
+ catch (failure) { failures.push(failure); }
591
+ }
592
+ if (failures.length) throw new AggregateError([error, ...failures], `${error.message}; rollback incompleto: ${failures.map(failure => failure.message).join('; ')}`);
593
+ throw error;
594
+ }
595
+ return { ...summary, ownedOutputs, preserved: preservedPaths, exitCode: 0, ...(backupRoot ? { backupRoot } : {}) };
452
596
  }
453
597
 
454
598
  export function main(args = process.argv.slice(2)) {
@@ -0,0 +1,190 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ import { projectRoot } from '../../lib/runtime.mjs';
9
+ import { nativePathBoundary } from '../../lib/native-paths.mjs';
10
+ import { synchronize } from '../SCRIPT.mjs';
11
+
12
+ const hash = content => createHash('sha256').update(content).digest('hex');
13
+ const put = (root, path, text) => { fs.mkdirSync(dirname(join(root, path)), { recursive: true }); fs.writeFileSync(join(root, path), text); };
14
+ const run = (root, options = {}) => synchronize({ root, localIgnores: false, output() {}, ...options });
15
+ function fixture(t) {
16
+ const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'agentdevkit-coexist-')));
17
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
18
+ put(root, '.pbai4devs/espelhos.conf', 'agente|.claude|plano\nagente|.codex|plano\nagente|.agents|plano\ndescoberta|.agents|antigravity\n');
19
+ put(root, '.pbai4devs/skills/demo/SKILL.md', '---\nname: demo\ndescription: Demo.\n---\n\n# Demo\n');
20
+ put(root, '.pbai4devs/agents/revisor/AGENT.md', '---\nname: revisor\ndescription: Revisor.\n---\n\n# Revisor\n');
21
+ for (const name of ['agents.ignore', 'shared.ignore', 'local.ignore', 'clients.json']) put(root, `.pbai4devs/templates/gitignore/${name}`, fs.readFileSync(join(projectRoot, '.pbai4devs/templates/gitignore', name)));
22
+ for (const name of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) put(root, `.pbai4devs/templates/entrypoints/${name}`, `---\ndescription: Entry.\n---\n\n# ${name}\n\nLeia AGENTS.md.\n`);
23
+ return root;
24
+ }
25
+ function link(t, root, target, path, type = 'file') {
26
+ fs.mkdirSync(dirname(join(root, path)), { recursive: true });
27
+ try { fs.symlinkSync(target, join(root, path), type); return true; }
28
+ catch (error) { if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { t.skip('Sistema não permite criar este link simbólico.'); return false; } throw error; }
29
+ }
30
+ function snapshot(root) {
31
+ const result = {};
32
+ function visit(prefix = '') {
33
+ for (const name of fs.readdirSync(join(root, prefix)).sort()) {
34
+ const path = prefix ? `${prefix}/${name}` : name, info = fs.lstatSync(join(root, path));
35
+ result[path] = info.isSymbolicLink() ? ['link', fs.readlinkSync(join(root, path))] : info.isDirectory() ? ['directory', info.mode & 0o777] : ['file', info.mode & 0o777, hash(fs.readFileSync(join(root, path)))];
36
+ if (info.isDirectory()) visit(path);
37
+ }
38
+ }
39
+ visit(); return result;
40
+ }
41
+
42
+ test('coexiste com aliases nativos, configuração e colisão humana sem alterar os destinos', t => {
43
+ const root = fixture(t);
44
+ put(root, '.claude/skills/existing/SKILL.md', '# Human skill\n');
45
+ put(root, '.claude/agents/revisor.md', '# Human reviewer\n');
46
+ put(root, '.claude/.gitignore', '# Human ignore\n*.private\n');
47
+ put(root, 'AGENTS.md', '# Human instructions\n');
48
+ put(root, 'external/config.json', '{"human":true}\n');
49
+ if (!link(t, root, '../.claude/skills', '.codex/skills', 'dir') || !link(t, root, 'AGENTS.md', 'CLAUDE.md') || !link(t, root, '../external/config.json', '.claude/settings.local.json')) return;
50
+ const original = snapshot(root), result = run(root, { force: true });
51
+ for (const [path, before] of Object.entries(original)) assert.deepEqual(snapshot(root)[path], before, path);
52
+ for (const path of ['.codex/skills', 'CLAUDE.md', '.claude/agents/revisor.md', '.claude/.gitignore', '.claude/settings.local.json']) assert.ok(result.preserved.some(item => item.path === path), path);
53
+ assert.ok(result.ownedOutputs.includes('.agents/skills/demo/SKILL.md'));
54
+ assert.ok(result.ownedOutputs.every(path => !path.startsWith('.codex/skills/') && path !== '.claude/agents/revisor.md' && path !== 'CLAUDE.md'));
55
+ const stable = snapshot(root);
56
+ assert.equal(run(root, { check: true }).exitCode, 0);
57
+ run(root, { force: true });
58
+ assert.deepEqual(snapshot(root), stable);
59
+ });
60
+
61
+ test('preserva raiz simbólica externa, link pendente e loop sem abri-los', t => {
62
+ for (const targetKind of ['external', 'dangling', 'loop']) {
63
+ const root = fixture(t), outside = fixture(t);
64
+ put(outside, 'sentinel.txt', 'outside\n');
65
+ const beforeOutside = snapshot(outside);
66
+ const target = targetKind === 'external' ? outside : targetKind === 'loop' ? '.codex' : join(outside, 'missing');
67
+ if (!link(t, root, target, '.codex', targetKind === 'external' && process.platform === 'win32' ? 'junction' : 'dir')) return;
68
+ const result = run(root, { force: true });
69
+ assert.equal(fs.readlinkSync(join(root, '.codex')), target);
70
+ assert.deepEqual(snapshot(outside), beforeOutside);
71
+ assert.ok(result.preserved.some(item => item.path === '.codex' && item.reason === 'symbolic-link'));
72
+ assert.equal(run(root, { check: true }).exitCode, 0);
73
+ }
74
+ });
75
+
76
+ test('ignore nativo simbólico permanece opaco na política local do Git', t => {
77
+ const root = fixture(t), outside = fixture(t);
78
+ const git = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
79
+ assert.equal(git.status, 0, git.stderr);
80
+ put(outside, 'ignore', '# External human ignore\n!*.private\n');
81
+ if (!link(t, root, join(outside, 'ignore'), '.claude/.gitignore')) return;
82
+ const before = snapshot(outside);
83
+ const result = run(root, { localIgnores: true });
84
+ assert.ok(result.preserved.some(item => item.path === '.claude/.gitignore'));
85
+ assert.deepEqual(snapshot(outside), before);
86
+ assert.equal(run(root, { localIgnores: true, check: true }).exitCode, 0);
87
+ });
88
+
89
+ test('link em caminho registrado continua sendo erro antes de qualquer escrita', t => {
90
+ for (const source of ['files', 'blocks', 'derived', 'outputs']) {
91
+ const root = fixture(t), path = source === 'derived' ? 'CLAUDE.md' : source === 'outputs' ? '.agents/skills.json' : '.claude/skills/demo/SKILL.md';
92
+ if (!link(t, root, 'missing', path)) return;
93
+ if (source === 'derived' || source === 'outputs') put(root, '.pbai4devs/estado/derived.json', JSON.stringify({ schemaVersion: 1, entries: source === 'derived' ? { [path]: 'a'.repeat(64) } : {}, ...(source === 'outputs' ? { outputs: { [path]: 'a'.repeat(64) } } : {}) }));
94
+ else put(root, '.pbai4devs/estado/agentdevkit.json', JSON.stringify({ schemaVersion: 1, files: source === 'files' ? { [path]: 'a'.repeat(64) } : {}, blocks: source === 'blocks' ? { [path]: 'a'.repeat(64) } : {} }));
95
+ const before = snapshot(root);
96
+ assert.throws(() => run(root, { force: true }), /Link simbolico em caminho gerenciado/);
97
+ assert.deepEqual(snapshot(root), before);
98
+ }
99
+ });
100
+
101
+ test('manifesto standalone tem propriedade explícita e alterações humanas exigem reparo autorizado', t => {
102
+ const root = fixture(t); run(root);
103
+ const path = '.agents/skills.json', original = fs.readFileSync(join(root, path), 'utf8');
104
+ const derived = JSON.parse(fs.readFileSync(join(root, '.pbai4devs/estado/derived.json')));
105
+ assert.equal(derived.outputs[path], hash(original));
106
+ put(root, path, '{"entries":[{"path":".agents/skills/custom"}]}\n');
107
+ const before = snapshot(root);
108
+ assert.throws(() => run(root), /--force/);
109
+ assert.deepEqual(snapshot(root), before);
110
+ const result = run(root, { force: true });
111
+ assert.equal(fs.readFileSync(join(root, path), 'utf8'), original);
112
+ assert.equal(fs.readFileSync(join(result.backupRoot, path), 'utf8'), '{"entries":[{"path":".agents/skills/custom"}]}\n');
113
+ });
114
+
115
+ test('manifesto nativo omite colisões preservadas e inclui somente apontamentos ativos', t => {
116
+ const root = fixture(t), collision = '.agents/skills/demo/SKILL.md';
117
+ put(root, collision, '# Human demo\n');
118
+ const result = run(root);
119
+ assert.deepEqual(JSON.parse(fs.readFileSync(join(root, '.agents/skills.json'))).entries, []);
120
+ assert.ok(result.preserved.some(item => item.path === collision));
121
+ assert.ok(!result.ownedOutputs.includes(collision));
122
+ assert.equal(run(root, { check: true }).exitCode, 0);
123
+ });
124
+
125
+ test('preserva diretórios humanos no lugar de índices, ignores e entradas, e arquivo no lugar de raiz', t => {
126
+ const root = fixture(t);
127
+ for (const path of ['.agents/skills.json', '.claude/.gitignore', 'CLAUDE.md']) put(root, `${path}/keep.txt`, 'Human data\n');
128
+ put(root, '.codex', 'Human native configuration\n');
129
+ const before = snapshot(root), result = run(root, { force: true });
130
+ for (const [path, value] of Object.entries(before)) assert.deepEqual(snapshot(root)[path], value, path);
131
+ for (const path of ['.agents/skills.json', '.claude/.gitignore', 'CLAUDE.md', '.codex']) {
132
+ assert.ok(result.preserved.some(item => item.path === path), path);
133
+ assert.ok(result.ownedOutputs.every(output => output !== path && !output.startsWith(`${path}/`)), path);
134
+ }
135
+ const stable = snapshot(root);
136
+ assert.equal(run(root, { check: true }).exitCode, 0);
137
+ run(root, { force: true });
138
+ assert.deepEqual(snapshot(root), stable);
139
+ });
140
+
141
+ test('tipo alterado em caminho nativo registrado continua recusado antes de gravar', t => {
142
+ for (const [path, owned] of [
143
+ ['.agents/skills.json', '.agents/skills.json'],
144
+ ['.claude/.gitignore', '.claude/.gitignore'],
145
+ ['CLAUDE.md', 'CLAUDE.md'],
146
+ ['.codex', '.codex/skills/demo/SKILL.md'],
147
+ ]) {
148
+ const root = fixture(t);
149
+ if (path === '.codex') put(root, path, 'Human root\n'); else put(root, `${path}/keep.txt`, 'Human directory\n');
150
+ put(root, '.pbai4devs/estado/agentdevkit.json', JSON.stringify({ schemaVersion: 1, files: { [owned]: 'a'.repeat(64) }, blocks: {} }));
151
+ const before = snapshot(root);
152
+ assert.throws(() => run(root, { force: true }), /caminho gerenciado/);
153
+ assert.deepEqual(snapshot(root), before);
154
+ }
155
+ });
156
+
157
+ test('classificação preservável não encobre aliases por caixa nem caminhos fora do escopo', t => {
158
+ const root = fixture(t);
159
+ put(root, '.Claude/notes.txt', 'human\n');
160
+ assert.throws(() => nativePathBoundary(root, '.claude/skills'), /maiusculas\/minusculas/);
161
+ assert.throws(() => nativePathBoundary(root, '.claude/skills', [], { preserveRootAliases: true }), /maiusculas\/minusculas/);
162
+ for (const path of ['../outside', '/outside', '.claude//skills', '.claude/./skills', '.claude/skills\n']) assert.throws(() => nativePathBoundary(root, path), /Caminho invalido/);
163
+ });
164
+
165
+ test('variantes humanas de entradas da raiz são preservadas sem criar duplicatas', t => {
166
+ const root = fixture(t);
167
+ for (const path of ['Readme.md', 'agents.md', 'claude.md', 'gemini.md']) put(root, path, `# Human ${path}\n`);
168
+ const before = snapshot(root), result = run(root, { force: true });
169
+ for (const [path, content] of Object.entries(before)) assert.deepEqual(snapshot(root)[path], content, path);
170
+ for (const [canonical, alias] of [['README.md', 'Readme.md'], ['AGENTS.md', 'agents.md'], ['CLAUDE.md', 'claude.md'], ['GEMINI.md', 'gemini.md']]) {
171
+ assert.deepEqual(nativePathBoundary(root, canonical, [], { preserveRootAliases: true }), { path: alias, reason: 'case-variant' });
172
+ if (canonical !== 'README.md') assert.ok(result.preserved.some(item => item.path === alias));
173
+ assert.ok(!result.ownedOutputs.includes(canonical));
174
+ assert.ok(!fs.readdirSync(root).includes(canonical), canonical);
175
+ }
176
+ const stable = snapshot(root);
177
+ assert.equal(run(root, { check: true }).exitCode, 0);
178
+ assert.deepEqual(snapshot(root), stable);
179
+ });
180
+
181
+ test('variante de caixa de entrada registrada não esconde drift de propriedade', t => {
182
+ for (const owned of ['AGENTS.md', 'agents.md']) {
183
+ const root = fixture(t); put(root, 'agents.md', '# Renamed human entry\n');
184
+ assert.throws(() => nativePathBoundary(root, 'AGENTS.md', [owned], { preserveRootAliases: true }), /maiusculas\/minusculas/);
185
+ put(root, '.pbai4devs/estado/agentdevkit.json', JSON.stringify({ schemaVersion: 1, files: { [owned]: 'a'.repeat(64) }, blocks: {} }));
186
+ const before = snapshot(root);
187
+ assert.throws(() => run(root, { force: true }), /maiusculas\/minusculas/);
188
+ assert.deepEqual(snapshot(root), before);
189
+ }
190
+ });