popeye-cli 1.5.0 → 1.6.0

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.
Files changed (161) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +50 -8
  3. package/dist/cli/commands/create.d.ts.map +1 -1
  4. package/dist/cli/commands/create.js +54 -4
  5. package/dist/cli/commands/create.js.map +1 -1
  6. package/dist/cli/interactive.d.ts +29 -0
  7. package/dist/cli/interactive.d.ts.map +1 -1
  8. package/dist/cli/interactive.js +90 -7
  9. package/dist/cli/interactive.js.map +1 -1
  10. package/dist/generators/all.d.ts +4 -1
  11. package/dist/generators/all.d.ts.map +1 -1
  12. package/dist/generators/all.js +36 -316
  13. package/dist/generators/all.js.map +1 -1
  14. package/dist/generators/doc-parser.d.ts +18 -3
  15. package/dist/generators/doc-parser.d.ts.map +1 -1
  16. package/dist/generators/doc-parser.js +81 -10
  17. package/dist/generators/doc-parser.js.map +1 -1
  18. package/dist/generators/frontend-design-analyzer.d.ts +30 -0
  19. package/dist/generators/frontend-design-analyzer.d.ts.map +1 -0
  20. package/dist/generators/frontend-design-analyzer.js +208 -0
  21. package/dist/generators/frontend-design-analyzer.js.map +1 -0
  22. package/dist/generators/shared-packages.d.ts +45 -0
  23. package/dist/generators/shared-packages.d.ts.map +1 -0
  24. package/dist/generators/shared-packages.js +456 -0
  25. package/dist/generators/shared-packages.js.map +1 -0
  26. package/dist/generators/templates/index.d.ts +4 -0
  27. package/dist/generators/templates/index.d.ts.map +1 -1
  28. package/dist/generators/templates/index.js +4 -0
  29. package/dist/generators/templates/index.js.map +1 -1
  30. package/dist/generators/templates/website-components.d.ts.map +1 -1
  31. package/dist/generators/templates/website-components.js +36 -11
  32. package/dist/generators/templates/website-components.js.map +1 -1
  33. package/dist/generators/templates/website-config.d.ts +15 -1
  34. package/dist/generators/templates/website-config.d.ts.map +1 -1
  35. package/dist/generators/templates/website-config.js +155 -13
  36. package/dist/generators/templates/website-config.js.map +1 -1
  37. package/dist/generators/templates/website-landing.d.ts +24 -0
  38. package/dist/generators/templates/website-landing.d.ts.map +1 -0
  39. package/dist/generators/templates/website-landing.js +276 -0
  40. package/dist/generators/templates/website-landing.js.map +1 -0
  41. package/dist/generators/templates/website-layout.d.ts +42 -0
  42. package/dist/generators/templates/website-layout.d.ts.map +1 -0
  43. package/dist/generators/templates/website-layout.js +408 -0
  44. package/dist/generators/templates/website-layout.js.map +1 -0
  45. package/dist/generators/templates/website-pricing.d.ts +11 -0
  46. package/dist/generators/templates/website-pricing.d.ts.map +1 -0
  47. package/dist/generators/templates/website-pricing.js +313 -0
  48. package/dist/generators/templates/website-pricing.js.map +1 -0
  49. package/dist/generators/templates/website-sections.d.ts +102 -0
  50. package/dist/generators/templates/website-sections.d.ts.map +1 -0
  51. package/dist/generators/templates/website-sections.js +444 -0
  52. package/dist/generators/templates/website-sections.js.map +1 -0
  53. package/dist/generators/templates/website.d.ts +10 -50
  54. package/dist/generators/templates/website.d.ts.map +1 -1
  55. package/dist/generators/templates/website.js +12 -788
  56. package/dist/generators/templates/website.js.map +1 -1
  57. package/dist/generators/website-content-scanner.d.ts +37 -0
  58. package/dist/generators/website-content-scanner.d.ts.map +1 -0
  59. package/dist/generators/website-content-scanner.js +165 -0
  60. package/dist/generators/website-content-scanner.js.map +1 -0
  61. package/dist/generators/website-context.d.ts +38 -2
  62. package/dist/generators/website-context.d.ts.map +1 -1
  63. package/dist/generators/website-context.js +179 -19
  64. package/dist/generators/website-context.js.map +1 -1
  65. package/dist/generators/website-debug.d.ts +68 -0
  66. package/dist/generators/website-debug.d.ts.map +1 -0
  67. package/dist/generators/website-debug.js +93 -0
  68. package/dist/generators/website-debug.js.map +1 -0
  69. package/dist/generators/website.d.ts +2 -0
  70. package/dist/generators/website.d.ts.map +1 -1
  71. package/dist/generators/website.js +66 -4
  72. package/dist/generators/website.js.map +1 -1
  73. package/dist/generators/workspace-root.d.ts +27 -0
  74. package/dist/generators/workspace-root.d.ts.map +1 -0
  75. package/dist/generators/workspace-root.js +100 -0
  76. package/dist/generators/workspace-root.js.map +1 -0
  77. package/dist/state/index.d.ts +8 -0
  78. package/dist/state/index.d.ts.map +1 -1
  79. package/dist/state/index.js +10 -0
  80. package/dist/state/index.js.map +1 -1
  81. package/dist/types/workflow.d.ts +6 -0
  82. package/dist/types/workflow.d.ts.map +1 -1
  83. package/dist/types/workflow.js +2 -0
  84. package/dist/types/workflow.js.map +1 -1
  85. package/dist/upgrade/handlers.d.ts +15 -0
  86. package/dist/upgrade/handlers.d.ts.map +1 -1
  87. package/dist/upgrade/handlers.js +52 -0
  88. package/dist/upgrade/handlers.js.map +1 -1
  89. package/dist/workflow/auto-fix-bundler.d.ts +37 -0
  90. package/dist/workflow/auto-fix-bundler.d.ts.map +1 -0
  91. package/dist/workflow/auto-fix-bundler.js +320 -0
  92. package/dist/workflow/auto-fix-bundler.js.map +1 -0
  93. package/dist/workflow/auto-fix.d.ts.map +1 -1
  94. package/dist/workflow/auto-fix.js +10 -3
  95. package/dist/workflow/auto-fix.js.map +1 -1
  96. package/dist/workflow/index.d.ts +1 -0
  97. package/dist/workflow/index.d.ts.map +1 -1
  98. package/dist/workflow/index.js +12 -0
  99. package/dist/workflow/index.js.map +1 -1
  100. package/dist/workflow/overview.d.ts.map +1 -1
  101. package/dist/workflow/overview.js +4 -0
  102. package/dist/workflow/overview.js.map +1 -1
  103. package/dist/workflow/plan-mode.d.ts +4 -3
  104. package/dist/workflow/plan-mode.d.ts.map +1 -1
  105. package/dist/workflow/plan-mode.js +69 -5
  106. package/dist/workflow/plan-mode.js.map +1 -1
  107. package/dist/workflow/website-strategy.d.ts +9 -0
  108. package/dist/workflow/website-strategy.d.ts.map +1 -1
  109. package/dist/workflow/website-strategy.js +73 -1
  110. package/dist/workflow/website-strategy.js.map +1 -1
  111. package/dist/workflow/website-updater.d.ts.map +1 -1
  112. package/dist/workflow/website-updater.js +15 -4
  113. package/dist/workflow/website-updater.js.map +1 -1
  114. package/package.json +1 -1
  115. package/src/cli/commands/create.ts +58 -4
  116. package/src/cli/interactive.ts +96 -7
  117. package/src/generators/all.ts +44 -332
  118. package/src/generators/doc-parser.ts +87 -10
  119. package/src/generators/frontend-design-analyzer.ts +261 -0
  120. package/src/generators/shared-packages.ts +500 -0
  121. package/src/generators/templates/index.ts +4 -0
  122. package/src/generators/templates/website-components.ts +36 -11
  123. package/src/generators/templates/website-config.ts +166 -13
  124. package/src/generators/templates/website-landing.ts +331 -0
  125. package/src/generators/templates/website-layout.ts +443 -0
  126. package/src/generators/templates/website-pricing.ts +330 -0
  127. package/src/generators/templates/website-sections.ts +541 -0
  128. package/src/generators/templates/website.ts +38 -851
  129. package/src/generators/website-content-scanner.ts +208 -0
  130. package/src/generators/website-context.ts +248 -20
  131. package/src/generators/website-debug.ts +130 -0
  132. package/src/generators/website.ts +71 -3
  133. package/src/generators/workspace-root.ts +113 -0
  134. package/src/state/index.ts +14 -0
  135. package/src/types/workflow.ts +6 -0
  136. package/src/upgrade/handlers.ts +65 -0
  137. package/src/workflow/auto-fix-bundler.ts +392 -0
  138. package/src/workflow/auto-fix.ts +11 -3
  139. package/src/workflow/index.ts +12 -0
  140. package/src/workflow/overview.ts +6 -0
  141. package/src/workflow/plan-mode.ts +81 -7
  142. package/src/workflow/website-strategy.ts +75 -1
  143. package/src/workflow/website-updater.ts +17 -6
  144. package/tests/cli/project-naming.test.ts +136 -0
  145. package/tests/generators/doc-parser.test.ts +121 -0
  146. package/tests/generators/frontend-design-analyzer.test.ts +90 -0
  147. package/tests/generators/quality-gate.test.ts +183 -0
  148. package/tests/generators/shared-packages.test.ts +83 -0
  149. package/tests/generators/website-components.test.ts +1 -1
  150. package/tests/generators/website-config.test.ts +84 -0
  151. package/tests/generators/website-content-scanner.test.ts +181 -0
  152. package/tests/generators/website-context.test.ts +109 -0
  153. package/tests/generators/website-debug.test.ts +77 -0
  154. package/tests/generators/website-landing.test.ts +188 -0
  155. package/tests/generators/website-pricing.test.ts +98 -0
  156. package/tests/generators/website-sections.test.ts +245 -0
  157. package/tests/generators/workspace-root.test.ts +105 -0
  158. package/tests/upgrade/handlers.test.ts +162 -0
  159. package/tests/workflow/auto-fix-bundler.test.ts +242 -0
  160. package/tests/workflow/plan-mode.test.ts +111 -1
  161. package/tests/workflow/website-strategy.test.ts +55 -0
