Unverified 提交 2c3fade9 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Fix presubmit failures in eval suite fixtures and helpers (#3286)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3286" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review"> </picture> </a> <!-- devin-review-badge-end -->
上级 a5f19192
{ {
"$schema": "./node_modules/oxlint/configuration_schema.json", "$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": ["e2e-tests/fixtures/**/*"], "ignorePatterns": [
"e2e-tests/fixtures/**/*",
"src/__tests__/evals/fixtures/**/*"
],
"categories": { "categories": {
"correctness": "warn" "correctness": "warn"
}, },
......
...@@ -5,7 +5,7 @@ the same three models (Claude Sonnet 4.6, GPT 5.4, Gemini 3 Flash) but with ...@@ -5,7 +5,7 @@ the same three models (Claude Sonnet 4.6, GPT 5.4, Gemini 3 Flash) but with
different tool sets and system prompts: different tool sets and system prompts:
| Suite name | Tools available | System prompt | | Suite name | Tools available | System prompt |
| ------------------------- | ------------------------------------------- | --------------------------------------------- | | ------------------------ | ------------------------------------------- | -------------------------------------------- |
| `search_replace` | `search_replace` only | Minimal custom "precise code editor" prompt | | `search_replace` | `search_replace` only | Minimal custom "precise code editor" prompt |
| `search_replace_few` | `search_replace` only | Variant prompt encouraging fewer tool calls | | `search_replace_few` | `search_replace` only | Variant prompt encouraging fewer tool calls |
| `edit_file` | `edit_file` only | Minimal custom `edit_file` prompt | | `edit_file` | `edit_file` only | Minimal custom `edit_file` prompt |
...@@ -213,8 +213,8 @@ one folder. Folder names sort chronologically under `ls`. ...@@ -213,8 +213,8 @@ one folder. Folder names sort chronologically under `ls`.
- `judge` — the judge's verdict: `label`, `modelName`, `durationMs`, - `judge` — the judge's verdict: `label`, `modelName`, `durationMs`,
`usage`, `pass` (boolean), and `explanation` (the judge's written `usage`, `pass` (boolean), and `explanation` (the judge's written
reasoning, with the trailing `PASS`/`FAIL` verdict line stripped). reasoning, with the trailing `PASS`/`FAIL` verdict line stripped).
- `passed` — the overall test outcome. Requires the judge to say `PASS` *and* - `passed` — the overall test outcome. Requires the judge to say `PASS` _and_
all structural checks to pass *and* no exceptions to be thrown. all structural checks to pass _and_ no exceptions to be thrown.
- `errorMessage` — set when the test threw (tool-call failure, structural - `errorMessage` — set when the test threw (tool-call failure, structural
check failure, judge FAIL, etc.); `null` otherwise. check failure, judge FAIL, etc.); `null` otherwise.
......
// UserProfile.tsx — class-based user profile component // UserProfile.tsx — class-based user profile component
import React from "react"; import React from "react";
import { fetchUser, updateUser, fetchUserActivity } from "../services/userService"; import {
fetchUser,
updateUser,
fetchUserActivity,
} from "../services/userService";
import type { User, ActivityEntry } from "../types"; import type { User, ActivityEntry } from "../types";
interface Props { interface Props {
...@@ -89,14 +93,19 @@ export class UserProfile extends React.Component<Props, State> { ...@@ -89,14 +93,19 @@ export class UserProfile extends React.Component<Props, State> {
this.setState({ activity, activityLoading: false }); this.setState({ activity, activityLoading: false });
} catch (err) { } catch (err) {
this.setState({ this.setState({
activityError: err instanceof Error ? err.message : "Failed to load activity", activityError:
err instanceof Error ? err.message : "Failed to load activity",
activityLoading: false, activityLoading: false,
}); });
} }
} }
handleEdit = () => { handleEdit = () => {
this.setState({ editing: true, draft: { ...this.state.user }, saveError: null }); this.setState({
editing: true,
draft: { ...this.state.user },
saveError: null,
});
}; };
handleCancel = () => { handleCancel = () => {
...@@ -111,11 +120,17 @@ export class UserProfile extends React.Component<Props, State> { ...@@ -111,11 +120,17 @@ export class UserProfile extends React.Component<Props, State> {
this.setState({ saving: true, saveError: null }); this.setState({ saving: true, saveError: null });
try { try {
const updated = await updateUser(this.props.userId, this.state.draft); const updated = await updateUser(this.props.userId, this.state.draft);
this.setState({ user: updated, editing: false, draft: {}, saving: false }); this.setState({
user: updated,
editing: false,
draft: {},
saving: false,
});
this.props.onProfileUpdated?.(updated); this.props.onProfileUpdated?.(updated);
} catch (err) { } catch (err) {
this.setState({ this.setState({
saveError: err instanceof Error ? err.message : "Failed to save changes", saveError:
err instanceof Error ? err.message : "Failed to save changes",
saving: false, saving: false,
}); });
} }
...@@ -147,11 +162,14 @@ export class UserProfile extends React.Component<Props, State> { ...@@ -147,11 +162,14 @@ export class UserProfile extends React.Component<Props, State> {
// Upload stub — real impl would POST to /api/avatars // Upload stub — real impl would POST to /api/avatars
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
const fakeUrl = URL.createObjectURL(file); const fakeUrl = URL.createObjectURL(file);
const updated = await updateUser(this.props.userId, { avatarUrl: fakeUrl }); const updated = await updateUser(this.props.userId, {
avatarUrl: fakeUrl,
});
this.setState({ user: updated, uploadingAvatar: false }); this.setState({ user: updated, uploadingAvatar: false });
} catch (err) { } catch (err) {
this.setState({ this.setState({
avatarError: err instanceof Error ? err.message : "Failed to upload avatar", avatarError:
err instanceof Error ? err.message : "Failed to upload avatar",
uploadingAvatar: false, uploadingAvatar: false,
}); });
} }
...@@ -216,14 +234,25 @@ export class UserProfile extends React.Component<Props, State> { ...@@ -216,14 +234,25 @@ export class UserProfile extends React.Component<Props, State> {
return ( return (
<div className="user-profile"> <div className="user-profile">
<div className="profile-header"> <div className="profile-header">
<div className="avatar-wrapper" onClick={!readOnly ? this.handleAvatarClick : undefined}> <div
className="avatar-wrapper"
onClick={!readOnly ? this.handleAvatarClick : undefined}
>
{user.avatarUrl ? ( {user.avatarUrl ? (
<img src={user.avatarUrl} alt={`${user.name}'s avatar`} className="avatar" /> <img
src={user.avatarUrl}
alt={`${user.name}'s avatar`}
className="avatar"
/>
) : ( ) : (
<div className="avatar-placeholder">{user.name.charAt(0).toUpperCase()}</div> <div className="avatar-placeholder">
{user.name.charAt(0).toUpperCase()}
</div>
)} )}
{!readOnly && ( {!readOnly && (
<div className="avatar-overlay">{uploadingAvatar ? "Uploading…" : "Change"}</div> <div className="avatar-overlay">
{uploadingAvatar ? "Uploading…" : "Change"}
</div>
)} )}
</div> </div>
{!readOnly && ( {!readOnly && (
...@@ -237,7 +266,9 @@ export class UserProfile extends React.Component<Props, State> { ...@@ -237,7 +266,9 @@ export class UserProfile extends React.Component<Props, State> {
)} )}
{avatarError && <p className="avatar-error">{avatarError}</p>} {avatarError && <p className="avatar-error">{avatarError}</p>}
<h1>{user.name}</h1> <h1>{user.name}</h1>
<span className={`role-badge role-badge--${user.role}`}>{user.role}</span> <span className={`role-badge role-badge--${user.role}`}>
{user.role}
</span>
</div> </div>
{editing ? ( {editing ? (
...@@ -276,7 +307,11 @@ export class UserProfile extends React.Component<Props, State> { ...@@ -276,7 +307,11 @@ export class UserProfile extends React.Component<Props, State> {
<button type="submit" disabled={saving}> <button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save"} {saving ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={this.handleCancel} disabled={saving}> <button
type="button"
onClick={this.handleCancel}
disabled={saving}
>
Cancel Cancel
</button> </button>
</div> </div>
...@@ -305,7 +340,10 @@ export class UserProfile extends React.Component<Props, State> { ...@@ -305,7 +340,10 @@ export class UserProfile extends React.Component<Props, State> {
)} )}
<div className="activity-section"> <div className="activity-section">
<button className="toggle-activity" onClick={this.handleToggleActivity}> <button
className="toggle-activity"
onClick={this.handleToggleActivity}
>
{showActivity ? "Hide activity" : "Show recent activity"} {showActivity ? "Hide activity" : "Show recent activity"}
</button> </button>
{showActivity && this.renderActivityFeed()} {showActivity && this.renderActivityFeed()}
......
// UserProfileFull.tsx — full-featured user profile page component // UserProfileFull.tsx — full-featured user profile page component
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { fetchUser, updateUser, uploadAvatar, fetchUserActivity } from "../services/userService"; import {
fetchUser,
updateUser,
uploadAvatar,
fetchUserActivity,
} from "../services/userService";
import type { User, ActivityItem } from "../types"; import type { User, ActivityItem } from "../types";
interface UserProfileFullProps { interface UserProfileFullProps {
...@@ -46,7 +57,8 @@ interface ActivityState { ...@@ -46,7 +57,8 @@ interface ActivityState {
function formatStatValue(value: number | string, unit?: string): string { function formatStatValue(value: number | string, unit?: string): string {
if (typeof value === "number") { if (typeof value === "number") {
const formatted = value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value); const formatted =
value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
return unit ? `${formatted} ${unit}` : formatted; return unit ? `${formatted} ${unit}` : formatted;
} }
return value; return value;
...@@ -133,7 +145,10 @@ export function UserProfile({ ...@@ -133,7 +145,10 @@ export function UserProfile({
if (!file) return; if (!file) return;
if (!file.type.startsWith("image/")) { if (!file.type.startsWith("image/")) {
setAvatar((prev) => ({ ...prev, error: "Please select an image file" })); setAvatar((prev) => ({
...prev,
error: "Please select an image file",
}));
return; return;
} }
...@@ -143,7 +158,12 @@ export function UserProfile({ ...@@ -143,7 +158,12 @@ export function UserProfile({
} }
const previewUrl = URL.createObjectURL(file); const previewUrl = URL.createObjectURL(file);
setAvatar((prev) => ({ ...prev, previewUrl, uploading: true, error: null })); setAvatar((prev) => ({
...prev,
previewUrl,
uploading: true,
error: null,
}));
try { try {
const result = await uploadAvatar(resolvedUserId, file); const result = await uploadAvatar(resolvedUserId, file);
...@@ -170,7 +190,8 @@ export function UserProfile({ ...@@ -170,7 +190,8 @@ export function UserProfile({
setAvatar({ url: null, uploading: false, error: null, previewUrl: null }); setAvatar({ url: null, uploading: false, error: null, previewUrl: null });
}, []); }, []);
const avatarDisplayUrl = avatar.previewUrl ?? avatar.url ?? "/default-avatar.png"; const avatarDisplayUrl =
avatar.previewUrl ?? avatar.url ?? "/default-avatar.png";
const renderAvatarBadge = useCallback(() => { const renderAvatarBadge = useCallback(() => {
if (avatar.uploading) { if (avatar.uploading) {
...@@ -247,18 +268,38 @@ export function UserProfile({ ...@@ -247,18 +268,38 @@ export function UserProfile({
period: INITIAL_STATS_PERIOD, period: INITIAL_STATS_PERIOD,
}); });
const loadStats = useCallback( const loadStats = useCallback(async (period: "week" | "month" | "year") => {
async (period: "week" | "month" | "year") => {
setStats((prev) => ({ ...prev, loading: true, error: null, period })); setStats((prev) => ({ ...prev, loading: true, error: null, period }));
try { try {
// Simulate loading stats — in production this hits the analytics API // Simulate loading stats — in production this hits the analytics API
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
const mockCards: StatCard[] = [ const mockCards: StatCard[] = [
{ label: "Commits", value: period === "week" ? 23 : period === "month" ? 87 : 1042, change: 12.5 }, {
{ label: "PRs Merged", value: period === "week" ? 5 : period === "month" ? 18 : 203, change: -3.2 }, label: "Commits",
{ label: "Reviews", value: period === "week" ? 11 : period === "month" ? 42 : 498, change: 8.1 }, value: period === "week" ? 23 : period === "month" ? 87 : 1042,
{ label: "Lines Changed", value: period === "week" ? 1250 : period === "month" ? 4800 : 58000, unit: "lines", change: 15.7 }, change: 12.5,
{ label: "Issues Closed", value: period === "week" ? 7 : period === "month" ? 25 : 312, change: 0 }, },
{
label: "PRs Merged",
value: period === "week" ? 5 : period === "month" ? 18 : 203,
change: -3.2,
},
{
label: "Reviews",
value: period === "week" ? 11 : period === "month" ? 42 : 498,
change: 8.1,
},
{
label: "Lines Changed",
value: period === "week" ? 1250 : period === "month" ? 4800 : 58000,
unit: "lines",
change: 15.7,
},
{
label: "Issues Closed",
value: period === "week" ? 7 : period === "month" ? 25 : 312,
change: 0,
},
{ label: "Build Success", value: "98.2%", change: 1.1 }, { label: "Build Success", value: "98.2%", change: 1.1 },
]; ];
setStats({ cards: mockCards, loading: false, error: null, period }); setStats({ cards: mockCards, loading: false, error: null, period });
...@@ -269,9 +310,7 @@ export function UserProfile({ ...@@ -269,9 +310,7 @@ export function UserProfile({
error: err instanceof Error ? err.message : "Failed to load stats", error: err instanceof Error ? err.message : "Failed to load stats",
})); }));
} }
}, }, []);
[],
);
useEffect(() => { useEffect(() => {
if (showStats && resolvedUserId) { if (showStats && resolvedUserId) {
...@@ -295,20 +334,19 @@ export function UserProfile({ ...@@ -295,20 +334,19 @@ export function UserProfile({
return typeof commitCard?.value === "number" ? commitCard.value : 0; return typeof commitCard?.value === "number" ? commitCard.value : 0;
}, [stats.cards]); }, [stats.cards]);
const renderStatCard = useCallback( const renderStatCard = useCallback((card: StatCard, index: number) => {
(card: StatCard, index: number) => {
return ( return (
<div key={index} className="stat-card"> <div key={index} className="stat-card">
<div className="stat-card__label">{card.label}</div> <div className="stat-card__label">{card.label}</div>
<div className="stat-card__value">{formatStatValue(card.value, card.unit)}</div> <div className="stat-card__value">
{formatStatValue(card.value, card.unit)}
</div>
<div className={`stat-card__change ${getChangeClass(card.change)}`}> <div className={`stat-card__change ${getChangeClass(card.change)}`}>
{formatChangePercent(card.change)} {formatChangePercent(card.change)}
</div> </div>
</div> </div>
); );
}, }, []);
[],
);
const renderStatsHeader = useCallback(() => { const renderStatsHeader = useCallback(() => {
const periods: Array<"week" | "month" | "year"> = ["week", "month", "year"]; const periods: Array<"week" | "month" | "year"> = ["week", "month", "year"];
...@@ -335,7 +373,8 @@ export function UserProfile({ ...@@ -335,7 +373,8 @@ export function UserProfile({
return ( return (
<div className="stats-summary"> <div className="stats-summary">
<p> <p>
Total activity: <strong>{totalCommits}</strong> commits this {stats.period}. Total activity: <strong>{totalCommits}</strong> commits this{" "}
{stats.period}.
</p> </p>
</div> </div>
); );
...@@ -393,13 +432,18 @@ export function UserProfile({ ...@@ -393,13 +432,18 @@ export function UserProfile({
return groups; return groups;
}, [activityState.items]); }, [activityState.items]);
const renderActivityItem = useCallback((item: ActivityItem) => { const renderActivityItem = useCallback(
(item: ActivityItem) => {
return ( return (
<div key={item.id} className="activity-item"> <div key={item.id} className="activity-item">
<span className="activity-item__icon">{getActivityIcon(item.type)}</span> <span className="activity-item__icon">
{getActivityIcon(item.type)}
</span>
<div className="activity-item__content"> <div className="activity-item__content">
<p className="activity-item__description">{item.description}</p> <p className="activity-item__description">{item.description}</p>
<span className="activity-item__time">{formatActivityDate(item.timestamp)}</span> <span className="activity-item__time">
{formatActivityDate(item.timestamp)}
</span>
{item.metadata?.pr && ( {item.metadata?.pr && (
<a <a
className="activity-item__link" className="activity-item__link"
...@@ -415,7 +459,9 @@ export function UserProfile({ ...@@ -415,7 +459,9 @@ export function UserProfile({
</div> </div>
</div> </div>
); );
}, [navigate]); },
[navigate],
);
const renderActivityGroup = useCallback( const renderActivityGroup = useCallback(
(dateKey: string, items: ActivityItem[]) => { (dateKey: string, items: ActivityItem[]) => {
...@@ -556,10 +602,19 @@ export function UserProfile({ ...@@ -556,10 +602,19 @@ export function UserProfile({
</div> </div>
{saveError && <p className="form-error">{saveError}</p>} {saveError && <p className="form-error">{saveError}</p>}
<div className="form-actions"> <div className="form-actions">
<button type="submit" disabled={saving} className="btn btn--primary"> <button
type="submit"
disabled={saving}
className="btn btn--primary"
>
{saving ? "Saving…" : "Save Changes"} {saving ? "Saving…" : "Save Changes"}
</button> </button>
<button type="button" onClick={handleCancel} disabled={saving} className="btn btn--secondary"> <button
type="button"
onClick={handleCancel}
disabled={saving}
className="btn btn--secondary"
>
Cancel Cancel
</button> </button>
</div> </div>
...@@ -648,7 +703,10 @@ export function UserProfile({ ...@@ -648,7 +703,10 @@ export function UserProfile({
{/* ── Footer ────────────────────────────────────────── */} {/* ── Footer ────────────────────────────────────────── */}
<footer className="user-profile__footer"> <footer className="user-profile__footer">
<p>Profile last updated: {user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "Never"}</p> <p>
Profile last updated:{" "}
{user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "Never"}
</p>
</footer> </footer>
</div> </div>
); );
......
...@@ -56,7 +56,9 @@ export function endSession(): void { ...@@ -56,7 +56,9 @@ export function endSession(): void {
return; return;
} }
const durationMs = Date.now() - currentSession.startedAt; const durationMs = Date.now() - currentSession.startedAt;
console.log(`analytics session ended: ${currentSession.id} (${durationMs}ms)`); console.log(
`analytics session ended: ${currentSession.id} (${durationMs}ms)`,
);
currentSession = null; currentSession = null;
} }
...@@ -90,7 +92,9 @@ export function track(name: string, props: Record<string, unknown> = {}): void { ...@@ -90,7 +92,9 @@ export function track(name: string, props: Record<string, unknown> = {}): void {
queue.push(event); queue.push(event);
console.log(`tracked event: ${name}`); console.log(`tracked event: ${name}`);
if (queue.length >= MAX_BATCH_SIZE) { if (queue.length >= MAX_BATCH_SIZE) {
console.warn(`analytics queue full (${queue.length}), flushing immediately`); console.warn(
`analytics queue full (${queue.length}), flushing immediately`,
);
flush(); flush();
} }
} }
...@@ -100,7 +104,10 @@ export function trackPageView(path: string, title: string): void { ...@@ -100,7 +104,10 @@ export function trackPageView(path: string, title: string): void {
track("page_view", { path, title }); track("page_view", { path, title });
} }
export function trackError(err: Error, context: Record<string, unknown> = {}): void { export function trackError(
err: Error,
context: Record<string, unknown> = {},
): void {
console.error(`analytics error event: ${err.message}`, err); console.error(`analytics error event: ${err.message}`, err);
track("error", { track("error", {
message: err.message, message: err.message,
...@@ -130,9 +137,15 @@ export function trackSearch(query: string, resultCount: number): void { ...@@ -130,9 +137,15 @@ export function trackSearch(query: string, resultCount: number): void {
track("search", { query, resultCount }); track("search", { query, resultCount });
} }
export function trackTiming(category: string, variable: string, durationMs: number): void { export function trackTiming(
category: string,
variable: string,
durationMs: number,
): void {
if (durationMs < 0) { if (durationMs < 0) {
console.error(`trackTiming: negative duration ${durationMs}ms for ${category}/${variable}`); console.error(
`trackTiming: negative duration ${durationMs}ms for ${category}/${variable}`,
);
return; return;
} }
track("timing", { category, variable, durationMs }); track("timing", { category, variable, durationMs });
...@@ -203,7 +216,10 @@ function sendToBackend(events: Event[]): void { ...@@ -203,7 +216,10 @@ function sendToBackend(events: Event[]): void {
let _identifiedUserId: string | null = null; let _identifiedUserId: string | null = null;
export function identify(userId: string, traits: Record<string, unknown> = {}): void { export function identify(
userId: string,
traits: Record<string, unknown> = {},
): void {
if (!userId) { if (!userId) {
console.warn("identify called with empty userId"); console.warn("identify called with empty userId");
return; return;
...@@ -243,18 +259,25 @@ export function trackAddToCart( ...@@ -243,18 +259,25 @@ export function trackAddToCart(
price: number, price: number,
): void { ): void {
if (quantity <= 0) { if (quantity <= 0) {
console.error(`trackAddToCart: invalid quantity ${quantity} for product ${productId}`); console.error(
`trackAddToCart: invalid quantity ${quantity} for product ${productId}`,
);
return; return;
} }
track("add_to_cart", { productId, quantity, price }); track("add_to_cart", { productId, quantity, price });
} }
export function trackCheckoutStarted(cartValue: number, itemCount: number): void { export function trackCheckoutStarted(
cartValue: number,
itemCount: number,
): void {
if (cartValue < 0) { if (cartValue < 0) {
console.error(`trackCheckoutStarted: negative cart value ${cartValue}`); console.error(`trackCheckoutStarted: negative cart value ${cartValue}`);
return; return;
} }
console.log(`checkout started — ${itemCount} items, $${cartValue.toFixed(2)}`); console.log(
`checkout started — ${itemCount} items, $${cartValue.toFixed(2)}`,
);
track("checkout_started", { cartValue, itemCount }); track("checkout_started", { cartValue, itemCount });
} }
...@@ -263,7 +286,9 @@ export function trackOrderCompleted( ...@@ -263,7 +286,9 @@ export function trackOrderCompleted(
revenue: number, revenue: number,
currency: string, currency: string,
): void { ): void {
console.log(`order completed: ${orderId}${currency} ${revenue.toFixed(2)}`); console.log(
`order completed: ${orderId}${currency} ${revenue.toFixed(2)}`,
);
track("order_completed", { orderId, revenue, currency }); track("order_completed", { orderId, revenue, currency });
} }
......
...@@ -260,7 +260,12 @@ export function createNamespace(prefix: string) { ...@@ -260,7 +260,12 @@ export function createNamespace(prefix: string) {
set<T>(key: string, value: T, sizeBytes: number): void { set<T>(key: string, value: T, sizeBytes: number): void {
set(ns(key), value, sizeBytes); set(ns(key), value, sizeBytes);
}, },
setWithTtl<T>(key: string, value: T, sizeBytes: number, ttlMs: number): void { setWithTtl<T>(
key: string,
value: T,
sizeBytes: number,
ttlMs: number,
): void {
setWithTtl(ns(key), value, sizeBytes, ttlMs); setWithTtl(ns(key), value, sizeBytes, ttlMs);
}, },
get<T>(key: string): T | null { get<T>(key: string): T | null {
......
...@@ -33,78 +33,120 @@ export interface AppEvent { ...@@ -33,78 +33,120 @@ export interface AppEvent {
// ── Individual handlers ──────────────────────────────────────────────────── // ── Individual handlers ────────────────────────────────────────────────────
async function notifyUserCreated(payload: Record<string, unknown>): Promise<void> { async function notifyUserCreated(
payload: Record<string, unknown>,
): Promise<void> {
logger.info(`Sending welcome email to ${payload.email}`); logger.info(`Sending welcome email to ${payload.email}`);
// implementation elided // implementation elided
} }
async function syncUserToSearchIndex(payload: Record<string, unknown>): Promise<void> { async function syncUserToSearchIndex(
payload: Record<string, unknown>,
): Promise<void> {
logger.info(`Syncing user ${payload.id} to search index`); logger.info(`Syncing user ${payload.id} to search index`);
// implementation elided // implementation elided
} }
async function revokeUserSessions(payload: Record<string, unknown>): Promise<void> { async function revokeUserSessions(
payload: Record<string, unknown>,
): Promise<void> {
logger.info(`Revoking all sessions for user ${payload.id}`); logger.info(`Revoking all sessions for user ${payload.id}`);
// implementation elided // implementation elided
} }
async function notifyUserRoleChanged(payload: Record<string, unknown>): Promise<void> { async function notifyUserRoleChanged(
logger.info(`Notifying user ${payload.id} of role change: ${payload.oldRole}${payload.newRole}`); payload: Record<string, unknown>,
): Promise<void> {
logger.info(
`Notifying user ${payload.id} of role change: ${payload.oldRole}${payload.newRole}`,
);
// implementation elided // implementation elided
} }
async function notifyProjectCreated(payload: Record<string, unknown>): Promise<void> { async function notifyProjectCreated(
payload: Record<string, unknown>,
): Promise<void> {
logger.info(`Notifying team about new project ${payload.id}`); logger.info(`Notifying team about new project ${payload.id}`);
// implementation elided // implementation elided
} }
async function archiveProjectAssets(payload: Record<string, unknown>): Promise<void> { async function archiveProjectAssets(
payload: Record<string, unknown>,
): Promise<void> {
logger.info(`Archiving assets for project ${payload.id}`); logger.info(`Archiving assets for project ${payload.id}`);
// implementation elided // implementation elided
} }
async function cleanupProjectResources(payload: Record<string, unknown>): Promise<void> { async function cleanupProjectResources(
payload: Record<string, unknown>,
): Promise<void> {
logger.info(`Cleaning up resources for deleted project ${payload.id}`); logger.info(`Cleaning up resources for deleted project ${payload.id}`);
// implementation elided // implementation elided
} }
async function notifyProjectMemberAdded(payload: Record<string, unknown>): Promise<void> { async function notifyProjectMemberAdded(
logger.info(`Notifying user ${payload.memberId} they were added to project ${payload.projectId}`); payload: Record<string, unknown>,
): Promise<void> {
logger.info(
`Notifying user ${payload.memberId} they were added to project ${payload.projectId}`,
);
// implementation elided // implementation elided
} }
async function notifyProjectMemberRemoved(payload: Record<string, unknown>): Promise<void> { async function notifyProjectMemberRemoved(
logger.info(`Notifying user ${payload.memberId} they were removed from project ${payload.projectId}`); payload: Record<string, unknown>,
): Promise<void> {
logger.info(
`Notifying user ${payload.memberId} they were removed from project ${payload.projectId}`,
);
// implementation elided // implementation elided
} }
async function recordPaymentSuccess(payload: Record<string, unknown>): Promise<void> { async function recordPaymentSuccess(
payload: Record<string, unknown>,
): Promise<void> {
logger.info(`Recording payment ${payload.transactionId}`); logger.info(`Recording payment ${payload.transactionId}`);
// implementation elided // implementation elided
} }
async function handlePaymentFailure(payload: Record<string, unknown>): Promise<void> { async function handlePaymentFailure(
payload: Record<string, unknown>,
): Promise<void> {
logger.warn(`Payment failed for order ${payload.orderId}`); logger.warn(`Payment failed for order ${payload.orderId}`);
// implementation elided // implementation elided
} }
async function processRefund(payload: Record<string, unknown>): Promise<void> { async function processRefund(payload: Record<string, unknown>): Promise<void> {
logger.info(`Processing refund ${payload.refundId} for transaction ${payload.transactionId}`); logger.info(
`Processing refund ${payload.refundId} for transaction ${payload.transactionId}`,
);
// implementation elided // implementation elided
} }
async function provisionSubscriptionFeatures(payload: Record<string, unknown>): Promise<void> { async function provisionSubscriptionFeatures(
logger.info(`Provisioning features for subscription ${payload.subscriptionId}`); payload: Record<string, unknown>,
): Promise<void> {
logger.info(
`Provisioning features for subscription ${payload.subscriptionId}`,
);
// implementation elided // implementation elided
} }
async function deprovisionSubscriptionFeatures(payload: Record<string, unknown>): Promise<void> { async function deprovisionSubscriptionFeatures(
logger.info(`Deprovisioning features for cancelled subscription ${payload.subscriptionId}`); payload: Record<string, unknown>,
): Promise<void> {
logger.info(
`Deprovisioning features for cancelled subscription ${payload.subscriptionId}`,
);
// implementation elided // implementation elided
} }
async function extendSubscriptionAccess(payload: Record<string, unknown>): Promise<void> { async function extendSubscriptionAccess(
logger.info(`Extending access for renewed subscription ${payload.subscriptionId}`); payload: Record<string, unknown>,
): Promise<void> {
logger.info(
`Extending access for renewed subscription ${payload.subscriptionId}`,
);
// implementation elided // implementation elided
} }
...@@ -113,7 +155,9 @@ async function extendSubscriptionAccess(payload: Record<string, unknown>): Promi ...@@ -113,7 +155,9 @@ async function extendSubscriptionAccess(payload: Record<string, unknown>): Promi
* TODO: Refactor this switch into a Record<EventType, handler> map + dispatch function. * TODO: Refactor this switch into a Record<EventType, handler> map + dispatch function.
*/ */
export async function handleEvent(event: AppEvent): Promise<void> { export async function handleEvent(event: AppEvent): Promise<void> {
logger.info(`Handling event ${event.type} (correlation: ${event.correlationId}, source: ${event.sourceService})`); logger.info(
`Handling event ${event.type} (correlation: ${event.correlationId}, source: ${event.sourceService})`,
);
switch (event.type) { switch (event.type) {
case "user.created": case "user.created":
...@@ -214,7 +258,9 @@ export interface BatchResult { ...@@ -214,7 +258,9 @@ export interface BatchResult {
* Processes a batch of events sequentially. Failures are recorded but * Processes a batch of events sequentially. Failures are recorded but
* do not abort the remaining events in the batch. * do not abort the remaining events in the batch.
*/ */
export async function handleEventBatch(batch: EventBatch): Promise<BatchResult> { export async function handleEventBatch(
batch: EventBatch,
): Promise<BatchResult> {
logger.info( logger.info(
`Processing batch ${batch.batchId} with ${batch.events.length} event(s)`, `Processing batch ${batch.batchId} with ${batch.events.length} event(s)`,
); );
...@@ -277,7 +323,9 @@ export async function retryDeadLetter( ...@@ -277,7 +323,9 @@ export async function retryDeadLetter(
try { try {
await handleEvent(entry.event); await handleEvent(entry.event);
retried++; retried++;
logger.info(`Retried dead-letter event ${entry.event.correlationId} successfully`); logger.info(
`Retried dead-letter event ${entry.event.correlationId} successfully`,
);
} catch (err) { } catch (err) {
logger.error( logger.error(
`Retry failed for dead-letter event ${entry.event.correlationId}`, `Retry failed for dead-letter event ${entry.event.correlationId}`,
......
...@@ -5,7 +5,8 @@ import { getAuthToken } from "./auth"; ...@@ -5,7 +5,8 @@ import { getAuthToken } from "./auth";
const logger = createLogger("fetch-client"); const logger = createLogger("fetch-client");
const BASE_URL = process.env.SERVICE_BASE_URL ?? "https://api.internal.example.com"; const BASE_URL =
process.env.SERVICE_BASE_URL ?? "https://api.internal.example.com";
const DEFAULT_TIMEOUT_MS = 8_000; const DEFAULT_TIMEOUT_MS = 8_000;
export interface FetchClientOptions { export interface FetchClientOptions {
...@@ -68,7 +69,12 @@ export async function serviceRequest<T>( ...@@ -68,7 +69,12 @@ export async function serviceRequest<T>(
path: string, path: string,
options: FetchClientOptions = {}, options: FetchClientOptions = {},
): Promise<T> { ): Promise<T> {
const { method = "GET", body, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = options; const {
method = "GET",
body,
headers = {},
timeoutMs = DEFAULT_TIMEOUT_MS,
} = options;
const token = await getAuthToken(); const token = await getAuthToken();
const controller = new AbortController(); const controller = new AbortController();
...@@ -100,7 +106,10 @@ export async function serviceRequest<T>( ...@@ -100,7 +106,10 @@ export async function serviceRequest<T>(
// ── Verb wrappers ────────────────────────────────────────────────────────── // ── Verb wrappers ──────────────────────────────────────────────────────────
export async function getResource<T>(path: string, headers?: Record<string, string>): Promise<T> { export async function getResource<T>(
path: string,
headers?: Record<string, string>,
): Promise<T> {
const data = await serviceRequest<T>(path, { method: "GET", headers }); const data = await serviceRequest<T>(path, { method: "GET", headers });
return data; return data;
} }
...@@ -115,7 +124,10 @@ export async function putResource<T>(path: string, body: unknown): Promise<T> { ...@@ -115,7 +124,10 @@ export async function putResource<T>(path: string, body: unknown): Promise<T> {
return data; return data;
} }
export async function patchResource<T>(path: string, body: unknown): Promise<T> { export async function patchResource<T>(
path: string,
body: unknown,
): Promise<T> {
const data = await serviceRequest<T>(path, { method: "PATCH", body }); const data = await serviceRequest<T>(path, { method: "PATCH", body });
return data; return data;
} }
...@@ -151,7 +163,9 @@ export async function listUsers( ...@@ -151,7 +163,9 @@ export async function listUsers(
); );
} }
export async function getUserSubscription(userId: string): Promise<Subscription | null> { export async function getUserSubscription(
userId: string,
): Promise<Subscription | null> {
return getResource<Subscription | null>(`/users/${userId}/subscription`); return getResource<Subscription | null>(`/users/${userId}/subscription`);
} }
...@@ -189,7 +203,9 @@ export async function deleteProject(projectId: string): Promise<void> { ...@@ -189,7 +203,9 @@ export async function deleteProject(projectId: string): Promise<void> {
// ── Project membership ───────────────────────────────────────────────────── // ── Project membership ─────────────────────────────────────────────────────
export async function getProjectMembers(projectId: string): Promise<ProjectMember[]> { export async function getProjectMembers(
projectId: string,
): Promise<ProjectMember[]> {
return getResource<ProjectMember[]>(`/projects/${projectId}/members`); return getResource<ProjectMember[]>(`/projects/${projectId}/members`);
} }
...@@ -213,12 +229,17 @@ export async function updateProjectMemberRole( ...@@ -213,12 +229,17 @@ export async function updateProjectMemberRole(
userId: string, userId: string,
role: string, role: string,
): Promise<ProjectMember> { ): Promise<ProjectMember> {
return patchResource<ProjectMember>(`/projects/${projectId}/members/${userId}`, { role }); return patchResource<ProjectMember>(
`/projects/${projectId}/members/${userId}`,
{ role },
);
} }
// ── Workspace resources ──────────────────────────────────────────────────── // ── Workspace resources ────────────────────────────────────────────────────
export async function getWorkspace(workspaceId: string): Promise<{ id: string; name: string; plan: string }> { export async function getWorkspace(
workspaceId: string,
): Promise<{ id: string; name: string; plan: string }> {
return getResource(`/workspaces/${workspaceId}`); return getResource(`/workspaces/${workspaceId}`);
} }
...@@ -262,34 +283,50 @@ export async function downloadInvoicePdf(invoiceId: string): Promise<Blob> { ...@@ -262,34 +283,50 @@ export async function downloadInvoicePdf(invoiceId: string): Promise<Blob> {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
const response = await fetch(`${BASE_URL}/billing/invoices/${invoiceId}/pdf`, { const response = await fetch(
`${BASE_URL}/billing/invoices/${invoiceId}/pdf`,
{
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
signal: controller.signal, signal: controller.signal,
}); },
);
clearTimeout(timer); clearTimeout(timer);
if (!response.ok) { if (!response.ok) {
throw { code: "DOWNLOAD_FAILED", message: "Failed to download invoice PDF", status: response.status } satisfies ServiceError; throw {
code: "DOWNLOAD_FAILED",
message: "Failed to download invoice PDF",
status: response.status,
} satisfies ServiceError;
} }
return response.blob(); return response.blob();
} }
export async function listPaymentMethods(workspaceId: string): Promise<PaymentMethod[]> { export async function listPaymentMethods(
return getResource<PaymentMethod[]>(`/workspaces/${workspaceId}/billing/payment-methods`); workspaceId: string,
): Promise<PaymentMethod[]> {
return getResource<PaymentMethod[]>(
`/workspaces/${workspaceId}/billing/payment-methods`,
);
} }
export async function addPaymentMethod( export async function addPaymentMethod(
workspaceId: string, workspaceId: string,
token: string, token: string,
): Promise<PaymentMethod> { ): Promise<PaymentMethod> {
return postResource<PaymentMethod>(`/workspaces/${workspaceId}/billing/payment-methods`, { token }); return postResource<PaymentMethod>(
`/workspaces/${workspaceId}/billing/payment-methods`,
{ token },
);
} }
export async function removePaymentMethod( export async function removePaymentMethod(
workspaceId: string, workspaceId: string,
paymentMethodId: string, paymentMethodId: string,
): Promise<void> { ): Promise<void> {
return deleteResource(`/workspaces/${workspaceId}/billing/payment-methods/${paymentMethodId}`); return deleteResource(
`/workspaces/${workspaceId}/billing/payment-methods/${paymentMethodId}`,
);
} }
export async function setDefaultPaymentMethod( export async function setDefaultPaymentMethod(
......
...@@ -110,7 +110,8 @@ export function couponAppliestoShipping(order: Order): boolean { ...@@ -110,7 +110,8 @@ export function couponAppliestoShipping(order: Order): boolean {
export function effectiveShippingCost(order: Order): number { export function effectiveShippingCost(order: Order): number {
const base = order.shippingCost; const base = order.shippingCost;
if (!order.coupon?.appliesToShipping) return base; if (!order.coupon?.appliesToShipping) return base;
if (order.coupon.type === "fixed") return Math.max(0, base - order.coupon.value); if (order.coupon.type === "fixed")
return Math.max(0, base - order.coupon.value);
return base * (1 - order.coupon.value / 100); return base * (1 - order.coupon.value / 100);
} }
...@@ -173,7 +174,11 @@ export function formatOrderLine(item: LineItem): string { ...@@ -173,7 +174,11 @@ export function formatOrderLine(item: LineItem): string {
return `${item.sku} × ${item.quantity} @ $${item.unitPrice.toFixed(2)}`; return `${item.sku} × ${item.quantity} @ $${item.unitPrice.toFixed(2)}`;
} }
export function applyBulkDiscount(items: LineItem[], threshold: number, pct: number): number { export function applyBulkDiscount(
items: LineItem[],
threshold: number,
pct: number,
): number {
const sub = subtotalOf(items); const sub = subtotalOf(items);
if (sub < threshold) return sub; if (sub < threshold) return sub;
return sub * (1 - pct); return sub * (1 - pct);
...@@ -269,9 +274,7 @@ export function isFullRefund(order: Order, returnedSkus: string[]): boolean { ...@@ -269,9 +274,7 @@ export function isFullRefund(order: Order, returnedSkus: string[]): boolean {
// ── Reporting helpers ────────────────────────────────────────────────────── // ── Reporting helpers ──────────────────────────────────────────────────────
export function totalsByCurrency( export function totalsByCurrency(orders: Order[]): Record<string, number> {
orders: Order[],
): Record<string, number> {
const totals: Record<string, number> = {}; const totals: Record<string, number> = {};
for (const order of orders) { for (const order of orders) {
const key = order.currency; const key = order.currency;
...@@ -287,17 +290,25 @@ export function averageOrderValue(orders: Order[]): number { ...@@ -287,17 +290,25 @@ export function averageOrderValue(orders: Order[]): number {
} }
export function topOrdersByValue(orders: Order[], n: number): Order[] { export function topOrdersByValue(orders: Order[], n: number): Order[] {
return [...orders].sort((a, b) => calculateTotal(b) - calculateTotal(a)).slice(0, n); return [...orders]
.sort((a, b) => calculateTotal(b) - calculateTotal(a))
.slice(0, n);
} }
export function totalRevenue(orders: Order[]): number { export function totalRevenue(orders: Order[]): number {
return orders.reduce((sum, o) => sum + calculateTotal(o), 0); return orders.reduce((sum, o) => sum + calculateTotal(o), 0);
} }
export function ordersAboveThreshold(orders: Order[], threshold: number): Order[] { export function ordersAboveThreshold(
orders: Order[],
threshold: number,
): Order[] {
return orders.filter((o) => calculateTotal(o) >= threshold); return orders.filter((o) => calculateTotal(o) >= threshold);
} }
export function ordersBelowThreshold(orders: Order[], threshold: number): Order[] { export function ordersBelowThreshold(
orders: Order[],
threshold: number,
): Order[] {
return orders.filter((o) => calculateTotal(o) < threshold); return orders.filter((o) => calculateTotal(o) < threshold);
} }
// order_processor.ts — core order processing logic // order_processor.ts — core order processing logic
import { createLogger } from "./logger"; import { createLogger } from "./logger";
import type { Order, InventoryItem, PaymentMethod, ShippingAddress } from "./types"; import type {
Order,
InventoryItem,
PaymentMethod,
ShippingAddress,
} from "./types";
const logger = createLogger("order-processor"); const logger = createLogger("order-processor");
...@@ -66,7 +71,12 @@ async function createShipment( ...@@ -66,7 +71,12 @@ async function createShipment(
items: string[], items: string[],
): Promise<{ trackingNumber: string; estimatedDelivery: string }> { ): Promise<{ trackingNumber: string; estimatedDelivery: string }> {
// Implementation elided — hits the shipping API // Implementation elided — hits the shipping API
return { trackingNumber: "track_placeholder", estimatedDelivery: new Date(Date.now() + 5 * 24 * 3600 * 1000).toISOString() }; return {
trackingNumber: "track_placeholder",
estimatedDelivery: new Date(
Date.now() + 5 * 24 * 3600 * 1000,
).toISOString(),
};
} }
async function cancelShipment(trackingNumber: string): Promise<void> { async function cancelShipment(trackingNumber: string): Promise<void> {
...@@ -120,7 +130,10 @@ export async function processOrder(order: Order): Promise<ProcessResult> { ...@@ -120,7 +130,10 @@ export async function processOrder(order: Order): Promise<ProcessResult> {
} }
// Validate payment method // Validate payment method
if (!order.payment.method || !["card", "paypal", "bank"].includes(order.payment.method)) { if (
!order.payment.method ||
!["card", "paypal", "bank"].includes(order.payment.method)
) {
return { success: false, error: "Invalid payment method" }; return { success: false, error: "Invalid payment method" };
} }
if (!order.payment.amount || order.payment.amount <= 0) { if (!order.payment.amount || order.payment.amount <= 0) {
...@@ -145,7 +158,10 @@ export async function processOrder(order: Order): Promise<ProcessResult> { ...@@ -145,7 +158,10 @@ export async function processOrder(order: Order): Promise<ProcessResult> {
for (const r of reservations) { for (const r of reservations) {
await releaseInventory(r.productId, r.quantity); await releaseInventory(r.productId, r.quantity);
} }
return { success: false, error: `Could not reserve stock for ${item.productId}` }; return {
success: false,
error: `Could not reserve stock for ${item.productId}`,
};
} }
reservations.push({ productId: item.productId, quantity: item.quantity }); reservations.push({ productId: item.productId, quantity: item.quantity });
} }
...@@ -153,7 +169,10 @@ export async function processOrder(order: Order): Promise<ProcessResult> { ...@@ -153,7 +169,10 @@ export async function processOrder(order: Order): Promise<ProcessResult> {
// Charge the customer // Charge the customer
let transaction: { transactionId: string }; let transaction: { transactionId: string };
try { try {
transaction = await chargePayment(order.payment.method, order.payment.amount); transaction = await chargePayment(
order.payment.method,
order.payment.amount,
);
} catch (err) { } catch (err) {
logger.error("Payment failed", err); logger.error("Payment failed", err);
for (const r of reservations) { for (const r of reservations) {
...@@ -186,7 +205,9 @@ export async function processOrder(order: Order): Promise<ProcessResult> { ...@@ -186,7 +205,9 @@ export async function processOrder(order: Order): Promise<ProcessResult> {
}; };
const orderId = await saveOrder(enrichedOrder); const orderId = await saveOrder(enrichedOrder);
logger.info(`Order ${orderId} confirmed (tracking: ${shipment.trackingNumber})`); logger.info(
`Order ${orderId} confirmed (tracking: ${shipment.trackingNumber})`,
);
// Send confirmation email (best-effort — don't fail the order) // Send confirmation email (best-effort — don't fail the order)
try { try {
...@@ -212,7 +233,10 @@ export async function cancelOrder( ...@@ -212,7 +233,10 @@ export async function cancelOrder(
try { try {
await cancelShipment(trackingNumber); await cancelShipment(trackingNumber);
} catch (err) { } catch (err) {
logger.warn(`Could not cancel shipment ${trackingNumber} for order ${orderId}`, err); logger.warn(
`Could not cancel shipment ${trackingNumber} for order ${orderId}`,
err,
);
} }
let refundOk = false; let refundOk = false;
...@@ -227,7 +251,10 @@ export async function cancelOrder( ...@@ -227,7 +251,10 @@ export async function cancelOrder(
await releaseInventory(r.productId, r.quantity); await releaseInventory(r.productId, r.quantity);
} }
await updateOrderStatus(orderId, refundOk ? "cancelled" : "cancellation_pending"); await updateOrderStatus(
orderId,
refundOk ? "cancelled" : "cancellation_pending",
);
logger.info(`Order ${orderId} cancelled (refund ok: ${refundOk})`); logger.info(`Order ${orderId} cancelled (refund ok: ${refundOk})`);
return { success: true, orderId }; return { success: true, orderId };
} }
...@@ -252,7 +279,9 @@ export async function listOrdersForCustomer( ...@@ -252,7 +279,9 @@ export async function listOrdersForCustomer(
// ── Order enrichment ─────────────────────────────────────────────────────── // ── Order enrichment ───────────────────────────────────────────────────────
export async function enrichOrderWithTracking(order: Order): Promise<Order & { trackingUrl: string }> { export async function enrichOrderWithTracking(
order: Order,
): Promise<Order & { trackingUrl: string }> {
if (!order.trackingNumber) { if (!order.trackingNumber) {
throw new Error("Order has no tracking number"); throw new Error("Order has no tracking number");
} }
...@@ -268,7 +297,9 @@ export async function estimateDeliveryDate( ...@@ -268,7 +297,9 @@ export async function estimateDeliveryDate(
): Promise<string> { ): Promise<string> {
const baseDays = expedited ? 2 : 5; const baseDays = expedited ? 2 : 5;
const regionBuffer = address.country !== "US" ? 7 : 0; const regionBuffer = address.country !== "US" ? 7 : 0;
const estimate = new Date(Date.now() + (baseDays + regionBuffer) * 24 * 3600 * 1000); const estimate = new Date(
Date.now() + (baseDays + regionBuffer) * 24 * 3600 * 1000,
);
return estimate.toISOString(); return estimate.toISOString();
} }
...@@ -301,7 +332,9 @@ export async function processOrderIdempotent( ...@@ -301,7 +332,9 @@ export async function processOrderIdempotent(
): Promise<ProcessResult> { ): Promise<ProcessResult> {
const existing = checkIdempotency(idempotencyKey); const existing = checkIdempotency(idempotencyKey);
if (existing) { if (existing) {
logger.info(`Idempotency hit: returning existing order ${existing} for key ${idempotencyKey}`); logger.info(
`Idempotency hit: returning existing order ${existing} for key ${idempotencyKey}`,
);
return { success: true, orderId: existing }; return { success: true, orderId: existing };
} }
const result = await processOrder(order); const result = await processOrder(order);
......
...@@ -110,16 +110,27 @@ export function createPolicy(role: string): Policy { ...@@ -110,16 +110,27 @@ export function createPolicy(role: string): Policy {
export function hasPermission(policy: Policy, action: string): boolean { export function hasPermission(policy: Policy, action: string): boolean {
switch (action) { switch (action) {
case "view_content": return policy.canViewContent(); case "view_content":
case "create_content": return policy.canCreateContent(); return policy.canViewContent();
case "edit_content": return policy.canEditContent(); case "create_content":
case "delete_content": return policy.canDeleteContent(); return policy.canCreateContent();
case "view_users": return policy.canViewUsers(); case "edit_content":
case "manage_users": return policy.canManageUsers(); return policy.canEditContent();
case "view_roles": return policy.canViewRoles(); case "delete_content":
case "manage_roles": return policy.canManageRoles(); return policy.canDeleteContent();
case "view_audit_log": return policy.canViewAuditLog(); case "view_users":
case "export_data": return policy.canExportData(); return policy.canViewUsers();
default: return false; case "manage_users":
return policy.canManageUsers();
case "view_roles":
return policy.canViewRoles();
case "manage_roles":
return policy.canManageRoles();
case "view_audit_log":
return policy.canViewAuditLog();
case "export_data":
return policy.canExportData();
default:
return false;
} }
} }
...@@ -37,8 +37,18 @@ interface ReportRange { ...@@ -37,8 +37,18 @@ interface ReportRange {
} }
const MONTH_NAMES = [ const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jan",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]; ];
// ── Sales reports ────────────────────────────────────────────────────────── // ── Sales reports ──────────────────────────────────────────────────────────
...@@ -117,7 +127,10 @@ export function salesByRegion( ...@@ -117,7 +127,10 @@ export function salesByRegion(
s.region, s.region,
(revenueByRegion.get(s.region) ?? 0) + s.unitPrice * s.quantity, (revenueByRegion.get(s.region) ?? 0) + s.unitPrice * s.quantity,
); );
unitsByRegion.set(s.region, (unitsByRegion.get(s.region) ?? 0) + s.quantity); unitsByRegion.set(
s.region,
(unitsByRegion.get(s.region) ?? 0) + s.quantity,
);
} }
const rows: Array<{ region: string; revenue: string; units: number }> = []; const rows: Array<{ region: string; revenue: string; units: number }> = [];
...@@ -208,7 +221,10 @@ export function refundsByReason( ...@@ -208,7 +221,10 @@ export function refundsByReason(
const amountByReason = new Map<string, number>(); const amountByReason = new Map<string, number>();
const countByReason = new Map<string, number>(); const countByReason = new Map<string, number>();
for (const r of inRange) { for (const r of inRange) {
amountByReason.set(r.reason, (amountByReason.get(r.reason) ?? 0) + r.amount); amountByReason.set(
r.reason,
(amountByReason.get(r.reason) ?? 0) + r.amount,
);
countByReason.set(r.reason, (countByReason.get(r.reason) ?? 0) + 1); countByReason.set(r.reason, (countByReason.get(r.reason) ?? 0) + 1);
} }
...@@ -307,10 +323,7 @@ export function mrrByPlan( ...@@ -307,10 +323,7 @@ export function mrrByPlan(
return rows; return rows;
} }
export function churnRate( export function churnRate(subs: Subscription[], range: ReportRange): string {
subs: Subscription[],
range: ReportRange,
): string {
const fromTs = Date.parse(range.from); const fromTs = Date.parse(range.from);
const toTs = Date.parse(range.to); const toTs = Date.parse(range.to);
...@@ -369,10 +382,7 @@ export function topCustomersByRevenue( ...@@ -369,10 +382,7 @@ export function topCustomersByRevenue(
})); }));
} }
export function averageSaleValue( export function averageSaleValue(sales: Sale[], range: ReportRange): string {
sales: Sale[],
range: ReportRange,
): string {
const fromTs = Date.parse(range.from); const fromTs = Date.parse(range.from);
const toTs = Date.parse(range.to); const toTs = Date.parse(range.to);
const inRange = sales.filter((s) => { const inRange = sales.filter((s) => {
......
...@@ -91,7 +91,9 @@ export async function archiveProject( ...@@ -91,7 +91,9 @@ export async function archiveProject(
} }
const id = req.params.id; const id = req.params.id;
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
logger.warn(`archiveProject called with invalid id from user ${req.userId}`); logger.warn(
`archiveProject called with invalid id from user ${req.userId}`,
);
res.status(400).json({ error: "invalid id" }); res.status(400).json({ error: "invalid id" });
return; return;
} }
...@@ -116,7 +118,9 @@ export async function getProjectMembers( ...@@ -116,7 +118,9 @@ export async function getProjectMembers(
} }
const id = req.params.id; const id = req.params.id;
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
logger.warn(`getProjectMembers called with invalid id from user ${req.userId}`); logger.warn(
`getProjectMembers called with invalid id from user ${req.userId}`,
);
res.status(400).json({ error: "invalid id" }); res.status(400).json({ error: "invalid id" });
return; return;
} }
...@@ -141,7 +145,9 @@ export async function addProjectMember( ...@@ -141,7 +145,9 @@ export async function addProjectMember(
} }
const id = req.params.id; const id = req.params.id;
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
logger.warn(`addProjectMember called with invalid id from user ${req.userId}`); logger.warn(
`addProjectMember called with invalid id from user ${req.userId}`,
);
res.status(400).json({ error: "invalid id" }); res.status(400).json({ error: "invalid id" });
return; return;
} }
...@@ -151,7 +157,9 @@ export async function addProjectMember( ...@@ -151,7 +157,9 @@ export async function addProjectMember(
"INSERT INTO project_members (project_id, user_id, role, added_at) VALUES (?, ?, ?, ?)", "INSERT INTO project_members (project_id, user_id, role, added_at) VALUES (?, ?, ?, ?)",
[id, memberId, role, new Date().toISOString()], [id, memberId, role, new Date().toISOString()],
); );
logger.info(`addProjectMember(${id}, member=${memberId}) took ${Date.now() - start}ms`); logger.info(
`addProjectMember(${id}, member=${memberId}) took ${Date.now() - start}ms`,
);
res.status(201).json({ ok: true }); res.status(201).json({ ok: true });
} }
...@@ -167,7 +175,9 @@ export async function removeProjectMember( ...@@ -167,7 +175,9 @@ export async function removeProjectMember(
} }
const id = req.params.id; const id = req.params.id;
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
logger.warn(`removeProjectMember called with invalid id from user ${req.userId}`); logger.warn(
`removeProjectMember called with invalid id from user ${req.userId}`,
);
res.status(400).json({ error: "invalid id" }); res.status(400).json({ error: "invalid id" });
return; return;
} }
...@@ -177,7 +187,9 @@ export async function removeProjectMember( ...@@ -177,7 +187,9 @@ export async function removeProjectMember(
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?", "DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
[id, memberId], [id, memberId],
); );
logger.info(`removeProjectMember(${id}, member=${memberId}) took ${Date.now() - start}ms`); logger.info(
`removeProjectMember(${id}, member=${memberId}) took ${Date.now() - start}ms`,
);
res.json({ ok: true }); res.json({ ok: true });
} }
...@@ -187,13 +199,17 @@ export async function transferProjectOwnership( ...@@ -187,13 +199,17 @@ export async function transferProjectOwnership(
): Promise<void> { ): Promise<void> {
const start = Date.now(); const start = Date.now();
if (!req.userId) { if (!req.userId) {
logger.warn(`transferProjectOwnership called without userId from ${req.ip}`); logger.warn(
`transferProjectOwnership called without userId from ${req.ip}`,
);
res.status(401).json({ error: "unauthorized" }); res.status(401).json({ error: "unauthorized" });
return; return;
} }
const id = req.params.id; const id = req.params.id;
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
logger.warn(`transferProjectOwnership called with invalid id from user ${req.userId}`); logger.warn(
`transferProjectOwnership called with invalid id from user ${req.userId}`,
);
res.status(400).json({ error: "invalid id" }); res.status(400).json({ error: "invalid id" });
return; return;
} }
...@@ -203,7 +219,9 @@ export async function transferProjectOwnership( ...@@ -203,7 +219,9 @@ export async function transferProjectOwnership(
"UPDATE projects SET owner_id = ?, updated_at = ? WHERE id = ?", "UPDATE projects SET owner_id = ?, updated_at = ? WHERE id = ?",
[newOwnerId, new Date().toISOString(), id], [newOwnerId, new Date().toISOString(), id],
); );
logger.info(`transferProjectOwnership(${id}, newOwner=${newOwnerId}) took ${Date.now() - start}ms`); logger.info(
`transferProjectOwnership(${id}, newOwner=${newOwnerId}) took ${Date.now() - start}ms`,
);
res.json({ ok: true }); res.json({ ok: true });
} }
...@@ -219,7 +237,9 @@ export async function listProjectVersions( ...@@ -219,7 +237,9 @@ export async function listProjectVersions(
} }
const id = req.params.id; const id = req.params.id;
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
logger.warn(`listProjectVersions called with invalid id from user ${req.userId}`); logger.warn(
`listProjectVersions called with invalid id from user ${req.userId}`,
);
res.status(400).json({ error: "invalid id" }); res.status(400).json({ error: "invalid id" });
return; return;
} }
...@@ -244,7 +264,9 @@ export async function restoreProjectVersion( ...@@ -244,7 +264,9 @@ export async function restoreProjectVersion(
} }
const id = req.params.id; const id = req.params.id;
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
logger.warn(`restoreProjectVersion called with invalid id from user ${req.userId}`); logger.warn(
`restoreProjectVersion called with invalid id from user ${req.userId}`,
);
res.status(400).json({ error: "invalid id" }); res.status(400).json({ error: "invalid id" });
return; return;
} }
...@@ -259,11 +281,14 @@ export async function restoreProjectVersion( ...@@ -259,11 +281,14 @@ export async function restoreProjectVersion(
return; return;
} }
await db.query( await db.query("UPDATE projects SET data = ?, updated_at = ? WHERE id = ?", [
"UPDATE projects SET data = ?, updated_at = ? WHERE id = ?", (versionRows[0] as { data: unknown }).data,
[(versionRows[0] as { data: unknown }).data, new Date().toISOString(), id], new Date().toISOString(),
id,
]);
logger.info(
`restoreProjectVersion(${id}, version=${versionId}) took ${Date.now() - start}ms`,
); );
logger.info(`restoreProjectVersion(${id}, version=${versionId}) took ${Date.now() - start}ms`);
res.json({ ok: true }); res.json({ ok: true });
} }
...@@ -279,7 +304,9 @@ export async function duplicateProject( ...@@ -279,7 +304,9 @@ export async function duplicateProject(
} }
const id = req.params.id; const id = req.params.id;
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
logger.warn(`duplicateProject called with invalid id from user ${req.userId}`); logger.warn(
`duplicateProject called with invalid id from user ${req.userId}`,
);
res.status(400).json({ error: "invalid id" }); res.status(400).json({ error: "invalid id" });
return; return;
} }
...@@ -295,6 +322,8 @@ export async function duplicateProject( ...@@ -295,6 +322,8 @@ export async function duplicateProject(
"INSERT INTO projects (name, owner_id, status, data, created_at) SELECT ?, owner_id, 'active', data, ? FROM projects WHERE id = ? RETURNING id", "INSERT INTO projects (name, owner_id, status, data, created_at) SELECT ?, owner_id, 'active', data, ? FROM projects WHERE id = ? RETURNING id",
[name, new Date().toISOString(), id], [name, new Date().toISOString(), id],
); );
logger.info(`duplicateProject(${id}${(newRows[0] as { id: string }).id}) took ${Date.now() - start}ms`); logger.info(
`duplicateProject(${id}${(newRows[0] as { id: string }).id}) took ${Date.now() - start}ms`,
);
res.status(201).json(newRows[0]); res.status(201).json(newRows[0]);
} }
...@@ -193,7 +193,10 @@ export async function updateUserHandler( ...@@ -193,7 +193,10 @@ export async function updateUserHandler(
return; return;
} }
if (body.age !== undefined && (typeof body.age !== "number" || body.age < 0)) { if (
body.age !== undefined &&
(typeof body.age !== "number" || body.age < 0)
) {
res.status(400).json({ error: "age must be a non-negative number" }); res.status(400).json({ error: "age must be a non-negative number" });
return; return;
} }
...@@ -255,17 +258,20 @@ export async function changeRoleHandler( ...@@ -255,17 +258,20 @@ export async function changeRoleHandler(
return; return;
} }
const existing = await db.query("SELECT id, role FROM users WHERE id = ?", [id]); const existing = await db.query("SELECT id, role FROM users WHERE id = ?", [
id,
]);
if (existing.length === 0) { if (existing.length === 0) {
res.status(404).json({ error: "user not found" }); res.status(404).json({ error: "user not found" });
return; return;
} }
const previousRole = (existing[0] as { role: string }).role; const previousRole = (existing[0] as { role: string }).role;
await db.query( await db.query("UPDATE users SET role = ?, updated_at = ? WHERE id = ?", [
"UPDATE users SET role = ?, updated_at = ? WHERE id = ?", role,
[role, new Date().toISOString(), id], new Date().toISOString(),
); id,
]);
logger.info(`changed role for user ${id}: ${previousRole}${role}`); logger.info(`changed role for user ${id}: ${previousRole}${role}`);
res.json({ id, role }); res.json({ id, role });
......
...@@ -161,13 +161,16 @@ export function reactivateUser(id: string): Promise<User> { ...@@ -161,13 +161,16 @@ export function reactivateUser(id: string): Promise<User> {
// ── Listing ──────────────────────────────────────────────────────────────── // ── Listing ────────────────────────────────────────────────────────────────
export function listUsers(page: number, limit: number): Promise<PaginatedUsers> { export function listUsers(
page: number,
limit: number,
): Promise<PaginatedUsers> {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
return db return db
.query( .query("SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?", [
"SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?", limit,
[limit, offset], offset,
) ])
.then((rows) => { .then((rows) => {
return db return db
.query("SELECT COUNT(*) AS total FROM users", []) .query("SELECT COUNT(*) AS total FROM users", [])
...@@ -213,10 +216,18 @@ export function logAuditEntry(entry: UserAuditEntry): Promise<void> { ...@@ -213,10 +216,18 @@ export function logAuditEntry(entry: UserAuditEntry): Promise<void> {
return db return db
.query( .query(
"INSERT INTO user_audit (user_id, action, performed_by, timestamp, metadata) VALUES (?, ?, ?, ?, ?)", "INSERT INTO user_audit (user_id, action, performed_by, timestamp, metadata) VALUES (?, ?, ?, ?, ?)",
[entry.userId, entry.action, entry.performedBy, entry.timestamp, JSON.stringify(entry.metadata)], [
entry.userId,
entry.action,
entry.performedBy,
entry.timestamp,
JSON.stringify(entry.metadata),
],
) )
.then(() => { .then(() => {
logger.info(`audit: ${entry.action} on user ${entry.userId} by ${entry.performedBy}`); logger.info(
`audit: ${entry.action} on user ${entry.userId} by ${entry.performedBy}`,
);
}) })
.catch((err) => { .catch((err) => {
logger.error(`logAuditEntry failed for userId=${entry.userId}`, err); logger.error(`logAuditEntry failed for userId=${entry.userId}`, err);
...@@ -245,7 +256,11 @@ export function requestEmailVerification(id: string): Promise<void> { ...@@ -245,7 +256,11 @@ export function requestEmailVerification(id: string): Promise<void> {
if (!user) throw new Error(`user ${id} not found`); if (!user) throw new Error(`user ${id} not found`);
return db.query( return db.query(
"INSERT INTO email_verifications (user_id, token, expires_at) VALUES (?, ?, ?)", "INSERT INTO email_verifications (user_id, token, expires_at) VALUES (?, ?, ?)",
[id, Math.random().toString(36).slice(2), new Date(Date.now() + 24 * 3600 * 1000).toISOString()], [
id,
Math.random().toString(36).slice(2),
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
],
); );
}) })
.then(() => { .then(() => {
...@@ -264,8 +279,12 @@ export function verifyEmail(id: string, token: string): Promise<User> { ...@@ -264,8 +279,12 @@ export function verifyEmail(id: string, token: string): Promise<User> {
[id, token, new Date().toISOString()], [id, token, new Date().toISOString()],
) )
.then((rows) => { .then((rows) => {
if (rows.length === 0) throw new Error("invalid or expired verification token"); if (rows.length === 0)
return db.query("UPDATE users SET email_verified = 1 WHERE id = ? RETURNING *", [id]); throw new Error("invalid or expired verification token");
return db.query(
"UPDATE users SET email_verified = 1 WHERE id = ? RETURNING *",
[id],
);
}) })
.then((rows) => { .then((rows) => {
logger.info(`verified email for user ${id}`); logger.info(`verified email for user ${id}`);
...@@ -292,9 +311,15 @@ export function requestPasswordReset(email: string): Promise<void> { ...@@ -292,9 +311,15 @@ export function requestPasswordReset(email: string): Promise<void> {
return db return db
.query( .query(
"INSERT INTO password_resets (user_id, token, expires_at) VALUES (?, ?, ?)", "INSERT INTO password_resets (user_id, token, expires_at) VALUES (?, ?, ?)",
[userId, Math.random().toString(36).slice(2), new Date(Date.now() + 3600 * 1000).toISOString()], [
userId,
Math.random().toString(36).slice(2),
new Date(Date.now() + 3600 * 1000).toISOString(),
],
) )
.then(() => { logger.info(`password reset token created for user ${userId}`); }); .then(() => {
logger.info(`password reset token created for user ${userId}`);
});
}) })
.catch((err) => { .catch((err) => {
logger.error(`requestPasswordReset failed for email`, err); logger.error(`requestPasswordReset failed for email`, err);
...@@ -302,7 +327,10 @@ export function requestPasswordReset(email: string): Promise<void> { ...@@ -302,7 +327,10 @@ export function requestPasswordReset(email: string): Promise<void> {
}); });
} }
export function resetPassword(token: string, newPasswordHash: string): Promise<void> { export function resetPassword(
token: string,
newPasswordHash: string,
): Promise<void> {
return db return db
.query( .query(
"SELECT user_id FROM password_resets WHERE token = ? AND expires_at > ? AND used = 0", "SELECT user_id FROM password_resets WHERE token = ? AND expires_at > ? AND used = 0",
...@@ -312,13 +340,18 @@ export function resetPassword(token: string, newPasswordHash: string): Promise<v ...@@ -312,13 +340,18 @@ export function resetPassword(token: string, newPasswordHash: string): Promise<v
if (rows.length === 0) throw new Error("invalid or expired reset token"); if (rows.length === 0) throw new Error("invalid or expired reset token");
const userId = (rows[0] as { user_id: string }).user_id; const userId = (rows[0] as { user_id: string }).user_id;
return db return db
.query("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", [ .query(
newPasswordHash, "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
new Date().toISOString(), [newPasswordHash, new Date().toISOString(), userId],
userId, )
]) .then(() =>
.then(() => db.query("UPDATE password_resets SET used = 1 WHERE token = ?", [token])) db.query("UPDATE password_resets SET used = 1 WHERE token = ?", [
.then(() => { logger.info(`password reset completed for user ${userId}`); }); token,
]),
)
.then(() => {
logger.info(`password reset completed for user ${userId}`);
});
}) })
.catch((err) => { .catch((err) => {
logger.error(`resetPassword failed`, err); logger.error(`resetPassword failed`, err);
......
...@@ -271,14 +271,8 @@ export function recordDirFor( ...@@ -271,14 +271,8 @@ export function recordDirFor(
caseName: string, caseName: string,
modelLabel: string, modelLabel: string,
): string { ): string {
const runDirName = const runDirName = `${fsTimestamp(RUN_START_TIMESTAMP)}__${sanitize(modelLabel)}`;
`${fsTimestamp(RUN_START_TIMESTAMP)}__${sanitize(modelLabel)}`; return resolve(RESULTS_ROOT, sanitize(suite), runDirName, sanitize(caseName));
return resolve(
RESULTS_ROOT,
sanitize(suite),
runDirName,
sanitize(caseName),
);
} }
export async function recordEvalRun(record: EvalRunRecord): Promise<void> { export async function recordEvalRun(record: EvalRunRecord): Promise<void> {
......
...@@ -201,7 +201,7 @@ const evalFetch: typeof fetch = async (input, init) => { ...@@ -201,7 +201,7 @@ const evalFetch: typeof fetch = async (input, init) => {
// we can surface token counts in the reassembled non-streaming // we can surface token counts in the reassembled non-streaming
// response instead of hard-coding zeros. // response instead of hard-coding zeros.
parsed.stream_options = { parsed.stream_options = {
...(parsed.stream_options ?? {}), ...parsed.stream_options,
include_usage: true, include_usage: true,
}; };
const modifiedInit = { ...init, body: JSON.stringify(parsed) }; const modifiedInit = { ...init, body: JSON.stringify(parsed) };
......
...@@ -39,7 +39,7 @@ function computeLcsTable( ...@@ -39,7 +39,7 @@ function computeLcsTable(
const m = oldLines.length; const m = oldLines.length;
const n = newLines.length; const n = newLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0), Array.from<number>({ length: n + 1 }).fill(0),
); );
for (let i = 1; i <= m; i++) { for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) { for (let j = 1; j <= n; j++) {
...@@ -99,7 +99,9 @@ function buildHunks( ...@@ -99,7 +99,9 @@ function buildHunks(
positioned: readonly PositionedOp[], positioned: readonly PositionedOp[],
context: number, context: number,
): Hunk[] { ): Hunk[] {
const include = new Array<boolean>(positioned.length).fill(false); const include = Array.from<boolean>({ length: positioned.length }).fill(
false,
);
for (let i = 0; i < positioned.length; i++) { for (let i = 0; i < positioned.length; i++) {
if (positioned[i].op.type !== "keep") { if (positioned[i].op.type !== "keep") {
const lo = Math.max(0, i - context); const lo = Math.max(0, i - context);
......
...@@ -752,8 +752,7 @@ const SUITES: SuiteConfig[] = [ ...@@ -752,8 +752,7 @@ const SUITES: SuiteConfig[] = [
// (see helpers/prompts.ts) so prompt variations can be recorded // (see helpers/prompts.ts) so prompt variations can be recorded
// without modifying the production prompt. // without modifying the production prompt.
name: "pro_agent_experimental", name: "pro_agent_experimental",
displayName: displayName: "pro_agent_experimental (pro_agent with editable prompt copy)",
"pro_agent_experimental (pro_agent with editable prompt copy)",
systemPrompt: PRO_AGENT_EXPERIMENTAL_SYSTEM_PROMPT, systemPrompt: PRO_AGENT_EXPERIMENTAL_SYSTEM_PROMPT,
buildTools: (state, c, label) => ({ buildTools: (state, c, label) => ({
search_replace: searchReplaceHarnessTool(state, c, label), search_replace: searchReplaceHarnessTool(state, c, label),
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论