tryassay 0.20.3 → 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 +4 -0
- package/dist/api/server.js +247 -5
- package/dist/api/server.js.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.d.ts +2 -0
- package/dist/commands/create.js +74 -0
- package/dist/commands/create.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 +46 -1
- package/dist/runtime/app-create-orchestrator.js +691 -7
- 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 +47 -0
- package/dist/runtime/functional-tester.js +775 -0
- package/dist/runtime/functional-tester.js.map +1 -0
- package/dist/runtime/plan-refiner.d.ts +14 -0
- package/dist/runtime/plan-refiner.js +160 -0
- package/dist/runtime/plan-refiner.js.map +1 -0
- package/dist/runtime/types.d.ts +126 -1
- package/package.json +1 -1
|
@@ -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';
|
|
@@ -17,6 +18,8 @@ import { IntegrationVerifier } from './integration-verifier.js';
|
|
|
17
18
|
import { CheckLoop } from './check-loop.js';
|
|
18
19
|
import { CheckStore } from './check-store.js';
|
|
19
20
|
import { FailureClassifier } from './failure-classifier.js';
|
|
21
|
+
import { PlanRefiner } from './plan-refiner.js';
|
|
22
|
+
import { FunctionalTester } from './functional-tester.js';
|
|
20
23
|
// ── Default safety policy ───────────────────────────────────
|
|
21
24
|
const DEFAULT_SAFETY = {
|
|
22
25
|
formalOverridesConsensus: true,
|
|
@@ -35,11 +38,32 @@ export class AppCreateOrchestrator extends EventEmitter {
|
|
|
35
38
|
options;
|
|
36
39
|
auditTrail = [];
|
|
37
40
|
startTime = 0;
|
|
41
|
+
planSummaryCache = null;
|
|
42
|
+
approvalResolve = null;
|
|
43
|
+
answersResolve = null;
|
|
38
44
|
constructor(description, options) {
|
|
39
45
|
super();
|
|
40
46
|
this.description = description;
|
|
41
47
|
this.options = options;
|
|
42
48
|
}
|
|
49
|
+
/** Get the current plan summary (available after planning phase). */
|
|
50
|
+
getPlanSummary() {
|
|
51
|
+
return this.planSummaryCache;
|
|
52
|
+
}
|
|
53
|
+
/** Approve or reject the plan. Resolves the blocked pipeline. */
|
|
54
|
+
approvePlan(result) {
|
|
55
|
+
if (this.approvalResolve) {
|
|
56
|
+
this.approvalResolve(result);
|
|
57
|
+
this.approvalResolve = null;
|
|
58
|
+
}
|
|
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
|
+
}
|
|
43
67
|
/** Run the full app creation pipeline. */
|
|
44
68
|
async run() {
|
|
45
69
|
this.startTime = Date.now();
|
|
@@ -49,19 +73,94 @@ export class AppCreateOrchestrator extends EventEmitter {
|
|
|
49
73
|
await mkdir(projectPath, { recursive: true });
|
|
50
74
|
await mkdir(join(projectPath, '.assay'), { recursive: true });
|
|
51
75
|
this.audit('task_status_change', { phase: 'initializing', projectPath });
|
|
52
|
-
// 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)
|
|
53
145
|
this.emitPhase({ phase: 'planning' });
|
|
54
|
-
|
|
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
|
+
}
|
|
153
|
+
let plan = await this.runPlanningPhase(projectPath);
|
|
55
154
|
if (!plan) {
|
|
56
155
|
this.emitPhase({ phase: 'failed', reason: 'Planning failed after retries' });
|
|
57
|
-
return this.makeResult('failed', projectPath, null, [], null, null, null);
|
|
156
|
+
return this.makeResult('failed', projectPath, null, [], null, null, null, null);
|
|
58
157
|
}
|
|
59
158
|
// Phase: verifying_plan
|
|
60
159
|
this.emitPhase({ phase: 'verifying_plan' });
|
|
61
160
|
const planValid = this.verifyPlanStructure(plan);
|
|
62
161
|
if (!planValid) {
|
|
63
162
|
this.emitPhase({ phase: 'failed', reason: 'Plan verification failed — invalid structure' });
|
|
64
|
-
return this.makeResult('failed', projectPath, plan, [], null, null, null);
|
|
163
|
+
return this.makeResult('failed', projectPath, plan, [], null, null, null, null);
|
|
65
164
|
}
|
|
66
165
|
this.audit('verification_completed', {
|
|
67
166
|
phase: 'verifying_plan',
|
|
@@ -69,6 +168,57 @@ export class AppCreateOrchestrator extends EventEmitter {
|
|
|
69
168
|
schema: plan.schema.length,
|
|
70
169
|
apiRoutes: plan.apiRoutes.length,
|
|
71
170
|
});
|
|
171
|
+
// Phase: requirements_refining (Plan Approval Gate)
|
|
172
|
+
{
|
|
173
|
+
this.emitPhase({ phase: 'requirements_refining' });
|
|
174
|
+
const refiner = new PlanRefiner(this.description);
|
|
175
|
+
const summary = refiner.generateSummary(plan);
|
|
176
|
+
this.planSummaryCache = summary;
|
|
177
|
+
if (this.options.autoApprove) {
|
|
178
|
+
// Auto-approve: validate heuristics, only pause if validation fails
|
|
179
|
+
const validation = refiner.validatePlan(plan);
|
|
180
|
+
this.audit('task_status_change', {
|
|
181
|
+
phase: 'requirements_refining',
|
|
182
|
+
mode: 'auto_approve',
|
|
183
|
+
valid: validation.valid,
|
|
184
|
+
warnings: validation.warnings,
|
|
185
|
+
});
|
|
186
|
+
if (!validation.valid) {
|
|
187
|
+
// Validation failed — fall through to interactive approval
|
|
188
|
+
this.emitPhase({ phase: 'awaiting_approval', planSummary: summary });
|
|
189
|
+
const approvalResult = await new Promise((resolve) => {
|
|
190
|
+
this.approvalResolve = resolve;
|
|
191
|
+
});
|
|
192
|
+
if (approvalResult.decision === 'rejected') {
|
|
193
|
+
this.emitPhase({ phase: 'failed', reason: approvalResult.reason });
|
|
194
|
+
return this.makeResult('failed', projectPath, plan, [], null, null, null, null);
|
|
195
|
+
}
|
|
196
|
+
if (approvalResult.decision === 'approved_with_modifications') {
|
|
197
|
+
plan = refiner.applyModifications(plan, approvalResult.modifications);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Auto-approve with valid heuristics — proceed
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
// Interactive mode: wait for human approval
|
|
204
|
+
this.emitPhase({ phase: 'awaiting_approval', planSummary: summary });
|
|
205
|
+
const approvalResult = await new Promise((resolve) => {
|
|
206
|
+
this.approvalResolve = resolve;
|
|
207
|
+
});
|
|
208
|
+
this.audit('task_status_change', {
|
|
209
|
+
phase: 'requirements_refining',
|
|
210
|
+
mode: 'interactive',
|
|
211
|
+
decision: approvalResult.decision,
|
|
212
|
+
});
|
|
213
|
+
if (approvalResult.decision === 'rejected') {
|
|
214
|
+
this.emitPhase({ phase: 'failed', reason: approvalResult.reason });
|
|
215
|
+
return this.makeResult('failed', projectPath, plan, [], null, null, null, null);
|
|
216
|
+
}
|
|
217
|
+
if (approvalResult.decision === 'approved_with_modifications') {
|
|
218
|
+
plan = refiner.applyModifications(plan, approvalResult.modifications);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
72
222
|
// Write plan to disk
|
|
73
223
|
await writeFile(join(projectPath, '.assay', 'architecture-plan.json'), JSON.stringify(plan, null, 2), 'utf-8');
|
|
74
224
|
// Phase: scaffolding
|
|
@@ -227,6 +377,36 @@ export class AppCreateOrchestrator extends EventEmitter {
|
|
|
227
377
|
}
|
|
228
378
|
}
|
|
229
379
|
}
|
|
380
|
+
// Phase: functional testing (HTTP-level tests with repair loop)
|
|
381
|
+
let functionalTestResult = null;
|
|
382
|
+
if (!this.options.skipFunctionalTesting && !this.options.skipBuildVerification) {
|
|
383
|
+
const maxFunctionalAttempts = this.options.maxBuildRepairAttempts ?? 3;
|
|
384
|
+
this.emitPhase({ phase: 'functional_testing', attempt: 1, maxAttempts: maxFunctionalAttempts });
|
|
385
|
+
const functionalTester = new FunctionalTester(projectPath, plan, {
|
|
386
|
+
maxRepairAttempts: maxFunctionalAttempts,
|
|
387
|
+
codeModel: this.options.models?.code,
|
|
388
|
+
framework: this.description.techStack.framework,
|
|
389
|
+
});
|
|
390
|
+
functionalTestResult = await functionalTester.test();
|
|
391
|
+
// Emit repair phases if repairs happened
|
|
392
|
+
for (const repair of functionalTestResult.repairAttempts) {
|
|
393
|
+
this.emitPhase({
|
|
394
|
+
phase: 'functional_repairing',
|
|
395
|
+
attempt: repair.attempt,
|
|
396
|
+
maxAttempts: maxFunctionalAttempts,
|
|
397
|
+
failureCount: repair.failures.length,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
this.audit('verification_completed', {
|
|
401
|
+
phase: 'functional_testing',
|
|
402
|
+
status: functionalTestResult.status,
|
|
403
|
+
passedCount: functionalTestResult.passedCount,
|
|
404
|
+
failedCount: functionalTestResult.failedCount,
|
|
405
|
+
repairAttempts: functionalTestResult.repairAttempts.length,
|
|
406
|
+
serverPort: functionalTestResult.serverPort,
|
|
407
|
+
totalDurationMs: functionalTestResult.totalDurationMs,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
230
410
|
// Phase: cross-verifying
|
|
231
411
|
this.emitPhase({ phase: 'cross_verifying' });
|
|
232
412
|
const crossVerification = await this.runCrossVerification(projectPath, plan);
|
|
@@ -241,9 +421,10 @@ export class AppCreateOrchestrator extends EventEmitter {
|
|
|
241
421
|
const anyCompleted = featureResults.some(r => r.status === 'completed');
|
|
242
422
|
const buildOk = !buildVerification || buildVerification.status === 'pass' || buildVerification.status === 'repaired' || buildVerification.status === 'skipped';
|
|
243
423
|
const integrationOk = !integrationVerification || integrationVerification.verdict === 'PASS';
|
|
244
|
-
const
|
|
424
|
+
const functionalOk = !functionalTestResult || functionalTestResult.status === 'pass' || functionalTestResult.status === 'repaired' || functionalTestResult.status === 'skipped';
|
|
425
|
+
const finalStatus = allCompleted && buildOk && integrationOk && functionalOk ? 'completed' : anyCompleted ? 'partial' : 'failed';
|
|
245
426
|
this.emitPhase({ phase: 'completed' });
|
|
246
|
-
return this.makeResult(finalStatus, projectPath, plan, featureResults, buildVerification, integrationVerification, crossVerification);
|
|
427
|
+
return this.makeResult(finalStatus, projectPath, plan, featureResults, buildVerification, integrationVerification, crossVerification, functionalTestResult);
|
|
247
428
|
}
|
|
248
429
|
// ── Planning Phase ─────────────────────────────────────────
|
|
249
430
|
async runPlanningPhase(projectPath) {
|
|
@@ -385,6 +566,7 @@ Include a .env.local file with placeholder values for all required environment v
|
|
|
385
566
|
try {
|
|
386
567
|
await mkdir(dirname(absPath), { recursive: true });
|
|
387
568
|
await writeFile(absPath, artifact.content, 'utf-8');
|
|
569
|
+
this.audit('code_generated', { path: artifact.path, language: artifact.language || this.inferLanguage(artifact.path), content: artifact.content });
|
|
388
570
|
}
|
|
389
571
|
catch {
|
|
390
572
|
// Best-effort write
|
|
@@ -431,6 +613,7 @@ Include a .env.local file with placeholder values for all required environment v
|
|
|
431
613
|
await mkdir(dirname(absPath), { recursive: true });
|
|
432
614
|
await writeFile(absPath, handoff.artifact.content, 'utf-8');
|
|
433
615
|
filesCreated.push(path);
|
|
616
|
+
this.audit('code_generated', { path, language: handoff.artifact.language || this.inferLanguage(path), content: handoff.artifact.content });
|
|
434
617
|
}
|
|
435
618
|
catch {
|
|
436
619
|
// Best-effort write
|
|
@@ -516,6 +699,27 @@ Include a .env.local file with placeholder values for all required environment v
|
|
|
516
699
|
});
|
|
517
700
|
}
|
|
518
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);
|
|
519
723
|
const passedCount = checks.filter(c => c.verdict === 'PASS').length;
|
|
520
724
|
const failedCount = checks.filter(c => c.verdict === 'FAIL').length;
|
|
521
725
|
return {
|
|
@@ -599,6 +803,7 @@ Include a .env.local file with placeholder values for all required environment v
|
|
|
599
803
|
await mkdir(dirname(absPath), { recursive: true });
|
|
600
804
|
await writeFile(absPath, artifact.content, 'utf-8');
|
|
601
805
|
filesCreated.push(artifact.path);
|
|
806
|
+
this.audit('code_generated', { path: artifact.path, language: artifact.language || this.inferLanguage(artifact.path), content: artifact.content });
|
|
602
807
|
}
|
|
603
808
|
catch {
|
|
604
809
|
// Best-effort write
|
|
@@ -892,7 +1097,7 @@ ${importNote}`;
|
|
|
892
1097
|
};
|
|
893
1098
|
this.emit('progress', progress);
|
|
894
1099
|
}
|
|
895
|
-
makeResult(status, projectPath, plan, featureResults, buildVerification, integrationVerification, crossVerification) {
|
|
1100
|
+
makeResult(status, projectPath, plan, featureResults, buildVerification, integrationVerification, crossVerification, functionalTestResult) {
|
|
896
1101
|
return {
|
|
897
1102
|
status,
|
|
898
1103
|
projectPath,
|
|
@@ -901,6 +1106,7 @@ ${importNote}`;
|
|
|
901
1106
|
buildVerification,
|
|
902
1107
|
integrationVerification,
|
|
903
1108
|
crossVerification,
|
|
1109
|
+
functionalTestResult,
|
|
904
1110
|
totalDurationMs: Date.now() - this.startTime,
|
|
905
1111
|
auditTrail: this.auditTrail,
|
|
906
1112
|
};
|
|
@@ -917,6 +1123,11 @@ ${importNote}`;
|
|
|
917
1123
|
this.auditTrail.push(entry);
|
|
918
1124
|
this.emit('audit', entry);
|
|
919
1125
|
}
|
|
1126
|
+
inferLanguage(filePath) {
|
|
1127
|
+
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
1128
|
+
const map = { ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', json: 'json', css: 'css', html: 'html', md: 'markdown', sql: 'sql', py: 'python' };
|
|
1129
|
+
return map[ext] || 'text';
|
|
1130
|
+
}
|
|
920
1131
|
slugify(name) {
|
|
921
1132
|
return name
|
|
922
1133
|
.toLowerCase()
|
|
@@ -1015,5 +1226,478 @@ The renderer process communicates with main via contextBridge APIs exposed in sr
|
|
|
1015
1226
|
}
|
|
1016
1227
|
return false;
|
|
1017
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
|
+
}
|
|
1018
1702
|
}
|
|
1019
1703
|
//# sourceMappingURL=app-create-orchestrator.js.map
|