Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
- name: Verify the feature contract
run: npm run verify:features

- name: Enforce feature-slice import boundaries
run: npm run verify:boundaries

- name: Verify the independent issue backlog
run: npm run verify:issues

Expand Down
16 changes: 16 additions & 0 deletions core/__tests__/featureBoundaries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import path from "node:path";
import { findFeatureBoundaryViolations } from "../../scripts/check-feature-boundaries.mjs";

const fixtureRoot = path.resolve(process.cwd(), "scripts/__tests__/fixtures/boundaries");

describe("feature-slice import boundaries", () => {
it("detects alias and deep-relative imports into another slice while allowing core", async () => {
const violations = await findFeatureBoundaryViolations(fixtureRoot);
expect(violations.map(({ owner, target }) => `${owner}->${target}`)).toEqual([
"alpha->beta",
"alpha->beta"
]);
expect(violations.some(({ specifier }) => specifier === "@/core/result/result")).toBe(false);
});
});
14 changes: 14 additions & 0 deletions core/__tests__/registrySchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { renderManifests } from "../../scripts/generate-registry.mjs";
import { assertGeneratedRegistryVersion } from "../../scripts/verify-features.mjs";

describe("generated registry schema", () => {
it("embeds the current schema version", () => {
expect(renderManifests([])).toContain("generatedRegistrySchemaVersion = 1");
});

it("rejects an old schema with upgrade instructions", () => {
expect(() => assertGeneratedRegistryVersion("export const generatedRegistrySchemaVersion = 0", "old.ts"))
.toThrow(/old\.ts uses schema v0.*Run npm run registry/);
});
});
3 changes: 1 addition & 2 deletions core/__tests__/verifyFeaturesContract.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { describe, expect, it } from "vitest";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
CONTRACT_STATES,
checkContractCoverage,
evaluateContractCoverage
} from "../../scripts/verify-features.mjs";

const fixturesDir = fileURLToPath(new URL("../../scripts/__tests__/fixtures/", import.meta.url));
const fixturesDir = path.resolve(process.cwd(), "scripts/__tests__/fixtures");

/**
* Guards the checker itself, so a regression that makes the runtime contract
Expand Down
46 changes: 35 additions & 11 deletions core/network/NetworkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {

export interface NetworkContextValue {
network: StellarNetwork;
/** Increases on every real network switch; async work captures this value. */
epoch: number;
label: string;
horizonUrl: string;
sorobanRpcUrl: string;
Expand Down Expand Up @@ -79,35 +81,57 @@ export function NetworkProvider({
}) {
const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const [override, setOverride] = useState<StellarNetwork | undefined>(initialNetwork);
const [epoch, setEpoch] = useState(0);
const network = override ?? stored;

const setNetwork = useCallback((next: StellarNetwork) => {
setOverride(undefined);
if (next === network) return;
setEpoch((current) => current + 1);
// Keep the in-memory choice authoritative even when storage is blocked.
setOverride(next);
writeNetwork(next);
// The Horizon client is memoised per network; a pruning job is enqueued as
// delayed maintenance work so rapid switches settle before the cache is torn down.
if (next !== getSnapshot()) {
getMaintenanceFramework().enqueue({
operation: PRUNE_HORIZON_CLIENTS_OP,
params: { network: next },
delayMs: PRUNE_HORIZON_CLIENTS_DELAY_MS
});
}
}, []);
getMaintenanceFramework().enqueue({
operation: PRUNE_HORIZON_CLIENTS_OP,
params: { network: next },
delayMs: PRUNE_HORIZON_CLIENTS_DELAY_MS
});
}, [network]);

const value = useMemo<NetworkContextValue>(
() => ({
network,
epoch,
label: NETWORK_LABELS[network],
horizonUrl: HORIZON_URLS[network],
sorobanRpcUrl: SOROBAN_RPC_URLS[network],
networkPassphrase: NETWORK_PASSPHRASES[network],
setNetwork
}),
[network, setNetwork]
[epoch, network, setNetwork]
);

return <NetworkContext.Provider value={value}>{children}</NetworkContext.Provider>;
return (
<NetworkContext.Provider value={value}>
<NetworkEpochBoundary network={network} epoch={epoch}>
{children}
</NetworkEpochBoundary>
</NetworkContext.Provider>
);
}

/** Remounts every mounted feature slice atomically when its network identity changes. */
export function NetworkEpochBoundary({
network,
epoch,
children
}: {
network: StellarNetwork;
epoch: number;
children: React.ReactNode;
}) {
return <div key={`${network}:${epoch}`} data-network-epoch={epoch}>{children}</div>;
}

