@polymorphism-tech/morph-spec 9.5.0 → 9.6.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 +2 -2
- package/bin/morph-spec.js +1 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/docs/CLI.md +9 -5
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/heavy-gate.js +5 -3
- package/framework/hooks/shared/feature-locator.js +7 -5
- package/framework/hooks/shared/heavy-command.js +12 -0
- package/framework/hooks/shared/heavy-config.js +28 -1
- package/framework/hooks/shared/heavy-slots.js +41 -5
- package/framework/schemas/config.schema.json +12 -1
- 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 +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +2 -2
- package/framework/skills/core/morph-review/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/infrastructure/docker/local-compose-isolation.md +1 -0
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +3 -0
- package/src/commands/project/env.js +3 -3
- package/src/commands/project/heavy.js +10 -3
- package/src/commands/project/worktree.js +2 -1
- package/src/commands/state/finish.js +17 -3
- package/src/commands/validation/mutate.js +2 -2
- package/src/commands/validation/verify.js +3 -0
- package/src/lib/e2e/compose-env.js +26 -3
- package/src/lib/heavy/gate.js +23 -4
- package/src/lib/heavy/live-stacks.js +64 -0
- package/src/lib/risk-detector.js +9 -1
- package/src/lib/worktree/docker-audit.js +7 -3
- package/src/lib/worktree/env-allocator.js +27 -5
- package/src/lib/worktree/ignored-triage.js +8 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
|
|
4
4
|
|
|
5
5
|
**Package:** `@polymorphism-tech/morph-spec`
|
|
6
|
-
**Version:** 9.
|
|
6
|
+
**Version:** 9.6.0
|
|
7
7
|
**Requires:** Node.js 22+, Claude Code
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -380,4 +380,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
|
|
|
380
380
|
|
|
381
381
|
---
|
|
382
382
|
|
|
383
|
-
*morph-spec v9.
|
|
383
|
+
*morph-spec v9.6.0 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/bin/morph-spec.js
CHANGED
|
@@ -520,6 +520,7 @@ program
|
|
|
520
520
|
.description('Print the worktree\'s dedicated environment (port block + COMPOSE_PROJECT_NAME), shell-loadable: eval "$(morph-spec env)" outside Claude Code; feature defaults to the morph/{f} or morph-task/{n} branch of cwd')
|
|
521
521
|
.option('--json', 'Output as JSON')
|
|
522
522
|
.option('--powershell', 'Emit $env:K="V" lines instead of POSIX exports')
|
|
523
|
+
.option('--with-secrets', 'Also emit the e2e.auth.envFile credentials (omitted by default: stdout lands in transcripts); use inside eval "$(…)"')
|
|
523
524
|
.action((feature, options) => envCommand(feature, options));
|
|
524
525
|
|
|
525
526
|
program
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
package/framework/docs/CLI.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
**`morph-spec approve <feature> <gate> --mode auto`** — aprova um gate (`plan`/`uiux`/`review`) em `trust: auto`.
|
|
12
12
|
|
|
13
|
-
**O Gate 3 exige os relatórios de review**: `4-review/verifier.md` e `reviewer.md`, mais `security.md` quando o diff toca caminho sensível (`securityReviewFor`) — ausente, PARCIAL ou sem `**Verdict:**`, o comando recusa (exit 1) nomeando o arquivo. A assinatura humana (`--approver`) aprova mesmo com `FIX` ou cenário não provado; a `auto` recalcula a pausa do `gate-check`, cobertura dos `Cn` incluída. `**Reviewed-SHA:**` é lido de todo relatório presente e o pior vence: o código andou depois dele → recusa; sem carimbo → aviso. Se o dispatch for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate).
|
|
13
|
+
**O Gate 3 exige os relatórios de review**: `4-review/verifier.md` e `reviewer.md`, mais `security.md` quando o diff toca caminho sensível fora de teste (`securityReviewFor`; spec, `e2e/` e `test-results/` não contam) — ausente, PARCIAL ou sem `**Verdict:**`, o comando recusa (exit 1) nomeando o arquivo. A assinatura humana (`--approver`) aprova mesmo com `FIX` ou cenário não provado; a `auto` recalcula a pausa do `gate-check`, cobertura dos `Cn` incluída. `**Reviewed-SHA:**` é lido de todo relatório presente e o pior vence: o código andou depois dele → recusa; sem carimbo → aviso. Se o dispatch for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate).
|
|
14
14
|
|
|
15
15
|
**O plano semeia `bin/`/`obj/`** (`plan-check` que grava o `checks.json`, `approve <f> plan`, `advance` saindo do plano e `advance --design` com o plano já aprovado — o `plan-check` cobre também o gate gravado pelo hook state-sync): num projeto .NET — `project.stack` com `dotnet`, fatia de `dotnet-senior`/`ef-modeler`/`maf-expert` no `checks.json` ou `.csproj`/`.sln`/`.slnx` na árvore (fora de `worktrees/`, `node_modules/`, `bin/`, `obj/`) — as duas linhas entram no `.gitignore` da árvore dona antes do primeiro código, e a saída as nomeia para irem no mesmo commit. O `create` aplica a mesma regra (`src/lib/stack/dotnet-build-ignores.js`); no greenfield só a fatia do plano existe para decidir.
|
|
16
16
|
|
|
@@ -124,7 +124,7 @@ Feature sem `workType` (pré-v8) é tratada como `feature`.
|
|
|
124
124
|
|
|
125
125
|
**Índice de ADRs:** no mesmo commit do archive, o `finish` lê o `2-plan/plan.md` da feature: os ADRs de `## Decisões` com `**Alcance:** projeto` entram em `.morph/specs/adrs/` (um arquivo por ADR, `{feature}--ADR-{nnn}.md` — features paralelas nunca tocam o mesmo arquivo), e a tabela `ADR | Mudança | Escopo` de `### Conflitos com o acervo` retira (total, o arquivo sai) ou estreita (parcial, `scope:` no arquivo) os ADRs revogados — forma em `morph-spec plan-check --help`; o `plan-check` avisa, enquanto o plano ainda muda, a linha que o `finish` não aplicaria. O payload traz `adrIndex: {added, revoked, narrowed}` e `revocationProblems: [{id, reason, message}]` (linha malformada ou ambígua, ADR fora do índice) — cada chave só aparece com conteúdo, e nenhum problema recusa o `finish`.
|
|
126
126
|
|
|
127
|
-
`--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`.
|
|
127
|
+
`--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Saída de teste não rastreada (`test-results/`, `TestResults/`, `playwright-report/`, `blob-report/`) não conta como árvore suja: na raiz só é tolerada, na worktree sai junto com o desmonte; qualquer outro arquivo não commitado recusa. Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`.
|
|
128
128
|
|
|
129
129
|
**Sessão ancorada no worktree → o desmonte é ADIADO** (`--merge` e `--pr`): quando a sessão do Claude Code que rodou o `finish` tem o próprio worktree como projeto (`CLAUDE_PROJECT_DIR` — exportado ao CLI como `MORPH_SESSION_PROJECT_DIR` pelo hook `worktree-env-inject`, porque o Bash tool não o exporta e `cd <raiz> && morph-spec finish` apaga o cwd), a integração acontece normalmente, mas o worktree fica: saída com `worktreeRemoved: false`, `teardownDeferred: "session-anchored"`, `worktree: {path, branch}` e um `hint` com `morph-spec worktree remove <f>` para rodar depois, de uma sessão aberta na raiz. O `--dry-run` já avisa (`teardownDeferred` no pré-voo).
|
|
130
130
|
|
|
@@ -158,7 +158,7 @@ O `.morph/state.json` do worktree é índice derivável: ele se refaz sozinho a
|
|
|
158
158
|
|
|
159
159
|
### env
|
|
160
160
|
|
|
161
|
-
**`morph-spec env [feature] [--json|--powershell]`** — imprime o bloco do worktree em forma shell-loadable (`eval "$(morph-spec env)"`; PowerShell: `morph-spec env --powershell | Invoke-Expression`). Feature vem do argumento ou da branch `morph/{f}`/`morph-task/{n}` do cwd. Deriva de `buildComposeEnv`, a mesma função do nó e2e — um shell e o stack nunca discordam. É o caminho para quem roda **fora** do Claude Code (terminal humano, CI, sub-agent sem hooks). As credenciais do arquivo apontado por `e2e.auth.envFile`
|
|
161
|
+
**`morph-spec env [feature] [--json|--powershell] [--with-secrets]`** — imprime o bloco do worktree em forma shell-loadable (`eval "$(morph-spec env)"`; PowerShell: `morph-spec env --powershell | Invoke-Expression`). Feature vem do argumento ou da branch `morph/{f}`/`morph-task/{n}` do cwd. Deriva de `buildComposeEnv`, a mesma função do nó e2e — um shell e o stack nunca discordam. É o caminho para quem roda **fora** do Claude Code (terminal humano, CI, sub-agent sem hooks). As credenciais do arquivo apontado por `e2e.auth.envFile` **só saem com `--with-secrets`** (o stdout vai parar no transcript; use a flag dentro de `eval "$(…)"`), e sem ela os nomes omitidos vão para stderr. Uma chave `*_PORT` desse arquivo que não bate com nenhuma porta do bloco é avisada em stderr com a sugestão de declará-la em `e2e.portEnvNames`. E **chave de layout de portas vinda de lá é ignorada** — o bloco alocado a esta worktree prevalece (um `.env.e2e` copiado de outra árvore carrega o bloco DELA). A chave ignorada é nomeada em **stderr**, nunca em stdout, para o `eval` do shell seguir limpo.
|
|
162
162
|
|
|
163
163
|
### worktree remove
|
|
164
164
|
|
|
@@ -382,13 +382,17 @@ Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os con
|
|
|
382
382
|
|
|
383
383
|
`heavy status` é **somente leitura**: quem segura cada slot (pid, label, feature, nó, idade), a limpeza exclusiva do build-server se houver e a RAM livre.
|
|
384
384
|
|
|
385
|
-
**Espera:** slot cheio, ou RAM livre (`os.freemem()`) abaixo
|
|
385
|
+
**Espera:** slot cheio, ou RAM livre (`os.freemem()`) abaixo do piso do tipo do comando, espera até `heavy.waitTimeoutSec` (600) avisando em **stderr** a cada 30 s e nomeando quem segura (o `--json` mantém o stdout limpo).
|
|
386
|
+
|
|
387
|
+
**Piso de RAM por tipo** (`heavy.minFreeMemGbByKind`, padrão `unit` 1,5 · `build` 4 · `e2e` 4 GB): `unit` = vitest/jest, `npm|pnpm|yarn test`, `npm run test[:x]` e o nó `tests` do verify; `e2e` = playwright, `docker compose up`, script com `e2e` no nome, o nó `e2e` e o `e2e up`; `build` = o resto (`dotnet build|test|publish` — o `dotnet test` compila —, `next build`, `docker build`, `mutate` e comando desconhecido). O `heavy run` e o hook do `gateBash` classificam pelo comando; o `verify` toma o slot com o piso do primeiro nó e, antes de um nó mais pesado (tests → e2e), espera de novo só pela RAM segurando o slot — estourar ali também é exit 3, sem stamp. `heavy.minFreeMemGb` (4) virou o fallback: **definido explicitamente**, vale para todo tipo que `minFreeMemGbByKind` não lista (`0` continua desligando a checagem em tudo); sem ele, cada tipo usa o seu padrão.
|
|
388
|
+
|
|
389
|
+
**Ocupado por RAM** (`verify`, `e2e up`, `heavy run`): a mensagem nomeia o piso e a chave (`heavy.minFreeMemGbByKind.<tipo>`) e lista as stacks e2e de pé deste repo (`docker compose ls`, prefixo `morph-`, compose sob a raiz principal — de todas as worktrees). Feature já arquivada (`.morph/archive/<f>` sem `.morph/features/<f>`) vem com `morph-spec e2e down <f>`; stack sem feature, com `docker compose -p <nome> down`. Docker fora ou lento (10 s) = lista vazia, nunca erro.
|
|
386
390
|
|
|
387
391
|
**Estouro = exit 3 (`busy`), nunca vermelho:** `verify --json` sai `{ status: "busy", persisted: false, busy: { reason, holders, freeGb, waitedMs } }`, **sem stamp e sem evento `Verify`** (nada foi verificado, e fila não é gate verde nem vermelho); `e2e up` e `heavy run` também saem 3. Quem orquestra repete a chamada — nas skills, exit 3 não conta como tentativa e não dispara o loop de correção.
|
|
388
392
|
|
|
389
393
|
**Slot vago:** pid morto, ou idade acima de `heavy.maxHoldMinutes` (90, contra reuso de PID); Ctrl-C deixa o arquivo, que o pid morto cobre. Um filho herda o slot por `MORPH_HEAVY_LEASE` e nunca pede um segundo.
|
|
390
394
|
|
|
391
|
-
**Config** (`.morph/config/config.json`, bloco `heavy`, schema em `config.schema.json`): `enabled`, `slots`, `minFreeMemGb`, `waitTimeoutSec`, `maxHoldMinutes`, `shutdownBuildServer`, `disableNodeReuse`, `gateBash`; valor ilegível cai no default **com aviso** — o bloco nunca bloqueia trabalho por estar errado.
|
|
395
|
+
**Config** (`.morph/config/config.json`, bloco `heavy`, schema em `config.schema.json`): `enabled`, `slots`, `minFreeMemGb`, `minFreeMemGbByKind`, `waitTimeoutSec`, `maxHoldMinutes`, `shutdownBuildServer`, `disableNodeReuse`, `gateBash`; valor ilegível cai no default **com aviso** — o bloco nunca bloqueia trabalho por estar errado.
|
|
392
396
|
|
|
393
397
|
Env: `MORPH_HEAVY=off` desliga o semáforo (a suíte do próprio framework roda assim, para não enfileirar nos slots reais de quem a executa), `MORPH_HEAVY_SLOTS`, `MORPH_HEAVY_DIR`.
|
|
394
398
|
|
|
Binary file
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { stateExists } from '../../shared/state-reader.js';
|
|
7
7
|
import { blockResult, runStandalone } from '../../shared/hook-response.js';
|
|
8
|
-
import { heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
|
|
8
|
+
import { heavyClass, heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
|
|
9
9
|
|
|
10
10
|
const HOOK_BUDGET_SEC = 540;
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ export async function run(payload, deps = {}) {
|
|
|
18
18
|
if (!kind || !payload?.tool_use_id) return null;
|
|
19
19
|
|
|
20
20
|
// Loaded only here: this link runs on every Bash call and almost none is heavy.
|
|
21
|
-
const { readHeavyConfig } = await import('../../shared/heavy-config.js');
|
|
21
|
+
const { minFreeMemFor, readHeavyConfig } = await import('../../shared/heavy-config.js');
|
|
22
22
|
const { acquireHeavy, describeBusy, heavyDir } = await import('../../shared/heavy-slots.js');
|
|
23
23
|
const env = deps.env ?? process.env;
|
|
24
24
|
const dir = env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
|
|
@@ -26,6 +26,7 @@ export async function run(payload, deps = {}) {
|
|
|
26
26
|
const config = readHeavyConfig(dir, env);
|
|
27
27
|
if (!config.enabled || !config.gateBash) return null;
|
|
28
28
|
|
|
29
|
+
const memClass = heavyClass(kind);
|
|
29
30
|
const result = (deps.acquire ?? acquireHeavy)({
|
|
30
31
|
dir: heavyDir(env),
|
|
31
32
|
slots: config.slots,
|
|
@@ -33,7 +34,8 @@ export async function run(payload, deps = {}) {
|
|
|
33
34
|
feature: null,
|
|
34
35
|
node: null,
|
|
35
36
|
cwd: dir,
|
|
36
|
-
|
|
37
|
+
kind: memClass,
|
|
38
|
+
minFreeMemGb: minFreeMemFor(config, memClass),
|
|
37
39
|
waitTimeoutMs: Math.min(config.waitTimeoutSec, HOOK_BUDGET_SEC) * 1000,
|
|
38
40
|
maxHoldMs: config.maxHoldMinutes * 60_000,
|
|
39
41
|
lease: { id: payload.tool_use_id, ttlMs: leaseTtlMs(input, env) },
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
6
|
import { loadState } from './state-reader.js';
|
|
7
|
+
import { morphBranchFeature } from './git-paths.js';
|
|
7
8
|
|
|
8
9
|
// `loadState`, not a directory listing: it also sees features only the index carries.
|
|
9
10
|
export function featureNamesIn(root) {
|
|
@@ -55,26 +56,27 @@ export function unrecognizedSlugLike(text, names) {
|
|
|
55
56
|
return tokens.find((t) => !known.has(t)) ?? null;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
// The
|
|
59
|
-
// `morph-spec approve`
|
|
59
|
+
// The tree on `morph/<f>` owns the feature and wins: the primary root only carries its index
|
|
60
|
+
// entry, and `morph-spec approve` refuses any other tree. Without an owner, the session root wins.
|
|
60
61
|
export function locateFeatureForQuestion(text, sessionRoot) {
|
|
61
62
|
const trees = allWorkTrees(sessionRoot);
|
|
62
63
|
const localNames = featureNamesIn(sessionRoot);
|
|
64
|
+
const ownerOr = (name, root) => trees.find((t) => morphBranchFeature(t) === name) ?? root;
|
|
63
65
|
|
|
64
66
|
const local = featureNamedIn(text, localNames);
|
|
65
|
-
if (local) return { name: local, root: sessionRoot };
|
|
67
|
+
if (local) return { name: local, root: ownerOr(local, sessionRoot) };
|
|
66
68
|
|
|
67
69
|
for (const root of trees) {
|
|
68
70
|
if (root === sessionRoot) continue;
|
|
69
71
|
const found = featureNamedIn(text, featureNamesIn(root));
|
|
70
|
-
if (found) return { name: found, root };
|
|
72
|
+
if (found) return { name: found, root: ownerOr(found, root) };
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const allNames = trees.flatMap(featureNamesIn);
|
|
74
76
|
if (unrecognizedSlugLike(text, allNames)) return null;
|
|
75
77
|
|
|
76
78
|
// With exactly ONE feature in the tree there is nothing else the answer could be about.
|
|
77
|
-
if (localNames.length === 1) return { name: localNames[0], root: sessionRoot };
|
|
79
|
+
if (localNames.length === 1) return { name: localNames[0], root: ownerOr(localNames[0], sessionRoot) };
|
|
78
80
|
|
|
79
81
|
return null;
|
|
80
82
|
}
|
|
@@ -106,6 +106,18 @@ export function heavyKind(command) {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const E2E_CLASS = /\be2e\b|playwright|compose up/;
|
|
110
|
+
const UNIT_CLASS = /^(tests|(npx )?(vitest|jest)|(npm|pnpm|yarn|bun) (test|run test(:\S+)?))$/;
|
|
111
|
+
|
|
112
|
+
// Maps a `heavyKind` label or a verify node to the RAM class it pays. `dotnet test` and anything
|
|
113
|
+
// unknown stay `build`: they compile before running, and the fallback must never lower a floor.
|
|
114
|
+
export function heavyClass(label) {
|
|
115
|
+
const s = String(label ?? '').toLowerCase().trim();
|
|
116
|
+
if (E2E_CLASS.test(s)) return 'e2e';
|
|
117
|
+
if (UNIT_CLASS.test(s)) return 'unit';
|
|
118
|
+
return 'build';
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
// The lease has no live process to watch: this TTL is the only thing that frees it after a
|
|
110
122
|
// denied or interrupted call.
|
|
111
123
|
const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
|
|
@@ -7,6 +7,7 @@ export const HEAVY_DEFAULTS = Object.freeze({
|
|
|
7
7
|
enabled: true,
|
|
8
8
|
slots: 2,
|
|
9
9
|
minFreeMemGb: 4,
|
|
10
|
+
minFreeMemGbByKind: Object.freeze({ unit: 1.5, build: 4, e2e: 4 }),
|
|
10
11
|
waitTimeoutSec: 600,
|
|
11
12
|
maxHoldMinutes: 90,
|
|
12
13
|
shutdownBuildServer: true,
|
|
@@ -14,11 +15,16 @@ export const HEAVY_DEFAULTS = Object.freeze({
|
|
|
14
15
|
gateBash: false,
|
|
15
16
|
});
|
|
16
17
|
|
|
18
|
+
export const HEAVY_KINDS = Object.freeze(['unit', 'build', 'e2e']);
|
|
19
|
+
|
|
17
20
|
const isBool = (v) => typeof v === 'boolean';
|
|
21
|
+
const isGb = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
22
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
18
23
|
const VALID = {
|
|
19
24
|
enabled: isBool,
|
|
20
25
|
slots: (v) => Number.isInteger(v) && v >= 1,
|
|
21
|
-
minFreeMemGb:
|
|
26
|
+
minFreeMemGb: isGb,
|
|
27
|
+
minFreeMemGbByKind: isPlainObject,
|
|
22
28
|
waitTimeoutSec: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
23
29
|
maxHoldMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
|
|
24
30
|
shutdownBuildServer: isBool,
|
|
@@ -40,6 +46,7 @@ export function resolveHeavyConfig(config, env = process.env) {
|
|
|
40
46
|
const out = { ...HEAVY_DEFAULTS };
|
|
41
47
|
const warnings = [];
|
|
42
48
|
const raw = config?.heavy;
|
|
49
|
+
let byKind = {};
|
|
43
50
|
|
|
44
51
|
if (raw !== undefined) {
|
|
45
52
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
@@ -47,12 +54,15 @@ export function resolveHeavyConfig(config, env = process.env) {
|
|
|
47
54
|
} else {
|
|
48
55
|
for (const [key, value] of Object.entries(raw)) {
|
|
49
56
|
if (!(key in VALID)) warnings.push(`heavy.${key} não existe; ignorada`);
|
|
57
|
+
else if (key === 'minFreeMemGbByKind' && VALID[key](value)) byKind = value;
|
|
50
58
|
else if (VALID[key](value)) out[key] = value;
|
|
51
59
|
else warnings.push(`heavy.${key} inválida (${JSON.stringify(value)}); usando ${JSON.stringify(HEAVY_DEFAULTS[key])}`);
|
|
52
60
|
}
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
out.minFreeMemGbByKind = resolveByKind(byKind, isGb(raw?.minFreeMemGb) ? raw.minFreeMemGb : null, warnings);
|
|
65
|
+
|
|
56
66
|
if (/^(off|0|false)$/i.test(env.MORPH_HEAVY ?? '')) out.enabled = false;
|
|
57
67
|
const envSlots = Number(env.MORPH_HEAVY_SLOTS);
|
|
58
68
|
if (env.MORPH_HEAVY_SLOTS !== undefined && VALID.slots(envSlots)) out.slots = envSlots;
|
|
@@ -60,6 +70,23 @@ export function resolveHeavyConfig(config, env = process.env) {
|
|
|
60
70
|
return { ...out, warnings };
|
|
61
71
|
}
|
|
62
72
|
|
|
73
|
+
// An explicit `minFreeMemGb` without a per-kind entry still governs that kind:
|
|
74
|
+
// a project that set 0 (RAM check off) or a stricter floor keeps it after the split.
|
|
75
|
+
function resolveByKind(byKind, explicitFloor, warnings) {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const kind of HEAVY_KINDS) out[kind] = explicitFloor ?? HEAVY_DEFAULTS.minFreeMemGbByKind[kind];
|
|
78
|
+
for (const [kind, value] of Object.entries(byKind)) {
|
|
79
|
+
if (!HEAVY_KINDS.includes(kind)) warnings.push(`heavy.minFreeMemGbByKind.${kind} não existe; use ${HEAVY_KINDS.join(', ')}`);
|
|
80
|
+
else if (isGb(value)) out[kind] = value;
|
|
81
|
+
else warnings.push(`heavy.minFreeMemGbByKind.${kind} inválida (${JSON.stringify(value)}); usando ${out[kind]}`);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function minFreeMemFor(config, kind) {
|
|
87
|
+
return config.minFreeMemGbByKind?.[kind] ?? config.minFreeMemGb;
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
export function readHeavyConfig(projectPath, env = process.env) {
|
|
64
91
|
return resolveHeavyConfig(readProjectConfig(projectPath), env);
|
|
65
92
|
}
|
|
@@ -49,6 +49,8 @@ function liveHoldersPruningStale(dir, ctx) {
|
|
|
49
49
|
return live;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const kindSuffix = (kind) => (kind ? ` (${kind})` : '');
|
|
53
|
+
|
|
52
54
|
function blockedText(blocked, slots, now) {
|
|
53
55
|
const holders = blocked.holders.map((h) => describeHolder(h, now)).join('; ');
|
|
54
56
|
if (blocked.reason === 'exclusive') return `aguardando limpeza do servidor de build (${describeHolder(blocked.exclusive, now)})`;
|
|
@@ -56,14 +58,14 @@ function blockedText(blocked, slots, now) {
|
|
|
56
58
|
const cause = blocked.holders.length
|
|
57
59
|
? `slots em uso: ${holders}`
|
|
58
60
|
: 'nenhum slot em uso: a pressão vem de fora do semáforo (vmmem do WSL, IDE, outros processos)';
|
|
59
|
-
return `aguardando RAM livre: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
|
|
61
|
+
return `aguardando RAM livre${kindSuffix(blocked.kind)}: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
|
|
60
62
|
}
|
|
61
63
|
return `aguardando slot pesado (${blocked.holders.length}/${slots}): ${holders}`;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
function tryClaim(dir, opts, ctx) {
|
|
65
67
|
const {
|
|
66
|
-
slots, label, feature, node, cwd, minFreeMemGb, freeMem, lease: leaseSpec,
|
|
68
|
+
slots, label, feature, node, cwd, minFreeMemGb, freeMem, kind, lease: leaseSpec,
|
|
67
69
|
} = opts;
|
|
68
70
|
const guarded = withClaimMutex(dir, () => {
|
|
69
71
|
const holders = liveHoldersPruningStale(dir, ctx);
|
|
@@ -71,7 +73,7 @@ function tryClaim(dir, opts, ctx) {
|
|
|
71
73
|
if (exclusive) return { blocked: { reason: 'exclusive', holders, exclusive } };
|
|
72
74
|
if (holders.length >= slots) return { blocked: { reason: 'slots', holders } };
|
|
73
75
|
const freeGb = freeMem() / GB;
|
|
74
|
-
if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb } };
|
|
76
|
+
if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb, kind } };
|
|
75
77
|
|
|
76
78
|
for (let i = 0; i < slots + holders.length + 1; i++) {
|
|
77
79
|
const path = join(dir, `slot-${i}.lock`);
|
|
@@ -124,7 +126,13 @@ export function acquireHeavy(opts) {
|
|
|
124
126
|
const { blocked } = attempt;
|
|
125
127
|
if (waited >= waitTimeoutMs) {
|
|
126
128
|
return {
|
|
127
|
-
status: 'busy',
|
|
129
|
+
status: 'busy',
|
|
130
|
+
reason: blocked.reason,
|
|
131
|
+
holders: blocked.holders,
|
|
132
|
+
freeGb: blocked.freeGb ?? null,
|
|
133
|
+
minFreeMemGb: claimOpts.minFreeMemGb,
|
|
134
|
+
kind: opts.kind ?? null,
|
|
135
|
+
waitedMs: waited,
|
|
128
136
|
};
|
|
129
137
|
}
|
|
130
138
|
if (blocked.reason !== 'contended' && now() - lastReport >= REPORT_EVERY_MS) {
|
|
@@ -135,6 +143,32 @@ export function acquireHeavy(opts) {
|
|
|
135
143
|
}
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
// For a holder whose next node needs more RAM than it was admitted with: it keeps its slot and
|
|
147
|
+
// waits only on memory, since re-claiming would hit the re-entrant path and skip the check.
|
|
148
|
+
export function awaitFreeMemory(opts) {
|
|
149
|
+
const {
|
|
150
|
+
minFreeMemGb, waitTimeoutMs, kind = null,
|
|
151
|
+
now = Date.now, sleep = sleepSync, freeMem = freemem, log = (m) => process.stderr.write(`${m}\n`),
|
|
152
|
+
} = opts;
|
|
153
|
+
const start = now();
|
|
154
|
+
let lastReport = -Infinity;
|
|
155
|
+
for (;;) {
|
|
156
|
+
const freeGb = freeMem() / GB;
|
|
157
|
+
const waited = now() - start;
|
|
158
|
+
if (!(minFreeMemGb > 0) || freeGb >= minFreeMemGb) return { status: 'acquired', waitedMs: waited };
|
|
159
|
+
if (waited >= waitTimeoutMs) {
|
|
160
|
+
return {
|
|
161
|
+
status: 'busy', reason: 'memory', holders: [], freeGb, minFreeMemGb, kind, waitedMs: waited,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (now() - lastReport >= REPORT_EVERY_MS) {
|
|
165
|
+
log(`aguardando RAM livre${kindSuffix(kind)}: ${freeGb.toFixed(1)} GB < ${minFreeMemGb} GB mínimos, segurando o slot já tomado`);
|
|
166
|
+
lastReport = now();
|
|
167
|
+
}
|
|
168
|
+
sleep(Math.min(POLL_MS, Math.max(1, waitTimeoutMs - waited)));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
export function updateLease(lease, patch) {
|
|
139
173
|
if (!lease) return;
|
|
140
174
|
try {
|
|
@@ -168,7 +202,9 @@ export function releaseLeaseById(dir, id) {
|
|
|
168
202
|
export function describeBusy(result, config) {
|
|
169
203
|
const waited = Math.round(result.waitedMs / 1000);
|
|
170
204
|
if (result.reason === 'memory') {
|
|
171
|
-
|
|
205
|
+
const floor = result.minFreeMemGb ?? config.minFreeMemGb;
|
|
206
|
+
const key = result.kind ? `heavy.minFreeMemGbByKind.${result.kind}` : 'heavy.minFreeMemGb';
|
|
207
|
+
return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${floor} GB mínimos${kindSuffix(result.kind)}, ${key})`;
|
|
172
208
|
}
|
|
173
209
|
if (result.reason === 'exclusive') return `limpeza do servidor de build ainda em curso após ${waited}s`;
|
|
174
210
|
return `${result.holders.length}/${config.slots} slots pesados ocupados por ${waited}s (heavy.waitTimeoutSec)`;
|
|
@@ -179,7 +179,18 @@
|
|
|
179
179
|
"type": "number",
|
|
180
180
|
"minimum": 0,
|
|
181
181
|
"default": 4,
|
|
182
|
-
"description": "Free RAM (os.freemem) below which a heavy node WAITS instead of starting and dying 'killed' for memory. It waits with or without a slot holder - the pressure often comes from outside (WSL vmmem, the IDE). 0 disables the check."
|
|
182
|
+
"description": "Free RAM (os.freemem) below which a heavy node WAITS instead of starting and dying 'killed' for memory. It waits with or without a slot holder - the pressure often comes from outside (WSL vmmem, the IDE). 0 disables the check. Since the per-kind floors exist it is the fallback: set explicitly, it applies to every kind not listed in minFreeMemGbByKind (so 0 still turns the check off everywhere)."
|
|
183
|
+
},
|
|
184
|
+
"minFreeMemGbByKind": {
|
|
185
|
+
"type": "object",
|
|
186
|
+
"additionalProperties": false,
|
|
187
|
+
"default": { "unit": 1.5, "build": 4, "e2e": 4 },
|
|
188
|
+
"description": "Free-RAM floor per kind of heavy command. unit = vitest/jest, npm|pnpm|yarn test, the verify tests node; e2e = playwright, docker compose up, the verify e2e node, e2e up; build = everything else (dotnet build|test|publish, next build, docker build, mutate, unknown commands). A verify admitted at a lighter node waits again, holding its slot, before a heavier one. A kind left out takes an explicit heavy.minFreeMemGb, else its default.",
|
|
189
|
+
"properties": {
|
|
190
|
+
"unit": { "type": "number", "minimum": 0 },
|
|
191
|
+
"build": { "type": "number", "minimum": 0 },
|
|
192
|
+
"e2e": { "type": "number", "minimum": 0 }
|
|
193
|
+
}
|
|
183
194
|
},
|
|
184
195
|
"waitTimeoutSec": {
|
|
185
196
|
"type": "number",
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando for iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- umbraco
|
|
10
10
|
---
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando o usuário pedir "use /migrate-lp-to-umbraco {projeto}" ou descrever a necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- umbraco
|
|
10
10
|
---
|
|
@@ -4,7 +4,7 @@ description: Executa as fatias do plano de uma feature morph-spec — dispatch p
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use com o gate do plano aprovado, em "use /morph-apply {feature}" ou "implementar feature"
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando chegar "/morph-hotfix {feature}", quando a morph-plan rotear o pedido como workType hotfix, ou quando o usuário disser "produção caiu / hotfix / urgente" a respeito de um incidente
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -4,7 +4,7 @@ description: Cuida da RAM e da CPU da máquina enquanto o morph-spec trabalha
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando a máquina estiver lenta ou sem RAM, um comando pesado esperou ou foi recusado pela fila, sobrou worktree de sub-agent, ou vários sub-agents pesados vão rodar em paralelo
|
|
6
6
|
user-invocable: false
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-machine
|
|
@@ -4,7 +4,7 @@ description: Conduz a AUTORIA de mídia gerada por IA — escolher o modelo pelo
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando a tarefa for PRODUZIR imagem ou vídeo por IA (catálogo, marketing, asset de LP) e alguém precisar decidir modelo, prompt e aprovação
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- dotnet
|
|
10
10
|
- nextjs
|
|
@@ -4,7 +4,7 @@ description: Leva uma feature do pedido ao plano aprovado — roteia o tipo de t
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando uma feature, bug ou chore começar ("/morph-plan {feature}", "vamos planejar X"), ou ao retomar uma feature cujo gate plan ainda não foi aprovado
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -71,7 +71,7 @@ O `create` já escreveu o esqueleto em `2-plan/plan.md` (leia e preencha; `chore
|
|
|
71
71
|
- **Observável**: que asserção (ou que linha de código) prova? Sem resposta, reescreva ou descarte.
|
|
72
72
|
- **Artefato não é comportamento.** Cenário que cita `grep`, "contém a string", "o binário", "nenhum arquivo", "arquivo/pasta/diretório em" ou glob `**/` descreve o repositório. Tente o efeito em runtime; se o artefato É o entregável, justifique em `> Por que artefato:` logo abaixo (nunca na mesma linha, nunca `> [!note]`).
|
|
73
73
|
- **Saída de LLM** (o que o modelo responde) prova-se por `eval` de runtime, nunca pelo texto do prompt: evidência em `3-implement/evidencias/{Cn}.md`, path listado em `arquivos:` da fatia; com nó `runner: "evals"` (standard `ai-agents-evals-with-cache`) o cenário mora no dataset. No máximo 1 teste de estrutura da fonte do prompt.
|
|
74
|
-
- **"O comando de teste da raiz roda de ponta a ponta"** prova-se pelo `verify {feature}` (o nó `e2e` e o nó de teste `e2e: true` já percorrem a cadeia): nomeie essa rodada como a prova, nunca uma execução avulsa. Precisando mesmo rodar fora dele, numa linha só (o Bash não guarda o env entre chamadas): `eval "$(morph-spec env {feature})" && morph-spec heavy run -- <comando>` — o `COMPOSE_PROJECT_NAME` da feature é o que o `finish` derruba com os volumes. `prova: manual` registra passo e resultado em `3-implement/evidencias/{Cn}.md`.
|
|
74
|
+
- **"O comando de teste da raiz roda de ponta a ponta"** prova-se pelo `verify {feature}` (o nó `e2e` e o nó de teste `e2e: true` já percorrem a cadeia): nomeie essa rodada como a prova, nunca uma execução avulsa. Precisando mesmo rodar fora dele, numa linha só (o Bash não guarda o env entre chamadas): `eval "$(morph-spec env {feature} --with-secrets)" && morph-spec heavy run -- <comando>` — o `COMPOSE_PROJECT_NAME` da feature é o que o `finish` derruba com os volumes. `prova: manual` registra passo e resultado em `3-implement/evidencias/{Cn}.md`.
|
|
75
75
|
- **Orçamento de teste**: um teste por cenário de risco — o cenário é a unidade, nunca a pós-condição nem o componente; variantes são linhas de tabela (`[Theory]`/`it.each`); acima disso, o porquê na linha do cenário. Nenhum teste que só fixa texto, prompt ou constante, nenhum de DTO/mapping, e o mesmo comportamento numa camada só (handler **ou** endpoint). Teste além do orçamento é escopo para o revisor. O `verify` avisa quando o teste passa da produção.
|
|
76
76
|
|
|
77
77
|
```markdown
|
|
@@ -4,7 +4,7 @@ description: Conduz o Gate 3 — despacha verificador ∥ revisor (∥ seguranç
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando a fase review começar, com todas as fatias com relatório final e o verify da feature verde
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -4,7 +4,7 @@ description: Localiza um standard específico via busca server-side (morph-spec
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX de uma feature com plano aprovado
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando o ramo de design for tomado (morph-spec advance {feature} --design, depois do gate do plano) e antes de /morph-apply
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-uiux
|
|
@@ -96,5 +96,6 @@ volumes:
|
|
|
96
96
|
```bash
|
|
97
97
|
# inside the worktree, outside Claude Code
|
|
98
98
|
eval "$(morph-spec env)" # PowerShell: morph-spec env --powershell | Invoke-Expression
|
|
99
|
+
# --with-secrets also loads the e2e.auth.envFile credentials
|
|
99
100
|
docker compose up -d --wait # lands in project morph-{feature}, on the block's ports
|
|
100
101
|
```
|
package/package.json
CHANGED
package/src/commands/e2e/e2e.js
CHANGED
|
@@ -28,6 +28,7 @@ import { buildComposeEnv } from '../../lib/e2e/compose-env.js';
|
|
|
28
28
|
import { findWorktreeForFeature, resolvePrimaryRoot } from '../project/worktree.js';
|
|
29
29
|
import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
30
30
|
import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
|
|
31
|
+
import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
|
|
31
32
|
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
32
33
|
|
|
33
34
|
const EXIT_USAGE = 2;
|
|
@@ -126,6 +127,8 @@ export async function e2eUpCommand(featureName, options = {}) {
|
|
|
126
127
|
console.log(JSON.stringify({ status: 'busy', feature: featureName, busy: { reason: err.reason, waitedMs: err.waitedMs } }, null, 2));
|
|
127
128
|
}
|
|
128
129
|
console.error(chalk.yellow(`\n⏳ e2e up não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\`).\n`));
|
|
130
|
+
const hint = memoryBusyHint(err, ctx.cwd);
|
|
131
|
+
if (hint) console.error(chalk.yellow(`${hint}\n`));
|
|
129
132
|
process.exit(EXIT_BUSY);
|
|
130
133
|
} finally {
|
|
131
134
|
gate.release();
|
|
@@ -35,7 +35,7 @@ function gitCommonDir(cwd) {
|
|
|
35
35
|
const shq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
36
36
|
|
|
37
37
|
/** The env map for a key, or `{ error }`. */
|
|
38
|
-
export function resolveWorktreeEnv(featureArg, cwd = process.cwd()) {
|
|
38
|
+
export function resolveWorktreeEnv(featureArg, cwd = process.cwd(), { withCredentials = true } = {}) {
|
|
39
39
|
const key = featureArg || envKeyFromBranch(currentBranch(cwd));
|
|
40
40
|
if (!key) {
|
|
41
41
|
return { error: 'no-feature', message: 'Nenhuma feature: passe o nome ou rode de uma branch morph/{f} / morph-task/{n}.' };
|
|
@@ -45,7 +45,7 @@ export function resolveWorktreeEnv(featureArg, cwd = process.cwd()) {
|
|
|
45
45
|
return { error: 'not-a-git-repo', message: 'Fora de um repositório git não há bloco de portas.' };
|
|
46
46
|
}
|
|
47
47
|
const root = resolvePrimaryRoot(cwd);
|
|
48
|
-
const env = buildComposeEnv({ cwd: root, config: readConfig(root), featureName: key, gitCommonDir: common });
|
|
48
|
+
const env = buildComposeEnv({ cwd: root, config: readConfig(root), featureName: key, gitCommonDir: common, withCredentials });
|
|
49
49
|
return { key, env };
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -57,7 +57,7 @@ export function render(env, format) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function envCommand(featureArg, options = {}) {
|
|
60
|
-
const result = resolveWorktreeEnv(featureArg || null, process.cwd());
|
|
60
|
+
const result = resolveWorktreeEnv(featureArg || null, process.cwd(), { withCredentials: Boolean(options.withSecrets) });
|
|
61
61
|
if (result.error) {
|
|
62
62
|
if (options.json) console.log(JSON.stringify({ error: result.error, message: result.message }));
|
|
63
63
|
else console.error(chalk.red(`✗ ${result.message}`));
|
|
@@ -5,6 +5,8 @@ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js
|
|
|
5
5
|
import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
6
6
|
import { describeHolder, heavyDir, heavyStatus } from '../../../framework/hooks/shared/heavy-slots.js';
|
|
7
7
|
import { resolveSpawn } from '../../../framework/hooks/shared/spawn-command.js';
|
|
8
|
+
import { heavyClass, heavyKind } from '../../../framework/hooks/shared/heavy-command.js';
|
|
9
|
+
import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
|
|
8
10
|
|
|
9
11
|
const EXIT_BUSY = 3;
|
|
10
12
|
|
|
@@ -15,6 +17,7 @@ export function heavyStatusCommand(options = {}) {
|
|
|
15
17
|
enabled: config.enabled,
|
|
16
18
|
slots: config.slots,
|
|
17
19
|
minFreeMemGb: config.minFreeMemGb,
|
|
20
|
+
minFreeMemGbByKind: config.minFreeMemGbByKind,
|
|
18
21
|
dir: status.dir,
|
|
19
22
|
freeGb: Number(status.freeGb.toFixed(2)),
|
|
20
23
|
holders: status.holders.map(({ pid, label, feature, node, cwd, since, leaseId }) => ({ pid, label, feature, node, cwd, since, ...(leaseId ? { leaseId } : {}) })),
|
|
@@ -29,8 +32,10 @@ export function heavyStatusCommand(options = {}) {
|
|
|
29
32
|
logger.header('Slots de nós pesados (build, testes, e2e)');
|
|
30
33
|
if (!config.enabled) console.log(chalk.yellow(' ⚠ semáforo desligado (heavy.enabled: false ou MORPH_HEAVY=off)'));
|
|
31
34
|
console.log(chalk.gray(` diretório: ${report.dir}`));
|
|
32
|
-
const
|
|
33
|
-
|
|
35
|
+
const floors = config.minFreeMemGbByKind;
|
|
36
|
+
const memMark = report.freeGb < Math.max(...Object.values(floors)) ? chalk.yellow('⚠') : chalk.green('✓');
|
|
37
|
+
const floorText = Object.entries(floors).map(([kind, gb]) => `${kind} ${gb}`).join(' · ');
|
|
38
|
+
console.log(` ${memMark} RAM livre: ${report.freeGb.toFixed(1)} GB (mínimo em GB: ${floorText})`);
|
|
34
39
|
console.log(` slots em uso: ${status.holders.length}/${config.slots}`);
|
|
35
40
|
for (const h of status.holders) console.log(chalk.gray(` • ${describeHolder(h)}${h.cwd ? ` — ${h.cwd}` : ''}`));
|
|
36
41
|
if (status.exclusive) console.log(chalk.yellow(` ⚠ limpeza do servidor de build em curso (${describeHolder(status.exclusive)})`));
|
|
@@ -42,10 +47,12 @@ export function heavyRunCommand(command, options = {}) {
|
|
|
42
47
|
const [file, ...args] = command;
|
|
43
48
|
const gate = createHeavyGate({ projectPath: process.cwd(), label: options.label ?? 'run' });
|
|
44
49
|
try {
|
|
45
|
-
gate.enter(file);
|
|
50
|
+
gate.enter(file, heavyClass(heavyKind(command.join(' '))));
|
|
46
51
|
} catch (err) {
|
|
47
52
|
if (!(err instanceof HeavyBusyError)) throw err;
|
|
48
53
|
console.error(chalk.yellow(`\n⏳ ${file} não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\` mostra quem segura os slots).\n`));
|
|
54
|
+
const hint = memoryBusyHint(err, process.cwd());
|
|
55
|
+
if (hint) console.error(chalk.yellow(`${hint}\n`));
|
|
49
56
|
process.exit(EXIT_BUSY);
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatS
|
|
|
3
3
|
import { join, resolve, dirname, basename } from 'path';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
6
|
-
import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
|
|
6
|
+
import { isTestArtifact, triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
|
|
7
7
|
import {
|
|
8
8
|
finishBackupDir,
|
|
9
9
|
writeFeatureScopeBackup,
|
|
@@ -710,6 +710,7 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
|
|
|
710
710
|
if (!record || record.length < 4) return false;
|
|
711
711
|
const p = record.slice(3).replace(/\\/g, '/');
|
|
712
712
|
if (opts.exemptOwnScope && p.startsWith(ownScope)) return false;
|
|
713
|
+
if (record.startsWith('?? ') && isTestArtifact(p)) return false;
|
|
713
714
|
return !isLiveSiblingWorktreeRegistration(p, root, feature);
|
|
714
715
|
});
|
|
715
716
|
return { ok: true, dirt };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
3
|
import { join, resolve } from 'path';
|
|
4
4
|
import {
|
|
5
5
|
findWorktreeForFeature,
|
|
@@ -23,6 +23,7 @@ import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.
|
|
|
23
23
|
import { runArchive } from './archive.js';
|
|
24
24
|
import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
|
|
25
25
|
import { newBackupStamp, enforceBackupRetention, inspectFinishBackups } from '../../lib/worktree/finish-backup.js';
|
|
26
|
+
import { isTestArtifact } from '../../lib/worktree/ignored-triage.js';
|
|
26
27
|
|
|
27
28
|
const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
|
|
28
29
|
|
|
@@ -289,7 +290,9 @@ export async function finishCommand(feature, options = {}) {
|
|
|
289
290
|
emit({ finished: false, error: 'status-failed', feature });
|
|
290
291
|
process.exit(1);
|
|
291
292
|
}
|
|
292
|
-
|
|
293
|
+
const dirtLines = withoutLeftoverIgnore(cleanCwd, dirty.split(/\r?\n/).filter((l) => l.trim()));
|
|
294
|
+
const testArtifacts = untrackedTestArtifacts(dirtLines);
|
|
295
|
+
if (dirtLines.length > testArtifacts.length) {
|
|
293
296
|
emit({ finished: false, error: isWorktreeFlow ? 'worktree-dirty' : 'working-dir-dirty', hint: 'commit seu trabalho na branch antes do finish', feature });
|
|
294
297
|
process.exit(1);
|
|
295
298
|
}
|
|
@@ -338,7 +341,10 @@ export async function finishCommand(feature, options = {}) {
|
|
|
338
341
|
|
|
339
342
|
// Every refusal is behind us: the leftover of a killed `graph refresh` goes now, never before.
|
|
340
343
|
dropLeftoverIgnore(cleanCwd);
|
|
341
|
-
|
|
344
|
+
if (isWorktreeFlow) {
|
|
345
|
+
for (const p of testArtifacts) rmSync(join(cleanCwd, p), { recursive: true, force: true });
|
|
346
|
+
}
|
|
347
|
+
const arch =runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
|
|
342
348
|
if (!arch.ok) {
|
|
343
349
|
emit({ finished: false, error: `archive-${arch.error}`, feature });
|
|
344
350
|
process.exit(1);
|
|
@@ -794,6 +800,14 @@ function currentBranch(cwd) {
|
|
|
794
800
|
}
|
|
795
801
|
}
|
|
796
802
|
|
|
803
|
+
/** Untracked Playwright/test output (`?? test-results/`): never work, so it neither blocks nor survives the teardown. */
|
|
804
|
+
function untrackedTestArtifacts(lines) {
|
|
805
|
+
return lines
|
|
806
|
+
.filter((l) => l.startsWith('?? '))
|
|
807
|
+
.map((l) => l.slice(3).replace(/^"|"$/g, '').replace(/\/$/, ''))
|
|
808
|
+
.filter((p) => isTestArtifact(p));
|
|
809
|
+
}
|
|
810
|
+
|
|
797
811
|
function gitStatusPorcelain(cwd) {
|
|
798
812
|
try {
|
|
799
813
|
return execFileSync('git', ['status', '--porcelain'], { cwd, stdio: 'pipe' }).toString();
|
|
@@ -113,13 +113,13 @@ function subsetFor({ node, group, root }) {
|
|
|
113
113
|
|
|
114
114
|
function e2eManualProtocol(feature, node) {
|
|
115
115
|
const f = feature ?? '<feature>';
|
|
116
|
-
const round = `eval "$(morph-spec env ${f})" && morph-spec e2e up ${f} && morph-spec heavy run -- ${node.command}`;
|
|
116
|
+
const round = `eval "$(morph-spec env ${f} --with-secrets)" && morph-spec e2e up ${f} && morph-spec heavy run -- ${node.command}`;
|
|
117
117
|
return 'mutate só recompila localmente, e a suíte que alcança esse código testa o container do compose: o mutante sairia sobrevivente em falso. ' +
|
|
118
118
|
`Prove à mão pelo nó "${node.id}", cada passo numa linha só e encadeado por \`&&\` (nunca \`;\`: um passo que falha não pode deixar a suíte rodar sobre o código original; ` +
|
|
119
119
|
'e o env da feature se perde entre chamadas de Bash): ' +
|
|
120
120
|
`(1) base verde antes do 1º mutante: \`${round}\`; ` +
|
|
121
121
|
`(2) por mutante: edite a linha && \`${round}\`; ` +
|
|
122
|
-
`(3) restaure, confirme com \`git diff --quiet -- <arquivo>\` e suba a stack de novo (\`eval "$(morph-spec env ${f})" && morph-spec e2e up ${f}\`). ` +
|
|
122
|
+
`(3) restaure, confirme com \`git diff --quiet -- <arquivo>\` e suba a stack de novo (\`eval "$(morph-spec env ${f} --with-secrets)" && morph-spec e2e up ${f}\`). ` +
|
|
123
123
|
'Registre cada mutante em 4-review (expect, resultado, teste que o matou).';
|
|
124
124
|
}
|
|
125
125
|
|
|
@@ -11,6 +11,7 @@ import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../l
|
|
|
11
11
|
import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
|
|
12
12
|
import { HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
13
13
|
import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
|
|
14
|
+
import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
|
|
14
15
|
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
15
16
|
import { runCiVerify, CiUsageError, reusableLocalStamp } from '../../lib/ci/ci-verify.js';
|
|
16
17
|
|
|
@@ -146,6 +147,8 @@ async function runLocalVerify(featureName, options) {
|
|
|
146
147
|
}, null, 2));
|
|
147
148
|
}
|
|
148
149
|
console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\` mostra quem segura os slots).\n`));
|
|
150
|
+
const hint = memoryBusyHint(err, process.cwd());
|
|
151
|
+
if (hint) console.error(chalk.yellow(`${hint}\n`));
|
|
149
152
|
process.exit(3);
|
|
150
153
|
}
|
|
151
154
|
throw err;
|
|
@@ -11,7 +11,9 @@ import { allocateEnvBlock, portLayout, resolveFleetConfig, resolvePortAliases }
|
|
|
11
11
|
// Ports come from `portLayout()`, the same function that renders `.morph/worktree.env`. The credentials
|
|
12
12
|
// file loads after the layout but cannot outrank it: a `.env.e2e` copied from a sibling worktree carries
|
|
13
13
|
// that worktree's block. Ignored keys are announced on stderr; stdout carries `morph-spec env` output.
|
|
14
|
-
|
|
14
|
+
// `withCredentials: false` keeps envFile values off stdout (`morph-spec env` lands in transcripts);
|
|
15
|
+
// the withheld names go to stderr.
|
|
16
|
+
export function buildComposeEnv({ cwd, config, featureName, gitCommonDir, withCredentials = true }) {
|
|
15
17
|
const env = {};
|
|
16
18
|
let layoutKeys = null;
|
|
17
19
|
|
|
@@ -38,16 +40,37 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
|
|
|
38
40
|
const envFile = config?.e2e?.auth?.envFile;
|
|
39
41
|
if (envFile) {
|
|
40
42
|
const ignored = [];
|
|
43
|
+
const withheld = [];
|
|
44
|
+
const foreignPorts = [];
|
|
45
|
+
const layoutPorts = layoutKeys ? new Set([...layoutKeys].map((key) => env[key])) : null;
|
|
41
46
|
for (const [key, value] of Object.entries(loadEnvFile(join(cwd, envFile)))) {
|
|
42
|
-
if (layoutKeys?.has(key))
|
|
43
|
-
|
|
47
|
+
if (layoutKeys?.has(key)) {
|
|
48
|
+
ignored.push(key);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (layoutPorts && /(^|_)PORT$/.test(key) && !layoutPorts.has(value)) foreignPorts.push(`${key}=${value}`);
|
|
52
|
+
if (withCredentials) env[key] = value;
|
|
53
|
+
else withheld.push(key);
|
|
44
54
|
}
|
|
45
55
|
if (ignored.length) reportIgnoredLayoutKeys(envFile, ignored, env);
|
|
56
|
+
if (foreignPorts.length) reportForeignPorts(envFile, foreignPorts);
|
|
57
|
+
if (withheld.length) {
|
|
58
|
+
console.warn(chalk.yellow(
|
|
59
|
+
` ⚠ ${envFile}: ${withheld.join(', ')} omitida(s) — credenciais não vão para o stdout; use --with-secrets dentro de eval "$(…)".`,
|
|
60
|
+
));
|
|
61
|
+
}
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
return env;
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
function reportForeignPorts(envFile, foreignPorts) {
|
|
68
|
+
console.warn(chalk.yellow(
|
|
69
|
+
` ⚠ ${envFile}: ${foreignPorts.join(', ')} não é porta do bloco desta worktree. `
|
|
70
|
+
+ 'Se nomeia uma das portas do layout, declare-a em e2e.portEnvNames (ex.: { "postgres": "PG_PORT" }) e o bloco passa a prevalecer.',
|
|
71
|
+
));
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
function reportIgnoredLayoutKeys(envFile, ignored, env) {
|
|
52
75
|
const shown = ignored.map((key) => `${key}=${env[key]}`).join(', ');
|
|
53
76
|
console.warn(chalk.yellow(
|
package/src/lib/heavy/gate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
1
|
+
import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
2
|
+
import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
|
|
2
3
|
import {
|
|
3
|
-
acquireHeavy, describeBusy, heavyDir, releaseHeavy, updateLease,
|
|
4
|
+
acquireHeavy, awaitFreeMemory, describeBusy, heavyDir, releaseHeavy, updateLease,
|
|
4
5
|
} from '../../../framework/hooks/shared/heavy-slots.js';
|
|
5
6
|
|
|
6
7
|
export class HeavyBusyError extends Error {
|
|
@@ -11,6 +12,8 @@ export class HeavyBusyError extends Error {
|
|
|
11
12
|
this.reason = result.reason;
|
|
12
13
|
this.holders = result.holders;
|
|
13
14
|
this.freeGb = result.freeGb;
|
|
15
|
+
this.minFreeMemGb = result.minFreeMemGb ?? null;
|
|
16
|
+
this.kind = result.kind ?? null;
|
|
14
17
|
this.waitedMs = result.waitedMs;
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -28,6 +31,7 @@ export function createHeavyGate({
|
|
|
28
31
|
} = {}) {
|
|
29
32
|
let entered = false;
|
|
30
33
|
let lease = null;
|
|
34
|
+
let admittedGb = 0;
|
|
31
35
|
let waitedMs = 0;
|
|
32
36
|
let exitHandler = null;
|
|
33
37
|
let saved = null;
|
|
@@ -50,26 +54,40 @@ export function createHeavyGate({
|
|
|
50
54
|
entered = false;
|
|
51
55
|
};
|
|
52
56
|
|
|
57
|
+
const raiseFloor = (kind) => {
|
|
58
|
+
const floor = minFreeMemFor(config, kind);
|
|
59
|
+
if (!lease || floor <= admittedGb) return;
|
|
60
|
+
const result = awaitFreeMemory({
|
|
61
|
+
minFreeMemGb: floor, waitTimeoutMs: config.waitTimeoutSec * 1000, kind, log, ...deps,
|
|
62
|
+
});
|
|
63
|
+
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
64
|
+
waitedMs += result.waitedMs;
|
|
65
|
+
admittedGb = floor;
|
|
66
|
+
};
|
|
67
|
+
|
|
53
68
|
return {
|
|
54
69
|
config,
|
|
55
70
|
get waitedMs() { return waitedMs; },
|
|
56
71
|
|
|
57
|
-
enter(node) {
|
|
72
|
+
enter(node, kind = heavyClass(node)) {
|
|
58
73
|
if (entered) {
|
|
74
|
+
raiseFloor(kind);
|
|
59
75
|
updateLease(lease, { node });
|
|
60
76
|
return;
|
|
61
77
|
}
|
|
62
78
|
for (const warning of config.warnings ?? []) log(`heavy: ${warning}`);
|
|
63
79
|
|
|
64
80
|
if (config.enabled) {
|
|
81
|
+
const minFreeMemGb = minFreeMemFor(config, kind);
|
|
65
82
|
const result = acquireHeavy({
|
|
66
83
|
dir: heavyDir(env),
|
|
67
84
|
slots: config.slots,
|
|
68
85
|
label,
|
|
69
86
|
feature,
|
|
70
87
|
node,
|
|
88
|
+
kind,
|
|
71
89
|
cwd: projectPath,
|
|
72
|
-
minFreeMemGb
|
|
90
|
+
minFreeMemGb,
|
|
73
91
|
waitTimeoutMs: config.waitTimeoutSec * 1000,
|
|
74
92
|
maxHoldMs: config.maxHoldMinutes * 60_000,
|
|
75
93
|
env,
|
|
@@ -78,6 +96,7 @@ export function createHeavyGate({
|
|
|
78
96
|
});
|
|
79
97
|
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
80
98
|
lease = result.lease;
|
|
99
|
+
admittedGb = minFreeMemGb;
|
|
81
100
|
waitedMs = result.waitedMs;
|
|
82
101
|
}
|
|
83
102
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { composeProjectName, defaultExec } from '../../../framework/hooks/shared/e2e-check.js';
|
|
4
|
+
import {
|
|
5
|
+
MORPH_PROJECT_PREFIX, parseDockerJson, resolveGitCommonDir, stackBelongsTo,
|
|
6
|
+
} from '../worktree/docker-audit.js';
|
|
7
|
+
|
|
8
|
+
// Runs right after a full queue wait: a hung daemon must not add another one.
|
|
9
|
+
const LS_TIMEOUT_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
// Stacks of every worktree live under the main checkout, and so does `.morph/archive`.
|
|
12
|
+
export function repoMainRoot(cwd) {
|
|
13
|
+
const common = resolveGitCommonDir(cwd);
|
|
14
|
+
return common && basename(common) === '.git' ? dirname(common) : cwd;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dirNames(dir) {
|
|
18
|
+
try {
|
|
19
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function liveE2eStacks(cwd, { exec = defaultExec, root = repoMainRoot(cwd) } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const ls = exec('docker', ['compose', 'ls', '--format', 'json'], { cwd: root, timeout: LS_TIMEOUT_MS });
|
|
28
|
+
if (ls.code !== 0) return [];
|
|
29
|
+
const projects = parseDockerJson(ls.output);
|
|
30
|
+
if (!projects) return [];
|
|
31
|
+
|
|
32
|
+
const active = dirNames(join(root, '.morph', 'features'));
|
|
33
|
+
const owners = new Map(active.map((f) => [composeProjectName(f), { feature: f, state: 'active' }]));
|
|
34
|
+
for (const f of dirNames(join(root, '.morph', 'archive'))) {
|
|
35
|
+
const project = composeProjectName(f);
|
|
36
|
+
if (!owners.has(project)) owners.set(project, { feature: f, state: 'archived' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return projects
|
|
40
|
+
.filter((p) => String(p?.Name ?? '').startsWith(MORPH_PROJECT_PREFIX) && stackBelongsTo(p, root))
|
|
41
|
+
.map((p) => {
|
|
42
|
+
const project = String(p.Name);
|
|
43
|
+
return { project, status: p.Status ?? null, ...(owners.get(project) ?? { feature: null, state: 'unknown' }) };
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stackLine(s) {
|
|
51
|
+
if (s.state === 'archived') return ` • ${s.project}: feature ${s.feature} já arquivada — \`morph-spec e2e down ${s.feature}\` devolve a RAM`;
|
|
52
|
+
if (s.state === 'unknown') return ` • ${s.project}: nenhuma feature deste repo responde por ela — \`docker compose -p ${s.project} down\``;
|
|
53
|
+
return ` • ${s.project}: feature ${s.feature} em andamento`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function describeLiveStacks(stacks) {
|
|
57
|
+
if (!stacks.length) return '';
|
|
58
|
+
return `stacks e2e de pé neste repo (seguram RAM):\n${stacks.map(stackLine).join('\n')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function memoryBusyHint(err, cwd, deps = {}) {
|
|
62
|
+
if (err?.reason !== 'memory') return '';
|
|
63
|
+
return describeLiveStacks(liveE2eStacks(cwd, deps));
|
|
64
|
+
}
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { MAX_SLICES } from './plan/checks.js';
|
|
2
2
|
import { BLOCKER_TYPES, reviewFileName } from './review-reports.js';
|
|
3
|
+
import { isTestPath } from './graph/sanitize.js';
|
|
4
|
+
import { isTestArtifact } from './worktree/ignored-triage.js';
|
|
3
5
|
|
|
4
6
|
const SECURITY_PATH_RE = /(^|[/._-])(auth|login|senha|password|token|secret|crypto|permiss|rbac|acl|lgpd|pii|privacy|payment|pagamento|billing|cobranca|invoice|asaas|stripe|migrations?)([/._-]|$)/i;
|
|
5
7
|
|
|
@@ -135,8 +137,14 @@ export function verifierModelFor({ promptModelTouched = false, generatesMedia =
|
|
|
135
137
|
return { model: reasons.length ? 'opus' : 'sonnet', reasons };
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
// Teste e artefato de teste não são superfície de ataque: `e2e/login.spec.ts` não pede a persona security.
|
|
141
|
+
const E2E_DIR_RE = /(^|\/)e2e\//;
|
|
142
|
+
const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
|
|
143
|
+
|
|
138
144
|
export function securityReviewFor(changedFiles = []) {
|
|
139
|
-
const risky = changedFiles
|
|
145
|
+
const risky = changedFiles
|
|
146
|
+
.map((f) => f.replaceAll('\\', '/'))
|
|
147
|
+
.filter((f) => SECURITY_PATH_RE.test(f) && !isTestOnly(f));
|
|
140
148
|
return { required: risky.length > 0, reasons: risky.length ? [`caminho sensível: ${firstOf(risky)}`] : [] };
|
|
141
149
|
}
|
|
142
150
|
|
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
import { allocateEnvBlock, allocatedFeatures, portLayout, resolveFleetConfig } from './env-allocator.js';
|
|
13
13
|
|
|
14
14
|
const PROJECT_PROBE = 'probe';
|
|
15
|
-
const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
|
|
15
|
+
export const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
|
|
16
16
|
|
|
17
|
-
function resolveGitCommonDir(cwd) {
|
|
17
|
+
export function resolveGitCommonDir(cwd) {
|
|
18
18
|
try {
|
|
19
19
|
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
20
20
|
cwd, stdio: 'pipe', encoding: 'utf-8', windowsHide: true,
|
|
@@ -57,6 +57,10 @@ function publishedPorts(entry) {
|
|
|
57
57
|
return ports;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
export function stackBelongsTo(entry, root) {
|
|
61
|
+
return belongsToRoot(entry, norm(root));
|
|
62
|
+
}
|
|
63
|
+
|
|
60
64
|
function composeProjectOf(entry) {
|
|
61
65
|
const labels = String(entry?.Labels ?? '');
|
|
62
66
|
for (const part of labels.split(',')) {
|
|
@@ -73,7 +77,7 @@ function containerNameOf(entry) {
|
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
// `null` = unreadable, distinct from `[]` = docker answered with nothing running.
|
|
76
|
-
function parseDockerJson(raw) {
|
|
80
|
+
export function parseDockerJson(raw) {
|
|
77
81
|
const text = String(raw ?? '').trim();
|
|
78
82
|
if (!text) return [];
|
|
79
83
|
const parsed = parseComposePs(text);
|
|
@@ -9,6 +9,22 @@ const DEFAULT_BASE_PORT = 4000;
|
|
|
9
9
|
const DEFAULT_BLOCK_SIZE = 10;
|
|
10
10
|
const MAX_PORT = 65535;
|
|
11
11
|
|
|
12
|
+
// Chromium's kRestrictedPorts (net/base/port_util.cc): the browser refuses them with
|
|
13
|
+
// ERR_UNSAFE_PORT, so a block holding one is a stack Playwright and the verifier cannot open.
|
|
14
|
+
const BROWSER_UNSAFE_PORTS = new Set([
|
|
15
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103,
|
|
16
|
+
104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513,
|
|
17
|
+
514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720,
|
|
18
|
+
1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function blockHasUnsafePort(base, blockSize) {
|
|
22
|
+
for (let port = base; port < base + blockSize; port += 1) {
|
|
23
|
+
if (BROWSER_UNSAFE_PORTS.has(port)) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
12
28
|
export function registryPath(gitCommonDir) {
|
|
13
29
|
return join(gitCommonDir, 'morph-spec', 'env-registry.json');
|
|
14
30
|
}
|
|
@@ -47,23 +63,29 @@ export function resolveFleetConfig(rootPath) {
|
|
|
47
63
|
}
|
|
48
64
|
}
|
|
49
65
|
|
|
50
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* First free index wins, so released slots are reused instead of creeping toward the port ceiling.
|
|
68
|
+
* A block with a browser-unsafe port is never handed out, and one recorded before this rule is moved.
|
|
69
|
+
*/
|
|
51
70
|
export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BASE_PORT, blockSize = DEFAULT_BLOCK_SIZE }) {
|
|
52
71
|
const registry = readRegistry(gitCommonDir);
|
|
53
72
|
|
|
54
73
|
const existing = registry.allocations[feature];
|
|
55
74
|
if (existing && Number.isInteger(existing.index)) {
|
|
56
75
|
const base = basePort + existing.index * blockSize;
|
|
57
|
-
|
|
76
|
+
if (!blockHasUnsafePort(base, blockSize)) {
|
|
77
|
+
return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
|
|
78
|
+
}
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
const used = new Set(
|
|
61
|
-
Object.
|
|
62
|
-
.
|
|
82
|
+
Object.entries(registry.allocations)
|
|
83
|
+
.filter(([name]) => name !== feature)
|
|
84
|
+
.map(([, a]) => a?.index)
|
|
63
85
|
.filter((i) => Number.isInteger(i)),
|
|
64
86
|
);
|
|
65
87
|
let index = 0;
|
|
66
|
-
while (used.has(index)) index += 1;
|
|
88
|
+
while (used.has(index) || blockHasUnsafePort(basePort + index * blockSize, blockSize)) index += 1;
|
|
67
89
|
|
|
68
90
|
const base = basePort + index * blockSize;
|
|
69
91
|
if (base + blockSize - 1 > MAX_PORT) {
|
|
@@ -18,6 +18,14 @@ const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
|
|
|
18
18
|
const REPLICABLE_MAX_FILES = 500;
|
|
19
19
|
const REPLICABLE_MAX_BYTES = 8 * 1024 * 1024;
|
|
20
20
|
|
|
21
|
+
// Narrower than the build set on purpose: an untracked `bin/` may be new code, a `test-results/` never is.
|
|
22
|
+
const TEST_ARTIFACT_DIR_NAMES = new Set(['test-results', 'TestResults', 'playwright-report', 'blob-report']);
|
|
23
|
+
|
|
24
|
+
/** @param {string} p - forward-slash relative path */
|
|
25
|
+
export function isTestArtifact(p) {
|
|
26
|
+
return p.split('/').filter(Boolean).some((seg) => TEST_ARTIFACT_DIR_NAMES.has(seg));
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
/** @param {string} p - forward-slash relative path */
|
|
22
30
|
export function isDisposableBuildArtifact(p) {
|
|
23
31
|
if (DISPOSABLE_FILE_RE.test(p)) return true;
|