@polymorphism-tech/morph-spec 9.7.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 +1 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +18 -9
- package/framework/evals/review-checklist.md +3 -3
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/shared/heavy-slots.js +41 -4
- package/framework/schemas/config.schema.json +1 -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 +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 +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/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/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/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/project/worktree.js +24 -2
- package/src/commands/state/finish.js +43 -1
- package/src/commands/state/report-check.js +36 -1
- package/src/commands/validation/verify.js +3 -0
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/heavy/gate.js +22 -1
- package/src/lib/review-reports.js +5 -3
- package/src/lib/tasks/test-nodes.js +24 -6
- package/src/lib/tasks/test-runner.js +40 -1
- package/src/lib/validators/verify-runner.js +50 -12
- package/src/utils/agents-installer.js +1 -1
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import { defineConfig } from 'vite';
|
|
2
|
-
import react from '@vitejs/plugin-react';
|
|
3
|
-
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
-
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import { dirname, resolve } from 'path';
|
|
7
|
-
|
|
8
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
-
const __dirname = dirname(__filename);
|
|
10
|
-
|
|
11
|
-
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
-
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
-
|
|
14
|
-
export default defineConfig({
|
|
15
|
-
root: __dirname,
|
|
16
|
-
plugins: [
|
|
17
|
-
react(),
|
|
18
|
-
tailwindcss(),
|
|
19
|
-
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
-
],
|
|
21
|
-
server: {
|
|
22
|
-
port: 3200,
|
|
23
|
-
},
|
|
24
|
-
});
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, resolve } from 'path';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
+
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
+
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
root: __dirname,
|
|
16
|
+
plugins: [
|
|
17
|
+
react(),
|
|
18
|
+
tailwindcss(),
|
|
19
|
+
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
+
],
|
|
21
|
+
server: {
|
|
22
|
+
port: 3200,
|
|
23
|
+
},
|
|
24
|
+
});
|
package/src/lib/heavy/gate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
2
2
|
import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
|
|
3
3
|
import {
|
|
4
|
-
acquireHeavy, awaitFreeMemory, describeBusy, heavyDir, releaseHeavy, updateLease,
|
|
4
|
+
acquireHeavy, awaitFreeMemory, describeBusy, enterVerifySuite, heavyDir, isVerifySuite, releaseHeavy, updateLease,
|
|
5
5
|
} from '../../../framework/hooks/shared/heavy-slots.js';
|
|
6
6
|
|
|
7
7
|
export class HeavyBusyError extends Error {
|
|
@@ -33,6 +33,7 @@ export function createHeavyGate({
|
|
|
33
33
|
let lease = null;
|
|
34
34
|
let admittedGb = 0;
|
|
35
35
|
let waitedMs = 0;
|
|
36
|
+
let suiteEntered = false;
|
|
36
37
|
let exitHandler = null;
|
|
37
38
|
let saved = null;
|
|
38
39
|
|
|
@@ -52,6 +53,7 @@ export function createHeavyGate({
|
|
|
52
53
|
lease = null;
|
|
53
54
|
restoreEnv();
|
|
54
55
|
entered = false;
|
|
56
|
+
suiteEntered = false;
|
|
55
57
|
};
|
|
56
58
|
|
|
57
59
|
const raiseFloor = (kind) => {
|
|
@@ -65,12 +67,29 @@ export function createHeavyGate({
|
|
|
65
67
|
admittedGb = floor;
|
|
66
68
|
};
|
|
67
69
|
|
|
70
|
+
const awaitSuite = () => {
|
|
71
|
+
const result = enterVerifySuite({
|
|
72
|
+
dir: heavyDir(env),
|
|
73
|
+
lease,
|
|
74
|
+
slots: config.slots,
|
|
75
|
+
waitTimeoutMs: config.waitTimeoutSec * 1000,
|
|
76
|
+
maxHoldMs: config.maxHoldMinutes * 60_000,
|
|
77
|
+
log,
|
|
78
|
+
...deps,
|
|
79
|
+
});
|
|
80
|
+
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
81
|
+
waitedMs += result.waitedMs;
|
|
82
|
+
suiteEntered = true;
|
|
83
|
+
};
|
|
84
|
+
|
|
68
85
|
return {
|
|
69
86
|
config,
|
|
70
87
|
get waitedMs() { return waitedMs; },
|
|
71
88
|
|
|
72
89
|
enter(node, kind = heavyClass(node)) {
|
|
90
|
+
const suite = isVerifySuite({ label, node });
|
|
73
91
|
if (entered) {
|
|
92
|
+
if (suite && lease && !suiteEntered) awaitSuite();
|
|
74
93
|
raiseFloor(kind);
|
|
75
94
|
updateLease(lease, { node });
|
|
76
95
|
return;
|
|
@@ -86,6 +105,7 @@ export function createHeavyGate({
|
|
|
86
105
|
feature,
|
|
87
106
|
node,
|
|
88
107
|
kind,
|
|
108
|
+
suite,
|
|
89
109
|
cwd: projectPath,
|
|
90
110
|
minFreeMemGb,
|
|
91
111
|
waitTimeoutMs: config.waitTimeoutSec * 1000,
|
|
@@ -96,6 +116,7 @@ export function createHeavyGate({
|
|
|
96
116
|
});
|
|
97
117
|
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
98
118
|
lease = result.lease;
|
|
119
|
+
suiteEntered = suite;
|
|
99
120
|
admittedGb = minFreeMemGb;
|
|
100
121
|
waitedMs = result.waitedMs;
|
|
101
122
|
}
|
|
@@ -53,8 +53,10 @@ const cleanId = (s) => String(s ?? '').replace(/[*`_]/g, '').trim().toUpperCase(
|
|
|
53
53
|
|
|
54
54
|
const isSeparator = (cells) => cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '');
|
|
55
55
|
|
|
56
|
-
// A
|
|
57
|
-
//
|
|
56
|
+
// A status table a confirmation lists under "Nenhum." is skipped; any other header off the
|
|
57
|
+
// contract still counts, so its rows land in `untyped` and pause the gate.
|
|
58
|
+
const isStatusHeader = (header) => /^(situacao|status)$/.test(normalize(header[1]));
|
|
59
|
+
|
|
58
60
|
function tableRows(body, keepHeader = () => true) {
|
|
59
61
|
const rows = [];
|
|
60
62
|
let skipping = false;
|
|
@@ -77,7 +79,7 @@ function tableRows(body, keepHeader = () => true) {
|
|
|
77
79
|
function parseBlockers(body) {
|
|
78
80
|
const blockers = [];
|
|
79
81
|
const untyped = [];
|
|
80
|
-
for (const cells of tableRows(body, (header) =>
|
|
82
|
+
for (const cells of tableRows(body, (header) => !isStatusHeader(header))) {
|
|
81
83
|
const row = { id: cleanId(cells[0]), type: normalize(cells[1]), where: cells[2] ?? '', evidence: cells[3] ?? '' };
|
|
82
84
|
(BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
|
|
83
85
|
}
|
|
@@ -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.
|
|
@@ -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,
|
|
@@ -24,8 +24,7 @@ import { runValidation } from './validation-runner.js';
|
|
|
24
24
|
import { checkOutputsTraceability } from './traceability-check.js';
|
|
25
25
|
import { createHeavyGate } from '../heavy/gate.js';
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
const DEFAULT_BUILD_TIMEOUT = 120_000;
|
|
27
|
+
const DEFAULT_BUILD_TIMEOUT = 1_800_000;
|
|
29
28
|
|
|
30
29
|
const VALIDATOR_FEEDBACK_LIMIT = 10;
|
|
31
30
|
|
|
@@ -364,7 +363,8 @@ async function runNodesInLinux({
|
|
|
364
363
|
// No exit code is NOT a pass: the container died before this node reported.
|
|
365
364
|
? { passed: false, output: raw.output || '', reason: raw.reason, exitCode: null }
|
|
366
365
|
: { passed: raw.exitCode === 0, output: raw.output || '', exitCode: raw.exitCode };
|
|
367
|
-
|
|
366
|
+
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
367
|
+
return judgeRun({ node, result, durationMs: raw.durationMs ?? 0, logPath });
|
|
368
368
|
});
|
|
369
369
|
|
|
370
370
|
return { runs, environment: suite.environment };
|
|
@@ -389,7 +389,9 @@ async function runOneTestNode({
|
|
|
389
389
|
const result = await _runTestSuite(projectPath, {
|
|
390
390
|
timeout, node, ...(childEnv ? { env: childEnv } : {}),
|
|
391
391
|
});
|
|
392
|
-
const
|
|
392
|
+
const durationMs = Date.now() - start;
|
|
393
|
+
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
394
|
+
const entry = judgeRun({ node, result, durationMs, logPath });
|
|
393
395
|
if (!evalChannel) return entry;
|
|
394
396
|
|
|
395
397
|
const judged = judgeEvalRun(entry, node, evalChannel.summaryPath);
|
|
@@ -397,6 +399,19 @@ async function runOneTestNode({
|
|
|
397
399
|
return judged;
|
|
398
400
|
}
|
|
399
401
|
|
|
402
|
+
// Fail-open: a log that cannot be written must not change the verdict.
|
|
403
|
+
function writeFailedRunLog(projectPath, node, result) {
|
|
404
|
+
if (result.passed || result.skipped || !result.output) return null;
|
|
405
|
+
const relPath = `.morph/logs/verify-tests-${String(node.id).replace(/[^\w.-]/g, '_')}.log`;
|
|
406
|
+
try {
|
|
407
|
+
mkdirSync(join(projectPath, '.morph', 'logs'), { recursive: true });
|
|
408
|
+
writeFileSync(join(projectPath, relPath), result.output);
|
|
409
|
+
return relPath;
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
400
415
|
function discardFile(path) {
|
|
401
416
|
try { rmSync(path, { force: true }); } catch { /* the next run removes it first */ }
|
|
402
417
|
}
|
|
@@ -476,13 +491,15 @@ function preDecideRun({ node, filter, projectPath = '.', refreshCache = false })
|
|
|
476
491
|
}
|
|
477
492
|
|
|
478
493
|
// The single count rule for host and container runs; a second copy makes them disagree.
|
|
479
|
-
function judgeRun({ node, result, durationMs }) {
|
|
494
|
+
function judgeRun({ node, result, durationMs, logPath = null }) {
|
|
480
495
|
const entry = emptyRunEntry(node);
|
|
481
496
|
|
|
482
497
|
if (result.skipped) {
|
|
483
498
|
return { ...entry, durationMs, reason: result.reason || 'no test command detected' };
|
|
484
499
|
}
|
|
485
500
|
|
|
501
|
+
const logLine = logPath ? [`log completo: ${logPath}`] : [];
|
|
502
|
+
|
|
486
503
|
// A truncated log tail reads like a passing run cut mid-sentence: lead with the timeout.
|
|
487
504
|
if (result.timedOut) {
|
|
488
505
|
const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
|
|
@@ -492,7 +509,7 @@ function judgeRun({ node, result, durationMs }) {
|
|
|
492
509
|
durationMs,
|
|
493
510
|
timedOut: true,
|
|
494
511
|
reason: result.message,
|
|
495
|
-
output:
|
|
512
|
+
output: [result.message, ...logLine, tail].filter(Boolean).join('\n'),
|
|
496
513
|
};
|
|
497
514
|
}
|
|
498
515
|
|
|
@@ -511,18 +528,39 @@ function judgeRun({ node, result, durationMs }) {
|
|
|
511
528
|
}
|
|
512
529
|
if (verdict.verdict === 'drop') warnings.push(verdict.reason);
|
|
513
530
|
|
|
531
|
+
// Without a baseline, zero is the floor: a green run over no test is a suite that stopped running.
|
|
532
|
+
if (result.passed && count === 0 && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
|
|
533
|
+
return { ...entry, status: 'fail', durationMs, count, warnings, reason: 'suíte verde sem nenhum teste executado', output: '' };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (result.passed) {
|
|
537
|
+
return { ...entry, status: 'pass', durationMs, count, warnings, reason: result.reason ?? null, output: '' };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// The failing test sits thousands of lines above a TAP summary, never in the tail.
|
|
541
|
+
const failed = failureLines(result.output);
|
|
514
542
|
return {
|
|
515
543
|
...entry,
|
|
516
|
-
status:
|
|
544
|
+
status: 'fail',
|
|
517
545
|
durationMs,
|
|
518
546
|
count,
|
|
519
547
|
warnings,
|
|
520
548
|
// `test-build-failed` / `stale-binary`: refused before any test ran.
|
|
521
|
-
reason: result.reason ??
|
|
522
|
-
output:
|
|
549
|
+
reason: result.reason ?? failureSummary(failed),
|
|
550
|
+
output: [
|
|
551
|
+
...failed.map((name) => `falhou: ${name}`),
|
|
552
|
+
...logLine,
|
|
553
|
+
tailLines(result.output, OUTPUT_TAIL_LINES),
|
|
554
|
+
].filter(Boolean).join('\n'),
|
|
523
555
|
};
|
|
524
556
|
}
|
|
525
557
|
|
|
558
|
+
function failureSummary(failed) {
|
|
559
|
+
if (failed.length === 0) return null;
|
|
560
|
+
const more = failed.length > 3 ? ` (+${failed.length - 3})` : '';
|
|
561
|
+
return `falhou: ${failed.slice(0, 3).join('; ')}${more}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
526
564
|
function acceptRecordedDrops(testsNode) {
|
|
527
565
|
const runs = testsNode.runs.map(({ countDrop, ...run }) => (countDrop ? { ...run, status: 'pass', reason: null } : run));
|
|
528
566
|
return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
|
|
@@ -615,7 +653,7 @@ function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
|
|
|
615
653
|
// exited 0 is the one red it accepts: lowering the number is how deleting tests passes.
|
|
616
654
|
function recordBaselineFrom(projectPath, testsNode) {
|
|
617
655
|
const runs = testsNode.runs || [];
|
|
618
|
-
const refuse = (reason) => ({ recorded: false, reason, updates: [] });
|
|
656
|
+
const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [] });
|
|
619
657
|
|
|
620
658
|
if (runs.length === 0) return refuse('nenhum nó de teste executou');
|
|
621
659
|
|
|
@@ -284,7 +284,7 @@ export const DISPATCH_CONTRACT = [
|
|
|
284
284
|
'ANTES de encerrar — é o retorno autoritativo, não a sua mensagem final.',
|
|
285
285
|
'2. ESTADO: NÃO toque em `plan.md`, `checks.json`, `feature.json` nem `state.json`: o estado é do orquestrador.',
|
|
286
286
|
'3. TESTE: a prova é do orquestrador (`morph-spec verify {feature}`, depois do seu retorno), não sua. Escreva os testes pelo orçamento: ' +
|
|
287
|
-
'um por Cn `prova: teste`, variantes em tabela (`[Theory]`/`it.each`); `leitura` sem teste. NÃO rode teste a cada marco nem faça ciclo red→green: ' +
|
|
287
|
+
'um por Cn `prova: teste`, na camada mais baixa que o prova (a de cima só para o que só existe lá), variantes em tabela (`[Theory]`/`it.each`); `leitura` sem teste. NÃO rode teste a cada marco nem faça ciclo red→green: ' +
|
|
288
288
|
'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório ' +
|
|
289
289
|
'(`vitest related <arquivos da fatia> --run`; `dotnet test --filter "FullyQualifiedName~<Classe>" --no-build` após o build); ' +
|
|
290
290
|
'se falhar, corrija e rode só os que falharam. Teto: 3 rodadas (`verify --filter` conta, exit 3 não); ' +
|