@@ -70,7 +70,7 @@ Analyze the following product documentation and generate a complete website mark
70
70
  PRODUCT NAME: ${input.projectName}
71
71
 
72
72
  PRODUCT DOCUMENTATION:
73
- ${input.productContext.slice(0, 8000)}
73
+ ${packProductContext(input.productContext)}
74
74
  ${competitorsBlock}${keywordsBlock}${marketNotesBlock}
75
75
 
76
76
  Generate a JSON response matching this exact structure:
@@ -290,6 +290,80 @@ export async function isStrategyStale(
290
290
  return stored.metadata.inputHash !== currentHash;
291
291
  }
292
292
 
293
+ /**
294
+ * Pack product context into a budget with priority-based ordering
295
+ * Ensures high-priority docs (spec, pricing, brand) are included first
296
+ *
297
+ * @param productContext - Raw concatenated docs with "--- filename ---" headers
298
+ * @param budget - Maximum character budget (default 16000)
299
+ * @returns Packed context string within budget
300
+ */
301
+ export function packProductContext(productContext: string, budget: number = 16000): string {
302
+ // Split by doc headers (--- filename ---)
303
+ const headerPattern = /^---\s+(.+?)\s+---$/gm;
304
+ const sections: Array<{ header: string; content: string; priority: number }> = [];
305
+ let lastIndex = 0;
306
+ let lastHeader = '';
307
+ let match;
308
+
309
+ while ((match = headerPattern.exec(productContext)) !== null) {
310
+ if (lastIndex > 0) {
311
+ sections.push({
312
+ header: lastHeader,
313
+ content: productContext.slice(lastIndex, match.index).trim(),
314
+ priority: getDocSortPriority(lastHeader),
315
+ });
316
+ }
317
+ lastHeader = match[0];
318
+ lastIndex = match.index + match[0].length;
319
+ }
320
+ // Push the last section
321
+ if (lastIndex > 0 && lastIndex < productContext.length) {
322
+ sections.push({
323
+ header: lastHeader,
324
+ content: productContext.slice(lastIndex).trim(),
325
+ priority: getDocSortPriority(lastHeader),
326
+ });
327
+ }
328
+
329
+ // If no headers found, return the raw context trimmed to budget
330
+ if (sections.length === 0) {
331
+ return productContext.slice(0, budget);
332
+ }
333
+
334
+ // Sort by priority (lower = more important)
335
+ sections.sort((a, b) => a.priority - b.priority);
336
+
337
+ // Pack into budget
338
+ let packed = '';
339
+ for (const section of sections) {
340
+ const block = `${section.header}\n${section.content}\n\n`;
341
+ if (packed.length + block.length <= budget) {
342
+ packed += block;
343
+ } else {
344
+ const remaining = budget - packed.length;
345
+ if (remaining > 200) {
346
+ packed += `${section.header}\n${section.content.slice(0, remaining - section.header.length - 10)}...\n`;
347
+ }
348
+ break;
349
+ }
350
+ }
351
+
352
+ return packed.trim();
353
+ }
354
+
355
+ /**
356
+ * Get sort priority for a doc section header (lower = higher priority)
357
+ */
358
+ function getDocSortPriority(header: string): number {
359
+ const lower = header.toLowerCase();
360
+ if (/spec/i.test(lower)) return 1;
361
+ if (/pricing/i.test(lower)) return 2;
362
+ if (/color|brand/i.test(lower)) return 3;
363
+ if (/feature/i.test(lower)) return 4;
364
+ return 5;
365
+ }
366
+
293
367
  /**
294
368
  * Compute SHA-256 hash of strategy inputs for staleness detection
295
369
  */
