From bda460865150c649cc9149d36102a3ae42c98c25 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 28 Aug 2026 09:17:02 +0200 Subject: [PATCH] feat(ui): computed sentences compose natively per locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- graph-ui/src/components/DashboardTab.tsx | 12 +- graph-ui/src/components/FlowsTab.tsx | 4 +- graph-ui/src/components/OverviewTab.tsx | 14 ++- graph-ui/src/components/RegionPanel.tsx | 5 +- graph-ui/src/components/TriggerTree.tsx | 35 +++--- graph-ui/src/lib/complexity.test.ts | 47 +++++-- graph-ui/src/lib/complexity.ts | 23 +++- graph-ui/src/lib/findings.test.ts | 7 ++ graph-ui/src/lib/findings.ts | 8 +- graph-ui/src/lib/firstread.test.ts | 63 ++++++++++ graph-ui/src/lib/firstread.ts | 38 +++--- graph-ui/src/lib/i18n.ts | 151 +++++++++++++++++++++++ graph-ui/src/lib/regions.test.ts | 27 ++++ graph-ui/src/lib/regions.ts | 20 +++ graph-ui/src/lib/why.test.ts | 25 ++++ graph-ui/src/lib/why.ts | 26 ++-- graph-ui/tsconfig.tsbuildinfo | 2 +- 17 files changed, 431 insertions(+), 76 deletions(-) create mode 100644 graph-ui/src/lib/firstread.test.ts diff --git a/graph-ui/src/components/DashboardTab.tsx b/graph-ui/src/components/DashboardTab.tsx index 45a0a1a81..457b01a61 100644 --- a/graph-ui/src/components/DashboardTab.tsx +++ b/graph-ui/src/components/DashboardTab.tsx @@ -7,6 +7,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { kneeCount, costSentence, findingKey, isDismissed, dismiss } from "../lib/findings"; +import { useUiMessages } from "../lib/i18n"; import { CPLX_BINS, CPLX_TAIL_MIN, @@ -239,6 +240,7 @@ export function DashboardTab({ onOpenModulesPath, onOpenWiki, }: DashboardTabProps) { + const t = useUiMessages(); const [metrics, setMetrics] = useState(null); const [error, setError] = useState(null); const [showMuted, setShowMuted] = useState(false); @@ -285,16 +287,14 @@ export function DashboardTab({ }; }, [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(() => { if (!metrics) return null; return complexityTakeaway( metrics.complexity_hist, metrics.top_complex.slice(0, 3).map((entry) => entry.name ?? ""), + t.dashboard, ); - }, [metrics]); + }, [metrics, t]); if (error) { return ( @@ -322,6 +322,7 @@ export function DashboardTab({ hero.headCommits, totals.files, metrics.churn_total_commits, + t.dashboard, ); return ( @@ -353,7 +354,8 @@ export function DashboardTab({ {cost ? (

- {cost} — change here is where defects concentrate. + {cost} + {t.dashboard.costConcentrate}

) : (

diff --git a/graph-ui/src/components/FlowsTab.tsx b/graph-ui/src/components/FlowsTab.tsx index b7799b0e8..74abb2352 100644 --- a/graph-ui/src/components/FlowsTab.tsx +++ b/graph-ui/src/components/FlowsTab.tsx @@ -330,7 +330,7 @@ export function FlowsTab({ }`} title={t.flows.guardTitleHop} > - {formatGuard(guard)} + {formatGuard(guard, t.why)} ))} {step.observed && ( @@ -511,7 +511,7 @@ export function FlowsTab({ }`} title={t.flows.guardTitleStep} > - {formatGuard(guard)} + {formatGuard(guard, t.why)} ))} {step.observed && } diff --git a/graph-ui/src/components/OverviewTab.tsx b/graph-ui/src/components/OverviewTab.tsx index c32b29773..73c613032 100644 --- a/graph-ui/src/components/OverviewTab.tsx +++ b/graph-ui/src/components/OverviewTab.tsx @@ -17,6 +17,7 @@ import { disambiguateRegionNames, lensRegionsPayload, isBuiltinQn, + localizeRegionWhy, } from "../lib/regions"; import { surprisingCouplings, suggestedQuestions } from "../lib/firstread"; import { kneeCount, findingKey, isDismissed, dismiss } from "../lib/findings"; @@ -371,11 +372,16 @@ export function OverviewTab({ const hiddenTestRegions = 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( () => - lensed ? suggestedQuestions(lensed, { deadCount: metrics?.totals.dead }, 4) : [], - [lensed, metrics], + lensed + ? suggestedQuestions(lensed, { deadCount: metrics?.totals.dead }, 4, t.firstread) + : [], + [lensed, metrics, t], ); /* Findings with dismissal (magnitude-bucketed keys re-alert on jumps). */ @@ -635,7 +641,7 @@ export function OverviewTab({ {region.why && (

- {region.why} + {localizeRegionWhy(region.why, t.regions)}

)} diff --git a/graph-ui/src/components/RegionPanel.tsx b/graph-ui/src/components/RegionPanel.tsx index 83c07b494..885202f5b 100644 --- a/graph-ui/src/components/RegionPanel.tsx +++ b/graph-ui/src/components/RegionPanel.tsx @@ -1,4 +1,6 @@ import { ScrollArea } from "@/components/ui/scroll-area"; +import { useUiMessages } from "../lib/i18n"; +import { localizeRegionWhy } from "../lib/regions"; import type { Region } from "../lib/types"; interface RegionPanelProps { @@ -10,6 +12,7 @@ interface RegionPanelProps { /* 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. */ export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) { + const t = useUiMessages(); return (
@@ -38,7 +41,7 @@ export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) { {region.why && (

- {region.why} + {localizeRegionWhy(region.why, t.regions)}

)} diff --git a/graph-ui/src/components/TriggerTree.tsx b/graph-ui/src/components/TriggerTree.tsx index 97925ea2f..190fc682b 100644 --- a/graph-ui/src/components/TriggerTree.tsx +++ b/graph-ui/src/components/TriggerTree.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useUiMessages } from "../lib/i18n"; import { fetchWhy, formatGuard, @@ -12,27 +13,29 @@ import { * Thin by default; elision declared; guards honest about being syntactic. */ function DispatchChip({ entry }: { entry: WhyEntry }) { + const t = useUiMessages(); if (!entry.candidates || entry.candidates <= 1) return null; return ( - ◇ 1 of {entry.candidates} targets + {t.why.dispatchChip(entry.candidates)} ); } function GuardChips({ entry }: { entry: WhyEntry }) { + const t = useUiMessages(); if (entry.guards_unavailable) - return guards unavailable (source not readable); + return {t.why.guardsUnavailable}; if (entry.guards.length === 0 && !entry.loop) - return unguarded — always on this path; + return {t.why.unguarded}; return ( {entry.loop && ( - - ⟳ loop + + {t.why.loopChip} )} {entry.guards.map((guard, index) => ( @@ -43,9 +46,9 @@ function GuardChips({ entry }: { entry: WhyEntry }) { ? "border-warn/50 text-warn/90 bg-warn/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)} ))} @@ -242,6 +245,7 @@ export function TriggerTree({ symbolId: number; onOpenSymbol: (ref: { id?: number; qn?: string }) => void; }) { + const t = useUiMessages(); const [direction, setDirection] = useState<"up" | "down">("up"); const [root, setRoot] = useState(null); const [asTable, setAsTable] = useState(false); @@ -264,7 +268,7 @@ export function TriggerTree({

- {direction === "up" ? "When does this run?" : "What does this trigger?"} + {direction === "up" ? t.why.whenRuns : t.why.whatTriggers}

{guardedCount >= 3 && ( @@ -280,16 +284,16 @@ export function TriggerTree({ onClick={() => setDirection((d) => (d === "up" ? "down" : "up"))} 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}
{root && allUnguarded(root.entries) && root.entries.length > 0 && root.entries.length <= 2 ? (

- {direction === "up" ? "Always runs when " : "Always triggers "} + {direction === "up" ? t.why.alwaysRunsPrefix : t.why.alwaysTriggersPrefix} {root.entries.map((entry, index) => ( - {index > 0 && " and "} + {index > 0 && t.why.alwaysJoiner} ))} - {direction === "up" ? " runs — no conditions at the call sites." : " — unconditionally."} + {direction === "up" ? t.why.alwaysRunsSuffix : t.why.alwaysTriggersSuffix}

) : asTable && root ? ( )} -

- Guards are the syntactic conditions around each call site — not proven path - conditions. Dispatch, events and reflection are invisible here. -

+

{t.why.honestyFooter}

); } diff --git a/graph-ui/src/lib/complexity.test.ts b/graph-ui/src/lib/complexity.test.ts index 6178697f8..4e5b133a0 100644 --- a/graph-ui/src/lib/complexity.test.ts +++ b/graph-ui/src/lib/complexity.test.ts @@ -6,6 +6,7 @@ import { CPLX_TAIL_START, complexityTakeaway, } from "./complexity"; +import { messages } from "./i18n"; /* 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 @@ -37,25 +38,47 @@ describe("complexity bins", () => { }); 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", () => { - const hist = [4, 6, 3, 2, 1, 1]; - const sentence = complexityTakeaway(hist, [])!; - /* 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}.`, + expect(complexityTakeaway(hist, [])).toBe( + `${pct}% of functions are simple (≤${simpleMax}); ${tail} exceed ${tailMin}.`, ); }); + 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", () => { 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", () => { expect(complexityTakeaway([0, 0, 0, 0, 0, 0], [])).toBeNull(); + expect(complexityTakeaway([0, 0, 0, 0, 0, 0], [], messages.zh.dashboard)).toBeNull(); }); }); diff --git a/graph-ui/src/lib/complexity.ts b/graph-ui/src/lib/complexity.ts index 0db9c8b47..1a3e40c15 100644 --- a/graph-ui/src/lib/complexity.ts +++ b/graph-ui/src/lib/complexity.ts @@ -1,7 +1,9 @@ /* Complexity bins — the single source of truth for the dashboard's * histogram labels and the takeaway sentence. The sentence's bin claims * ("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 { 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_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); if (total === 0) return null; const simple = hist @@ -33,6 +43,11 @@ export function complexityTakeaway(hist: number[], names: string[]): string | nu const tail = hist .filter((_, index) => index >= CPLX_TAIL_START) .reduce((a, b) => a + b, 0); - const led = names.filter(Boolean).join(", "); - 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}` : ""}.`; + return m.takeaway( + ((simple / total) * 100).toFixed(0), + CPLX_SIMPLE_MAX, + tail, + CPLX_TAIL_MIN, + names.filter(Boolean), + ); } diff --git a/graph-ui/src/lib/findings.test.ts b/graph-ui/src/lib/findings.test.ts index 9f86910a5..e492a4142 100644 --- a/graph-ui/src/lib/findings.test.ts +++ b/graph-ui/src/lib/findings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { kneeCount, costSentence, findingKey } from "./findings"; +import { messages } from "./i18n"; import { isTestRegion, lensRegionsPayload, isBuiltinQn } from "./regions"; import type { RegionsPayload } from "./types"; @@ -41,9 +42,15 @@ describe("costSentence", () => { "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", () => { expect(costSentence(9, 340, 0, 1000)).toBeNull(); expect(costSentence(0, 0, 800, 1000)).toBeNull(); + expect(costSentence(9, 340, 0, 1000, messages.zh.dashboard)).toBeNull(); }); }); diff --git a/graph-ui/src/lib/findings.ts b/graph-ui/src/lib/findings.ts index 669ed5ef0..902fc2b98 100644 --- a/graph-ui/src/lib/findings.ts +++ b/graph-ui/src/lib/findings.ts @@ -1,5 +1,6 @@ /* Findings machinery: knee-point cutoffs, the cost sentence, and finding * 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: * "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))); } -/* "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( headFiles: number, headCommits: number, totalFiles: number, totalCommits: number, + m: UiMessages["dashboard"] = messages.en.dashboard, ): string | null { if (headFiles <= 0 || totalFiles <= 0 || totalCommits <= 0) return null; const filesShare = ((headFiles / totalFiles) * 100).toFixed(1); 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 diff --git a/graph-ui/src/lib/firstread.test.ts b/graph-ui/src/lib/firstread.test.ts new file mode 100644 index 000000000..e05428489 --- /dev/null +++ b/graph-ui/src/lib/firstread.test.ts @@ -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 到达它们——已排除入口点与测试"); + }); +}); diff --git a/graph-ui/src/lib/firstread.ts b/graph-ui/src/lib/firstread.ts index 40973825e..7b49be421 100644 --- a/graph-ui/src/lib/firstread.ts +++ b/graph-ui/src/lib/firstread.ts @@ -2,6 +2,7 @@ * graph already holds — every insight carries the reason it is interesting. * No model, no scores without provenance. */ import type { Region, RegionsPayload } from "./types"; +import { messages, type UiMessages } from "./i18n"; import { disambiguateRegionNames } from "./regions"; export interface SurprisingCoupling { @@ -13,10 +14,12 @@ export interface SurprisingCoupling { /* Cross-region couplings ranked by an explained score: heavier links, links * 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( payload: RegionsPayload, limit = 5, + m: UiMessages["firstread"] = messages.en.firstread, ): SurprisingCoupling[] { const regions = disambiguateRegionNames(payload.regions); 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; const reasons: string[] = []; 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)) { score += 2; - reasons.push( - `links ${top(source.name)}/ to ${top(target.name)}/ — different top-level areas`, - ); + reasons.push(m.linksAreas(top(source.name), top(target.name))); } if (source.cohesion < 0.3 || target.cohesion < 0.3) { score += 1; const loose = source.cohesion < target.cohesion ? source : target; - reasons.push( - `${loose.name} holds together loosely (cohesion ${loose.cohesion.toFixed(2)})`, - ); + reasons.push(m.holdsLoosely(loose.name, loose.cohesion)); } 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 - * 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( payload: RegionsPayload, stats: FirstReadStats = {}, limit = 5, + m: UiMessages["firstread"] = messages.en.firstread, ): SuggestedQuestion[] { const regions = disambiguateRegionNames(payload.regions).filter( (region) => region.name !== "misc", ); const questions: SuggestedQuestion[] = []; - const couplings = surprisingCouplings(payload, 2); + const couplings = surprisingCouplings(payload, 2, m); for (const coupling of couplings) { questions.push({ - question: `Why does ${coupling.source.name} depend on ${coupling.target.name}?`, - why: coupling.reasons.join("; "), + question: m.questionWhyDepend(coupling.source.name, coupling.target.name), + why: coupling.reasons.join(m.reasonSeparator), about: [coupling.source.name, coupling.target.name], }); } @@ -89,24 +91,24 @@ export function suggestedQuestions( .sort((a, b) => a.cohesion - b.cohesion)[0]; if (loose) { questions.push({ - question: `Should ${loose.name} be split into smaller modules?`, - why: `only ${(loose.cohesion * 100).toFixed(0)}% of its edges stay inside the region (${loose.members.toLocaleString("en-US")} symbols)`, + question: m.questionSplit(loose.name), + why: m.whySplit((loose.cohesion * 100).toFixed(0), loose.members), about: [loose.name], }); } if (stats.deadCount && stats.deadCount > 0) { questions.push({ - question: `Are the ${stats.deadCount.toLocaleString("en-US")} functions with no callers safe to delete?`, - why: "zero CALLS and zero USAGE reach them, excluding entry points and tests", + question: m.questionDeadSafe(stats.deadCount), + why: m.whyDeadSafe, about: ["dead code"], }); } if (stats.unresolvedShare !== undefined && stats.unresolvedShare > 0.3) { questions.push({ - question: "Which call sites does the graph fail to resolve, and why?", - why: `${(stats.unresolvedShare * 100).toFixed(0)}% of call-ish edges are USAGE (no proven single target)`, + question: m.questionUnresolved, + why: m.whyUnresolved((stats.unresolvedShare * 100).toFixed(0)), about: ["resolution certainty"], }); } diff --git a/graph-ui/src/lib/i18n.ts b/graph-ui/src/lib/i18n.ts index ee204f950..c690d1e32 100644 --- a/graph-ui/src/lib/i18n.ts +++ b/graph-ui/src/lib/i18n.ts @@ -277,6 +277,84 @@ export const messages = { inventory: (symbols: string, edges: string, regions: string) => `${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: { panel: "Control Panel", totalCpu: "Total CPU", @@ -559,6 +637,79 @@ export const messages = { inventory: (symbols: string, edges: string, regions: string) => `${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: { panel: "控制面板", totalCpu: "总 CPU", diff --git a/graph-ui/src/lib/regions.test.ts b/graph-ui/src/lib/regions.test.ts index 94da6b22e..0b0ca5ba6 100644 --- a/graph-ui/src/lib/regions.test.ts +++ b/graph-ui/src/lib/regions.test.ts @@ -5,7 +5,9 @@ import { regionsViewWorthwhile, REGIONS_MIN_TOTAL_NODES, cohesionWord, + localizeRegionWhy, } from "./regions"; +import { messages } from "./i18n"; import { minimapLayout, minimapProject } from "../components/Minimap"; 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", () => { it("maps scores to the 0.7/0.4 word ladder", () => { expect(cohesionWord(0.84)).toBe("tightly connected"); diff --git a/graph-ui/src/lib/regions.ts b/graph-ui/src/lib/regions.ts index faa2557db..5d061d61d 100644 --- a/graph-ui/src/lib/regions.ts +++ b/graph-ui/src/lib/regions.ts @@ -1,5 +1,6 @@ /* Region level of detail — adapters between the level=regions payload * (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"; /* 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 * costs trust in everything else on the page. */ export function isBuiltinQn(qn: string): boolean { diff --git a/graph-ui/src/lib/why.test.ts b/graph-ui/src/lib/why.test.ts index d89890cb1..17bf5fe46 100644 --- a/graph-ui/src/lib/why.test.ts +++ b/graph-ui/src/lib/why.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { messages } from "./i18n"; import { formatGuard, formatGuardChain, allUnguarded, type WhyEntry } from "./why"; describe("formatGuard", () => { @@ -20,6 +21,30 @@ describe("formatGuard", () => { ]), ).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", () => { diff --git a/graph-ui/src/lib/why.ts b/graph-ui/src/lib/why.ts index 4555bedd4..73e99e4b4 100644 --- a/graph-ui/src/lib/why.ts +++ b/graph-ui/src/lib/why.ts @@ -1,3 +1,4 @@ +import { messages, type UiMessages } from "./i18n"; import { fetchJsonFrom } from "./whyfetch"; /* 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` / - * `case CBM_LANG_C` / `in a loop` / `in catch`. */ -export function formatGuard(guard: Guard): string { + * `case CBM_LANG_C` / `in a loop` / `in catch`. Pass the active locale's + * 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 ?? ""; switch (guard.kind) { case "if": case "ternary": - if (!cond) return guard.negated ? "in an else arm" : "conditionally"; - return `${guard.negated ? "unless" : "when"} ${cond}`; + if (!cond) return guard.negated ? m.guardElseArm : m.guardConditionally; + return guard.negated ? m.guardUnless(cond) : m.guardWhen(cond); case "case": - return cond ? `case ${cond}` : "in a switch case"; + return cond ? m.guardCase(cond) : m.guardSwitchCase; case "switch": - return cond ? `switch on ${cond}` : "in a switch"; + return cond ? m.guardSwitchOn(cond) : m.guardSwitch; case "loop": - return cond ? `looping while ${cond}` : "in a loop"; + return cond ? m.guardLoopWhile(cond) : m.guardLoop; case "catch": - return "on error handling"; + return m.guardCatch; default: return cond || guard.kind; } } /* A whole chain as one sentence fragment, outermost first. */ -export function formatGuardChain(guards: Guard[]): string { - return guards.map(formatGuard).join(" → "); +export function formatGuardChain( + 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 diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index ec2f4520c..61e387c66 100644 --- a/graph-ui/tsconfig.tsbuildinfo +++ b/graph-ui/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file