Skip to content

Commit a1c712f

Browse files
committed
feat: implement safe YAML parsing options and enhance comparative testing documentation for Rust HTTP API
1 parent 188e9fd commit a1c712f

File tree

9 files changed

+143
-17
lines changed

9 files changed

+143
-17
lines changed

packages/ui/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
"leanspec-ui": "./bin/ui.js"
88
},
99
"scripts": {
10-
"dev": "next dev",
10+
"dev": "next dev --port 3001",
1111
"build": "next build && node ./scripts/post-build.mjs",
12-
"start": "next start",
12+
"start": "next start --port 3001",
1313
"lint": "eslint",
1414
"typecheck": "tsc --noEmit",
1515
"test": "vitest run",

packages/ui/src/lib/db/seed.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { readFileSync, readdirSync, statSync } from 'fs';
88
import { join } from 'path';
99
import { randomUUID } from 'crypto';
1010
import { eq } from 'drizzle-orm';
11+
import { safeMatterOptions } from '../spec-utils/frontmatter';
1112

1213
// Path to specs directory (relative to monorepo root)
1314
const SPECS_DIR = join(process.cwd(), '../../specs');
@@ -38,7 +39,7 @@ function parseSpecDirectory(dirPath: string): ParsedSpec | null {
3839
try {
3940
const readmePath = join(dirPath, 'README.md');
4041
const content = readFileSync(readmePath, 'utf-8');
41-
const { data: frontmatter, content: markdownContent } = matter(content);
42+
const { data: frontmatter, content: markdownContent } = matter(content, safeMatterOptions);
4243

4344
// Extract spec number and name from directory name
4445
const dirName = dirPath.split('/').pop() || '';

packages/ui/src/lib/db/service-queries.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { detectSubSpecs } from '../sub-specs';
1111
import { join, resolve, dirname } from 'path';
1212
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
1313
import matter from 'gray-matter';
14+
import { safeMatterOptions } from '../spec-utils/frontmatter';
1415

1516
/**
1617
* Spec with parsed tags (for client consumption)
@@ -66,7 +67,7 @@ function parseSpecTags(spec: Spec): ParsedSpec {
6667
let contentMd = spec.contentMd;
6768
if (contentMd.startsWith('---')) {
6869
try {
69-
const { content } = matter(contentMd);
70+
const { content } = matter(contentMd, safeMatterOptions);
7071
contentMd = content;
7172
} catch {
7273
// If parsing fails, use original content

packages/ui/src/lib/spec-utils/frontmatter.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
import matter from 'gray-matter';
1010
import { load, FAILSAFE_SCHEMA } from 'js-yaml';
1111

12+
// Shared YAML engine for gray-matter to avoid js-yaml safeLoad removal
13+
export const safeMatterOptions = {
14+
engines: {
15+
yaml: (str: string) => load(str, { schema: FAILSAFE_SCHEMA }) as Record<string, unknown>,
16+
},
17+
};
18+
1219
export type SpecStatus = 'planned' | 'in-progress' | 'complete' | 'archived';
1320
export type SpecPriority = 'low' | 'medium' | 'high' | 'critical';
1421

@@ -168,13 +175,7 @@ export function createUpdatedFrontmatter(
168175
existingContent: string,
169176
updates: Partial<SpecFrontmatter>
170177
): { content: string; frontmatter: SpecFrontmatter } {
171-
const parsed = matter(existingContent, {
172-
engines: {
173-
// FAILSAFE_SCHEMA is the safest option - only allows strings, arrays, and objects
174-
// This prevents any code execution vulnerabilities from YAML parsing
175-
yaml: (str) => load(str, { schema: FAILSAFE_SCHEMA }) as Record<string, unknown>
176-
}
177-
});
178+
const parsed = matter(existingContent, safeMatterOptions);
178179

179180
// Store previous data for timestamp enrichment
180181
const previousData = { ...parsed.data };

packages/ui/src/lib/specs/relationships.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import matter from 'gray-matter';
99
import type { SpecRelationships } from './types';
1010
import type { Spec } from '../db/schema';
11+
import { safeMatterOptions } from '../spec-utils/frontmatter';
1112

1213
/**
1314
* Normalize a depends_on value to a string array
@@ -31,7 +32,7 @@ export function normalizeRelationshipList(value: unknown): string[] {
3132
export function extractDependsOn(contentOrFrontmatter: string | Record<string, unknown>): string[] {
3233
if (typeof contentOrFrontmatter === 'string') {
3334
try {
34-
const { data } = matter(contentOrFrontmatter);
35+
const { data } = matter(contentOrFrontmatter, safeMatterOptions);
3536
return normalizeRelationshipList(data?.depends_on ?? data?.dependsOn);
3637
} catch {
3738
return [];

packages/ui/src/lib/specs/sources/filesystem-source.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as path from 'node:path';
88
import matter from 'gray-matter';
99
import type { SpecSource, CachedSpec } from '../types';
1010
import type { Spec } from '../../db/schema';
11+
import { safeMatterOptions } from '../../spec-utils/frontmatter';
1112

1213
// Cache TTL from environment
1314
// Default: 0ms in development, 60s in production
@@ -218,7 +219,7 @@ export class FilesystemSource implements SpecSource {
218219

219220
try {
220221
const rawContent = await fs.readFile(readmePath, 'utf-8');
221-
const { data: frontmatter, content: markdownContent } = matter(rawContent);
222+
const { data: frontmatter, content: markdownContent } = matter(rawContent, safeMatterOptions);
222223

223224
if (!frontmatter || !frontmatter.status) {
224225
return null;

packages/ui/src/lib/specs/sources/multi-project-source.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import matter from 'gray-matter';
99
import type { SpecSource, CachedSpec } from '../types';
1010
import type { Spec } from '../../db/schema';
1111
import { projectRegistry } from '../../projects';
12+
import { safeMatterOptions } from '../../spec-utils/frontmatter';
1213

1314
// Cache TTL from environment
1415
const isDev = process.env.NODE_ENV === 'development';
@@ -225,7 +226,7 @@ export class MultiProjectFilesystemSource implements SpecSource {
225226
projectId: string
226227
): Spec | null {
227228
try {
228-
const { data: frontmatter, content: markdown } = matter(content);
229+
const { data: frontmatter, content: markdown } = matter(content, safeMatterOptions);
229230

230231
// Validate frontmatter has required status field
231232
if (!frontmatter || !frontmatter.status) {

packages/ui/src/lib/sub-specs.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
66
import { join } from 'path';
77
import matter from 'gray-matter';
8+
import { safeMatterOptions } from './spec-utils/frontmatter';
89

910
export interface SubSpec {
1011
name: string;
@@ -129,7 +130,7 @@ export function detectSubSpecs(specDirPath: string): SubSpec[] {
129130
try {
130131
const content = readFileSync(filePath, 'utf-8');
131132
// Remove frontmatter if present
132-
const { content: markdownContent } = matter(content);
133+
const { content: markdownContent } = matter(content, safeMatterOptions);
133134
const iconConfig = getIconForSubSpec(entry);
134135

135136
subSpecs.push({
@@ -162,7 +163,7 @@ export function getSubSpec(specDirPath: string, fileName: string): SubSpec | nul
162163

163164
try {
164165
const content = readFileSync(filePath, 'utf-8');
165-
const { content: markdownContent } = matter(content);
166+
const { content: markdownContent } = matter(content, safeMatterOptions);
166167
const iconConfig = getIconForSubSpec(fileName);
167168

168169
return {

specs/191-rust-http-api-test-suite/README.md

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ rust/leanspec-http/tests/
8585
- Verify response structure compatibility
8686
- Test serialization consistency
8787

88+
**Comparative Testing** (RECOMMENDED):
89+
- Run both Next.js API (`pnpm -F @leanspec/ui dev`) and Rust HTTP server side-by-side
90+
- Make identical requests to both servers with same test data
91+
- Compare JSON responses field-by-field
92+
- Validate identical behavior and structure
93+
- Catch subtle differences that static schema checks might miss
94+
8895
**Test Fixtures**:
8996
- Reusable test projects with known spec structure
9097
- Sample specs with various statuses, priorities, tags
@@ -93,10 +100,11 @@ rust/leanspec-http/tests/
93100
**Tools**:
94101
- `axum-test` or raw Axum router testing
95102
- `reqwest` for HTTP client
96-
- `serde_json` for response validation
103+
- `serde_json` for response validation and comparison
97104
- `schemars` for JSON Schema generation and validation
98105
- `tempfile` for temporary test projects
99106
- `tokio::test` for async tests
107+
- `assert_json_diff` for comparing JSON responses between servers
100108

101109
## Plan
102110

@@ -107,6 +115,8 @@ rust/leanspec-http/tests/
107115
- [ ] Add test utilities (assertions, matchers)
108116
- [ ] Set up schema validation utilities
109117
- [ ] Document reference Next.js API schemas
118+
- [ ] Create dual-server test helper (optional: spawn both Next.js + Rust)
119+
- [ ] Add JSON response comparison utilities
110120

111121
### Phase 2: Project Management Tests (Day 2)
112122
- [ ] Test GET `/api/projects` (list all, empty state, multi-project)
@@ -183,9 +193,82 @@ rust/leanspec-http/tests/
183193
- [ ] Large dataset testing (100+ specs)
184194
- [ ] Test documentation/examples
185195
- [ ] JSON Schema exports for documentation
196+
- [ ] **Comparative tests with live Next.js API** (side-by-side validation)
186197

187198
## Test Examples
188199

200+
### Comparative Testing (Next.js vs Rust)
201+
202+
```rust
203+
#[tokio::test]
204+
#[ignore] // Only run when Next.js server is running
205+
async fn test_compare_specs_response_with_nextjs() {
206+
// Set up test project with known specs
207+
let test_project = setup_test_project_with_fixtures().await;
208+
209+
// Start Rust HTTP server
210+
let rust_client = reqwest::Client::new();
211+
let rust_base = "http://localhost:3001"; // Rust server
212+
213+
// Assume Next.js is running on default port
214+
let nextjs_client = reqwest::Client::new();
215+
let nextjs_base = "http://localhost:3000"; // Next.js server
216+
217+
// Compare GET /api/specs responses
218+
let rust_res = rust_client
219+
.get(format!("{}/api/specs", rust_base))
220+
.send()
221+
.await
222+
.unwrap();
223+
let rust_json: serde_json::Value = rust_res.json().await.unwrap();
224+
225+
let nextjs_res = nextjs_client
226+
.get(format!("{}/api/projects/default/specs", nextjs_base))
227+
.send()
228+
.await
229+
.unwrap();
230+
let nextjs_json: serde_json::Value = nextjs_res.json().await.unwrap();
231+
232+
// Compare response structure
233+
assert_json_diff::assert_json_eq!(
234+
rust_json["specs"][0]["specNumber"],
235+
nextjs_json["specs"][0]["specNumber"]
236+
);
237+
assert_json_diff::assert_json_eq!(
238+
rust_json["specs"][0]["specName"],
239+
nextjs_json["specs"][0]["specName"]
240+
);
241+
242+
// Verify both use camelCase
243+
assert!(rust_json["specs"][0].get("specNumber").is_some());
244+
assert!(rust_json["specs"][0].get("spec_number").is_none());
245+
}
246+
247+
#[tokio::test]
248+
async fn test_compare_stats_response_structure() {
249+
let rust_app = test_server_with_fixtures().await;
250+
251+
// Get stats from Rust API
252+
let rust_res = rust_app.get("/api/stats").send().await;
253+
let rust_json: serde_json::Value = rust_res.json().await;
254+
255+
// Validate structure matches Next.js format
256+
// Next.js returns: { stats: { total, byStatus, byPriority, byTag, ... } }
257+
assert!(rust_json.get("total").is_some());
258+
assert!(rust_json.get("byStatus").is_some());
259+
assert!(rust_json.get("byPriority").is_some());
260+
assert!(rust_json.get("byTag").is_some());
261+
assert!(rust_json.get("completionPercentage").is_some());
262+
263+
// Validate nested structure (camelCase)
264+
let by_status = &rust_json["byStatus"];
265+
assert!(by_status.get("planned").is_some());
266+
assert!(by_status.get("inProgress").is_some()); // camelCase
267+
assert!(by_status.get("in_progress").is_none()); // NOT snake_case
268+
assert!(by_status.get("complete").is_some());
269+
}
270+
```
271+
189272
### Schema Validation Test
190273

191274
```rust
@@ -347,6 +430,35 @@ async fn test_search_relevance_ranking() {
347430
4. Document any intentional differences
348431
5. Create compatibility tests that would fail on schema drift
349432

433+
**Comparative Testing (Recommended)**:
434+
1. Run Next.js dev server: `pnpm -F @leanspec/ui dev` (port 3000)
435+
2. Run Rust HTTP server: `cargo run --bin leanspec-http` (port 3001)
436+
3. Point both at same test project directory
437+
4. Make identical requests to both APIs
438+
5. Compare JSON responses field-by-field using `assert_json_diff`
439+
6. Validates not just schema but actual behavior
440+
441+
**Benefits of Live Comparison**:
442+
- Catches subtle differences that static checks miss
443+
- Validates actual serialization behavior
444+
- Tests with real Next.js API implementation
445+
- No need to manually extract/maintain reference schemas
446+
- Confirms identical responses for same input
447+
448+
**Setup for Comparative Tests**:
449+
```bash
450+
# Terminal 1: Start Next.js API
451+
cd /path/to/lean-spec
452+
pnpm -F @leanspec/ui dev
453+
454+
# Terminal 2: Start Rust HTTP server
455+
cd /path/to/lean-spec/rust/leanspec-http
456+
cargo run
457+
458+
# Terminal 3: Run comparative tests
459+
cargo test --test comparative -- --ignored
460+
```
461+
350462
**Key Fields to Validate**:
351463
- `specNumber` (not `spec_number`)
352464
- `specName` (not `spec_name`)
@@ -405,3 +517,10 @@ async fn test_search_relevance_ranking() {
405517
- Defined test architecture and strategy
406518
- 5-day implementation plan
407519
- Priority: HIGH - prerequisite for Spec 190
520+
521+
### 2025-12-19: Schema Alignment Added
522+
- Added explicit schema compatibility testing with Next.js APIs
523+
- Documented key fields to validate (camelCase serialization)
524+
- Added comparative testing strategy (run both servers side-by-side)
525+
- Confirmed Rust types already use camelCase via `#[serde(rename_all = "camelCase")]`
526+
- Added example tests for live API comparison

0 commit comments

Comments
 (0)