@elyracode/laravel 0.7.12
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/CHANGELOG.md +9 -0
- package/README.md +25 -0
- package/extensions/index.ts +530 -0
- package/package.json +37 -0
- package/skills/elyra-laravel/SKILL.md +82 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.7.12] - 2026-05-24
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `laravel_models` tool: map all Eloquent models with relationships, casts, scopes, and traits
|
|
7
|
+
- `laravel_routes` tool: route listing with middleware, controllers, and request classes
|
|
8
|
+
- `laravel_analyze` tool: project architecture analysis (stack, patterns, conventions)
|
|
9
|
+
- `elyra-laravel` skill: Laravel conventions and best practices
|
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# @elyracode/laravel
|
|
2
|
+
|
|
3
|
+
Deep Laravel project understanding for Elyra. Gives the agent x-ray vision into your models, routes, and architecture so it generates code that matches your project's conventions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
elyra install npm:@elyracode/laravel
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Available Tools
|
|
12
|
+
|
|
13
|
+
| Tool | Description |
|
|
14
|
+
|------|-------------|
|
|
15
|
+
| `laravel_models` | Map all Eloquent models with relationships, casts, scopes, traits, and fillable fields |
|
|
16
|
+
| `laravel_routes` | List routes with middleware, controllers, form requests, and resources |
|
|
17
|
+
| `laravel_analyze` | Analyze project architecture: stack, patterns, auth, queue, conventions |
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
Other coding agents read Laravel files one at a time and guess at conventions. With these tools, the agent sees your entire data model and architecture before writing a single line — fewer wrong guesses, fewer wasted tokens, code that matches your project.
|
|
22
|
+
|
|
23
|
+
## Included Skill
|
|
24
|
+
|
|
25
|
+
The `elyra-laravel` skill provides deep knowledge of Laravel conventions, Eloquent patterns, common architectures, and best practices.
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, join, relative } from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@elyracode/coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
|
|
7
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function isLaravelProject(cwd: string): boolean {
|
|
10
|
+
const composerPath = join(cwd, "composer.json");
|
|
11
|
+
if (!existsSync(composerPath)) return false;
|
|
12
|
+
try {
|
|
13
|
+
const composer = JSON.parse(readFileSync(composerPath, "utf-8")) as Record<string, unknown>;
|
|
14
|
+
const require = composer.require as Record<string, string> | undefined;
|
|
15
|
+
return Boolean(require?.["laravel/framework"]);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readPhpFile(filePath: string): string {
|
|
22
|
+
try {
|
|
23
|
+
return readFileSync(filePath, "utf-8");
|
|
24
|
+
} catch {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findPhpFiles(dir: string, maxDepth = 5): string[] {
|
|
30
|
+
const results: string[] = [];
|
|
31
|
+
if (!existsSync(dir)) return results;
|
|
32
|
+
|
|
33
|
+
function walk(current: string, depth: number): void {
|
|
34
|
+
if (depth > maxDepth) return;
|
|
35
|
+
let entries: string[];
|
|
36
|
+
try {
|
|
37
|
+
entries = readdirSync(current);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.startsWith(".")) continue;
|
|
43
|
+
const full = join(current, entry);
|
|
44
|
+
try {
|
|
45
|
+
const stat = statSync(full);
|
|
46
|
+
if (stat.isDirectory()) {
|
|
47
|
+
walk(full, depth + 1);
|
|
48
|
+
} else if (entry.endsWith(".php")) {
|
|
49
|
+
results.push(full);
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// skip
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
walk(dir, 0);
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function artisan(cwd: string, args: string, timeoutMs = 15_000): string {
|
|
61
|
+
try {
|
|
62
|
+
return execSync(`php artisan ${args}`, {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
timeout: timeoutMs,
|
|
66
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
}).trim();
|
|
68
|
+
} catch {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Model Parser ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
interface ModelInfo {
|
|
76
|
+
name: string;
|
|
77
|
+
path: string;
|
|
78
|
+
relationships: Array<{ type: string; related: string; method: string }>;
|
|
79
|
+
fillable: string[];
|
|
80
|
+
casts: string[];
|
|
81
|
+
scopes: string[];
|
|
82
|
+
traits: string[];
|
|
83
|
+
implements: string[];
|
|
84
|
+
useSoftDeletes: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseModel(filePath: string, cwd: string): ModelInfo | null {
|
|
88
|
+
const content = readPhpFile(filePath);
|
|
89
|
+
if (!content.includes("extends Model") && !content.includes("extends Authenticatable")) return null;
|
|
90
|
+
|
|
91
|
+
const nameMatch = content.match(/class\s+(\w+)\s+extends/);
|
|
92
|
+
if (!nameMatch) return null;
|
|
93
|
+
|
|
94
|
+
const name = nameMatch[1];
|
|
95
|
+
const relPath = relative(cwd, filePath);
|
|
96
|
+
|
|
97
|
+
// Relationships
|
|
98
|
+
const relationships: ModelInfo["relationships"] = [];
|
|
99
|
+
const relRegex =
|
|
100
|
+
/(?:public\s+)?function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->(hasOne|hasMany|belongsTo|belongsToMany|hasManyThrough|hasOneThrough|morphOne|morphMany|morphToMany|morphTo|morphedByMany)\s*\(\s*(?:\\?[\w\\]*\\)?(\w+)::class/g;
|
|
101
|
+
let relMatch: RegExpExecArray | null = null;
|
|
102
|
+
while ((relMatch = relRegex.exec(content)) !== null) {
|
|
103
|
+
relationships.push({
|
|
104
|
+
method: relMatch[1],
|
|
105
|
+
type: relMatch[2],
|
|
106
|
+
related: relMatch[3],
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Fillable
|
|
111
|
+
const fillable: string[] = [];
|
|
112
|
+
const fillableMatch = content.match(/\$fillable\s*=\s*\[([\s\S]*?)\]/);
|
|
113
|
+
if (fillableMatch) {
|
|
114
|
+
const items = fillableMatch[1].match(/'([^']+)'/g);
|
|
115
|
+
if (items) {
|
|
116
|
+
for (const item of items) fillable.push(item.replace(/'/g, ""));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Casts
|
|
121
|
+
const casts: string[] = [];
|
|
122
|
+
const castsMatch = content.match(/(?:protected\s+function\s+casts\s*\(\)[\s\S]*?return\s*\[([\s\S]*?)\]|\$casts\s*=\s*\[([\s\S]*?)\])/);
|
|
123
|
+
if (castsMatch) {
|
|
124
|
+
const castBlock = castsMatch[1] ?? castsMatch[2] ?? "";
|
|
125
|
+
const castItems = castBlock.match(/'(\w+)'\s*=>\s*'?([^',\]]+)/g);
|
|
126
|
+
if (castItems) {
|
|
127
|
+
for (const item of castItems) {
|
|
128
|
+
const parts = item.match(/'(\w+)'\s*=>\s*'?([^',\]]+)/);
|
|
129
|
+
if (parts) casts.push(`${parts[1]} (${parts[2].trim().replace(/'/g, "")})`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Scopes
|
|
135
|
+
const scopes: string[] = [];
|
|
136
|
+
const scopeRegex = /function\s+scope(\w+)\s*\(/g;
|
|
137
|
+
let scopeMatch: RegExpExecArray | null = null;
|
|
138
|
+
while ((scopeMatch = scopeRegex.exec(content)) !== null) {
|
|
139
|
+
scopes.push(scopeMatch[1].charAt(0).toLowerCase() + scopeMatch[1].slice(1));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Traits
|
|
143
|
+
const traits: string[] = [];
|
|
144
|
+
const useRegex = /use\s+([\w\\]+(?:\s*,\s*[\w\\]+)*)\s*;/g;
|
|
145
|
+
let useMatch: RegExpExecArray | null = null;
|
|
146
|
+
while ((useMatch = useRegex.exec(content)) !== null) {
|
|
147
|
+
// Only traits inside the class body (after 'class X extends')
|
|
148
|
+
if (content.indexOf(useMatch[0]) > content.indexOf("extends")) {
|
|
149
|
+
const traitNames = useMatch[1].split(",").map((t) => {
|
|
150
|
+
const parts = t.trim().split("\\");
|
|
151
|
+
return parts[parts.length - 1];
|
|
152
|
+
});
|
|
153
|
+
traits.push(...traitNames);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const useSoftDeletes = traits.includes("SoftDeletes");
|
|
158
|
+
|
|
159
|
+
// Implements
|
|
160
|
+
const implMatch = content.match(/implements\s+([\w\\,\s]+)/);
|
|
161
|
+
const implements_: string[] = [];
|
|
162
|
+
if (implMatch) {
|
|
163
|
+
implements_.push(
|
|
164
|
+
...implMatch[1].split(",").map((i) => {
|
|
165
|
+
const parts = i.trim().split("\\");
|
|
166
|
+
return parts[parts.length - 1];
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
name,
|
|
173
|
+
path: relPath,
|
|
174
|
+
relationships,
|
|
175
|
+
fillable,
|
|
176
|
+
casts,
|
|
177
|
+
scopes,
|
|
178
|
+
traits: traits.filter((t) => t !== ""),
|
|
179
|
+
implements: implements_,
|
|
180
|
+
useSoftDeletes,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatModels(models: ModelInfo[]): string {
|
|
185
|
+
if (models.length === 0) return "No Eloquent models found.";
|
|
186
|
+
|
|
187
|
+
const lines: string[] = [`${models.length} Eloquent models found:\n`];
|
|
188
|
+
|
|
189
|
+
for (const model of models) {
|
|
190
|
+
lines.push(`${model.name} (${model.path})`);
|
|
191
|
+
|
|
192
|
+
if (model.relationships.length > 0) {
|
|
193
|
+
for (const rel of model.relationships) {
|
|
194
|
+
lines.push(` ${rel.type}: ${rel.related} (via ${rel.method}())`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (model.fillable.length > 0) {
|
|
198
|
+
lines.push(` fillable: ${model.fillable.join(", ")}`);
|
|
199
|
+
}
|
|
200
|
+
if (model.casts.length > 0) {
|
|
201
|
+
lines.push(` casts: ${model.casts.join(", ")}`);
|
|
202
|
+
}
|
|
203
|
+
if (model.scopes.length > 0) {
|
|
204
|
+
lines.push(` scopes: ${model.scopes.join(", ")}`);
|
|
205
|
+
}
|
|
206
|
+
if (model.traits.length > 0) {
|
|
207
|
+
lines.push(` traits: ${model.traits.join(", ")}`);
|
|
208
|
+
}
|
|
209
|
+
if (model.implements.length > 0) {
|
|
210
|
+
lines.push(` implements: ${model.implements.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
lines.push("");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Route Parser ────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
function parseRoutes(cwd: string, prefix?: string): string {
|
|
221
|
+
let args = "route:list --json";
|
|
222
|
+
if (prefix) args += ` --path=${prefix}`;
|
|
223
|
+
|
|
224
|
+
const output = artisan(cwd, args);
|
|
225
|
+
if (!output) return "Could not fetch routes. Is php artisan available?";
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const routes = JSON.parse(output) as Array<{
|
|
229
|
+
method: string;
|
|
230
|
+
uri: string;
|
|
231
|
+
name: string | null;
|
|
232
|
+
action: string;
|
|
233
|
+
middleware: string[] | string;
|
|
234
|
+
}>;
|
|
235
|
+
|
|
236
|
+
if (routes.length === 0) return prefix ? `No routes matching prefix "${prefix}".` : "No routes defined.";
|
|
237
|
+
|
|
238
|
+
const lines: string[] = [`${routes.length} routes:\n`];
|
|
239
|
+
|
|
240
|
+
for (const route of routes) {
|
|
241
|
+
const methods = route.method.toUpperCase();
|
|
242
|
+
const mw = Array.isArray(route.middleware)
|
|
243
|
+
? route.middleware.join(", ")
|
|
244
|
+
: route.middleware || "";
|
|
245
|
+
|
|
246
|
+
lines.push(`${methods} /${route.uri}`);
|
|
247
|
+
if (route.name) lines.push(` name: ${route.name}`);
|
|
248
|
+
lines.push(` action: ${route.action}`);
|
|
249
|
+
if (mw) lines.push(` middleware: ${mw}`);
|
|
250
|
+
lines.push("");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return lines.join("\n");
|
|
254
|
+
} catch {
|
|
255
|
+
return "Could not parse route list output.";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Project Analyzer ────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function analyzeProject(cwd: string, models: ModelInfo[]): string {
|
|
262
|
+
const lines: string[] = [];
|
|
263
|
+
|
|
264
|
+
// Laravel version
|
|
265
|
+
const composerLock = join(cwd, "composer.lock");
|
|
266
|
+
let laravelVersion = "unknown";
|
|
267
|
+
if (existsSync(composerLock)) {
|
|
268
|
+
try {
|
|
269
|
+
const lock = JSON.parse(readFileSync(composerLock, "utf-8")) as {
|
|
270
|
+
packages: Array<{ name: string; version: string }>;
|
|
271
|
+
};
|
|
272
|
+
const laravel = lock.packages.find((p) => p.name === "laravel/framework");
|
|
273
|
+
if (laravel) laravelVersion = laravel.version;
|
|
274
|
+
} catch {
|
|
275
|
+
// skip
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Detect stack
|
|
280
|
+
const composer = JSON.parse(readFileSync(join(cwd, "composer.json"), "utf-8")) as Record<string, unknown>;
|
|
281
|
+
const require = { ...(composer.require as Record<string, string> ?? {}), ...(composer["require-dev"] as Record<string, string> ?? {}) };
|
|
282
|
+
const packageJson = existsSync(join(cwd, "package.json"))
|
|
283
|
+
? (JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8")) as Record<string, unknown>)
|
|
284
|
+
: null;
|
|
285
|
+
const npmDeps = packageJson
|
|
286
|
+
? { ...(packageJson.dependencies as Record<string, string> ?? {}), ...(packageJson.devDependencies as Record<string, string> ?? {}) }
|
|
287
|
+
: {};
|
|
288
|
+
|
|
289
|
+
const stack: string[] = [`Laravel ${laravelVersion}`];
|
|
290
|
+
if (require["livewire/livewire"]) stack.push("Livewire");
|
|
291
|
+
if (require["inertiajs/inertia-laravel"]) stack.push("Inertia");
|
|
292
|
+
if (require["laravel/sanctum"]) stack.push("Sanctum");
|
|
293
|
+
if (require["laravel/passport"]) stack.push("Passport");
|
|
294
|
+
if (require["filament/filament"]) stack.push("Filament");
|
|
295
|
+
if (npmDeps.vue) stack.push("Vue");
|
|
296
|
+
if (npmDeps.react) stack.push("React");
|
|
297
|
+
if (npmDeps.svelte) stack.push("Svelte");
|
|
298
|
+
if (npmDeps.tailwindcss || npmDeps["@tailwindcss/vite"]) stack.push("Tailwind");
|
|
299
|
+
if (npmDeps.alpinejs) stack.push("Alpine.js");
|
|
300
|
+
|
|
301
|
+
lines.push(`Stack: ${stack.join(" + ")}`);
|
|
302
|
+
|
|
303
|
+
// Auth
|
|
304
|
+
if (require["laravel/sanctum"]) lines.push("Auth: Sanctum");
|
|
305
|
+
else if (require["laravel/passport"]) lines.push("Auth: Passport");
|
|
306
|
+
else if (require["laravel/fortify"]) lines.push("Auth: Fortify");
|
|
307
|
+
else if (require["laravel/breeze"]) lines.push("Auth: Breeze");
|
|
308
|
+
else if (require["laravel/jetstream"]) lines.push("Auth: Jetstream");
|
|
309
|
+
|
|
310
|
+
// Queue/Cache
|
|
311
|
+
const envPath = join(cwd, ".env");
|
|
312
|
+
if (existsSync(envPath)) {
|
|
313
|
+
const env = readFileSync(envPath, "utf-8");
|
|
314
|
+
const queueMatch = env.match(/QUEUE_CONNECTION=(\w+)/);
|
|
315
|
+
const cacheMatch = env.match(/CACHE_STORE=(\w+)/);
|
|
316
|
+
const dbMatch = env.match(/DB_CONNECTION=(\w+)/);
|
|
317
|
+
if (queueMatch) lines.push(`Queue: ${queueMatch[1]}`);
|
|
318
|
+
if (cacheMatch) lines.push(`Cache: ${cacheMatch[1]}`);
|
|
319
|
+
if (dbMatch) lines.push(`Database: ${dbMatch[1]}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Patterns detection
|
|
323
|
+
const patterns: string[] = [];
|
|
324
|
+
if (existsSync(join(cwd, "app/Actions"))) patterns.push("Action classes (app/Actions/)");
|
|
325
|
+
if (existsSync(join(cwd, "app/Services"))) patterns.push("Service classes (app/Services/)");
|
|
326
|
+
if (existsSync(join(cwd, "app/Repositories"))) patterns.push("Repository pattern (app/Repositories/)");
|
|
327
|
+
if (existsSync(join(cwd, "app/DTOs")) || existsSync(join(cwd, "app/DataTransferObjects"))) patterns.push("DTOs");
|
|
328
|
+
if (existsSync(join(cwd, "app/Enums"))) patterns.push("Enums (app/Enums/)");
|
|
329
|
+
if (existsSync(join(cwd, "app/Observers"))) patterns.push("Observers (app/Observers/)");
|
|
330
|
+
if (existsSync(join(cwd, "app/Events"))) patterns.push("Events (app/Events/)");
|
|
331
|
+
if (existsSync(join(cwd, "app/Listeners"))) patterns.push("Listeners (app/Listeners/)");
|
|
332
|
+
if (existsSync(join(cwd, "app/Jobs"))) patterns.push("Jobs (app/Jobs/)");
|
|
333
|
+
|
|
334
|
+
if (patterns.length > 0) {
|
|
335
|
+
lines.push(`Patterns: ${patterns.join(", ")}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Controller style
|
|
339
|
+
const controllerDir = join(cwd, "app/Http/Controllers");
|
|
340
|
+
if (existsSync(controllerDir)) {
|
|
341
|
+
const controllers = findPhpFiles(controllerDir, 2);
|
|
342
|
+
let invokable = 0;
|
|
343
|
+
let resource = 0;
|
|
344
|
+
for (const c of controllers.slice(0, 20)) {
|
|
345
|
+
const content = readPhpFile(c);
|
|
346
|
+
if (content.includes("__invoke")) invokable++;
|
|
347
|
+
if (content.includes("function index") && content.includes("function store")) resource++;
|
|
348
|
+
}
|
|
349
|
+
if (invokable > resource && invokable > 0) {
|
|
350
|
+
lines.push("Controllers: primarily invokable (single-action)");
|
|
351
|
+
} else if (resource > 0) {
|
|
352
|
+
lines.push("Controllers: primarily resource-style");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Models summary
|
|
357
|
+
lines.push(`Models: ${models.length}`);
|
|
358
|
+
|
|
359
|
+
// Migrations
|
|
360
|
+
const migrationDir = join(cwd, "database/migrations");
|
|
361
|
+
if (existsSync(migrationDir)) {
|
|
362
|
+
try {
|
|
363
|
+
const migrations = readdirSync(migrationDir).filter((f) => f.endsWith(".php"));
|
|
364
|
+
lines.push(`Migrations: ${migrations.length}`);
|
|
365
|
+
} catch {
|
|
366
|
+
// skip
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Factories
|
|
371
|
+
const factoryDir = join(cwd, "database/factories");
|
|
372
|
+
if (existsSync(factoryDir)) {
|
|
373
|
+
try {
|
|
374
|
+
const factories = readdirSync(factoryDir).filter((f) => f.endsWith(".php"));
|
|
375
|
+
const modelsWithoutFactory = models.filter(
|
|
376
|
+
(m) => !factories.some((f) => f.includes(m.name)),
|
|
377
|
+
);
|
|
378
|
+
lines.push(`Factories: ${factories.length}`);
|
|
379
|
+
if (modelsWithoutFactory.length > 0) {
|
|
380
|
+
lines.push(`Missing factories: ${modelsWithoutFactory.map((m) => m.name).join(", ")}`);
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// skip
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Tests
|
|
388
|
+
const testDir = join(cwd, "tests");
|
|
389
|
+
if (existsSync(testDir)) {
|
|
390
|
+
const testFiles = findPhpFiles(testDir);
|
|
391
|
+
const usesPest = testFiles.some((f) => readPhpFile(f).includes("it(") || readPhpFile(f).includes("test("));
|
|
392
|
+
lines.push(`Tests: ${testFiles.length} files (${usesPest ? "Pest" : "PHPUnit"})`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Pending migrations
|
|
396
|
+
const pendingOutput = artisan(cwd, "migrate:status --no-interaction 2>&1");
|
|
397
|
+
if (pendingOutput) {
|
|
398
|
+
const pending = (pendingOutput.match(/Pending/gi) ?? []).length;
|
|
399
|
+
if (pending > 0) {
|
|
400
|
+
lines.push(`Pending migrations: ${pending}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return lines.join("\n");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Extension ───────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
export default function (elyra: ExtensionAPI): void {
|
|
410
|
+
let cwd = "";
|
|
411
|
+
let toolsRegistered = false;
|
|
412
|
+
|
|
413
|
+
elyra.on("session_start", async (_event, ctx) => {
|
|
414
|
+
cwd = ctx.cwd;
|
|
415
|
+
|
|
416
|
+
// Only register tools when running inside a Laravel project
|
|
417
|
+
if (!toolsRegistered && isLaravelProject(getCwd())) {
|
|
418
|
+
toolsRegistered = true;
|
|
419
|
+
registerTools(elyra, () => cwd);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function registerTools(elyra: ExtensionAPI, getCwd: () => string): void {
|
|
425
|
+
|
|
426
|
+
// ── laravel_models ───────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
const modelsSchema = Type.Object({
|
|
429
|
+
filter: Type.Optional(
|
|
430
|
+
Type.String({ description: "Filter models by name (case-insensitive substring match)" }),
|
|
431
|
+
),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
elyra.registerTool({
|
|
435
|
+
name: "laravel_models",
|
|
436
|
+
label: "Laravel Models",
|
|
437
|
+
description:
|
|
438
|
+
"Map all Eloquent models in the project with their relationships, fillable fields, casts, scopes, and traits. Returns a complete data model overview. Use this before generating code that involves database models.",
|
|
439
|
+
parameters: modelsSchema,
|
|
440
|
+
promptSnippet: "Map all Eloquent models with relationships and structure",
|
|
441
|
+
async execute(_toolCallId, params) {
|
|
442
|
+
if (!isLaravelProject(getCwd())) {
|
|
443
|
+
return { content: [{ type: "text", text: "Not a Laravel project (no laravel/framework in composer.json)" }], isError: true };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const modelDirs = [
|
|
447
|
+
join(getCwd(), "app/Models"),
|
|
448
|
+
join(getCwd(), "app"), // Laravel < 8 style
|
|
449
|
+
];
|
|
450
|
+
|
|
451
|
+
const allModels: ModelInfo[] = [];
|
|
452
|
+
const seen = new Set<string>();
|
|
453
|
+
|
|
454
|
+
for (const dir of modelDirs) {
|
|
455
|
+
const files = findPhpFiles(dir, 3);
|
|
456
|
+
for (const file of files) {
|
|
457
|
+
const model = parseModel(file, getCwd());
|
|
458
|
+
if (model && !seen.has(model.name)) {
|
|
459
|
+
seen.add(model.name);
|
|
460
|
+
if (!params.filter || model.name.toLowerCase().includes(params.filter.toLowerCase())) {
|
|
461
|
+
allModels.push(model);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
allModels.sort((a, b) => a.name.localeCompare(b.name));
|
|
468
|
+
return { content: [{ type: "text", text: formatModels(allModels) }] };
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ── laravel_routes ───────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
const routesSchema = Type.Object({
|
|
475
|
+
prefix: Type.Optional(
|
|
476
|
+
Type.String({ description: "Filter routes by URI prefix (e.g. 'api', 'auth', 'admin')" }),
|
|
477
|
+
),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
elyra.registerTool({
|
|
481
|
+
name: "laravel_routes",
|
|
482
|
+
label: "Laravel Routes",
|
|
483
|
+
description:
|
|
484
|
+
"List all registered routes with their HTTP methods, middleware, controller actions, and names. Requires php artisan to be available. Use this to understand the existing API surface before adding new routes.",
|
|
485
|
+
parameters: routesSchema,
|
|
486
|
+
promptSnippet: "List Laravel routes with middleware and controllers",
|
|
487
|
+
async execute(_toolCallId, params) {
|
|
488
|
+
if (!isLaravelProject(getCwd())) {
|
|
489
|
+
return { content: [{ type: "text", text: "Not a Laravel project" }], isError: true };
|
|
490
|
+
}
|
|
491
|
+
const result = parseRoutes(getCwd(), params.prefix);
|
|
492
|
+
return { content: [{ type: "text", text: result }] };
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ── laravel_analyze ──────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
const analyzeSchema = Type.Object({});
|
|
499
|
+
|
|
500
|
+
elyra.registerTool({
|
|
501
|
+
name: "laravel_analyze",
|
|
502
|
+
label: "Laravel Analyze",
|
|
503
|
+
description:
|
|
504
|
+
"Analyze the Laravel project architecture: framework version, stack (Livewire/Inertia/Vue/React), authentication method, patterns used (Actions/Services/Repositories), controller style, model count, test framework, and missing factories. Run this first when working on a Laravel project to understand conventions.",
|
|
505
|
+
parameters: analyzeSchema,
|
|
506
|
+
promptSnippet: "Analyze Laravel project architecture and conventions",
|
|
507
|
+
async execute() {
|
|
508
|
+
if (!isLaravelProject(getCwd())) {
|
|
509
|
+
return { content: [{ type: "text", text: "Not a Laravel project" }], isError: true };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Parse models for the analysis
|
|
513
|
+
const modelDirs = [join(getCwd(), "app/Models"), join(getCwd(), "app")];
|
|
514
|
+
const allModels: ModelInfo[] = [];
|
|
515
|
+
const seen = new Set<string>();
|
|
516
|
+
for (const dir of modelDirs) {
|
|
517
|
+
for (const file of findPhpFiles(dir, 3)) {
|
|
518
|
+
const model = parseModel(file, getCwd());
|
|
519
|
+
if (model && !seen.has(model.name)) {
|
|
520
|
+
seen.add(model.name);
|
|
521
|
+
allModels.push(model);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const result = analyzeProject(getCwd(), allModels);
|
|
527
|
+
return { content: [{ type: "text", text: result }] };
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elyracode/laravel",
|
|
3
|
+
"version": "0.7.12",
|
|
4
|
+
"description": "Deep Laravel project understanding for Elyra -- model relationships, route mapping, architecture analysis",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"elyra-package",
|
|
8
|
+
"laravel",
|
|
9
|
+
"eloquent",
|
|
10
|
+
"artisan",
|
|
11
|
+
"php"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Knut W. Horne",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/kwhorne/elyra.git",
|
|
18
|
+
"directory": "packages/laravel"
|
|
19
|
+
},
|
|
20
|
+
"elyra": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./extensions/index.ts"
|
|
23
|
+
],
|
|
24
|
+
"skills": [
|
|
25
|
+
"./skills"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@elyracode/coding-agent": "*",
|
|
30
|
+
"typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"clean": "echo 'nothing to clean'",
|
|
34
|
+
"build": "echo 'nothing to build'",
|
|
35
|
+
"check": "echo 'nothing to check'"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: elyra-laravel
|
|
3
|
+
description: Laravel project understanding. Use when the project has a composer.json with laravel/framework, or when the user asks about Eloquent models, routes, migrations, controllers, or any Laravel-specific topic.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Laravel Development
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
Use Laravel tools when:
|
|
11
|
+
- The project has `composer.json` with `laravel/framework`
|
|
12
|
+
- The user asks about models, relationships, migrations, or database structure
|
|
13
|
+
- The user wants to add a feature that touches multiple layers (model, controller, routes, tests)
|
|
14
|
+
- You need to understand the existing architecture before generating code
|
|
15
|
+
|
|
16
|
+
## Available Tools
|
|
17
|
+
|
|
18
|
+
| Tool | Use when |
|
|
19
|
+
|------|----------|
|
|
20
|
+
| `laravel_models` | Understanding data model, relationships, and model structure before generating code |
|
|
21
|
+
| `laravel_routes` | Understanding existing routes, middleware, and controllers before adding new endpoints |
|
|
22
|
+
| `laravel_analyze` | Understanding project conventions and patterns before writing any code |
|
|
23
|
+
|
|
24
|
+
## Key Principle
|
|
25
|
+
|
|
26
|
+
Run `laravel_analyze` or `laravel_models` BEFORE generating Laravel code. Match the project's existing patterns — if it uses Actions, don't create Services. If controllers are invokable, don't create resource controllers.
|
|
27
|
+
|
|
28
|
+
## Common Laravel Patterns
|
|
29
|
+
|
|
30
|
+
| Pattern | Files | When used |
|
|
31
|
+
|---------|-------|-----------|
|
|
32
|
+
| Action classes | `app/Actions/` | Single-purpose business logic |
|
|
33
|
+
| Service classes | `app/Services/` | Complex business logic |
|
|
34
|
+
| Repository pattern | `app/Repositories/` | Database abstraction |
|
|
35
|
+
| Form Requests | `app/Http/Requests/` | Input validation |
|
|
36
|
+
| API Resources | `app/Http/Resources/` | Response transformation |
|
|
37
|
+
| Policies | `app/Policies/` | Authorization |
|
|
38
|
+
| Observers | `app/Observers/` | Model event hooks |
|
|
39
|
+
| Enums | `app/Enums/` | Type-safe constants (PHP 8.1+) |
|
|
40
|
+
|
|
41
|
+
## Eloquent Relationship Methods
|
|
42
|
+
|
|
43
|
+
| Method | Meaning |
|
|
44
|
+
|--------|---------|
|
|
45
|
+
| `hasOne` | One-to-one (parent side) |
|
|
46
|
+
| `belongsTo` | One-to-one/many inverse (child side) |
|
|
47
|
+
| `hasMany` | One-to-many (parent side) |
|
|
48
|
+
| `belongsToMany` | Many-to-many (either side, uses pivot table) |
|
|
49
|
+
| `hasOneThrough` | One-to-one through intermediate model |
|
|
50
|
+
| `hasManyThrough` | One-to-many through intermediate model |
|
|
51
|
+
| `morphOne` | Polymorphic one-to-one |
|
|
52
|
+
| `morphMany` | Polymorphic one-to-many |
|
|
53
|
+
| `morphToMany` | Polymorphic many-to-many |
|
|
54
|
+
| `morphTo` | Polymorphic inverse |
|
|
55
|
+
|
|
56
|
+
## Migration Best Practices
|
|
57
|
+
|
|
58
|
+
- Always create a migration with a new model
|
|
59
|
+
- Use `$table->foreignId('user_id')->constrained()->cascadeOnDelete()`
|
|
60
|
+
- Use `$table->timestamps()` on every table
|
|
61
|
+
- Name pivot tables alphabetically: `post_tag` not `tag_post`
|
|
62
|
+
- Add indexes on frequently queried columns
|
|
63
|
+
|
|
64
|
+
## Testing Conventions
|
|
65
|
+
|
|
66
|
+
- Use Pest for new Laravel 11+ projects
|
|
67
|
+
- One test file per feature or model
|
|
68
|
+
- Use factories for test data, never manual inserts
|
|
69
|
+
- Name test methods descriptively: `it('creates a post with valid data')`
|
|
70
|
+
|
|
71
|
+
## Feature Generation Checklist
|
|
72
|
+
|
|
73
|
+
When adding a new feature, generate in this order:
|
|
74
|
+
1. Migration
|
|
75
|
+
2. Model (with relationships, casts, fillable)
|
|
76
|
+
3. Factory
|
|
77
|
+
4. Form Request (validation)
|
|
78
|
+
5. Controller or Action
|
|
79
|
+
6. API Resource (if API)
|
|
80
|
+
7. Routes
|
|
81
|
+
8. Policy (authorization)
|
|
82
|
+
9. Tests
|