@pathbit/agentdevkit 0.0.24 → 0.0.26

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.
@@ -8,6 +8,11 @@ Entrada: referência da mudança, spec quando houver e arquivos do escopo.
8
8
  Pré-requisitos: implementação pronta e Node.js 24+. Sem spec em uma correção
9
9
  urgente, defina critérios próprios AC-001 e registre o motivo.
10
10
 
11
+ A coleta automática recusa submódulos, sparse-index, flags que ocultam mudanças
12
+ e filtros Git aplicados; nesses casos, use `--scope` para caminhos comprováveis.
13
+ Split-index e clones parciais são recusados em todos os modos. As consultas Git
14
+ desativam fsmonitor somente no subprocesso; nenhuma configuração é alterada.
15
+
11
16
  1. Siga [atualizar-documentacao][documentacao]
12
17
  e [verificar-trabalho](../../skills/projeto/verificar-trabalho/SKILL.md).
13
18
  2. Use `executar-script` para `revisar-entrega prepare --id <referencia>`, com
@@ -24,6 +29,19 @@ urgente, defina critérios próprios AC-001 e registre o motivo.
24
29
  revisão e `revisar-entrega check <relatorio>` pelo mesmo seletor. Falha,
25
30
  critério sem prova ou revisão pendente bloqueiam o commit/merge.
26
31
 
32
+ Antes do commit, execute `revisar-entrega check-commit <relatorio>
33
+ [outros-relatorios...]`. A união das revisões seladas deve cobrir todos os
34
+ arquivos preparados, inclusive ambas as pontas de renomeações. O check aceita
35
+ somente arquivos regulares com modo Git `100644` cujos blobs correspondam aos
36
+ bytes revisados. Filtros ou CRLF que mudem esses bytes são recusados.
37
+ Ele não é atômico: repita-o se o índice ou os arquivos mudarem antes do commit.
38
+
39
+ Antes de um merge fast-forward, na origem, execute `revisar-entrega check-merge
40
+ --destino refs/heads/<destino> <relatorio> [outros-relatorios...]`. A verificação
41
+ exige cobertura do diff e correspondência com o commit revisado. Ela bloqueia
42
+ históricos divergentes, links, submódulos, arquivos executáveis e operações Git
43
+ acima de 64 MiB. Confirme os hashes retornados antes de integrar.
44
+
27
45
  Saída: resultado por critério, pendências e caminho do único `revisao.json` em
28
46
  `.agent-tests/<referencia>/<execucao>/`. Esta etapa é obrigatória após qualquer
29
47
  implementação; a ausência de spec não elimina a revisão.
@@ -13,6 +13,9 @@ node .pbai4devs/scripts/revisar-entrega/SCRIPT.mjs prepare --id minha-mudanca
13
13
  Use `--spec caminho/SPEC.md` para extrair os critérios `AC-001` da spec.
14
14
  `--scope arquivo` limita uma revisão; sem ele, o snapshot reúne alterações Git.
15
15
  Use `--document arquivo` para incluir PRD, design e outros documentos.
16
+ Na coleta automática, submódulos, índice esparso, flags que ocultam mudanças e
17
+ filtros Git aplicados são bloqueados; use `--scope` quando o escopo for
18
+ conhecido. Índice dividido e clone parcial são recusados em qualquer modo.
16
19
  README, AGENTS, CLAUDE e GEMINI versionáveis na raiz entram automaticamente na
17
20
  revisão, mesmo sem alterações. Saídas locais ignoradas fora do índice não são
18
21
  documentos obrigatórios; documentos rastreados continuam cobertos mesmo quando
@@ -21,6 +24,17 @@ Preencha decisões, resultados e evidências no JSON retornado; peça revisão
21
24
  independente, execute `seal <caminho/revisao.json>` e depois
22
25
  `check <caminho/revisao.json>` antes de concluir.
23
26
 
27
+ `check` valida somente o escopo dos relatórios. Antes de um commit, use
28
+ `check-commit <relatorio> [outros-relatorios...]`: todos os arquivos preparados
29
+ devem estar cobertos por revisões seladas. O comando aceita somente arquivos
30
+ regulares com modo Git `100644` e compara os bytes preparados aos revisados;
31
+ normalizações por filtros ou CRLF que mudem os bytes são bloqueadas. Cada blob
32
+ preparado está limitado a 64 MiB.
33
+
34
+ Antes de um merge fast-forward, use `check-merge --destino refs/heads/<branch>
35
+ <relatorio> [outros-relatorios...]` na origem. Ele compara o commit e todo o diff
36
+ com as revisões seladas; confirme os hashes retornados antes de integrar.
37
+
24
38
  O check recusa resultados pendentes, evidências ausentes/versionadas e arquivos
25
39
  alterados depois da revisão. Relatórios ficam em
26
40
  `.agent-tests/<id>/<execução>/`.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
3
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { isMain, projectRoot, requireNode } from '../lib/runtime.mjs';
@@ -11,9 +11,13 @@ const portable = value => value.split(sep).join('/');
11
11
  const nonempty = value => typeof value === 'string' && value.trim().length > 0;
12
12
  const completed = value => nonempty(value) && !/^(?:a definir|pendente|pending|todo|tbd|\.\.\.)[.!]?$/i.test(value.trim());
13
13
  const digest = file => createHash('sha256').update(readFileSync(file)).digest('hex');
14
+ const decodeGit = data => {
15
+ try { return data ? new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data) : ''; }
16
+ catch { fail('Caminho ou saida Git nao e UTF-8 valido.'); }
17
+ };
14
18
 
