@polymorphism-tech/morph-spec 8.42.0 → 8.43.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/CLAUDE.md +9 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +48 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +5 -4
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +10 -3
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
- package/framework/hooks/claude-code/core/heavy-release.js +34 -0
- package/framework/hooks/claude-code/core/worktree-context.js +21 -20
- package/framework/hooks/claude-code/core/worktree-create.js +4 -4
- package/framework/hooks/shared/agent-reap.js +89 -0
- package/framework/hooks/shared/e2e-check.js +3 -1
- package/framework/hooks/shared/graph-refresh.js +1 -9
- package/framework/hooks/shared/heavy-command.js +128 -0
- package/framework/hooks/shared/heavy-config.js +65 -0
- package/framework/hooks/shared/heavy-holders.js +85 -0
- package/framework/hooks/shared/heavy-slots.js +207 -0
- package/framework/hooks/shared/process-alive.js +8 -0
- package/framework/hooks/shared/worktree-root.js +93 -0
- package/framework/schemas/config.schema.json +80 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -3
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +9 -4
- package/framework/skills/core/morph-machine/SKILL.md +46 -0
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +45 -16
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +10 -10
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +27 -8
- package/src/commands/mcp/mcp-setup.js +10 -9
- package/src/commands/project/doctor-procs.js +162 -0
- package/src/commands/project/doctor.js +42 -4
- package/src/commands/project/heavy.js +61 -0
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/update.js +4 -0
- package/src/commands/project/worktree-reap.js +34 -0
- package/src/commands/project/worktree.js +78 -27
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +26 -1
- package/src/dashboard/scanner/features-scanner.js +4 -4
- package/src/dashboard/scanner/project-api.js +4 -4
- package/src/lib/detectors/claude-config-detector.js +30 -1
- package/src/lib/heavy/cleanup.js +70 -0
- package/src/lib/heavy/gate.js +96 -0
- package/src/lib/installers/mcp-installer.js +15 -2
- package/src/lib/mutation/adapters.js +90 -0
- package/src/lib/mutation/commands.js +60 -0
- package/src/lib/mutation/manifest.js +138 -0
- package/src/lib/mutation/plan.js +36 -0
- package/src/lib/mutation/report.js +66 -0
- package/src/lib/mutation/runner.js +351 -0
- package/src/lib/mutation/schemata.js +60 -0
- package/src/lib/mutation/snapshot.js +69 -0
- package/src/lib/mutation/subset.js +75 -0
- package/src/lib/mutation/trx.js +51 -0
- package/src/lib/procs/classify.js +91 -0
- package/src/lib/procs/list.js +124 -0
- package/src/lib/procs/mcp-config.js +103 -0
- package/src/lib/procs/prune.js +25 -0
- package/src/lib/procs/wslconfig.js +70 -0
- package/src/lib/projects/registry.js +6 -2
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/verify-runner.js +26 -8
- package/src/lib/worktree/agent-reaper.js +247 -0
- package/src/lib/worktree/dirs.js +3 -32
- package/src/lib/worktree/vscode-settings.js +113 -0
- package/src/lib/worktree/worktree-audit.js +49 -5
- package/src/lib/worktree/worktree-include.js +46 -0
- package/src/scripts/setup-infra.js +4 -0
- package/src/utils/hooks-installer.js +11 -4
|
@@ -10,24 +10,39 @@ Um teste verde prova que o código roda. Só a **mutação** prova que o teste M
|
|
|
10
10
|
propósito a linha que carrega a decisão e veja quem grita. Se ninguém grita, a suíte é decoração —
|
|
11
11
|
e foi assim que 11 testes verdes cobriram um artefato que o build recusava.
|
|
12
12
|
|
|
13
|
-
A pergunta desta rubrica é sempre a mesma: **você mutou o call site que carrega a decisão,
|
|
14
|
-
suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte
|
|
13
|
+
A pergunta desta rubrica é sempre a mesma: **você mutou o call site que carrega a decisão, provou
|
|
14
|
+
cada sobrevivente contra a suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte
|
|
15
|
+
— com o binário provado?**
|
|
16
|
+
|
|
17
|
+
O método é `morph-spec mutate` (manifesto de mutantes → uma chamada → a seção pronta). Você escolhe
|
|
18
|
+
os mutantes; a ferramenta escolhe o subconjunto de testes, roda o lote, refaz todo sobrevivente com o
|
|
19
|
+
mutante compilado de verdade contra a suíte INTEIRA e reverte por bytes.
|
|
15
20
|
|
|
16
21
|
## Bandas 0-10
|
|
17
22
|
|
|
18
23
|
| 0-3 | 4-6 | 7-8 | 9-10 |
|
|
19
24
|
|---|---|---|---|
|
|
20
|
-
| Nenhuma mutação. Ou mutação declarada sem diff, sem md5 e sem testes nomeados — afirmação, não prova. Ou revert por `git checkout --`, que descarta trabalho não commitado de terceiros | Mutou, mas no lugar errado (o `if` refatorado, não o call site que decide) ou
|
|
25
|
+
| Nenhuma mutação. Ou mutação declarada sem diff, sem md5 e sem testes nomeados — afirmação, não prova. Ou revert por `git checkout --`, que descarta trabalho não commitado de terceiros | Mutou, mas no lugar errado (o `if` refatorado, não o call site que decide) ou declarou "sobreviveu" / "derruba N" a partir de um filtro escolhido por você ("derrubou 3" medido sobre 40 testes de um arquivo só). Reportou md5 sem ler o motivo da falha | Mutação no call site certo, todo sobrevivente refeito contra a suíte INTEIRA, diff e md5 antes/depois. Falta o MOTIVO literal da falha, ou uma cláusula de um predicado composto ficou sem rodada própria | Uma rodada por cláusula, cada uma com arquivo:linha, diff, md5 antes/depois, contagem com o ESCOPO dito (`≥K de N` no subconjunto que a ferramenta escolheu; `K de M` na suíte inteira) e a mensagem literal de falha. Revert por snapshot de bytes com escrita nova, rebuild forçado e DLL final igual (md5) à de antes do 1º mutante, com `git status --porcelain src/` vazio ao fim |
|
|
21
26
|
|
|
22
27
|
## Anti-padrões literais
|
|
23
28
|
|
|
24
29
|
- **Mutar o `if` refatorado em vez do call site que carrega a decisão** — o extract-method moveu a
|
|
25
30
|
regra; o teste continua batendo no invólucro. Ancore onde o valor é DECIDIDO. (memória: guard do
|
|
26
31
|
remédio, cuja checagem real vivia duas camadas acima do `if` mutado)
|
|
27
|
-
- **
|
|
28
|
-
|
|
32
|
+
- **Reportar "sobreviveu" ou "derruba N" a partir de um filtro que você escolheu** — "3 de 40" e
|
|
33
|
+
"3 de 2684" são afirmações diferentes. **"Sobreviveu" só vale contra a suíte INTEIRA**, com o
|
|
34
|
+
mutante compilado de verdade. **"Morto" pode sair de um subconjunto, se a ferramenta o escolheu**
|
|
35
|
+
(testes que a feature alterou + os que citam o tipo mutado): teste que falha no subconjunto falha
|
|
36
|
+
na suíte, então a morte é a mesma — mas a contagem passa a ser piso (`≥K de N (subconjunto)`), e
|
|
37
|
+
`K de M` só existe medido na suíte inteira. (memória: F30, filtro no Gate 3)
|
|
38
|
+
- **Contar como morto o teste instável** — morte na suíte inteira só vale se a mesma falha se repete
|
|
39
|
+
na segunda rodada (dos testes que falharam, isolados, ou da suíte inteira). Medido: um teste de host, sem relação com o mutante,
|
|
40
|
+
"derrubou" um mutante uma vez; em sete rodadas o mutante sobreviveu seis. Falha que não se
|
|
41
|
+
repete é sobrevivente com razão dita, não morte. (mesma rodada: `out var` removido que só compila
|
|
42
|
+
no schemata é `build-error`, não morto)
|
|
29
43
|
- **Filtro tão estreito que a prova fica vácua** — mutar e rodar só o teste que você já sabia que
|
|
30
|
-
quebraria não testa nada além da sua própria expectativa.
|
|
44
|
+
quebraria não testa nada além da sua própria expectativa. O subconjunto é da ferramenta, nunca
|
|
45
|
+
seu: escolhido à mão, ele volta a ser a sua expectativa.
|
|
31
46
|
- **Aceitar um seed sem perguntar "que sequência real produz este seed?"** — fixture que só existe
|
|
32
47
|
no teste prova o teste, não o sistema.
|
|
33
48
|
- **Imprimir `diff` + md5 e parar por aí** — md5 prova que ALGO mudou, nunca O QUÊ. Sem a mensagem
|
|
@@ -36,13 +51,23 @@ suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte?**
|
|
|
36
51
|
- **Reverter por replace reverso, ou por `git checkout --`** — o replace reverso erra em arquivo com
|
|
37
52
|
ocorrências repetidas; o `git checkout --` **apaga alterações não commitadas de outra pessoa** e
|
|
38
53
|
aconteceu de verdade num Gate 3 desta epic. Reverta por **snapshot/restore de bytes** (cópia feita
|
|
39
|
-
ANTES)
|
|
54
|
+
ANTES), por escrita nova, e confirme por `md5sum`.
|
|
55
|
+
- **Devolver o mtime original com `os.utime` depois de restaurar** — os bytes voltam, mas com data
|
|
56
|
+
ANTIGA o arquivo fica mais velho que a DLL do último mutante: o MSBuild acha o binário em dia, não
|
|
57
|
+
recompila, e a rodada-base roda sobre o mutante (falso vermelho, ou pior: falso verde). Medido num
|
|
58
|
+
projeto .NET 10 de 697 arquivos: restore com data antiga → build de 1,9 s, nenhum `CoreCompile`,
|
|
59
|
+
DLL do mutante intacta; escrita nova → DLL de volta ao md5 da baseline. Restaure por **escrita
|
|
60
|
+
nova**, force o rebuild e **prove que o binário é o do disco** (md5 da DLL == a construída da
|
|
61
|
+
árvore limpa antes do 1º mutante).
|
|
40
62
|
- **Escrever a mutação por heredoc do shell quando ela contém barra invertida** — o transporte come
|
|
41
63
|
o escape: `\b` vira o byte `0x08`, `\n` vira quebra de linha real, e o regex mutado **não é o que
|
|
42
64
|
você escreveu**. Sintoma: a mutação parece aplicada e o resultado não muda, ou o arquivo quebra
|
|
43
65
|
num ponto que você não tocou. Diagnóstico em um comando: `cat -A` mostra `^H` onde devia estar
|
|
44
|
-
`\b`.
|
|
45
|
-
|
|
66
|
+
`\b`. Medido: o transporte do Bash reduz `\\` a `\` e decodifica escapes unicode, até dentro de
|
|
67
|
+
heredoc de aspas simples. Saída: o manifesto de `morph-spec mutate` com a barra invertida do código
|
|
68
|
+
escrita como o token `{{BS}}` (a ferramenta a expande), ou patch por **índice de linha** com o
|
|
69
|
+
conteúdo montado sem barra invertida no fonte (`String.fromCharCode(92)`) — nunca a barra invertida
|
|
70
|
+
crua num heredoc.
|
|
46
71
|
(memória: **três agentes** desta epic tropeçaram nisto; dois descartaram o resultado, um só
|
|
47
72
|
descobriu ao conferir os bytes)
|
|
48
73
|
- **Mutar antes de commitar o fix** — se a sessão morrer no meio, o que sobrevive é a mutação, não a
|
|
@@ -69,9 +94,14 @@ Na seção `## Mutações` do `evaluator-report.md`, uma linha por mutação, co
|
|
|
69
94
|
```markdown
|
|
70
95
|
| # | Arquivo:linha | Mutação (diff) | md5 antes | md5 depois | Testes derrubados | Motivo da falha |
|
|
71
96
|
|---|---|---|---|---|---|---|
|
|
72
|
-
| M1 | src/lib/x.js:42 | `- if (a && b)` / `+ if (a)` | abc… | def… | 3 de
|
|
97
|
+
| M1 | src/lib/x.js:42 | `- if (a && b)` / `+ if (a)` | abc… | def… | ≥3 de 212 (subconjunto) | `expected 3 got 0` em x.test.js:88 |
|
|
98
|
+
| M2 | src/lib/y.js:7 | `- return ok` / `+ return true` | 123… | 456… | 0 de 2684 (SOBREVIVEU, suíte inteira) | nenhum teste falhou na suíte inteira |
|
|
73
99
|
```
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
A coluna "Testes derrubados" sempre diz o escopo: `≥K de N (subconjunto)` é piso; `K de M (suíte
|
|
102
|
+
inteira)` é contagem; sobrevivente é sempre `0 de M (suíte inteira)`. `morph-spec mutate` imprime a
|
|
103
|
+
seção inteira nesse formato — cole-a, não a redigite.
|
|
104
|
+
|
|
105
|
+
E, ao fim da seção: **Reversão:** snapshot/restore de bytes por escrita nova; DLL final igual (md5)
|
|
106
|
+
à de antes do 1º mutante; `git status --porcelain src/` vazio. Feature sem código de produção
|
|
107
|
+
declara a seção com a linha `não aplicável — feature sem código de produção`.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* heavy-gate.js — PreToolUse hook (Bash matcher), link of the Bash chain before worktree-env-inject.
|
|
4
|
+
*
|
|
5
|
+
* A sub-agent that runs `dotnet build` or `npm test` straight through Bash bypasses the heavy-node
|
|
6
|
+
* semaphore that `morph-spec verify` respects. With `heavy.gateBash: true` this hook takes a slot
|
|
7
|
+
* before such a command runs and `heavy-release` gives it back on Post/PostFailure, matched by
|
|
8
|
+
* `tool_use_id`. It never rewrites the command: a rewrite would be judged against the user's
|
|
9
|
+
* permission rules in its rewritten form, and would auto-approve what the prompt would have asked.
|
|
10
|
+
*
|
|
11
|
+
* A slot taken here has no live process to watch — the hook exits at once — so it lapses by TTL
|
|
12
|
+
* (the call's own timeout plus slack). A call refused by a permission rule or interrupted by the
|
|
13
|
+
* user fires no Post hook and keeps its slot until then.
|
|
14
|
+
*
|
|
15
|
+
* Waits below the platform's 600 s hook timeout, because a timed-out PreToolUse hook lets the call
|
|
16
|
+
* run: the refusal has to come from here. Fail-open otherwise.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { stateExists } from '../../shared/state-reader.js';
|
|
20
|
+
import { blockResult, runStandalone } from '../../shared/hook-response.js';
|
|
21
|
+
import { heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
|
|
22
|
+
|
|
23
|
+
const HOOK_BUDGET_SEC = 540;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
|
|
27
|
+
* @param {{ env?: object, acquire?: Function }} [deps] - test seams; production passes none
|
|
28
|
+
* @returns {Promise<object|null>} a block result when no slot came free, else null (pass)
|
|
29
|
+
*/
|
|
30
|
+
export async function run(payload, deps = {}) {
|
|
31
|
+
try {
|
|
32
|
+
if (payload?.tool_name && payload.tool_name !== 'Bash') return null;
|
|
33
|
+
const input = payload?.tool_input ?? {};
|
|
34
|
+
if (input.run_in_background) return null;
|
|
35
|
+
const kind = heavyKind(input.command);
|
|
36
|
+
if (!kind || !payload?.tool_use_id) return null;
|
|
37
|
+
|
|
38
|
+
// Loaded only here: this link runs on every Bash call and almost none is heavy.
|
|
39
|
+
const { readHeavyConfig } = await import('../../shared/heavy-config.js');
|
|
40
|
+
const { acquireHeavy, describeBusy, heavyDir } = await import('../../shared/heavy-slots.js');
|
|
41
|
+
const env = deps.env ?? process.env;
|
|
42
|
+
const dir = env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
|
|
43
|
+
if (!stateExists(dir)) return null;
|
|
44
|
+
const config = readHeavyConfig(dir, env);
|
|
45
|
+
if (!config.enabled || !config.gateBash) return null;
|
|
46
|
+
|
|
47
|
+
const result = (deps.acquire ?? acquireHeavy)({
|
|
48
|
+
dir: heavyDir(env),
|
|
49
|
+
slots: config.slots,
|
|
50
|
+
label: kind,
|
|
51
|
+
feature: null,
|
|
52
|
+
node: null,
|
|
53
|
+
cwd: dir,
|
|
54
|
+
minFreeMemGb: config.minFreeMemGb,
|
|
55
|
+
waitTimeoutMs: Math.min(config.waitTimeoutSec, HOOK_BUDGET_SEC) * 1000,
|
|
56
|
+
maxHoldMs: config.maxHoldMinutes * 60_000,
|
|
57
|
+
lease: { id: payload.tool_use_id, ttlMs: leaseTtlMs(input, env) },
|
|
58
|
+
env,
|
|
59
|
+
log: () => {},
|
|
60
|
+
});
|
|
61
|
+
if (result.status !== 'busy') return null;
|
|
62
|
+
|
|
63
|
+
return blockResult(
|
|
64
|
+
`⏳ \`${kind}\` não rodou: ${describeBusy(result, config)}. Não é falha do código — aguarde e repita o MESMO comando. `
|
|
65
|
+
+ '`morph-spec heavy status` mostra quem segura os slots; `heavy.gateBash: false` desliga esta espera.',
|
|
66
|
+
);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await runStandalone(run);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* heavy-release.js — PostToolUse / PostToolUseFailure link: gives back the slot `heavy-gate` took
|
|
4
|
+
* for this Bash call, matched by `tool_use_id`.
|
|
5
|
+
*
|
|
6
|
+
* Silent and fail-open. A call that fires neither event (refused by a rule, interrupted) leaves its
|
|
7
|
+
* slot to the TTL. Releasing is not deciding, so this link is safe in the async failure binding.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { stateExists } from '../../shared/state-reader.js';
|
|
12
|
+
import { runStandalone } from '../../shared/hook-response.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} payload - PostToolUse or PostToolUseFailure stdin payload
|
|
16
|
+
* @param {{ env?: object }} [deps] - test seam
|
|
17
|
+
* @returns {Promise<null>}
|
|
18
|
+
*/
|
|
19
|
+
export async function run(payload, deps = {}) {
|
|
20
|
+
try {
|
|
21
|
+
if (payload?.tool_name !== 'Bash' || !payload.tool_use_id) return null;
|
|
22
|
+
const env = deps.env ?? process.env;
|
|
23
|
+
if (!stateExists(env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd())) return null;
|
|
24
|
+
// Runs after every tool call: pay for the slot module only when a semaphore directory exists.
|
|
25
|
+
const { heavyDir } = await import('../../shared/heavy-holders.js');
|
|
26
|
+
const dir = heavyDir(env);
|
|
27
|
+
if (!existsSync(dir)) return null;
|
|
28
|
+
const { releaseLeaseById } = await import('../../shared/heavy-slots.js');
|
|
29
|
+
releaseLeaseById(dir, payload.tool_use_id);
|
|
30
|
+
} catch { /* fail-open */ }
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await runStandalone(run);
|
|
@@ -51,34 +51,35 @@ import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registr
|
|
|
51
51
|
import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
|
|
52
52
|
import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
|
|
53
53
|
import { kickGraphRefresh, seedFromPrimary } from '../../shared/graph-refresh.js';
|
|
54
|
+
import { kickAgentReap } from '../../shared/agent-reap.js';
|
|
55
|
+
import { isInContainer } from '../../shared/worktree-root.js';
|
|
56
|
+
import { isSecondaryWorktree, resolveGitCommonDir } from '../../shared/git-paths.js';
|
|
54
57
|
|
|
55
58
|
function projectDir() {
|
|
56
59
|
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
return execSync(`git rev-parse ${arg}`, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
61
|
-
.toString()
|
|
62
|
-
.trim();
|
|
63
|
-
}
|
|
62
|
+
const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
* worktree" — fail-open.
|
|
71
|
-
*/
|
|
72
|
-
function isSecondaryWorktree(dir) {
|
|
64
|
+
// Inside a worktree Claude Code keeps CLAUDE_PROJECT_DIR at the project root and moves only the payload `cwd`.
|
|
65
|
+
function sessionDir(payload) {
|
|
66
|
+
const base = projectDir();
|
|
67
|
+
const cwd = typeof payload?.cwd === 'string' ? payload.cwd : '';
|
|
68
|
+
if (!cwd || samePath(resolve(cwd), resolve(base))) return base;
|
|
73
69
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
return gitDir !== commonDir;
|
|
70
|
+
const common = resolveGitCommonDir(cwd);
|
|
71
|
+
return common && samePath(resolve(common), resolve(resolveGitCommonDir(base) ?? '')) && isSecondaryWorktree(cwd) ? cwd : base;
|
|
77
72
|
} catch {
|
|
78
|
-
return
|
|
73
|
+
return base;
|
|
79
74
|
}
|
|
80
75
|
}
|
|
81
76
|
|
|
77
|
+
function gitRevParse(arg, dir) {
|
|
78
|
+
return execSync(`git rev-parse ${arg}`, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
79
|
+
.toString()
|
|
80
|
+
.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
82
83
|
/** Feature name from the current branch, if it matches `morph/{feature}`. */
|
|
83
84
|
function featureFromBranch(dir) {
|
|
84
85
|
try {
|
|
@@ -118,8 +119,7 @@ function isOutsideContainer(dir) {
|
|
|
118
119
|
try {
|
|
119
120
|
const common = resolve(dir, gitRevParse('--git-common-dir', dir)).replace(/\\/g, '/');
|
|
120
121
|
const root = common.replace(/\/\.git$/, '');
|
|
121
|
-
|
|
122
|
-
return !['worktrees', '.worktrees'].some((d) => norm.startsWith(`${root}/${d}/`));
|
|
122
|
+
return !isInContainer(root, dir);
|
|
123
123
|
} catch {
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
@@ -280,7 +280,7 @@ function claimBlock(dir, feature, sessionId) {
|
|
|
280
280
|
*/
|
|
281
281
|
export async function run(payload) {
|
|
282
282
|
try {
|
|
283
|
-
const dir =
|
|
283
|
+
const dir = sessionDir(payload);
|
|
284
284
|
// The SessionStart payload carries session_id — the identity the claim
|
|
285
285
|
// registry uses to tell "me" from "another session".
|
|
286
286
|
const sessionId = payload?.session_id || `pid-${process.pid}`;
|
|
@@ -303,6 +303,7 @@ export async function run(payload) {
|
|
|
303
303
|
// Returns immediately — nothing here waits on graphify.
|
|
304
304
|
try { seedFromPrimary(dir); } catch { /* non-blocking — a no-op on the root */ }
|
|
305
305
|
try { kickGraphRefresh(dir); } catch { /* non-blocking */ }
|
|
306
|
+
try { kickAgentReap(dir); } catch { /* non-blocking */ }
|
|
306
307
|
|
|
307
308
|
if (isSecondaryWorktree(dir)) {
|
|
308
309
|
const task = taskFromBranch(dir);
|
|
@@ -43,8 +43,7 @@ import { homedir } from 'os';
|
|
|
43
43
|
import { stateExists } from '../../shared/state-reader.js';
|
|
44
44
|
import { resolveGitCommonDir } from '../../shared/git-paths.js';
|
|
45
45
|
import { runMorphSpec } from '../../shared/morph-cli.js';
|
|
46
|
-
|
|
47
|
-
const WORKTREES_DIR = 'worktrees';
|
|
46
|
+
import { newWorktreesDir } from '../../shared/worktree-root.js';
|
|
48
47
|
|
|
49
48
|
const fwd = (p) => String(p).replace(/\\/g, '/');
|
|
50
49
|
|
|
@@ -152,14 +151,15 @@ function defaultBranch(root) {
|
|
|
152
151
|
*/
|
|
153
152
|
function gitFallback(root, name, base) {
|
|
154
153
|
const branch = `morph-task/${name}`;
|
|
155
|
-
const
|
|
154
|
+
const container = newWorktreesDir(root);
|
|
155
|
+
const path = `${container}/${name}`;
|
|
156
156
|
if (existsSync(path)) return { path };
|
|
157
157
|
try { git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], root); }
|
|
158
158
|
catch {
|
|
159
159
|
const from = base || defaultBranch(root);
|
|
160
160
|
git(from ? ['branch', branch, from] : ['branch', branch], root);
|
|
161
161
|
}
|
|
162
|
-
mkdirSync(
|
|
162
|
+
mkdirSync(container, { recursive: true });
|
|
163
163
|
git(['worktree', 'add', path, branch], root);
|
|
164
164
|
return { path };
|
|
165
165
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-reap.js — dispara `morph-spec worktree reap` em background no SessionStart.
|
|
3
|
+
*
|
|
4
|
+
* O hook de SessionStart tem 5 s de timeout e o reaper mede git por árvore: por isso o
|
|
5
|
+
* disparo só olha o disco (nenhum subprocesso) e solta este arquivo como worker DESTACADO
|
|
6
|
+
* (`--run <raiz>`), que chama o CLI. Só a raiz: o worker nasce com `cwd` no git common dir
|
|
7
|
+
* e nunca segura o diretório de uma worktree.
|
|
8
|
+
*
|
|
9
|
+
* Fail-open em tudo. `MORPH_WORKTREE_REAP=0` desliga.
|
|
10
|
+
*
|
|
11
|
+
* @module agent-reap
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'child_process';
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
16
|
+
import { join, resolve } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { isSecondaryWorktree, resolveGitCommonDir } from './git-paths.js';
|
|
19
|
+
import { primaryTree } from './graph-freshness.js';
|
|
20
|
+
import { runMorphSpec } from './morph-cli.js';
|
|
21
|
+
|
|
22
|
+
const WORKER = fileURLToPath(import.meta.url);
|
|
23
|
+
const THROTTLE_MS = 10 * 60_000;
|
|
24
|
+
const WORKER_TIMEOUT_MS = 15 * 60_000;
|
|
25
|
+
const ADMIN_DIR = /^agent-[0-9a-f]+\d*$/;
|
|
26
|
+
const AGENT_BRANCH_FILE = /^agent-[0-9a-f]+$/;
|
|
27
|
+
|
|
28
|
+
function anyEntry(dir, pattern) {
|
|
29
|
+
try { return readdirSync(dir).some((name) => pattern.test(name)); } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Uma worktree de agent registrada, ou uma branch `morph-task/agent-*` (solta ou empacotada). */
|
|
33
|
+
export function hasAgentLeftovers(common) {
|
|
34
|
+
if (anyEntry(join(common, 'worktrees'), ADMIN_DIR)) return true;
|
|
35
|
+
if (anyEntry(join(common, 'refs', 'heads', 'morph-task'), AGENT_BRANCH_FILE)) return true;
|
|
36
|
+
try {
|
|
37
|
+
return /refs\/heads\/morph-task\/agent-[0-9a-f]+/.test(readFileSync(join(common, 'packed-refs'), 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recentlyKicked(marker, now) {
|
|
44
|
+
try {
|
|
45
|
+
const age = now - Number(readFileSync(marker, 'utf8'));
|
|
46
|
+
return age >= 0 && age < THROTTLE_MS;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} dir - diretório do projeto da sessão
|
|
54
|
+
* @returns {{ kicked: boolean, reason?: string, pid?: number }}
|
|
55
|
+
*/
|
|
56
|
+
export function kickAgentReap(dir, { spawnFn = spawn, env = process.env, now = Date.now } = {}) {
|
|
57
|
+
if (env.MORPH_WORKTREE_REAP === '0') return { kicked: false, reason: 'disabled' };
|
|
58
|
+
const common = resolveGitCommonDir(dir);
|
|
59
|
+
const root = common && primaryTree(dir);
|
|
60
|
+
if (!root) return { kicked: false, reason: 'not-a-repo' };
|
|
61
|
+
if (isSecondaryWorktree(dir)) return { kicked: false, reason: 'secondary-worktree' };
|
|
62
|
+
if (!hasAgentLeftovers(common)) return { kicked: false, reason: 'nothing-to-reap' };
|
|
63
|
+
|
|
64
|
+
const marker = join(common, 'morph-spec', 'agent-reap.kick');
|
|
65
|
+
if (recentlyKicked(marker, now())) return { kicked: false, reason: 'throttled' };
|
|
66
|
+
try {
|
|
67
|
+
mkdirSync(join(common, 'morph-spec'), { recursive: true });
|
|
68
|
+
writeFileSync(marker, String(now()));
|
|
69
|
+
const child = spawnFn(process.execPath, [WORKER, '--run', root], {
|
|
70
|
+
cwd: common, env, detached: true, stdio: 'ignore', windowsHide: true,
|
|
71
|
+
});
|
|
72
|
+
child.on?.('error', () => {});
|
|
73
|
+
child.unref?.();
|
|
74
|
+
return { kicked: true, pid: child.pid };
|
|
75
|
+
} catch {
|
|
76
|
+
return { kicked: false, reason: 'spawn-failed' };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isMain = (() => {
|
|
81
|
+
const argv1 = process.argv[1];
|
|
82
|
+
if (!argv1) return false;
|
|
83
|
+
const norm = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
|
|
84
|
+
return norm(argv1) === norm(WORKER);
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
if (isMain && process.argv[2] === '--run' && process.argv[3] && existsSync(process.argv[3])) {
|
|
88
|
+
try { runMorphSpec(['worktree', 'reap', '--json'], { cwd: process.argv[3], timeoutMs: WORKER_TIMEOUT_MS }); } catch { /* fail-open */ }
|
|
89
|
+
}
|
|
@@ -675,7 +675,7 @@ function sleep(ms) {
|
|
|
675
675
|
|
|
676
676
|
/** Tear the stack down. Best-effort: teardown failure never fails a check. */
|
|
677
677
|
export function tearDownStack(cwd, { composeFile, projectName, env, volumes = false } = {}, exec = defaultExec) {
|
|
678
|
-
const args = [...composeArgs(composeFile, projectName), 'down'];
|
|
678
|
+
const args = [...composeArgs(composeFile, projectName), 'down', '--remove-orphans'];
|
|
679
679
|
if (volumes) args.push('-v');
|
|
680
680
|
const result = exec('docker', args, { cwd, env });
|
|
681
681
|
return { status: result.code === 0 ? 'pass' : 'fail', output: result.output };
|
|
@@ -863,6 +863,7 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
863
863
|
insideWorktree = isSecondaryWorktree(cwd),
|
|
864
864
|
healthTimeout = DEFAULT_HEALTH_TIMEOUT,
|
|
865
865
|
specTimeout = DEFAULT_SPEC_TIMEOUT,
|
|
866
|
+
beforeHeavy = null,
|
|
866
867
|
_exec: exec = defaultExec,
|
|
867
868
|
} = options;
|
|
868
869
|
|
|
@@ -910,6 +911,7 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
910
911
|
: {};
|
|
911
912
|
const composeEnv = { COMPOSE_PROJECT_NAME: projectName, ...authEnv, ...(env || {}) };
|
|
912
913
|
|
|
914
|
+
if (typeof beforeHeavy === 'function') beforeHeavy();
|
|
913
915
|
const up = bringUpStack(
|
|
914
916
|
cwd,
|
|
915
917
|
{ composeFile, projectName, gitCommonDir, env: composeEnv, forceFresh, healthTimeout },
|
|
@@ -50,6 +50,7 @@ import { fileURLToPath } from 'url';
|
|
|
50
50
|
import { readPrimaryHead, resolveGitCommonDir, workTreeTop } from './git-paths.js';
|
|
51
51
|
import { findFirstOnPath, quoteForCmd } from './morph-cli.js';
|
|
52
52
|
import { ensureCommonExclude } from './git-exclude.js';
|
|
53
|
+
import { pidAlive } from './process-alive.js';
|
|
53
54
|
import {
|
|
54
55
|
GRAPH_DIR, graphFile, graphFreshness, ignoreRulesHash, primaryTree, readRefreshRecord,
|
|
55
56
|
recordRefreshRun, refreshPending, treeKey, writeRefreshRecord,
|
|
@@ -110,15 +111,6 @@ function mtimeOf(path) {
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
function pidAlive(pid) {
|
|
114
|
-
try {
|
|
115
|
-
process.kill(pid, 0);
|
|
116
|
-
return true;
|
|
117
|
-
} catch (err) {
|
|
118
|
-
return err?.code === 'EPERM';
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
114
|
/**
|
|
123
115
|
* Estado do lock do repositório — o que `graph status` mostra como "refresh em
|
|
124
116
|
* andamento". A idade conta da última renovação: um lock de processo morto,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { tokenize } from './command-tokens.js';
|
|
2
|
+
|
|
3
|
+
const DOTNET_HEAVY = new Set(['build', 'test', 'publish', 'pack', 'msbuild', 'vstest']);
|
|
4
|
+
const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
5
|
+
const HEAVY_SCRIPT = /^(test|build)(:.+)?$/;
|
|
6
|
+
const WRAPPERS = new Set(['time', 'nohup', 'nice', 'command', 'exec', 'env']);
|
|
7
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
8
|
+
const INFO_FLAGS = new Set(['--help', '-h', '--version', '-?']);
|
|
9
|
+
|
|
10
|
+
function splitSegments(command) {
|
|
11
|
+
const segments = [];
|
|
12
|
+
let current = '';
|
|
13
|
+
let quote = null;
|
|
14
|
+
const s = String(command || '');
|
|
15
|
+
for (let i = 0; i < s.length; i++) {
|
|
16
|
+
const ch = s[i];
|
|
17
|
+
if (quote) {
|
|
18
|
+
current += ch;
|
|
19
|
+
if (ch === quote) quote = null;
|
|
20
|
+
else if (ch === '\\' && quote === '"') current += s[++i] ?? '';
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
|
|
24
|
+
if (ch === '\\') { current += ch + (s[++i] ?? ''); continue; }
|
|
25
|
+
if (';|&\n'.includes(ch)) { segments.push(current); current = ''; continue; }
|
|
26
|
+
current += ch;
|
|
27
|
+
}
|
|
28
|
+
segments.push(current);
|
|
29
|
+
return segments;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function executable(token) {
|
|
33
|
+
return token.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '').toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function skipWrappers(tokens) {
|
|
37
|
+
let i = 0;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const t = tokens[i];
|
|
40
|
+
if (t === undefined) return [];
|
|
41
|
+
if (ASSIGNMENT.test(t)) { i++; continue; }
|
|
42
|
+
if (t === 'timeout') { i += 2; continue; }
|
|
43
|
+
if (WRAPPERS.has(executable(t))) { i++; continue; }
|
|
44
|
+
return tokens.slice(i);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DOCKER_VALUE_FLAGS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-file', '--project-directory', '-H', '--host', '--context']);
|
|
49
|
+
|
|
50
|
+
function firstArg(args, valueFlags = new Set()) {
|
|
51
|
+
for (let i = 0; i < args.length; i++) {
|
|
52
|
+
if (!args[i].startsWith('-')) return args[i];
|
|
53
|
+
if (valueFlags.has(args[i])) i++;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function kindOfTokens(tokens) {
|
|
59
|
+
const [head, ...args] = tokens;
|
|
60
|
+
if (!head || args.some((a) => INFO_FLAGS.has(a))) return null;
|
|
61
|
+
const exe = executable(head);
|
|
62
|
+
const sub = firstArg(args, exe === 'docker' || exe === 'docker-compose' ? DOCKER_VALUE_FLAGS : undefined);
|
|
63
|
+
|
|
64
|
+
if (exe === 'dotnet') return DOTNET_HEAVY.has(sub) ? `dotnet ${sub}` : null;
|
|
65
|
+
if (exe === 'msbuild') return 'msbuild';
|
|
66
|
+
if (exe === 'vitest' || exe === 'jest') return exe;
|
|
67
|
+
if (exe === 'playwright') return sub === 'test' ? 'playwright test' : null;
|
|
68
|
+
if (exe === 'next' || exe === 'vite') return sub === 'build' ? `${exe} build` : null;
|
|
69
|
+
|
|
70
|
+
if (PACKAGE_MANAGERS.has(exe)) {
|
|
71
|
+
if (sub === 'test' || sub === 'build') return `${exe} ${sub}`;
|
|
72
|
+
if (sub === 'run' || sub === 'run-script') {
|
|
73
|
+
const script = args.slice(args.indexOf(sub) + 1).find((t) => !t.startsWith('-'));
|
|
74
|
+
return script && HEAVY_SCRIPT.test(script) ? `${exe} run ${script}` : null;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (exe === 'npx' || exe === 'bunx') {
|
|
80
|
+
if (sub === 'vitest' || sub === 'jest') return `npx ${sub}`;
|
|
81
|
+
const rest = args.slice(args.indexOf(sub) + 1).find((t) => !t.startsWith('-'));
|
|
82
|
+
if (sub === 'playwright') return rest === 'test' ? 'npx playwright test' : null;
|
|
83
|
+
if (sub === 'next') return rest === 'build' ? 'npx next build' : null;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (exe === 'docker-compose') return sub === 'up' || sub === 'build' ? `docker-compose ${sub}` : null;
|
|
88
|
+
if (exe === 'docker') {
|
|
89
|
+
if (sub === 'build' || sub === 'buildx') return `docker ${sub}`;
|
|
90
|
+
if (sub === 'compose') {
|
|
91
|
+
const action = firstArg(args.slice(args.indexOf(sub) + 1), DOCKER_VALUE_FLAGS);
|
|
92
|
+
return action === 'up' || action === 'build' ? `docker compose ${action}` : null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The first heavy command of a Bash command line, as a label, or null. Looks at the executable of
|
|
100
|
+
* each `;`/`&&`/`||`/`|`/newline segment, so `morph-spec heavy run -- dotnet test` and `echo "dotnet build"`
|
|
101
|
+
* are not heavy: the first runs under its own slot, the second runs nothing.
|
|
102
|
+
* @param {string} command
|
|
103
|
+
* @returns {string|null}
|
|
104
|
+
*/
|
|
105
|
+
export function heavyKind(command) {
|
|
106
|
+
for (const segment of splitSegments(command)) {
|
|
107
|
+
const tokens = skipWrappers(tokenize(segment.replace(/^[\s({]+/, '').replace(/[\s)}]+$/, '')));
|
|
108
|
+
const kind = kindOfTokens(tokens);
|
|
109
|
+
if (kind) return kind;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How long a foreground Bash call can run: its own `timeout` (capped at the platform maximum) plus slack.
|
|
116
|
+
* The lease has no live process to watch, so this is the only thing that frees it after a denied or
|
|
117
|
+
* interrupted call.
|
|
118
|
+
*/
|
|
119
|
+
const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
|
|
120
|
+
const BASH_MAX_TIMEOUT_MS = 10 * 60_000;
|
|
121
|
+
const LEASE_SLACK_MS = 30_000;
|
|
122
|
+
|
|
123
|
+
export function leaseTtlMs(toolInput, env = process.env) {
|
|
124
|
+
const fallback = Number(env.BASH_DEFAULT_TIMEOUT_MS) || BASH_DEFAULT_TIMEOUT_MS;
|
|
125
|
+
const max = Math.max(Number(env.BASH_MAX_TIMEOUT_MS) || BASH_MAX_TIMEOUT_MS, fallback);
|
|
126
|
+
const asked = Number(toolInput?.timeout);
|
|
127
|
+
return Math.min(Number.isFinite(asked) && asked > 0 ? asked : fallback, max) + LEASE_SLACK_MS;
|
|
128
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILE = join('.morph', 'config', 'config.json');
|
|
5
|
+
|
|
6
|
+
export const HEAVY_DEFAULTS = Object.freeze({
|
|
7
|
+
enabled: true,
|
|
8
|
+
slots: 2,
|
|
9
|
+
minFreeMemGb: 4,
|
|
10
|
+
waitTimeoutSec: 600,
|
|
11
|
+
maxHoldMinutes: 90,
|
|
12
|
+
shutdownBuildServer: true,
|
|
13
|
+
disableNodeReuse: false,
|
|
14
|
+
gateBash: false,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const isBool = (v) => typeof v === 'boolean';
|
|
18
|
+
const VALID = {
|
|
19
|
+
enabled: isBool,
|
|
20
|
+
slots: (v) => Number.isInteger(v) && v >= 1,
|
|
21
|
+
minFreeMemGb: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
22
|
+
waitTimeoutSec: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
23
|
+
maxHoldMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
|
|
24
|
+
shutdownBuildServer: isBool,
|
|
25
|
+
disableNodeReuse: isBool,
|
|
26
|
+
gateBash: isBool,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function readProjectConfig(projectPath) {
|
|
30
|
+
const file = join(projectPath, CONFIG_FILE);
|
|
31
|
+
if (!existsSync(file)) return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(file, 'utf8').replace(/^/, ''));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveHeavyConfig(config, env = process.env) {
|
|
40
|
+
const out = { ...HEAVY_DEFAULTS };
|
|
41
|
+
const warnings = [];
|
|
42
|
+
const raw = config?.heavy;
|
|
43
|
+
|
|
44
|
+
if (raw !== undefined) {
|
|
45
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
46
|
+
warnings.push('`heavy` no config.json não é um objeto; usando os padrões');
|
|
47
|
+
} else {
|
|
48
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
49
|
+
if (!(key in VALID)) warnings.push(`heavy.${key} não existe; ignorada`);
|
|
50
|
+
else if (VALID[key](value)) out[key] = value;
|
|
51
|
+
else warnings.push(`heavy.${key} inválida (${JSON.stringify(value)}); usando ${JSON.stringify(HEAVY_DEFAULTS[key])}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (/^(off|0|false)$/i.test(env.MORPH_HEAVY ?? '')) out.enabled = false;
|
|
57
|
+
const envSlots = Number(env.MORPH_HEAVY_SLOTS);
|
|
58
|
+
if (env.MORPH_HEAVY_SLOTS !== undefined && VALID.slots(envSlots)) out.slots = envSlots;
|
|
59
|
+
|
|
60
|
+
return { ...out, warnings };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readHeavyConfig(projectPath, env = process.env) {
|
|
64
|
+
return resolveHeavyConfig(readProjectConfig(projectPath), env);
|
|
65
|
+
}
|