@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.
- package/README.md +27 -0
- package/core/rules/adr.md +11 -0
- package/core/rules/fluxo-git.md +5 -0
- package/core/rules/limites.md +11 -0
- package/core/rules/publicacao.md +7 -0
- package/core/rules/questionamento.md +8 -0
- package/core/rules/state.md +16 -0
- package/core/skills/architect/SKILL.md +46 -0
- package/core/skills/dev/SKILL.md +43 -0
- package/core/skills/discovery/SKILL.md +47 -0
- package/core/skills/prd/SKILL.md +43 -0
- package/dist/atomicWrite.js +89 -0
- package/dist/capabilities.js +361 -0
- package/dist/capabilityCommands.js +285 -0
- package/dist/cli.js +165 -0
- package/dist/config.js +159 -0
- package/dist/coreClassification.js +76 -0
- package/dist/errors.js +38 -0
- package/dist/gateRunner.js +109 -0
- package/dist/generate.js +467 -0
- package/dist/gitPlumbing.js +213 -0
- package/dist/hookBehavior.js +212 -0
- package/dist/hooks.js +49 -0
- package/dist/init.js +265 -0
- package/dist/integrate.js +192 -0
- package/dist/lock.js +108 -0
- package/dist/outputPlan.js +61 -0
- package/dist/paths.js +22 -0
- package/dist/project.js +29 -0
- package/dist/publish.js +207 -0
- package/dist/update.js +434 -0
- package/dist/validate.js +142 -0
- package/docs/guia/01-comecar.md +156 -0
- package/docs/guia/02-conceitos.md +53 -0
- package/docs/guia/03-comandos.md +65 -0
- package/docs/guia/04-equivalentes-manuais.md +135 -0
- package/docs/guia/05-metodo.md +56 -0
- package/docs/guia/06-falhas.md +103 -0
- package/package.json +38 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readConfig } from './config.js';
|
|
3
|
+
import { KuyperRefusal } from './errors.js';
|
|
4
|
+
import { gatesForStage, runExact } from './gateRunner.js';
|
|
5
|
+
import { branchSha, changedPaths, commitsAhead, currentBranch, dirtyPaths, headSha, isAncestor, isGitRepo, mergeFastForward, mergeNoFF, switchBranch, } from './gitPlumbing.js';
|
|
6
|
+
import { validate } from './validate.js';
|
|
7
|
+
/**
|
|
8
|
+
* Onde procurar "a fonte que mudou" pra R18 (PRD §3.4). Não é o lock — a
|
|
9
|
+
* ADR `generated-lock` decide, de propósito, não guardar checksum de fonte
|
|
10
|
+
* (`.kuyper/project/**` fica fora por natureza). `integrate` já compara
|
|
11
|
+
* `dev` contra `main`; é essa comparação, via Git, que nomeia a fonte, sem
|
|
12
|
+
* estado novo nenhum.
|
|
13
|
+
*/
|
|
14
|
+
const SOURCE_PATHS = ['.kuyper/core', '.kuyper/project', '.kuyper/config.yaml'];
|
|
15
|
+
/**
|
|
16
|
+
* Sem código: R2 ("não é repositório Git") é escopo do `init` na tabela do
|
|
17
|
+
* §5. `.kuyper/` existir sem `.git` não deveria acontecer em uso normal (só
|
|
18
|
+
* o `init` cria `.kuyper/`, e exige um repositório Git pra isso) — mas sem
|
|
19
|
+
* esta checagem, o primeiro comando Git de `integrate` (`git status
|
|
20
|
+
* --porcelain`) falha cru, com stack trace de Git, não uma recusa do
|
|
21
|
+
* produto.
|
|
22
|
+
*/
|
|
23
|
+
function refuseNotGitRepo() {
|
|
24
|
+
throw new KuyperRefusal({
|
|
25
|
+
headline: 'Este diretório não é um repositório Git.',
|
|
26
|
+
details: ['.kuyper/ existe, mas não há um repositório Git aqui — o integrate precisa de um pra funcionar.'],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function refuseR15() {
|
|
30
|
+
throw new KuyperRefusal({
|
|
31
|
+
code: 'R15',
|
|
32
|
+
headline: 'Você está na main.',
|
|
33
|
+
details: ['O integrate leva a dev para a main; rodando dele mesmo não há o que levar.'],
|
|
34
|
+
route: ['git switch dev'],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Sem código: a PRD só nomeia a R15 pra "você está na main" — uma terceira
|
|
39
|
+
* branch (ou nenhuma, HEAD destacada) não tem recusa numerada. Mesmo padrão
|
|
40
|
+
* do B6 pras precondições do `pre-push` que a R23 não cobre.
|
|
41
|
+
*/
|
|
42
|
+
function refuseNotOnDev(branch) {
|
|
43
|
+
throw new KuyperRefusal({
|
|
44
|
+
headline: branch === undefined ? 'Não foi possível determinar a branch atual.' : `A branch atual é "${branch}", não a dev.`,
|
|
45
|
+
details: ['O integrate opera a partir da dev — é de lá que ele leva o trabalho para a main.'],
|
|
46
|
+
route: ['git switch dev'],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function refuseR14(paths) {
|
|
50
|
+
throw new KuyperRefusal({
|
|
51
|
+
code: 'R14',
|
|
52
|
+
headline: 'A árvore de trabalho está suja.',
|
|
53
|
+
details: [...paths, '', 'Os gates precisam rodar sobre o que será integrado, e não sobre alterações que ficariam de fora.'],
|
|
54
|
+
route: ['git add -A && git commit'],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function refuseR16() {
|
|
58
|
+
throw new KuyperRefusal({
|
|
59
|
+
code: 'R16',
|
|
60
|
+
headline: 'A main tem commits que a dev não tem.',
|
|
61
|
+
details: ['Integrar agora criaria um merge com conflito no meio do seu fluxo.'],
|
|
62
|
+
route: ['git switch dev && git merge main'],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Sem rota: a PRD mostra este exemplo sem bloco de rota — não há o que consertar, só nada a fazer. */
|
|
66
|
+
function refuseR26() {
|
|
67
|
+
throw new KuyperRefusal({
|
|
68
|
+
code: 'R26',
|
|
69
|
+
headline: 'Não há nada novo para integrar.',
|
|
70
|
+
details: ['dev e main apontam para o mesmo commit — não existe merge a criar.'],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function refuseR18(report, changedSources) {
|
|
74
|
+
if (report.code === 1) {
|
|
75
|
+
throw new KuyperRefusal({
|
|
76
|
+
code: 'R18',
|
|
77
|
+
headline: 'kuyper validate ficou inconclusivo.',
|
|
78
|
+
details: [report.reason],
|
|
79
|
+
// §3.4: "a rota não é regenerar — regenerar sobre um estado ilegível
|
|
80
|
+
// não conserta nada." A rota ainda é obrigatória (§3) — aponta pra
|
|
81
|
+
// como confirmar a correção, não pra qual correção fazer.
|
|
82
|
+
route: ['Corrija o que o motivo acima descreve.', 'pnpm exec kuyper validate', 'pnpm exec kuyper integrate'],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const details = [...report.findings];
|
|
86
|
+
if (changedSources.length > 0) {
|
|
87
|
+
details.push('', `fonte alterada: ${changedSources.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
details.push('', 'Faltou regenerar. A main não foi tocada.');
|
|
90
|
+
throw new KuyperRefusal({
|
|
91
|
+
code: 'R18',
|
|
92
|
+
headline: 'A geração está divergente.',
|
|
93
|
+
details,
|
|
94
|
+
route: ['pnpm exec kuyper generate', 'git add -A && git commit', 'pnpm exec kuyper integrate'],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function refuseGateFailed(gate, output) {
|
|
98
|
+
throw new KuyperRefusal({
|
|
99
|
+
code: 'R19',
|
|
100
|
+
headline: `O gate ${gate} falhou.`,
|
|
101
|
+
details: [...output.split('\n').filter((l) => l.length > 0), '', 'Conserte na dev e rode de novo. A main não foi tocada.'],
|
|
102
|
+
route: ['Corrija o que o gate acusou e rode pnpm exec kuyper integrate de novo.'],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function refuseGateWrote(paths) {
|
|
106
|
+
throw new KuyperRefusal({
|
|
107
|
+
code: 'R20',
|
|
108
|
+
headline: paths.length === 1 ? 'Um gate alterou um arquivo durante a execução.' : 'Um gate alterou arquivos durante a execução.',
|
|
109
|
+
details: [...paths, '', 'Em integrate a árvore precisa continuar limpa. Commite a correção na dev e rode de novo.'],
|
|
110
|
+
route: ['git add -- <arquivos acima> && git commit', 'pnpm exec kuyper integrate'],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function formatSuccess(mergeSha, commitCount, gates) {
|
|
114
|
+
const lines = [commitCount === 1 ? '✓ 1 commit integrado na main.' : `✓ ${commitCount} commits integrados na main.`, ''];
|
|
115
|
+
if (gates.length > 0) {
|
|
116
|
+
lines.push(` ${gates.map((g) => `${g.gate} ${(g.durationMs / 1000).toFixed(1)}s`).join(' ')}`, '');
|
|
117
|
+
}
|
|
118
|
+
lines.push(` merge ${mergeSha.slice(0, 7)} "Merge branch 'dev'"`);
|
|
119
|
+
lines.push(' dev avançada até a main');
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Sucesso parcial declarado, nunca sucesso (§3.4): o merge na main já
|
|
124
|
+
* aconteceu quando isto imprime — só o fast-forward de volta pra dev falhou,
|
|
125
|
+
* quase sempre porque a dev avançou durante os gates. Marcador `⚠`, não `✗`:
|
|
126
|
+
* não é recusa, é aviso — o trabalho não se perdeu.
|
|
127
|
+
*/
|
|
128
|
+
function formatPartial(mergeSha, devSha) {
|
|
129
|
+
return [
|
|
130
|
+
'⚠ O merge foi criado, mas a dev não avançou.',
|
|
131
|
+
'',
|
|
132
|
+
` main ${mergeSha.slice(0, 7)} ← correta, com o seu trabalho integrado`,
|
|
133
|
+
` dev ${devSha.slice(0, 7)} ← uma posição atrás`,
|
|
134
|
+
'',
|
|
135
|
+
' Nada se perdeu. Para concluir:',
|
|
136
|
+
'',
|
|
137
|
+
' git switch dev && git merge --ff-only main',
|
|
138
|
+
].join('\n');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* `kuyper integrate` (PRD §3.4). Reaproveita `runExact` (B6) inteiro — mesmo
|
|
142
|
+
* padrão do `validate` reaproveitando o `generate` no B3. As quatro
|
|
143
|
+
* primeiras recusas são de precondição, antes de qualquer gate rodar; a
|
|
144
|
+
* main só é tocada depois de R14/R15(ou branch)/R16/R26/R18/R19/R20 todas
|
|
145
|
+
* passarem.
|
|
146
|
+
*/
|
|
147
|
+
export async function integrate(options = {}) {
|
|
148
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
149
|
+
if (!(await isGitRepo(projectRoot)))
|
|
150
|
+
refuseNotGitRepo();
|
|
151
|
+
const dirty = await dirtyPaths(projectRoot);
|
|
152
|
+
if (dirty.length > 0)
|
|
153
|
+
refuseR14(dirty);
|
|
154
|
+
const branch = await currentBranch(projectRoot);
|
|
155
|
+
if (branch === 'main')
|
|
156
|
+
refuseR15();
|
|
157
|
+
if (branch !== 'dev')
|
|
158
|
+
refuseNotOnDev(branch);
|
|
159
|
+
const mainIsAncestorOfDev = await isAncestor(projectRoot, 'main', 'dev');
|
|
160
|
+
if (!mainIsAncestorOfDev)
|
|
161
|
+
refuseR16();
|
|
162
|
+
const mainSha = await branchSha(projectRoot, 'main');
|
|
163
|
+
const devSha = await branchSha(projectRoot, 'dev');
|
|
164
|
+
if (mainSha === devSha)
|
|
165
|
+
refuseR26();
|
|
166
|
+
const report = await validate({ projectRoot, silent: true });
|
|
167
|
+
if (report.code !== 0) {
|
|
168
|
+
const changedSources = report.code === 2 ? await changedPaths(projectRoot, 'main', 'dev', SOURCE_PATHS) : [];
|
|
169
|
+
refuseR18(report, changedSources);
|
|
170
|
+
}
|
|
171
|
+
const config = await readConfig(join(projectRoot, '.kuyper', 'config.yaml'));
|
|
172
|
+
const outcome = await runExact(gatesForStage(config.gates, 'publish'), projectRoot);
|
|
173
|
+
if (outcome.kind === 'gate-failed')
|
|
174
|
+
refuseGateFailed(outcome.gate, outcome.output);
|
|
175
|
+
if (outcome.kind === 'wrote')
|
|
176
|
+
refuseGateWrote(outcome.paths);
|
|
177
|
+
const commitCount = await commitsAhead(projectRoot, 'main', 'dev');
|
|
178
|
+
await switchBranch(projectRoot, 'main');
|
|
179
|
+
await mergeNoFF(projectRoot, 'dev');
|
|
180
|
+
const mergeSha = (await headSha(projectRoot));
|
|
181
|
+
try {
|
|
182
|
+
await switchBranch(projectRoot, 'dev');
|
|
183
|
+
await mergeFastForward(projectRoot, 'main');
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
const currentDevSha = (await branchSha(projectRoot, 'dev'));
|
|
187
|
+
console.log(formatPartial(mergeSha, currentDevSha));
|
|
188
|
+
return { code: 1, mergeSha, devSha: currentDevSha };
|
|
189
|
+
}
|
|
190
|
+
console.log(formatSuccess(mergeSha, commitCount, outcome.results));
|
|
191
|
+
return { code: 0, mergeSha, gates: outcome.results };
|
|
192
|
+
}
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { writeFileAtomic } from './atomicWrite.js';
|
|
4
|
+
/**
|
|
5
|
+
* Um lock corrompido ou adulterado não pode fazer o Harness tocar em nada
|
|
6
|
+
* fora das raízes gerenciadas (PRD §4.1). Caminho fora delas é recusa, não
|
|
7
|
+
* aviso — checado na leitura, antes de qualquer entrada virar candidato a
|
|
8
|
+
* escrita.
|
|
9
|
+
*/
|
|
10
|
+
const MANAGED_ROOT_PREFIXES = ['.kuyper/', '.claude/', '.agents/'];
|
|
11
|
+
const MANAGED_ROOT_FILES = new Set(['CLAUDE.md', 'AGENTS.md']);
|
|
12
|
+
export function isManagedLockPath(path) {
|
|
13
|
+
if (MANAGED_ROOT_FILES.has(path))
|
|
14
|
+
return true;
|
|
15
|
+
return MANAGED_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
16
|
+
}
|
|
17
|
+
/** Nome de capacidade: minúsculas, dígitos e hífen, sem hífen inicial/final/duplicado. */
|
|
18
|
+
export const CAPABILITY_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
19
|
+
export function isValidCapabilityName(name) {
|
|
20
|
+
return CAPABILITY_NAME_PATTERN.test(name);
|
|
21
|
+
}
|
|
22
|
+
/** Só `\r\n` → `\n`. É a única normalização que o produto decidiu (ADR generated-lock, decisão 3). */
|
|
23
|
+
export function normalizeLineEndings(text) {
|
|
24
|
+
return text.replace(/\r\n/g, '\n');
|
|
25
|
+
}
|
|
26
|
+
export function computeChecksum(content) {
|
|
27
|
+
const text = typeof content === 'string' ? content : content.toString('utf8');
|
|
28
|
+
const normalized = normalizeLineEndings(text);
|
|
29
|
+
const hex = createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
30
|
+
return `sha256:${hex}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Serializa o lock: chaves ordenadas por caminho, indentação de dois espaços,
|
|
34
|
+
* uma entrada por linha, newline final. Duas execuções sobre a mesma fonte
|
|
35
|
+
* produzem bytes idênticos (A4c) — não depende da ordem de inserção.
|
|
36
|
+
*/
|
|
37
|
+
export function serializeLock(lock) {
|
|
38
|
+
const sortedEntries = {};
|
|
39
|
+
for (const key of Object.keys(lock.entries).sort()) {
|
|
40
|
+
sortedEntries[key] = lock.entries[key];
|
|
41
|
+
}
|
|
42
|
+
const ordered = {
|
|
43
|
+
schemaVersion: lock.schemaVersion,
|
|
44
|
+
harness: lock.harness,
|
|
45
|
+
entries: sortedEntries,
|
|
46
|
+
};
|
|
47
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
48
|
+
}
|
|
49
|
+
export class LockParseError extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'LockParseError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function parseLock(text) {
|
|
56
|
+
let data;
|
|
57
|
+
try {
|
|
58
|
+
data = JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
throw new LockParseError(`generated.lock não é JSON válido: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
64
|
+
throw new LockParseError('generated.lock não é um objeto');
|
|
65
|
+
}
|
|
66
|
+
const obj = data;
|
|
67
|
+
if (obj['schemaVersion'] !== 1) {
|
|
68
|
+
throw new LockParseError(`generated.lock: schemaVersion inesperado (${JSON.stringify(obj['schemaVersion'])})`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof obj['harness'] !== 'string') {
|
|
71
|
+
throw new LockParseError('generated.lock: campo harness ausente ou inválido');
|
|
72
|
+
}
|
|
73
|
+
const entriesRaw = obj['entries'];
|
|
74
|
+
if (typeof entriesRaw !== 'object' || entriesRaw === null || Array.isArray(entriesRaw)) {
|
|
75
|
+
throw new LockParseError('generated.lock: campo entries ausente ou inválido');
|
|
76
|
+
}
|
|
77
|
+
const entries = {};
|
|
78
|
+
const unmanaged = [];
|
|
79
|
+
for (const [key, value] of Object.entries(entriesRaw)) {
|
|
80
|
+
if (typeof value !== 'string') {
|
|
81
|
+
throw new LockParseError(`generated.lock: entrada inválida para ${key}`);
|
|
82
|
+
}
|
|
83
|
+
if (!isManagedLockPath(key))
|
|
84
|
+
unmanaged.push(key);
|
|
85
|
+
entries[key] = value;
|
|
86
|
+
}
|
|
87
|
+
if (unmanaged.length > 0) {
|
|
88
|
+
throw new LockParseError(`generated.lock: caminho fora das raízes gerenciadas: ${unmanaged.join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
return { schemaVersion: 1, harness: obj['harness'], entries };
|
|
91
|
+
}
|
|
92
|
+
/** `undefined` quando o lock não existe — o estado de bootstrap da SPEC §3.1.1. */
|
|
93
|
+
export async function readLock(lockPath) {
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = await readFile(lockPath, 'utf8');
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err.code === 'ENOENT')
|
|
100
|
+
return undefined;
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
return parseLock(raw);
|
|
104
|
+
}
|
|
105
|
+
/** Grava o lock pelo mesmo escritor atômico das demais saídas — ele também é `.kuyper-tmp` + rename. */
|
|
106
|
+
export async function writeLock(lockPath, lock) {
|
|
107
|
+
await writeFileAtomic(lockPath, serializeLock(lock));
|
|
108
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { readdir, rm, rmdir } from 'node:fs/promises';
|
|
3
|
+
export function decideOutput(diskChecksum, lockChecksum, newChecksum) {
|
|
4
|
+
if (diskChecksum === undefined) {
|
|
5
|
+
return { action: 'write' };
|
|
6
|
+
}
|
|
7
|
+
if (diskChecksum === lockChecksum) {
|
|
8
|
+
return newChecksum === diskChecksum ? { action: 'already-correct' } : { action: 'write' };
|
|
9
|
+
}
|
|
10
|
+
if (diskChecksum === newChecksum) {
|
|
11
|
+
return { action: 'already-correct' };
|
|
12
|
+
}
|
|
13
|
+
if (lockChecksum === undefined) {
|
|
14
|
+
return { action: 'refuse-foreign' };
|
|
15
|
+
}
|
|
16
|
+
return { action: 'refuse-edited' };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* saídas órfãs = entradas de saída do lock anterior − caminhos de saída
|
|
20
|
+
* desejados (PRD §3.2). Entradas `.kuyper/core/**` nunca participam — sua
|
|
21
|
+
* remoção pertence só ao `update`.
|
|
22
|
+
*/
|
|
23
|
+
export function computeOrphanPaths(lockEntries, desiredPaths) {
|
|
24
|
+
return Object.keys(lockEntries)
|
|
25
|
+
.filter((p) => !p.startsWith('.kuyper/core/'))
|
|
26
|
+
.filter((p) => !desiredPaths.has(p));
|
|
27
|
+
}
|
|
28
|
+
export function decideOrphan(diskChecksum, lockChecksum) {
|
|
29
|
+
if (diskChecksum === undefined)
|
|
30
|
+
return { action: 'already-gone' };
|
|
31
|
+
if (diskChecksum === lockChecksum)
|
|
32
|
+
return { action: 'remove' };
|
|
33
|
+
return { action: 'refuse-edited' };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Remove o arquivo e sobe removendo diretórios vazios até o primeiro
|
|
37
|
+
* não-vazio ou até a raiz do projeto — "não é `rm -rf`" (PRD §3.2). Um
|
|
38
|
+
* arquivo alheio deixado numa pasta de skill impede a subida naquele ponto.
|
|
39
|
+
*/
|
|
40
|
+
export async function removeOrphanFile(projectRoot, relPath) {
|
|
41
|
+
const root = resolve(projectRoot);
|
|
42
|
+
const absPath = resolve(root, relPath);
|
|
43
|
+
await rm(absPath, { force: true });
|
|
44
|
+
let dir = dirname(absPath);
|
|
45
|
+
while (dir !== root && dir.startsWith(`${root}/`)) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = await readdir(dir);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (entries.length > 0)
|
|
54
|
+
break;
|
|
55
|
+
await rmdir(dir);
|
|
56
|
+
dir = dirname(dir);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function outputPath(projectRoot, relPath) {
|
|
60
|
+
return join(projectRoot, relPath);
|
|
61
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Resolvido a partir DESTE módulo — nunca por `process.cwd()`, nunca
|
|
5
|
+
* procurando `@kuyper/harness` a partir do projeto. É a mesma regra do
|
|
6
|
+
* relançamento do binário no `update`: a instalação que responde é sempre a
|
|
7
|
+
* que está rodando, nunca a que for encontrada (ADR empacotamento-do-core,
|
|
8
|
+
* decisão 2).
|
|
9
|
+
*
|
|
10
|
+
* Isto vale tanto compilado (`dist/paths.js`, um nível abaixo da raiz do
|
|
11
|
+
* pacote) quanto em desenvolvimento (`src/paths.ts`, um nível abaixo da raiz
|
|
12
|
+
* do repositório) — a mesma profundidade nos dois layouts.
|
|
13
|
+
*/
|
|
14
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
/** Raiz do pacote em execução: onde ficam `package.json`, `dist/` e `core/`. */
|
|
16
|
+
export function packageRoot() {
|
|
17
|
+
return join(moduleDir, '..');
|
|
18
|
+
}
|
|
19
|
+
/** `core/` do pacote em execução — a origem de distribuição do `core:` (§4.2). */
|
|
20
|
+
export function packageCorePath() {
|
|
21
|
+
return join(packageRoot(), 'core');
|
|
22
|
+
}
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { KuyperRefusal } from './errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* A checagem comum a "todos, menos init" (R1). Todo comando que a chama
|
|
6
|
+
* herda a mesma recusa — é o que o BUILD B1 chama de "uma rota já
|
|
7
|
+
* implementada prova a recusa comum".
|
|
8
|
+
*/
|
|
9
|
+
export async function requireProject(cwd = process.cwd()) {
|
|
10
|
+
const kuyperDir = join(cwd, '.kuyper');
|
|
11
|
+
try {
|
|
12
|
+
await access(kuyperDir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new KuyperRefusal({
|
|
16
|
+
code: 'R1',
|
|
17
|
+
headline: 'Este diretório não é um projeto Kuyper.',
|
|
18
|
+
details: ['Não existe .kuyper/ aqui.'],
|
|
19
|
+
route: [
|
|
20
|
+
'Se for iniciar um projeto novo:',
|
|
21
|
+
' pnpm exec kuyper init',
|
|
22
|
+
'',
|
|
23
|
+
'Se .kuyper/ deveria existir, confira se você está na raiz do',
|
|
24
|
+
'repositório.',
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return kuyperDir;
|
|
29
|
+
}
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { parseConfig, readConfig } from './config.js';
|
|
3
|
+
import { KuyperRefusal } from './errors.js';
|
|
4
|
+
import { gatesForStage, runExact } from './gateRunner.js';
|
|
5
|
+
import { commitsAhead, currentBranch, diffNameStatus, diffShortstat, dirtyPaths, headSha, isGitRepo, oldestCommitDate, push, readFileAtRef, refExists, resolveRef, switchBranch, totalCommits, } from './gitPlumbing.js';
|
|
6
|
+
import { buildDesiredOutputs, loadCapabilities } from './generate.js';
|
|
7
|
+
import { validate } from './validate.js';
|
|
8
|
+
/** O hash universal da árvore vazia — o "antes" da primeira publicação, sem nenhum estado novo. */
|
|
9
|
+
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
10
|
+
const TEST_PATH_PATTERN = /(\.test\.|\.spec\.|\/tests?\/|\/__tests__\/)/;
|
|
11
|
+
function refuseR14(paths) {
|
|
12
|
+
throw new KuyperRefusal({
|
|
13
|
+
code: 'R14',
|
|
14
|
+
headline: 'A árvore de trabalho está suja.',
|
|
15
|
+
details: [...paths, '', 'Os gates precisam rodar sobre o que será publicado, e não sobre alterações que ficariam de fora.'],
|
|
16
|
+
route: ['git add -A && git commit'],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Sem rota: o exemplo da PRD mostra este caso sem bloco de rota — não há o que consertar. */
|
|
20
|
+
function refuseR17(remoteSha) {
|
|
21
|
+
throw new KuyperRefusal({
|
|
22
|
+
code: 'R17',
|
|
23
|
+
headline: 'Não há nada novo para publicar.',
|
|
24
|
+
details: [`origin/main já está em ${remoteSha.slice(0, 7)}.`],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function refuseR18(report) {
|
|
28
|
+
if (report.code === 1) {
|
|
29
|
+
throw new KuyperRefusal({
|
|
30
|
+
code: 'R18',
|
|
31
|
+
headline: 'kuyper validate ficou inconclusivo.',
|
|
32
|
+
details: [report.reason],
|
|
33
|
+
route: ['Corrija o que o motivo acima descreve.', 'pnpm exec kuyper validate', 'pnpm exec kuyper publish'],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
throw new KuyperRefusal({
|
|
37
|
+
code: 'R18',
|
|
38
|
+
headline: 'A geração está divergente.',
|
|
39
|
+
details: [...report.findings, '', 'Faltou regenerar.'],
|
|
40
|
+
route: ['pnpm exec kuyper generate', 'git add -A && git commit', 'pnpm exec kuyper publish'],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* O gate roda de novo sobre a `main` porque código commitado à mão nunca
|
|
45
|
+
* passou por um `integrate` — a mensagem diz isso (§3.5), não é
|
|
46
|
+
* redundância burocrática.
|
|
47
|
+
*/
|
|
48
|
+
function refuseGateFailed(gate, output) {
|
|
49
|
+
throw new KuyperRefusal({
|
|
50
|
+
code: 'R19',
|
|
51
|
+
headline: `O gate ${gate} falhou.`,
|
|
52
|
+
details: [
|
|
53
|
+
...output.split('\n').filter((l) => l.length > 0),
|
|
54
|
+
'',
|
|
55
|
+
'Este código nunca passou por um integrate — foi commitado direto na main.',
|
|
56
|
+
'É a única checagem antes de sair da máquina, e ela reprovou.',
|
|
57
|
+
],
|
|
58
|
+
route: ['Conserte, e rode de novo. Nada saiu daqui.'],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function refuseGateWrote(paths) {
|
|
62
|
+
throw new KuyperRefusal({
|
|
63
|
+
code: 'R20',
|
|
64
|
+
headline: paths.length === 1 ? 'Um gate alterou um arquivo durante a execução.' : 'Um gate alterou arquivos durante a execução.',
|
|
65
|
+
details: [...paths, '', 'Em publish a árvore precisa continuar limpa. Nada saiu daqui.'],
|
|
66
|
+
route: ['git add -- <arquivos acima> && git commit', 'pnpm exec kuyper publish'],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function parsePackageDependencyKeys(text) {
|
|
70
|
+
if (text === undefined)
|
|
71
|
+
return new Set();
|
|
72
|
+
try {
|
|
73
|
+
const data = JSON.parse(text);
|
|
74
|
+
return new Set([...Object.keys(data.dependencies ?? {}), ...Object.keys(data.devDependencies ?? {})]);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return new Set();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function gateSignature(gate) {
|
|
81
|
+
return `${gate.command}\n${gate.stages.slice().sort().join(',')}`;
|
|
82
|
+
}
|
|
83
|
+
function changedGateIds(oldGates, newGates) {
|
|
84
|
+
const oldById = new Map(oldGates.map((g) => [g.id, g]));
|
|
85
|
+
const newById = new Map(newGates.map((g) => [g.id, g]));
|
|
86
|
+
const ids = new Set([...oldById.keys(), ...newById.keys()]);
|
|
87
|
+
const changed = [];
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
const before = oldById.get(id);
|
|
90
|
+
const after = newById.get(id);
|
|
91
|
+
if (before === undefined || after === undefined || gateSignature(before) !== gateSignature(after)) {
|
|
92
|
+
changed.push(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return changed.sort();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Os quatro "sinais que merecem atenção" do §3.5 — nenhum tem código A/R,
|
|
99
|
+
* mecanismo de detecção é decisão de implementação (ver o plano do B8).
|
|
100
|
+
* `fromRef` é `undefined` na primeira publicação: "dependência nova" e
|
|
101
|
+
* "gate alterado" ficam sem "antes" pra comparar e não são calculados;
|
|
102
|
+
* "saída gerada" e "testes removidos" não precisam de um "antes".
|
|
103
|
+
*/
|
|
104
|
+
async function collectSignals(projectRoot, fromRef, toRef, config) {
|
|
105
|
+
const diffFrom = fromRef ?? EMPTY_TREE;
|
|
106
|
+
const entries = await diffNameStatus(projectRoot, diffFrom, toRef);
|
|
107
|
+
const { set } = await loadCapabilities(projectRoot);
|
|
108
|
+
const desired = buildDesiredOutputs(set, config);
|
|
109
|
+
const desiredPaths = new Set(desired.map((o) => o.path));
|
|
110
|
+
const generatedOutputs = entries.filter((e) => desiredPaths.has(e.path)).map((e) => e.path);
|
|
111
|
+
const removedTests = entries.filter((e) => e.status === 'D' && TEST_PATH_PATTERN.test(e.path)).map((e) => e.path);
|
|
112
|
+
let newDependencies = [];
|
|
113
|
+
let changedGates = [];
|
|
114
|
+
if (fromRef !== undefined) {
|
|
115
|
+
const [oldPkg, newPkg] = await Promise.all([
|
|
116
|
+
readFileAtRef(projectRoot, fromRef, 'package.json'),
|
|
117
|
+
readFileAtRef(projectRoot, toRef, 'package.json'),
|
|
118
|
+
]);
|
|
119
|
+
const oldDeps = parsePackageDependencyKeys(oldPkg);
|
|
120
|
+
const newDeps = parsePackageDependencyKeys(newPkg);
|
|
121
|
+
newDependencies = [...newDeps].filter((d) => !oldDeps.has(d)).sort();
|
|
122
|
+
const oldConfigText = await readFileAtRef(projectRoot, fromRef, '.kuyper/config.yaml');
|
|
123
|
+
if (oldConfigText !== undefined) {
|
|
124
|
+
try {
|
|
125
|
+
const oldConfig = parseConfig(oldConfigText);
|
|
126
|
+
changedGates = changedGateIds(oldConfig.gates, config.gates);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// config antigo malformado: sinal best-effort, não bloqueia o publish.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { generatedOutputs, newDependencies, removedTests, changedGates };
|
|
134
|
+
}
|
|
135
|
+
function formatSummary(commitCount, since, stat, gates, signals) {
|
|
136
|
+
const lines = [
|
|
137
|
+
`✓ Pronto para publicar: ${commitCount} commit${commitCount === 1 ? '' : 's'}${since !== undefined ? ` desde ${since}` : ''}.`,
|
|
138
|
+
'',
|
|
139
|
+
` ${stat.files} arquivo${stat.files === 1 ? '' : 's'} +${stat.insertions} −${stat.deletions}`,
|
|
140
|
+
];
|
|
141
|
+
if (gates.length > 0) {
|
|
142
|
+
lines.push('', ` ${gates.map((g) => `${g.gate} ${(g.durationMs / 1000).toFixed(1)}s`).join(' ')}`);
|
|
143
|
+
}
|
|
144
|
+
const warnings = [];
|
|
145
|
+
if (signals.removedTests.length > 0)
|
|
146
|
+
warnings.push(` ⚠ testes removidos: ${signals.removedTests.join(', ')}`);
|
|
147
|
+
if (signals.changedGates.length > 0)
|
|
148
|
+
warnings.push(` ⚠ gate alterado: ${signals.changedGates.join(', ')}`);
|
|
149
|
+
if (signals.generatedOutputs.length > 0)
|
|
150
|
+
warnings.push(` ⚠ saída gerada no conjunto: ${signals.generatedOutputs.join(', ')}`);
|
|
151
|
+
if (signals.newDependencies.length > 0)
|
|
152
|
+
warnings.push(` ⚠ dependência nova: ${signals.newDependencies.join(', ')}`);
|
|
153
|
+
if (warnings.length > 0)
|
|
154
|
+
lines.push('', ...warnings);
|
|
155
|
+
lines.push('', ' → origin/main');
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* `kuyper publish` (PRD §3.5). O único comando que fala com o remoto.
|
|
160
|
+
* Guarda a branch de origem e só volta pra ela no sucesso completo —
|
|
161
|
+
* R14/R17 (precondição, passo 1) nunca trocam de branch; R18/R19/R20
|
|
162
|
+
* acontecem já na `main` e não restauram, do mesmo jeito que o
|
|
163
|
+
* equivalente manual pararia no meio sem `trap`/`finally`.
|
|
164
|
+
*/
|
|
165
|
+
export async function publish(options = {}) {
|
|
166
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
167
|
+
if (!(await isGitRepo(projectRoot))) {
|
|
168
|
+
throw new KuyperRefusal({ headline: 'Este diretório não é um repositório Git.' });
|
|
169
|
+
}
|
|
170
|
+
const dirty = await dirtyPaths(projectRoot);
|
|
171
|
+
if (dirty.length > 0)
|
|
172
|
+
refuseR14(dirty);
|
|
173
|
+
const hasRemote = await refExists(projectRoot, 'origin/main');
|
|
174
|
+
if (hasRemote) {
|
|
175
|
+
const ahead = await commitsAhead(projectRoot, 'origin/main', 'main');
|
|
176
|
+
if (ahead === 0) {
|
|
177
|
+
const remoteSha = (await resolveRef(projectRoot, 'origin/main'));
|
|
178
|
+
refuseR17(remoteSha);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const originalBranch = await currentBranch(projectRoot);
|
|
182
|
+
if (originalBranch !== 'main') {
|
|
183
|
+
await switchBranch(projectRoot, 'main');
|
|
184
|
+
}
|
|
185
|
+
const report = await validate({ projectRoot, silent: true });
|
|
186
|
+
if (report.code !== 0)
|
|
187
|
+
refuseR18(report);
|
|
188
|
+
const config = await readConfig(join(projectRoot, '.kuyper', 'config.yaml'));
|
|
189
|
+
const outcome = await runExact(gatesForStage(config.gates, 'publish'), projectRoot);
|
|
190
|
+
if (outcome.kind === 'gate-failed')
|
|
191
|
+
refuseGateFailed(outcome.gate, outcome.output);
|
|
192
|
+
if (outcome.kind === 'wrote')
|
|
193
|
+
refuseGateWrote(outcome.paths);
|
|
194
|
+
const fromRef = hasRemote ? 'origin/main' : undefined;
|
|
195
|
+
const commitCount = await totalCommits(projectRoot, fromRef !== undefined ? `${fromRef}..main` : 'main');
|
|
196
|
+
const since = await oldestCommitDate(projectRoot, fromRef !== undefined ? `${fromRef}..main` : 'main');
|
|
197
|
+
const stat = await diffShortstat(projectRoot, fromRef ?? EMPTY_TREE, 'main');
|
|
198
|
+
const signals = await collectSignals(projectRoot, fromRef, 'main', config);
|
|
199
|
+
console.log(formatSummary(commitCount, since, stat, outcome.results, signals));
|
|
200
|
+
await push(projectRoot, 'origin', 'main');
|
|
201
|
+
const pushedSha = (await headSha(projectRoot));
|
|
202
|
+
if (originalBranch !== undefined && originalBranch !== 'main') {
|
|
203
|
+
await switchBranch(projectRoot, originalBranch);
|
|
204
|
+
}
|
|
205
|
+
console.log(`✓ main publicada. ${pushedSha.slice(0, 7)} → origin/main`);
|
|
206
|
+
return { code: 0, pushedSha };
|
|
207
|
+
}
|