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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/skills/performance/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
77 changes: 77 additions & 0 deletions .claude/skills/performance/references/typecheck.md
Original file line number Diff line number Diff line change
@@ -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 <file>`: one file's own work (everything else checked first). THE before and after
instrument for an edit.
- `tools ts program closure` (`--why <package>`): 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 <text>`): 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<typeof …>` 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 <file>`, change the TYPE only, `own <file>` 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<ChartType>(…)`: 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<P>` (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<P>`: 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<CSSProperties, "backgroundImage">`.
- **A type derived from a library function's parameters.** `Parameters<typeof generateText>[0]["model"]` instantiates
the whole generic signature; the library exports the same type as `LanguageModel`.
- **A generic inferred from a zod schema parameter.** `fn<T>(schema: z.ZodType<T>)` 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<S extends z.ZodType>(schema: S): z.output<S>`. 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.
11 changes: 11 additions & 0 deletions .eve-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions scripts/ci/typecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 4 additions & 3 deletions src/artifact/runtime/kit/chartjs.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<ChartType>(canvasRef.current, {
...base,
options: { ...themeRef.current, ...base.options },
});
Expand Down
3 changes: 2 additions & 1 deletion src/artifact/runtime/kit/gantt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CSSProperties, "backgroundImage"> = {
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)`,
Expand Down
4 changes: 3 additions & 1 deletion src/jev/lib/browser/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WriterAnswer>) => Promise<{ object: WriterAnswer }>;
export type WriterCall = (
options: CallLLMStructuredOptions<z.ZodType<WriterAnswer>>
) => Promise<{ object: WriterAnswer }>;

const answerSchema = z
.object({
Expand Down
9 changes: 7 additions & 2 deletions src/question/lib/decisions/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,13 @@ export type PostDecisionsInput = z.infer<typeof postDecisionsInputSchema>;
export type DecisionPatch = z.infer<typeof decisionPatchSchema>;
export type DecisionUpdate = z.infer<typeof decisionUpdateInputSchema>;

/** Parses or throws one readable error that names every bad field. */
export function parseDecisionInput<T>(schema: z.ZodType<T>, 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<T>`
* parameter instead makes the checker walk the whole schema type against ZodType at every call.
*/
export function parseDecisionInput<S extends z.ZodType>(schema: S, value: unknown, label: string): z.output<S> {
const result = schema.safeParse(value);

if (!result.success) {
Expand Down
Loading
Loading