Deferred work — Tu

A living list of every “leave for later” decision made during a milestone, with the milestone that introduced the gap and (when known) where it should land. Rows are removed in the commit that fills them, so the diff shows the loop closing.

Open

Item Introduced Target Notes
Sandbox API for executing user-provided Tu post-M8 TBD Tu rejects raw eval but the use case (playgrounds, plugin systems, in-browser editors) is real. Default direction: SES (Hardened JS) for portable isolation, fall back to Web Worker for browser-only. Don’t speculate-design ahead of a real consumer. See memory/project_sandbox_design.md.
Per-component fine-grained HMR boundaries M1.6 post-M1.7 The @tu-lang/vite plugin currently triggers a full module re-import + re-mount on .tu save. Per-component preserve-state HMR is future work.
Local reactivity (per-cell-read subscriptions) M1.7 M2+ Keyed diff is cheap, but the component thunk still re-runs in full on every cell mutation. Solid-style per-binding patches that only touch the affected text node / attribute are a deeper rework — needs a different compiler IR that wraps each cell read in its own reactive scope.
generic syntax (no class — permanently rejected) M6.7 TBD class is permanently banned in Tu (M8 decision 2026-05-02 — components are lambdas, types are interfaces). Generic-lambda syntax (<T, U>) is still a candidate for future addition when a use-site asks. Generics will pair with the M9 type-system extensions (interface generics + runtime descriptors that take type params).
Qwik-style SSR resumability M1.7 post-M6.11 M6.11 closed the async / Suspense / streaming SSR triad (#60-#62 — renderToStringAsync, Suspense, renderToStream). Still missing: Qwik-style resumability where the client never re-runs the first-frame thunk after hydration. Today hydrate(thunk, container) re-executes thunk once on the client to bind listeners; resumability would serialize listener references into the SSR HTML and have the client adopt them without re-rendering. Big design + runtime change — separate milestone. See docs/SSR-ASYNC-DESIGN.md for the SSR design context.
LSP DX comprehensive update M3 M6.12 User-flagged 2026-05-02 as URGENT. Beyond the per-row LSP gaps tracked elsewhere (style-class union types, DOM type isolation), the user reports module-level type errors don’t surface, hover at module scope is patchy, and reference jump (find-references / cross-.tu) is missing. Treat as a focused milestone to comprehensively improve the editor experience: full diagnostics across the import graph, hover/goto-def/find-refs working end-to-end on every identifier kind, completion polish, error-message quality.
Lifecycle hooks + element ref sugar M0 post-LSP DX User flagged 2026-05-02 — Tu has no lifecycle hooks (onMount / onUnmount / beforeUnmount) and no element-ref sugar. Wants Vue-2-style explicit ref (NOT React useRef): <button ref: btn /> then read btn.get(), with expose { btn } for children-expose to parents. Independent feature, sizable. See memory/project_lifecycle_and_element_ref.md for the design notes.
File-based app router (Next.js-style) M0 post-M9 Minimal SSR/client route matching landed as @tu-lang/router, and tu-shu now reuses its file-path URL helper. Still deferred: Next.js-style app/[slug]/page.tu discovery, layouts, loaders, server functions, and framework-level conventions. See memory/project_router_system.md.
HTML-section await sugar (Svelte-{#await} analogue) + for await M6.6 post-LSP DX User flagged 2026-05-02 — wants await user = fetchUser(id) directly in markup, plus for await event in events { … } for async iterables. Codegen lowers to M6.11 Suspense; iteration form needs a new runtime primitive. The else "loading…" fallback shape is the core syntax decision. See memory/project_html_async_sugar.md.
Style ↔ JS state interop (CSS variables auto-bound to cells) M1.8 TBD VERIFIED ABSENT 2026-05-02. The compiler’s emitStyleBlock (codegen.ts:1248) emits h("style", {}, [JSON.stringify(css)]) — pure static CSS string with no cell binding, no variable substitution. The user remembered this landing in an earlier version of Tu, but it was never implemented. Design from scratch: --name: $cellRef syntax inside style block (compiler emits style="--name: ${cell.get()}" on the component root + reactive update on cell change), or an @bind cellName rule. Pair with the M1.8 scoping infrastructure.
Strict type-level DOM isolation in LSP shadow M6.10 TBD M6.10 split the runtime into universal (@tu-lang/runtime) + browser (@tu-lang/dom) and enforces the boundary at the runtime-function layer: mount/hydrate/defineCustomElement only live in @tu-lang/dom, so SSR/Node consumers can’t accidentally pull DOM impls. Type-level isolation is NOT yet enforced — the LSP shadow keeps lib.dom ambient, so a .tu file can still write document.x / (e: Event) without importing from @tu-lang/dom. Strict isolation needs us to ship our own minimal DOM-types fork inside @tu-lang/dom’s .d.ts (so lib.dom.d.ts can be dropped from getTuCompilerOptions) — sizable but the right long-term shape.

Closed in M9 (2026-05-03 partial)

  • Module-scope destructuring let { a, b } = obj — landed as a flat-field MVP: top-level private let { a, b } = obj now evaluates the RHS once into a compiler-generated temp and emits one Signal.State per field (a from tmp.a, b from tmp.b). Reads of those bindings participate in normal .get() injection, and TS shadow output keeps the temp typed from the RHS or optional destructure annotation. export let { … }, rename/default/nested patterns, and array destructuring remain intentionally unsupported.
  • Minimal universal router package — landed as @tu-lang/router: static, dynamic (:id), and trailing catch-all (*slug) matching; deployment base stripping; query parsing; fallback handlers; and SSR helpers layered on renderPageAsync / renderToStream. Playground routes now use shareable paths like /tu/playground/typed instead of hash fragments, with generated GitHub Pages fallback HTML.
  • Filtered catch + Exception narrowing — landed: catch if ValidationError as e { ... } lowers to one JS catch with type.is dispatch and narrows e inside the branch through the TS/LSP shadow. catch e { ... } is the fallback branch and gets an Error-like default type, including message: string. Legacy catch (e: T) remains accepted during alpha compatibility, but examples now prefer filtered catch syntax.
  • Enum declarations — landed: Tu now accepts top-level enum Name { A, B = "custom", C = 3 } with optional export. JS emit produces a frozen runtime object (const Name = Object.freeze(...)); TS shadow emit also adds a like-named value-union alias (type Name = (typeof Name)[keyof typeof Name]) so enum values work both at runtime (Name.A) and in annotations ((tone: Tone) => ...). class remains permanently banned, and generic syntax stays deferred.
  • CSS4 nesting / @layer / @scope awareness in style block — landed: style-block validation now walks nested CSS rule blocks instead of only depth-0 selectors. Grouping at-rules such as @layer, @media, @supports, and @container recurse and still enforce class-rooted style rules, selector-list splitting respects commas inside pseudo-class functions like :is(.a, .b), and @scope (.class) permits element selectors inside the scoped body while rejecting non-class scope roots.
  • Deprecate positional component calls — landed: legacy uppercase positional calls such as Card("title") { ... } still compile during the alpha compatibility window, but LSP diagnostics and tu check now emit a warning on the component callee asking users to migrate to named-prop calls such as Card(title: value) { ... }. docs/LANGUAGE.tu now documents named props as the preferred form and marks positional calls as deprecated.
  • Default export (export default let …) — landed: Tu now accepts export default let Name = … for component-as-file modules. JS/TS emit uses const Name = … followed by export default Name so default-exported lambdas, state cells, and computed cells avoid invalid export default const syntax. Default imports from .tu files are classified through the same state/computed/function import-kind path as named imports, so default-imported cells still get .get() injection in Vite/LSP shadow graphs.
  • Tu-native type conversion API (type.tryFrom) — landed: @tu-lang/std/type now exports type.tryFrom<T>(value, descriptor, castFn?), a non-throwing sibling of type.as. It returns { ok: true, value } when the input or cast result matches the descriptor and { ok: false, error: TypeMismatchError } on shape mismatch or cast-function failure, giving Tu code a Result-shaped alternative to JS implicit coercion and exception-only parsing paths.
  • LSP type-annotation source mapping polish — landed: TS shadow emit now maps raw type spans for lambda params, exported auto-Props fields, lambda return types, local-let annotations, wrapped state/computed annotations, and type.tag(User, …) descriptor refs back to the original .tu type token. Hover/definition/references/rename can now work on interface names written inside lambda type annotations instead of only on value-position identifiers, and reference/rename locations are de-duped by final .tu source location so generated helper/interface refs do not show duplicate entries.
  • Object-literal computed keys ({ [expr]: v }) — landed: peekObjectLitShape now recognizes { [expr]: value } as an object literal by scanning to the matching ] and requiring a following :. parseObjectProp stores the key expression on computedKey, and JS/TS emit prints bracket properties ({ [key]: value }) while still applying normal cell-read injection inside the key expression. Static anonymous descriptor synthesis and canonical shape merging skip computed-key objects conservatively because their field names are runtime values.
  • Style-class literal-type union in TS emit — landed: TS shadow output now emits ClassesOf_<Component> unions from class selectors declared in each component’s style { … } block, plus a TS-only __tu_class<C>() helper. Scoped component class: props are wrapped with __tu_class<ClassesOf_X>(…), so object-form keys like { card: cond } are checked against declared classes while plain string class values remain allowed for utility/global CSS. LSP diagnostics now map unknown object keys such as { ghost: true } back to the offending Tu key.
  • Remaining JS expression operators compatibility sweep — closed: M5.10/M6.5 already covered \|\|, &&, ??, optional chaining, obj[k], prefix ! / - / +, postfix !, new for genuine constructors, compound assignment, spread, template literals, parens, TS optional params, and regex literals. The final missing candidate set landed in M9: computed object keys, **, **=, and bitwise & / \| / ^ / ~ / << / >>. Permanent bans remain intentional: ternary ?:, ++ / --, instanceof, typeof, value-position undefined / void, expression-position this / arguments / class / var / with, bare Date / new Date(...), new Array(...), and explicit any outside external JS. Future JS compatibility should be added only from concrete use cases, not from blanket parity.
  • ** exponent operator — landed: lexer emits StarStar, parser treats it as the highest-precedence right-associative binary operator (2 ** 3 ** 2 parses as 2 ** (3 ** 2)), JS/TS emit passes it through, and inference treats exponent expressions as number.
  • Bitwise expression operators (& / \| / ^ / ~ / << / >>) — landed: lexer/parser/codegen now support the common numeric bitwise set with JS precedence (shift above relational, bitwise AND/XOR/OR between equality and logical AND). Prefix ~ emits as a parenthesized unary expression and inference treats bitwise expressions as number.
  • Exponent compound assignment (**=) — landed: lexer emits StarStarEq and the existing compound-assignment desugar now lowers x **= y, obj.x **= y, and arr[i] **= y to assignment with a BinaryExpr("**") RHS, preserving the same cell/member handling as the other compound operators.
  • LSP shadow-graph integration of canonicalizer — landed: buildShadowGraph() now runs canonicalizeShapes() across the parsed import graph and stores the canonical result on each shadow. Hover expansion uses it for merge-aware interface docs, e.g. hovering a User value whose shape matches Person appends Merged with: Person (from person.tu) alongside the expanded field list. Emit semantics stay unchanged — LSP uses the canonical map only for editor explanation.
  • M9 — JIT param-type inference (Phase D) — landed: omitted lambda params now infer in the TS shadow from same-file callsites, direct/re-exported cross-file callsites in bundle mode and LSP shadow graphs, same-file helper return values, body member-use shapes, and conservative non-member body-use hints. Supported callsite expressions include primitives, arithmetic/comparison/unary expressions, ??/&&/\|\| widening, object literals, arrays, member reads from same-file static object lets (user.profile.name), and indexed reads from same-file static array lets (items[0]). Multiple callsites widen into unions (number | string, object-shape unions). Body fallback covers nested member chains ({ profile: { name: unknown } }) plus number/boolean/array patterns such as arithmetic, !ok, [...items], items[0], for item in items, and literal comparisons. Explicit param annotations and callsite evidence win over body fallback.
  • LocalLet destructuring let { a, b } = obj — landed: object-destructure pattern in block-scoped let. Codegen emits TS-native let { a, b } = obj so tsserver narrows each binding from RHS’s inferred shape (no type annotation needed — RHS provides the type). Destructured field names extend the block’s local-shadow set so module cells of the same name don’t clash. MVP scope: flat object only (no nesting / defaults / rename / array). Module-scope let destructuring stays deferred — needs cell-wrap design.
  • Param destructuring ({ title, footer }: T) => … — landed: object-destructure pattern in lambda param position. Codegen emits TS-native { a, b }: T so both TypeScript narrowing and JS runtime get the destructure for free; no body-rewrite. The destructured field names extend the lambda’s shadow set so cells of the same name in module scope don’t clash (export let count = 0; let X = ({ count }: P) => count reads the destructured local, not the cell). Auto-${Name}Props is suppressed when any param is destructured — the user already named the prop shape via the destructure annotation. Type annotation is REQUIRED — untyped destructure on M9-default unknown would error on every key access. MVP scope: flat object only (no nesting / defaults / rename / array destructure). 3 tests added; tu-xing/Badge.tu migrated as a proof point.
  • class: / style: array + object syntax — landed: @tu-lang/runtime exports normalizeClassValue / normalizeStyleValue plus the ClassValue / StyleObject type aliases. class accepts string | number | (recursive)[] | { [k]: cond }; style accepts string | { camelCaseKey: string | number }. Both wired into the SSR sync path, the SSR async path, the streaming-shell renderer, and the @tu-lang/dom mount + diff prop-application loops. camelCase → kebab-case for style keys (fontSizefont-size); empty results omit the attribute entirely (no more class="" noise). Numeric style values pass through verbatim — no auto-px (avoids the React/Vue corner case where unitless props like lineHeight: 1.5 get the wrong unit). 14 new tests (11 runtime + 3 dom).
  • Auto-rewrite ${Name}Props to all-optional + children?: Child[] — landed: the emitted interface now matches M6.1 named-arg call-site reality. Every prop becomes ?: optional (callers can omit any key — runtime gets undefined) and a trailing children?: Child[] slot is appended. The children-append is suppressed when the lambda already declares its own children param so user-typed children: T continues to win. Skipped when the user hand-declares ${Name}Props (existing collision guard preserved). 2 tests added; downstream tu-xing-demo bundle unchanged.
  • if let a = x { … } bind-and-test sugar — landed: parses if let Ident [: Type] = expr { body } [else …] via parser-level desugar to an IIFE wrapping { let a = expr; if ((a !== null) && (a !== undefined)) body else else }. The double-strict cond is what tsserver recognizes for NonNullable<T> narrowing inside the then branch under Tu’s ===== rewrite. RHS is parsed under noBraceBlock so the body’s { doesn’t get eaten as a tag-call children block. Closes the deferred row; 4 codegen tests added.
  • TS-style as type assertion / cast — landed: expr as Type parses as a postfix-style AsExpr (contextual keyword as followed by a type-name ident, optional <...> generics depth-tracked, optional trailing [] array suffixes). TS-emit wraps as (arg as Type); JS-emit erases the cast (no runtime effect). Removes the external JS workaround in tu-xing-demo for (e.target as HTMLInputElement).value. Unions / namespaced types deferred — wrap in a named type alias if needed. The <Type>expr legacy form is intentionally NOT planned (collides with JSX-like generics).
  • Phase B — untyped lambda params default to unknown in TS shadow — landed: codegen emits (x: unknown) for params with no annotation, replacing TS’s implicit-any footgun.
  • Phase C — type.as<T>(value, descriptor, castFn?): T runtime cast — landed in @tu-lang/std/type: 2-arg form is assertion-only (throws TypeMismatchError), 3-arg form runs castFn(value) first then checks. Generic T infers from destination annotation. Works with primitive descriptors AND user-declared interfaces (verified by 5-test end-to-end suite).
  • Phase A polish — explicit any parser ban — landed: every non-external JS raw type span is scanned for whole-word any and rejected with a directive pointing at unknown plus the type.as(v, T) runtime-narrowing path. The escape hatch stays intact: external JS bodies and their lambda signatures may still use any because that code is raw JS/TS by design.
  • JS legacy ban polish — receiver/scope/date footguns — landed: parser directive errors now cover expression-position void, this, arguments, class, var, with, plus bare Date / new Date(...) (directing users to @tu-lang/std/time). Existing bans for ternary, ++ / --, new Array(...), sparse-array normalization, and == / != strict-emission remain covered by tests.
  • JS legacy ban polish — null/undefined source unification — landed: value-position undefined is rejected in .tu source with a directive to use null; playground demos and docs now use null for intentional empty values, and Monaco no longer advertises undefined as a Tu constant. Compiler-generated JS may still use undefined for omitted/fallthrough/internal narrowing values, and external JS { … } remains the escape hatch.
  • Phase A — any migration — landed: 9 non-external-JS any annotations in playground/examples migrated to typed (unknown + external-JS body for Monaco-touching code; () => void for Promise resolve callbacks; CaseDefinition / CaseFile interface refs for case-typed params). Residual any only inside external JS block bodies (intended escape hatch). Parser-level ban for explicit any outside escape hatches is a small follow-up.
  • LSP — interface-expanding hover — landed: tsserver-rendered hover content scanned for known interface names; matches append a Markdown block listing the fields with their declared types and source file. Works for typed let bindings, typed lambda params, prop reads on typed objects.
  • LSP — type-name goto-definition — landed: definition.ts extracts the cursor’s identifier and probes the shadow-graph’s interface decls before AND after the tsserver path, so User in let alice: User = … jumps to the interface User { … } declaration even when the cursor is inside a type-annotation raw-text span tsserver can’t see.
  • M8 build-tool integration — CLI + Vite plugin — landed: tu bundle <files…> [-o outDir] [--ts] ships in @tu-lang/cli; tuBundle() plugin ships in @tu-lang/vite (production bundle by default; opt-in for dev mode). Both run compileBundle() cross-module + emit shared __tu_types.generated.{ts,js} + collision-free per-file canonical refs (__tu_canon_<Name> import alias).

Closed in M8

  • Phase 0 design doc + Phase 1 runtime API — landed: docs/TYPE-METADATA-DESIGN.md design doc + @tu-lang/std/type runtime API (primitives, Array(T), Optional(T), struct, native, tag, of, is — 18 tests).
  • Phase 2 — interface keyword + codegen + 12-file repo migration — landed: interface Foo { … } compiles to BOTH a TS interface AND a runtime const Foo = type.struct(…) descriptor; auto-import of type from @tu-lang/std; tuTypeToDescriptorExpr maps Tu types to descriptor calls (primitives, T[], T|null, function fallback). Migrated tu-xing 7 components, examples, playground.
  • Phase 2.5 — typed-let type.tag(I, …) injection — landed: let alice: User = { … } wraps the value so type.of(alice) === User. Triggers only for locally-declared interfaces in Phase 2.5; Phase 3c widens to imported.
  • Phase 3a/3b — anonymous synthesis + shape interning — landed: untyped let X = { … } synthesizes a hoisted __tu_anon_N = type.struct(…) and wraps the value. Shape interning means same-shape literals share one descriptor (order-insensitive hash). Spread members disable synthesis (Phase 3d work). 10 new tests.
  • Phase 3c — import-graph classification — landed: LSP shadow-graph collects export interface names per file; cross-.tu imports flow through to codegen so import { User } from "./other.tu"; let bob: User = { … } also tags. Compiler-only path (no LSP) keeps Phase 2.5’s conservative fallback. 2 new LSP tests.
  • Phase 4 — typeof / instanceof parser bans — landed: parsePrimary + parseStmt throw directive errors pointing at type.of(value) / type.is(value, Interface). Inside external JS { … } block bodies the lexer doesn’t tokenize — escape hatch preserved. 3 new tests.
  • Phase 5 — @tu-lang/std/time Temporal-based Date replacement — landed: @tu-lang/std/time re-exports @js-temporal/polyfill’s namespace (Instant, ZonedDateTime, PlainDate, etc.). 8 Temporal native descriptors in type.X so type.is(v, type.Instant) works. Lazy polyfill load (constructor-name fast path; await preloadTemporal() upgrades to strict instanceof). 15 new tests.
  • Phase 6a — canonicalizeShapes() algorithm core — landed: pure-function cross-module merge in @tu-lang/compiler. Walks N programs, hashes every interface + every untyped object-let shape (FNV-1a 64-bit, sorted-fields-stable), merges identical hashes, returns canonical descriptors + per-file rewrite tables. Anon-vs-named cross-merge supported. 13 new tests. Phase 6b/6c (integration) is the next M8 commit.

Closed in M6.12

  • Static-HTML optimization (verify M6.0 actually works) — verified + proof example landed: examples/static-html/ ships Welcome.tu (a deeply-nested static <div> tree) + verify.mjs that compiles the source, asserts the output contains h("$static", {}, [], "<div>…</div>") (vs the ~10 nested h() calls a non-static tree would produce), then runs the result through renderToString and confirms the HTML round-trips with proper escaping. Run pnpm --filter @tu-examples/static-html test. The compiler’s isStaticTree (codegen.ts:1950) + countStaticNodes >= 3 threshold gate the optimization correctly; reactive subtrees fall back to h() calls.

  • Suspense + streaming SSR proof example — landed: examples/suspense/ ships Page.tu with two async UserCards wrapped in Suspense, plus a run.mjs that exercises both renderToStringAsync (await-all flavor) and renderToStream (per-boundary flush). Streaming output shows 5 chunks: shell + placeholders, the $tu_replace polyfill, two <template> chunks in resolution order (the fast card flushes first even though it appears second in source), and the body close. End-to-end proof that the M6.11 ladder works.

Closed in M6.11

  • Streaming SSR (renderToStream + <template> per-boundary flush) — landed (#62): renderToStream(thunk, options) returns a Web ReadableStream<Uint8Array> ready to pipe into a Response. The first chunk holds the full page shell + the body’s sync regions, with a <div data-tu-suspense="N">…fallback…</div> placeholder per pending boundary (or empty placeholder for a bare-Promise child outside any Suspense). A ~150-byte inline $tu_replace polyfill flushes once before the first per-boundary template arrives. Each boundary then resolves in parallel and flushes a <template id="S:N">…body…</template><script>$tu_replace("N")</script> chunk in resolution order — a fast boundary preceded by a slow one still arrives first. Rejected boundaries leave the placeholder (and its fallback content) in the DOM unmodified. New RenderToStreamOptions.onShellReady callback fires after the shell + placeholders enqueue, before resolution work starts. assemblePage was refactored to share assembleShellParts with the streamer so the head + body-open emit identically across the sync/async/streaming flavors. 7 new tests covering chunk ordering, rejection, async-thunk root, bare-Promise placeholder, onShellReady timing.

  • <Suspense> boundary primitive — landed (#61): new $suspense tag sentinel + Suspense({ fallback, children }) runtime helper. The async render walk catches both Promise rejections and throws inside the boundary’s children pipeline, emitting fallback (which can itself be async) instead of propagating. Boundaries compose — an inner Suspense’s catch produces a fallback string the outer boundary sees as success, so nesting “just works”. Sync renderToString of a Suspense emits fallback directly (the body might contain promises the sync path can’t await). Tu call-site uses M6.1 named-args: Suspense(fallback: div { "Loading…" }) { AsyncChild() } — compiles to Suspense({ fallback: …, children: [...] }). 9 new tests covering rejection / composition / siblings / async fallback / sync fallback path / end-to-end via renderPageAsync.

  • Async path for SSR (renderToStringAsync + renderPageAsync) — landed (#60): Child gained a Promise<Child> member; sync renderToString now throws a typed TuRenderError on Promise children (was silently emitting [object Promise]). New renderToStringAsync walks the same shape and awaits any promise it meets, with Promise.all-style parallelism over each vnode’s children + array nodes so independent await fetch(...) calls don’t serialize. renderPageAsync accepts both sync and async thunks. tu-shu/loadTuPage uses the async path so a .tu page exporting async let Page = … round-trips through to HTML. @tu-lang/dom mount/hydrate keep their sync contract — Promise children there throw a directive pointing at SSR or Suspense (#61). 11 new tests in packages/runtime/tests/index.test.ts; full repo (414 tests across 10 packages) green. Design doc at docs/SSR-ASYNC-DESIGN.md drives the rest of the M6.11 ladder (#61 Suspense, #62 streaming).

Closed in M6.10.1

  • external JS return type can’t start with { — landed: parseRawTypeUntilBrace now disambiguates "type-literal { " vs "body-opener { " by looking at the immediately preceding token. If it’s a type-continuation token (:, &, |, =>, ,, [, (, <, ?), the { opens a type literal; the parser scans the matching } via the new findBalancedBraceClose and keeps consuming type tokens. Otherwise the { is the body opener. Inline object-shape returns like external JS (xs: T): { ms: number; out: any[] } { … } now parse without a type alias workaround.

  • Object-literal multi-prop shorthand { x, y, z: 1 } — landed: peekObjectLitShape now treats Ident Comma (an Ident immediately followed by ,) as an unambiguous object-literal signal; Block bodies don’t separate statements with ,, so there’s no ambiguity. parseObjectProp accepts an Ident with no following : as shorthand and synthesizes an Ident value expression with the same name — { id, title } desugars to { id: id, title: title }. Single-item { x } deliberately stays a Block to preserve last-expression-returns semantics. The examples/js-compat/JsCompat.tu workaround ({ id: id, title: title }) was reverted to native shorthand.

  • Member compound assignment obj.x += 1 / arr[i] ||= "x" — verified+tested: the parser already desugars compound assign on MemberExpr/IndexExpr targets to MemberAssignExpr with a synthetic BinaryExpr value (parser.ts:455-475). The DEFERRED row was stale. Added regression tests covering: bare obj.x += 1, indexed arr[i] += 1, logical obj.x ||= "default", and cell-backed counts.a += 5 (where counts is a top-level Signal.State; the desugar correctly emits counts.get().a = counts.get().a + 5).

Closed in M6.10

  • Platform-API split — separate the universal runtime from browser-only DOM glue — landed: @tu-lang/dom is now its own workspace package owning mount, hydrate, defineCustomElement, the diff/patch path, and typed re-exports of common DOM types (Event, MouseEvent, Element, HTMLElement, Node, RequestInit, AbortController, …). @tu-lang/runtime keeps only the universal half: Signal, h, Fragment, VNode, Child, renderToString, renderPage, renderPageHtml. All four DOM-touching test files (mount/hydrate/diff/custom-element) moved to packages/dom/tests/. The vite plugin’s HMR template now emits import { mount } from '@tu-lang/dom', and every example/playground that mounts in a browser pulls from @tu-lang/dom. The LSP shadow gained a generic TU_PLATFORM_PACKAGES registry — adding a future @tu-lang/node / @tu-lang/workers is one line. The release workflow (release.yml) was extended to pack @tu-lang/dom; npm “Pending Publisher” needs to be configured for the package’s first publish (mowtwo/tu, release.yml). The user’s design call (2026-05-02): “比如 document api,这玩意其实是 dom 专属的,按道理必须通过 external 来引入” — done at the runtime-function layer; type-level isolation is now its own deferred row above.

Closed in M3.13

  • CSS diagnostics inside style { … } — landed: validateCssBlocks (in the new css-lsp.ts module) walks every style block in the program and runs cssLs.doValidation. Diagnostic ranges come back in CSS-doc-relative coordinates and are translated to source-doc absolute (line, col, length) via the same offset-math the M3.12 hover path uses. checkTuSource augments its TS-only diagnostic list with these. Misspellings (colour: red) and other CSS-LS-known issues now squiggle on the offending property/value, not the surrounding let-decl. CSS diagnostics use a code: -1 sentinel since CSS LS doesn’t carry TS-style numeric codes — tu check and the LSP server suppress the [code] tag for those.

Closed in M3.12

  • CSS hover inside style { … } — landed: hoverAtTuPosition checks findCssContextAt first; if the cursor is in a CSS body, it delegates to cssLs.doHover() and translates the returned range from CSS-doc coordinates to source-doc coordinates so the LSP underline lands on the property/value the cursor pointed at. CSS hover content (a Markdown-formatted property doc with a link to MDN) flows back as the TuHover.contents. Falls through to tsserver only when the cursor is outside any style block.

Closed in M3.11

  • CSS completion inside style { … } blocks — landed: @tu-lang/lsp now depends on vscode-css-languageservice. The parser was extended to record cssStart / cssEnd byte offsets on the StyleBlock AST node so completion can find the inner CSS span; completionsAtTuPosition checks first whether the cursor is inside any StyleBlock’s CSS region and, if so, slices that text into a synthetic CSS TextDocument, calls ls.doComplete() at the cursor’s CSS-relative position, and maps the items to TuCompletionItem. tsserver/HTML-tag/ClassRef paths skip entirely while inside a style block — CSS context is exclusive. CSS hover and diagnostics are split out as a fresh open row.

Closed in M3.10

  • Tu-aware completion (HTML tags + ClassRefs) — landed: completionsAtTuPosition now augments tsserver’s results with two Tu-specific sources. (1) HTML tag names appear when the cursor is at expression-head — detected by tokenizing up to the cursor and checking that the previous token is one of {, (, ,, =, :, =>, or else. (2) Declared class names appear when the cursor sits right after a . inside a scoped component — found by parsing the source (with a placeholder-retry for incomplete forms like class: .) and looking up the host LetDecl’s style-block classes via the new getScopedClassMap export from @tu-lang/compiler. Existing tsserver-based completions stay; this only adds items, deduped by label. CSS-content completion inside style { … } is split out as a fresh open row for later.

Closed in M2.5 (empty-array widening)

  • let xs = [] inferred as Signal.State<never[]> — landed: codegen detects empty ArrayLit initializers (without an explicit type annotation) and emits new Signal.State<any[]>(…) / new Signal.Computed<any[]>(…) so subsequent .set(["a", "b"]) and for item in xs calls don’t trip on never inference. Explicit annotations (let xs: number[] = []) take precedence — the annotation fully determines the type.

Closed in M4 V1

  • Client-side hydrate(thunk, container) — landed: hydrate adopted existing DOM children instead of creating new ones, and later moved with mount into the browser-only @tu-lang/dom package. The first render keeps the same <button> / <input> instance pre- and post-hydrate, so focus / scroll / <input>.value survives the server-to-client handoff. Event listeners (which SSR can’t serialize) and DOM-property props (value, checked, selected) are applied during the walk. Hydration splits any Text node that SSR fused from adjacent text vnodes (e.g. "count = " count → one Text “count = 0” → two Text nodes) so future cell mutations only touch the dynamic tail. Whitespace-only text nodes between elements (incidental from pretty-printed HTML) are skipped. Structural mismatches log hydration mismatch warnings and fall back to materializing the offending vnode. A new examples/ssr/ runs the full round-trip (renderToString → jsdom-with-pre-stamped-HTML → hydrate → simulated clicks) end-to-end. Pre-resumability — Qwik-style “no first-frame thunk re-execution” is deferred to a later M4 milestone.

Closed in M2.5

  • Array literals [a, b, c] — landed: parser recognizes [ in expression position as an ArrayLit (the [ / ] tokens that M2.4 added for type spans now do double duty for value syntax). AST walkers (class-ref + style-block scanners) descend into elements. Codegen emits the JS-equivalent literal verbatim. Todo.tu now owns its empty/one/three-items controls via three private lambdas that build fresh items arrays inline — playground’s external controls block deleted, narrowing M1.14’s scope and closing the “Todo.tu needs its own controls” row.

Closed in M3.9

  • Synthesize component-prop interfaces in TS emit — landed: codegen now emits export interface ${Name}Props { p1: T1; p2: T2 } immediately before each exported lambda whose params are all typed. Lambdas with zero params, any untyped param, or non-exported scope are skipped — no fictional unknown fields. JS-mode emission stays unchanged. Verified end-to-end via the Greeting.tu example: export interface GreetingProps { name: string } now precedes the const, giving downstream .d.ts consumers a named, reusable shape.

Closed in M2.4

  • Type vs value namespace + type aliases (type X = …) — landed: type X = … and export type X = … now parse as top-level declarations. type is a contextual keyword (only triggers when followed by Ident = at statement boundary), so users keep the freedom to name a value type (lambda param, let-decl name, etc.) without conflict. The RHS is captured as a raw source slice and emitted verbatim into the TS shadow; JS mode erases the alias entirely. Lexer gained [, ], |, &, ; tokens — currently consumed only inside raw-type spans, but available to future expression syntax. Verified let count: Counter = 0 (using a previously-declared alias) and round-trips through tsserver. Type-namespace question dissolves automatically: TS already keeps type and const separate.

Closed in M2.3

  • Imported names are always classified as functions in codegen — landed: compileWithMap / compileToTSWithMap accept an optional importedNameKinds: Map<string, CellKind> so the caller can tell codegen which imported names are state / computed / function cells. Imports without an explicit kind still default to 'function', preserving the standalone-compile path. The LSP’s shadow graph and the @tu-lang/vite plugin both build this map by AST-classifying each .tu neighbor’s export let bindings; the M2.1 reactivity bug (importing a Signal.State silently dropped .get() injection) is fixed for both flows. Multi-hop re-export chains still fall through to 'function' — small follow-up.

Closed in M1.14

  • Counter.tu owns its buttons — landed: Counter.tu now declares private inc / dec / reset lambdas and wires them via onClick: props on three buttons inside the component body. The playground’s external controls: () => [...] block was deleted. The SSR run.mjs continues to demonstrate external mutation (mod.count.set(5) from JS), so both interactive and out-of-band paths are exercised. Todo.tu is left externally-driven for now — without Tu array literals, the .tu source can’t build a fresh items list in-place; tracked in a narrowed open row.

Closed in M1.15

  • LIS-based move minimization in keyed reorder — landed: patchChildren replaces the forward insertBefore pass with a patience-sort longest-increasing-subsequence over newToOld[]. Items whose old indices form an increasing subsequence (the stable middle) skip movement; the position pass walks right-to-left and insertBefores only the others. Verified with a regression test: swapping list endpoints [A B C D E] → [E B C D A] was 4 moves before, exactly 2 (A and E) now. Same algorithm as Vue 3 / Inferno, O(n log n).

Closed in M1.13

  • :global(.foo) escape hatch for unscoped selectors — landed: scanner skips class tokens inside :global(...) regions (depth-tracked across nested parens) so they’re never registered as “declared” classes for the component’s hash. The rewriter strips the wrapper itself, emitting .legacy (unhashed) where the source had :global(.legacy). Compound selectors mix freely: .card :global(.icon) becomes .card-tu-h .icon.

Closed in M2.2

  • Annotated let X: type = … declarations — landed: parser captures the raw source slice between : and = (depth-tracked across () / {} / <…> so generic args don’t terminate the type early). The slice is plumbed through LetDecl.type and emitted by codegen in TS mode only — JS mode continues to erase types. Wrapping rules match the value: lambdas pass the annotation through to the const directly, state cells emit Signal.State<T>, computed cells emit Signal.Computed<T>. Lets users override TS inference for opaque cells (let total: BigDecimal = computed(...)) and document component shapes (let App: () => VNode = …).

Closed in M1.12

  • Multi-class pug shorthand .foo.bar() — landed: parser greedily consumes a .foo.bar.baz chain. Single-ref → ClassRef (unchanged). Multi-ref → a +-chain interleaved with " " StringLits, so codegen emits (("foo-tu-h" + " ") + "bar-tu-h") and the runtime sees a space-joined class string. Same chain works in non-shorthand position too: class: .foo.bar is now valid.
  • Pug-shorthand tag override .foo(tag: "section") — landed: the tag: prop is special-cased inside parsePugShorthandTail — extracted from the args, validated as a StringLit, and used as the synthetic TagCall’s tag. Default stays div when omitted. The tag: prop is consumed (never emitted as an HTML attribute) and a non-literal value (e.g. tag: someExpr) throws a parse error since the tag is a compile-time decision.

Closed in M3.8

  • LSP V2: rename — landed: renameAtTuPosition calls LanguageService.findRenameLocations and groups the results by fileName, mapping each TS textSpan back through the target shadow’s tokenMappings so cross-.tu references receive the same edit as the local declaration. The new identifier is validated against Tu’s identifier rules (/^[A-Za-z_$][A-Za-z0-9_$]*$/) before any TS work, so a malformed rename never produces broken sources. LSP server advertises renameProvider: true and assembles WorkspaceEdit { changes } from the per-file edit groups. Verified end-to-end: renaming count rewrites both decl and read; renaming Card from a call-site rewrites the import + call in App.tu AND the declaration in Card.tu.

Closed in M3.7

  • LSP hover: cache LanguageService across hovers — landed: new packages/lsp/src/lsp-session.ts owns a single-slot cache keyed by (rootSource, rootFilename) plus a snapshot of every transitively-imported .tu file’s mtime. Hover / completion / definition all delegate through getOrCreateSession instead of building a fresh ts.LanguageService each call; the duplicate createLsHost was deleted from each surface. Cache invalidates when the root text changes, the filename changes, or any imported file’s mtime advances. Disposal hook (disposeSessionCache) keeps tests isolated. The interactive loop (hover → click another ident → completion → goto-def) now reuses one TS Program.

Closed in M3.6

  • tu check CLI command — landed: tu check <file…> in @tu-lang/cli calls checkTuFile from @tu-lang/lsp and pretty-prints each diagnostic as path:line:col: SEVERITY [TS####] message followed by a 3-line code frame with ^^^ carets sized by the source-byte token range from M3.2. Empty input, non-.tu extension, missing files, and any error-severity diagnostic exit 1; clean files print a one-line tu check: N file(s) OK summary and exit 0. The CLI logic is exposed as runCheck(args, options) from @tu-lang/cli so the test suite drives it without spawning a subprocess.

Closed in M3.5

  • LSP V2: goto-definition — landed: same LanguageService + reverse-mapping infrastructure as hover/completion. New definitionAtTuPosition calls getDefinitionAtPosition, then maps each TS DefinitionInfo back through the target shadow’s tokenMappings (the definition might live in a different .tu file when crossing imports). Definitions whose fileName falls outside the shadow graph (e.g. @tu-lang/runtime’s .d.ts) are dropped — we don’t surface internal .ts files as a goto target. LSP server now advertises definitionProvider: true. Verified end-to-end: jumping from a count read to its let count = 0 lands on cols 11…15 of line 0; jumping from a cross-file Card("hi") call lands on the Card ident in Card.tu.

Closed in M3.4

  • LSP V2: completion — landed: completionsAtTuPosition reuses the shadow graph + LanguageService and calls getCompletionsAtPosition. The reverse mapping was extended with an inclusiveEnd flag so cursors sitting at exactly srcEnd of an identifier (the typical case while typing) still resolve — the cap on the interior offset goes from jsWidth - 1 (strict) to jsWidth (inclusive). LSP server advertises completionProvider: { resolveProvider: false } and maps TS ScriptElementKind → LSP CompletionItemKind. Verified that previously-declared idents, typed lambda params, and cross-.tu imported names all surface in the completion list.

Closed in M3.3

  • LSP V2: hover (type / docs at cursor) — landed: built on M3.2’s TokenMapping[]. New mapSourceLineColToTS reverse-maps a (line, col) cursor in .tu to a TS byte offset using the same tightest-token algorithm as the diagnostic forward path. Shared shadow-graph helpers (buildShadowGraph, tuPathToTs, getTuCompilerOptions, Shadow) extracted to packages/lsp/src/shadow-graph.ts so both checkTuSource and the new hoverAtTuPosition use one BFS + one set of compiler options. Hover spins up a ts.LanguageService (one-shot per call) backed by the shadow graph and calls getQuickInfoAtPosition; results are formatted as Markdown-fenced TypeScript with optional JSDoc body. The originating source token’s range — not quickInfo.textSpan — drives the hover range, so hovering on count underlines exactly count, not the surrounding statement. LSP server now advertises hoverProvider: true and routes connection.onHover through. Whitespace, Tu keywords (let, if), and punctuation (=, =>) gracefully return null because no TokenMapping covers them.

Closed in M3.2

  • LSP V2: token-level diagnostic ranges — landed: every AST node now carries start / end byte offsets (plus per-feature anchors like nameStart / tagStart / calleeStart). Codegen was refactored from string-returning emit to a streaming buffer that records a TokenMapping { jsStart, jsEnd, srcStart, srcEnd } for each emitted leaf token (idents, literals, callee names, param names, class refs). compileToTSWithMap returns the full TokenMapping[] alongside the V3 map; the LSP’s mapTSRangeToSource finds the tightest TokenMapping containing the diagnostic’s TS span and uses its source range. Squiggles now bracket the offending token (e.g. just 42 for an arg-type mismatch, or "not a number" for a state-cell assign) instead of the whole let header. Per-statement mapping remains as the fallback for diagnostics that land inside synthetic emit (.get(), runtime import). Same plumbing also enables the M3 hover work — every source token has a known TS counterpart now.
  • Token-level (vs per-statement) source maps — landed as a side-effect of token-level diagnostics: buildV3Map now folds the same TokenMapping[] into the V3 mappings field as additional segments, so browser stack traces (and any tool that consumes the standard source map) resolve to the precise source token’s start. Per-statement segments remain as anchors for emit regions that have no token (synthetic .get(), runtime import, control-flow scaffolding).

Closed in M3.1

  • LSP V2: cross-.tu import resolution — landed: checkTuSource now BFS-walks the import graph from the root file. Every reachable .tu is compiled to a TS shadow and registered in the CompilerHost’s virtual fs, so tsserver resolves cross-.tu imports as if they were native .ts files. Compiler options gain allowImportingTsExtensions: true because Tu’s codegen rewrites ./Foo.tu./Foo.ts in the shadow. Cycles tolerated via seen-set. In-memory edits to NON-root files are not yet seen — those still come from disk; lifting this restriction is small follow-up work for incremental multi-doc editing.

Closed in M3 V1

  • LSP — diagnostics-only V1 — landed: @tu-lang/lsp package boots a vscode-languageserver server; on document events it runs compileToTS() + the in-process TypeScript Compiler API and publishes diagnostics with positions mapped back to .tu line/col via the embedded V3 source map. vscode-tu activates the client on onLanguage:tu. End-to-end smoke-tested: a typed-param mismatch (G(42) against (name: string) =>) produces TS error 2345 mapped to the right .tu line. Hover / completion / goto-definition are all V2 work tracked in new rows above.

Closed in M2.1

  • Cross-.tu import { X } from "./other.tu" — landed: lexer + parser recognize import { … } from "…", codegen emits the ESM line verbatim (and rewrites .tu.ts in the TS shadow). Imported names are classified as function so reads emit as plain idents (no .get()). Verified end-to-end via examples/scoped split into 3 files (Scoped.tu imports RedCard.tu + BlueCard.tu) — real browser shows the two cards each with their own M1.8 hash.
  • export { X } from "./other.tu" re-exports — landed alongside imports.
  • Cross-.tu import follow-through in TS shadow — landed: compileToTS rewrites .tu source paths to .ts so tsserver resolves the sibling shadow.

Closed in M2

  • Type system via TypeScript (Volar pattern) — V1 — landed: compileToTS(source, options) emits TypeScript with preserved lambda param type annotations. tsserver INFERS the rest from the existing JS shape (new Signal.State(0)Signal.State<number>, lambdas → return-typed-from-body, etc.). The .d.ts emit (tsc --emitDeclarationOnly over the shadow) reflects M1.10’s public-surface decisions exactly: only export let bindings appear. New rows above track the post-V1 polish (component-prop interfaces, style-class literal-type unions, annotated let X: type, tu check CLI, cross-.tu import follow-through).
  • mount() bug: stop() didn’t clean up DOM — the playground sidebar accumulated stale subtrees when switching demos. Fix landed in the runtime alongside M2: stop() now removes the mount’s own DOM children (sibling DOM in the container is left untouched). Two new regression tests cover this.

Closed in M1.11

  • Remove match — landed: dropped TokenKind.Match + Underscore, MatchExpr / MatchArm / MatchPattern AST nodes, parser branches, codegen emit + AST walkers. examples/todo’s pluralized label rewrites cleanly as a chained if/else if/else. The new feedback_avoid_tc39_conflicts.md rule (saved alongside this milestone) is now the gate for any future sugar — check TC39 stage-2/3 first.

Closed in M1.10

  • Module visibility design (export/pub/default) — landed: bare let is module-private, export let is public. Parser accepts an optional export prefix on let-decls; codegen continues to honor the exported flag. New rows above track the still-open module work (cross-.tu import, re-exports, default export, type namespace).

Closed in M1.9

  • Source maps from compiled JS back to .tu source — V3 source map (per-top-level-statement) emitted both as inline data-URL footer and as the map field returned by compileWithMap / the @tu-lang/vite load hook. Token-level granularity tracked as a new row above.
  • Better error messages with source location + caretformatError helper used by lexer + parser produces file:line:col plus a 3-line code-frame caret. Threaded via compile(src, { filename }) and through @tu-lang/vite so Vite’s overlay shows the formatted message.

Closed in M1.8

These rows tracked the pre-M1.8 scoping gap; M1.8 fills them and they are removed from Open. Kept here briefly so the closure shows in commit diffs:

  • Style scoping (auto class hash or [data-tu-…] attribute rewrite) — landed via per-component FNV-1a hash + CSS rewrite
  • Symbolic class ref .card + pug-style .card() {…} shorthand — landed
  • let style = … user variable name conflicts with style { … } block — verified safe in current lexer (no { after style-as-RHS triggers CSS mode); covered by behaviour rather than syntax change