diff --git a/.claude/skills/performance/SKILL.md b/.claude/skills/performance/SKILL.md index dcc794fc96..1a549de049 100644 --- a/.claude/skills/performance/SKILL.md +++ b/.claude/skills/performance/SKILL.md @@ -69,6 +69,8 @@ measured. So the rule of this skill is simple: no change, and no claim, without - "Which call sites still spawn, and at what cost?" → `bun scripts/benchmarks/process/call-sites-bench.ts --runs 3 --compare` (cold and warm CPU per site, spawned children included). - "Did CI really pass, and why is it red?" → `references/tests-and-ci.md` (a green job can carry failing tests). +- "Why does type-checking need gigabytes, and which code makes the checker work?" → `tools ts program cost|files|own|closure|trace`, + `references/typecheck.md` (the shapes that cost 175k instantiations per file, and the traps of a smaller program). ## Measured facts to start from (2026-10-12, this Mac) @@ -105,5 +107,7 @@ Every number above has its method and commit in `references/` and in the ledger. - `references/caching-and-freshness.md`: the never-staler rule in practice, `FileIdentityCache`, persisted caches, serve-time clocks, the rows cache, the hub server, and the rejected skip-rebuild. - `references/ledger.md`: where results go and the entry format. +- `references/typecheck.md`: the TypeScript program's memory per phase, `tools ts program`, the code shapes that + multiply checker work (with before and after numbers), and what a smaller program misses. - `references/compression.md`: zstd levels and speeds, the Bun 1.3.13 first-frame trap, seekable frames, reading compressed data within the 30% budget (measured per real command), backpressure for a spawned reader, APFS clones. diff --git a/.claude/skills/performance/references/typecheck.md b/.claude/skills/performance/references/typecheck.md new file mode 100644 index 0000000000..b269aafcd6 --- /dev/null +++ b/.claude/skills/performance/references/typecheck.md @@ -0,0 +1,77 @@ +# Type-check cost: memory and time of a TypeScript program + +A TypeScript program's memory follows its size first and its code shapes second. Measured on the GenesisTools root +program on 2026-10-25 (TypeScript 6.0, 9,995 files, 101.6 MB of source): retained heap after a full GC was parse +1,943 MiB, bind 2,451 MiB, check 4,389 MiB. Parse plus bind costs about 24 MiB of heap per MB of source, `.d.ts` +included. A reviewer that runs `ts.createProgram` with a 3 GiB heap failed on it. + +## The instrument: `tools ts program` + +It checks in a separate `node` process (V8, the heap `tsc` and reviewers use) with the project's own `typescript`. + +- `tools ts program cost [tsconfig]`: heap after each phase, counts (types, instantiations, symbols), diagnostics. + `--heap 3072` passes or fails as out of memory, like a 3 GiB reviewer. +- `tools ts program files --top 30` (`--by instantiations|types|symbols|ms`, `--folders`): what each file's check + created. The first file that touches a library type pays for it: read a ranking, not ownership. +- `tools ts program own `: one file's own work (everything else checked first). THE before and after + instrument for an edit. +- `tools ts program closure` (`--why `): what a tsconfig of one folder would hold; declaration packages by + size with their direct importers; packages present from more than one installed copy (from an audit of a large + private app on 2026-07-10, where duplicate copies were typechecked 2 to 6 times). +- `tools ts program trace` (`--min-ms`): `tsc --generateTrace`, then the slowest files and single expressions with + their source line. +- `tools ts program sites` (`--by median --min-sites 3`, `--project-only`, `--key `): THE "next worse" finder, + no agents and no reading. Each call, `new`, JSX element and type reference is resolved on its own during the first + check and grouped by what it calls (`name @ declaring file`). A high median over many sites is a shape that costs + at every use; one big site with a low median is first use of a library type. Positive control (2026-10-25): on the + commit before the fixes below it ranked the ElementType icons 2nd and 6th to 11th, `new Chart` 12th, + `generateText` 15th and `Parameters` 34th, with nothing pointed at them. + +## The loop + +1. `cost` for the baseline, `sites` (then `--by median --min-sites 3`) and `trace --min-ms 50` for candidates. +2. For each candidate: `own `, change the TYPE only, `own ` again. Keep the change only when the numbers + drop and diagnostics stay 0. +3. Prove the JavaScript did not change: transpile old and new with `new Bun.Transpiler({ loader: "tsx" })`, strip + comments, compare. A planted change must read DIFFERENT (positive control). +4. `cost` again for the program-wide number, and an interleaved A/B (a HEAD worktree against the change, 3 rounds, + medians) for time. A single file's `own` number overstates program savings when the cost was shared first-touch + work: on 2026-10-25 a per-file sum claimed 43k fewer instantiations and the program showed 10.8k. + +## Shapes that cost a lot, with numbers + +- **Inference over a big union parameter.** `new Chart(canvas, config)` (chart.js) inferred three type parameters + over a union of every chart type: 1,084 ms and 250k symbols for ONE expression. `new Chart(…)`: 12 ms. + A typed variable and an `Object.assign` instead of the spread did not help; the explicit type argument did. +- **A prop type that is secretly a union of every HTML tag.** `React.ElementType

` (icons, `LinkComponent`) made + each JSX use check its props against every intrinsic element: about 175k instantiations and 220 to 250 ms per file. + `React.ComponentType

