@daniellins/power-claude 0.16.3 → 0.16.4

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.

Potentially problematic release.


This version of @daniellins/power-claude might be problematic. Click here for more details.

@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * build-public-tree.cjs — monta a árvore do repositório PÚBLICO, REPO-ONLY.
5
+ *
6
+ * Um verbo único que: lê a fronteira decidida (Opção B) de UM manifesto, monta a
7
+ * árvore correspondente num diretório alvo, roda o gate de dado pessoal DENTRO
8
+ * da árvore montada — não no repositório privado — e se recusa a declarar
9
+ * sucesso quando qualquer etapa reprova. Story SF15.8; invariantes I1/I2 do
10
+ * ADR-037.
11
+ *
12
+ * ┌─ ESTE ARQUIVO NÃO VIAJA PARA INSTALAÇÃO ─────────────────────────────────┐
13
+ * │ Está no `exclude` de `installer/motor-scripts.json`, mesma classe de │
14
+ * │ `owner-layer.cjs` e `validate-git-vetor.cjs` (que ele importa). │
15
+ * └──────────────────────────────────────────────────────────────────────────┘
16
+ *
17
+ * CLI
18
+ * --out <dir> (obrigatório no modo montagem) diretório alvo
19
+ * --root <dir> (opcional) raiz-fonte; default = raiz deste repo
20
+ * --report <path> (opcional) grava o relatório JSON; default = stdout
21
+ * --verify-only <dir> (modo alternativo) re-audita uma árvore JÁ montada
22
+ * --allow-nonempty-out opt-in explícito para sobrescrever um `--out`
23
+ * existente e não-vazio (fail-closed por default)
24
+ *
25
+ * TODO — dono: `--expect-sha <sha>` (tripwire I3 do ADR-037) é ponto de extensão
26
+ * RESERVADO e NÃO implementado nesta story. I3 só passa a valer da 2ª publicação
27
+ * em diante (não há HEAD remoto para divergir no primeiro push) e pertence ao
28
+ * mecanismo de sincronização contínua (SF15.9+). Quem o implementar estende este
29
+ * verbo; não precisa redesenhá-lo.
30
+ *
31
+ * Node >= 18, sem dependências externas.
32
+ */
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+ const { spawnSync } = require('node:child_process');
37
+
38
+ // I1 — manifesto ÚNICO: as entradas varridas pelo gate vêm por IMPORT, nunca
39
+ // redeclaradas aqui (lição SF13.9/SF13.10 — roster copiado 3×, uma errada).
40
+ const gitVetor = require('./validate-git-vetor.cjs');
41
+
42
+ // As 3 entradas que a Opção B COPIA mas que o gate de branding não VARRE por
43
+ // conteúdo — coisas diferentes, decisões diferentes, não fundidas aqui:
44
+ // • `scripts` — fora do escopo varrido por decisão da SF15.7 (Escopo OUT #1,
45
+ // auto-referência: o próprio validate-branding.js carrega os termos como
46
+ // literal). Fica fora do CONTEÚDO varrido, nunca fora do repositório público.
47
+ // • `LICENSE` — nunca esteve no escopo varrido, por desenho anterior a esta story.
48
+ // • `package.json` — decisão do dono sobre `m9` (2026-08-10): incluído para que
49
+ // o clone vire projeto funcional (bin/ e installer/ dependem dele em runtime).
50
+ const ADDITIONAL_MANIFEST_ENTRIES = ['scripts', 'LICENSE', 'package.json'];
51
+ const PUBLIC_MANIFEST = [...gitVetor.SCAN_TARGETS, ...ADDITIONAL_MANIFEST_ENTRIES];
52
+
53
+ // I2 — o gate é invocado por caminho RELATIVO, com `cwd` na árvore montada.
54
+ // `REPO_ROOT` do gate é `path.resolve(__dirname, '..')`, relativo ao PRÓPRIO
55
+ // arquivo: como o diretório de scripts está dentro do manifesto copiado, o gate
56
+ // reancora na árvore montada sozinho. Caminho absoluto do repositório aqui seria
57
+ // o mutante do falso verde (o gate varreria a fonte, não a montagem).
58
+ const GATE_SCRIPT = 'scripts/validate-git-vetor.cjs';
59
+
60
+ const REPO_ROOT = path.resolve(__dirname, '..');
61
+
62
+ const COBERTURA_GATE =
63
+ `o gate composto varre ${gitVetor.SCAN_TARGETS.length} das ${PUBLIC_MANIFEST.length} entradas do manifesto; ` +
64
+ `${ADDITIONAL_MANIFEST_ENTRIES.join(', ')} não são varridas por conteúdo ` +
65
+ `(auto-referência — SF15.7 Escopo OUT #1). Um sucesso aqui significa "montada e auditada nesse limite", ` +
66
+ `nunca "sem dado pessoal em toda a árvore".`;
67
+
68
+ /* ------------------------------------------------------------------ *
69
+ * Níveis permitidos — DERIVADOS do manifesto em runtime, nunca escritos
70
+ * à mão (escrever os nomes aqui recriaria a 2ª lista que I1 existe para
71
+ * impedir, e reprovaria o AC-1 por cópia literal).
72
+ * ------------------------------------------------------------------ */
73
+ function manifestLevels(manifest) {
74
+ const top = new Set();
75
+ const nested = new Map();
76
+ for (const entry of manifest) {
77
+ const parts = entry.split('/');
78
+ top.add(parts[0]);
79
+ if (parts.length > 1) {
80
+ if (!nested.has(parts[0])) nested.set(parts[0], new Set());
81
+ nested.get(parts[0]).add(parts[1]);
82
+ }
83
+ }
84
+ return { top, nested };
85
+ }
86
+
87
+ function git(root, args) {
88
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
89
+ if (r.error) throw new Error(`git ${args[0]} falhou: ${r.error.message}`);
90
+ if (r.status !== 0) {
91
+ throw new Error(`git ${args[0]} saiu com ${r.status}: ${String(r.stderr || '').trim()}`);
92
+ }
93
+ return String(r.stdout || '');
94
+ }
95
+
96
+ function gitZ(root, args) {
97
+ return git(root, args).split('\0').filter(Boolean);
98
+ }
99
+
100
+ /* ------------------------------------------------------------------ *
101
+ * Checagem estrutural: PRESENÇA de todas as entradas do manifesto E
102
+ * ausência de qualquer coisa a mais, em todo nível onde o manifesto
103
+ * corta. Reusada pelo modo montagem e pelo --verify-only (M5/B1).
104
+ * ------------------------------------------------------------------ */
105
+ function auditStructure(dir) {
106
+ const problemas = [];
107
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
108
+ problemas.push(`diretório não encontrado: ${dir}`);
109
+ return problemas;
110
+ }
111
+
112
+ const { top, nested } = manifestLevels(PUBLIC_MANIFEST);
113
+
114
+ for (const entry of PUBLIC_MANIFEST) {
115
+ if (!fs.existsSync(path.join(dir, ...entry.split('/')))) {
116
+ problemas.push(`entrada do manifesto ausente na árvore montada: ${entry}`);
117
+ }
118
+ }
119
+
120
+ const raiz = fs.readdirSync(dir).sort();
121
+ if (raiz.length === 0) problemas.push(`árvore vazia: nenhuma das ${PUBLIC_MANIFEST.length} entradas do manifesto está presente`);
122
+ for (const nome of raiz) {
123
+ if (!top.has(nome)) problemas.push(`entrada de 1º nível fora do manifesto: ${nome}`);
124
+ }
125
+
126
+ for (const [topSeg, permitidos] of nested) {
127
+ const sub = path.join(dir, topSeg);
128
+ if (!fs.existsSync(sub)) continue;
129
+ for (const nome of fs.readdirSync(sub).sort()) {
130
+ if (!permitidos.has(nome)) {
131
+ problemas.push(`entrada fora do manifesto sob ${topSeg}/: ${topSeg}/${nome}`);
132
+ }
133
+ }
134
+ }
135
+
136
+ return problemas;
137
+ }
138
+
139
+ /* ------------------------------------------------------------------ *
140
+ * Gate composto, com `cwd` na árvore auditada (I2). Código de saída
141
+ * fail-closed: `status === null` (filho morto por sinal) e `error`
142
+ * contam como FALHA — nunca 0 (B4).
143
+ * ------------------------------------------------------------------ */
144
+ function runGate(dir) {
145
+ const r = spawnSync(process.execPath, [GATE_SCRIPT], { cwd: dir, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
146
+ if (r.stdout) process.stdout.write(r.stdout);
147
+ if (r.stderr) process.stderr.write(r.stderr);
148
+ const code = r.error ? 1 : (r.status === 0 ? 0 : (r.status || 1));
149
+ if (r.error) process.stderr.write(`gate não pôde ser executado: ${r.error.message}\n`);
150
+ if (r.status === null && !r.error) {
151
+ process.stderr.write(`gate encerrado por sinal (${r.signal}) — tratado como reprovação, nunca como sucesso.\n`);
152
+ }
153
+ return code;
154
+ }
155
+
156
+ function relatorioExclusoes(root) {
157
+ const { top, nested } = manifestLevels(PUBLIC_MANIFEST);
158
+ const rastreados = gitZ(root, ['ls-files', '-z']);
159
+
160
+ const excluidos = [];
161
+
162
+ const raizVistos = new Set();
163
+ for (const file of rastreados) {
164
+ const seg = file.split('/')[0];
165
+ if (top.has(seg) || raizVistos.has(seg)) continue;
166
+ raizVistos.add(seg);
167
+ excluidos.push({ nivel: 'raiz', path: seg, regra: 'fora das entradas de 1º nível do manifesto público' });
168
+ }
169
+
170
+ for (const [topSeg, permitidos] of nested) {
171
+ const vistos = new Set();
172
+ for (const file of rastreados) {
173
+ const parts = file.split('/');
174
+ if (parts[0] !== topSeg || parts.length < 2) continue;
175
+ if (permitidos.has(parts[1]) || vistos.has(parts[1])) continue;
176
+ vistos.add(parts[1]);
177
+ excluidos.push({
178
+ nivel: `${topSeg}/`,
179
+ path: `${topSeg}/${parts[1]}`,
180
+ regra: `fora das entradas permitidas sob ${topSeg}/ pelo manifesto público`,
181
+ });
182
+ }
183
+ }
184
+
185
+ excluidos.sort((a, b) => (a.nivel + a.path).localeCompare(b.nivel + b.path));
186
+ return excluidos;
187
+ }
188
+
189
+ /* ------------------------------------------------------------------ *
190
+ * Montagem por ÍNDICE do git, nunca por disco (B2). Arquivo não-rastreado
191
+ * (inclusive ignorado) não aparece na enumeração e, por construção, nunca
192
+ * entra na árvore montada.
193
+ * ------------------------------------------------------------------ */
194
+ function copiarDoIndice(root, destino) {
195
+ const arquivos = gitZ(root, ['ls-files', '-z', '--', ...PUBLIC_MANIFEST]);
196
+ for (const rel of arquivos) {
197
+ const origem = path.join(root, rel);
198
+ const alvo = path.join(destino, rel);
199
+ fs.mkdirSync(path.dirname(alvo), { recursive: true });
200
+ fs.copyFileSync(origem, alvo);
201
+ }
202
+ return arquivos;
203
+ }
204
+
205
+ function checarSujeira(root) {
206
+ // `--untracked-files=no` é OBRIGATÓRIO: sem ele, arquivo NUNCA rastreado
207
+ // apareceria aqui e a montagem seria recusada por debris local. A regra é
208
+ // sobre COMMIT pendente, não sobre debris: arquivo rastreado com edição
209
+ // pendente recusa; arquivo não-rastreado é excluído em silêncio pelo índice.
210
+ const linhas = gitZ(root, ['status', '--porcelain', '-z', '--untracked-files=no', '--', ...PUBLIC_MANIFEST]);
211
+ return linhas.map((l) => l.trim()).filter(Boolean);
212
+ }
213
+
214
+ function limpar(alvo) {
215
+ try {
216
+ fs.rmSync(alvo, { recursive: true, force: true });
217
+ } catch (e) {
218
+ /* melhor esforço — a recusa já foi comunicada */
219
+ }
220
+ }
221
+
222
+ function estaDentro(pai, filho) {
223
+ const rel = path.relative(pai, filho);
224
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
225
+ }
226
+
227
+ function dirNaoVazio(dir) {
228
+ return fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length > 0;
229
+ }
230
+
231
+ function parseArgs(argv) {
232
+ const opts = { out: null, root: null, report: null, verifyOnly: null, allowNonEmptyOut: false };
233
+ for (let i = 0; i < argv.length; i++) {
234
+ const a = argv[i];
235
+ if (a === '--out') opts.out = argv[++i];
236
+ else if (a === '--root') opts.root = argv[++i];
237
+ else if (a === '--report') opts.report = argv[++i];
238
+ else if (a === '--verify-only') opts.verifyOnly = argv[++i];
239
+ else if (a === '--allow-nonempty-out') opts.allowNonEmptyOut = true;
240
+ else throw new Error(`argumento desconhecido: ${a}`);
241
+ }
242
+ return opts;
243
+ }
244
+
245
+ /* ------------------------------------------------------------------ *
246
+ * Modo --verify-only: re-audita uma árvore JÁ montada. Estrutura
247
+ * (presença ∧ contenção) primeiro, gate composto depois — auditoria de
248
+ * conteúdo real, não só estrutural (B1/M5).
249
+ * ------------------------------------------------------------------ */
250
+ function verifyOnly(dirArg) {
251
+ const dir = path.resolve(dirArg);
252
+ const problemas = auditStructure(dir);
253
+ if (problemas.length > 0) {
254
+ for (const p of problemas) process.stderr.write(`${p}\n`);
255
+ process.stderr.write(`\n${problemas.length} problema(s) estrutural(is) em ${dir}. Auditoria vermelha.\n`);
256
+ return 1;
257
+ }
258
+ const code = runGate(dir);
259
+ if (code !== 0) {
260
+ process.stderr.write(`\nGate composto reprovou a árvore em ${dir}. Auditoria vermelha.\n`);
261
+ return code;
262
+ }
263
+ process.stdout.write(`Cobertura do gate: ${COBERTURA_GATE}\n`);
264
+ process.stdout.write(`Árvore auditada em ${dir}: contenção, presença e gate composto verdes.\n`);
265
+ return 0;
266
+ }
267
+
268
+ function montar(opts) {
269
+ // ── Validação de argumentos ANTES de criar qualquer `.partial` (M6) ──
270
+ if (!opts.out) throw new Error('--out <dir> é obrigatório (ou use --verify-only <dir>)');
271
+ const root = path.resolve(opts.root || REPO_ROOT);
272
+ const out = path.resolve(opts.out);
273
+ if (!fs.existsSync(root)) throw new Error(`--root não encontrado: ${root}`);
274
+ if (estaDentro(root, out)) {
275
+ process.stderr.write(`--out não pode ficar dentro de --root (auto-cópia): ${out} está sob ${root}\n`);
276
+ return 2;
277
+ }
278
+ if (dirNaoVazio(out) && !opts.allowNonEmptyOut) {
279
+ process.stderr.write(`--out existe e não está vazio: ${out}\n`);
280
+ process.stderr.write('Recusado sem apagar nada. Use --allow-nonempty-out para autorizar a sobrescrita explicitamente.\n');
281
+ return 2;
282
+ }
283
+
284
+ // ── Sujeira sob o manifesto: fail-closed (B2) ──
285
+ const sujos = checarSujeira(root);
286
+ if (sujos.length > 0) {
287
+ process.stderr.write('modificação não-commitada sob o manifesto — o que se publica é um commit, não o estado da mesa de alguém:\n');
288
+ for (const s of sujos) process.stderr.write(` ${s}\n`);
289
+ return 3;
290
+ }
291
+
292
+ const partial = `${out}.partial`;
293
+ limpar(partial);
294
+ fs.mkdirSync(partial, { recursive: true });
295
+
296
+ let arquivos;
297
+ try {
298
+ arquivos = copiarDoIndice(root, partial);
299
+ } catch (e) {
300
+ limpar(partial);
301
+ throw e;
302
+ }
303
+
304
+ // ── Gate composto DENTRO da árvore montada (I2) ──
305
+ const gateCode = runGate(partial);
306
+ if (gateCode !== 0) {
307
+ limpar(partial);
308
+ process.stderr.write(`\nGate composto reprovou a árvore montada a partir de ${root}. Nada foi publicado.\n`);
309
+ return gateCode;
310
+ }
311
+
312
+ // ── Contenção COM presença, sobre a mesma árvore (M5) ──
313
+ const problemas = auditStructure(partial);
314
+ if (problemas.length > 0) {
315
+ for (const p of problemas) process.stderr.write(`${p}\n`);
316
+ limpar(partial);
317
+ process.stderr.write(`\n${problemas.length} problema(s) estrutural(is) na árvore montada. Nada foi publicado.\n`);
318
+ return 1;
319
+ }
320
+
321
+ // ── Só agora o rename: ele É o marcador de conclusão (M6/AC-20). ──
322
+ // `renameSync` sobre um alvo existente e não-vazio falha com ENOTEMPTY, então
323
+ // o alvo é removido antes — só se chegou aqui, ou seja, ou estava vazio, ou
324
+ // --allow-nonempty-out autorizou explicitamente.
325
+ if (fs.existsSync(out)) fs.rmSync(out, { recursive: true, force: true });
326
+ fs.renameSync(partial, out);
327
+
328
+ const relatorio = {
329
+ gerado_em: new Date().toISOString(),
330
+ root,
331
+ out,
332
+ arquivos_copiados: arquivos.length,
333
+ entradas_manifesto: PUBLIC_MANIFEST,
334
+ cobertura_gate: `Cobertura do gate: ${COBERTURA_GATE}`,
335
+ excluidos: relatorioExclusoes(root),
336
+ nao_rastreados_excluidos: gitZ(root, ['ls-files', '--others', '-z', '--', ...PUBLIC_MANIFEST]),
337
+ };
338
+
339
+ const json = JSON.stringify(relatorio, null, 2);
340
+ if (opts.report) {
341
+ fs.mkdirSync(path.dirname(path.resolve(opts.report)), { recursive: true });
342
+ fs.writeFileSync(path.resolve(opts.report), `${json}\n`);
343
+ process.stdout.write(`Relatório em ${path.resolve(opts.report)}\n`);
344
+ } else {
345
+ process.stdout.write(`${json}\n`);
346
+ }
347
+
348
+ process.stdout.write(`Cobertura do gate: ${COBERTURA_GATE}\n`);
349
+ process.stdout.write(`Árvore pública pronta em ${out}.\n`);
350
+ return 0;
351
+ }
352
+
353
+ function main() {
354
+ let code = 1;
355
+ try {
356
+ const opts = parseArgs(process.argv.slice(2));
357
+ if (opts.verifyOnly) {
358
+ if (opts.out) throw new Error('--verify-only e --out são modos distintos; use um de cada vez');
359
+ code = verifyOnly(opts.verifyOnly);
360
+ } else {
361
+ code = montar(opts);
362
+ }
363
+ } catch (e) {
364
+ process.stderr.write(`${e && e.message ? e.message : e}\n`);
365
+ code = 1;
366
+ }
367
+ process.exit(typeof code === 'number' && Number.isInteger(code) ? code : 1);
368
+ }
369
+
370
+ module.exports = { PUBLIC_MANIFEST, ADDITIONAL_MANIFEST_ENTRIES, COBERTURA_GATE };
371
+
372
+ if (require.main === module) main();
@@ -46,6 +46,7 @@
46
46
  // [--since <ref|data>] [--stories-dir <dir>]
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
+ const fs = require('fs');
49
50
  const path = require('path');
50
51
  const { execFileSync } = require('child_process');
51
52
 
@@ -89,6 +90,23 @@ function gitOrNull(repo, args) {
89
90
  }
90
91
  }
91
92
 
93
+ // `F-4` do gate `SF13.8`: "binário `git` ausente do PATH" e "o path não existe /
94
+ // não é repo git" são causas DISTINTAS, e a mensagem antiga tratava as duas como
95
+ // a mesma coisa — acertava por acidente, via o parêntese "(ou o path não existe)".
96
+ // `ENOENT` no spawn é o único sinal que separa as duas: qualquer outro erro
97
+ // significa que o binário existe e falhou por outro motivo.
98
+ function gitBinarioAusente() {
99
+ try {
100
+ execFileSync('git', ['--version'], {
101
+ encoding: 'utf8',
102
+ stdio: ['ignore', 'pipe', 'pipe'],
103
+ });
104
+ return false;
105
+ } catch (err) {
106
+ return Boolean(err) && err.code === 'ENOENT';
107
+ }
108
+ }
109
+
92
110
  // --- CLI ---------------------------------------------------------------
93
111
 
94
112
  function parseArgv(argv) {
@@ -186,7 +204,15 @@ function main(argv) {
186
204
  const repo = path.resolve(opts.repoPath);
187
205
  const topo = gitOrNull(repo, ['rev-parse', '--show-toplevel']);
188
206
  if (topo === null) {
189
- diagnostico(`"${repo}" não é um repositório git acessível (ou o path não existe) — nada a derivar.`);
207
+ // Três causas distintas, nomeadas separadamente (`F-4` do gate `SF13.8`).
208
+ if (gitBinarioAusente()) {
209
+ const pathEnv = process.env.PATH ? process.env.PATH.slice(0, 200) : '(vazia)';
210
+ diagnostico(`binário \`git\` ausente do PATH (PATH=${pathEnv}) — o alvo "${repo}" não foi sequer consultado; sem git não há histórico a derivar.`);
211
+ } else if (!fs.existsSync(repo)) {
212
+ diagnostico(`o path "${repo}" não existe — nada a derivar.`);
213
+ } else {
214
+ diagnostico(`"${repo}" existe e o git está disponível, mas não é um repositório git acessível — nada a derivar.`);
215
+ }
190
216
  return;
191
217
  }
192
218
  const repoRoot = topo.trim();
@@ -310,9 +336,19 @@ function main(argv) {
310
336
  }
311
337
  }
312
338
 
313
- // D4: `exit 0` SEMPRE. A rede é aqui, e é a única — nenhuma rota interna tem
314
- // try/catch próprio para a mesma classe de falha, senão esta nunca seria
315
- // exercitada e viraria letra morta (mesma disciplina de `runWithFailSafe`).
339
+ // D4: `exit 0` SEMPRE. Esta é a rede EXTERNA, e ela existe para o IMPREVISTO.
340
+ // Há, sim, 2 try/catch internos, e eles tratam classes ESPERADAS e DISTINTAS
341
+ // desta, cada uma com diagnóstico declarado: `gitOrNull()` (git indisponível ou
342
+ // comando que falha ⇒ `null`, tratado no ponto de uso com causa nomeada) e o
343
+ // replay de `evaluateReadyOracles` no laço (avaliação que lança ⇒ veredito
344
+ // `ERRO` visível, jamais contado como `silencio`). Nenhum deles cobre a classe
345
+ // desta rede, então ela não é redundante — mas também não é exercitada por
346
+ // teste: o `F-2` do gate `SF13.8` registrou 5 tentativas de alcançá-la, todas
347
+ // capturadas antes, com diagnóstico. Fica por desenho (mesma disciplina de
348
+ // `runWithFailSafe`): a garantia é `exit 0` mesmo no que ninguém previu.
349
+ // Leitura superada, não repetir: a formulação anterior deste comentário NEGAVA
350
+ // a existência dos 2 try/catch internos acima — o desenho estava certo, a prosa
351
+ // é que afirmava mais do que o código faz.
316
352
  if (require.main === module) {
317
353
  try {
318
354
  main(process.argv.slice(2));
@@ -44,14 +44,24 @@ function collectFiles(target) {
44
44
  return out.sort();
45
45
  }
46
46
 
47
- function scanFile(filePath) {
47
+ /**
48
+ * scanFile(filePath, terms) — varre um arquivo por termos proibidos.
49
+ *
50
+ * O 2º parâmetro é opcional e defaulta para `FORBIDDEN_TERMS` deste módulo:
51
+ * `main()` daqui continua se comportando exatamente como antes. Ele existe
52
+ * para que um gate REPO-ONLY (`scripts/validate-git-vetor.cjs`, SF15.7) possa
53
+ * REUSAR esta varredura com uma lista composta, em vez de fazer uma 2ª cópia
54
+ * da lógica. Sem o parâmetro, `scanFile` fecharia sobre a lista de 5 deste
55
+ * módulo e o composto varreria com a lista errada — bug silencioso.
56
+ */
57
+ function scanFile(filePath, terms = FORBIDDEN_TERMS) {
48
58
  const buf = fs.readFileSync(filePath);
49
59
  if (isProbablyBinary(buf)) return [];
50
60
  const violations = [];
51
61
  const lines = buf.toString('utf8').split(/\r?\n/);
52
62
  lines.forEach((line, idx) => {
53
63
  const lower = line.toLowerCase();
54
- for (const term of FORBIDDEN_TERMS) {
64
+ for (const term of terms) {
55
65
  if (lower.includes(term)) {
56
66
  violations.push({ line: idx + 1, term, text: line.trim() });
57
67
  }
@@ -79,6 +89,12 @@ function main() {
79
89
  process.exit(0);
80
90
  }
81
91
 
82
- module.exports = { FORBIDDEN_TERMS, SCAN_TARGETS };
92
+ // Exports ADITIVOS (SF15.7): `FORBIDDEN_TERMS`/`SCAN_TARGETS` são o contrato
93
+ // que `scripts/validate-no-clone.cjs` importa e que VIAJA para toda instalação
94
+ // (`installer/motor-scripts.json`) — inalterados. `collectFiles`,
95
+ // `isProbablyBinary` e `scanFile` passam a ser exportados para reuso local do
96
+ // repositório; este arquivo NÃO ganha nenhum `require` novo (um require para
97
+ // módulo repo-only aqui crasharia toda instalação — medido, SF15.7 `B1`).
98
+ module.exports = { FORBIDDEN_TERMS, SCAN_TARGETS, collectFiles, isProbablyBinary, scanFile };
83
99
 
84
100
  if (require.main === module) main();
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * validate-git-vetor.cjs — guard-rail do VETOR GIT (commits/PRs), REPO-ONLY.
5
+ *
6
+ * O vetor `npm pack` já tem guarda (`scripts/owner-layer.cjs` + `npm-scope`);
7
+ * o vetor `git` — o que um repositório PÚBLICO expõe a cada commit — não tinha.
8
+ * Esta é a ampliação medida na `SF15.7`: o gate de branding
9
+ * (`scripts/validate-branding.js`) já PROIBIA os nomes que vazaram na `SF15.6`,
10
+ * mas não OLHAVA para `docs/adr/` nem `docs/research/`, que era onde eles
11
+ * estavam. "Não era ausência de regra: era vão de superfície."
12
+ *
13
+ * ┌─ ESTE ARQUIVO NÃO VIAJA PARA INSTALAÇÃO — e é por isso que ele existe ────┐
14
+ * │ Está no `exclude` de `installer/motor-scripts.json`, mesma classe de │
15
+ * │ `owner-layer.cjs` e `derive-ready-transitions.cjs`. A dependência corre │
16
+ * │ REPO-ONLY → DISTRIBUÍDO (este arquivo importa `validate-branding.js`), │
17
+ * │ nunca o contrário: um `require('./owner-layer.cjs')` DENTRO de │
18
+ * │ `validate-branding.js` crasharia com `MODULE_NOT_FOUND` toda instalação │
19
+ * │ de terceiro que roda `validate-no-clone.cjs` (medido, `SF15.7` `B1`). │
20
+ * │ Por isso as 2 listas são COMPOSTAS aqui por import, e o contrato │
21
+ * │ distribuído (`FORBIDDEN_TERMS`/`SCAN_TARGETS` de `validate-branding.js`) │
22
+ * │ fica intocado — 5 termos, 5 alvos, exatamente como antes. │
23
+ * └──────────────────────────────────────────────────────────────────────────┘
24
+ *
25
+ * ┌─ FRONTEIRA HONESTA — o que este gate NÃO detecta, por desenho ───────────┐
26
+ * │ O gate reprova SE E SOMENTE SE um dos literais enumerados aparecer, em │
27
+ * │ forma literal contígua, no conteúdo de um arquivo regular sob um dos │
28
+ * │ alvos enumerados. Nome de arquivo, symlink, forma não-literal │
29
+ * │ (espaçamento, ênfase, quebra de linha, `~`, URL-encode) e diretório fora │
30
+ * │ de `SCAN_TARGETS` NÃO são cobertos, por desenho. Classe nova — termo, │
31
+ * │ alvo ou forma — entra por CURADORIA HUMANA, com dono declarado; o │
32
+ * │ mecanismo não promete detectar o desconhecido. │
33
+ * │ │
34
+ * │ DONO DAS LISTAS: `EXTRA_TARGETS`/`EXTRA_TERMS` (as 2 listas que a │
35
+ * │ `SF15.7` acrescenta) são mantidas por quem editar ESTE arquivo. │
36
+ * │ `SCAN_TARGETS`/`FORBIDDEN_TERMS` de origem continuam com o dono de sempre │
37
+ * │ (`scripts/validate-branding.js`, que hoje não tem cabeçalho de manutenção │
38
+ * │ formal — acrescentar um está fora do escopo da `SF15.7`). │
39
+ * └──────────────────────────────────────────────────────────────────────────┘
40
+ *
41
+ * Node >= 18, sem dependências externas. Exit 1 listando arquivo:linha de cada
42
+ * ocorrência; exit 0 = vetor git limpo.
43
+ */
44
+
45
+ const path = require('node:path');
46
+
47
+ const branding = require('./validate-branding.js');
48
+ const { patternByRole } = require('./owner-layer.cjs');
49
+
50
+ const REPO_ROOT = path.resolve(__dirname, '..');
51
+
52
+ // Alvos que o gate distribuído não varre e que o repositório público expõe.
53
+ const EXTRA_TARGETS = ['docs/adr', 'docs/research'];
54
+
55
+ // Termos que NÃO entram no contrato distribuído:
56
+ // • `venturus` / `executive circle` — identidade de terceiro medida na `SF15.6`
57
+ // (`F-8`), ainda ausente do denylist de clone;
58
+ // • slug e path de máquina do DONO do framework — IMPORTADOS de
59
+ // `owner-layer.cjs` por papel (`patternByRole`), nunca copiados como literal:
60
+ // uma 3ª cópia derivaria em silêncio (lição `SF13.9`/`SF13.10`). Não entram
61
+ // em `FORBIDDEN_TERMS` distribuído por erro de categoria: o path do autor do
62
+ // framework não é "identidade de clone" (`SF15.7` `B1`, nota semântica).
63
+ // • `patternByRole('nome-civil')` NÃO entra — varrer por nome do dono foi
64
+ // aposentado como proxy por decisão dele (`owner-layer.cjs`, cabeçalho da
65
+ // seção 2; `ADR-027` Emenda 2).
66
+ const EXTRA_TERMS = [
67
+ 'venturus',
68
+ 'executive circle',
69
+ patternByRole('slug').toLowerCase(),
70
+ patternByRole('path-maquina').toLowerCase(),
71
+ ];
72
+
73
+ const SCAN_TARGETS = [...branding.SCAN_TARGETS, ...EXTRA_TARGETS];
74
+ const FORBIDDEN_TERMS = [...branding.FORBIDDEN_TERMS, ...EXTRA_TERMS];
75
+
76
+ function main() {
77
+ let total = 0;
78
+ for (const target of SCAN_TARGETS) {
79
+ // `collectFiles` e `scanFile` vêm de `validate-branding.js` — nenhuma 2ª
80
+ // implementação da varredura, só uma 2ª LISTA.
81
+ for (const filePath of branding.collectFiles(target)) {
82
+ const rel = path.relative(REPO_ROOT, filePath);
83
+ for (const v of branding.scanFile(filePath, FORBIDDEN_TERMS)) {
84
+ total++;
85
+ console.error(`${rel}:${v.line}: termo proibido "${v.term}" — ${v.text.slice(0, 100)}`);
86
+ }
87
+ }
88
+ }
89
+ if (total > 0) {
90
+ console.error(`\n${total} ocorrência(s) no vetor git (branding + identidade de terceiro + padrões do dono). Gate vermelho.`);
91
+ process.exit(1);
92
+ }
93
+ console.log('Vetor git limpo.');
94
+ process.exit(0);
95
+ }
96
+
97
+ module.exports = { SCAN_TARGETS, FORBIDDEN_TERMS, EXTRA_TARGETS, EXTRA_TERMS };
98
+
99
+ if (require.main === module) main();
@@ -2,7 +2,7 @@
2
2
  name: pc-devops
3
3
  description: "Papel DevOps do ciclo POWER CLAUDE — o ÚNICO autorizado a publicar (git push, PR, merge, release). Use para: gate pre-push completo, push fast-forward, criar PR com o porquê, merge-back de branches de wave, release com tag semver e changelog."
4
4
  model: sonnet
5
- budget_tokens: 1450
5
+ budget_tokens: 1500
6
6
  tools: Read, Grep, Glob, Write, Edit, Bash
7
7
  permissionMode: acceptEdits
8
8
  ---
@@ -27,6 +27,7 @@ Você é o papel **DevOps** do ciclo POWER CLAUDE — o único autorizado a publ
27
27
 
28
28
  - **Superfície (5 arquivos), resposta POR ARQUIVO escrita na story da release, nunca um "sim" global** (`DF-SF13.5-5`): `README.md`, `template/pods/README.md`, `BACKLOG.md`, `CHANGELOG.md` e este arquivo (`pc-devops.md`) — os 2 últimos entraram porque a classe já ocorreu neles (`ADR-036`). Para cada um: "revisado; linha `<path>:<n>` reconciliada" ou "revisado; nada a mudar porque `<razão>`".
29
29
  - **Todo achado carregado é pré-condição, não nota** (`DF-SF13.5-6`): antes da tag, abrir o `docs/qa/gates/release-*.yaml` mais recente e responder na story, uma a uma e na mesma forma acima, as entradas com `dono: pc-devops` de desfecho não fechado. Sem momento obrigatório o achado reaparece: o `F-1` da `0.16.1` voltou idêntico na `0.16.2`.
30
+ - **Espelho público (Vitrine, `ADR-037`)**, depois do `npm publish`: siga `docs/mecanismos/espelho-publico-vitrine.md` — monta com `build-public-tree.cjs`, sincroniza o remoto de NOME DISTINTIVO (nunca `origin`) e empurra por `git push` explícito, jamais dentro de script: só assim o hook vê o comando e pede `ask` (`I4`). Cadência hoje: release.
30
31
 
31
32
  ## Publicação no npm — 3 lições operacionais (`0.16.0`, 2026-08-04)
32
33