15
19
  function pathInRoot(root, input) {
16
- if (!nonempty(input) || /[\0\r\n]/.test(input)) fail('Caminho invalido.');
20
+ if (!nonempty(input) || !input.isWellFormed() || /[\0\r\n]/.test(input)) fail('Caminho invalido.');
17
21
  const name = portable(relative(root, resolve(root, input)));
18
22
  if (!name || name === '..' || name.startsWith('../') || isAbsolute(name) || name.split('/').some(part => part.toLowerCase() === '.git')) fail(`Caminho fora do escopo: ${input}`);
19
23
  let current = root;
@@ -25,16 +29,85 @@ function pathInRoot(root, input) {
25
29
  return name;
26
30
  }
27
31
 
28
- function git(root, args, allowFailure = false) {
29
- const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 15000, windowsHide: true });
32
+ function git(root, args, allowFailure = false, input) {
33
+ const raw = spawnSync('git', ['-c', 'core.fsmonitor=false', ...args], { cwd: root, input, timeout: 15000, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' } });
34
+ const result = { ...raw, stdout: decodeGit(raw.stdout), stderr: raw.stderr?.toString('utf8') || '' };
30
35
  if ((result.error || result.status !== 0) && !allowFailure) fail(`Git: ${result.error?.message || result.stderr?.trim() || 'operacao falhou'}`);
31
36
  return result;
32
37
  }
33
38
 
39
+ function indexStatEqual(a, b) {
40
+ return a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
41
+ }
42
+
43
+ // Inspect the index without asking Git to load it: loading a split index can
44
+ // touch sharedindex metadata even when optional locks are disabled.
45
+ function inspectIndex(root) {
46
+ const split = git(root, ['config', '--bool', '--get', 'core.splitIndex'], true);
47
+ if (split.error || ![0, 1].includes(split.status) || (split.status === 0 && !['true', 'false'].includes(split.stdout.trim()))) fail('Nao foi possivel verificar split-index Git.');
48
+ if (split.status === 0 && split.stdout.trim() === 'true') fail('Split-index Git nao suportado para revisao; preserve a configuracao e consulte o responsavel.');
49
+ const name = git(root, ['rev-parse', '--git-path', 'index']).stdout.replace(/\r?\n$/, '');
50
+ if (!name || /[\0\r\n]/.test(name)) fail('Caminho do indice Git nao suportado.');
51
+ const path = resolve(root, name);
52
+ let before;
53
+ try { before = lstatSync(path, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') return { path, missing: true }; throw error; }
54
+ if (!before.isFile() || before.isSymbolicLink() || before.ino === 0n || before.size > 134217728n) fail('Indice Git nao e arquivo regular suportado.');
55
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
56
+ let data, after;
57
+ try {
58
+ const opened = fstatSync(fd, { bigint: true });
59
+ if (!opened.isFile() || !indexStatEqual(before, opened)) fail('Indice Git mudou antes da leitura.');
60
+ data = readFileSync(fd);
61
+ const finished = fstatSync(fd, { bigint: true }); after = lstatSync(path, { bigint: true });
62
+ if (!indexStatEqual(opened, finished) || !indexStatEqual(finished, after) || BigInt(data.length) !== before.size) fail('Indice Git mudou durante a leitura.');
63
+ } finally { closeSync(fd); }
64
+ if (data.length < 32 || data.toString('ascii', 0, 4) !== 'DIRC') fail('Indice Git invalido.');
65
+ const hashes = [['sha1', 20], ['sha256', 32]].filter(([algo, size]) => data.length >= size + 12 && createHash(algo).update(data.subarray(0, -size)).digest().equals(data.subarray(-size)));
66
+ if (hashes.length !== 1) fail('Checksum ou formato do indice Git nao comprovado.');
67
+ const objectSize = hashes[0][1], end = data.length - objectSize, version = data.readUInt32BE(4), count = data.readUInt32BE(8);
68
+ if (![2, 3, 4].includes(version)) fail('Versao do indice Git nao suportada.');
69
+ let offset = 12, submodule = false, hidden = false, sparse = false;
70
+ for (let i = 0; i < count; i++) {
71
+ const start = offset, fixed = 42 + objectSize;
72
+ if (start + fixed > end) fail('Entrada do indice Git truncada.');
73
+ const mode = data.readUInt32BE(start + 24), flags = data.readUInt16BE(start + 40 + objectSize);
74
+ if ((mode & 0o170000) === 0o160000) submodule = true;
75
+ if ((mode & 0o170000) === 0o040000) sparse = true;
76
+ if (flags & 0x8000) hidden = true;
77
+ offset = start + fixed;
78
+ if (flags & 0x4000) {
79
+ if (version < 3 || offset + 2 > end) fail('Entrada estendida do indice Git invalida.');
80
+ if (data.readUInt16BE(offset) & 0x4000) hidden = true;
81
+ offset += 2;
82
+ }
83
+ if (version === 4) {
84
+ let size = 0, byte;
85
+ do { if (offset >= end || ++size > 10) fail('Caminho comprimido do indice Git invalido.'); byte = data[offset++]; } while (byte & 0x80);
86
+ }
87
+ const nul = data.indexOf(0, offset);
88
+ if (nul < offset || nul >= end) fail('Caminho do indice Git truncado.');
89
+ offset = version === 4 ? nul + 1 : start + Math.ceil((nul + 1 - start) / 8) * 8;
90
+ if (offset > end || (version !== 4 && data.subarray(nul, offset).some(byte => byte !== 0))) fail('Entrada do indice Git invalida.');
91
+ }
92
+ while (offset < end) {
93
+ if (offset + 8 > end) fail('Extensao do indice Git truncada.');
94
+ const signature = data.toString('ascii', offset, offset + 4), size = data.readUInt32BE(offset + 4);
95
+ if (signature === 'link') fail('Split-index Git nao suportado para revisao; preserve a configuracao e consulte o responsavel.');
96
+ if (signature === 'sdir') sparse = true;
97
+ offset += 8 + size;
98
+ if (offset > end) fail('Extensao do indice Git truncada.');
99
+ }
100
+ return { path, stat: after, hash: createHash('sha256').update(data).digest('hex'), submodule, hidden, sparse };
101
+ }
102
+
34
103
  function repository(root) {
104
+ if (Object.entries(process.env).some(([key, value]) => key.toUpperCase().startsWith('GIT_TRACE') && value && value !== '0')) fail('Variaveis GIT_TRACE ativas nao sao suportadas na revisao.');
105
+ if (Object.entries(process.env).some(([key, value]) => key.toUpperCase().startsWith('GIT_TEST_') && value)) fail('Variaveis GIT_TEST_ nao sao suportadas na revisao.');
35
106
  const canonical = realpathSync(root);
36
107
  const top = git(canonical, ['rev-parse', '--show-toplevel']).stdout.trim();
37
108
  if (realpathSync(top) !== canonical) fail('Use a raiz do repositorio.');
109
+ requireCompleteObjects(canonical, 'revisao de entrega');
110
+ inspectIndex(canonical);
38
111
  return canonical;
39
112
  }
40
113
 
@@ -53,20 +126,45 @@ function tracked(root, file) {
53
126
 
54
127
  function ignoredPaths(root, files) {
55
128
  if (!files.length) return new Set();
56
- const result = spawnSync('git', ['check-ignore', '--no-index', '--stdin', '-z'], { cwd: root, input: files.join('\0') + '\0', encoding: 'utf8', timeout: 15000, windowsHide: true });
129
+ const raw = spawnSync('git', ['-c', 'core.fsmonitor=false', 'check-ignore', '--no-index', '--stdin', '-z'], { cwd: root, input: Buffer.from(files.join('\0') + '\0'), timeout: 15000, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' } });
130
+ const result = { ...raw, stdout: decodeGit(raw.stdout), stderr: raw.stderr?.toString('utf8') || '' };
57
131
  if (result.error || ![0, 1].includes(result.status)) fail(`Git: ${result.error?.message || result.stderr?.trim() || 'falha ao conferir ignores'}`);
58
132
  return new Set(result.stdout.split('\0').filter(Boolean));
59
133
  }
60
134
 
61
135
  function stagedDeletions(root) {
62
- return new Set(git(root, ['diff', '--cached', '--diff-filter=D', '--no-renames', '--name-only', '-z', '--']).stdout.split('\0').filter(Boolean));
136
+ return new Set(git(root, ['diff', '--cached', '--diff-filter=D', '--no-renames', '--ignore-submodules=none', '--name-only', '-z', '--']).stdout.split('\0').filter(Boolean));
137
+ }
138
+
139
+ function ensureNoAppliedFilters(root, indexed) {
140
+ const configured = git(root, ['config', '--get-regexp', '^filter\\.'], true);
141
+ if (configured.error || ![0, 1].includes(configured.status)) fail('Nao foi possivel verificar filtros Git.');
142
+ if (configured.status !== 0 || !indexed) return;
143
+ const paths = new Set(indexed.split('\0').filter(Boolean));
144
+ const result = git(root, ['check-attr', '-z', '--stdin', '--all'], false, Buffer.from(indexed));
145
+ const fields = result.stdout.split('\0');
146
+ if (fields.pop() !== '' || fields.length % 3) fail('Resposta ambigua dos atributos Git.');
147
+ for (let i = 0; i < fields.length; i += 3) {
148
+ if (!paths.has(fields[i])) fail('Caminho inesperado nos atributos Git.');
149
+ if (fields[i + 1] === 'filter') fail('Coleta automatica nao suporta filtro Git aplicado; use --scope para revisar caminhos especificos.');
150
+ }
63
151
  }
64
152
 
65
153
  function changedFiles(root) {
66
- const baseline = head(root);
67
- const versioned = git(root, baseline ? ['diff', '--name-only', '-z', 'HEAD', '--'] : ['ls-files', '-z']).stdout;
68
- const untracked = git(root, ['ls-files', '--others', '--exclude-standard', '-z']).stdout;
69
- return [...new Set((versioned + '\0' + untracked).split('\0').filter(Boolean))].sort();
154
+ const before = inspectIndex(root);
155
+ if (before.submodule || before.sparse || before.hidden) fail('Coleta automatica nao suporta submodulo, sparse-index ou flags que ocultam alteracoes; use --scope.');
156
+ const indexed = git(root, ['ls-files', '-z']).stdout;
157
+ ensureNoAppliedFilters(root, indexed);
158
+ const status = git(root, ['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all', '--ignore-submodules=none']).stdout;
159
+ const entries = status.split('\0');
160
+ if (entries.pop() !== '') fail('Status Git incompleto.');
161
+ const names = entries.map(entry => {
162
+ if (entry.length < 4 || entry[2] !== ' ' || !/^[ MADRCUT?!]{2}$/.test(entry.slice(0, 2)) || entry.slice(0, 2) === '!!') fail('Status Git ambiguo.');
163
+ return entry.slice(3);
164
+ });
165
+ const after = inspectIndex(root);
166
+ if (before.path !== after.path || !!before.missing !== !!after.missing || (!before.missing && (!indexStatEqual(before.stat, after.stat) || before.hash !== after.hash))) fail('Indice Git mudou durante a coleta; resultado descartado.');
167
+ return [...new Set(names)].sort();
70
168
  }
71
169
 
72
170
  function snapshot(root, mode, paths, documents = [], spec = null) {
@@ -100,11 +198,25 @@ function expectedDocuments(root, state, extra = []) {
100
198
  return [...new Set([...candidates.filter(file => !local.has(file) || versioned.has(file)), ...extra.map(file => pathInRoot(root, file))])].sort();
101
199
  }
102
200
 
201
+ function requireCompleteObjects(root, operation) {
202
+ const partial = git(root, ['config', '--get-regexp', '^(extensions\\.partialclone|remote\\..*\\.promisor)$'], true);
203
+ if (partial.error || ![0, 1].includes(partial.status)) fail('Nao foi possivel verificar clone parcial.');
204
+ if (partial.status === 0) fail(`Clone parcial ou remote promisor nao suportado para ${operation}.`);
205
+ }
206
+
207
+ function stagedBlobDigest(root, object, path) {
208
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(object)) fail(`Objeto preparado invalido: ${path}`);
209
+ const blob = spawnSync('git', ['-c', 'core.fsmonitor=false', 'cat-file', 'blob', object], { cwd: root, timeout: 15000, windowsHide: true, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' } });
210
+ if (blob.error || blob.status !== 0 || !blob.stdout) fail(`Blob preparado indisponivel ou muito grande: ${path}`);
211
+ return createHash('sha256').update(blob.stdout).digest('hex');
212
+ }
213
+
103
214
  function checkIndex(root, state) {
104
- const staged = new Set(git(root, ['diff', '--cached', '--name-only', '-z', '--']).stdout.split('\0').filter(Boolean));
215
+ const staged = new Set(git(root, ['diff', '--cached', '--name-only', '-z', '--no-renames', '--ignore-submodules=none', '--']).stdout.split('\0').filter(Boolean));
105
216
  const deleted = stagedDeletions(root);
106
217
  const indexed = new Set(git(root, ['ls-files', '-z']).stdout.split('\0').filter(Boolean));
107
218
  const local = ignoredPaths(root, state.files.filter(file => file.indexAction === 'untrack').map(file => file.path));
219
+ let checkedObjects = false;
108
220
  for (const file of state.files) {
109
221
  if (file.indexAction === 'untrack') {
110
222
  if (!staged.has(file.path) || !deleted.has(file.path) || indexed.has(file.path) || file.sha256 === null || !existsSync(join(root, file.path)) || !local.has(file.path)) fail(`Acao de desindexacao mudou: ${file.path}`);
@@ -112,9 +224,19 @@ function checkIndex(root, state) {
112
224
  }
113
225
  if (file.indexAction !== undefined) fail(`Acao de indice invalida: ${file.path}`);
114
226
  if (!staged.has(file.path)) continue;
227
+ // Diff can hide mismatches behind index flags or a clean filter. Compare
228
+ // the raw staged blob to the reviewed bytes without running either.
229
+ const flags = git(root, ['--literal-pathspecs', 'ls-files', '-v', '--stage', '-z', '--', file.path]).stdout.split('\0').filter(Boolean);
230
+ if (flags.length !== (indexed.has(file.path) ? 1 : 0)) fail(`Flags do indice Git ambiguas: ${file.path}`);
115
231
  const deletedButPresent = !indexed.has(file.path) && file.sha256 !== null;
116
- const differs = git(root, ['--literal-pathspecs', 'diff', '--quiet', '--no-ext-diff', '--', file.path], true);
117
- if (deletedButPresent || differs.error || differs.status !== 0) fail(`Indice Git difere do arquivo testado: ${file.path}`);
232
+ if (deletedButPresent) fail(`Indice Git difere do arquivo testado: ${file.path}`);
233
+ if (!flags.length) continue;
234
+ const match = /^([A-Za-z]) (\d{6}) ([a-f0-9]+) ([0-3])\t([\s\S]+)$/.exec(flags[0]);
235
+ if (!match || match[5] !== file.path || match[4] !== '0') fail(`Flags do indice Git ambiguas: ${file.path}`);
236
+ if (match[1] === 'S' || /^[a-z]$/.test(match[1])) fail(`Arquivo preparado usa assume-unchanged ou skip-worktree: ${file.path}`);
237
+ if (!['100644', '100755'].includes(match[2])) fail(`Tipo de arquivo preparado nao comprovado: ${file.path}`);
238
+ if (!checkedObjects) { requireCompleteObjects(root, 'revisao de indice'); checkedObjects = true; }
239
+ if (stagedBlobDigest(root, match[3], file.path) !== file.sha256) fail(`Indice Git difere dos bytes testados: ${file.path} (confira filtros e CRLF).`);
118
240
  }
119
241
  }
120
242
 
@@ -211,11 +333,122 @@ function validateReview(file, { root = projectRoot, requireSeal = true } = {}) {
211
333
  const recorded = JSON.parse(readFileSync(join(root, sealPath), 'utf8'));
212
334
  if (JSON.stringify(recorded) !== JSON.stringify(seal)) fail('Relatorio, baseline ou evidencia alterados apos selar a revisao.');
213
335
  }
214
- return { root, prefix, seal, result: { status: 'passed', report: reportPath, criteria: ids.size, documents: docs.size } };
336
+ return { root, prefix, seal, snapshot: state, result: { status: 'passed', report: reportPath, criteria: ids.size, documents: docs.size } };
215
337
  }
216
338
 
217
339
  export function checkReview(file, { root = projectRoot } = {}) { return validateReview(file, { root, requireSeal: true }).result; }
218
340
 
341
+ // A review may cover only part of a project. A commit includes the entire index,
342
+ // so every staged path must be covered by a sealed review before committing.
343
+ export function checkCommit(reports, { root = projectRoot } = {}) {
344
+ requireNode(); root = repository(root);
345
+ if (!Array.isArray(reports) || !reports.length) fail('Informe um ou mais relatorios selados, sem repeticao.');
346
+ reports = reports.map(file => pathInRoot(root, file));
347
+ if (new Set(reports).size !== reports.length) fail('Informe relatorios selados sem repeticao.');
348
+ const baselineHead = head(root);
349
+ const staged = () => git(root, ['diff', '--cached', '--raw', '-z', '--no-abbrev', '--no-renames', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none', '--']).stdout;
350
+ const before = staged();
351
+ if (!before) fail('Nenhuma alteracao preparada para commit.');
352
+ const reviews = reports.map(file => validateReview(file, { root, requireSeal: true }));
353
+ if (head(root) !== baselineHead || staged() !== before) fail('HEAD ou indice preparado mudou durante a verificacao.');
354
+ const fields = before.split('\0');
355
+ if (fields.pop() !== '' || fields.length % 2) fail('Diff preparado incompleto ou ambiguo.');
356
+ const paths = new Map();
357
+ for (let i = 0; i < fields.length; i += 2) {
358
+ const match = /^:(\d{6}) (\d{6}) ([a-f0-9]+) ([a-f0-9]+) ([AMD])$/.exec(fields[i]);
359
+ if (!match) fail('Tipo de alteracao preparada nao suportado.');
360
+ const [, oldMode, newMode, , object, action] = match, path = pathInRoot(root, fields[i + 1]);
361
+ const valid = action === 'A' ? oldMode === '000000' && newMode === '100644'
362
+ : action === 'D' ? oldMode === '100644' && newMode === '000000'
363
+ : oldMode === '100644' && newMode === '100644';
364
+ if (!valid || paths.has(path) || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(object)) fail(`Tipo ou modo preparado nao comprovado: ${path}`);
365
+ paths.set(path, { action, object });
366
+ }
367
+ const covered = new Map(reviews.flatMap(review => review.snapshot.files.map(file => [file.path, file])));
368
+ const missing = [...paths.keys()].filter(path => !covered.has(path));
369
+ if (missing.length) fail(`Arquivos preparados sem revisao selada: ${missing.join(', ')}`);
370
+ requireCompleteObjects(root, 'check-commit');
371
+ for (const [path, entry] of paths) {
372
+ if (entry.action === 'D') {
373
+ const reviewed = covered.get(path);
374
+ if (reviewed.sha256 !== null && reviewed.indexAction !== 'untrack') fail(`Exclusao preparada difere do arquivo revisado: ${path}`);
375
+ continue;
376
+ }
377
+ if (stagedBlobDigest(root, entry.object, path) !== covered.get(path).sha256) fail(`Blob preparado difere dos bytes revisados: ${path}`);
378
+ }
379
+ for (const file of reports) validateReview(file, { root, requireSeal: true });
380
+ if (head(root) !== baselineHead || staged() !== before) fail('HEAD ou indice preparado mudou durante a verificacao.');
381
+ return { status: 'passed', head: baselineHead, reports: reports.length, staged: paths.size, stagedSha256: createHash('sha256').update(before).digest('hex') };
382
+ }
383
+
384
+ // This deliberately permits only a fast-forward and regular non-executable
385
+ // files. Existing review snapshots do not record modes or merge results.
386
+ export function checkMerge(reports, destination, { root = projectRoot } = {}) {
387
+ requireNode(); root = repository(root);
388
+ if (!Array.isArray(reports) || !reports.length) fail('Informe um ou mais relatorios selados, sem repeticao.');
389
+ reports = reports.map(file => pathInRoot(root, file));
390
+ if (new Set(reports).size !== reports.length) fail('Informe relatorios selados sem repeticao.');
391
+ if (typeof destination !== 'string' || !destination.startsWith('refs/heads/') || git(root, ['check-ref-format', destination], true).status !== 0) fail('Destino deve ser uma branch local completa: refs/heads/<nome>.');
392
+ const currentBranch = git(root, ['symbolic-ref', '--quiet', 'HEAD'], true);
393
+ if (currentBranch.status !== 0) fail('Confira a branch de origem; HEAD destacado nao e aceito.');
394
+ const sourceRef = currentBranch.stdout.trim();
395
+ if (!sourceRef.startsWith('refs/heads/') || git(root, ['check-ref-format', sourceRef], true).status !== 0) fail('A origem deve ser uma branch local valida.');
396
+ if (sourceRef === destination) fail('Origem e destino devem ser branches distintas.');
397
+ for (const ref of [sourceRef, destination]) if (git(root, ['symbolic-ref', '--quiet', ref], true).status === 0) fail(`Branch simbolica nao e aceita: ${ref}`);
398
+ const resolveCommit = ref => {
399
+ const out = git(root, ['rev-parse', '--verify', `${ref}^{commit}`], true);
400
+ const value = out.stdout?.trim();
401
+ if (out.status !== 0 || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value || '')) fail(`Branch ou commit invalido: ${ref}`);
402
+ return value;
403
+ };
404
+ const source = resolveCommit(sourceRef), target = resolveCommit(destination);
405
+ if (head(root) !== source) fail('HEAD diverge da branch de origem.');
406
+ if (git(root, ['merge-base', '--is-ancestor', target, source], true).status !== 0) fail('Apenas fast-forward comprovado e suportado; revise o resultado integrado para historico divergente.');
407
+ const bytes = args => {
408
+ const result = spawnSync('git', ['-c', 'core.fsmonitor=false', ...args], { cwd: root, timeout: 15000, windowsHide: true, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' } });
409
+ if (result.error || result.status !== 0) fail(`Git: ${result.error?.message || result.stderr?.toString().trim() || 'operacao falhou'}`);
410
+ return result.stdout;
411
+ };
412
+ const decode = data => { try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data); } catch { fail('Caminho Git nao e UTF-8 valido.'); } };
413
+ const raw = bytes(['diff', '--raw', '-z', '--no-abbrev', '--no-renames', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none', target, source, '--']);
414
+ const parts = decode(raw).split('\0');
415
+ if (parts.pop() !== '' || parts.length % 2) fail('Diff Git incompleto ou ambiguo.');
416
+ const changes = new Map();
417
+ for (let i = 0; i < parts.length; i += 2) {
418
+ const match = /^:(\d{6}) (\d{6}) ([a-f0-9]+) ([a-f0-9]+) ([AMD])$/.exec(parts[i]);
419
+ if (!match) fail('Tipo de alteracao Git nao suportado.');
420
+ const [, beforeMode, afterMode, , , action] = match, path = pathInRoot(root, parts[i + 1]);
421
+ if (changes.has(path) || !['000000', '100644'].includes(beforeMode) || !['000000', '100644'].includes(afterMode)) fail(`Tipo ou modo nao comprovado: ${path}`);
422
+ changes.set(path, action);
423
+ }
424
+ if (!changes.size) fail('Nao ha alteracoes para integrar.');
425
+ const reviewed = new Set();
426
+ const verified = reports.map(file => validateReview(file, { root, requireSeal: true }));
427
+ for (const review of verified) {
428
+ if (review.snapshot.head !== source) fail('Relatorio nao corresponde ao commit de origem.');
429
+ for (const item of review.snapshot.files) {
430
+ const path = pathInRoot(root, item.path);
431
+ if (item.indexAction !== undefined) fail(`Acao de indice nao pode comprovar merge: ${path}`);
432
+ const tree = decode(bytes(['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', source, '--', path]));
433
+ if (!tree) {
434
+ if (item.sha256 !== null) fail(`Conteudo revisado nao esta no commit de origem: ${path}`);
435
+ } else {
436
+ const m = /^(\d{6}) blob ([a-f0-9]+)\t([^\0]+)\0$/.exec(tree);
437
+ if (!m || m[1] !== '100644' || m[3] !== path) fail(`Arquivo, link ou modo nao comprovado no commit: ${path}`);
438
+ if (item.sha256 !== createHash('sha256').update(bytes(['cat-file', 'blob', m[2]])).digest('hex')) fail(`Conteudo revisado difere do commit de origem: ${path}`);
439
+ }
440
+ if (changes.has(path)) reviewed.add(path);
441
+ }
442
+ }
443
+ const missing = [...changes.keys()].filter(path => !reviewed.has(path));
444
+ if (missing.length) fail(`Alteracoes sem revisao selada: ${missing.join(', ')}`);
445
+ // Detect moved refs or changed worktree/reports during the validation.
446
+ if (git(root, ['symbolic-ref', '--quiet', 'HEAD']).stdout.trim() !== sourceRef || resolveCommit(sourceRef) !== source || resolveCommit(destination) !== target || head(root) !== source) fail('Origem ou destino mudou durante a verificacao.');
447
+ for (const file of reports) validateReview(file, { root, requireSeal: true });
448
+ if (git(root, ['symbolic-ref', '--quiet', 'HEAD']).stdout.trim() !== sourceRef || resolveCommit(sourceRef) !== source || resolveCommit(destination) !== target || head(root) !== source) fail('Origem ou destino mudou durante a verificacao.');
449
+ return { status: 'passed', sourceRef, source, destination, target, changes: changes.size, reports: reports.length, mode: 'fast-forward' };
450
+ }
451
+
219
452
  export function sealReview(file, options) {
220
453
  const checked = validateReview(file, { ...options, requireSeal: false });
221
454
  const path = artifact(checked.root, `${checked.prefix}/seal.json`, checked.prefix, false);
@@ -224,12 +457,14 @@ export function sealReview(file, options) {
224
457
  return checkReview(file, options);
225
458
  }
226
459
 
227
- const usage = 'Uso: revisar-entrega prepare --id <slug> [--execution <slug>] [--spec <arquivo>] [--scope <arquivo>] [--document <arquivo>] [--output <artefato>]; seal|check <revisao.json>';
460
+ const usage = 'Uso: revisar-entrega prepare --id <slug> [--execution <slug>] [--spec <arquivo>] [--scope <arquivo>] [--document <arquivo>] [--output <artefato>]; seal|check <revisao.json>; check-commit <revisao.json> [...]; check-merge --destino refs/heads/<branch> <revisao.json> [...]';
228
461
  export function main(args = process.argv.slice(2)) {
229
462
  requireNode(); const [mode, ...rest] = args;
230
463
  if (!mode || ['--help', '-h'].includes(mode)) { console.log(usage); return 0; }
231
464
  if (mode === 'check' && rest.length === 1) { console.log(JSON.stringify(checkReview(rest[0]))); return 0; }
232
465
  if (mode === 'seal' && rest.length === 1) { console.log(JSON.stringify(sealReview(rest[0]))); return 0; }
466
+ if (mode === 'check-commit' && rest.length) { console.log(JSON.stringify(checkCommit(rest))); return 0; }
467
+ if (mode === 'check-merge' && rest.length >= 3 && rest[0] === '--destino') { console.log(JSON.stringify(checkMerge(rest.slice(2), rest[1]))); return 0; }
233
468
  if (mode !== 'prepare') fail(usage);
234
469
  const options = { scope: [], documents: [] };
235
470
  const fields = { '--id': 'id', '--execution': 'execution', '--spec': 'spec', '--output': 'output' };
@@ -1,9 +1,10 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
- import { checkReview, prepareReview, sealReview, specCriteria } from '../SCRIPT.mjs';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { checkCommit, checkMerge, checkReview, prepareReview, sealReview, specCriteria } from '../SCRIPT.mjs';
7
8
  import { projectRoot } from '../../lib/runtime.mjs';
8
9
 
9
10
  function fixture(t) {
@@ -166,6 +167,460 @@ test('indice Git precisa corresponder ao codigo testado antes de commit', t => {
166
167
  assert.equal(checkReview(f.file, { root: f.root }).status, 'passed');
167
168
  });
168
169
 
170
+ test('prepare, seal, check e check-commit preservam indice com stat defasado', t => {
171
+ const f = fixture(t);
172
+ f.git('add', '.');
173
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=disabled-hooks', 'commit', '-q', '-m', 'test: base');
174
+ f.put('src/main.mjs', 'export const value=2;\n'); f.git('add', 'src/main.mjs');
175
+ const path = join(f.root, 'src/main.mjs'), index = join(f.root, '.git/index');
176
+ const stale = () => { const info = statSync(path); utimesSync(path, info.atime, new Date(info.mtimeMs - 120000)); };
177
+ const preserved = call => { stale(); const before = readFileSync(index); const result = call(); assert.deepEqual(readFileSync(index), before); return result; };
178
+ const made = preserved(() => prepareReview({ root: f.root, id: 'stat-cache', execution: 'case' }));
179
+ for (const item of made.report.documentation) Object.assign(item, { decision: 'no-impact', reason: 'Documento examinado na fixture.' });
180
+ const proof = '.agent-tests/stat-cache/case/proof.txt'; f.put(proof, 'Prova sintetica.\n');
181
+ for (const item of made.report.validation) Object.assign(item, { method: 'fixture', prerequisite: 'Git local', assertion: 'Blob revisado', evidence: [proof], status: 'passed' });
182
+ made.report.review = { status: 'approved', reviewer: 'simulacao de teste', findings: [] };
183
+ f.put(made.file, JSON.stringify(made.report));
184
+ assert.equal(preserved(() => sealReview(made.file, { root: f.root })).status, 'passed');
185
+ assert.equal(preserved(() => checkReview(made.file, { root: f.root })).status, 'passed');
186
+ assert.equal(preserved(() => checkCommit([made.file], { root: f.root })).status, 'passed');
187
+ });
188
+
189
+ test('coleta automatica inclui staged cancelado e ambas pontas de renomeacao', async t => {
190
+ for (const name of ['cancelado', 'renomeado']) await t.test(name, t => {
191
+ const f = fixture(t);
192
+ f.git('add', '.');
193
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=disabled-hooks', 'commit', '-q', '-m', 'test: base');
194
+ if (name === 'cancelado') {
195
+ f.put('src/main.mjs', 'export const value=2;\n'); f.git('add', 'src/main.mjs');
196
+ f.put('src/main.mjs', 'export const value=1;\n');
197
+ } else f.git('mv', 'src/main.mjs', 'src/new name.mjs');
198
+ const index = readFileSync(join(f.root, '.git/index'));
199
+ const made = prepareReview({ root: f.root, id: 'coleta', execution: 'case' });
200
+ const paths = made.report.snapshot.files.map(file => file.path);
201
+ assert.ok(paths.includes('src/main.mjs'));
202
+ if (name === 'renomeado') assert.ok(paths.includes('src/new name.mjs'));
203
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
204
+ });
205
+ });
206
+
207
+ test('coleta automatica recusa nome com quebra de linha sem tocar indice', t => {
208
+ if (process.platform === 'win32') return t.skip('Quebras de linha nao sao nomes de arquivo validos no Windows.');
209
+ const f = fixture(t); f.put('line\nbreak.txt', 'conteudo\n'); f.git('add', 'line\nbreak.txt');
210
+ const index = readFileSync(join(f.root, '.git/index'));
211
+ assert.throws(() => prepareReview({ root: f.root, id: 'newline', execution: 'case' }), /Caminho invalido/);
212
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
213
+ assert.equal(existsSync(join(f.root, '.agent-tests/newline')), false);
214
+ });
215
+
216
+ test('coleta permite filtro global configurado sem atributo aplicado', t => {
217
+ const f = fixture(t); f.git('add', 'src/main.mjs');
218
+ f.put('.agent-tests/global-git-config', '');
219
+ const globalConfig = join(f.root, '.agent-tests/global-git-config');
220
+ f.git('config', '--file', globalConfig, 'filter.fixture.clean', 'false');
221
+ const previous = process.env.GIT_CONFIG_GLOBAL;
222
+ process.env.GIT_CONFIG_GLOBAL = globalConfig;
223
+ t.after(() => { if (previous === undefined) delete process.env.GIT_CONFIG_GLOBAL; else process.env.GIT_CONFIG_GLOBAL = previous; });
224
+ const index = readFileSync(join(f.root, '.git/index'));
225
+ const made = prepareReview({ root: f.root, id: 'global-filter', execution: 'case' });
226
+ assert.ok(made.report.snapshot.files.some(file => file.path === 'src/main.mjs'));
227
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
228
+ });
229
+
230
+ test('coleta automatica recusa filtro aplicado sem executa-lo e scope continua disponivel', t => {
231
+ const f = fixture(t); f.git('add', '.');
232
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=disabled-hooks', 'commit', '-q', '-m', 'test: base');
233
+ const executable = process.execPath.replaceAll('\\', '/');
234
+ const clean = `"${executable}" -e "require('fs').writeFileSync('filter-marker','hit');process.stdin.pipe(process.stdout)"`;
235
+ f.git('config', 'filter.fixture.clean', clean);
236
+ f.put('.git/info/attributes', 'src/main.mjs filter=fixture\n');
237
+ f.put('src/main.mjs', 'export const value=2;\n');
238
+ const marker = join(f.root, 'filter-marker'), index = readFileSync(join(f.root, '.git/index'));
239
+ assert.throws(() => prepareReview({ root: f.root, id: 'filtro', execution: 'auto' }), /filtro Git aplicado.*--scope/);
240
+ assert.equal(existsSync(marker), false);
241
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
242
+ const made = prepareReview({ root: f.root, id: 'filtro', execution: 'scope', scope: ['src/main.mjs'] });
243
+ assert.ok(made.report.snapshot.files.some(file => file.path === 'src/main.mjs'));
244
+ assert.equal(existsSync(marker), false);
245
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
246
+ });
247
+
248
+ test('gate recusa split-index sem alterar indice ou sharedindex', t => {
249
+ const f = fixture(t); f.git('add', 'src/main.mjs'); f.git('update-index', '--split-index');
250
+ const sharedPath = f.git('rev-parse', '--shared-index-path').trim();
251
+ assert.ok(sharedPath);
252
+ const shared = resolve(f.root, sharedPath), before = statSync(shared, { bigint: true });
253
+ const index = readFileSync(join(f.root, '.git/index'));
254
+ assert.throws(() => prepareReview({ root: f.root, id: 'split', execution: 'case', scope: ['src/main.mjs'] }), /Split-index Git nao suportado/);
255
+ const after = statSync(shared, { bigint: true });
256
+ assert.equal(after.mtimeNs, before.mtimeNs); assert.equal(after.ctimeNs, before.ctimeNs);
257
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
258
+ });
259
+
260
+ test('coleta automatica recusa flags ocultas sem modifica-las', async t => {
261
+ for (const flag of ['--assume-unchanged', '--skip-worktree']) await t.test(flag, t => {
262
+ const f = fixture(t); f.git('add', 'src/main.mjs'); f.git('update-index', flag, 'src/main.mjs');
263
+ f.put('src/main.mjs', 'export const value=2;\n');
264
+ const index = readFileSync(join(f.root, '.git/index'));
265
+ assert.throws(() => prepareReview({ root: f.root, id: 'hidden', execution: 'case' }), /flags que ocultam alteracoes.*--scope/);
266
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
267
+ });
268
+ });
269
+
270
+ test('gate recusa flags que ocultam diferencas entre indice e arquivo', async t => {
271
+ for (const [name, options] of [
272
+ ['assume-unchanged', ['--assume-unchanged']],
273
+ ['skip-worktree', ['--skip-worktree']],
274
+ ['ambas', ['--assume-unchanged', '--skip-worktree']],
275
+ ]) await t.test(name, t => {
276
+ const f = approved(t, {}, f => {
277
+ f.git('add', 'src/main.mjs');
278
+ for (const option of options) f.git('update-index', option, 'src/main.mjs');
279
+ f.put('src/main.mjs', 'export const value=2;\n');
280
+ }, false);
281
+ // Git itself reports equality here even though staged and working differ.
282
+ f.git('--literal-pathspecs', 'diff', '--quiet', '--no-ext-diff', '--', 'src/main.mjs');
283
+ const index = readFileSync(join(f.root, '.git/index'));
284
+ const flags = f.git('-c', 'core.fsmonitor=false', 'ls-files', '-v', '--', 'src/main.mjs');
285
+ const staged = f.git('show', ':src/main.mjs');
286
+ assert.notEqual(staged, readFileSync(join(f.root, 'src/main.mjs'), 'utf8'));
287
+ assert.throws(() => sealReview(f.file, { root: f.root }), /assume-unchanged ou skip-worktree/);
288
+ assert.throws(() => checkReview(f.file, { root: f.root }), /assume-unchanged ou skip-worktree/);
289
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
290
+ assert.equal(f.git('-c', 'core.fsmonitor=false', 'ls-files', '-v', '--', 'src/main.mjs'), flags);
291
+ assert.equal(f.git('show', ':src/main.mjs'), staged);
292
+ });
293
+ });
294
+
295
+ test('gate aceita delecao preparada sem entrada no indice', t => {
296
+ const f = approved(t, {}, f => {
297
+ f.git('add', '.');
298
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'test: base');
299
+ f.git('rm', '-q', 'src/main.mjs');
300
+ }, false);
301
+ const index = readFileSync(join(f.root, '.git/index'));
302
+ assert.equal(sealReview(f.file, { root: f.root }).status, 'passed');
303
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
304
+ });
305
+
306
+ test('gate recusa multiplas entradas de indice em conflito', t => {
307
+ const f = approved(t, {}, f => {
308
+ const commit = message => f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', message);
309
+ f.git('add', '.'); commit('test: base');
310
+ const base = f.git('symbolic-ref', '--short', 'HEAD').trim();
311
+ f.git('checkout', '-q', '-b', 'fixture-conflict');
312
+ f.put('src/main.mjs', 'export const value=2;\n'); f.git('add', 'src/main.mjs'); commit('test: feature');
313
+ f.git('checkout', '-q', base);
314
+ f.put('src/main.mjs', 'export const value=3;\n'); f.git('add', 'src/main.mjs'); commit('test: target');
315
+ const merge = spawnSync('git', ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'merge', '--no-commit', 'fixture-conflict'], { cwd: f.root, encoding: 'utf8' });
316
+ assert.equal(merge.status, 1, merge.stderr);
317
+ }, false);
318
+ const index = readFileSync(join(f.root, '.git/index'));
319
+ assert.throws(() => sealReview(f.file, { root: f.root }), /Flags do indice Git ambiguas/);
320
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
321
+ });
322
+
323
+ test('gate recusa nomes Git UTF-8 invalidos antes de aprovar ou gerar relatorio', t => {
324
+ if (process.platform === 'win32') return t.skip('Nomes Git arbitrarios em bytes nao sao representaveis no filesystem Windows.');
325
+ const f = approved(t, {}, () => {}, false);
326
+ f.put('collision\ufffd.txt', 'arquivo humano valido\n'); f.git('add', 'collision\ufffd.txt');
327
+ const blob = spawnSync('git', ['hash-object', '-w', '--stdin'], { cwd: f.root, input: 'arquivo de teste\n', encoding: 'utf8' });
328
+ assert.equal(blob.status, 0, blob.stderr);
329
+ const invalidName = Buffer.concat([Buffer.from('collision'), Buffer.from([0xff]), Buffer.from('.txt')]);
330
+ const entry = Buffer.concat([Buffer.from(`100644 ${blob.stdout.trim()}\t`), invalidName, Buffer.from([0])]);
331
+ const update = spawnSync('git', ['update-index', '-z', '--index-info'], { cwd: f.root, input: entry });
332
+ assert.equal(update.status, 0, update.stderr?.toString());
333
+ const index = readFileSync(join(f.root, '.git/index'));
334
+ assert.throws(() => prepareReview({ root: f.root, id: 'invalido', execution: 'case' }), /UTF-8 valido/);
335
+ assert.equal(existsSync(join(f.root, '.agent-tests/invalido')), false);
336
+ assert.throws(() => sealReview(f.file, { root: f.root }), /UTF-8 valido/);
337
+ assert.throws(() => checkReview(f.file, { root: f.root }), /UTF-8 valido/);
338
+ assert.throws(() => checkCommit([f.file], { root: f.root }), /UTF-8 valido/);
339
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
340
+ });
341
+
342
+ test('caminho com surrogate isolado nao e convertido em outro arquivo', t => {
343
+ const f = fixture(t); f.put('collision\ufffd.txt', 'arquivo humano\n');
344
+ assert.throws(() => prepareReview({ root: f.root, id: 'invalido', scope: ['collision\ud800.txt'] }), /Caminho invalido/);
345
+ assert.equal(readFileSync(join(f.root, 'collision\ufffd.txt'), 'utf8'), 'arquivo humano\n');
346
+ });
347
+
348
+ test('nome iniciado por BOM e preservado em staged e na consulta de ignores', async t => {
349
+ await t.test('staged', t => {
350
+ const name = '\ufeffbom.txt';
351
+ const f = approved(t, { scope: [name] }, f => {
352
+ f.put(name, 'conteudo revisado\n');
353
+ f.put('bom.txt', 'outro arquivo humano\n');
354
+ f.git('add', '--', name);
355
+ }, false);
356
+ const index = readFileSync(join(f.root, '.git/index'));
357
+ assert.ok(f.report.snapshot.files.some(file => file.path === name));
358
+ assert.equal(sealReview(f.file, { root: f.root }).status, 'passed');
359
+ assert.equal(checkCommit([f.file], { root: f.root }).status, 'passed');
360
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
361
+ assert.equal(readFileSync(join(f.root, 'bom.txt'), 'utf8'), 'outro arquivo humano\n');
362
+ });
363
+ await t.test('ignorado', t => {
364
+ const f = fixture(t), name = '\ufeffignored.md';
365
+ f.put('.gitignore', `/.agent-tests/\n/${name}\n`);
366
+ f.put(name, '# Ignorado\n');
367
+ f.put('ignored.md', '# Documento versionavel\n');
368
+ const made = prepareReview({ root: f.root, id: 'bom', execution: 'case', scope: [name, 'ignored.md'] });
369
+ assert.ok(!made.report.documentation.some(file => file.path === name));
370
+ assert.ok(made.report.documentation.some(file => file.path === 'ignored.md'));
371
+ });
372
+ });
373
+
374
+ test('check-commit bloqueia staged humano fora da revisao sem altera-lo', t => {
375
+ const f = approved(t, {}, f => {
376
+ f.git('add', 'src/main.mjs');
377
+ f.put('humano extra.txt', 'arquivo preparado por outra pessoa\n');
378
+ f.git('add', 'humano extra.txt');
379
+ }, false);
380
+ sealReview(f.file, { root: f.root });
381
+ assert.equal(checkReview(f.file, { root: f.root }).status, 'passed');
382
+ const index = readFileSync(join(f.root, '.git/index'));
383
+ const content = readFileSync(join(f.root, 'humano extra.txt'));
384
+ assert.throws(() => checkCommit([f.file], { root: f.root }), /Arquivos preparados sem revisao selada: humano extra\.txt/);
385
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
386
+ assert.deepEqual(readFileSync(join(f.root, 'humano extra.txt')), content);
387
+ });
388
+
389
+ test('check-commit aceita cobertura unida de relatorios selados', t => {
390
+ const f = approved(t, {}, f => {
391
+ f.git('add', 'src/main.mjs'); f.put('extra.txt', 'conteudo\n'); f.git('add', 'extra.txt');
392
+ }, false);
393
+ sealReview(f.file, { root: f.root });
394
+ const extra = prepareReview({ root: f.root, id: 'extra', execution: 'exec', scope: ['extra.txt'] });
395
+ for (const item of extra.report.documentation) Object.assign(item, { decision: 'no-impact', reason: 'Documento examinado na fixture.' });
396
+ const proof = '.agent-tests/extra/exec/proof.txt'; f.put(proof, 'Prova sintetica complementar.\n');
397
+ for (const item of extra.report.validation) Object.assign(item, { method: 'fixture', prerequisite: 'Git local', assertion: 'Conteudo revisado', evidence: [proof], status: 'passed' });
398
+ extra.report.review = { status: 'approved', reviewer: 'simulacao de teste', findings: [] };
399
+ f.put(extra.file, JSON.stringify(extra.report)); sealReview(extra.file, { root: f.root });
400
+ const index = readFileSync(join(f.root, '.git/index'));
401
+ const result = checkCommit([f.file, extra.file], { root: f.root });
402
+ assert.equal(result.status, 'passed'); assert.equal(result.reports, 2); assert.equal(result.staged, 2);
403
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
404
+ assert.throws(() => checkCommit([f.file, `./${f.file}`], { root: f.root }), /sem repeticao/);
405
+ });
406
+
407
+ test('check-commit na CLI aplica a mesma protecao de staged extra', t => {
408
+ const f = approved(t, {}, f => f.git('add', 'src/main.mjs'), false);
409
+ sealReview(f.file, { root: f.root });
410
+ const script = '.pbai4devs/scripts/revisar-entrega/SCRIPT.mjs', runtime = '.pbai4devs/scripts/lib/runtime.mjs';
411
+ for (const file of [script, runtime]) { mkdirSync(dirname(join(f.root, file)), { recursive: true }); cpSync(join(projectRoot, file), join(f.root, file)); }
412
+ const args = [join(f.root, script), 'check-commit', f.file];
413
+ let run = spawnSync(process.execPath, args, { cwd: f.root, encoding: 'utf8' });
414
+ assert.equal(run.status, 0, run.stderr); assert.equal(JSON.parse(run.stdout).status, 'passed');
415
+ f.put('extra.txt', 'preparado\n'); f.git('add', 'extra.txt');
416
+ const index = readFileSync(join(f.root, '.git/index'));
417
+ run = spawnSync(process.execPath, args, { cwd: f.root, encoding: 'utf8' });
418
+ assert.equal(run.status, 1); assert.match(run.stderr, /sem revisao selada: extra\.txt/);
419
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
420
+ });
421
+
422
+ test('check-commit recusa nome preparado com quebra de linha', t => {
423
+ if (process.platform === 'win32') return t.skip('Quebras de linha nao sao nomes de arquivo validos no Windows.');
424
+ const f = approved(t, {}, f => f.git('add', 'src/main.mjs'), false);
425
+ sealReview(f.file, { root: f.root });
426
+ f.put('line\nbreak.txt', 'conteudo humano\n'); f.git('add', 'line\nbreak.txt');
427
+ const index = readFileSync(join(f.root, '.git/index'));
428
+ assert.throws(() => checkCommit([f.file], { root: f.root }), /Caminho invalido/);
429
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
430
+ });
431
+
432
+ test('check-commit exige as duas pontas de renomeacao preparada', async t => {
433
+ for (const [name, scope, expected] of [
434
+ ['cobertura completa', ['src/main.mjs', 'src/new name.mjs'], true],
435
+ ['origem ausente', ['src/new name.mjs'], false],
436
+ ]) await t.test(name, t => {
437
+ const f = approved(t, { scope }, f => {
438
+ f.git('add', '.');
439
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'test: base');
440
+ f.git('mv', 'src/main.mjs', 'src/new name.mjs');
441
+ }, false);
442
+ sealReview(f.file, { root: f.root });
443
+ const index = readFileSync(join(f.root, '.git/index'));
444
+ if (expected) assert.equal(checkCommit([f.file], { root: f.root }).staged, 2);
445
+ else assert.throws(() => checkCommit([f.file], { root: f.root }), /sem revisao selada: src\/main\.mjs/);
446
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
447
+ });
448
+ });
449
+
450
+ test('check-commit rejeita modo executavel staged apos o selo', t => {
451
+ const f = approved(t, {}, f => {
452
+ f.git('add', '.');
453
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'test: base');
454
+ }, false);
455
+ sealReview(f.file, { root: f.root });
456
+ f.git('config', 'core.filemode', 'false');
457
+ f.git('update-index', '--chmod=+x', 'src/main.mjs');
458
+ assert.equal(checkReview(f.file, { root: f.root }).status, 'passed');
459
+ const index = readFileSync(join(f.root, '.git/index'));
460
+ assert.throws(() => checkCommit([f.file], { root: f.root }), /modo preparado nao comprovado: src\/main\.mjs/);
461
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
462
+ });
463
+
464
+ test('gate rejeita blob transformado por clean filter sem executar filtro na verificacao', t => {
465
+ const f = approved(t, {}, f => {
466
+ f.git('add', '.');
467
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'test: base');
468
+ f.put('src/main.mjs', 'export const value=2;\n');
469
+ }, false);
470
+ sealReview(f.file, { root: f.root });
471
+ const executable = process.execPath.replaceAll('\\', '/');
472
+ const clean = `"${executable}" -e "require('fs').writeFileSync('filter-marker','hit');process.stdin.on('data',x=>process.stdout.write(x.toString().toUpperCase()))"`;
473
+ f.git('config', 'filter.fixture.clean', clean);
474
+ f.put('.git/info/attributes', 'src/main.mjs filter=fixture\n');
475
+ f.git('add', 'src/main.mjs');
476
+ const marker = join(f.root, 'filter-marker'); assert.equal(existsSync(marker), true); rmSync(marker);
477
+ const index = readFileSync(join(f.root, '.git/index'));
478
+ assert.throws(() => checkReview(f.file, { root: f.root }), /Indice Git difere dos bytes testados.*filtros e CRLF/);
479
+ assert.throws(() => checkCommit([f.file], { root: f.root }), /Indice Git difere dos bytes testados.*filtros e CRLF/);
480
+ assert.equal(existsSync(marker), false);
481
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
482
+ });
483
+
484
+ test('gate aceita blob identico com clean filter configurado sem executar filtro', t => {
485
+ const f = approved(t, {}, f => {
486
+ const executable = process.execPath.replaceAll('\\', '/');
487
+ const clean = `"${executable}" -e "require('fs').writeFileSync('filter-marker','hit');process.stdin.pipe(process.stdout)"`;
488
+ f.git('config', 'filter.fixture.clean', clean);
489
+ f.put('.git/info/attributes', 'src/main.mjs filter=fixture\n');
490
+ f.git('add', 'src/main.mjs');
491
+ }, false);
492
+ const marker = join(f.root, 'filter-marker'); assert.equal(existsSync(marker), true); rmSync(marker);
493
+ const index = readFileSync(join(f.root, '.git/index'));
494
+ assert.equal(sealReview(f.file, { root: f.root }).status, 'passed');
495
+ assert.equal(checkCommit([f.file], { root: f.root }).status, 'passed');
496
+ assert.equal(existsSync(marker), false);
497
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
498
+ });
499
+
500
+ test('gate bloqueia staged CRLF normalizado quando difere dos bytes revisados', t => {
501
+ const f = approved(t, {}, f => {
502
+ f.git('add', '.');
503
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'test: base');
504
+ f.put('src/main.mjs', 'export const value=2;\r\n');
505
+ }, false);
506
+ sealReview(f.file, { root: f.root });
507
+ f.put('.git/info/attributes', 'src/main.mjs text eol=crlf\n'); f.git('add', 'src/main.mjs');
508
+ const index = readFileSync(join(f.root, '.git/index'));
509
+ assert.throws(() => checkCommit([f.file], { root: f.root }), /Indice Git difere dos bytes testados.*CRLF/);
510
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
511
+ });
512
+
513
+ test('gate recusa promisor antes de ler blob preparado', t => {
514
+ const f = approved(t, {}, f => f.git('add', 'src/main.mjs'), false);
515
+ f.git('config', 'remote.fixture.promisor', 'true');
516
+ const index = readFileSync(join(f.root, '.git/index'));
517
+ assert.throws(() => sealReview(f.file, { root: f.root }), /Clone parcial ou remote promisor/);
518
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
519
+ });
520
+
521
+ test('consultas do gate nao executam hook fsmonitor configurado', t => {
522
+ const f = fixture(t);
523
+ f.git('add', 'src/main.mjs');
524
+ f.put('.git/fsmonitor-hook', "#!/bin/sh\nprintf hit > .git/fsmonitor-marker\nprintf 'builtin:fake\\0'\n");
525
+ f.git('config', 'core.fsmonitor', 'sh .git/fsmonitor-hook');
526
+ f.git('ls-files', '-z');
527
+ const marker = join(f.root, '.git/fsmonitor-marker');
528
+ assert.equal(existsSync(marker), true, 'controle deve executar o hook'); rmSync(marker);
529
+ const index = readFileSync(join(f.root, '.git/index'));
530
+ const made = prepareReview({ root: f.root, id: 'monitor', execution: 'case', scope: ['src/main.mjs'] });
531
+ for (const item of made.report.documentation) Object.assign(item, { decision: 'no-impact', reason: 'Documento examinado na fixture.' });
532
+ const proof = '.agent-tests/monitor/case/proof.txt'; f.put(proof, 'Prova sintetica.\n');
533
+ for (const item of made.report.validation) Object.assign(item, { method: 'fixture', prerequisite: 'Git local', assertion: 'Blob revisado', evidence: [proof], status: 'passed' });
534
+ made.report.review = { status: 'approved', reviewer: 'simulacao de teste', findings: [] };
535
+ f.put(made.file, JSON.stringify(made.report));
536
+ assert.equal(sealReview(made.file, { root: f.root }).status, 'passed');
537
+ assert.equal(checkReview(made.file, { root: f.root }).status, 'passed');
538
+ assert.equal(checkCommit([made.file], { root: f.root }).status, 'passed');
539
+ assert.equal(existsSync(marker), false);
540
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
541
+ assert.equal(f.git('config', '--get', 'core.fsmonitor').trim(), 'sh .git/fsmonitor-hook');
542
+ });
543
+
544
+ test('diff do gate nao executa hook fsmonitor de submodulo', t => {
545
+ const f = fixture(t);
546
+ const commit = message => f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=disabled-hooks', 'commit', '-q', '-m', message);
547
+ f.git('add', '.'); commit('test: base');
548
+ const source = join(f.root, '.agent-tests/submodule-source'); mkdirSync(source, { recursive: true });
549
+ const env = { ...process.env, GIT_ALLOW_PROTOCOL: 'file', GIT_TERMINAL_PROMPT: '0' };
550
+ const git = (cwd, args) => {
551
+ const run = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: 30000, windowsHide: true });
552
+ assert.equal(run.status, 0, run.stderr); return run.stdout;
553
+ };
554
+ git(source, ['init', '-q']); writeFileSync(join(source, 'data.txt'), 'original\n'); git(source, ['add', '.']);
555
+ git(source, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=disabled-hooks', 'commit', '-q', '-m', 'test: submodule']);
556
+ git(f.root, ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', pathToFileURL(source).href, 'nested']);
557
+ f.git('add', '.gitmodules', 'nested'); commit('test: add submodule');
558
+ f.put('nested/monitor.sh', "#!/bin/sh\nprintf hit > monitor-marker\nprintf 'builtin:fake\\0'\n");
559
+ f.git('-C', 'nested', 'config', 'core.fsmonitor', 'sh monitor.sh');
560
+ f.git('config', 'diff.ignoreSubmodules', 'none');
561
+ f.git('-C', 'nested', 'ls-files', '-z');
562
+ const marker = join(f.root, 'nested/monitor-marker');
563
+ assert.equal(existsSync(marker), true, 'controle deve executar o hook do submodulo'); rmSync(marker);
564
+ f.put('nested/data.txt', 'alterado\n');
565
+ const index = readFileSync(join(f.root, '.git/index'));
566
+ assert.throws(() => prepareReview({ root: f.root, id: 'nested', execution: 'case' }), /Coleta automatica nao suporta submodulo.*use --scope/);
567
+ assert.equal(existsSync(marker), false);
568
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
569
+ });
570
+
571
+ test('todos os gates recusam partial clone antes de buscar objetos', t => {
572
+ mkdirSync(join(projectRoot, '.agent-tests'), { recursive: true });
573
+ const base = mkdtempSync(join(projectRoot, '.agent-tests/partial-gate-'));
574
+ t.after(() => rmSync(base, { recursive: true, force: true }));
575
+ const source = join(base, 'source'), remote = join(base, 'remote.git'), target = join(base, 'clone');
576
+ mkdirSync(source);
577
+ const emptyConfig = join(base, 'empty-git-config'); writeFileSync(emptyConfig, '');
578
+ const env = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: emptyConfig, GIT_ALLOW_PROTOCOL: 'file', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' };
579
+ const git = (cwd, args) => {
580
+ const run = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: 30000, windowsHide: true });
581
+ assert.equal(run.status, 0, run.stderr); return run.stdout;
582
+ };
583
+ git(source, ['init', '-q']);
584
+ writeFileSync(join(source, 'README.md'), '# Fixture\n'); writeFileSync(join(source, 'data.txt'), 'original\n');
585
+ git(source, ['add', '.']);
586
+ git(source, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=disabled-hooks', 'commit', '-q', '-m', 'test: base']);
587
+ git(base, ['clone', '--bare', '--quiet', source, remote]);
588
+ git(base, ['--git-dir', remote, 'config', 'uploadpack.allowFilter', 'true']);
589
+ git(base, ['clone', '--quiet', '--no-checkout', '--filter=tree:0', '--no-local', pathToFileURL(remote).href, target]);
590
+ writeFileSync(join(target, '.gitignore'), '/.agent-tests/\n'); writeFileSync(join(target, 'data.txt'), 'working\n');
591
+ const packDir = join(target, '.git/objects/pack');
592
+ const packs = () => Object.fromEntries(readdirSync(packDir).sort().map(name => [name, readFileSync(join(packDir, name))]));
593
+ const before = packs(), index = join(target, '.git/index'), hadIndex = existsSync(index);
594
+ const moduleUrl = new URL('../SCRIPT.mjs', import.meta.url).href;
595
+ const code = `import {prepareReview,sealReview,checkReview,checkCommit,checkMerge} from ${JSON.stringify(moduleUrl)};
596
+ const root=${JSON.stringify(target)}, file='.agent-tests/no/revisao.json';
597
+ const calls=[()=>prepareReview({root,id:'parcial',execution:'prova',scope:['data.txt']}),()=>sealReview(file,{root}),()=>checkReview(file,{root}),()=>checkCommit([file],{root}),()=>checkMerge([file],'refs/heads/main',{root})];
598
+ process.stdout.write(JSON.stringify(calls.map(call=>{try{call();return 'passed'}catch(error){return error.message}})));`;
599
+ const run = spawnSync(process.execPath, ['--input-type=module', '-e', code], { cwd: target, env, encoding: 'utf8', timeout: 30000, windowsHide: true });
600
+ assert.equal(run.status, 0, run.stderr);
601
+ const messages = JSON.parse(run.stdout);
602
+ assert.equal(messages.length, 5);
603
+ for (const message of messages) assert.match(message, /Clone parcial ou remote promisor/);
604
+ assert.deepEqual(packs(), before);
605
+ assert.equal(existsSync(index), hadIndex);
606
+ assert.equal(existsSync(join(target, '.agent-tests/parcial')), false);
607
+ });
608
+
609
+ test('check-commit nao oculta submodulo por configuracao Git', t => {
610
+ const f = approved(t, {}, f => {
611
+ f.git('add', '.');
612
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'test: base');
613
+ f.put('src/main.mjs', 'export const value=2;\n'); f.git('add', 'src/main.mjs');
614
+ }, false);
615
+ sealReview(f.file, { root: f.root });
616
+ const object = f.git('rev-parse', 'HEAD').trim();
617
+ f.git('update-index', '--add', '--cacheinfo', `160000,${object},vendor`);
618
+ f.git('config', 'diff.ignoreSubmodules', 'all');
619
+ const index = readFileSync(join(f.root, '.git/index'));
620
+ assert.throws(() => checkCommit([f.file], { root: f.root }), /modo preparado nao comprovado: vendor/);
621
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
622
+ });
623
+
169
624
  test('alterar evidencia apos a aprovacao invalida o selo', t => {
170
625
  const f = approved(t); f.put(f.proof, 'TESTE FALHOU\n');
171
626
  assert.throws(() => checkReview(f.file, { root: f.root }), /apos selar/);
@@ -205,6 +660,10 @@ test('desindexação real registra ação e hash físico sem exigir documento lo
205
660
  assert.ok(existsSync(join(f.root, 'generated.md')));
206
661
  assert.equal(f.git('ls-files', 'generated.md'), '');
207
662
  assert.equal(checkReview(f.file, { root: f.root }).status, 'passed');
663
+ const index = readFileSync(join(f.root, '.git/index')), content = readFileSync(join(f.root, 'generated.md'));
664
+ assert.equal(checkCommit([f.file], { root: f.root }).status, 'passed');
665
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index);
666
+ assert.deepEqual(readFileSync(join(f.root, 'generated.md')), content);
208
667
  });
209
668
  });
210
669
 
@@ -247,3 +706,113 @@ test('documentos rastreados permanecem obrigatórios mesmo com regra de ignore',
247
706
  assert.ok(f.report.documentation.some(item => item.path === 'spec.md'));
248
707
  assert.equal(checkReview(f.file, { root: f.root }).status, 'passed');
249
708
  });
709
+
710
+ function mergeFixture(t, { scope = ['src/main.mjs', 'src/old.txt', 'src/new.txt'], dirty = false, seal = true, ignoredSubmodule = false } = {}) {
711
+ const f = fixture(t);
712
+ const commit = message => f.git('-c', 'user.name=Test User', '-c', 'user.email=test@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=disabled-hooks', 'commit', '-q', '-m', message);
713
+ f.put('src/old.txt', 'original\n'); f.git('add', '.'); commit('test: base');
714
+ f.git('branch', '-M', 'main'); f.git('checkout', '-q', '-b', 'feature');
715
+ f.put('src/main.mjs', 'export const value=2;\n');
716
+ f.git('mv', 'src/old.txt', 'src/new.txt'); f.git('add', 'src/main.mjs'); commit('test: source');
717
+ if (ignoredSubmodule) {
718
+ const object = f.git('rev-parse', 'HEAD').trim();
719
+ f.git('update-index', '--add', '--cacheinfo', `160000,${object},vendor`);
720
+ commit('test: submodule');
721
+ f.git('config', 'diff.ignoreSubmodules', 'all');
722
+ }
723
+ if (dirty) f.put('src/main.mjs', 'export const value=3; // not committed\n');
724
+ const made = prepareReview({ root: f.root, id: 'merge', execution: 'review', scope });
725
+ for (const item of made.report.documentation) Object.assign(item, { decision: 'no-impact', reason: 'Documento examinado na fixture.' });
726
+ const proof = '.agent-tests/merge/review/proof.txt'; f.put(proof, 'Prova sintetica para testar validacao.\n');
727
+ for (const item of made.report.validation) Object.assign(item, { method: 'fixture', prerequisite: 'fixture local', assertion: 'snapshot comparado', evidence: [proof], status: 'passed' });
728
+ made.report.review = { status: 'approved', reviewer: 'simulacao de teste', findings: [] };
729
+ f.put(made.file, JSON.stringify(made.report));
730
+ if (seal) sealReview(made.file, { root: f.root });
731
+ return { ...f, ...made, commit };
732
+ }
733
+
734
+ test('check-merge prova fast-forward completo sem alterar indice ou arquivos humanos', t => {
735
+ const f = mergeFixture(t);
736
+ f.put('human.txt', 'staged humano\n'); f.git('add', 'human.txt'); f.put('human.txt', 'alteracao humana\n');
737
+ const index = readFileSync(join(f.root, '.git/index')), human = readFileSync(join(f.root, 'human.txt'));
738
+ const result = checkMerge([f.file], 'refs/heads/main', { root: f.root });
739
+ assert.equal(result.status, 'passed'); assert.equal(result.mode, 'fast-forward'); assert.equal(result.changes, 3);
740
+ assert.equal(result.source, f.git('rev-parse', 'HEAD').trim()); assert.equal(result.target, f.git('rev-parse', 'main').trim());
741
+ assert.deepEqual(readFileSync(join(f.root, '.git/index')), index); assert.deepEqual(readFileSync(join(f.root, 'human.txt')), human);
742
+ });
743
+
744
+ test('check-merge funciona na CLI e rejeita relatorio duplicado por alias', t => {
745
+ const f = mergeFixture(t);
746
+ const script = '.pbai4devs/scripts/revisar-entrega/SCRIPT.mjs', runtime = '.pbai4devs/scripts/lib/runtime.mjs';
747
+ for (const file of [script, runtime]) { mkdirSync(dirname(join(f.root, file)), { recursive: true }); cpSync(join(projectRoot, file), join(f.root, file)); }
748
+ const args = [join(f.root, script), 'check-merge', '--destino', 'refs/heads/main', f.file];
749
+ const run = spawnSync(process.execPath, args, { cwd: f.root, encoding: 'utf8' });
750
+ assert.equal(run.status, 0, run.stderr); assert.equal(JSON.parse(run.stdout).status, 'passed');
751
+ assert.throws(() => checkMerge([f.file, `./${f.file}`], 'refs/heads/main', { root: f.root }), /sem repeticao/);
752
+ });
753
+
754
+ test('check-merge bloqueia conteudo nao commitado e escopo parcial', async t => {
755
+ await t.test('working tree diferente do commit', t => {
756
+ const f = mergeFixture(t, { dirty: true });
757
+ assert.equal(checkReview(f.file, { root: f.root }).status, 'passed');
758
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /difere do commit/);
759
+ });
760
+ await t.test('omissao de arquivo removido', t => {
761
+ const f = mergeFixture(t, { scope: ['src/main.mjs', 'src/new.txt'] });
762
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /sem revisao selada: src\/old.txt/);
763
+ });
764
+ await t.test('relatorio sem selo', t => {
765
+ const f = mergeFixture(t, { seal: false });
766
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /nao selada/);
767
+ });
768
+ });
769
+
770
+ test('check-merge aceita cobertura distribuida por relatorios selados', t => {
771
+ const f = mergeFixture(t, { scope: ['src/main.mjs', 'src/new.txt'] });
772
+ const made = prepareReview({ root: f.root, id: 'merge-extra', execution: 'review', scope: ['src/old.txt'] });
773
+ for (const item of made.report.documentation) Object.assign(item, { decision: 'no-impact', reason: 'Documento examinado na fixture.' });
774
+ const proof = '.agent-tests/merge-extra/review/proof.txt'; f.put(proof, 'Prova sintetica complementar.\n');
775
+ for (const item of made.report.validation) Object.assign(item, { method: 'fixture', prerequisite: 'fixture local', assertion: 'delecao revisada', evidence: [proof], status: 'passed' });
776
+ made.report.review = { status: 'approved', reviewer: 'simulacao de teste', findings: [] };
777
+ f.put(made.file, JSON.stringify(made.report)); sealReview(made.file, { root: f.root });
778
+ const result = checkMerge([f.file, made.file], 'refs/heads/main', { root: f.root });
779
+ assert.equal(result.status, 'passed'); assert.equal(result.reports, 2); assert.equal(result.changes, 3);
780
+ });
781
+
782
+ test('check-merge recusa origem e destino invalidos, divergencia e tipos nao comprovados', async t => {
783
+ await t.test('branch invalida ou identica', t => {
784
+ const f = mergeFixture(t);
785
+ for (const name of ['main', 'refs/heads/missing', 'refs/heads/feature', '--help']) assert.throws(() => checkMerge([f.file], name, { root: f.root }));
786
+ });
787
+ await t.test('historico divergente', t => {
788
+ const f = mergeFixture(t); f.git('checkout', '-q', 'main'); f.put('different.txt', 'destino\n'); f.git('add', 'different.txt'); f.commit('test: target'); f.git('checkout', '-q', 'feature');
789
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /fast-forward/);
790
+ });
791
+ await t.test('HEAD destacado', t => {
792
+ const f = mergeFixture(t); f.git('checkout', '-q', '--detach');
793
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /HEAD destacado/);
794
+ });
795
+ await t.test('origem fora das branches locais', t => {
796
+ const f = mergeFixture(t); f.git('symbolic-ref', 'HEAD', 'refs/remotes/origin/feature');
797
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /origem deve ser uma branch local/);
798
+ });
799
+ await t.test('submodulo ignorado pela configuracao Git', t => {
800
+ const f = mergeFixture(t, { ignoredSubmodule: true });
801
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /modo nao comprovado: vendor/);
802
+ const script = '.pbai4devs/scripts/revisar-entrega/SCRIPT.mjs', runtime = '.pbai4devs/scripts/lib/runtime.mjs';
803
+ for (const file of [script, runtime]) { mkdirSync(dirname(join(f.root, file)), { recursive: true }); cpSync(join(projectRoot, file), join(f.root, file)); }
804
+ const run = spawnSync(process.execPath, [join(f.root, script), 'check-merge', '--destino', 'refs/heads/main', f.file], { cwd: f.root, encoding: 'utf8' });
805
+ assert.equal(run.status, 1); assert.match(run.stderr, /modo nao comprovado: vendor/);
806
+ });
807
+ await t.test('link simbolico', t => {
808
+ const f = mergeFixture(t);
809
+ try { symlinkSync('main.mjs', join(f.root, 'src/link')); }
810
+ catch (error) { if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) return t.skip('Sem permissao de symlink.'); throw error; }
811
+ f.git('add', 'src/link'); f.commit('test: link');
812
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /simbolico|modo nao comprovado/);
813
+ });
814
+ await t.test('arquivo executavel', t => {
815
+ const f = mergeFixture(t); f.git('update-index', '--chmod=+x', 'src/main.mjs'); f.commit('test: mode');
816
+ assert.throws(() => checkMerge([f.file], 'refs/heads/main', { root: f.root }), /modo nao comprovado/);
817
+ });
818
+ });
@@ -9,8 +9,10 @@ Confira status, diff, alterações previamente preparadas e identidades efetivas
9
9
  com `git var GIT_AUTHOR_IDENT` e `git var GIT_COMMITTER_IDENT`. Siga a regra
