diff --git a/README.md b/README.md index 49a301a..575f88d 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,7 @@ The runtime owns the scene/composition model. External libraries are adopted where they make a specific layer stronger, not where they force the project back into slide semantics. -See `docs/adrs/` for architectural decisions. +See `docs/adrs/` for architectural decisions. The trust model for +scene modules — what the runtime treats as trusted application code, +and what current validation does not cover — is documented in +[`docs/scene-trust-model.md`](docs/scene-trust-model.md). diff --git a/docs/asset-url-policy.md b/docs/asset-url-policy.md index 781c162..d91410c 100644 --- a/docs/asset-url-policy.md +++ b/docs/asset-url-policy.md @@ -296,5 +296,9 @@ Anti-patterns this doc rules out (per preflight): - [ADR-012 — asset preloader fetch + drain](adrs/012-asset-preloader-fetch-and-drain.md) - [Issue #101 preflight](design/issue-101-production-asset-policy-preflight.md) +- [`docs/scene-trust-model.md`](scene-trust-model.md) — scene module + trust model. Asset URL policy is a URL and credential gate; it is + not a sandbox for scene code. Untrusted scene-module execution is a + separate, currently-non-existent surface tracked there. - `src/runtime/asset-preloader.ts` — `createAssetPreloader`, `resolveAssetUrl`, `DEFAULT_ALLOWED_SCHEMES`. - `src/runtime/validation.ts` — `validateRuntime`, `ValidationInput.assets`. diff --git a/docs/design/README.md b/docs/design/README.md index 22b3f20..9fe92bd 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -13,6 +13,7 @@ Design context for Pulsar. Source material for the ADRs in `../adrs/`. | [issue-099-repeated-scene-activation-context-preflight.md](issue-099-repeated-scene-activation-context-preflight.md) | Guardrails for supporting repeated scene entries through per-occurrence activation ownership without changing scene, composition, URL, registry, timeline, or audio boundaries. | | [issue-100-runtime-comment-audit-preflight.md](issue-100-runtime-comment-audit-preflight.md) | Guardrails for auditing stale runtime comments without changing behavior, duplicating ADR rationale, or weakening source-policy comments. | | [issue-101-production-asset-policy-preflight.md](issue-101-production-asset-policy-preflight.md) | Guardrails for documenting and optionally enforcing production asset URL, redirect, base URL, cross-origin, and credential policy through the existing preloader and validation seams. | +| [issue-102-scene-module-trust-boundary-preflight.md](issue-102-scene-module-trust-boundary-preflight.md) | Guardrails for documenting that scene modules are trusted application code and that validation/source/asset policies are not sandboxing. | | [positioning-and-landscape.md](positioning-and-landscape.md) | Category, adjacent OSS projects, differentiation, strategic risks. | | [pul-p002-validation-ci-preflight.md](pul-p002-validation-ci-preflight.md) | Guardrails for gating pull-request CI on the canonical runtime validation pass. | | [pul-p003-adr-format-preflight.md](pul-p003-adr-format-preflight.md) | Guardrails for keeping ADR markdown and Ground Control ADR records aligned without duplicate schemas or workflow logic. | diff --git a/docs/design/issue-102-scene-module-trust-boundary-preflight.md b/docs/design/issue-102-scene-module-trust-boundary-preflight.md new file mode 100644 index 0000000..1933232 --- /dev/null +++ b/docs/design/issue-102-scene-module-trust-boundary-preflight.md @@ -0,0 +1,139 @@ +# Issue 102 Scene Module Trust Boundary Preflight + +Date: 2026-05-23 + +Issue 102 is a documentation/security-boundary change. Scene modules +are executable application code. The implementation should document that +trust model where scene authors and future import/workflow designers will +see it, without changing runtime behavior or implying that current +validation makes untrusted code safe. + +This preflight is not the issue-closing trust-boundary documentation. It +is the repo-wide guardrail for that documentation. + +## Boundary + +- Pulsar scene modules are trusted, repo-owned application code loaded + through the authored bundle and registered scene/composition graph. +- The current runtime has no sandbox for scene lifecycle hooks. A scene's + `create(ctx)`, `timeline(ctx)`, and `cleanup(ctx)` execute with the + privileges of the application context that invoked them. +- Runtime validation checks declarative shape and metadata. It does not + inspect code safety, execute hooks safely, restrict browser APIs, or + prove that a module is trustworthy. +- The PUL-Q007 no-remote-code-execution policy prevents runtime string + execution and unsafe dynamic imports in authored source. It does not + transform bundled third-party scene code into untrusted-safe code. +- Asset policy governs declared URLs and fetch credentials. It is not a + code sandbox and must not be presented as one. +- User-submitted, third-party, plugin, marketplace, or shared-scene + execution is out of scope until a future requirement designs isolation. + +## Required Reuse + +Implementation guidance must build on these incumbents: + +- Scene contract: `SceneModule`, `SceneLifecycleFn`, + `assertSceneModule()`, and `sceneDeclaresAudio()` in + `src/runtime/scene.ts`. +- Structural validation: `validateRuntime()`, `ValidationInput`, + `Finding`, and `assertNoValidationFindings()` in + `src/runtime/validation.ts`. +- Registry and composition boundaries: + `createSceneRegistry()`, `createCompositionRegistry()`, + `assertCompositionManifest()`, and `resolveComposition()`. +- Lifecycle and context seams: `createSceneLoader()`, + `WorkbenchSceneCtx`, `SceneActivation`, `SceneFailureEvent`, + `create(ctx)`, `timeline(ctx)`, and `cleanup(ctx)`. +- Scene-facing capabilities exposed through context: `ctx.stage`, + `ctx.chrome`, `ctx.gsap`, `ctx.audio`, `ctx.presenter`, `ctx.mode`, + `ctx.rng`, and `ctx.activation`. +- Asset/security docs: `docs/asset-url-policy.md`, ADR-012, and + `docs/design/issue-101-production-asset-policy-preflight.md`. +- Source execution policy: `tests/runtime/policy-q007-remote-code-execution.test.ts` + and `docs/design/pul-q007-runtime-code-execution-preflight.md`. +- Error and diagnostic boundaries: `describeError()`, + `describeErrorDetailed()`, ADR-028, and the PUL-Q006 preflight. + +## Cross-Cutting Layers + +| Layer | Guardrail | +|-------|-----------| +| Source execution policy | Keep Q007's scope precise: no `eval`, `Function`, unsafe dynamic import, or remote code execution outside the bundle. Do not claim Q007 makes arbitrary bundled scene modules safe. | +| Scene schema gate | `assertSceneModule()` validates required fields, captions, ids, assets, audio membership, and lifecycle function presence. It is a shape gate only. Do not add `trusted`, `sandboxed`, `origin`, or `author` fields to `SceneModule` for this issue. | +| Runtime validation | `validateRuntime()` remains pure structural metadata validation. It must not execute lifecycle hooks, scan code trust, fetch assets, read files, or emit "safe to run" claims. | +| Registry/composition graph | Registries remain the static authored graph. Do not add plugin discovery, remote registry import, marketplace loading, or user-uploaded scene ingestion under this issue. | +| Lifecycle hooks | The docs must name the authority of `create(ctx)`, `timeline(ctx)`, and `cleanup(ctx)`: they can mutate mounted DOM through context, create timelines, use audio, subscribe to presenter state, and allocate resources that cleanup must release. Lifecycle failure isolation is reliability behavior, not security isolation. | +| Scene context | `WorkbenchSceneCtx` is the capability surface. Current scenes receive full application context for the active navigation. Do not document it as a reduced-permission or sandboxed capability set. | +| Asset/network policy | Declared assets and audio sources pass through `scene.assets`, `scene.audio`, `resolveAssetUrl()`, `createAssetPreloader()`, `baseUrl`, and `allowedSchemes`. These govern resource URLs and credentials, not arbitrary code behavior inside a trusted scene module. | +| Auth and secrets | The browser runtime should not require secrets for scene execution. Docs and examples must not put credentials in scene modules, asset URLs, validation findings, shell commands, or committed config. | +| Config/env/OS exposure | No env var, argv flag, browser storage toggle, or hidden config should mark third-party scene code as safe. Future isolation must be an explicit runtime/import architecture, not a deployment switch. | +| Error envelope | Diagnostics may name scene ids, lifecycle phases, asset strings, and policy names. Do not dump raw scene objects, captions, DOM, stacks, request headers, cookies, env, auth values, or serialized causes. | +| Observability/workflow | Existing docs, tests, source-policy scans, `pnpm test`, `pnpm typecheck`, and `pnpm lint` are enough. Do not add telemetry, SARIF, audit logs, or a separate security workflow for a documentation-only boundary. | +| Persistence | No persistence is required. Do not add trust allowlists in localStorage, files, caches, or committed registries. | + +## Intended Design + +The user-facing documentation should state the trust model in the same +vocabulary as the runtime: + +- scene modules execute as trusted application code; +- validation checks schema/metadata only, not code safety; +- third-party or user-submitted scene execution is not sandboxed by + default; +- lifecycle hooks and `WorkbenchSceneCtx` are the API surface through + which scene code affects the runtime; +- asset policy and no-remote-code-execution policy are related + guardrails with narrower scopes, not substitutes for sandboxing. + +Prefer one canonical trust-boundary section, cross-linked from related +docs, over scattered warnings. Do not bury the boundary only in a +preflight note or test comment. + +## Extensibility + +The future sandbox seam belongs outside the current scene schema. If a +future requirement accepts untrusted or third-party scene execution, it +must define a separate import/execution model and a capability-reduced +context at the runtime entrypoint that chooses which scene catalog is +loaded. + +That future design must be parameterized by execution profile and +granted capabilities, not by ad hoc per-scene booleans. It must also +cover module loading, DOM authority, network and asset policy, audio, +timelines, presenter/control surfaces, cleanup guarantees, diagnostics, +and host/browser isolation. Until then, the documented position is +"trusted application code only." + +## Gotchas And Anti-Patterns + +- Do not write "validated scene" as shorthand for "safe scene." +- Do not conflate "untrusted scene metadata" in the asset policy with + untrusted executable scene modules. +- Do not describe Q007 as a sandbox. It is a source-policy gate against + executing code outside the published bundle. +- Do not add a `trusted: true` or `sandboxed: false` field to every + scene. The trust boundary is a runtime/documentation invariant, not + per-scene metadata. +- Do not add plugin import, remote module loading, user upload, + marketplace, code-signing, CSP, iframe, worker, SES, or permission + systems as part of the documentation issue. +- Do not rely on lifecycle cleanup, scene failure isolation, or + `ctx.audio` group teardown as security containment. They are + reliability and resource-management contracts. +- Do not create a duplicate validator, exception hierarchy, source + scanner, logging surface, config loader, or policy file. +- Do not include a future-work promise unless the project accepts a + requirement or ADR for sandbox design. + +## Non-Goals + +Issue 102 does not implement a sandbox, plugin system, third-party scene +marketplace, upload workflow, import resolver, code signing, +permissions model, CSP generator, iframe/worker isolation, dependency +audit, telemetry, persistence, or a new CI gate. + +It should not change scene metadata shape, composition manifest shape, +registry behavior, URL grammar, workbench modes, asset preloading, +audio/timeline APIs, lifecycle ordering, validation categories, error +classification, logging, or package dependencies. diff --git a/docs/scene-trust-model.md b/docs/scene-trust-model.md new file mode 100644 index 0000000..62fcda5 --- /dev/null +++ b/docs/scene-trust-model.md @@ -0,0 +1,229 @@ +# Scene module trust model + +This is the canonical statement of how Pulsar treats the code inside +scene modules. The position the runtime takes is explicit: scene +modules are trusted, repo-owned application code. There is no sandbox +for scene execution today, and the existing structural gates do not +turn untrusted code into safe code. + +This doc exists so a future contributor adding scene import, plugin +loading, third-party sharing, marketplace flow, or any other surface +that brings in code Pulsar did not author cannot accidentally inherit +the current trust model. The current model only covers code in this +repository. + +## Scope + +- **Scene authors** writing modules under `src/scenes/**`. +- **Future workflow designers** considering import, upload, + marketplace, plugin, or third-party scene flows. +- **Security reviewers** checking what the runtime's structural + validation, source-policy gates, and asset URL policy do and do not + cover. + +Out of scope: asset URLs and credentials (see +[`docs/asset-url-policy.md`](asset-url-policy.md)); runtime-source +constructions that would execute code outside the published bundle +(see the PUL-Q007 entry under [Related guardrails](#related-guardrails)). + +## Trust position + +Scene modules are trusted, repo-owned application code, loaded through +the authored bundle and registered in the scene and composition +graphs ([ADR-002](adrs/002-scene-registry-and-compositions.md), +[ADR-008](adrs/008-agent-native-authoring.md)). + +A scene's lifecycle hooks — `create(ctx)`, `timeline(ctx)`, and +`cleanup(ctx)` — execute with the privileges of the application +context that invoked them. There is no capability-reduced execution +profile, no iframe or worker isolation, no permission gate, no +per-scene allowlist of browser APIs, no CSP boundary owned by the +runtime, and no code-signing or origin check on the scene module +itself. + +A scene module that lands in this repository can therefore reach any +browser API the workbench (or the test runner) exposes to its host +JavaScript context. The fact that a scene "passes validation" is a +statement about its declared shape and metadata only — it is not a +statement that the code inside its lifecycle hooks is safe to run. + +## What runtime validation does cover + +The runtime ships two structural gates over scene declarations. Both +are metadata-only. + +- `assertSceneModule(value)` in + [`src/runtime/scene.ts`](../src/runtime/scene.ts) validates the + `SceneModule` contract: id format and kebab-case grammar, title, + duration, tag/asset/audio/captions array shape, caption time and + text fields, `defaultNext` shape, `standalone` / `trailerSafe` + booleans, presence and type of `create`, `timeline`, and `cleanup`, + and the cross-field invariant that every audio entry is declared in + `assets`. It does not call any lifecycle hook, fetch anything, + inspect the function body of `create` / `timeline` / `cleanup`, or + evaluate code. +- `validateRuntime({ scenes, compositions, assets })` in + [`src/runtime/validation.ts`](../src/runtime/validation.ts) runs the + same shape check over a registry plus composition manifests, plus a + resolvable-asset check against the configured asset URL policy + (`baseUrl`, `allowedSchemes`). It collects findings instead of + failing fast. It is a pure orchestrator over the existing per-record + checks — it never executes lifecycle hooks, walks `import()` graphs, + fetches anything, touches the DOM, reads cookies or process state, + or makes "safe to run" claims about scene code. + +## What runtime validation does NOT cover + +- Code safety inside `create(ctx)`, `timeline(ctx)`, or + `cleanup(ctx)`. The lifecycle hooks are application-privileged + function bodies that the validation pass never executes. +- Reduction of the capability set available through `ctx` (see + [Capability surface](#capability-surface-lifecycle-and-context)). +- Containment of side effects from a misbehaving scene. The + composition resolver isolates scene *failures* for reliability + (see ADR-028 under [Related guardrails](#related-guardrails)), but + that is a reliability boundary, not a security boundary — a scene + that does not throw can still write to the DOM, schedule timers, + hold listeners, etc. +- Trust of any third-party, user-submitted, plugin, marketplace, or + remotely loaded scene module. The runtime has no such surface today + (see [Third-party / user-submitted scenes — non-goal](#third-party--user-submitted-scenes--non-goal)). + +The phrase "validated scene" in code review, comments, or docs means +"its declarative shape was checked." It does not mean "this scene is +safe to run." + +## Capability surface (lifecycle and context) + +Every active scene receives a `WorkbenchSceneCtx` (in +[`src/runtime/scene-loader.ts`](../src/runtime/scene-loader.ts)) +constructed by the loader per navigation. The full set of capabilities +a scene can reach the runtime through is: + +| Field | What it grants | +|-------|----------------| +| `ctx.stage` | The workbench stage DOM element (or `null` in Node tests). Scenes mount, mutate, and tear down DOM through this handle in `create(ctx)` and `cleanup(ctx)`. | +| `ctx.presenter` | The per-navigation `PresenterController` under `mode=present` (undefined under other modes). Scenes that subscribe to advance/pause commands use this seam; the controller auto-detaches on the navigation's `AbortSignal`. | +| `ctx.chrome` | Optional L2 chrome slot refs (title, brand, centerpiece, lower-third, tag, act-frame, flash). Scenes built from the L2 template library address chrome slots through this field rather than ambient `document` lookups. | +| `ctx.mode` | The effective workbench mode (`present` / `standalone` / `loop` / `paused` / `scrub` / `screenshot` / `prompter` / `rehearsal`). Read-only — scene code can branch on it but not change it. | +| `ctx.gsap` | The GSAP instance scenes build timelines with in `timeline(ctx)`. Scenes call `ctx.gsap.timeline()` rather than importing GSAP directly. | +| `ctx.audio` | The per-navigation `AudioService` (load / play / fade / stop / `stopGroup` / mute). Scoped to the navigation's `AbortSignal`; per-scene sound is unloaded on cleanup. | +| `ctx.rng` | A deterministic seeded random generator (one float per call). Scoped per occurrence — distinct activations of the same scene id get distinct streams. | +| `ctx.activation` | The per-occurrence identity `{ sceneId, entryIndex, occurrence }`. Lets a scene own its occurrence's DOM, listeners, and state without colliding with sibling occurrences. | + +The lifecycle hooks themselves: + +- `create(ctx)` — mount DOM, set up listeners, register audio, + allocate per-scene resources. Runs once per occurrence at scene + entry. Application-privileged. +- `timeline(ctx)` — return the scene's `gsap.timeline()`. Runs once + per occurrence after `create(ctx)`. Application-privileged. +- `cleanup(ctx)` — tear down everything `create(ctx)` and + `timeline(ctx)` allocated. Mandatory per PUL-P001 and + [ADR-008](adrs/008-agent-native-authoring.md) #10. Runs once per + occurrence at scene exit. Application-privileged. + +The fact that this surface is the whole capability set is significant +twice: scene authors know exactly which seams the runtime owns, and +any future sandbox would have to parameterize this surface (see +[Future sandbox seam](#future-sandbox-seam)). + +## Related guardrails (narrower scopes, not sandboxing) + +These existing gates each cover a slice of "what the runtime can +trust." None of them is a code sandbox; do not present any of them as +one. + +- **PUL-Q007 source policy** — + [`tests/runtime/policy-q007-remote-code-execution.test.ts`](../tests/runtime/policy-q007-remote-code-execution.test.ts) + bans `eval`, `new Function(...)`, `Function(...)`, and dynamic + `import(specifier)` whose specifier is a remote URL or non-static + expression in authored runtime source (`src/**/*.ts`). It catches + attempts to execute code that is not present in the published + bundle. It does not transform a bundled scene module into + untrusted-safe code, and it does not restrict what bundled scene + code can do once it runs. + See + [`docs/design/pul-q007-runtime-code-execution-preflight.md`](design/pul-q007-runtime-code-execution-preflight.md). +- **Asset URL and credential policy** — [`docs/asset-url-policy.md`](asset-url-policy.md) + plus the `AssetPreloaderOptions` seams in + [`src/runtime/asset-preloader.ts`](../src/runtime/asset-preloader.ts) + govern resource URLs (scheme allowlist, `baseUrl`, redirects) and + credential delivery (no global `Authorization`, per-origin custom + `fetch`). It is a URL and credential gate, not a code sandbox. A + scene that declares only allowed asset URLs is no more or less + trusted as executable code than one that doesn't. +- **Scene-level failure isolation** — + [ADR-028](adrs/028-scene-level-error-isolation.md) and the resolver + in [`src/runtime/composition-resolver.ts`](../src/runtime/composition-resolver.ts) + keep a thrown scene from halting the composition, attempt cleanup + for the failing scene, and surface a redacted diagnostic. That is a + *reliability* contract — the next scene gets to run — not a + *security* contract. A scene that does not throw is not contained + by it. + +## Third-party / user-submitted scenes — non-goal + +Pulsar does not currently accept third-party, user-submitted, plugin, +marketplace, remote-registry, or upload-flow scene modules. There is +no import resolver, no remote registry client, no plugin loader, no +upload endpoint, no code-signing flow, no CSP that the runtime owns, +no iframe/worker isolation that the runtime owns, and no per-scene +`trusted` / `sandboxed` / `origin` / `author` field on the +`SceneModule` contract. + +A change that adds any of those is not in scope for any current +requirement, and the existing structural gates do not silently extend +to cover them. Until a future requirement designs isolation, the +expected position is "we run only the scene catalog this repository +ships." + +## Future sandbox seam + +If a future requirement accepts untrusted or third-party scene +execution, the implementation must define a separate import and +execution model and a *capability-reduced* `ctx` at the runtime +entrypoint that chooses which scene catalog is loaded. That design +must be parameterized by an execution profile and the granted +capabilities — not by ad-hoc per-scene booleans on `SceneModule` and +not by a deployment toggle that flips arbitrary scene code from +"untrusted" to "trusted." + +A complete future sandbox would have to cover: module loading, DOM +authority, network and asset policy (which is already a separate +gate), audio, timelines, presenter and control surfaces, cleanup +guarantees, diagnostics redaction, and host or browser isolation +(iframe / worker / web sandbox / SES / similar). None of that exists +today, and the trust model documented above is the position the +runtime takes until such a design is accepted. + +The preflight note for issue #102 — +[`docs/design/issue-102-scene-module-trust-boundary-preflight.md`](design/issue-102-scene-module-trust-boundary-preflight.md) — +records the boundary in more detail and is the binding guardrail for +this doc. + +## Related + +- [`docs/asset-url-policy.md`](asset-url-policy.md) — asset URL and + credential policy (narrower scope). +- [`docs/design/issue-102-scene-module-trust-boundary-preflight.md`](design/issue-102-scene-module-trust-boundary-preflight.md) + — the design preflight that binds this doc. +- [`docs/design/pul-q007-runtime-code-execution-preflight.md`](design/pul-q007-runtime-code-execution-preflight.md) + — PUL-Q007 boundary. +- [`tests/runtime/policy-q007-remote-code-execution.test.ts`](../tests/runtime/policy-q007-remote-code-execution.test.ts) + — PUL-Q007 source-policy gate. +- [ADR-001](adrs/001-custom-experience-runtime.md) — the runtime owns + the scene/composition model rather than delegating to a slide + framework. +- [ADR-002](adrs/002-scene-registry-and-compositions.md) — scene and + composition contract. +- [ADR-008](adrs/008-agent-native-authoring.md) — scene contract and + mandatory cleanup invariant. +- [ADR-028](adrs/028-scene-level-error-isolation.md) — scene + lifecycle failure isolation (reliability, not security). +- [`src/runtime/scene.ts`](../src/runtime/scene.ts) — `SceneModule`, + `assertSceneModule()`. +- [`src/runtime/scene-loader.ts`](../src/runtime/scene-loader.ts) — + `WorkbenchSceneCtx` capability surface. +- [`src/runtime/validation.ts`](../src/runtime/validation.ts) — + `validateRuntime()`. diff --git a/tests/runtime/policy-scene-trust-model-doc.test.ts b/tests/runtime/policy-scene-trust-model-doc.test.ts new file mode 100644 index 0000000..7c34e87 --- /dev/null +++ b/tests/runtime/policy-scene-trust-model-doc.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import * as ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +import { REPO_ROOT, parseSource } from './source-policy'; + +// Issue #102 — scene module trust boundary documentation. +// +// `docs/scene-trust-model.md` is the canonical user-facing statement +// of the scene trust model: scenes are trusted application code, +// runtime validation is shape-only, and there is no sandbox. Issue +// #102's acceptance criterion 4 ("Identify APIs that scene code can +// affect through lifecycle hooks and context") names the structural +// surface the doc enumerates — every field on `WorkbenchSceneCtx` (the +// `ctx.X` an active scene receives, in `src/runtime/scene-loader.ts`) +// and every lifecycle hook on `SceneModule` (any member typed as +// `SceneLifecycleFn`, in `src/runtime/scene.ts`). +// +// This test pins the doc to the types. Without it, adding a new ctx +// field (`ctx.network`, etc.) or a fourth lifecycle hook would silently +// drift the trust-model doc away from the runtime's actual capability +// surface — exactly the failure mode AC4 exists to prevent. Both +// checks fail loudly with the missing identifier in the assertion +// message so the contributor sees what to add to the doc. +// +// The lifecycle-hook check derives its set from the live `SceneModule` +// interface rather than a hardcoded list — a hardcoded constant would +// only ever check the names it already knows about, which would let a +// fourth hook ship undocumented (test-quality cycle 1 finding F1). +// `lifecycleMembersOf` filters interface members down to property +// signatures whose declared type references `SceneLifecycleFn`, so any +// future hook added to the interface is automatically included in the +// doc-coverage assertion. + +const DOC_PATH = join(REPO_ROOT, 'docs/scene-trust-model.md'); +const CTX_SRC = join(REPO_ROOT, 'src/runtime/scene-loader.ts'); +const SCENE_SRC = join(REPO_ROOT, 'src/runtime/scene.ts'); +const LIFECYCLE_TYPE_NAME = 'SceneLifecycleFn'; + +function findInterface(file: string, interfaceName: string): ts.InterfaceDeclaration { + const text = readFileSync(file, 'utf8'); + const sf = parseSource(text, file); + for (const stmt of sf.statements) { + if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === interfaceName) { + return stmt; + } + } + throw new Error(`interface ${interfaceName} not found in ${file}`); +} + +function memberNamesOf(file: string, interfaceName: string): readonly string[] { + const decl = findInterface(file, interfaceName); + const names: string[] = []; + for (const m of decl.members) { + if ( + (ts.isPropertySignature(m) || ts.isMethodSignature(m)) && + m.name && + ts.isIdentifier(m.name) + ) { + names.push(m.name.text); + } + } + return names; +} + +function lifecycleMembersOf(file: string, interfaceName: string): readonly string[] { + const decl = findInterface(file, interfaceName); + const names: string[] = []; + for (const m of decl.members) { + if (!ts.isPropertySignature(m) || !m.name || !ts.isIdentifier(m.name) || !m.type) continue; + if (!ts.isTypeReferenceNode(m.type)) continue; + const typeName = m.type.typeName; + if (!ts.isIdentifier(typeName)) continue; + if (typeName.text === LIFECYCLE_TYPE_NAME) { + names.push(m.name.text); + } + } + return names; +} + +describe('docs/scene-trust-model.md — capability surface drift gate', () => { + it('names every WorkbenchSceneCtx field as `ctx.`', () => { + const doc = readFileSync(DOC_PATH, 'utf8'); + const ctxFields = memberNamesOf(CTX_SRC, 'WorkbenchSceneCtx'); + expect(ctxFields.length).toBeGreaterThan(0); + const missing = ctxFields.filter((field) => !doc.includes(`ctx.${field}`)); + expect(missing).toEqual([]); + }); + + it('names every SceneModule SceneLifecycleFn member as `(ctx)`', () => { + const doc = readFileSync(DOC_PATH, 'utf8'); + const lifecycleHooks = lifecycleMembersOf(SCENE_SRC, 'SceneModule'); + expect( + lifecycleHooks.length, + `SceneModule must declare at least one ${LIFECYCLE_TYPE_NAME} member`, + ).toBeGreaterThan(0); + const missingFromDoc = lifecycleHooks.filter((hook) => !doc.includes(`${hook}(ctx)`)); + expect(missingFromDoc, 'trust-model doc must name each lifecycle hook').toEqual([]); + }); +});