01ecb43ebc
apps/web/tests/README.md records why these e2e type-check in the Host aggregate and why importing a Client package there pulls its project tree into the Host build graph, with mirroring as the standing answer. The Agent Note drops the directory-picker face split (assessed and declined) and the grep-level gate in favour of that README. docs: regenerate the catalogs and retarget the moved declarations The forwarded-event change moved three owner packages' cordis `Events` declarations and their branded types into client-safe `./types` modules, and the settings-scope split moves the shell spec into ui-settings-general. Point the type-equivalence manifest and the affected Agent Note at those homes, register the new `remote/*` event scope and the `ctx.settingsScope` service in the catalog partition, and re-run the generators. `$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note fences that quote a bare member signature are marked `ignore-check`: they are declaration fragments, not compilable units. refactor(client): make ui-settings the settings domain's base layer The settings-namespace transport lived in client/runtime, where every feature could value-import it because runtime is a platform module. It belongs to the settings domain, but moving it into ui-settings as a shared function fails twice: the client bundle purity gate forbids cross-plugin value imports, and ui-settings reached ui-sidebar for its shell, so any feature depending on it closed a cycle through ui-layout and ui-theme. Both halves move. `ctx.settingsScope` is now a cordis service — the collaboration shape the purity gate prescribes, and the service proxy binds `this.ctx` to the caller, so a bound scope's disposer belongs to the calling fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its navigation, and the nav-row projection) moves to ui-settings-general, which already owns the chrome and the General section. What stays in ui-settings is what carries no `ui-*` dependency: the scope service and the canonical settings slot types, `settings.general.item` included. That type was parked in the locale package precisely because the declarer was unreachable without a cycle; every registrant now depends on this base layer, so it comes home. The scope CONTRACT stays in client/runtime: a feature service accepts a scope through its own signature without depending on the surface that binds it. The forwarded settings invalidation replaces the deleted client-side `settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches `$on` through the gateway's Client half plus the allowlist's type-only subpath rather than api-remotes' Client face: that face imports a Host-tsdown-generated artifact, and this package is reachable from the Host build graph through its callers. refactor(client): reach the settings transport through ctx.settingsScope Every feature that owns a preference row switches from value-importing a shared binder to the settings domain's service, and declares the two injections that binding needs: `settingsScope` for the transport and `remote` for the forwarded invalidation it subscribes to on the caller's own context. The rows stay with the features that own the preferences — Language with locale, Appearance with ui-theme, Composer Enter with ui-conversation. Only their route to the transport changes, so no settings surface moves and no feature gains a dependency on the shell. The `settings.general.item` slot type now arrives from ui-settings, the base layer every registrant already depends on, which retires the re-export outlet ui-theme kept and the parked declaration in the locale package. client/runtime drops its settings-form and schemastery dependencies with the transport that used them. test(client): bind the settings transport in the specs that boot a preference row Every bench that activates a plugin owning a preference row now supplies the two services that plugin injects: the forwarded-event port and the scope service. Specs that exercise no settings path get the minimal doubles; the ones that do drive their refresh chains through `remote/host-event`, the same signal client/runtime republishes from a forwarded frame, replacing the deleted client-side `settings/changed` event. Also fixes a publication defect the built-invariant gate catches once it runs: api-remotes' invariant companion shared the allowlist module with the package index, so rolldown hoisted it into a third chunk beside the two bundled entries — a file the mechanically derived publication list does not carry, leaving an installed companion unable to import it. The companion now reads the allowlist through this package's own published `./types` subpath, which the bundle keeps external, so each entry stays self-contained. The dynamic-subscription cast in apiproxy is gone: after the vendored cordis rescope, `on` accepts the rest-parameter handler directly, and the allowlist's shape assertion still carries the safety argument. fix(client): carry the settings-scope move across the release manifests Rebasing onto the publishable release set replaced every manifest's dependency block, so the packages this change touches restate their additions in the workspace-protocol form: the base layer's own transport dependencies, and the `ui-settings` plus `remote` edges each preference-row owner now needs. ui-settings-general takes clsx with the shell it received, and client/runtime drops the settings-form and schemastery dependencies that left with the transport. fix(api-gateway): give each $on subscription its own registration and containment Two defects in the forwarded-event subscription table, both raised in review: A set keyed on listener identity stored one entry when two callers subscribed the same function object to the same event, so the first frame reached it once instead of twice and either disposer silenced the surviving registration. Subscriptions are now records addressed by registration, which is what "the disposer belongs to the calling fiber" requires. A listener declared void may still be `async`, and the synchronous `try/catch` could not see its rejection: the promise was dropped and surfaced as an unhandled rejection outside the documented containment. Delivery now attaches a rejection handler when a listener returns a promise, so both failure modes are logged and isolated alike. Delivery also iterates a snapshot, so a listener that subscribes or disposes during a frame no longer changes who receives that frame, and production matches the TestRemote double instead of relying on live Set iteration order. Both fixes are pinned by tests that fail against the previous implementation. The double gains its own spec for the `$mount` refusal and the unsubscribed-name drop — per-file coverage reaches it — plus a note that it propagates a throwing listener where production contains one, so no spec mistakes it for the containment guarantee. Three prose corrections: `assertJsonArgs` states where its throw actually surfaces (the emitter's listener containment, not load or emit time), the browser e2e README names every standing Client import rather than claiming one exception, and two comments and a test title state the forwarded event instead of the deleted client-side one. refactor(remote): deliver forwarded frames through ctx.remote.$dispatch The carrier used to relay each decoded frame over an internal `remote/host-event` cordis event so the delivery port could stay off the Remote contract. The relay was the wrong shape twice over: it put a client-face event into a scan whose subject is the Host vocabulary, forcing a walk exemption for something that is not a Host event at all, and it made a direct handoff between two Client plugins look like a broadcast any plugin participates in. `TypeRTClientRemote` now carries both roles of one surface — consumers subscribe with `$on`, and whoever owns the Host frame sink hands frames over with `$dispatch` — so client/runtime calls the Remote service directly and the event declaration is gone. A cordis service method is the collaboration shape the client bundle purity gate prescribes, and it needs no relay to satisfy it. The trade is that the handoff is now developer-visible: any plugin holding `ctx.remote` can synthesize a forwarded event. That is the exposure the relay already had — `ctx.emit` was equally reachable — stated in the contract instead of hidden behind a private subscriber. runtime reaches `ctx.remote` through the gateway's Client face rather than api-remotes': that face imports a Host-tsdown-generated artifact, and this project sits in the Host build graph. refactor(api-remotes): keep the allowlist value out of types.ts `src/types.ts` carries only types by package convention, but it held the forwarded-event array, so the type-only subpath published runtime code. The array moves to `src/remote-events.ts` and `types.ts` derives its projection from it; both compiler faces list both files, so the Host forwarding loop and the consumer key face still read one declaration and the package's exports are unchanged. The invariant companion returns to an empty installer. Its dispatch-shape check was the only reason the companion imported the allowlist, which made the two bundled entries share a module: rolldown hoisted it into a third chunk that the mechanically derived publication list does not carry, so an installed companion could not import it. Dropping the check retires that coupling along with the subpath-import and bundle-external workarounds it needed, and the shape the check enforced at runtime is the part the Host face's `TypeRTForwardableEvent` assertion already refuses at compile time. test(ui-task): bind the locale plugin's new injections in its bench The bench boots the real locale plugin, which now injects the settings-scope service and the forwarded-event port, so it stayed pending and left `ctx.locale` undefined. Supplies both doubles like the other benches that boot a plugin owning a preference row. docs: close the documentation gates for the forwarded-event surface Regenerates the two graph catalogs and re-records every bilingual pair this branch edited. Several pairs needed real work beyond the record: - The generators write only the English side, so the Chinese sides of `event-producer-consumer` and `module-graph` had drifted: the former still listed the three deleted client-face events and pointed at declaration sites this branch moved into `types.ts` modules, and the latter carried a stale dependency graph. - `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides. - The pairing contract requires both sides to link the same target, so the apiproxy README and the design note now link the English note from both languages, and the note's code blocks are byte-identical across the pair (a translated comment inside a fence counts as divergence). - `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane documents a discipline reviewers apply, so it belongs in the bilingual corpus rather than in the pairing exemption list. - Four fences in the design note are marked `ignore-check`: each quotes a member signature, a union arm, or a snippet that names symbols it does not import, so none is a compilable unit. docs(agent-note): transition the forwarded-event note to implemented The design shipped in this PR, so the pair moves into `implemented/` and takes that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`, and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what pins the behavior) and `## Consequences` (what the shipped shape costs). Facts that moved after the proposal are corrected rather than preserved: the allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`, the delivery port is `$dispatch` rather than an internal cordis event, and the invariant companion is an explained empty installer. `Verification` states the two `$on` defects the review found — independent registration identity and async-rejection containment — since those are now the properties tests pin. Supersession is partial, so five active notes stay active and gain a cross-link each: `web-config-plane`, `web-client-session-scope`, `config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and `permission-default-for-new-sessions` each described a frame this change replaced. Only the mechanism sentence is annotated; every conclusion those notes own is untouched, and `host/models-changed` remains apiproxy's own derived frame in all of them. Also pins the disposer's idempotence: calling one `$on` disposer twice must not splice a surviving twin registration out from under its owner. fix: docs fix: test
861 lines
43 KiB
TypeScript
861 lines
43 KiB
TypeScript
/**
|
|
* Generate the per-subsystem Cordis service/event reference regions from the
|
|
* Typert catalog projection. Every harness `ctx.<key>` service and event scope
|
|
* maps to exactly one `docs/subsystems/` page through the curated tables below;
|
|
* the generator injects each page's Cordis API reference between its GENERATED markers —
|
|
* byte-identically into both language sides of the pair — and re-records a
|
|
* pair's `.i18n.yaml` only when nothing outside the region changed. The
|
|
* projection enforces event modes, JSDoc parameter/return completeness, and
|
|
* signature type-link coverage; the inherited (vendor) tier renders to
|
|
* `docs/cordis-api/inherited.md`. `--check` verifies every generated artifact.
|
|
*/
|
|
|
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { dirname, resolve } from 'node:path'
|
|
import {
|
|
projectCordisCatalog,
|
|
renderInheritedPage,
|
|
renderPageRegion,
|
|
REGION_BEGIN,
|
|
REGION_END,
|
|
} from '@deepseek-ai/dsh-typert-generator'
|
|
import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator'
|
|
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
|
|
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
|
import {
|
|
blobHash,
|
|
parsePairMeta,
|
|
partitionGeneratedRegions,
|
|
renderPairMeta,
|
|
} from './translation-pairing.ts'
|
|
|
|
const root = resolve(import.meta.dirname, '..')
|
|
const SUBSYSTEMS_DIR = 'docs/subsystems'
|
|
const OUT_INHERITED = 'docs/cordis-api/inherited.md'
|
|
const OUT_RUNTIME_API = 'packages/self-modification/tool-cordis/src/api-catalog.ts'
|
|
|
|
export { REGION_BEGIN, REGION_END }
|
|
|
|
/**
|
|
* The owning subsystems page for every harness `ctx.<key>` service the
|
|
* projection discovers. Fail-closed both ways: a discovered key absent here
|
|
* and an entry whose key the projection no longer discovers are both hard
|
|
* errors, so the partition can never silently drift from the service API.
|
|
*/
|
|
export const SERVICE_PAGE: Record<string, string> = {
|
|
agentLoop: 'core.md',
|
|
agentDefaultModel: 'core.md',
|
|
agentPresets: 'core.md',
|
|
agents: 'core.md',
|
|
approval: 'approval.md',
|
|
attachments: 'attachment.md',
|
|
bash: 'bash.md',
|
|
bashEnv: 'bash.md',
|
|
clientModuleHost: 'client-modules.md',
|
|
codeRuntime: 'code-runtime.md',
|
|
commands: 'commands.md',
|
|
compact: 'compaction.md',
|
|
credentials: 'credentials.md',
|
|
directoryPicker: 'workspace.md',
|
|
e2b: 'subprocess.md',
|
|
fs: 'filesystem.md',
|
|
goals: 'goal.md',
|
|
httpServer: 'http-server.md',
|
|
invariants: 'invariants.md',
|
|
llm: 'llm-streaming.md',
|
|
messageFeedback: 'feedback.md',
|
|
permission: 'permission.md',
|
|
planMode: 'plan.md',
|
|
pty: 'pty.md',
|
|
sandbox: 'sandbox.md',
|
|
sandboxPolicy: 'sandbox.md',
|
|
sessionPersistence: 'persistence.md',
|
|
sessionQuery: 'session-query.md',
|
|
sessionReferences: 'session-reference.md',
|
|
sessionProjectionCache: 'session-projection.md',
|
|
sessionProjections: 'session-projection.md',
|
|
sessions: 'session.md',
|
|
settings: 'settings.md',
|
|
sessionTitle: 'session-title.md',
|
|
skills: 'skills.md',
|
|
spillStore: 'spill.md',
|
|
storage: 'storage.md',
|
|
storageDomain: 'storage.md',
|
|
subagents: 'subagent.md',
|
|
subprocess: 'subprocess.md',
|
|
systemPrompt: 'system-prompt.md',
|
|
tasks: 'tasks.md',
|
|
telemetry: 'telemetry.md',
|
|
tokenMeter: 'token-meter.md',
|
|
toolResultPrune: 'compaction.md',
|
|
tools: 'tools.md',
|
|
typert: 'typert.md',
|
|
typertGateway: 'typert.md',
|
|
userInteraction: 'user-interaction.md',
|
|
web: 'web.md',
|
|
workflows: 'workflow.md',
|
|
workspace: 'workspace.md',
|
|
}
|
|
|
|
/**
|
|
* Context keys declared in `interface Context` merges that the rendering
|
|
* projection cannot see, each with the reason and its documentation owner.
|
|
* The scan that enforces this list reads EVERY `declare module '@deepseek-ai/cordis'`
|
|
* Context merge under `packages/x/x/src/**` — any depth, not only root
|
|
* `index.ts` files with a same-named service class — so a new service can
|
|
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
|
|
* or names itself here. Client-face keys (the projection analyzes the host
|
|
* face only) name the package README that owns their API.
|
|
* TODO(cordis-catalog-interface-services): the interface-typed and
|
|
* non-index-declared entries would all render once the projection resolves a
|
|
* Context key through its declaring file's imports to the class declaration.
|
|
*/
|
|
export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
|
agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle',
|
|
appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract',
|
|
cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract',
|
|
configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract',
|
|
launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract',
|
|
dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
|
|
launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns this launcher contract',
|
|
lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the API',
|
|
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the API',
|
|
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the API',
|
|
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the API',
|
|
settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API',
|
|
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API',
|
|
command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the API',
|
|
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
|
|
conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the API',
|
|
conversationViews: 'client-side interface-typed registry — packages/client/runtime/README.md owns the API',
|
|
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API',
|
|
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API',
|
|
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the API',
|
|
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the API',
|
|
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the API',
|
|
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the API',
|
|
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API',
|
|
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
|
|
workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API',
|
|
}
|
|
|
|
/**
|
|
* The owning subsystems page for every harness event scope (the segment
|
|
* before the first `/`) the projection renders. Fail-closed exactly like
|
|
* {@link SERVICE_PAGE}. Client-face events (`slash/*`, `theme/change`, …) are
|
|
* invisible to the host-face projection and therefore never reach this map;
|
|
* {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner.
|
|
*/
|
|
export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
|
'agent': 'core.md',
|
|
'agent-loop': 'core.md',
|
|
'approval': 'approval.md',
|
|
'commands': 'commands.md',
|
|
'credentials': 'credentials.md',
|
|
'domain': 'storage.md',
|
|
'fs': 'filesystem.md',
|
|
'goal': 'goal.md',
|
|
'llm': 'llm-streaming.md',
|
|
'session': 'session.md',
|
|
'settings': 'settings.md',
|
|
'skills': 'skills.md',
|
|
'subagent': 'subagent.md',
|
|
'system-prompt': 'system-prompt.md',
|
|
'telemetry': 'telemetry.md',
|
|
'tools': 'tools.md',
|
|
'workflow': 'workflow.md',
|
|
}
|
|
|
|
/**
|
|
* Event names declared in `interface Events` merges that the rendering
|
|
* projection cannot see, each with the reason and its documentation owner.
|
|
* The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent
|
|
* scan reads EVERY `declare module '@deepseek-ai/cordis'` Events merge under
|
|
* `packages/x/x/src/**`, so a declared event either renders onto a subsystems
|
|
* page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes
|
|
* silently. Keys are full event names, not scopes: client-face events share
|
|
* scopes with rendered host events (`models/changed` beside `llm/*`),
|
|
* so a scope-level exemption would mask a host-face regression.
|
|
*/
|
|
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
|
|
'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the API',
|
|
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API',
|
|
'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API',
|
|
'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the API',
|
|
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API',
|
|
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API',
|
|
'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API',
|
|
'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API',
|
|
'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the API',
|
|
'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the API',
|
|
}
|
|
|
|
/**
|
|
* One primary subsystems page per project type used by a generated
|
|
* signature. This stays curated because union names intentionally do not
|
|
* reuse the type-equivalence manifest's map-symbol entries and some symbols
|
|
* appear on more than one page.
|
|
*/
|
|
export const LINK_MAP: Readonly<Record<string, string>> = {
|
|
Agent: 'core.md',
|
|
AgentCancelCause: 'core.md',
|
|
AgentFactory: 'core.md',
|
|
AgentHandle: 'core.md',
|
|
ModelSelection: 'core.md',
|
|
AgentOptions: 'core.md',
|
|
AgentStatus: 'core.md',
|
|
ContentBlock: 'llm-streaming.md',
|
|
CreateAgentOptions: 'core.md',
|
|
GenerateOptions: 'llm-streaming.md',
|
|
InboxItem: 'core.md',
|
|
InboxPlacement: 'core.md',
|
|
MessageId: 'llm-streaming.md',
|
|
ResumeAgentOptions: 'core.md',
|
|
SettleReason: 'core.md',
|
|
AdapterRegistrationHandle: 'llm-streaming.md',
|
|
DirectoryRegistrationHandle: 'llm-streaming.md',
|
|
LlmCallConfig: 'llm-streaming.md',
|
|
LlmModelContext: 'llm-streaming.md',
|
|
LlmModelReasoningInfo: 'llm-streaming.md',
|
|
LlmResolvedModelInfo: 'llm-streaming.md',
|
|
LlmFailure: 'llm-streaming.md',
|
|
LlmModelInfo: 'llm-streaming.md',
|
|
LlmProviderInfo: 'llm-streaming.md',
|
|
LlmConfigurableProvider: 'llm-streaming.md',
|
|
LlmModelDiscoveryRequest: 'llm-streaming.md',
|
|
LlmDiscoveredModel: 'llm-streaming.md',
|
|
ResolvedRetryPolicy: 'llm-streaming.md',
|
|
Message: 'llm-streaming.md',
|
|
MessageSource: 'llm-streaming.md',
|
|
MessageFeedbackDeleteRequest: 'feedback.md',
|
|
MessageFeedbackDeleteResult: 'feedback.md',
|
|
MessageFeedbackDeleteValue: 'feedback.md',
|
|
MessageFeedbackFailure: 'feedback.md',
|
|
MessageFeedbackItem: 'feedback.md',
|
|
MessageFeedbackListRequest: 'feedback.md',
|
|
MessageFeedbackListResult: 'feedback.md',
|
|
MessageFeedbackListValue: 'feedback.md',
|
|
MessageFeedbackNoteBlank: 'feedback.md',
|
|
MessageFeedbackNoteTooLarge: 'feedback.md',
|
|
MessageFeedbackPutRequest: 'feedback.md',
|
|
MessageFeedbackPutResult: 'feedback.md',
|
|
MessageFeedbackRating: 'feedback.md',
|
|
MessageFeedbackRejected: 'feedback.md',
|
|
MessageFeedbackSessionNotFound: 'feedback.md',
|
|
MessageFeedbackSuccess: 'feedback.md',
|
|
MessageFeedbackTargetNotFound: 'feedback.md',
|
|
MessageFeedbackVersion: 'feedback.md',
|
|
MessageFeedbackVersionConflict: 'feedback.md',
|
|
UserMessage: 'session.md',
|
|
PreStepDecision: 'core.md',
|
|
PreStepContext: 'core.md',
|
|
RequestErrorAction: 'core.md',
|
|
RequestFailureContext: 'core.md',
|
|
PreparedReferencedMessage: 'session-reference.md',
|
|
SessionReferenceCandidate: 'session-reference.md',
|
|
SessionReferenceInput: 'session-reference.md',
|
|
SessionEvent: 'session.md',
|
|
SessionId: 'core.md',
|
|
SessionStartSource: 'core.md',
|
|
SessionLogSnapshot: 'session-query.md',
|
|
SessionSurfaceSnapshot: 'session-query.md',
|
|
ApprovalOutcome: 'approval.md',
|
|
ApprovalPolicy: 'approval.md',
|
|
ApprovalRequest: 'approval.md',
|
|
ApprovalService: 'approval.md',
|
|
ImageAttachmentRef: 'attachment.md',
|
|
SaveImageAttachment: 'attachment.md',
|
|
StoredImageAttachment: 'attachment.md',
|
|
BashExecRequest: 'bash.md',
|
|
BashExecSpec: 'bash.md',
|
|
BashProcess: 'bash.md',
|
|
BashRunResult: 'bash.md',
|
|
DshEnvironment: 'subprocess.md',
|
|
SubprocessHandle: 'subprocess.md',
|
|
SubprocessOutcome: 'subprocess.md',
|
|
SubprocessOutputRead: 'subprocess.md',
|
|
SubprocessOutputReader: 'subprocess.md',
|
|
SubprocessSpawnSpec: 'subprocess.md',
|
|
SubprocessTerminalHandle: 'subprocess.md',
|
|
SubprocessTerminalSpawnSpec: 'subprocess.md',
|
|
CodeRunRequest: 'code-runtime.md',
|
|
CodeRunResult: 'code-runtime.md',
|
|
CompactionResult: 'compaction.md',
|
|
CompactionTrigger: 'compaction.md',
|
|
PruneResult: 'compaction.md',
|
|
FileReadOutcome: 'filesystem.md',
|
|
FsDirEntry: 'filesystem.md',
|
|
FsEditOutcome: 'filesystem.md',
|
|
FsEditRequest: 'filesystem.md',
|
|
FsInfo: 'filesystem.md',
|
|
FsObservation: 'filesystem.md',
|
|
FsPathInfo: 'filesystem.md',
|
|
FsPolicyExec: 'filesystem.md',
|
|
FsTarget: 'filesystem.md',
|
|
FsVersion: 'filesystem.md',
|
|
FsWriteIntent: 'filesystem.md',
|
|
FsWriteOutcome: 'filesystem.md',
|
|
CreateGoalRequest: 'goal.md',
|
|
EditGoalRequest: 'goal.md',
|
|
GoalBlockReason: 'goal.md',
|
|
GoalChanged: 'goal.md',
|
|
GoalRef: 'goal.md',
|
|
GoalView: 'goal.md',
|
|
CreateGoalResult: 'goal.md',
|
|
CommandDefinition: 'commands.md',
|
|
CommandDescriptor: 'commands.md',
|
|
CommandId: 'commands.md',
|
|
CommandResult: 'commands.md',
|
|
LlmAdapter: 'llm-streaming.md',
|
|
PreparedLlmCall: 'llm-streaming.md',
|
|
LlmService: 'llm-streaming.md',
|
|
StreamChunk: 'llm-streaming.md',
|
|
SkillProviderControl: 'skills.md',
|
|
CreateSessionOptions: 'persistence.md',
|
|
PrepareSessionOptions: 'persistence.md',
|
|
SessionHeader: 'persistence.md',
|
|
SessionInspection: 'persistence.md',
|
|
SessionLocation: 'persistence.md',
|
|
SessionPreparation: 'persistence.md',
|
|
SessionPersistenceSnapshot: 'persistence.md',
|
|
SessionRawArtifact: 'persistence.md',
|
|
ConfinedArgv: 'sandbox.md',
|
|
SandboxExecutionPolicy: 'sandbox.md',
|
|
SandboxMode: 'sandbox.md',
|
|
SandboxPolicy: 'sandbox.md',
|
|
PtyBackend: 'pty.md',
|
|
PtyReadRequest: 'pty.md',
|
|
PtyReadResult: 'pty.md',
|
|
PtySendOperation: 'pty.md',
|
|
PtySendRequest: 'pty.md',
|
|
PtySessionId: 'pty.md',
|
|
PtySessionSnapshot: 'pty.md',
|
|
PtySignal: 'pty.md',
|
|
PtySignalResult: 'pty.md',
|
|
PtySpawnRequest: 'pty.md',
|
|
PtySpawnResult: 'pty.md',
|
|
SandboxPolicyRequest: 'sandbox.md',
|
|
ScopeKey: 'scope.md',
|
|
Scoped: 'scope.md',
|
|
EpochHeader: 'session.md',
|
|
Session: 'session.md',
|
|
SessionEventMap: 'session.md',
|
|
TurnEndReason: 'session.md',
|
|
TurnTrigger: 'session.md',
|
|
SessionEventReadRequest: 'session-query.md',
|
|
SessionEventRecord: 'session-query.md',
|
|
SessionEventResultFilter: 'session-query.md',
|
|
SessionEventSearchDocument: 'session-query.md',
|
|
SessionEventSearchHit: 'session-query.md',
|
|
SessionEventSearchPage: 'session-query.md',
|
|
SessionEventSearchRequest: 'session-query.md',
|
|
SessionEventTrace: 'session-query.md',
|
|
SessionEventTraceObservation: 'session-query.md',
|
|
SessionEventTraceRequest: 'session-query.md',
|
|
SessionEventWindow: 'session-query.md',
|
|
SessionLineageTrace: 'session-query.md',
|
|
SessionRecord: 'session-query.md',
|
|
SessionResultFilter: 'session-query.md',
|
|
SessionSearchExecContext: 'session-query.md',
|
|
SessionSearchHit: 'session-query.md',
|
|
SessionSearchPage: 'session-query.md',
|
|
SessionSearchRequest: 'session-query.md',
|
|
SessionTitleObservation: 'session-query.md',
|
|
SessionTitleObservationResult: 'session-query.md',
|
|
SessionTitleProvider: 'session-title.md',
|
|
SessionTitleSnapshot: 'session-title.md',
|
|
SkillCatalogSnapshot: 'skills.md',
|
|
SkillDefinition: 'skills.md',
|
|
SkillLookupOptions: 'skills.md',
|
|
SkillProvider: 'skills.md',
|
|
SkillProviderObservation: 'skills.md',
|
|
SkillRegistration: 'skills.md',
|
|
SkillViewOptions: 'skills.md',
|
|
SkillSummary: 'skills.md',
|
|
SaveTextSpill: 'spill.md',
|
|
SpillRef: 'spill.md',
|
|
ContinuableCreateRequest: 'subagent.md',
|
|
ContinuableCreateSpec: 'subagent.md',
|
|
ContinuableSetupContribution: 'subagent.md',
|
|
ContinuableStart: 'subagent.md',
|
|
ContinuableStartSpec: 'subagent.md',
|
|
CoordinatorMessageSource: 'subagent.md',
|
|
SubagentDescendantListEntry: 'subagent.md',
|
|
SubagentFollowupOptions: 'subagent.md',
|
|
SubagentInterruptAuthority: 'subagent.md',
|
|
SubagentListEntry: 'subagent.md',
|
|
SubagentProvider: 'subagent.md',
|
|
SubagentReportDelivery: 'subagent.md',
|
|
SubagentReportMessageSource: 'subagent.md',
|
|
SubagentReportOptions: 'subagent.md',
|
|
SubagentRun: 'subagent.md',
|
|
SubagentService: 'subagent.md',
|
|
SubagentStartRequest: 'subagent.md',
|
|
AssembleContext: 'system-prompt.md',
|
|
PromptContext: 'system-prompt.md',
|
|
PromptSection: 'system-prompt.md',
|
|
SystemPrompt: 'system-prompt.md',
|
|
ToolProviderResult: 'system-prompt.md',
|
|
TaskDoneListener: 'tasks.md',
|
|
TaskId: 'tasks.md',
|
|
TaskRead: 'tasks.md',
|
|
TaskSnapshot: 'tasks.md',
|
|
TaskStart: 'tasks.md',
|
|
TasksChangedListener: 'tasks.md',
|
|
TokenMeasurement: 'token-meter.md',
|
|
CodeDispatchLog: 'tools.md',
|
|
PostToolDecision: 'tools.md',
|
|
PreToolDecision: 'tools.md',
|
|
ToolDefinition: 'tools.md',
|
|
ToolExecution: 'tools.md',
|
|
ToolDispatchExecution: 'tools.md',
|
|
ToolExecutionInput: 'tools.md',
|
|
ToolExecutionMode: 'tools.md',
|
|
ToolExecutionResult: 'tools.md',
|
|
ToolExecutionToken: 'tools.md',
|
|
ToolGuard: 'tools.md',
|
|
ToolPresentationMode: 'tools.md',
|
|
ToolRegistry: 'tools.md',
|
|
ToolRestriction: 'tools.md',
|
|
ToolSchema: 'tools.md',
|
|
SettingsNamespace: 'settings.md',
|
|
SettingsRegisterOptions: 'settings.md',
|
|
SettingsScope: 'settings.md',
|
|
SettingsDescriptor: 'settings.md',
|
|
SettingsPathOp: 'settings.md',
|
|
SettingsDescribeOptions: 'settings.md',
|
|
SettingsUpdateSource: 'settings.md',
|
|
CredentialRef: 'credentials.md',
|
|
CredentialInfo: 'credentials.md',
|
|
ResolvedCredential: 'credentials.md',
|
|
AskUserQuestionAnswer: 'user-interaction.md',
|
|
AskUserQuestionRequest: 'user-interaction.md',
|
|
UserInteractionProvider: 'user-interaction.md',
|
|
WebFetchProvider: 'web.md',
|
|
WebFetchRequest: 'web.md',
|
|
WebFetchResult: 'web.md',
|
|
WebSearchProvider: 'web.md',
|
|
WebSearchRequest: 'web.md',
|
|
WebSearchResult: 'web.md',
|
|
WorkflowRun: 'workflow.md',
|
|
PresetOption: 'permission.md',
|
|
PresetSpec: 'permission.md',
|
|
InvariantInstaller: 'invariants.md',
|
|
WebRoute: 'http-server.md',
|
|
StorageBackend: 'storage.md',
|
|
StorageForms: 'storage.md',
|
|
Domain: 'storage.md',
|
|
DomainSpec: 'storage.md',
|
|
DomainChanged: 'storage.md',
|
|
DomainFacility: 'storage.md',
|
|
Workspace: 'workspace.md',
|
|
WorkspaceId: 'workspace.md',
|
|
WebBootGraph: 'client-modules.md',
|
|
TelemetryRecord: 'telemetry.md',
|
|
WorkflowRunInfo: 'workflow.md',
|
|
WorkflowStartRequest: 'workflow.md',
|
|
ProjectionDefinition: 'session-projection.md',
|
|
SessionProjectionMap: 'session-projection.md',
|
|
ProjectionChangeListener: 'session-projection.md',
|
|
ProjectionSnapshot: 'session-projection.md',
|
|
ProjectionCheckpoint: 'session-projection.md',
|
|
DirectoryPickerCapability: 'workspace.md',
|
|
TypertContribution: 'invariants.md',
|
|
TypertFace: 'invariants.md',
|
|
TypertPackageFilter: 'invariants.md',
|
|
TypertPackageRecord: 'invariants.md',
|
|
TypertSchemaFilter: 'invariants.md',
|
|
TypertSchemaRecord: 'invariants.md',
|
|
}
|
|
|
|
/** TypeScript lib and pinned framework types with no repository-owned data page. */
|
|
export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
|
'AbortSignal',
|
|
'AsyncIterable',
|
|
'Context',
|
|
'Error',
|
|
'Map',
|
|
'Partial',
|
|
'Pick',
|
|
'Promise',
|
|
'Record',
|
|
'Readonly',
|
|
'Uint8Array',
|
|
])
|
|
|
|
/** Project types deliberately documented outside the subsystems catalog. */
|
|
export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
|
z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)',
|
|
BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
|
InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
|
ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
|
InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
|
AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
|
|
AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
|
|
PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md',
|
|
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
|
|
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
|
|
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
|
|
ManualCompactAgentContext: 'manual compaction service input is owned by packages/compact/compact/src/index.ts',
|
|
DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md',
|
|
CommandExecution: 'executor return contract is owned by packages/interaction/commands/src/index.ts',
|
|
'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API',
|
|
'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API',
|
|
TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md',
|
|
InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md',
|
|
LocaleDict: 'service-local dictionary fields are owned by packages/client/i18n/src/index.ts',
|
|
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
|
|
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
|
|
WebUpgradeRoute:
|
|
'upgrade route registration contract is owned by packages/host/webserver/src/index.ts',
|
|
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
|
KnobState: 'projection unit state fields are owned by packages/interaction/permission/README.md',
|
|
PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission/src/types.ts',
|
|
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
|
Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md',
|
|
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
|
SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
|
SubagentRunInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
|
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
|
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
|
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
|
}
|
|
|
|
/** Repository data policy consumed by the Cordis catalog projector. */
|
|
export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
|
linkedTypePages: LINK_MAP,
|
|
foundationTypeNames: FOUNDATION_TYPE_NAMES,
|
|
typeLinkExemptions: TYPE_LINK_EXEMPTIONS,
|
|
inheritedEvents: [
|
|
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
|
|
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
|
|
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
|
|
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
|
|
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
|
|
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
|
|
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
|
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
|
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
|
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
|
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
|
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
|
|
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
|
|
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
|
|
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
|
|
],
|
|
inheritedServices: [
|
|
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
|
|
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / short-circuit chain).', source: 'vendor/cordis/src/events.ts:34' },
|
|
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
|
|
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
|
|
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
|
|
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
|
|
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
|
|
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
|
|
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
|
|
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
|
|
],
|
|
}
|
|
|
|
|
|
/**
|
|
* Splice a page's generated Cordis API region into its Markdown content.
|
|
* The page must contain exactly one `cordis-surface` marker region (the markers are
|
|
* part of the hand-owned page skeleton once, then owned by the generator);
|
|
* zero or several is a partition error the caller reports with the page path.
|
|
* The match is on THIS generator's exact markers, not the generic region
|
|
* grammar, so a page carrying only some other generator's region fails loud
|
|
* instead of having that region overwritten.
|
|
* @param content - the page's current full Markdown text.
|
|
* @param region - the freshly rendered marker-delimited region.
|
|
* @returns the page text with the region replaced.
|
|
*/
|
|
export function spliceRegion(content: string, region: string): string {
|
|
const lines = content.split('\n')
|
|
const begins = lines.flatMap((line, index) => (line === REGION_BEGIN ? [index] : []))
|
|
const ends = lines.flatMap((line, index) => (line === REGION_END ? [index] : []))
|
|
if (begins.length !== 1 || ends.length !== 1) {
|
|
throw new Error(`expected exactly 1 cordis-surface region, found ${begins.length} BEGIN/${ends.length} END; add the BEGIN/END cordis-surface markers once`)
|
|
}
|
|
const begin = begins[0] ?? -1
|
|
const end = ends[0] ?? -1
|
|
if (end < begin) throw new Error('cordis-surface END marker precedes its BEGIN')
|
|
return [...lines.slice(0, begin), ...region.split('\n'), ...lines.slice(end + 1)].join('\n')
|
|
}
|
|
|
|
/** The declared-vs-rendered inputs {@link walkPartitionProblems} judges. */
|
|
export interface WalkPartitionInput {
|
|
/** Service key → source pointer, as the rendering projection produced them. */
|
|
readonly renderedKeys: ReadonlyMap<string, string>
|
|
/** Event scopes the rendering projection produced. */
|
|
readonly renderedScopes: ReadonlySet<string>
|
|
/** Event names the rendering projection produced. */
|
|
readonly renderedEventNames: ReadonlySet<string>
|
|
/** Context key → first declaring file, from the independent AST scan. */
|
|
readonly declaredKeys: ReadonlyMap<string, string>
|
|
/** Event name → first declaring file, from the independent AST scan. */
|
|
readonly declaredEvents: ReadonlyMap<string, string>
|
|
}
|
|
|
|
/** The curated partition maps {@link walkPartitionProblems} enforces. */
|
|
export interface WalkPartitionMaps {
|
|
readonly servicePage: Readonly<Record<string, string>>
|
|
readonly serviceWalkExemptions: Readonly<Record<string, string>>
|
|
readonly eventScopePage: Readonly<Record<string, string>>
|
|
readonly eventWalkExemptions: Readonly<Record<string, string>>
|
|
}
|
|
|
|
/**
|
|
* Judge the rendered API and the independent AST scan against the curated
|
|
* partition maps, fail-closed in both directions for services AND events: a
|
|
* rendered key/scope must be mapped to a page, a mapped key/scope must still
|
|
* render, and — the backstop — a DECLARED key/event the projection cannot see
|
|
* must carry a named walk exemption (a rendered one must not). A third
|
|
* direction guards the scan itself: everything rendered must also be declared
|
|
* to the scan, so a scan blind spot cannot decay silently. Pure so the
|
|
* acceptance paths are provable without running the projection.
|
|
* @param input - rendered API plus the declared-key/event scans.
|
|
* @param maps - the curated page maps and walk exemptions.
|
|
* @returns one message per violation, empty when the partition holds.
|
|
*/
|
|
export function walkPartitionProblems(input: WalkPartitionInput, maps: WalkPartitionMaps): string[] {
|
|
const problems: string[] = []
|
|
for (const [key, source] of input.renderedKeys) {
|
|
if (!Object.hasOwn(maps.servicePage, key)) problems.push(`service ctx.${key} (${source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
|
}
|
|
for (const scope of [...input.renderedScopes].sort()) {
|
|
if (!Object.hasOwn(maps.eventScopePage, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
|
}
|
|
for (const key of Object.keys(maps.servicePage)) {
|
|
if (!input.renderedKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
|
}
|
|
for (const scope of Object.keys(maps.eventScopePage)) {
|
|
if (!input.renderedScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
|
}
|
|
// The rendering projection only sees a Context key it can resolve to a
|
|
// documented service class. The independent scan reads EVERY Context merge
|
|
// so a key the projection cannot render must either be rendered (mapped) or
|
|
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
|
for (const [key, rel] of input.declaredKeys) {
|
|
const rendered = input.renderedKeys.has(key)
|
|
const exempt = Object.hasOwn(maps.serviceWalkExemptions, key)
|
|
if (!rendered && !exempt) {
|
|
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
|
}
|
|
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
|
}
|
|
for (const key of Object.keys(maps.serviceWalkExemptions)) {
|
|
if (!input.declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
|
}
|
|
// The event mirror of the service backstop: the projection walks only files
|
|
// reachable from host-face package exports, so a client-face or unreachable
|
|
// Events merge would otherwise vanish without a trace.
|
|
for (const [name, rel] of input.declaredEvents) {
|
|
const rendered = input.renderedEventNames.has(name)
|
|
const exempt = Object.hasOwn(maps.eventWalkExemptions, name)
|
|
if (!rendered && !exempt) {
|
|
problems.push(`event '${name}' (${rel}) is declared in an Events merge but invisible to the rendering projection; make it renderable (mapped via EVENT_SCOPE_PAGE) or name it in EVENT_WALK_EXEMPTIONS with its documentation owner.`)
|
|
}
|
|
if (rendered && exempt) problems.push(`event '${name}' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS; remove the stale exemption.`)
|
|
}
|
|
for (const name of Object.keys(maps.eventWalkExemptions)) {
|
|
if (!input.declaredEvents.has(name)) problems.push(`EVENT_WALK_EXEMPTIONS names '${name}' but no Events merge declares it; remove the stale exemption.`)
|
|
}
|
|
// Self-check the scan itself: everything the projection renders is declared
|
|
// in a Context/Events merge the scan must also reach, so a rendered key or
|
|
// event the scan cannot see means the SCAN regressed (glob, prefilter, or
|
|
// block walk) — a partial blind spot that exemption staleness alone would
|
|
// never appear.
|
|
for (const key of input.renderedKeys.keys()) {
|
|
if (!input.declaredKeys.has(key)) problems.push(`ctx.${key} is rendered by the projection but the independent scan finds no Context merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
|
}
|
|
for (const name of input.renderedEventNames) {
|
|
if (!input.declaredEvents.has(name)) problems.push(`event '${name}' is rendered by the projection but the independent scan finds no Events merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
|
}
|
|
return problems
|
|
}
|
|
|
|
/**
|
|
* Compute every generated artifact: the inherited-tier page, the model-facing
|
|
* runtime API module, plus, per mapped subsystems page, the pair's two updated
|
|
* documents with the injected region. Fail-loud partition checks live here: an
|
|
* unmapped service/event scope, a mapping whose page file does not exist, a
|
|
* curated entry whose key/scope the projection no longer discovers, a declared
|
|
* Context key or Events member the projection cannot see without a named walk
|
|
* exemption, and a mapped page missing its markers are all aggregated errors.
|
|
* @returns `[repo-relative path, exact content]` for every generated artifact.
|
|
*/
|
|
export function computeOutputs(): [string, string][] {
|
|
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
|
|
const services = [...model.services]
|
|
const events = [...model.events]
|
|
|
|
const declaredKeys = new Map<string, string>()
|
|
const declaredEvents = new Map<string, string>()
|
|
for (const { rel, sf, body } of contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])) {
|
|
for (const key of contextKeyMap(body, sf).keys()) {
|
|
if (!declaredKeys.has(key)) declaredKeys.set(key, rel)
|
|
}
|
|
for (const name of eventNameList(body, sf)) {
|
|
if (!declaredEvents.has(name)) declaredEvents.set(name, rel)
|
|
}
|
|
}
|
|
const problems = walkPartitionProblems({
|
|
renderedKeys: new Map(services.map(s => [s.key, s.source])),
|
|
renderedScopes: new Set(events.map(e => e.scope)),
|
|
renderedEventNames: new Set(events.map(e => e.name)),
|
|
declaredKeys,
|
|
declaredEvents,
|
|
}, {
|
|
servicePage: SERVICE_PAGE,
|
|
serviceWalkExemptions: SERVICE_WALK_EXEMPTIONS,
|
|
eventScopePage: EVENT_SCOPE_PAGE,
|
|
eventWalkExemptions: EVENT_WALK_EXEMPTIONS,
|
|
})
|
|
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} partition violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
|
|
|
const pages = [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])].sort()
|
|
const outputs: [string, string][] = [
|
|
[OUT_INHERITED, renderInheritedPage(CORDIS_CATALOG_POLICY)],
|
|
[OUT_RUNTIME_API, projector.renderRuntimeApi(model)],
|
|
]
|
|
for (const page of pages) {
|
|
const region = renderPageRegion(
|
|
page,
|
|
services.filter(s => SERVICE_PAGE[s.key] === page),
|
|
events.filter(e => EVENT_SCOPE_PAGE[e.scope] === page),
|
|
CORDIS_CATALOG_POLICY,
|
|
)
|
|
for (const side of [page, page.replace(/\.md$/, '.zh.md')]) {
|
|
const rel = `${SUBSYSTEMS_DIR}/${side}`
|
|
let current: string
|
|
try {
|
|
current = readFileSync(resolve(root, rel), 'utf8')
|
|
} catch {
|
|
// Both pair sides must exist before a region can be injected; the
|
|
// pairing gate owns pair completeness, this generator names the miss.
|
|
problems.push(`${rel}: mapped subsystems page does not exist.`)
|
|
continue
|
|
}
|
|
try {
|
|
outputs.push([rel, spliceRegion(current, region)])
|
|
} catch (error) {
|
|
problems.push(`${rel}: ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
}
|
|
}
|
|
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} page violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
|
return outputs
|
|
}
|
|
|
|
/**
|
|
* Re-record a pair's `.i18n.yaml` after a region write ONLY when the write is
|
|
* region-confined: both sides' region-stripped content must be byte-equal to
|
|
* the region-stripped previous content whose hashes the record holds. The
|
|
* caller supplies the previous bytes (read before writing); human-content
|
|
* drift leaves the record untouched so the pairing gate still demands the
|
|
* normal translation flow.
|
|
* @param pageRel - repo-relative English page path (`docs/subsystems/x.md`).
|
|
* @param before - pre-write bytes per repo-relative path.
|
|
* @param scanRoot - repository root override for tests.
|
|
* @returns true when the record was refreshed.
|
|
*/
|
|
export function maybeRecordPair(pageRel: string, before: Map<string, Buffer>, scanRoot: string = root): boolean {
|
|
const zhRel = pageRel.replace(/\.md$/, '.zh.md')
|
|
const metaRel = pageRel.replace(/\.md$/, '.i18n.yaml')
|
|
const metaAbs = resolve(scanRoot, metaRel)
|
|
let meta: string
|
|
try {
|
|
meta = readFileSync(metaAbs, 'utf8')
|
|
} catch {
|
|
// No record yet: a brand-new pair is recorded by the author's --write
|
|
// after review, never silently by regeneration.
|
|
return false
|
|
}
|
|
// The record must contain exactly the two valid entries for THIS pair;
|
|
// a malformed or renamed-key sidecar is the pairing gate's problem to
|
|
// report, never something regeneration silently repairs into validity.
|
|
const recorded = parsePairMeta(meta)
|
|
const names = [pageRel, zhRel].map(rel => rel.split('/').at(-1) ?? rel)
|
|
if (!recorded || recorded.size !== 2 || !names.every(name => recorded.has(name))) return false
|
|
for (const rel of [pageRel, zhRel]) {
|
|
const previous = before.get(rel)
|
|
if (!previous) return false
|
|
if (recorded.get(rel.split('/').at(-1) ?? rel) !== blobHash(previous)) return false
|
|
const current = readFileSync(resolve(scanRoot, rel))
|
|
const strippedBefore = partitionGeneratedRegions(previous.toString('utf8')).stripped
|
|
const strippedAfter = partitionGeneratedRegions(current.toString('utf8')).stripped
|
|
if (strippedBefore !== strippedAfter) return false
|
|
}
|
|
const source = readFileSync(resolve(scanRoot, pageRel))
|
|
const zh = readFileSync(resolve(scanRoot, zhRel))
|
|
writeFileSync(metaAbs, renderPairMeta(pageRel, blobHash(source), zhRel, blobHash(zh)))
|
|
return true
|
|
}
|
|
|
|
/** CLI entry: default regenerates every artifact, `--check` fails if any is
|
|
* stale. Guarded behind an entry-point check so importing this module for
|
|
* tests neither regenerates the committed files nor calls process.exit.
|
|
* @returns nothing; writes files or reports freshness through the process.
|
|
*/
|
|
export function main(): void {
|
|
const outputs: [string, string][] = [
|
|
...computeOutputs(),
|
|
...renderCordisCoreApiPages(),
|
|
]
|
|
if (process.argv.includes('--check')) {
|
|
const stale: string[] = []
|
|
for (const [out, content] of outputs) {
|
|
let committed: string | null = null
|
|
try {
|
|
committed = readFileSync(resolve(root, out), 'utf8')
|
|
} catch {
|
|
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
|
// file is not a state this repo produces. Either way the remedy is the
|
|
// same — regenerate — so treat a read failure as "stale".
|
|
committed = null
|
|
}
|
|
if (committed !== content) stale.push(out)
|
|
}
|
|
if (stale.length === 0) {
|
|
console.log(`gen-cordis-catalog: ${outputs.length} generated file(s)/region(s) are up to date.`)
|
|
process.exit(0)
|
|
}
|
|
console.error(`gen-cordis-catalog: stale — ${stale.join(', ')}. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const before = new Map<string, Buffer>()
|
|
for (const [out] of outputs) {
|
|
try {
|
|
before.set(out, readFileSync(resolve(root, out)))
|
|
} catch {
|
|
// First generation of this artifact; nothing to guard, nothing to record.
|
|
}
|
|
}
|
|
let changedPages = 0
|
|
let recorded = 0
|
|
for (const [out, content] of outputs) {
|
|
const destination = resolve(root, out)
|
|
if (before.get(out)?.toString('utf8') === content) continue
|
|
mkdirSync(dirname(destination), { recursive: true })
|
|
writeFileSync(destination, content)
|
|
changedPages++
|
|
}
|
|
for (const page of [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])]) {
|
|
const rel = `${SUBSYSTEMS_DIR}/${page}`
|
|
const zhRel = rel.replace(/\.md$/, '.zh.md')
|
|
const wroteEither = [rel, zhRel].some((side) => {
|
|
const previous = before.get(side)
|
|
return previous !== undefined && previous.toString('utf8') !== readFileSync(resolve(root, side), 'utf8')
|
|
})
|
|
if (wroteEither && maybeRecordPair(rel, before)) recorded++
|
|
}
|
|
console.log(`gen-cordis-catalog: ${outputs.length} artifact(s) computed, ${changedPages} written, ${recorded} pair record(s) refreshed.`)
|
|
}
|
|
|
|
// Run only when invoked as a script, not when imported by a test.
|
|
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
|
main()
|
|
}
|