10
10
  compartilhada `.pbai4devs/rules/git/RULE.md` para identidade, mensagem e assinatura.
11
11
  Prepare somente arquivos autorizados e avaliados na revisão da entrega. Após
12
- preparar o índice, execute `revisar-entrega check` para alterações de implementação;
13
- o índice deve corresponder aos arquivos testados. Crie o commit somente com
14
- critérios, documentação e revisão aprovados. Confira autor, committer e mensagem;
12
+ preparar o índice, execute `revisar-entrega check-commit <relatorio>
13
+ [outros-relatorios...]`: todos os arquivos staged devem ter revisão selada.
14
+ Se falhar ou o índice mudar, interrompa e refaça a verificação. Crie o commit
15
+ somente com critérios, documentação e revisão aprovados. Confira autor,
16
+ committer e mensagem;
15
17
  não inclua trabalho
16
18
  incompleto ou alterações alheias.
@@ -8,6 +8,29 @@ description: Integra branches com trabalho concluído e verificado.
8
8
  Confira origem, destino, histórico, identidades, mensagens, alterações locais e
9
9
  verificações; integre somente tarefas completas conforme AGENTS.md. Se os
10
10
  commits a integrar identificarem agentes como autores ou coautores, informe e
11
- não os integre. Se gerar commit de merge, confira as identidades efetivas e siga
11
+ não os integre.
12
+
13
+ Quando houver implementação ou gate exigido, na origem execute isoladamente:
14
+
15
+ ```sh
16
+ node .pbai4devs/scripts/revisar-entrega/SCRIPT.mjs check-merge \
17
+ --destino refs/heads/<branch> <revisao.json> [outros...]
18
+ ```
19
+
20
+ O check exige fast-forward, cobertura de todo o diff e snapshots correspondentes
21
+ ao commit de origem; qualquer falha bloqueia a integração. Registre os hashes
22
+ de origem e destino retornados e confirme ambos imediatamente antes do merge,
23
+ com comandos que interrompam a execução em qualquer falha; não mascare o status.
24
+ Confirme também que `git symbolic-ref HEAD` é a ref de destino e que o HEAD
25
+ corresponde ao hash de destino validado. Se alguma conferência falhar,
26
+ interrompa; se os hashes mudarem, refaça a revisão e o check. Integre com
27
+ `git merge --ff-only <hash-da-origem-validada>`. Essas verificações não são
28
+ atômicas. O check recusa links, submódulos, arquivos executáveis e saídas Git
29
+ maiores que 64 MiB; informe bloqueios sem contorná-los. Preserve trabalho fora
30
+ do escopo.
31
+ Se o resultado integrado divergir do conteúdo revisado, faça nova revisão e
32
+ verificações antes de concluir. Sem implementação, siga os critérios aplicáveis.
33
+
34
+ Se gerar commit de merge, confira as identidades efetivas e siga
12
35
  `.pbai4devs/rules/git/RULE.md` para identidade, mensagem e assinatura. Preserve