@@ -9,13 +9,14 @@ import path from 'node:path';
9
9
  import type { ProjectState } from '../types/workflow.js';
10
10
  import type { OutputLanguage } from '../types/project.js';
11
11
  import { isWorkspace } from '../types/project.js';
12
- import { buildWebsiteContext } from '../generators/website-context.js';
12
+ import { buildWebsiteContext, resolveBrandAssets, validateWebsiteContext } from '../generators/website-context.js';
13
+ import { resolveWorkspaceRoot } from '../generators/workspace-root.js';
14
+ import { generateWebsiteLandingPage } from '../generators/templates/website-landing.js';
15
+ import { generateWebsitePricingPage } from '../generators/templates/website-pricing.js';
13
16
  import {
14
- generateWebsiteLandingPage,
15
- generateWebsitePricingPage,
16
17
  generateWebsiteLayout,
17
18
  generateWebsiteGlobalsCss,
18
- } from '../generators/templates/website.js';
19
+ } from '../generators/templates/website-layout.js';
19
20
  import {
20
21
  generateWebsiteHeader,
21
22
  generateWebsiteFooter,
@@ -71,6 +72,10 @@ export async function updateWebsiteContent(
71
72
  };
72
73
  }
73
74
 
75
+ // Resolve brand assets using workspace root for proper logo resolution
76
+ const workspaceRoot = await resolveWorkspaceRoot(parentDir);
77
+ context.brandAssets = await resolveBrandAssets(workspaceRoot, context.brand);
78
+
74
79
  // Load website strategy if available
