@pathbit/agentdevkit 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pbai4devs/COMPATIBILIDADE.md +4 -2
- package/.pbai4devs/INSTRUCTIONS.md +4 -0
- package/.pbai4devs/commands/sincronizar/COMMAND.md +4 -3
- package/.pbai4devs/rules/documentacao/RULE.md +4 -0
- package/.pbai4devs/rules/qualidade/RULE.md +4 -2
- package/.pbai4devs/scripts/README.md +3 -3
- package/.pbai4devs/scripts/inspecionar-projeto/README.md +31 -0
- package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +142 -1
- package/.pbai4devs/scripts/inspecionar-projeto/tests/TEST.mjs +168 -1
- package/.pbai4devs/scripts/lib/gitignore.mjs +84 -15
- package/.pbai4devs/scripts/lib/native-paths.mjs +35 -0
- package/.pbai4devs/scripts/preparar-ferramentas/SCRIPT.mjs +41 -20
- package/.pbai4devs/scripts/preparar-ferramentas/tests/TEST.mjs +81 -0
- package/.pbai4devs/scripts/sincronizar-agentes/README.md +3 -0
- package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +193 -49
- package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +190 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +151 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +145 -1
- package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +30 -29
- package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +56 -8
- package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +92 -1
- package/.pbai4devs/skills/produto/criar-design-doc/SKILL.md +2 -0
- package/.pbai4devs/skills/produto/criar-prd/SKILL.md +2 -0
- package/.pbai4devs/skills/produto/criar-ux-ui/SKILL.md +2 -0
- package/.pbai4devs/skills/projeto/atualizar-documentacao/SKILL.md +18 -4
- package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +2 -0
- package/.pbai4devs/skills/projeto/criar-recurso/SKILL.md +1 -0
- package/.pbai4devs/skills/projeto/sincronizar-agentes/SKILL.md +3 -2
- package/.pbai4devs/skills/specs/criar-spec/SKILL.md +1 -0
- package/.pbai4devs/skills/specs/gerenciar-roadmap/SKILL.md +2 -1
- package/.pbai4devs/templates/README.md +4 -1
- package/.pbai4devs/templates/configuracao/TEMPLATE.md +6 -0
- package/CHANGELOG.md +19 -0
- package/README.en.md +3 -2
- package/README.es.md +3 -2
- package/README.md +3 -2
- package/docs/README.md +1 -0
- package/docs/guides/README.md +1 -0
- package/docs/guides/cli.md +11 -0
- package/docs/guides/documentation.md +63 -0
- package/docs/guides/installation.md +18 -5
- package/docs/guides/resources.md +6 -5
- package/docs/guides/tools-and-testing.md +2 -0
- package/docs/guides/troubleshooting.md +6 -6
- package/docs/guides/workflow.md +2 -0
- package/docs/maintainers/packages.md +1 -1
- package/docs/product/design.md +12 -0
- package/docs/product/prd.md +4 -1
- package/docs/product/roadmap.md +4 -0
- package/docs/specs/README.md +2 -0
- package/docs/specs/v0.0.8/README.md +10 -0
- package/docs/specs/v0.0.8/SPEC.md +62 -0
- package/docs/specs/v0.0.9/README.md +9 -0
- package/docs/specs/v0.0.9/SPEC.md +68 -0
- package/package.json +3 -2
- package/src/cli.mjs +13 -4
- package/src/i18n.mjs +3 -0
- package/src/install.mjs +145 -38
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
135
|
-
|
|
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 (
|
|
241
|
-
|
|
242
|
-
else if (force) planRemove(value, true);
|
|
243
|
-
else
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
+
});
|