@polymorphism-tech/morph-spec 8.8.0 → 8.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/mcp/registry.json +6 -5
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +163 -106
- package/src/commands/project/init.js +20 -9
- package/src/commands/state/approve.js +25 -0
- package/src/core/paths/output-schema.js +0 -18
- package/src/lib/detectors/claude-config-detector.js +37 -5
- package/src/lib/generators/architecture-scanner.js +19 -6
- package/src/lib/validators/architecture/architecture-validator.js +12 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +7 -2
- package/src/lib/validators/packages/package-validator.js +14 -1
- package/src/lib/validators/validation-runner.js +101 -16
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
|
|
4
4
|
|
|
5
5
|
**Package:** `@polymorphism-tech/morph-spec`
|
|
6
|
-
**Version:** 8.
|
|
6
|
+
**Version:** 8.10.0
|
|
7
7
|
**Requires:** Node.js 22+, Claude Code
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -368,4 +368,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
|
|
|
368
368
|
|
|
369
369
|
---
|
|
370
370
|
|
|
371
|
-
*morph-spec v8.
|
|
371
|
+
*morph-spec v8.10.0 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Centralized MCP registry with phase relevance and install configuration for morph-spec workflows",
|
|
4
4
|
"mcps": {
|
|
5
5
|
"context7": {
|
|
6
|
-
"phases": ["proposal", "
|
|
6
|
+
"phases": ["proposal", "plan", "implement"],
|
|
7
7
|
"tools": ["resolve-library-id", "query-docs"],
|
|
8
8
|
"detection": "enabledPlugins includes context7 or mcpServers includes context7",
|
|
9
9
|
"fallback": "WebSearch + WebFetch for library documentation",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
}
|
|
87
87
|
},
|
|
88
88
|
"asaas": {
|
|
89
|
-
"phases": ["
|
|
89
|
+
"phases": ["plan", "implement"],
|
|
90
90
|
"tools": ["enumerate_endpoints", "get_endpoint_schema", "generate_code_example", "execute_api_call", "search_docs"],
|
|
91
91
|
"detection": "mcpServers includes asaas",
|
|
92
92
|
"fallback": "WebFetch https://docs.asaas.com for API documentation",
|
|
@@ -201,11 +201,12 @@
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
},
|
|
204
|
+
"_phaseMatrixNote": "Keys must be the v5 phases from framework/phases.json. The v4 keys 'design' and 'tasks' both collapsed into 'plan'; 'review' was absent entirely, so a project in review resolved to undefined. Stack-scoped MCPs (asaas) stay out of this matrix on purpose — only universal ones are listed.",
|
|
204
205
|
"phaseMatrix": {
|
|
205
206
|
"proposal": ["context7"],
|
|
206
207
|
"uiux": ["playwright", "context7", "stitch", "gemini-image"],
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
208
|
+
"plan": ["context7"],
|
|
209
|
+
"implement": ["context7", "playwright", "vercel", "gemini-image"],
|
|
210
|
+
"review": ["playwright", "context7"]
|
|
210
211
|
}
|
|
211
212
|
}
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# create-umbraco-lp
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# migrate-lp-to-umbraco
|
|
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber um novo request de feature, antes de morph-plan
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-brainstorming
|
|
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
|
|
6
6
|
user-invokable: false
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-eval
|
|
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
|
|
6
6
|
user-invokable: false
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-implement
|
|
@@ -4,7 +4,7 @@ description: Guia a produção dos artefatos de planejamento de uma feature (use
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-proposal
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-stack-scan
|
|
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.10.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -88,15 +88,109 @@ const REQUIRED_COMMAND_FILES = [
|
|
|
88
88
|
'src/commands/context/context-refresh.js',
|
|
89
89
|
];
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Validate a parsed `agents.json` against the v5 contract.
|
|
93
|
+
*
|
|
94
|
+
* Exported, and pure, so the tests can exercise the REAL rules. They used to
|
|
95
|
+
* re-implement this logic inline "because doctorCommand doesn't export
|
|
96
|
+
* individual check functions" — and the two promptly disagreed: the tests
|
|
97
|
+
* asserted a v4 document (version 4.x, `tier` and `active_phases` required)
|
|
98
|
+
* while this code treats exactly those fields as deprecated. Both stayed green
|
|
99
|
+
* because they never met. A canary that destroyed this whole check left the
|
|
100
|
+
* suite at 18/18.
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} agentsData - parsed agents.json
|
|
103
|
+
* @returns {Array<{name: string, status: 'ok'|'warn'|'error', msg?: string}>}
|
|
104
|
+
*/
|
|
105
|
+
export function validateAgentsJson(agentsData) {
|
|
106
|
+
const checks = [];
|
|
107
|
+
const agentEntries = Object.entries(agentsData.agents || {})
|
|
108
|
+
.filter(([k]) => !k.startsWith('_comment'));
|
|
109
|
+
const actualCount = agentEntries.length;
|
|
110
|
+
const declaredCount = agentsData.total_agents;
|
|
111
|
+
|
|
112
|
+
// 1: total_agents matches actual count
|
|
113
|
+
if (actualCount !== declaredCount) {
|
|
114
|
+
checks.push({
|
|
115
|
+
name: 'agents.json count',
|
|
116
|
+
status: 'warn',
|
|
117
|
+
msg: `total_agents declares ${declaredCount} but found ${actualCount} agents`
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
checks.push({ name: 'agents.json count', status: 'ok', msg: `${actualCount} agents` });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2: required v5 personas present
|
|
124
|
+
const missingPersonas = REQUIRED_AGENTS.filter(id => !(agentsData.agents || {})[id]);
|
|
125
|
+
if (missingPersonas.length > 0) {
|
|
126
|
+
checks.push({
|
|
127
|
+
name: 'agents.json required personas',
|
|
128
|
+
status: 'error',
|
|
129
|
+
msg: `Missing required personas: ${missingPersonas.join(', ')}`
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
checks.push({ name: 'agents.json required personas', status: 'ok' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3: v5 schema (flat personas, no tiers)
|
|
136
|
+
const version = agentsData.version || 'unknown';
|
|
137
|
+
const isV5 = version.startsWith('5.');
|
|
138
|
+
const v4Fields = [];
|
|
139
|
+
const missingV5Fields = [];
|
|
140
|
+
|
|
141
|
+
for (const [id, data] of agentEntries) {
|
|
142
|
+
if (data.tier !== undefined) v4Fields.push(`${id}.tier`);
|
|
143
|
+
if (data.active_phases !== undefined) v4Fields.push(`${id}.active_phases`);
|
|
144
|
+
if (data.reports_to !== undefined) v4Fields.push(`${id}.reports_to`);
|
|
145
|
+
if (data.teammate !== undefined) v4Fields.push(`${id}.teammate`);
|
|
146
|
+
if (!data.title) missingV5Fields.push(`${id}.title`);
|
|
147
|
+
if (!Array.isArray(data.domains)) missingV5Fields.push(`${id}.domains`);
|
|
148
|
+
if (typeof data.persona !== 'string' || !data.persona) missingV5Fields.push(`${id}.persona`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (isV5) {
|
|
152
|
+
checks.push({ name: `agents.json schema (v${version})`, status: 'ok' });
|
|
153
|
+
} else {
|
|
154
|
+
checks.push({
|
|
155
|
+
name: `agents.json schema (v${version})`,
|
|
156
|
+
status: 'warn',
|
|
157
|
+
msg: 'expected v5.x — run agents migration'
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (v4Fields.length > 0) {
|
|
162
|
+
const sample = v4Fields.slice(0, 3).join(', ');
|
|
163
|
+
checks.push({
|
|
164
|
+
name: 'agents.json deprecated v4 fields',
|
|
165
|
+
status: 'warn',
|
|
166
|
+
msg: `${v4Fields.length} v4 field(s) found: ${sample}${v4Fields.length > 3 ? '...' : ''}`
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (missingV5Fields.length > 0 && isV5) {
|
|
171
|
+
const sample = missingV5Fields.slice(0, 3).join(', ');
|
|
172
|
+
checks.push({
|
|
173
|
+
name: 'agents.json v5 required fields',
|
|
174
|
+
status: 'warn',
|
|
175
|
+
msg: `${missingV5Fields.length} missing: ${sample}${missingV5Fields.length > 3 ? '...' : ''}`
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return checks;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// framework standards (stack-relevant only).
|
|
183
|
+
// Keep every entry pointing at a file that exists: a check that can never pass
|
|
184
|
+
// carries the same information as one that can never fail — none. This list
|
|
185
|
+
// still named two standards from a `workflows/` directory that no longer
|
|
186
|
+
// exists, which pinned `doctor --full` at a permanent 3/6 and made a real
|
|
187
|
+
// regression indistinguishable from the usual red.
|
|
92
188
|
const FRAMEWORK_STANDARDS = [
|
|
93
189
|
'framework/standards/architecture/vertical-slice/vertical-slice.md',
|
|
94
190
|
'framework/standards/data/nosql/cosmos-db.md',
|
|
95
191
|
'framework/standards/data/nosql/cache/redis.md',
|
|
96
192
|
'framework/standards/data/vector-search/rag-chunking.md',
|
|
97
|
-
'framework/standards/integration/mcp/mcp-tools.md'
|
|
98
|
-
'framework/standards/workflows/thread-management.md',
|
|
99
|
-
'framework/standards/workflows/parallel-execution.md'
|
|
193
|
+
'framework/standards/integration/mcp/mcp-tools.md'
|
|
100
194
|
];
|
|
101
195
|
|
|
102
196
|
// required personas expected in agents.json (v5 — the universal, stack-agnostic
|
|
@@ -127,39 +221,61 @@ async function doctorFullCommand(frameworkRoot) {
|
|
|
127
221
|
}
|
|
128
222
|
};
|
|
129
223
|
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
224
|
+
// Two layouts reach this command and they disagree on where everything is.
|
|
225
|
+
//
|
|
226
|
+
// framework repo src/, framework/, no .morph/ at all
|
|
227
|
+
// installed project .morph/framework/, and the CLI in node_modules
|
|
228
|
+
//
|
|
229
|
+
// Half the checks assumed one layout and half the other, so `--full` could
|
|
230
|
+
// not pass anywhere: red in the repo (no .morph/) and red in a project (no
|
|
231
|
+
// src/). A check that can never pass reports exactly as much as one that can
|
|
232
|
+
// never fail. Each check now runs only where it means something.
|
|
233
|
+
const installedAgentsPath = join(frameworkRoot, '.morph/framework/agents.json');
|
|
234
|
+
const canonicalAgentsPath = join(frameworkRoot, 'framework/agents.json');
|
|
235
|
+
const inFrameworkRepo = !(await pathExists(installedAgentsPath))
|
|
236
|
+
&& await pathExists(canonicalAgentsPath);
|
|
237
|
+
const agentsPath = inFrameworkRepo ? canonicalAgentsPath : installedAgentsPath;
|
|
238
|
+
|
|
239
|
+
console.log(chalk.gray(`\n Contexto: ${inFrameworkRepo ? 'repo do framework' : 'projeto instalado'}`));
|
|
240
|
+
|
|
241
|
+
// ── 1-2. Source tree (framework repo only) ──────────────────────────────
|
|
242
|
+
// An installed project has no src/ — the CLI lives in node_modules, and its
|
|
243
|
+
// integrity is npm's job, not the doctor's.
|
|
244
|
+
if (inFrameworkRepo) {
|
|
245
|
+
console.log(chalk.cyan(`\n src/lib/ — Core Libraries (${REQUIRED_LIB_FILES.length} files)`));
|
|
246
|
+
const missingLibs = [];
|
|
247
|
+
for (const f of REQUIRED_LIB_FILES) {
|
|
248
|
+
if (!(await pathExists(join(frameworkRoot, f)))) missingLibs.push(f.split('/').pop());
|
|
249
|
+
}
|
|
250
|
+
check(`Core Libraries (${REQUIRED_LIB_FILES.length - missingLibs.length}/${REQUIRED_LIB_FILES.length})`,
|
|
251
|
+
missingLibs.length === 0, false,
|
|
252
|
+
missingLibs.length > 0 ? `missing: ${missingLibs.join(', ')}` : '');
|
|
253
|
+
|
|
254
|
+
console.log(chalk.cyan(`\n src/commands/ — Command Files (${REQUIRED_COMMAND_FILES.length} files)`));
|
|
255
|
+
const missingCmds = [];
|
|
256
|
+
for (const f of REQUIRED_COMMAND_FILES) {
|
|
257
|
+
if (!(await pathExists(join(frameworkRoot, f)))) missingCmds.push(f.split('/').pop());
|
|
258
|
+
}
|
|
259
|
+
check(`Command Files (${REQUIRED_COMMAND_FILES.length - missingCmds.length}/${REQUIRED_COMMAND_FILES.length})`,
|
|
260
|
+
missingCmds.length === 0, false,
|
|
261
|
+
missingCmds.length > 0 ? `missing: ${missingCmds.join(', ')}` : '');
|
|
145
262
|
}
|
|
146
|
-
check(`Command Files (${REQUIRED_COMMAND_FILES.length - missingCmds.length}/${REQUIRED_COMMAND_FILES.length})`,
|
|
147
|
-
missingCmds.length === 0, false,
|
|
148
|
-
missingCmds.length > 0 ? `missing: ${missingCmds.join(', ')}` : '');
|
|
149
263
|
|
|
150
264
|
// ── 3. Framework Standards ───────────────────────────────────────────────
|
|
151
|
-
|
|
265
|
+
// Same files, different root: the repo holds them at framework/standards/,
|
|
266
|
+
// an installed project at .morph/framework/standards/.
|
|
267
|
+
const stdsRoot = inFrameworkRepo ? frameworkRoot : join(frameworkRoot, '.morph');
|
|
268
|
+
console.log(chalk.cyan(`\n ${inFrameworkRepo ? '' : '.morph/'}framework/standards/ — Framework Standards (${FRAMEWORK_STANDARDS.length} files)`));
|
|
152
269
|
const missingStds = [];
|
|
153
270
|
for (const f of FRAMEWORK_STANDARDS) {
|
|
154
|
-
if (!(await pathExists(join(
|
|
271
|
+
if (!(await pathExists(join(stdsRoot, f)))) missingStds.push(f.replace('framework/standards/', ''));
|
|
155
272
|
}
|
|
156
273
|
check(`Framework Standards (${FRAMEWORK_STANDARDS.length - missingStds.length}/${FRAMEWORK_STANDARDS.length})`,
|
|
157
274
|
missingStds.length === 0, false,
|
|
158
275
|
missingStds.length > 0 ? `missing: ${missingStds.join(', ')}` : '');
|
|
159
276
|
|
|
160
277
|
// ── 5. Required Agents in agents.json ───────────────────────────────────
|
|
161
|
-
console.log(chalk.cyan(`\n .morph/framework/agents.json — Required Agents (${REQUIRED_AGENTS.length})`));
|
|
162
|
-
const agentsPath = join(frameworkRoot, '.morph/framework/agents.json');
|
|
278
|
+
console.log(chalk.cyan(`\n ${inFrameworkRepo ? 'framework' : '.morph/framework'}/agents.json — Required Agents (${REQUIRED_AGENTS.length})`));
|
|
163
279
|
if (await pathExists(agentsPath)) {
|
|
164
280
|
try {
|
|
165
281
|
const agentsConfig = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
|
|
@@ -176,8 +292,10 @@ async function doctorFullCommand(frameworkRoot) {
|
|
|
176
292
|
}
|
|
177
293
|
|
|
178
294
|
// ── 6. agents.json version freshness ──────────────────────────────────
|
|
179
|
-
|
|
180
|
-
|
|
295
|
+
// Only meaningful for an installed project: it compares what was installed
|
|
296
|
+
// against the canonical source. In the framework repo the two are the same
|
|
297
|
+
// file, so the comparison is vacuous and is skipped.
|
|
298
|
+
if (!inFrameworkRepo && await pathExists(agentsPath) && await pathExists(canonicalAgentsPath)) {
|
|
181
299
|
try {
|
|
182
300
|
const installed = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
|
|
183
301
|
const canonical = JSON.parse(await fs.readFile(canonicalAgentsPath, 'utf8'));
|
|
@@ -193,7 +311,9 @@ async function doctorFullCommand(frameworkRoot) {
|
|
|
193
311
|
// agents.json is the single source of truth; the .claude/agents/morph-*.md
|
|
194
312
|
// files are derived artifacts (ADR-001). Missing = broken dispatch (error);
|
|
195
313
|
// stale/hand-edited or orphan = drift (warning).
|
|
196
|
-
|
|
314
|
+
// Skipped in the framework repo: the generated `.claude/agents/morph-*.md`
|
|
315
|
+
// are an artifact of installing into a project, not of the source tree.
|
|
316
|
+
if (!inFrameworkRepo && await pathExists(agentsPath)) {
|
|
197
317
|
console.log(chalk.cyan('\n .claude/agents/ — Generated personas (drift vs agents.json)'));
|
|
198
318
|
try {
|
|
199
319
|
let projectTags = [];
|
|
@@ -527,7 +647,16 @@ export async function doctorCommand(options = {}) {
|
|
|
527
647
|
|
|
528
648
|
// Worktree reconciliation mode (C2)
|
|
529
649
|
if (options.worktrees) {
|
|
530
|
-
|
|
650
|
+
const clean = await doctorWorktreesCommand(process.cwd(), options);
|
|
651
|
+
// The audit result has to reach the exit code, not just the terminal.
|
|
652
|
+
// Commander discards an action's return value, so this reported success to
|
|
653
|
+
// every script while printing "⚠ 5 inconsistência(s)" to the human — a
|
|
654
|
+
// `doctor --worktrees || echo falhou` in CI or a pre-commit hook saw a
|
|
655
|
+
// clean run with orphaned worktrees piling up. The flag-misuse branch
|
|
656
|
+
// above already treats the exit code as this command's signal channel;
|
|
657
|
+
// only the audit itself was left out of it.
|
|
658
|
+
if (!clean) process.exitCode = 1;
|
|
659
|
+
return clean;
|
|
531
660
|
}
|
|
532
661
|
|
|
533
662
|
const targetPath = process.cwd();
|
|
@@ -661,82 +790,10 @@ export async function doctorCommand(options = {}) {
|
|
|
661
790
|
if (await pathExists(agentsPath)) {
|
|
662
791
|
try {
|
|
663
792
|
const agentsData = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
|
|
664
|
-
const
|
|
665
|
-
.
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
// Check 1: total_agents matches actual count
|
|
670
|
-
if (actualCount !== declaredCount) {
|
|
671
|
-
checks.push({
|
|
672
|
-
name: 'agents.json count',
|
|
673
|
-
status: 'warn',
|
|
674
|
-
msg: `total_agents declares ${declaredCount} but found ${actualCount} agents`
|
|
675
|
-
});
|
|
676
|
-
hasWarnings = true;
|
|
677
|
-
} else {
|
|
678
|
-
checks.push({ name: 'agents.json count', status: 'ok', msg: `${actualCount} agents` });
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Check 2: required v5 personas present
|
|
682
|
-
const requiredPersonas = REQUIRED_AGENTS;
|
|
683
|
-
const missingPersonas = requiredPersonas.filter(id => !agentsData.agents[id]);
|
|
684
|
-
if (missingPersonas.length > 0) {
|
|
685
|
-
checks.push({
|
|
686
|
-
name: 'agents.json required personas',
|
|
687
|
-
status: 'error',
|
|
688
|
-
msg: `Missing required personas: ${missingPersonas.join(', ')}`
|
|
689
|
-
});
|
|
690
|
-
hasErrors = true;
|
|
691
|
-
} else {
|
|
692
|
-
checks.push({ name: 'agents.json required personas', status: 'ok' });
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// Check 3: v5 schema validation (flat personas, no tiers)
|
|
696
|
-
const version = agentsData.version || 'unknown';
|
|
697
|
-
const isV5 = version.startsWith('5.');
|
|
698
|
-
const v4Fields = [];
|
|
699
|
-
const missingV5Fields = [];
|
|
700
|
-
|
|
701
|
-
for (const [id, data] of agentEntries) {
|
|
702
|
-
if (data.tier !== undefined) v4Fields.push(`${id}.tier`);
|
|
703
|
-
if (data.active_phases !== undefined) v4Fields.push(`${id}.active_phases`);
|
|
704
|
-
if (data.reports_to !== undefined) v4Fields.push(`${id}.reports_to`);
|
|
705
|
-
if (data.teammate !== undefined) v4Fields.push(`${id}.teammate`);
|
|
706
|
-
if (!data.title) missingV5Fields.push(`${id}.title`);
|
|
707
|
-
if (!Array.isArray(data.domains)) missingV5Fields.push(`${id}.domains`);
|
|
708
|
-
if (typeof data.persona !== 'string' || !data.persona) missingV5Fields.push(`${id}.persona`);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
if (isV5) {
|
|
712
|
-
checks.push({ name: `agents.json schema (v${version})`, status: 'ok' });
|
|
713
|
-
} else {
|
|
714
|
-
checks.push({
|
|
715
|
-
name: `agents.json schema (v${version})`,
|
|
716
|
-
status: 'warn',
|
|
717
|
-
msg: 'expected v5.x — run agents migration'
|
|
718
|
-
});
|
|
719
|
-
hasWarnings = true;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
if (v4Fields.length > 0) {
|
|
723
|
-
const sample = v4Fields.slice(0, 3).join(', ');
|
|
724
|
-
checks.push({
|
|
725
|
-
name: 'agents.json deprecated v4 fields',
|
|
726
|
-
status: 'warn',
|
|
727
|
-
msg: `${v4Fields.length} v4 field(s) found: ${sample}${v4Fields.length > 3 ? '...' : ''}`
|
|
728
|
-
});
|
|
729
|
-
hasWarnings = true;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
if (missingV5Fields.length > 0 && isV5) {
|
|
733
|
-
const sample = missingV5Fields.slice(0, 3).join(', ');
|
|
734
|
-
checks.push({
|
|
735
|
-
name: 'agents.json v5 required fields',
|
|
736
|
-
status: 'warn',
|
|
737
|
-
msg: `${missingV5Fields.length} missing: ${sample}${missingV5Fields.length > 3 ? '...' : ''}`
|
|
738
|
-
});
|
|
739
|
-
hasWarnings = true;
|
|
793
|
+
for (const c of validateAgentsJson(agentsData)) {
|
|
794
|
+
checks.push(c);
|
|
795
|
+
if (c.status === 'error') hasErrors = true;
|
|
796
|
+
else if (c.status === 'warn') hasWarnings = true;
|
|
740
797
|
}
|
|
741
798
|
} catch {
|
|
742
799
|
checks.push({ name: 'agents.json', status: 'error', msg: 'invalid JSON' });
|
|
@@ -15,7 +15,7 @@ import { getInstalledCLIVersion, checkProjectOutdated } from '../../utils/versio
|
|
|
15
15
|
import { installClaudeHooks } from '../../utils/claude-settings-manager.js';
|
|
16
16
|
import inquirer from 'inquirer';
|
|
17
17
|
import { detectProject } from '../../lib/detectors/index.js';
|
|
18
|
-
import { detectClaudeConfig,
|
|
18
|
+
import { detectClaudeConfig, mcpAvailabilityByPhase, formatConfigSummary } from '../../lib/detectors/claude-config-detector.js';
|
|
19
19
|
import { generateSettings, saveIntegrations } from '../../lib/generators/settings-generator.js';
|
|
20
20
|
import {
|
|
21
21
|
orchestrateMcpSetup,
|
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
generateSetupInstructions,
|
|
26
26
|
formatMcpStatusTable,
|
|
27
27
|
isRemoteMcp,
|
|
28
|
-
setupStitch
|
|
28
|
+
setupStitch,
|
|
29
|
+
loadMcpRegistry
|
|
29
30
|
} from '../../lib/installers/mcp-installer.js';
|
|
30
31
|
import { setupInfra, installRulesForStack } from '../../scripts/setup-infra.js';
|
|
31
32
|
import { installSkills } from '../../utils/skills-installer.js';
|
|
@@ -147,14 +148,24 @@ export async function initCommand(options) {
|
|
|
147
148
|
logger.info(summary);
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
// Show MCP-to-phase mapping
|
|
151
|
+
// Show MCP-to-phase mapping.
|
|
152
|
+
//
|
|
153
|
+
// This called mapMcpsToPhases with one argument where it takes three,
|
|
154
|
+
// so `phaseMatrix[currentPhase]` threw a TypeError on every project
|
|
155
|
+
// that had any MCP configured. The outer catch reported it as
|
|
156
|
+
// "Claude config detection skipped" — which reads like "nothing to
|
|
157
|
+
// detect" — and everything below it never ran: integrations.json was
|
|
158
|
+
// never written and the optimized settings were never generated.
|
|
151
159
|
if (claudeConfigDetected.mcpServers.length > 0) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
const byPhase = mcpAvailabilityByPhase(
|
|
161
|
+
claudeConfigDetected.mcpServers,
|
|
162
|
+
loadMcpRegistry().phaseMatrix || {}
|
|
163
|
+
);
|
|
164
|
+
if (byPhase.length > 0) {
|
|
165
|
+
logger.blank();
|
|
166
|
+
logger.dim('MCP usage by phase:');
|
|
167
|
+
for (const { phase, available } of byPhase) {
|
|
168
|
+
logger.dim(` ${phase}: ${available.join(', ')}`);
|
|
158
169
|
}
|
|
159
170
|
}
|
|
160
171
|
}
|
|
@@ -256,6 +256,31 @@ export async function approveCommand(featureName, gateName, options = {}) {
|
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// Skipped-pause backstop: a gate whose recorded decision demanded a human
|
|
260
|
+
// pause (gateDecisions[gate].pause === true) cannot then be stamped by a
|
|
261
|
+
// non-human signature. The detection already existed — in gate-guard.js —
|
|
262
|
+
// but that hook is registered for `Write|Edit|AskUserQuestion`, and a Bash
|
|
263
|
+
// tool call fires no hook at all. Since MORPH.md requires gate approval to
|
|
264
|
+
// go through THIS command and forbids hand-editing the state, the only path
|
|
265
|
+
// the hook could ever see was the one the charter bans. The check belongs
|
|
266
|
+
// where the stamp is applied, not beside it.
|
|
267
|
+
//
|
|
268
|
+
// Gated on the RESOLVED `approvedBy`, matching the hotfix backstop above:
|
|
269
|
+
// an omitted --mode in a headless context also resolves to "auto" and must
|
|
270
|
+
// not slip through. `--force` does NOT bypass this either — it is the
|
|
271
|
+
// out-of-order recovery flag, a different concern. The way out is a human
|
|
272
|
+
// signature, or rewriting the decision that asked for the pause.
|
|
273
|
+
const decision = feature.gateDecisions?.[gateName];
|
|
274
|
+
if (decision?.pause === true && approvedBy === 'auto') {
|
|
275
|
+
console.error(chalk.red(`\n❌ O Gate "${gateName}" registrou pause:true — auto-aprovação não se aplica.`));
|
|
276
|
+
console.error(chalk.gray(` Sinal: ${decision.reason || 'risco detectado'}`));
|
|
277
|
+
console.error(chalk.gray(' A decisão gravada exigia pausa humana. Aprove com --approver <nome>, num'));
|
|
278
|
+
console.error(chalk.gray(' terminal interativo, ou pela resposta humana via AskUserQuestion (state-sync).'));
|
|
279
|
+
console.error(chalk.gray(' Se o sinal de risco já não vale, regrave a decisão:'));
|
|
280
|
+
console.error(chalk.gray(` morph-spec gate-decision ${featureName} ${gateName} --pause false --reason "<motivo>"\n`));
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
|
|
259
284
|
let selfAssessed = false;
|
|
260
285
|
if (gateName === 'review') {
|
|
261
286
|
const evaluatorReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'evaluator-report.md');
|
|
@@ -120,24 +120,6 @@ export function getAbsoluteOutputPath(projectRoot, featureName, outputType) {
|
|
|
120
120
|
return join(projectRoot, '.morph', 'features', featureName, entry.phaseDir, leafSegment(entry));
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
* Human-readable path of a schema entry, relative to the feature directory.
|
|
125
|
-
*
|
|
126
|
-
* SINGLE source for the `morph-outputs` tables: `scripts/generate-refs.js`
|
|
127
|
-
* writes them and `test/framework/refs.test.js` verifies them, and if the two
|
|
128
|
-
* rebuilt the string independently they would drift apart the first time the
|
|
129
|
-
* schema grew a new shape. Multi-file types render the filename PATTERN, so a
|
|
130
|
-
* reader sees it's a folder of N files.
|
|
131
|
-
*
|
|
132
|
-
* @param {Object} entry - an OUTPUT_SCHEMA value
|
|
133
|
-
* @returns {string} e.g. "2-plan/spec.md" or "2-plan/usecases/UC-{nn}-{slug}.md"
|
|
134
|
-
*/
|
|
135
|
-
export function getSchemaDisplayPath(entry) {
|
|
136
|
-
return entry.multi
|
|
137
|
-
? `${entry.phaseDir}/${entry.dirname}/${entry.displayFilename}`
|
|
138
|
-
: `${entry.phaseDir}/${entry.filename}`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
123
|
/**
|
|
142
124
|
* Get the feature base directory (morph-relative, forward slashes).
|
|
143
125
|
*/
|
|
@@ -115,14 +115,24 @@ export function detectClaudeConfig(targetPath) {
|
|
|
115
115
|
/**
|
|
116
116
|
* Cross-reference configured MCPs with the MCP registry's phase matrix.
|
|
117
117
|
*
|
|
118
|
+
* `phaseMatrix` and `currentPhase` default rather than being required: the
|
|
119
|
+
* `init` caller passed only `mcpServers`, so `phaseMatrix[currentPhase]` threw
|
|
120
|
+
* a TypeError that `init`'s outer catch swallowed as
|
|
121
|
+
* "Claude config detection skipped" — taking `saveIntegrations` and
|
|
122
|
+
* `generateSettings` down with it, since both run after this line.
|
|
123
|
+
*
|
|
118
124
|
* @param {Array<{name: string}>} mcpServers - Detected MCP server list
|
|
119
|
-
* @param {Object} phaseMatrix - From mcp
|
|
120
|
-
* @param {string} currentPhase -
|
|
125
|
+
* @param {Object} [phaseMatrix] - From framework/mcp/registry.json → phaseMatrix
|
|
126
|
+
* @param {string} [currentPhase] - Phase to look up; omit for every phase
|
|
121
127
|
* @returns {{ available: string[], recommended: string[], missing: string[] }}
|
|
122
128
|
*/
|
|
123
|
-
export function mapMcpsToPhases(mcpServers, phaseMatrix, currentPhase) {
|
|
124
|
-
const configuredNames = new Set(mcpServers.map(m => m.name.toLowerCase()));
|
|
125
|
-
|
|
129
|
+
export function mapMcpsToPhases(mcpServers, phaseMatrix = {}, currentPhase = null) {
|
|
130
|
+
const configuredNames = new Set((mcpServers || []).map(m => m.name.toLowerCase()));
|
|
131
|
+
// No phase asked for → the union across every phase, deduped. That is what a
|
|
132
|
+
// caller showing "which of my MCPs this framework uses" wants.
|
|
133
|
+
const recommended = currentPhase
|
|
134
|
+
? (phaseMatrix[currentPhase] || [])
|
|
135
|
+
: [...new Set(Object.values(phaseMatrix).flat())];
|
|
126
136
|
|
|
127
137
|
return {
|
|
128
138
|
available: recommended.filter(name => configuredNames.has(name)),
|
|
@@ -131,6 +141,28 @@ export function mapMcpsToPhases(mcpServers, phaseMatrix, currentPhase) {
|
|
|
131
141
|
};
|
|
132
142
|
}
|
|
133
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The MCP-by-phase table `init` prints: every phase that recommends at least
|
|
146
|
+
* one of the MCPs this machine actually has configured.
|
|
147
|
+
*
|
|
148
|
+
* Separate from `mapMcpsToPhases` because the caller wants a per-phase
|
|
149
|
+
* breakdown, not one phase's slice — reaching for the wrong one is what
|
|
150
|
+
* produced `Object.entries()` over `{available, recommended, missing}` and a
|
|
151
|
+
* `.map(m => m.name)` over plain strings.
|
|
152
|
+
*
|
|
153
|
+
* @param {Array<{name: string}>} mcpServers
|
|
154
|
+
* @param {Object} phaseMatrix
|
|
155
|
+
* @returns {Array<{phase: string, available: string[]}>} phases with ≥1 match
|
|
156
|
+
*/
|
|
157
|
+
export function mcpAvailabilityByPhase(mcpServers, phaseMatrix = {}) {
|
|
158
|
+
return Object.keys(phaseMatrix)
|
|
159
|
+
.map(phase => ({
|
|
160
|
+
phase,
|
|
161
|
+
available: mapMcpsToPhases(mcpServers, phaseMatrix, phase).available,
|
|
162
|
+
}))
|
|
163
|
+
.filter(({ available }) => available.length > 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
/**
|
|
135
167
|
* Format a human-readable one-liner summary of detected config.
|
|
136
168
|
*
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import fs from 'fs/promises';
|
|
18
18
|
import path from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
19
20
|
import { existsSync, readFileSync } from 'fs';
|
|
20
21
|
import { renderTemplate } from '../../core/templates/template-renderer.js';
|
|
21
22
|
|
|
@@ -469,14 +470,29 @@ function resolveTemplatePath(projectPath, templateRelPath) {
|
|
|
469
470
|
|
|
470
471
|
// 2. CLI package framework/ (for running from source or during init)
|
|
471
472
|
const cliFramework = path.join(
|
|
472
|
-
|
|
473
|
-
'..', '..', '..', 'framework', 'templates', templateRelPath
|
|
473
|
+
cliRootDir(), 'framework', 'templates', templateRelPath
|
|
474
474
|
);
|
|
475
475
|
if (existsSync(cliFramework)) return cliFramework;
|
|
476
476
|
|
|
477
477
|
return null;
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
/**
|
|
481
|
+
* The CLI package root, as a real filesystem path.
|
|
482
|
+
*
|
|
483
|
+
* fileURLToPath, not `new URL(...).pathname`: the latter leaves the path
|
|
484
|
+
* percent-encoded, so an install under a directory with a space resolved to
|
|
485
|
+
* ".../Program%20Files/..." — a path that never exists. `resolveTemplatePath`
|
|
486
|
+
* then returned null and the caller fell through to its plain-text fallback
|
|
487
|
+
* WITHOUT any warning. The first lines of that fallback are identical to the
|
|
488
|
+
* template's, which is why nobody noticed the tables were gone. On Windows a
|
|
489
|
+
* global npm install lives under a spaced path by default, so this was the
|
|
490
|
+
* common case, not the edge one.
|
|
491
|
+
*/
|
|
492
|
+
function cliRootDir() {
|
|
493
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
494
|
+
}
|
|
495
|
+
|
|
480
496
|
/**
|
|
481
497
|
* Render architecture context to .morph/context/README.md
|
|
482
498
|
*
|
|
@@ -509,10 +525,7 @@ export async function renderArchitectureContext(projectPath, scanResult, options
|
|
|
509
525
|
// Get morph-spec version
|
|
510
526
|
let morphVersion = 'unknown';
|
|
511
527
|
try {
|
|
512
|
-
const pkgPath = path.join(
|
|
513
|
-
path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')),
|
|
514
|
-
'..', '..', '..', 'package.json'
|
|
515
|
-
);
|
|
528
|
+
const pkgPath = path.join(cliRootDir(), 'package.json');
|
|
516
529
|
if (existsSync(pkgPath)) {
|
|
517
530
|
morphVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
518
531
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
11
12
|
import chalk from 'chalk';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -76,6 +77,11 @@ export class ArchitectureValidator {
|
|
|
76
77
|
totalIssues: allIssues.length,
|
|
77
78
|
errors: allIssues.filter(i => i.level === 'error').length,
|
|
78
79
|
warnings: allIssues.filter(i => i.level === 'warning').length,
|
|
80
|
+
// Top-level too, not only nested under `results`: the runner reports
|
|
81
|
+
// "❌ 2 error(s)" from the counts but prints the issue list, and reading
|
|
82
|
+
// only the top level meant a failure arrived with no file, no line and
|
|
83
|
+
// no fix — impossible to act on.
|
|
84
|
+
issues: allIssues,
|
|
79
85
|
results
|
|
80
86
|
};
|
|
81
87
|
}
|
|
@@ -84,7 +90,12 @@ export class ArchitectureValidator {
|
|
|
84
90
|
* Validate Program.cs
|
|
85
91
|
*/
|
|
86
92
|
async validateProgramCs() {
|
|
87
|
-
|
|
93
|
+
// Resolved against `projectPath`, not the cwd. The constructor stored
|
|
94
|
+
// `projectPath` and nothing ever read it, so this validator inspected
|
|
95
|
+
// whatever directory the process happened to be in — in a worktree or a
|
|
96
|
+
// fleet run that is a different project, and reporting "no Program.cs,
|
|
97
|
+
// nothing to check" reads exactly like a pass.
|
|
98
|
+
const programCsPath = join(this.projectPath, 'Program.cs');
|
|
88
99
|
|
|
89
100
|
try {
|
|
90
101
|
const content = readFileSync(programCsPath, 'utf-8');
|
|
@@ -11,7 +11,7 @@ import { readFileSync, existsSync } from 'fs';
|
|
|
11
11
|
import { join } from 'path';
|
|
12
12
|
import { glob } from 'glob';
|
|
13
13
|
import chalk from 'chalk';
|
|
14
|
-
import { getAbsoluteOutputPath } from '../../../core/paths/output-schema.js';
|
|
14
|
+
import { getAbsoluteOutputPath, resolveContractsOutputType } from '../../../core/paths/output-schema.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Validate that contracts.cs interfaces are implemented in project code
|
|
@@ -22,7 +22,12 @@ import { getAbsoluteOutputPath } from '../../../core/paths/output-schema.js';
|
|
|
22
22
|
* @returns {Object} { status, errors, warnings, issues, coverage }
|
|
23
23
|
*/
|
|
24
24
|
export async function validateContracts(projectPath, featureName, options = {}) {
|
|
25
|
-
|
|
25
|
+
// `'contracts'` is not an output type — `getAbsoluteOutputPath` threw
|
|
26
|
+
// "Unknown output type" on every call. The runner already resolves this the
|
|
27
|
+
// right way before deciding to schedule this validator
|
|
28
|
+
// (`resolveContractsOutputType()`); using the same resolver here is what
|
|
29
|
+
// keeps the two from disagreeing again.
|
|
30
|
+
const contractsPath = getAbsoluteOutputPath(projectPath, featureName, resolveContractsOutputType());
|
|
26
31
|
|
|
27
32
|
if (!existsSync(contractsPath)) {
|
|
28
33
|
return {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
11
12
|
import { glob } from 'glob';
|
|
12
13
|
import chalk from 'chalk';
|
|
13
14
|
|
|
@@ -82,11 +83,17 @@ export class PackageValidator {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
const results = [];
|
|
86
|
+
const allIssues = [];
|
|
85
87
|
|
|
86
88
|
for (const file of csprojFiles) {
|
|
87
|
-
|
|
89
|
+
// glob returns paths relative to `cwd: this.projectPath`, so they only
|
|
90
|
+
// resolve against projectPath — reading them relative to process.cwd()
|
|
91
|
+
// raised ENOENT whenever the two differed (worktree, fleet, a verify
|
|
92
|
+
// launched from the primary root), and the throw read as a clean pass.
|
|
93
|
+
const result = await this.validateFile(join(this.projectPath, file));
|
|
88
94
|
if (result.issues.length > 0) {
|
|
89
95
|
results.push({ file, ...result });
|
|
96
|
+
allIssues.push(...result.issues.map(i => ({ ...i, file })));
|
|
90
97
|
}
|
|
91
98
|
}
|
|
92
99
|
|
|
@@ -94,6 +101,12 @@ export class PackageValidator {
|
|
|
94
101
|
status: results.length === 0 ? 'ok' : 'error',
|
|
95
102
|
totalFiles: csprojFiles.length,
|
|
96
103
|
filesWithIssues: results.length,
|
|
104
|
+
// The runner's contract. Without these three the findings below were
|
|
105
|
+
// read as `errors: 0` and the run reported "✅ All validations passed"
|
|
106
|
+
// while holding a list of NuGet packages that do not build on .NET 10.
|
|
107
|
+
errors: allIssues.filter(i => i.level === 'error').length,
|
|
108
|
+
warnings: allIssues.filter(i => i.level === 'warning').length,
|
|
109
|
+
issues: allIssues,
|
|
97
110
|
results
|
|
98
111
|
};
|
|
99
112
|
}
|
|
@@ -107,27 +107,34 @@ export async function runValidation(projectPath, featureName, options = {}) {
|
|
|
107
107
|
const validatorResult = await runSingleValidator(validatorId, projectPath, featureName, validatorOptions);
|
|
108
108
|
|
|
109
109
|
if (validatorResult) {
|
|
110
|
-
|
|
111
|
-
result.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
result.issues.push(...validatorResult.issues);
|
|
115
|
-
}
|
|
110
|
+
const { errors, warnings, issues } = normalizeValidatorResult(validatorId, validatorResult);
|
|
111
|
+
result.errors += errors;
|
|
112
|
+
result.warnings += warnings;
|
|
113
|
+
result.issues.push(...issues);
|
|
116
114
|
|
|
117
115
|
// Store per-validator results for hooks
|
|
118
|
-
result.results[validatorId] = {
|
|
119
|
-
passed: (validatorResult.errors || 0) === 0,
|
|
120
|
-
issues: validatorResult.issues || []
|
|
121
|
-
};
|
|
116
|
+
result.results[validatorId] = { passed: errors === 0, issues };
|
|
122
117
|
}
|
|
123
118
|
} catch (err) {
|
|
119
|
+
// A validator that throws is a FAILED validation, not a silent skip.
|
|
120
|
+
// This used to record `passed: false` in `results` and stop there —
|
|
121
|
+
// `result.errors` never moved, so `result.passed = errors === 0` stayed
|
|
122
|
+
// true and the run reported "✅ All validations passed". The only trace
|
|
123
|
+
// was a console line, and `verify` silences stdout. That is how a broken
|
|
124
|
+
// import (`css`) and a path bug (`packages` under a different cwd) went
|
|
125
|
+
// unnoticed: both threw, both read as clean.
|
|
124
126
|
if (options.verbose) {
|
|
125
127
|
console.log(chalk.yellow(` ⚠ Validator '${validatorId}' failed: ${err.message}`));
|
|
126
128
|
}
|
|
127
|
-
result.
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
result.errors += 1;
|
|
130
|
+
const issue = {
|
|
131
|
+
level: 'error',
|
|
132
|
+
validator: validatorId,
|
|
133
|
+
message: `Validator '${validatorId}' crashed: ${err.message}`,
|
|
134
|
+
fix: 'This is a bug in the validator itself — the code under test was never checked.'
|
|
130
135
|
};
|
|
136
|
+
result.issues.push(issue);
|
|
137
|
+
result.results[validatorId] = { passed: false, error: err.message, issues: [issue] };
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
140
|
|
|
@@ -160,6 +167,68 @@ function detectValidators(projectPath = '.') {
|
|
|
160
167
|
return [...profile.defaultValidators];
|
|
161
168
|
}
|
|
162
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Reduce any validator's return value to the runner's contract:
|
|
172
|
+
* `{ errors, warnings, issues }`.
|
|
173
|
+
*
|
|
174
|
+
* Validators were written at different times and return different shapes. The
|
|
175
|
+
* runner used to read `errors`/`warnings`/`issues` off the raw object, so a
|
|
176
|
+
* validator that reports through any other shape contributed ZERO — and since
|
|
177
|
+
* `passed = errors === 0`, its findings were recorded as a pass. Measured: of
|
|
178
|
+
* seven validators only two returned all three keys. `packages` — one of the
|
|
179
|
+
* two defaults for the `dotnet` stack — returned
|
|
180
|
+
* `{status, totalFiles, filesWithIssues, results}` and had every NuGet
|
|
181
|
+
* incompatibility it found thrown away, while `verify` reported 10/10.
|
|
182
|
+
*
|
|
183
|
+
* Reading the nested `results` shape here keeps that contract in ONE place
|
|
184
|
+
* instead of asking every validator to remember it.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} validatorId
|
|
187
|
+
* @param {Object} raw - whatever the validator returned
|
|
188
|
+
* @returns {{errors: number, warnings: number, issues: Array}}
|
|
189
|
+
*/
|
|
190
|
+
export function normalizeValidatorResult(validatorId, raw) {
|
|
191
|
+
if (!raw || typeof raw !== 'object') return { errors: 0, warnings: 0, issues: [] };
|
|
192
|
+
|
|
193
|
+
// Issues can be top-level, or nested one level under `results` — either as
|
|
194
|
+
// an array of `{issues}` (packages: one entry per .csproj) or an object of
|
|
195
|
+
// them (architecture: keyed by check name). The nested read is a FALLBACK,
|
|
196
|
+
// never additive: validators that expose both would otherwise report every
|
|
197
|
+
// finding twice.
|
|
198
|
+
const topLevel = Array.isArray(raw.issues) ? raw.issues : [];
|
|
199
|
+
const nested = raw.results && typeof raw.results === 'object'
|
|
200
|
+
? (Array.isArray(raw.results) ? raw.results : Object.values(raw.results))
|
|
201
|
+
.flatMap(r => (r && Array.isArray(r.issues)) ? r.issues : [])
|
|
202
|
+
: [];
|
|
203
|
+
const issues = (topLevel.length ? topLevel : nested)
|
|
204
|
+
.map(i => ({ validator: validatorId, ...i }));
|
|
205
|
+
|
|
206
|
+
// Prefer the validator's own counts when it reports them — it may weigh
|
|
207
|
+
// issues differently than one-each. Otherwise derive from the issue levels.
|
|
208
|
+
const counted = {
|
|
209
|
+
errors: issues.filter(i => i.level === 'error').length,
|
|
210
|
+
warnings: issues.filter(i => i.level === 'warning').length,
|
|
211
|
+
};
|
|
212
|
+
const errors = Number.isFinite(raw.errors) ? raw.errors : counted.errors;
|
|
213
|
+
const warnings = Number.isFinite(raw.warnings) ? raw.warnings : counted.warnings;
|
|
214
|
+
|
|
215
|
+
// Last resort: a validator that says `status: 'error'` but exposes no
|
|
216
|
+
// countable detail still must not read as a pass.
|
|
217
|
+
if (errors === 0 && raw.status === 'error') {
|
|
218
|
+
return {
|
|
219
|
+
errors: 1,
|
|
220
|
+
warnings,
|
|
221
|
+
issues: issues.length ? issues : [{
|
|
222
|
+
level: 'error',
|
|
223
|
+
validator: validatorId,
|
|
224
|
+
message: raw.message || `${validatorId} reported status "error" without detail`,
|
|
225
|
+
}],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { errors, warnings, issues };
|
|
230
|
+
}
|
|
231
|
+
|
|
163
232
|
/**
|
|
164
233
|
* Run a single validator by ID
|
|
165
234
|
*/
|
|
@@ -181,8 +250,10 @@ async function runSingleValidator(validatorId, projectPath, featureName, options
|
|
|
181
250
|
}
|
|
182
251
|
|
|
183
252
|
case 'css': {
|
|
184
|
-
|
|
185
|
-
|
|
253
|
+
// The module exports `validateCssClasses`; this asked for `validateCss`,
|
|
254
|
+
// which is undefined — the call threw on every invocation.
|
|
255
|
+
const { validateCssClasses } = await import('./css/css-validator.js');
|
|
256
|
+
return await validateCssClasses(projectPath, options);
|
|
186
257
|
}
|
|
187
258
|
|
|
188
259
|
case 'contract-compliance': {
|
|
@@ -201,10 +272,24 @@ async function runSingleValidator(validatorId, projectPath, featureName, options
|
|
|
201
272
|
}
|
|
202
273
|
|
|
203
274
|
default:
|
|
275
|
+
// A warning, not silence: an id with no case runs nothing, and returning
|
|
276
|
+
// a bare null made that indistinguishable from a validator that ran and
|
|
277
|
+
// found nothing. A typo in a validator list would have read as a clean
|
|
278
|
+
// pass. Not an error — an unknown id is a configuration mistake, not a
|
|
279
|
+
// defect in the code under validation.
|
|
204
280
|
if (options.verbose) {
|
|
205
281
|
console.log(chalk.gray(` Unknown validator: ${validatorId}`));
|
|
206
282
|
}
|
|
207
|
-
return
|
|
283
|
+
return {
|
|
284
|
+
status: 'ok',
|
|
285
|
+
errors: 0,
|
|
286
|
+
warnings: 1,
|
|
287
|
+
issues: [{
|
|
288
|
+
level: 'warning',
|
|
289
|
+
message: `Unknown validator '${validatorId}' — nothing was run for it`,
|
|
290
|
+
fix: 'Check the validator id against the cases in runSingleValidator().',
|
|
291
|
+
}],
|
|
292
|
+
};
|
|
208
293
|
}
|
|
209
294
|
}
|
|
210
295
|
|