tryassay 0.21.0 → 0.21.1
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/api/server.d.ts +3 -0
- package/dist/api/server.js +144 -2
- package/dist/api/server.js.map +1 -1
- package/dist/runtime/__tests__/check-loop.test.js +2 -2
- package/dist/runtime/__tests__/cross-verification-checks.test.d.ts +8 -0
- package/dist/runtime/__tests__/cross-verification-checks.test.js +365 -0
- package/dist/runtime/__tests__/cross-verification-checks.test.js.map +1 -0
- package/dist/runtime/agents/planner-agent.d.ts +18 -1
- package/dist/runtime/agents/planner-agent.js +162 -0
- package/dist/runtime/agents/planner-agent.js.map +1 -1
- package/dist/runtime/app-create-orchestrator.d.ts +39 -1
- package/dist/runtime/app-create-orchestrator.js +579 -1
- package/dist/runtime/app-create-orchestrator.js.map +1 -1
- package/dist/runtime/check-catalog.js +53 -0
- package/dist/runtime/check-catalog.js.map +1 -1
- package/dist/runtime/fs-helpers.d.ts +2 -0
- package/dist/runtime/fs-helpers.js +14 -0
- package/dist/runtime/fs-helpers.js.map +1 -1
- package/dist/runtime/functional-tester.d.ts +8 -0
- package/dist/runtime/functional-tester.js +230 -0
- package/dist/runtime/functional-tester.js.map +1 -1
- package/dist/runtime/types.d.ts +41 -2
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import type { AppDescription, AppCreateOptions, AppCreateResult, PlanSummary, PlanApprovalResult } from './types.js';
|
|
2
|
+
import type { AppDescription, AppCreateOptions, AppCreateResult, PlanSummary, PlanApprovalResult, PlanAnswer } from './types.js';
|
|
3
3
|
export declare class AppCreateOrchestrator extends EventEmitter {
|
|
4
4
|
private description;
|
|
5
5
|
private options;
|
|
@@ -7,11 +7,14 @@ export declare class AppCreateOrchestrator extends EventEmitter {
|
|
|
7
7
|
private startTime;
|
|
8
8
|
private planSummaryCache;
|
|
9
9
|
private approvalResolve;
|
|
10
|
+
private answersResolve;
|
|
10
11
|
constructor(description: AppDescription, options: AppCreateOptions);
|
|
11
12
|
/** Get the current plan summary (available after planning phase). */
|
|
12
13
|
getPlanSummary(): PlanSummary | null;
|
|
13
14
|
/** Approve or reject the plan. Resolves the blocked pipeline. */
|
|
14
15
|
approvePlan(result: PlanApprovalResult): void;
|
|
16
|
+
/** Submit answers to plan questions. Resolves the waiting questioning loop. */
|
|
17
|
+
submitAnswers(answers: PlanAnswer[]): void;
|
|
15
18
|
/** Run the full app creation pipeline. */
|
|
16
19
|
run(): Promise<AppCreateResult>;
|
|
17
20
|
private runPlanningPhase;
|
|
@@ -68,4 +71,39 @@ export declare class AppCreateOrchestrator extends EventEmitter {
|
|
|
68
71
|
private pageToFilePath;
|
|
69
72
|
private fileExists;
|
|
70
73
|
private searchForSchemaEntity;
|
|
74
|
+
/**
|
|
75
|
+
* Scan generated page files for href/Link targets and verify
|
|
76
|
+
* each internal route has a corresponding page file.
|
|
77
|
+
* Catches hallucinated links like href="/categories" when
|
|
78
|
+
* no /categories/page.tsx exists.
|
|
79
|
+
*/
|
|
80
|
+
private checkLinkIntegrity;
|
|
81
|
+
/**
|
|
82
|
+
* Check A1: Verify all import paths resolve to actual files.
|
|
83
|
+
* Catches: broken relative imports, unresolvable @/ aliases.
|
|
84
|
+
*/
|
|
85
|
+
private checkImportResolution;
|
|
86
|
+
/**
|
|
87
|
+
* Check A2: Verify all imported packages exist in package.json.
|
|
88
|
+
* Also detects invalid alias entries (e.g. "@/lib": "latest").
|
|
89
|
+
*/
|
|
90
|
+
private checkPackageDepsComplete;
|
|
91
|
+
/**
|
|
92
|
+
* Check A3: Detect server components that fetch their own API routes (deadlock risk).
|
|
93
|
+
* Only applies to Next.js apps.
|
|
94
|
+
*/
|
|
95
|
+
private checkNoSelfFetch;
|
|
96
|
+
/**
|
|
97
|
+
* Check A4: Verify TypeScript compiles without errors.
|
|
98
|
+
* Uses `npx tsc --noEmit` with 60s timeout.
|
|
99
|
+
*/
|
|
100
|
+
private checkTypescriptCompiles;
|
|
101
|
+
/**
|
|
102
|
+
* Check A5: Verify all referenced environment variables are defined in .env files.
|
|
103
|
+
*/
|
|
104
|
+
private checkEnvVarsComplete;
|
|
105
|
+
/**
|
|
106
|
+
* Check A6: Verify asset references (images, CSS, icons) point to existing files.
|
|
107
|
+
*/
|
|
108
|
+
private checkAssetRefsValid;
|
|
71
109
|
}
|
|
@@ -7,6 +7,7 @@ import { EventEmitter } from 'node:events';
|
|
|
7
7
|
import { mkdir, writeFile, readFile, readdir, stat } from 'node:fs/promises';
|
|
8
8
|
import { join, dirname } from 'node:path';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
10
11
|
import { PlannerAgent } from './agents/planner-agent.js';
|
|
11
12
|
import { CodeAgent } from './agents/code-agent.js';
|
|
12
13
|
import { MessageBus } from './message-bus.js';
|
|
@@ -39,6 +40,7 @@ export class AppCreateOrchestrator extends EventEmitter {
|
|
|
39
40
|
startTime = 0;
|
|
40
41
|
planSummaryCache = null;
|
|
41
42
|
approvalResolve = null;
|
|
43
|
+
answersResolve = null;
|
|
42
44
|
constructor(description, options) {
|
|
43
45
|
super();
|
|
44
46
|
this.description = description;
|
|
@@ -55,6 +57,13 @@ export class AppCreateOrchestrator extends EventEmitter {
|
|
|
55
57
|
this.approvalResolve = null;
|
|
56
58
|
}
|
|
57
59
|
}
|
|
60
|
+
/** Submit answers to plan questions. Resolves the waiting questioning loop. */
|
|
61
|
+
submitAnswers(answers) {
|
|
62
|
+
if (this.answersResolve) {
|
|
63
|
+
this.answersResolve(answers);
|
|
64
|
+
this.answersResolve = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
58
67
|
/** Run the full app creation pipeline. */
|
|
59
68
|
async run() {
|
|
60
69
|
this.startTime = Date.now();
|
|
@@ -64,8 +73,83 @@ export class AppCreateOrchestrator extends EventEmitter {
|
|
|
64
73
|
await mkdir(projectPath, { recursive: true });
|
|
65
74
|
await mkdir(join(projectPath, '.assay'), { recursive: true });
|
|
66
75
|
this.audit('task_status_change', { phase: 'initializing', projectPath });
|
|
67
|
-
// Phase: planning
|
|
76
|
+
// Phase: plan questioning (refine requirements BEFORE planning)
|
|
77
|
+
let refinedRequirements = '';
|
|
78
|
+
if (!this.options.skipPlanQuestions && !this.options.autoApprove) {
|
|
79
|
+
const messageBus = new MessageBus();
|
|
80
|
+
const planner = new PlannerAgent(messageBus, { model: this.options.models?.planner });
|
|
81
|
+
const maxRounds = 3;
|
|
82
|
+
let currentConfidence = 0;
|
|
83
|
+
const allAnswers = [];
|
|
84
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
85
|
+
// Generate questions from the user prompt (no plan yet)
|
|
86
|
+
let questions = [];
|
|
87
|
+
try {
|
|
88
|
+
questions = await planner.generateQuestions(this.description.description);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
this.audit('task_status_change', {
|
|
92
|
+
phase: 'plan_questioning',
|
|
93
|
+
round,
|
|
94
|
+
error: err instanceof Error ? err.message : String(err),
|
|
95
|
+
reason: 'question_generation_failed',
|
|
96
|
+
});
|
|
97
|
+
break; // Skip questioning, proceed to planning
|
|
98
|
+
}
|
|
99
|
+
if (questions.length === 0) {
|
|
100
|
+
this.audit('task_status_change', {
|
|
101
|
+
phase: 'plan_questioning',
|
|
102
|
+
round,
|
|
103
|
+
questionsGenerated: 0,
|
|
104
|
+
reason: 'no_questions',
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
// Emit questions to frontend
|
|
109
|
+
this.emitPhase({
|
|
110
|
+
phase: 'plan_questioning',
|
|
111
|
+
questions,
|
|
112
|
+
round,
|
|
113
|
+
confidence: currentConfidence,
|
|
114
|
+
});
|
|
115
|
+
this.audit('task_status_change', {
|
|
116
|
+
phase: 'plan_questioning',
|
|
117
|
+
round,
|
|
118
|
+
questionsGenerated: questions.length,
|
|
119
|
+
});
|
|
120
|
+
// Wait for answers from frontend
|
|
121
|
+
const answers = await new Promise((resolve) => {
|
|
122
|
+
this.answersResolve = resolve;
|
|
123
|
+
});
|
|
124
|
+
allAnswers.push(...answers);
|
|
125
|
+
// Update confidence based on answers received
|
|
126
|
+
this.emitPhase({ phase: 'plan_refining', round });
|
|
127
|
+
currentConfidence = Math.min(0.5 + (allAnswers.length * 0.15), 1.0);
|
|
128
|
+
this.audit('task_status_change', {
|
|
129
|
+
phase: 'plan_refining',
|
|
130
|
+
round,
|
|
131
|
+
confidence: currentConfidence,
|
|
132
|
+
answersReceived: answers.length,
|
|
133
|
+
});
|
|
134
|
+
if (currentConfidence >= 0.95) {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Build refined requirements string from all answers
|
|
139
|
+
if (allAnswers.length > 0) {
|
|
140
|
+
refinedRequirements = '\n\nREFINED REQUIREMENTS (from user selections):\n' +
|
|
141
|
+
allAnswers.map(a => `- ${a.questionId}: ${a.selectedOptions.join(', ')}${a.customText ? ` (${a.customText})` : ''}`).join('\n');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Phase: planning (now with refined requirements)
|
|
68
145
|
this.emitPhase({ phase: 'planning' });
|
|
146
|
+
// Enrich the description with user's answers before planning
|
|
147
|
+
if (refinedRequirements) {
|
|
148
|
+
this.description = {
|
|
149
|
+
...this.description,
|
|
150
|
+
description: this.description.description + refinedRequirements,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
69
153
|
let plan = await this.runPlanningPhase(projectPath);
|
|
70
154
|
if (!plan) {
|
|
71
155
|
this.emitPhase({ phase: 'failed', reason: 'Planning failed after retries' });
|
|
@@ -615,6 +699,27 @@ Include a .env.local file with placeholder values for all required environment v
|
|
|
615
699
|
});
|
|
616
700
|
}
|
|
617
701
|
}
|
|
702
|
+
// Check 6: Link integrity — href targets in generated code resolve to actual pages
|
|
703
|
+
const linkChecks = await this.checkLinkIntegrity(projectPath, plan);
|
|
704
|
+
checks.push(...linkChecks);
|
|
705
|
+
// Check 7: Import paths resolve to actual files
|
|
706
|
+
const importChecks = await this.checkImportResolution(projectPath);
|
|
707
|
+
checks.push(...importChecks);
|
|
708
|
+
// Check 8: Package dependencies complete
|
|
709
|
+
const pkgChecks = await this.checkPackageDepsComplete(projectPath);
|
|
710
|
+
checks.push(...pkgChecks);
|
|
711
|
+
// Check 9: No self-fetch in server components
|
|
712
|
+
const selfFetchChecks = await this.checkNoSelfFetch(projectPath);
|
|
713
|
+
checks.push(...selfFetchChecks);
|
|
714
|
+
// Check 10: TypeScript compiles
|
|
715
|
+
const tsChecks = await this.checkTypescriptCompiles(projectPath);
|
|
716
|
+
checks.push(...tsChecks);
|
|
717
|
+
// Check 11: Environment variables complete
|
|
718
|
+
const envChecks = await this.checkEnvVarsComplete(projectPath);
|
|
719
|
+
checks.push(...envChecks);
|
|
720
|
+
// Check 12: Asset references valid
|
|
721
|
+
const assetChecks = await this.checkAssetRefsValid(projectPath);
|
|
722
|
+
checks.push(...assetChecks);
|
|
618
723
|
const passedCount = checks.filter(c => c.verdict === 'PASS').length;
|
|
619
724
|
const failedCount = checks.filter(c => c.verdict === 'FAIL').length;
|
|
620
725
|
return {
|
|
@@ -1121,5 +1226,478 @@ The renderer process communicates with main via contextBridge APIs exposed in sr
|
|
|
1121
1226
|
}
|
|
1122
1227
|
return false;
|
|
1123
1228
|
}
|
|
1229
|
+
// ── Link Integrity Check ──────────────────────────────────
|
|
1230
|
+
/**
|
|
1231
|
+
* Scan generated page files for href/Link targets and verify
|
|
1232
|
+
* each internal route has a corresponding page file.
|
|
1233
|
+
* Catches hallucinated links like href="/categories" when
|
|
1234
|
+
* no /categories/page.tsx exists.
|
|
1235
|
+
*/
|
|
1236
|
+
async checkLinkIntegrity(projectPath, plan) {
|
|
1237
|
+
const checks = [];
|
|
1238
|
+
const framework = this.description.techStack.framework.toLowerCase();
|
|
1239
|
+
// Build set of known routes from the plan
|
|
1240
|
+
const knownRoutes = new Set();
|
|
1241
|
+
for (const page of plan.pages) {
|
|
1242
|
+
knownRoutes.add(page.path);
|
|
1243
|
+
}
|
|
1244
|
+
// Collect all page files to scan
|
|
1245
|
+
const pageFiles = [];
|
|
1246
|
+
for (const page of plan.pages) {
|
|
1247
|
+
const filePath = this.pageToFilePath(page.path, page.component, this.description.techStack.framework);
|
|
1248
|
+
pageFiles.push(join(projectPath, filePath));
|
|
1249
|
+
}
|
|
1250
|
+
// Also scan layout files which often contain nav links
|
|
1251
|
+
const layoutCandidates = framework.includes('next')
|
|
1252
|
+
? ['app/layout.tsx', 'app/layout.jsx', 'app/page.tsx', 'app/page.jsx',
|
|
1253
|
+
'components/nav.tsx', 'components/navbar.tsx', 'components/header.tsx',
|
|
1254
|
+
'components/sidebar.tsx', 'components/layout.tsx',
|
|
1255
|
+
'src/components/nav.tsx', 'src/components/navbar.tsx', 'src/components/header.tsx']
|
|
1256
|
+
: ['src/App.tsx', 'src/App.jsx', 'src/components/Nav.tsx', 'src/components/Navbar.tsx',
|
|
1257
|
+
'src/components/Header.tsx', 'src/components/Sidebar.tsx', 'src/components/Layout.tsx'];
|
|
1258
|
+
for (const candidate of layoutCandidates) {
|
|
1259
|
+
pageFiles.push(join(projectPath, candidate));
|
|
1260
|
+
}
|
|
1261
|
+
// Regex patterns to extract internal href targets
|
|
1262
|
+
// Matches: href="/path", href='/path', to="/path", to='/path'
|
|
1263
|
+
const hrefPattern = /(?:href|to)=["'](\/([\w\-/]*)?)["']/g;
|
|
1264
|
+
// Also match Next.js Link component: <Link href="/path">
|
|
1265
|
+
const linkPattern = /<Link[^>]*\s+href=["'](\/([\w\-/]*)?)["']/g;
|
|
1266
|
+
const seenLinks = new Set();
|
|
1267
|
+
for (const filePath of pageFiles) {
|
|
1268
|
+
let content;
|
|
1269
|
+
try {
|
|
1270
|
+
content = await readFile(filePath, 'utf-8');
|
|
1271
|
+
}
|
|
1272
|
+
catch {
|
|
1273
|
+
continue; // File doesn't exist, skip
|
|
1274
|
+
}
|
|
1275
|
+
// Extract all href targets
|
|
1276
|
+
for (const pattern of [hrefPattern, linkPattern]) {
|
|
1277
|
+
pattern.lastIndex = 0; // Reset regex state
|
|
1278
|
+
let match;
|
|
1279
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
1280
|
+
const target = match[1];
|
|
1281
|
+
if (!target || target === '/' || target === '#')
|
|
1282
|
+
continue;
|
|
1283
|
+
// Skip external links, anchors, api routes, auth routes
|
|
1284
|
+
if (target.startsWith('/api/') || target.startsWith('/auth/'))
|
|
1285
|
+
continue;
|
|
1286
|
+
if (target.includes('://') || target.startsWith('mailto:'))
|
|
1287
|
+
continue;
|
|
1288
|
+
// Normalize: strip trailing slash
|
|
1289
|
+
const normalized = target.endsWith('/') ? target.slice(0, -1) : target;
|
|
1290
|
+
if (seenLinks.has(normalized))
|
|
1291
|
+
continue;
|
|
1292
|
+
seenLinks.add(normalized);
|
|
1293
|
+
// Check if this route has a page file
|
|
1294
|
+
const expectedPath = this.pageToFilePath(normalized, '', this.description.techStack.framework);
|
|
1295
|
+
const exists = await this.fileExists(join(projectPath, expectedPath));
|
|
1296
|
+
// Also check if it might be a dynamic route (e.g., /categories/[slug])
|
|
1297
|
+
// by looking for [param] directories
|
|
1298
|
+
let dynamicMatch = false;
|
|
1299
|
+
if (!exists && framework.includes('next')) {
|
|
1300
|
+
// For /categories, check if app/categories/page.tsx OR app/categories/[slug]/page.tsx exists
|
|
1301
|
+
// The link might be to a listing page that was never created
|
|
1302
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
1303
|
+
if (segments.length === 1) {
|
|
1304
|
+
// Single-segment route like /categories — check if only a dynamic child exists
|
|
1305
|
+
try {
|
|
1306
|
+
const dirPath = join(projectPath, 'app', segments[0]);
|
|
1307
|
+
const entries = await readdir(dirPath);
|
|
1308
|
+
const hasDynamicChild = entries.some(e => e.startsWith('['));
|
|
1309
|
+
if (hasDynamicChild && !entries.includes('page.tsx') && !entries.includes('page.jsx')) {
|
|
1310
|
+
// Directory exists with dynamic routes but no index page — this IS the bug
|
|
1311
|
+
dynamicMatch = false; // Intentionally false: missing index page is a real failure
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
catch {
|
|
1315
|
+
// Directory doesn't exist at all
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
const passed = exists || dynamicMatch;
|
|
1320
|
+
const sourceFile = filePath.replace(projectPath + '/', '');
|
|
1321
|
+
checks.push({
|
|
1322
|
+
type: 'link_integrity',
|
|
1323
|
+
description: `Link href="${normalized}" in ${sourceFile} resolves to a page`,
|
|
1324
|
+
verdict: passed ? 'PASS' : 'FAIL',
|
|
1325
|
+
evidence: passed
|
|
1326
|
+
? `Page exists: ${expectedPath}`
|
|
1327
|
+
: `No page at ${expectedPath} — link target has no route handler`,
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
return checks;
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Check A1: Verify all import paths resolve to actual files.
|
|
1336
|
+
* Catches: broken relative imports, unresolvable @/ aliases.
|
|
1337
|
+
*/
|
|
1338
|
+
async checkImportResolution(projectPath) {
|
|
1339
|
+
const checks = [];
|
|
1340
|
+
const sourceFiles = await this.collectFiles(projectPath, ['.ts', '.tsx', '.js', '.jsx']);
|
|
1341
|
+
// Read tsconfig paths for alias resolution
|
|
1342
|
+
const { readTsConfigPaths } = await import('./fs-helpers.js');
|
|
1343
|
+
const paths = await readTsConfigPaths(projectPath);
|
|
1344
|
+
for (const filePath of sourceFiles) {
|
|
1345
|
+
let content;
|
|
1346
|
+
try {
|
|
1347
|
+
content = await readFile(filePath, 'utf-8');
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
const importRegex = /(?:import\s+(?:[\s\S]*?)\s+from\s+|import\s+|require\s*\(\s*)['"]([^'"]+)['"]/g;
|
|
1353
|
+
let match;
|
|
1354
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
1355
|
+
const importPath = match[1];
|
|
1356
|
+
// Skip external packages (no . or @ prefix, or @scoped packages)
|
|
1357
|
+
if (!importPath.startsWith('.') && !importPath.startsWith('@/') && !importPath.startsWith('~/'))
|
|
1358
|
+
continue;
|
|
1359
|
+
// Skip @scoped npm packages like @supabase/ssr
|
|
1360
|
+
if (importPath.startsWith('@') && !importPath.startsWith('@/'))
|
|
1361
|
+
continue;
|
|
1362
|
+
const relFile = filePath.replace(projectPath + '/', '');
|
|
1363
|
+
if (importPath.startsWith('.')) {
|
|
1364
|
+
// Relative import — use resolveImport from fs-helpers
|
|
1365
|
+
const { resolveImport } = await import('./fs-helpers.js');
|
|
1366
|
+
const resolved = resolveImport(filePath, importPath, sourceFiles);
|
|
1367
|
+
if (!resolved) {
|
|
1368
|
+
checks.push({
|
|
1369
|
+
type: 'import_path_valid',
|
|
1370
|
+
description: `Import '${importPath}' in ${relFile} resolves to file`,
|
|
1371
|
+
verdict: 'FAIL',
|
|
1372
|
+
evidence: `Import '${importPath}' in ${relFile} does not resolve to any file`,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
else if (importPath.startsWith('@/') || importPath.startsWith('~/')) {
|
|
1377
|
+
// Alias import — resolve via tsconfig paths
|
|
1378
|
+
let resolved = false;
|
|
1379
|
+
const prefix = importPath.startsWith('@/') ? '@/*' : '~/*';
|
|
1380
|
+
const aliasTargets = paths[prefix];
|
|
1381
|
+
if (aliasTargets && aliasTargets.length > 0) {
|
|
1382
|
+
const suffix = importPath.slice(2); // strip @/ or ~/
|
|
1383
|
+
for (const target of aliasTargets) {
|
|
1384
|
+
const targetDir = target.replace('*', '');
|
|
1385
|
+
const base = join(projectPath, targetDir, suffix);
|
|
1386
|
+
const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js', '/index.jsx'];
|
|
1387
|
+
for (const ext of extensions) {
|
|
1388
|
+
try {
|
|
1389
|
+
await stat(base + ext);
|
|
1390
|
+
resolved = true;
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
catch {
|
|
1394
|
+
// continue
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (resolved)
|
|
1398
|
+
break;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (!resolved) {
|
|
1402
|
+
checks.push({
|
|
1403
|
+
type: 'import_path_valid',
|
|
1404
|
+
description: `Import '${importPath}' in ${relFile} resolves via tsconfig alias`,
|
|
1405
|
+
verdict: 'FAIL',
|
|
1406
|
+
evidence: `Import '${importPath}' in ${relFile} — alias not configured or target missing`,
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return checks;
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Check A2: Verify all imported packages exist in package.json.
|
|
1416
|
+
* Also detects invalid alias entries (e.g. "@/lib": "latest").
|
|
1417
|
+
*/
|
|
1418
|
+
async checkPackageDepsComplete(projectPath) {
|
|
1419
|
+
const checks = [];
|
|
1420
|
+
const pkgPath = join(projectPath, 'package.json');
|
|
1421
|
+
let pkg;
|
|
1422
|
+
try {
|
|
1423
|
+
pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
return checks; // No package.json
|
|
1427
|
+
}
|
|
1428
|
+
const deps = (pkg.dependencies ?? {});
|
|
1429
|
+
const devDeps = (pkg.devDependencies ?? {});
|
|
1430
|
+
const allDeps = new Set([...Object.keys(deps), ...Object.keys(devDeps)]);
|
|
1431
|
+
// Check for invalid alias entries in package.json
|
|
1432
|
+
for (const key of allDeps) {
|
|
1433
|
+
if (key.startsWith('@/') || key.startsWith('./') || key.startsWith('~/')) {
|
|
1434
|
+
checks.push({
|
|
1435
|
+
type: 'package_deps_complete',
|
|
1436
|
+
description: `Package "${key}" is a valid npm package name`,
|
|
1437
|
+
verdict: 'FAIL',
|
|
1438
|
+
evidence: `"${key}" in package.json is a path alias, not a valid npm package`,
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Collect imports from source files
|
|
1443
|
+
const sourceFiles = await this.collectFiles(projectPath, ['.ts', '.tsx', '.js', '.jsx']);
|
|
1444
|
+
const importedPackages = new Set();
|
|
1445
|
+
for (const filePath of sourceFiles) {
|
|
1446
|
+
try {
|
|
1447
|
+
const content = await readFile(filePath, 'utf-8');
|
|
1448
|
+
const importRegex = /(?:import\s+.*?\s+from\s+['"]|import\s+['"]|require\s*\(\s*['"])([^./~][^'"]*)['"]/g;
|
|
1449
|
+
let match;
|
|
1450
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
1451
|
+
const raw = match[1];
|
|
1452
|
+
// Skip @/ aliases — they're not packages
|
|
1453
|
+
if (raw.startsWith('@/'))
|
|
1454
|
+
continue;
|
|
1455
|
+
const pkgName = raw.startsWith('@')
|
|
1456
|
+
? raw.split('/').slice(0, 2).join('/')
|
|
1457
|
+
: raw.split('/')[0];
|
|
1458
|
+
importedPackages.add(pkgName);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
const builtins = new Set([
|
|
1466
|
+
'node:events', 'node:fs', 'node:path', 'node:crypto', 'node:url', 'node:http', 'node:https',
|
|
1467
|
+
'node:stream', 'node:util', 'node:os', 'node:child_process', 'node:buffer', 'node:assert',
|
|
1468
|
+
'events', 'fs', 'path', 'crypto', 'url', 'http', 'https', 'stream', 'util', 'os',
|
|
1469
|
+
'child_process', 'buffer', 'assert', 'fs/promises',
|
|
1470
|
+
'react', 'react-dom', 'react-dom/client', 'next', 'electron',
|
|
1471
|
+
]);
|
|
1472
|
+
for (const importedPkg of importedPackages) {
|
|
1473
|
+
if (builtins.has(importedPkg))
|
|
1474
|
+
continue;
|
|
1475
|
+
if (allDeps.has(importedPkg))
|
|
1476
|
+
continue;
|
|
1477
|
+
if (importedPkg.startsWith('next/'))
|
|
1478
|
+
continue;
|
|
1479
|
+
checks.push({
|
|
1480
|
+
type: 'package_deps_complete',
|
|
1481
|
+
description: `Package "${importedPkg}" is listed in package.json`,
|
|
1482
|
+
verdict: 'FAIL',
|
|
1483
|
+
evidence: `"${importedPkg}" is imported but not in package.json dependencies`,
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
return checks;
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Check A3: Detect server components that fetch their own API routes (deadlock risk).
|
|
1490
|
+
* Only applies to Next.js apps.
|
|
1491
|
+
*/
|
|
1492
|
+
async checkNoSelfFetch(projectPath) {
|
|
1493
|
+
const checks = [];
|
|
1494
|
+
const framework = this.description.techStack.framework.toLowerCase();
|
|
1495
|
+
if (!framework.includes('next'))
|
|
1496
|
+
return checks;
|
|
1497
|
+
const appDir = join(projectPath, 'app');
|
|
1498
|
+
let appFiles;
|
|
1499
|
+
try {
|
|
1500
|
+
appFiles = await this.collectFiles(appDir, ['.ts', '.tsx', '.js', '.jsx']);
|
|
1501
|
+
}
|
|
1502
|
+
catch {
|
|
1503
|
+
return checks;
|
|
1504
|
+
}
|
|
1505
|
+
const selfFetchPattern = /fetch\s*\(\s*['"`](?:\/api\/|https?:\/\/localhost)/;
|
|
1506
|
+
for (const filePath of appFiles) {
|
|
1507
|
+
let content;
|
|
1508
|
+
try {
|
|
1509
|
+
content = await readFile(filePath, 'utf-8');
|
|
1510
|
+
}
|
|
1511
|
+
catch {
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
// Client components can self-fetch — skip
|
|
1515
|
+
if (content.includes("'use client'") || content.includes('"use client"'))
|
|
1516
|
+
continue;
|
|
1517
|
+
if (selfFetchPattern.test(content)) {
|
|
1518
|
+
const relFile = filePath.replace(projectPath + '/', '');
|
|
1519
|
+
checks.push({
|
|
1520
|
+
type: 'no_self_fetch',
|
|
1521
|
+
description: `Server component ${relFile} does not self-fetch API routes`,
|
|
1522
|
+
verdict: 'FAIL',
|
|
1523
|
+
evidence: `${relFile} is a server component that fetches /api/ or localhost — this causes a deadlock in Next.js`,
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return checks;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Check A4: Verify TypeScript compiles without errors.
|
|
1531
|
+
* Uses `npx tsc --noEmit` with 60s timeout.
|
|
1532
|
+
*/
|
|
1533
|
+
async checkTypescriptCompiles(projectPath) {
|
|
1534
|
+
const tsconfigPath = join(projectPath, 'tsconfig.json');
|
|
1535
|
+
try {
|
|
1536
|
+
await stat(tsconfigPath);
|
|
1537
|
+
}
|
|
1538
|
+
catch {
|
|
1539
|
+
return []; // No tsconfig — skip
|
|
1540
|
+
}
|
|
1541
|
+
try {
|
|
1542
|
+
execSync('npx tsc --noEmit --pretty false', {
|
|
1543
|
+
cwd: projectPath,
|
|
1544
|
+
timeout: 60_000,
|
|
1545
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1546
|
+
});
|
|
1547
|
+
return [{
|
|
1548
|
+
type: 'typescript_compiles',
|
|
1549
|
+
description: 'TypeScript compiles without errors',
|
|
1550
|
+
verdict: 'PASS',
|
|
1551
|
+
evidence: 'npx tsc --noEmit exited with code 0',
|
|
1552
|
+
}];
|
|
1553
|
+
}
|
|
1554
|
+
catch (err) {
|
|
1555
|
+
const stderr = err?.stderr?.toString() ?? '';
|
|
1556
|
+
const stdout = err?.stdout?.toString() ?? '';
|
|
1557
|
+
const output = stderr || stdout;
|
|
1558
|
+
const errorLines = output.split('\n').filter(l => l.includes('error TS'));
|
|
1559
|
+
return [{
|
|
1560
|
+
type: 'typescript_compiles',
|
|
1561
|
+
description: 'TypeScript compiles without errors',
|
|
1562
|
+
verdict: 'FAIL',
|
|
1563
|
+
evidence: errorLines.length > 0
|
|
1564
|
+
? `tsc --noEmit failed with ${errorLines.length} error(s): ${errorLines.slice(0, 5).join('; ')}${errorLines.length > 5 ? ` ... and ${errorLines.length - 5} more` : ''}`
|
|
1565
|
+
: `tsc --noEmit failed: ${output.slice(0, 500)}`,
|
|
1566
|
+
}];
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Check A5: Verify all referenced environment variables are defined in .env files.
|
|
1571
|
+
*/
|
|
1572
|
+
async checkEnvVarsComplete(projectPath) {
|
|
1573
|
+
const checks = [];
|
|
1574
|
+
const sourceFiles = await this.collectFiles(projectPath, ['.ts', '.tsx', '.js', '.jsx']);
|
|
1575
|
+
// Standard exclusions — always available at runtime
|
|
1576
|
+
const standardVars = new Set([
|
|
1577
|
+
'NODE_ENV', 'PORT', 'HOME', 'PATH', 'PWD', 'USER', 'CI',
|
|
1578
|
+
'HOSTNAME', 'VERCEL', 'NEXT_RUNTIME', 'NODE_PATH', 'NODE_OPTIONS',
|
|
1579
|
+
'VERCEL_URL', 'NEXT_PUBLIC_VERCEL_URL', 'NEXT_PUBLIC_VERCEL_ENV',
|
|
1580
|
+
]);
|
|
1581
|
+
// Collect all referenced env vars
|
|
1582
|
+
const referencedVars = new Set();
|
|
1583
|
+
const processEnvPattern = /process\.env\.([A-Z_][A-Z0-9_]+)/g;
|
|
1584
|
+
const importMetaPattern = /import\.meta\.env\.([A-Z_][A-Z0-9_]+)/g;
|
|
1585
|
+
for (const filePath of sourceFiles) {
|
|
1586
|
+
try {
|
|
1587
|
+
const content = await readFile(filePath, 'utf-8');
|
|
1588
|
+
let match;
|
|
1589
|
+
while ((match = processEnvPattern.exec(content)) !== null) {
|
|
1590
|
+
referencedVars.add(match[1]);
|
|
1591
|
+
}
|
|
1592
|
+
processEnvPattern.lastIndex = 0;
|
|
1593
|
+
while ((match = importMetaPattern.exec(content)) !== null) {
|
|
1594
|
+
referencedVars.add(match[1]);
|
|
1595
|
+
}
|
|
1596
|
+
importMetaPattern.lastIndex = 0;
|
|
1597
|
+
}
|
|
1598
|
+
catch {
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
// Collect defined env vars from all .env* files
|
|
1603
|
+
const definedVars = new Set();
|
|
1604
|
+
try {
|
|
1605
|
+
const entries = await readdir(projectPath);
|
|
1606
|
+
for (const entry of entries) {
|
|
1607
|
+
if (entry.startsWith('.env')) {
|
|
1608
|
+
try {
|
|
1609
|
+
const content = await readFile(join(projectPath, entry), 'utf-8');
|
|
1610
|
+
for (const line of content.split('\n')) {
|
|
1611
|
+
const trimmed = line.trim();
|
|
1612
|
+
if (trimmed && !trimmed.startsWith('#')) {
|
|
1613
|
+
const eqIdx = trimmed.indexOf('=');
|
|
1614
|
+
if (eqIdx > 0) {
|
|
1615
|
+
definedVars.add(trimmed.slice(0, eqIdx).trim());
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
catch {
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
// Can't read directory
|
|
1628
|
+
}
|
|
1629
|
+
for (const varName of referencedVars) {
|
|
1630
|
+
if (standardVars.has(varName))
|
|
1631
|
+
continue;
|
|
1632
|
+
if (definedVars.has(varName))
|
|
1633
|
+
continue;
|
|
1634
|
+
checks.push({
|
|
1635
|
+
type: 'env_vars_complete',
|
|
1636
|
+
description: `Environment variable ${varName} is defined in .env file`,
|
|
1637
|
+
verdict: 'FAIL',
|
|
1638
|
+
evidence: `${varName} is referenced in source code but not defined in any .env* file`,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
return checks;
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Check A6: Verify asset references (images, CSS, icons) point to existing files.
|
|
1645
|
+
*/
|
|
1646
|
+
async checkAssetRefsValid(projectPath) {
|
|
1647
|
+
const checks = [];
|
|
1648
|
+
const framework = this.description.techStack.framework.toLowerCase();
|
|
1649
|
+
// Determine public asset directory
|
|
1650
|
+
let publicDir;
|
|
1651
|
+
if (framework.includes('electron')) {
|
|
1652
|
+
publicDir = join(projectPath, 'src', 'renderer', 'assets');
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
publicDir = join(projectPath, 'public');
|
|
1656
|
+
}
|
|
1657
|
+
const sourceFiles = await this.collectFiles(projectPath, ['.ts', '.tsx', '.js', '.jsx', '.html', '.css']);
|
|
1658
|
+
// Patterns for asset references
|
|
1659
|
+
const assetPatterns = [
|
|
1660
|
+
/(?:src|href)=["'](\/[^"']+\.(?:png|jpg|jpeg|gif|svg|webp|ico|css))["']/g,
|
|
1661
|
+
/url\(["']?(\/[^"')]+\.(?:png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|eot))["']?\)/g,
|
|
1662
|
+
];
|
|
1663
|
+
const checkedPaths = new Set();
|
|
1664
|
+
for (const filePath of sourceFiles) {
|
|
1665
|
+
let content;
|
|
1666
|
+
try {
|
|
1667
|
+
content = await readFile(filePath, 'utf-8');
|
|
1668
|
+
}
|
|
1669
|
+
catch {
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
for (const pattern of assetPatterns) {
|
|
1673
|
+
pattern.lastIndex = 0;
|
|
1674
|
+
let match;
|
|
1675
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
1676
|
+
const assetPath = match[1];
|
|
1677
|
+
// Skip external URLs and data URIs
|
|
1678
|
+
if (assetPath.includes('://') || assetPath.startsWith('//') || assetPath.startsWith('data:'))
|
|
1679
|
+
continue;
|
|
1680
|
+
if (checkedPaths.has(assetPath))
|
|
1681
|
+
continue;
|
|
1682
|
+
checkedPaths.add(assetPath);
|
|
1683
|
+
// Resolve against public directory (strip leading /)
|
|
1684
|
+
const resolved = join(publicDir, assetPath.slice(1));
|
|
1685
|
+
try {
|
|
1686
|
+
await stat(resolved);
|
|
1687
|
+
}
|
|
1688
|
+
catch {
|
|
1689
|
+
const relFile = filePath.replace(projectPath + '/', '');
|
|
1690
|
+
checks.push({
|
|
1691
|
+
type: 'asset_refs_valid',
|
|
1692
|
+
description: `Asset ${assetPath} referenced in ${relFile} exists`,
|
|
1693
|
+
verdict: 'FAIL',
|
|
1694
|
+
evidence: `${assetPath} referenced in ${relFile} not found at ${resolved.replace(projectPath + '/', '')}`,
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
return checks;
|
|
1701
|
+
}
|
|
1124
1702
|
}
|
|
1125
1703
|
//# sourceMappingURL=app-create-orchestrator.js.map
|