@qodfy/core 0.1.1 → 0.1.2
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/dist/index.d.ts +1 -0
- package/dist/index.js +256 -54
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,56 +1,98 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import path from "path";
|
|
3
2
|
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
4
|
import fg from "fast-glob";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
5
|
+
var sourceFilePatterns = ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"];
|
|
6
|
+
var ignoredPaths = [
|
|
7
|
+
"node_modules/**",
|
|
8
|
+
".next/**",
|
|
9
|
+
"dist/**",
|
|
10
|
+
"build/**",
|
|
11
|
+
".turbo/**",
|
|
12
|
+
".vercel/**"
|
|
13
|
+
];
|
|
14
|
+
var aiKeywords = [
|
|
15
|
+
"openai",
|
|
16
|
+
"@ai-sdk",
|
|
17
|
+
"ai/react",
|
|
18
|
+
"anthropic",
|
|
19
|
+
"gemini",
|
|
20
|
+
"generateText",
|
|
21
|
+
"streamText",
|
|
22
|
+
"useChat"
|
|
23
|
+
];
|
|
17
24
|
async function scanProject(projectPath) {
|
|
25
|
+
const resolvedProjectPath = path.resolve(projectPath);
|
|
18
26
|
const issues = [];
|
|
19
|
-
const packageJsonPath = path.join(
|
|
27
|
+
const packageJsonPath = path.join(resolvedProjectPath, "package.json");
|
|
20
28
|
const hasPackageJson = await fileExists(packageJsonPath);
|
|
29
|
+
let isNextProject = false;
|
|
21
30
|
if (!hasPackageJson) {
|
|
22
31
|
issues.push({
|
|
23
32
|
severity: "critical",
|
|
24
33
|
title: "Missing package.json",
|
|
25
|
-
message: "Qodfy could not find a package.json file in this project."
|
|
34
|
+
message: "Qodfy could not find a package.json file in this project.",
|
|
35
|
+
suggestion: "Run Qodfy from the project root or pass --path to the app folder."
|
|
26
36
|
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const packageJson = await readJson(packageJsonPath);
|
|
31
|
-
const deps = {
|
|
32
|
-
...packageJson.dependencies,
|
|
33
|
-
...packageJson.devDependencies
|
|
34
|
-
};
|
|
35
|
-
isNextProject = Boolean(deps.next);
|
|
36
|
-
if (!isNextProject) {
|
|
37
|
+
} else {
|
|
38
|
+
const packageJsonResult = await safeReadJson(packageJsonPath);
|
|
39
|
+
if (!packageJsonResult.ok) {
|
|
37
40
|
issues.push({
|
|
38
|
-
severity: "
|
|
39
|
-
title: "
|
|
40
|
-
message:
|
|
41
|
+
severity: "critical",
|
|
42
|
+
title: "Could not read package.json",
|
|
43
|
+
message: packageJsonResult.reason,
|
|
44
|
+
file: "package.json",
|
|
45
|
+
suggestion: "Fix package.json so Qodfy can detect the framework and dependencies."
|
|
46
|
+
});
|
|
47
|
+
} else if (!isPackageJsonObject(packageJsonResult.data)) {
|
|
48
|
+
issues.push({
|
|
49
|
+
severity: "critical",
|
|
50
|
+
title: "Invalid package.json",
|
|
51
|
+
message: "package.json must contain a JSON object at the top level.",
|
|
52
|
+
file: "package.json",
|
|
53
|
+
suggestion: "Fix package.json so Qodfy can detect the framework and dependencies."
|
|
41
54
|
});
|
|
55
|
+
} else {
|
|
56
|
+
const deps = {
|
|
57
|
+
...packageJsonResult.data.dependencies,
|
|
58
|
+
...packageJsonResult.data.devDependencies
|
|
59
|
+
};
|
|
60
|
+
isNextProject = Boolean(deps.next);
|
|
61
|
+
if (!isNextProject) {
|
|
62
|
+
issues.push({
|
|
63
|
+
severity: "warning",
|
|
64
|
+
title: "Next.js not detected",
|
|
65
|
+
message: "This first version of Qodfy is optimized for Next.js projects.",
|
|
66
|
+
suggestion: "If this is a monorepo, scan the Next.js app folder directly."
|
|
67
|
+
});
|
|
68
|
+
}
|
|
42
69
|
}
|
|
43
70
|
}
|
|
44
|
-
const envExamplePath = path.join(
|
|
71
|
+
const envExamplePath = path.join(resolvedProjectPath, ".env.example");
|
|
45
72
|
const hasEnvExample = await fileExists(envExamplePath);
|
|
73
|
+
let envExampleVariables = null;
|
|
46
74
|
if (!hasEnvExample) {
|
|
47
75
|
issues.push({
|
|
48
76
|
severity: "warning",
|
|
49
77
|
title: "Missing .env.example",
|
|
50
|
-
message: "Add a .env.example file so future developers know which environment variables are required."
|
|
78
|
+
message: "Add a .env.example file so future developers know which environment variables are required.",
|
|
79
|
+
suggestion: "Document required variable names only, never real secret values."
|
|
51
80
|
});
|
|
81
|
+
} else {
|
|
82
|
+
const envExampleResult = await safeReadFile(envExamplePath);
|
|
83
|
+
if (!envExampleResult.ok) {
|
|
84
|
+
issues.push({
|
|
85
|
+
severity: "warning",
|
|
86
|
+
title: "Could not read .env.example",
|
|
87
|
+
message: envExampleResult.reason,
|
|
88
|
+
file: ".env.example",
|
|
89
|
+
suggestion: "Make sure .env.example is readable and contains variable names without real secret values."
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
envExampleVariables = getEnvExampleVariables(envExampleResult.content);
|
|
93
|
+
}
|
|
52
94
|
}
|
|
53
|
-
const hasReadme = await fileExists(path.join(
|
|
95
|
+
const hasReadme = await fileExists(path.join(resolvedProjectPath, "README.md"));
|
|
54
96
|
if (!hasReadme) {
|
|
55
97
|
issues.push({
|
|
56
98
|
severity: "info",
|
|
@@ -58,35 +100,38 @@ async function scanProject(projectPath) {
|
|
|
58
100
|
message: "A README helps other developers understand how to run and maintain the project."
|
|
59
101
|
});
|
|
60
102
|
}
|
|
61
|
-
const files = await
|
|
62
|
-
cwd: projectPath,
|
|
63
|
-
ignore: ["node_modules/**", ".next/**", "dist/**", "build/**"],
|
|
64
|
-
absolute: true
|
|
65
|
-
});
|
|
103
|
+
const files = await getSourceFiles(resolvedProjectPath, issues);
|
|
66
104
|
const apiRoutes = files.filter((file) => {
|
|
67
105
|
return file.includes(`${path.sep}app${path.sep}api${path.sep}`) || file.includes(`${path.sep}pages${path.sep}api${path.sep}`);
|
|
68
106
|
});
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"anthropic",
|
|
74
|
-
"gemini",
|
|
75
|
-
"generateText",
|
|
76
|
-
"streamText"
|
|
77
|
-
];
|
|
107
|
+
const apiRouteSet = new Set(apiRoutes);
|
|
108
|
+
const readableFiles = /* @__PURE__ */ new Map();
|
|
109
|
+
const envExampleWarningKeys = /* @__PURE__ */ new Set();
|
|
110
|
+
const clientSecretWarningKeys = /* @__PURE__ */ new Set();
|
|
78
111
|
let aiFiles = 0;
|
|
79
112
|
let largeFiles = 0;
|
|
80
113
|
for (const file of files) {
|
|
81
|
-
const
|
|
82
|
-
const relativeFile = path.relative(
|
|
114
|
+
const fileResult = await safeReadFile(file);
|
|
115
|
+
const relativeFile = normalizePath(path.relative(resolvedProjectPath, file));
|
|
116
|
+
if (!fileResult.ok) {
|
|
117
|
+
issues.push({
|
|
118
|
+
severity: "info",
|
|
119
|
+
title: "File could not be read",
|
|
120
|
+
message: fileResult.reason,
|
|
121
|
+
file: relativeFile
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const content = fileResult.content;
|
|
126
|
+
readableFiles.set(file, content);
|
|
83
127
|
if (content.length > 15e3) {
|
|
84
128
|
largeFiles++;
|
|
85
129
|
issues.push({
|
|
86
130
|
severity: "info",
|
|
87
131
|
title: "Large file detected",
|
|
88
132
|
message: "Large files are harder to maintain and often appear in AI-generated codebases.",
|
|
89
|
-
file: relativeFile
|
|
133
|
+
file: relativeFile,
|
|
134
|
+
suggestion: "Consider splitting this file into smaller modules if it mixes unrelated responsibilities."
|
|
90
135
|
});
|
|
91
136
|
}
|
|
92
137
|
const usesAI = aiKeywords.some(
|
|
@@ -95,26 +140,68 @@ async function scanProject(projectPath) {
|
|
|
95
140
|
if (usesAI) {
|
|
96
141
|
aiFiles++;
|
|
97
142
|
const hasRateLimit = content.includes("rateLimit") || content.includes("ratelimit") || content.includes("upstash") || content.includes("limiter");
|
|
98
|
-
if (!hasRateLimit) {
|
|
143
|
+
if (apiRouteSet.has(file) && !hasRateLimit) {
|
|
99
144
|
issues.push({
|
|
100
145
|
severity: "critical",
|
|
101
146
|
title: "AI route may be missing rate limiting",
|
|
102
147
|
message: "AI routes can create real API costs. Add rate limiting or usage limits before launch.",
|
|
103
|
-
file: relativeFile
|
|
148
|
+
file: relativeFile,
|
|
149
|
+
suggestion: "Add rate limiting, usage limits, or per-user quotas before launch."
|
|
104
150
|
});
|
|
105
151
|
}
|
|
106
152
|
}
|
|
153
|
+
const usedEnvVariables = getUsedEnvVariables(content);
|
|
154
|
+
if (envExampleVariables) {
|
|
155
|
+
for (const variableName of usedEnvVariables) {
|
|
156
|
+
if (!envExampleVariables.has(variableName)) {
|
|
157
|
+
const warningKey = `${relativeFile}:${variableName}`;
|
|
158
|
+
if (envExampleWarningKeys.has(warningKey)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
envExampleWarningKeys.add(warningKey);
|
|
162
|
+
issues.push({
|
|
163
|
+
severity: "warning",
|
|
164
|
+
title: "Environment variable missing from .env.example",
|
|
165
|
+
message: `${variableName} is used in ${relativeFile} but is not documented in .env.example.`,
|
|
166
|
+
file: relativeFile,
|
|
167
|
+
suggestion: `Add ${variableName}= to .env.example without including a real value.`
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (isClientSideFile(relativeFile, content)) {
|
|
173
|
+
for (const variableName of usedEnvVariables) {
|
|
174
|
+
if (!variableName.startsWith("NEXT_PUBLIC_")) {
|
|
175
|
+
const warningKey = `${relativeFile}:${variableName}`;
|
|
176
|
+
if (clientSecretWarningKeys.has(warningKey)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
clientSecretWarningKeys.add(warningKey);
|
|
180
|
+
issues.push({
|
|
181
|
+
severity: "warning",
|
|
182
|
+
title: "Possible server secret used in client-side code",
|
|
183
|
+
message: `${variableName} appears in a client-side file. Server secrets should not be exposed to the browser.`,
|
|
184
|
+
file: relativeFile,
|
|
185
|
+
suggestion: "Move server-only environment variable access to a server component, API route, or server action."
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
107
190
|
}
|
|
108
191
|
for (const route of apiRoutes) {
|
|
109
|
-
const content =
|
|
110
|
-
|
|
192
|
+
const content = readableFiles.get(route);
|
|
193
|
+
if (!content) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const relativeFile = normalizePath(path.relative(resolvedProjectPath, route));
|
|
111
197
|
const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
|
|
112
198
|
if (!hasAuth) {
|
|
113
199
|
issues.push({
|
|
114
200
|
severity: "warning",
|
|
115
201
|
title: "API route may be missing authentication",
|
|
116
202
|
message: "This API route does not appear to contain an auth/session check.",
|
|
117
|
-
file: relativeFile
|
|
203
|
+
file: relativeFile,
|
|
204
|
+
suggestion: "Confirm the route is public, or add an auth/session check before handling user data."
|
|
118
205
|
});
|
|
119
206
|
}
|
|
120
207
|
}
|
|
@@ -122,7 +209,7 @@ async function scanProject(projectPath) {
|
|
|
122
209
|
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
|
|
123
210
|
const score = Math.max(0, 100 - criticalCount * 20 - warningCount * 8);
|
|
124
211
|
return {
|
|
125
|
-
projectPath,
|
|
212
|
+
projectPath: resolvedProjectPath,
|
|
126
213
|
isNextProject,
|
|
127
214
|
score,
|
|
128
215
|
issues,
|
|
@@ -134,6 +221,121 @@ async function scanProject(projectPath) {
|
|
|
134
221
|
}
|
|
135
222
|
};
|
|
136
223
|
}
|
|
224
|
+
async function fileExists(filePath) {
|
|
225
|
+
try {
|
|
226
|
+
await fs.access(filePath);
|
|
227
|
+
return true;
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function safeReadFile(filePath) {
|
|
233
|
+
try {
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
content: await fs.readFile(filePath, "utf-8")
|
|
237
|
+
};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const code = getErrorCode(error);
|
|
240
|
+
if (code === "ENOENT") {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
code,
|
|
244
|
+
reason: "The file disappeared while Qodfy was scanning it."
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
code,
|
|
251
|
+
reason: "Qodfy does not have permission to read this file."
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
code,
|
|
257
|
+
reason: "Qodfy could not read this file."
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function safeReadJson(filePath) {
|
|
262
|
+
const fileResult = await safeReadFile(filePath);
|
|
263
|
+
if (!fileResult.ok) {
|
|
264
|
+
return fileResult;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
return {
|
|
268
|
+
ok: true,
|
|
269
|
+
data: JSON.parse(fileResult.content)
|
|
270
|
+
};
|
|
271
|
+
} catch {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
reason: "package.json is not valid JSON."
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function getSourceFiles(projectPath, issues) {
|
|
279
|
+
try {
|
|
280
|
+
return await fg(sourceFilePatterns, {
|
|
281
|
+
cwd: projectPath,
|
|
282
|
+
ignore: ignoredPaths,
|
|
283
|
+
absolute: true,
|
|
284
|
+
onlyFiles: true,
|
|
285
|
+
dot: false
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
issues.push({
|
|
289
|
+
severity: "critical",
|
|
290
|
+
title: "Could not scan source files",
|
|
291
|
+
message: "Qodfy could not list source files in this project.",
|
|
292
|
+
suggestion: "Check that the project path exists and is readable."
|
|
293
|
+
});
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function getEnvExampleVariables(content) {
|
|
298
|
+
const variables = /* @__PURE__ */ new Set();
|
|
299
|
+
for (const line of content.split(/\r?\n/)) {
|
|
300
|
+
const trimmedLine = line.trim();
|
|
301
|
+
if (!trimmedLine || trimmedLine.startsWith("#")) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const match = trimmedLine.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|$)/);
|
|
305
|
+
if (match) {
|
|
306
|
+
variables.add(match[1]);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return variables;
|
|
310
|
+
}
|
|
311
|
+
function getUsedEnvVariables(content) {
|
|
312
|
+
const variables = /* @__PURE__ */ new Set();
|
|
313
|
+
const dotAccessPattern = /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
314
|
+
const bracketAccessPattern = /\bprocess\.env\[['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\]/g;
|
|
315
|
+
for (const match of content.matchAll(dotAccessPattern)) {
|
|
316
|
+
variables.add(match[1]);
|
|
317
|
+
}
|
|
318
|
+
for (const match of content.matchAll(bracketAccessPattern)) {
|
|
319
|
+
variables.add(match[1]);
|
|
320
|
+
}
|
|
321
|
+
return variables;
|
|
322
|
+
}
|
|
323
|
+
function isClientSideFile(relativeFile, content) {
|
|
324
|
+
const fileName = path.basename(relativeFile);
|
|
325
|
+
return fileName.includes(".client.") || /(^|\n)\s*["']use client["'];?/.test(content);
|
|
326
|
+
}
|
|
327
|
+
function isPackageJsonObject(data) {
|
|
328
|
+
return typeof data === "object" && data !== null && !Array.isArray(data);
|
|
329
|
+
}
|
|
330
|
+
function normalizePath(filePath) {
|
|
331
|
+
return filePath.split(path.sep).join("/");
|
|
332
|
+
}
|
|
333
|
+
function getErrorCode(error) {
|
|
334
|
+
if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
|
|
335
|
+
return error.code;
|
|
336
|
+
}
|
|
337
|
+
return void 0;
|
|
338
|
+
}
|
|
137
339
|
export {
|
|
138
340
|
scanProject
|
|
139
341
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qodfy/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Scanner engine for Qodfy, an open-source launch readiness scanner for AI-built apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"qodfy",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
|
-
"dist"
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md"
|
|
37
38
|
],
|
|
38
39
|
"license": "MIT",
|
|
39
40
|
"engines": {
|