@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/dist/update.js ADDED
@@ -0,0 +1,434 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { checksumTree, classifyCore, coreEntriesFromLock, mapsEqual } from './coreClassification.js';
5
+ import { KuyperRefusal } from './errors.js';
6
+ import { checkReferences, generate, harnessVersion, loadCapabilities } from './generate.js';
7
+ import { dirtyPaths, isGitRepo } from './gitPlumbing.js';
8
+ import { readLock } from './lock.js';
9
+ import { removeOrphanFile } from './outputPlan.js';
10
+ import { packageCorePath } from './paths.js';
11
+ import { validate } from './validate.js';
12
+ import { writeFileAtomic } from './atomicWrite.js';
13
+ const CORE_REL = join('.kuyper', 'core');
14
+ const LOCK_REL = join('.kuyper', 'generated.lock');
15
+ function corePath(projectRoot) {
16
+ return join(projectRoot, CORE_REL);
17
+ }
18
+ function lockPath(projectRoot) {
19
+ return join(projectRoot, LOCK_REL);
20
+ }
21
+ /**
22
+ * `spawn`, não `execFile`: as duas chamadas deste módulo (`pnpm add`, o
23
+ * relançamento) precisam de stdio **herdado** — o usuário vê o pnpm
24
+ * instalando e o binário novo relatando, do jeito que veria rodando à
25
+ * mão — e `execFile` é feito pra capturar saída como string, não pra
26
+ * repassar o terminal. Resolve com o código de saída (nunca rejeita por
27
+ * saída ≠0); só rejeita quando o processo **nem chega a nascer**
28
+ * (`ENOENT` etc.) — é essa distinção que a Fase 1 usa pra saber se a Fase
29
+ * 2 teve chance de explicar a própria falha.
30
+ */
31
+ function runInherited(command, args, cwd) {
32
+ return new Promise((resolve, reject) => {
33
+ const child = spawn(command, args, { cwd, stdio: 'inherit' });
34
+ child.on('error', reject);
35
+ child.on('exit', (code) => resolve(code ?? 1));
36
+ });
37
+ }
38
+ function diffKeys(a, b) {
39
+ const diffs = new Set();
40
+ for (const [k, v] of a)
41
+ if (b.get(k) !== v)
42
+ diffs.add(k);
43
+ for (const k of b.keys())
44
+ if (!a.has(k))
45
+ diffs.add(k);
46
+ return [...diffs].sort();
47
+ }
48
+ function refuseR14(paths) {
49
+ throw new KuyperRefusal({
50
+ code: 'R14',
51
+ headline: 'A árvore de trabalho está suja.',
52
+ details: [
53
+ ...paths,
54
+ '',
55
+ 'O update reescreve .kuyper/core/ inteiro. Com a árvore suja, o git diff da atualização viria misturado com o seu trabalho, e revisar seria impossível. Nada foi instalado.',
56
+ ],
57
+ route: ['git add -A && git commit'],
58
+ });
59
+ }
60
+ /**
61
+ * A PRD nomeia o arquivo específico ("core:fluxo-git.md foi editado à
62
+ * mão"), mas `generate()`'s própria recusa de core (`refuseCoreR7`) é
63
+ * genérica — "edição manual, remoção ou transição incompleta", sem listar
64
+ * caminho. Por isso `update()` faz o próprio diagnóstico (compara `D`
65
+ * contra o `L` do lock) em vez de reaproveitar a exceção do `generate`.
66
+ *
67
+ * A rota **não** é `git restore <path>` (revisão do B9, Achado 2): a R14
68
+ * já exige árvore limpa antes de chegar aqui, então a edição que causou a
69
+ * R21 **sempre** já está commitada — `git restore` sem `--source` restaura
70
+ * do índice/HEAD, que já contém a própria edição. É sempre um no-op,
71
+ * confirmado por reprodução (git init; commit original; edita; commit;
72
+ * git restore não muda nada). A rota certa copia de volta do pacote **já
73
+ * instalado** — neste ponto do `update`, ainda a versão antiga, e ela
74
+ * precisa coincidir com `L` pra o projeto ter chegado coerente até aqui —
75
+ * o mesmo mecanismo que `formatRelaunchBroken`/`formatEqualizedButIncomplete`
76
+ * já usam pra igualação, estendido pra este ponto mais cedo.
77
+ */
78
+ function refuseR21CoreEdited(paths) {
79
+ throw new KuyperRefusal({
80
+ code: 'R21',
81
+ headline: 'A geração tem achados.',
82
+ details: [
83
+ ...paths.map((p) => `${p} foi editado à mão.`),
84
+ '',
85
+ 'O update substituiria esse(s) arquivo(s) e a edição se perderia.',
86
+ 'Nada foi instalado. Resolva primeiro:',
87
+ ],
88
+ route: [
89
+ 'rm -rf .kuyper/core && cp -R node_modules/@kuyper/harness/core .kuyper/core',
90
+ 'git add -A && git commit',
91
+ 'pnpm exec kuyper update',
92
+ ],
93
+ });
94
+ }
95
+ function refuseR21PendingWrites(paths) {
96
+ throw new KuyperRefusal({
97
+ code: 'R21',
98
+ headline: 'A geração tem achados.',
99
+ details: [...paths, '', 'O generate encontrou e corrigiu isto agora, antes de instalar qualquer coisa — revise antes de tentar de novo.'],
100
+ route: ['git add -A && git commit', 'pnpm exec kuyper update'],
101
+ });
102
+ }
103
+ /** Sem código: o exemplo do PRD não mostra tag `(Rxx)` pra esta recusa. */
104
+ function refusePnpmFailed(spec) {
105
+ throw new KuyperRefusal({
106
+ headline: `O pnpm falhou ao instalar ${spec}.`,
107
+ details: ['Nada em .kuyper/ foi alterado — a instalação vem antes da igualação.'],
108
+ route: [
109
+ 'Para voltar à versão do último commit:',
110
+ ' git restore package.json pnpm-lock.yaml && pnpm install',
111
+ '',
112
+ 'Para insistir na nova:',
113
+ ` pnpm add -D ${spec} && pnpm exec kuyper update`,
114
+ ],
115
+ });
116
+ }
117
+ /** Sem código: cenário que a PRD não antecipa por nome (o pnpm terminou bem, mas o binário instalado não roda). */
118
+ function refuseRelaunchBroken(binPath) {
119
+ throw new KuyperRefusal({
120
+ headline: 'O binário recém-instalado não pôde ser executado.',
121
+ details: [`esperado em: ${binPath}`, '', 'O pnpm terminou, mas node_modules/@kuyper/harness parece incompleto.'],
122
+ route: ['rm -rf node_modules && pnpm install', 'pnpm exec kuyper update'],
123
+ });
124
+ }
125
+ function formatPartialCoreHybrid(newVersion) {
126
+ return [
127
+ '⚠ A versão foi instalada, mas a atualização não concluiu.',
128
+ '',
129
+ ' parou em: igualação de .kuyper/core/ (passo 6 de 9)',
130
+ '',
131
+ ` O pacote em node_modules já é ${newVersion} e .kuyper/core/ ficou híbrido.`,
132
+ ' O generate vai recusar até a árvore coincidir inteira com o pacote.',
133
+ '',
134
+ ' Para concluir:',
135
+ ' rm -rf .kuyper/core && cp -R node_modules/@kuyper/harness/core .kuyper/core',
136
+ ' pnpm exec kuyper generate',
137
+ ' pnpm exec kuyper validate',
138
+ ].join('\n');
139
+ }
140
+ /**
141
+ * `.kuyper/core/` continua `= L` (nada foi tocado) e o relançamento saiu
142
+ * ≠0 — a Fase 2 morreu antes de qualquer escrita, tipicamente porque o
143
+ * binário recém-instalado não carrega. Achado real (revisão do B9,
144
+ * Achado 1): `spawn()` num `binPath` ausente/quebrado dispara `exit` com
145
+ * código ≠0, nunca o evento `error` — `refuseRelaunchBroken()` (o `catch`
146
+ * em `update()`) nunca roda pra este caso, só pra uma falha de spawn
147
+ * genuína (o próprio `node` não existir). Sem este ramo, o usuário via só
148
+ * o stack trace cru do Node, sem marcador, sem rota.
149
+ */
150
+ function formatRelaunchBroken(newVersion, binPath) {
151
+ return [
152
+ '⚠ A versão foi instalada, mas não pôde ser executada.',
153
+ '',
154
+ ' parou em: relançamento do binário (passo 4 de 9)',
155
+ '',
156
+ ` O pacote em node_modules já é ${newVersion}, mas .kuyper/core/ não foi`,
157
+ ' tocado — nada foi perdido. O binário recém-instalado não conseguiu rodar.',
158
+ ` Esperado em: ${binPath}`,
159
+ '',
160
+ ' Para investigar:',
161
+ ' rm -rf node_modules && pnpm install',
162
+ ' pnpm exec kuyper update',
163
+ ].join('\n');
164
+ }
165
+ /**
166
+ * `.kuyper/core/` já é `= P` (a igualação terminou de verdade) mas o
167
+ * relançamento saiu ≠0 — a Fase 2 morreu **depois** de igualar, mas antes
168
+ * de terminar `generate`/`validate`/o próprio relatório. `D = P` só prova
169
+ * que o laço de escrita terminou, nada sobre o resto (Achado 1 da revisão
170
+ * do B9) — sem este ramo, o comando ficava calado justamente quando mais
171
+ * já tinha acontecido.
172
+ */
173
+ function formatEqualizedButIncomplete(newVersion) {
174
+ return [
175
+ '⚠ A versão foi instalada e o core foi igualado, mas a atualização não concluiu.',
176
+ '',
177
+ ' parou em: geração/validação (passos 7-8 de 9)',
178
+ '',
179
+ ` .kuyper/core/ já é ${newVersion} — a igualação terminou. Falta confirmar`,
180
+ ' que as saídas e o validate convergem.',
181
+ '',
182
+ ' Para concluir:',
183
+ ' pnpm exec kuyper generate',
184
+ ' pnpm exec kuyper validate',
185
+ ].join('\n');
186
+ }
187
+ /**
188
+ * Sem código: R9 na tabela do §5 é escopo de `generate`/capacidades, não
189
+ * de `update`. Mensagem exata do §3.6 — o core novo já está em disco
190
+ * (passo 3 já rodou), as saídas continuam intactas.
191
+ */
192
+ function refuseOrphanReplaces(rule, target) {
193
+ throw new KuyperRefusal({
194
+ headline: `core:${target} foi aposentado nesta versão, e a sua rule o substitui.`,
195
+ details: [`sua regra: ${rule.sourcePath}`],
196
+ route: ['Remova o replaces: — a regra passa a valer por si — ou apague a sua rule.', 'Depois rode pnpm exec kuyper generate.'],
197
+ });
198
+ }
199
+ function refuseValidateInconsistent(reason) {
200
+ throw new KuyperRefusal({
201
+ headline: 'O update terminou a igualação e o generate, mas o validate não ficou coerente.',
202
+ details: [reason, '', 'Isto não deveria acontecer — a igualação e o generate já convergiram.'],
203
+ route: ['pnpm exec kuyper validate', 'Revise o que ele aponta antes de confiar na atualização.'],
204
+ });
205
+ }
206
+ function formatReplacedRuleWarning(rule, target) {
207
+ return [
208
+ `⚠ core:${target} mudou nesta versão, e você a substituiu.`,
209
+ '',
210
+ ` sua regra: ${rule.sourcePath}`,
211
+ ` regra do core: ${join(CORE_REL, 'rules', `${target}.md`)} (veja o git diff)`,
212
+ '',
213
+ ' Compare as duas e veja se a sua ainda cobre o que você precisa.',
214
+ ' Manter a regra do core é preferível: ela evolui com o Harness, a sua não.',
215
+ ].join('\n');
216
+ }
217
+ /**
218
+ * Nome da capacidade dona de um caminho relativo do core (revisão do B9,
219
+ * Achado 3): `rules/<nome>.md` → `<nome>`; `skills/<nome>/**` (SKILL.md e
220
+ * todo `scripts/*` junto) → `<nome>` — uma skill de N arquivos conta como 1,
221
+ * não N.
222
+ */
223
+ function capabilityKeyOf(relPath) {
224
+ const [kind, name] = relPath.split('/');
225
+ if (kind === 'rules' && name !== undefined)
226
+ return `rule:${name.replace(/\.md$/, '')}`;
227
+ if (kind === 'skills' && name !== undefined)
228
+ return `skill:${name}`;
229
+ return `outro:${relPath}`;
230
+ }
231
+ function groupByCapability(keys) {
232
+ const groups = new Map();
233
+ for (const key of keys) {
234
+ const capKey = capabilityKeyOf(key);
235
+ const existing = groups.get(capKey);
236
+ if (existing !== undefined)
237
+ existing.push(key);
238
+ else
239
+ groups.set(capKey, [key]);
240
+ }
241
+ return groups;
242
+ }
243
+ /**
244
+ * Diff estrutural de L vs P por CAPACIDADE, não por arquivo (Achado 3): uma
245
+ * skill com `SKILL.md` + scripts que mudam juntos conta 1 vez. "aposentada"
246
+ * é calculado direto das chaves de L, nunca de `loadCapabilities`/`set` —
247
+ * quando `continueUpdate()` chegou até aqui, arquivos aposentados já foram
248
+ * removidos do disco (passo de igualação, acima), então `set` nunca os veria.
249
+ */
250
+ function diffCapabilities(L, P) {
251
+ const Lgroups = groupByCapability(L.keys());
252
+ const Pgroups = groupByCapability(P.keys());
253
+ const allCaps = new Set([...Lgroups.keys(), ...Pgroups.keys()]);
254
+ let changed = 0;
255
+ let added = 0;
256
+ let retired = 0;
257
+ for (const cap of allCaps) {
258
+ const inL = Lgroups.has(cap);
259
+ const inP = Pgroups.has(cap);
260
+ if (inL && !inP) {
261
+ retired += 1;
262
+ continue;
263
+ }
264
+ if (!inL && inP) {
265
+ added += 1;
266
+ continue;
267
+ }
268
+ const paths = new Set([...(Lgroups.get(cap) ?? []), ...(Pgroups.get(cap) ?? [])]);
269
+ if ([...paths].some((p) => L.get(p) !== P.get(p)))
270
+ changed += 1;
271
+ }
272
+ return { changed, added, retired };
273
+ }
274
+ function formatSuccess(previousVersion, newVersion, changed, added, retired, written, warnings) {
275
+ const parts = [];
276
+ if (changed > 0)
277
+ parts.push(`${changed} capacidade${changed === 1 ? '' : 's'} alterada${changed === 1 ? '' : 's'}`);
278
+ if (added > 0)
279
+ parts.push(`${added} acrescentada${added === 1 ? '' : 's'}`);
280
+ if (retired > 0)
281
+ parts.push(`${retired} aposentada${retired === 1 ? '' : 's'}`);
282
+ const lines = [`✓ Harness ${previousVersion} → ${newVersion}`, ''];
283
+ lines.push(` core: ${parts.length > 0 ? parts.join(', ') : 'sem mudanças'}`);
284
+ lines.push(written.length > 0 ? ` saídas atualizadas: ${written.join(', ')}` : ' saídas atualizadas: nenhuma');
285
+ if (warnings.length > 0) {
286
+ lines.push('');
287
+ for (const w of warnings)
288
+ lines.push(w, '');
289
+ }
290
+ lines.push('', ' Revise antes de commitar: git diff .kuyper/core/');
291
+ return lines.join('\n');
292
+ }
293
+ /**
294
+ * Fase 1 — `kuyper update` de verdade (PRD §3.6, passos 1–4). Preflight,
295
+ * `pnpm add`, e relançamento do binário recém-instalado **de dentro da
296
+ * instalação do projeto**, nunca por `PATH` (mesma regra do `paths.ts`).
297
+ * A Fase 2 (`continueUpdate`, `__update-continue`) roda com stdio
298
+ * herdado — o que ela imprime é o que o usuário vê; esta função só
299
+ * acrescenta mensagem própria quando a Fase 2 não teve chance de
300
+ * explicar a própria falha.
301
+ */
302
+ export async function update(options = {}) {
303
+ const projectRoot = options.projectRoot ?? process.cwd();
304
+ if (!(await isGitRepo(projectRoot))) {
305
+ throw new KuyperRefusal({ headline: 'Este diretório não é um repositório Git.' });
306
+ }
307
+ const dirty = await dirtyPaths(projectRoot);
308
+ if (dirty.length > 0)
309
+ refuseR14(dirty);
310
+ const lock = await readLock(lockPath(projectRoot));
311
+ if (lock !== undefined) {
312
+ const D = await checksumTree(corePath(projectRoot));
313
+ const L = coreEntriesFromLock(lock.entries);
314
+ if (!mapsEqual(D, L)) {
315
+ refuseR21CoreEdited(diffKeys(L, D).map((k) => join(CORE_REL, k)));
316
+ }
317
+ }
318
+ await generate({ projectRoot, ...(options.packageCoreDir !== undefined ? { packageCoreDir: options.packageCoreDir } : {}) });
319
+ const dirtyAfterGenerate = await dirtyPaths(projectRoot);
320
+ if (dirtyAfterGenerate.length > 0)
321
+ refuseR21PendingWrites(dirtyAfterGenerate);
322
+ const spec = process.env['KUYPER_UPDATE_SPEC'] ?? '@kuyper/harness@latest';
323
+ const pnpmCode = await runInherited('pnpm', ['add', '-D', spec], projectRoot).catch(() => 1);
324
+ if (pnpmCode !== 0)
325
+ refusePnpmFailed(spec);
326
+ const binPath = join(projectRoot, 'node_modules', '@kuyper', 'harness', 'dist', 'cli.js');
327
+ let relaunchCode;
328
+ try {
329
+ relaunchCode = await runInherited(process.execPath, [binPath, '__update-continue'], projectRoot);
330
+ }
331
+ catch {
332
+ refuseRelaunchBroken(binPath);
333
+ }
334
+ if (relaunchCode !== 0) {
335
+ await reportRelaunchFailure(projectRoot, binPath);
336
+ }
337
+ return relaunchCode;
338
+ }
339
+ /**
340
+ * Diagnóstico de três ramos pra quando a Fase 2 sai ≠0 (revisão do B9,
341
+ * Achado 1) — `classifyCore` já distingue os três estados possíveis, sem
342
+ * precisar de contador de passo em memória:
343
+ * - `.kuyper/core/` ainda `= L` (nada tocado) → a Fase 2 morreu antes de
344
+ * escrever qualquer coisa, tipicamente porque o binário recém-instalado
345
+ * não carrega;
346
+ * - `.kuyper/core/` já `= P` (igualação terminou) → a Fase 2 morreu
347
+ * depois de igualar, mas antes de terminar generate/validate/relatório;
348
+ * - nem um nem outro → árvore híbrida de verdade, o caso original.
349
+ *
350
+ * Exportada só pra teste direto (sem precisar derrubar um relançamento de
351
+ * verdade no meio da execução pra chegar em cada estado) — a mesma
352
+ * categoria de seam de `packageCoreDir`, não injeção de falha: lê estado de
353
+ * disco, não simula uma queda.
354
+ */
355
+ export async function reportRelaunchFailure(projectRoot, binPath) {
356
+ const lock = await readLock(lockPath(projectRoot));
357
+ if (lock === undefined)
358
+ return; // sem lock, sem classificação possível — a Fase 2 já explicou o que pôde
359
+ const newPackageCore = join(binPath, '..', '..', 'core');
360
+ const [D, L, P] = await Promise.all([
361
+ checksumTree(corePath(projectRoot)),
362
+ Promise.resolve(coreEntriesFromLock(lock.entries)),
363
+ checksumTree(newPackageCore),
364
+ ]);
365
+ const classification = classifyCore(L, P, D);
366
+ const newPkgRaw = await readFile(join(newPackageCore, '..', 'package.json'), 'utf8').catch(() => undefined);
367
+ const newVersion = newPkgRaw !== undefined ? (JSON.parse(newPkgRaw).version) : 'nova versão';
368
+ if (classification.state === 'anterior-intact') {
369
+ console.log(formatRelaunchBroken(newVersion, binPath));
370
+ }
371
+ else if (classification.state === 'transition-complete') {
372
+ console.log(formatEqualizedButIncomplete(newVersion));
373
+ }
374
+ else {
375
+ console.log(formatPartialCoreHybrid(newVersion));
376
+ }
377
+ }
378
+ /**
379
+ * Fase 2 — `__update-continue`, o binário recém-instalado (PRD §3.6,
380
+ * passos 5–9). Iguala `.kuyper/core/` arquivo por arquivo com escritor
381
+ * atômico (A13) em vez de `rm -rf`+`cp -R` cru — o equivalente manual usa
382
+ * o cru porque é o que dá pra digitar; o comando mantém a mesma garantia
383
+ * de escritor único do resto do produto. Se o processo morre no meio, o
384
+ * resultado é uma árvore que `classifyCore` já reconhece como híbrida —
385
+ * sem máquina de estado nova.
386
+ */
387
+ export async function continueUpdate(options = {}) {
388
+ const projectRoot = options.projectRoot ?? process.cwd();
389
+ const packageCoreDir = options.packageCoreDir ?? packageCorePath();
390
+ const lock = await readLock(lockPath(projectRoot));
391
+ const previousVersion = lock?.harness ?? 'desconhecida';
392
+ const newVersion = await harnessVersion();
393
+ const L = lock !== undefined ? coreEntriesFromLock(lock.entries) : new Map();
394
+ const P = await checksumTree(packageCoreDir);
395
+ const D = await checksumTree(corePath(projectRoot));
396
+ for (const [relKey, checksum] of P) {
397
+ if (D.get(relKey) === checksum)
398
+ continue;
399
+ const srcPath = join(packageCoreDir, relKey);
400
+ const [content, srcStat] = await Promise.all([readFile(srcPath), stat(srcPath)]);
401
+ await writeFileAtomic(join(corePath(projectRoot), relKey), content, { mode: srcStat.mode & 0o777 });
402
+ }
403
+ for (const relKey of D.keys()) {
404
+ if (!P.has(relKey))
405
+ await removeOrphanFile(projectRoot, join(CORE_REL, relKey));
406
+ }
407
+ const { set, issues } = await loadCapabilities(projectRoot);
408
+ if (issues.length === 0) {
409
+ const orphan = checkReferences(set).find((i) => i.code === 'R9');
410
+ if (orphan !== undefined) {
411
+ const rule = set.projectRules.find((r) => r.sourcePath === orphan.path);
412
+ const target = rule?.replaces?.slice('core:'.length) ?? '?';
413
+ refuseOrphanReplaces(rule, target);
414
+ }
415
+ }
416
+ const generateReport = await generate({ projectRoot, packageCoreDir });
417
+ const validateReport = await validate({ projectRoot, packageCoreDir, silent: true });
418
+ if (validateReport.code !== 0) {
419
+ refuseValidateInconsistent(validateReport.code === 1 ? validateReport.reason : validateReport.findings.join('; '));
420
+ }
421
+ const warnings = [];
422
+ for (const rule of set.projectRules) {
423
+ if (rule.replaces === undefined)
424
+ continue;
425
+ const target = rule.replaces.slice('core:'.length);
426
+ const key = `rules/${target}.md`; // chaves de L/P usam sempre `/` (checksumTree, coreEntriesFromLock), nunca join()
427
+ if (P.has(key) && L.get(key) !== P.get(key)) {
428
+ warnings.push(formatReplacedRuleWarning(rule, target));
429
+ }
430
+ }
431
+ const { changed, added, retired } = diffCapabilities(L, P);
432
+ console.log(formatSuccess(previousVersion, newVersion, changed, added, retired, generateReport.written, warnings));
433
+ return 0;
434
+ }
@@ -0,0 +1,142 @@
1
+ import { join } from 'node:path';
2
+ import { readConfig, readPackageScripts } from './config.js';
3
+ import { checksumTree, classifyCore, coreEntriesFromLock, mapsEqual, } from './coreClassification.js';
4
+ import { readLock, computeChecksum } from './lock.js';
5
+ import { decideOutput, decideOrphan, computeOrphanPaths } from './outputPlan.js';
6
+ import { packageCorePath } from './paths.js';
7
+ import { getCoreHooksPath, classifyHooksPath } from './hooks.js';
8
+ import { loadCapabilities, checkReferences, buildDesiredOutputs, diskChecksumOf, diskIsExecutable } from './generate.js';
9
+ /**
10
+ * A string exata que o `init` grava (B5) e que o `validate` exige de volta,
11
+ * byte a byte (PRD §3.3, A8c).
12
+ */
13
+ export const PREPARE_SCRIPT = 'pnpm exec kuyper generate';
14
+ function formatReport(report) {
15
+ if (report.code === 0) {
16
+ return '✓ Coerente: disco bate com o que a fonte canônica produziria.';
17
+ }
18
+ if (report.code === 1) {
19
+ return `⚠ Inconclusivo: ${report.reason}`;
20
+ }
21
+ const lines = [
22
+ report.findings.length === 1
23
+ ? '✗ Divergente: 1 achado.'
24
+ : `✗ Divergente: ${report.findings.length} achados.`,
25
+ '',
26
+ ];
27
+ for (const f of report.findings)
28
+ lines.push(` ${f}`);
29
+ return lines.join('\n');
30
+ }
31
+ /**
32
+ * O `generate` sem escrita (PRD §3.3). Reaproveita a mesma comparação de três
33
+ * pontas do B2 — não decide nada novo, só não grava o resultado.
34
+ */
35
+ export async function validate(options = {}) {
36
+ const projectRoot = options.projectRoot ?? process.cwd();
37
+ const lockPath = join(projectRoot, '.kuyper', 'generated.lock');
38
+ const log = (report) => {
39
+ if (!options.silent)
40
+ console.log(formatReport(report));
41
+ };
42
+ const P = await checksumTree(options.packageCoreDir ?? packageCorePath());
43
+ const lock = await readLock(lockPath);
44
+ let config;
45
+ try {
46
+ config = await readConfig(join(projectRoot, '.kuyper', 'config.yaml'));
47
+ }
48
+ catch {
49
+ const report = {
50
+ code: 1,
51
+ findings: [],
52
+ reason: '.kuyper/config.yaml ausente ou inválido — não é possível calcular as saídas desejadas.',
53
+ };
54
+ log(report);
55
+ return report;
56
+ }
57
+ const { set, issues } = await loadCapabilities(projectRoot);
58
+ if (issues.length > 0) {
59
+ const report = {
60
+ code: 1,
61
+ findings: [],
62
+ reason: `A fonte canônica é inválida (${issues.length} achados) — não é possível calcular as saídas desejadas.`,
63
+ };
64
+ log(report);
65
+ return report;
66
+ }
67
+ const refIssues = checkReferences(set);
68
+ if (refIssues.length > 0) {
69
+ const report = {
70
+ code: 1,
71
+ findings: [],
72
+ reason: `Referências de capacidade inválidas (${refIssues.length} achados) — não é possível calcular as saídas desejadas.`,
73
+ };
74
+ log(report);
75
+ return report;
76
+ }
77
+ const findings = [];
78
+ const D = await checksumTree(join(projectRoot, '.kuyper', 'core'));
79
+ if (lock === undefined) {
80
+ if (D.size === 0 || !mapsEqual(D, P)) {
81
+ findings.push('.kuyper/core/ ausente, ou não coincide inteiramente com o core/ do pacote instalado.');
82
+ }
83
+ }
84
+ else {
85
+ const L = coreEntriesFromLock(lock.entries);
86
+ const classification = classifyCore(L, P, D);
87
+ if (classification.state === 'unknown-path') {
88
+ findings.push(`.kuyper/core/ contém conteúdo desconhecido: ${classification.paths.join(', ')}.`);
89
+ }
90
+ else if (classification.state === 'mismatch') {
91
+ findings.push('.kuyper/core/ não coincide integralmente com o lock anterior nem com o pacote em execução.');
92
+ }
93
+ }
94
+ const desired = buildDesiredOutputs(set, config);
95
+ const desiredPaths = new Set(desired.map((o) => o.path));
96
+ for (const output of desired) {
97
+ const disk = await diskChecksumOf(projectRoot, output.path);
98
+ const newChecksum = computeChecksum(output.content);
99
+ const lockChecksum = lock?.entries[output.path];
100
+ const decision = decideOutput(disk, lockChecksum, newChecksum);
101
+ if (decision.action === 'write') {
102
+ findings.push(`${output.path}: desatualizado em relação à fonte canônica — generate reescreveria.`);
103
+ }
104
+ else if (decision.action === 'refuse-edited') {
105
+ findings.push(`${output.path}: editado à mão — generate recusaria (R7). Descarte com "git restore ${output.path}" ou aceite com "pnpm exec kuyper generate --force".`);
106
+ }
107
+ else if (decision.action === 'refuse-foreign') {
108
+ findings.push(`${output.path}: ocupado por conteúdo alheio, não rastreado pelo Harness — generate recusaria (R8).`);
109
+ }
110
+ else if (output.mode !== undefined && !(await diskIsExecutable(projectRoot, output.path))) {
111
+ findings.push(`${output.path}: sem bit de execução — generate corrigiria.`);
112
+ }
113
+ }
114
+ if (lock !== undefined) {
115
+ const orphanPaths = computeOrphanPaths(lock.entries, desiredPaths);
116
+ for (const path of orphanPaths) {
117
+ const disk = await diskChecksumOf(projectRoot, path);
118
+ const decision = decideOrphan(disk, lock.entries[path]);
119
+ if (decision.action === 'remove') {
120
+ findings.push(`${path}: órfão de uma capacidade removida, ainda presente — generate o removeria.`);
121
+ }
122
+ else if (decision.action === 'refuse-edited') {
123
+ findings.push(`${path}: órfão editado à mão — generate recusaria (R7).`);
124
+ }
125
+ }
126
+ }
127
+ const hooksPathCurrent = await getCoreHooksPath(projectRoot);
128
+ const hooksPathState = classifyHooksPath(hooksPathCurrent);
129
+ if (hooksPathState === 'absent') {
130
+ findings.push('core.hooksPath não está configurado. "pnpm exec kuyper generate" resolve.');
131
+ }
132
+ else if (hooksPathState === 'other') {
133
+ findings.push(`core.hooksPath aponta para "${hooksPathCurrent}", fora de .kuyper/hooks — "pnpm exec kuyper generate" vai recusar (R24), não corrige sozinho.`);
134
+ }
135
+ const scripts = await readPackageScripts(join(projectRoot, 'package.json')).catch(() => ({}));
136
+ if (scripts['prepare'] !== PREPARE_SCRIPT) {
137
+ findings.push(`package.json "prepare" ausente ou diferente de "${PREPARE_SCRIPT}".`);
138
+ }
139
+ const report = findings.length > 0 ? { code: 2, findings } : { code: 0, findings: [] };
140
+ log(report);
141
+ return report;
142
+ }