@polymorphism-tech/morph-spec 9.6.0 → 9.8.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 +1 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +13 -6
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +3 -3
- package/framework/docs/CLI.md +53 -16
- package/framework/evals/review-checklist.md +5 -5
- package/framework/hooks/claude-code/core/README.md +4 -0
- package/framework/hooks/claude-code/core/test-budget.js +89 -0
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/heavy-command.js +60 -1
- package/framework/hooks/shared/heavy-slots.js +41 -4
- package/framework/hooks/shared/host-services.js +433 -0
- package/framework/schemas/config.schema.json +54 -2
- 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 +2 -2
- 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 +3 -3
- package/framework/skills/core/morph-review/SKILL.md +3 -2
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +2 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +14 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +47 -24
- package/src/commands/observability/cost.js +12 -0
- package/src/commands/project/worktree.js +31 -5
- package/src/commands/state/advance.js +3 -0
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/delete.js +1 -1
- package/src/commands/state/finish.js +46 -2
- package/src/commands/state/report-check.js +36 -1
- package/src/commands/validation/plan-check.js +1 -0
- package/src/commands/validation/verify.js +3 -0
- package/src/lib/e2e/stack-teardown.js +19 -4
- package/src/lib/heavy/gate.js +22 -1
- package/src/lib/review-reports.js +23 -10
- package/src/lib/tasks/test-nodes.js +24 -6
- package/src/lib/tasks/test-runner.js +43 -1
- package/src/lib/telemetry/metrics.js +47 -3
- package/src/lib/validators/packages/package-validator.js +2 -2
- package/src/lib/validators/verify-runner.js +50 -12
- package/src/utils/agents-installer.js +5 -2
- package/src/utils/file-copier.js +26 -4
- package/src/utils/hooks-installer.js +4 -4
|
@@ -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.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -105,8 +105,8 @@ O `create` já escreveu o esqueleto em `2-plan/plan.md` (leia e preencha; `chore
|
|
|
105
105
|
- Cenário que se observa por um artefato de outra fatia (rota, endpoint, componente que ela cria) faz da fatia que o cobre um `depende:` daquela — senão as duas caem na mesma onda e o teste do cenário não tem por onde provar. O `plan-check` não vê de quem é a rota: a regra é sua.
|
|
106
106
|
|
|
107
107
|
**Contrato de implementação**: o que quem implementa precisa e o código não diz.
|
|
108
|
-
- **Verificação de runtime:** o stack sobe por `morph-spec e2e up {feature}` (compose do projeto; sem compose, isso é uma fatia); os fluxos que o verificador navega, nos verbos dos cenários; base URL `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``, compose com `"${PORT:-3000}:3000"` e sem `container_name` (standard `infrastructure/docker/local-compose-isolation`). App com login: `e2e.auth` em `config.json` (`mode: seed|storageState|bypass|none`, `seedCommand`, `storageStatePath`, `envFile`), credenciais num `.env.e2e` gitignored — **`mode` é obrigatório sempre que houver `seedCommand`**, sem ele o seed fica inerte. Biblioteca pura: registre a dispensa e o porquê.
|
|
109
|
-
- **Baseline é um COMANDO, nunca um número.** O plano congela e é lido por dias: um número cravado está errado na primeira fatia, e quem confia nele "conserta" o que não quebrou. Registre como medir (`npm test`, `dotnet test`); o `expectedCount` sai de `morph-spec verify {feature} --record-baseline`, nunca digitado. Sem `project.stack`/`project.tests` no `config.json`, a `morph-apply` os declara a partir destes comandos antes do primeiro verify.
|
|
108
|
+
- **Verificação de runtime:** o stack sobe por `morph-spec e2e up {feature}` (compose do projeto; sem compose, isso é uma fatia); os fluxos que o verificador navega, nos verbos dos cenários; base URL `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``, compose com `"${PORT:-3000}:3000"` e sem `container_name` (standard `infrastructure/docker/local-compose-isolation`). App cujo build é caro dentro do Docker sobe como processo do host por `e2e.hostServices` (compose só com as dependências; campos em `morph-spec e2e --help`). App com login: `e2e.auth` em `config.json` (`mode: seed|storageState|bypass|none`, `seedCommand`, `storageStatePath`, `envFile`), credenciais num `.env.e2e` gitignored — **`mode` é obrigatório sempre que houver `seedCommand`**, sem ele o seed fica inerte. Biblioteca pura: registre a dispensa e o porquê.
|
|
109
|
+
- **Baseline é um COMANDO, nunca um número.** O plano congela e é lido por dias: um número cravado está errado na primeira fatia, e quem confia nele "conserta" o que não quebrou. Registre como medir (`npm test`, `dotnet test`); o `expectedCount` (só `node-test`/`evals`; nos outros runners vale o piso zero) sai de `morph-spec verify {feature} --record-baseline`, nunca digitado. Sem `project.stack`/`project.tests` no `config.json`, a `morph-apply` os declara a partir destes comandos antes do primeiro verify.
|
|
110
110
|
- **Pré-requisito é um PREDICADO, nunca um SHA literal:** "`git merge-base --is-ancestor origin/main HEAD` sai 0" — o SHA muda no primeiro squash.
|
|
111
111
|
|
|
112
112
|
Plano bom cabe em duas páginas; crescendo além, corte escopo. Feche com `morph-spec plan-check {feature}` até zero erro. Ele também recusa veredito de reuso fora do enum, id `C`/`F`/`ADR` duplicado, `## Errata` fora do fim, `## Cenários` vazio e fatia sem `persona:`; avisa chave de fatia desconhecida, superfície fora da `ui-designer`, ADR sem **Alcance**, fatia sem `cobre:` e mais de 12 fatias.
|
|
@@ -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.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -51,7 +51,8 @@ símbolo público do diff, `morph-spec graph affected <símbolo> --prod --json`;
|
|
|
51
51
|
(`no-graph`/`not-found`/`ambiguous`), siga sem o blast radius — o revisor cai para `git grep` — e
|
|
52
52
|
registre a ausência no recado a ele. Cada agente nasce com contexto fresco e **nunca recebe relatório anterior** (ancoragem).
|
|
53
53
|
`chore` tem review leve: só o revisor (∥ segurança, quando exigida) — sem verificador, sem cobertura de
|
|
54
|
-
`Cn`, sem mutação. Despache todos na mesma resposta
|
|
54
|
+
`Cn`, sem mutação. Despache todos na mesma resposta, **sem nome no dispatch**: com nome, o `agent_type` que o hook
|
|
55
|
+
`test-budget` lê vira o nome, e o guard de review não reconhece o papel.
|
|
55
56
|
```
|
|
56
57
|
dispatch Agent (subagent_type: "morph-verifier", model: {reviewModels.verifier.model}) with prompt:
|
|
57
58
|
feature, base, sha, JSON do verify (nós, e2e, URL) se já terminou, 1-design/mockups.md e
|
|
@@ -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.8.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.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-uiux
|
|
@@ -1629,9 +1629,10 @@
|
|
|
1629
1629
|
"portNames": "#project-named-port-variables",
|
|
1630
1630
|
"rules": "#core-rules",
|
|
1631
1631
|
"enforcement": "#what-the-harness-enforces",
|
|
1632
|
+
"hostServices": "#services-on-the-host",
|
|
1632
1633
|
"skeleton": "#minimal-compose-skeleton"
|
|
1633
1634
|
},
|
|
1634
|
-
"digest": "Every worktree runs its OWN compose stack (project morph-{feature}, own postgres, own volumes). Ports come from the worktree block: PORT +0, API_PORT +1 (ASPNETCORE_URLS on .NET), POSTGRES_PORT +2, REDIS_PORT +3 — publish via ${VAR:-default}:container, never a fixed host port, never container_name (not prefixed by the project → collides). Stable POSTGRES_DB, healthcheck on every service, DATABASE_URL with an in-network default, playwright.config reads PLAYWRIGHT_BASE_URL. A non-isolable compose fails the verify e2e node inside a worktree. finish
|
|
1635
|
+
"digest": "Every worktree runs its OWN compose stack (project morph-{feature}, own postgres, own volumes). Ports come from the worktree block: PORT +0, API_PORT +1 (ASPNETCORE_URLS on .NET), POSTGRES_PORT +2, REDIS_PORT +3 — publish via ${VAR:-default}:container, never a fixed host port, never container_name (not prefixed by the project → collides). Stable POSTGRES_DB, healthcheck on every service, DATABASE_URL with an in-network default, playwright.config reads PLAYWRIGHT_BASE_URL. A non-isolable compose fails the verify e2e node inside a worktree. finish/delete tear down with --rmi local -v (--keep-volumes drops -v on finish); worktree remove of a morph-task/* branch with -v, of morph/{f} without. An app whose build is expensive in the Docker VM runs as a host process via e2e.hostServices (compose keeps only the dependencies; ports from the block). Cloud DB credentials go in e2e.auth.envFile, never in .morph/worktree.env. A compose that reads a port by ANOTHER name (PG_PORT) is not fed by the block: declare it in config.json e2e.portEnvNames ({\"postgres\": \"PG_PORT\"}) and the harness exports it with the same value in worktree.env, the compose env and the hook."
|
|
1635
1636
|
},
|
|
1636
1637
|
{
|
|
1637
1638
|
"id": "infrastructure-neon-pgrag",
|
|
@@ -51,10 +51,23 @@ Keys are `web`, `api`, `postgres` and `redis`. Each declared name carries the **
|
|
|
51
51
|
## What the harness enforces
|
|
52
52
|
|
|
53
53
|
- Inside a secondary worktree, a compose with a fixed host port or a `container_name` **fails the `verify` e2e node** (`compose-not-isolable`) — Gate 3 cannot pass on a stack that cannot run beside its siblings. At the primary root the same finding is a warning.
|
|
54
|
-
- `morph-spec finish`
|
|
54
|
+
- `morph-spec finish` and `morph-spec delete` tear the stack down with `down --remove-orphans --rmi local -v` (`--keep-volumes` on `finish` drops the `-v`) and delete the freshness stamp. `--rmi local` removes only the images compose built, never a pulled one (`postgres`) that the next feature would download again; `docker builder prune` never runs, because the build cache is global to the machine. `morph-spec worktree remove` (and `worktree reap`, which goes through it) tears down a `morph/{f}` branch without volumes (the feature is still open) and a `morph-task/*` branch with `-v` (the task is disposable). Every one of these also stops the `e2e.hostServices` processes.
|
|
55
55
|
- Two worktrees never share `POSTGRES_PORT`: the block is allocated first-free-index per feature and released only when the worktree is removed.
|
|
56
56
|
- The credentials env file is read **after** the block but cannot outrank it. All four surfaces (`e2e up`, the `verify` e2e node, the teardown, `morph-spec env`) build their environment through the same `buildComposeEnv`, which drops the layout keys coming from that file and names them on **stderr** — stdout stays eval-safe for `eval "$(morph-spec env)"`. With no block allocated (outside a worktree) nothing is filtered: there the file is the only port source.
|
|
57
57
|
|
|
58
|
+
## Services on the host
|
|
59
|
+
|
|
60
|
+
When building an app inside the Docker VM is expensive (a `dotnet publish` or `next build` repeating the build the `verify` already did on the host), run it as a host process through `config.json → e2e.hostServices` and keep only the dependencies (postgres, redis) in compose. The app listens on the ports of the worktree block — `${API_PORT}` / `${PORT}` in its `env` and `health` — so two worktrees still never collide.
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
"e2e": { "hostServices": [
|
|
64
|
+
{ "name": "web", "build": "npm run build", "start": "node .next/standalone/server.js",
|
|
65
|
+
"env": { "PORT": "${PORT}" }, "health": "http://localhost:${PORT}/" }
|
|
66
|
+
] }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`e2e up` and the `verify` e2e node run `compose up --wait`, then each host service in array order (build → start → health), then the auth seed. They stay up until `e2e down`, `finish`, `delete` or `worktree remove`. Fields, reuse and failure rules: `morph-spec e2e --help` and the `e2e` section of `.morph/framework/CLI.md`.
|
|
70
|
+
|
|
58
71
|
## Minimal compose skeleton
|
|
59
72
|
|
|
60
73
|
```yaml
|
package/package.json
CHANGED
package/src/commands/e2e/e2e.js
CHANGED
|
@@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
8
8
|
import { join, resolve } from 'path';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
bringUpE2e,
|
|
12
12
|
composeProjectName,
|
|
13
13
|
computeSourceStamp,
|
|
14
14
|
decideIsolation,
|
|
@@ -17,13 +17,13 @@ import {
|
|
|
17
17
|
getStackStatus,
|
|
18
18
|
isDockerAvailable,
|
|
19
19
|
lintComposeIsolation,
|
|
20
|
-
prepareAuth,
|
|
21
20
|
readStamp,
|
|
22
21
|
resolveComposeFile,
|
|
23
22
|
resolveSpecCommand,
|
|
24
23
|
tearDownStack,
|
|
25
24
|
} from '../../../framework/hooks/shared/e2e-check.js';
|
|
26
25
|
import { isSecondaryWorktree } from '../../../framework/hooks/shared/git-paths.js';
|
|
26
|
+
import { hostServicesStatus, stopHostServices } from '../../../framework/hooks/shared/host-services.js';
|
|
27
27
|
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';
|
|
@@ -105,7 +105,8 @@ export async function e2eUpCommand(featureName, options = {}) {
|
|
|
105
105
|
console.log(chalk.yellow(`⚠️ ${isolation.reason}`));
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
const exec = options._exec ?? defaultExec;
|
|
109
|
+
if (!isDockerAvailable(ctx.cwd, exec)) {
|
|
109
110
|
console.error(chalk.red('\n❌ Docker is not available in this environment.\n'));
|
|
110
111
|
process.exit(EXIT_USAGE);
|
|
111
112
|
}
|
|
@@ -114,13 +115,14 @@ export async function e2eUpCommand(featureName, options = {}) {
|
|
|
114
115
|
let result;
|
|
115
116
|
try {
|
|
116
117
|
gate.enter('e2e');
|
|
117
|
-
result =
|
|
118
|
+
result = bringUpE2e(ctx.cwd, {
|
|
119
|
+
config: ctx.config,
|
|
118
120
|
composeFile: ctx.composeFile,
|
|
119
121
|
projectName: ctx.projectName,
|
|
120
122
|
gitCommonDir: ctx.gitCommonDir,
|
|
121
123
|
env: ctx.env,
|
|
122
124
|
forceFresh: Boolean(options.fresh),
|
|
123
|
-
});
|
|
125
|
+
}, exec);
|
|
124
126
|
} catch (err) {
|
|
125
127
|
if (!(err instanceof HeavyBusyError)) throw err;
|
|
126
128
|
if (options.json) {
|
|
@@ -134,40 +136,42 @@ export async function e2eUpCommand(featureName, options = {}) {
|
|
|
134
136
|
gate.release();
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
const auth =
|
|
138
|
-
result.status === 'pass'
|
|
139
|
-
? prepareAuth(ctx.cwd, { config: ctx.config, env: ctx.env, forceSeed: Boolean(options.fresh) })
|
|
140
|
-
: { status: 'skip', reason: null };
|
|
141
|
-
|
|
139
|
+
const { auth, hostServices } = result;
|
|
142
140
|
// A seedCommand that `mode` switches off is reported, never swallowed.
|
|
143
141
|
const warnings = [];
|
|
144
142
|
if (isolation.status === 'warn') warnings.push(isolation.reason);
|
|
145
|
-
|
|
143
|
+
warnings.push(...result.warnings);
|
|
146
144
|
|
|
147
|
-
const failed = result.status === 'fail'
|
|
145
|
+
const failed = result.status === 'fail';
|
|
148
146
|
const payload = {
|
|
149
147
|
feature: featureName,
|
|
150
148
|
project: ctx.projectName,
|
|
151
149
|
tree: ctx.tree,
|
|
152
150
|
composeFile: ctx.composeFile,
|
|
153
151
|
status: failed ? 'fail' : 'pass',
|
|
154
|
-
reused: result.reused,
|
|
152
|
+
reused: result.stack.reused,
|
|
155
153
|
reason: result.reason,
|
|
156
154
|
auth: { status: auth.status, reason: auth.reason },
|
|
155
|
+
...(hostServices.status === 'skip' ? {} : { hostServices: hostServices.services }),
|
|
157
156
|
...(warnings.length ? { warnings } : {}),
|
|
157
|
+
...(failed && result.output ? { output: result.output } : {}),
|
|
158
158
|
};
|
|
159
159
|
|
|
160
160
|
if (options.json) {
|
|
161
161
|
console.log(JSON.stringify(payload, null, 2));
|
|
162
162
|
} else if (failed) {
|
|
163
|
-
console.error(chalk.red(`\n✗ ${result.reason
|
|
163
|
+
console.error(chalk.red(`\n✗ ${result.reason}\n`));
|
|
164
164
|
if (result.output) console.error(result.output);
|
|
165
|
+
for (const w of result.warnings) console.error(chalk.yellow(`⚠️ ${w}`));
|
|
165
166
|
} else {
|
|
166
|
-
const how = result.reused ? chalk.gray(`(${result.reason})`) : chalk.gray('(built)');
|
|
167
|
+
const how = result.stack.reused ? chalk.gray(`(${result.stack.reason})`) : chalk.gray('(built)');
|
|
167
168
|
console.log(chalk.green(`\n✓ Stack up — ${ctx.projectName} ${how}`));
|
|
168
169
|
console.log(chalk.gray(` tree: ${ctx.tree}`));
|
|
170
|
+
for (const s of hostServices.services) {
|
|
171
|
+
console.log(chalk.gray(` host: ${s.name} pid ${s.pid} (${s.reused ? 'reused' : 'started'}) — log ${s.log}`));
|
|
172
|
+
}
|
|
169
173
|
if (auth.reason) console.log(chalk.gray(` auth: ${auth.reason}`));
|
|
170
|
-
|
|
174
|
+
for (const w of result.warnings) console.log(chalk.yellow(`⚠️ ${w}`));
|
|
171
175
|
console.log('');
|
|
172
176
|
}
|
|
173
177
|
|
|
@@ -185,17 +189,19 @@ export async function e2eStatusCommand(featureName, options = {}) {
|
|
|
185
189
|
const ctx = resolveContext(featureName, options);
|
|
186
190
|
if (!ctx) process.exit(EXIT_USAGE);
|
|
187
191
|
|
|
192
|
+
const exec = options._exec ?? defaultExec;
|
|
188
193
|
const status = getStackStatus(ctx.cwd, {
|
|
189
194
|
composeFile: ctx.composeFile,
|
|
190
195
|
projectName: ctx.projectName,
|
|
191
196
|
env: ctx.env,
|
|
192
|
-
});
|
|
197
|
+
}, exec);
|
|
193
198
|
|
|
194
|
-
const stamp = computeSourceStamp(ctx.cwd);
|
|
199
|
+
const stamp = computeSourceStamp(ctx.cwd, exec);
|
|
195
200
|
const previous = ctx.gitCommonDir ? readStamp(ctx.gitCommonDir, ctx.projectName) : null;
|
|
196
201
|
const fresh = Boolean(stamp && previous?.stamp === stamp);
|
|
197
202
|
|
|
198
203
|
const spec = resolveSpecCommand(ctx.cwd, ctx.config, ctx.config?.project?.frontendPath);
|
|
204
|
+
const hostServices = ctx.gitCommonDir ? hostServicesStatus(ctx.gitCommonDir, ctx.projectName) : [];
|
|
199
205
|
|
|
200
206
|
const payload = {
|
|
201
207
|
feature: featureName,
|
|
@@ -213,6 +219,7 @@ export async function e2eStatusCommand(featureName, options = {}) {
|
|
|
213
219
|
state: s.State ?? null,
|
|
214
220
|
health: s.Health || null,
|
|
215
221
|
})),
|
|
222
|
+
...(hostServices.length ? { hostServices } : {}),
|
|
216
223
|
};
|
|
217
224
|
|
|
218
225
|
if (options.json) {
|
|
@@ -233,6 +240,10 @@ export async function e2eStatusCommand(featureName, options = {}) {
|
|
|
233
240
|
for (const s of payload.services) {
|
|
234
241
|
console.log(chalk.gray(` · ${s.name}: ${s.state}${s.health ? ` / ${s.health}` : ''}`));
|
|
235
242
|
}
|
|
243
|
+
for (const h of hostServices) {
|
|
244
|
+
const health = h.health === null ? 'no health check' : h.health ? 'healthy' : 'unhealthy';
|
|
245
|
+
console.log(chalk.gray(` · host ${h.name}: pid ${h.pid} ${h.alive ? 'alive' : 'dead'} / ${health}`));
|
|
246
|
+
}
|
|
236
247
|
console.log('');
|
|
237
248
|
}
|
|
238
249
|
|
|
@@ -245,30 +256,42 @@ export async function e2eDownCommand(featureName, options = {}) {
|
|
|
245
256
|
const ctx = resolveContext(featureName, options);
|
|
246
257
|
if (!ctx) process.exit(EXIT_USAGE);
|
|
247
258
|
|
|
259
|
+
const hostServices = stopHostServices(ctx.gitCommonDir, ctx.projectName);
|
|
248
260
|
const result = tearDownStack(ctx.cwd, {
|
|
249
261
|
composeFile: ctx.composeFile,
|
|
250
262
|
projectName: ctx.projectName,
|
|
251
263
|
env: ctx.env,
|
|
252
264
|
volumes: Boolean(options.volumes),
|
|
253
|
-
});
|
|
265
|
+
}, options._exec ?? defaultExec);
|
|
254
266
|
const stampDeleted = deleteStamp(ctx.gitCommonDir, ctx.projectName);
|
|
255
267
|
const cleanup = afterHeavyRun({ projectPath: ctx.cwd, config: readHeavyConfig(ctx.cwd) });
|
|
256
268
|
|
|
257
269
|
if (options.json) {
|
|
258
270
|
console.log(JSON.stringify(
|
|
259
|
-
{
|
|
271
|
+
{
|
|
272
|
+
feature: featureName, project: ctx.projectName, tree: ctx.tree, status: result.status, stampDeleted, cleanup,
|
|
273
|
+
...(stoppedAnything(hostServices) ? { hostServices } : {}),
|
|
274
|
+
},
|
|
260
275
|
null, 2,
|
|
261
276
|
));
|
|
262
|
-
} else if (result.status === 'pass') {
|
|
263
|
-
console.log(chalk.green(`\n✓ Stack down — ${ctx.projectName}`));
|
|
264
|
-
console.log(chalk.gray(` tree: ${ctx.tree}\n`));
|
|
265
277
|
} else {
|
|
266
|
-
|
|
278
|
+
if (result.status === 'pass') {
|
|
279
|
+
console.log(chalk.green(`\n✓ Stack down — ${ctx.projectName}`));
|
|
280
|
+
console.log(chalk.gray(` tree: ${ctx.tree}\n`));
|
|
281
|
+
} else {
|
|
282
|
+
console.log(chalk.yellow(`\n⚠️ Teardown reported a problem (not fatal):\n${result.output}\n`));
|
|
283
|
+
}
|
|
284
|
+
if (hostServices.killed.length) console.log(chalk.gray(` host services stopped: ${hostServices.killed.join(', ')}`));
|
|
285
|
+
for (const w of hostServices.warnings) console.log(chalk.yellow(`⚠️ ${w}`));
|
|
267
286
|
}
|
|
268
287
|
|
|
269
288
|
process.exit(0);
|
|
270
289
|
}
|
|
271
290
|
|
|
291
|
+
function stoppedAnything({ killed, skipped, warnings }) {
|
|
292
|
+
return killed.length + skipped.length + warnings.length > 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
272
295
|
function readConfig(cwd) {
|
|
273
296
|
try {
|
|
274
297
|
const path = join(cwd, '.morph', 'config', 'config.json');
|
|
@@ -108,6 +108,18 @@ function printMetricsBody(m) {
|
|
|
108
108
|
console.log(` ${klass.padEnd(12)} ${fmtDuration(t.measuredMs / 1000).padStart(8)} ${t.runs} execução(ões)${unmeasured}`);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const tov = m.testOutsideVerify;
|
|
112
|
+
const timed = (t) => `${String(t.runs).padStart(4)}× ${fmtDuration(t.measuredMs / 1000).padStart(8)}`
|
|
113
|
+
+ (t.unmeasured ? chalk.gray(` (${t.unmeasured} sem duração)`) : '');
|
|
114
|
+
console.log('\n' + chalk.white.bold('Teste fora do verify (sub-agentes)')
|
|
115
|
+
+ chalk.gray(` · de ${tov.total.runs} testes+verifies, ${fmtDuration(tov.total.measuredMs / 1000)} cronometrados`
|
|
116
|
+
+ (tov.total.unmeasured ? `, ${tov.total.unmeasured} sem duração` : '')));
|
|
117
|
+
console.log(` sub-agente rodando verify cheio ${timed(tov.subagentVerify)}`);
|
|
118
|
+
console.log(` implementador, 3ª rodada em diante ${timed(tov.implementerRound3Plus)}`);
|
|
119
|
+
console.log(` agente de review rodando teste ${timed(tov.reviewTests)}`);
|
|
120
|
+
console.log(chalk.gray(` ${tov.reviewTests.byPersona.runs}× pela persona morph-* (o que o hook test-budget reconhece);`
|
|
121
|
+
+ ' o resto veio de um nome de dispatch (ver-, rev-, gate3-…)'));
|
|
122
|
+
|
|
111
123
|
console.log('\n' + chalk.white.bold('Rerun do mesmo comando em ≤10 min (mesma feature; o filtro de saída não conta)'));
|
|
112
124
|
console.log(` total: ${pct(m.reruns.rate)} repetidas (${m.reruns.reruns}/${m.reruns.runs}) · ${pct(m.reruns.burstRate)} em rajada — testes, builds e verifies`);
|
|
113
125
|
for (const [kind, r] of Object.entries(m.rerunsByKind).sort((a, b) => b[1].runs - a[1].runs)) {
|
|
@@ -85,8 +85,27 @@ function getDateSuffix() {
|
|
|
85
85
|
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
function trackedPackageNodeModules(mainCwd) {
|
|
89
|
+
let out;
|
|
90
|
+
try {
|
|
91
|
+
out = execFileSync('git', ['ls-files', '-z', '--', '*package.json'], { cwd: mainCwd, stdio: 'pipe' }).toString();
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const dirs = [];
|
|
96
|
+
for (const file of out.split('\0')) {
|
|
97
|
+
const parts = file.split('/');
|
|
98
|
+
if (parts.length < 2 || parts.at(-1) !== 'package.json') continue;
|
|
99
|
+
const dirParts = parts.slice(0, -1);
|
|
100
|
+
if (dirParts.includes('node_modules') || WORKTREE_DIR_NAMES.includes(dirParts[0])) continue;
|
|
101
|
+
const rel = join(...dirParts, 'node_modules');
|
|
102
|
+
if (existsSync(join(mainCwd, rel))) dirs.push(rel);
|
|
103
|
+
}
|
|
104
|
+
return dirs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The ONLY links `finish` may auto-remove (never widened to any reparse point found), plus the
|
|
108
|
+
// `node_modules` of each tracked nested package: a monorepo package has its own install.
|
|
90
109
|
function resolveInfraLinkDirs(mainCwd) {
|
|
91
110
|
const dirs = ['node_modules', '.claude'];
|
|
92
111
|
if (existsSync(join(mainCwd, '.morph', 'framework'))) {
|
|
@@ -100,6 +119,9 @@ function resolveInfraLinkDirs(mainCwd) {
|
|
|
100
119
|
if (frontendPath) dirs.push(join(frontendPath, 'node_modules'));
|
|
101
120
|
}
|
|
102
121
|
} catch {}
|
|
122
|
+
for (const rel of trackedPackageNodeModules(mainCwd)) {
|
|
123
|
+
if (!dirs.includes(rel)) dirs.push(rel);
|
|
124
|
+
}
|
|
103
125
|
return dirs;
|
|
104
126
|
}
|
|
105
127
|
|
|
@@ -1013,11 +1035,13 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1013
1035
|
// Read BEFORE the teardown: the entry is gone from `git worktree list` afterwards.
|
|
1014
1036
|
const envKey = envKeyForWorktree(worktreePath, root);
|
|
1015
1037
|
|
|
1016
|
-
// Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows.
|
|
1038
|
+
// Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows.
|
|
1039
|
+
// A `morph/{f}` volume survives for the finish that reclaims it; a `morph-task/*` one has no later owner.
|
|
1040
|
+
const volumes = /^morph-task\//.test(branch ?? '');
|
|
1017
1041
|
let e2e = null;
|
|
1018
1042
|
try {
|
|
1019
1043
|
const { tearDownFeatureStack } = await import('../../lib/e2e/stack-teardown.js');
|
|
1020
|
-
e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes:
|
|
1044
|
+
e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes, _exec: options._exec });
|
|
1021
1045
|
} catch { e2e = null; }
|
|
1022
1046
|
|
|
1023
1047
|
// The stamp this removal's backup is written under — protected from the
|
|
@@ -1044,7 +1068,9 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1044
1068
|
...(feature ? { feature } : {}),
|
|
1045
1069
|
};
|
|
1046
1070
|
if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
|
|
1047
|
-
if (e2e?.attempted
|
|
1071
|
+
if (e2e?.attempted || e2e?.hostServices) {
|
|
1072
|
+
out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes, ...(e2e.hostServices ? { hostServices: e2e.hostServices } : {}) };
|
|
1073
|
+
}
|
|
1048
1074
|
if (teardown.worktreeRemoved) {
|
|
1049
1075
|
if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
1050
1076
|
if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
|
|
@@ -76,6 +76,9 @@ function resolveApprovedBy(options) {
|
|
|
76
76
|
|
|
77
77
|
export function reportDotnetIgnores(added) {
|
|
78
78
|
if (added.length) console.log(chalk.gray(` .gitignore: ${added.join(' ')} adicionados (projeto .NET) — commite antes do primeiro build.`));
|
|
79
|
+
for (const { entry, file } of added.skipped || []) {
|
|
80
|
+
console.log(chalk.yellow(` .gitignore: ${entry} não adicionado — esconderia fonte versionada (${file}); ignore a saída de build pelo caminho do projeto.`));
|
|
81
|
+
}
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
function reportDesignSeed({ mockups, designSystem }) {
|
|
@@ -9,6 +9,7 @@ import { runWorktreeSetup, materializeState, isGitRepo, branchExists, buildBranc
|
|
|
9
9
|
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
10
10
|
import { writePlanSkeleton } from '../../lib/plan/plan-template.js';
|
|
11
11
|
import { seedDotnetBuildIgnores } from '../../lib/stack/dotnet-build-ignores.js';
|
|
12
|
+
import { reportDotnetIgnores } from './advance.js';
|
|
12
13
|
|
|
13
14
|
// Branches from the DEFAULT branch, never HEAD, so it never inherits another feature's commits.
|
|
14
15
|
// `checkout: false` still CREATES the branch, which `finish` depends on. Fail-open: any git error
|
|
@@ -221,6 +222,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
221
222
|
if (branchResult) out.branch = branchResult;
|
|
222
223
|
if (planSkeleton) out.planSkeleton = planSkeleton;
|
|
223
224
|
if (gitignoreAdded.length) out.gitignoreAdded = gitignoreAdded;
|
|
225
|
+
if (gitignoreAdded.skipped) out.gitignoreSkipped = gitignoreAdded.skipped;
|
|
224
226
|
// Under --json the move goes to stderr, so it survives `... --json > out.json`.
|
|
225
227
|
if (branchResult?.movedFrom) {
|
|
226
228
|
console.error(chalk.yellow(branchMoveWarning(branchResult).join('\n')));
|
|
@@ -234,9 +236,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
234
236
|
console.log(chalk.gray(` .morph/features/${featureName}/feature.json (canonical shape)`));
|
|
235
237
|
console.log(chalk.gray(` status: ${finalFeature.status} | createdAt: ${finalFeature.createdAt}`));
|
|
236
238
|
console.log(chalk.gray(` scaffolded: ${wt.scaffold.join(', ')}`));
|
|
237
|
-
|
|
238
|
-
console.log(chalk.gray(` .gitignore: ${gitignoreAdded.join(' ')} adicionados (projeto .NET)`));
|
|
239
|
-
}
|
|
239
|
+
reportDotnetIgnores(gitignoreAdded);
|
|
240
240
|
if (planSkeleton?.written) {
|
|
241
241
|
console.log(chalk.gray(` 2-plan/plan.md: esqueleto escrito (gramática: morph-spec plan-check --help)`));
|
|
242
242
|
}
|
|
@@ -109,7 +109,7 @@ export async function deleteCommand(featureName, options = {}) {
|
|
|
109
109
|
const root = resolvePrimaryRoot(cwd);
|
|
110
110
|
// The stack comes down BEFORE the worktree, where its compose file and bind mount live. Fail-open.
|
|
111
111
|
try {
|
|
112
|
-
tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true });
|
|
112
|
+
tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true, images: 'local', _exec: options._exec });
|
|
113
113
|
} catch { /* fail-open */ }
|
|
114
114
|
const teardown = await teardownWorktree(owner.tree, {
|
|
115
115
|
root,
|
|
@@ -292,7 +292,8 @@ export async function finishCommand(feature, options = {}) {
|
|
|
292
292
|
}
|
|
293
293
|
const dirtLines = withoutLeftoverIgnore(cleanCwd, dirty.split(/\r?\n/).filter((l) => l.trim()));
|
|
294
294
|
const testArtifacts = untrackedTestArtifacts(dirtLines);
|
|
295
|
-
|
|
295
|
+
const commitState = dirtLines.length > testArtifacts.length && onlyFeatureStateDirty(cleanCwd, feature);
|
|
296
|
+
if (dirtLines.length > testArtifacts.length && !commitState) {
|
|
296
297
|
emit({ finished: false, error: isWorktreeFlow ? 'worktree-dirty' : 'working-dir-dirty', hint: 'commit seu trabalho na branch antes do finish', feature });
|
|
297
298
|
process.exit(1);
|
|
298
299
|
}
|
|
@@ -341,6 +342,10 @@ export async function finishCommand(feature, options = {}) {
|
|
|
341
342
|
|
|
342
343
|
// Every refusal is behind us: the leftover of a killed `graph refresh` goes now, never before.
|
|
343
344
|
dropLeftoverIgnore(cleanCwd);
|
|
345
|
+
if (commitState && !commitFeatureState(cleanCwd, feature)) {
|
|
346
|
+
emit({ finished: false, error: 'state-commit-failed', hint: `não foi possível commitar .morph/features/${feature} (identidade git ausente?) — commite o estado na branch e rode o finish de novo`, feature });
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
344
349
|
if (isWorktreeFlow) {
|
|
345
350
|
for (const p of testArtifacts) rmSync(join(cleanCwd, p), { recursive: true, force: true });
|
|
346
351
|
}
|
|
@@ -409,6 +414,15 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
|
|
|
409
414
|
|
|
410
415
|
if (defaultBranch) out.defaultBranch = defaultBranch;
|
|
411
416
|
|
|
417
|
+
try {
|
|
418
|
+
const tree = worktreePath || root;
|
|
419
|
+
const dirt = gitStatusPorcelain(tree);
|
|
420
|
+
const work = dirt === null ? [] : withoutLeftoverIgnore(tree, dirt.split(/\r?\n/).filter((l) => l.trim()));
|
|
421
|
+
if (work.length > untrackedTestArtifacts(work).length && onlyFeatureStateDirty(tree, feature)) {
|
|
422
|
+
out.wouldDo.unshift(`commitar o estado sujo de .morph/features/${feature} (${stateCommitMessage(feature)}) na branch ${branch}`);
|
|
423
|
+
}
|
|
424
|
+
} catch { /* o pré-voo nunca lança */ }
|
|
425
|
+
|
|
412
426
|
// A retenção automática também é mutação do finish real; a contagem é a de AGORA, antes do
|
|
413
427
|
// backup que este finish gravar.
|
|
414
428
|
try {
|
|
@@ -808,6 +822,34 @@ function untrackedTestArtifacts(lines) {
|
|
|
808
822
|
.filter((p) => isTestArtifact(p));
|
|
809
823
|
}
|
|
810
824
|
|
|
825
|
+
const stateCommitMessage = (feature) => `chore(morph): estado da feature ${feature}`;
|
|
826
|
+
|
|
827
|
+
// `--untracked-files=all`: o porcelain padrão colapsa `?? .morph/` e esconderia sujeira fora do escopo.
|
|
828
|
+
function onlyFeatureStateDirty(cwd, feature) {
|
|
829
|
+
let out;
|
|
830
|
+
try {
|
|
831
|
+
out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, stdio: 'pipe' }).toString();
|
|
832
|
+
} catch {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
const scope = `.morph/features/${feature}/`;
|
|
836
|
+
const paths = out.split(/\r?\n/).filter((l) => l.trim())
|
|
837
|
+
.flatMap((l) => l.slice(3).split(' -> '))
|
|
838
|
+
.map((p) => p.replace(/^"|"$/g, ''));
|
|
839
|
+
return paths.length > 0 && paths.every((p) => p.startsWith(scope));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function commitFeatureState(cwd, feature) {
|
|
843
|
+
const scope = `.morph/features/${feature}`;
|
|
844
|
+
try {
|
|
845
|
+
execFileSync('git', ['add', '-A', '--', scope], { cwd, stdio: 'pipe' });
|
|
846
|
+
execFileSync('git', ['commit', '-q', '-m', stateCommitMessage(feature), '--', scope], { cwd, stdio: 'pipe' });
|
|
847
|
+
return true;
|
|
848
|
+
} catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
811
853
|
function gitStatusPorcelain(cwd) {
|
|
812
854
|
try {
|
|
813
855
|
return execFileSync('git', ['status', '--porcelain'], { cwd, stdio: 'pipe' }).toString();
|
|
@@ -901,7 +943,9 @@ function openPrFor(branch, cwd) {
|
|
|
901
943
|
// Never throws: a stack that refuses to come down must not fail a finish that already merged.
|
|
902
944
|
function reclaimStack(feature, { root, worktreePath, options }) {
|
|
903
945
|
try {
|
|
904
|
-
return tearDownFeatureStack(feature, {
|
|
946
|
+
return tearDownFeatureStack(feature, {
|
|
947
|
+
root, worktreePath, volumes: !options.keepVolumes, images: 'local', _exec: options._exec,
|
|
948
|
+
});
|
|
905
949
|
} catch {
|
|
906
950
|
return { attempted: false, cwd: worktreePath || root, status: 'fail', stampDeleted: false };
|
|
907
951
|
}
|
|
@@ -15,6 +15,7 @@ import { verifierModelFor } from '../../lib/risk-detector.js';
|
|
|
15
15
|
import { changedPathsAgainst, detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
16
16
|
import { readMorphConfig } from '../../lib/tasks/test-runner.js';
|
|
17
17
|
import { readPlanChecks } from '../../lib/plan/checks.js';
|
|
18
|
+
import { isTestPath } from '../../lib/graph/sanitize.js';
|
|
18
19
|
import { featureGeneratesMedia, collectReviewRequirement } from './gate-check.js';
|
|
19
20
|
|
|
20
21
|
export { inspectReport, REPORT_MARKERS };
|
|
@@ -139,12 +140,45 @@ function decide(report, observed, stale) {
|
|
|
139
140
|
: { verdict: 'no-work-no-report', action: 'redispatch' };
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
const isTestRef = (p) => isTestPath(p) || /\.(test|spec)\.[a-z]+$|Tests?\.cs$/i.test(p);
|
|
144
|
+
|
|
145
|
+
/** Cn whose `**Checks:**` entry cites tests in more than one file, as `{ id, files }`. */
|
|
146
|
+
export function checksSpread(body) {
|
|
147
|
+
const lines = String(body ?? '').split(/\r?\n/);
|
|
148
|
+
const start = lines.findIndex((l) => /^\s*(?:[-*]\s*)?\*\*checks\s*:?\*\*/i.test(l));
|
|
149
|
+
if (start < 0) return [];
|
|
150
|
+
const block = [lines[start].replace(/^.*?\*\*checks\s*:?\*\*\s*:?/i, '')];
|
|
151
|
+
for (const l of lines.slice(start + 1)) {
|
|
152
|
+
if (/^\s*(\*\*|#)/.test(l)) break;
|
|
153
|
+
block.push(l);
|
|
154
|
+
}
|
|
155
|
+
const filesByCn = new Map();
|
|
156
|
+
let current = null;
|
|
157
|
+
for (const piece of block.join('\n').split(/[;\n]/)) {
|
|
158
|
+
const lead = /^\s*(?:[-*]\s*)?\(?(C\d+)\b/.exec(piece);
|
|
159
|
+
if (lead) current = lead[1];
|
|
160
|
+
if (!current) continue;
|
|
161
|
+
const refs = (piece.match(/[\w@.\/\\-]+\.[A-Za-z]+/g) ?? []).map(slash).filter(isTestRef);
|
|
162
|
+
if (!filesByCn.has(current)) filesByCn.set(current, new Set());
|
|
163
|
+
for (const f of refs) filesByCn.get(current).add(f);
|
|
164
|
+
}
|
|
165
|
+
return [...filesByCn].filter(([, files]) => files.size > 1).map(([id, files]) => ({ id, files: [...files] }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function spreadWarnings(fsPath) {
|
|
169
|
+
let body = '';
|
|
170
|
+
try { body = readFileSync(fsPath, 'utf8'); } catch { return []; }
|
|
171
|
+
return checksSpread(body).map(({ id, files }) =>
|
|
172
|
+
`${id} cita testes em ${files.length} arquivos (${files.join(', ')}): um teste por Cn, na camada mais baixa que o prova`);
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
function checkSlice(cwd, feature, slice, telemetry) {
|
|
143
176
|
const fsPath = sliceReportPath(join(cwd, '.morph', 'features', feature), slice.id);
|
|
144
177
|
const report = inspectReport(fsPath, { covers: slice.covers ?? [] });
|
|
145
178
|
const observed = observedEvidence(telemetry, feature, slice);
|
|
146
179
|
const stale = report.exists && isReportStale(fsPath, observed);
|
|
147
|
-
|
|
180
|
+
const warnings = report.exists ? spreadWarnings(fsPath) : [];
|
|
181
|
+
return { slice: slice.id, report, observed, warnings, ...decide(report, observed, stale) };
|
|
148
182
|
}
|
|
149
183
|
|
|
150
184
|
const ACTION_HINT = {
|
|
@@ -220,6 +254,7 @@ function printHuman(obj) {
|
|
|
220
254
|
}
|
|
221
255
|
console.log(chalk.gray(` → ${r.hint}`));
|
|
222
256
|
}
|
|
257
|
+
for (const w of r.warnings ?? []) console.log(chalk.yellow(` ⚠ ${w}`));
|
|
223
258
|
}
|
|
224
259
|
for (const [role, r] of Object.entries(obj.reviews ?? {})) {
|
|
225
260
|
if (!r.exists && role === 'security') continue;
|
|
@@ -57,6 +57,7 @@ export function planCheckCommand(featureName, options = {}) {
|
|
|
57
57
|
const gitignoreAdded = result.written ? seedDotnetBuildIgnores(process.cwd(), { featureName }) : [];
|
|
58
58
|
if (options.json) {
|
|
59
59
|
if (gitignoreAdded.length) result.gitignoreAdded = gitignoreAdded;
|
|
60
|
+
if (gitignoreAdded.skipped) result.gitignoreSkipped = gitignoreAdded.skipped;
|
|
60
61
|
console.log(JSON.stringify(result, null, 2));
|
|
61
62
|
process.exit(result.exit);
|
|
62
63
|
}
|
|
@@ -309,6 +309,9 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
309
309
|
for (const u of result.baseline.updates) {
|
|
310
310
|
console.log(chalk.green(` baseline ${u.id}: ${u.from ?? '—'} → ${u.to}`));
|
|
311
311
|
}
|
|
312
|
+
for (const id of result.baseline.removed ?? []) {
|
|
313
|
+
console.log(chalk.gray(` baseline ${id}: removida (runner sem baseline, só o piso zero)`));
|
|
314
|
+
}
|
|
312
315
|
console.log(chalk.gray(`Gravada em ${result.baseline.path}
|
|
313
316
|
`));
|
|
314
317
|
}
|