13
36
  alterações, resolva conflitos no escopo autorizado e respeite proteções da branch.
@@ -15,7 +15,9 @@ Reutilize nomes, formatos, decisões, links e estrutura existentes; um documento
15
15
  como `ARCHITECTURE.md` pode cumprir o papel de design sem ser renomeado.
16
16
  Organize primeiro por índices e links. Não apague, mova ou substitua arquivos
17
17
  para padronizá-los. Acrescente apenas fatos comprovados e preserve o conteúdo
18
- anterior; trate contradições com o usuário antes de editar.
18
+ anterior. Se houver contradição relevante sem solução nas instruções ou
19
+ evidências disponíveis, apresente os fatos e pergunte ao usuário; aguarde a
20
+ resposta antes de editar o documento afetado. Continue tarefas independentes.
19
21
 
20
22
  Use o [catálogo](../../../templates/README.md) como lista de lacunas, nunca como
21
23
  substituto de um documento existente. Crie somente o que a fase e o pedido
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ description: Alterações verificáveis por versão do AgentDevKit.
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## 0.0.26 — 2026-11-14
8
+
9
+ - Preserva os bytes do índice nas consultas do gate para os casos suportados e
10
+ recusa configurações que não consegue verificar com segurança.
11
+ - Exige cobertura e comparação dos arquivos preparados em `check-commit`;
12
+ bloqueia flags, nomes, modos e transformações não comprovados.
13
+ - Aguarda a decisão antes de editar documentos com contradições não resolvidas.
14
+
15
+ ## 0.0.25 — 2026-11-14
16
+
17
+ - Verifica a cobertura e o conteúdo revisado antes de um merge fast-forward;
18
+ bloqueia situações que o gate não consegue comprovar.
19
+ - Esclarece a validação na origem e a interrupção do merge quando o gate falha.
20
+ - Amplia as provas de preservação em projetos existentes e explica os limites
21
+ do campo `preserved`. Os projetos de exemplo gerados continuam locais.
22
+
7
23
  ## 0.0.24 — 2026-11-14
