@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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { KuyperRefusal } from './errors.js';
|
|
6
|
+
import { requireProject } from './project.js';
|
|
7
|
+
import { generate } from './generate.js';
|
|
8
|
+
import { validate } from './validate.js';
|
|
9
|
+
import { init } from './init.js';
|
|
10
|
+
import { integrate } from './integrate.js';
|
|
11
|
+
import { publish } from './publish.js';
|
|
12
|
+
import { update, continueUpdate } from './update.js';
|
|
13
|
+
import { runCapabilityCommand } from './capabilityCommands.js';
|
|
14
|
+
import { runPreCommitHook, runPrePushHook } from './hookBehavior.js';
|
|
15
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const pkg = JSON.parse(readFileSync(join(moduleDir, '..', 'package.json'), 'utf8'));
|
|
17
|
+
/** As oito interfaces públicas (PRD §1). Nada além disso. */
|
|
18
|
+
export const COMMANDS = [
|
|
19
|
+
'init',
|
|
20
|
+
'generate',
|
|
21
|
+
'validate',
|
|
22
|
+
'integrate',
|
|
23
|
+
'publish',
|
|
24
|
+
'update',
|
|
25
|
+
'rule',
|
|
26
|
+
'skill',
|
|
27
|
+
];
|
|
28
|
+
function isCommand(value) {
|
|
29
|
+
return COMMANDS.includes(value);
|
|
30
|
+
}
|
|
31
|
+
function printCommandList() {
|
|
32
|
+
console.log('kuyper — copiloto de desenvolvimento\n');
|
|
33
|
+
console.log('Comandos:');
|
|
34
|
+
for (const cmd of COMMANDS)
|
|
35
|
+
console.log(` kuyper ${cmd}`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Todos os comandos, menos `init`, herdam a checagem comum (R1) — mesmo
|
|
39
|
+
* antes de ter lógica própria (prova do B1). No B10, as oito interfaces
|
|
40
|
+
* públicas passam a ter lógica real.
|
|
41
|
+
*/
|
|
42
|
+
const HANDLERS = {
|
|
43
|
+
init: async () => init(),
|
|
44
|
+
generate: async (rest) => {
|
|
45
|
+
await requireProject();
|
|
46
|
+
await generate({ force: rest.includes('--force') });
|
|
47
|
+
return 0;
|
|
48
|
+
},
|
|
49
|
+
validate: async () => {
|
|
50
|
+
await requireProject();
|
|
51
|
+
const report = await validate();
|
|
52
|
+
return report.code;
|
|
53
|
+
},
|
|
54
|
+
integrate: async () => {
|
|
55
|
+
await requireProject();
|
|
56
|
+
const report = await integrate();
|
|
57
|
+
return report.code;
|
|
58
|
+
},
|
|
59
|
+
publish: async () => {
|
|
60
|
+
await requireProject();
|
|
61
|
+
const report = await publish();
|
|
62
|
+
return report.code;
|
|
63
|
+
},
|
|
64
|
+
update: async () => {
|
|
65
|
+
await requireProject();
|
|
66
|
+
return update();
|
|
67
|
+
},
|
|
68
|
+
rule: async (rest) => {
|
|
69
|
+
await requireProject();
|
|
70
|
+
return runCapabilityCommand('rule', rest, { readStdin: () => readStdin(process.stdin) });
|
|
71
|
+
},
|
|
72
|
+
skill: async (rest) => {
|
|
73
|
+
await requireProject();
|
|
74
|
+
return runCapabilityCommand('skill', rest, { readStdin: () => readStdin(process.stdin) });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
async function readStdin(stream) {
|
|
78
|
+
const chunks = [];
|
|
79
|
+
for await (const chunk of stream) {
|
|
80
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `__hook` e `__update-continue` são maquinaria declarada, não a nona
|
|
86
|
+
* interface: fora de `COMMANDS`, da ajuda e da lista de comandos. `__hook`
|
|
87
|
+
* (PRD §3.8) é o ponto de entrada que o script de hook gerado chama;
|
|
88
|
+
* `__update-continue` (PRD §3.6, passos 5–9) é o que `update()` (Fase 1,
|
|
89
|
+
* `update.ts`) invoca relançando o binário recém-instalado — nunca algo
|
|
90
|
+
* que um humano ou uma LLM digita.
|
|
91
|
+
*/
|
|
92
|
+
async function runHook(rest) {
|
|
93
|
+
const [name] = rest;
|
|
94
|
+
if (name === 'pre-commit')
|
|
95
|
+
return runPreCommitHook(process.cwd());
|
|
96
|
+
if (name === 'pre-push')
|
|
97
|
+
return runPrePushHook(process.cwd(), await readStdin(process.stdin));
|
|
98
|
+
console.error(`✗ __hook desconhecido: ${String(name)}`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
export async function main(argv) {
|
|
102
|
+
const [maybeCmd, ...rest] = argv;
|
|
103
|
+
if (maybeCmd === '--version' || maybeCmd === '-v') {
|
|
104
|
+
console.log(pkg.version);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (maybeCmd === undefined) {
|
|
108
|
+
printCommandList();
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
if (maybeCmd === '__hook') {
|
|
113
|
+
return await runHook(rest);
|
|
114
|
+
}
|
|
115
|
+
if (maybeCmd === '__update-continue') {
|
|
116
|
+
return await continueUpdate({ projectRoot: process.cwd() });
|
|
117
|
+
}
|
|
118
|
+
if (!isCommand(maybeCmd)) {
|
|
119
|
+
console.error(`✗ Comando desconhecido: ${maybeCmd}\n`);
|
|
120
|
+
printCommandList();
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
return await HANDLERS[maybeCmd](rest);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (err instanceof KuyperRefusal) {
|
|
127
|
+
console.error(err.format());
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Comparar `process.argv[1]` direto com `fileURLToPath(import.meta.url)`
|
|
135
|
+
* quebra em qualquer invocação com um symlink no meio do caminho — que é
|
|
136
|
+
* exatamente como o binário é invocado de verdade: `node_modules/.bin/kuyper`
|
|
137
|
+
* (o que `pnpm add -D` cria) É um symlink, e macOS já resolve `/tmp` para
|
|
138
|
+
* `/private/tmp` por conta própria. Sem `realpathSync` dos dois lados,
|
|
139
|
+
* `isMain` dá falso sempre que a instalação real acontece, e o binário
|
|
140
|
+
* inteiro não faz nada — silenciosamente, saindo com 0. Achado testando o
|
|
141
|
+
* hook materializado de verdade via `node_modules/.bin/`, não em uso comum
|
|
142
|
+
* (B6) — nenhum teste anterior invocava por aí.
|
|
143
|
+
*/
|
|
144
|
+
function isMainModule() {
|
|
145
|
+
const invoked = process.argv[1];
|
|
146
|
+
if (invoked === undefined)
|
|
147
|
+
return false;
|
|
148
|
+
try {
|
|
149
|
+
return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const isMain = isMainModule();
|
|
156
|
+
if (isMain) {
|
|
157
|
+
main(process.argv.slice(2))
|
|
158
|
+
.then((code) => {
|
|
159
|
+
process.exitCode = code;
|
|
160
|
+
})
|
|
161
|
+
.catch((err) => {
|
|
162
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
});
|
|
165
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
/**
|
|
4
|
+
* Schema inválido recusa (R25): campo obrigatório ausente, gate duplicado,
|
|
5
|
+
* estágio desconhecido, comando vazio, ou campo que o produto já declarou
|
|
6
|
+
* morto (`mainBranch`, `workBranch`, `remote` — PRD §4.1).
|
|
7
|
+
*/
|
|
8
|
+
export class ConfigSchemaError extends Error {
|
|
9
|
+
issues;
|
|
10
|
+
constructor(issues) {
|
|
11
|
+
super(`.kuyper/config.yaml inválido:\n${issues.map((i) => ` - ${i}`).join('\n')}`);
|
|
12
|
+
this.issues = issues;
|
|
13
|
+
this.name = 'ConfigSchemaError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const KNOWN_PROVIDERS = new Set(['claude', 'codex']);
|
|
17
|
+
const KNOWN_STAGES = new Set(['pre-commit', 'publish']);
|
|
18
|
+
const TOP_LEVEL_KEYS = new Set(['schemaVersion', 'providers', 'gates']);
|
|
19
|
+
const PROVIDERS_KEYS = new Set(['enabled']);
|
|
20
|
+
const GATE_KEYS = new Set(['id', 'command', 'stages']);
|
|
21
|
+
function isPlainObject(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Puro: recebe o texto do YAML, devolve a config validada ou lança
|
|
26
|
+
* `ConfigSchemaError` com todos os achados — não só o primeiro.
|
|
27
|
+
*/
|
|
28
|
+
export function parseConfig(text) {
|
|
29
|
+
const issues = [];
|
|
30
|
+
let data;
|
|
31
|
+
try {
|
|
32
|
+
data = parseYaml(text);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
throw new ConfigSchemaError([`YAML malformado: ${err.message}`]);
|
|
36
|
+
}
|
|
37
|
+
if (!isPlainObject(data)) {
|
|
38
|
+
throw new ConfigSchemaError(['o arquivo não é um mapa YAML']);
|
|
39
|
+
}
|
|
40
|
+
for (const key of Object.keys(data)) {
|
|
41
|
+
if (!TOP_LEVEL_KEYS.has(key))
|
|
42
|
+
issues.push(`campo desconhecido: ${key}`);
|
|
43
|
+
}
|
|
44
|
+
if (data['schemaVersion'] !== 1) {
|
|
45
|
+
issues.push(`schemaVersion deve ser 1 (encontrado: ${JSON.stringify(data['schemaVersion'])})`);
|
|
46
|
+
}
|
|
47
|
+
const enabled = [];
|
|
48
|
+
const providers = data['providers'];
|
|
49
|
+
if (!isPlainObject(providers)) {
|
|
50
|
+
issues.push('providers ausente ou inválido');
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
for (const key of Object.keys(providers)) {
|
|
54
|
+
if (!PROVIDERS_KEYS.has(key))
|
|
55
|
+
issues.push(`providers: campo desconhecido: ${key}`);
|
|
56
|
+
}
|
|
57
|
+
const enabledRaw = providers['enabled'];
|
|
58
|
+
if (!Array.isArray(enabledRaw)) {
|
|
59
|
+
issues.push('providers.enabled ausente ou não é uma lista');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
for (const p of enabledRaw) {
|
|
63
|
+
if (typeof p === 'string' && KNOWN_PROVIDERS.has(p)) {
|
|
64
|
+
enabled.push(p);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
issues.push(`provider desconhecido: ${JSON.stringify(p)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const gates = [];
|
|
73
|
+
const gatesRaw = data['gates'];
|
|
74
|
+
const seenIds = new Set();
|
|
75
|
+
if (!Array.isArray(gatesRaw)) {
|
|
76
|
+
issues.push('gates ausente ou não é uma lista');
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
gatesRaw.forEach((gateRaw, i) => {
|
|
80
|
+
if (!isPlainObject(gateRaw)) {
|
|
81
|
+
issues.push(`gates[${i}]: não é um mapa`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const key of Object.keys(gateRaw)) {
|
|
85
|
+
if (!GATE_KEYS.has(key))
|
|
86
|
+
issues.push(`gates[${i}]: campo desconhecido: ${key}`);
|
|
87
|
+
}
|
|
88
|
+
const id = gateRaw['id'];
|
|
89
|
+
const command = gateRaw['command'];
|
|
90
|
+
const stagesRaw = gateRaw['stages'];
|
|
91
|
+
let idOk = false;
|
|
92
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
93
|
+
issues.push(`gates[${i}]: id ausente ou vazio`);
|
|
94
|
+
}
|
|
95
|
+
else if (seenIds.has(id)) {
|
|
96
|
+
issues.push(`gate duplicado: ${id}`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
seenIds.add(id);
|
|
100
|
+
idOk = true;
|
|
101
|
+
}
|
|
102
|
+
const commandOk = typeof command === 'string' && command.trim().length > 0;
|
|
103
|
+
if (!commandOk)
|
|
104
|
+
issues.push(`gates[${i}]: comando vazio`);
|
|
105
|
+
const stages = [];
|
|
106
|
+
if (!Array.isArray(stagesRaw)) {
|
|
107
|
+
issues.push(`gates[${i}]: stages ausente ou não é uma lista`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
for (const s of stagesRaw) {
|
|
111
|
+
if (typeof s === 'string' && KNOWN_STAGES.has(s)) {
|
|
112
|
+
stages.push(s);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
issues.push(`gates[${i}]: estágio desconhecido: ${JSON.stringify(s)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (idOk && commandOk) {
|
|
120
|
+
gates.push({ id: id, command: command, stages });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (issues.length > 0) {
|
|
125
|
+
throw new ConfigSchemaError(issues);
|
|
126
|
+
}
|
|
127
|
+
return { schemaVersion: 1, providers: { enabled }, gates };
|
|
128
|
+
}
|
|
129
|
+
export async function readConfig(configPath) {
|
|
130
|
+
const text = await readFile(configPath, 'utf8');
|
|
131
|
+
return parseConfig(text);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* "Um gate que não pode ser executado não é gate vermelho: é configuração
|
|
135
|
+
* quebrada, e o Harness acusa o config.yaml, não o código" (PRD §4.1).
|
|
136
|
+
*
|
|
137
|
+
* Só confere gates cujo comando é `pnpm <script>` — é a convenção que o
|
|
138
|
+
* corpus inteiro usa, e a checagem aponta o config.yaml, não tenta validar
|
|
139
|
+
* comando arbitrário. Devolve os gates cujo script não existe no
|
|
140
|
+
* `package.json` do projeto.
|
|
141
|
+
*/
|
|
142
|
+
export function findGatesWithMissingScript(gates, packageJsonScripts) {
|
|
143
|
+
const missing = [];
|
|
144
|
+
for (const gate of gates) {
|
|
145
|
+
const match = /^pnpm\s+([\w:-]+)/.exec(gate.command);
|
|
146
|
+
if (!match)
|
|
147
|
+
continue;
|
|
148
|
+
const scriptName = match[1];
|
|
149
|
+
if (!(scriptName in packageJsonScripts)) {
|
|
150
|
+
missing.push(gate);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return missing;
|
|
154
|
+
}
|
|
155
|
+
export async function readPackageScripts(packageJsonPath) {
|
|
156
|
+
const text = await readFile(packageJsonPath, 'utf8');
|
|
157
|
+
const data = JSON.parse(text);
|
|
158
|
+
return data.scripts ?? {};
|
|
159
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isTempPath } from './atomicWrite.js';
|
|
4
|
+
import { computeChecksum } from './lock.js';
|
|
5
|
+
/**
|
|
6
|
+
* Chaves relativas (com `/`) → checksum, de todo arquivo regular sob
|
|
7
|
+
* `rootDir`. Leitores ignoram `.kuyper-tmp` e não-regulares (symlink etc.) —
|
|
8
|
+
* "o mesmo conjunto de arquivos regulares" da SPEC protocolo-core-lock §4.
|
|
9
|
+
*/
|
|
10
|
+
export async function checksumTree(rootDir) {
|
|
11
|
+
const result = new Map();
|
|
12
|
+
await walk(rootDir, '');
|
|
13
|
+
return result;
|
|
14
|
+
async function walk(absDir, relPrefix) {
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = await readdir(absDir, { withFileTypes: true });
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (err.code === 'ENOENT')
|
|
21
|
+
return;
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (isTempPath(entry.name))
|
|
26
|
+
continue;
|
|
27
|
+
const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
28
|
+
const absPath = join(absDir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
await walk(absPath, relPath);
|
|
31
|
+
}
|
|
32
|
+
else if (entry.isFile()) {
|
|
33
|
+
const content = await readFile(absPath);
|
|
34
|
+
result.set(relPath, computeChecksum(content));
|
|
35
|
+
}
|
|
36
|
+
// symlinks e outros tipos: fora de "arquivo regular", ignorados.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function mapsEqual(a, b) {
|
|
41
|
+
if (a.size !== b.size)
|
|
42
|
+
return false;
|
|
43
|
+
for (const [k, v] of a) {
|
|
44
|
+
if (b.get(k) !== v)
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `.kuyper/core/**` do lock anterior, com o prefixo removido — o candidato
|
|
51
|
+
* `L` da SPEC protocolo-core-lock §4.
|
|
52
|
+
*/
|
|
53
|
+
export function coreEntriesFromLock(lockEntries) {
|
|
54
|
+
const prefix = '.kuyper/core/';
|
|
55
|
+
const out = new Map();
|
|
56
|
+
for (const [key, value] of Object.entries(lockEntries)) {
|
|
57
|
+
if (key.startsWith(prefix))
|
|
58
|
+
out.set(key.slice(prefix.length), value);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A classificação agregada (SPEC protocolo-core-lock §4). A unidade de
|
|
64
|
+
* autorização é a árvore inteira, nunca um arquivo — não existe aceitação
|
|
65
|
+
* independente por caminho.
|
|
66
|
+
*/
|
|
67
|
+
export function classifyCore(L, P, D) {
|
|
68
|
+
const unknown = [...D.keys()].filter((k) => !L.has(k) && !P.has(k));
|
|
69
|
+
if (unknown.length > 0)
|
|
70
|
+
return { state: 'unknown-path', paths: unknown.sort() };
|
|
71
|
+
if (mapsEqual(D, L))
|
|
72
|
+
return { state: 'anterior-intact' };
|
|
73
|
+
if (mapsEqual(D, P))
|
|
74
|
+
return { state: 'transition-complete' };
|
|
75
|
+
return { state: 'mismatch' };
|
|
76
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const LINE_WIDTH = 68;
|
|
2
|
+
function withCodeTag(headline, code) {
|
|
3
|
+
const prefix = `✗ ${headline}`;
|
|
4
|
+
const tag = `(${code})`;
|
|
5
|
+
const gap = LINE_WIDTH - prefix.length - tag.length;
|
|
6
|
+
return gap > 1 ? `${prefix}${' '.repeat(gap)}${tag}` : `${prefix} ${tag}`;
|
|
7
|
+
}
|
|
8
|
+
function indentBlock(lines, indent) {
|
|
9
|
+
return lines.map((line) => (line === '' ? '' : `${indent}${line}`));
|
|
10
|
+
}
|
|
11
|
+
export function formatRefusal(opts) {
|
|
12
|
+
const out = [];
|
|
13
|
+
out.push(opts.code ? withCodeTag(opts.headline, opts.code) : `✗ ${opts.headline}`);
|
|
14
|
+
if (opts.details && opts.details.length > 0) {
|
|
15
|
+
out.push('');
|
|
16
|
+
out.push(...indentBlock(opts.details, ' '));
|
|
17
|
+
}
|
|
18
|
+
if (opts.route && opts.route.length > 0) {
|
|
19
|
+
out.push('');
|
|
20
|
+
out.push(...indentBlock(opts.route, ' '));
|
|
21
|
+
}
|
|
22
|
+
return out.join('\n');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Uma recusa do produto. `format()` produz o texto exatamente na forma do
|
|
26
|
+
* PRD §3 — quem lança isto no dispatcher só precisa imprimir e sair com 1.
|
|
27
|
+
*/
|
|
28
|
+
export class KuyperRefusal extends Error {
|
|
29
|
+
opts;
|
|
30
|
+
constructor(opts) {
|
|
31
|
+
super(opts.headline);
|
|
32
|
+
this.opts = opts;
|
|
33
|
+
this.name = 'KuyperRefusal';
|
|
34
|
+
}
|
|
35
|
+
format() {
|
|
36
|
+
return formatRefusal(this.opts);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { addPaths, dirtyPaths, hashFiles, unstagedAndUntrackedPaths } from './gitPlumbing.js';
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
export function gatesForStage(gates, stage) {
|
|
6
|
+
return gates.filter((g) => g.stages.includes(stage));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Roda o comando do gate via shell (`sh -c`) — o mesmo tanto faz `pnpm
|
|
10
|
+
* typecheck` quanto algo com pipe ou aspas. Um gate que não pode ser
|
|
11
|
+
* executado (comando inexistente) e um gate que roda e falha caem no mesmo
|
|
12
|
+
* `ok: false` — a distinção entre "configuração quebrada" e "código
|
|
13
|
+
* quebrado" não é deste módulo (PRD §4.1); ele só relata.
|
|
14
|
+
*/
|
|
15
|
+
export async function runGateCommand(command, cwd) {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout, stderr } = await execAsync(command, { cwd, maxBuffer: 16 * 1024 * 1024 });
|
|
18
|
+
return { ok: true, output: `${stdout}${stderr}` };
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
const e = err;
|
|
22
|
+
const output = `${e.stdout ?? ''}${e.stderr ?? ''}`;
|
|
23
|
+
return { ok: false, output: output.length > 0 ? output : e.message };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* O laço do `pre-commit` (PRD §4.1, ADR gates-que-escrevem). Até
|
|
28
|
+
* `maxRounds` voltas, cada volta rodando **todos** os gates do estágio em
|
|
29
|
+
* ordem — se o lint conserta um arquivo, o typecheck da mesma volta já
|
|
30
|
+
* rodou sobre a versão de antes, então uma volta que mudou algo sempre
|
|
31
|
+
* repete a lista inteira, nunca só o gate seguinte.
|
|
32
|
+
*
|
|
33
|
+
* A fotografia (diff + não rastreados) é tirada **por gate**, não por
|
|
34
|
+
* volta — é o que permite o relatório final dizer qual gate corrigiu o
|
|
35
|
+
* quê (§3.8: *"lint corrigiu 2 arquivos"*), sem mudar a regra de quando
|
|
36
|
+
* repetir a volta.
|
|
37
|
+
*
|
|
38
|
+
* Um arquivo que já estava sujo antes de um gate rodar e teve o
|
|
39
|
+
* **conteúdo** mudado por ele (não só a categoria do `status` — daí o
|
|
40
|
+
* hash) nunca é readicionado: é recusa, a limitação do staging parcial
|
|
41
|
+
* declarada no §3.8.
|
|
42
|
+
*/
|
|
43
|
+
export async function runConverging(gates, cwd, options = {}) {
|
|
44
|
+
const maxRounds = options.maxRounds ?? 3;
|
|
45
|
+
const fixedByGate = new Map();
|
|
46
|
+
let lastTouched = [];
|
|
47
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
48
|
+
let touchedThisRound = false;
|
|
49
|
+
for (const gate of gates) {
|
|
50
|
+
const before = await unstagedAndUntrackedPaths(cwd);
|
|
51
|
+
const beforeHashes = await hashFiles(cwd, before);
|
|
52
|
+
const result = await runGateCommand(gate.command, cwd);
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
return { kind: 'gate-failed', gate: gate.id, output: result.output };
|
|
55
|
+
}
|
|
56
|
+
const after = await unstagedAndUntrackedPaths(cwd);
|
|
57
|
+
const touchedNew = [...after].filter((p) => !before.has(p)).sort();
|
|
58
|
+
const stillDirty = [...before].filter((p) => after.has(p));
|
|
59
|
+
const afterHashesForStillDirty = await hashFiles(cwd, stillDirty);
|
|
60
|
+
const touchedPreexisting = stillDirty.filter((p) => afterHashesForStillDirty.get(p) !== beforeHashes.get(p)).sort();
|
|
61
|
+
if (touchedPreexisting.length > 0) {
|
|
62
|
+
return { kind: 'edited-unstaged', gate: gate.id, paths: touchedPreexisting };
|
|
63
|
+
}
|
|
64
|
+
if (touchedNew.length === 0)
|
|
65
|
+
continue;
|
|
66
|
+
touchedThisRound = true;
|
|
67
|
+
lastTouched = touchedNew;
|
|
68
|
+
await addPaths(cwd, touchedNew);
|
|
69
|
+
const existing = fixedByGate.get(gate.id) ?? new Set();
|
|
70
|
+
for (const p of touchedNew)
|
|
71
|
+
existing.add(p);
|
|
72
|
+
fixedByGate.set(gate.id, existing);
|
|
73
|
+
}
|
|
74
|
+
if (!touchedThisRound) {
|
|
75
|
+
const fixed = [...fixedByGate.entries()].map(([id, paths]) => ({ gate: id, paths: [...paths].sort() }));
|
|
76
|
+
return { kind: 'ok', fixed };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { kind: 'no-converge', paths: lastTouched };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* O modo `integrate`/`publish`/`pre-push` (PRD §4.1, ADR gates-que-escrevem,
|
|
83
|
+
* decisão 4): sem conserto. Quem chama garante a árvore limpa **antes** de
|
|
84
|
+
* entrar aqui — a R14 nos comandos, a precondição do §3.8 no `pre-push`;
|
|
85
|
+
* este runner não reconfere isso, só detecta se algum gate sujou o que
|
|
86
|
+
* devia continuar limpo. `git status --porcelain` basta (não precisa de
|
|
87
|
+
* `write-tree`): a árvore começou limpa, então qualquer entrada nova já é
|
|
88
|
+
* a resposta — diferente do `pre-commit`, onde ela é suja por natureza.
|
|
89
|
+
*
|
|
90
|
+
* A duração por gate (`results`) é o que o `integrate` (§3.4) e o `publish`
|
|
91
|
+
* (§3.5) imprimem no relatório de sucesso — medida aqui porque é aqui que
|
|
92
|
+
* cada gate roda, não recalculada por quem chama.
|
|
93
|
+
*/
|
|
94
|
+
export async function runExact(gates, cwd) {
|
|
95
|
+
const results = [];
|
|
96
|
+
for (const gate of gates) {
|
|
97
|
+
const start = Date.now();
|
|
98
|
+
const result = await runGateCommand(gate.command, cwd);
|
|
99
|
+
if (!result.ok) {
|
|
100
|
+
return { kind: 'gate-failed', gate: gate.id, output: result.output };
|
|
101
|
+
}
|
|
102
|
+
results.push({ gate: gate.id, durationMs: Date.now() - start });
|
|
103
|
+
}
|
|
104
|
+
const paths = await dirtyPaths(cwd);
|
|
105
|
+
if (paths.length > 0) {
|
|
106
|
+
return { kind: 'wrote', paths };
|
|
107
|
+
}
|
|
108
|
+
return { kind: 'ok', results };
|
|
109
|
+
}
|