@kuyper/harness 0.1.0

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.
@@ -0,0 +1,467 @@
1
+ import { access, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { discoverRules, discoverSkills, compileInstructionFile, compileSkillRoutingSection, } from './capabilities.js';
4
+ import { readConfig, ConfigSchemaError } from './config.js';
5
+ import { KuyperRefusal } from './errors.js';
6
+ import { checksumTree, classifyCore, coreEntriesFromLock, mapsEqual, } from './coreClassification.js';
7
+ import { readLock, writeLock, computeChecksum } from './lock.js';
8
+ import { writeFileAtomic, sweepTempFiles } from './atomicWrite.js';
9
+ import { decideOutput, decideOrphan, computeOrphanPaths, removeOrphanFile, outputPath, } from './outputPlan.js';
10
+ import { packageCorePath, packageRoot } from './paths.js';
11
+ import { HOOK_NAMES, HOOKS_PATH, HOOK_MODE, hookScript, getCoreHooksPath, setCoreHooksPath, classifyHooksPath, } from './hooks.js';
12
+ function controlledRoots(projectRoot) {
13
+ return [join(projectRoot, '.kuyper'), join(projectRoot, '.claude'), join(projectRoot, '.agents')];
14
+ }
15
+ export async function loadCapabilities(projectRoot) {
16
+ const issues = [];
17
+ const coreRulesR = await discoverRules(join(projectRoot, '.kuyper', 'core', 'rules'), 'core');
18
+ const projectRulesR = await discoverRules(join(projectRoot, '.kuyper', 'project', 'rules'), 'project');
19
+ const coreSkillsR = await discoverSkills(join(projectRoot, '.kuyper', 'core', 'skills'), 'core');
20
+ const projectSkillsR = await discoverSkills(join(projectRoot, '.kuyper', 'project', 'skills'), 'project');
21
+ issues.push(...coreRulesR.issues, ...projectRulesR.issues, ...coreSkillsR.issues, ...projectSkillsR.issues);
22
+ return {
23
+ set: {
24
+ coreRules: coreRulesR.items,
25
+ projectRules: projectRulesR.items,
26
+ coreSkills: coreSkillsR.items,
27
+ projectSkills: projectSkillsR.items,
28
+ },
29
+ issues,
30
+ };
31
+ }
32
+ /**
33
+ * Colisão de nome, referência que não resolve, `replaces:` para regra
34
+ * inexistente (R9) e duas rules substituindo a mesma regra do core (R10).
35
+ * Roda contra o inventário ativo — precisa passar antes de qualquer saída
36
+ * ou lock ser alterado (SPEC protocolo-core-lock §4).
37
+ *
38
+ * Tagueado com o próprio código — R9 e R10 são fechados separadamente de
39
+ * R25 (BUILD.md B2, "Fecha"), não é achado de schema.
40
+ */
41
+ export function checkReferences(set) {
42
+ const issues = [];
43
+ const coreRuleNames = new Set(set.coreRules.map((r) => r.name));
44
+ const replacesTargets = new Map();
45
+ for (const rule of set.projectRules) {
46
+ if (rule.replaces === undefined)
47
+ continue;
48
+ const target = rule.replaces.slice('core:'.length);
49
+ if (!coreRuleNames.has(target)) {
50
+ issues.push({ code: 'R9', path: rule.sourcePath, message: `replaces aponta para regra inexistente: core:${target}` });
51
+ continue;
52
+ }
53
+ const existing = replacesTargets.get(target) ?? [];
54
+ existing.push(rule.name);
55
+ replacesTargets.set(target, existing);
56
+ }
57
+ for (const [target, names] of replacesTargets) {
58
+ if (names.length > 1) {
59
+ issues.push({
60
+ code: 'R10',
61
+ path: names.join(', '),
62
+ message: `duas rules do projeto substituem core:${target}`,
63
+ });
64
+ }
65
+ }
66
+ const coreSkillNames = new Set(set.coreSkills.map((s) => s.name));
67
+ const skillReplacesTargets = new Map();
68
+ for (const skill of set.projectSkills) {
69
+ if (coreSkillNames.has(skill.name)) {
70
+ issues.push({ code: 'R9', path: skill.sourceDir, message: `colide com a skill core:${skill.name}` });
71
+ }
72
+ if (skill.replaces === undefined)
73
+ continue;
74
+ if (!coreSkillNames.has(skill.replaces)) {
75
+ issues.push({ code: 'R9', path: skill.sourceDir, message: `replaces aponta para skill inexistente: ${skill.replaces}` });
76
+ continue;
77
+ }
78
+ const existing = skillReplacesTargets.get(skill.replaces) ?? [];
79
+ existing.push(skill.name);
80
+ skillReplacesTargets.set(skill.replaces, existing);
81
+ }
82
+ for (const [target, names] of skillReplacesTargets) {
83
+ if (names.length > 1) {
84
+ issues.push({
85
+ code: 'R10',
86
+ path: names.join(', '),
87
+ message: `duas skills do projeto substituem ${target} na lista de roteamento`,
88
+ });
89
+ }
90
+ }
91
+ return issues;
92
+ }
93
+ export function buildDesiredOutputs(set, config) {
94
+ const outputs = [];
95
+ const rulesSection = compileInstructionFile(set.coreRules, set.projectRules);
96
+ const routingSection = compileSkillRoutingSection(set.coreSkills, set.projectSkills);
97
+ const instructionContent = `${rulesSection}\n${routingSection}`;
98
+ const claudeEnabled = config.providers.enabled.includes('claude');
99
+ const codexEnabled = config.providers.enabled.includes('codex');
100
+ if (claudeEnabled)
101
+ outputs.push({ path: 'CLAUDE.md', content: instructionContent });
102
+ outputs.push({ path: 'AGENTS.md', content: instructionContent });
103
+ const allSkills = [...set.coreSkills, ...set.projectSkills];
104
+ for (const skill of allSkills) {
105
+ if (claudeEnabled) {
106
+ outputs.push({ path: `.claude/skills/${skill.name}/SKILL.md`, content: skill.normalizedSkillMd });
107
+ for (const script of skill.scripts) {
108
+ outputs.push({
109
+ path: `.claude/skills/${skill.name}/${script.relPath}`,
110
+ content: script.content.toString('utf8'),
111
+ mode: script.mode,
112
+ });
113
+ }
114
+ }
115
+ if (codexEnabled) {
116
+ outputs.push({ path: `.agents/skills/${skill.name}/SKILL.md`, content: skill.normalizedSkillMd });
117
+ for (const script of skill.scripts) {
118
+ outputs.push({
119
+ path: `.agents/skills/${skill.name}/${script.relPath}`,
120
+ content: script.content.toString('utf8'),
121
+ mode: script.mode,
122
+ });
123
+ }
124
+ }
125
+ }
126
+ for (const name of HOOK_NAMES) {
127
+ outputs.push({ path: `${HOOKS_PATH}/${name}`, content: hookScript(name), mode: HOOK_MODE });
128
+ }
129
+ return outputs;
130
+ }
131
+ export async function diskChecksumOf(projectRoot, relPath) {
132
+ try {
133
+ const content = await readFile(outputPath(projectRoot, relPath));
134
+ return computeChecksum(content);
135
+ }
136
+ catch (err) {
137
+ if (err.code === 'ENOENT')
138
+ return undefined;
139
+ throw err;
140
+ }
141
+ }
142
+ /**
143
+ * O modo continua fora do checksum (PRD §3.3) — um hook com conteúdo correto
144
+ * mas sem bit de execução passaria pela comparação de conteúdo sem ser
145
+ * notado. `false` também quando o caminho não existe (quem chama já sabe,
146
+ * via `diskChecksumOf`).
147
+ */
148
+ export async function diskIsExecutable(projectRoot, relPath) {
149
+ try {
150
+ const info = await stat(outputPath(projectRoot, relPath));
151
+ return (info.mode & 0o111) !== 0;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ function refuseR8(paths) {
158
+ throw new KuyperRefusal({
159
+ code: 'R8',
160
+ headline: paths.length === 1
161
+ ? `${paths[0]} existe e não foi gerado pelo Harness.`
162
+ : 'Caminhos existem e não foram gerados pelo Harness.',
163
+ details: [...paths, '', 'Não estão rastreados pelo Git — não há backup.'],
164
+ route: ['Confira o conteúdo, remova o arquivo se não precisar dele, e rode de novo.'],
165
+ });
166
+ }
167
+ function refuseR7(paths) {
168
+ const first = paths[0];
169
+ throw new KuyperRefusal({
170
+ code: 'R7',
171
+ headline: paths.length === 1
172
+ ? `${first} não corresponde ao que foi gerado antes nem ao que a fonte produz agora.`
173
+ : 'Saídas não correspondem ao que foi gerado antes nem ao que a fonte produz agora.',
174
+ details: ['Foi(ram) editado(s) à mão.', '', ...paths],
175
+ route: [
176
+ 'Para descartar a edição: git restore <arquivo>',
177
+ 'Ou, se não estiver commitado: pnpm exec kuyper generate --force',
178
+ ],
179
+ });
180
+ }
181
+ function refuseCoreR7() {
182
+ throw new KuyperRefusal({
183
+ code: 'R7',
184
+ headline: '.kuyper/core/ não coincide integralmente com o lock anterior nem com o pacote em execução.',
185
+ details: ['Edição manual, remoção ou transição incompleta.'],
186
+ route: ['git restore .kuyper/core/', '', 'Ou conclua a igualação ao pacote instalado antes de rodar generate.'],
187
+ });
188
+ }
189
+ function refuseCoreR8(paths) {
190
+ throw new KuyperRefusal({
191
+ code: 'R8',
192
+ headline: 'Conteúdo desconhecido no namespace do core.',
193
+ details: paths,
194
+ route: ['Remova o(s) caminho(s) acima de .kuyper/core/, ou restaure a árvore com Git.'],
195
+ });
196
+ }
197
+ function refuseR24(current) {
198
+ throw new KuyperRefusal({
199
+ code: 'R24',
200
+ headline: 'O core.hooksPath já aponta para fora de .kuyper/hooks.',
201
+ details: [`atual: ${current}`, '', 'Outro gerenciador de hooks controla este repositório. O Harness não', 'assume esse controle por conta própria — a decisão é sua.'],
202
+ route: ['Para entregar os hooks ao Harness:', ' git config --unset core.hooksPath', ' pnpm exec kuyper generate'],
203
+ });
204
+ }
205
+ function refuseIssues(issues) {
206
+ throw new KuyperRefusal({
207
+ code: 'R25',
208
+ headline: issues.length === 1 ? 'A fonte canônica é inválida.' : `A fonte canônica é inválida (${issues.length} achados).`,
209
+ details: issues.map((i) => `${i.path}: ${i.message}`),
210
+ route: ['Corrija a fonte e rode pnpm exec kuyper generate de novo.'],
211
+ });
212
+ }
213
+ function refuseReferenceIssues(issues) {
214
+ // um lote pode misturar R9 e R10; a etiqueta segue a maioria dos achados,
215
+ // e cada linha do detalhe já nomeia o próprio código.
216
+ const r10Count = issues.filter((i) => i.code === 'R10').length;
217
+ const code = r10Count > issues.length / 2 ? 'R10' : 'R9';
218
+ throw new KuyperRefusal({
219
+ code,
220
+ headline: issues.length === 1 ? 'Referência de capacidade inválida.' : `Referências de capacidade inválidas (${issues.length} achados).`,
221
+ details: issues.map((i) => `[${i.code}] ${i.path}: ${i.message}`),
222
+ route: ['Corrija a fonte e rode pnpm exec kuyper generate de novo.'],
223
+ });
224
+ }
225
+ function formatSuccessReport(written, alreadyCorrect) {
226
+ const lines = [`✓ ${written.length} saídas atualizadas, ${alreadyCorrect} já estavam corretas.`];
227
+ if (written.length > 0) {
228
+ lines.push('');
229
+ for (const p of written)
230
+ lines.push(` ${p}`);
231
+ }
232
+ return lines.join('\n');
233
+ }
234
+ /**
235
+ * SPEC protocolo-core-lock §3.1.1 — `.kuyper/` sem lock. Bootstrap e
236
+ * interrupção são o mesmo estado; a base só converge se for estruturalmente
237
+ * completa E a árvore core inteira já coincidir com o pacote.
238
+ */
239
+ async function bootstrapGenerate(projectRoot, P, options) {
240
+ const missing = [];
241
+ let config;
242
+ try {
243
+ config = await readConfig(join(projectRoot, '.kuyper', 'config.yaml'));
244
+ }
245
+ catch (err) {
246
+ if (err instanceof ConfigSchemaError)
247
+ missing.push(`.kuyper/config.yaml inválido: ${err.issues.join('; ')}`);
248
+ else
249
+ missing.push('.kuyper/config.yaml ausente');
250
+ }
251
+ const D = await checksumTree(join(projectRoot, '.kuyper', 'core'));
252
+ if (D.size === 0 || !mapsEqual(D, P)) {
253
+ missing.push('.kuyper/core/ ausente, ou não coincide inteiramente com o core/ do pacote instalado');
254
+ }
255
+ try {
256
+ await access(join(projectRoot, '.kuyper', 'STATE.md'));
257
+ }
258
+ catch {
259
+ missing.push('.kuyper/STATE.md ausente');
260
+ }
261
+ // O conflito de core.hooksPath é ortogonal à completude da base e tem rota
262
+ // própria e não destrutiva — não deve virar "apague .kuyper/ e reinicie".
263
+ const hooksPathCurrent = await getCoreHooksPath(projectRoot);
264
+ const hooksPathState = classifyHooksPath(hooksPathCurrent);
265
+ if (hooksPathState === 'other') {
266
+ refuseR24(hooksPathCurrent);
267
+ }
268
+ if (missing.length > 0) {
269
+ throw new KuyperRefusal({
270
+ headline: 'A base em .kuyper/ está incompleta.',
271
+ details: missing,
272
+ route: ['Apague .kuyper/ e execute pnpm exec kuyper init novamente.'],
273
+ });
274
+ }
275
+ const { set, issues } = await loadCapabilities(projectRoot);
276
+ if (issues.length > 0)
277
+ refuseIssues(issues);
278
+ const refIssues = checkReferences(set);
279
+ if (refIssues.length > 0)
280
+ refuseReferenceIssues(refIssues);
281
+ const config2 = config;
282
+ const desired = buildDesiredOutputs(set, config2);
283
+ const decisions = new Map();
284
+ const foreignPaths = [];
285
+ for (const output of desired) {
286
+ const disk = await diskChecksumOf(projectRoot, output.path);
287
+ const newChecksum = computeChecksum(output.content);
288
+ if (disk !== undefined && disk !== newChecksum) {
289
+ foreignPaths.push(output.path);
290
+ continue;
291
+ }
292
+ let decision = disk === undefined ? { action: 'write' } : { action: 'already-correct' };
293
+ if (decision.action === 'already-correct' && output.mode !== undefined && !(await diskIsExecutable(projectRoot, output.path))) {
294
+ decision = { action: 'write' };
295
+ }
296
+ decisions.set(output.path, {
297
+ decision,
298
+ newChecksum,
299
+ content: output.content,
300
+ mode: output.mode,
301
+ });
302
+ }
303
+ if (foreignPaths.length > 0) {
304
+ throw new KuyperRefusal({
305
+ headline: 'A base em .kuyper/ está incompleta.',
306
+ details: [
307
+ 'Estes caminhos de saída já existem com conteúdo diferente do desejado:',
308
+ ...foreignPaths,
309
+ ],
310
+ route: ['Apague .kuyper/ e execute pnpm exec kuyper init novamente.'],
311
+ });
312
+ }
313
+ const written = [];
314
+ let alreadyCorrect = 0;
315
+ for (const [path, entry] of decisions) {
316
+ if (entry.decision.action === 'write') {
317
+ await writeFileAtomic(outputPath(projectRoot, path), entry.content, entry.mode !== undefined ? { mode: entry.mode } : {});
318
+ written.push(path);
319
+ }
320
+ else {
321
+ alreadyCorrect++;
322
+ }
323
+ }
324
+ if (hooksPathState === 'absent') {
325
+ await setCoreHooksPath(projectRoot, HOOKS_PATH);
326
+ }
327
+ const lockEntries = {};
328
+ for (const [key, checksum] of D)
329
+ lockEntries[`.kuyper/core/${key}`] = checksum;
330
+ for (const [path, entry] of decisions)
331
+ lockEntries[path] = entry.newChecksum;
332
+ await writeLock(join(projectRoot, '.kuyper', 'generated.lock'), {
333
+ schemaVersion: 1,
334
+ harness: await harnessVersion(),
335
+ entries: lockEntries,
336
+ });
337
+ await sweepTempFiles(controlledRoots(projectRoot));
338
+ if (!options.silent)
339
+ console.log(formatSuccessReport(written, alreadyCorrect));
340
+ return { written, removed: [], alreadyCorrect };
341
+ }
342
+ let cachedVersion;
343
+ export async function harnessVersion() {
344
+ if (cachedVersion)
345
+ return cachedVersion;
346
+ const raw = await readFile(join(packageRoot(), 'package.json'), 'utf8');
347
+ const pkg = JSON.parse(raw);
348
+ cachedVersion = pkg.version;
349
+ return cachedVersion;
350
+ }
351
+ async function normalGenerate(projectRoot, lock, P, options) {
352
+ const L = coreEntriesFromLock(lock.entries);
353
+ const D = await checksumTree(join(projectRoot, '.kuyper', 'core'));
354
+ const classification = classifyCore(L, P, D);
355
+ if (classification.state === 'unknown-path') {
356
+ refuseCoreR8(classification.paths);
357
+ }
358
+ if (classification.state === 'mismatch') {
359
+ refuseCoreR7();
360
+ }
361
+ const coreTransitioned = classification.state === 'transition-complete';
362
+ const config = await readConfig(join(projectRoot, '.kuyper', 'config.yaml'));
363
+ const { set, issues } = await loadCapabilities(projectRoot);
364
+ if (issues.length > 0)
365
+ refuseIssues(issues);
366
+ const refIssues = checkReferences(set);
367
+ if (refIssues.length > 0)
368
+ refuseReferenceIssues(refIssues);
369
+ const hooksPathCurrent = await getCoreHooksPath(projectRoot);
370
+ const hooksPathState = classifyHooksPath(hooksPathCurrent);
371
+ if (hooksPathState === 'other')
372
+ refuseR24(hooksPathCurrent);
373
+ const desired = buildDesiredOutputs(set, config);
374
+ const desiredPaths = new Set(desired.map((o) => o.path));
375
+ const editedRefused = [];
376
+ const foreignRefused = [];
377
+ const toWrite = [];
378
+ const alreadyCorrectPaths = [];
379
+ const newChecksums = {};
380
+ for (const output of desired) {
381
+ const disk = await diskChecksumOf(projectRoot, output.path);
382
+ const newChecksum = computeChecksum(output.content);
383
+ newChecksums[output.path] = newChecksum;
384
+ const lockChecksum = lock.entries[output.path];
385
+ const decision = decideOutput(disk, lockChecksum, newChecksum);
386
+ if (decision.action === 'refuse-foreign') {
387
+ foreignRefused.push(output.path);
388
+ }
389
+ else if (decision.action === 'refuse-edited') {
390
+ if (options.force)
391
+ toWrite.push(output);
392
+ else
393
+ editedRefused.push(output.path);
394
+ }
395
+ else if (decision.action === 'write') {
396
+ toWrite.push(output);
397
+ }
398
+ else if (output.mode !== undefined && !(await diskIsExecutable(projectRoot, output.path))) {
399
+ toWrite.push(output);
400
+ }
401
+ else {
402
+ alreadyCorrectPaths.push(output.path);
403
+ }
404
+ }
405
+ const orphanPaths = computeOrphanPaths(lock.entries, desiredPaths);
406
+ const orphansToRemove = [];
407
+ for (const path of orphanPaths) {
408
+ const disk = await diskChecksumOf(projectRoot, path);
409
+ const decision = decideOrphan(disk, lock.entries[path]);
410
+ if (decision.action === 'refuse-edited') {
411
+ if (options.force)
412
+ orphansToRemove.push(path);
413
+ else
414
+ editedRefused.push(path);
415
+ }
416
+ else if (decision.action === 'remove') {
417
+ orphansToRemove.push(path);
418
+ }
419
+ }
420
+ if (foreignRefused.length > 0)
421
+ refuseR8(foreignRefused);
422
+ if (editedRefused.length > 0)
423
+ refuseR7(editedRefused);
424
+ if (hooksPathState === 'absent') {
425
+ await setCoreHooksPath(projectRoot, HOOKS_PATH);
426
+ }
427
+ const written = [];
428
+ for (const output of toWrite) {
429
+ await writeFileAtomic(outputPath(projectRoot, output.path), output.content, output.mode !== undefined ? { mode: output.mode } : {});
430
+ written.push(output.path);
431
+ }
432
+ for (const path of orphansToRemove) {
433
+ await removeOrphanFile(projectRoot, path);
434
+ }
435
+ const coreLockEntries = {};
436
+ if (coreTransitioned) {
437
+ for (const [key, checksum] of D)
438
+ coreLockEntries[`.kuyper/core/${key}`] = checksum;
439
+ }
440
+ else {
441
+ for (const [key, checksum] of L)
442
+ coreLockEntries[`.kuyper/core/${key}`] = checksum;
443
+ }
444
+ const outputLockEntries = {};
445
+ for (const path of desiredPaths)
446
+ outputLockEntries[path] = newChecksums[path];
447
+ await writeLock(join(projectRoot, '.kuyper', 'generated.lock'), {
448
+ schemaVersion: 1,
449
+ harness: await harnessVersion(),
450
+ entries: { ...coreLockEntries, ...outputLockEntries },
451
+ });
452
+ await sweepTempFiles(controlledRoots(projectRoot));
453
+ const alreadyCorrect = alreadyCorrectPaths.length;
454
+ if (!options.silent)
455
+ console.log(formatSuccessReport(written, alreadyCorrect));
456
+ return { written, removed: orphansToRemove, alreadyCorrect };
457
+ }
458
+ export async function generate(options = {}) {
459
+ const projectRoot = options.projectRoot ?? process.cwd();
460
+ const lockPath = join(projectRoot, '.kuyper', 'generated.lock');
461
+ const P = await checksumTree(options.packageCoreDir ?? packageCorePath());
462
+ const lock = await readLock(lockPath);
463
+ if (lock === undefined) {
464
+ return bootstrapGenerate(projectRoot, P, options);
465
+ }
466
+ return normalGenerate(projectRoot, lock, P, options);
467
+ }
@@ -0,0 +1,213 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /**
5
+ * Wrappers finos do Git — nenhuma função aqui decide nada, mesmo as que
6
+ * escrevem (`addPaths`, `switchBranch`, os dois `merge*`). As decisões (o
7
+ * que fazer com o que foi lido, quando mudar de branch) pertencem a quem
8
+ * chama: `gateRunner.ts`, `hookBehavior.ts`, `init.ts`, `integrate.ts`.
9
+ */
10
+ export async function isGitRepo(cwd) {
11
+ try {
12
+ await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd });
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /**
20
+ * `symbolic-ref`, não `rev-parse --abbrev-ref HEAD`: o segundo falha num
21
+ * repositório sem nenhum commit ainda, porque HEAD não resolve pra revisão
22
+ * nenhuma — o estado normal logo depois de um `git init` (achado do B5).
23
+ */
24
+ export async function currentBranch(cwd) {
25
+ try {
26
+ const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd });
27
+ return stdout.trim();
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ /** `undefined` quando HEAD não resolve (repositório sem nenhum commit). */
34
+ export async function headSha(cwd) {
35
+ try {
36
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd });
37
+ return stdout.trim();
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ export async function statusPorcelain(cwd) {
44
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd });
45
+ return stdout;
46
+ }
47
+ function splitLines(text) {
48
+ return text.split('\n').filter((l) => l.trim().length > 0);
49
+ }
50
+ /**
51
+ * Os caminhos de `git status --porcelain` — staged, não staged e não
52
+ * rastreados juntos, sem distinguir categoria. "Árvore suja" pra um comando
53
+ * como `integrate` é qualquer coisa não commitada; diferente da "fotografia
54
+ * dupla" de `unstagedAndUntrackedPaths`, que existe só pra saber o que **um
55
+ * gate** tocou durante a convergência do `pre-commit`.
56
+ */
57
+ export async function dirtyPaths(cwd) {
58
+ const status = await statusPorcelain(cwd);
59
+ return splitLines(status).map((line) => line.slice(3).trim());
60
+ }
61
+ /** `git merge-base --is-ancestor` — `ancestor` alcança `descendant`? Verdadeiro também quando são o mesmo commit. */
62
+ export async function isAncestor(cwd, ancestor, descendant) {
63
+ try {
64
+ await execFileAsync('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd });
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /** O SHA que uma branch **local** aponta agora, independente de qual está em checkout. `undefined` se a branch não existe. */
72
+ export async function branchSha(cwd, branch) {
73
+ return resolveRef(cwd, `refs/heads/${branch}`);
74
+ }
75
+ /**
76
+ * `git rev-parse <ref>` cru, sem assumir `refs/heads/` — o que `branchSha`
77
+ * assume, e por isso não serve pra um ref de tracking remoto como
78
+ * `origin/main` (vive em `refs/remotes/`, não em `refs/heads/`).
79
+ * `undefined` se `ref` não resolve.
80
+ */
81
+ export async function resolveRef(cwd, ref) {
82
+ try {
83
+ const { stdout } = await execFileAsync('git', ['rev-parse', ref], { cwd });
84
+ return stdout.trim();
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
90
+ /** Quantos commits `descendant` tem à frente de `ancestor` — `git rev-list --count`. */
91
+ export async function commitsAhead(cwd, ancestor, descendant) {
92
+ return totalCommits(cwd, `${ancestor}..${descendant}`);
93
+ }
94
+ /**
95
+ * `git rev-list --count <rangeOrRef>` cru — aceita tanto um único ref (conta
96
+ * tudo alcançável dali, o caso da primeira publicação, sem `origin/main`
97
+ * pra formar um intervalo) quanto um intervalo `A..B`. Diferente de
98
+ * `diffShortstat`/`diffNameStatus`/`readFileAtRef`, que aceitam objetos de
99
+ * árvore direto, `rev-list` exige commit-ish — por isso a árvore vazia
100
+ * nunca entra aqui como extremidade de intervalo.
101
+ */
102
+ export async function totalCommits(cwd, rangeOrRef) {
103
+ const { stdout } = await execFileAsync('git', ['rev-list', '--count', rangeOrRef], { cwd });
104
+ return Number(stdout.trim());
105
+ }
106
+ /** Caminhos que mudaram entre duas referências, restritos a `pathspecs`. */
107
+ export async function changedPaths(cwd, refA, refB, pathspecs) {
108
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', refA, refB, '--', ...pathspecs], { cwd });
109
+ return splitLines(stdout);
110
+ }
111
+ export async function switchBranch(cwd, branch) {
112
+ await execFileAsync('git', ['switch', branch], { cwd });
113
+ }
114
+ /** `git merge --no-ff` — sempre cria um commit de merge, mesmo quando um fast-forward bastaria. */
115
+ export async function mergeNoFF(cwd, branch) {
116
+ await execFileAsync('git', ['merge', '--no-ff', branch, '-m', `Merge branch '${branch}'`], { cwd });
117
+ }
118
+ /** `git merge --ff-only` — falha (lança) se não for um fast-forward puro. */
119
+ export async function mergeFastForward(cwd, branch) {
120
+ await execFileAsync('git', ['merge', '--ff-only', branch], { cwd });
121
+ }
122
+ /**
123
+ * A "fotografia dupla" do PRD §4.1: `git diff` da árvore de trabalho contra
124
+ * o índice (não staged) mais a lista de não rastreados. É contra isto que
125
+ * `gateRunner.ts` compara antes/depois de rodar os gates, pra saber
126
+ * exatamente o que cada volta tocou — nunca `git status`, que descreve
127
+ * categoria, não conteúdo.
128
+ */
129
+ export async function unstagedAndUntrackedPaths(cwd) {
130
+ const [diffResult, untrackedResult] = await Promise.all([
131
+ execFileAsync('git', ['diff', '--name-only'], { cwd }),
132
+ execFileAsync('git', ['ls-files', '--others', '--exclude-standard'], { cwd }),
133
+ ]);
134
+ return new Set([...splitLines(diffResult.stdout), ...splitLines(untrackedResult.stdout)]);
135
+ }
136
+ /** `git add --`, nunca `-A` — só os caminhos exatos que o gate tocou (PRD §4.1). */
137
+ export async function addPaths(cwd, paths) {
138
+ if (paths.length === 0)
139
+ return;
140
+ await execFileAsync('git', ['add', '--', ...paths], { cwd });
141
+ }
142
+ /**
143
+ * O hash de conteúdo (blob) de um arquivo no disco, sem precisar dele estar
144
+ * staged. `undefined` quando o caminho não existe mais — um gate pode
145
+ * apagar um arquivo, e ausência também conta como "mudou" pra quem compara.
146
+ */
147
+ export async function hashFile(cwd, path) {
148
+ try {
149
+ const { stdout } = await execFileAsync('git', ['hash-object', path], { cwd });
150
+ return stdout.trim();
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }
156
+ /** `hashFile` para vários caminhos de uma vez, em paralelo. */
157
+ export async function hashFiles(cwd, paths) {
158
+ const list = [...paths];
159
+ const hashes = await Promise.all(list.map((p) => hashFile(cwd, p)));
160
+ return new Map(list.map((p, i) => [p, hashes[i]]));
161
+ }
162
+ /** `true` se `ref` resolve pra algo — usado pra "origin/main existe?" sem lançar. */
163
+ export async function refExists(cwd, ref) {
164
+ try {
165
+ await execFileAsync('git', ['rev-parse', '--verify', '--quiet', ref], { cwd });
166
+ return true;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ /** O conteúdo de um arquivo num ref específico (`git show <ref>:<path>`). `undefined` se não existe ali. */
173
+ export async function readFileAtRef(cwd, ref, path) {
174
+ try {
175
+ const { stdout } = await execFileAsync('git', ['show', `${ref}:${path}`], { cwd });
176
+ return stdout;
177
+ }
178
+ catch {
179
+ return undefined;
180
+ }
181
+ }
182
+ /** `git diff --shortstat` entre duas árvores, parseado. Zeros quando não há diferença. */
183
+ export async function diffShortstat(cwd, from, to) {
184
+ const { stdout } = await execFileAsync('git', ['diff', '--shortstat', from, to], { cwd });
185
+ const files = /(\d+) files? changed/.exec(stdout);
186
+ const insertions = /(\d+) insertions?\(\+\)/.exec(stdout);
187
+ const deletions = /(\d+) deletions?\(-\)/.exec(stdout);
188
+ return {
189
+ files: files ? Number(files[1]) : 0,
190
+ insertions: insertions ? Number(insertions[1]) : 0,
191
+ deletions: deletions ? Number(deletions[1]) : 0,
192
+ };
193
+ }
194
+ /** `git diff --name-status` entre duas árvores. Renomes/cópias (`R*`/`C*`) contam como `M` — não há decisão de renome neste nível. */
195
+ export async function diffNameStatus(cwd, from, to) {
196
+ const { stdout } = await execFileAsync('git', ['diff', '--name-status', from, to], { cwd });
197
+ return splitLines(stdout).map((line) => {
198
+ const [rawStatus, ...rest] = line.split('\t');
199
+ const status = (rawStatus ?? '').charAt(0);
200
+ const normalized = status === 'A' ? 'A' : status === 'D' ? 'D' : 'M';
201
+ return { status: normalized, path: rest[rest.length - 1] ?? '' };
202
+ });
203
+ }
204
+ /** A data (AAAA-MM-DD) do commit mais antigo em `range` — o "desde quando" do resumo do `publish`. `undefined` se `range` não tem commit nenhum. */
205
+ export async function oldestCommitDate(cwd, range) {
206
+ const { stdout } = await execFileAsync('git', ['log', '--format=%ad', '--date=short', range], { cwd });
207
+ const lines = splitLines(stdout);
208
+ return lines.length > 0 ? lines[lines.length - 1] : undefined;
209
+ }
210
+ /** `git push -u <remote> <branch>` — o único ponto do produto que fala com a rede. */
211
+ export async function push(cwd, remote, branch) {
212
+ await execFileAsync('git', ['push', '-u', remote, branch], { cwd });
213
+ }