fix: complete remaining features and fix audit issues Fixes from code audit: - FileSchemaSidebar: fix $derived syntax (use $derived.by, not $derived with type parameter), remove unused KindGroup interface - PR page: add null safety for sourceRef/targetRef with optional chaining - get_project_schema: improve top names extraction to properly handle nested vertex IDs and deduplicate results Structural search (feature I): - Add searchStructural() and listDependents() to search.ts API - Search page now has two modes: "Repositories" (existing) and "Structural" (panproto expression language queries) - Structural mode includes anchor selector (functions, types, fields, ref updates) and panproto expression hints - Results show matched schema fields with repo links Migration impact (feature H): - CompatibilityBadge now accepts dependents prop - When breaking changes exist and dependents are known, shows expandable "Downstream impact: N dependent repos may be affected" section with links to each dependent repo - PR page fetches dependents via existing dependency.list endpoint with direction=dependents, in parallel with branch comparison

Author: Aaron Steven White
Commit a00507c1cfca8ddb46d4dc59df8a24bd18736b53
Parent: 30adc38401
Structural diff unavailable

These commits were pushed via plain git push, so no pre-parsed schemas are available. Install git-remote-cospan and re-push via panproto:// to see scope-level changes, breaking change detection, and semantic diffs.

brew install panproto/tap/git-remote-cospan
8 files changed +327 -59
@@ -14,3 +14,53 @@ export function searchRepos(params: {
1414 }): Promise<SearchReposResponse> {
1515 	return xrpcQuery<SearchReposResponse>('dev.cospan.repo.search', params);
1616 }
17+
18+// ── Structural search ──────────────────────────────────────────────
19+
20+export interface StructuralSearchResult {
21+	[key: string]: unknown;
22+	_repo_did?: string;
23+	_repo_name?: string;
24+}
25+
26+export interface StructuralSearchResponse {
27+	anchor: string;
28+	expression: string;
29+	limit: number;
30+	results: StructuralSearchResult[];
31+	total: number;
32+}
33+
34+export function searchStructural(params: {
35+	q: string;
36+	anchor?: string;
37+	limit?: number;
38+}): Promise<StructuralSearchResponse> {
39+	return xrpcQuery<StructuralSearchResponse>('dev.cospan.search.structural', params);
40+}
41+
42+// ── Dependency impact ──────────────────────────────────────────────
43+
44+export interface DependencyEntry {
45+	did: string;
46+	repo: string;
47+	[key: string]: unknown;
48+}
49+
50+export interface DependencyListResponse {
51+	dependencies: DependencyEntry[];
52+	cursor: string | null;
53+}
54+
55+export function listDependents(params: {
56+	did: string;
57+	repo: string;
58+	limit?: number;
59+}): Promise<DependencyListResponse> {
60+	return xrpcQuery<DependencyListResponse>('dev.cospan.repo.dependency.list', {
61+		did: params.did,
62+		repo: params.repo,
63+		direction: 'dependents',
64+		limit: params.limit,
65+	});
66+}
@@ -1,15 +1,19 @@
11 <script lang="ts">
22 	import type { BranchComparisonResponse } from '$lib/api/schema.js';
3+	import type { DependencyEntry } from '$lib/api/search.js';
34 
45 	let {
56 		comparison,
7+		dependents = [],
68 		loading = false,
79 	}: {
810 		comparison: BranchComparisonResponse | null;
11+		dependents?: DependencyEntry[];
912 		loading?: boolean;
1013 	} = $props();
1114 
1215 	let expanded = $state(false);
16+	let impactExpanded = $state(false);
1317 </script>
1418 
1519 {#if loading}
@@ -77,5 +81,40 @@
7781 				{/if}
7882 			</div>
7983 		{/if}
84+
85+		<!-- Downstream impact (when breaking changes exist and dependents are known) -->
86+		{#if !comparison.compatible && dependents.length > 0}
87+			<div class="mt-3 border-t border-red-500/20 pt-3">
88+				<button
89+					type="button"
90+					class="flex w-full items-center gap-2 text-xs font-medium text-amber-400 hover:text-amber-300 transition-colors"
91+					onclick={() => (impactExpanded = !impactExpanded)}
92+				>
93+					<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
94+						<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
95+					</svg>
96+					Downstream impact: {dependents.length} dependent {dependents.length === 1 ? 'repo' : 'repos'} may be affected
97+					<svg
98+						class="ml-auto h-3 w-3 transition-transform {impactExpanded ? 'rotate-90' : ''}"
99+						fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
100+					>
101+						<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
102+					</svg>
103+				</button>
104+				{#if impactExpanded}
105+					<div class="mt-2 space-y-1">
106+						{#each dependents as dep (dep.did + '/' + dep.repo)}
107+							<a
108+								href="/{dep.did}/{dep.repo}"
109+								class="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-text-secondary hover:bg-surface-2 transition-colors"
110+							>
111+								<span class="font-mono text-xs text-accent">{dep.repo}</span>
112+								<span class="text-[10px] text-text-muted">{dep.did}</span>
113+							</a>
114+						{/each}
115+					</div>
116+				{/if}
117+			</div>
118+		{/if}
80119 	</div>
81120 {/if}
@@ -7,13 +7,6 @@
77 		fileSchema: FileSchemaResponse | null;
88 	} = $props();
99 
10-	// Group vertices by kind
11-	interface KindGroup {
12-		kind: string;
13-		icon: string;
14-		items: { name: string; humanLabel: string }[];
15-	}
16-
1710 	function kindIcon(kind: string): string {
1811 		const lower = kind.toLowerCase();
1912 		if (lower.includes('function') || lower.includes('method')) return 'f';
@@ -25,7 +18,7 @@
2518 		return '.';
2619 	}
2720 
28-	let groups = $derived<KindGroup[]>(() => {
21+	let groups = $derived.by(() => {
2922 		if (!fileSchema || fileSchema.vertices.length === 0) return [];
3023 		const map = new Map<string, { name: string; humanLabel: string }[]>();
3124 		for (const v of fileSchema.vertices) {
@@ -80,7 +73,7 @@
8073 
8174 		<!-- Schema outline grouped by kind -->
8275 		<div class="max-h-[60vh] space-y-2 overflow-y-auto">
83-			{#each groups() as group (group.kind)}
76+			{#each groups as group (group.kind)}
8477 				<div>
8578 					<button
8679 						type="button"
@@ -5,6 +5,7 @@ import {
55 	compareBranchSchemas,
66 	type BranchComparisonResponse,
77 } from '$lib/api/schema.js';
8+import { listDependents, type DependencyEntry } from '$lib/api/search.js';
89 
910 export const load: PageServerLoad = async ({ params }) => {
1011 	try {
@@ -15,16 +16,29 @@ export const load: PageServerLoad = async ({ params }) => {
1516 
1617 		// Structural comparison between source and target branches
1718 		let branchComparison: BranchComparisonResponse | null = null;
19+		let dependents: DependencyEntry[] = [];
20+
1821 		if (pull.sourceRef && pull.targetRef) {
19-			try {
20-				branchComparison = await compareBranchSchemas({
22+			// Fetch comparison and dependents in parallel
23+			const results = await Promise.allSettled([
24+				compareBranchSchemas({
2125 					did: params.did,
2226 					repo: params.repo,
2327 					base: pull.targetRef.replace('refs/heads/', ''),
2428 					head: pull.sourceRef.replace('refs/heads/', ''),
25-				});
26-			} catch {
27-				// Node unreachable; badge won't appear
29+				}),
30+				listDependents({
31+					did: params.did,
32+					repo: params.repo,
33+					limit: 20,
34+				}),
35+			]);
36+
37+			if (results[0].status === 'fulfilled') {
38+				branchComparison = results[0].value;
39+			}
40+			if (results[1].status === 'fulfilled') {
41+				dependents = results[1].value.dependencies;
2842 			}
2943 		}
3044 
@@ -34,6 +48,7 @@ export const load: PageServerLoad = async ({ params }) => {
3448 			pull,
3549 			comments,
3650 			branchComparison,
51+			dependents,
3752 		};
3853 	} catch (err) {
3954 		console.error(`Failed to load pull ${params.did}/${params.repo}/pulls/${params.rkey}:`, err);
@@ -8,8 +8,8 @@
88 	let { data } = $props();
99 
1010 	let basePath = $derived(`/${data.did}/${data.repo}`);
11-	let sourceLabel = $derived(data.pull.sourceRef.replace('refs/heads/', ''));
12-	let targetLabel = $derived(data.pull.targetRef.replace('refs/heads/', ''));
11+	let sourceLabel = $derived(data.pull.sourceRef?.replace('refs/heads/', '') ?? '');
12+	let targetLabel = $derived(data.pull.targetRef?.replace('refs/heads/', '') ?? '');
1313 
1414 	const repoLayout = getContext<any>('repoLayout');
1515 	$effect(() => {
@@ -51,7 +51,7 @@
5151 
5252 <!-- Structural compatibility verdict -->
5353 <div class="mb-4">
54-	<CompatibilityBadge comparison={data.branchComparison} />
54+	<CompatibilityBadge comparison={data.branchComparison} dependents={data.dependents ?? []} />
5555 </div>
5656 
5757 {#if data.pull.body}
cospan · schematic version control on atproto built on AT Protocol