@polymorphism-tech/morph-spec 8.35.0 → 8.36.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 (55) hide show
  1. package/CLAUDE.md +11 -9
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -1
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +24 -23
  8. package/framework/docs/CLI.md +187 -13
  9. package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
  10. package/framework/evals/rubrics/condensed.md +3 -1
  11. package/framework/evals/rubrics/test-coverage.md +8 -0
  12. package/framework/hooks/claude-code/core/worktree-env-inject.js +43 -0
  13. package/framework/hooks/shared/payload-utils.js +15 -2
  14. package/framework/hooks/shared/phase-utils.js +1 -0
  15. package/framework/phases.json +1 -1
  16. package/framework/rules/morph-workflow.md +1 -1
  17. package/framework/schemas/config.schema.json +7 -0
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +21 -10
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  24. package/framework/skills/core/morph-implement/SKILL.md +20 -3
  25. package/framework/skills/core/morph-media/SKILL.md +1 -1
  26. package/framework/skills/core/morph-plan/SKILL.md +41 -3
  27. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  28. package/framework/skills/core/morph-review/SKILL.md +11 -2
  29. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  30. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  31. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  32. package/framework/standards/STANDARDS.json +2 -2
  33. package/framework/standards/ai-agents/prompt-sources.md +4 -3
  34. package/framework/standards/ai-agents/testing-ai.md +14 -13
  35. package/framework/templates/dotnet/ai-kit/README.md +14 -8
  36. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
  37. package/package.json +1 -1
  38. package/src/commands/project/doctor.js +59 -3
  39. package/src/commands/project/worktree.js +68 -3
  40. package/src/commands/state/finish.js +48 -5
  41. package/src/commands/tasks/dag.js +13 -1
  42. package/src/commands/validation/verify.js +47 -18
  43. package/src/core/paths/output-schema.js +7 -0
  44. package/src/lib/detectors/index.js +225 -217
  45. package/src/lib/git/pr-files.js +56 -0
  46. package/src/lib/risk-detector.js +38 -18
  47. package/src/lib/specs/done-criteria-check.js +200 -0
  48. package/src/lib/specs/usecase-check.js +129 -5
  49. package/src/lib/tasks/dotnet-test-introspection.js +517 -0
  50. package/src/lib/tasks/test-nodes.js +190 -20
  51. package/src/lib/tasks/test-runner.js +122 -2
  52. package/src/lib/tasks/test-scope.js +567 -0
  53. package/src/lib/validators/traceability-check.js +9 -2
  54. package/src/lib/validators/verify-runner.js +150 -18
  55. package/src/lib/worktree/docker-audit.js +5 -1
