@elyracode/doctor 0.4.4

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 ADDED
@@ -0,0 +1,63 @@
1
+ # @elyracode/doctor
2
+
3
+ Project health analysis for Elyra -- security audit, dependency check, code quality, and more.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ elyra install npm:@elyracode/doctor
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ - `/doctor` -- Run all health checks and send the report to the agent for analysis
14
+
15
+ ## Tools
16
+
17
+ | Tool | Description |
18
+ |------|-------------|
19
+ | `project_health_check` | Run health checks. Can filter by category: security, dependencies, config, code-debt, code-quality, git, project |
20
+
21
+ ## Checks
22
+
23
+ | Check | What it does |
24
+ |-------|-------------|
25
+ | **Security** | Runs `npm audit` and `composer audit` for known vulnerabilities |
26
+ | **Dependencies** | Checks for outdated npm packages |
27
+ | **Configuration** | Compares .env with .env.example for missing keys |
28
+ | **Code Debt** | Scans for TODO, FIXME, HACK, WORKAROUND comments |
29
+ | **Code Quality** | Finds source files over 500 lines |
30
+ | **Git** | Reports uncommitted changes and untracked files |
31
+ | **Project** | Checks for essential files (.gitignore, README, configs) |
32
+
33
+ ## Usage
34
+
35
+ ```
36
+ > Run a health check on this project
37
+ > Are there any security vulnerabilities?
38
+ > How many TODO comments are in the codebase?
39
+ > Check if my .env is missing any keys
40
+ ```
41
+
42
+ The agent runs the checks automatically and suggests fixes based on findings.
43
+
44
+ ## Report Format
45
+
46
+ ```
47
+ # Project Health Report
48
+
49
+ Errors: 1 | Warnings: 3 | Info: 5
50
+
51
+ ## Errors
52
+ - [Security] 2 critical/high npm vulnerabilities found
53
+
54
+ ## Warnings
55
+ - [Dependencies] 12 outdated npm packages
56
+ - [Code Debt] Found 8 debt markers: 5 TODO, 2 FIXME, 1 HACK
57
+ - [Code Quality] 3 files over 500 lines
58
+
59
+ ## Info
60
+ - [Configuration] All .env.example keys present in .env
61
+ - [Git] Working tree clean
62
+ - [Project] All essential files present
63
+ ```
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Individual health checks for elyra doctor.
3
+ * Each check runs without LLM -- pure file/process analysis.
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { join, relative } from "node:path";
9
+
10
+ export interface Finding {
11
+ severity: "error" | "warning" | "info";
12
+ category: string;
13
+ message: string;
14
+ file?: string;
15
+ }
16
+
17
+ // ── Check: Security audit (npm/composer) ──
18
+
19
+ export function checkSecurityAudit(cwd: string): Finding[] {
20
+ const findings: Finding[] = [];
21
+
22
+ // npm audit
23
+ if (existsSync(join(cwd, "package.json"))) {
24
+ try {
25
+ const result = execSync("npm audit --json 2>/dev/null", {
26
+ cwd,
27
+ timeout: 30000,
28
+ encoding: "utf-8",
29
+ });
30
+ const audit = JSON.parse(result);
31
+ const vulns = audit.metadata?.vulnerabilities ?? {};
32
+ const critical = (vulns.critical ?? 0) + (vulns.high ?? 0);
33
+ const moderate = vulns.moderate ?? 0;
34
+ const low = vulns.low ?? 0;
35
+ if (critical > 0) {
36
+ findings.push({ severity: "error", category: "Security", message: `${critical} critical/high npm vulnerabilities found` });
37
+ }
38
+ if (moderate > 0) {
39
+ findings.push({ severity: "warning", category: "Security", message: `${moderate} moderate npm vulnerabilities found` });
40
+ }
41
+ if (low > 0) {
42
+ findings.push({ severity: "info", category: "Security", message: `${low} low npm vulnerabilities found` });
43
+ }
44
+ } catch {
45
+ // npm audit returns non-zero when vulnerabilities exist
46
+ try {
47
+ const result = execSync("npm audit --json 2>/dev/null || true", {
48
+ cwd,
49
+ timeout: 30000,
50
+ encoding: "utf-8",
51
+ });
52
+ if (result.includes('"critical"') || result.includes('"high"')) {
53
+ findings.push({ severity: "error", category: "Security", message: "npm security vulnerabilities detected. Run `npm audit` for details." });
54
+ }
55
+ } catch {
56
+ // Skip if npm audit fails entirely
57
+ }
58
+ }
59
+ }
60
+
61
+ // composer audit
62
+ if (existsSync(join(cwd, "composer.json"))) {
63
+ try {
64
+ execSync("composer audit --format=json 2>/dev/null", {
65
+ cwd,
66
+ timeout: 30000,
67
+ encoding: "utf-8",
68
+ });
69
+ } catch (error) {
70
+ if (error && typeof error === "object" && "status" in error && (error as { status: unknown }).status !== 0) {
71
+ findings.push({ severity: "warning", category: "Security", message: "Composer security vulnerabilities detected. Run `composer audit` for details." });
72
+ }
73
+ }
74
+ }
75
+
76
+ if (findings.length === 0 && (existsSync(join(cwd, "package.json")) || existsSync(join(cwd, "composer.json")))) {
77
+ findings.push({ severity: "info", category: "Security", message: "No known vulnerabilities found" });
78
+ }
79
+
80
+ return findings;
81
+ }
82
+
83
+ // ── Check: Outdated dependencies ──
84
+
85
+ export function checkOutdatedDeps(cwd: string): Finding[] {
86
+ const findings: Finding[] = [];
87
+
88
+ if (existsSync(join(cwd, "package.json"))) {
89
+ try {
90
+ const result = execSync("npm outdated --json 2>/dev/null || true", {
91
+ cwd,
92
+ timeout: 30000,
93
+ encoding: "utf-8",
94
+ });
95
+ const outdated = JSON.parse(result || "{}");
96
+ const count = Object.keys(outdated).length;
97
+ if (count > 10) {
98
+ findings.push({ severity: "warning", category: "Dependencies", message: `${count} outdated npm packages. Run \`npm outdated\` for details.` });
99
+ } else if (count > 0) {
100
+ findings.push({ severity: "info", category: "Dependencies", message: `${count} outdated npm packages` });
101
+ } else {
102
+ findings.push({ severity: "info", category: "Dependencies", message: "All npm packages are up to date" });
103
+ }
104
+ } catch {
105
+ // Skip
106
+ }
107
+ }
108
+
109
+ return findings;
110
+ }
111
+
112
+ // ── Check: Missing .env keys ──
113
+
114
+ export function checkEnvFile(cwd: string): Finding[] {
115
+ const findings: Finding[] = [];
116
+ const examplePath = join(cwd, ".env.example");
117
+ const envPath = join(cwd, ".env");
118
+
119
+ if (!existsSync(examplePath)) return findings;
120
+
121
+ if (!existsSync(envPath)) {
122
+ findings.push({ severity: "error", category: "Configuration", message: ".env file is missing (but .env.example exists)" });
123
+ return findings;
124
+ }
125
+
126
+ const exampleKeys = parseEnvKeys(readFileSync(examplePath, "utf-8"));
127
+ const envKeys = parseEnvKeys(readFileSync(envPath, "utf-8"));
128
+ const missing = exampleKeys.filter((k) => !envKeys.includes(k));
129
+
130
+ if (missing.length > 0) {
131
+ findings.push({
132
+ severity: "warning",
133
+ category: "Configuration",
134
+ message: `${missing.length} keys in .env.example missing from .env: ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ` (+${missing.length - 5} more)` : ""}`,
135
+ });
136
+ } else {
137
+ findings.push({ severity: "info", category: "Configuration", message: "All .env.example keys present in .env" });
138
+ }
139
+
140
+ return findings;
141
+ }
142
+
143
+ function parseEnvKeys(content: string): string[] {
144
+ return content
145
+ .split("\n")
146
+ .map((l) => l.trim())
147
+ .filter((l) => l && !l.startsWith("#"))
148
+ .map((l) => l.split("=")[0].trim())
149
+ .filter((k) => k.length > 0);
150
+ }
151
+
152
+ // ── Check: TODO/FIXME/HACK comments ──
153
+
154
+ export function checkCodeDebt(cwd: string): Finding[] {
155
+ const findings: Finding[] = [];
156
+
157
+ try {
158
+ const result = execSync(
159
+ "grep -rn --include='*.php' --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='*.vue' --include='*.blade.php' -E '(TODO|FIXME|HACK|XXX|WORKAROUND):?' . 2>/dev/null | head -100 || true",
160
+ { cwd, timeout: 15000, encoding: "utf-8" },
161
+ );
162
+ const lines = result.trim().split("\n").filter((l) => l.length > 0);
163
+ if (lines.length > 0) {
164
+ const todoCount = lines.filter((l) => /TODO/i.test(l)).length;
165
+ const fixmeCount = lines.filter((l) => /FIXME/i.test(l)).length;
166
+ const hackCount = lines.filter((l) => /HACK|WORKAROUND/i.test(l)).length;
167
+ const parts: string[] = [];
168
+ if (todoCount > 0) parts.push(`${todoCount} TODO`);
169
+ if (fixmeCount > 0) parts.push(`${fixmeCount} FIXME`);
170
+ if (hackCount > 0) parts.push(`${hackCount} HACK/WORKAROUND`);
171
+ findings.push({
172
+ severity: hackCount > 0 || fixmeCount > 0 ? "warning" : "info",
173
+ category: "Code Debt",
174
+ message: `Found ${lines.length} debt markers: ${parts.join(", ")}`,
175
+ });
176
+ } else {
177
+ findings.push({ severity: "info", category: "Code Debt", message: "No TODO/FIXME/HACK comments found" });
178
+ }
179
+ } catch {
180
+ // Skip
181
+ }
182
+
183
+ return findings;
184
+ }
185
+
186
+ // ── Check: Large files ──
187
+
188
+ export function checkLargeFiles(cwd: string): Finding[] {
189
+ const findings: Finding[] = [];
190
+ const threshold = 500; // lines
191
+ const largeFiles: string[] = [];
192
+
193
+ function scan(dir: string, depth: number): void {
194
+ if (depth > 5) return;
195
+ try {
196
+ for (const entry of readdirSync(dir)) {
197
+ if (entry.startsWith(".") || entry === "node_modules" || entry === "vendor" || entry === "dist" || entry === "build") continue;
198
+ const fullPath = join(dir, entry);
199
+ const stat = statSync(fullPath);
200
+ if (stat.isDirectory()) {
201
+ scan(fullPath, depth + 1);
202
+ } else if (stat.isFile() && /\.(php|ts|tsx|js|jsx|vue)$/.test(entry)) {
203
+ const content = readFileSync(fullPath, "utf-8");
204
+ const lineCount = content.split("\n").length;
205
+ if (lineCount > threshold) {
206
+ largeFiles.push(`${relative(cwd, fullPath)} (${lineCount} lines)`);
207
+ }
208
+ }
209
+ }
210
+ } catch {
211
+ // Permission errors etc
212
+ }
213
+ }
214
+
215
+ scan(cwd, 0);
216
+
217
+ if (largeFiles.length > 0) {
218
+ findings.push({
219
+ severity: "warning",
220
+ category: "Code Quality",
221
+ message: `${largeFiles.length} files over ${threshold} lines: ${largeFiles.slice(0, 5).join(", ")}${largeFiles.length > 5 ? ` (+${largeFiles.length - 5} more)` : ""}`,
222
+ });
223
+ } else {
224
+ findings.push({ severity: "info", category: "Code Quality", message: `No source files over ${threshold} lines` });
225
+ }
226
+
227
+ return findings;
228
+ }
229
+
230
+ // ── Check: Git status ──
231
+
232
+ export function checkGitStatus(cwd: string): Finding[] {
233
+ const findings: Finding[] = [];
234
+
235
+ if (!existsSync(join(cwd, ".git"))) {
236
+ findings.push({ severity: "warning", category: "Git", message: "Not a git repository" });
237
+ return findings;
238
+ }
239
+
240
+ try {
241
+ const status = execSync("git status --porcelain 2>/dev/null", { cwd, timeout: 10000, encoding: "utf-8" });
242
+ const lines = status.trim().split("\n").filter((l) => l.length > 0);
243
+ const untracked = lines.filter((l) => l.startsWith("??")).length;
244
+ const modified = lines.filter((l) => !l.startsWith("??")).length;
245
+
246
+ if (modified > 0) {
247
+ findings.push({ severity: "info", category: "Git", message: `${modified} uncommitted change${modified > 1 ? "s" : ""}` });
248
+ }
249
+ if (untracked > 0) {
250
+ findings.push({ severity: "info", category: "Git", message: `${untracked} untracked file${untracked > 1 ? "s" : ""}` });
251
+ }
252
+ if (modified === 0 && untracked === 0) {
253
+ findings.push({ severity: "info", category: "Git", message: "Working tree clean" });
254
+ }
255
+ } catch {
256
+ // Skip
257
+ }
258
+
259
+ return findings;
260
+ }
261
+
262
+ // ── Check: Missing project essentials ──
263
+
264
+ export function checkProjectEssentials(cwd: string): Finding[] {
265
+ const findings: Finding[] = [];
266
+
267
+ const checks: Array<{ file: string; label: string; severity: Finding["severity"] }> = [
268
+ { file: ".gitignore", label: ".gitignore", severity: "warning" },
269
+ { file: "README.md", label: "README.md", severity: "warning" },
270
+ { file: ".env.example", label: ".env.example", severity: "info" },
271
+ ];
272
+
273
+ // Laravel-specific
274
+ if (existsSync(join(cwd, "artisan"))) {
275
+ checks.push(
276
+ { file: "phpunit.xml", label: "PHPUnit config (phpunit.xml)", severity: "info" },
277
+ { file: "database/migrations", label: "Database migrations", severity: "info" },
278
+ );
279
+ }
280
+
281
+ // Node-specific
282
+ if (existsSync(join(cwd, "package.json"))) {
283
+ checks.push(
284
+ { file: "tsconfig.json", label: "TypeScript config", severity: "info" },
285
+ );
286
+ }
287
+
288
+ for (const check of checks) {
289
+ if (!existsSync(join(cwd, check.file))) {
290
+ findings.push({ severity: check.severity, category: "Project", message: `Missing ${check.label}` });
291
+ }
292
+ }
293
+
294
+ if (findings.length === 0) {
295
+ findings.push({ severity: "info", category: "Project", message: "All essential files present" });
296
+ }
297
+
298
+ return findings;
299
+ }
300
+
301
+ // ── Run all checks ──
302
+
303
+ export function runAllChecks(cwd: string): Finding[] {
304
+ return [
305
+ ...checkSecurityAudit(cwd),
306
+ ...checkOutdatedDeps(cwd),
307
+ ...checkEnvFile(cwd),
308
+ ...checkCodeDebt(cwd),
309
+ ...checkLargeFiles(cwd),
310
+ ...checkGitStatus(cwd),
311
+ ...checkProjectEssentials(cwd),
312
+ ];
313
+ }
@@ -0,0 +1,111 @@
1
+ import type { ExtensionAPI } from "@elyracode/coding-agent";
2
+ import { Type } from "typebox";
3
+ import { type Finding, runAllChecks } from "./checks.js";
4
+
5
+ export default function (elyra: ExtensionAPI): void {
6
+ // ── Command: /doctor ──
7
+ elyra.registerCommand("doctor", {
8
+ description: "Run project health analysis",
9
+ handler: async (_args: string, ctx) => {
10
+ ctx.ui.notify("Running health checks...");
11
+
12
+ const cwd = process.cwd();
13
+ const findings = runAllChecks(cwd);
14
+ const report = formatReport(findings);
15
+
16
+ elyra.sendUserMessage(
17
+ `Here is the project health report. Analyze the findings and suggest the most important fixes:\n\n${report}`,
18
+ );
19
+ },
20
+ });
21
+
22
+ // ── Tool: project_health_check ──
23
+ elyra.registerTool({
24
+ name: "project_health_check",
25
+ label: "Project Health Check",
26
+ description:
27
+ "Analyze the current project for security vulnerabilities, outdated dependencies, " +
28
+ "missing configuration, code debt (TODO/FIXME/HACK), large files, and git status. " +
29
+ "Use this when the user asks about project health, code quality, or wants an audit.",
30
+ parameters: Type.Object({
31
+ category: Type.Optional(
32
+ Type.Union(
33
+ [
34
+ Type.Literal("all"),
35
+ Type.Literal("security"),
36
+ Type.Literal("dependencies"),
37
+ Type.Literal("config"),
38
+ Type.Literal("code-debt"),
39
+ Type.Literal("code-quality"),
40
+ Type.Literal("git"),
41
+ Type.Literal("project"),
42
+ ],
43
+ { description: "Check category to run (default: all)" },
44
+ ),
45
+ ),
46
+ }),
47
+ execute: async (_toolCallId, params) => {
48
+ const cwd = process.cwd();
49
+ const findings = runAllChecks(cwd);
50
+
51
+ const filtered =
52
+ !params.category || params.category === "all"
53
+ ? findings
54
+ : findings.filter((f) => f.category.toLowerCase().replace(/\s+/g, "-") === params.category);
55
+
56
+ const report = formatReport(filtered);
57
+
58
+ return {
59
+ content: [{ type: "text", text: report }],
60
+ details: {
61
+ errors: filtered.filter((f) => f.severity === "error").length,
62
+ warnings: filtered.filter((f) => f.severity === "warning").length,
63
+ info: filtered.filter((f) => f.severity === "info").length,
64
+ },
65
+ };
66
+ },
67
+ });
68
+ }
69
+
70
+ function formatReport(findings: Finding[]): string {
71
+ const errors = findings.filter((f) => f.severity === "error");
72
+ const warnings = findings.filter((f) => f.severity === "warning");
73
+ const infos = findings.filter((f) => f.severity === "info");
74
+
75
+ const lines: string[] = [
76
+ "# Project Health Report",
77
+ "",
78
+ `Errors: ${errors.length} | Warnings: ${warnings.length} | Info: ${infos.length}`,
79
+ "",
80
+ ];
81
+
82
+ if (errors.length > 0) {
83
+ lines.push("## Errors");
84
+ for (const f of errors) {
85
+ lines.push(`- [${f.category}] ${f.message}${f.file ? ` (${f.file})` : ""}`);
86
+ }
87
+ lines.push("");
88
+ }
89
+
90
+ if (warnings.length > 0) {
91
+ lines.push("## Warnings");
92
+ for (const f of warnings) {
93
+ lines.push(`- [${f.category}] ${f.message}${f.file ? ` (${f.file})` : ""}`);
94
+ }
95
+ lines.push("");
96
+ }
97
+
98
+ if (infos.length > 0) {
99
+ lines.push("## Info");
100
+ for (const f of infos) {
101
+ lines.push(`- [${f.category}] ${f.message}${f.file ? ` (${f.file})` : ""}`);
102
+ }
103
+ lines.push("");
104
+ }
105
+
106
+ if (findings.length === 0) {
107
+ lines.push("No findings. Project looks healthy.");
108
+ }
109
+
110
+ return lines.join("\n");
111
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@elyracode/doctor",
3
+ "version": "0.4.4",
4
+ "description": "Elyra extension for project health analysis -- dependency audit, security checks, code quality, and more",
5
+ "type": "module",
6
+ "keywords": [
7
+ "elyra-package",
8
+ "doctor",
9
+ "health-check",
10
+ "audit",
11
+ "code-quality"
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/doctor"
19
+ },
20
+ "elyra": {
21
+ "extensions": [
22
+ "./extensions/index.ts"
23
+ ]
24
+ },
25
+ "peerDependencies": {
26
+ "@elyracode/coding-agent": "*",
27
+ "typebox": "*"
28
+ },
29
+ "scripts": {
30
+ "clean": "echo 'nothing to clean'",
31
+ "build": "echo 'nothing to build'",
32
+ "check": "echo 'nothing to check'"
33
+ }
34
+ }