@qodfy/core 0.1.1 → 0.1.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/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ type Issue = {
4
4
  title: string;
5
5
  message: string;
6
6
  file?: string;
7
+ suggestion?: string;
7
8
  };
8
9
  type ScanReport = {
9
10
  projectPath: string;
@@ -15,6 +16,7 @@ type ScanReport = {
15
16
  apiRoutes: number;
16
17
  aiFiles: number;
17
18
  largeFiles: number;
19
+ durationMs: number;
18
20
  };
19
21
  };
20
22
  declare function scanProject(projectPath: string): Promise<ScanReport>;
package/dist/index.js CHANGED
@@ -1,56 +1,155 @@
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
- async function fileExists(filePath) {
6
- try {
7
- await fs.access(filePath);
8
- return true;
9
- } catch {
10
- return false;
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
+ "coverage/**",
14
+ "**/coverage/**",
15
+ ".cache/**",
16
+ "**/.cache/**",
17
+ ".output/**",
18
+ "**/.output/**",
19
+ ".open-next/**",
20
+ "**/.open-next/**",
21
+ "storybook-static/**",
22
+ "**/storybook-static/**",
23
+ "playwright-report/**",
24
+ "**/playwright-report/**",
25
+ "test-results/**",
26
+ "**/test-results/**",
27
+ "**/*.d.ts",
28
+ "**/*.map",
29
+ "generated/**",
30
+ "**/generated/**",
31
+ "__generated__/**",
32
+ "**/__generated__/**"
33
+ ];
34
+ var aiKeywords = [
35
+ "openai",
36
+ "@ai-sdk",
37
+ "ai/react",
38
+ "anthropic",
39
+ "gemini",
40
+ "generateText",
41
+ "streamText",
42
+ "useChat"
43
+ ];
44
+ var hardcodedSecretPatterns = [
45
+ {
46
+ label: "OpenAI API key",
47
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g
48
+ },
49
+ {
50
+ label: "Stripe secret key",
51
+ pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g
52
+ },
53
+ {
54
+ label: "Stripe webhook secret",
55
+ pattern: /\bwhsec_[A-Za-z0-9]{16,}\b/g
56
+ },
57
+ {
58
+ label: "GitHub token",
59
+ pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{30,}\b/g
60
+ },
61
+ {
62
+ label: "GitHub fine-grained token",
63
+ pattern: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g
64
+ },
65
+ {
66
+ label: "Google API key",
67
+ pattern: /\bAIza[A-Za-z0-9_-]{20,}\b/g
68
+ },
69
+ {
70
+ label: "Slack token",
71
+ pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g
72
+ },
73
+ {
74
+ label: "private key",
75
+ pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g
11
76
  }
12
- }
13
- async function readJson(filePath) {
14
- const content = await fs.readFile(filePath, "utf-8");
15
- return JSON.parse(content);
16
- }
77
+ ];
78
+ var LARGE_FILE_WARNING_BYTES = 15e3;
79
+ var MAX_FILE_SIZE_BYTES = 500 * 1024;
17
80
  async function scanProject(projectPath) {
81
+ const startTime = Date.now();
82
+ const resolvedProjectPath = path.resolve(projectPath);
18
83
  const issues = [];
19
- const packageJsonPath = path.join(projectPath, "package.json");
84
+ const packageJsonPath = path.join(resolvedProjectPath, "package.json");
20
85
  const hasPackageJson = await fileExists(packageJsonPath);
86
+ let isNextProject = false;
21
87
  if (!hasPackageJson) {
22
88
  issues.push({
23
89
  severity: "critical",
24
90
  title: "Missing package.json",
25
- message: "Qodfy could not find a package.json file in this project."
91
+ message: "Qodfy could not find a package.json file in this project.",
92
+ suggestion: "Run Qodfy from the project root or pass --path to the app folder."
26
93
  });
27
- }
28
- let isNextProject = false;
29
- if (hasPackageJson) {
30
- const packageJson = await readJson(packageJsonPath);
31
- const deps = {
32
- ...packageJson.dependencies,
33
- ...packageJson.devDependencies
34
- };
35
- isNextProject = Boolean(deps.next);
36
- if (!isNextProject) {
94
+ } else {
95
+ const packageJsonResult = await safeReadJson(packageJsonPath);
96
+ if (!packageJsonResult.ok) {
37
97
  issues.push({
38
- severity: "warning",
39
- title: "Next.js not detected",
40
- message: "This first version of Qodfy is optimized for Next.js projects."
98
+ severity: "critical",
99
+ title: "Could not read package.json",
100
+ message: packageJsonResult.reason,
101
+ file: "package.json",
102
+ suggestion: "Fix package.json so Qodfy can detect the framework and dependencies."
41
103
  });
104
+ } else if (!isPackageJsonObject(packageJsonResult.data)) {
105
+ issues.push({
106
+ severity: "critical",
107
+ title: "Invalid package.json",
108
+ message: "package.json must contain a JSON object at the top level.",
109
+ file: "package.json",
110
+ suggestion: "Fix package.json so Qodfy can detect the framework and dependencies."
111
+ });
112
+ } else {
113
+ const deps = {
114
+ ...packageJsonResult.data.dependencies,
115
+ ...packageJsonResult.data.devDependencies
116
+ };
117
+ isNextProject = Boolean(deps.next);
118
+ if (!isNextProject) {
119
+ issues.push({
120
+ severity: "warning",
121
+ title: "Next.js not detected",
122
+ message: "This first version of Qodfy is optimized for Next.js projects.",
123
+ suggestion: "If this is a monorepo, scan the Next.js app folder directly."
124
+ });
125
+ }
42
126
  }
43
127
  }
44
- const envExamplePath = path.join(projectPath, ".env.example");
128
+ const envExamplePath = path.join(resolvedProjectPath, ".env.example");
45
129
  const hasEnvExample = await fileExists(envExamplePath);
130
+ let envExampleVariables = null;
46
131
  if (!hasEnvExample) {
47
132
  issues.push({
48
133
  severity: "warning",
49
134
  title: "Missing .env.example",
50
- message: "Add a .env.example file so future developers know which environment variables are required."
135
+ message: "Add a .env.example file so future developers know which environment variables are required.",
136
+ suggestion: "Document required variable names only, never real secret values."
51
137
  });
138
+ } else {
139
+ const envExampleResult = await safeReadFile(envExamplePath);
140
+ if (!envExampleResult.ok) {
141
+ issues.push({
142
+ severity: "warning",
143
+ title: "Could not read .env.example",
144
+ message: envExampleResult.reason,
145
+ file: ".env.example",
146
+ suggestion: "Make sure .env.example is readable and contains variable names without real secret values."
147
+ });
148
+ } else {
149
+ envExampleVariables = getEnvExampleVariables(envExampleResult.content);
150
+ }
52
151
  }
53
- const hasReadme = await fileExists(path.join(projectPath, "README.md"));
152
+ const hasReadme = await fileExists(path.join(resolvedProjectPath, "README.md"));
54
153
  if (!hasReadme) {
55
154
  issues.push({
56
155
  severity: "info",
@@ -58,35 +157,58 @@ async function scanProject(projectPath) {
58
157
  message: "A README helps other developers understand how to run and maintain the project."
59
158
  });
60
159
  }
61
- const files = await fg(["**/*.{ts,tsx,js,jsx}"], {
62
- cwd: projectPath,
63
- ignore: ["node_modules/**", ".next/**", "dist/**", "build/**"],
64
- absolute: true
65
- });
160
+ const files = await getSourceFiles(resolvedProjectPath, issues);
66
161
  const apiRoutes = files.filter((file) => {
67
- return file.includes(`${path.sep}app${path.sep}api${path.sep}`) || file.includes(`${path.sep}pages${path.sep}api${path.sep}`);
162
+ return isApiRoute(file);
68
163
  });
69
- const aiKeywords = [
70
- "openai",
71
- "@ai-sdk",
72
- "ai/react",
73
- "anthropic",
74
- "gemini",
75
- "generateText",
76
- "streamText"
77
- ];
164
+ const apiRouteSet = new Set(apiRoutes);
165
+ const envExampleWarningKeys = /* @__PURE__ */ new Set();
166
+ const clientSecretWarningKeys = /* @__PURE__ */ new Set();
167
+ const hardcodedSecretWarningKeys = /* @__PURE__ */ new Set();
78
168
  let aiFiles = 0;
79
169
  let largeFiles = 0;
80
170
  for (const file of files) {
81
- const content = await fs.readFile(file, "utf-8");
82
- const relativeFile = path.relative(projectPath, file);
83
- if (content.length > 15e3) {
171
+ const relativeFile = normalizePath(path.relative(resolvedProjectPath, file));
172
+ const statResult = await safeStatFile(file);
173
+ if (!statResult.ok) {
174
+ issues.push({
175
+ severity: "info",
176
+ title: "File could not be checked",
177
+ message: statResult.reason,
178
+ file: relativeFile
179
+ });
180
+ continue;
181
+ }
182
+ if (statResult.size > MAX_FILE_SIZE_BYTES) {
183
+ largeFiles++;
184
+ issues.push({
185
+ severity: "info",
186
+ title: "Large file skipped from deep scan",
187
+ message: "This file is larger than 500KB and was skipped from deep content checks.",
188
+ file: relativeFile,
189
+ suggestion: "Review large generated or bundled files manually."
190
+ });
191
+ continue;
192
+ }
193
+ const fileResult = await safeReadFile(file);
194
+ if (!fileResult.ok) {
195
+ issues.push({
196
+ severity: "info",
197
+ title: "File could not be read",
198
+ message: fileResult.reason,
199
+ file: relativeFile
200
+ });
201
+ continue;
202
+ }
203
+ const content = fileResult.content;
204
+ if (statResult.size > LARGE_FILE_WARNING_BYTES) {
84
205
  largeFiles++;
85
206
  issues.push({
86
207
  severity: "info",
87
208
  title: "Large file detected",
88
209
  message: "Large files are harder to maintain and often appear in AI-generated codebases.",
89
- file: relativeFile
210
+ file: relativeFile,
211
+ suggestion: "Consider splitting this file into smaller modules if it mixes unrelated responsibilities."
90
212
  });
91
213
  }
92
214
  const usesAI = aiKeywords.some(
@@ -95,34 +217,96 @@ async function scanProject(projectPath) {
95
217
  if (usesAI) {
96
218
  aiFiles++;
97
219
  const hasRateLimit = content.includes("rateLimit") || content.includes("ratelimit") || content.includes("upstash") || content.includes("limiter");
98
- if (!hasRateLimit) {
220
+ if (apiRouteSet.has(file) && !hasRateLimit) {
99
221
  issues.push({
100
222
  severity: "critical",
101
223
  title: "AI route may be missing rate limiting",
102
224
  message: "AI routes can create real API costs. Add rate limiting or usage limits before launch.",
103
- file: relativeFile
225
+ file: relativeFile,
226
+ suggestion: "Add rate limiting, usage limits, or per-user quotas before launch."
104
227
  });
105
228
  }
106
229
  }
107
- }
108
- for (const route of apiRoutes) {
109
- const content = await fs.readFile(route, "utf-8");
110
- const relativeFile = path.relative(projectPath, route);
111
- const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
112
- if (!hasAuth) {
230
+ const isStripeWebhook = isStripeWebhookRoute(relativeFile, content);
231
+ const hasStripeSignatureVerification = hasStripeWebhookSignatureVerification(content);
232
+ if (isStripeWebhook && !hasStripeSignatureVerification) {
113
233
  issues.push({
114
- severity: "warning",
115
- title: "API route may be missing authentication",
116
- message: "This API route does not appear to contain an auth/session check.",
117
- file: relativeFile
234
+ severity: "critical",
235
+ title: "Stripe webhook may be missing signature verification",
236
+ message: "This Stripe webhook route does not appear to verify the Stripe signature before handling the event.",
237
+ file: relativeFile,
238
+ suggestion: "Use stripe.webhooks.constructEvent with the raw request body, Stripe-Signature header, and STRIPE_WEBHOOK_SECRET."
118
239
  });
119
240
  }
241
+ for (const secretMatch of getHardcodedSecretMatches(content)) {
242
+ const warningKey = `${relativeFile}:${secretMatch.label}`;
243
+ if (hardcodedSecretWarningKeys.has(warningKey)) {
244
+ continue;
245
+ }
246
+ hardcodedSecretWarningKeys.add(warningKey);
247
+ issues.push({
248
+ severity: "critical",
249
+ title: "Possible hardcoded secret",
250
+ message: `A string literal in ${relativeFile} matches the pattern for ${secretMatch.label}. Qodfy does not print possible secret values.`,
251
+ file: relativeFile,
252
+ suggestion: "Move secrets into environment variables and rotate the value if this is a real secret."
253
+ });
254
+ }
255
+ if (apiRouteSet.has(file)) {
256
+ const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
257
+ if (!hasAuth && !isStripeWebhook) {
258
+ issues.push({
259
+ severity: "warning",
260
+ title: "API route may be missing authentication",
261
+ message: "This API route does not appear to contain an auth/session check.",
262
+ file: relativeFile,
263
+ suggestion: "Confirm the route is public, or add an auth/session check before handling user data."
264
+ });
265
+ }
266
+ }
267
+ const usedEnvVariables = getUsedEnvVariables(content);
268
+ if (envExampleVariables) {
269
+ for (const variableName of usedEnvVariables) {
270
+ if (!envExampleVariables.has(variableName)) {
271
+ const warningKey = `${relativeFile}:${variableName}`;
272
+ if (envExampleWarningKeys.has(warningKey)) {
273
+ continue;
274
+ }
275
+ envExampleWarningKeys.add(warningKey);
276
+ issues.push({
277
+ severity: "warning",
278
+ title: "Environment variable missing from .env.example",
279
+ message: `${variableName} is used in ${relativeFile} but is not documented in .env.example.`,
280
+ file: relativeFile,
281
+ suggestion: `Add ${variableName}= to .env.example without including a real value.`
282
+ });
283
+ }
284
+ }
285
+ }
286
+ if (isClientSideFile(relativeFile, content)) {
287
+ for (const variableName of usedEnvVariables) {
288
+ if (!variableName.startsWith("NEXT_PUBLIC_")) {
289
+ const warningKey = `${relativeFile}:${variableName}`;
290
+ if (clientSecretWarningKeys.has(warningKey)) {
291
+ continue;
292
+ }
293
+ clientSecretWarningKeys.add(warningKey);
294
+ issues.push({
295
+ severity: "warning",
296
+ title: "Possible server secret used in client-side code",
297
+ message: `${variableName} appears in a client-side file. Server secrets should not be exposed to the browser.`,
298
+ file: relativeFile,
299
+ suggestion: "Move server-only environment variable access to a server component, API route, or server action."
300
+ });
301
+ }
302
+ }
303
+ }
120
304
  }
121
305
  const criticalCount = issues.filter((issue) => issue.severity === "critical").length;
122
306
  const warningCount = issues.filter((issue) => issue.severity === "warning").length;
123
307
  const score = Math.max(0, 100 - criticalCount * 20 - warningCount * 8);
124
308
  return {
125
- projectPath,
309
+ projectPath: resolvedProjectPath,
126
310
  isNextProject,
127
311
  score,
128
312
  issues,
@@ -130,10 +314,193 @@ async function scanProject(projectPath) {
130
314
  totalFiles: files.length,
131
315
  apiRoutes: apiRoutes.length,
132
316
  aiFiles,
133
- largeFiles
317
+ largeFiles,
318
+ durationMs: Date.now() - startTime
134
319
  }
135
320
  };
136
321
  }
322
+ async function fileExists(filePath) {
323
+ try {
324
+ await fs.access(filePath);
325
+ return true;
326
+ } catch {
327
+ return false;
328
+ }
329
+ }
330
+ async function safeReadFile(filePath) {
331
+ try {
332
+ return {
333
+ ok: true,
334
+ content: await fs.readFile(filePath, "utf-8")
335
+ };
336
+ } catch (error) {
337
+ const code = getErrorCode(error);
338
+ if (code === "ENOENT") {
339
+ return {
340
+ ok: false,
341
+ code,
342
+ reason: "The file disappeared while Qodfy was scanning it."
343
+ };
344
+ }
345
+ if (code === "EACCES" || code === "EPERM") {
346
+ return {
347
+ ok: false,
348
+ code,
349
+ reason: "Qodfy does not have permission to read this file."
350
+ };
351
+ }
352
+ return {
353
+ ok: false,
354
+ code,
355
+ reason: "Qodfy could not read this file."
356
+ };
357
+ }
358
+ }
359
+ async function safeStatFile(filePath) {
360
+ try {
361
+ const stats = await fs.stat(filePath);
362
+ return {
363
+ ok: true,
364
+ size: stats.size
365
+ };
366
+ } catch (error) {
367
+ const code = getErrorCode(error);
368
+ if (code === "ENOENT") {
369
+ return {
370
+ ok: false,
371
+ code,
372
+ reason: "The file disappeared while Qodfy was scanning it."
373
+ };
374
+ }
375
+ if (code === "EACCES" || code === "EPERM") {
376
+ return {
377
+ ok: false,
378
+ code,
379
+ reason: "Qodfy does not have permission to check this file."
380
+ };
381
+ }
382
+ return {
383
+ ok: false,
384
+ code,
385
+ reason: "Qodfy could not check this file."
386
+ };
387
+ }
388
+ }
389
+ async function safeReadJson(filePath) {
390
+ const fileResult = await safeReadFile(filePath);
391
+ if (!fileResult.ok) {
392
+ return fileResult;
393
+ }
394
+ try {
395
+ return {
396
+ ok: true,
397
+ data: JSON.parse(fileResult.content)
398
+ };
399
+ } catch {
400
+ return {
401
+ ok: false,
402
+ reason: "package.json is not valid JSON."
403
+ };
404
+ }
405
+ }
406
+ async function getSourceFiles(projectPath, issues) {
407
+ try {
408
+ return await fg(sourceFilePatterns, {
409
+ cwd: projectPath,
410
+ ignore: ignoredPaths,
411
+ absolute: true,
412
+ onlyFiles: true,
413
+ dot: false
414
+ });
415
+ } catch {
416
+ issues.push({
417
+ severity: "critical",
418
+ title: "Could not scan source files",
419
+ message: "Qodfy could not list source files in this project.",
420
+ suggestion: "Check that the project path exists and is readable."
421
+ });
422
+ return [];
423
+ }
424
+ }
425
+ function getEnvExampleVariables(content) {
426
+ const variables = /* @__PURE__ */ new Set();
427
+ for (const line of content.split(/\r?\n/)) {
428
+ const trimmedLine = line.trim();
429
+ if (!trimmedLine || trimmedLine.startsWith("#")) {
430
+ continue;
431
+ }
432
+ const match = trimmedLine.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|$)/);
433
+ if (match) {
434
+ variables.add(match[1]);
435
+ }
436
+ }
437
+ return variables;
438
+ }
439
+ function getUsedEnvVariables(content) {
440
+ const variables = /* @__PURE__ */ new Set();
441
+ const dotAccessPattern = /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
442
+ const bracketAccessPattern = /\bprocess\.env\[['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\]/g;
443
+ const destructuredEnvPattern = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*process\.env\b/g;
444
+ for (const match of content.matchAll(dotAccessPattern)) {
445
+ variables.add(match[1]);
446
+ }
447
+ for (const match of content.matchAll(bracketAccessPattern)) {
448
+ variables.add(match[1]);
449
+ }
450
+ for (const match of content.matchAll(destructuredEnvPattern)) {
451
+ for (const variableName of parseDestructuredEnvNames(match[1])) {
452
+ variables.add(variableName);
453
+ }
454
+ }
455
+ return variables;
456
+ }
457
+ function parseDestructuredEnvNames(destructuredContent) {
458
+ const variables = [];
459
+ for (const part of destructuredContent.split(",")) {
460
+ const variableName = part.trim().split(":")[0].split("=")[0].trim();
461
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(variableName)) {
462
+ variables.push(variableName);
463
+ }
464
+ }
465
+ return variables;
466
+ }
467
+ function getHardcodedSecretMatches(content) {
468
+ const matches = [];
469
+ for (const secretPattern of hardcodedSecretPatterns) {
470
+ secretPattern.pattern.lastIndex = 0;
471
+ if (secretPattern.pattern.test(content)) {
472
+ matches.push({ label: secretPattern.label });
473
+ }
474
+ }
475
+ return matches;
476
+ }
477
+ function isClientSideFile(relativeFile, content) {
478
+ const fileName = path.basename(relativeFile);
479
+ return fileName.includes(".client.") || /(^|\n)\s*["']use client["'];?/.test(content);
480
+ }
481
+ function isApiRoute(filePath) {
482
+ return filePath.includes(`${path.sep}app${path.sep}api${path.sep}`) || filePath.includes(`${path.sep}pages${path.sep}api${path.sep}`);
483
+ }
484
+ function isStripeWebhookRoute(relativeFile, content) {
485
+ const normalizedFile = relativeFile.toLowerCase();
486
+ const normalizedContent = content.toLowerCase();
487
+ return normalizedContent.includes("stripe") && (normalizedFile.includes("webhook") || normalizedContent.includes("webhook") || normalizedContent.includes("stripe.webhooks"));
488
+ }
489
+ function hasStripeWebhookSignatureVerification(content) {
490
+ return content.includes("stripe.webhooks.constructEvent") || content.includes("webhooks.constructEvent") || content.includes("constructEvent(");
491
+ }
492
+ function isPackageJsonObject(data) {
493
+ return typeof data === "object" && data !== null && !Array.isArray(data);
494
+ }
495
+ function normalizePath(filePath) {
496
+ return filePath.split(path.sep).join("/");
497
+ }
498
+ function getErrorCode(error) {
499
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
500
+ return error.code;
501
+ }
502
+ return void 0;
503
+ }
137
504
  export {
138
505
  scanProject
139
506
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qodfy/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
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": {