@polymorphism-tech/morph-spec 7.1.0 → 7.2.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 +14 -14
- package/docs/CHEATSHEET.md +199 -199
- package/docs/QUICKSTART.md +200 -200
- package/framework/CLAUDE.md +4 -2
- package/framework/MORPH.md +7 -4
- package/framework/agents.json +1 -1
- package/framework/hooks/claude-code/core/protect-spec-files.js +193 -72
- package/framework/hooks/shared/phase-utils.js +10 -0
- package/framework/rules/morph-workflow.md +16 -9
- package/framework/skills/core/morph-apply/SKILL.md +1 -0
- package/framework/skills/core/morph-brainstorming/SKILL.md +2 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -0
- package/framework/skills/core/morph-implement/SKILL.md +7 -0
- package/framework/skills/core/morph-plan/SKILL.md +4 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -0
- package/framework/skills/core/morph-review/SKILL.md +1 -0
- package/framework/skills/core/morph-stack-scan/SKILL.md +4 -3
- package/framework/skills/core/morph-standards/SKILL.md +1 -0
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +7 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +32 -0
- package/framework/templates/CLAUDE.md.core.template +1 -1
- package/framework/templates/code/dotnet/backend/repository.cs +3 -10
- package/package.json +1 -1
- package/src/core/paths/output-schema.js +18 -1
|
@@ -1,72 +1,193 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* PreToolUse Hook: Protect Approved Spec Files
|
|
5
|
-
*
|
|
6
|
-
* Event: PreToolUse | Matcher: Write|Edit
|
|
7
|
-
*
|
|
8
|
-
* Blocks Write/Edit to spec artifacts after their approval gate has passed:
|
|
9
|
-
* - 2-plan/spec.md,
|
|
10
|
-
* - 1-design/design-system.md, etc. → blocked if 'uiux' gate approved
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse Hook: Protect Approved Spec Files
|
|
5
|
+
*
|
|
6
|
+
* Event: PreToolUse | Matcher: Write|Edit
|
|
7
|
+
*
|
|
8
|
+
* Blocks Write/Edit to spec artifacts after their approval gate has passed:
|
|
9
|
+
* - 2-plan/spec.md, mandate.md, etc. → blocked if 'plan' gate approved
|
|
10
|
+
* - 1-design/design-system.md, etc. → blocked if 'uiux' gate approved
|
|
11
|
+
*
|
|
12
|
+
* Semantic exception for files in MUTABLE_SPEC_FIELDS (e.g. tasks.json):
|
|
13
|
+
* the plan contract is frozen, but per-task progress fields (status, outputs,
|
|
14
|
+
* notes) may still change during the implement phase. For those files a Write
|
|
15
|
+
* is allowed only when the semantic diff touches solely the mutable fields;
|
|
16
|
+
* Edit is always blocked because the legitimate progress path is a full Write
|
|
17
|
+
* via writeTasksJson(). This lets the implement phase record task status
|
|
18
|
+
* without unfreezing the plan.
|
|
19
|
+
*
|
|
20
|
+
* Fail-open: exits 0 on any error.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from 'fs';
|
|
24
|
+
import { isAbsolute, join } from 'path';
|
|
25
|
+
import { readStdin } from '../../shared/stdin-reader.js';
|
|
26
|
+
import { stateExists, isGateApproved } from '../../shared/state-reader.js';
|
|
27
|
+
import {
|
|
28
|
+
isFeaturePath,
|
|
29
|
+
extractFeatureName,
|
|
30
|
+
getBasename,
|
|
31
|
+
PROTECTED_SPEC_FILES,
|
|
32
|
+
MUTABLE_SPEC_FIELDS
|
|
33
|
+
} from '../../shared/phase-utils.js';
|
|
34
|
+
import { block, pass } from '../../shared/hook-response.js';
|
|
35
|
+
import { logHookActivity } from '../../shared/activity-logger.js';
|
|
36
|
+
import { getFilePath, getToolName } from '../../shared/payload-utils.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compare two tasks.json structures and return the list of plan-contract
|
|
40
|
+
* violations — changes that touch anything other than the mutable fields.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} oldDoc - parsed previous file content
|
|
43
|
+
* @param {object} newDoc - parsed proposed file content
|
|
44
|
+
* @param {string[]} mutableFields - field names allowed to change
|
|
45
|
+
* @returns {string[]} human-readable violation messages (empty = OK)
|
|
46
|
+
*/
|
|
47
|
+
function diffPlanContract(oldDoc, newDoc, mutableFields) {
|
|
48
|
+
const violations = [];
|
|
49
|
+
const mutable = new Set(mutableFields);
|
|
50
|
+
|
|
51
|
+
if (oldDoc.version !== newDoc.version) {
|
|
52
|
+
violations.push(`'version' changed (${oldDoc.version} → ${newDoc.version})`);
|
|
53
|
+
}
|
|
54
|
+
if (oldDoc.feature !== newDoc.feature) {
|
|
55
|
+
violations.push(`'feature' changed (${oldDoc.feature} → ${newDoc.feature})`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const oldTasks = Array.isArray(oldDoc.tasks) ? oldDoc.tasks : [];
|
|
59
|
+
const newTasks = Array.isArray(newDoc.tasks) ? newDoc.tasks : [];
|
|
60
|
+
|
|
61
|
+
if (oldTasks.length !== newTasks.length) {
|
|
62
|
+
violations.push(`task count changed (${oldTasks.length} → ${newTasks.length}) — tasks cannot be added or removed`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const oldById = new Map(oldTasks.map(t => [t.id, t]));
|
|
66
|
+
const newById = new Map(newTasks.map(t => [t.id, t]));
|
|
67
|
+
|
|
68
|
+
// A task present before must still exist (no removals / id renames).
|
|
69
|
+
for (const id of oldById.keys()) {
|
|
70
|
+
if (!newById.has(id)) violations.push(`task '${id}' was removed`);
|
|
71
|
+
}
|
|
72
|
+
for (const id of newById.keys()) {
|
|
73
|
+
if (!oldById.has(id)) violations.push(`task '${id}' was added`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// For tasks present on both sides, only mutable fields may differ.
|
|
77
|
+
for (const [id, newTask] of newById) {
|
|
78
|
+
const oldTask = oldById.get(id);
|
|
79
|
+
if (!oldTask) continue;
|
|
80
|
+
const keys = new Set([...Object.keys(oldTask), ...Object.keys(newTask)]);
|
|
81
|
+
for (const key of keys) {
|
|
82
|
+
if (mutable.has(key)) continue;
|
|
83
|
+
if (JSON.stringify(oldTask[key]) !== JSON.stringify(newTask[key])) {
|
|
84
|
+
violations.push(`task '${id}': immutable field '${key}' changed`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return violations;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Amend mode: bypass spec protection for legitimate in-implementation corrections
|
|
94
|
+
if (process.env.MORPH_AMEND_PHASE) {
|
|
95
|
+
console.error(`[morph-spec] AMEND MODE: bypassing spec protection for phase '${process.env.MORPH_AMEND_PHASE}'`);
|
|
96
|
+
pass();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!stateExists()) pass();
|
|
100
|
+
|
|
101
|
+
const payload = await readStdin();
|
|
102
|
+
if (!payload) pass();
|
|
103
|
+
|
|
104
|
+
const filePath = getFilePath(payload);
|
|
105
|
+
if (!filePath) pass();
|
|
106
|
+
|
|
107
|
+
// Only check files inside .morph/features/
|
|
108
|
+
if (!isFeaturePath(filePath)) pass();
|
|
109
|
+
|
|
110
|
+
const featureName = extractFeatureName(filePath);
|
|
111
|
+
if (!featureName) pass();
|
|
112
|
+
|
|
113
|
+
const filename = getBasename(filePath);
|
|
114
|
+
const requiredGate = PROTECTED_SPEC_FILES[filename];
|
|
115
|
+
|
|
116
|
+
// Not a protected file
|
|
117
|
+
if (!requiredGate) pass();
|
|
118
|
+
|
|
119
|
+
// Gate not yet approved — allow editing
|
|
120
|
+
if (!isGateApproved(featureName, requiredGate)) {
|
|
121
|
+
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
122
|
+
pass();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Gate approved — file is locked ──────────────────────────────────────
|
|
126
|
+
const mutableFields = MUTABLE_SPEC_FIELDS[filename];
|
|
127
|
+
|
|
128
|
+
// Files with mutable progress fields (e.g. tasks.json): allow a full Write
|
|
129
|
+
// that touches only those fields. Anything else stays blocked.
|
|
130
|
+
if (mutableFields) {
|
|
131
|
+
const toolName = getToolName(payload);
|
|
132
|
+
const newContent = payload?.tool_input?.content;
|
|
133
|
+
|
|
134
|
+
// Edit is never the legitimate progress path — progress is written as a
|
|
135
|
+
// full file via writeTasksJson(). Block Edit; fall through to the message.
|
|
136
|
+
if (toolName !== 'Write' || typeof newContent !== 'string') {
|
|
137
|
+
logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
|
|
138
|
+
block(
|
|
139
|
+
`MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
|
|
140
|
+
`Progress updates (${mutableFields.join(', ')}) must be written as a full-file\n` +
|
|
141
|
+
`Write, not an Edit. The plan contract (titles, dependencies, doneCriteria…)\n` +
|
|
142
|
+
`is frozen. To change the plan itself, ask the user to revoke the\n` +
|
|
143
|
+
`'${requiredGate}' gate in .morph/state.json, then re-present it for approval.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Semantic diff: old = current file on disk, new = proposed content.
|
|
148
|
+
// Any failure to read/parse → fail-open (pass), never a false block.
|
|
149
|
+
let oldDoc, newDoc;
|
|
150
|
+
try {
|
|
151
|
+
// filePath may already be absolute (Claude Code usually sends absolute
|
|
152
|
+
// paths); only join against cwd when it is relative.
|
|
153
|
+
const absPath = isAbsolute(filePath) ? filePath : join(process.cwd(), filePath);
|
|
154
|
+
oldDoc = JSON.parse(readFileSync(absPath, 'utf-8'));
|
|
155
|
+
newDoc = JSON.parse(newContent);
|
|
156
|
+
} catch {
|
|
157
|
+
// Cannot compare — fail-open rather than block a possibly-valid write.
|
|
158
|
+
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
159
|
+
pass();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const violations = diffPlanContract(oldDoc, newDoc, mutableFields);
|
|
163
|
+
if (violations.length === 0) {
|
|
164
|
+
// Only mutable progress fields changed — this is a legitimate
|
|
165
|
+
// implement-phase update.
|
|
166
|
+
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
167
|
+
pass();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
|
|
171
|
+
block(
|
|
172
|
+
`MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
|
|
173
|
+
`This Write changes the frozen plan contract:\n` +
|
|
174
|
+
violations.map(v => ` • ${v}`).join('\n') + '\n\n' +
|
|
175
|
+
`During the implement phase only these fields may change: ${mutableFields.join(', ')}.\n` +
|
|
176
|
+
`To change the plan itself, ask the user to revoke the '${requiredGate}' gate\n` +
|
|
177
|
+
`in .morph/state.json, then re-present it for approval.`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fully-locked file (spec.md, mandate.md, design files): blanket block.
|
|
182
|
+
logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
|
|
183
|
+
block(
|
|
184
|
+
`MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
|
|
185
|
+
`Editing approved specs breaks the spec contract.\n\n` +
|
|
186
|
+
`If changes are truly needed, ask the user to revoke the gate approval\n` +
|
|
187
|
+
`in .morph/state.json (set approvals.${requiredGate} to false), make\n` +
|
|
188
|
+
`your edits, then re-present the gate for approval.`
|
|
189
|
+
);
|
|
190
|
+
} catch {
|
|
191
|
+
// Fail-open
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
@@ -65,6 +65,16 @@ export const PROTECTED_SPEC_FILES = {
|
|
|
65
65
|
'tasks.json': 'plan',
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* For protected files that carry mutable progress fields: filename → the
|
|
70
|
+
* top-level array-item fields that may change after the gate is approved.
|
|
71
|
+
* When a protected file is listed here, the protect-spec-files hook does a
|
|
72
|
+
* semantic diff (allowing only these fields to change) instead of a blanket block.
|
|
73
|
+
*/
|
|
74
|
+
export const MUTABLE_SPEC_FIELDS = {
|
|
75
|
+
'tasks.json': ['status', 'outputs', 'notes'],
|
|
76
|
+
};
|
|
77
|
+
|
|
68
78
|
/**
|
|
69
79
|
* Extract feature name from a .morph/features/{feature}/... path
|
|
70
80
|
* @param {string} filePath - File path to analyze
|
|
@@ -125,19 +125,26 @@ After making structural changes (new packages, dependencies, project config chan
|
|
|
125
125
|
|
|
126
126
|
BEFORE implementing ANY task during the implement phase:
|
|
127
127
|
|
|
128
|
-
1. **Read tasks.
|
|
129
|
-
2. **Read spec.md** → the sections
|
|
130
|
-
3. **Read contracts
|
|
128
|
+
1. **Read tasks.json** → identify the current task object (`id`, `description`, `doneCriteria`, `dependencies`)
|
|
129
|
+
2. **Read spec.md** → the FR sections that the task `description` references
|
|
130
|
+
3. **Read the contracts** → the DTOs/interfaces are defined inside `spec.md` (v5 keeps contracts in the spec, not separate files)
|
|
131
131
|
4. Only then begin implementation
|
|
132
132
|
|
|
133
133
|
This is **NON-NEGOTIABLE**. The spec is the source of truth. Code that doesn't match the spec is a bug.
|
|
134
134
|
|
|
135
|
-
Every task in `tasks.
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
Every task in `tasks.json` MUST reference its FR(s) in the `description` field so the link to the spec is explicit:
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"id": "T1",
|
|
139
|
+
"title": "Create user endpoint",
|
|
140
|
+
"description": "Implement user CRUD (FR001) and input validation (FR003). Contracts: CreateUserRequest, CreateUserResponse, UserErrors.",
|
|
141
|
+
"dependencies": [],
|
|
142
|
+
"effort": "M",
|
|
143
|
+
"doneCriteria": "POST /users creates a user and rejects invalid input",
|
|
144
|
+
"status": "pending",
|
|
145
|
+
"outputs": ["Features/UserFeature/Create/Handler.cs", "Validator.cs", "Endpoint.cs"],
|
|
146
|
+
"group": "backend"
|
|
147
|
+
}
|
|
141
148
|
```
|
|
142
149
|
|
|
143
150
|
---
|
|
@@ -4,6 +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: "7.2.0"
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# morph-brainstorming
|
|
@@ -26,7 +27,7 @@ O harness conhece o stack por padrão. Não pergunte sobre tecnologia a menos qu
|
|
|
26
27
|
| Frontend | Next.js App Router + Tailwind + Framer Motion + Shadcn |
|
|
27
28
|
| Jobs async | Hangfire — apenas se a feature requer processamento em background |
|
|
28
29
|
|
|
29
|
-
**Padrões proibidos:** `Guid.NewGuid()`, classes não-sealed sem justificativa, exceptions sem result pattern,
|
|
30
|
+
**Padrões proibidos:** `Guid.NewGuid()`, classes não-sealed sem justificativa, exceptions sem result pattern, camada de Service/Manager entre handler e dados, SQL cru sem justificativa. Acesso a dados em VSA é híbrido: `IRepository<T>` + `IUnitOfWork` para CRUD; `DbContext` direto no slice para queries específicas.
|
|
30
31
|
|
|
31
32
|
## O que fazer
|
|
32
33
|
|
|
@@ -4,6 +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: "7.2.0"
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# morph-implement
|
|
@@ -78,6 +79,12 @@ Ao iniciar cada task de `tasks.json` — antes de escrever qualquer código. Nor
|
|
|
78
79
|
await writeTasksJson(tasksPath, data);
|
|
79
80
|
```
|
|
80
81
|
|
|
82
|
+
`tasks.json` é protegido após o Gate 2, mas os campos de progresso (`status`,
|
|
83
|
+
`outputs`, `notes`) continuam graváveis durante a fase implement. `writeTasksJson`
|
|
84
|
+
grava o arquivo **inteiro** (Write) — esse é o caminho permitido. Nunca use a
|
|
85
|
+
tool `Edit` em `tasks.json` após o Gate 2: o hook `protect-spec-files` bloqueia
|
|
86
|
+
Edit e qualquer mudança em campos de plano (`title`, `dependencies`, `doneCriteria`…).
|
|
87
|
+
|
|
81
88
|
9. Quando invocada por `morph-apply`, retornar o controle à skill orquestradora — ela decide qual task vem em seguida via `getReadyTasks(data)`. Quando invocada manualmente, verificar se há próxima task ready e iniciá-la imediatamente aplicando esta skill desde o passo 1 (auto-advance). Pause apenas se encontrar ambiguidade bloqueante não coberta pelo mandate.
|
|
82
89
|
|
|
83
90
|
## Output esperado
|
|
@@ -4,6 +4,7 @@ description: Guia a produção dos três artefatos de planejamento de uma featur
|
|
|
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: "7.2.0"
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# morph-plan
|
|
@@ -33,7 +34,9 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
33
34
|
- `Guid.NewGuid()` → usar `Guid.CreateVersion7()`
|
|
34
35
|
- Classes não-sealed sem justificativa documentada
|
|
35
36
|
- Exceptions como fluxo de controle → usar result pattern com `result.Match()`
|
|
36
|
-
-
|
|
37
|
+
- Camada de Service/Manager entre handler e dados → handler usa `IRepository<T>` ou `DbContext` direto
|
|
38
|
+
- SQL cru (`FromSqlRaw`, `ExecuteQuery(string)`) sem justificativa em `decisions.md` → usar LINQ
|
|
39
|
+
- Política de acesso a dados (registre no mandate como orientação, não proibição): `IRepository<T>` + `IUnitOfWork` para CRUD simples; `ApplicationDbContext` injetado direto no slice para queries específicas (joins, projeção, paginação).
|
|
37
40
|
- Checklist de done com critérios verificáveis por tarefa.
|
|
38
41
|
|
|
39
42
|
4. Produza `tasks.json` em `.morph/features/{feature}/2-plan/` seguindo o schema em `.morph/framework/schemas/tasks.schema.json`.
|
|
@@ -4,6 +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: "7.2.0"
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# morph-proposal
|
|
@@ -4,6 +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: "7.2.0"
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# morph-review
|
|
@@ -4,6 +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: "7.2.0"
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# morph-stack-scan
|
|
@@ -28,7 +29,7 @@ Quando o projeto já tem código existente e você precisa entender o estado atu
|
|
|
28
29
|
| Frontend | Next.js App Router + Tailwind + Framer Motion + Shadcn |
|
|
29
30
|
| Jobs | Hangfire (apenas quando necessário) |
|
|
30
31
|
|
|
31
|
-
**Padrões proibidos:** `Guid.NewGuid()`, classes não-sealed sem justificativa, exceptions como fluxo de controle,
|
|
32
|
+
**Padrões proibidos:** `Guid.NewGuid()`, classes não-sealed sem justificativa, exceptions como fluxo de controle, camada de Service/Manager entre handler e dados, SQL cru sem justificativa. Acesso a dados em VSA é híbrido: `IRepository<T>` + `IUnitOfWork` para CRUD; `DbContext` direto no slice para queries específicas.
|
|
32
33
|
|
|
33
34
|
## Greenfield short-circuit
|
|
34
35
|
|
|
@@ -61,7 +62,7 @@ Antes de despachar Explore, verifique se o projeto é greenfield:
|
|
|
61
62
|
- Erros: Result pattern (`result.Match()`)
|
|
62
63
|
|
|
63
64
|
## Padrões proibidos
|
|
64
|
-
`Guid.NewGuid()`, classes não-sealed sem justificativa, exceptions como fluxo de controle,
|
|
65
|
+
`Guid.NewGuid()`, classes não-sealed sem justificativa, exceptions como fluxo de controle, camada de Service/Manager entre handler e dados, SQL cru sem justificativa. Acesso a dados em VSA é híbrido: `IRepository<T>` + `IUnitOfWork` para CRUD; `DbContext` direto no slice para queries específicas.
|
|
65
66
|
|
|
66
67
|
## Recomendação
|
|
67
68
|
Compliance trivial (greenfield). Prosseguir para morph-brainstorming.
|
|
@@ -101,7 +102,7 @@ Antes de despachar Explore, verifique se o projeto é greenfield:
|
|
|
101
102
|
| Severidade | Critério |
|
|
102
103
|
|------------|---------|
|
|
103
104
|
| **Bloqueante** | Runtime diferente (.NET 8 ou anterior), ORM diferente (Dapper puro sem EF Core), banco diferente (MySQL, SQL Server) |
|
|
104
|
-
| **Alto** | `Guid.NewGuid()` generalizado,
|
|
105
|
+
| **Alto** | `Guid.NewGuid()` generalizado, camada de Service/Manager entre handler e dados, SQL cru generalizado, exceptions como fluxo de controle |
|
|
105
106
|
| **Médio** | Classes não-sealed, Pages Router em vez de App Router, biblioteca de UI diferente |
|
|
106
107
|
| **Baixo** | Versões de pacotes desatualizadas mas compatíveis |
|
|
107
108
|
|
|
@@ -323,6 +323,12 @@ public interface IRepository<T> where T : class
|
|
|
323
323
|
}
|
|
324
324
|
```
|
|
325
325
|
|
|
326
|
+
**Quando NÃO usar o repository:** o `IRepository<T>` genérico cobre CRUD por chave.
|
|
327
|
+
Para queries com join, projeção, paginação ou filtros compostos, injete
|
|
328
|
+
`ApplicationDbContext` direto no handler e escreva o LINQ no slice — não polua a
|
|
329
|
+
interface genérica com métodos de um único slice, e não use `ExecuteQuery(string sql)`.
|
|
330
|
+
Veja `backend/dotnet/vsa-handler-patterns.md` → "When to Use DbContext Directly".
|
|
331
|
+
|
|
326
332
|
---
|
|
327
333
|
|
|
328
334
|
## DI Registration (Program.cs)
|
|
@@ -379,7 +385,7 @@ app.MapApiEndpoints();
|
|
|
379
385
|
4. **`result.Match()`** nos endpoints — nunca acessar `result.Value` sem checar `IsSuccess`.
|
|
380
386
|
5. **IApiEndpoint `internal sealed`** — endpoints não são públicos.
|
|
381
387
|
6. **`{Entity}Errors` compartilhado** por todas as operações da feature (1 arquivo por feature, não por slice).
|
|
382
|
-
7. **
|
|
388
|
+
7. **Acesso a dados híbrido**: `IRepository<T>` injetado no handler para CRUD por chave; `ApplicationDbContext` injetado direto no slice para queries específicas (joins, projeção, paginação). Nunca uma Application Service layer entre handler e dados.
|
|
383
389
|
8. **`IUnitOfWork.CommitAsync()`** em operações de escrita (Create, Update, Delete).
|
|
384
390
|
9. **Sem validator em GetAll** — GetAllBooksHandler não tem validator (sem parâmetros para validar).
|
|
385
391
|
|
|
@@ -197,6 +197,36 @@ public interface IUnitOfWork
|
|
|
197
197
|
|
|
198
198
|
**Pattern:** `Repository.AddAsync()` -> `UnitOfWork.CommitAsync()` — always commit explicitly.
|
|
199
199
|
|
|
200
|
+
### When to Use DbContext Directly Instead
|
|
201
|
+
|
|
202
|
+
`IRepository<T>` is the right tool for CRUD by key. It is **not** the right tool for
|
|
203
|
+
slice-specific queries — joins, projections, pagination, composite filters. For those,
|
|
204
|
+
inject `ApplicationDbContext` directly into the handler and write the LINQ in the slice:
|
|
205
|
+
|
|
206
|
+
```csharp
|
|
207
|
+
public sealed class SearchBooksHandler(
|
|
208
|
+
ApplicationDbContext db) : IHandler<SearchBooksRequest, Result<IReadOnlyList<BookDto>>>
|
|
209
|
+
{
|
|
210
|
+
public async Task<Result<IReadOnlyList<BookDto>>> HandleAsync(
|
|
211
|
+
SearchBooksRequest query, CancellationToken cancellationToken)
|
|
212
|
+
{
|
|
213
|
+
var books = await db.Books
|
|
214
|
+
.Where(b => b.Author.Contains(query.Author))
|
|
215
|
+
.OrderBy(b => b.Title)
|
|
216
|
+
.Skip(query.Skip).Take(query.Take)
|
|
217
|
+
.Select(b => new BookDto(b.Id, b.Title, b.Author))
|
|
218
|
+
.ToListAsync(cancellationToken);
|
|
219
|
+
|
|
220
|
+
return Result.Success<IReadOnlyList<BookDto>>(books);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Rule of thumb:** CRUD by key → `IRepository<T>` + `IUnitOfWork`. Anything that needs a
|
|
226
|
+
custom query shape → `ApplicationDbContext` in the slice. Never reach for raw SQL
|
|
227
|
+
(`FromSqlRaw`) unless a query genuinely cannot be expressed in LINQ — and document that
|
|
228
|
+
in `decisions.md`.
|
|
229
|
+
|
|
200
230
|
---
|
|
201
231
|
|
|
202
232
|
## Feature Error Factory
|
|
@@ -272,6 +302,8 @@ builder.Services.AddExceptionHandler<CustomExceptionHandler>()
|
|
|
272
302
|
| Share handlers across features | Each feature has its own handler, even if similar |
|
|
273
303
|
| Use constructor + field for DI | Use primary constructors: `class Handler(IDep dep)` |
|
|
274
304
|
| Group by technical concern (Controllers/, Services/) | Group by feature: `Features/BookFeature/CreateBook/` |
|
|
305
|
+
| Force a slice-specific query through `IRepository<T>` | Inject `DbContext` and write LINQ in the slice |
|
|
306
|
+
| Use raw SQL (`FromSqlRaw` / `ExecuteQuery(string)`) by default | Use LINQ; raw SQL only with a `decisions.md` justification |
|
|
275
307
|
|
|
276
308
|
---
|
|
277
309
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
VSA + .NET 10 + EF Core + Neon (Postgres) + Microsoft Agent Framework + Next.js (App Router) + Tailwind + Framer Motion + Shadcn + Hangfire (quando necessário).
|
|
8
8
|
|
|
9
|
-
Padrões proibidos por padrão: `Guid.NewGuid()` (use `Guid.CreateVersion7()`), classes não-sealed sem justificativa, exceptions como fluxo de controle,
|
|
9
|
+
Padrões proibidos por padrão: `Guid.NewGuid()` (use `Guid.CreateVersion7()`), classes não-sealed sem justificativa, exceptions como fluxo de controle, camada de Service/Manager entre handler e dados, SQL cru sem justificativa. Acesso a dados é híbrido: `IRepository<T>` + `IUnitOfWork` para CRUD; `DbContext` direto no slice para queries específicas.
|
|
10
10
|
|
|
11
11
|
## Harness
|
|
12
12
|
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// Generated by MORPH Framework
|
|
4
4
|
// ============================================================
|
|
5
5
|
|
|
6
|
-
using System.Data.Common;
|
|
7
6
|
using Microsoft.EntityFrameworkCore;
|
|
8
7
|
using {{pascalCase PROJECT_NAME}}.Database;
|
|
9
8
|
|
|
@@ -13,6 +12,9 @@ namespace {{pascalCase PROJECT_NAME}}.Repository;
|
|
|
13
12
|
// IRepository<T> — Generic repository contract
|
|
14
13
|
// ============================================================
|
|
15
14
|
|
|
15
|
+
// Generic CRUD by key. For slice-specific queries (joins, projection, pagination),
|
|
16
|
+
// inject ApplicationDbContext directly into the handler and write LINQ in the slice —
|
|
17
|
+
// do NOT add query methods here, and do NOT use raw SQL.
|
|
16
18
|
public interface IRepository<T> where T : class
|
|
17
19
|
{
|
|
18
20
|
Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default);
|
|
@@ -20,7 +22,6 @@ public interface IRepository<T> where T : class
|
|
|
20
22
|
Task<T> AddAsync(T entity, CancellationToken cancellationToken = default);
|
|
21
23
|
Task UpdateAsync(T entity, CancellationToken cancellationToken = default);
|
|
22
24
|
Task DeleteAsync(T entity, CancellationToken cancellationToken = default);
|
|
23
|
-
IEnumerable<T> ExecuteQuery(string query, DbParameter[] dbParams);
|
|
24
25
|
Task AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
|
|
25
26
|
void RemoveRange(IEnumerable<T> entities);
|
|
26
27
|
}
|
|
@@ -80,14 +81,6 @@ public class Repository<T> : IRepository<T> where T : class
|
|
|
80
81
|
return Task.CompletedTask;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
public IEnumerable<T> ExecuteQuery(string query, DbParameter[] dbParams)
|
|
84
|
-
{
|
|
85
|
-
return _dbSet
|
|
86
|
-
.FromSqlRaw(query, dbParams)
|
|
87
|
-
.AsNoTracking()
|
|
88
|
-
.AsEnumerable();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
84
|
public async Task AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
|
|
92
85
|
{
|
|
93
86
|
await _dbSet.AddRangeAsync(entities, cancellationToken);
|
package/package.json
CHANGED
|
@@ -20,6 +20,11 @@ import { join } from 'path';
|
|
|
20
20
|
* phase - The morph-spec phase that produces this output
|
|
21
21
|
* protected - Whether the file requires an approval gate before editing
|
|
22
22
|
* approvalGate - (optional) Which gate protects this file
|
|
23
|
+
* mutableFields- (optional) For protected JSON files: top-level array-item
|
|
24
|
+
* field names that MAY still change after the gate is approved.
|
|
25
|
+
* Lets the implement phase update task progress without
|
|
26
|
+
* unfreezing the plan contract. When present, the protect
|
|
27
|
+
* hook does a semantic diff instead of a blanket block.
|
|
23
28
|
*/
|
|
24
29
|
export const OUTPUT_SCHEMA = {
|
|
25
30
|
// 0-proposal/
|
|
@@ -36,7 +41,7 @@ export const OUTPUT_SCHEMA = {
|
|
|
36
41
|
// 2-plan/
|
|
37
42
|
spec: { filename: 'spec.md', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
|
|
38
43
|
mandate: { filename: 'mandate.md', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
|
|
39
|
-
tasks: { filename: 'tasks.json', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
|
|
44
|
+
tasks: { filename: 'tasks.json', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan', mutableFields: ['status', 'outputs', 'notes'] },
|
|
40
45
|
decisions: { filename: 'decisions.md', phaseDir: '2-plan', phase: 'plan', protected: false },
|
|
41
46
|
|
|
42
47
|
// 3-implement/
|
|
@@ -119,6 +124,18 @@ export const PROTECTED_SPEC_FILES = Object.fromEntries(
|
|
|
119
124
|
.map(([, entry]) => [entry.filename, entry.approvalGate])
|
|
120
125
|
);
|
|
121
126
|
|
|
127
|
+
/**
|
|
128
|
+
* For protected files that carry mutable progress fields: filename → the
|
|
129
|
+
* top-level array-item fields that may change after the gate is approved.
|
|
130
|
+
* The protect-spec-files hook uses this to allow progress updates (e.g.
|
|
131
|
+
* task status) while still freezing the plan contract.
|
|
132
|
+
*/
|
|
133
|
+
export const MUTABLE_SPEC_FIELDS = Object.fromEntries(
|
|
134
|
+
Object.entries(OUTPUT_SCHEMA)
|
|
135
|
+
.filter(([, entry]) => entry.protected && entry.approvalGate && entry.mutableFields)
|
|
136
|
+
.map(([, entry]) => [entry.filename, entry.mutableFields])
|
|
137
|
+
);
|
|
138
|
+
|
|
122
139
|
// ============================================================================
|
|
123
140
|
// Backwards-compat shim (will be removed in v5.1)
|
|
124
141
|
// ============================================================================
|