Skip to content

Commit 18ad774

Browse files
committed
Merge branch 'main' of github.com:codervisor/lean-spec
2 parents a801411 + ed1f614 commit 18ad774

File tree

7 files changed

+455
-20
lines changed

7 files changed

+455
-20
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Test the dependency-graph API endpoint
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { GET } from './route';
7+
import { NextRequest } from 'next/server';
8+
9+
describe('Dependency Graph API', () => {
10+
it('should return complete dependency graph for spec 097', async () => {
11+
// Mock params
12+
const params = Promise.resolve({ id: '097' });
13+
14+
// Create mock request
15+
const request = new Request('http://localhost:3000/api/specs/097/dependency-graph');
16+
17+
// Call the endpoint
18+
const response = await GET(request, { params });
19+
20+
// Parse response
21+
const data = await response.json();
22+
23+
// If spec not found in test environment, skip detailed assertions
24+
if (response.status === 404) {
25+
expect(data).toHaveProperty('error');
26+
return;
27+
}
28+
29+
// Verify structure
30+
expect(data).toHaveProperty('current');
31+
expect(data).toHaveProperty('dependsOn');
32+
expect(data).toHaveProperty('requiredBy');
33+
expect(data).toHaveProperty('related');
34+
35+
// Verify arrays
36+
expect(data.dependsOn).toBeInstanceOf(Array);
37+
expect(data.requiredBy).toBeInstanceOf(Array);
38+
expect(data.related).toBeInstanceOf(Array);
39+
});
40+
41+
it('should return 404 for non-existent spec', async () => {
42+
const params = Promise.resolve({ id: '999' });
43+
const request = new Request('http://localhost:3000/api/specs/999/dependency-graph');
44+
45+
const response = await GET(request, { params });
46+
47+
expect(response.status).toBe(404);
48+
const data = await response.json();
49+
expect(data).toHaveProperty('error');
50+
});
51+
});
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* GET /api/specs/[id]/dependency-graph - Get complete dependency graph for a spec
3+
*
4+
* Returns upstream dependencies, downstream dependents, and related specs
5+
* for the specified spec using the SpecDependencyGraph from @leanspec/core
6+
*/
7+
8+
import { NextResponse } from 'next/server';
9+
import { getSpecById } from '@/lib/db/service-queries';
10+
import { SpecDependencyGraph, type SpecInfo } from '@/lib/dependency-graph';
11+
import { readFileSync, readdirSync, statSync } from 'node:fs';
12+
import { join } from 'node:path';
13+
import matter from 'gray-matter';
14+
15+
/**
16+
* Load all specs with relationships from filesystem
17+
*/
18+
function loadAllSpecsWithRelationships(specsDir: string): SpecInfo[] {
19+
const specInfos: SpecInfo[] = [];
20+
21+
try {
22+
const entries = readdirSync(specsDir);
23+
24+
for (const entry of entries) {
25+
const specPath = join(specsDir, entry);
26+
const stat = statSync(specPath);
27+
28+
if (!stat.isDirectory()) continue;
29+
if (entry === 'archived') continue; // Skip archived
30+
if (!/^\d{2,}-/.test(entry)) continue; // Must be spec pattern (e.g., 001-name)
31+
32+
try {
33+
const readmePath = join(specPath, 'README.md');
34+
const raw = readFileSync(readmePath, 'utf-8');
35+
const { data } = matter(raw);
36+
37+
if (!data || !data.status) continue;
38+
39+
const frontmatter: any = {
40+
status: data.status || 'planned',
41+
created: data.created_at || data.created || new Date().toISOString().split('T')[0],
42+
};
43+
44+
// Parse relationships - normalize to arrays
45+
if (data.depends_on) {
46+
frontmatter.depends_on = Array.isArray(data.depends_on)
47+
? data.depends_on
48+
: [data.depends_on];
49+
} else {
50+
frontmatter.depends_on = [];
51+
}
52+
53+
if (data.related) {
54+
frontmatter.related = Array.isArray(data.related)
55+
? data.related
56+
: [data.related];
57+
} else {
58+
frontmatter.related = [];
59+
}
60+
61+
// Add optional fields
62+
if (data.priority) frontmatter.priority = data.priority;
63+
if (data.tags) frontmatter.tags = data.tags;
64+
if (data.assignee) frontmatter.assignee = data.assignee;
65+
66+
specInfos.push({
67+
path: entry,
68+
fullPath: specPath,
69+
filePath: readmePath,
70+
name: entry,
71+
frontmatter,
72+
});
73+
} catch (err) {
74+
console.warn(`Failed to parse spec ${entry}:`, err);
75+
}
76+
}
77+
} catch (error) {
78+
console.error('Failed to load specs:', error);
79+
}
80+
81+
return specInfos;
82+
}
83+
84+
export async function GET(
85+
request: Request,
86+
{ params }: { params: Promise<{ id: string }> }
87+
) {
88+
try {
89+
const { id } = await params;
90+
91+
// Get the target spec
92+
const spec = await getSpecById(id);
93+
if (!spec) {
94+
return NextResponse.json(
95+
{ error: 'Spec not found' },
96+
{ status: 404 }
97+
);
98+
}
99+
100+
// Load all specs from filesystem with relationships
101+
const specsDir = join(process.cwd(), '../../specs');
102+
const specInfos = loadAllSpecsWithRelationships(specsDir);
103+
104+
// Build dependency graph
105+
const graph = new SpecDependencyGraph(specInfos);
106+
107+
// Get complete graph for the target spec
108+
const completeGraph = graph.getCompleteGraph(spec.specName);
109+
110+
// Helper to extract spec number from name
111+
const getSpecNumber = (name: string): number | null => {
112+
const match = name.match(/^(\d+)/);
113+
return match ? parseInt(match[1], 10) : null;
114+
};
115+
116+
// Helper to extract title from name
117+
const getTitle = (name: string): string => {
118+
const parts = name.split('-');
119+
if (parts.length > 1) {
120+
return parts.slice(1).join('-').replace(/-/g, ' ');
121+
}
122+
return name;
123+
};
124+
125+
// Format response with simplified spec metadata
126+
const response = {
127+
current: {
128+
id: spec.id,
129+
specNumber: spec.specNumber,
130+
specName: spec.specName,
131+
title: spec.title,
132+
status: spec.status,
133+
priority: spec.priority,
134+
},
135+
dependsOn: completeGraph.dependsOn.map(s => ({
136+
specNumber: getSpecNumber(s.name),
137+
specName: s.name,
138+
title: getTitle(s.name),
139+
status: s.frontmatter.status,
140+
priority: s.frontmatter.priority,
141+
})),
142+
requiredBy: completeGraph.requiredBy.map(s => ({
143+
specNumber: getSpecNumber(s.name),
144+
specName: s.name,
145+
title: getTitle(s.name),
146+
status: s.frontmatter.status,
147+
priority: s.frontmatter.priority,
148+
})),
149+
related: completeGraph.related.map(s => ({
150+
specNumber: getSpecNumber(s.name),
151+
specName: s.name,
152+
title: getTitle(s.name),
153+
status: s.frontmatter.status,
154+
priority: s.frontmatter.priority,
155+
})),
156+
};
157+
158+
// Return with cache headers
159+
return NextResponse.json(response, {
160+
headers: {
161+
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
162+
}
163+
});
164+
} catch (error) {
165+
console.error('Error fetching dependency graph:', error);
166+
return NextResponse.json(
167+
{ error: 'Failed to fetch dependency graph' },
168+
{ status: 500 }
169+
);
170+
}
171+
}