@@ -1,217 +1,225 @@
1
- /**
2
- * @fileoverview Project Detector
3
- *
4
- * Scans a project directory for stack, architecture, UI library, and integrations.
5
- * Used by mcp-setup, morph-init, and session-start hooks.
6
- *
7
- * @module detectors/index
8
- */
9
-
10
- import { existsSync, readFileSync, readdirSync } from 'fs';
11
- import { join } from 'path';
12
-
13
- /**
14
- * Detect project structure from filesystem evidence.
15
- *
16
- * @param {string} targetPath - Root path of the project
17
- * @param {Object} [opts] - Options
18
- * @param {boolean} [opts.generateStandards=false] - Whether to generate inferred markdown
19
- * @returns {{ structure: { stack: string, architecture: string|null, uiLibrary: string|null, integrations: string[], hasVercel: boolean }, inferred: { markdown: string } }}
20
- */
21
- export function detectProject(targetPath, opts = {}) {
22
- const structure = {
23
- stack: 'unknown',
24
- architecture: null,
25
- uiLibrary: null,
26
- integrations: [],
27
- hasVercel: false,
28
- };
29
-
30
- try {
31
- // ── Package.json analysis ──────────────────────────────────────────────
32
- // Collect deps from root and common monorepo subfolders
33
- const pkgLocations = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
34
- const allDeps = {};
35
- let rootPkg = null;
36
- let scripts = [];
37
- for (const loc of pkgLocations) {
38
- const pkgPath = join(targetPath, loc, 'package.json');
39
- if (!existsSync(pkgPath)) continue;
40
- try {
41
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
42
- if (loc === '.') rootPkg = pkg;
43
- Object.assign(allDeps, pkg.dependencies, pkg.devDependencies);
44
- scripts.push(...Object.keys(pkg.scripts || {}));
45
- // Track frontend path for config
46
- if (loc !== '.' && (pkg.dependencies?.next || pkg.dependencies?.react || pkg.dependencies?.vue)) {
47
- structure.frontendPath = loc;
48
- }
49
- } catch { /* invalid JSON */ }
50
- }
51
- const depNames = Object.keys(allDeps);
52
-
53
- if (depNames.length > 0) {
54
-
55
- // Stack detection from deps
56
- if (depNames.includes('next')) {
57
- structure.stack = 'nextjs';
58
- } else if (depNames.includes('react')) {
59
- structure.stack = 'react';
60
- } else if (depNames.includes('vue')) {
61
- structure.stack = 'vue';
62
- } else if (depNames.includes('express') || depNames.includes('fastify') || depNames.includes('hono')) {
63
- structure.stack = 'nodejs';
64
- } else if (rootPkg?.name) {
65
- structure.stack = 'nodejs';
66
- }
67
-
68
- // UI library detection
69
- if (existsSync(join(targetPath, 'components.json')) || existsSync(join(targetPath, structure.frontendPath || '.', 'components.json')) || depNames.includes('@shadcn/ui')) {
70
- structure.uiLibrary = 'shadcn';
71
- }
72
-
73
- // Integrations
74
- if (depNames.includes('@neondatabase/serverless') || depNames.includes('postgres') || depNames.includes('pg')) {
75
- structure.integrations.push('neon');
76
- }
77
- if (depNames.includes('@neondatabase/auth')) {
78
- structure.integrations.push('neon-auth');
79
- }
80
- if (depNames.includes('stripe')) {
81
- structure.integrations.push('stripe');
82
- }
83
-
84
- // Vercel detection
85
- if (depNames.includes('vercel') || scripts.includes('vercel')) {
86
- structure.hasVercel = true;
87
- }
88
- }
89
-
90
- // ── .csproj detection ──────────────────────────────────────────────────
91
- let hasDotnet = false;
92
- const csprojLocations = ['.', 'src', 'src/Api', 'src/Web', 'src/backend'];
93
-
94
- function checkCsproj(filePath) {
95
- hasDotnet = true;
96
- try {
97
- const content = readFileSync(filePath, 'utf8');
98
- if (content.includes('Hangfire')) {
99
- if (!structure.integrations.includes('hangfire')) structure.integrations.push('hangfire');
100
- }
101
- } catch { /* ignore */ }
102
- }
103
-
104
- for (const loc of csprojLocations) {
105
- try {
106
- const dir = join(targetPath, loc);
107
- if (!existsSync(dir)) continue;
108
- const entries = readdirSyncSafe(dir);
109
- // Direct .csproj files
110
- for (const e of entries.filter(f => f.endsWith('.csproj'))) {
111
- checkCsproj(join(dir, e));
112
- }
113
- // One level deeper (e.g., src/backend/MyProject/*.csproj)
114
- for (const sub of entries) {
115
- const subDir = join(dir, sub);
116
- for (const e of readdirSyncSafe(subDir).filter(f => f.endsWith('.csproj'))) {
117
- checkCsproj(join(subDir, e));
118
- }
119
- }
120
- } catch { /* ignore */ }
121
- }
122
-
123
- // Determine stack: monorepo (nextjs+dotnet), dotnet, or keep frontend stack
124
- if (hasDotnet) {
125
- if (structure.stack === 'nextjs' || structure.stack === 'react' || structure.stack === 'vue') {
126
- // Monorepo: frontend + .NET backend
127
- structure.stack = `${structure.stack}+dotnet`;
128
- } else if (structure.stack === 'unknown') {
129
- structure.stack = 'dotnet';
130
- }
131
- }
132
-
133
- // ── vercel.json detection ──────────────────────────────────────────────
134
- if (existsSync(join(targetPath, 'vercel.json'))) {
135
- structure.hasVercel = true;
136
- }
137
-
138
- // ── tsconfig.json detection (fallback for stack) ────────────────────────
139
- if (structure.stack === 'unknown' && existsSync(join(targetPath, 'tsconfig.json'))) {
140
- structure.stack = 'nodejs';
141
- }
142
-
143
- // ── Architecture detection ─────────────────────────────────────────────
144
- try {
145
- const configPath = join(targetPath, '.morph/config/config.json');
146
- if (existsSync(configPath)) {
147
- const config = JSON.parse(readFileSync(configPath, 'utf8'));
148
- if (config.architecture?.style) {
149
- structure.architecture = config.architecture.style;
150
- }
151
- }
152
- } catch { /* ignore */ }
153
-
154
- // ── Docker detection ───────────────────────────────────────────────────
155
- if (existsSync(join(targetPath, 'docker-compose.yml')) ||
156
- existsSync(join(targetPath, 'docker-compose.yaml')) ||
157
- existsSync(join(targetPath, 'Dockerfile'))) {
158
- if (!structure.integrations.includes('docker')) structure.integrations.push('docker');
159
- }
160
-
161
- // ── Azure detection ────────────────────────────────────────────────────
162
- const azureSignals = [
163
- '.azure',
164
- 'azure-pipelines.yml',
165
- 'infra/main.bicep',
166
- 'infra',
167
- ];
168
- const hasAzure = azureSignals.some(signal => {
169
- const p = join(targetPath, signal);
170
- if (!existsSync(p)) return false;
171
- if (signal === 'infra') {
172
- // Check for .bicep files inside infra/
173
- return readdirSyncSafe(p).some(f => f.endsWith('.bicep'));
174
- }
175
- return true;
176
- });
177
- if (hasAzure && !structure.integrations.includes('azure')) {
178
- structure.integrations.push('azure');
179
- }
180
- } catch {
181
- // Fail-safe — return defaults
182
- }
183
-
184
- return {
185
- structure,
186
- inferred: {
187
- markdown: opts.generateStandards ? generateInferredMarkdown(structure) : '',
188
- },
189
- };
190
- }
191
-
192
- /**
193
- * Safe readdir that returns [] on error.
194
- * @param {string} dir
195
- * @returns {string[]}
196
- */
197
- function readdirSyncSafe(dir) {
198
- try {
199
- return readdirSync(dir);
200
- } catch {
201
- return [];
202
- }
203
- }
204
-
205
- /**
206
- * Generate a markdown summary from detected structure.
207
- * @param {Object} structure
208
- * @returns {string}
209
- */
210
- function generateInferredMarkdown(structure) {
211
- const lines = [`## Detected Stack: ${structure.stack}`];
212
- if (structure.architecture) lines.push(`Architecture: ${structure.architecture}`);
213
- if (structure.uiLibrary) lines.push(`UI Library: ${structure.uiLibrary}`);
214
- if (structure.integrations.length > 0) lines.push(`Integrations: ${structure.integrations.join(', ')}`);
215
- if (structure.hasVercel) lines.push(`Vercel: detected`);
216
- return lines.join('\n');
217
- }
1
+ /**
2
+ * @fileoverview Project Detector
3
+ *
4
+ * Scans a project directory for stack, architecture, UI library, and integrations.
5
+ * Used by mcp-setup, morph-init, and session-start hooks.
6
+ *
7
+ * @module detectors/index
8
+ */
9
+
10
+ import { existsSync, readFileSync, readdirSync } from 'fs';
11
+ import { join } from 'path';
12
+
13
+ /**
14
+ * Common monorepo locations for a frontend `package.json`, root included.
15
+ *
16
+ * Exported so `discoverFrontendPackages()` (src/lib/tasks/test-nodes.js, #87
17
+ * PR B) scans the SAME candidate list this detector already uses — one
18
+ * spelling of "where a frontend package might live", not two that drift.
19
+ */
20
+ export const PACKAGE_LOCATIONS = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
21
+
22
+ /**
23
+ * Detect project structure from filesystem evidence.
24
+ *
25
+ * @param {string} targetPath - Root path of the project
26
+ * @param {Object} [opts] - Options
27
+ * @param {boolean} [opts.generateStandards=false] - Whether to generate inferred markdown
28
+ * @returns {{ structure: { stack: string, architecture: string|null, uiLibrary: string|null, integrations: string[], hasVercel: boolean }, inferred: { markdown: string } }}
29
+ */
30
+ export function detectProject(targetPath, opts = {}) {
31
+ const structure = {
32
+ stack: 'unknown',
33
+ architecture: null,
34
+ uiLibrary: null,
35
+ integrations: [],
36
+ hasVercel: false,
37
+ };
38
+
39
+ try {
40
+ // ── Package.json analysis ──────────────────────────────────────────────
41
+ // Collect deps from root and common monorepo subfolders
42
+ const allDeps = {};
43
+ let rootPkg = null;
44
+ let scripts = [];
45
+ for (const loc of PACKAGE_LOCATIONS) {
46
+ const pkgPath = join(targetPath, loc, 'package.json');
47
+ if (!existsSync(pkgPath)) continue;
48
+ try {
49
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
50
+ if (loc === '.') rootPkg = pkg;
51
+ Object.assign(allDeps, pkg.dependencies, pkg.devDependencies);
52
+ scripts.push(...Object.keys(pkg.scripts || {}));
53
+ // Track frontend path for config
54
+ if (loc !== '.' && (pkg.dependencies?.next || pkg.dependencies?.react || pkg.dependencies?.vue)) {
55
+ structure.frontendPath = loc;
56
+ }
57
+ } catch { /* invalid JSON */ }
58
+ }
59
+ const depNames = Object.keys(allDeps);
60
+
61
+ if (depNames.length > 0) {
62
+
63
+ // Stack detection from deps
64
+ if (depNames.includes('next')) {
65
+ structure.stack = 'nextjs';
66
+ } else if (depNames.includes('react')) {
67
+ structure.stack = 'react';
68
+ } else if (depNames.includes('vue')) {
69
+ structure.stack = 'vue';
70
+ } else if (depNames.includes('express') || depNames.includes('fastify') || depNames.includes('hono')) {
71
+ structure.stack = 'nodejs';
72
+ } else if (rootPkg?.name) {
73
+ structure.stack = 'nodejs';
74
+ }
75
+
76
+ // UI library detection
77
+ if (existsSync(join(targetPath, 'components.json')) || existsSync(join(targetPath, structure.frontendPath || '.', 'components.json')) || depNames.includes('@shadcn/ui')) {
78
+ structure.uiLibrary = 'shadcn';
79
+ }
80
+
81
+ // Integrations
82
+ if (depNames.includes('@neondatabase/serverless') || depNames.includes('postgres') || depNames.includes('pg')) {
83
+ structure.integrations.push('neon');
84
+ }
85
+ if (depNames.includes('@neondatabase/auth')) {
86
+ structure.integrations.push('neon-auth');
87
+ }
88
+ if (depNames.includes('stripe')) {
89
+ structure.integrations.push('stripe');
90
+ }
91
+
92
+ // Vercel detection
93
+ if (depNames.includes('vercel') || scripts.includes('vercel')) {
94
+ structure.hasVercel = true;
95
+ }
96
+ }
97
+
98
+ // ── .csproj detection ──────────────────────────────────────────────────
99
+ let hasDotnet = false;
100
+ const csprojLocations = ['.', 'src', 'src/Api', 'src/Web', 'src/backend'];
101
+
102
+ function checkCsproj(filePath) {
103
+ hasDotnet = true;
104
+ try {
105
+ const content = readFileSync(filePath, 'utf8');
106
+ if (content.includes('Hangfire')) {
107
+ if (!structure.integrations.includes('hangfire')) structure.integrations.push('hangfire');
108
+ }
109
+ } catch { /* ignore */ }
110
+ }
111
+
112
+ for (const loc of csprojLocations) {
113
+ try {
114
+ const dir = join(targetPath, loc);
115
+ if (!existsSync(dir)) continue;
116
+ const entries = readdirSyncSafe(dir);
117
+ // Direct .csproj files
118
+ for (const e of entries.filter(f => f.endsWith('.csproj'))) {
119
+ checkCsproj(join(dir, e));
120
+ }
121
+ // One level deeper (e.g., src/backend/MyProject/*.csproj)
122
+ for (const sub of entries) {
123
+ const subDir = join(dir, sub);
124
+ for (const e of readdirSyncSafe(subDir).filter(f => f.endsWith('.csproj'))) {
125
+ checkCsproj(join(subDir, e));
126
+ }
127
+ }
128
+ } catch { /* ignore */ }
129
+ }
130
+
131
+ // Determine stack: monorepo (nextjs+dotnet), dotnet, or keep frontend stack
132
+ if (hasDotnet) {
133
+ if (structure.stack === 'nextjs' || structure.stack === 'react' || structure.stack === 'vue') {
134
+ // Monorepo: frontend + .NET backend
135
+ structure.stack = `${structure.stack}+dotnet`;
136
+ } else if (structure.stack === 'unknown') {
137
+ structure.stack = 'dotnet';
138
+ }
139
+ }
140
+
141
+ // ── vercel.json detection ──────────────────────────────────────────────
142
+ if (existsSync(join(targetPath, 'vercel.json'))) {
143
+ structure.hasVercel = true;
144
+ }
145
+
146
+ // ── tsconfig.json detection (fallback for stack) ────────────────────────
147
+ if (structure.stack === 'unknown' && existsSync(join(targetPath, 'tsconfig.json'))) {
148
+ structure.stack = 'nodejs';
149
+ }
150
+
151
+ // ── Architecture detection ─────────────────────────────────────────────
152
+ try {
153
+ const configPath = join(targetPath, '.morph/config/config.json');
154
+ if (existsSync(configPath)) {
155
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
156
+ if (config.architecture?.style) {
157
+ structure.architecture = config.architecture.style;
158
+ }
159
+ }
160
+ } catch { /* ignore */ }
161
+
162
+ // ── Docker detection ───────────────────────────────────────────────────
163
+ if (existsSync(join(targetPath, 'docker-compose.yml')) ||
164
+ existsSync(join(targetPath, 'docker-compose.yaml')) ||
165
+ existsSync(join(targetPath, 'Dockerfile'))) {
166
+ if (!structure.integrations.includes('docker')) structure.integrations.push('docker');
167
+ }
168
+
169
+ // ── Azure detection ────────────────────────────────────────────────────
170
+ const azureSignals = [
171
+ '.azure',
172
+ 'azure-pipelines.yml',
173
+ 'infra/main.bicep',
174
+ 'infra',
175
+ ];
176
+ const hasAzure = azureSignals.some(signal => {
177
+ const p = join(targetPath, signal);
178
+ if (!existsSync(p)) return false;
179
+ if (signal === 'infra') {
180
+ // Check for .bicep files inside infra/
181
+ return readdirSyncSafe(p).some(f => f.endsWith('.bicep'));
182
+ }
183
+ return true;
184
+ });
185
+ if (hasAzure && !structure.integrations.includes('azure')) {
186
+ structure.integrations.push('azure');
187
+ }
188
+ } catch {
189
+ // Fail-safe — return defaults
190
+ }
191
+
192
+ return {
193
+ structure,
194
+ inferred: {
195
+ markdown: opts.generateStandards ? generateInferredMarkdown(structure) : '',
196
+ },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Safe readdir that returns [] on error.
202
+ * @param {string} dir
203
+ * @returns {string[]}
204
+ */
205
+ function readdirSyncSafe(dir) {
206
+ try {
207
+ return readdirSync(dir);
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Generate a markdown summary from detected structure.
215
+ * @param {Object} structure
216
+ * @returns {string}
217
+ */
218
+ function generateInferredMarkdown(structure) {
219
+ const lines = [`## Detected Stack: ${structure.stack}`];
220
+ if (structure.architecture) lines.push(`Architecture: ${structure.architecture}`);
221
+ if (structure.uiLibrary) lines.push(`UI Library: ${structure.uiLibrary}`);
222
+ if (structure.integrations.length > 0) lines.push(`Integrations: ${structure.integrations.join(', ')}`);
223
+ if (structure.hasVercel) lines.push(`Vercel: detected`);
224
+ return lines.join('\n');
225
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * pr-files.js — o diff de um PR carregou uma feature morph-spec?
3
+ *
4
+ * `morph/{f}` é a convenção de nome das branches que o framework cria, não
5
+ * prova de que a branch carregou uma feature: nada impede um PR só de docs
6
+ * numa `morph/x`. O `doctor` lia o nome como prova e acusava
7
+ * `pr-merged-not-landed` para sempre sobre PRs que nunca tiveram archive para
8
+ * pousar (medido no MORPH_GHLBrain: #5, um único `docs/*.md`, e #23, a onda 2
9
+ * de outra feature — nenhum caminho `.morph/` no diff de nenhum dos dois).
10
+ *
11
+ * A pergunta é feita ao diff, e só para o candidato: o `doctor` chama isto
12
+ * depois de já saber que o PR mergeou e que `archive/{f}` falta aqui, então o
13
+ * custo (~650 ms por `gh pr view`) não recai sobre quem não tem pendência. O
14
+ * índice (`pr-index.js`) segue sem o campo de propósito: pedir `mergeCommit` no
15
+ * `pr list` custou ~400 ms a mais em CADA chamada, paga pelos quatro leitores.
16
+ *
17
+ * Três respostas, nunca duas: `true` (o diff tocou `.morph/{features,archive}/{f}/`),
18
+ * `false` (o diff foi lido inteiro e não tocou) e `null` (DESCONHECIDO — sem
19
+ * `gh`, falha, JSON ilegível, ou o diff bateu no teto de arquivos do `gh` sem
20
+ * achar). Só `false` autoriza calar um aviso.
21
+ */
22
+
23
+ import { execFileSync } from 'child_process';
24
+ import { resolveGh } from './gh.js';
25
+
26
+ /** Quantos arquivos o `gh pr view --json files` devolve, no máximo. */
27
+ export const GH_FILES_CAP = 100;
28
+
29
+ /**
30
+ * @param {string} root - de onde o `gh` roda (resolve o repositório pelo origin)
31
+ * @param {number} number - número do PR
32
+ * @param {string} feature - o `{f}` de `morph/{f}`
33
+ * @param {{ timeout?: number }} [opts]
34
+ * @returns {boolean|null} null = DESCONHECIDO, nunca "não carregou"
35
+ */
36
+ export function prCarriesFeature(root, number, feature, opts = {}) {
37
+ const timeout = opts.timeout ?? 8000;
38
+ try {
39
+ const { bin, prefix } = resolveGh();
40
+ const out = execFileSync(
41
+ bin,
42
+ [...prefix, 'pr', 'view', String(number), '--json', 'files'],
43
+ { cwd: root, stdio: 'pipe', timeout },
44
+ ).toString();
45
+ const files = JSON.parse(out)?.files;
46
+ if (!Array.isArray(files)) return null;
47
+ // A barra final é a fronteira do slug: `vitrine` não pode casar `vitrine-lead/`.
48
+ const owned = [`.morph/features/${feature}/`, `.morph/archive/${feature}/`];
49
+ if (files.some((f) => typeof f?.path === 'string' && owned.some((p) => f.path.startsWith(p)))) return true;
50
+ // Diff truncado sem o caminho não prova ausência — o archive pode estar além do teto.
51
+ if (files.length >= GH_FILES_CAP) return null;
52
+ return false;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
@@ -9,10 +9,12 @@
9
9
  * - designSystemDivergence: boolean — conflicting design sources detected
10
10
  * - standardsViolations: string[] — violations of mandate.md proibições
11
11
  * - verificationStatus: string — último `morph-spec verify` ('fail' pausa, qualquer gate)
12
+ * - verificationScope + gate: string — presente e != 'feature' pausa SÓ no ship: o
13
+ * último `verify` foi um recorte de task, não da feature inteira (`null`/ausente
14
+ * não conta — narrow, nunca widen)
12
15
  * - e2eStatus + e2eReason + gate — nó e2e do último verify. Um `skip`
13
- * cuja razão NÃO seja dispensa legítima pausa SÓ no ship (gate 'review')
14
- * - verificationScope: string — escopo do stamp que carimbou esse
15
- * `skip`. Dispensa legítima só conta vinda de 'feature' (ver abaixo)
16
+ * cuja razão NÃO seja dispensa legítima pausa SÓ no ship (gate 'review').
17
+ * Sem escopo (stamp sem o campo) cai do mesmo lado seguro que `unknown`
16
18
  * - verificationStale + gate: bool/str — stamp com sha != HEAD pausa SÓ no
17
19
  * ship (gate 'review') — código mudou depois do último verify
18
20
  * - evalScoreSource + gate: str — no ship, score que NÃO veio do avaliador
@@ -172,6 +174,30 @@ export function shouldPauseGate(config, signals) {
172
174
  };
173
175
  }
174
176
 
177
+ // No gate de SHIP: um `verification` PRESENTE cujo escopo não é o da feature
178
+ // é um recorte parcial — de UMA task, nunca da app inteira. O stamp é
179
+ // SINGULAR, sobrescrito por qualquer verify de qualquer escopo, e vale
180
+ // INDEPENDENTE do que os nós individuais dizem: mesmo com e2e 'pass' dentro
181
+ // daquele recorte, build/tests/validators também rodaram só para a task, não
182
+ // para a feature. Antes desta regra, um `verify {f} {task}` totalmente verde
183
+ // comprava o Gate 3 da feature que ele nem tentou verificar por inteiro — já
184
+ // acontecia manualmente (`--filter`), e o recorte automático de PR B faz
185
+ // acontecer em TODA task. Por isso corre ANTES do bloco de e2e abaixo: ele
186
+ // pausa por si só, sem depender de e2eStatus/e2eReason.
187
+ //
188
+ // `typeof === 'string'` exclui `null`/ausente (features legadas sem stamp) —
189
+ // narrow, nunca widen. O caminho de volta é sempre o mesmo, barato e nomeado:
190
+ // `morph-spec verify {feature}` (suíte inteira) antes do ship.
191
+ if (signals.gate === 'review'
192
+ && typeof signals.verificationScope === 'string'
193
+ && signals.verificationScope !== FEATURE_SCOPE) {
194
+ return {
195
+ pause: true,
196
+ reason: `último morph-spec verify foi de escopo "${signals.verificationScope}" (recorte parcial de task)`
197
+ + ' — rode `morph-spec verify {feature}` (suíte inteira) antes do ship',
198
+ };
199
+ }
200
+
175
201
  // No gate de SHIP apenas: o nó e2e do último verify PULOU. Um skip não
176
202
  // reprova o rollup, então `verificationStatus` sai 'pass' e o gate ficava
177
203
  // cego — auto-passando um Gate 3 sobre uma app que ninguém subiu. A razão
@@ -187,21 +213,15 @@ export function shouldPauseGate(config, signals) {
187
213
  if (E2E_SKIP_CLASSES_THAT_PAUSE.includes(klass)) {
188
214
  return { pause: true, reason: e2ePauseReason(klass, signals.e2eReason) };
189
215
  }
190
- // Segunda pergunta, que a razão sozinha não responde: QUEM carimbou. O
191
- // `verification` é um stamp SINGULAR sobrescrito por qualquer verify, de
192
- // qualquer escopo — então um stamp de escopo `task:{id}` no momento do
193
- // ship significa literalmente que o último verify NÃO foi o da feature. A
194
- // razão que ele carrega descreve outra corrida: 'task group "docs" does
195
- // not touch runtime' é dispensa legítima do que AQUELA task verificou, e a
196
- // última task de uma feature tipicamente é `group: docs` — ela compraria o
197
- // Gate 3 da feature que acabou de escrever, numa máquina onde a app nunca
198
- // subiu. Vale para as duas classes de dispensa, e não só para `scope`:
199
- // mesmo um "project does not containerize" carimbado por task é a resposta
200
- // a uma pergunta que o gate não fez. O caminho de volta é barato e nomeado
201
- // (rodar `verify {feature}`), e é o que morph-review já manda fazer antes
202
- // do Gate 3. Narrow, nunca widen: nenhuma razão nova passa a valer aqui —
203
- // uma que já valia deixa de valer fora do escopo que responde pela app.
204
- if (signals.verificationScope !== FEATURE_SCOPE) {
216
+ // Segunda pergunta, que a razão sozinha não responde: QUEM carimbou. Um
217
+ // escopo de TASK (`task:{id}`) já pausou acima, antes deste bloco — o que
218
+ // sobra a checar aqui é só a ausência de escopo (`null`/indefinido:
219
+ // feature legada, ou stamp anterior à introdução do campo). Cai do mesmo
220
+ // lado seguro que `unknown` pelo mesmo motivo de sempre: recusar é barato,
221
+ // aprovar sem prova de QUEM respondeu pela app não é. O caminho de volta é
222
+ // o mesmo (rodar `verify {feature}`), e é o que morph-review já manda
223
+ // fazer antes do Gate 3.
224
+ if (signals.verificationScope == null) {
205
225
  return {
206
226
  pause: true,
207
227
  reason: e2eScopeRefusal(klass, signals.verificationScope, signals.e2eReason),