@polymorphism-tech/morph-spec 10.1.1 → 10.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -7
- package/bin/morph-spec.js +10 -41
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +1 -1
- package/framework/agents.json +1 -2
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-preflight.md +3 -4
- package/framework/docs/CLI.md +45 -22
- package/framework/evals/review-checklist.md +5 -5
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/core/heavy-gate.js +21 -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/heavy-config.js +12 -0
- package/framework/hooks/shared/host-services.js +9 -6
- package/framework/hooks/shared/next-step.js +12 -8
- package/framework/phases.json +4 -4
- package/framework/rules/plan-grammar.md +4 -1
- package/framework/schemas/config.schema.json +59 -1
- package/framework/schemas/feature.schema.json +15 -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 -5
- package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
- package/framework/skills/core/morph-apply/references/machine.md +3 -2
- package/framework/skills/core/morph-apply/references/review.md +9 -7
- package/framework/skills/core/morph-plan/SKILL.md +7 -6
- package/framework/skills/core/morph-plan/references/critique.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +2 -2
- 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/docs/plan.md +2 -1
- package/framework/templates/examples/design-system-examples.md +0 -3
- 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/doctor.js +0 -1
- package/src/commands/project/heavy.js +13 -0
- package/src/commands/state/approve.js +9 -109
- package/src/commands/state/report-check.js +44 -2
- package/src/commands/validation/verify.js +57 -7
- package/src/core/state/state-manager.js +0 -24
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/heavy/cleanup.js +8 -2
- package/src/lib/heavy/gate.js +24 -1
- package/src/lib/heavy/reclaim.js +107 -0
- package/src/lib/heavy/watchdog.js +46 -0
- package/src/lib/next-step.js +1 -0
- package/src/lib/plan/checks.js +1 -1
- package/src/lib/plan/critique.js +23 -3
- package/src/lib/procs/prune.js +23 -1
- 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 +10 -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 +131 -26
- package/src/lib/verify/affected.js +141 -0
- package/src/lib/verify/detached.js +154 -0
- 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/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
- package/src/commands/observability/telemetry.js +0 -60
- package/src/commands/validation/validate-feature.js +0 -337
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { basename } from 'path';
|
|
3
|
+
import { treeMovedSince } from './git/evidence-staleness.js';
|
|
4
|
+
import { isTestOnly, securityReviewFor } from './risk-detector.js';
|
|
5
|
+
|
|
6
|
+
function resolveCommit(cwd, ref) {
|
|
7
|
+
try {
|
|
8
|
+
return execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { cwd, stdio: 'pipe' }).toString().trim();
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function changedBetween(cwd, from, to) {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync('git', ['diff', '--name-only', '--no-renames', from, to, '--', '.', ':(exclude).morph'], { cwd, stdio: 'pipe' })
|
|
17
|
+
.toString().split('\n').map((line) => line.trim()).filter(Boolean);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function citedByBlockers(reports) {
|
|
24
|
+
return Object.values(reports)
|
|
25
|
+
.flatMap((report) => report.rounds ?? [])
|
|
26
|
+
.flatMap((round) => round.blockers ?? [])
|
|
27
|
+
.map((blocker) => `${blocker.where} ${blocker.evidence}`)
|
|
28
|
+
.join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const openedBlockers = (report) => (report.rounds ?? []).some((round) => (round.blockers ?? []).length > 0);
|
|
32
|
+
const isCleanShip = (report) => report.verdict === 'SHIP' && (report.blockers ?? []).length === 0;
|
|
33
|
+
|
|
34
|
+
function deltaIsNarrow(role, delta, cited) {
|
|
35
|
+
if (delta === null) return false;
|
|
36
|
+
if (role === 'security') return !securityReviewFor(delta).required;
|
|
37
|
+
return delta.every((file) => isTestOnly(file) || cited.includes(file) || cited.includes(basename(file)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* After a fix, a role that shipped clean need not return when the delta stays inside what the
|
|
42
|
+
* blockers named (security: when it touches no sensitive path). `moved` lists the roles behind
|
|
43
|
+
* `head`; `skippable` says it holds; `confirmed` says a role that opened blockers re-judged `head`,
|
|
44
|
+
* without which nothing is skippable at approval.
|
|
45
|
+
*/
|
|
46
|
+
export function reviewConfirmation(cwd, reports, head) {
|
|
47
|
+
const cited = citedByBlockers(reports);
|
|
48
|
+
const moved = [];
|
|
49
|
+
let confirmed = false;
|
|
50
|
+
|
|
51
|
+
for (const [role, report] of Object.entries(reports)) {
|
|
52
|
+
if (report.status === 'missing' || !report.reviewedSha) continue;
|
|
53
|
+
const reviewed = resolveCommit(cwd, report.reviewedSha);
|
|
54
|
+
if (!reviewed) continue;
|
|
55
|
+
if (!treeMovedSince(cwd, reviewed, head)) {
|
|
56
|
+
if (openedBlockers(report)) confirmed = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
moved.push({
|
|
60
|
+
role,
|
|
61
|
+
reviewed,
|
|
62
|
+
skippable: isCleanShip(report) && deltaIsNarrow(role, changedBetween(cwd, reviewed, head), cited),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { moved, confirmed };
|
|
67
|
+
}
|
|
@@ -32,6 +32,7 @@ const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
|
|
|
32
32
|
|
|
33
33
|
/** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Chore: só o revisor (review leve). */
|
|
34
34
|
export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
|
|
35
|
+
// Sem `security` mesmo em caminho sensível: o humano assina todo hotfix.
|
|
35
36
|
if (workType === 'hotfix') return ['verifier'];
|
|
36
37
|
const base = workType === 'chore' ? ['reviewer'] : ['verifier', 'reviewer'];
|
|
37
38
|
return securityRequired ? [...base, 'security'] : base;
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -83,7 +83,7 @@ export function reviewPause(signal) {
|
|
|
83
83
|
+ ' auto-avaliação de quem implementou não decide o ship';
|
|
84
84
|
}
|
|
85
85
|
if (r.status === 'partial') {
|
|
86
|
-
return `\`${file}\` está PARCIAL —
|
|
86
|
+
return `\`${file}\` está PARCIAL — despache o mesmo papel de novo dizendo o que já está no relatório; parcial não é veredito`;
|
|
87
87
|
}
|
|
88
88
|
if (r.status !== 'ok') {
|
|
89
89
|
return `\`${file}\` sem \`**Verdict:** SHIP|FIX\` — o relatório não seguiu o contrato`;
|
|
@@ -144,7 +144,7 @@ export function verifierModelFor({ promptModelTouched = false, generatesMedia =
|
|
|
144
144
|
|
|
145
145
|
// Teste e artefato de teste não são superfície de ataque: `e2e/login.spec.ts` não pede a persona security.
|
|
146
146
|
const E2E_DIR_RE = /(^|\/)e2e\//;
|
|
147
|
-
const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
|
|
147
|
+
export const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
|
|
148
148
|
|
|
149
149
|
export function securityReviewFor(changedFiles = []) {
|
|
150
150
|
const risky = changedFiles
|
|
@@ -83,12 +83,14 @@ function makeNode(partial) {
|
|
|
83
83
|
runner,
|
|
84
84
|
expectedCount: keepsBaseline(runner) ? partial.expectedCount ?? null : null,
|
|
85
85
|
timeoutMs: partial.timeoutMs ?? null,
|
|
86
|
+
parallel: partial.parallel ?? false,
|
|
86
87
|
build: partial.build ?? [],
|
|
87
88
|
e2e: partial.e2e ?? false,
|
|
88
89
|
linuxImage: partial.linuxImage ?? null,
|
|
89
90
|
projectCount: partial.projectCount ?? 1,
|
|
90
91
|
// null is "not an eval node", distinct from an empty block (already refused).
|
|
91
92
|
evals: partial.evals ?? null,
|
|
93
|
+
affected: partial.affected ?? null,
|
|
92
94
|
status: partial.status ?? null,
|
|
93
95
|
reason: partial.reason ?? null,
|
|
94
96
|
};
|
|
@@ -127,12 +129,18 @@ function firstProblem(raw, projectPath) {
|
|
|
127
129
|
if (raw.runner !== undefined && !TEST_RUNNERS.includes(raw.runner)) {
|
|
128
130
|
return `runner must be one of ${TEST_RUNNERS.join(' | ')}`;
|
|
129
131
|
}
|
|
132
|
+
if (raw.affected !== undefined && (!isPlainObject(raw.affected) || typeof raw.affected.flag !== 'string' || !raw.affected.flag.trim())) {
|
|
133
|
+
return 'affected must be an object with a non-empty flag';
|
|
134
|
+
}
|
|
130
135
|
if (raw.expectedCount !== undefined && !isNonNegativeInteger(raw.expectedCount)) {
|
|
131
136
|
return 'expectedCount must be an integer >= 0';
|
|
132
137
|
}
|
|
133
138
|
if (raw.timeoutMs !== undefined && !(Number.isInteger(raw.timeoutMs) && raw.timeoutMs > 0)) {
|
|
134
139
|
return 'timeoutMs must be an integer > 0';
|
|
135
140
|
}
|
|
141
|
+
if (raw.parallel !== undefined && typeof raw.parallel !== 'boolean') {
|
|
142
|
+
return 'parallel must be a boolean';
|
|
143
|
+
}
|
|
136
144
|
if (raw.build !== undefined && (!Array.isArray(raw.build) || raw.build.some((b) => typeof b !== 'string'))) {
|
|
137
145
|
return 'build must be an array of strings';
|
|
138
146
|
}
|
|
@@ -208,10 +216,12 @@ export function validateTestNodes(config, projectPath = null) {
|
|
|
208
216
|
runner: entry.runner ?? inferRunner(entry.command),
|
|
209
217
|
expectedCount: entry.expectedCount ?? null,
|
|
210
218
|
timeoutMs: entry.timeoutMs ?? null,
|
|
219
|
+
parallel: entry.parallel ?? false,
|
|
211
220
|
build: entry.build ?? [],
|
|
212
221
|
e2e: entry.e2e ?? false,
|
|
213
222
|
linuxImage: entry.linuxImage ?? null,
|
|
214
223
|
evals: entry.evals ?? null,
|
|
224
|
+
affected: entry.affected ?? null,
|
|
215
225
|
projectCount: 1,
|
|
216
226
|
});
|
|
217
227
|
});
|
|
@@ -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
|
+
}
|