@polymorphism-tech/morph-spec 10.7.0 → 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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +17 -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/hooks/statusline.js +3 -1
  20. package/framework/rules/implement-report.md +1 -1
  21. package/framework/rules/plan-grammar.md +3 -2
  22. package/framework/schemas/config.schema.json +6 -1
  23. package/framework/schemas/feature.schema.json +4 -2
  24. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  25. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  26. package/framework/skills/core/morph-apply/SKILL.md +8 -7
  27. package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
  28. package/framework/skills/core/morph-apply/references/review.md +9 -3
  29. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  30. package/framework/skills/core/morph-plan/references/critique.md +26 -18
  31. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  32. package/framework/templates/context/ARCHITECTURE.md +0 -3
  33. package/package.json +1 -1
  34. package/src/commands/graph/diff.js +9 -2
  35. package/src/commands/project/worktree.js +86 -7
  36. package/src/commands/state/approve.js +4 -7
  37. package/src/commands/state/create.js +3 -3
  38. package/src/commands/state/finish.js +75 -4
  39. package/src/commands/state/gate-check.js +7 -2
  40. package/src/commands/state/report-check.js +33 -6
  41. package/src/commands/validation/plan-check.js +11 -2
  42. package/src/commands/validation/prove-red.js +95 -0
  43. package/src/commands/validation/verify.js +56 -40
  44. package/src/lib/ci/ci-verify.js +1 -106
  45. package/src/lib/ci/github-ci.js +5 -117
  46. package/src/lib/generators/architecture-scanner.js +1 -14
  47. package/src/lib/git/default-branch.js +28 -1
  48. package/src/lib/plan/artifact-vocabulary.js +7 -3
  49. package/src/lib/plan/critique.js +17 -0
  50. package/src/lib/review-confirmation.js +10 -11
  51. package/src/lib/review-reports.js +12 -3
  52. package/src/lib/risk/diff-risk.js +116 -0
  53. package/src/lib/risk-detector.js +1 -1
  54. package/src/lib/validators/code-budget/code-budget-validator.js +16 -4
  55. package/src/lib/validators/verify-runner.js +24 -2
  56. package/src/lib/verify/affected.js +3 -1
  57. package/src/lib/verify/detached.js +3 -0
  58. package/src/utils/refusal-line.js +37 -0
  59. package/src/lib/ci/workflow-scaffold.js +0 -126
@@ -1,45 +1,8 @@
1
1
  import { execFileSync, spawnSync } from 'child_process';
2
- import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs';
3
- import { tmpdir } from 'os';
4
- import { join } from 'path';
5
2
  import { resolveGh } from '../git/gh.js';
6
3
 
7
- export const CI_WORKFLOW_DIR = '.github/workflows';
8
- export const CI_WORKFLOW_FILE = 'morph-verify.yml';
9
- export const CI_ARTIFACT = 'morph-verify';
10
- export const CI_RESULT_FILE = 'morph-verify.json';
11
-
12
- // A run that never judged the code (superseded, filtered out, quota) is no proof either way.
13
- const NO_VERDICT = new Set(['cancelled', 'skipped', 'startup_failure', 'stale', 'neutral']);
14
-
15
- const VERIFY_CALL = /morph-spec\S*\s+verify\b/;
16
-
17
4
  const git = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
18
5
 