export function useNetwork(): NetworkContextValue {
Expand Down
25 changes: 23 additions & 2 deletions core/network/__tests__/NetworkProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NetworkProvider, useNetwork } from "@/core/network/NetworkProvider";
import { NETWORK_STORAGE_KEY } from "@/core/network/config";

function NetworkConsumer() {
const { network, setNetwork } = useNetwork();
const { network, epoch, setNetwork } = useNetwork();

return (
<>
<span role="status">{network}</span>
<span data-testid="epoch">{epoch}</span>
<button type="button" onClick={() => setNetwork("mainnet")}>
Use mainnet
</button>
<button type="button" onClick={() => setNetwork("testnet")}>Use testnet</button>
</>
);
}
Expand Down Expand Up @@ -88,4 +91,22 @@ describe("NetworkProvider", () => {
expect(() => fireEvent.click(screen.getByRole("button", { name: "Use mainnet" }))).not.toThrow();
expect(screen.getByRole("status").textContent).toBe("mainnet");
});

it("invalidates results and prevents an old slow request from overwriting after rapid switches", async () => {
let resolveOld!: (value: string) => void;
const oldRequest = new Promise<string>((resolve) => { resolveOld = resolve; });
function SlowResult() {
const { network } = useNetwork();
const [result, setResult] = useState("empty");
return <button type="button" onClick={() => void oldRequest.then(setResult)}>{network}:{result}</button>;
}
render(<NetworkProvider><NetworkConsumer /><SlowResult /></NetworkProvider>);
fireEvent.click(screen.getByRole("button", { name: "testnet:empty" }));
fireEvent.click(screen.getByRole("button", { name: "Use mainnet" }));
fireEvent.click(screen.getByRole("button", { name: "Use testnet" }));
expect(screen.getByRole("button", { name: "testnet:empty" })).toBeTruthy();
expect(screen.getByTestId("epoch").textContent).toBe("2");
resolveOld("old mainnet result");
await waitFor(() => expect(screen.queryByText(/old mainnet result/)).toBeNull());
});
});
8 changes: 7 additions & 1 deletion core/registry/manifests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { generatedManifests } from "@/core/registry/manifests.generated";
import {
generatedManifests,
generatedRegistrySchemaVersion
} from "@/core/registry/manifests.generated";
import { assertFeatureRegistryVersion } from "@/core/registry/schema";
import {
FEATURE_CATEGORIES,
type FeatureCategory,
Expand All @@ -11,6 +15,8 @@ import {
* Navigation, the dashboard and search import from here so that listing tools
* never pulls a single feature panel into the bundle.
*/
assertFeatureRegistryVersion(generatedRegistrySchemaVersion, "navigation/search manifests");

export const manifests: readonly FeatureManifest[] = [...generatedManifests].sort((a, b) =>
a.title.localeCompare(b.title)
);
Expand Down
8 changes: 7 additions & 1 deletion core/registry/registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { generatedPanels } from "@/core/registry/panels.generated";
import {
generatedPanels,
generatedRegistrySchemaVersion
} from "@/core/registry/panels.generated";
import { assertFeatureRegistryVersion } from "@/core/registry/schema";
import { findManifest } from "@/core/registry/manifests";
import type { FeatureEntry } from "@/core/registry/types";

Expand All @@ -10,6 +14,8 @@ import type { FeatureEntry } from "@/core/registry/types";
* by `scripts/generate-registry.mjs` and are gitignored — that is what lets
* many feature branches stay open without touching a shared file.
*/
assertFeatureRegistryVersion(generatedRegistrySchemaVersion, "tool panel registry");

export function findFeature(slug: string): FeatureEntry | undefined {
const manifest = findManifest(slug);
const Panel = generatedPanels[slug];
Expand Down
14 changes: 14 additions & 0 deletions core/registry/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const FEATURE_REGISTRY_SCHEMA_VERSION = 1 as const;

export function assertFeatureRegistryVersion(
actual: number,
consumer: string
): asserts actual is typeof FEATURE_REGISTRY_SCHEMA_VERSION {
if (actual !== FEATURE_REGISTRY_SCHEMA_VERSION) {
throw new Error(
`[registry] ${consumer} expects schema v${FEATURE_REGISTRY_SCHEMA_VERSION}, ` +
`but the generated registry is v${actual}. Run npm run registry; if the shape changed, ` +
`bump FEATURE_REGISTRY_SCHEMA_VERSION and document the migration.`
);
}
}
2 changes: 2 additions & 0 deletions core/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface FeatureManifest {
keywords: readonly string[];
/** True when the tool performs no network requests at all. */
offline?: boolean;
/** Explicit opt-out for tools whose purpose is to query multiple networks independently. */
networkEpochIndependent?: boolean;
}

export interface FeatureEntry {
Expand Down
104 changes: 104 additions & 0 deletions core/ui/AccessibleTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"use client";

import { useMemo, useState } from "react";

export interface AccessibleTreeNode {
id: string;
label: string;
value?: string;
children?: AccessibleTreeNode[];
}

export function AccessibleTree({ label, nodes }: { label: string; nodes: AccessibleTreeNode[] }) {
const branches = useMemo(
() => new Set(flatten(nodes).filter((node) => node.children?.length).map((node) => node.id)),
[nodes]
);
const [expanded, setExpanded] = useState(branches);
const [active, setActive] = useState(nodes[0]?.id ?? "");
const visible = flattenVisible(nodes, expanded);

function focus(id: string) {
setActive(id);
requestAnimationFrame(() => document.getElementById(id)?.focus());
}

return (
<div role="tree" aria-label={label} className="space-y-1 text-xs">
{nodes.map((node) => (
<TreeItem
key={node.id}
node={node}
level={1}
expanded={expanded}
active={active}
setActive={setActive}
setExpanded={setExpanded}
onNavigate={(event, current) => {
const index = visible.findIndex((item) => item.id === current.id);
if (event.key === "ArrowDown") focus(visible[Math.min(index + 1, visible.length - 1)].id);
else if (event.key === "ArrowUp") focus(visible[Math.max(index - 1, 0)].id);
else if (event.key === "Home") focus(visible[0].id);
else if (event.key === "End") focus(visible[visible.length - 1].id);
else if (event.key === "ArrowRight" && current.children?.length) {
if (!expanded.has(current.id)) setExpanded(new Set(expanded).add(current.id));
else focus(current.children[0].id);
} else if (event.key === "ArrowLeft" && expanded.has(current.id)) {
setExpanded(new Set([...expanded].filter((id) => id !== current.id)));
} else return;
event.preventDefault();
}}
/>
))}
</div>
);
}

function TreeItem({ node, level, expanded, active, setActive, setExpanded, onNavigate }: {
node: AccessibleTreeNode;
level: number;
expanded: Set<string>;
active: string;
setActive: (id: string) => void;
setExpanded: (value: Set<string>) => void;
onNavigate: (event: React.KeyboardEvent, node: AccessibleTreeNode) => void;
}) {
const branch = Boolean(node.children?.length);
const open = branch && expanded.has(node.id);
const toggle = () => branch && setExpanded(open
? new Set([...expanded].filter((id) => id !== node.id))
: new Set(expanded).add(node.id));

return (
<div>
<div
id={node.id}
role="treeitem"
aria-selected={active === node.id}
aria-level={level}
aria-expanded={branch ? open : undefined}
tabIndex={active === node.id ? 0 : -1}
onFocus={() => setActive(node.id)}
onKeyDown={(event) => onNavigate(event, node)}
className="rounded px-2 py-1 outline-none focus:ring-2 focus:ring-ring"
>
{branch ? <button type="button" tabIndex={-1} aria-label={`${open ? "Collapse" : "Expand"} ${node.label}`} className="mr-1 w-4" onClick={toggle}>{open ? "−" : "+"}</button> : <span className="mr-1 inline-block w-4" />}
<span className="text-muted-foreground">{node.label}</span>
{node.value === undefined ? null : <>: <span className="font-mono">{node.value}</span></>}
</div>
{open ? (
<div role="group" className="ml-4">
{node.children!.map((child) => <TreeItem key={child.id} node={child} level={level + 1} expanded={expanded} active={active} setActive={setActive} setExpanded={setExpanded} onNavigate={onNavigate} />)}
</div>
) : null}
</div>
);
}

function flatten(nodes: AccessibleTreeNode[]): AccessibleTreeNode[] {
return nodes.flatMap((node) => [node, ...flatten(node.children ?? [])]);
}

function flattenVisible(nodes: AccessibleTreeNode[], expanded: Set<string>): AccessibleTreeNode[] {
return nodes.flatMap((node) => [node, ...(expanded.has(node.id) ? flattenVisible(node.children ?? [], expanded) : [])]);
}
4 changes: 2 additions & 2 deletions core/ui/VirtualizedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function VirtualizedList<T>({
className="absolute inset-x-0"
style={{ height: rowHeight, top: index * rowHeight }}
>
{renderItem(item, index)}
<div role="gridcell">{renderItem(item, index)}</div>
</div>
);
})}
Expand All @@ -70,4 +70,4 @@ export function VirtualizedList<T>({
) : null}
</div>
);
}
}
Loading