@polymorphism-tech/morph-spec 9.7.0 → 10.0.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 +11 -11
- package/README.md +4 -4
- package/bin/morph-spec.js +41 -44
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +24 -30
- package/framework/MORPH.md +47 -123
- package/framework/agents.json +6 -374
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-archive.md +4 -3
- package/framework/commands/morph-hotfix.md +8 -30
- package/framework/commands/morph-infra.md +2 -2
- package/framework/commands/morph-media.md +10 -9
- package/framework/commands/morph-review.md +15 -0
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +100 -102
- package/framework/evals/review-checklist.md +46 -54
- package/framework/hooks/README.md +4 -4
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/README.md +1 -1
- package/framework/hooks/claude-code/core/denylist.json +4 -4
- package/framework/hooks/claude-code/core/gate-guard.js +9 -0
- package/framework/hooks/claude-code/core/masked-exit.js +49 -0
- package/framework/hooks/claude-code/core/state-sync.js +4 -1
- package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
- package/framework/hooks/claude-code/core/test-budget.js +10 -70
- package/framework/hooks/claude-code/core/validate-completion.js +11 -24
- package/framework/hooks/claude-code/core/worktree-context.js +5 -22
- package/framework/hooks/claude-code/core/worktree-create.js +6 -45
- package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/gate-event.js +2 -2
- package/framework/hooks/shared/graph-refresh.js +20 -3
- package/framework/hooks/shared/heavy-command.js +32 -1
- package/framework/hooks/shared/heavy-config.js +1 -1
- package/framework/hooks/shared/heavy-slots.js +41 -4
- package/framework/hooks/shared/next-step.js +63 -24
- package/framework/hooks/shared/phase-utils.js +6 -9
- package/framework/hooks/shared/report-progress.js +86 -0
- package/framework/hooks/shared/state-reader.js +3 -2
- package/framework/hooks/statusline.js +332 -0
- package/framework/phases.json +32 -10
- package/framework/rules/ai-agents-standards.md +9 -1
- package/framework/rules/csharp-standards.md +4 -0
- package/framework/rules/ef-standards.md +20 -0
- package/framework/rules/frontend-standards.md +1 -1
- package/framework/rules/implement-report.md +25 -0
- package/framework/rules/infrastructure-standards.md +2 -0
- package/framework/rules/nextjs-standards.md +7 -0
- package/framework/rules/plan-grammar.md +53 -0
- package/framework/rules/ui-design.md +27 -0
- package/framework/schemas/config.schema.json +12 -19
- package/framework/schemas/feature.schema.json +7 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
- package/framework/skills/core/morph-apply/SKILL.md +38 -166
- package/framework/skills/core/morph-apply/references/debugging.md +8 -0
- package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
- package/framework/skills/core/morph-apply/references/machine.md +29 -0
- package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
- package/framework/skills/core/morph-apply/references/review.md +65 -0
- package/framework/skills/core/morph-plan/SKILL.md +42 -108
- package/framework/skills/core/morph-plan/references/critique.md +40 -0
- package/framework/skills/core/morph-plan/references/interview.md +25 -0
- package/framework/skills/core/morph-uiux/SKILL.md +47 -64
- package/framework/skills/core/morph-uiux/surfaces.json +32 -0
- package/framework/standards/STANDARDS.json +32 -5
- package/framework/standards/STANDARDS.meta.json +5 -1
- package/framework/standards/ai-agents/agent-session.md +2 -3
- package/framework/standards/ai-agents/context-providers.md +2 -2
- package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
- package/framework/standards/ai-agents/providers/model-registry.md +2 -2
- package/framework/standards/ai-agents/rag-custom-pgvector.md +6 -6
- package/framework/standards/ai-agents/setup.md +1 -1
- package/framework/standards/ai-agents/tool-adapters.md +2 -2
- package/framework/standards/architecture/stack-baseline.md +47 -0
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -4
- package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
- package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
- package/framework/standards/frontend/design-system/art-direction.md +2 -2
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
- package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
- package/framework/templates/.idea/morph-templates.xml +92 -0
- package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/REGISTRY.json +1 -2
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/context/ARCHITECTURE.md +1 -1
- package/framework/templates/docs/plan.md +12 -12
- package/framework/templates/docs/ui-mockups.md +17 -105
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +88 -23
- package/src/commands/graph/diff.js +74 -0
- package/src/commands/graph/status.js +1 -1
- package/src/commands/observability/cost.js +2 -4
- package/src/commands/observability/telemetry.js +1 -1
- package/src/commands/project/doctor.js +23 -39
- package/src/commands/project/fleet.js +5 -6
- package/src/commands/project/init.js +63 -36
- package/src/commands/project/stack-scan.js +3 -42
- package/src/commands/project/stack-set.js +26 -0
- package/src/commands/project/status.js +12 -13
- package/src/commands/project/update.js +39 -3
- package/src/commands/project/worktree.js +27 -16
- package/src/commands/state/advance.js +17 -17
- package/src/commands/state/approve.js +0 -6
- package/src/commands/state/archive.js +27 -1
- package/src/commands/state/auto-approval.js +1 -1
- package/src/commands/state/create.js +0 -5
- package/src/commands/state/finish.js +43 -1
- package/src/commands/state/gate-check.js +37 -30
- package/src/commands/state/gate-decision.js +3 -2
- package/src/commands/state/next.js +1 -0
- package/src/commands/state/recap.js +67 -0
- package/src/commands/state/report-check.js +39 -146
- package/src/commands/validation/plan-check.js +16 -11
- package/src/commands/validation/validate-feature.js +3 -3
- package/src/commands/validation/verify.js +6 -6
- package/src/core/paths/output-schema.js +3 -3
- package/src/core/state/state-manager.js +2 -4
- package/src/dashboard/app/components/CommandPalette.jsx +2 -5
- package/src/dashboard/app/components/GatePauses.jsx +116 -0
- package/src/dashboard/app/components/SessionMap.jsx +77 -0
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/Activity.jsx +4 -0
- package/src/dashboard/app/pages/AgentMap.jsx +1 -2
- package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
- package/src/dashboard/app/pages/Features.jsx +7 -7
- package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
- package/src/dashboard/app/pages/Portfolio.jsx +40 -5
- package/src/dashboard/scanner/activity-scanner.js +29 -0
- package/src/dashboard/scanner/agents-scanner.js +11 -14
- package/src/dashboard/scanner/features-scanner.js +45 -24
- package/src/dashboard/scanner/project-api.js +11 -2
- package/src/dashboard/scanner/skills-scanner.js +11 -1
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/e2e/applicability.js +3 -1
- package/src/lib/git/tree-state.js +1 -16
- package/src/lib/graph/diff-tree.js +127 -0
- package/src/lib/graph/graphify-runtime.js +12 -5
- package/src/lib/heavy/gate.js +22 -1
- package/src/lib/installers/memory-installer.js +2 -2
- package/src/lib/installers/rules-installer.js +57 -0
- package/src/lib/installers/stack-assets.js +17 -0
- package/src/lib/installers/stack-sync.js +219 -0
- package/src/lib/plan/checks.js +55 -110
- package/src/lib/plan/critique.js +28 -0
- package/src/lib/plan/media.js +42 -0
- package/src/lib/plan/plan-html.js +125 -0
- package/src/lib/plan/plan-parser.js +34 -45
- package/src/lib/recap/piper.js +36 -0
- package/src/lib/recap/recap.js +232 -0
- package/src/lib/review-reports.js +33 -7
- package/src/lib/risk-detector.js +90 -11
- package/src/lib/specs/screen-checklist.js +33 -48
- package/src/lib/stack/dotnet-build-ignores.js +8 -9
- package/src/lib/tasks/test-nodes.js +24 -6
- package/src/lib/tasks/test-runner.js +40 -1
- package/src/lib/telemetry/metrics.js +27 -29
- package/src/lib/validators/reuse/reuse-validator.js +1 -9
- package/src/lib/validators/verify-runner.js +50 -19
- package/src/lib/worktree/fleet-scanner.js +7 -20
- package/src/lib/worktree/worktree-audit.js +0 -37
- package/src/scripts/setup-infra.js +15 -50
- package/src/utils/agents-installer.js +27 -201
- package/src/utils/hooks-installer.js +11 -95
- package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
- package/framework/hooks/claude-code/statusline.py +0 -699
- package/framework/hooks/claude-code/statusline.sh +0 -11
- package/framework/hooks/shared/agent-reap.js +0 -74
- package/framework/hooks/shared/slice-progress.js +0 -110
- package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
- package/framework/skills/core/morph-machine/SKILL.md +0 -46
- package/framework/skills/core/morph-review/SKILL.md +0 -129
- package/framework/skills/core/morph-standards/SKILL.md +0 -52
- package/framework/standards/PROVENANCE.json +0 -119
- package/src/commands/project/worktree-reap.js +0 -34
- package/src/commands/state/handoff.js +0 -65
- package/src/commands/validation/mutate.js +0 -263
- package/src/commands/validation/scout-check.js +0 -28
- package/src/lib/generators/handoff-generator.js +0 -172
- package/src/lib/mutation/adapters.js +0 -79
- package/src/lib/mutation/commands.js +0 -61
- package/src/lib/mutation/dotnet-introspection.js +0 -281
- package/src/lib/mutation/manifest.js +0 -144
- package/src/lib/mutation/plan.js +0 -44
- package/src/lib/mutation/report.js +0 -68
- package/src/lib/mutation/runner.js +0 -301
- package/src/lib/mutation/schemata.js +0 -60
- package/src/lib/mutation/snapshot.js +0 -65
- package/src/lib/mutation/subset.js +0 -109
- package/src/lib/mutation/trx.js +0 -50
- package/src/lib/scout/scout-check.js +0 -293
- package/src/lib/validators/traceability-check.js +0 -103
- package/src/lib/worktree/agent-reaper.js +0 -247
|
@@ -5,30 +5,19 @@
|
|
|
5
5
|
import { createHash } from 'crypto';
|
|
6
6
|
import { existsSync, readFileSync } from 'fs';
|
|
7
7
|
import { dirname, join } from 'path';
|
|
8
|
-
import { fileURLToPath } from 'url';
|
|
9
8
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
10
9
|
import { verifierScreenChecklist } from '../review-reports.js';
|
|
11
10
|
import { LEGACY_MOCKUP_EXEMPTION, uiuxChecklistPause } from '../risk-detector.js';
|
|
12
11
|
|
|
13
|
-
const PACKAGE_AGENTS = fileURLToPath(new URL('../../../framework/agents.json', import.meta.url));
|
|
14
|
-
|
|
15
12
|
/** `match` roda sobre o rótulo normalizado; um rótulo que casa com vários itens responde a todos. */
|
|
16
13
|
export const SCREEN_CHECKLIST_ITEMS = [
|
|
17
|
-
{ id: '
|
|
18
|
-
{ id: '
|
|
19
|
-
{ id: '
|
|
20
|
-
{ id: '
|
|
21
|
-
{ id: '
|
|
22
|
-
{ id: 'vazio', label: 'Estado vazio', match: /\bvazio\b/ },
|
|
23
|
-
{ id: 'carregando', label: 'Estado carregando', match: /\bcarregando\b|\bcarregamento\b/ },
|
|
24
|
-
{ id: 'erro', label: 'Estado de erro', match: /\berro\b/ },
|
|
25
|
-
{ id: 'mobile', label: 'Mobile', match: /\bmobile\b/ },
|
|
26
|
-
{ id: 'abas', label: 'Abas', match: /\babas\b/ },
|
|
14
|
+
{ id: 'servidor', label: 'Lista no servidor', match: /\bpaginacao\b|\bordenacao\b|\bservidor\b/ },
|
|
15
|
+
{ id: 'estados', label: 'Estados vazio e carregando', match: /\bvazio\b|\bcarregando\b|\bcarregamento\b/ },
|
|
16
|
+
{ id: 'erro', label: 'Erro com próxima ação', match: /\berro\b/ },
|
|
17
|
+
{ id: 'acao', label: 'Uma ação primária', match: /\bprimaria\b/ },
|
|
18
|
+
{ id: 'mobile', label: 'Celular', match: /\bmobile\b|\bcelular\b/ },
|
|
27
19
|
];
|
|
28
20
|
|
|
29
|
-
// A superfície decide quais standards a tela lê, não um comportamento que a tela entregue exiba.
|
|
30
|
-
const NOT_VERIFIED_AT_REVIEW = new Set(['superficie']);
|
|
31
|
-
|
|
32
21
|
// As exatas, e as que começam adiando a decisão ("a definir depois", "TBD.").
|
|
33
22
|
const PLACEHOLDERS = new Set(['', '-', '--', '—', '–', '...', 'x', 'todo', 'definir', 'pendente']);
|
|
34
23
|
const PLACEHOLDER_PREFIX_RE = /^(a definir|tbd|\?|…)/;
|
|
@@ -141,30 +130,9 @@ const isNotApplicable = (answer) => NOT_APPLICABLE_RE.test(normalizeLabel(answer
|
|
|
141
130
|
|
|
142
131
|
const labelOf = (id) => SCREEN_CHECKLIST_ITEMS.find((item) => item.id === id)?.label ?? id;
|
|
143
132
|
|
|
144
|
-
// O vocabulário válido de `Superfície`: as `surfaces` da `ui-designer`, da cópia instalada ou do pacote.
|
|
145
|
-
// null quando a persona não declara o campo, e então o valor não é validado.
|
|
146
|
-
export function knownSurfaces(cwd) {
|
|
147
|
-
const surfaces = readAgentsJson(cwd)?.agents?.['ui-designer']?.surfaces;
|
|
148
|
-
return surfaces && typeof surfaces === 'object' && !Array.isArray(surfaces) ? Object.keys(surfaces) : null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** The project's installed `agents.json`, else the package's; null when unreadable. */
|
|
152
|
-
export function readAgentsJson(cwd) {
|
|
153
|
-
for (const path of [join(cwd, '.morph', 'framework', 'agents.json'), PACKAGE_AGENTS]) {
|
|
154
|
-
if (!existsSync(path)) continue;
|
|
155
|
-
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
156
|
-
}
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function namesSurface(answer, surfaces) {
|
|
161
|
-
const norm = normalizeLabel(answer);
|
|
162
|
-
return surfaces.some((key) => new RegExp(`(^|[^a-z0-9-])${key}([^a-z0-9-]|$)`).test(norm));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
133
|
// Sem nenhuma `## Tela:`, só o formato antigo é legado; qualquer outro heading é "nenhuma tela
|
|
166
134
|
// declarada", senão trocar o heading pularia o checklist.
|
|
167
|
-
export function auditScreenChecklist(markdown
|
|
135
|
+
export function auditScreenChecklist(markdown) {
|
|
168
136
|
const parsed = parseScreens(markdown);
|
|
169
137
|
const problems = [];
|
|
170
138
|
if (parsed.length === 0) {
|
|
@@ -185,18 +153,25 @@ export function auditScreenChecklist(markdown, { surfaces = null } = {}) {
|
|
|
185
153
|
} else {
|
|
186
154
|
if (missing.length) problems.push(`tela "${screen.name}": falta ${missing.map(labelOf).join(', ')}`);
|
|
187
155
|
if (unanswered.length) problems.push(`tela "${screen.name}": sem resposta em ${unanswered.map(labelOf).join(', ')}`);
|
|
188
|
-
const surface = screen.answers.get('superficie');
|
|
189
|
-
if (surfaces?.length && surface !== undefined && !isPlaceholder(surface) && !isNotApplicable(surface)
|
|
190
|
-
&& !namesSurface(surface, surfaces)) {
|
|
191
|
-
problems.push(`tela "${screen.name}": Superfície "${surface}" não é uma das superfícies da ui-designer (${surfaces.join(', ')})`);
|
|
192
|
-
}
|
|
193
156
|
}
|
|
194
|
-
return { name: screen.name, hasChecklist: screen.hasChecklist, missing, unanswered, prototype: screen.prototype };
|
|
157
|
+
return { name: screen.name, hasChecklist: screen.hasChecklist, missing, unanswered, prototype: screen.prototype, states: statesOf(screen) };
|
|
195
158
|
});
|
|
196
159
|
return { legacy: false, screens, problems };
|
|
197
160
|
}
|
|
198
161
|
|
|
199
|
-
|
|
162
|
+
/** The states the prototype must switch to, by the checklist items that apply to the screen. */
|
|
163
|
+
export const PROTOTYPE_STATES = { estados: ['vazio', 'carregando'], erro: ['erro'] };
|
|
164
|
+
export const STATE_PRINT_WIDTH = 390;
|
|
165
|
+
|
|
166
|
+
function statesOf(screen) {
|
|
167
|
+
return Object.entries(PROTOTYPE_STATES).flatMap(([id, states]) => {
|
|
168
|
+
const answer = screen.answers.get(id);
|
|
169
|
+
return answer && !isPlaceholder(answer) && !isNotApplicable(answer) ? states : [];
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Linha **Protótipo:** da tela + o HTML + os prints do laço de autocrítica ao lado dele; cada estado
|
|
174
|
+
// que o checklist diz que se aplica tem `data-estado` no HTML e o print de celular.
|
|
200
175
|
export function auditPrototypes(screens, designDir) {
|
|
201
176
|
const problems = [];
|
|
202
177
|
for (const screen of screens) {
|
|
@@ -205,9 +180,19 @@ export function auditPrototypes(screens, designDir) {
|
|
|
205
180
|
continue;
|
|
206
181
|
}
|
|
207
182
|
const base = screen.prototype.replace(/\.html$/i, '');
|
|
208
|
-
const
|
|
183
|
+
const states = screen.states ?? [];
|
|
184
|
+
const expected = [
|
|
185
|
+
screen.prototype,
|
|
186
|
+
...PROTOTYPE_WIDTHS.map((width) => `${base}-${width}.png`),
|
|
187
|
+
...states.map((state) => `${base}-${state}-${STATE_PRINT_WIDTH}.png`),
|
|
188
|
+
];
|
|
209
189
|
const missing = expected.filter((rel) => !existsSync(join(designDir, rel)));
|
|
210
190
|
if (missing.length) problems.push(`tela "${screen.name}": falta ${missing.map((rel) => `1-design/${rel}`).join(', ')}`);
|
|
191
|
+
const html = existsSync(join(designDir, screen.prototype)) ? readFileSync(join(designDir, screen.prototype), 'utf8') : '';
|
|
192
|
+
const unswitchable = html ? states.filter((state) => !new RegExp(`data-estado\\s*=\\s*["']${state}["']`).test(html)) : [];
|
|
193
|
+
if (unswitchable.length) {
|
|
194
|
+
problems.push(`tela "${screen.name}": o protótipo não alterna para ${unswitchable.join(', ')} (falta \`data-estado="…"\` no seletor de estado)`);
|
|
195
|
+
}
|
|
211
196
|
}
|
|
212
197
|
return problems;
|
|
213
198
|
}
|
|
@@ -216,7 +201,7 @@ export function auditPrototypes(screens, designDir) {
|
|
|
216
201
|
export function collectScreenChecklist(cwd, featureName, gateName) {
|
|
217
202
|
const mockups = readMockups(cwd, featureName);
|
|
218
203
|
if (mockups === null) return { mockups: false, legacy: false, screens: [], problems: [] };
|
|
219
|
-
const audit = auditScreenChecklist(mockups
|
|
204
|
+
const audit = auditScreenChecklist(mockups);
|
|
220
205
|
const signal = { mockups: true, legacy: audit.legacy, screens: audit.screens.map((s) => s.name), problems: audit.problems };
|
|
221
206
|
if (audit.legacy) signal.exempt = LEGACY_MOCKUP_EXEMPTION;
|
|
222
207
|
if (gateName === 'uiux' && !audit.legacy) {
|
|
@@ -255,7 +240,7 @@ export function checklistCoverage(designMarkdown, reportSection) {
|
|
|
255
240
|
for (const screen of parseScreens(designMarkdown)) {
|
|
256
241
|
const done = covered.get(screenKey(screen.name)) ?? new Set();
|
|
257
242
|
const owed = [...screen.answers]
|
|
258
|
-
.filter(([
|
|
243
|
+
.filter(([, answer]) => !isPlaceholder(answer) && !isNotApplicable(answer))
|
|
259
244
|
.map(([id]) => id)
|
|
260
245
|
.filter((id) => !done.has(id));
|
|
261
246
|
if (owed.length) gaps.push(`${screen.name}: ${owed.map(labelOf).join(', ')}`);
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import { readdirSync } from 'fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { ensureDotnetBuildIgnores } from '../../utils/file-copier.js';
|
|
4
4
|
import { getProjectTags } from '../stack-filter.js';
|
|
5
5
|
import { readMorphConfig } from '../tasks/test-runner.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
7
7
|
import { WORKTREE_DIR_NAMES } from '../worktree/dirs.js';
|
|
8
8
|
|
|
9
|
-
export const DOTNET_IMPLEMENTERS = ['dotnet-senior', 'ef-modeler', 'maf-expert'];
|
|
10
|
-
|
|
11
9
|
const SKIPPED_DIRS = new Set([...WORKTREE_DIR_NAMES, 'node_modules', 'bin', 'obj']);
|
|
12
10
|
const DOTNET_PROJECT_FILE = /\.(?:csproj|slnx?)$/i;
|
|
11
|
+
const DOTNET_IN_PLAN = /\.(?:cs|csproj|slnx?)\b|\bdotnet\s+(?:test|build|new)\b/i;
|
|
13
12
|
const MAX_DEPTH = 6;
|
|
14
13
|
|
|
15
14
|
export function hasDotnetProjectFile(root, depth = 0) {
|
|
@@ -21,16 +20,16 @@ export function hasDotnetProjectFile(root, depth = 0) {
|
|
|
21
20
|
&& hasDotnetProjectFile(join(root, e.name), depth + 1));
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
function
|
|
23
|
+
function planMentionsDotnet(tree, featureName) {
|
|
25
24
|
if (!featureName) return false;
|
|
26
|
-
const
|
|
27
|
-
|
|
25
|
+
const path = getAbsoluteOutputPath(tree, featureName, 'plan');
|
|
26
|
+
try { return existsSync(path) && DOTNET_IN_PLAN.test(readFileSync(path, 'utf8')); } catch { return false; }
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
// The plan
|
|
29
|
+
// The plan is read because a greenfield tree has no stack or .csproj yet.
|
|
31
30
|
export function isDotnetProject(tree, { featureName, configRoot = tree } = {}) {
|
|
32
31
|
if (getProjectTags(readMorphConfig(configRoot)).includes('dotnet')) return true;
|
|
33
|
-
return
|
|
32
|
+
return planMentionsDotnet(tree, featureName) || hasDotnetProjectFile(tree);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
/** Returns the `.gitignore` entries added; `[]` when not .NET or on any error. */
|
|
@@ -9,6 +9,14 @@ import { PACKAGE_LOCATIONS } from '../detectors/index.js';
|
|
|
9
9
|
/** `none` yields an UNKNOWN count, never a fabricated zero. */
|
|
10
10
|
export const TEST_RUNNERS = ['dotnet', 'vitest', 'jest', 'node-test', 'playwright', 'none', EVAL_RUNNER];
|
|
11
11
|
|
|
12
|
+
// A count is a baseline only where it stays still: dotnet/vitest/jest/playwright grow every
|
|
13
|
+
// slice, so their baseline only warned or blocked a deliberate deletion.
|
|
14
|
+
const BASELINE_RUNNERS = ['node-test', EVAL_RUNNER];
|
|
15
|
+
|
|
16
|
+
export function keepsBaseline(runner) {
|
|
17
|
+
return BASELINE_RUNNERS.includes(runner);
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export const TEST_NODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
13
21
|
|
|
14
22
|
const FALLBACK_NODE_ID = 'default';
|
|
@@ -67,12 +75,13 @@ export function inferRunner(command) {
|
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
function makeNode(partial) {
|
|
78
|
+
const runner = partial.runner ?? inferRunner(partial.command);
|
|
70
79
|
return {
|
|
71
80
|
id: partial.id,
|
|
72
81
|
command: partial.command ?? null,
|
|
73
82
|
cwd: partial.cwd ?? '.',
|
|
74
|
-
runner
|
|
75
|
-
expectedCount: partial.expectedCount ?? null,
|
|
83
|
+
runner,
|
|
84
|
+
expectedCount: keepsBaseline(runner) ? partial.expectedCount ?? null : null,
|
|
76
85
|
timeoutMs: partial.timeoutMs ?? null,
|
|
77
86
|
build: partial.build ?? [],
|
|
78
87
|
e2e: partial.e2e ?? false,
|
|
@@ -329,11 +338,11 @@ export function morphConfigPath(projectPath = '.') {
|
|
|
329
338
|
* Refuses a non-integer count (null is UNKNOWN, never 0), an undeclared id, or a
|
|
330
339
|
* declared node with no measurement. Every other config key survives.
|
|
331
340
|
* @param {Array<{id: string, count: number|null}>} measurements
|
|
332
|
-
* @returns {{ recorded: boolean, reason: string|null, updates: Array<{id: string, from: number|null, to: number}>, path: string }}
|
|
341
|
+
* @returns {{ recorded: boolean, reason: string|null, updates: Array<{id: string, from: number|null, to: number}>, removed: string[], path: string }}
|
|
333
342
|
*/
|
|
334
343
|
export function recordTestBaselines(projectPath, measurements) {
|
|
335
344
|
const path = morphConfigPath(projectPath);
|
|
336
|
-
const refuse = (reason) => ({ recorded: false, reason, updates: [], path });
|
|
345
|
+
const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [], path });
|
|
337
346
|
|
|
338
347
|
if (!existsSync(path)) return refuse('não existe .morph/config/config.json — não há onde gravar');
|
|
339
348
|
|
|
@@ -349,7 +358,10 @@ export function recordTestBaselines(projectPath, measurements) {
|
|
|
349
358
|
return refuse('config.json não declara project.tests[] — não há entrada onde gravar a baseline');
|
|
350
359
|
}
|
|
351
360
|
|
|
352
|
-
const
|
|
361
|
+
const runnerOf = new Map(declared.filter(isPlainObject).map((e) => [e.id, e.runner ?? inferRunner(e.command)]));
|
|
362
|
+
const unreadable = measurements
|
|
363
|
+
.filter((m) => keepsBaseline(runnerOf.get(m.id)) && !Number.isInteger(m.count))
|
|
364
|
+
.map((m) => m.id);
|
|
353
365
|
if (unreadable.length > 0) {
|
|
354
366
|
return refuse(`contagem desconhecida em: ${unreadable.join(', ')} — desconhecido nunca vira 0`);
|
|
355
367
|
}
|
|
@@ -370,8 +382,14 @@ export function recordTestBaselines(projectPath, measurements) {
|
|
|
370
382
|
}
|
|
371
383
|
|
|
372
384
|
const updates = [];
|
|
385
|
+
const removed = [];
|
|
373
386
|
for (const entry of declared) {
|
|
374
387
|
if (!entry || !byId.has(entry.id)) continue;
|
|
388
|
+
if (!keepsBaseline(runnerOf.get(entry.id))) {
|
|
389
|
+
if ('expectedCount' in entry) removed.push(entry.id);
|
|
390
|
+
delete entry.expectedCount;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
375
393
|
const to = byId.get(entry.id);
|
|
376
394
|
updates.push({
|
|
377
395
|
id: entry.id,
|
|
@@ -382,5 +400,5 @@ export function recordTestBaselines(projectPath, measurements) {
|
|
|
382
400
|
}
|
|
383
401
|
|
|
384
402
|
atomicWriteJson(path, config);
|
|
385
|
-
return { recorded: true, reason: null, updates, path };
|
|
403
|
+
return { recorded: true, reason: null, updates, removed, path };
|
|
386
404
|
}
|
|
@@ -8,7 +8,7 @@ import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
|
|
|
8
8
|
import { CONFIG_FILE_NAME } from '../../core/state/state-manager.js';
|
|
9
9
|
|
|
10
10
|
// Per chained project: a fixed cap timed a long `a && b` chain out for being long, not broken.
|
|
11
|
-
export const DEFAULT_TEST_TIMEOUT_PER_PROJECT =
|
|
11
|
+
export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 1_800_000;
|
|
12
12
|
const TEST_OUTPUT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -424,6 +424,45 @@ export function parseTestCount(output, runner) {
|
|
|
424
424
|
return extractor(stripAnsi(output));
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
const FAILURE_LINES_MAX = 10;
|
|
428
|
+
const TAP_NOT_OK = /^(\s*)not ok \d+ - (.+?)\s*$/;
|
|
429
|
+
const TAP_DIRECTIVE = /\s#\s*(?:TODO|SKIP)\b/i;
|
|
430
|
+
const VITEST_FAIL = /^\s*FAIL\s+(.+?)\s*$/;
|
|
431
|
+
const VITEST_CROSS = /^\s*[×✗]\s+(.+?)(?:\s+\d+(?:\.\d+)?\s*m?s)?\s*$/;
|
|
432
|
+
// The duration suffix is required: it separates a test line from the summary's `failed: 1` / `Failed!`.
|
|
433
|
+
const DOTNET_FAILED = /^\s*(?:failed|com falha)\s+(\S.*?)\s*(?:\[[^\]]*\]|\([^)]*\))\s*$/i;
|
|
434
|
+
|
|
435
|
+
function tapLeafFailures(lines) {
|
|
436
|
+
const seen = [];
|
|
437
|
+
for (const line of lines) {
|
|
438
|
+
const match = TAP_NOT_OK.exec(line);
|
|
439
|
+
if (!match || TAP_DIRECTIVE.test(match[2])) continue;
|
|
440
|
+
const depth = match[1].length;
|
|
441
|
+
// TAP prints a subtest's result before its parent's: deeper unclaimed entries above are its children.
|
|
442
|
+
const children = seen.filter((e) => !e.claimed && e.depth > depth);
|
|
443
|
+
for (const child of children) child.claimed = true;
|
|
444
|
+
seen.push({ depth, name: match[2], leaf: children.length === 0, claimed: false });
|
|
445
|
+
}
|
|
446
|
+
return seen.filter((e) => e.leaf).map((e) => e.name);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function captures(lines, pattern) {
|
|
450
|
+
return lines.map((line) => pattern.exec(line)?.[1]).filter(Boolean);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Failed test names anywhere in the output (TAP leaves, vitest, dotnet), deduplicated, at most 10; [] when no form matches. */
|
|
454
|
+
export function failureLines(output) {
|
|
455
|
+
if (typeof output !== 'string' || !output) return [];
|
|
456
|
+
const lines = stripAnsi(output).split(/\r?\n/);
|
|
457
|
+
const vitestFiles = captures(lines, VITEST_FAIL);
|
|
458
|
+
const names = [
|
|
459
|
+
...tapLeafFailures(lines),
|
|
460
|
+
...(vitestFiles.length > 0 ? vitestFiles : captures(lines, VITEST_CROSS)),
|
|
461
|
+
...captures(lines, DOTNET_FAILED),
|
|
462
|
+
];
|
|
463
|
+
return [...new Set(names)].slice(0, FAILURE_LINES_MAX);
|
|
464
|
+
}
|
|
465
|
+
|
|
427
466
|
/**
|
|
428
467
|
* Asymmetric on purpose: only a drop fails (non-null `reason`); a rise only warns,
|
|
429
468
|
* or every new test would redden the gate and teach deleting tests.
|
|
@@ -95,36 +95,16 @@ function addRun(slot, durationMs) {
|
|
|
95
95
|
const REVIEW_NAME = /^(?:ver|rev|sec|g3|gate3|eval|review|reviewer|security)\d*-/;
|
|
96
96
|
const isReviewAgent = (type) => REVIEW_AGENT_TYPES.has(type) || type === 'morph-evaluator' || REVIEW_NAME.test(String(type ?? ''));
|
|
97
97
|
|
|
98
|
-
// Same split as the test-budget hook: review first, then an unfiltered verify, then the round count.
|
|
99
98
|
// A run refused by the full heavy queue ran no test.
|
|
100
99
|
function testOutsideVerify(bashEvents) {
|
|
101
|
-
const out = {
|
|
102
|
-
total: newTimeSlot(),
|
|
103
|
-
subagentVerify: newTimeSlot(),
|
|
104
|
-
implementerRound3Plus: newTimeSlot(),
|
|
105
|
-
reviewTests: { ...newTimeSlot(), byPersona: newTimeSlot() },
|
|
106
|
-
};
|
|
107
|
-
const byAgent = new Map();
|
|
100
|
+
const out = { total: newTimeSlot(), reviewTests: { ...newTimeSlot(), byPersona: newTimeSlot() } };
|
|
108
101
|
for (const e of bashEvents) {
|
|
109
102
|
if (e.event === 'PostToolUseFailure' && e.exitCode === QUEUE_FULL_EXIT) continue;
|
|
110
|
-
|
|
111
|
-
if (!verify && !testRunKind(e.command)) continue;
|
|
103
|
+
if (!morphVerifyCall(e.command) && !testRunKind(e.command)) continue;
|
|
112
104
|
addRun(out.total, e.durationMs);
|
|
113
|
-
if (!e.agentId) continue;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (REVIEW_AGENT_TYPES.has(e.agentType)) addRun(out.reviewTests.byPersona, e.durationMs);
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
if (!byAgent.has(e.agentId)) byAgent.set(e.agentId, []);
|
|
120
|
-
byAgent.get(e.agentId).push({ e, unfilteredVerify: Boolean(verify && !verify.filtered) });
|
|
121
|
-
}
|
|
122
|
-
for (const runs of byAgent.values()) {
|
|
123
|
-
runs.sort((a, b) => String(a.e.ts ?? '').localeCompare(String(b.e.ts ?? '')));
|
|
124
|
-
runs.forEach(({ e, unfilteredVerify }, round) => {
|
|
125
|
-
if (unfilteredVerify) addRun(out.subagentVerify, e.durationMs);
|
|
126
|
-
else if (round >= 2) addRun(out.implementerRound3Plus, e.durationMs);
|
|
127
|
-
});
|
|
105
|
+
if (!e.agentId || !isReviewAgent(e.agentType)) continue;
|
|
106
|
+
addRun(out.reviewTests, e.durationMs);
|
|
107
|
+
if (REVIEW_AGENT_TYPES.has(e.agentType)) addRun(out.reviewTests.byPersona, e.durationMs);
|
|
128
108
|
}
|
|
129
109
|
return out;
|
|
130
110
|
}
|
|
@@ -164,7 +144,7 @@ function reuseStateOf(e) {
|
|
|
164
144
|
return { state: 'absent', reason: null };
|
|
165
145
|
}
|
|
166
146
|
|
|
167
|
-
const newGateSlot = () => ({ decisions: 0, paused: 0, autoPassed: 0, approvedAfterPause: 0, reworkedAfterPause: 0, unapproved: 0, bySignal: {} });
|
|
147
|
+
const newGateSlot = () => ({ decisions: 0, paused: 0, pausedForHuman: 0, pausedUnattributed: 0, autoPassed: 0, approvedAfterPause: 0, reworkedAfterPause: 0, unapproved: 0, bySignal: {} });
|
|
168
148
|
|
|
169
149
|
// A pause stays open until the next approve or a decision that no longer pauses on the same signal:
|
|
170
150
|
// re-running gate-check over the same unchanged pause is not rework.
|
|
@@ -173,7 +153,7 @@ function trackGate(gates, open, e) {
|
|
|
173
153
|
const slot = gates[e.gate] ||= newGateSlot();
|
|
174
154
|
const key = `${e.feature || ''}\u0000${e.gate}`;
|
|
175
155
|
const pending = open.get(key);
|
|
176
|
-
const bySignal = (signal) => slot.bySignal[signal || 'desconhecido'] ||= { paused: 0, approvedAfterPause: 0, reworkedAfterPause: 0 };
|
|
156
|
+
const bySignal = (signal) => slot.bySignal[signal || 'desconhecido'] ||= { paused: 0, pausedForHuman: 0, pausedUnattributed: 0, approvedAfterPause: 0, reworkedAfterPause: 0 };
|
|
177
157
|
const credit = (field) => {
|
|
178
158
|
slot[field] += 1;
|
|
179
159
|
bySignal(pending.signal)[field] += 1;
|
|
@@ -189,12 +169,30 @@ function trackGate(gates, open, e) {
|
|
|
189
169
|
return;
|
|
190
170
|
}
|
|
191
171
|
slot.decisions += 1;
|
|
192
|
-
|
|
172
|
+
const resolver = e.resolver ?? 'human';
|
|
173
|
+
if (pending && e.pause && e.signal === pending.signal) {
|
|
174
|
+
// Same signal, new owner: the session ran out of attempts and the pause escalated to the human.
|
|
175
|
+
if (pending.resolver === 'session' && resolver === 'human') {
|
|
176
|
+
slot.pausedForHuman += 1;
|
|
177
|
+
bySignal(pending.signal).pausedForHuman += 1;
|
|
178
|
+
}
|
|
179
|
+
pending.resolver = resolver;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
193
182
|
if (pending) credit(e.source === 'manual' && !e.pause ? 'approvedAfterPause' : 'reworkedAfterPause');
|
|
194
183
|
if (e.pause) {
|
|
195
184
|
slot.paused += 1;
|
|
196
185
|
bySignal(e.signal).paused += 1;
|
|
197
|
-
|
|
186
|
+
if (resolver === 'human') {
|
|
187
|
+
slot.pausedForHuman += 1;
|
|
188
|
+
bySignal(e.signal).pausedForHuman += 1;
|
|
189
|
+
}
|
|
190
|
+
// Written before the resolver existed: counted as human above, but nobody recorded who cleared it.
|
|
191
|
+
if (e.resolver === undefined) {
|
|
192
|
+
slot.pausedUnattributed += 1;
|
|
193
|
+
bySignal(e.signal).pausedUnattributed += 1;
|
|
194
|
+
}
|
|
195
|
+
open.set(key, { signal: e.signal, resolver });
|
|
198
196
|
} else {
|
|
199
197
|
slot.autoPassed += 1;
|
|
200
198
|
open.delete(key);
|
|
@@ -123,16 +123,8 @@ export function readReuseVerdicts(projectPath, featureName) {
|
|
|
123
123
|
return verdicts;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
// ao commit; `git status` pega o que o plano não previu.
|
|
128
|
-
export function collectChangedFiles(projectPath, featureName) {
|
|
126
|
+
export function collectChangedFiles(projectPath) {
|
|
129
127
|
const files = new Set();
|
|
130
|
-
for (const slice of readPlanChecks(projectPath, featureName).checks?.slices ?? []) {
|
|
131
|
-
for (const file of slice.files ?? []) {
|
|
132
|
-
if (typeof file === 'string') files.add(file.replace(/\\/g, '/'));
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
128
|
try {
|
|
137
129
|
const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], {
|
|
138
130
|
cwd: projectPath,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
|
-
import { existsSync, readFileSync, rmSync } from 'fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
3
|
import { join, resolve } from 'path';
|
|
4
4
|
|
|
5
5
|
import { runBuildCheck, getBuildSteps, isDotnetStack } from '../../../framework/hooks/shared/build-check.js';
|
|
@@ -9,7 +9,7 @@ import { composeProjectName, runE2eCheck } from '../../../framework/hooks/shared
|
|
|
9
9
|
import { buildComposeEnv } from '../e2e/compose-env.js';
|
|
10
10
|
import { e2eApplicability } from '../e2e/applicability.js';
|
|
11
11
|
import {
|
|
12
|
-
runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution,
|
|
12
|
+
runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution, failureLines,
|
|
13
13
|
} from '../tasks/test-runner.js';
|
|
14
14
|
import {
|
|
15
15
|
resolveTestNodes, parseTestFilter, nodeMatchesFilter, recordTestBaselines, morphConfigPath,
|
|
@@ -21,11 +21,9 @@ import {
|
|
|
21
21
|
evalEmptyRunProblem, evalColdCacheWarning,
|
|
22
22
|
} from '../tasks/eval-node.js';
|
|
23
23
|
import { runValidation } from './validation-runner.js';
|
|
24
|
-
import { checkOutputsTraceability } from './traceability-check.js';
|
|
25
24
|
import { createHeavyGate } from '../heavy/gate.js';
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
const DEFAULT_BUILD_TIMEOUT = 120_000;
|
|
26
|
+
const DEFAULT_BUILD_TIMEOUT = 1_800_000;
|
|
29
27
|
|
|
30
28
|
const VALIDATOR_FEEDBACK_LIMIT = 10;
|
|
31
29
|
|
|
@@ -71,7 +69,6 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
71
69
|
_runValidation = runValidation,
|
|
72
70
|
_runE2eCheck = runE2eCheck,
|
|
73
71
|
_e2eApplicability = e2eApplicability,
|
|
74
|
-
_checkOutputsTraceability = checkOutputsTraceability,
|
|
75
72
|
} = options;
|
|
76
73
|
|
|
77
74
|
const stack = readStack(projectPath);
|
|
@@ -171,10 +168,6 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
171
168
|
appendTestsFeedback(feedback, nodes.tests);
|
|
172
169
|
}
|
|
173
170
|
|
|
174
|
-
// Outside `nodes` on purpose: status comes only from the nodes, so a traceability
|
|
175
|
-
// warning never turns red.
|
|
176
|
-
const traceability = _checkOutputsTraceability(projectPath, featureName);
|
|
177
|
-
|
|
178
171
|
const baseline = recordBaseline ? recordBaselineFrom(projectPath, nodes.tests) : null;
|
|
179
172
|
if (baseline && !baseline.recorded) {
|
|
180
173
|
feedback.push(`[baseline] não gravada: ${baseline.reason}`);
|
|
@@ -194,7 +187,6 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
194
187
|
status,
|
|
195
188
|
nodes,
|
|
196
189
|
feedback,
|
|
197
|
-
traceability,
|
|
198
190
|
...(baseline ? { baseline } : {}),
|
|
199
191
|
};
|
|
200
192
|
}
|
|
@@ -364,7 +356,8 @@ async function runNodesInLinux({
|
|
|
364
356
|
// No exit code is NOT a pass: the container died before this node reported.
|
|
365
357
|
? { passed: false, output: raw.output || '', reason: raw.reason, exitCode: null }
|
|
366
358
|
: { passed: raw.exitCode === 0, output: raw.output || '', exitCode: raw.exitCode };
|
|
367
|
-
|
|
359
|
+
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
360
|
+
return judgeRun({ node, result, durationMs: raw.durationMs ?? 0, logPath });
|
|
368
361
|
});
|
|
369
362
|
|
|
370
363
|
return { runs, environment: suite.environment };
|
|
@@ -389,7 +382,9 @@ async function runOneTestNode({
|
|
|
389
382
|
const result = await _runTestSuite(projectPath, {
|
|
390
383
|
timeout, node, ...(childEnv ? { env: childEnv } : {}),
|
|
391
384
|
});
|
|
392
|
-
const
|
|
385
|
+
const durationMs = Date.now() - start;
|
|
386
|
+
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
387
|
+
const entry = judgeRun({ node, result, durationMs, logPath });
|
|
393
388
|
if (!evalChannel) return entry;
|
|
394
389
|
|
|
395
390
|
const judged = judgeEvalRun(entry, node, evalChannel.summaryPath);
|
|
@@ -397,6 +392,19 @@ async function runOneTestNode({
|
|
|
397
392
|
return judged;
|
|
398
393
|
}
|
|
399
394
|
|
|
395
|
+
// Fail-open: a log that cannot be written must not change the verdict.
|
|
396
|
+
function writeFailedRunLog(projectPath, node, result) {
|
|
397
|
+
if (result.passed || result.skipped || !result.output) return null;
|
|
398
|
+
const relPath = `.morph/logs/verify-tests-${String(node.id).replace(/[^\w.-]/g, '_')}.log`;
|
|
399
|
+
try {
|
|
400
|
+
mkdirSync(join(projectPath, '.morph', 'logs'), { recursive: true });
|
|
401
|
+
writeFileSync(join(projectPath, relPath), result.output);
|
|
402
|
+
return relPath;
|
|
403
|
+
} catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
400
408
|
function discardFile(path) {
|
|
401
409
|
try { rmSync(path, { force: true }); } catch { /* the next run removes it first */ }
|
|
402
410
|
}
|
|
@@ -476,13 +484,15 @@ function preDecideRun({ node, filter, projectPath = '.', refreshCache = false })
|
|
|
476
484
|
}
|
|
477
485
|
|
|
478
486
|
// The single count rule for host and container runs; a second copy makes them disagree.
|
|
479
|
-
function judgeRun({ node, result, durationMs }) {
|
|
487
|
+
function judgeRun({ node, result, durationMs, logPath = null }) {
|
|
480
488
|
const entry = emptyRunEntry(node);
|
|
481
489
|
|
|
482
490
|
if (result.skipped) {
|
|
483
491
|
return { ...entry, durationMs, reason: result.reason || 'no test command detected' };
|
|
484
492
|
}
|
|
485
493
|
|
|
494
|
+
const logLine = logPath ? [`log completo: ${logPath}`] : [];
|
|
495
|
+
|
|
486
496
|
// A truncated log tail reads like a passing run cut mid-sentence: lead with the timeout.
|
|
487
497
|
if (result.timedOut) {
|
|
488
498
|
const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
|
|
@@ -492,7 +502,7 @@ function judgeRun({ node, result, durationMs }) {
|
|
|
492
502
|
durationMs,
|
|
493
503
|
timedOut: true,
|
|
494
504
|
reason: result.message,
|
|
495
|
-
output:
|
|
505
|
+
output: [result.message, ...logLine, tail].filter(Boolean).join('\n'),
|
|
496
506
|
};
|
|
497
507
|
}
|
|
498
508
|
|
|
@@ -511,18 +521,39 @@ function judgeRun({ node, result, durationMs }) {
|
|
|
511
521
|
}
|
|
512
522
|
if (verdict.verdict === 'drop') warnings.push(verdict.reason);
|
|
513
523
|
|
|
524
|
+
// Without a baseline, zero is the floor: a green run over no test is a suite that stopped running.
|
|
525
|
+
if (result.passed && count === 0 && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
|
|
526
|
+
return { ...entry, status: 'fail', durationMs, count, warnings, reason: 'suíte verde sem nenhum teste executado', output: '' };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (result.passed) {
|
|
530
|
+
return { ...entry, status: 'pass', durationMs, count, warnings, reason: result.reason ?? null, output: '' };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// The failing test sits thousands of lines above a TAP summary, never in the tail.
|
|
534
|
+
const failed = failureLines(result.output);
|
|
514
535
|
return {
|
|
515
536
|
...entry,
|
|
516
|
-
status:
|
|
537
|
+
status: 'fail',
|
|
517
538
|
durationMs,
|
|
518
539
|
count,
|
|
519
540
|
warnings,
|
|
520
541
|
// `test-build-failed` / `stale-binary`: refused before any test ran.
|
|
521
|
-
reason: result.reason ??
|
|
522
|
-
output:
|
|
542
|
+
reason: result.reason ?? failureSummary(failed),
|
|
543
|
+
output: [
|
|
544
|
+
...failed.map((name) => `falhou: ${name}`),
|
|
545
|
+
...logLine,
|
|
546
|
+
tailLines(result.output, OUTPUT_TAIL_LINES),
|
|
547
|
+
].filter(Boolean).join('\n'),
|
|
523
548
|
};
|
|
524
549
|
}
|
|
525
550
|
|
|
551
|
+
function failureSummary(failed) {
|
|
552
|
+
if (failed.length === 0) return null;
|
|
553
|
+
const more = failed.length > 3 ? ` (+${failed.length - 3})` : '';
|
|
554
|
+
return `falhou: ${failed.slice(0, 3).join('; ')}${more}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
526
557
|
function acceptRecordedDrops(testsNode) {
|
|
527
558
|
const runs = testsNode.runs.map(({ countDrop, ...run }) => (countDrop ? { ...run, status: 'pass', reason: null } : run));
|
|
528
559
|
return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
|
|
@@ -615,7 +646,7 @@ function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
|
|
|
615
646
|
// exited 0 is the one red it accepts: lowering the number is how deleting tests passes.
|
|
616
647
|
function recordBaselineFrom(projectPath, testsNode) {
|
|
617
648
|
const runs = testsNode.runs || [];
|
|
618
|
-
const refuse = (reason) => ({ recorded: false, reason, updates: [] });
|
|
649
|
+
const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [] });
|
|
619
650
|
|
|
620
651
|
if (runs.length === 0) return refuse('nenhum nó de teste executou');
|
|
621
652
|
|