`: 4 to 38 ms. ElementType stays right only for a polymorphic `as` prop. +- **Spreading a wide type into an object literal.** `{ ...bandLayer }` with `bandLayer: CSSProperties` copies every + CSS property into a new type: types 4,996 → 2,403 with `Pick`. +- **A type derived from a library function's parameters.** `Parameters[0]["model"]` instantiates + the whole generic signature; the library exports the same type as `LanguageModel`. +- **A generic inferred from a zod schema parameter.** `fn(schema: z.ZodType)` makes the checker infer `T` by + walking the whole schema type against ZodType at every call: about 3,400 instantiations per `callLLMStructured` + call and 81k for the first `parseDecisionInput` of a large schema. Make the schema the type parameter: + `fn(schema: S): z.output`. Both together: program instantiations 2,380,257 → 2,333,331 + (−2.0%), 0 diagnostics, identical JavaScript (2026-10-25). Found by `sites --by median --min-sites 3`. +- **A library's typed routes or query builder, paid once per program.** Any typed `octokit.request("GET …")` costs + about 44k types and 160k instantiations whatever the call shape; kysely chains were 17 to 21% of all instantiations + with no type-only fix found. Measure, then leave them unless the program can drop the library. +- **A large `.d.ts` package used by one file.** Stripe: 228 files, 5.9 MB, one importer; moved to the package store + (installed on first use) with a local type for the one call: −228 files from every program. +- **Test files.** 1,983 bun:test files held 35% of all symbols and 27% of check time (2026-10-25). Not a code fix; a + reason a smaller program (one folder, or only a change's files) is the real lever. + +## Correctness traps of a smaller program + +A program of one folder, or of only a change's files, needs 0.3 to 1.5 GB instead of 4 GiB, and it misses what the big +program got by accident: + +- **TypeScript 6 adds no `@types` package to the global scope unless `types` names it.** An import of a package still + loads that package's types; only the automatic global inclusion stopped. Bun's globals came only from the one file + that imports `bun`, so a smaller program said "Cannot find name 'Bun'" (141 errors in one sample). Name it: + `"types": ["bun"]`. +- **Ambient `.d.ts` files that nothing imports** (`declare module "asciichart"`, a `marked` augmentation) exist only in + a program whose include matches them. Include `src/**/*.d.ts` (or the folder's `../**/*.d.ts`). +- **`declare global` inside a module file** is seen only when that file is in the program. diff --git a/.eve-review.yml b/.eve-review.yml index 1dd603fb5e..86571ecacd 100644 --- a/.eve-review.yml +++ b/.eve-review.yml @@ -9,3 +9,14 @@ summaryVerbosity: standard depth: mode: always maxToolSteps: 24 +# Big PRs (+12k lines in 125 files) lost 13-44 patches to the 600k diff budget, +# and a pass with a gap re-reviewed the whole PR on every push. Large-PR mode +# reviews every patch in slices and carries what it misses into the next push. +largePrMode: auto +# The reviewer's typecheck checks only the changed files, their callers and the +# global declarations, not the whole root program (~5 GB, out of memory or time in +# about 100 of 949 reviews): about 1.0-1.3 GB and under 2 s on TypeScript 7. Its +# compiler errors are evidence for the finder and verifier; CI stays the gate. +checks: + typecheck: true + typecheckScope: changed diff --git a/bun.lock b/bun.lock index b175e60f21..94fb344795 100644 --- a/bun.lock +++ b/bun.lock @@ -133,7 +133,6 @@ "sonner": "^2.0.8", "sqlite-vec": "^0.1.9", "streamx": "^2.28.1", - "stripe": "^22.6.0", "tar-stream": "^3.2.1", "telegram": "^2.26.22", "tough-cookie": "^6.0.2", @@ -2703,8 +2702,6 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "stripe": ["stripe@22.6.0", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Ezk+WYEEwX2DLxFrKfb6coznlCMZvCaCYMNPXNHFSZkBIEzF5Hgm7RLb38q/0h9Ol5jsUJGJRorvhzAvckgfhw=="], - "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], "subsume": ["subsume@4.0.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "unique-string": "^3.0.0" } }, "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg=="], diff --git a/package.json b/package.json index 874011d37e..1d9919083f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "prepare": "bun scripts/git/install-hooks.ts", "typecheck": "bun scripts/ci/typecheck.ts", "typecheck:all": "bun scripts/ci/typecheck.ts --all", + "typecheck:tsgate": "if command -v vybava >/dev/null; then vybava tsgate tsconfig.json src/utils/tsconfig.json; else bun scripts/ci/typecheck.ts --all; fi", "tsgo": "tsc --noEmit", "tsc": "tsc --noEmit", "lint": "bun run lint:biome && bun run lint:rules", @@ -214,7 +215,6 @@ "sonner": "^2.0.8", "sqlite-vec": "^0.1.9", "streamx": "^2.28.1", - "stripe": "^22.6.0", "tar-stream": "^3.2.1", "telegram": "^2.26.22", "tough-cookie": "^6.0.2", diff --git a/scripts/ci/typecheck.ts b/scripts/ci/typecheck.ts index 636acee6cc..48a1ff7c49 100644 --- a/scripts/ci/typecheck.ts +++ b/scripts/ci/typecheck.ts @@ -32,6 +32,9 @@ export const PROJECTS: Project[] = [ { name: "clarity-ui", tsconfig: "src/clarity/ui/tsconfig.json" }, { name: "dev-dashboard-ui", tsconfig: "src/dev-dashboard/ui/tsconfig.json" }, { name: "fable-replace", tsconfig: "plugins/genesis-tools/skills/fable-replace/scripts/tsconfig.json" }, + // The @genesiscz/utils package as consumers see it: its own paths and no @app/* alias, so an @app/* import + // under src/utils fails here while the root program (which maps @app/*) passes it. + { name: "utils", tsconfig: "src/utils/tsconfig.json" }, ]; /** What a project can take in: the root project has `allowJs`, so JavaScript inputs count too. */ diff --git a/src/artifact/runtime/kit/chartjs.tsx b/src/artifact/runtime/kit/chartjs.tsx index ce83c456a0..c4105a970e 100644 --- a/src/artifact/runtime/kit/chartjs.tsx +++ b/src/artifact/runtime/kit/chartjs.tsx @@ -1,4 +1,4 @@ -import { Chart, type ChartConfiguration, registerables } from "chart.js"; +import { Chart, type ChartConfiguration, type ChartType, registerables } from "chart.js"; import { useEffect, useRef } from "react"; import { onColorSchemeChange, resolveCssColor } from "./mermaid-core"; @@ -77,8 +77,9 @@ export function ChartJs({ config, height = 320, ariaLabel }: ChartJsProps) { themeRef.current = themeChartColors(); const base = configRef.current; - // Caller options win: the theme only supplies what the config omits. - const chart = new Chart(canvasRef.current, { + // Caller options win: the theme only supplies what the config omits. The explicit type argument saves every + // TypeScript program about 1 s and 250k symbols of inference over Chart's config union (2026-10-25). + const chart = new Chart(canvasRef.current, { ...base, options: { ...themeRef.current, ...base.options }, }); diff --git a/src/artifact/runtime/kit/gantt.tsx b/src/artifact/runtime/kit/gantt.tsx index c1264a3d1f..b56b9ca992 100644 --- a/src/artifact/runtime/kit/gantt.tsx +++ b/src/artifact/runtime/kit/gantt.tsx @@ -641,7 +641,8 @@ export function Gantt({ const todayIndex = todayDate ? ganttDayIndex(todayDate, axis.start) : -1; const showToday = todayDate !== null && todayIndex >= 0 && todayIndex < totalDays; const cells = ganttCells(axis, locale); - const bandLayer: CSSProperties = { + // Only the property it sets: spreading a whole CSSProperties into a style copies every CSS property into a new type. + const bandLayer: Pick = { backgroundImage: [ `repeating-linear-gradient(to right, var(--border) 0 1px, transparent 1px ${cellWidth}px)`, `repeating-linear-gradient(to right, transparent 0 ${cellWidth}px, var(--wash) ${cellWidth}px ${cellWidth * 2}px)`, diff --git a/src/jev/lib/browser/writer.ts b/src/jev/lib/browser/writer.ts index 623d3a0bcf..7228804316 100644 --- a/src/jev/lib/browser/writer.ts +++ b/src/jev/lib/browser/writer.ts @@ -35,7 +35,9 @@ export interface WriterAnswer { } /** The one model call the writer makes; `callLLMStructured` in production, a fake in tests. */ -export type WriterCall = (options: CallLLMStructuredOptions) => Promise<{ object: WriterAnswer }>; +export type WriterCall = ( + options: CallLLMStructuredOptions> +) => Promise<{ object: WriterAnswer }>; const answerSchema = z .object({ diff --git a/src/question/lib/decisions/schema.ts b/src/question/lib/decisions/schema.ts index 9bc348499a..a0542fe283 100644 --- a/src/question/lib/decisions/schema.ts +++ b/src/question/lib/decisions/schema.ts @@ -172,8 +172,13 @@ export type PostDecisionsInput = z.infer; export type DecisionPatch = z.infer; export type DecisionUpdate = z.infer; -/** Parses or throws one readable error that names every bad field. */ -export function parseDecisionInput(schema: z.ZodType, value: unknown, label: string): T { +/** + * Parses or throws one readable error that names every bad field. + * + * The type parameter is the schema itself, and the result its `z.output`. Inferring `T` from a `z.ZodType` + * parameter instead makes the checker walk the whole schema type against ZodType at every call. + */ +export function parseDecisionInput(schema: S, value: unknown, label: string): z.output { const result = schema.safeParse(value); if (!result.success) { diff --git a/src/ts/README.md b/src/ts/README.md index b3a47c60e2..d4c8e3ddc3 100644 --- a/src/ts/README.md +++ b/src/ts/README.md @@ -1,10 +1,11 @@ # tools ts -> **What is in this TypeScript file, what is written twice, and where does the startup time of an entry point go?** +> **What is in this TypeScript file, what is written twice, where does the startup time of an entry point go, and what does type-checking cost?** -Five command groups. `skeleton` prints a file's API. `duplicates` finds code written more than +Six command groups. `skeleton` prints a file's API. `duplicates` finds code written more than once. `refactors` ranks what to change. `imports` answers the startup-cost question. `profile` answers where -the CPU time of a whole run goes. +the CPU time of a whole run goes. `program` answers what a TypeScript program costs to check, and which code makes it +cost that. `skeleton`, `duplicates` and `refactors` take `--format text|md|json|json-compact|toon`, with `--md`, `--json`, `--json-compact` and `--toon` as shorthands. Asking for two different formats is @@ -232,6 +233,73 @@ The `.cpuprofile` files stay in `~/.genesis-tools/ts/profiles/

