mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-10 09:13:53 +08:00
feat(ui): computed sentences compose natively per locale
The takeaway/cost sentences, the trigger tree's guard grammar, and the first-read content each build their own full sentence in the viewer's language from shared parameters — never word substitution into an English frame (zh restructures where zh reads better: 只要 X 运行,它就 一定运行——调用点上没有任何条件). The load-bearing discipline holds bilingually: the complexity takeaway's simple/exceed claims are parsed back out of BOTH languages and asserted against the same CPLX_BINS metadata, so a bin edit moves every locale or fails loudly. The server's region-why templates get parsed and recomposed client-side with byte-identical English reproduction and verbatim pass-through for unknown shapes. English output is byte-identical everywhere, guarded by the untouched en expectations. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { kneeCount, costSentence, findingKey, isDismissed, dismiss } from "../lib/findings";
|
import { kneeCount, costSentence, findingKey, isDismissed, dismiss } from "../lib/findings";
|
||||||
|
import { useUiMessages } from "../lib/i18n";
|
||||||
import {
|
import {
|
||||||
CPLX_BINS,
|
CPLX_BINS,
|
||||||
CPLX_TAIL_MIN,
|
CPLX_TAIL_MIN,
|
||||||
@@ -239,6 +240,7 @@ export function DashboardTab({
|
|||||||
onOpenModulesPath,
|
onOpenModulesPath,
|
||||||
onOpenWiki,
|
onOpenWiki,
|
||||||
}: DashboardTabProps) {
|
}: DashboardTabProps) {
|
||||||
|
const t = useUiMessages();
|
||||||
const [metrics, setMetrics] = useState<MetricsPayload | null>(null);
|
const [metrics, setMetrics] = useState<MetricsPayload | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showMuted, setShowMuted] = useState(false);
|
const [showMuted, setShowMuted] = useState(false);
|
||||||
@@ -285,16 +287,14 @@ export function DashboardTab({
|
|||||||
};
|
};
|
||||||
}, [metrics, showMuted, mutedTick]);
|
}, [metrics, showMuted, mutedTick]);
|
||||||
|
|
||||||
/* TODO(content-i18n): the computed takeaway and cost sentences interpolate
|
|
||||||
* many numbers and stay English this pass; route them through the i18n
|
|
||||||
* messages mechanism in a follow-up. */
|
|
||||||
const takeaway = useMemo(() => {
|
const takeaway = useMemo(() => {
|
||||||
if (!metrics) return null;
|
if (!metrics) return null;
|
||||||
return complexityTakeaway(
|
return complexityTakeaway(
|
||||||
metrics.complexity_hist,
|
metrics.complexity_hist,
|
||||||
metrics.top_complex.slice(0, 3).map((entry) => entry.name ?? ""),
|
metrics.top_complex.slice(0, 3).map((entry) => entry.name ?? ""),
|
||||||
|
t.dashboard,
|
||||||
);
|
);
|
||||||
}, [metrics]);
|
}, [metrics, t]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -322,6 +322,7 @@ export function DashboardTab({
|
|||||||
hero.headCommits,
|
hero.headCommits,
|
||||||
totals.files,
|
totals.files,
|
||||||
metrics.churn_total_commits,
|
metrics.churn_total_commits,
|
||||||
|
t.dashboard,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -353,7 +354,8 @@ export function DashboardTab({
|
|||||||
</div>
|
</div>
|
||||||
{cost ? (
|
{cost ? (
|
||||||
<p className="text-[13px] text-foreground/55 mt-1">
|
<p className="text-[13px] text-foreground/55 mt-1">
|
||||||
{cost} — change here is where defects concentrate.
|
{cost}
|
||||||
|
{t.dashboard.costConcentrate}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[13px] text-foreground/45 mt-1">
|
<p className="text-[13px] text-foreground/45 mt-1">
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function FlowsTab({
|
|||||||
}`}
|
}`}
|
||||||
title={t.flows.guardTitleHop}
|
title={t.flows.guardTitleHop}
|
||||||
>
|
>
|
||||||
{formatGuard(guard)}
|
{formatGuard(guard, t.why)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{step.observed && (
|
{step.observed && (
|
||||||
@@ -511,7 +511,7 @@ export function FlowsTab({
|
|||||||
}`}
|
}`}
|
||||||
title={t.flows.guardTitleStep}
|
title={t.flows.guardTitleStep}
|
||||||
>
|
>
|
||||||
{formatGuard(guard)}
|
{formatGuard(guard, t.why)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{step.observed && <ObservedChip observed={step.observed} />}
|
{step.observed && <ObservedChip observed={step.observed} />}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
disambiguateRegionNames,
|
disambiguateRegionNames,
|
||||||
lensRegionsPayload,
|
lensRegionsPayload,
|
||||||
isBuiltinQn,
|
isBuiltinQn,
|
||||||
|
localizeRegionWhy,
|
||||||
} from "../lib/regions";
|
} from "../lib/regions";
|
||||||
import { surprisingCouplings, suggestedQuestions } from "../lib/firstread";
|
import { surprisingCouplings, suggestedQuestions } from "../lib/firstread";
|
||||||
import { kneeCount, findingKey, isDismissed, dismiss } from "../lib/findings";
|
import { kneeCount, findingKey, isDismissed, dismiss } from "../lib/findings";
|
||||||
@@ -371,11 +372,16 @@ export function OverviewTab({
|
|||||||
const hiddenTestRegions =
|
const hiddenTestRegions =
|
||||||
regions && lensed ? regions.regions.length - lensed.regions.length : 0;
|
regions && lensed ? regions.regions.length - lensed.regions.length : 0;
|
||||||
|
|
||||||
const couplings = useMemo(() => (lensed ? surprisingCouplings(lensed, 5) : []), [lensed]);
|
const couplings = useMemo(
|
||||||
|
() => (lensed ? surprisingCouplings(lensed, 5, t.firstread) : []),
|
||||||
|
[lensed, t],
|
||||||
|
);
|
||||||
const questions = useMemo(
|
const questions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
lensed ? suggestedQuestions(lensed, { deadCount: metrics?.totals.dead }, 4) : [],
|
lensed
|
||||||
[lensed, metrics],
|
? suggestedQuestions(lensed, { deadCount: metrics?.totals.dead }, 4, t.firstread)
|
||||||
|
: [],
|
||||||
|
[lensed, metrics, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Findings with dismissal (magnitude-bucketed keys re-alert on jumps). */
|
/* Findings with dismissal (magnitude-bucketed keys re-alert on jumps). */
|
||||||
@@ -635,7 +641,7 @@ export function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
{region.why && (
|
{region.why && (
|
||||||
<p className="text-[12px] text-foreground/35 mt-1 line-clamp-2">
|
<p className="text-[12px] text-foreground/35 mt-1 line-clamp-2">
|
||||||
{region.why}
|
{localizeRegionWhy(region.why, t.regions)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useUiMessages } from "../lib/i18n";
|
||||||
|
import { localizeRegionWhy } from "../lib/regions";
|
||||||
import type { Region } from "../lib/types";
|
import type { Region } from "../lib/types";
|
||||||
|
|
||||||
interface RegionPanelProps {
|
interface RegionPanelProps {
|
||||||
@@ -10,6 +12,7 @@ interface RegionPanelProps {
|
|||||||
/* Detail panel for a selected region body: what it is, why it got its name,
|
/* Detail panel for a selected region body: what it is, why it got its name,
|
||||||
* its representative symbols, and the door into its full-detail layout. */
|
* its representative symbols, and the door into its full-detail layout. */
|
||||||
export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) {
|
export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) {
|
||||||
|
const t = useUiMessages();
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-card/95 backdrop-blur-xl flex flex-col h-full min-h-0 overflow-hidden">
|
<div className="w-full bg-card/95 backdrop-blur-xl flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
<div className="px-4 pt-4 pb-3 border-b border-border/30">
|
<div className="px-4 pt-4 pb-3 border-b border-border/30">
|
||||||
@@ -38,7 +41,7 @@ export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) {
|
|||||||
|
|
||||||
{region.why && (
|
{region.why && (
|
||||||
<p className="text-[13px] text-foreground/40 mt-2 leading-relaxed">
|
<p className="text-[13px] text-foreground/40 mt-2 leading-relaxed">
|
||||||
{region.why}
|
{localizeRegionWhy(region.why, t.regions)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useUiMessages } from "../lib/i18n";
|
||||||
import {
|
import {
|
||||||
fetchWhy,
|
fetchWhy,
|
||||||
formatGuard,
|
formatGuard,
|
||||||
@@ -12,27 +13,29 @@ import {
|
|||||||
* Thin by default; elision declared; guards honest about being syntactic. */
|
* Thin by default; elision declared; guards honest about being syntactic. */
|
||||||
|
|
||||||
function DispatchChip({ entry }: { entry: WhyEntry }) {
|
function DispatchChip({ entry }: { entry: WhyEntry }) {
|
||||||
|
const t = useUiMessages();
|
||||||
if (!entry.candidates || entry.candidates <= 1) return null;
|
if (!entry.candidates || entry.candidates <= 1) return null;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="text-[11px] px-1.5 rounded-full border border-border/60 text-foreground/55"
|
className="text-[11px] px-1.5 rounded-full border border-border/60 text-foreground/55"
|
||||||
title="dynamic dispatch: the resolver saw several possible targets for this call site — this is one of them"
|
title={t.why.dispatchTitle}
|
||||||
>
|
>
|
||||||
◇ 1 of {entry.candidates} targets
|
{t.why.dispatchChip(entry.candidates)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GuardChips({ entry }: { entry: WhyEntry }) {
|
function GuardChips({ entry }: { entry: WhyEntry }) {
|
||||||
|
const t = useUiMessages();
|
||||||
if (entry.guards_unavailable)
|
if (entry.guards_unavailable)
|
||||||
return <span className="text-[12px] text-foreground/35">guards unavailable (source not readable)</span>;
|
return <span className="text-[12px] text-foreground/35">{t.why.guardsUnavailable}</span>;
|
||||||
if (entry.guards.length === 0 && !entry.loop)
|
if (entry.guards.length === 0 && !entry.loop)
|
||||||
return <span className="text-[12px] text-foreground/35">unguarded — always on this path</span>;
|
return <span className="text-[12px] text-foreground/35">{t.why.unguarded}</span>;
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-wrap gap-1 items-center">
|
<span className="inline-flex flex-wrap gap-1 items-center">
|
||||||
{entry.loop && (
|
{entry.loop && (
|
||||||
<span className="text-[11px] px-1.5 rounded-full border border-border/60 text-foreground/50" title="the call site sits inside a loop">
|
<span className="text-[11px] px-1.5 rounded-full border border-border/60 text-foreground/50" title={t.why.loopChipTitle}>
|
||||||
⟳ loop
|
{t.why.loopChip}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.guards.map((guard, index) => (
|
{entry.guards.map((guard, index) => (
|
||||||
@@ -43,9 +46,9 @@ function GuardChips({ entry }: { entry: WhyEntry }) {
|
|||||||
? "border-warn/50 text-warn/90 bg-warn/10"
|
? "border-warn/50 text-warn/90 bg-warn/10"
|
||||||
: "border-primary/40 text-primary/90 bg-primary/10"
|
: "border-primary/40 text-primary/90 bg-primary/10"
|
||||||
}`}
|
}`}
|
||||||
title={`syntactic ${guard.kind} guard around the call site — not a proven path condition`}
|
title={t.why.guardChipTitle(guard.kind)}
|
||||||
>
|
>
|
||||||
? {formatGuard(guard)}
|
? {formatGuard(guard, t.why)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
@@ -242,6 +245,7 @@ export function TriggerTree({
|
|||||||
symbolId: number;
|
symbolId: number;
|
||||||
onOpenSymbol: (ref: { id?: number; qn?: string }) => void;
|
onOpenSymbol: (ref: { id?: number; qn?: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useUiMessages();
|
||||||
const [direction, setDirection] = useState<"up" | "down">("up");
|
const [direction, setDirection] = useState<"up" | "down">("up");
|
||||||
const [root, setRoot] = useState<WhyPayload | null>(null);
|
const [root, setRoot] = useState<WhyPayload | null>(null);
|
||||||
const [asTable, setAsTable] = useState(false);
|
const [asTable, setAsTable] = useState(false);
|
||||||
@@ -264,7 +268,7 @@ export function TriggerTree({
|
|||||||
<div className="bg-card border border-border/50 rounded-md p-4">
|
<div className="bg-card border border-border/50 rounded-md p-4">
|
||||||
<div className="flex items-baseline gap-2 mb-2">
|
<div className="flex items-baseline gap-2 mb-2">
|
||||||
<p className="text-[12px] uppercase tracking-widest text-foreground/40">
|
<p className="text-[12px] uppercase tracking-widest text-foreground/40">
|
||||||
{direction === "up" ? "When does this run?" : "What does this trigger?"}
|
{direction === "up" ? t.why.whenRuns : t.why.whatTriggers}
|
||||||
</p>
|
</p>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{guardedCount >= 3 && (
|
{guardedCount >= 3 && (
|
||||||
@@ -280,16 +284,16 @@ export function TriggerTree({
|
|||||||
onClick={() => setDirection((d) => (d === "up" ? "down" : "up"))}
|
onClick={() => setDirection((d) => (d === "up" ? "down" : "up"))}
|
||||||
className="text-[12px] text-foreground/45 hover:text-primary transition-colors"
|
className="text-[12px] text-foreground/45 hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{direction === "up" ? "→ what it triggers" : "← when it runs"}
|
{direction === "up" ? t.why.toWhatTriggers : t.why.toWhenRuns}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{root && allUnguarded(root.entries) && root.entries.length > 0 && root.entries.length <= 2 ? (
|
{root && allUnguarded(root.entries) && root.entries.length > 0 && root.entries.length <= 2 ? (
|
||||||
<p className="text-[13px] text-foreground/60">
|
<p className="text-[13px] text-foreground/60">
|
||||||
{direction === "up" ? "Always runs when " : "Always triggers "}
|
{direction === "up" ? t.why.alwaysRunsPrefix : t.why.alwaysTriggersPrefix}
|
||||||
{root.entries.map((entry, index) => (
|
{root.entries.map((entry, index) => (
|
||||||
<span key={entry.id}>
|
<span key={entry.id}>
|
||||||
{index > 0 && " and "}
|
{index > 0 && t.why.alwaysJoiner}
|
||||||
<button
|
<button
|
||||||
onClick={() => onOpenSymbol({ id: entry.id })}
|
onClick={() => onOpenSymbol({ id: entry.id })}
|
||||||
className="font-mono text-foreground/80 hover:text-primary transition-colors"
|
className="font-mono text-foreground/80 hover:text-primary transition-colors"
|
||||||
@@ -298,7 +302,7 @@ export function TriggerTree({
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{direction === "up" ? " runs — no conditions at the call sites." : " — unconditionally."}
|
{direction === "up" ? t.why.alwaysRunsSuffix : t.why.alwaysTriggersSuffix}
|
||||||
</p>
|
</p>
|
||||||
) : asTable && root ? (
|
) : asTable && root ? (
|
||||||
<GuardTable
|
<GuardTable
|
||||||
@@ -314,10 +318,7 @@ export function TriggerTree({
|
|||||||
onOpenSymbol={onOpenSymbol}
|
onOpenSymbol={onOpenSymbol}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-foreground/30 mt-2">
|
<p className="text-[11px] text-foreground/30 mt-2">{t.why.honestyFooter}</p>
|
||||||
Guards are the syntactic conditions around each call site — not proven path
|
|
||||||
conditions. Dispatch, events and reflection are invisible here.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CPLX_TAIL_START,
|
CPLX_TAIL_START,
|
||||||
complexityTakeaway,
|
complexityTakeaway,
|
||||||
} from "./complexity";
|
} from "./complexity";
|
||||||
|
import { messages } from "./i18n";
|
||||||
|
|
||||||
/* Parse a bin label back into its bounds so the text the user reads under
|
/* Parse a bin label back into its bounds so the text the user reads under
|
||||||
* the bars cannot drift from the numeric metadata the sentence derives
|
* the bars cannot drift from the numeric metadata the sentence derives
|
||||||
@@ -37,25 +38,47 @@ describe("complexity bins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("complexityTakeaway", () => {
|
describe("complexityTakeaway", () => {
|
||||||
|
/* Expectations recounted from the label-parsed bounds, independent of the
|
||||||
|
* metadata. Both languages' sentences derive from the same recount, so a
|
||||||
|
* bin edit fails both or neither. */
|
||||||
|
const hist = [4, 6, 3, 2, 1, 1];
|
||||||
|
const total = hist.reduce((a, b) => a + b, 0);
|
||||||
|
const simple = hist
|
||||||
|
.filter((_, index) => parseLabel(CPLX_BINS[index].label).hi <= CPLX_SIMPLE_MAX)
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
const tail = hist
|
||||||
|
.filter((_, index) => parseLabel(CPLX_BINS[index].label).lo > CPLX_TAIL_MIN)
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
const simpleMax = parseLabel(CPLX_BINS[1].label).hi;
|
||||||
|
const tailMin = parseLabel(CPLX_BINS[CPLX_TAIL_START].label).lo - 1;
|
||||||
|
const pct = ((simple / total) * 100).toFixed(0);
|
||||||
|
|
||||||
it("claims exactly what the labels claim", () => {
|
it("claims exactly what the labels claim", () => {
|
||||||
const hist = [4, 6, 3, 2, 1, 1];
|
expect(complexityTakeaway(hist, [])).toBe(
|
||||||
const sentence = complexityTakeaway(hist, [])!;
|
`${pct}% of functions are simple (≤${simpleMax}); ${tail} exceed ${tailMin}.`,
|
||||||
/* Recount from the label-parsed bounds, independent of the metadata. */
|
|
||||||
const simple = hist
|
|
||||||
.filter((_, index) => parseLabel(CPLX_BINS[index].label).hi <= CPLX_SIMPLE_MAX)
|
|
||||||
.reduce((a, b) => a + b, 0);
|
|
||||||
const tail = hist
|
|
||||||
.filter((_, index) => parseLabel(CPLX_BINS[index].label).lo > CPLX_TAIL_MIN)
|
|
||||||
.reduce((a, b) => a + b, 0);
|
|
||||||
const total = hist.reduce((a, b) => a + b, 0);
|
|
||||||
expect(sentence).toBe(
|
|
||||||
`${((simple / total) * 100).toFixed(0)}% of functions are simple (≤${CPLX_SIMPLE_MAX}); ${tail} exceed ${CPLX_TAIL_MIN}.`,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it("composes the zh sentence from the same label-parsed bounds", () => {
|
||||||
|
expect(complexityTakeaway(hist, [], messages.zh.dashboard)).toBe(
|
||||||
|
`${pct}% 的函数是简单的(≤${simpleMax});${tail} 个超过 ${tailMin}。`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("parses each language's threshold claims back to the bin metadata", () => {
|
||||||
|
for (const m of [messages.en.dashboard, messages.zh.dashboard]) {
|
||||||
|
const sentence = complexityTakeaway(hist, [], m)!;
|
||||||
|
expect(Number(/≤(\d+)/.exec(sentence)![1])).toBe(CPLX_SIMPLE_MAX);
|
||||||
|
const over = /exceed (\d+)|超过 (\d+)/.exec(sentence)!;
|
||||||
|
expect(Number(over[1] ?? over[2])).toBe(CPLX_TAIL_MIN);
|
||||||
|
}
|
||||||
|
});
|
||||||
it("appends the leading names when given", () => {
|
it("appends the leading names when given", () => {
|
||||||
expect(complexityTakeaway([10, 0, 0, 0, 0, 2], ["a", "b"])).toContain("— led by a, b");
|
expect(complexityTakeaway([10, 0, 0, 0, 0, 2], ["a", "b"])).toContain("— led by a, b");
|
||||||
|
expect(
|
||||||
|
complexityTakeaway([10, 0, 0, 0, 0, 2], ["a", "b"], messages.zh.dashboard),
|
||||||
|
).toContain("——以 a、b 为首");
|
||||||
});
|
});
|
||||||
it("returns null on an empty population", () => {
|
it("returns null on an empty population", () => {
|
||||||
expect(complexityTakeaway([0, 0, 0, 0, 0, 0], [])).toBeNull();
|
expect(complexityTakeaway([0, 0, 0, 0, 0, 0], [])).toBeNull();
|
||||||
|
expect(complexityTakeaway([0, 0, 0, 0, 0, 0], [], messages.zh.dashboard)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/* Complexity bins — the single source of truth for the dashboard's
|
/* Complexity bins — the single source of truth for the dashboard's
|
||||||
* histogram labels and the takeaway sentence. The sentence's bin claims
|
* histogram labels and the takeaway sentence. The sentence's bin claims
|
||||||
* ("simple (≤5)", "exceed 20") derive from this table, and a test binds
|
* ("simple (≤5)", "exceed 20") derive from this table, and a test binds
|
||||||
* label text to the numeric bounds, so a label edit cannot silently lie. */
|
* label text to the numeric bounds — in every catalog language — so a
|
||||||
|
* label edit cannot silently lie. */
|
||||||
|
import { messages, type UiMessages } from "./i18n";
|
||||||
|
|
||||||
export interface CplxBin {
|
export interface CplxBin {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,7 +26,15 @@ export const CPLX_SIMPLE_MAX = CPLX_BINS[1].hi;
|
|||||||
export const CPLX_TAIL_START = CPLX_BINS.length - 2;
|
export const CPLX_TAIL_START = CPLX_BINS.length - 2;
|
||||||
export const CPLX_TAIL_MIN = CPLX_BINS[CPLX_TAIL_START - 1].hi;
|
export const CPLX_TAIL_MIN = CPLX_BINS[CPLX_TAIL_START - 1].hi;
|
||||||
|
|
||||||
export function complexityTakeaway(hist: number[], names: string[]): string | null {
|
/* The takeaway sentence, composed by the active locale's catalog message.
|
||||||
|
* Every locale receives the same bin-derived parameters — no locale may
|
||||||
|
* hardcode a threshold. Pass the active locale's dashboard messages to
|
||||||
|
* localize. */
|
||||||
|
export function complexityTakeaway(
|
||||||
|
hist: number[],
|
||||||
|
names: string[],
|
||||||
|
m: UiMessages["dashboard"] = messages.en.dashboard,
|
||||||
|
): string | null {
|
||||||
const total = hist.reduce((a, b) => a + b, 0);
|
const total = hist.reduce((a, b) => a + b, 0);
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
const simple = hist
|
const simple = hist
|
||||||
@@ -33,6 +43,11 @@ export function complexityTakeaway(hist: number[], names: string[]): string | nu
|
|||||||
const tail = hist
|
const tail = hist
|
||||||
.filter((_, index) => index >= CPLX_TAIL_START)
|
.filter((_, index) => index >= CPLX_TAIL_START)
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
const led = names.filter(Boolean).join(", ");
|
return m.takeaway(
|
||||||
return `${((simple / total) * 100).toFixed(0)}% of functions are simple (≤${CPLX_SIMPLE_MAX}); ${tail.toLocaleString("en-US")} exceed ${CPLX_TAIL_MIN}${led ? ` — led by ${led}` : ""}.`;
|
((simple / total) * 100).toFixed(0),
|
||||||
|
CPLX_SIMPLE_MAX,
|
||||||
|
tail,
|
||||||
|
CPLX_TAIL_MIN,
|
||||||
|
names.filter(Boolean),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { kneeCount, costSentence, findingKey } from "./findings";
|
import { kneeCount, costSentence, findingKey } from "./findings";
|
||||||
|
import { messages } from "./i18n";
|
||||||
import { isTestRegion, lensRegionsPayload, isBuiltinQn } from "./regions";
|
import { isTestRegion, lensRegionsPayload, isBuiltinQn } from "./regions";
|
||||||
import type { RegionsPayload } from "./types";
|
import type { RegionsPayload } from "./types";
|
||||||
|
|
||||||
@@ -41,9 +42,15 @@ describe("costSentence", () => {
|
|||||||
"9 files — 1.1% of the codebase, 34% of all commits this year",
|
"9 files — 1.1% of the codebase, 34% of all commits this year",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it("composes the zh sentence from the same numbers", () => {
|
||||||
|
expect(costSentence(9, 340, 800, 1000, messages.zh.dashboard)).toBe(
|
||||||
|
"9 个文件——占代码库的 1.1%,占今年全部提交的 34%",
|
||||||
|
);
|
||||||
|
});
|
||||||
it("returns null without denominators", () => {
|
it("returns null without denominators", () => {
|
||||||
expect(costSentence(9, 340, 0, 1000)).toBeNull();
|
expect(costSentence(9, 340, 0, 1000)).toBeNull();
|
||||||
expect(costSentence(0, 0, 800, 1000)).toBeNull();
|
expect(costSentence(0, 0, 800, 1000)).toBeNull();
|
||||||
|
expect(costSentence(9, 340, 0, 1000, messages.zh.dashboard)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* Findings machinery: knee-point cutoffs, the cost sentence, and finding
|
/* Findings machinery: knee-point cutoffs, the cost sentence, and finding
|
||||||
* dismissals — the difference between a dashboard and a number museum. */
|
* dismissals — the difference between a dashboard and a number museum. */
|
||||||
|
import { messages, type UiMessages } from "./i18n";
|
||||||
|
|
||||||
/* Knee of a descending score list — how many head items deserve emphasis:
|
/* Knee of a descending score list — how many head items deserve emphasis:
|
||||||
* "the head that matters" (CodeScene's refusal to show 5,000 items).
|
* "the head that matters" (CodeScene's refusal to show 5,000 items).
|
||||||
@@ -37,17 +38,20 @@ export function kneeCount(values: number[]): number {
|
|||||||
return Math.min(Math.max(knee, 3), Math.min(15, Math.ceil(n / 2)));
|
return Math.min(Math.max(knee, 3), Math.min(15, Math.ceil(n / 2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "9 files — 1.1% of the codebase, 34% of all commits this year." */
|
/* "9 files — 1.1% of the codebase, 34% of all commits this year." The
|
||||||
|
* shares are computed here so every locale interpolates identical numbers.
|
||||||
|
* Pass the active locale's dashboard messages to localize. */
|
||||||
export function costSentence(
|
export function costSentence(
|
||||||
headFiles: number,
|
headFiles: number,
|
||||||
headCommits: number,
|
headCommits: number,
|
||||||
totalFiles: number,
|
totalFiles: number,
|
||||||
totalCommits: number,
|
totalCommits: number,
|
||||||
|
m: UiMessages["dashboard"] = messages.en.dashboard,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (headFiles <= 0 || totalFiles <= 0 || totalCommits <= 0) return null;
|
if (headFiles <= 0 || totalFiles <= 0 || totalCommits <= 0) return null;
|
||||||
const filesShare = ((headFiles / totalFiles) * 100).toFixed(1);
|
const filesShare = ((headFiles / totalFiles) * 100).toFixed(1);
|
||||||
const commitShare = ((headCommits / totalCommits) * 100).toFixed(0);
|
const commitShare = ((headCommits / totalCommits) * 100).toFixed(0);
|
||||||
return `${headFiles} file${headFiles > 1 ? "s" : ""} — ${filesShare}% of the codebase, ${commitShare}% of all commits this year`;
|
return m.costSentence(headFiles, filesShare, commitShare);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dismissals: a finding stays dismissed until its magnitude changes bucket
|
/* Dismissals: a finding stays dismissed until its magnitude changes bucket
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { surprisingCouplings, suggestedQuestions } from "./firstread";
|
||||||
|
import { messages } from "./i18n";
|
||||||
|
import type { RegionsPayload } from "./types";
|
||||||
|
|
||||||
|
/* One coupling that trips all three reasons: heavy, cross-top-level, and
|
||||||
|
* touching a low-cohesion region big enough to also earn the split
|
||||||
|
* question. */
|
||||||
|
const payload: RegionsPayload = {
|
||||||
|
level: "regions",
|
||||||
|
method: "leiden+folders",
|
||||||
|
total_nodes: 100,
|
||||||
|
unmapped_nodes: 0,
|
||||||
|
regions: [
|
||||||
|
{ id: 0, name: "src/core", files: 4, members: 60, cohesion: 0.8, top_nodes: [], x: 0, y: 0, z: 0, size: 1, color: "#111111" },
|
||||||
|
{ id: 1, name: "web/api", files: 3, members: 50, cohesion: 0.2, top_nodes: [], x: 0, y: 0, z: 0, size: 1, color: "#222222" },
|
||||||
|
],
|
||||||
|
edges: [{ source: 0, target: 1, weight: 1200 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("surprisingCouplings", () => {
|
||||||
|
it("explains the ranking in English by default", () => {
|
||||||
|
const [coupling] = surprisingCouplings(payload);
|
||||||
|
expect(coupling.reasons).toEqual([
|
||||||
|
"1,200 edges cross the boundary",
|
||||||
|
"links src/ to web/ — different top-level areas",
|
||||||
|
"web/api holds together loosely (cohesion 0.20)",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("composes the reasons natively in zh from the same parameters", () => {
|
||||||
|
const [coupling] = surprisingCouplings(payload, 5, messages.zh.firstread);
|
||||||
|
expect(coupling.reasons).toEqual([
|
||||||
|
"1,200 条边跨越这条边界",
|
||||||
|
"连接 src/ 与 web/——分属不同的顶层目录",
|
||||||
|
"web/api 内聚松散(cohesion 0.20)",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestedQuestions", () => {
|
||||||
|
it("asks in English by default, evidence joined with a semicolon", () => {
|
||||||
|
const questions = suggestedQuestions(payload, { deadCount: 1234 });
|
||||||
|
expect(questions[0].question).toBe("Why does src/core depend on web/api?");
|
||||||
|
expect(questions[0].why).toContain("; ");
|
||||||
|
expect(questions.map((q) => q.question)).toContain(
|
||||||
|
"Are the 1,234 functions with no callers safe to delete?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("asks the zh questions with zh evidence, seeds untouched", () => {
|
||||||
|
const questions = suggestedQuestions(
|
||||||
|
payload,
|
||||||
|
{ deadCount: 1234 },
|
||||||
|
5,
|
||||||
|
messages.zh.firstread,
|
||||||
|
);
|
||||||
|
expect(questions[0].question).toBe("为什么 src/core 依赖 web/api?");
|
||||||
|
expect(questions[0].why).toContain(";");
|
||||||
|
expect(questions.map((q) => q.question)).toContain("web/api 应该拆分成更小的模块吗?");
|
||||||
|
const dead = questions.find((q) => q.about.includes("dead code"))!;
|
||||||
|
expect(dead.question).toBe("这 1,234 个没有调用者的函数可以安全删除吗?");
|
||||||
|
expect(dead.why).toBe("没有任何 CALLS 或 USAGE 到达它们——已排除入口点与测试");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* graph already holds — every insight carries the reason it is interesting.
|
* graph already holds — every insight carries the reason it is interesting.
|
||||||
* No model, no scores without provenance. */
|
* No model, no scores without provenance. */
|
||||||
import type { Region, RegionsPayload } from "./types";
|
import type { Region, RegionsPayload } from "./types";
|
||||||
|
import { messages, type UiMessages } from "./i18n";
|
||||||
import { disambiguateRegionNames } from "./regions";
|
import { disambiguateRegionNames } from "./regions";
|
||||||
|
|
||||||
export interface SurprisingCoupling {
|
export interface SurprisingCoupling {
|
||||||
@@ -13,10 +14,12 @@ export interface SurprisingCoupling {
|
|||||||
|
|
||||||
/* Cross-region couplings ranked by an explained score: heavier links, links
|
/* Cross-region couplings ranked by an explained score: heavier links, links
|
||||||
* that cross top-level directories, and links touching a low-cohesion region
|
* that cross top-level directories, and links touching a low-cohesion region
|
||||||
* rank higher. `misc` never surprises anyone. */
|
* rank higher. `misc` never surprises anyone. Pass the active locale's
|
||||||
|
* firstread messages to localize the reasons. */
|
||||||
export function surprisingCouplings(
|
export function surprisingCouplings(
|
||||||
payload: RegionsPayload,
|
payload: RegionsPayload,
|
||||||
limit = 5,
|
limit = 5,
|
||||||
|
m: UiMessages["firstread"] = messages.en.firstread,
|
||||||
): SurprisingCoupling[] {
|
): SurprisingCoupling[] {
|
||||||
const regions = disambiguateRegionNames(payload.regions);
|
const regions = disambiguateRegionNames(payload.regions);
|
||||||
const byId = new Map(regions.map((region) => [region.id, region]));
|
const byId = new Map(regions.map((region) => [region.id, region]));
|
||||||
@@ -29,19 +32,15 @@ export function surprisingCouplings(
|
|||||||
if (source.name === "misc" || target.name === "misc") continue;
|
if (source.name === "misc" || target.name === "misc") continue;
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
let score = Math.log2(1 + edge.weight);
|
let score = Math.log2(1 + edge.weight);
|
||||||
reasons.push(`${edge.weight.toLocaleString("en-US")} edges cross the boundary`);
|
reasons.push(m.edgesCross(edge.weight));
|
||||||
if (top(source.name) !== top(target.name)) {
|
if (top(source.name) !== top(target.name)) {
|
||||||
score += 2;
|
score += 2;
|
||||||
reasons.push(
|
reasons.push(m.linksAreas(top(source.name), top(target.name)));
|
||||||
`links ${top(source.name)}/ to ${top(target.name)}/ — different top-level areas`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (source.cohesion < 0.3 || target.cohesion < 0.3) {
|
if (source.cohesion < 0.3 || target.cohesion < 0.3) {
|
||||||
score += 1;
|
score += 1;
|
||||||
const loose = source.cohesion < target.cohesion ? source : target;
|
const loose = source.cohesion < target.cohesion ? source : target;
|
||||||
reasons.push(
|
reasons.push(m.holdsLoosely(loose.name, loose.cohesion));
|
||||||
`${loose.name} holds together loosely (cohesion ${loose.cohesion.toFixed(2)})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
scored.push({ source, target, weight: edge.weight, reasons, score });
|
scored.push({ source, target, weight: edge.weight, reasons, score });
|
||||||
}
|
}
|
||||||
@@ -64,22 +63,25 @@ export interface FirstReadStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Question templates keyed to structural signals — each one is a prompt
|
/* Question templates keyed to structural signals — each one is a prompt
|
||||||
* starter the human can hand to their agent. */
|
* starter the human can hand to their agent. Pass the active locale's
|
||||||
|
* firstread messages to localize question and evidence; the `about` seeds
|
||||||
|
* stay English (they feed the composer, not the page). */
|
||||||
export function suggestedQuestions(
|
export function suggestedQuestions(
|
||||||
payload: RegionsPayload,
|
payload: RegionsPayload,
|
||||||
stats: FirstReadStats = {},
|
stats: FirstReadStats = {},
|
||||||
limit = 5,
|
limit = 5,
|
||||||
|
m: UiMessages["firstread"] = messages.en.firstread,
|
||||||
): SuggestedQuestion[] {
|
): SuggestedQuestion[] {
|
||||||
const regions = disambiguateRegionNames(payload.regions).filter(
|
const regions = disambiguateRegionNames(payload.regions).filter(
|
||||||
(region) => region.name !== "misc",
|
(region) => region.name !== "misc",
|
||||||
);
|
);
|
||||||
const questions: SuggestedQuestion[] = [];
|
const questions: SuggestedQuestion[] = [];
|
||||||
|
|
||||||
const couplings = surprisingCouplings(payload, 2);
|
const couplings = surprisingCouplings(payload, 2, m);
|
||||||
for (const coupling of couplings) {
|
for (const coupling of couplings) {
|
||||||
questions.push({
|
questions.push({
|
||||||
question: `Why does ${coupling.source.name} depend on ${coupling.target.name}?`,
|
question: m.questionWhyDepend(coupling.source.name, coupling.target.name),
|
||||||
why: coupling.reasons.join("; "),
|
why: coupling.reasons.join(m.reasonSeparator),
|
||||||
about: [coupling.source.name, coupling.target.name],
|
about: [coupling.source.name, coupling.target.name],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -89,24 +91,24 @@ export function suggestedQuestions(
|
|||||||
.sort((a, b) => a.cohesion - b.cohesion)[0];
|
.sort((a, b) => a.cohesion - b.cohesion)[0];
|
||||||
if (loose) {
|
if (loose) {
|
||||||
questions.push({
|
questions.push({
|
||||||
question: `Should ${loose.name} be split into smaller modules?`,
|
question: m.questionSplit(loose.name),
|
||||||
why: `only ${(loose.cohesion * 100).toFixed(0)}% of its edges stay inside the region (${loose.members.toLocaleString("en-US")} symbols)`,
|
why: m.whySplit((loose.cohesion * 100).toFixed(0), loose.members),
|
||||||
about: [loose.name],
|
about: [loose.name],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.deadCount && stats.deadCount > 0) {
|
if (stats.deadCount && stats.deadCount > 0) {
|
||||||
questions.push({
|
questions.push({
|
||||||
question: `Are the ${stats.deadCount.toLocaleString("en-US")} functions with no callers safe to delete?`,
|
question: m.questionDeadSafe(stats.deadCount),
|
||||||
why: "zero CALLS and zero USAGE reach them, excluding entry points and tests",
|
why: m.whyDeadSafe,
|
||||||
about: ["dead code"],
|
about: ["dead code"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.unresolvedShare !== undefined && stats.unresolvedShare > 0.3) {
|
if (stats.unresolvedShare !== undefined && stats.unresolvedShare > 0.3) {
|
||||||
questions.push({
|
questions.push({
|
||||||
question: "Which call sites does the graph fail to resolve, and why?",
|
question: m.questionUnresolved,
|
||||||
why: `${(stats.unresolvedShare * 100).toFixed(0)}% of call-ish edges are USAGE (no proven single target)`,
|
why: m.whyUnresolved((stats.unresolvedShare * 100).toFixed(0)),
|
||||||
about: ["resolution certainty"],
|
about: ["resolution certainty"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,84 @@ export const messages = {
|
|||||||
inventory: (symbols: string, edges: string, regions: string) =>
|
inventory: (symbols: string, edges: string, regions: string) =>
|
||||||
`${symbols} symbols · ${edges} edges · ${regions} regions`,
|
`${symbols} symbols · ${edges} edges · ${regions} regions`,
|
||||||
},
|
},
|
||||||
|
dashboard: {
|
||||||
|
/* Both thresholds arrive from CPLX_BINS via complexityTakeaway — the
|
||||||
|
* binding test in complexity.test.ts parses each language's claims
|
||||||
|
* back against the bin labels, so a bin edit fails both or neither. */
|
||||||
|
takeaway: (
|
||||||
|
simplePct: string,
|
||||||
|
simpleMax: number,
|
||||||
|
tail: number,
|
||||||
|
tailMin: number,
|
||||||
|
led: string[],
|
||||||
|
) =>
|
||||||
|
`${simplePct}% of functions are simple (≤${simpleMax}); ${tail.toLocaleString("en-US")} exceed ${tailMin}${led.length > 0 ? ` — led by ${led.join(", ")}` : ""}.`,
|
||||||
|
costSentence: (files: number, filesShare: string, commitShare: string) =>
|
||||||
|
`${files} file${files > 1 ? "s" : ""} — ${filesShare}% of the codebase, ${commitShare}% of all commits this year`,
|
||||||
|
costConcentrate: " — change here is where defects concentrate.",
|
||||||
|
},
|
||||||
|
why: {
|
||||||
|
whenRuns: "When does this run?",
|
||||||
|
whatTriggers: "What does this trigger?",
|
||||||
|
toWhatTriggers: "→ what it triggers",
|
||||||
|
toWhenRuns: "← when it runs",
|
||||||
|
dispatchChip: (n: number) => `◇ 1 of ${n} targets`,
|
||||||
|
dispatchTitle:
|
||||||
|
"dynamic dispatch: the resolver saw several possible targets for this call site — this is one of them",
|
||||||
|
guardsUnavailable: "guards unavailable (source not readable)",
|
||||||
|
unguarded: "unguarded — always on this path",
|
||||||
|
loopChip: "⟳ loop",
|
||||||
|
loopChipTitle: "the call site sits inside a loop",
|
||||||
|
guardChipTitle: (kind: string) =>
|
||||||
|
`syntactic ${kind} guard around the call site — not a proven path condition`,
|
||||||
|
alwaysRunsPrefix: "Always runs when ",
|
||||||
|
alwaysRunsSuffix: " runs — no conditions at the call sites.",
|
||||||
|
alwaysTriggersPrefix: "Always triggers ",
|
||||||
|
alwaysTriggersSuffix: " — unconditionally.",
|
||||||
|
alwaysJoiner: " and ",
|
||||||
|
honestyFooter:
|
||||||
|
"Guards are the syntactic conditions around each call site — not proven path conditions. Dispatch, events and reflection are invisible here.",
|
||||||
|
/* Guard fragments — the condition expression stays verbatim code. */
|
||||||
|
guardWhen: (cond: string) => `when ${cond}`,
|
||||||
|
guardUnless: (cond: string) => `unless ${cond}`,
|
||||||
|
guardConditionally: "conditionally",
|
||||||
|
guardElseArm: "in an else arm",
|
||||||
|
guardCase: (cond: string) => `case ${cond}`,
|
||||||
|
guardSwitchCase: "in a switch case",
|
||||||
|
guardSwitchOn: (cond: string) => `switch on ${cond}`,
|
||||||
|
guardSwitch: "in a switch",
|
||||||
|
guardLoopWhile: (cond: string) => `looping while ${cond}`,
|
||||||
|
guardLoop: "in a loop",
|
||||||
|
guardCatch: "on error handling",
|
||||||
|
chainJoiner: " → ",
|
||||||
|
},
|
||||||
|
firstread: {
|
||||||
|
edgesCross: (weight: number) =>
|
||||||
|
`${weight.toLocaleString("en-US")} edges cross the boundary`,
|
||||||
|
linksAreas: (a: string, b: string) =>
|
||||||
|
`links ${a}/ to ${b}/ — different top-level areas`,
|
||||||
|
holdsLoosely: (name: string, cohesion: number) =>
|
||||||
|
`${name} holds together loosely (cohesion ${cohesion.toFixed(2)})`,
|
||||||
|
reasonSeparator: "; ",
|
||||||
|
questionWhyDepend: (a: string, b: string) => `Why does ${a} depend on ${b}?`,
|
||||||
|
questionSplit: (name: string) => `Should ${name} be split into smaller modules?`,
|
||||||
|
whySplit: (pct: string, members: number) =>
|
||||||
|
`only ${pct}% of its edges stay inside the region (${members.toLocaleString("en-US")} symbols)`,
|
||||||
|
questionDeadSafe: (n: number) =>
|
||||||
|
`Are the ${n.toLocaleString("en-US")} functions with no callers safe to delete?`,
|
||||||
|
whyDeadSafe: "zero CALLS and zero USAGE reach them, excluding entry points and tests",
|
||||||
|
questionUnresolved: "Which call sites does the graph fail to resolve, and why?",
|
||||||
|
whyUnresolved: (pct: string) =>
|
||||||
|
`${pct}% of call-ish edges are USAGE (no proven single target)`,
|
||||||
|
},
|
||||||
|
regions: {
|
||||||
|
/* The en messages reproduce layout_regions.c's templates byte-for-byte
|
||||||
|
* — localizeRegionWhy round-trips English through them unchanged. */
|
||||||
|
whyCallCommunity: (files: number, pct: number, dir: string) =>
|
||||||
|
`call community: ${files} files, ${pct}% under ${dir}`,
|
||||||
|
whyFolderGroup: "folder group (not explained by a kept call community)",
|
||||||
|
whyMisc: "files outside every kept community and folder group",
|
||||||
|
},
|
||||||
control: {
|
control: {
|
||||||
panel: "Control Panel",
|
panel: "Control Panel",
|
||||||
totalCpu: "Total CPU",
|
totalCpu: "Total CPU",
|
||||||
@@ -559,6 +637,79 @@ export const messages = {
|
|||||||
inventory: (symbols: string, edges: string, regions: string) =>
|
inventory: (symbols: string, edges: string, regions: string) =>
|
||||||
`${symbols} 个符号 · ${edges} 条边 · ${regions} 个区域`,
|
`${symbols} 个符号 · ${edges} 条边 · ${regions} 个区域`,
|
||||||
},
|
},
|
||||||
|
dashboard: {
|
||||||
|
takeaway: (
|
||||||
|
simplePct: string,
|
||||||
|
simpleMax: number,
|
||||||
|
tail: number,
|
||||||
|
tailMin: number,
|
||||||
|
led: string[],
|
||||||
|
) =>
|
||||||
|
`${simplePct}% 的函数是简单的(≤${simpleMax});${tail.toLocaleString("en-US")} 个超过 ${tailMin}${led.length > 0 ? `——以 ${led.join("、")} 为首` : ""}。`,
|
||||||
|
costSentence: (files: number, filesShare: string, commitShare: string) =>
|
||||||
|
`${files} 个文件——占代码库的 ${filesShare}%,占今年全部提交的 ${commitShare}%`,
|
||||||
|
costConcentrate: "——缺陷就集中在这里的变更中。",
|
||||||
|
},
|
||||||
|
why: {
|
||||||
|
whenRuns: "它何时运行?",
|
||||||
|
whatTriggers: "它会触发什么?",
|
||||||
|
toWhatTriggers: "→ 它触发什么",
|
||||||
|
toWhenRuns: "← 它何时运行",
|
||||||
|
dispatchChip: (n: number) => `◇ ${n} 个候选目标之一`,
|
||||||
|
dispatchTitle:
|
||||||
|
"动态分发:解析器在这个调用点看到多个可能的目标——这是其中之一",
|
||||||
|
guardsUnavailable: "守卫不可用(源码不可读)",
|
||||||
|
unguarded: "无守卫——这条路径上必然执行",
|
||||||
|
loopChip: "⟳ 循环",
|
||||||
|
loopChipTitle: "调用点位于循环内部",
|
||||||
|
guardChipTitle: (kind: string) =>
|
||||||
|
`调用点周围的语法 ${kind} 守卫——不是经过证明的路径条件`,
|
||||||
|
alwaysRunsPrefix: "只要 ",
|
||||||
|
alwaysRunsSuffix: " 运行,它就一定运行——调用点上没有任何条件。",
|
||||||
|
alwaysTriggersPrefix: "总是触发 ",
|
||||||
|
alwaysTriggersSuffix: "——无条件。",
|
||||||
|
alwaysJoiner: " 与 ",
|
||||||
|
honestyFooter:
|
||||||
|
"守卫只是各调用点周围的语法条件——不是经过证明的路径条件。分发、事件与反射在这里不可见。",
|
||||||
|
/* 守卫片段——条件表达式保留代码原文。 */
|
||||||
|
guardWhen: (cond: string) => `当 ${cond} 时`,
|
||||||
|
guardUnless: (cond: string) => `除非 ${cond}`,
|
||||||
|
guardConditionally: "视条件而定",
|
||||||
|
guardElseArm: "在 else 分支中",
|
||||||
|
guardCase: (cond: string) => `匹配 ${cond} 分支`,
|
||||||
|
guardSwitchCase: "在 switch 分支中",
|
||||||
|
guardSwitchOn: (cond: string) => `按 ${cond} 分支`,
|
||||||
|
guardSwitch: "在 switch 中",
|
||||||
|
guardLoopWhile: (cond: string) => `当 ${cond} 时循环`,
|
||||||
|
guardLoop: "在循环中",
|
||||||
|
guardCatch: "在错误处理中",
|
||||||
|
chainJoiner: " → ",
|
||||||
|
},
|
||||||
|
firstread: {
|
||||||
|
edgesCross: (weight: number) =>
|
||||||
|
`${weight.toLocaleString("en-US")} 条边跨越这条边界`,
|
||||||
|
linksAreas: (a: string, b: string) =>
|
||||||
|
`连接 ${a}/ 与 ${b}/——分属不同的顶层目录`,
|
||||||
|
holdsLoosely: (name: string, cohesion: number) =>
|
||||||
|
`${name} 内聚松散(cohesion ${cohesion.toFixed(2)})`,
|
||||||
|
reasonSeparator: ";",
|
||||||
|
questionWhyDepend: (a: string, b: string) => `为什么 ${a} 依赖 ${b}?`,
|
||||||
|
questionSplit: (name: string) => `${name} 应该拆分成更小的模块吗?`,
|
||||||
|
whySplit: (pct: string, members: number) =>
|
||||||
|
`只有 ${pct}% 的边留在区域内部(${members.toLocaleString("en-US")} 个符号)`,
|
||||||
|
questionDeadSafe: (n: number) =>
|
||||||
|
`这 ${n.toLocaleString("en-US")} 个没有调用者的函数可以安全删除吗?`,
|
||||||
|
whyDeadSafe: "没有任何 CALLS 或 USAGE 到达它们——已排除入口点与测试",
|
||||||
|
questionUnresolved: "图谱未能解析哪些调用点?为什么?",
|
||||||
|
whyUnresolved: (pct: string) =>
|
||||||
|
`${pct}% 的调用类边是 USAGE(没有可证明的唯一目标)`,
|
||||||
|
},
|
||||||
|
regions: {
|
||||||
|
whyCallCommunity: (files: number, pct: number, dir: string) =>
|
||||||
|
`调用社区:${files} 个文件,${pct}% 位于 ${dir} 之下`,
|
||||||
|
whyFolderGroup: "目录分组(未被任何保留的调用社区解释)",
|
||||||
|
whyMisc: "游离于所有保留社区与目录分组之外的文件",
|
||||||
|
},
|
||||||
control: {
|
control: {
|
||||||
panel: "控制面板",
|
panel: "控制面板",
|
||||||
totalCpu: "总 CPU",
|
totalCpu: "总 CPU",
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
regionsViewWorthwhile,
|
regionsViewWorthwhile,
|
||||||
REGIONS_MIN_TOTAL_NODES,
|
REGIONS_MIN_TOTAL_NODES,
|
||||||
cohesionWord,
|
cohesionWord,
|
||||||
|
localizeRegionWhy,
|
||||||
} from "./regions";
|
} from "./regions";
|
||||||
|
import { messages } from "./i18n";
|
||||||
import { minimapLayout, minimapProject } from "../components/Minimap";
|
import { minimapLayout, minimapProject } from "../components/Minimap";
|
||||||
import type { Region, RegionsPayload } from "./types";
|
import type { Region, RegionsPayload } from "./types";
|
||||||
|
|
||||||
@@ -69,6 +71,31 @@ describe("region adapters", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("localizeRegionWhy", () => {
|
||||||
|
const templates = [
|
||||||
|
"call community: 23 files, 87% under src/parser",
|
||||||
|
"folder group (not explained by a kept call community)",
|
||||||
|
"files outside every kept community and folder group",
|
||||||
|
"some future server template",
|
||||||
|
];
|
||||||
|
it("passes the server's English through byte-identically", () => {
|
||||||
|
for (const why of templates) expect(localizeRegionWhy(why)).toBe(why);
|
||||||
|
});
|
||||||
|
it("recomposes the known templates in zh, unknown ones verbatim", () => {
|
||||||
|
const zh = messages.zh.regions;
|
||||||
|
expect(localizeRegionWhy(templates[0], zh)).toBe(
|
||||||
|
"调用社区:23 个文件,87% 位于 src/parser 之下",
|
||||||
|
);
|
||||||
|
expect(localizeRegionWhy(templates[1], zh)).toBe(
|
||||||
|
"目录分组(未被任何保留的调用社区解释)",
|
||||||
|
);
|
||||||
|
expect(localizeRegionWhy(templates[2], zh)).toBe(
|
||||||
|
"游离于所有保留社区与目录分组之外的文件",
|
||||||
|
);
|
||||||
|
expect(localizeRegionWhy(templates[3], zh)).toBe(templates[3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("cohesionWord", () => {
|
describe("cohesionWord", () => {
|
||||||
it("maps scores to the 0.7/0.4 word ladder", () => {
|
it("maps scores to the 0.7/0.4 word ladder", () => {
|
||||||
expect(cohesionWord(0.84)).toBe("tightly connected");
|
expect(cohesionWord(0.84)).toBe("tightly connected");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* Region level of detail — adapters between the level=regions payload
|
/* Region level of detail — adapters between the level=regions payload
|
||||||
* (layout_regions.c) and the node/edge shapes the 3D scene renders. */
|
* (layout_regions.c) and the node/edge shapes the 3D scene renders. */
|
||||||
|
import { messages, type UiMessages } from "./i18n";
|
||||||
import type { GraphData, GraphNode, Region, RegionsPayload } from "./types";
|
import type { GraphData, GraphNode, Region, RegionsPayload } from "./types";
|
||||||
|
|
||||||
/* The scene renders GraphNodes; a region becomes one body. Region ids live in
|
/* The scene renders GraphNodes; a region becomes one body. Region ids live in
|
||||||
@@ -80,6 +81,25 @@ export function lensRegionsPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* region.why arrives from layout_regions.c as one of three English
|
||||||
|
* templates; recompose it in the viewer's locale. The en messages
|
||||||
|
* reproduce the server strings byte-for-byte, and an unrecognized string
|
||||||
|
* (a future server template) passes through verbatim — honest English
|
||||||
|
* beats a wrong translation. */
|
||||||
|
const REGION_WHY_CALL_COMMUNITY = /^call community: (\d+) files, (\d+)% under (.+)$/;
|
||||||
|
|
||||||
|
export function localizeRegionWhy(
|
||||||
|
why: string,
|
||||||
|
m: UiMessages["regions"] = messages.en.regions,
|
||||||
|
): string {
|
||||||
|
const match = REGION_WHY_CALL_COMMUNITY.exec(why);
|
||||||
|
if (match) return m.whyCallCommunity(Number(match[1]), Number(match[2]), match[3]);
|
||||||
|
if (why === "folder group (not explained by a kept call community)")
|
||||||
|
return m.whyFolderGroup;
|
||||||
|
if (why === "files outside every kept community and folder group") return m.whyMisc;
|
||||||
|
return why;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hub lists must never headline builtins — a `len` hub reads as a bug and
|
/* Hub lists must never headline builtins — a `len` hub reads as a bug and
|
||||||
* costs trust in everything else on the page. */
|
* costs trust in everything else on the page. */
|
||||||
export function isBuiltinQn(qn: string): boolean {
|
export function isBuiltinQn(qn: string): boolean {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { messages } from "./i18n";
|
||||||
import { formatGuard, formatGuardChain, allUnguarded, type WhyEntry } from "./why";
|
import { formatGuard, formatGuardChain, allUnguarded, type WhyEntry } from "./why";
|
||||||
|
|
||||||
describe("formatGuard", () => {
|
describe("formatGuard", () => {
|
||||||
@@ -20,6 +21,30 @@ describe("formatGuard", () => {
|
|||||||
]),
|
]),
|
||||||
).toBe("when mode > 2 → when strict");
|
).toBe("when mode > 2 → when strict");
|
||||||
});
|
});
|
||||||
|
it("composes zh guards with native grammar, conditions verbatim", () => {
|
||||||
|
const zh = messages.zh.why;
|
||||||
|
expect(formatGuard({ kind: "if", cond: "mode > 2" }, zh)).toBe("当 mode > 2 时");
|
||||||
|
expect(formatGuard({ kind: "if", cond: "strict", negated: true }, zh)).toBe(
|
||||||
|
"除非 strict",
|
||||||
|
);
|
||||||
|
expect(formatGuard({ kind: "case", cond: "CBM_LANG_C" }, zh)).toBe(
|
||||||
|
"匹配 CBM_LANG_C 分支",
|
||||||
|
);
|
||||||
|
expect(formatGuard({ kind: "loop", cond: "i < n" }, zh)).toBe("当 i < n 时循环");
|
||||||
|
expect(formatGuard({ kind: "loop" }, zh)).toBe("在循环中");
|
||||||
|
expect(formatGuard({ kind: "catch" }, zh)).toBe("在错误处理中");
|
||||||
|
});
|
||||||
|
it("chains a zh guard sentence outermost-first", () => {
|
||||||
|
expect(
|
||||||
|
formatGuardChain(
|
||||||
|
[
|
||||||
|
{ kind: "if", cond: "mode > 2" },
|
||||||
|
{ kind: "if", cond: "strict", negated: true },
|
||||||
|
],
|
||||||
|
messages.zh.why,
|
||||||
|
),
|
||||||
|
).toBe("当 mode > 2 时 → 除非 strict");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("allUnguarded", () => {
|
describe("allUnguarded", () => {
|
||||||
|
|||||||
+16
-10
@@ -1,3 +1,4 @@
|
|||||||
|
import { messages, type UiMessages } from "./i18n";
|
||||||
import { fetchJsonFrom } from "./whyfetch";
|
import { fetchJsonFrom } from "./whyfetch";
|
||||||
|
|
||||||
/* The Why view's data layer. Guards are SYNTACTIC: the conditions the code
|
/* The Why view's data layer. Guards are SYNTACTIC: the conditions the code
|
||||||
@@ -44,30 +45,35 @@ export function fetchWhy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* One guard as human text: `when mode > 2` / `unless strict` /
|
/* One guard as human text: `when mode > 2` / `unless strict` /
|
||||||
* `case CBM_LANG_C` / `in a loop` / `in catch`. */
|
* `case CBM_LANG_C` / `in a loop` / `in catch`. Pass the active locale's
|
||||||
export function formatGuard(guard: Guard): string {
|
* why messages to localize the frame — the condition expression stays
|
||||||
|
* verbatim code in every language. */
|
||||||
|
export function formatGuard(guard: Guard, m: UiMessages["why"] = messages.en.why): string {
|
||||||
const cond = guard.cond ?? "";
|
const cond = guard.cond ?? "";
|
||||||
switch (guard.kind) {
|
switch (guard.kind) {
|
||||||
case "if":
|
case "if":
|
||||||
case "ternary":
|
case "ternary":
|
||||||
if (!cond) return guard.negated ? "in an else arm" : "conditionally";
|
if (!cond) return guard.negated ? m.guardElseArm : m.guardConditionally;
|
||||||
return `${guard.negated ? "unless" : "when"} ${cond}`;
|
return guard.negated ? m.guardUnless(cond) : m.guardWhen(cond);
|
||||||
case "case":
|
case "case":
|
||||||
return cond ? `case ${cond}` : "in a switch case";
|
return cond ? m.guardCase(cond) : m.guardSwitchCase;
|
||||||
case "switch":
|
case "switch":
|
||||||
return cond ? `switch on ${cond}` : "in a switch";
|
return cond ? m.guardSwitchOn(cond) : m.guardSwitch;
|
||||||
case "loop":
|
case "loop":
|
||||||
return cond ? `looping while ${cond}` : "in a loop";
|
return cond ? m.guardLoopWhile(cond) : m.guardLoop;
|
||||||
case "catch":
|
case "catch":
|
||||||
return "on error handling";
|
return m.guardCatch;
|
||||||
default:
|
default:
|
||||||
return cond || guard.kind;
|
return cond || guard.kind;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A whole chain as one sentence fragment, outermost first. */
|
/* A whole chain as one sentence fragment, outermost first. */
|
||||||
export function formatGuardChain(guards: Guard[]): string {
|
export function formatGuardChain(
|
||||||
return guards.map(formatGuard).join(" → ");
|
guards: Guard[],
|
||||||
|
m: UiMessages["why"] = messages.en.why,
|
||||||
|
): string {
|
||||||
|
return guards.map((guard) => formatGuard(guard, m)).join(m.chainJoiner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trivial-trigger check: the complexity gate. A symbol whose callers are all
|
/* Trivial-trigger check: the complexity gate. A symbol whose callers are all
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/dashboardtab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.observed.test.tsx","./src/components/flowstab.tsx","./src/components/flowstab.zh.test.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/halolayer.tsx","./src/components/metricchip.tsx","./src/components/minimap.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/projectswitcher.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/regiontooltip.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.tsx","./src/components/tabbar.tsx","./src/components/triggertree.tsx","./src/components/wikihovercard.test.tsx","./src/components/wikihovercard.tsx","./src/components/wikipanel.test.tsx","./src/components/wikipanel.tsx","./src/components/wikipanel.zh.test.tsx","./src/components/wikitier.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/complexity.test.ts","./src/lib/complexity.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/findings.test.ts","./src/lib/findings.ts","./src/lib/firstread.ts","./src/lib/flowgroup.test.ts","./src/lib/flowgroup.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/impact.test.ts","./src/lib/impact.ts","./src/lib/observed.test.ts","./src/lib/observed.ts","./src/lib/questions.test.ts","./src/lib/questions.ts","./src/lib/rationale.test.ts","./src/lib/rationale.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/who.test.ts","./src/lib/who.ts","./src/lib/why.test.ts","./src/lib/why.ts","./src/lib/whyfetch.ts","./src/test/setup.ts","./src/wiki/entries.test.ts","./src/wiki/entries.ts"],"version":"5.9.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/dashboardtab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.observed.test.tsx","./src/components/flowstab.tsx","./src/components/flowstab.zh.test.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/halolayer.tsx","./src/components/metricchip.tsx","./src/components/minimap.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/projectswitcher.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/regiontooltip.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.tsx","./src/components/tabbar.tsx","./src/components/triggertree.tsx","./src/components/wikihovercard.test.tsx","./src/components/wikihovercard.tsx","./src/components/wikipanel.test.tsx","./src/components/wikipanel.tsx","./src/components/wikipanel.zh.test.tsx","./src/components/wikitier.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/complexity.test.ts","./src/lib/complexity.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/findings.test.ts","./src/lib/findings.ts","./src/lib/firstread.test.ts","./src/lib/firstread.ts","./src/lib/flowgroup.test.ts","./src/lib/flowgroup.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/impact.test.ts","./src/lib/impact.ts","./src/lib/observed.test.ts","./src/lib/observed.ts","./src/lib/questions.test.ts","./src/lib/questions.ts","./src/lib/rationale.test.ts","./src/lib/rationale.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/who.test.ts","./src/lib/who.ts","./src/lib/why.test.ts","./src/lib/why.ts","./src/lib/whyfetch.ts","./src/test/setup.ts","./src/wiki/entries.test.ts","./src/wiki/entries.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user