@polymorphism-tech/morph-spec 10.7.1 → 10.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/README.md +2 -2
- package/bin/morph-spec.js +15 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +4 -2
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +32 -17
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +12 -4
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +43 -4
- package/framework/hooks/claude-code/core/conventional-commit.js +1 -1
- package/framework/hooks/claude-code/core/protect-spec-files.js +8 -1
- package/framework/hooks/claude-code/core/worktree-context.js +5 -1
- package/framework/hooks/shared/command-tokens.js +1 -1
- package/framework/hooks/shared/e2e-check.js +16 -0
- package/framework/hooks/shared/heavy-command.js +3 -2
- package/framework/hooks/shared/state-reader.js +3 -0
- package/framework/rules/implement-report.md +1 -1
- package/framework/rules/plan-grammar.md +3 -2
- package/framework/schemas/config.schema.json +6 -1
- package/framework/schemas/feature.schema.json +4 -2
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -7
- package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
- package/framework/skills/core/morph-apply/references/review.md +9 -3
- package/framework/skills/core/morph-plan/SKILL.md +3 -3
- package/framework/skills/core/morph-plan/references/critique.md +26 -18
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/templates/context/ARCHITECTURE.md +0 -3
- package/package.json +1 -1
- package/src/commands/graph/diff.js +9 -2
- package/src/commands/project/worktree.js +86 -7
- package/src/commands/state/approve.js +4 -7
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/finish.js +75 -4
- package/src/commands/state/gate-check.js +7 -2
- package/src/commands/state/report-check.js +33 -6
- package/src/commands/validation/plan-check.js +11 -2
- package/src/commands/validation/prove-red.js +95 -0
- package/src/commands/validation/verify.js +56 -40
- package/src/lib/ci/ci-verify.js +1 -106
- package/src/lib/ci/github-ci.js +5 -117
- package/src/lib/generators/architecture-scanner.js +1 -14
- package/src/lib/git/default-branch.js +28 -1
- package/src/lib/plan/artifact-vocabulary.js +7 -3
- package/src/lib/plan/critique.js +17 -0
- package/src/lib/review-confirmation.js +10 -11
- package/src/lib/review-reports.js +12 -3
- package/src/lib/risk/diff-risk.js +116 -0
- package/src/lib/risk-detector.js +1 -1
- package/src/lib/validators/code-budget/code-budget-validator.js +16 -4
- package/src/lib/validators/verify-runner.js +24 -2
- package/src/lib/verify/affected.js +3 -1
- package/src/utils/refusal-line.js +37 -0
- package/src/lib/ci/workflow-scaffold.js +0 -126
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
buildBranchName,
|
|
12
12
|
branchExists,
|
|
13
13
|
cwdIsInsideWorktree,
|
|
14
|
+
worktreeHeldElsewhere,
|
|
14
15
|
sessionAnchoredInWorktree,
|
|
15
16
|
classifyIgnoredLeftovers,
|
|
16
17
|
} from '../project/worktree.js';
|
|
@@ -51,6 +52,50 @@ export function buildPrTitle(feature, fj) {
|
|
|
51
52
|
return prefix + (raw.length <= room ? raw : `${raw.slice(0, room - 1)}…`);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
const ISSUE_URL_RE = /https?:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)/;
|
|
56
|
+
const ISSUE_KEYWORD_RE = /\b(close[sd]?|fix(e[sd])?|resolve[sd]?|refs?)\s+([\w.-]+\/[\w.-]+)?#\d+/i;
|
|
57
|
+
|
|
58
|
+
// The description's own keyword wins (`Refs #N` keeps the issue open); a bare issue link closes it.
|
|
59
|
+
export function prBodyFor(feature, fj) {
|
|
60
|
+
const described = typeof fj?.description === 'string' ? fj.description.trim() : '';
|
|
61
|
+
const body = described || `Feature ${feature} — integrada via morph-spec finish --pr.`;
|
|
62
|
+
const issue = described.match(ISSUE_URL_RE);
|
|
63
|
+
if (!issue || ISSUE_KEYWORD_RE.test(described)) return body;
|
|
64
|
+
return `${body}\n\nCloses ${issue[1]}#${issue[2]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Regenerated from the tree: on a merge, the default's copy is as good as ours.
|
|
68
|
+
const GENERATED_PATHS = new Set(['.morph/context/README.md']);
|
|
69
|
+
|
|
70
|
+
/** Merges origin/<default> into the feature branch so the PR is never born CONFLICTING. */
|
|
71
|
+
function joinDefault(tree, defaultBranch) {
|
|
72
|
+
const target = `origin/${defaultBranch}`;
|
|
73
|
+
try {
|
|
74
|
+
execFileSync('git', ['fetch', '-q', 'origin', defaultBranch], { cwd: tree, stdio: 'pipe' });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { error: 'default-fetch-failed', output: gitErr(err) };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
execFileSync('git', ['merge-base', '--is-ancestor', target, 'HEAD'], { cwd: tree, stdio: 'pipe' });
|
|
80
|
+
return { merged: false };
|
|
81
|
+
} catch { /* behind the default: merge it */ }
|
|
82
|
+
try {
|
|
83
|
+
execFileSync('git', ['merge', '-q', '--no-edit', target], { cwd: tree, stdio: 'pipe' });
|
|
84
|
+
return { merged: true };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const conflicts = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: tree, stdio: 'pipe' })
|
|
87
|
+
.toString().split(/\r?\n/).filter(Boolean);
|
|
88
|
+
if (conflicts.length > 0 && conflicts.every((p) => GENERATED_PATHS.has(p))) {
|
|
89
|
+
execFileSync('git', ['checkout', '--theirs', '--', ...conflicts], { cwd: tree, stdio: 'pipe' });
|
|
90
|
+
execFileSync('git', ['add', '--', ...conflicts], { cwd: tree, stdio: 'pipe' });
|
|
91
|
+
execFileSync('git', ['commit', '-q', '--no-edit'], { cwd: tree, stdio: 'pipe' });
|
|
92
|
+
return { merged: true, resolved: conflicts };
|
|
93
|
+
}
|
|
94
|
+
tryGit(['merge', '--abort'], tree);
|
|
95
|
+
return { error: 'default-conflict', conflicts, output: conflicts.length ? undefined : gitErr(err) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
54
99
|
const MEMORY_NOTE = 'o fechamento desta feature já está registrado no repositório (commit de archive, `plan.md` com as decisões, índice de ADRs, `git log`) — memória de sessão só para o que NENHUM deles guarda: decisão do usuário, pendência a observar em produção, custo medido.';
|
|
55
100
|
|
|
56
101
|
// Every refusal is fail-CLOSED and happens BEFORE the archive, the first mutation. Resumable: a
|
|
@@ -167,6 +212,18 @@ export async function finishCommand(feature, options = {}) {
|
|
|
167
212
|
});
|
|
168
213
|
process.exit(1);
|
|
169
214
|
}
|
|
215
|
+
// The session's own anchor defers the teardown instead; any other holder would orphan the folder.
|
|
216
|
+
if (!options.dryRun && !sessionAnchoredInWorktree(worktreePath) && worktreeHeldElsewhere(worktreePath)) {
|
|
217
|
+
emit({
|
|
218
|
+
finished: false,
|
|
219
|
+
error: 'worktree-in-use',
|
|
220
|
+
feature,
|
|
221
|
+
path: worktreePath,
|
|
222
|
+
root,
|
|
223
|
+
hint: `outro processo tem "${worktreePath}" como pasta de trabalho (um terminal, o editor, um servidor de dev): feche-o ou saia da pasta e rode o finish de novo. Nada foi mexido.`,
|
|
224
|
+
});
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
170
227
|
} else {
|
|
171
228
|
// A feature with no morph/{f} branch predates this flow: route it to archive-without-integrate.
|
|
172
229
|
if (!branchExists(expectedBranch, root)) {
|
|
@@ -293,9 +350,7 @@ export async function finishCommand(feature, options = {}) {
|
|
|
293
350
|
process.exit(1);
|
|
294
351
|
}
|
|
295
352
|
const prTitle = buildPrTitle(feature, fj);
|
|
296
|
-
const prBody = (
|
|
297
|
-
? String(fj.description).trim()
|
|
298
|
-
: `Feature ${feature} — integrada via morph-spec finish --pr.`;
|
|
353
|
+
const prBody = prBodyFor(feature, fj);
|
|
299
354
|
|
|
300
355
|
// Clean, so the scoped archive commit contains only the move.
|
|
301
356
|
const cleanCwd = isWorktreeFlow ? worktreePath : root;
|
|
@@ -363,7 +418,23 @@ export async function finishCommand(feature, options = {}) {
|
|
|
363
418
|
if (isWorktreeFlow) {
|
|
364
419
|
for (const p of testArtifacts) rmSync(join(cleanCwd, p), { recursive: true, force: true });
|
|
365
420
|
}
|
|
366
|
-
|
|
421
|
+
if (mode === 'pr') {
|
|
422
|
+
const joined = joinDefault(cleanCwd, defaultBranch);
|
|
423
|
+
if (joined.error) {
|
|
424
|
+
emit({
|
|
425
|
+
finished: false,
|
|
426
|
+
error: joined.error,
|
|
427
|
+
feature,
|
|
428
|
+
...(joined.conflicts?.length ? { conflicts: joined.conflicts } : {}),
|
|
429
|
+
...(joined.output ? { output: joined.output } : {}),
|
|
430
|
+
hint: joined.error === 'default-conflict'
|
|
431
|
+
? `origin/${defaultBranch} conflita com ${expectedBranch} em ${joined.conflicts.join(', ') || '(ver output)'}; o merge foi desfeito e nada foi arquivado nem enviado. Rode \`git merge origin/${defaultBranch}\` em ${cleanCwd}, resolva, commite e rode o finish de novo`
|
|
432
|
+
: `não consegui buscar origin/${defaultBranch}; nada foi arquivado nem enviado. Confira a rede e o remoto e rode de novo`,
|
|
433
|
+
});
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const arch = runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
|
|
367
438
|
if (!arch.ok) {
|
|
368
439
|
emit({ finished: false, error: `archive-${arch.error}`, feature });
|
|
369
440
|
process.exit(1);
|
|
@@ -3,6 +3,7 @@ import { getFeature } from '../../core/state/state-manager.js';
|
|
|
3
3
|
import { shouldPauseGate, getRiskSignals, classifyE2eSkip, planChecksPause, securityReviewFor, pausingRungs, migrationsTouched, contractsTouched, humanApprover, rungPause, planCritiquePause } from '../../lib/risk-detector.js';
|
|
4
4
|
import { readPlanCritique } from '../../lib/plan/critique.js';
|
|
5
5
|
import { reviewReports, reviewSignal, requiredReviewRoles, verifierMediaSection } from '../../lib/review-reports.js';
|
|
6
|
+
import { featureDiffRisk } from '../../lib/risk/diff-risk.js';
|
|
6
7
|
import { collectScreenChecklist } from '../../lib/specs/screen-checklist.js';
|
|
7
8
|
import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-decision.js';
|
|
8
9
|
import { minimatch } from 'minimatch';
|
|
@@ -48,8 +49,12 @@ function collectPromptModel(cwd, defaultBranch) {
|
|
|
48
49
|
export function collectReviewRequirement(cwd, feature) {
|
|
49
50
|
let changed = [];
|
|
50
51
|
try { changed = changedPathsAgainst(detectDefaultBranch(cwd), cwd); } catch { changed = []; }
|
|
51
|
-
const
|
|
52
|
-
|
|
52
|
+
const byPath = securityReviewFor(changed);
|
|
53
|
+
const risk = featureDiffRisk(cwd, detectDefaultBranch(cwd));
|
|
54
|
+
const reasons = [...new Set([...byPath.reasons, ...(risk?.security.reasons ?? [])])];
|
|
55
|
+
const security = { required: reasons.length > 0, reasons };
|
|
56
|
+
const required = requiredReviewRoles({ workType: feature?.workType, securityRequired: security.required, risk: risk?.level ?? null });
|
|
57
|
+
return { required, security, risk: risk ? { level: risk.level, reasons: risk.reasons } : null };
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
export function collectReviewSignal(cwd, featureName, feature, planChecks = readPlanChecks(cwd, featureName)) {
|
|
@@ -41,13 +41,19 @@ function reviewDispatch(cwd, feature) {
|
|
|
41
41
|
const globs = Array.isArray(watch) ? watch.filter((g) => typeof g === 'string' && g.trim()) : [];
|
|
42
42
|
let workType;
|
|
43
43
|
try { workType = JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8')).workType; } catch { workType = undefined; }
|
|
44
|
-
const { required, security } = collectReviewRequirement(cwd, { workType });
|
|
44
|
+
const { required, security, risk } = collectReviewRequirement(cwd, { workType });
|
|
45
|
+
// A confirmation re-judges a delta against scenarios already proven: never the expensive model.
|
|
46
|
+
const confirming = Object.values(reviewReports(cwd, feature)).some((r) => r.status !== 'missing');
|
|
45
47
|
return {
|
|
46
48
|
required,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
risk,
|
|
50
|
+
selfReview: ['low', 'none'].includes(risk?.level) && workType !== 'hotfix',
|
|
51
|
+
verifier: confirming
|
|
52
|
+
? { model: 'sonnet', reasons: ['confirmação de delta'] }
|
|
53
|
+
: verifierModelFor({
|
|
54
|
+
promptModelTouched: changedFiles.some((f) => globs.some((g) => minimatch(f, g))),
|
|
55
|
+
generatesMedia: featureGeneratesMedia(readOrEmpty(getAbsoluteOutputPath(cwd, feature, 'plan'))),
|
|
56
|
+
}),
|
|
51
57
|
reviewer: { model: 'sonnet', reasons: [] },
|
|
52
58
|
security: { required: security.required, model: 'opus', reasons: security.reasons },
|
|
53
59
|
};
|
|
@@ -118,6 +124,22 @@ function gate3Briefing(cwd, feature) {
|
|
|
118
124
|
};
|
|
119
125
|
}
|
|
120
126
|
|
|
127
|
+
// An agent writes its report and never commits: left loose it rides into whatever commit comes next, and
|
|
128
|
+
// a revert of that commit takes the report with it. Only these files, in a commit of their own.
|
|
129
|
+
function commitLooseReports(cwd, feature) {
|
|
130
|
+
const rels = Object.keys(reviewReports(cwd, feature)).map((role) => `.morph/features/${feature}/4-review/${reviewFileName(role)}`);
|
|
131
|
+
const loose = gitLine(cwd, ['status', '--porcelain', '--untracked-files=all', '--', ...rels]);
|
|
132
|
+
if (!loose) return [];
|
|
133
|
+
const paths = loose.split('\n').map((line) => line.slice(3).trim()).filter(Boolean);
|
|
134
|
+
try {
|
|
135
|
+
execFileSync('git', ['add', '--', ...paths], { cwd, stdio: 'pipe' });
|
|
136
|
+
execFileSync('git', ['commit', '-q', '-m', `chore(${feature}): relatórios do Gate 3`, '--', ...paths], { cwd, stdio: 'pipe' });
|
|
137
|
+
return paths;
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
121
143
|
export function reportCheckCommand(feature, options = {}) {
|
|
122
144
|
const cwd = options.cwd || process.cwd();
|
|
123
145
|
const emit = (obj, code) => {
|
|
@@ -138,7 +160,12 @@ export function reportCheckCommand(feature, options = {}) {
|
|
|
138
160
|
? { total: progress.total, done: progress.done, pending: progress.checks.filter((c) => c.status === 'pendente').map((c) => c.id),
|
|
139
161
|
blocked: progress.checks.filter((c) => c.status === 'bloqueado').map((c) => c.id), legacy: progress.legacy, warnings }
|
|
140
162
|
: null;
|
|
141
|
-
|
|
163
|
+
const committedReports = commitLooseReports(cwd, feature);
|
|
164
|
+
emit({
|
|
165
|
+
feature, implement, reviews: inspectReviewReports(cwd, feature),
|
|
166
|
+
...(committedReports.length ? { committedReports } : {}),
|
|
167
|
+
...treeFor(cwd, feature, options.beforeEval),
|
|
168
|
+
}, 0);
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
function printHuman(obj) {
|
|
@@ -6,6 +6,7 @@ import { isGateApproved } from '../../../framework/hooks/shared/state-reader.js'
|
|
|
6
6
|
import { FEATURE_VIEW_FILE, getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
7
7
|
import { parsePlan } from '../../lib/plan/plan-parser.js';
|
|
8
8
|
import { buildChecks, planHash, planRuleOptions, validatePlan } from '../../lib/plan/checks.js';
|
|
9
|
+
import { critiqueByAgent } from '../../lib/plan/critique.js';
|
|
9
10
|
import { revocationWarnings } from '../../lib/specs/adr-index.js';
|
|
10
11
|
import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
|
|
11
12
|
import { seedDotnetBuildIgnores } from '../../lib/stack/dotnet-build-ignores.js';
|
|
@@ -37,8 +38,13 @@ export function runPlanCheck(featureName, cwd = process.cwd()) {
|
|
|
37
38
|
return { ...result, ok: false, exit: 1, error: 'frozen', previousHash: previous.planHash ?? null };
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
const checks = buildChecks(featureName, text, plan);
|
|
42
|
+
atomicWriteJson(checksPath, checks);
|
|
43
|
+
const critique = critiqueByAgent(checks, text);
|
|
44
|
+
return {
|
|
45
|
+
...result, ok: true, exit: 0, written: true, viewPath: join(cwd, '.morph', 'features', featureName, FEATURE_VIEW_FILE),
|
|
46
|
+
critique: { by: critique.agent ? 'agent' : 'session', reasons: critique.reasons },
|
|
47
|
+
};
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export function planCheckCommand(featureName, options = {}) {
|
|
@@ -80,6 +86,9 @@ export function planCheckCommand(featureName, options = {}) {
|
|
|
80
86
|
} else {
|
|
81
87
|
console.log(chalk.green(`✓ plan-check: ${join('2-plan', 'checks.json')} gravado (${result.planHash.slice(0, 12)})`));
|
|
82
88
|
console.log(chalk.gray(` página da feature: ${result.viewPath}`));
|
|
89
|
+
console.log(chalk.gray(result.critique.by === 'agent'
|
|
90
|
+
? ` crítica do plano: sub-agent em contexto fresco (${result.critique.reasons.join('; ')})`
|
|
91
|
+
: ' crítica do plano: a própria sessão, com o checklist de grep (sem sinal de risco)'));
|
|
83
92
|
reportDotnetIgnores(gitignoreAdded);
|
|
84
93
|
}
|
|
85
94
|
process.exit(result.exit);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
6
|
+
import { diffLines, fileClass } from '../../lib/risk/diff-risk.js';
|
|
7
|
+
|
|
8
|
+
const git = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
|
|
9
|
+
|
|
10
|
+
function mergeBase(cwd) {
|
|
11
|
+
const def = detectDefaultBranch(cwd);
|
|
12
|
+
for (const ref of def ? [`origin/${def}`, def] : []) {
|
|
13
|
+
try {
|
|
14
|
+
return git(cwd, ['merge-base', 'HEAD', ref]).trim();
|
|
15
|
+
} catch { /* next ref */ }
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function baseContent(cwd, base, path) {
|
|
21
|
+
try {
|
|
22
|
+
return execFileSync('git', ['show', `${base}:${path}`], { cwd, stdio: 'pipe' });
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runTest(cwd, command) {
|
|
29
|
+
const r = spawnSync(command, { cwd, shell: true, stdio: 'pipe', encoding: 'utf8' });
|
|
30
|
+
return r.status === 0 ? 'pass' : 'fail';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The "see it fail without the change" proof, done by the CLI: run the scenario's test with the change,
|
|
35
|
+
* put the feature's production code back to the merge-base, run it again, restore. Only `code` files
|
|
36
|
+
* are reverted (never tests), and the restore runs in `finally` from an in-memory copy.
|
|
37
|
+
*/
|
|
38
|
+
export function proveRed(cwd, { test, files = null }) {
|
|
39
|
+
const base = mergeBase(cwd);
|
|
40
|
+
if (!base) return { code: 2, out: { error: 'no-base', hint: 'sem merge-base com a branch padrão: não há "sem a mudança" para comparar' } };
|
|
41
|
+
const changed = diffLines(cwd, base).filter((f) => fileClass(f.path) === 'code').map((f) => f.path);
|
|
42
|
+
const targets = files ? changed.filter((p) => files.includes(p)) : changed;
|
|
43
|
+
if (targets.length === 0) {
|
|
44
|
+
return { code: 2, out: { error: 'nothing-to-revert', changed, hint: 'nenhum arquivo de produção do diff da feature casa com --files' } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const withChange = runTest(cwd, test);
|
|
48
|
+
if (withChange === 'fail') {
|
|
49
|
+
return { code: 2, out: { withChange, withoutChange: null, reverted: [], hint: 'o teste já está vermelho COM a mudança: conserte antes de provar' } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const saved = new Map(targets.map((p) => [p, existsSync(join(cwd, p)) ? readFileSync(join(cwd, p)) : null]));
|
|
53
|
+
let withoutChange;
|
|
54
|
+
try {
|
|
55
|
+
for (const path of targets) {
|
|
56
|
+
const old = baseContent(cwd, base, path);
|
|
57
|
+
if (old === null) rmSync(join(cwd, path), { force: true });
|
|
58
|
+
else {
|
|
59
|
+
mkdirSync(dirname(join(cwd, path)), { recursive: true });
|
|
60
|
+
writeFileSync(join(cwd, path), old);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
withoutChange = runTest(cwd, test);
|
|
64
|
+
} finally {
|
|
65
|
+
for (const [path, content] of saved) {
|
|
66
|
+
if (content === null) rmSync(join(cwd, path), { force: true });
|
|
67
|
+
else {
|
|
68
|
+
mkdirSync(dirname(join(cwd, path)), { recursive: true });
|
|
69
|
+
writeFileSync(join(cwd, path), content);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const out = { base, withChange, withoutChange, reverted: targets };
|
|
75
|
+
if (withoutChange === 'pass') {
|
|
76
|
+
return { code: 1, out: { ...out, hint: 'o teste passa SEM a mudança: ele não prova o cenário (filtro errado, ou asserção que já era verdade)' } };
|
|
77
|
+
}
|
|
78
|
+
return { code: 0, out };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function proveRedCommand(feature, options = {}) {
|
|
82
|
+
const cwd = process.cwd();
|
|
83
|
+
if (!options.test) {
|
|
84
|
+
console.error(chalk.red('\n❌ --test "<comando do teste do cenário>" é obrigatório\n'));
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
const files = options.files ? String(options.files).split(',').map((f) => f.trim().replace(/\\/g, '/')).filter(Boolean) : null;
|
|
88
|
+
const { code, out } = proveRed(cwd, { test: options.test, files });
|
|
89
|
+
const payload = { feature, ...out };
|
|
90
|
+
if (options.json) console.log(JSON.stringify(payload, null, 2));
|
|
91
|
+
else if (code === 0) console.log(chalk.green(`\n✓ ${feature}: o teste fica vermelho sem a mudança (${out.reverted.join(', ')}) e verde com ela\n`));
|
|
92
|
+
if (code !== 0 && !options.json) console.error(chalk.red(`\n❌ ${out.hint ?? out.error}\n`));
|
|
93
|
+
else if (code !== 0) console.error(chalk.red(`❌ ${out.hint ?? out.error}`));
|
|
94
|
+
process.exit(code);
|
|
95
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
2
3
|
import { resolve } from 'path';
|
|
3
4
|
import { getFeature, updateFeature } from '../../core/state/state-manager.js';
|
|
4
5
|
import { runVerify, VerifyUsageError } from '../../lib/validators/verify-runner.js';
|
|
5
6
|
import { resolvePrimaryRoot } from '../project/worktree.js';
|
|
6
7
|
import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
|
|
7
8
|
import { canonicalPath, readMorphConfig } from '../../lib/tasks/test-runner.js';
|
|
8
|
-
import { getHeadSha } from '../../lib/git/default-branch.js';
|
|
9
|
+
import { detectDefaultBranch, getHeadSha } from '../../lib/git/default-branch.js';
|
|
10
|
+
import { featureDiffRisk } from '../../lib/risk/diff-risk.js';
|
|
11
|
+
import { uncommittedCode } from '../../lib/ci/github-ci.js';
|
|
9
12
|
import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
|
|
10
13
|
import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../lib/telemetry/verify-event.js';
|
|
11
14
|
import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
|
|
@@ -13,7 +16,7 @@ import { HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
|
13
16
|
import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
|
|
14
17
|
import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
|
|
15
18
|
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
16
|
-
import {
|
|
19
|
+
import { reusableLocalStamp, resolvePrProof } from '../../lib/ci/ci-verify.js';
|
|
17
20
|
import { syncStackRules } from '../../lib/installers/stack-sync.js';
|
|
18
21
|
import {
|
|
19
22
|
DETACH_RUN_ENV, DETACH_STAGE_ENV, DETACH_WORKER_ENV, launchWorker, markWorkerExit, runFilesOf, superviseVerify,
|
|
@@ -184,6 +187,33 @@ function exitWithReusedStamp(featureName, reused, why, options) {
|
|
|
184
187
|
process.exit(0);
|
|
185
188
|
}
|
|
186
189
|
|
|
190
|
+
const E2E_PATH = /(^|\/)(e2e|playwright)\/|\.spec\.[cm]?[jt]sx?$/i;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* When a green affected run stands for the gate: over a low-risk diff it IS the proof (the e2e waiver is
|
|
194
|
+
* classified, never "requested"); over a green complete stamp it renews it when the delta since that stamp
|
|
195
|
+
* reaches only what it ran (selection is from the merge-base, so it covers the delta; e2e it never runs).
|
|
196
|
+
* Only a committed tree: the stamp names a SHA.
|
|
197
|
+
*/
|
|
198
|
+
function affectedProof(cwd, prev, stamp) {
|
|
199
|
+
const dirty = uncommittedCode(cwd);
|
|
200
|
+
if (!stamp.sha || dirty === null || dirty.length > 0) return null;
|
|
201
|
+
const risk = featureDiffRisk(cwd, detectDefaultBranch(cwd));
|
|
202
|
+
if (risk && ['low', 'none'].includes(risk.level)) {
|
|
203
|
+
return { ...stamp, scope: 'affected', e2eReason: 'not applicable: diff de baixo risco, sem lógica (verify --affected)' };
|
|
204
|
+
}
|
|
205
|
+
if (prev?.status !== 'pass' || !prev.sha || prev.pendingPr) return null;
|
|
206
|
+
let delta;
|
|
207
|
+
try {
|
|
208
|
+
delta = execFileSync('git', ['diff', '--name-only', '--no-renames', prev.sha, stamp.sha, '--', '.', ':(exclude).morph'], { cwd, stdio: 'pipe' })
|
|
209
|
+
.toString().split('\n').map((l) => l.trim()).filter(Boolean);
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
if (delta.some((p) => E2E_PATH.test(p))) return null;
|
|
214
|
+
return { ...prev, sha: stamp.sha, ts: stamp.ts, renewedBy: 'affected' };
|
|
215
|
+
}
|
|
216
|
+
|
|
187
217
|
// Exit 2 is "the command cannot mean what you typed" (unknown feature, `--filter` matching
|
|
188
218
|
// nothing, `--record-baseline` without config.json, `--linux` without Docker): never a verdict.
|
|
189
219
|
export async function verifyCommand(featureName, options = {}) {
|
|
@@ -219,6 +249,12 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
219
249
|
console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
|
|
220
250
|
process.exit(2);
|
|
221
251
|
}
|
|
252
|
+
// A green run of this same tree, complete or affected, already covers what an affected run selects.
|
|
253
|
+
if (options.affected && !options.fresh) {
|
|
254
|
+
const fresh = reusableLocalStamp(process.cwd(), feature.verification)
|
|
255
|
+
?? reusableLocalStamp(process.cwd(), feature.verifications?.affected);
|
|
256
|
+
if (fresh) exitWithReusedStamp(featureName, fresh, 'rodada verde deste mesmo tree — não roda de novo', options);
|
|
257
|
+
}
|
|
222
258
|
if (options.detach) await runDetached(featureName, options);
|
|
223
259
|
|
|
224
260
|
syncStackRules(process.cwd(), { persistTags: false, log: (line) => console.error(chalk.gray(line)) });
|
|
@@ -226,49 +262,28 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
226
262
|
if (options.ci) {
|
|
227
263
|
const conflict = CI_EXCLUSIVE_FLAGS.find(([flag]) => options[flag])?.[1];
|
|
228
264
|
if (conflict) {
|
|
229
|
-
console.error(chalk.red(`\n❌ --ci
|
|
265
|
+
console.error(chalk.red(`\n❌ --ci é a prova completa da feature; não combina com ${conflict}\n`));
|
|
230
266
|
process.exit(2);
|
|
231
267
|
}
|
|
232
268
|
}
|
|
233
269
|
|
|
234
|
-
|
|
270
|
+
// Nothing is pushed here: the suites the PR proves are waited for by `finish --pr`, one CI run per SHA.
|
|
235
271
|
let prProof = null;
|
|
236
272
|
if (options.ci) {
|
|
237
273
|
const fresh = reusableLocalStamp(process.cwd(), feature.verification);
|
|
238
|
-
if (fresh) exitWithReusedStamp(featureName, fresh, 'stamp verde e fresco —
|
|
274
|
+
if (fresh) exitWithReusedStamp(featureName, fresh, 'stamp verde e fresco — não roda de novo', options);
|
|
239
275
|
prProof = resolvePrProof(process.cwd(), featureName, readMorphConfig(process.cwd()));
|
|
240
|
-
if (prProof
|
|
241
|
-
|
|
242
|
-
const outcome = prProof ? null : await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
|
|
243
|
-
log: (line) => console.error(chalk.gray(` ${line}`)),
|
|
244
|
-
});
|
|
245
|
-
if (!outcome) {
|
|
246
|
-
// The PR proof has no run to wait for here: `finish --pr` waits for it.
|
|
247
|
-
} else if (outcome.mode === 'ci') {
|
|
248
|
-
ci = outcome;
|
|
249
|
-
} else if (outcome.mode === 'pending') {
|
|
250
|
-
if (options.json) console.log(JSON.stringify({ status: 'pending', persisted: false, reason: outcome.reason }, null, 2));
|
|
251
|
-
console.error(chalk.yellow(`\n⏳ ${outcome.reason}. Não é vermelho e não há stamp: não repita em laço — siga e deixe o gate-check decidir.\n`));
|
|
252
|
-
process.exit(3);
|
|
253
|
-
} else {
|
|
254
|
-
console.error(chalk.yellow(`\n↪ CI indisponível (${outcome.reason}) — verify local.\n`));
|
|
255
|
-
}
|
|
256
|
-
} catch (err) {
|
|
257
|
-
if (err instanceof CiUsageError) {
|
|
258
|
-
console.error(chalk.red(`\n❌ ${err.message}\n`));
|
|
259
|
-
process.exit(2);
|
|
260
|
-
}
|
|
261
|
-
throw err;
|
|
262
|
-
}
|
|
276
|
+
if (!prProof) console.error(chalk.yellow('\n↪ sem project.prChecks — verify local completo.\n'));
|
|
277
|
+
else if (prProof.local) console.error(chalk.yellow(`\n↪ completa local: ${prProof.local}\n`));
|
|
263
278
|
}
|
|
264
279
|
|
|
265
280
|
// Taken before the run: it is the tree the run is about to read.
|
|
266
281
|
const fingerprint = treeFingerprint(process.cwd());
|
|
267
282
|
|
|
268
283
|
const pendingPr = prProof?.covers ? prProof : null;
|
|
269
|
-
const result =
|
|
284
|
+
const result = await runLocalVerify(featureName, { ...options, excludeNodes: pendingPr?.covers }, fingerprint);
|
|
270
285
|
|
|
271
|
-
const cleanup =
|
|
286
|
+
const cleanup = afterHeavyRun({
|
|
272
287
|
projectPath: process.cwd(),
|
|
273
288
|
config: readHeavyConfig(process.cwd()),
|
|
274
289
|
onlyUnderPressure: true,
|
|
@@ -308,13 +323,20 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
308
323
|
status: result.status,
|
|
309
324
|
nodes: nodeStatuses(result),
|
|
310
325
|
e2eReason: result.nodes.e2e.status === 'skip' ? (result.nodes.e2e.reason ?? null) : null,
|
|
311
|
-
sha:
|
|
326
|
+
sha: getHeadSha(process.cwd()),
|
|
312
327
|
ts: now,
|
|
313
328
|
...(result.flaky?.length ? { flaky: result.flaky } : {}),
|
|
314
|
-
...(ci ? { source: 'ci', ciRun: ci.run.url } : {}),
|
|
315
329
|
...(pendingPr && result.status === 'pass' ? { pendingPr } : {}),
|
|
316
330
|
};
|
|
317
331
|
if (!scopedRun) await updateFeature(featureName, 'verification', stamp);
|
|
332
|
+
else if (result.status === 'pass') {
|
|
333
|
+
const proof = affectedProof(process.cwd(), feature.verification, stamp);
|
|
334
|
+
if (proof) {
|
|
335
|
+
await updateFeature(featureName, 'verification', proof);
|
|
336
|
+
console.error(chalk.gray(proof.renewedBy ? `↪ carimbo renovado: a rodada afetada cobre o delta desde ${feature.verification.sha.slice(0, 8)}`
|
|
337
|
+
: '↪ diff de baixo risco: a rodada afetada é a prova do gate'));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
318
340
|
}
|
|
319
341
|
|
|
320
342
|
// A run over the exact tree of the previous one proves nothing
|
|
@@ -322,9 +344,9 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
322
344
|
let repeatOf = null;
|
|
323
345
|
try {
|
|
324
346
|
const cwd = process.cwd();
|
|
325
|
-
if (persist
|
|
347
|
+
if (persist) repeatOf = findRepeatedVerify(cwd, { feature: featureName, fingerprint });
|
|
326
348
|
const tree = resolve(cwd).replace(/\\/g, '/') === resolvePrimaryRoot(cwd) ? 'root' : 'worktree';
|
|
327
|
-
recordVerifyEvent(cwd, buildVerifyEvent(result, { sha:
|
|
349
|
+
recordVerifyEvent(cwd, buildVerifyEvent(result, { sha: getHeadSha(cwd), tree, persisted: persist && !scopedRun, reason: scopedRun ? 'affected' : reason, fingerprint, source: 'local' }));
|
|
328
350
|
} catch { /* telemetry never changes the verdict */ }
|
|
329
351
|
|
|
330
352
|
// The refusal always goes to stderr, so it survives --json (whose stdout has
|
|
@@ -343,16 +365,10 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
343
365
|
const output = {
|
|
344
366
|
...result, nodes, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup,
|
|
345
367
|
...(pendingPr ? { pendingPr } : {}),
|
|
346
|
-
...(ci ? { source: 'ci', ciRun: { id: ci.run.databaseId, url: ci.run.url, sha: ci.run.headSha }, workflowCreated: ci.created } : {}),
|
|
347
368
|
};
|
|
348
369
|
console.log(JSON.stringify(output, null, 2));
|
|
349
370
|
} else {
|
|
350
371
|
console.log(formatVerifyResult(result));
|
|
351
|
-
if (ci) {
|
|
352
|
-
console.log(chalk.cyan(`fonte: CI ${ci.run.url}`));
|
|
353
|
-
if (result.status === 'fail') console.log(chalk.gray(` log das falhas: gh run view ${ci.run.databaseId} --log-failed`));
|
|
354
|
-
console.log('');
|
|
355
|
-
}
|
|
356
372
|
if (cleanup.status !== 'skipped') {
|
|
357
373
|
const mark = cleanup.status === 'ran' ? chalk.green('✓') : chalk.yellow('⚠');
|
|
358
374
|
console.log(chalk.gray(`${mark} dotnet build-server shutdown: ${cleanup.status}${cleanup.reason ? ` (${cleanup.reason})` : ''}\n`));
|
package/src/lib/ci/ci-verify.js
CHANGED
|
@@ -1,55 +1,10 @@
|
|
|
1
|
-
import { execFileSync } from 'child_process';
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
1
|
import { getHeadSha } from '../git/default-branch.js';
|
|
5
2
|
import { treeMovedSince } from '../git/evidence-staleness.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
CI_WORKFLOW_DIR, CI_WORKFLOW_FILE, ciAvailability, downloadResult, findCiWorkflow, pushBranch,
|
|
9
|
-
uncommittedCode, waitForRun, workflowPushProblem,
|
|
10
|
-
} from './github-ci.js';
|
|
3
|
+
import { ciAvailability, uncommittedCode } from './github-ci.js';
|
|
11
4
|
import { prProofConfig } from './pr-checks.js';
|
|
12
|
-
import { renderCiWorkflow } from './workflow-scaffold.js';
|
|
13
5
|
import { resolveTestNodes } from '../tasks/test-nodes.js';
|
|
14
6
|
import { selectAffected } from '../verify/affected.js';
|
|
15
7
|
|
|
16
|
-
export const CI_WAIT_MS = 50 * 60 * 1000;
|
|
17
|
-
const APPEAR_MS = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000);
|
|
18
|
-
|
|
19
|
-
export class CiUsageError extends Error {}
|
|
20
|
-
|
|
21
|
-
const NODE_KEYS = ['build', 'tests', 'validators', 'e2e'];
|
|
22
|
-
|
|
23
|
-
function isVerifyResult(result) {
|
|
24
|
-
return Boolean(result) && ['pass', 'fail'].includes(result.status)
|
|
25
|
-
&& NODE_KEYS.every((k) => typeof result.nodes?.[k]?.status === 'string');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function scaffoldWorkflow(root, config) {
|
|
29
|
-
const rel = `${CI_WORKFLOW_DIR}/${CI_WORKFLOW_FILE}`;
|
|
30
|
-
const abs = join(root, rel);
|
|
31
|
-
if (existsSync(abs)) return { error: `${rel} já existe e não chama morph-spec verify publicando o JSON` };
|
|
32
|
-
try {
|
|
33
|
-
mkdirSync(join(root, CI_WORKFLOW_DIR), { recursive: true });
|
|
34
|
-
writeFileSync(abs, renderCiWorkflow(root, config));
|
|
35
|
-
execFileSync('git', ['add', '--', rel], { cwd: root, stdio: 'pipe' });
|
|
36
|
-
execFileSync('git', ['commit', '-m', 'ci: workflow morph-verify (prova do Gate 3 no CI)', '--', rel], { cwd: root, stdio: 'pipe' });
|
|
37
|
-
return { path: rel };
|
|
38
|
-
} catch (err) {
|
|
39
|
-
return { error: `não consegui criar ${rel}: ${(err.stderr?.toString() || err.message).trim()}` };
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function dispatchWorkflow(root, workflow, branch) {
|
|
44
|
-
try {
|
|
45
|
-
const { bin, prefix } = resolveGh();
|
|
46
|
-
execFileSync(bin, [...prefix, 'workflow', 'run', workflow, '--ref', branch], { cwd: root, stdio: 'pipe', timeout: 15000 });
|
|
47
|
-
return true;
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
8
|
// The last green stamp still proves the tree if nothing moved since it and nothing is uncommitted
|
|
54
9
|
// (treeMovedSince sees only commits). A null sha or HEAD would read as "not moved", so both are required.
|
|
55
10
|
export function reusableLocalStamp(root, stamp) {
|
|
@@ -73,63 +28,3 @@ export function resolvePrProof(root, featureName, config, { select = selectAffec
|
|
|
73
28
|
const selection = select({ projectPath: root, nodes: resolveTestNodes(root).nodes });
|
|
74
29
|
return selection.scope === 'full' ? { local: selection.reason } : proof;
|
|
75
30
|
}
|
|
76
|
-
|
|
77
|
-
// Every way CI cannot answer is `mode: 'local'` with the reason: no CI is never a pause. Only the
|
|
78
|
-
// uploaded `verify --json` becomes a result, judged by the same stamp and rules as a local run.
|
|
79
|
-
export async function runCiVerify(root, featureName, config, { log = () => {}, waitMs = CI_WAIT_MS, wait = waitForRun } = {}) {
|
|
80
|
-
const avail = ciAvailability(root, featureName, config);
|
|
81
|
-
if (!avail.ok) return { mode: 'local', reason: avail.reason };
|
|
82
|
-
|
|
83
|
-
const dirty = uncommittedCode(root);
|
|
84
|
-
if (dirty === null) return { mode: 'local', reason: 'git status não respondeu' };
|
|
85
|
-
if (dirty.length > 0) {
|
|
86
|
-
const shown = dirty.slice(0, 5).join(', ') + (dirty.length > 5 ? ` (+${dirty.length - 5})` : '');
|
|
87
|
-
throw new CiUsageError(`o CI só vê commits e há trabalho fora deles: ${shown}. Commite na branch da feature (git add <paths> && git commit) e rode de novo, ou rode sem --ci`);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let workflow = findCiWorkflow(root);
|
|
91
|
-
let created = null;
|
|
92
|
-
if (!workflow) {
|
|
93
|
-
const problem = workflowPushProblem(avail);
|
|
94
|
-
if (problem) return { mode: 'local', reason: problem };
|
|
95
|
-
const scaffold = scaffoldWorkflow(root, config);
|
|
96
|
-
if (scaffold.error) return { mode: 'local', reason: scaffold.error };
|
|
97
|
-
created = scaffold.path;
|
|
98
|
-
workflow = CI_WORKFLOW_FILE;
|
|
99
|
-
log(`workflow criado e commitado: ${created}`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const pushError = pushBranch(root, avail.branch);
|
|
103
|
-
if (pushError) return { mode: 'local', reason: `push de ${avail.branch} falhou: ${pushError}` };
|
|
104
|
-
|
|
105
|
-
const head = getHeadSha(root);
|
|
106
|
-
const waitOpts = {
|
|
107
|
-
branch: avail.branch,
|
|
108
|
-
workflow,
|
|
109
|
-
isCurrent: (sha) => !treeMovedSince(root, sha, head),
|
|
110
|
-
timeoutMs: waitMs,
|
|
111
|
-
appearMs: APPEAR_MS,
|
|
112
|
-
onPending: (run) => log(`CI rodando: ${run.url}`),
|
|
113
|
-
};
|
|
114
|
-
let outcome = await wait(root, waitOpts);
|
|
115
|
-
if (outcome.state === 'no-run' && dispatchWorkflow(root, workflow, avail.branch)) {
|
|
116
|
-
log(`nenhum run para este commit; disparei ${workflow} manualmente`);
|
|
117
|
-
outcome = await wait(root, waitOpts);
|
|
118
|
-
}
|
|
119
|
-
if (outcome.state === 'no-run') return { mode: 'local', reason: `${workflow} não rodou para este commit` };
|
|
120
|
-
if (outcome.state === 'unknown') return { mode: 'local', reason: 'gh run list não respondeu' };
|
|
121
|
-
if (outcome.state === 'timeout') {
|
|
122
|
-
return { mode: 'pending', reason: `o CI não terminou em ${Math.round(waitMs / 60000)} min`, run: outcome.run ?? null };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const { run } = outcome;
|
|
126
|
-
const result = downloadResult(root, run.databaseId);
|
|
127
|
-
if (!isVerifyResult(result)) {
|
|
128
|
-
return {
|
|
129
|
-
mode: 'local',
|
|
130
|
-
reason: `run ${run.conclusion} sem o JSON do verify (${run.url}; gh run view ${run.databaseId} --log-failed)`,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
const { persisted, reason, repeatOf, cleanup, ...verdict } = result;
|
|
134
|
-
return { mode: 'ci', result: verdict, run, created };
|
|
135
|
-
}
|