;\n"); + writeFileSync(join(project, "src", "broken.ts"), "export const broken = ;\n"); + const tsconfig = join(project, "tsconfig.json"); + + const own = await runProgramWorker("own", { tsconfig, files: [join(project, "src", "broken.ts")] }); + expect(own.rows[0]?.diagnostics.some((line) => line.includes("TS1109"))).toBe(true); + + const graph = await runProgramWorker("graph", { tsconfig }); + const view = graph.files.find((file) => file.path === "src/view.tsx"); + expect(view?.imports.map((at) => graph.files[at]?.path)).toEqual(["node_modules/react/jsx-runtime.d.ts"]); + }, 60_000); + + it("includes automatic typeRoots declarations in every folder closure, respecting an explicit empty types list", async () => { + const project = join(dir, "automatic-types"); + const globals = join(project, "node_modules", "@types", "fixture-globals"); + mkdirSync(join(project, "src", "a"), { recursive: true }); + mkdirSync(join(project, "src", "b"), { recursive: true }); + mkdirSync(globals, { recursive: true }); + writeFileSync(join(globals, "index.d.ts"), "declare const FIXTURE_GLOBAL: string;\n"); + writeFileSync(join(project, "src", "a", "index.ts"), "export const a = FIXTURE_GLOBAL;\n"); + writeFileSync(join(project, "src", "b", "index.ts"), "export const b = 1;\n"); + const tsconfig = join(project, "tsconfig.json"); + const compilerOptions = { noEmit: true, lib: ["ES2022"], types: ["*"], typeRoots: ["./node_modules/@types"] }; + writeFileSync(tsconfig, SafeJSON.stringify({ compilerOptions, include: ["src/**/*"] })); + const graph = await runProgramWorker("graph", { tsconfig }); + expect( + graph.files.find((file) => file.path === "node_modules/@types/fixture-globals/index.d.ts") + ).toMatchObject({ base: true }); + const withGlobals = folderClosures(graph); + + writeFileSync( + tsconfig, + SafeJSON.stringify({ compilerOptions: { ...compilerOptions, types: [] }, include: ["src/**/*"] }) + ); + const withoutTypes = await runProgramWorker("graph", { tsconfig }); + expect(withoutTypes.files.some((file) => file.path.includes("fixture-globals"))).toBe(false); + const withoutGlobals = folderClosures(withoutTypes); + expect(withGlobals.map((row) => row.folder)).toEqual(withoutGlobals.map((row) => row.folder)); + expect(withGlobals.map((row) => row.nodeModulesFiles)).toEqual( + withoutGlobals.map((row) => row.nodeModulesFiles + 1) + ); + }, 60_000); + + it("reports a heap that is too small as a usage error, not a stack dump", async () => { + const tsconfig = join(dir, "tsconfig.json"); + await expect(runProgramWorker("phases", { tsconfig, heapMiB: 8 })).rejects.toThrow( + /does not fit in 8 MiB of heap/ + ); + }, 60_000); +}); diff --git a/src/ts/lib/program/run.ts b/src/ts/lib/program/run.ts new file mode 100644 index 0000000000..36d81dd386 --- /dev/null +++ b/src/ts/lib/program/run.ts @@ -0,0 +1,227 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { env } from "@genesiscz/utils/env"; +import { removeRecursive } from "@genesiscz/utils/fs"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; +import { boundedCommand } from "@genesiscz/utils/process/bounded-command"; +import { toolDataDir } from "@genesiscz/utils/storage/root"; +import { UsageError } from "../format"; +import type { + FilesResult, + GraphResult, + OwnResult, + PhasesResult, + ProgramMode, + SitesResult, + WorkerResult, +} from "./types"; + +const WORKER = join(import.meta.dir, "check-worker.ts"); +export const DEFAULT_HEAP_MIB = 16_384; +/** A whole check of the GenesisTools root took 35-41 s (2026-10-25); a run past this is stuck, not slow. */ +export const DEFAULT_TIMEOUT_MS = 15 * 60_000; +/** Room for a program with thousands of diagnostics on stdout (tsc) or a long stack on stderr. */ +const OUTPUT_BUDGET_BYTES = 32 * 1024 * 1024; + +export type ProgramRuntime = "node" | "bun"; + +export interface ProgramRunOptions { + tsconfig: string; + /** node (default): V8, the heap `tsc` and a reviewer's `ts.createProgram` use. bun: JavaScriptCore. */ + runtime?: ProgramRuntime; + /** + * The worker's heap limit in MiB (node only). A run that needs more fails as out of memory, as a reviewer's would. + * Unset means DEFAULT_HEAP_MIB, not node's own default (about 4 GiB on this Mac), which the GenesisTools root + * program exceeded before 2026-10-25: a measurement must finish, a limit is asked for on purpose. + */ + heapMiB?: number; + /** The check is killed (its whole process group) after this long. Default DEFAULT_TIMEOUT_MS. */ + timeoutMs?: number; + /** Aborts the check, as Ctrl-C does through `withInterrupt`. */ + signal?: AbortSignal; + files?: string[]; +} + +/** The tsconfig to check: a path to the file, or a folder holding `tsconfig.json`. */ +export function resolveTsconfig(input: string | undefined): string { + const path = resolve(input ?? "tsconfig.json"); + const file = existsSync(join(path, "tsconfig.json")) ? join(path, "tsconfig.json") : path; + + if (!existsSync(file)) { + throw new UsageError(`no tsconfig at ${file}`); + } + + return file; +} + +/** Checked before the spawn, so a worker's own "not found" error is never mistaken for a missing node. */ +function requireNode(): void { + if (!Bun.which("node")) { + throw new UsageError("node is not on PATH; install it or pass --runtime bun (JavaScriptCore heap numbers)"); + } +} + +interface BoundedRun { + command: string[]; + timeoutMs: number; + signal?: AbortSignal; + environment?: Record; + what: string; +} + +/** A child with a deadline: on expiry or abort its process group gets SIGTERM, then SIGKILL. */ +function seconds(ms: number): string { + return ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`; +} + +async function runBounded(run: BoundedRun): Promise<{ status: number | null; stderr: string }> { + // boundedCommand throws a bare AbortError for a signal that is already aborted; it starts no child either way. + if (run.signal?.aborted) { + throw new UsageError(`${run.what} was interrupted`); + } + + const result = await boundedCommand({ + command: run.command, + timeoutMs: run.timeoutMs, + signal: run.signal, + maxBufferBytes: OUTPUT_BUDGET_BYTES, + environment: run.environment, + }); + + if (result.error?.code === "ETIMEDOUT") { + throw new UsageError( + `${run.what} did not finish in ${seconds(run.timeoutMs)} and was stopped; pass a larger --timeout` + ); + } + + if (result.error?.code === "ABORT_ERR") { + throw new UsageError(`${run.what} was interrupted`); + } + + if (result.error) { + throw new Error(`${run.what} failed: ${result.error.message}`, { cause: result.error }); + } + + return { status: result.status, stderr: result.stderr }; +} + +export async function runProgramWorker(mode: "phases", options: ProgramRunOptions): Promise; +export async function runProgramWorker(mode: "files", options: ProgramRunOptions): Promise; +export async function runProgramWorker(mode: "own", options: ProgramRunOptions): Promise; +export async function runProgramWorker(mode: "graph", options: ProgramRunOptions): Promise; +export async function runProgramWorker(mode: "sites", options: ProgramRunOptions): Promise; +export async function runProgramWorker(mode: ProgramMode, options: ProgramRunOptions): Promise { + const runtime = options.runtime ?? "node"; + + if (runtime === "node") { + requireNode(); + } + + const dir = mkdtempSync(join(tmpdir(), "gt-ts-program-")); + const outPath = join(dir, "result.json"); + const cmd = + runtime === "node" + ? ["node", "--expose-gc", `--max-old-space-size=${options.heapMiB ?? DEFAULT_HEAP_MIB}`, WORKER] + : [process.execPath, "--smol", WORKER]; + + logger.debug({ mode, runtime, tsconfig: options.tsconfig, cmd }, "ts program: spawning check worker"); + + try { + const { status, stderr } = await runBounded({ + command: cmd, + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + signal: options.signal, + what: "the check", + environment: { + ...env.getProcessEnv(), + GT_TSP_CONFIG: options.tsconfig, + GT_TSP_MODE: mode, + GT_TSP_FILES: (options.files ?? []).map((file) => resolve(file)).join("\n"), + GT_TSP_OUT: outPath, + }, + }); + + if (status !== 0 || !existsSync(outPath)) { + logger.warn({ mode, runtime, status, stderr: stderr.slice(-2000) }, "ts program: check worker failed"); + + // An out-of-memory run is a result (the program does not fit that heap), not a crash to dump. + if (/heap out of memory|Reached heap limit/i.test(stderr)) { + throw new UsageError( + `the program does not fit in ${options.heapMiB ?? DEFAULT_HEAP_MIB} MiB of heap (out of memory); pass a larger --heap to measure it` + ); + } + + throw new Error( + `the check worker failed (exit ${status}): ${stderr.trim().split("\n").slice(-3).join(" ")}` + ); + } + + const result: WorkerResult = SafeJSON.parse(readFileSync(outPath, "utf8"), { strict: true }); + return result; + } finally { + removeRecursive(dir); + } +} + +export interface TraceRunOptions { + tsconfig: string; + heapMiB?: number; + timeoutMs?: number; + signal?: AbortSignal; +} + +/** `tsc --generateTrace` of the project's own TypeScript, under node, into a dated folder; returns the folder. */ +export async function generateTrace(options: TraceRunOptions): Promise { + requireNode(); + const require = createRequire(options.tsconfig); + let tsc: string; + + try { + tsc = require.resolve("typescript/lib/tsc.js"); + } catch (error) { + throw new UsageError(`no typescript/lib/tsc.js resolvable from ${dirname(options.tsconfig)}`, { cause: error }); + } + + const dir = toolDataDir("ts", "traces", new Date().toISOString().replace(/[:.]/g, "-")); + mkdirSync(dir, { recursive: true }); + + try { + return await traceInto(dir, tsc, options); + } catch (error) { + // A stopped or failed tsc leaves a partial trace that no later read can use. + removeRecursive(dir); + throw error; + } +} + +async function traceInto(dir: string, tsc: string, options: TraceRunOptions): Promise { + const { status, stderr } = await runBounded({ + command: [ + "node", + `--max-old-space-size=${options.heapMiB ?? DEFAULT_HEAP_MIB}`, + tsc, + "-p", + options.tsconfig, + "--noEmit", + "--pretty", + "false", + "--generateTrace", + dir, + ], + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + signal: options.signal, + what: "tsc --generateTrace", + }); + + // tsc exits 1 or 2 when the program has errors; the trace is still complete. + if (status === null || status > 2 || !existsSync(join(dir, "trace.json"))) { + throw new Error( + `tsc --generateTrace failed (exit ${status}): ${stderr.trim().split("\n").slice(-3).join(" ")}` + ); + } + + return dir; +} diff --git a/src/ts/lib/program/sites.ts b/src/ts/lib/program/sites.ts new file mode 100644 index 0000000000..f5aa6d3abf --- /dev/null +++ b/src/ts/lib/program/sites.ts @@ -0,0 +1,75 @@ +import type { SiteCost } from "./types"; + +export interface SiteGroup { + kind: SiteCost["kind"]; + key: string; + external: boolean; + sites: number; + files: number; + instantiations: number; + types: number; + ms: number; + /** The median site: high with many sites means a code shape that costs at every use. */ + medianInstantiations: number; + /** The costliest site; one large site and a low median means the first use of a library type. */ + max: Pick; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 0) { + return ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2; + } + + return sorted[mid] ?? 0; +} + +/** Site costs grouped by what each site calls or references. */ +export function groupSites(sites: SiteCost[]): SiteGroup[] { + const groups = new Map(); + + for (const site of sites) { + const id = `${site.kind} ${site.key}`; + const members = groups.get(id); + + if (members) { + members.push(site); + } else { + groups.set(id, [site]); + } + } + + return [...groups.values()].map((members) => { + const first = members[0] as SiteCost; + const max = members.reduce((best, site) => (site.instantiations > best.instantiations ? site : best), first); + return { + kind: first.kind, + key: first.key, + external: first.external, + sites: members.length, + files: new Set(members.map((site) => site.file)).size, + instantiations: members.reduce((sum, site) => sum + site.instantiations, 0), + types: members.reduce((sum, site) => sum + site.types, 0), + ms: Math.round(members.reduce((sum, site) => sum + site.ms, 0)), + medianInstantiations: median(members.map((site) => site.instantiations)), + max: { file: max.file, line: max.line, instantiations: max.instantiations }, + }; + }); +} + +/** The single sites whose key holds `needle`, costliest first; `projectOnly` drops package and lib keys. */ +export function sitesForKey(sites: SiteCost[], needle: string, projectOnly: boolean): SiteCost[] { + return sites + .filter((site) => site.key.includes(needle) && (!projectOnly || !site.external)) + .sort((a, b) => b.instantiations - a.instantiations); +} + +export const SITE_SORT_KEYS = ["instantiations", "median", "types", "ms", "sites"] as const; +export type SiteSortKey = (typeof SITE_SORT_KEYS)[number]; + +export function sortGroups(groups: SiteGroup[], by: SiteSortKey): SiteGroup[] { + const value = (group: SiteGroup): number => (by === "median" ? group.medianInstantiations : group[by]); + return [...groups].sort((a, b) => value(b) - value(a)); +} diff --git a/src/ts/lib/program/trace.ts b/src/ts/lib/program/trace.ts new file mode 100644 index 0000000000..f05f0e8abe --- /dev/null +++ b/src/ts/lib/program/trace.ts @@ -0,0 +1,157 @@ +import { readFileSync } from "node:fs"; +import { SafeJSON } from "@genesiscz/utils/json"; + +/** One event of a `tsc --generateTrace` trace.json (Chrome trace format). */ +interface TraceEvent { + ph: string; + cat?: string; + name: string; + ts: number; + dur?: number; + args?: { path?: string; pos?: number }; +} + +export interface TraceFile { + file: string; + ms: number; +} + +export interface TraceHotSpot { + file: string; + line: number; + event: string; + ms: number; + /** The source line, trimmed. */ + source: string; +} + +export interface TraceReport { + checkMs: number; + files: TraceFile[]; + hotSpots: TraceHotSpot[]; +} + +/** A trace path relative to the project. tsc lowercases trace paths on a case-insensitive disk, so compare lowercased. */ +function relativeTo(root: string, path: string): string { + const prefix = `${root.replace(/\/$/, "")}/`; + return path.toLowerCase().startsWith(prefix.toLowerCase()) ? path.slice(prefix.length) : path; +} + +/** + * `checkSourceFile` time per file, and the slowest single check events with their source line. Nested events + * overlap: a statement's time includes the expressions inside it, so one hot expression shows on several lines. + */ +export function analyzeTrace(options: { + traceJson: string; + root: string; + minMs?: number; + readSource?: (path: string) => string; +}): TraceReport { + const events: TraceEvent[] = SafeJSON.parse(options.traceJson, { strict: true }); + const minMs = options.minMs ?? 50; + const read = options.readSource ?? ((path: string) => readFileSync(path, "utf8")); + const files: TraceFile[] = []; + const spots: Array<{ path: string; pos: number; event: string; ms: number }> = []; + const open: TraceEvent[] = []; + + const take = (event: TraceEvent, ms: number) => { + const path = event.args?.path; + + if (!path) { + return; + } + + if (event.name === "checkSourceFile") { + files.push({ file: path, ms }); + } else if (event.cat === "check" && ms >= minMs) { + spots.push({ path, pos: event.args?.pos ?? 0, event: event.name, ms }); + } + }; + + for (const event of events) { + if (event.ph === "X") { + take(event, (event.dur ?? 0) / 1000); + } else if (event.ph === "B") { + open.push(event); + } else if (event.ph === "E") { + const begin = open.pop(); + + if (begin) { + take(begin, (event.ts - begin.ts) / 1000); + } + } + } + + // One line index per source: a slow nested event costs a binary search, not a split of the file. + const sources = new Map(); + const hotSpots: TraceHotSpot[] = []; + const seen = new Set(); + + for (const spot of spots.sort((a, b) => b.ms - a.ms)) { + let source = sources.get(spot.path); + + if (source === undefined) { + let text = ""; + + try { + text = read(spot.path); + } catch { + // An unreadable source still lists the spot, at line 1 with no text. + } + + const lines = text.split("\n"); + const starts: number[] = []; + let at = 0; + + for (const line of lines) { + starts.push(at); + at += line.length + 1; + } + + source = { lines, starts }; + sources.set(spot.path, source); + } + + const line = lineOf(source.starts, spot.pos); + const key = `${spot.path}:${line}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + hotSpots.push({ + file: relativeTo(options.root, spot.path), + line, + event: spot.event, + ms: Math.round(spot.ms), + source: (source.lines[line - 1] ?? "").trim().slice(0, 140), + }); + } + + return { + checkMs: Math.round(files.reduce((sum, file) => sum + file.ms, 0)), + files: files + .map((file) => ({ file: relativeTo(options.root, file.file), ms: Math.round(file.ms) })) + .sort((a, b) => b.ms - a.ms), + hotSpots, + }; +} + +/** The 1-based line that holds `pos`, given each line's start offset in ascending order. */ +function lineOf(starts: number[], pos: number): number { + let low = 0; + let high = starts.length - 1; + + while (low < high) { + const mid = (low + high + 1) >> 1; + + if ((starts[mid] ?? 0) <= pos) { + low = mid; + } else { + high = mid - 1; + } + } + + return low + 1; +} diff --git a/src/ts/lib/program/types.ts b/src/ts/lib/program/types.ts new file mode 100644 index 0000000000..74710f15c3 --- /dev/null +++ b/src/ts/lib/program/types.ts @@ -0,0 +1,107 @@ +/** What `tools ts program` measures. The check worker writes these as JSON; it imports this file for types only. */ + +export type ProgramMode = "phases" | "files" | "own" | "graph" | "sites"; + +export interface PhaseSample { + phase: "start" | "parse" | "bind" | "check"; + /** Heap after a full collection when the runtime allows one (`gcForced`), else the raw heap in use. */ + heapMiB: number; + rssMiB: number; + ms: number; +} + +export interface CheckerCounts { + types: number; + instantiations: number; + symbols: number; +} + +export interface ProgramSize { + files: number; + declarationFiles: number; + nodeModulesFiles: number; + nodeModulesBytes: number; + sourceBytes: number; +} + +interface WorkerBase { + typescript: string; + runtime: string; + config: string; + gcForced: boolean; +} + +export interface PhasesResult extends WorkerBase, CheckerCounts { + mode: "phases"; + size: ProgramSize; + phases: PhaseSample[]; + diagnostics: number; + firstDiagnostics: string[]; +} + +/** What checking one file created; the first file to touch a shared library type pays for it. */ +export interface FileCost { + file: string; + types: number; + instantiations: number; + symbols: number; + ms: number; + bytes: number; +} + +export interface FilesResult extends WorkerBase, CheckerCounts { + mode: "files"; + size: ProgramSize; + rows: FileCost[]; +} + +export interface OwnCost extends FileCost { + diagnostics: string[]; +} + +export interface OwnResult extends WorkerBase { + mode: "own"; + rows: OwnCost[]; +} + +export interface GraphFile { + /** Relative to the tsconfig's folder, or absolute when outside it. */ + path: string; + bytes: number; + /** Named by the tsconfig's include or files. */ + root: boolean; + /** Loaded by every program of these options: a default library, or a package named in `types`. */ + base: boolean; + declaration: boolean; + /** Indexes into `files` of what this file imports or references. */ + imports: number[]; +} + +export interface GraphResult extends WorkerBase { + mode: "graph"; + files: GraphFile[]; +} + +/** What one call, `new`, JSX element or type reference created when it was resolved on its own, during the first check. */ +export interface SiteCost { + file: string; + line: number; + kind: "call" | "new" | "jsx" | "type"; + /** What it calls or references: `name @ declaring file` (a package file is named from its package folder). */ + key: string; + /** The key's declaration is in a package or a default library, not in the project. */ + external: boolean; + instantiations: number; + types: number; + symbols: number; + ms: number; +} + +export interface SitesResult extends WorkerBase, CheckerCounts { + mode: "sites"; + /** Sites under `minInstantiations` are left out; their work stays in the program totals. */ + minInstantiations: number; + sites: SiteCost[]; +} + +export type WorkerResult = PhasesResult | FilesResult | OwnResult | GraphResult | SitesResult; diff --git a/src/utils/ai/core/call.ts b/src/utils/ai/core/call.ts index bf3b37aab0..6f8008e2a4 100644 --- a/src/utils/ai/core/call.ts +++ b/src/utils/ai/core/call.ts @@ -84,14 +84,18 @@ export interface CallLLMResult { usage?: LanguageModelUsage; } -export interface CallLLMStructuredOptions { +/** + * `S` is the schema itself and the result its `z.output`. Inferring an output `T` from a `z.ZodType` field instead + * walks the whole schema type against ZodType at every call (about 3,400 instantiations per call, 2026-10-25). + */ +export interface CallLLMStructuredOptions { systemPrompt: string; userPrompt: string; providerChoice?: ProviderChoice; model?: ModelRef | ResolvedBinding; task?: ResolveOptions["task"]; app?: ResolveOptions["app"]; - schema: z.ZodType; + schema: S; maxTokens?: number; temperature?: number; /** The SDK's own retries; 0 lets a caller with another account to try fail at once. */ @@ -525,7 +529,9 @@ export async function streamLLM( } } -export async function callLLMStructured(options: CallLLMStructuredOptions): Promise> { +export async function callLLMStructured( + options: CallLLMStructuredOptions +): Promise>> { const target = await resolveCallTarget(options); try { @@ -535,10 +541,10 @@ export async function callLLMStructured(options: CallLLMStructuredOptions) } } -async function runStructured( +async function runStructured( target: CallTarget, - options: CallLLMStructuredOptions -): Promise> { + options: CallLLMStructuredOptions +): Promise>> { const { schema, maxTokens, onPartial } = options; const temperature = acceptedTemperature(target, options.temperature); @@ -553,7 +559,7 @@ async function runStructured( }; if (onPartial) { - const streamed = await tryStreamObject({ model: target.model, callArgs, onPartial }); + const streamed = await tryStreamObject({ model: target.model, callArgs, onPartial }); if (streamed) { return streamed; @@ -561,23 +567,23 @@ async function runStructured( } const result = await generateObject({ - model: target.model as unknown as Parameters[0]["model"], + model: target.model, ...callArgs, }); return { - object: result.object as T, + object: result.object as z.output, content: SafeJSON.stringify(result.object, null, 2), usage: result.usage, }; } -interface TryStreamObjectOpts { +interface TryStreamObjectOpts { model: LanguageModel; callArgs: { system: string | undefined; prompt: string; - schema: z.ZodType; + schema: S; providerOptions: ReturnType; maxOutputTokens?: number; temperature?: number; @@ -590,12 +596,14 @@ interface TryStreamObjectOpts { * `generateObject` fallback) when the stream fails before the first chunk; * errors after the first chunk propagate like a failed `generateObject` call. */ -async function tryStreamObject(opts: TryStreamObjectOpts): Promise | null> { +async function tryStreamObject( + opts: TryStreamObjectOpts +): Promise> | null> { let sawChunk = false; try { const result = streamObject({ - model: opts.model as unknown as Parameters[0]["model"], + model: opts.model, ...opts.callArgs, }); @@ -604,7 +612,7 @@ async function tryStreamObject(opts: TryStreamObjectOpts): Promise; return { object, diff --git a/src/utils/ai/providers/AICloudProvider.ts b/src/utils/ai/providers/AICloudProvider.ts index 35e1dbf658..884976e5e0 100644 --- a/src/utils/ai/providers/AICloudProvider.ts +++ b/src/utils/ai/providers/AICloudProvider.ts @@ -24,6 +24,8 @@ import { sniffAudioExt } from "@genesiscz/utils/audio/detect-format"; import type { AIProviderType } from "@genesiscz/utils/config/ai.types"; import { env } from "@genesiscz/utils/env"; import { logger } from "@genesiscz/utils/logger"; +// The exported model type: deriving it from generateText's parameters cost about 40k type instantiations here. +import type { LanguageModel } from "ai"; type LlmCloudType = "openai" | "groq" | "openrouter"; type TranscribeOnlyCloudType = "assemblyai" | "deepgram" | "gladia"; @@ -228,9 +230,7 @@ export class AICloudProvider return results; } - private async getLanguageModel( - modelSpec?: string - ): Promise[0]["model"]> { + private async getLanguageModel(modelSpec?: string): Promise { if (modelSpec) { return this.resolveModel(modelSpec); } @@ -255,7 +255,7 @@ export class AICloudProvider throw new Error("No cloud LLM API key available. Set GROQ_API_KEY, OPENROUTER_API_KEY, or OPENAI_API_KEY."); } - private async resolveModel(spec: string): Promise[0]["model"]> { + private async resolveModel(spec: string): Promise { const [providerName, ...modelParts] = spec.split("/"); const modelId = modelParts.join("/"); diff --git a/src/utils/package-store.ts b/src/utils/package-store.ts index ad6fe2f0c0..6ef0711c75 100644 --- a/src/utils/package-store.ts +++ b/src/utils/package-store.ts @@ -22,6 +22,9 @@ export const STORE_PACKAGES: Readonly> = { "@huggingface/transformers": "4.2.0", "@lancedb/lancedb": "0.38.0", "@qdrant/js-client-rest": "1.19.0", + // Only the youtube server's checkout uses it. As a repo dependency its 5.9 MB of declarations were about 6% of + // every TypeScript program (2026-10-25). + stripe: "22.6.0", }; /** diff --git a/src/utils/package.json b/src/utils/package.json index 2b08cc4235..b0cd55a444 100644 --- a/src/utils/package.json +++ b/src/utils/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", + "scripts": { + "typecheck:tsgate": "if command -v vybava >/dev/null; then vybava tsgate tsconfig.json; else tsc --noEmit -p tsconfig.json; fi" + }, "description": "GenesisTools pure utils surface for external consumption. Full GT tree remains under the @genesiscz/tools root; this package only exports modules without tool (@app/*) imports.", "exports": { ".": { diff --git a/src/utils/tsconfig.json b/src/utils/tsconfig.json index 74793e16ba..e4e3fb05e2 100644 --- a/src/utils/tsconfig.json +++ b/src/utils/tsconfig.json @@ -3,9 +3,11 @@ // and Bun resolves a dependency's internal aliases from the dependency's own // nearest tsconfig — so @genesiscz/utils/* and @ui/* self-imports keep // working when the package is consumed from node_modules, where the repo - // root tsconfig.json does not exist. Mirrors the root compilerOptions. + // root tsconfig.json does not exist. Mirrors the root compilerOptions except `paths` (only the package's own + // aliases); the root's `false` flags are TypeScript's defaults. Keep `types` in step with the root. "compilerOptions": { "lib": ["ESNext", "DOM"], + "types": ["bun"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", diff --git a/src/utils/ui/custom/app-sidebar.tsx b/src/utils/ui/custom/app-sidebar.tsx index 6c8a6aa2f5..314f6113e3 100644 --- a/src/utils/ui/custom/app-sidebar.tsx +++ b/src/utils/ui/custom/app-sidebar.tsx @@ -16,7 +16,11 @@ import type React from "react"; import { type ReactNode, useState } from "react"; export type SidebarGroupTheme = "primary" | "purple" | "accent" | "neutral"; -type IconComponent = React.ElementType<{ className?: string }>; +// Component types, not React.ElementType: ElementType is a union of every HTML tag too, so each JSX use checked its +// props against all of them (about 175k type instantiations per file, 2026-10-25). +type IconComponent = React.ComponentType<{ className?: string }>; +/** The router's link (TanStack `Link`). */ +export type SidebarLinkComponent = React.ComponentType<{ to: string; className?: string; children?: ReactNode }>; export interface SidebarNavItem { title: string; @@ -50,9 +54,9 @@ interface AppSidebarProps { MenuItemComponent?: React.ComponentType<{ item: SidebarNavItem; active: boolean; - LinkComponent: React.ElementType; + LinkComponent: SidebarLinkComponent; }>; - LinkComponent: React.ElementType; + LinkComponent: SidebarLinkComponent; } const groupLabel: Record = { @@ -250,7 +254,7 @@ function MenuLink({ to, children, }: { - LinkComponent: React.ElementType; + LinkComponent: SidebarLinkComponent; to: string; children: ReactNode; }) { diff --git a/src/utils/ui/custom/coming-soon-card.tsx b/src/utils/ui/custom/coming-soon-card.tsx index e271dc7376..e009eab6f9 100644 --- a/src/utils/ui/custom/coming-soon-card.tsx +++ b/src/utils/ui/custom/coming-soon-card.tsx @@ -11,7 +11,8 @@ import { FeatureCardNexusTitle, } from "./feature-card"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; const tint: Record< FeatureCardNexusColor, diff --git a/src/utils/ui/custom/empty-state.tsx b/src/utils/ui/custom/empty-state.tsx index 57eada7c34..ecf68e821a 100644 --- a/src/utils/ui/custom/empty-state.tsx +++ b/src/utils/ui/custom/empty-state.tsx @@ -1,7 +1,8 @@ import { cn } from "@ui/lib/utils"; import type React from "react"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; interface EmptyStateIconProps { icon: IconComponent; diff --git a/src/utils/ui/custom/feature-grid.tsx b/src/utils/ui/custom/feature-grid.tsx index 27a37ca39b..07fda8d507 100644 --- a/src/utils/ui/custom/feature-grid.tsx +++ b/src/utils/ui/custom/feature-grid.tsx @@ -9,7 +9,8 @@ import { FeatureCardNexusHeader, } from "./feature-card"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; const colorStyles: Record = { cyan: { bg: "bg-cyan-500/10", icon: "text-cyan-400", badge: "bg-cyan-500/20 text-cyan-400" }, @@ -33,7 +34,8 @@ export interface FeatureGridItem { interface FeatureGridProps { items: FeatureGridItem[]; - LinkComponent: React.ElementType; + /** A component type, not React.ElementType (see IconComponent). */ + LinkComponent: React.ComponentType<{ to: string; children?: React.ReactNode }>; } export function FeatureGrid({ items, LinkComponent }: FeatureGridProps) { diff --git a/src/utils/ui/custom/floating-action-button.tsx b/src/utils/ui/custom/floating-action-button.tsx index 7448fda11d..de48dd7a04 100644 --- a/src/utils/ui/custom/floating-action-button.tsx +++ b/src/utils/ui/custom/floating-action-button.tsx @@ -2,7 +2,8 @@ import { Button } from "@ui/components/button"; import { cn } from "@ui/lib/utils"; import type React from "react"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; interface FloatingActionButtonProps { icon: IconComponent; diff --git a/src/utils/ui/custom/index.ts b/src/utils/ui/custom/index.ts index 09efe98caf..2196da925e 100644 --- a/src/utils/ui/custom/index.ts +++ b/src/utils/ui/custom/index.ts @@ -1,7 +1,7 @@ export { AlertBlock } from "./alert-block"; export { AnimatedCard } from "./animated-card"; export { AppShell } from "./app-shell"; -export type { SidebarGroupTheme, SidebarNavGroup, SidebarNavItem } from "./app-sidebar"; +export type { SidebarGroupTheme, SidebarLinkComponent, SidebarNavGroup, SidebarNavItem } from "./app-sidebar"; export { AppSidebar } from "./app-sidebar"; export { AppleWindow } from "./apple-window"; export { ChartSkeleton } from "./chart-skeleton"; diff --git a/src/utils/ui/custom/tab-bar.tsx b/src/utils/ui/custom/tab-bar.tsx index d4cca4072d..29aad24ca0 100644 --- a/src/utils/ui/custom/tab-bar.tsx +++ b/src/utils/ui/custom/tab-bar.tsx @@ -1,7 +1,8 @@ import { cn } from "@ui/lib/utils"; import type React from "react"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; export interface TabDef { value: V; diff --git a/src/utils/ui/custom/view-mode-toggle.tsx b/src/utils/ui/custom/view-mode-toggle.tsx index e19ba09902..360f7ceb0b 100644 --- a/src/utils/ui/custom/view-mode-toggle.tsx +++ b/src/utils/ui/custom/view-mode-toggle.tsx @@ -2,7 +2,8 @@ import { IconButton } from "@ui/components/icon-button"; import { cn } from "@ui/lib/utils"; import type React from "react"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; export interface ViewMode { value: V; diff --git a/src/youtube/lib/__tests__/billing-gateway.test.ts b/src/youtube/lib/__tests__/billing-gateway.test.ts index 3a147588a7..a36a82631a 100644 --- a/src/youtube/lib/__tests__/billing-gateway.test.ts +++ b/src/youtube/lib/__tests__/billing-gateway.test.ts @@ -1,5 +1,48 @@ import { describe, expect, it } from "bun:test"; -import { createStripeGateway } from "@app/youtube/lib/billing-gateway"; +import { + createStripeGateway, + type StripeCheckoutSessionParams, + type StripeLoader, +} from "@app/youtube/lib/billing-gateway"; + +/** A loader that installs nothing and returns a fake SDK recording every key and checkout request. */ +function fakeLoader(options: { failFirstLoad?: boolean } = {}) { + const keys: string[] = []; + const requests: StripeCheckoutSessionParams[] = []; + let ensures = 0; + let loads = 0; + const loader: StripeLoader = { + ensure: async () => { + ensures++; + }, + load: async () => { + loads++; + + if (options.failFirstLoad && loads === 1) { + throw new Error("import failed"); + } + + return { + default: class { + checkout = { + sessions: { + create: async (params: StripeCheckoutSessionParams) => { + requests.push(params); + return { id: `cs_${requests.length}`, url: `https://checkout.test/${requests.length}` }; + }, + }, + }; + + constructor(secretKey: string) { + keys.push(secretKey); + } + }, + }; + }, + }; + + return { loader, keys, requests, counts: () => ({ ensures, loads }) }; +} describe("createStripeGateway", () => { it("constructs offline and exposes both checkout methods", () => { @@ -8,4 +51,41 @@ describe("createStripeGateway", () => { expect(typeof gateway.createPackCheckout).toBe("function"); expect(typeof gateway.createSubscriptionCheckout).toBe("function"); }); + + it("loads the SDK once for concurrent checkouts and sends the attribution metadata", async () => { + const fake = fakeLoader(); + const gateway = createStripeGateway("sk_test_key", fake.loader); + + const [pack, subscription] = await Promise.all([ + gateway.createPackCheckout({ priceId: "price_pack", userId: 7, packId: "pack_1" }), + gateway.createSubscriptionCheckout({ priceId: "price_plan", userId: 7, planId: "plan_1" }), + ]); + + expect(fake.counts()).toEqual({ ensures: 1, loads: 1 }); + expect(fake.keys).toEqual(["sk_test_key"]); + expect([pack.id, subscription.id]).toEqual(["cs_1", "cs_2"]); + expect(fake.requests[0]).toMatchObject({ + mode: "payment", + client_reference_id: "7", + line_items: [{ price: "price_pack", quantity: 1 }], + payment_intent_data: { metadata: { packId: "pack_1", userId: "7" } }, + }); + expect(fake.requests[1]).toMatchObject({ + mode: "subscription", + subscription_data: { metadata: { planId: "plan_1", userId: "7" } }, + }); + }); + + it("retries the load on the next checkout after a failed import", async () => { + const fake = fakeLoader({ failFirstLoad: true }); + const gateway = createStripeGateway("sk_test_key", fake.loader); + const input = { priceId: "price_pack", userId: 1, packId: "pack_1" }; + + await expect(gateway.createPackCheckout(input)).rejects.toThrow("import failed"); + await expect(gateway.createPackCheckout(input)).resolves.toEqual({ + id: "cs_1", + url: "https://checkout.test/1", + }); + expect(fake.counts()).toEqual({ ensures: 2, loads: 2 }); + }); }); diff --git a/src/youtube/lib/billing-gateway.ts b/src/youtube/lib/billing-gateway.ts index aedc18a56c..55f18213b8 100644 --- a/src/youtube/lib/billing-gateway.ts +++ b/src/youtube/lib/billing-gateway.ts @@ -1,4 +1,5 @@ -import Stripe from "stripe"; +import { importStorePackage } from "@genesiscz/utils/package-store"; +import { ensurePackage } from "@genesiscz/utils/packages"; /** Per spec, success/cancel both return to YouTube — the extension polls balance; no landing page. */ const CHECKOUT_RETURN_URL = "https://www.youtube.com"; @@ -26,12 +27,64 @@ export interface StripeGateway { createSubscriptionCheckout(input: SubscriptionCheckoutInput): Promise; } -export function createStripeGateway(secretKey: string): StripeGateway { - const stripe = new Stripe(secretKey); +/** + * The part of the Stripe SDK this file calls. The SDK is a package-store package (src/utils/package-store.ts), + * installed on the first checkout, not a repo dependency: its 228 declaration files (5.9 MB) were about 6% of every + * TypeScript program that holds the youtube server, for this one file (2026-10-25). + */ +export interface StripeCheckoutSessionParams { + mode: "payment" | "subscription"; + client_reference_id: string; + line_items: Array<{ price: string; quantity: number }>; + metadata: Record; + payment_intent_data?: { metadata: Record }; + subscription_data?: { metadata: Record }; + success_url: string; + cancel_url: string; +} + +export interface StripeCheckoutSession { + id: string; + url: string | null; +} + +export interface StripeClient { + checkout: { sessions: { create(params: StripeCheckoutSessionParams): Promise } }; +} + +export interface StripeModule { + default: new (secretKey: string) => StripeClient; +} + +/** How the SDK reaches the process; tests pass fakes so no package is installed and Stripe is never called. */ +export interface StripeLoader { + ensure: (name: string, options: { label: string }) => Promise; + load: (name: string) => Promise; +} + +const storeLoader: StripeLoader = { + ensure: ensurePackage, + load: (name) => importStorePackage(name), +}; + +async function loadStripe(secretKey: string, loader: StripeLoader): Promise { + await loader.ensure("stripe", { label: "Stripe SDK (youtube checkout)" }); + const { default: Stripe } = await loader.load("stripe"); + return new Stripe(secretKey); +} + +export function createStripeGateway(secretKey: string, loader: StripeLoader = storeLoader): StripeGateway { + let client: Promise | undefined; + // Loaded on the first checkout; a failed install or import is retried by the next one. + const stripe = () => + (client ??= loadStripe(secretKey, loader).catch((err: unknown) => { + client = undefined; + throw err; + })); return { async createPackCheckout(input: PackCheckoutInput): Promise { - const session = await stripe.checkout.sessions.create({ + const session = await (await stripe()).checkout.sessions.create({ mode: "payment", client_reference_id: String(input.userId), line_items: [{ price: input.priceId, quantity: 1 }], @@ -46,7 +99,7 @@ export function createStripeGateway(secretKey: string): StripeGateway { return toResult(session); }, async createSubscriptionCheckout(input: SubscriptionCheckoutInput): Promise { - const session = await stripe.checkout.sessions.create({ + const session = await (await stripe()).checkout.sessions.create({ mode: "subscription", client_reference_id: String(input.userId), line_items: [{ price: input.priceId, quantity: 1 }], @@ -63,7 +116,7 @@ export function createStripeGateway(secretKey: string): StripeGateway { }; } -function toResult(session: Stripe.Checkout.Session): CheckoutSessionResult { +function toResult(session: StripeCheckoutSession): CheckoutSessionResult { if (!session.url) { throw new Error("stripe checkout session response missing url"); } diff --git a/src/youtube/lib/collection-ask.ts b/src/youtube/lib/collection-ask.ts index dc0d83b5f3..a7b6c2e618 100644 --- a/src/youtube/lib/collection-ask.ts +++ b/src/youtube/lib/collection-ask.ts @@ -31,7 +31,9 @@ const SYSTEM_PROMPT = [ ].join("\n"); export interface CollectionAskDeps { - callLLMStructured: (options: CallLLMStructuredOptions) => Promise>; + callLLMStructured: ( + options: CallLLMStructuredOptions> + ) => Promise>; } export interface CollectionAskInput { diff --git a/src/youtube/lib/summarize.types.ts b/src/youtube/lib/summarize.types.ts index e8edcff12c..1b920929d2 100644 --- a/src/youtube/lib/summarize.types.ts +++ b/src/youtube/lib/summarize.types.ts @@ -13,6 +13,7 @@ import type { CallLLMStructuredResult, } from "@genesiscz/utils/ai/core/call"; import type { ProviderChoice } from "@genesiscz/utils/ask/types"; +import type { z } from "zod"; export interface SummaryProgressInfo { phase: "summarize"; @@ -75,7 +76,9 @@ export interface SummaryBin { export interface SummaryServiceDeps { callLLM: (opts: CallLLMOptions) => Promise; - callLLMStructured: (opts: CallLLMStructuredOptions) => Promise>; + callLLMStructured: ( + opts: CallLLMStructuredOptions + ) => Promise>>; } /** Pre-flight cost estimate for an LLM summarize/QA call (GET /videos/:id/estimate). */ diff --git a/src/youtube/ui/routes/__root.tsx b/src/youtube/ui/routes/__root.tsx index dc375eed3a..20229055dd 100644 --- a/src/youtube/ui/routes/__root.tsx +++ b/src/youtube/ui/routes/__root.tsx @@ -7,13 +7,12 @@ import { fetchUiConfig } from "@app/yt/config.client"; import { applyAppearance } from "@app/yt/lib/appearance"; import { pageTitleFromPath } from "@app/yt/lib/theme"; import { useEventStream } from "@app/yt/ws.client"; -import { AppShell, AppSidebar, type SidebarNavItem } from "@genesiscz/utils/ui/custom"; +import { AppShell, AppSidebar, type SidebarLinkComponent, type SidebarNavItem } from "@genesiscz/utils/ui/custom"; import { cn } from "@genesiscz/utils/ui/lib/utils"; import { useDashboardTheme } from "@genesiscz/utils/ui/theme/dashboard-theme"; import type { QueryClient } from "@tanstack/react-query"; import { createRootRouteWithContext, Link, Outlet, redirect, useRouterState } from "@tanstack/react-router"; import { BriefcaseBusiness, History, Library, Newspaper, PlaySquare, Settings, Tv } from "lucide-react"; -import type React from "react"; import { useEffect } from "react"; interface RouterContext { @@ -42,7 +41,7 @@ function YtSidebarItem({ }: { item: SidebarNavItem; active: boolean; - LinkComponent: React.ElementType; + LinkComponent: SidebarLinkComponent; }) { const Icon = item.icon; diff --git a/tsconfig.json b/tsconfig.json index ef0e9b91ef..95f4e8e062 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,11 +14,18 @@ // `bun run typecheck:all` (src/spotify/ui/tsconfig.json). "src/spotify/ui", "src/monitor/ui", - "src/Internal/commands/reas/ui" + "src/Internal/commands/reas/ui", + // Build output (a 764 KB minified bundle under src/youtube/ui cost 0.6 s and 23k types). + "src/**/dist" ], "compilerOptions": { // Enable latest features "lib": ["ESNext", "DOM"], + // TypeScript 6 adds no @types package to the global scope unless named here (an import still loads its + // package's types). Without it Bun's globals came only from the one + // file that imports "bun", so a smaller program (a reviewer's, a per-folder one) reported + // "Cannot find name 'Bun'" in files that never import it. + "types": ["bun"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force",