diff --git a/docs/AGENTS.md b/docs/AGENTS.md index dc06018c16..27db1d88d7 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -27,7 +27,7 @@ Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; t - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems. - **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. -- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc are fenced ` ```ts type-equiv ` and registered in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). +- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses its `public-api` variant; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dfb59b3ac6..732b6d9599 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -36,9 +36,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | -> Type declarations and their JSDoc on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). - -FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog. +> Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services use the generated [service catalog](../cordis-catalog/services.md). ## The `…Map → derived-union` pattern diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 0ef9ae02f7..b27c21a69c 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -91,10 +91,79 @@ interface TokenUsage { `BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s, usage, finish reason, and replay state. The loop logs the raw chunks while feeding the same chunks through an assembler, then stores the assembled assistant content with its provider/model provenance. A consumer that needs the assembled result without re-implementing the fold uses this. +```ts type-equiv public-api +/** + * Incrementally assembles raw {@link StreamChunk}s into complete + * {@link ContentBlock}s and a final assistant {@link Message}. + * + * The agent loop feeds it while logging raw chunks for replay fidelity, then + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * + * Tolerant of delta-only protocols (no block-start/end); deltas arriving for + * an index already closed by `block-end` are ignored (malformed stream) so a + * misbehaving adapter cannot grow memory or corrupt a completed block. + */ +declare class BlockAssembler { + /** + * Feed one chunk into the assembly state. + * @param chunk - the next raw chunk, in stream order. + */ + push(chunk: StreamChunk): void; + /** + * Assemble all blocks seen so far, in stream order. + * @returns one block per seen index; an open block assembles from its + * accumulated deltas (an unknown block type never closed by `block-end` throws). + */ + blocks(): ContentBlock[]; + /** Usage from the `usage` chunk; undefined until one arrives. */ + get usage(): TokenUsage | undefined; + /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ + get finish(): FinishReason; + /** Adapter-private replay state from the terminal finish chunk, if any. */ + get replayState(): unknown; + /** + * The assembled assistant message. + * @returns an assistant-role message over `blocks()` (same open-block assembly rules). + */ + message(): Message; +} +``` + ## The seam `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +```ts type-equiv public-api +/** + * Provider-wire adapter for the harness message and stream vocabulary. Register implementations + * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled + * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + */ +declare abstract class LlmAdapter { + /** + * Describe one provider route owned by this adapter. + * @param provider - a route passed to `registerAdapter()` for this instance. + * @returns detached display metadata whose id must equal `provider`. + */ + providerInfo(provider: string): LlmProviderInfo; + /** + * List models this adapter can currently advertise for one owned provider. + * The result is advisory: an adapter may accept unlisted model ids, and + * consumers must not turn absence into request rejection. + * @param _provider - one provider route owned by this adapter. + * @returns discoverable models in adapter-preferred order. + */ + listModels(_provider: string): Promise; + /** + * Stream one model call as raw chunks. The only required method. + * @param options - the fully-assembled request; implementations must honor `options.signal`. + * @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`. + */ + abstract stream(options: GenerateOptions): AsyncIterable; +} +``` + `ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`: ```ts type-equiv diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 3d3fca045a..1ece65c1c3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -305,6 +305,126 @@ interface SurfaceFoldResult { } ``` +## `Session` public API + +The body-stripped declaration keeps the plain class's public constructor, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore). + +```ts type-equiv public-api +/** + * An event-sourced session: an append-only log of {@link SessionEvent}s. + * + * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Seeding with an existing event log replays/forks a session. + */ +declare class Session { + /** The ordered surface over this session's event log. */ + get surface(): SessionSurface; + /** + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so + * `session.header` is always present. Kept out of the event log — it is a + * storage concern, not replayable conversation state. + */ + readonly header: SessionHeader; + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId; + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); + /** + * An immutable snapshot of the append-only event log. The snapshot is reused + * until the next append; a previously returned array does not grow later. + * Events and their nested data are deep-frozen at acceptance, so neither a + * cast nor ordinary JavaScript can rewrite durable history. + */ + get events(): readonly SessionEvent[]; + /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ + get seq(): number; + /** + * Append one typed event to the log and synchronously notify observers via + * the store-owned, module-private publication hooks. The hot path never blocks + * on I/O — persistence plugins buffer asynchronously. Once the event enters + * the log, the append is committed: observer failures are logged and + * contained per listener, so they do not change the return value or prevent + * later listeners from observing the same accepted event. + * + * @param type - The event type (key of {@link SessionEventMap}). + * @param data - The event payload; must be JSON-serializable. + * @param opts - Surface metadata: `surfaceOp` controls how the event enters + * the ordered surface; `sourceEventSeqs` records provenance (the seq + * numbers of events this one derives from). REQUIRED for + * {@link SurfaceEventType} events (every message-producing event must + * declare how it joins the surface, the sole source of derived history) and + * rejected by the compiler for non-surface types like `turn/start` or + * `assistant/chunk`. + * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of + * `data` that entered the log, so reading `event.data` back sees the logged + * value, never the caller's still-mutable input. + * @throws if `data` or surface metadata is not losslessly JSON-serializable + * (BigInt, function, symbol, undefined, negative zero, non-finite number, + * circular reference, sparse array, or an exotic object such as + * Map/Set/Date/class instance), or when the candidate violates the + * canonical surface contract (marker shape and eligibility, unique + * earlier provenance, positional replacement validity, and complete + * shadowed-node coverage). One recursive pass reads, validates, and + * copies each nested value once, so a stateful getter cannot supply one value + * to validation and another to storage. The event log is the durable source + * of truth, so a bad event fails at the append site rather than later during + * a backend flush. A synchronous internal dispatch validation failure or an + * append reentered while this acceptance/publication boundary is open also + * rejects before the log changes. + */ + append( + type: T, + data: SessionEventMap[T], + ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] + ): SessionEvent; + /** + * The {@link EpochHeader} in force after the log's last header event — the + * header the NEXT request will be compared against — or undefined before + * the first `request/header` snapshot. The live, incrementally-maintained + * form of `foldRequestHeader(session.events)`: each header event is folded + * once, when first seen, so a per-step read costs O(new events). + * @returns the folded header, or undefined when no header event exists yet. + */ + requestHeader(): EpochHeader | undefined; + /** + * Derive the LLM message history by walking the ordered sequences of + * message-producing events maintained by `surfaceOp` markers. The + * surface is the single source of derived history: every message-producing + * append records its `surfaceOp`, so a raw event with no marker (a chunk, a + * turn boundary) is correctly absent, and a compaction `replace` deletes the + * shadowed nodes from the derivation. The projection rules are + * {@link deriveEventMessage}, folded per node. + * + * CACHED: each surface node is projected exactly once, when first seen — a + * call costs O(new nodes), and a surface rewrite (a `replace`; + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * a fresh snapshot per call (later appends never grow an array a caller + * already holds); the `Message` objects in it are SHARED and **deep-frozen**. + * Their content reuses the already frozen durable event data, so the cache + * needs no second deep clone and consumers still cannot mutate the log. + * @returns a fresh array of the shared, frozen derived history. + */ + deriveMessages(): Message[]; + /** + * Project a single event into the LLM message it derives to, or null when + * it produces none — a non-surface event (chunk, boundary, log-only record) + * or an empty-content assistant/message (which exists only to host usage). + * The per-node pure function {@link deriveMessages} folds over the surface; + * an external reconstructor (or the dev invariant) folds the same function + * over a log prefix's surface to rebuild the exact messages any request was + * built from (the reconstructability RFC). The returned message wrapper is + * fresh; its content reuses the logged event's already deep-frozen durable + * data, so changing the wrapper cannot rewrite the log and changing content + * throws. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. + */ + deriveEventMessage(event: SessionEvent): Message | null; +} +``` + ## Derived history: `deriveMessages()` and `deriveEventMessage()` `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index e5b3feba33..e41578431b 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 37811f7215001fc371ac4943fe109dd5512ea8b0 -development.zh.md: 5036e3e75516fcaf063675fc9ab4e63c1fca851a +development.md: 204b50d7733ba18aa6bac1ae714ad09972f3db25 +development.zh.md: 11a844fa6939aa0894d0657d207c5082f5406732 diff --git a/docs/development.md b/docs/development.md index 37811f7215..204b50d773 100644 --- a/docs/development.md +++ b/docs/development.md @@ -145,13 +145,13 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel ## Documenting types verbatim (`ts type-equiv`) -The [core data structures](core-data-structures/core.md) docs paste real type declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: +The [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts type-equiv public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both variants (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. ## Architecture context diff --git a/docs/development.zh.md b/docs/development.zh.md index 5036e3e755..11a844fa69 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -145,13 +145,13 @@ pnpm run demo:acp ## 逐字记录类型(`ts type-equiv`) -[核心数据结构](core-data-structures/core.md)文档会把真实类型声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: +[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: ```json { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts type-equiv public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种变体(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 ## 架构上下文 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index bf70879c71..974a19a621 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -29,8 +29,8 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei The durability requirement was specific: the doc shows the **literal** current type declaration and original JSDoc (so a reader sees the real shape and source contract, not a paraphrase) **and** is mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability* — a renamed field or changed JSDoc can pass. So: -- Type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch. -- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. +- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A `public-api` variant carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches. +- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. - Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. - Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates. @@ -52,7 +52,7 @@ The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and de ## Consequences -- The vocabulary now has a single home that **cannot silently drift**: a field rename in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. +- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here. - The spine-vs-seam line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering. - The `ts type-equiv` fence is a third doc-block category alongside ` ```ts ` (compiled) and ` ```ts ignore-check ` (sketch). A later sibling added a fourth, ` ```ts cordis-catalog ` (generated signature), reusing the same skip-and-exclude treatment. - Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing *new* type), backstopped by the `dsh-code-review` checklist. diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 7549ee982b..c4d6c04a35 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -33,6 +33,7 @@ const KIND_BY_INFO: Record = { 'ts': 'check', 'ts ignore-check': 'ignore', 'ts type-equiv': 'type-equiv', + 'ts type-equiv public-api': 'type-equiv', 'ts cordis-catalog': 'cordis-catalog', 'ts persistence-catalog': 'persistence-catalog', 'ts config-catalog': 'config-catalog', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 566bdade7b..09888916d0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,5 +1,5 @@ { - "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source declaration and original JSDoc it must match. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", + "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Omit projection for the complete declaration; use public-api with a ` ```ts type-equiv public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, @@ -33,6 +33,8 @@ { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "BlockAssembler", "source": "packages/llm/llm/src/assembler.ts", "projection": "public-api" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "LlmAdapter", "source": "packages/llm/llm/src/index.ts", "projection": "public-api" }, { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" }, { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceNode", "source": "packages/llm/token-meter/src/types.ts" }, @@ -50,6 +52,7 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SessionSurface", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "Session", "source": "packages/core/session/src/index.ts", "projection": "public-api" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 585796206c..58affaffca 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -1,8 +1,10 @@ /** * Verify every `ts type-equiv` block against the source symbol named by the - * manifest. Blocks and entries have a one-to-one relationship; comparison - * ignores whitespace and non-JSDoc comments but preserves declaration - * structure and every original JSDoc comment. + * manifest. Ordinary entries preserve the complete declaration; `public-api` + * entries preserve a class's body-stripped public declaration. Blocks and + * entries have a one-to-one relationship; comparison ignores whitespace and + * non-JSDoc comments but preserves declaration structure and every original + * JSDoc comment. */ import { globSync, readFileSync, existsSync } from 'node:fs' @@ -22,6 +24,8 @@ interface ManifestEntry { symbol: string /** Source file (repo-relative) that exports the symbol. */ source: string + /** Complete declaration (default), or a body-stripped public class API. */ + projection?: 'public-api' } /** One extracted ` ```ts type-equiv ` block. */ @@ -31,6 +35,8 @@ interface EquivBlock { line: number /** Symbol name parsed from the block's declaration. */ symbol: string + /** Complete declaration (default), or a body-stripped public class API. */ + projection?: 'public-api' /** Block body (the pasted declaration). */ code: string } @@ -75,7 +81,7 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { const text = readFileSync(resolve(root, docRel), 'utf8') const lines = text.split('\n') const blocks: EquivBlock[] = [] - let open: { line: number; body: string[] } | null = null + let open: { line: number; body: string[]; projection?: 'public-api' } | null = null for (let i = 0; i < lines.length; i++) { const raw = lines[i] ?? '' @@ -90,11 +96,19 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { if (!symbol) { throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`) } - blocks.push({ doc: docRel, line: open.line, symbol, code }) + blocks.push({ + doc: docRel, + line: open.line, + symbol, + code, + ...(open.projection === undefined ? {} : { projection: open.projection }), + }) open = null continue } - if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] } + const info = (fence[2] ?? '').trim() + if (info === 'ts type-equiv') open = { line: i + 1, body: [] } + if (info === 'ts type-equiv public-api') open = { line: i + 1, body: [], projection: 'public-api' } } if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`) return blocks @@ -127,13 +141,77 @@ function sourceDeclaration(sourceRel: string, symbol: string): string | null { return null } +/** Leading source JSDoc attached to one declaration or member. */ +function sourceJSDoc(text: string, node: ts.Node): string { + return ts.getJSDocCommentsAndTags(node) + .filter(ts.isJSDoc) + .map(doc => text.slice(doc.pos, doc.end)) + .join('\n') +} + +/** Whether a class member is part of its public declaration. */ +function isPublicMember(member: ts.ClassElement): boolean { + if (ts.isClassStaticBlockDeclaration(member)) return false + const name = ts.getNameOfDeclaration(member) + if (name && ts.isPrivateIdentifier(name)) return false + const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined + return !(modifiers?.some(modifier => + modifier.kind === ts.SyntaxKind.PrivateKeyword + || modifier.kind === ts.SyntaxKind.ProtectedKeyword, + ) ?? false) +} + +/** Remove an implementation body while retaining the source signature. */ +function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement): string { + const start = member.getStart(sf) + let end = member.end + if (ts.isConstructorDeclaration(member) || ts.isMethodDeclaration(member) + || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) { + if (member.body) end = member.body.getStart(sf) + } + if (ts.isPropertyDeclaration(member) && member.initializer) end = member.initializer.getStart(sf) + const signature = text.slice(start, end).trimEnd().replace(/;$/, '').replace(/=\s*$/, '').trimEnd() + return `${signature};` +} + +/** + * Render a class as an ambient declaration containing only its public fields, + * constructor, accessors, and methods. Implementation bodies and private or + * protected members are deliberately absent; original class/member JSDoc is + * retained so the projection is the source-owned public contract. + */ +function sourcePublicApi(sourceRel: string, symbol: string): string | null { + const abs = resolve(root, sourceRel) + const text = readFileSync(abs, 'utf8') + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + for (const stmt of sf.statements) { + if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue + const classDoc = sourceJSDoc(text, stmt) + const abstract = stmt.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ? 'abstract ' : '' + const typeParameters = stmt.typeParameters?.map(parameter => parameter.getText(sf)).join(', ') + const heritage = stmt.heritageClauses?.map(clause => clause.getText(sf)).join(' ') + const header = `declare ${abstract}class ${symbol}${typeParameters ? `<${typeParameters}>` : ''}${heritage ? ` ${heritage}` : ''} {` + const members = stmt.members + .filter(isPublicMember) + .map((member) => { + const jsDoc = sourceJSDoc(text, member) + const declaration = bodylessMember(text, sf, member) + return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}` + }) + const declaration = [header, ...members.map(member => member.split('\n').map(line => ` ${line}`).join('\n')), '}'].join('\n') + return classDoc === '' ? declaration : `${classDoc}\n${declaration}` + } + return null +} + const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8') const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] } const entries = manifest.entries -// Key a block/entry by doc + symbol (a symbol may be documented in more than one -// doc, but at most once per doc). -const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}` +// Key a block/entry by doc + symbol + projection. A symbol may be documented in +// more than one doc, and a doc may carry both complete and projected forms. +const keyOf = (x: { doc: string; symbol: string; projection?: 'public-api' }): string => + `${x.doc}::${x.symbol}::${x.projection ?? 'declaration'}` // Collect every type-equiv block across ALL docs in scope — not only the docs // the manifest names — so a block in an unmanifested doc is found and reported @@ -152,7 +230,7 @@ for (const d of [...new Set(entries.map(e => e.doc))]) { else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`) } -// Duplicate-block guard: the same symbol twice in one doc is ambiguous. +// Duplicate-block guard: the same projected symbol twice in one doc is ambiguous. const blockByKey = new Map() for (const b of blocks) { const k = keyOf(b) @@ -192,7 +270,9 @@ let verified = 0 for (const e of entries) { const b = blockByKey.get(keyOf(e)) if (!b) continue // already reported as an orphan entry - const decl = sourceDeclaration(e.source, e.symbol) + const decl = e.projection === 'public-api' + ? sourcePublicApi(e.source, e.symbol) + : sourceDeclaration(e.source, e.symbol) if (decl === null) { errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`) continue