@polymorphism-tech/morph-spec 10.0.0 → 10.2.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 +2 -0
- package/README.md +3 -7
- package/bin/morph-spec.js +1 -41
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +27 -38
- package/docs/QUICKSTART.md +31 -191
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +2 -2
- package/framework/agents.json +0 -1
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-preflight.md +6 -7
- package/framework/docs/CLI.md +24 -21
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/core/validate-completion.js +0 -35
- package/framework/hooks/shared/e2e-check.js +44 -7
- package/framework/hooks/shared/heavy-command.js +12 -1
- package/framework/hooks/shared/next-step.js +4 -4
- package/framework/mcp/registry.json +4 -4
- package/framework/phases.json +4 -4
- package/framework/rules/ai-agents-standards.md +1 -1
- package/framework/rules/data-standards.md +1 -1
- package/framework/rules/hangfire-standards.md +1 -1
- package/framework/rules/neon-auth-standards.md +1 -1
- package/framework/rules/neon-standards.md +3 -3
- package/framework/schemas/config.schema.json +20 -1
- package/framework/schemas/feature.schema.json +1 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -7
- package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
- package/framework/skills/core/morph-apply/references/review.md +5 -5
- package/framework/skills/core/morph-plan/SKILL.md +22 -16
- package/framework/skills/core/morph-plan/references/critique.md +11 -8
- package/framework/skills/core/morph-plan/references/interview.md +18 -6
- package/framework/skills/core/morph-uiux/SKILL.md +2 -2
- package/framework/standards/STANDARDS.json +20 -24
- package/framework/standards/STANDARDS.meta.json +12 -19
- package/framework/standards/ai-agents/rag-custom-pgvector.md +1 -0
- package/framework/standards/architecture/stack-baseline.md +26 -3
- package/framework/standards/backend/database/postgresql/database.md +22 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +10 -9
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +2 -2
- package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
- package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
- package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
- package/framework/standards/integration/mcp/mcp-tools.md +20 -20
- package/framework/templates/docs/plan.md +4 -1
- package/framework/templates/examples/design-system-examples.md +0 -3
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +0 -1
- package/src/commands/state/approve.js +9 -109
- package/src/commands/state/auto-approval.js +1 -1
- package/src/commands/state/create.js +1 -1
- package/src/commands/state/gate-check.js +2 -2
- package/src/commands/state/report-check.js +44 -2
- package/src/commands/validation/verify.js +7 -3
- package/src/core/state/state-manager.js +0 -24
- package/src/lib/detectors/index.js +1 -1
- package/src/lib/heavy/cleanup.js +8 -2
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/plan/checks.js +8 -1
- package/src/lib/plan/critique.js +23 -3
- package/src/lib/review-confirmation.js +67 -0
- package/src/lib/review-reports.js +1 -0
- package/src/lib/risk-detector.js +2 -2
- package/src/lib/tasks/test-nodes.js +5 -0
- package/src/lib/tasks/test-runner.js +37 -43
- package/src/lib/telemetry/telemetry-reader.js +1 -2
- package/src/lib/telemetry/verify-event.js +7 -0
- package/src/lib/validators/verify-cache.js +63 -0
- package/src/lib/validators/verify-runner.js +98 -23
- package/src/utils/agents-installer.js +2 -2
- package/src/utils/hooks-installer.js +0 -20
- package/bin/validate.js +0 -212
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
- package/src/commands/observability/telemetry.js +0 -60
- package/src/commands/validation/validate-feature.js +0 -337
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { execSync, execFileSync } from 'child_process';
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, realpathSync
|
|
1
|
+
import { exec as execCallback, execSync, execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
|
|
3
3
|
import { join, resolve, relative, isAbsolute } from 'path';
|
|
4
4
|
import { globSync } from 'glob';
|
|
5
5
|
|
|
@@ -157,6 +157,7 @@ function countCompletedRuns(output) {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
const NO_BUILD_FLAG = /(^|\s)--no-build(\s|$)/;
|
|
160
|
+
const DOTNET_COMMAND = /^\s*dotnet\s/;
|
|
160
161
|
|
|
161
162
|
// `[]` is the safe answer: nothing compiled here, so a `--no-build` in the same command is refused.
|
|
162
163
|
export function extractCsprojTargets(command) {
|
|
@@ -177,28 +178,6 @@ export function resolveTestBuildTargets(node) {
|
|
|
177
178
|
return extractCsprojTargets(node.command);
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
const DOTNET_BUILDABLE = /"([^"]+\.(?:slnx?|csproj))"|'([^']+\.(?:slnx?|csproj))'|(\S+\.(?:slnx?|csproj))/gi;
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* What `dotnet test` compiles for this node, relative to its `cwd`: declared `build`,
|
|
184
|
-
* else what the command names, else the directory's single solution/project; `[]` = undiscoverable.
|
|
185
|
-
*/
|
|
186
|
-
export function discoverDotnetBuildTargets(node, cwd) {
|
|
187
|
-
if (node?.runner !== 'dotnet') return [];
|
|
188
|
-
if (Array.isArray(node.build) && node.build.length > 0) return node.build;
|
|
189
|
-
const command = typeof node.command === 'string' ? node.command : '';
|
|
190
|
-
const named = [...command.matchAll(DOTNET_BUILDABLE)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
191
|
-
if (named.length > 0) return [...new Set(named)];
|
|
192
|
-
|
|
193
|
-
const dirArg = command.split(/\s+/).find((token) => token && !token.startsWith('-') && token !== 'dotnet' && token !== 'test'
|
|
194
|
-
&& isDirectory(join(cwd, token)));
|
|
195
|
-
const dir = dirArg ?? '.';
|
|
196
|
-
let entries;
|
|
197
|
-
try { entries = readdirSync(join(cwd, dir)); } catch { return []; }
|
|
198
|
-
const only = singleDotnetBuildable(entries);
|
|
199
|
-
return only ? [dir === '.' ? only : join(dir, only).replace(/\\/g, '/')] : [];
|
|
200
|
-
}
|
|
201
|
-
|
|
202
181
|
function singleDotnetBuildable(files) {
|
|
203
182
|
const solutions = files.filter((f) => /\.slnx?$/i.test(f));
|
|
204
183
|
const projects = files.filter((f) => /\.csproj$/i.test(f));
|
|
@@ -237,10 +216,6 @@ export function discoverDotnetSolution(root) {
|
|
|
237
216
|
return { target: single, candidates: pool };
|
|
238
217
|
}
|
|
239
218
|
|
|
240
|
-
function isDirectory(path) {
|
|
241
|
-
try { return statSync(path).isDirectory(); } catch { return false; }
|
|
242
|
-
}
|
|
243
|
-
|
|
244
219
|
// Derived from build-check.js, the single spelling of "dotnet build". `--no-restore` is
|
|
245
220
|
// dropped: a test project outside the solution may never have been restored.
|
|
246
221
|
export function dotnetBuildCommandFor(target, projectPath) {
|
|
@@ -253,7 +228,7 @@ export function dotnetBuildCommandFor(target, projectPath) {
|
|
|
253
228
|
|
|
254
229
|
// Runs only with declared `build[]` or `--no-build`: a plain `dotnet test` builds
|
|
255
230
|
// itself, and pre-compiling it would double the clock of the hot path.
|
|
256
|
-
function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
|
|
231
|
+
function runTestPrebuild({ node, cwd, exec, timeout, projectPath, builtTargets = new Set() }) {
|
|
257
232
|
if (node.runner !== 'dotnet') return { built: false, failure: null };
|
|
258
233
|
|
|
259
234
|
const declared = Array.isArray(node.build) ? node.build : [];
|
|
@@ -265,10 +240,13 @@ function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
|
|
|
265
240
|
const outputs = [];
|
|
266
241
|
|
|
267
242
|
for (const target of targets) {
|
|
243
|
+
const key = `${cwd}\0${target}`;
|
|
244
|
+
if (builtTargets.has(key)) continue;
|
|
268
245
|
const command = dotnetBuildCommandFor(target, projectPath);
|
|
269
246
|
if (!command) return { built: false, failure: null };
|
|
270
247
|
try {
|
|
271
248
|
exec(command, { cwd, timeout, encoding: 'utf8', stdio: 'pipe' });
|
|
249
|
+
builtTargets.add(key);
|
|
272
250
|
} catch (err) {
|
|
273
251
|
outputs.push(`[${command}]\n${(err.stdout || '') + (err.stderr || '')}`);
|
|
274
252
|
return {
|
|
@@ -511,12 +489,12 @@ export function runTestSuite(projectPath = '.', opts = {}) {
|
|
|
511
489
|
const cwd = resolve(projectPath, node.cwd || '.');
|
|
512
490
|
|
|
513
491
|
// Prebuild BEFORE judging `--no-build`: the build is what makes the flag legal.
|
|
514
|
-
const prebuild = runTestPrebuild({ node, cwd, exec, timeout, projectPath });
|
|
492
|
+
const prebuild = runTestPrebuild({ node, cwd, exec, timeout, projectPath, builtTargets: opts.builtTargets });
|
|
515
493
|
if (prebuild.failure) return prebuild.failure;
|
|
516
494
|
|
|
517
495
|
// `--no-build` without a build here would run a stale assembly. The command is never
|
|
518
496
|
// rewritten: a silent fix would make the reported `command` a lie about what ran.
|
|
519
|
-
if (
|
|
497
|
+
if (DOTNET_COMMAND.test(cmd) && NO_BUILD_FLAG.test(cmd) && !prebuild.built && !opts.solutionBuilt) {
|
|
520
498
|
return {
|
|
521
499
|
passed: false,
|
|
522
500
|
buildFailed: false,
|
|
@@ -530,18 +508,17 @@ export function runTestSuite(projectPath = '.', opts = {}) {
|
|
|
530
508
|
// Layered OVER process.env: a child that lost PATH fails for reasons unrelated to the suite.
|
|
531
509
|
const env = opts.env ? { ...process.env, ...opts.env } : undefined;
|
|
532
510
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
} catch (err) {
|
|
511
|
+
const execOptions = {
|
|
512
|
+
cwd,
|
|
513
|
+
timeout,
|
|
514
|
+
encoding: 'utf8',
|
|
515
|
+
stdio: 'pipe',
|
|
516
|
+
// Node's 1 MiB default kills a large suite mid-run (ENOBUFS), which then reads as a red suite.
|
|
517
|
+
maxBuffer: TEST_OUTPUT_MAX_BUFFER,
|
|
518
|
+
...(env ? { env } : {}),
|
|
519
|
+
};
|
|
520
|
+
const passed = (output) => ({ passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout });
|
|
521
|
+
const failed = (err) => {
|
|
545
522
|
// A spawn failure or a kill leaves both streams empty; its own error is then the only lead.
|
|
546
523
|
const output = (err.stdout || '') + (err.stderr || '')
|
|
547
524
|
|| [err.code, err.signal, err.message].filter(Boolean).join(' ');
|
|
@@ -571,5 +548,22 @@ export function runTestSuite(projectPath = '.', opts = {}) {
|
|
|
571
548
|
exitCode: err.status ?? 1,
|
|
572
549
|
timeoutMs: timeout,
|
|
573
550
|
};
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
if (opts.background) return runInBackground(cmd, execOptions).then(passed, failed);
|
|
554
|
+
try {
|
|
555
|
+
return passed(exec(cmd, execOptions));
|
|
556
|
+
} catch (err) {
|
|
557
|
+
return failed(err);
|
|
574
558
|
}
|
|
575
559
|
}
|
|
560
|
+
|
|
561
|
+
// `exec` reports the exit code in `err.code`; the shared handler reads `err.status`, as from execSync.
|
|
562
|
+
function runInBackground(cmd, options) {
|
|
563
|
+
return new Promise((resolvePromise, reject) => {
|
|
564
|
+
execCallback(cmd, options, (err, stdout, stderr) => {
|
|
565
|
+
if (!err) return resolvePromise(stdout);
|
|
566
|
+
return reject(Object.assign(err, { stdout, stderr, status: Number.isInteger(err.code) ? err.code : null }));
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
@@ -164,8 +164,7 @@ export function aggregateTelemetry(events, opts = {}) {
|
|
|
164
164
|
};
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
//
|
|
168
|
-
// there would leak into that contract. Bytes re-ingested is a proxy for cost, never a token count.
|
|
167
|
+
// Bytes re-ingested is a proxy for cost, never a token count.
|
|
169
168
|
|
|
170
169
|
function mapToObj(map) {
|
|
171
170
|
const out = {};
|
|
@@ -11,6 +11,11 @@ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerp
|
|
|
11
11
|
if (!node) continue;
|
|
12
12
|
nodes[name] = { status: node.status, durationMs: Number.isFinite(node.durationMs) ? node.durationMs : null };
|
|
13
13
|
}
|
|
14
|
+
if (nodes.tests && Array.isArray(result.nodes.tests.runs)) {
|
|
15
|
+
nodes.tests.runs = result.nodes.tests.runs.map(({ id, status, durationMs }) => ({
|
|
16
|
+
id, status: status ?? null, durationMs: Number.isFinite(durationMs) ? durationMs : null,
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
14
19
|
if (nodes.validators) {
|
|
15
20
|
nodes.validators.ran = [...(result.nodes.validators.ran || [])];
|
|
16
21
|
nodes.validators.skipped = (result.nodes.validators.skipped || [])
|
|
@@ -26,6 +31,8 @@ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerp
|
|
|
26
31
|
persisted,
|
|
27
32
|
reason: persisted ? null : (reason ?? null),
|
|
28
33
|
source,
|
|
34
|
+
reused: result.reused ?? [],
|
|
35
|
+
flaky: result.flaky ?? [],
|
|
29
36
|
nodes,
|
|
30
37
|
};
|
|
31
38
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
|
|
4
|
+
// Ignored files (`.env`, a database) are outside the fingerprint: `verify --fresh` is the way out.
|
|
5
|
+
function cachePath(gitCommonDir, featureName) {
|
|
6
|
+
return join(gitCommonDir, 'morph-spec', `verify-nodes-${featureName}.json`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readVerifyCache(gitCommonDir, featureName) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(cachePath(gitCommonDir, featureName), 'utf8'));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Fail-open: a cache that cannot be written costs a rerun, never a verdict. */
|
|
18
|
+
export function writeVerifyCache(gitCommonDir, featureName, entry) {
|
|
19
|
+
try {
|
|
20
|
+
const path = cachePath(gitCommonDir, featureName);
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
writeFileSync(path, JSON.stringify(entry));
|
|
23
|
+
} catch { /* see above */ }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const withoutOutput = ({ output: _output, ...rest }) => rest;
|
|
27
|
+
|
|
28
|
+
export function cacheEntryFrom(fingerprint, nodes) {
|
|
29
|
+
return {
|
|
30
|
+
fingerprint,
|
|
31
|
+
build: withoutOutput(nodes.build),
|
|
32
|
+
tests: (nodes.tests.runs || []).map(withoutOutput),
|
|
33
|
+
e2e: withoutOutput(nodes.e2e),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NOTHING = Object.freeze({
|
|
38
|
+
build: null, tests: new Map(), e2e: null, redFirst: new Set(), retried: new Set(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Same tree: green nodes stand, red ones run again (`retried`, to name a flake when they turn green).
|
|
43
|
+
* Tree moved: nothing stands, and last round's red suites run first (`redFirst`).
|
|
44
|
+
*/
|
|
45
|
+
export function planReuse(previous, fingerprint) {
|
|
46
|
+
if (!previous || !fingerprint) return NOTHING;
|
|
47
|
+
const runs = previous.tests || [];
|
|
48
|
+
const red = new Set(runs.filter((run) => run.status === 'fail').map((run) => run.id));
|
|
49
|
+
|
|
50
|
+
if (previous.fingerprint !== fingerprint) return { ...NOTHING, redFirst: red };
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
build: previous.build?.status === 'pass' ? previous.build : null,
|
|
54
|
+
tests: new Map(runs.filter((run) => run.status === 'pass').map((run) => [run.id, run])),
|
|
55
|
+
e2e: previous.e2e?.status === 'pass' ? previous.e2e : null,
|
|
56
|
+
redFirst: red,
|
|
57
|
+
retried: previous.e2e?.status === 'fail' ? new Set([...red, 'e2e']) : red,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function reusedEntry(entry) {
|
|
62
|
+
return { ...entry, output: '', durationMs: 0, reused: true };
|
|
63
|
+
}
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
} from '../tasks/eval-node.js';
|
|
23
23
|
import { runValidation } from './validation-runner.js';
|
|
24
24
|
import { createHeavyGate } from '../heavy/gate.js';
|
|
25
|
+
import {
|
|
26
|
+
cacheEntryFrom, planReuse, readVerifyCache, reusedEntry, writeVerifyCache,
|
|
27
|
+
} from './verify-cache.js';
|
|
25
28
|
|
|
26
29
|
const DEFAULT_BUILD_TIMEOUT = 1_800_000;
|
|
27
30
|
|
|
@@ -62,6 +65,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
62
65
|
refreshCache = false,
|
|
63
66
|
linux = false,
|
|
64
67
|
linuxImage = null,
|
|
68
|
+
fingerprint = null,
|
|
69
|
+
fresh = false,
|
|
65
70
|
_runLinuxSuite = runLinuxSuite,
|
|
66
71
|
_exec = undefined,
|
|
67
72
|
_runBuildCheck = runBuildCheck,
|
|
@@ -117,13 +122,23 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
117
122
|
const nodes = {};
|
|
118
123
|
const feedback = [];
|
|
119
124
|
|
|
120
|
-
|
|
125
|
+
// Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
|
|
126
|
+
const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
|
|
127
|
+
&& !refreshCache && !linux && !freshE2e
|
|
128
|
+
? resolveGitCommonDir(projectPath)
|
|
129
|
+
: null;
|
|
130
|
+
const reuse = planReuse(cacheDir && !fresh ? readVerifyCache(cacheDir, featureName) : null, fingerprint);
|
|
131
|
+
|
|
132
|
+
nodes.build = reuse.build
|
|
133
|
+
? reusedEntry(reuse.build)
|
|
134
|
+
: await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
|
|
121
135
|
appendBuildFeedback(feedback, nodes.build);
|
|
122
136
|
|
|
123
137
|
// Only a TYPED --timeout reaches the tests: the build default would outrank
|
|
124
138
|
// config.project.testTimeoutMs and the per-project scaling.
|
|
125
139
|
const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
|
|
126
140
|
const stackNodes = [];
|
|
141
|
+
const buildFacts = { solutionBuilt: builtDotnet(nodes.build), builtTargets: new Set() };
|
|
127
142
|
nodes.tests = await runTestsNode({
|
|
128
143
|
projectPath,
|
|
129
144
|
testPlan,
|
|
@@ -136,6 +151,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
136
151
|
linuxImage,
|
|
137
152
|
gate,
|
|
138
153
|
stackNodes,
|
|
154
|
+
reuse,
|
|
155
|
+
buildFacts,
|
|
139
156
|
_runTestSuite,
|
|
140
157
|
_runLinuxSuite,
|
|
141
158
|
_exec,
|
|
@@ -145,25 +162,28 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
145
162
|
nodes.validators = await runValidatorsNode({ projectPath, featureName, _runValidation });
|
|
146
163
|
appendValidatorsFeedback(feedback, nodes.validators);
|
|
147
164
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
165
|
+
// A suite with `e2e: true` still to run needs the stack's env, which only a real run returns.
|
|
166
|
+
const e2e = reuse.e2e && stackNodes.length === 0 && nodes.tests.status !== 'fail'
|
|
167
|
+
? { node: reusedEntry(reuse.e2e), stackEnv: null }
|
|
168
|
+
: await runE2eNode({
|
|
169
|
+
projectPath,
|
|
170
|
+
featureName,
|
|
171
|
+
skipE2e,
|
|
172
|
+
freshE2e,
|
|
173
|
+
buildNode: nodes.build,
|
|
174
|
+
testsNode: nodes.tests,
|
|
175
|
+
hasStackNodes: stackNodes.length > 0,
|
|
176
|
+
gate,
|
|
177
|
+
_runE2eCheck,
|
|
178
|
+
_e2eApplicability,
|
|
179
|
+
});
|
|
160
180
|
nodes.e2e = e2e.node;
|
|
161
181
|
appendE2eFeedback(feedback, nodes.e2e);
|
|
162
182
|
|
|
163
183
|
if (stackNodes.length > 0) {
|
|
164
184
|
nodes.tests = await runStackTestNodes({
|
|
165
185
|
projectPath, testsNode: nodes.tests, stackNodes, e2eNode: nodes.e2e, stackEnv: e2e.stackEnv,
|
|
166
|
-
timeout: explicitTimeout, gate, _runTestSuite,
|
|
186
|
+
timeout: explicitTimeout, gate, buildFacts, _runTestSuite,
|
|
167
187
|
});
|
|
168
188
|
appendTestsFeedback(feedback, nodes.tests);
|
|
169
189
|
}
|
|
@@ -180,6 +200,13 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
180
200
|
|
|
181
201
|
const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
|
|
182
202
|
|
|
203
|
+
if (cacheDir) writeVerifyCache(cacheDir, featureName, cacheEntryFrom(fingerprint, nodes));
|
|
204
|
+
|
|
205
|
+
const reused = reusedNames(nodes);
|
|
206
|
+
if (reused.length > 0) feedback.push(`[verify] aproveitado da rodada anterior na mesma árvore: ${reused.join(', ')}`);
|
|
207
|
+
const flaky = flakyNames(nodes, reuse.retried);
|
|
208
|
+
if (flaky.length > 0) feedback.push(`[verify] vermelho sem mudança de código, verde agora (instável): ${flaky.join(', ')}`);
|
|
209
|
+
|
|
183
210
|
return {
|
|
184
211
|
version: 1,
|
|
185
212
|
feature: featureName,
|
|
@@ -187,10 +214,28 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
187
214
|
status,
|
|
188
215
|
nodes,
|
|
189
216
|
feedback,
|
|
217
|
+
reused,
|
|
218
|
+
flaky,
|
|
190
219
|
...(baseline ? { baseline } : {}),
|
|
191
220
|
};
|
|
192
221
|
}
|
|
193
222
|
|
|
223
|
+
function reusedNames(nodes) {
|
|
224
|
+
return [
|
|
225
|
+
...(nodes.build.reused ? ['build'] : []),
|
|
226
|
+
...(nodes.tests.runs || []).filter((run) => run.reused).map((run) => `tests/${run.id}`),
|
|
227
|
+
...(nodes.e2e.reused ? ['e2e'] : []),
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function flakyNames(nodes, retried) {
|
|
232
|
+
return [
|
|
233
|
+
...(nodes.tests.runs || []).filter((run) => run.status === 'pass' && retried.has(run.id)).map((run) => `tests/${run.id}`),
|
|
234
|
+
...(nodes.e2e.status === 'pass' && retried.has('e2e') ? ['e2e'] : []),
|
|
235
|
+
...(nodes.e2e.flakySpecs || []).map((spec) => `e2e: ${spec}`),
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
|
|
194
239
|
/**
|
|
195
240
|
* A root `dotnet build` with no solution at the root fails MSB1003: build the
|
|
196
241
|
* repository's single solution instead, or say which candidates and which key decide.
|
|
@@ -259,7 +304,7 @@ function appendBuildFeedback(feedback, buildNode) {
|
|
|
259
304
|
// with `status` because verify.js and the persisted stamp read `nodes.tests.status`.
|
|
260
305
|
async function runTestsNode({
|
|
261
306
|
projectPath, testPlan, filter, skipTests, buildNode, timeout, refreshCache = false,
|
|
262
|
-
linux = false, linuxImage = null, gate, stackNodes = [], _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
|
|
307
|
+
linux = false, linuxImage = null, gate, stackNodes = [], reuse, buildFacts, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
|
|
263
308
|
}) {
|
|
264
309
|
const { nodes, source } = testPlan;
|
|
265
310
|
const command = nodes.map((n) => n.command).filter(Boolean).join(' && ') || null;
|
|
@@ -282,24 +327,53 @@ async function runTestsNode({
|
|
|
282
327
|
return rollUpTestRuns(runs, { command, source, environment });
|
|
283
328
|
}
|
|
284
329
|
|
|
330
|
+
const declaredOrder = new Map(nodes.map((node, index) => [node.id, index]));
|
|
331
|
+
const redFirst = [...nodes].sort((a, b) => reuse.redFirst.has(b.id) - reuse.redFirst.has(a.id));
|
|
332
|
+
|
|
333
|
+
// A round chasing a red suite stays serial: it must be able to stop at the first one.
|
|
334
|
+
const together = reuse.redFirst.size > 0
|
|
335
|
+
? []
|
|
336
|
+
: nodes.filter((node) => node.parallel && !node.e2e && !reuse.tests.has(node.id));
|
|
337
|
+
const finished = new Map(await Promise.all(together.map(async (node) => [node.id, await runOneTestNode({
|
|
338
|
+
projectPath, node, filter, timeout, refreshCache, gate, buildFacts, _runTestSuite,
|
|
339
|
+
})])));
|
|
340
|
+
|
|
285
341
|
const runs = [];
|
|
286
|
-
|
|
342
|
+
let stillRed = null;
|
|
343
|
+
for (const node of redFirst) {
|
|
344
|
+
if (reuse.tests.has(node.id)) {
|
|
345
|
+
runs.push(reusedEntry(reuse.tests.get(node.id)));
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (finished.has(node.id)) {
|
|
349
|
+
runs.push(finished.get(node.id));
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (stillRed) {
|
|
353
|
+
runs.push({ ...emptyRunEntry(node), reason: `adiado: ${stillRed} segue vermelho` });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
287
356
|
if (node.e2e && !preDecideRun({ node, filter, projectPath, refreshCache })) {
|
|
288
357
|
stackNodes.push(node);
|
|
289
358
|
runs.push(emptyRunEntry(node));
|
|
290
359
|
continue;
|
|
291
360
|
}
|
|
292
|
-
|
|
293
|
-
projectPath, node, filter, timeout, refreshCache, gate, _runTestSuite,
|
|
294
|
-
})
|
|
361
|
+
const run = await runOneTestNode({
|
|
362
|
+
projectPath, node, filter, timeout, refreshCache, gate, buildFacts, _runTestSuite,
|
|
363
|
+
});
|
|
364
|
+
runs.push(run);
|
|
365
|
+
if (run.status === 'fail' && reuse.redFirst.has(node.id)) stillRed = node.id;
|
|
295
366
|
}
|
|
296
367
|
|
|
368
|
+
runs.sort((a, b) => declaredOrder.get(a.id) - declaredOrder.get(b.id));
|
|
297
369
|
return rollUpTestRuns(runs, { command, source });
|
|
298
370
|
}
|
|
299
371
|
|
|
300
372
|
// No stack, no run: the skip carries the e2e verdict as reason AND warning, so the
|
|
301
373
|
// suite never disappears behind a green rollup.
|
|
302
|
-
|
|
374
|
+
const builtDotnet = (buildNode) => buildNode.status === 'pass' && /\bdotnet build\b/.test(buildNode.command ?? '');
|
|
375
|
+
|
|
376
|
+
async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode, stackEnv, timeout, gate, buildFacts, _runTestSuite }) {
|
|
303
377
|
const byId = new Map(stackNodes.map((node) => [node.id, node]));
|
|
304
378
|
const runs = [];
|
|
305
379
|
for (const run of testsNode.runs) {
|
|
@@ -310,7 +384,7 @@ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode,
|
|
|
310
384
|
runs.push({ ...run, reason, warnings: [reason] });
|
|
311
385
|
continue;
|
|
312
386
|
}
|
|
313
|
-
runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, _runTestSuite }));
|
|
387
|
+
runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, buildFacts, _runTestSuite }));
|
|
314
388
|
}
|
|
315
389
|
return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
|
|
316
390
|
}
|
|
@@ -364,7 +438,7 @@ async function runNodesInLinux({
|
|
|
364
438
|
}
|
|
365
439
|
|
|
366
440
|
async function runOneTestNode({
|
|
367
|
-
projectPath, node, filter, timeout, refreshCache = false, gate, env = null, _runTestSuite,
|
|
441
|
+
projectPath, node, filter, timeout, refreshCache = false, gate, env = null, buildFacts = {}, _runTestSuite,
|
|
368
442
|
}) {
|
|
369
443
|
const decided = preDecideRun({ node, filter, projectPath, refreshCache });
|
|
370
444
|
if (decided) return decided;
|
|
@@ -380,7 +454,7 @@ async function runOneTestNode({
|
|
|
380
454
|
const start = Date.now();
|
|
381
455
|
const childEnv = env || evalChannel ? { ...(env || {}), ...(evalChannel?.env || {}) } : null;
|
|
382
456
|
const result = await _runTestSuite(projectPath, {
|
|
383
|
-
timeout, node, ...(childEnv ? { env: childEnv } : {}),
|
|
457
|
+
timeout, node, ...buildFacts, background: node.parallel === true, ...(childEnv ? { env: childEnv } : {}),
|
|
384
458
|
});
|
|
385
459
|
const durationMs = Date.now() - start;
|
|
386
460
|
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
@@ -763,6 +837,7 @@ async function runE2eNode({
|
|
|
763
837
|
output: tailLines(result.output || '', OUTPUT_TAIL_LINES),
|
|
764
838
|
count: Number.isInteger(result.count) ? result.count : null,
|
|
765
839
|
warnings: Array.isArray(result.warnings) ? result.warnings : [],
|
|
840
|
+
flakySpecs: result.flakySpecs ?? [],
|
|
766
841
|
},
|
|
767
842
|
stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
|
|
768
843
|
};
|
|
@@ -183,7 +183,7 @@ function buildFrontmatter(agent, description, knownMcpIds, maxTurns) {
|
|
|
183
183
|
`maxTurns: ${maxTurns}`,
|
|
184
184
|
];
|
|
185
185
|
if (agent.color) lines.push(`color: ${agent.color}`);
|
|
186
|
-
lines.push(
|
|
186
|
+
lines.push('');
|
|
187
187
|
return lines.join('\n');
|
|
188
188
|
}
|
|
189
189
|
|
|
@@ -208,7 +208,7 @@ export const REVIEW_REPORT_CONTRACT = [
|
|
|
208
208
|
'tabela `## Bloqueadores` (`| id | tipo | onde | evidência |`; vazia = `Nenhum.`) e `## Notas`. ' +
|
|
209
209
|
'Bloqueador só com evidência e tipo; o resto é nota, e nota nunca pede rodada nova.',
|
|
210
210
|
'',
|
|
211
|
-
'Numa confirmação (
|
|
211
|
+
'Numa confirmação (um dispatch novo te traz depois da correção, com o `sha` novo) acrescente ao fim uma seção nova com ' +
|
|
212
212
|
`\`${VERDICT_TOKEN}\` e \`${REVIEWED_SHA_TOKEN} {sha novo}\`: se você abriu bloqueadores, julgue só eles; ` +
|
|
213
213
|
'se não abriu, julgue só o delta `{sha anterior}..{sha novo}` — o que a correção abriu. ' +
|
|
214
214
|
'O relatório é append-only: vale o último veredito e, na `## Checks`, a última linha de cada Cn.',
|
|
@@ -6,7 +6,6 @@ import { realEffects } from '../lib/installers/effects.js';
|
|
|
6
6
|
|
|
7
7
|
const HOOKS_VERSION = '3.17.0';
|
|
8
8
|
|
|
9
|
-
const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
|
|
10
9
|
|
|
11
10
|
// `Edit(path)` only: an Edit rule also covers Write, while a `Write(path)` rule
|
|
12
11
|
// matches nothing and makes Claude Code warn on every session start.
|
|
@@ -195,7 +194,6 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
|
|
|
195
194
|
const managed = settings._morph_managed || {};
|
|
196
195
|
const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
|
|
197
196
|
|
|
198
|
-
removeOldDispatch(settings);
|
|
199
197
|
removeMorphHooks(settings);
|
|
200
198
|
|
|
201
199
|
let installed = 0;
|
|
@@ -250,8 +248,6 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
|
|
|
250
248
|
const STALE_ALLOW = [
|
|
251
249
|
'Write(.morph/features/**)',
|
|
252
250
|
'Write(.morph/context/**)',
|
|
253
|
-
'Bash(morph-spec mutate:*)',
|
|
254
|
-
'Bash(npx morph-spec mutate:*)',
|
|
255
251
|
];
|
|
256
252
|
settings.permissions.allow = settings.permissions.allow.filter(
|
|
257
253
|
p => !MORPH_ALLOW.includes(p) && !STALE_ALLOW.includes(p)
|
|
@@ -319,22 +315,6 @@ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOve
|
|
|
319
315
|
return { installed: true, globalDir };
|
|
320
316
|
}
|
|
321
317
|
|
|
322
|
-
function removeOldDispatch(settings) {
|
|
323
|
-
if (!settings.hooks?.PostToolUse) return;
|
|
324
|
-
|
|
325
|
-
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
|
|
326
|
-
if (!Array.isArray(entry.hooks)) return true;
|
|
327
|
-
return !entry.hooks.some(h =>
|
|
328
|
-
h.command === OLD_DISPATCH_COMMAND ||
|
|
329
|
-
(typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
|
|
330
|
-
);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
if (settings.hooks.PostToolUse.length === 0) {
|
|
334
|
-
delete settings.hooks.PostToolUse;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
318
|
function removeMorphHooks(settings) {
|
|
339
319
|
const morphPathPattern = /framework\/hooks\/claude-code\//;
|
|
340
320
|
|