packages/web/src/components/spec-dependency-graph.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ const NODE_WIDTH = 280;
2222
const NODE_HEIGHT = 110;
2323
const precedenceColor = '#f59e0b';
2424
const relatedColor = '#38bdf8';
25+
const requiredByColor = '#ef4444'; // Red color for downstream dependents
2526

26-
type GraphTone = 'precedence' | 'related' | 'current';
27+
type GraphTone = 'precedence' | 'related' | 'current' | 'required-by';
2728

2829
interface SpecNodeData {
2930
label: string;
@@ -38,6 +39,7 @@ const toneClasses: Record<GraphTone, string> = {
3839
current: 'border-primary/70 bg-primary/5 text-foreground',
3940
precedence: 'border-amber-400/70 bg-amber-400/10 text-amber-900 dark:text-amber-200',
4041
related: 'border-sky-400/70 bg-sky-400/10 text-sky-900 dark:text-sky-200',
42+
'required-by': 'border-red-400/70 bg-red-400/10 text-red-900 dark:text-red-200',
4143
};
4244

4345
const dagreConfig: dagre.GraphLabel = {
@@ -198,6 +200,45 @@ function buildGraph(relationships: SpecRelationships, specNumber: number | null
198200
});
199201
});
200202

203+
// Required By: Specs that depend on this one (downstream, blocked)
204+
relationships.requiredBy?.forEach((value, index) => {
205+
const id = nodeId('required-by', value, index);
206+
nodes.push({
207+
id,
208+
type: 'specNode',
209+
data: {
210+
label: formatRelationshipLabel(value),
211+
badge: 'Required By',
212+
subtitle: 'Blocked by this spec',
213+
tone: 'required-by',
214+
href: buildRelationshipHref(value),
215+
interactive: true,
216+
},
217+
position: { x: 0, y: 0 },
218+
draggable: false,
219+
selectable: true,
220+
sourcePosition: Position.Right,
221+
targetPosition: Position.Left,
222+
});
223+
224+
edges.push({
225+
id: `edge-current-${id}`,
226+
source: currentNode.id,
227+
target: id,
228+
type: 'smoothstep',
229+
markerEnd: {
230+
type: MarkerType.ArrowClosed,
231+
color: requiredByColor,
232+
width: 28,
233+
height: 28,
234+
},
235+
style: {
236+
stroke: requiredByColor,
237+
strokeWidth: 3,
238+
},
239+
});
240+
});
241+
201242
// Related: Bidirectional informational connections
202243
relationships.related?.forEach((value, index) => {
203244
const id = nodeId('related', value, index);
@@ -310,6 +351,10 @@ export function SpecDependencyGraph({ relationships, specNumber, specTitle }: Sp
310351
<span className="inline-block h-2.5 w-8 rounded-full bg-amber-400/80" />
311352
Depends On → blocks until complete
312353
</span>
354+
<span className="inline-flex items-center gap-2 font-medium">
355+
<span className="inline-block h-2.5 w-8 rounded-full bg-red-400/80" />
356+
Required By ← blocked by this spec
357+
</span>
313358
<span className="inline-flex items-center gap-2 font-medium">
314359
<span className="inline-block h-2.5 w-8 rounded-full bg-sky-400/80" />
315360
Related ↔ connected work (bidirectional)

packages/web/src/components/spec-detail-client.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,35 @@ export function SpecDetailClient({ initialSpec, initialSubSpec }: SpecDetailClie
103103
}
104104
);
105105

106+
// Fetch complete dependency graph when dialog opens
107+
const { data: dependencyGraphData } = useSWR<{
108+
current: any;
109+
dependsOn: any[];
110+
requiredBy: any[];
111+
related: any[];
112+
}>(
113+
dependenciesDialogOpen ? `/api/specs/${initialSpec.specNumber || initialSpec.id}/dependency-graph` : null,
114+
fetcher,
115+
{
116+
revalidateOnFocus: false,
117+
dedupingInterval: 60000, // Cache for 1 minute
118+
}
119+
);
120+
106121
const spec = specData?.spec || initialSpec;
107122
const tags = spec.tags || [];
108123
const updatedRelative = spec.updatedAt ? formatRelativeTime(spec.updatedAt) : 'N/A';
109124
const relationships = spec.relationships;
125+
126+
// Use complete graph if available, otherwise fall back to basic relationships
127+
const completeRelationships = dependencyGraphData
128+
? {
129+
dependsOn: dependencyGraphData.dependsOn.map(s => s.specName),
130+
requiredBy: dependencyGraphData.requiredBy.map(s => s.specName),
131+
related: dependencyGraphData.related.map(s => s.specName),
132+
}
133+
: relationships;
134+
110135
const hasRelationships = Boolean(
111136
relationships && ((relationships.dependsOn?.length ?? 0) > 0 || (relationships.related?.length ?? 0) > 0)
112137
);
@@ -263,9 +288,9 @@ export function SpecDetailClient({ initialSpec, initialSubSpec }: SpecDetailClie
263288
</DialogDescription>
264289
</DialogHeader>
265290
<div className="min-h-0 flex-1">
266-
{relationships && (
291+
{completeRelationships && (
267292
<SpecDependencyGraph
268-
relationships={relationships}
293+
relationships={completeRelationships}
269294
specNumber={spec.specNumber}
270295
specTitle={displayTitle}
271296
/>

0 commit comments

Comments
 (0)