8
24
 
9
25
  - Preserva catálogos, configuração e blocos existentes sem comprovação de
@@ -183,9 +183,11 @@ Arquivos mistos não passam a pertencer ao kit. Sua visibilidade para o Git
183
183
  também depende da regra aplicada à pasta do cliente.
184
184
 
185
185
  Links simbólicos preexistentes são preservados sem atravessá-los. O campo
186
- `preserved` informa rotas que continuam sob controle do projeto. Um formato
187
- não suportado ou um bloco alterado é informado; `--force` não permite apagar
188
- instruções humanas para reconstruir um apontamento.
186
+ `preserved` lista caminhos próprios encontrados no planejamento ou em colisões,
187
+ sem inventariar rotas não examinadas. A ausência de um caminho não prova que ele
188
+ foi verificado ou preservado. Um formato não suportado ou um bloco alterado é
189
+ informado; `--force` não permite apagar instruções humanas para reconstruir um
190
+ apontamento.
189
191
 
190
192
  Essa preservação não autoriza trocar arquivos gerenciados por links. Links
191
193
  em caminhos já pertencentes ao kit, no hub, no estado ou em ferramentas
@@ -158,7 +158,10 @@ Não apague pastas inteiras para contornar um conflito.
158
158
 
159
159
  Recursos nativos humanos são preservados, inclusive com `--force` e nomes
160
160
  iguais aos do kit. Links humanos são tratados como caminhos opacos; o gerador
161
- não os atravessa. A CLI informa essas rotas em `preserved` no JSON.
161
+ não os atravessa. No JSON, `preserved` lista caminhos humanos encontrados no
162
+ planejamento ou em colisões; não inventaria rotas não examinadas, como um
163
+ fallback não utilizado. A ausência de um caminho não prova que ele foi
164
+ verificado ou preservado.
162
165
 
163
166
  Não edite ou versione `catalog.json`, `skills.json`, `rules.json` ou
164
167
  `agents.json`: são derivados locais. Configurações nativas e documentos
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pathbit/agentdevkit",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "Shared skills, specifications and workflows for AI coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",