@polymorphism-tech/morph-spec 10.7.1 → 10.8.1

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.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +15 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/MORPH.md +4 -2
  7. package/framework/agents.json +1 -1
  8. package/framework/docs/CLI.md +32 -17
  9. package/framework/hooks/README.md +2 -2
  10. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +12 -4
  11. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +43 -4
  12. package/framework/hooks/claude-code/core/conventional-commit.js +1 -1
  13. package/framework/hooks/claude-code/core/protect-spec-files.js +8 -1
  14. package/framework/hooks/claude-code/core/worktree-context.js +5 -1
  15. package/framework/hooks/shared/command-tokens.js +1 -1
  16. package/framework/hooks/shared/e2e-check.js +16 -0
  17. package/framework/hooks/shared/heavy-command.js +3 -2
  18. package/framework/hooks/shared/state-reader.js +3 -0
  19. package/framework/rules/implement-report.md +1 -1
  20. package/framework/rules/plan-grammar.md +3 -2
  21. package/framework/schemas/config.schema.json +6 -1
  22. package/framework/schemas/feature.schema.json +4 -2
  23. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  24. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  25. package/framework/skills/core/morph-apply/SKILL.md +8 -7
  26. package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
  27. package/framework/skills/core/morph-apply/references/review.md +9 -3
  28. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  29. package/framework/skills/core/morph-plan/references/critique.md +26 -18
  30. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  31. package/framework/templates/context/ARCHITECTURE.md +0 -3
  32. package/package.json +1 -1
  33. package/src/commands/graph/diff.js +9 -2
  34. package/src/commands/project/worktree.js +86 -7
  35. package/src/commands/state/approve.js +4 -7
  36. package/src/commands/state/create.js +3 -3
  37. package/src/commands/state/finish.js +75 -4
  38. package/src/commands/state/gate-check.js +7 -2
  39. package/src/commands/state/report-check.js +33 -6
  40. package/src/commands/validation/plan-check.js +11 -2
  41. package/src/commands/validation/prove-red.js +95 -0
  42. package/src/commands/validation/verify.js +56 -40
  43. package/src/lib/ci/ci-verify.js +1 -106
  44. package/src/lib/ci/github-ci.js +5 -117
  45. package/src/lib/generators/architecture-scanner.js +1 -14
  46. package/src/lib/git/default-branch.js +28 -1
  47. package/src/lib/plan/artifact-vocabulary.js +7 -3
  48. package/src/lib/plan/critique.js +17 -0
  49. package/src/lib/review-confirmation.js +10 -11
  50. package/src/lib/review-reports.js +12 -3
  51. package/src/lib/risk/diff-risk.js +137 -0
  52. package/src/lib/risk-detector.js +1 -1
  53. package/src/lib/validators/code-budget/code-budget-validator.js +16 -4
  54. package/src/lib/validators/verify-runner.js +24 -2
  55. package/src/lib/verify/affected.js +3 -1
  56. package/src/utils/refusal-line.js +37 -0
  57. 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 = (fj?.description && String(fj.description).trim())
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
- const arch =runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
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 security = securityReviewFor(changed);
52
- return { required: requiredReviewRoles({ workType: feature?.workType, securityRequired: security.required }), security };
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
- verifier: verifierModelFor({
48
- promptModelTouched: changedFiles.some((f) => globs.some((g) => minimatch(f, g))),
49
- generatesMedia: featureGeneratesMedia(readOrEmpty(getAbsoluteOutputPath(cwd, feature, 'plan'))),
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
- emit({ feature, implement, reviews: inspectReviewReports(cwd, feature), ...treeFor(cwd, feature, options.beforeEval) }, 0);
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
- atomicWriteJson(checksPath, buildChecks(featureName, text, plan));
41
- return { ...result, ok: true, exit: 0, written: true, viewPath: join(cwd, '.morph', 'features', featureName, FEATURE_VIEW_FILE) };
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 { runCiVerify, CiUsageError, reusableLocalStamp, resolvePrProof } from '../../lib/ci/ci-verify.js';
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 roda a suíte inteira da feature no CI; não combina com ${conflict}\n`));
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
- let ci = null;
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 — o CI não roda de novo', options);
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?.local) console.error(chalk.yellow(`\n↪ completa local: ${prProof.local}\n`));
241
- try {
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 = ci ? ci.result : await runLocalVerify(featureName, { ...options, excludeNodes: pendingPr?.covers }, fingerprint);
284
+ const result = await runLocalVerify(featureName, { ...options, excludeNodes: pendingPr?.covers }, fingerprint);
270
285
 
271
- const cleanup = ci ? { status: 'skipped', reason: 'ci' } : afterHeavyRun({
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: ci ? ci.run.headSha : getHeadSha(process.cwd()),
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 && !ci) repeatOf = findRepeatedVerify(cwd, { feature: featureName, fingerprint });
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: ci ? ci.run.headSha : getHeadSha(cwd), tree, persisted: persist && !scopedRun, reason: scopedRun ? 'affected' : reason, fingerprint, source: ci ? 'ci' : 'local' }));
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`));
@@ -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 { resolveGh } from '../git/gh.js';
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
- }