75
80
  const strategyData = await loadWebsiteStrategy(projectDir);
76
81
  if (strategyData) {
@@ -78,6 +83,12 @@ export async function updateWebsiteContent(
78
83
  onProgress?.('Loaded website strategy for content update');
79
84
  }
80
85
 
86
+ // Soft validation: report quality issues via progress callback
87
+ const validation = validateWebsiteContext(context, state.name);
88
+ for (const msg of [...validation.issues, ...validation.warnings]) {
89
+ onProgress?.(`[quality-gate] ${msg}`);
90
+ }
91
+
81
92
  onProgress?.('Updating website content with project context...');
82
93
 
83
94
  // Re-generate content files
@@ -116,11 +127,11 @@ export async function updateWebsiteContent(
116
127
  }
117
128
  }
118
129
 
119
- // Copy logo to public/ if brand context has one
130
+ // Copy logo to public/brand/ if brand context has one
120
131
  if (context.brand?.logoPath) {
121
132
  try {
122
133
  const logoExt = path.extname(context.brand.logoPath);
123
- const destPath = path.join(websiteDir, 'public', `logo${logoExt}`);
134
+ const destPath = path.join(websiteDir, 'public', 'brand', `logo${logoExt}`);
124
135
  await fs.copyFile(context.brand.logoPath, destPath);
125
136
  } catch {
126
137
  // Non-blocking
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tests for project naming logic
3
+ * Verifies CWD-aware naming, doc-derived names, and fallback behavior
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import {
11
+ generateProjectName,
12
+ generateProjectNameFromIdea,
13
+ extractNameFromDocs,
14
+ } from '../../src/cli/interactive.js';
15
+
16
+ let tmpDir: string;
17
+
18
+ beforeEach(async () => {
19
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-naming-'));
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await fs.rm(tmpDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe('generateProjectNameFromIdea', () => {
27
+ it('should extract explicit project name patterns', () => {
28
+ // "named" pattern extracts the word after it
29
+ expect(generateProjectNameFromIdea("Create an app named 'Gateco'")).toBe('gateco');
30
+ // "called" pattern extracts the word after it
31
+ expect(generateProjectNameFromIdea('Build something called TodoApp')).toBe('todo-app');
32
+ });
33
+
34
+ it('should extract CamelCase project names', () => {
35
+ // Without "for" keyword that triggers the explicit pattern, CamelCase is detected
36
+ expect(generateProjectNameFromIdea('Build TodoMaster with style')).toBe('todo-master');
37
+ });
38
+
39
+ it('should filter out action words from prompt text', () => {
40
+ // "read all files" should NOT produce "read-all-files" since those are stop words
41
+ const result = generateProjectNameFromIdea('read all files');
42
+ expect(result).not.toBe('read-all-files');
43
+ // Meaningful word extraction filters all three, but the fallback takes first 2 raw words
44
+ expect(result).toBe('read-all');
45
+ });
46
+
47
+ it('should extract meaningful words when no explicit name found', () => {
48
+ const result = generateProjectNameFromIdea('secure enterprise authentication system');
49
+ expect(result).toBe('secure-enterprise-authentication');
50
+ });
51
+
52
+ it('should return my-project as last resort', () => {
53
+ expect(generateProjectNameFromIdea('')).toBe('my-project');
54
+ });
55
+ });
56
+
57
+ describe('extractNameFromDocs', () => {
58
+ it('should extract product name from markdown heading', async () => {
59
+ const docPath = path.join(tmpDir, 'Gateco-spec.md');
60
+ await fs.writeFile(docPath, '# Gateco\n\nSome description here.');
61
+
62
+ const name = await extractNameFromDocs(tmpDir);
63
+ expect(name).toBe('Gateco');
64
+ });
65
+
66
+ it('should skip README files', async () => {
67
+ await fs.writeFile(path.join(tmpDir, 'README.md'), '# MyProject\n\nDescription.');
68
+
69
+ const name = await extractNameFromDocs(tmpDir);
70
+ expect(name).toBeNull();
71
+ });
72
+
73
+ it('should return null when no docs found', async () => {
74
+ const name = await extractNameFromDocs(tmpDir);
75
+ expect(name).toBeNull();
76
+ });
77
+
78
+ it('should return null for non-existent directory', async () => {
79
+ const name = await extractNameFromDocs('/nonexistent/path/123456');
80
+ expect(name).toBeNull();
81
+ });
82
+
83
+ it('should ignore generic headings', async () => {
84
+ // "Home" is too short, but length >= 3 so it would pass... let's test with a generic dir name
85
+ await fs.writeFile(path.join(tmpDir, 'spec.md'), '# Src\n\nDescription.');
86
+
87
+ const name = await extractNameFromDocs(tmpDir);
88
+ // "Src" is in GENERIC_DIR_NAMES
89
+ expect(name).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe('generateProjectName (CWD-aware)', () => {
94
+ it('should prefer doc-derived name over CWD basename', async () => {
95
+ const projectDir = path.join(tmpDir, 'SomeDir');
96
+ await fs.mkdir(projectDir, { recursive: true });
97
+ await fs.writeFile(
98
+ path.join(projectDir, 'product-spec.md'),
99
+ '# Gateco\n\nA security layer for AI.'
100
+ );
101
+
102
+ const name = await generateProjectName('read all files', projectDir);
103
+ expect(name).toBe('gateco');
104
+ });
105
+
106
+ it('should use CWD basename when no docs found', async () => {
107
+ const projectDir = path.join(tmpDir, 'MyAwesomeApp');
108
+ await fs.mkdir(projectDir, { recursive: true });
109
+
110
+ const name = await generateProjectName('read all files', projectDir);
111
+ expect(name).toBe('my-awesome-app');
112
+ });
113
+
114
+ it('should skip generic CWD names and fall back to idea', async () => {
115
+ const projectDir = path.join(tmpDir, 'Projects');
116
+ await fs.mkdir(projectDir, { recursive: true });
117
+
118
+ // "Projects" is a generic dir name, so falls back to idea extraction
119
+ // The idea contains "named SuperApp" pattern -> extracts "SuperApp"
120
+ const name = await generateProjectName('Build an app named SuperApp', projectDir);
121
+ expect(name).toBe('super-app');
122
+ });
123
+
124
+ it('should fall back to idea extraction when no CWD provided', async () => {
125
+ const name = await generateProjectName('Build an app called Gateco');
126
+ expect(name).toBe('gateco');
127
+ });
128
+
129
+ it('should handle CamelCase CWD basenames', async () => {
130
+ const projectDir = path.join(tmpDir, 'MyGateco');
131
+ await fs.mkdir(projectDir, { recursive: true });
132
+
133
+ const name = await generateProjectName('some idea', projectDir);
134
+ expect(name).toBe('my-gateco');
135
+ });
136
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Tests for doc-parser module
3
+ * Verifies feature extraction, dev-task filtering, docs-first priority
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import {
8
+ extractFeatures,
9
+ extractProductName,
10
+ extractTagline,
11
+ extractDescription,
12
+ extractPrimaryColor,
13
+ extractPricing,
14
+ isSuspiciousProductName,
15
+ } from '../../src/generators/doc-parser.js';
16
+
17
+ describe('extractFeatures', () => {
18
+ it('extracts features from docs bullet list', () => {
19
+ const docs = `# Product\n## Features\n- **Fast Search** - Lightning fast search\n- **Auth** - Built-in authentication`;
20
+ const features = extractFeatures(docs);
21
+ expect(features.length).toBeGreaterThanOrEqual(2);
22
+ expect(features[0].title).toBe('Fast Search');
23
+ });
24
+
25
+ it('extracts from docs before specification', () => {
26
+ const docs = `# Product\n## Features\n- **Real Feature** - From docs`;
27
+ const spec = `## Features\n- Implement login page\n- Fix CSS bug`;
28
+ const features = extractFeatures(docs, spec);
29
+ // Should use docs features, not spec dev tasks
30
+ expect(features[0].title).toBe('Real Feature');
31
+ });
32
+
33
+ it('falls back to specification when docs have no features', () => {
34
+ const docs = `# Product\n## About\nJust a description.`;
35
+ const spec = `## Core Design Principles\n1. **Vector DB agnostic**\n2. **Embedding agnostic**`;
36
+ const features = extractFeatures(docs, spec);
37
+ expect(features.length).toBeGreaterThanOrEqual(2);
38
+ expect(features[0].title).toContain('Vector DB');
39
+ });
40
+
41
+ it('filters out dev-task verbs', () => {
42
+ const docs = `# Tasks\n## Features\n- Implement user auth\n- Fix broken login\n- **Real Feature** - Does real stuff\n- Refactor database layer`;
43
+ const features = extractFeatures(docs);
44
+ // Dev tasks should be filtered out
45
+ expect(features.some(f => f.title.startsWith('Implement'))).toBe(false);
46
+ expect(features.some(f => f.title.startsWith('Fix'))).toBe(false);
47
+ expect(features.some(f => f.title.startsWith('Refactor'))).toBe(false);
48
+ // Real feature should remain
49
+ expect(features.some(f => f.title === 'Real Feature')).toBe(true);
50
+ });
51
+
52
+ it('returns empty when no feature sections exist', () => {
53
+ const docs = `# README\nJust a readme file.`;
54
+ const features = extractFeatures(docs);
55
+ expect(features).toEqual([]);
56
+ });
57
+
58
+ it('limits features to 6', () => {
59
+ const items = Array.from({ length: 10 }, (_, i) => `- **Feature ${i + 1}** - Description ${i + 1}`).join('\n');
60
+ const docs = `## Features\n${items}`;
61
+ const features = extractFeatures(docs);
62
+ expect(features.length).toBeLessThanOrEqual(6);
63
+ });
64
+ });
65
+
66
+ describe('extractProductName', () => {
67
+ it('extracts from "# Name -- tagline" heading', () => {
68
+ const docs = '# Gateco -- Permission-Aware Retrieval';
69
+ expect(extractProductName(docs)).toBe('Gateco');
70
+ });
71
+
72
+ it('picks shortest name when multiple headings', () => {
73
+ const docs = '# Gateco -- main product\n# Gateco UI Color System -- colors';
74
+ expect(extractProductName(docs)).toBe('Gateco');
75
+ });
76
+
77
+ it('returns undefined when no match', () => {
78
+ expect(extractProductName('Just some text')).toBeUndefined();
79
+ });
80
+ });
81
+
82
+ describe('extractPrimaryColor', () => {
83
+ it('extracts accent-primary token', () => {
84
+ const docs = '| `accent-primary` | `#2563EB` | Primary CTA |';
85
+ expect(extractPrimaryColor(docs)).toBe('#2563EB');
86
+ });
87
+
88
+ it('skips dark background colors', () => {
89
+ const docs = '| bg | #0F172A |\n| accent-primary | #2563EB |';
90
+ expect(extractPrimaryColor(docs)).toBe('#2563EB');
91
+ });
92
+ });
93
+
94
+ describe('isSuspiciousProductName', () => {
95
+ it('flags directory-like names', () => {
96
+ expect(isSuspiciousProductName('my-cool-project')).toBe(true);
97
+ expect(isSuspiciousProductName('my-app')).toBe(true);
98
+ });
99
+
100
+ it('allows real product names', () => {
101
+ expect(isSuspiciousProductName('Gateco')).toBe(false);
102
+ expect(isSuspiciousProductName('SuperApp')).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe('extractPricing', () => {
107
+ it('extracts pricing tiers from markdown table', () => {
108
+ const docs = `## Pricing\n| Plan | Price |\n|---|---|\n| Free | Free |\n| Pro | $99/month minimum |\n| Enterprise | Custom pricing |`;
109
+ const tiers = extractPricing(docs);
110
+ expect(tiers).toBeDefined();
111
+ expect(tiers!.length).toBe(3);
112
+ expect(tiers![0].name).toContain('Free');
113
+ expect(tiers![1].price).toBe('$99');
114
+ expect(tiers![2].price).toBe('Custom');
115
+ });
116
+
117
+ it('returns undefined when no pricing found', () => {
118
+ const docs = '# Product\nJust a product.';
119
+ expect(extractPricing(docs)).toBeUndefined();
120
+ });
121
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tests for frontend design language analyzer
3
+ * Verifies CSS variable extraction, component library detection, and tailwind parsing
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { analyzeFrontendDesign } from '../../src/generators/frontend-design-analyzer.js';
11
+
12
+ describe('analyzeFrontendDesign', () => {
13
+ let tmpDir: string;
14
+
15
+ beforeEach(async () => {
16
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-fe-design-'));
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await fs.rm(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ it('returns null when no frontend app directory exists', async () => {
24
+ const result = await analyzeFrontendDesign(tmpDir);
25
+
26
+ expect(result).toBeNull();
27
+ });
28
+
29
+ it('extracts CSS custom properties from index.css', async () => {
30
+ const frontendDir = path.join(tmpDir, 'apps', 'frontend', 'src');
31
+ await fs.mkdir(frontendDir, { recursive: true });
32
+ await fs.writeFile(
33
+ path.join(frontendDir, 'index.css'),
34
+ `:root {
35
+ --primary: 222.2 47.4% 11.2%;
36
+ --radius: 0.5rem;
37
+ }
38
+
39
+ .dark {
40
+ --primary: 210 40% 98%;
41
+ }
42
+ `
43
+ );
44
+ // Need package.json for component lib detection
45
+ await fs.writeFile(
46
+ path.join(tmpDir, 'apps', 'frontend', 'package.json'),
47
+ JSON.stringify({ dependencies: { '@shadcn/ui': '^1.0.0' } })
48
+ );
49
+
50
+ const result = await analyzeFrontendDesign(tmpDir);
51
+
52
+ expect(result).not.toBeNull();
53
+ expect(result!.primaryColor).toBeDefined();
54
+ expect(result!.borderRadius).toBe('0.5rem');
55
+ expect(result!.darkMode).toBe(true);
56
+ expect(result!.source).toBe('css-variables');
57
+ });
58
+
59
+ it('detects shadcn component library from package.json', async () => {
60
+ const frontendDir = path.join(tmpDir, 'apps', 'frontend');
61
+ await fs.mkdir(frontendDir, { recursive: true });
62
+ await fs.writeFile(
63
+ path.join(frontendDir, 'package.json'),
64
+ JSON.stringify({
65
+ dependencies: { '@shadcn/ui': '^1.0.0', react: '^18.0.0' },
66
+ })
67
+ );
68
+
69
+ const result = await analyzeFrontendDesign(tmpDir);
70
+
71
+ expect(result).not.toBeNull();
72
+ expect(result!.componentLibrary).toBe('shadcn');
73
+ });
74
+
75
+ it('detects MUI component library', async () => {
76
+ const frontendDir = path.join(tmpDir, 'apps', 'frontend');
77
+ await fs.mkdir(frontendDir, { recursive: true });
78
+ await fs.writeFile(
79
+ path.join(frontendDir, 'package.json'),
80
+ JSON.stringify({
81
+ dependencies: { '@mui/material': '^5.0.0', react: '^18.0.0' },
82
+ })
83
+ );
84
+
85
+ const result = await analyzeFrontendDesign(tmpDir);
86
+
87
+ expect(result).not.toBeNull();
88
+ expect(result!.componentLibrary).toBe('mui');
89
+ });
90
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tests for website content quality gate
3
+ * Verifies that validateWebsiteContextOrThrow blocks generic websites
4
+ * and validateWebsiteContext returns structured soft validation results
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import {
9
+ validateWebsiteContextOrThrow,
10
+ validateWebsiteContext,
11
+ } from '../../src/generators/website-context.js';
12
+ import type { WebsiteContentContext } from '../../src/generators/website-context.js';
13
+
14
+ function makeContext(overrides: Partial<WebsiteContentContext> = {}): WebsiteContentContext {
15
+ return {
16
+ productName: 'Gateco',
17
+ features: [
18
+ { title: 'Access Control', description: 'Fine-grained permissions' },
19
+ { title: 'Vector DB Agnostic', description: 'Works with any vector database' },
20
+ ],
21
+ rawDocs: '--- spec.md ---\n# Gateco\nLong enough content to pass validation threshold.\n' + 'x'.repeat(100),
22
+ strategy: {
23
+ icp: { primaryPersona: 'devs', painPoints: [], goals: [], objections: [] },
24
+ positioning: { category: 'Security', differentiators: [], valueProposition: 'Secure AI', proofPoints: [] },
25
+ messaging: { headline: 'h', subheadline: 's', elevatorPitch: 'e', longDescription: 'l' },
26
+ seoStrategy: { primaryKeywords: [], secondaryKeywords: [], longTailKeywords: [], titleTemplates: {}, metaDescriptions: {} },
27
+ siteArchitecture: { pages: [], navigation: [], footerSections: [] },
28
+ conversionStrategy: { primaryCta: { text: 'Go', href: '/' }, secondaryCta: { text: 'More', href: '/' }, trustSignals: [], socialProof: [], leadCapture: 'none' },
29
+ competitiveContext: { category: 'sec', competitors: [], differentiators: [] },
30
+ },
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ describe('validateWebsiteContextOrThrow', () => {
36
+ it('passes validation with valid product context', () => {
37
+ const context = makeContext();
38
+
39
+ expect(() => validateWebsiteContextOrThrow(context, 'gateco')).not.toThrow();
40
+ });
41
+
42
+ it('throws on suspicious product name (directory-like)', () => {
43
+ const context = makeContext({ productName: 'read-all-files' });
44
+
45
+ expect(() => validateWebsiteContextOrThrow(context, 'read-all-files')).toThrow(
46
+ /looks like a directory name/
47
+ );
48
+ });
49
+
50
+ it('throws when no docs found', () => {
51
+ const context = makeContext({ rawDocs: '' });
52
+
53
+ expect(() => validateWebsiteContextOrThrow(context, 'test')).toThrow(
54
+ /No project documentation found/
55
+ );
56
+ });
57
+
58
+ it('throws when no features extracted', () => {
59
+ const context = makeContext({ features: [] });
60
+
61
+ expect(() => validateWebsiteContextOrThrow(context, 'test')).toThrow(
62
+ /No product features extracted/
63
+ );
64
+ });
65
+
66
+ it('includes POPEYE_DEBUG_WEBSITE hint in error message', () => {
67
+ const context = makeContext({ rawDocs: '' });
68
+
69
+ try {
70
+ validateWebsiteContextOrThrow(context, 'test');
71
+ expect.fail('Should have thrown');
72
+ } catch (error) {
73
+ expect((error as Error).message).toContain('POPEYE_DEBUG_WEBSITE=1');
74
+ }
75
+ });
76
+
77
+ it('still throws when passed is false from validateWebsiteContext', () => {
78
+ // Context with multiple blocking issues
79
+ const context = makeContext({
80
+ productName: 'my-app',
81
+ rawDocs: '',
82
+ features: [],
83
+ strategy: undefined,
84
+ });
85
+
86
+ expect(() => validateWebsiteContextOrThrow(context, 'my-app')).toThrow(
87
+ /Website generation blocked/
88
+ );
89
+ });
90
+ });
91
+
92
+ describe('validateWebsiteContext (soft mode)', () => {
93
+ it('returns passed=true with no issues for valid context', () => {
94
+ const context = makeContext({ tagline: 'Secure your AI' });
95
+ const result = validateWebsiteContext(context, 'gateco');
96
+
97
+ expect(result.passed).toBe(true);
98
+ expect(result.issues).toHaveLength(0);
99
+ expect(result.contentScore).toBeGreaterThanOrEqual(90);
100
+ });
101
+
102
+ it('returns warnings without marking passed=false', () => {
103
+ // Valid context but missing tagline triggers a warning, not an issue
104
+ const context = makeContext({ tagline: undefined });
105
+ const result = validateWebsiteContext(context, 'gateco');
106
+
107
+ expect(result.passed).toBe(true);
108
+ expect(result.warnings.length).toBeGreaterThan(0);
109
+ expect(result.warnings.some((w) => /tagline/i.test(w))).toBe(true);
110
+ });
111
+
112
+ it('detects default pricing fingerprint as a warning', () => {
113
+ const context = makeContext({
114
+ pricing: [
115
+ { name: 'Free', price: '$0', description: 'Basic', features: ['1 user'], cta: 'Start' },
116
+ { name: 'Pro', price: '$29', description: 'Team', features: ['10 users'], cta: 'Go', featured: true },
117
+ { name: 'Enterprise', price: 'Custom', description: 'Scale', features: ['Unlimited'], cta: 'Contact' },
118
+ ],
119
+ });
120
+ const result = validateWebsiteContext(context, 'gateco');
121
+
122
+ expect(result.passed).toBe(true);
123
+ expect(result.warnings.some((w) => /default values/i.test(w))).toBe(true);
124
+ });
125
+
126
+ it('does not flag custom pricing as default', () => {
127
+ const context = makeContext({
128
+ pricing: [
129
+ { name: 'Hobby', price: '$9', description: 'For side projects', features: ['5 APIs'], cta: 'Start' },
130
+ { name: 'Team', price: '$49', description: 'For teams', features: ['50 APIs'], cta: 'Go' },
131
+ ],
132
+ });
133
+ const result = validateWebsiteContext(context, 'gateco');
134
+
135
+ expect(result.warnings.some((w) => /default values/i.test(w))).toBe(false);
136
+ });
137
+
138
+ it('decreases contentScore with more defaults', () => {
139
+ const minimal = makeContext({
140
+ tagline: undefined,
141
+ description: undefined,
142
+ brand: undefined,
143
+ });
144
+ const rich = makeContext({
145
+ tagline: 'Secure your AI',
146
+ description: 'The best AI security platform',
147
+ brand: { primaryColor: '#3B82F6' },
148
+ });
149
+
150
+ const minResult = validateWebsiteContext(minimal, 'gateco');
151
+ const richResult = validateWebsiteContext(rich, 'gateco');
152
+
153
+ expect(richResult.contentScore).toBeGreaterThan(minResult.contentScore);
154
+ });
155
+
156
+ it('returns blocking issues for missing docs', () => {
157
+ const context = makeContext({ rawDocs: '' });
158
+ const result = validateWebsiteContext(context, 'test');
159
+
160
+ expect(result.passed).toBe(false);
161
+ expect(result.issues.some((i) => /documentation/i.test(i))).toBe(true);
162
+ });
163
+
164
+ it('warns about missing description', () => {
165
+ const context = makeContext({ description: undefined });
166
+ const result = validateWebsiteContext(context, 'gateco');
167
+
168
+ expect(result.warnings.some((w) => /description/i.test(w))).toBe(true);
169
+ });
170
+
171
+ it('clamps score to 0 when everything is wrong', () => {
172
+ const context: WebsiteContentContext = {
173
+ productName: 'my-app',
174
+ features: [],
175
+ rawDocs: '',
176
+ strategy: undefined,
177
+ };
178
+ const result = validateWebsiteContext(context, 'my-app');
179
+
180
+ expect(result.contentScore).toBe(0);
181
+ expect(result.passed).toBe(false);
182
+ });
183
+ });