19
- function gh(cwd, args, timeout = 15000) {
20
- const { bin, prefix } = resolveGh();
21
- return execFileSync(bin, [...prefix, ...args], { cwd, stdio: 'pipe', timeout }).toString();
22
- }
23
-
24
- // Must call `morph-spec verify` AND publish its JSON: only that artifact becomes a stamp, and a green
25
- // lint or deploy run proves nothing about the suite.
26
- export function findCiWorkflow(root) {
27
- const dir = join(root, CI_WORKFLOW_DIR);
28
- if (!existsSync(dir)) return null;
29
- const files = readdirSync(dir).filter((f) => /\.ya?ml$/i.test(f)).sort((a, b) =>
30
- (a === CI_WORKFLOW_FILE ? -1 : b === CI_WORKFLOW_FILE ? 1 : a.localeCompare(b)));
31
- for (const file of files) {
32
- let text;
33
- try {
34
- text = readFileSync(join(dir, file), 'utf8');
35
- } catch {
36
- continue;
37
- }
38
- if (VERIFY_CALL.test(text) && text.includes(CI_RESULT_FILE)) return file;
39
- }
40
- return null;
41
- }
42
-
43
6
  export function currentBranch(root) {
44
7
  try {
45
8
  return git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
@@ -56,14 +19,13 @@ function originUrl(root) {
56
19
  }
57
20
  }
58
21
 
59
- function ghAuthText(root) {
22
+ function ghAuthOk(root) {
60
23
  const { bin, prefix } = resolveGh();
61
24
  const res = spawnSync(bin, [...prefix, 'auth', 'status'], { cwd: root, stdio: 'pipe', timeout: 10000 });
62
- if (res.error || res.status !== 0) return null;
63
- return `${res.stdout ?? ''}${res.stderr ?? ''}`;
25
+ return !res.error && res.status === 0;
64
26
  }
65
27
 
66
- /** Never throws: every "no" is a reason the caller prints before falling back to the local verify. */
28
+ /** Never throws: every "no" is a reason the caller prints before running everything locally. */
67
29
  export function ciAvailability(root, featureName, config) {
68
30
  if (config?.project?.ci === false) return { ok: false, reason: 'project.ci = false no config.json' };
69
31
  const url = originUrl(root);
@@ -73,17 +35,8 @@ export function ciAvailability(root, featureName, config) {
73
35
  if (branch !== `morph/${featureName}`) {
74
36
  return { ok: false, reason: `branch atual "${branch}" não é morph/${featureName}` };
75
37
  }
76
- const auth = ghAuthText(root);
77
- if (auth === null) return { ok: false, reason: 'gh ausente ou não autenticado' };
78
- const scopes = auth.match(/Token scopes:\s*(.*)/i)?.[1] ?? null;
79
- return { ok: true, branch, https: /^https?:/i.test(url), scopes };
80
- }
81
-
82
- // Creating a workflow file over HTTPS needs the token's `workflow` scope, or GitHub rejects the whole push.
83
- // Unknown scopes (fine-grained token, SSH remote) are not a refusal.
84
- export function workflowPushProblem({ https, scopes }) {
85
- if (!https || scopes === null) return null;
86
- return /\bworkflow\b/.test(scopes) ? null : 'o token do gh não tem o escopo "workflow" (gh auth refresh -s workflow)';
38
+ if (!ghAuthOk(root)) return { ok: false, reason: 'gh ausente ou não autenticado' };
39
+ return { ok: true, branch };
87
40
  }
88
41
 
89
42
  /** Uncommitted or untracked files outside `.morph/`, plus the config; null when git did not answer. */
@@ -97,68 +50,3 @@ export function uncommittedCode(root) {
97
50
  return out.split('\0').filter(Boolean).map((line) => line.slice(3))
98
51
  .filter((path) => !path.startsWith('.morph/') || path === '.morph/config/config.json');
99
52
  }
100
-
101
- export function pushBranch(root, branch) {
102
- try {
103
- git(root, ['push', '-u', 'origin', branch]);
104
- return null;
105
- } catch (err) {
106
- return (err.stderr?.toString() || err.message).trim().split('\n').slice(-3).join(' ');
107
- }
108
- }
109
-
110
- /** @returns {Array<object>|null} newest first; null = gh did not answer */
111
- export function listRuns(root, branch, workflow) {
112
- try {
113
- return JSON.parse(gh(root, ['run', 'list', '--branch', branch, '--workflow', workflow, '--limit', '20',
114
- '--json', 'databaseId,status,conclusion,headSha,url,createdAt']));
115
- } catch {
116
- return null;
117
- }
118
- }
119
-
120
- // `isCurrent` compares trees, not SHAs: the review agents and the archive commit only `.morph/`,
121
- // and the workflow does not re-run for them.
122
- export function pickRun(runs, isCurrent) {
123
- return (runs || []).find((run) => !(run.status === 'completed' && NO_VERDICT.has(run.conclusion))
124
- && run.headSha && isCurrent(run.headSha)) ?? null;
125
- }
126
-
127
- /** @returns {object|null} the parsed `verify --json` the run uploaded; null when absent or unreadable */
128
- export function downloadResult(root, runId) {
129
- const dir = mkdtempSync(join(tmpdir(), 'morph-ci-'));
130
- try {
131
- gh(root, ['run', 'download', String(runId), '--name', CI_ARTIFACT, '--dir', dir], 120000);
132
- return JSON.parse(readFileSync(join(dir, CI_RESULT_FILE), 'utf8'));
133
- } catch {
134
- return null;
135
- } finally {
136
- rmSync(dir, { recursive: true, force: true });
137
- }
138
- }
139
-
140
- /** Never decides: the caller turns `timeout`/`no-run`/`unknown` into "no CI proof", never into red. */
141
- export async function waitForRun(root, { branch, workflow, isCurrent, timeoutMs, appearMs, pollMs = 15000,
142
- sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onPending = () => {}, list = listRuns }) {
143
- const start = now();
144
- let seen = false;
145
- let misses = 0;
146
- for (;;) {
147
- const runs = list(root, branch, workflow);
148
- if (runs === null) {
149
- if (++misses >= 3) return { state: 'unknown' };
150
- } else {
151
- misses = 0;
152
- const run = pickRun(runs, isCurrent);
153
- if (run?.status === 'completed') return { state: 'completed', run };
154
- if (run) {
155
- if (!seen) onPending(run);
156
- seen = true;
157
- }
158
- }
159
- const elapsed = now() - start;
160
- if (!seen && elapsed >= appearMs) return { state: 'no-run' };
161
- if (elapsed >= timeoutMs) return { state: 'timeout' };
162
- await sleep(pollMs);
163
- }
164
- }
@@ -417,18 +417,8 @@ export async function renderArchitectureContext(projectPath, scanResult, options
417
417
  }
418
418
  }
419
419
 
420
- let morphVersion = 'unknown';
421
- try {
422
- const pkgPath = path.join(cliRootDir(), 'package.json');
423
- if (existsSync(pkgPath)) {
424
- morphVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
425
- }
426
- } catch {}
427
-
428
420
  const variables = {
429
421
  projectName,
430
- timestamp: new Date().toISOString().replace('T', ' ').split('.')[0],
431
- morphVersion,
432
422
  hasDotnet: scanResult.entities.length > 0 || scanResult.endpoints.length > 0,
433
423
  entities: scanResult.entities,
434
424
  entityCount: scanResult.entities.length,
@@ -473,8 +463,7 @@ export async function renderArchitectureContext(projectPath, scanResult, options
473
463
 
474
464
  function generateFallbackMarkdown(vars) {
475
465
  const lines = [`# ${vars.projectName} — Architecture Context\n`];
476
- lines.push(`> Auto-generated by morph-spec context refresh`);
477
- lines.push(`> Last updated: ${vars.timestamp}\n`);
466
+ lines.push('> Auto-generated by morph-spec context refresh\n');
478
467
 
479
468
  if (vars.hasDotnet) {
480
469
  lines.push('## .NET Entities\n');
@@ -550,7 +539,5 @@ function generateFallbackMarkdown(vars) {
550
539
  lines.push('');
551
540
  }
552
541
  }
553
-
554
- lines.push(`---\n*Generated by morph-spec v${vars.morphVersion}*`);
555
542
  return lines.join('\n');
556
543
  }
@@ -28,6 +28,24 @@ export function detectDefaultBranch(cwd = process.cwd()) {
28
28
  return null;
29
29
  }
30
30
 
31
+ /**
32
+ * Where a new feature branch starts: origin's default after a best-effort fetch, so a local default that
33
+ * was never pulled does not hand the feature an old base. Falls back to the local default.
34
+ */
35
+ export function freshDefaultRef(cwd = process.cwd()) {
36
+ const branch = detectDefaultBranch(cwd);
37
+ if (!branch) return null;
38
+ try {
39
+ execFileSync('git', ['fetch', '-q', 'origin', branch], { cwd, stdio: 'pipe', timeout: 30_000 });
40
+ } catch { /* offline or no origin: the local default */ }
41
+ try {
42
+ execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`], { cwd, stdio: 'pipe' });
43
+ return `origin/${branch}`;
44
+ } catch {
45
+ return branch;
46
+ }
47
+ }
48
+
31
49
  export function hasOriginRemote(cwd = process.cwd()) {
32
50
  try {
33
51
  const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, stdio: 'pipe' }).toString().trim();
@@ -107,8 +125,17 @@ export function changedPathsAgainst(baseRef, cwd = process.cwd()) {
107
125
 
108
126
  let tracked;
109
127
  try {
128
+ // From the merge-base: what the default gained after the branch point is not this feature's.
129
+ // origin's copy first: a local default that was never pulled holds the merge-base back.
130
+ let base = baseRef;
131
+ for (const ref of [`origin/${baseRef}`, baseRef]) {
132
+ try {
133
+ base = execFileSync('git', ['merge-base', 'HEAD', ref], { cwd, stdio: 'pipe' }).toString().trim() || baseRef;
134
+ break;
135
+ } catch { /* next ref */ }
136
+ }
110
137
  tracked = splitLines(
111
- execFileSync('git', ['diff', '--name-only', baseRef], { cwd, stdio: 'pipe' }).toString()
138
+ execFileSync('git', ['diff', '--name-only', base], { cwd, stdio: 'pipe' }).toString()
112
139
  );
113
140
  } catch {
114
141
  return [];
@@ -26,15 +26,19 @@ export const ARTIFACT_VOCABULARY = [
26
26
  'u',
27
27
  ),
28
28
  },
29
- { term: 'glob **/', re: /\*\*\/|\/\*\*/ },
29
+ // A glob inside backticks is a value the change is about (a path a CI filters), not a claim about files.
30
+ { term: 'glob **/', re: /\*\*\/|\/\*\*/, outsideCode: true },
30
31
  ];
31
32
 
33
+ const CODE_SPAN = /`[^`]*`/g;
34
+
32
35
  /** Each term at most once, in vocabulary order. */
33
36
  export function findArtifactTerms(text) {
34
37
  const s = String(text ?? '');
38
+ const prose = s.replace(CODE_SPAN, '');
35
39
  const found = [];
36
- for (const { term, re } of ARTIFACT_VOCABULARY) {
37
- if (re.test(s)) found.push(term);
40
+ for (const { term, re, outsideCode } of ARTIFACT_VOCABULARY) {
41
+ if (re.test(outsideCode ? prose : s)) found.push(term);
38
42
  }
39
43
  return found;
40
44
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
3
3
  import { featureGeneratesMedia } from './media.js';
4
+ import { SENSITIVE_LINE } from '../risk/diff-risk.js';
4
5
 
5
6
  export const CRITIQUE_EXEMPT_WORK_TYPES = ['chore', 'hotfix'];
6
7
 
@@ -16,6 +17,22 @@ export function isSmallPlan(checks, planText = '') {
16
17
  && !featureGeneratesMedia(planText);
17
18
  }
18
19
 
20
+ /**
21
+ * Who writes `critica.md`. By default the session, with the grep checklist of morph-plan
22
+ * (references/critique.md): the premises it catches are what a grep confirms. A rung, generated
23
+ * media or a sensitive theme (PII, permission, sending to a third party, payment) asks for the
24
+ * fresh-context agent, whose blind spot is not the planner's.
25
+ */
26
+ export function critiqueByAgent(checks, planText = '') {
27
+ const reasons = [];
28
+ const rungs = (checks?.checks ?? []).filter((c) => c.rung).map((c) => `${c.id} (${c.rung})`);
29
+ if (rungs.length) reasons.push(`degrau: ${rungs.join(', ')}`);
30
+ if (featureGeneratesMedia(planText)) reasons.push('mídia gerada');
31
+ const sensitive = String(planText).split(/\r?\n/).find((line) => SENSITIVE_LINE.test(line));
32
+ if (sensitive) reasons.push(`tema sensível: ${sensitive.trim().slice(0, 80)}`);
33
+ return { agent: reasons.length > 0, reasons };
34
+ }
35
+
19
36
  const PLAN_HASH_RE = /^\*\*Plan-Hash:\*\*\s*`?([0-9a-f]{64})`?\s*$/m;
20
37
  const APPLIED_RE = /^\*\*Aplicado-em:\*\*\s*`?([0-9a-f]{64})`?\s*$/gm;
21
38
 
@@ -2,6 +2,7 @@ import { execFileSync } from 'child_process';
2
2
  import { basename } from 'path';
3
3
  import { treeMovedSince } from './git/evidence-staleness.js';
4
4
  import { isTestOnly, securityReviewFor } from './risk-detector.js';
5
+ import { deltaRisk } from './risk/diff-risk.js';
5
6
 
6
7
  function resolveCommit(cwd, ref) {
7
8
  try {
@@ -38,30 +39,28 @@ function deltaIsNarrow(role, delta, cited) {
38
39
  }
39
40
 
40
41
  /**
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.
42
+ * The one oracle of "this review is stale", read by `approve` and by `report-check` alike. A delta that
43
+ * touches only `.morph/`, docs, config, imports or comments (`deltaRisk` trivial) moves no role. Otherwise a
44
+ * role that shipped clean need not return when the delta stays inside what the blockers named or in
45
+ * tests (security: no sensitive path), and only once a role that opened blockers re-judged `head`.
45
46
  */
46
47
  export function reviewConfirmation(cwd, reports, head) {
47
48
  const cited = citedByBlockers(reports);
48
- const moved = [];
49
+ const behind = [];
49
50
  let confirmed = false;
50
51
 
51
52
  for (const [role, report] of Object.entries(reports)) {
52
53
  if (report.status === 'missing' || !report.reviewedSha) continue;
53
54
  const reviewed = resolveCommit(cwd, report.reviewedSha);
54
55
  if (!reviewed) continue;
55
- if (!treeMovedSince(cwd, reviewed, head)) {
56
+ if (!treeMovedSince(cwd, reviewed, head) || deltaRisk(cwd, reviewed, head)?.trivial) {
56
57
  if (openedBlockers(report)) confirmed = true;
57
58
  continue;
58
59
  }
59
- moved.push({
60
- role,
61
- reviewed,
62
- skippable: isCleanShip(report) && deltaIsNarrow(role, changedBetween(cwd, reviewed, head), cited),
63
- });
60
+ behind.push({ role, reviewed, narrow: isCleanShip(report) && deltaIsNarrow(role, changedBetween(cwd, reviewed, head), cited) });
64
61
  }
65
62
 
63
+ const anyBlockers = Object.values(reports).some(openedBlockers);
64
+ const moved = behind.map(({ narrow, ...m }) => ({ ...m, skippable: narrow && (confirmed || !anyBlockers) }));
66
65
  return { moved, confirmed };
67
66
  }
@@ -30,11 +30,16 @@ const CHECKS_SECTION_RE = sectionRe('Checks');
30
30
  const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
31
31
  const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
32
32
 
33
- /** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Chore: só o revisor (review leve). */
34
- export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
33
+ /**
34
+ * Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Fora dele manda o `risk` do diff
35
+ * (`src/lib/risk/diff-risk.js`): `low`/`none` = só o revisor, escrito pela sessão com o checklist.
36
+ * Sem risco medido, o tipo decide (chore leve).
37
+ */
38
+ export function requiredReviewRoles({ workType, securityRequired = false, risk = null } = {}) {
35
39
  // Sem `security` mesmo em caminho sensível: o humano assina todo hotfix.
36
40
  if (workType === 'hotfix') return ['verifier'];
37
- const base = workType === 'chore' ? ['reviewer'] : ['verifier', 'reviewer'];
41
+ const light = risk ? ['low', 'none'].includes(risk) : workType === 'chore';
42
+ const base = light ? ['reviewer'] : ['verifier', 'reviewer'];
38
43
  return securityRequired ? [...base, 'security'] : base;
39
44
  }
40
45
 
@@ -78,12 +83,16 @@ function tableRows(body, keepHeader = () => true) {
78
83
  return rows;
79
84
  }
80
85
 
86
+ // A confirmation round that relists a blocker in the typed table to close it ("**Fechado.** …").
87
+ const CLOSED_ROW_RE = /^(fechado|resolvido|closed|resolved)\b/;
88
+
81
89
  // `| id | tipo | onde | evidência |`; `untyped` = tipo fora de BLOCKER_TYPES.
82
90
  function parseBlockers(body) {
83
91
  const blockers = [];
84
92
  const untyped = [];
85
93
  for (const cells of tableRows(body, (header) => !isStatusHeader(header))) {
86
94
  const row = { id: cleanId(cells[0]), type: normalize(cells[1]), where: cells[2] ?? '', evidence: cells[3] ?? '' };
95
+ if (CLOSED_ROW_RE.test(normalize(row.evidence.replace(/[*_`]/g, '')))) continue;
87
96
  (BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
88
97
  }
89
98
  return { blockers, untyped };
@@ -0,0 +1,116 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { isTestPath } from '../graph/sanitize.js';
3
+ import { contractsTouched, migrationsTouched, securityReviewFor } from '../risk-detector.js';
4
+
5
+ const DOC_PATH = /(^|\/)docs\/|\.(md|mdx|txt|rst|adoc|png|jpe?g|gif|svg|webp|ico)$/i;
6
+ const CONFIG_PATH = /\.(json|jsonc|ya?ml|toml|ini|xml|editorconfig|env\.example)$|(^|\/)\.(gitignore|gitattributes|npmrc|nvmrc|prettierrc|eslintignore)$/i;
7
+ // Resolves or compiles everything: a change here reaches code the diff does not name.
8
+ const WIDE_PATH = /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|global\.json|Directory\.(Build|Packages)\.(props|targets)|tsconfig[^/]*\.json|[^/]+\.(csproj|sln|slnx|props|targets))$/i;
9
+ const TRIVIAL_LINE = /^\s*(|\/\/.*|#(?!include).*|\/\*.*|\*.*|\*\/|--.*|(import|using|export\s+\*\s+from|#include)\b.*|.*\brequire\(['"][^'"]+['"]\);?\s*)$/;
10
+ // PII, permission, sending to a third party, payment, contract: what turns a diff into a security review.
11
+ export const SENSITIVE_LINE = /\b(pii|lgpd|gdpr|cpf|cnpj|redact\w*|anonimi\w*|opt.?out|dnd|consent\w*|permiss\w*|authoriz\w*|autoriza\w*|requirerole|isadmin|password|senha|secret|apikey|api_key|whatsapp|sendmessage|sendtemplate|sms|webhook|smtp|sendemail|payment|pagamento|cobran\w*|invoice|fatura|checkout|refund|estorno|stripe|asaas|contrato)\b/i;
12
+
13
+ const camelSplit = (path) => path.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
14
+
15
+ function git(cwd, args) {
16
+ return execFileSync('git', args, { cwd, stdio: 'pipe', maxBuffer: 256 * 1024 * 1024 }).toString();
17
+ }
18
+
19
+ export function fileClass(path) {
20
+ if (path.startsWith('.morph/')) return 'state';
21
+ if (DOC_PATH.test(path)) return 'doc';
22
+ if (CONFIG_PATH.test(path) && !WIDE_PATH.test(path)) return 'config';
23
+ return isTestPath(path) || /(^|\/)e2e\//.test(path) ? 'test' : 'code';
24
+ }
25
+
26
+ /** Per file: `added`/`removed` lines and `deleted` (the whole file went away). */
27
+ export function diffLines(cwd, from, to = null) {
28
+ const range = to ? [from, to] : [from];
29
+ const text = git(cwd, ['-c', 'core.quotepath=off', 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range, '--', '.']);
30
+ const files = new Map();
31
+ let current = null;
32
+ for (const line of text.split('\n')) {
33
+ if (line.startsWith('diff --git ')) {
34
+ const path = line.split(' b/').pop();
35
+ current = { path, added: [], removed: [], deleted: false };
36
+ files.set(path, current);
37
+ } else if (!current) {
38
+ continue;
39
+ } else if (line.startsWith('deleted file mode')) {
40
+ current.deleted = true;
41
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
42
+ current.added.push(line.slice(1));
43
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
44
+ current.removed.push(line.slice(1));
45
+ }
46
+ }
47
+ if (!to) {
48
+ for (const path of git(cwd, ['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean)) {
49
+ files.set(path, { path, added: [''], removed: [], deleted: false, untracked: true });
50
+ }
51
+ }
52
+ return [...files.values()];
53
+ }
54
+
55
+ /** `trivial` = only imports, comments, blank lines; `removal` = logic only taken out; `logic` = logic added. */
56
+ export function changeKind(file) {
57
+ if (file.deleted) return 'removal';
58
+ if (file.untracked) return 'logic';
59
+ if (file.added.some((l) => !TRIVIAL_LINE.test(l))) return 'logic';
60
+ return file.removed.some((l) => !TRIVIAL_LINE.test(l)) ? 'removal' : 'trivial';
61
+ }
62
+
63
+ /** `low`: docs, `.morph/`, config, removals, imports. `normal`: logic in code or tests. `security` rides on either. */
64
+ export function classifyFiles(files) {
65
+ const reasons = [];
66
+ const relevant = files.filter((f) => fileClass(f.path) !== 'state');
67
+ const logic = relevant.filter((f) => ['code', 'test'].includes(fileClass(f.path)) && changeKind(f) === 'logic');
68
+ const wide = relevant.filter((f) => WIDE_PATH.test(f.path));
69
+ const paths = relevant.map((f) => f.path);
70
+ if (logic.length) reasons.push(`lógica em ${logic.slice(0, 3).map((f) => f.path).join(', ')}${logic.length > 3 ? ` (+${logic.length - 3})` : ''}`);
71
+ if (wide.length) reasons.push(`arquivo que alcança o projeto inteiro: ${wide[0].path}`);
72
+ const migration = migrationsTouched(paths);
73
+ const contract = contractsTouched(paths);
74
+ if (migration.length) reasons.push(`migration: ${migration[0]}`);
75
+
76
+ const securityReasons = [...securityReviewFor(paths.map(camelSplit)).reasons];
77
+ if (contract.length) securityReasons.push(`contrato: ${contract[0]}`);
78
+ const sensitive = relevant.find((f) => fileClass(f.path) === 'code' && f.added.some((l) => SENSITIVE_LINE.test(l)));
79
+ if (sensitive) {
80
+ const line = sensitive.added.find((l) => SENSITIVE_LINE.test(l)).trim();
81
+ securityReasons.push(`linha sensível em ${sensitive.path}: ${line.slice(0, 80)}`);
82
+ }
83
+
84
+ const level = relevant.length === 0 ? 'none' : reasons.length || securityReasons.length ? 'normal' : 'low';
85
+ const trivial = level !== 'normal' && relevant.every((f) => !['code', 'test'].includes(fileClass(f.path)) || changeKind(f) === 'trivial');
86
+ return { level, trivial, reasons, security: { required: securityReasons.length > 0, reasons: securityReasons } };
87
+ }
88
+
89
+ function mergeBase(cwd, defaultBranch) {
90
+ for (const ref of [`origin/${defaultBranch}`, defaultBranch]) {
91
+ try {
92
+ return git(cwd, ['merge-base', 'HEAD', ref]).trim() || null;
93
+ } catch { /* next ref */ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /** The feature's diff from the merge-base with the default, working tree included; null when git cannot tell. */
99
+ export function featureDiffRisk(cwd, defaultBranch) {
100
+ const base = defaultBranch ? mergeBase(cwd, defaultBranch) : null;
101
+ if (!base) return null;
102
+ try {
103
+ return { base, ...classifyFiles(diffLines(cwd, base)) };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /** The delta between two commits; `trivial` (no logic added or removed) never asks for a second review. */
110
+ export function deltaRisk(cwd, from, to) {
111
+ try {
112
+ return classifyFiles(diffLines(cwd, from, to));
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
@@ -214,7 +214,7 @@ const PLAN_CRITIQUE_REASONS = {
214
214
  // Regra única do gate-check (pausa) e do approve/advance automáticos do plano (recusa).
215
215
  export function planCritiquePause(status) {
216
216
  const what = PLAN_CRITIQUE_REASONS[status];
217
- return what ? `${what} — despache a crítica em contexto fresco (morph-plan, references/critique.md) antes do gate do plano` : null;
217
+ return what ? `${what} — escreva a crítica antes do gate do plano: a sessão com o checklist de grep, ou o sub-agent quando o plan-check pedir (morph-plan, references/critique.md)` : null;
218
218
  }
219
219
 
220
220
  // Pauses the session clears by doing the work the reason names; the rest wait for the human.
@@ -6,6 +6,7 @@ import { join } from 'path';
6
6
  import { addedLinesAgainst, detectDefaultBranch } from '../../git/default-branch.js';
7
7
  import { resolveBaseRef } from '../../git/branch-classifier.js';
8
8
  import { isTestFile } from '../reuse/symbol-scanner.js';
9
+ import { readPlanChecks } from '../../plan/checks.js';
9
10
  import { isGenerated, isProjectSource } from '../../../../framework/hooks/shared/source-files.js';
10
11
  import { citesProcess, commentLines } from '../../../../framework/hooks/shared/comment-scanner.js';
11
12
 
@@ -14,6 +15,9 @@ export const MAX_TEST_RATIO = 1;
14
15
  /** Abaixo disto a razão não é julgada: a regressão de um bug de 2 linhas cabe aqui. */
15
16
  export const MIN_TEST_LINES = 40;
16
17
 
18
+ /** Linhas de teste que cada cenário com `prova: teste` (sempre com `risco:`) pode pedir além da razão. */
19
+ export const REQUIRED_TEST_ALLOWANCE = 40;
20
+
17
21
  export const MAX_COMMENT_RATIO = 0.1;
18
22
 
19
23
  /** Abaixo disto a densidade não é julgada: um comentário legítimo num arquivo pequeno já passaria da razão. */
@@ -48,7 +52,7 @@ export function measureCodeBudget(files) {
48
52
  return measured;
49
53
  }
50
54
 
51
- export function budgetIssues(measured) {
55
+ export function budgetIssues(measured, { requiredTests = 0 } = {}) {
52
56
  const issues = [];
53
57
  const { commentLines: comments, processLines, testLines, productionLines, commentsByFile = {} } = measured;
54
58
 
@@ -68,13 +72,15 @@ export function budgetIssues(measured) {
68
72
  });
69
73
  }
70
74
 
71
- if (testLines > MIN_TEST_LINES && testLines > MAX_TEST_RATIO * productionLines) {
75
+ const allowance = REQUIRED_TEST_ALLOWANCE * requiredTests;
76
+ if (testLines > MIN_TEST_LINES && testLines > MAX_TEST_RATIO * productionLines + allowance) {
72
77
  const ratio = productionLines > 0 ? ` (${decimal(testLines / productionLines)}×)` : '';
78
+ const required = requiredTests > 0 ? `, já descontadas ${allowance} de ${requiredTests} cenário(s) com prova: teste` : '';
73
79
  issues.push({
74
80
  level: 'warning',
75
81
  message:
76
82
  `linhas de teste adicionadas passam de ${decimal(MAX_TEST_RATIO)}× as de produção: ` +
77
- `${testLines} de teste para ${productionLines} de produção${ratio}`,
83
+ `${testLines} de teste para ${productionLines} de produção${ratio}${required}`,
78
84
  solution:
79
85
  'Orçamento por task: um teste por pós-condição + um de regressão por bug, cenários do mesmo ' +
80
86
  'comportamento numa tabela, nenhum teste que só fixa texto. Excesso justificado fica escrito na task.',
@@ -103,6 +109,12 @@ export function budgetIssues(measured) {
103
109
  return issues;
104
110
  }
105
111
 
112
+ function requiredTestCount(projectPath, featureName) {
113
+ if (!featureName) return 0;
114
+ const checks = readPlanChecks(projectPath, featureName).checks?.checks ?? [];
115
+ return checks.filter((c) => c.proof === 'teste').length;
116
+ }
117
+
106
118
  export async function validateCodeBudget(projectPath, featureName = null, options = {}) {
107
119
  const result = { errors: 0, warnings: 0, issues: [] };
108
120
 
@@ -138,7 +150,7 @@ export async function validateCodeBudget(projectPath, featureName = null, option
138
150
  return result;
139
151
  }
140
152
 
141
- result.issues = budgetIssues(measured);
153
+ result.issues = budgetIssues(measured, { requiredTests: requiredTestCount(projectPath, featureName) });
142
154
  if (unreadable.length > 0) {
143
155
  result.issues.push({
144
156
  level: 'warning',
@@ -60,14 +60,32 @@ export function withCpuShare(node, share) {
60
60
  return { ...node, command: `${node.command} --maxWorkers=${share}` };
61
61
  }
62
62
 
63
+ // The complete run still runs each suite whole; it only leaves out a suite the feature diff cannot reach.
64
+ // The same selection `--affected` uses, so what escalates it (migration, contract, shared code) keeps all.
65
+ function outOfDiffSuites(projectPath, options) {
66
+ if (options.filter || options.recordBaseline || options.refreshCache || options.linux || options.skipTests) return options;
67
+ const nodes = resolveTestNodes(projectPath).nodes;
68
+ const selection = selectAffected({ projectPath, nodes, deps: options._affectedDeps });
69
+ if (selection.scope !== 'affected') return options;
70
+ // A suite rooted at the project root reaches everything: only a suite with its own `cwd` is a slice.
71
+ const kept = new Set(nodes.filter((n) => n.e2e || n.evals || n.runner === EVAL_RUNNER || !n.cwd || n.cwd === '.').map((n) => n.id));
72
+ const outOfDiff = selection.skipped.filter((id) => !kept.has(id));
73
+ return outOfDiff.length ? { ...options, outOfDiff } : options;
74
+ }
75
+
63
76
  function applyAffectedScope(projectPath, options) {
64
- if (!options.affected) return options;
77
+ if (!options.affected) return outOfDiffSuites(projectPath, options);
65
78
  const conflict = [['filter', '--filter'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'], ['linux', '--linux']]
66
79
  .find(([key]) => options[key]);
67
80
  if (conflict) throw new VerifyUsageError(`--affected escolhe os testes sozinho; não combina com ${conflict[1]}`);
68
81
 
69
82
  const selection = selectAffected({ projectPath, nodes: resolveTestNodes(projectPath).nodes, deps: options._affectedDeps });
70
83
  if (selection.scope === 'full') return { ...options, escalatedBecause: selection.reason };
84
+ // Every suite selected and none narrowed is the complete run: stamp it once, so the gate's run reuses it.
85
+ const all = resolveTestNodes(projectPath).nodes;
86
+ const whole = selection.skipped.length === 0 && selection.nodes.length === all.length
87
+ && selection.nodes.every((n, i) => n.command === all[i].command);
88
+ if (whole) return outOfDiffSuites(projectPath, { ...options, escalatedBecause: 'a seleção afetada já é a suíte inteira' });
71
89
  return {
72
90
  ...options,
73
91
  selection,
@@ -106,8 +124,9 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
106
124
  // is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
107
125
  const resolvedPlan = resolveTestNodes(projectPath);
108
126
  const excluded = new Set(options.excludeNodes ?? []);
127
+ const outOfDiff = new Set(options.outOfDiff ?? []);
109
128
  const scopedNodes = options.selection ? options.selection.nodes : resolvedPlan.nodes;
110
- const testPlan = { ...resolvedPlan, nodes: scopedNodes.filter((node) => !excluded.has(node.id)) };
129
+ const testPlan = { ...resolvedPlan, nodes: scopedNodes.filter((node) => !excluded.has(node.id) && !outOfDiff.has(node.id)) };
111
130
  const filter = parseTestFilter(options.filter);
112
131
  if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
113
132
  const declared = testPlan.nodes.map((node) => node.id).join(', ') || '(none)';
@@ -153,6 +172,7 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
153
172
  feedback.push(`[verify] rodada afetada, suítes: ${kept}; a completa roda uma vez antes da aprovação`);
154
173
  }
155
174
  if (excluded.size > 0) feedback.push(`[verify] provadas pelo CI da PR no finish: ${[...excluded].join(', ')}`);
175
+ if (outOfDiff.size > 0) feedback.push(`[verify] suítes fora do diff da feature: ${[...outOfDiff].join(', ')}`);
156
176
 
157
177
  // Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
158
178
  const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
@@ -266,6 +286,7 @@ function flakyNames(nodes, retried) {
266
286
  ...(nodes.tests.runs || []).filter((run) => run.status === 'pass' && retried.has(run.id)).map((run) => `tests/${run.id}`),
267
287
  ...(nodes.e2e.status === 'pass' && retried.has('e2e') ? ['e2e'] : []),
268
288
  ...(nodes.e2e.flakySpecs || []).map((spec) => `e2e: ${spec}`),
289
+ ...(nodes.e2e.quarantinedSpecs || []).map((spec) => `e2e (quarentena): ${spec}`),
269
290
  ];
270
291
  }
271
292
 
@@ -871,6 +892,7 @@ async function runE2eNode({
871
892
  count: Number.isInteger(result.count) ? result.count : null,
872
893
  warnings: Array.isArray(result.warnings) ? result.warnings : [],
873
894
  flakySpecs: result.flakySpecs ?? [],
895
+ quarantinedSpecs: result.quarantinedSpecs ?? [],
874
896
  },
875
897
  stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
876
898
  };
@@ -100,7 +100,9 @@ function selectNode(node, { changed, base, cwd, git, read }) {
100
100
  const relevant = inside.filter((p) => DOTNET_SOURCE.test(p));
101
101
  return relevant.length ? selectDotnet(node, relevant, cwd, git, read) : { skip: true };
102
102
  }
103
- const relevant = inside.filter((p) => SCRIPT_SOURCE.test(p));
103
+ // A generic runner (`none`, a script) tests whatever the project says it does: any code in its cwd reaches it.
104
+ const generic = !node.runner || node.runner === 'none';
105
+ const relevant = generic ? inside : inside.filter((p) => SCRIPT_SOURCE.test(p));
104
106
  if (relevant.length === 0) return { skip: true };
105
107
  return node.runner === 'vitest' ? selectVitest(node, base) : { node };
106
108
  }