From 3810065613697a05a0a2eb3bfef785e5880c78b8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 18:07:04 +0900 Subject: [PATCH 01/15] Make two-pass context phases explicit Replace the sentinel-based SourceContext phase contract with an explicit SourceContextRequest object. Two-pass contexts now receive phase: "phase1" or phase: "phase2" requests, which removes the ambiguity between the first collection pass and a real successful parser value of undefined. Update the core runner plumbing, remove the config-specific undefined wrapping workaround, and add regression coverage for the undefined first-pass case. Refresh manual getAnnotations() callers, examples, and documentation so they use the new phase-two request shape. Update the changelog and related docs for the breaking API change. Fixes https://github.com/dahlia/optique/issues/271 Co-Authored-By: OpenAI Codex --- CHANGES.md | 17 +- docs/concepts/extend.md | 98 ++-- docs/concepts/runners.md | 7 +- docs/cookbook.md | 5 +- docs/integrations/config.md | 13 +- docs/integrations/env.md | 5 +- .../interactive-env-config-composition.ts | 5 +- packages/config/src/index.test.ts | 478 ++++++++---------- packages/config/src/index.ts | 44 +- packages/config/src/run.test.ts | 42 +- packages/core/src/context.test.ts | 74 ++- packages/core/src/context.ts | 90 +++- packages/core/src/facade.test.ts | 259 +++++++--- packages/core/src/facade.ts | 104 ++-- packages/env/src/index.test.ts | 4 +- packages/env/src/index.ts | 5 +- packages/inquirer/src/index.test.ts | 114 +++-- packages/run/src/run.test.ts | 59 ++- 18 files changed, 825 insertions(+), 598 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f6eb152a3..603c6fce8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,14 @@ To be released. ### @optique/core + - Replaced the sentinel-based two-pass `SourceContext` contract with an + explicit `SourceContextRequest` object. `getAnnotations()` and + `getInternalAnnotations()` now receive `phase: "phase1"` / `"phase2"` + requests, so successful first-pass values of `undefined` are no longer + ambiguous. This removes the need for context-local `undefined` wrapping + workarounds and fixes custom two-pass contexts that previously could not + distinguish phase 1 from a real `undefined` parse result. [[#271], [#786]] + - Added the optional `Parser.validateValue()` method, which lets a parser check whether an arbitrary value satisfies its underlying `ValueParser`'s constraints (e.g., regex patterns, numeric bounds, @@ -171,8 +179,9 @@ To be released. - `SourceContext.getInternalAnnotations()`: optional method for contexts to inject additional annotations during collection - - `SourceContext.finalizeParsed()`: optional method for contexts to - transform parsed values before phase-2 annotation collection + - `SourceContextRequest`: explicit phase-1 / phase-2 request object for + `SourceContext.getAnnotations()` and + `SourceContext.getInternalAnnotations()` - `Parser.shouldDeferCompletion()`: optional method that combinators (`optional()`, `withDefault()`, `group()`) forward from inner parsers @@ -221,7 +230,7 @@ To be released. same context returned an empty annotation object in phase 2. In two-phase runs, each context's phase-2 annotation set is now treated as that context's final snapshot for the second parse pass. Returning `{}` from - `getAnnotations(parsed)` now clears that context's earlier phase-1 + phase-two `getAnnotations()` now clears that context's earlier phase-1 contribution instead of letting stale data override later contexts. [[#231], [#782]] @@ -1396,6 +1405,7 @@ To be released. [#262]: https://github.com/dahlia/optique/issues/262 [#264]: https://github.com/dahlia/optique/issues/264 [#269]: https://github.com/dahlia/optique/issues/269 +[#271]: https://github.com/dahlia/optique/issues/271 [#275]: https://github.com/dahlia/optique/issues/275 [#279]: https://github.com/dahlia/optique/issues/279 [#290]: https://github.com/dahlia/optique/issues/290 @@ -1670,6 +1680,7 @@ To be released. [#782]: https://github.com/dahlia/optique/pull/782 [#783]: https://github.com/dahlia/optique/pull/783 [#784]: https://github.com/dahlia/optique/pull/784 +[#786]: https://github.com/dahlia/optique/pull/786 ### @optique/config diff --git a/docs/concepts/extend.md b/docs/concepts/extend.md index 997d6b991..b566c9fc9 100644 --- a/docs/concepts/extend.md +++ b/docs/concepts/extend.md @@ -730,16 +730,25 @@ API reference - `id: symbol` — Unique identifier for the context - `phase: SourceContextPhase` — Required policy declaring whether this context is `"single-pass"` or `"two-pass"` - - `getAnnotations(parsed?, options?): Promise | Annotations` + - `getAnnotations(request?, options?): Promise | Annotations` — Returns annotations to inject into parsing - - `getInternalAnnotations?(parsed, annotations): Annotations | undefined` + - `getInternalAnnotations?(request, annotations): Annotations | undefined` — Optional hook called after `getAnnotations()` to inject additional internal annotations (e.g., phase-specific markers). Returns additional annotations to merge, or `undefined` to add nothing. - - `finalizeParsed?(parsed): unknown` — Optional hook to transform the - parsed value before it is passed to `getAnnotations()` during phase-2 - annotation collection. This allows contexts to distinguish between - “parsed value was `undefined`” and “no parse happened yet.” + +`SourceContextRequest` +: Request object passed to `getAnnotations()` and + `getInternalAnnotations()`. + + `phase: "phase1"` + +: Initial annotation collection before the first parse pass. + +`phase: "phase2"` +: Second annotation collection after a usable first pass. The + `parsed` field holds the first-pass value, which may itself be + `undefined`. Use `ParserValuePlaceholder` in `TRequiredOptions` when the options depend on the parser's result type. @@ -835,7 +844,10 @@ providing annotations to parsers. Each context has: Here's a simple context that provides environment variables to parsers: ~~~~ typescript -import type { SourceContext } from "@optique/core/context"; +import type { + SourceContext, + SourceContextRequest, +} from "@optique/core/context"; const envContext: SourceContext = { id: Symbol.for("@myapp/env"), @@ -891,10 +903,10 @@ const envContext: SourceContext = { const configContext: SourceContext = { id: Symbol.for("@myapp/config"), phase: "two-pass", - async getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // Return empty on first pass - - const result = parsed as { config?: string }; + async getAnnotations(request?: SourceContextRequest) { + if (request == null || request.phase === "phase1") return {}; + + const result = request.parsed as { config?: string }; if (!result.config) return {}; // Load config file asynchronously @@ -909,9 +921,8 @@ const configContext: SourceContext = { The single-pass `envContext` reads environment variables directly and doesn't need any parsed values. The two-pass `configContext`, however, needs to know the config file path from the parsed `--config` option before it can load the -file. When `parsed` is `undefined` (phase 1), it can return empty data or a -provisional snapshot; phase 2 still runs because the context explicitly opted -into `"two-pass"`. +file. Phase 1 and phase 2 are explicit through `request.phase`, so a real +phase-two parsed value of `undefined` is no longer ambiguous. Using `runWith()` @@ -989,13 +1000,15 @@ When two-pass contexts are present, `runWith()` automatically performs two-phase parsing: 1. *Phase 1*: Parse with phase-1 annotations from all contexts -2. *Phase 2*: Call `getAnnotations(parsed)` on two-pass contexts with the - parsed result, then parse again with the merged final annotations +2. *Phase 2*: Call `getAnnotations({ phase: "phase2", parsed })` on + two-pass contexts with the parsed result, then parse again with the + merged final annotations For each two-pass context, the phase-2 return value replaces that context's phase-1 annotation set for the final parse. This means returning an empty -object from `getAnnotations(parsed)` clears any annotations the same context -contributed during phase 1. Single-pass contexts keep their phase-1 snapshot. +object from phase-two `getAnnotations()` clears any annotations the same +context contributed during phase 1. Single-pass contexts keep their phase-1 +snapshot. This ensures that: @@ -1076,7 +1089,11 @@ accepts a prefix. This pattern allows different applications to use their own environment variable naming conventions: ~~~~ typescript -import type { SourceContext, Annotations } from "@optique/core/context"; +import type { + Annotations, + SourceContext, + SourceContextRequest, +} from "@optique/core/context"; const envKey = Symbol.for("@myapp/env"); @@ -1130,10 +1147,12 @@ export function createConfigContext(): SourceContext { return { id: configKey, phase: "two-pass", - async getAnnotations(parsed?: unknown): Promise { - if (parsed === undefined) return {}; // First pass - no config yet - - const result = parsed as { config?: string }; + async getAnnotations( + request?: SourceContextRequest, + ): Promise { + if (request == null || request.phase === "phase1") return {}; + + const result = request.parsed as { config?: string }; if (!result.config) return {}; // No config file specified try { @@ -1151,10 +1170,10 @@ export function createConfigContext(): SourceContext { const configContext = createConfigContext(); ~~~~ -Note the defensive checks: when `parsed` is `undefined` (first pass), return -empty. When the user didn't specify `--config`, return empty. When the file -can't be read or parsed, return empty. This ensures the context never throws -and gracefully degrades when config isn't available. +Note the defensive checks: when `request.phase` is `"phase1"`, return empty. +When the user didn't specify `--config`, return empty. When the file can't be +read or parsed, return empty. This ensures the context never throws and +gracefully degrades when config isn't available. ### Creating a type-safe config context @@ -1165,9 +1184,10 @@ the caller must provide a `getConfigPath` function: ~~~~ typescript import type { - SourceContext, Annotations, ParserValuePlaceholder, + SourceContext, + SourceContextRequest, } from "@optique/core/context"; const configKey = Symbol.for("@myapp/config"); @@ -1184,19 +1204,24 @@ interface ConfigContextOptions { } // The context type includes required options -interface ConfigContext extends SourceContext { - getConfigPath?: (parsed: unknown) => string | undefined; -} +type ConfigContext = SourceContext; export function createConfigContext(): ConfigContext { - const context: ConfigContext = { + return { id: configKey, phase: "two-pass", - async getAnnotations(parsed?: unknown): Promise { - if (parsed === undefined) return {}; - + async getAnnotations( + request?: SourceContextRequest, + options?: ConfigContextOptions, + ): Promise { + if (request == null || request.phase === "phase1") return {}; + + const parsed = request.parsed; + // Use the injected getConfigPath function - const configPath = context.getConfigPath?.(parsed); + const configPath = options?.getConfigPath( + parsed as ParserValuePlaceholder, + ); if (!configPath) return {}; try { @@ -1208,7 +1233,6 @@ export function createConfigContext(): ConfigContext { } } }; - return context; } ~~~~ diff --git a/docs/concepts/runners.md b/docs/concepts/runners.md index 0061ab649..9449ff198 100644 --- a/docs/concepts/runners.md +++ b/docs/concepts/runners.md @@ -1259,9 +1259,10 @@ When `contexts` is provided, the runner delegates to `runWith()` (or single-pass and two-pass contexts automatically and performs two-phase parsing only when needed. In two-phase runs, each two-pass context's phase-two annotations replace that same context's phase-one contribution for the final -parse, so returning an empty object from `getAnnotations(parsed)` clears that -context's earlier annotations. Context-specific options like `getConfigPath` -are passed through to the contexts via the `contextOptions` property. +parse, so returning an empty object from +`getAnnotations({ phase: "phase2", parsed })` clears that context's earlier +annotations. Context-specific options like `getConfigPath` are passed +through to the contexts via the `contextOptions` property. For more details on config file integration, see the [config file integration guide](../integrations/config.md). diff --git a/docs/cookbook.md b/docs/cookbook.md index b0c1cefaa..1fafef760 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1397,7 +1397,10 @@ const configContext = createConfigContext({ schema: configSchema }); const args = ["--config", "./config.json"] as const; const configAnnotations = await configContext.getAnnotations( - { config: getConfigPathFromArgs(args) }, + { + phase: "phase2", + parsed: { config: getConfigPathFromArgs(args) }, + }, { getConfigPath: (parsed: { readonly config?: string }) => parsed.config }, ); diff --git a/docs/integrations/config.md b/docs/integrations/config.md index 55a4913df..218ab12be 100644 --- a/docs/integrations/config.md +++ b/docs/integrations/config.md @@ -868,10 +868,12 @@ Returns : `ConfigContext` implementing `SourceContext` interface > [!IMPORTANT] -> If you call `configContext.getAnnotations()` manually, pass the returned -> object to low-level APIs such as `parse()`, `parseAsync()`, -> `parser.complete()`, `suggest()`, or `getDocPage()`. Calling -> `getAnnotations()` alone does not affect later parses. +> If you call `configContext.getAnnotations()` manually, omit the request for +> a phase-1 snapshot or pass `{ phase: "phase2", parsed }` for a phase-two +> snapshot, then pass the returned object to low-level APIs such as +> `parse()`, `parseAsync()`, `parser.complete()`, `suggest()`, or +> `getDocPage()`. Calling `getAnnotations()` alone does not affect later +> parses. ### `bindConfig(parser, options)` @@ -925,7 +927,8 @@ per run, so reusing the same `ConfigContext` instance across independent or concurrent runs is safe. When calling `configContext.getAnnotations()` manually, remember that the -call only returns annotations. It does not mutate global state or affect +call only returns annotations. Use `{ phase: "phase2", parsed }` when you +need a manual phase-two snapshot. It does not mutate global state or affect later parses by itself. To use those values with low-level APIs such as `parse()` or `suggestSync()`, pass the returned annotations explicitly. diff --git a/docs/integrations/env.md b/docs/integrations/env.md index b62c7f591..7448f3a04 100644 --- a/docs/integrations/env.md +++ b/docs/integrations/env.md @@ -468,8 +468,9 @@ Returns > [!IMPORTANT] > If you call `envContext.getAnnotations()` manually, pass the returned > object to low-level APIs such as `parse()`, `parseAsync()`, -> `parser.complete()`, `suggest()`, or `getDocPage()`. Calling -> `getAnnotations()` alone does not affect later parses. +> `parser.complete()`, `suggest()`, or `getDocPage()`. Environment contexts +> are single-pass, so calling `getAnnotations()` without a request still +> reads the final snapshot. Calling it alone does not affect later parses. ### `bindEnv(parser, options)` diff --git a/examples/patterns/interactive-env-config-composition.ts b/examples/patterns/interactive-env-config-composition.ts index 87a474f19..5a2c2bd9b 100644 --- a/examples/patterns/interactive-env-config-composition.ts +++ b/examples/patterns/interactive-env-config-composition.ts @@ -49,7 +49,10 @@ const args = Deno.args; // Preload config annotations once and expose them through a single-pass context so // prompt() remains the final fallback after CLI/env/config values. const configAnnotations = await configContext.getAnnotations( - { config: getConfigPathFromArgs(args) }, + { + phase: "phase2", + parsed: { config: getConfigPathFromArgs(args) }, + }, { getConfigPath: (parsed: { readonly config?: string }) => parsed.config }, ); diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index d68c6898c..dcc87101d 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -12,6 +12,7 @@ import { tuple, } from "@optique/core/constructs"; import { getAnnotations, injectAnnotations } from "@optique/core/annotations"; +import type { SourceContextRequest } from "@optique/core/context"; import { dependency } from "@optique/core/dependency"; import { map, multiple, optional, withDefault } from "@optique/core/modifiers"; import { constant, fail, flag, option } from "@optique/core/primitives"; @@ -43,6 +44,10 @@ function requireValue(value: T | undefined, message: string): T { return value; } +function phase2(parsed: T): SourceContextRequest { + return { phase: "phase2", parsed }; +} + describe("createConfigContext", () => { test("creates a config context with Standard Schema", () => { const schema = z.object({ @@ -102,21 +107,18 @@ describe("createConfigContext", () => { const context = createConfigContext({ schema }); let receivedParsed: unknown; - const annotations = await context.getAnnotations( - parsed, - { - load: (value: unknown) => { - receivedParsed = value; - return { - config: { host: expectedHost }, - meta: { - configDir: "/app", - configPath: "/app/config.json", - } satisfies ConfigMeta, - }; - }, + const annotations = await context.getAnnotations(phase2(parsed), { + load: (value: unknown) => { + receivedParsed = value; + return { + config: { host: expectedHost }, + meta: { + configDir: "/app", + configPath: "/app/config.json", + } satisfies ConfigMeta, + }; }, - ); + }); assert.equal(receivedParsed, parsed); const contextAnnotation = annotations[context.id] as @@ -221,7 +223,7 @@ describe("bindConfig", () => { key: (async (config: { name: string }) => config.name.toUpperCase()) as never, }); - const annotations = await context.getAnnotations({} as never, { + const annotations = await context.getAnnotations(phase2({}), { load: () => ({ config: { name: "alice" }, meta: undefined }), }); assert.throws( @@ -244,7 +246,7 @@ describe("bindConfig", () => { context, key: (() => thenable) as never, }); - const annotations = await context.getAnnotations({} as never, { + const annotations = await context.getAnnotations(phase2({}), { load: () => ({ config: { name: "alice" }, meta: undefined }), }); assert.throws( @@ -268,7 +270,7 @@ describe("bindConfig", () => { context, key: (() => callableThenable) as never, }); - const annotations = await context.getAnnotations({} as never, { + const annotations = await context.getAnnotations(phase2({}), { load: () => ({ config: { name: "alice" }, meta: undefined }), }); assert.throws( @@ -902,10 +904,10 @@ describe("bindConfig", () => { }); try { - const leftAnnotations = await leftContext.getAnnotations(true, { + const leftAnnotations = await leftContext.getAnnotations(phase2(true), { load: () => ({ config: { host: "left.example.com" } }), }); - const rightAnnotations = await rightContext.getAnnotations(true, { + const rightAnnotations = await rightContext.getAnnotations(phase2(true), { load: () => ({ config: { host: "right.example.com" } }), }); const annotations: Annotations = { @@ -1478,10 +1480,7 @@ describe("createConfigContext input validation", () => { const context = createConfigContext({ schema }); assert.throws( () => - context.getAnnotations( - { any: 1 }, - { load: "nope" as never }, - ), + context.getAnnotations(phase2({ any: 1 }), { load: "nope" as never }), { name: "TypeError", message: "Expected load to be a function, but got: string.", @@ -1494,10 +1493,9 @@ describe("createConfigContext input validation", () => { const context = createConfigContext({ schema }); assert.throws( () => - context.getAnnotations( - { any: 1 }, - { getConfigPath: "nope" as never }, - ), + context.getAnnotations(phase2({ any: 1 }), { + getConfigPath: "nope" as never, + }), { name: "TypeError", message: "Expected getConfigPath to be a function, but got: string.", @@ -1509,16 +1507,13 @@ describe("createConfigContext input validation", () => { const schema = z.object({ host: z.string() }); const context = createConfigContext({ schema }); // load takes precedence; getConfigPath should not be validated - const result = context.getAnnotations( - { any: 1 }, - { - load: () => ({ - config: { host: "ok" }, - meta: undefined, - }), - getConfigPath: "nope" as never, - }, - ); + const result = context.getAnnotations(phase2({ any: 1 }), { + load: () => ({ + config: { host: "ok" }, + meta: undefined, + }), + getConfigPath: "nope" as never, + }); assert.ok(result != null); }); @@ -1527,10 +1522,9 @@ describe("createConfigContext input validation", () => { const context = createConfigContext({ schema }); assert.throws( () => - context.getAnnotations( - {}, - { getConfigPath: (() => ({ path: "./foo.json" })) as never }, - ), + context.getAnnotations(phase2({}), { + getConfigPath: (() => ({ path: "./foo.json" })) as never, + }), { name: "TypeError", message: @@ -1544,12 +1538,9 @@ describe("createConfigContext input validation", () => { const context = createConfigContext({ schema }); assert.throws( () => - context.getAnnotations( - {}, - { - getConfigPath: (() => Promise.resolve("./foo.json")) as never, - }, - ), + context.getAnnotations(phase2({}), { + getConfigPath: (() => Promise.resolve("./foo.json")) as never, + }), { name: "TypeError", message: @@ -1563,10 +1554,9 @@ describe("createConfigContext input validation", () => { const context = createConfigContext({ schema }); assert.throws( () => - context.getAnnotations( - {}, - { getConfigPath: (() => null) as never }, - ), + context.getAnnotations(phase2({}), { + getConfigPath: (() => null) as never, + }), { name: "TypeError", message: @@ -1580,10 +1570,9 @@ describe("createConfigContext input validation", () => { const context = createConfigContext({ schema }); assert.throws( () => - context.getAnnotations( - {}, - { getConfigPath: (() => 123) as never }, - ), + context.getAnnotations(phase2({}), { + getConfigPath: (() => 123) as never, + }), { name: "TypeError", message: @@ -1602,11 +1591,7 @@ describe("load() return value validation", () => { test("rejects non-object return value from load()", () => { const context = createNameContext(); assert.throws( - () => - context.getAnnotations( - {}, - { load: (() => 123) as never }, - ), + () => context.getAnnotations(phase2({}), { load: (() => 123) as never }), { name: "TypeError", message: "Expected load() to return an object, but got: number.", @@ -1616,19 +1601,17 @@ describe("load() return value validation", () => { test("returns empty annotations when load() returns null", () => { const context = createNameContext(); - const annotations = context.getAnnotations( - {}, - { load: () => null }, - ); + const annotations = context.getAnnotations(phase2({}), { + load: () => null, + }); assert.deepStrictEqual(annotations, {}); }); test("returns empty annotations when load() returns undefined", () => { const context = createNameContext(); - const annotations = context.getAnnotations( - {}, - { load: () => undefined }, - ); + const annotations = context.getAnnotations(phase2({}), { + load: () => undefined, + }); assert.deepStrictEqual(annotations, {}); }); @@ -1636,10 +1619,9 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { load: () => ({ config: undefined, meta: undefined }) }, - ), + context.getAnnotations(phase2({}), { + load: () => ({ config: undefined, meta: undefined }), + }), { message: /Config validation failed/ }, ); }); @@ -1648,10 +1630,9 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { load: () => ({ config: null, meta: undefined }) }, - ), + context.getAnnotations(phase2({}), { + load: () => ({ config: null, meta: undefined }), + }), { message: /Config validation failed/ }, ); }); @@ -1659,10 +1640,9 @@ describe("load() return value validation", () => { test("permissive schema can transform { config: undefined }", () => { const schema = z.undefined().transform(() => ({ name: "from-schema" })); const context = createConfigContext({ schema }); - const annotations = context.getAnnotations( - {}, - { load: () => ({ config: undefined, meta: undefined }) }, - ); + const annotations = context.getAnnotations(phase2({}), { + load: () => ({ config: undefined, meta: undefined }), + }); const symbols = Object.getOwnPropertySymbols(annotations); assert.equal(symbols.length, 1); const value = (annotations as Record)[symbols[0]] as { @@ -1673,19 +1653,17 @@ describe("load() return value validation", () => { test("returns empty annotations when async load() resolves undefined", async () => { const context = createNameContext(); - const annotations = await context.getAnnotations( - {}, - { load: () => Promise.resolve(undefined) }, - ); + const annotations = await context.getAnnotations(phase2({}), { + load: () => Promise.resolve(undefined), + }); assert.deepStrictEqual(annotations, {}); }); test("returns empty annotations when async load() resolves null", async () => { const context = createNameContext(); - const annotations = await context.getAnnotations( - {}, - { load: () => Promise.resolve(null) }, - ); + const annotations = await context.getAnnotations(phase2({}), { + load: () => Promise.resolve(null), + }); assert.deepStrictEqual(annotations, {}); }); @@ -1693,12 +1671,9 @@ describe("load() return value validation", () => { const context = createNameContext(); await assert.rejects( async () => - await context.getAnnotations( - {}, - { - load: () => Promise.resolve({ config: undefined, meta: undefined }), - }, - ), + await context.getAnnotations(phase2({}), { + load: () => Promise.resolve({ config: undefined, meta: undefined }), + }), { message: /Config validation failed/ }, ); }); @@ -1707,10 +1682,9 @@ describe("load() return value validation", () => { const context = createNameContext(); await assert.rejects( async () => - await context.getAnnotations( - {}, - { load: () => Promise.resolve({ config: null, meta: undefined }) }, - ), + await context.getAnnotations(phase2({}), { + load: () => Promise.resolve({ config: null, meta: undefined }), + }), { message: /Config validation failed/ }, ); }); @@ -1722,10 +1696,9 @@ describe("load() return value validation", () => { key: "name", }); - const annotations = context.getAnnotations( - {}, - { load: () => ({ config: { name: "configured" }, meta: undefined }) }, - ); + const annotations = context.getAnnotations(phase2({}), { + load: () => ({ config: { name: "configured" }, meta: undefined }), + }); if (annotations instanceof Promise) { throw new TypeError("Expected synchronous annotations."); } @@ -1760,10 +1733,9 @@ describe("load() return value validation", () => { key: "name", }); - const annotations = context.getAnnotations( - {}, - { load: () => ({ config: { name: "configured" }, meta: undefined }) }, - ); + const annotations = context.getAnnotations(phase2({}), { + load: () => ({ config: { name: "configured" }, meta: undefined }), + }); if (annotations instanceof Promise) { throw new TypeError("Expected synchronous annotations."); } @@ -1774,15 +1746,12 @@ describe("load() return value validation", () => { assert.throws( () => - context.getAnnotations( - {}, - { - load: () => ({ - config: { name: 123 }, - meta: undefined, - }), - }, - ), + context.getAnnotations(phase2({}), { + load: () => ({ + config: { name: 123 }, + meta: undefined, + }), + }), { message: /Config validation failed/ }, ); @@ -1803,10 +1772,9 @@ describe("load() return value validation", () => { key: "name", }); - const annotations = context.getAnnotations( - {}, - { load: () => ({ config: { name: "configured" }, meta: undefined }) }, - ); + const annotations = context.getAnnotations(phase2({}), { + load: () => ({ config: { name: "configured" }, meta: undefined }), + }); if (annotations instanceof Promise) { throw new TypeError("Expected synchronous annotations."); } @@ -1816,10 +1784,7 @@ describe("load() return value validation", () => { ); assert.deepEqual( - context.getAnnotations( - {}, - { getConfigPath: () => undefined }, - ), + context.getAnnotations(phase2({}), { getConfigPath: () => undefined }), {}, ); @@ -1835,11 +1800,7 @@ describe("load() return value validation", () => { test("rejects array return value from load()", () => { const context = createNameContext(); assert.throws( - () => - context.getAnnotations( - {}, - { load: (() => []) as never }, - ), + () => context.getAnnotations(phase2({}), { load: (() => []) as never }), { name: "TypeError", message: "Expected load() to return an object, but got: array.", @@ -1851,10 +1812,9 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { load: (() => ({ meta: { source: "x" } })) as never }, - ), + context.getAnnotations(phase2({}), { + load: (() => ({ meta: { source: "x" } })) as never, + }), { name: "TypeError", message: "Expected load() result to have a config property.", @@ -1866,15 +1826,12 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { - load: (() => ({ - then: (resolve: (value: unknown) => void) => - resolve({ config: { name: "ALICE" }, meta: undefined }), - })) as never, - }, - ), + context.getAnnotations(phase2({}), { + load: (() => ({ + then: (resolve: (value: unknown) => void) => + resolve({ config: { name: "ALICE" }, meta: undefined }), + })) as never, + }), { name: "TypeError", message: "Expected load() to return a plain object or Promise, " + @@ -1895,10 +1852,9 @@ describe("load() return value validation", () => { resolve({ config: { name: "ALICE" }, meta: undefined }); }, }; - const annotations = await context.getAnnotations( - {}, - { load: (() => crossRealmPromise) as never }, - ); + const annotations = await context.getAnnotations(phase2({}), { + load: (() => crossRealmPromise) as never, + }); assert.ok(annotations != null); const symbols = Object.getOwnPropertySymbols(annotations); assert.equal(symbols.length, 1); @@ -1913,14 +1869,11 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { - load: (() => ({ - then: (resolve: (value: unknown) => void) => resolve(123), - })) as never, - }, - ), + context.getAnnotations(phase2({}), { + load: (() => ({ + then: (resolve: (value: unknown) => void) => resolve(123), + })) as never, + }), { name: "TypeError", message: "Expected load() to return a plain object or Promise, " + @@ -1933,15 +1886,12 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { - load: (() => ({ - config: Promise.resolve({ name: "ALICE" }) as never, - meta: undefined, - })) as never, - }, - ), + context.getAnnotations(phase2({}), { + load: (() => ({ + config: Promise.resolve({ name: "ALICE" }) as never, + meta: undefined, + })) as never, + }), { name: "TypeError", message: "Expected config in load() result to not be a Promise. " + @@ -1954,18 +1904,15 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { - load: (() => ({ - config: { name: "ALICE" }, - meta: Promise.resolve({ - configPath: "x", - configDir: ".", - }) as never, - })) as never, - }, - ), + context.getAnnotations(phase2({}), { + load: (() => ({ + config: { name: "ALICE" }, + meta: Promise.resolve({ + configPath: "x", + configDir: ".", + }) as never, + })) as never, + }), { name: "TypeError", message: "Expected meta in load() result to not be a Promise. " + @@ -1980,15 +1927,12 @@ describe("load() return value validation", () => { then: z.function(), }); const context = createConfigContext({ schema }); - const annotations = context.getAnnotations( - {}, - { - load: (() => ({ - config: { name: "ALICE", then: () => "domain method" }, - meta: undefined, - })) as never, - }, - ); + const annotations = context.getAnnotations(phase2({}), { + load: (() => ({ + config: { name: "ALICE", then: () => "domain method" }, + meta: undefined, + })) as never, + }); assert.ok(annotations != null); const symbols = Object.getOwnPropertySymbols(annotations); assert.equal(symbols.length, 1); @@ -2007,19 +1951,16 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { - load: (() => ({ - config: { - [Symbol.toStringTag]: "Promise", - then: (resolve: (v: unknown) => void) => - resolve({ name: "ALICE" }), - }, - meta: undefined, - })) as never, - }, - ), + context.getAnnotations(phase2({}), { + load: (() => ({ + config: { + [Symbol.toStringTag]: "Promise", + then: (resolve: (v: unknown) => void) => + resolve({ name: "ALICE" }), + }, + meta: undefined, + })) as never, + }), { name: "TypeError", message: "Expected config in load() result to not be a Promise. " + @@ -2032,19 +1973,16 @@ describe("load() return value validation", () => { const context = createNameContext(); assert.throws( () => - context.getAnnotations( - {}, - { - load: (() => ({ - config: { name: "ALICE" }, - meta: { - [Symbol.toStringTag]: "Promise", - then: (resolve: (v: unknown) => void) => - resolve({ configPath: "x", configDir: "." }), - }, - })) as never, - }, - ), + context.getAnnotations(phase2({}), { + load: (() => ({ + config: { name: "ALICE" }, + meta: { + [Symbol.toStringTag]: "Promise", + then: (resolve: (v: unknown) => void) => + resolve({ configPath: "x", configDir: "." }), + }, + })) as never, + }), { name: "TypeError", message: "Expected meta in load() result to not be a Promise. " + @@ -2057,10 +1995,9 @@ describe("load() return value validation", () => { const context = createNameContext(); await assert.rejects( () => - context.getAnnotations( - {}, - { load: (() => Promise.resolve(123)) as never }, - ) as Promise, + context.getAnnotations(phase2({}), { + load: (() => Promise.resolve(123)) as never, + }) as Promise, { name: "TypeError", message: "Expected load() to return an object, but got: number.", @@ -2072,12 +2009,9 @@ describe("load() return value validation", () => { const context = createNameContext(); await assert.rejects( () => - context.getAnnotations( - {}, - { - load: (() => Promise.resolve({ meta: undefined })) as never, - }, - ) as Promise, + context.getAnnotations(phase2({}), { + load: (() => Promise.resolve({ meta: undefined })) as never, + }) as Promise, { name: "TypeError", message: "Expected load() result to have a config property.", @@ -2089,16 +2023,13 @@ describe("load() return value validation", () => { const context = createNameContext(); await assert.rejects( () => - context.getAnnotations( - {}, - { - load: (() => - Promise.resolve({ - config: Promise.resolve({ name: "ALICE" }), - meta: undefined, - })) as never, - }, - ) as Promise, + context.getAnnotations(phase2({}), { + load: (() => + Promise.resolve({ + config: Promise.resolve({ name: "ALICE" }), + meta: undefined, + })) as never, + }) as Promise, { name: "TypeError", message: "Expected config in load() result to not be a Promise. " + @@ -2111,16 +2042,13 @@ describe("load() return value validation", () => { const context = createNameContext(); await assert.rejects( () => - context.getAnnotations( - {}, - { - load: (() => - Promise.resolve({ - config: { name: "ALICE" }, - meta: Promise.resolve({ configPath: "x", configDir: "." }), - })) as never, - }, - ) as Promise, + context.getAnnotations(phase2({}), { + load: (() => + Promise.resolve({ + config: { name: "ALICE" }, + meta: Promise.resolve({ configPath: "x", configDir: "." }), + })) as never, + }) as Promise, { name: "TypeError", message: "Expected meta in load() result to not be a Promise. " + @@ -2142,7 +2070,7 @@ describe("createConfigContext error paths", () => { const context = createConfigContext({ schema: asyncSchema }); const annotations = await context.getAnnotations( - { configPath: "/app/config.json" }, + phase2({ configPath: "/app/config.json" }), { load: () => ({ config: { host: "async-host", port: 443 }, @@ -2166,7 +2094,7 @@ describe("createConfigContext error paths", () => { const context = createConfigContext({ schema }); const annotations = await context.getAnnotations( - { configPath: "/app/config.json" }, + phase2({ configPath: "/app/config.json" }), { load: () => ({ config: { host: "meta-less" }, @@ -2199,7 +2127,7 @@ describe("createConfigContext error paths", () => { await assert.rejects( async () => await context.getAnnotations( - { configPath: "/app/config.json" }, + phase2({ configPath: "/app/config.json" }), { load: () => ({ config: { host: "x" }, @@ -2221,7 +2149,10 @@ describe("createConfigContext error paths", () => { // Phase 2 call (parsed is truthy) without required options await assert.rejects( async () => - await context.getAnnotations({ config: "test.json" }, undefined), + await context.getAnnotations( + phase2({ config: "test.json" }), + undefined, + ), TypeError, ); }); @@ -2231,7 +2162,8 @@ describe("createConfigContext error paths", () => { const context = createConfigContext({ schema }); await assert.rejects( - async () => await context.getAnnotations({ config: "test.json" }, {}), + async () => + await context.getAnnotations(phase2({ config: "test.json" }), {}), TypeError, ); }); @@ -2250,7 +2182,7 @@ describe("createConfigContext error paths", () => { const context = createConfigContext({ schema }); const annotations = await context.getAnnotations( - { config: undefined }, + phase2({ config: undefined }), { getConfigPath: () => undefined }, ); @@ -2264,7 +2196,7 @@ describe("createConfigContext error paths", () => { // ENOENT should be handled gracefully (optional config file) const annotations = await context.getAnnotations( - { config: "/nonexistent/path/config.json" }, + phase2({ config: "/nonexistent/path/config.json" }), { getConfigPath: () => "/nonexistent/path/does-not-exist.json", }, @@ -2291,10 +2223,9 @@ describe("createConfigContext error paths", () => { try { await assert.rejects( async () => - await context.getAnnotations( - { config: tmpFile }, - { getConfigPath: () => tmpFile }, - ), + await context.getAnnotations(phase2({ config: tmpFile }), { + getConfigPath: () => tmpFile, + }), RangeError, ); } finally { @@ -2319,10 +2250,9 @@ describe("createConfigContext error paths", () => { try { await assert.rejects( async () => - await context.getAnnotations( - { config: tmpFile }, - { getConfigPath: () => tmpFile }, - ), + await context.getAnnotations(phase2({ config: tmpFile }), { + getConfigPath: () => tmpFile, + }), (error: Error) => { assert.ok(error.message.includes("Failed to parse config file")); return true; @@ -2348,10 +2278,9 @@ describe("createConfigContext error paths", () => { try { await assert.rejects( async () => - await context.getAnnotations( - { config: tmpFile }, - { getConfigPath: () => tmpFile }, - ), + await context.getAnnotations(phase2({ config: tmpFile }), { + getConfigPath: () => tmpFile, + }), (error: Error) => { assert.ok(error.message.includes("Config validation failed")); return true; @@ -2420,10 +2349,9 @@ describe("createConfigContext error paths", () => { key: "host", }); - const annotations = context.getAnnotations( - {}, - { load: () => ({ config: { host: "test-host" }, meta: undefined }) }, - ); + const annotations = context.getAnnotations(phase2({}), { + load: () => ({ config: { host: "test-host" }, meta: undefined }), + }); if (annotations instanceof Promise) { throw new TypeError("Expected synchronous annotations."); } @@ -2441,7 +2369,7 @@ describe("createConfigContext error paths", () => { let receivedParsed: unknown; const annotations = await context.getAnnotations( - { configPath: "/app/config.json" }, + phase2({ configPath: "/app/config.json" }), { load: (parsed: unknown) => { receivedParsed = parsed; @@ -2478,14 +2406,11 @@ describe("createConfigContext error paths", () => { await assert.rejects( async () => - await context.getAnnotations( - { config: "test" }, - { - load: () => { - throw new Error("Load failed."); - }, + await context.getAnnotations(phase2({ config: "test" }), { + load: () => { + throw new Error("Load failed."); }, - ), + }), (error: Error) => { assert.equal(error.message, "Load failed."); return true; @@ -2499,14 +2424,11 @@ describe("createConfigContext error paths", () => { await assert.rejects( async () => - await context.getAnnotations( - { config: "test" }, - { - load: () => ({ - config: { host: 123, port: "not-a-number" }, // invalid types - }), - }, - ), + await context.getAnnotations(phase2({ config: "test" }), { + load: () => ({ + config: { host: 123, port: "not-a-number" }, // invalid types + }), + }), (error: Error) => { assert.ok(error.message.includes("Config validation failed")); return true; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index d3445a3dc..e54cdd76b 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -15,6 +15,7 @@ import type { Annotations, ParserValuePlaceholder, SourceContext, + SourceContextRequest, } from "@optique/core/context"; import type { ExecutionContext, @@ -38,10 +39,6 @@ import { message } from "@optique/core/message"; import { mapModeValue, wrapForMode } from "@optique/core/mode-dispatch"; import type { ValueParserResult } from "@optique/core/valueparser"; -const phase2UndefinedParsedValueKey = Symbol( - "@optique/config/phase2UndefinedParsedValue", -); - /** * Metadata about the loaded config source. * @@ -63,12 +60,6 @@ const phase1ConfigAnnotationMarker = Symbol( "@optique/config/phase1Annotation", ); -function isPhase2UndefinedParsedValue(value: unknown): boolean { - return value != null && - typeof value === "object" && - phase2UndefinedParsedValueKey in value; -} - /** * Options for creating a config context. * @@ -319,11 +310,13 @@ function validateWithSchema( * *@optique/run* to provide configuration file support. Each runner call * receives its own annotation snapshot, so the same `ConfigContext` * instance can be reused safely across independent or concurrent runs. - * When calling `context.getAnnotations()` manually, pass the returned - * annotations to low-level APIs such as `parse()`, `parseAsync()`, - * `parser.complete()`, `suggest()`, or `getDocPage()`. Calling - * `getAnnotations()` by itself does not affect later parses unless those - * returned annotations are explicitly threaded into a low-level API call. + * When calling `context.getAnnotations()` manually, omit the request for a + * phase-1 snapshot or pass `{ phase: "phase2", parsed }` for a phase-two + * snapshot, then thread the returned annotations into low-level APIs such + * as `parse()`, `parseAsync()`, `parser.complete()`, `suggest()`, or + * `getDocPage()`. Calling `getAnnotations()` by itself does not affect + * later parses unless those returned annotations are explicitly threaded + * into a low-level API call. * * @template T The output type of the config schema. * @template TConfigMeta The metadata type for config sources. @@ -377,29 +370,27 @@ export function createConfigContext( id: contextId, schema: rawSchema, phase: "two-pass", - getInternalAnnotations(parsed: unknown, annotations: Annotations) { - if (parsed === undefined) { + getInternalAnnotations( + request: SourceContextRequest, + annotations: Annotations, + ) { + if (request.phase === "phase1") { return { [contextId]: phase1ConfigAnnotationMarker }; } return Object.getOwnPropertySymbols(annotations).includes(contextId) ? undefined : { [contextId]: undefined }; }, - finalizeParsed(parsed: unknown) { - return parsed === undefined - ? { [phase2UndefinedParsedValueKey]: true } - : parsed; - }, getAnnotations( - parsed?: unknown, + request?: SourceContextRequest, runtimeOptions?: unknown, ): Promise | Annotations { // Phase 1 (no parsed result): return no public annotations here. // Runners add the phase-1 unresolved marker through // getInternalAnnotations() so prompt(bindConfig(...)) can defer // interactive fallback without exposing that marker as user data. - if (parsed === undefined) { + if (request == null || request.phase === "phase1") { return {}; } @@ -431,10 +422,7 @@ export function createConfigContext( // At runtime, `parsed` is the actual parser value. The // ParserValuePlaceholder brand is compile-time only. - const parsedValue: unknown = isPhase2UndefinedParsedValue(parsed) - ? undefined - : parsed; - const parsedPlaceholder = parsedValue as ParserValuePlaceholder; + const parsedPlaceholder = request.parsed as ParserValuePlaceholder; const emptyAnnotations = (): Annotations => ({}); diff --git a/packages/config/src/run.test.ts b/packages/config/src/run.test.ts index d9c6603e4..69a275cb5 100644 --- a/packages/config/src/run.test.ts +++ b/packages/config/src/run.test.ts @@ -5,12 +5,15 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { z } from "zod"; import { object } from "@optique/core/constructs"; -import type { SourceContext } from "@optique/core/context"; +import type { + SourceContext, + SourceContextRequest, +} from "@optique/core/context"; import { message } from "@optique/core/message"; import { parse, type Parser } from "@optique/core/parser"; import { fail, flag, option } from "@optique/core/primitives"; import { integer, string } from "@optique/core/valueparser"; -import { withDefault } from "@optique/core/modifiers"; +import { optional, withDefault } from "@optique/core/modifiers"; import { runWith, runWithSync } from "@optique/core/facade"; import type { OptionName } from "@optique/core/usage"; import { bindConfig, createConfigContext } from "./index.ts"; @@ -178,7 +181,8 @@ describe("run with config context", { concurrency: false }, () => { const identityContext: SourceContext = { id: Symbol.for("@test/config-loader-identity"), phase: "two-pass", - getAnnotations(parsed?: unknown) { + getAnnotations(request?: SourceContextRequest) { + const parsed = request?.phase === "phase2" ? request.parsed : undefined; if (parsed != null && typeof parsed === "object") { metadataByParsed.set(parsed as object, "seen"); } @@ -726,6 +730,38 @@ describe("run with config context", { concurrency: false }, () => { ); } + test( + "runs phase-two config loading when the top-level parser returns undefined", + async () => { + const schema = z.object({ + host: z.string(), + }); + const context = createConfigContext({ schema }); + const parser = optional(flag("--debug")); + + let loadCalls = 0; + let observedParsed: boolean | undefined | null = null; + + const result = await runWith(parser, "test", [context], { + contextOptions: { + load: (parsed: boolean | undefined) => { + loadCalls += 1; + observedParsed = parsed; + return { + config: { host: "config-host" }, + meta: undefined, + }; + }, + }, + args: [], + }); + + assert.equal(result, undefined); + assert.equal(loadCalls, 1); + assert.equal(observedParsed, undefined); + }, + ); + test("works with nested config values", async () => { await mkdir(TEST_DIR, { recursive: true }); const configPath = join(TEST_DIR, "test-config-nested.json"); diff --git a/packages/core/src/context.test.ts b/packages/core/src/context.test.ts index f97dcb21c..c01b2dd65 100644 --- a/packages/core/src/context.test.ts +++ b/packages/core/src/context.test.ts @@ -1,8 +1,17 @@ import type { Annotations } from "@optique/core/annotations"; -import type { SourceContext } from "@optique/core/context"; +import type { + SourceContext, + SourceContextRequest, +} from "@optique/core/context"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; +function getPhase2Parsed( + request?: SourceContextRequest, +): T | undefined { + return request?.phase === "phase2" ? request.parsed as T : undefined; +} + describe("SourceContext", () => { describe("interface implementation", () => { it("should allow creating a single-pass context", () => { @@ -32,12 +41,12 @@ describe("SourceContext", () => { const context: SourceContext = { id: configKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + getAnnotations(request?: SourceContextRequest) { + const parsed = getPhase2Parsed<{ config?: string }>(request); + if (request == null || request.phase === "phase1") { return { [configKey]: { phase1: true } }; } - const result = parsed as { config?: string }; - if (!result.config) return {}; + if (!parsed?.config) return {}; return Promise.resolve({ [configKey]: { host: "example.com", port: 8080 }, }); @@ -52,7 +61,8 @@ describe("SourceContext", () => { assert.deepEqual(firstPass[configKey], { phase1: true }); const secondPass = await context.getAnnotations({ - config: "config.json", + phase: "phase2", + parsed: { config: "config.json" }, }); assert.deepEqual(secondPass[configKey], { host: "example.com", @@ -94,15 +104,16 @@ describe("SourceContext", () => { const configContext: SourceContext = { id: configKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + getAnnotations(request?: SourceContextRequest) { + if (request == null || request.phase === "phase1") return {}; return { [configKey]: { host: "config-host" } }; }, }; const envAnnotations = envContext.getAnnotations(); const configAnnotations = configContext.getAnnotations({ - config: "test.json", + phase: "phase2", + parsed: { config: "test.json" }, }); assert.ok(!(envAnnotations instanceof Promise)); @@ -154,13 +165,16 @@ describe("SourceContext", () => { getAnnotations() { return { [key]: { value: "primary" } }; }, - getInternalAnnotations(_parsed, _annotations) { + getInternalAnnotations(_request, _annotations) { return { [internalKey]: { value: "internal" } }; }, }; const annotations = context.getAnnotations() as Annotations; - const internal = context.getInternalAnnotations?.(undefined, annotations); + const internal = context.getInternalAnnotations?.( + { phase: "phase1" }, + annotations, + ); assert.ok(internal != null); assert.deepEqual(internal[internalKey], { value: "internal" }); }); @@ -179,39 +193,15 @@ describe("SourceContext", () => { }); }); - describe("finalizeParsed", () => { - it("should allow a context to transform parsed values", () => { - const key = Symbol("@test/finalize"); - const marker = Symbol("undefined-marker"); - const context: SourceContext = { - id: key, - phase: "two-pass", - getAnnotations() { - return {}; - }, - finalizeParsed(parsed) { - return parsed === undefined ? { [marker]: true } : parsed; - }, - }; - - assert.deepEqual( - context.finalizeParsed?.(undefined), - { [marker]: true }, - ); - assert.equal(context.finalizeParsed?.("hello"), "hello"); - }); - - it("should be optional on SourceContext", () => { - const key = Symbol("@test/no-finalize"); - const context: SourceContext = { - id: key, - phase: "single-pass", - getAnnotations() { - return {}; - }, + describe("request contract", () => { + it("should make phase 2 explicit even when parsed is undefined", () => { + const request: SourceContextRequest = { + phase: "phase2", + parsed: undefined, }; - assert.equal(context.finalizeParsed, undefined); + assert.equal(request.phase, "phase2"); + assert.equal(request.parsed, undefined); }); }); }); diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index f1bda56ae..32d35d748 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -25,6 +25,52 @@ export type { Annotations } from "./annotations.ts"; */ export type SourceContextPhase = "single-pass" | "two-pass"; +/** + * Phase-1 annotation collection request for a {@link SourceContext}. + * + * @since 1.0.0 + */ +export interface SourceContextPhase1Request { + /** + * Indicates that the runner is collecting initial annotations before the + * first parse pass. + */ + readonly phase: "phase1"; +} + +/** + * Phase-2 annotation collection request for a {@link SourceContext}. + * + * @since 1.0.0 + */ +export interface SourceContextPhase2Request { + /** + * Indicates that the runner is recollecting annotations after a usable + * first parse pass. + */ + readonly phase: "phase2"; + + /** + * Parsed result from the first pass, or a best-effort partial value + * extracted from parser state when the first pass reached a usable + * intermediate state but did not complete successfully. + */ + readonly parsed: unknown; +} + +/** + * Request object passed to {@link SourceContext.getAnnotations} and + * {@link SourceContext.getInternalAnnotations}. + * + * This makes phase 1 and phase 2 explicit so successful parser results of + * `undefined` are no longer ambiguous. + * + * @since 1.0.0 + */ +export type SourceContextRequest = + | SourceContextPhase1Request + | SourceContextPhase2Request; + /** * Brand symbol for ParserValuePlaceholder type. * @internal @@ -146,10 +192,11 @@ export interface SourceContext { * This method is called during phase 1 for every context and during phase 2 * only for `two-pass` contexts: * - * 1. *Phase 1*: `parsed` is `undefined`. - * 2. *Phase 2*: `parsed` contains the first pass result, or a best-effort - * partial value extracted from parser state when the first pass reached a - * usable intermediate state but still did not complete successfully. + * 1. *Phase 1*: `request.phase` is `"phase1"`. + * 2. *Phase 2*: `request.phase` is `"phase2"` and `request.parsed` + * contains the first pass result, or a best-effort partial value + * extracted from parser state when the first pass reached a usable + * intermediate state but still did not complete successfully. * Deferred or otherwise unresolved fields may be `undefined`. This * second return value is treated as the context's final annotation * snapshot for the second parse pass, replacing that context's phase-one @@ -157,9 +204,15 @@ export interface SourceContext { * second call is skipped and the original parse failure is reported * instead. * - * @param parsed Optional parsed result from a previous parse pass. - * `single-pass` contexts can ignore this parameter. - * `two-pass` contexts use this to extract or refine data. + * Omitting the request is treated as a manual phase-1 call for + * convenience, so `context.getAnnotations()` continues to work for + * simple one-shot annotation reads. + * + * @param request Optional request describing which collection phase the + * runner is performing. `single-pass` contexts can ignore + * this parameter. `two-pass` contexts should branch on + * `request.phase` rather than inferring phases from + * `request.parsed`. * @param options Optional context-required options provided by the caller * of `runWith()`. These are the options declared via the * `TRequiredOptions` type parameter. @@ -169,44 +222,29 @@ export interface SourceContext { * loading config files). */ getAnnotations( - parsed?: unknown, + request?: SourceContextRequest, options?: unknown, ): Promise | Annotations; /** * Optional hook to provide additional internal annotations during * annotation collection. Called after {@link getAnnotations} with the - * same parsed value and the annotations returned by `getAnnotations()`. + * same request object and the annotations returned by `getAnnotations()`. * * Returns additional annotations to merge, or `undefined` to add nothing. * This enables contexts to inject phase-specific markers without * exposing them through the primary `getAnnotations()` API. * - * @param parsed The parsed result from a previous parse pass, or - * `undefined` during the first pass. + * @param request The request describing the current collection phase. * @param annotations The annotations returned by `getAnnotations()`. * @returns Additional annotations to merge, or `undefined`. * @since 1.0.0 */ getInternalAnnotations?( - parsed: unknown, + request: SourceContextRequest, annotations: Annotations, ): Annotations | undefined; - /** - * Optional hook to transform the parsed value before it is passed to - * {@link getAnnotations} during phase-2 annotation collection. - * - * This allows contexts to distinguish between "parsed value was - * `undefined`" and "no parse happened yet" by wrapping `undefined` - * values with a context-private marker. - * - * @param parsed The parsed value to finalize. - * @returns The finalized parsed value. - * @since 1.0.0 - */ - finalizeParsed?(parsed: unknown): unknown; - /** * Optional synchronous cleanup method. Called by `runWith()` and * `runWithSync()` after parsing completes. In `runWith()`, this happens diff --git a/packages/core/src/facade.test.ts b/packages/core/src/facade.test.ts index cd797ad02..d31e40726 100644 --- a/packages/core/src/facade.test.ts +++ b/packages/core/src/facade.test.ts @@ -8,7 +8,10 @@ import { tuple, } from "@optique/core/constructs"; import { getAnnotations, inheritAnnotations } from "@optique/core/annotations"; -import type { SourceContext } from "@optique/core/context"; +import type { + SourceContext, + SourceContextRequest, +} from "@optique/core/context"; import type { DocSection } from "@optique/core/doc"; import { defineInheritedAnnotationParser, @@ -51,6 +54,22 @@ import { bindEnv, createEnvContext } from "../../env/src/index.ts"; type AssertNever = T; +function isPhase1ContextRequest(request: unknown): boolean { + return request == null || + (typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase1"); +} + +function getPhase2ContextParsed(request: unknown): T | undefined { + if (request != null && typeof request === "object" && "phase" in request) { + return (request as { readonly phase?: unknown }).phase === "phase2" + ? (request as SourceContextRequest & { readonly parsed: T }).parsed + : undefined; + } + return request as T | undefined; +} + function getRuntimeExtractPhase2SeedKey(): symbol { const parser = command("probe", constant(null)); const key = Object.getOwnPropertySymbols(parser).find((symbol) => @@ -5596,7 +5615,7 @@ describe("runWith", () => { id: Symbol.for("@test/phase-merge-late"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed == null) { + if (isPhase1ContextRequest(parsed)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -5650,7 +5669,7 @@ describe("runWith", () => { id: Symbol.for("@test/phase-clear-early"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed == null) { + if (isPhase1ContextRequest(parsed)) { return { [sharedKey]: "phase1-early" }; } return {}; @@ -5661,7 +5680,7 @@ describe("runWith", () => { id: Symbol.for("@test/phase-clear-late"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed == null) { + if (isPhase1ContextRequest(parsed)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -5688,9 +5707,9 @@ describe("runWith", () => { id: configKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + const result = getPhase2ContextParsed<{ config?: string }>(parsed); + if (result == null) return {}; phase2Called = true; - const result = parsed as { config?: string }; if (!result.config) return {}; // Simulate loaded config return { [configKey]: { host: "dynamic-host" } }; @@ -5770,7 +5789,7 @@ describe("runWith", () => { id: configKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + if (isPhase1ContextRequest(parsed)) return {}; phase2Called = true; return { [configKey]: { host: "config-host" } }; }, @@ -6916,7 +6935,7 @@ describe("runWith", () => { phase: "two-pass", getAnnotations(_parsed?: unknown, options?: unknown) { receivedOptions = options; - if (!_parsed) return {}; + if (isPhase1ContextRequest(_parsed)) return {}; return { [passthroughKey]: { value: "loaded" } }; }, }; @@ -6947,7 +6966,7 @@ describe("runWith", () => { phase: "two-pass", getAnnotations(parsed?: unknown, options?: unknown) { receivedOptionsPerCall.push(options); - if (parsed === undefined) return {}; + if (isPhase1ContextRequest(parsed)) return {}; return { [dynamicKey]: { value: "loaded" } }; }, }; @@ -7284,7 +7303,7 @@ describe("runWithSync", () => { id: mixedKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // sync (empty → dynamic) + if (isPhase1ContextRequest(parsed)) return {}; // sync (empty → dynamic) return Promise.resolve({ [mixedKey]: { value: "loaded" } }); }, }; @@ -7925,7 +7944,7 @@ describe("runWithSync", () => { id: Symbol.for("@test/phase-merge-sync-late"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed == null) { + if (isPhase1ContextRequest(parsed)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -7979,7 +7998,7 @@ describe("runWithSync", () => { id: Symbol.for("@test/phase-clear-sync-early"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed == null) { + if (isPhase1ContextRequest(parsed)) { return { [sharedKey]: "phase1-early" }; } return {}; @@ -7990,7 +8009,7 @@ describe("runWithSync", () => { id: Symbol.for("@test/phase-clear-sync-late"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed == null) { + if (isPhase1ContextRequest(parsed)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -8018,7 +8037,7 @@ describe("runWithSync", () => { phase: "two-pass", getAnnotations(_parsed?: unknown, options?: unknown) { receivedOptions = options; - if (!_parsed) return {}; + if (isPhase1ContextRequest(_parsed)) return {}; return { [syncKey]: { value: "loaded" } }; }, }; @@ -10142,7 +10161,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: dynKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + if (isPhase1ContextRequest(parsed)) return {}; phase2Called = true; return { [dynKey]: {} }; }, @@ -10196,7 +10215,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: key, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + if (isPhase1ContextRequest(parsed)) { return { [key]: { phase1: true } }; } phase2Called = true; @@ -10254,16 +10273,19 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly config: string } | undefined, + request: SourceContextRequest | undefined, options?: { readonly getConfigPath: ( parsed: { readonly config: string }, ) => string | undefined; }, ) { - if (parsed === undefined) return {}; - phase2Parsed = parsed; - const configPath = options?.getConfigPath(parsed); + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(request); + if (phase2ParsedValue === undefined) return {}; + phase2Parsed = phase2ParsedValue; + const configPath = options?.getConfigPath(phase2ParsedValue); return configPath == null ? {} : { [tokenKey]: `token:${configPath}` }; }, }; @@ -10330,16 +10352,19 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly config: string } | undefined, + request: SourceContextRequest | undefined, options?: { readonly getConfigPath: ( parsed: { readonly config: string }, ) => string | undefined; }, ) { - if (parsed === undefined) return {}; + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(request); + if (phase2ParsedValue === undefined) return {}; phase2Called = true; - const configPath = options?.getConfigPath(parsed); + const configPath = options?.getConfigPath(phase2ParsedValue); return configPath == null ? {} : { [tokenKey]: `token:${configPath}` }; }, }; @@ -10411,16 +10436,19 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly config: string } | undefined, + request: SourceContextRequest | undefined, options?: { readonly getConfigPath: ( parsed: { readonly config: string }, ) => string | undefined; }, ) { - if (parsed === undefined) return {}; + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(request); + if (phase2ParsedValue === undefined) return {}; phase2Called = true; - const configPath = options?.getConfigPath(parsed); + const configPath = options?.getConfigPath(phase2ParsedValue); return configPath == null ? {} : { [tokenKey]: `token:${configPath}` }; }, }; @@ -10537,16 +10565,19 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly config: string } | undefined, + request: SourceContextRequest | undefined, options?: { readonly getConfigPath: ( parsed: { readonly config: string }, ) => string | undefined; }, ) { - if (parsed === undefined) return {}; + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(request); + if (phase2ParsedValue === undefined) return {}; phase2Called = true; - const configPath = options?.getConfigPath(parsed); + const configPath = options?.getConfigPath(phase2ParsedValue); return configPath == null ? {} : { [tokenKey]: `token:${configPath}` }; @@ -10663,16 +10694,19 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly config: string } | undefined, + request: SourceContextRequest | undefined, options?: { readonly getConfigPath: ( parsed: { readonly config: string }, ) => string | undefined; }, ) { - if (parsed == null) return {}; + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(request); + if (phase2ParsedValue == null) return {}; phase2Called = true; - const configPath = options?.getConfigPath(parsed); + const configPath = options?.getConfigPath(phase2ParsedValue); return configPath == null ? {} : { [tokenKey]: `token:${configPath}` }; }, }; @@ -10740,7 +10774,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations(parsed) { - if (parsed == null) return {}; + if (isPhase1ContextRequest(parsed)) return {}; phase2Called = true; return { [tokenKey]: "from-phase-two" }; }, @@ -10805,7 +10839,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations(parsed) { - if (parsed == null) return {}; + if (isPhase1ContextRequest(parsed)) return {}; phase2Called = true; return { [tokenKey]: "from-phase-two" }; }, @@ -10862,15 +10896,18 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly config: string } | undefined, + request: SourceContextRequest | undefined, options?: { readonly getConfigPath: ( parsed: { readonly config: string }, ) => string | undefined; }, ) { - if (parsed == null) return {}; - const configPath = options?.getConfigPath(parsed); + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(request); + if (phase2ParsedValue == null) return {}; + const configPath = options?.getConfigPath(phase2ParsedValue); return configPath == null ? {} : { [tokenKey]: `token:${configPath}` }; }, }; @@ -10937,15 +10974,18 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly config: string } | undefined, + request: SourceContextRequest | undefined, options?: { readonly getConfigPath: ( parsed: { readonly config: string }, ) => string | undefined; }, ) { - if (parsed == null) return {}; - const configPath = options?.getConfigPath(parsed); + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(request); + if (phase2ParsedValue == null) return {}; + const configPath = options?.getConfigPath(phase2ParsedValue); return configPath == null ? {} : { [tokenKey]: `token:${configPath}` }; }, }; @@ -11013,10 +11053,13 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly commandPath: readonly string[] } | undefined, + request: SourceContextRequest | undefined, ) { - if (parsed == null) return {}; - return parsed.commandPath[0] === "serve" + const phase2ParsedValue = getPhase2ContextParsed< + { readonly commandPath: readonly string[] } + >(request); + if (phase2ParsedValue == null) return {}; + return phase2ParsedValue.commandPath[0] === "serve" ? { [tokenKey]: "from-phase-two" } : {}; }, @@ -11078,10 +11121,13 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly commandPath: readonly string[] } | undefined, + request: SourceContextRequest | undefined, ) { - if (parsed == null) return {}; - return parsed.commandPath[0] === "serve" + const phase2ParsedValue = getPhase2ContextParsed< + { readonly commandPath: readonly string[] } + >(request); + if (phase2ParsedValue == null) return {}; + return phase2ParsedValue.commandPath[0] === "serve" ? { [tokenKey]: "from-phase-two" } : {}; }, @@ -11185,15 +11231,18 @@ describe("branch coverage: facade.ts edge cases", () => { id: tokenKey, phase: "two-pass", getAnnotations( - parsed: { readonly configs: readonly string[] } | undefined, + request: SourceContextRequest | undefined, options?: { readonly getConfigPath: ( parsed: { readonly configs: readonly string[] }, ) => string | undefined; }, ) { - if (parsed == null) return {}; - const configPath = options?.getConfigPath(parsed); + const phase2ParsedValue = getPhase2ContextParsed< + { readonly configs: readonly string[] } + >(request); + if (phase2ParsedValue == null) return {}; + const configPath = options?.getConfigPath(phase2ParsedValue); return configPath == null ? {} : { [tokenKey]: `token:${configPath}` }; }, }; @@ -11227,7 +11276,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: dynKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + if (isPhase1ContextRequest(parsed)) return {}; return { [dynKey]: {} }; }, }; @@ -11250,7 +11299,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: dynKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // dynamic (no symbols → hasDynamic = true) + if (isPhase1ContextRequest(parsed)) return {}; // dynamic (no symbols → hasDynamic = true) return { [dynKey]: {} }; }, }; @@ -11303,13 +11352,59 @@ describe("branch coverage: facade.ts edge cases", () => { assert.equal(result, "world"); }); + it( + "runWith: two-pass context still refines a successful undefined first-pass value", + async () => { + const key = Symbol.for("@test/two-pass-undefined-first-pass-async"); + const parser: Parser<"sync", string | undefined, null> = { + $mode: "sync", + $valueType: [] as readonly (string | undefined)[], + $stateType: [] as readonly null[], + priority: 0, + usage: [], + leadingNames: new Set(), + acceptingAnyToken: false, + initialState: null, + parse(context) { + return { + success: true as const, + next: context, + consumed: [], + }; + }, + complete(state) { + return { + success: true as const, + value: getAnnotations(state)?.[key] as string | undefined, + }; + }, + *suggest() {}, + getDocFragments() { + return { fragments: [] }; + }, + }; + const context: SourceContext = { + id: key, + phase: "two-pass", + getAnnotations(parsed?: unknown) { + if (isPhase1ContextRequest(parsed)) return {}; + return { [key]: "phase2" }; + }, + }; + + const result = await runWith(parser, "test", [context], { args: [] }); + + assert.equal(result, "phase2"); + }, + ); + it("runWith: async parser + hasDynamic + first-pass fails", async () => { const dynKey = Symbol.for("@test/dyn-async-fail2"); const dynamicContext: SourceContext = { id: dynKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // dynamic + if (isPhase1ContextRequest(parsed)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11379,7 +11474,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: dynKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + if (isPhase1ContextRequest(parsed)) { phase1Calls++; return { [dynKey]: {} }; } @@ -11475,7 +11570,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: dynKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // dynamic + if (isPhase1ContextRequest(parsed)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11500,7 +11595,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: dynKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + if (isPhase1ContextRequest(parsed)) return {}; return { [dynKey]: {} }; }, }; @@ -11543,13 +11638,59 @@ describe("branch coverage: facade.ts edge cases", () => { assert.equal(staleParserReused, false); }); + it( + "runWithSync: two-pass context still refines a successful undefined first-pass value", + () => { + const key = Symbol.for("@test/two-pass-undefined-first-pass-sync"); + const parser: Parser<"sync", string | undefined, null> = { + $mode: "sync", + $valueType: [] as readonly (string | undefined)[], + $stateType: [] as readonly null[], + priority: 0, + usage: [], + leadingNames: new Set(), + acceptingAnyToken: false, + initialState: null, + parse(context) { + return { + success: true as const, + next: context, + consumed: [], + }; + }, + complete(state) { + return { + success: true as const, + value: getAnnotations(state)?.[key] as string | undefined, + }; + }, + *suggest() {}, + getDocFragments() { + return { fragments: [] }; + }, + }; + const context: SourceContext = { + id: key, + phase: "two-pass", + getAnnotations(parsed?: unknown) { + if (isPhase1ContextRequest(parsed)) return {}; + return { [key]: "phase2" }; + }, + }; + + const result = runWithSync(parser, "test", [context], { args: [] }); + + assert.equal(result, "phase2"); + }, + ); + it("runWithSync: two-phase, first pass throws → handled via runParser", () => { const dynKey = Symbol.for("@test/sync-two-phase-throw"); const context: SourceContext = { id: dynKey, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // dynamic + if (isPhase1ContextRequest(parsed)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11607,7 +11748,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: key, phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + if (isPhase1ContextRequest(parsed)) { return { [key]: { phase1: true } }; } phase2Called = true; @@ -11826,7 +11967,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: Symbol.for("@test/dyn-throw-in-first-pass"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + if (isPhase1ContextRequest(parsed)) return {}; return { [Symbol.for("@test/dyn-throw-in-first-pass")]: {} }; }, }; @@ -11872,7 +12013,7 @@ describe("branch coverage: facade.ts edge cases", () => { id: Symbol.for("@test/sync-dyn-throw-in-first-pass"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + if (isPhase1ContextRequest(parsed)) return {}; return { [Symbol.for("@test/sync-dyn-throw-in-first-pass")]: {} }; }, }; diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index 38a2fc740..7cc8fcc37 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -67,9 +67,13 @@ import { validateOptionNames, validateProgramName, } from "./validate.ts"; -import type { ParserValuePlaceholder, SourceContext } from "./context.ts"; +import type { + ParserValuePlaceholder, + SourceContext, + SourceContextRequest, +} from "./context.ts"; -export type { ParserValuePlaceholder, SourceContext }; +export type { ParserValuePlaceholder, SourceContext, SourceContextRequest }; type SuppressedErrorConstructor = new ( error: unknown, @@ -93,15 +97,6 @@ const SuppressedErrorCtor: SuppressedErrorConstructor = return SuppressedErrorPolyfill; })(); -function finalizeParsedForContext( - context: SourceContext, - parsed: unknown, -): unknown { - return context.finalizeParsed != null - ? context.finalizeParsed(parsed) - : parsed; -} - function isPlainObject(value: object): boolean { const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; @@ -231,14 +226,6 @@ function prepareParsedForContexts( return parsed; } -function withPreparedParsedForContext( - context: SourceContext, - preparedParsed: unknown, - run: (prepared: unknown) => T, -): T { - return run(finalizeParsedForContext(context, preparedParsed)); -} - function isBufferUnchanged( previous: readonly string[], current: readonly string[], @@ -2882,12 +2869,13 @@ async function collectPhase1Annotations( ): Promise { const annotationsList: Annotations[] = []; let snapshots: Annotations[] | undefined; + const request: SourceContextRequest = { phase: "phase1" }; for (const context of contexts) { - const result = context.getAnnotations(undefined, options); + const result = context.getAnnotations(request, options); const annotations = result instanceof Promise ? await result : result; const internalAnnotations = context.getInternalAnnotations?.( - undefined, + request, annotations, ); const snapshot = internalAnnotations == null @@ -2935,6 +2923,10 @@ async function collectFinalAnnotations( deferred, deferredKeys, ); + const request: SourceContextRequest = { + phase: "phase2", + parsed: preparedParsed, + }; for (let index = 0; index < contexts.length; index++) { const context = contexts[index]; @@ -2943,21 +2935,15 @@ async function collectFinalAnnotations( continue; } - const mergedAnnotations = await withPreparedParsedForContext( - context, - preparedParsed, - async (contextParsed) => { - const result = context.getAnnotations(contextParsed, options); - const annotations = result instanceof Promise ? await result : result; - const internalAnnotations = context.getInternalAnnotations?.( - contextParsed, - annotations, - ); - return internalAnnotations == null - ? annotations - : mergeAnnotations([annotations, internalAnnotations]); - }, + const result = context.getAnnotations(request, options); + const annotations = result instanceof Promise ? await result : result; + const internalAnnotations = context.getInternalAnnotations?.( + request, + annotations, ); + const mergedAnnotations = internalAnnotations == null + ? annotations + : mergeAnnotations([annotations, internalAnnotations]); annotationsList.push(mergedAnnotations); } @@ -2981,9 +2967,10 @@ function collectPhase1AnnotationsSync( ): CollectedPhase1Annotations { const annotationsList: Annotations[] = []; let snapshots: Annotations[] | undefined; + const request: SourceContextRequest = { phase: "phase1" }; for (const context of contexts) { - const result = context.getAnnotations(undefined, options); + const result = context.getAnnotations(request, options); if (result instanceof Promise) { throw new Error( `Context ${String(context.id)} returned a Promise in sync mode. ` + @@ -2991,7 +2978,7 @@ function collectPhase1AnnotationsSync( ); } const internalAnnotations = context.getInternalAnnotations?.( - undefined, + request, result, ); const snapshot = internalAnnotations == null @@ -3040,6 +3027,10 @@ function collectFinalAnnotationsSync( deferred, deferredKeys, ); + const request: SourceContextRequest = { + phase: "phase2", + parsed: preparedParsed, + }; for (let index = 0; index < contexts.length; index++) { const context = contexts[index]; @@ -3048,26 +3039,20 @@ function collectFinalAnnotationsSync( continue; } - const mergedAnnotations = withPreparedParsedForContext( - context, - preparedParsed, - (contextParsed) => { - const result = context.getAnnotations(contextParsed, options); - if (result instanceof Promise) { - throw new Error( - `Context ${String(context.id)} returned a Promise in sync mode. ` + - "Use runWith() or runWithAsync() for async contexts.", - ); - } - const internalAnnotations = context.getInternalAnnotations?.( - contextParsed, - result, - ); - return internalAnnotations == null - ? result - : mergeAnnotations([result, internalAnnotations]); - }, + const result = context.getAnnotations(request, options); + if (result instanceof Promise) { + throw new Error( + `Context ${String(context.id)} returned a Promise in sync mode. ` + + "Use runWith() or runWithAsync() for async contexts.", + ); + } + const internalAnnotations = context.getInternalAnnotations?.( + request, + result, ); + const mergedAnnotations = internalAnnotations == null + ? result + : mergeAnnotations([result, internalAnnotations]); annotationsList.push(mergedAnnotations); } @@ -3423,9 +3408,10 @@ async function runWithBody< * reaches a usable intermediate state but still does not complete * successfully, the runner extracts a best-effort seed from that state * instead. - * 3. *Phase 2*: Call `getAnnotations(parsed)` on all two-pass contexts with - * the first pass value. Deferred or otherwise unresolved fields in - * `parsed` may be `undefined`. Each two-pass context's phase-two return + * 3. *Phase 2*: Call `getAnnotations({ phase: "phase2", parsed })` on all + * two-pass contexts with the first pass value. Deferred or otherwise + * unresolved fields in `parsed` may be `undefined`. Each two-pass + * context's phase-two return * value replaces its own phase-one contribution for the final parse, so * returning `{}` clears any annotations that context provided during * phase 1. Single-pass contexts reuse their phase-one snapshot. diff --git a/packages/env/src/index.test.ts b/packages/env/src/index.test.ts index 0bac76b74..e40105a4e 100644 --- a/packages/env/src/index.test.ts +++ b/packages/env/src/index.test.ts @@ -1268,7 +1268,7 @@ describe("bindEnv()", () => { }, }); const annotations = configContext.getAnnotations( - {}, + { phase: "phase2", parsed: {} }, { load: () => ({ config: { mode: "prod" as const }, @@ -2722,7 +2722,7 @@ describe("bindEnv() with dependency sources", () => { parser: string(), }); const annotations = configContext.getAnnotations( - {}, + { phase: "phase2", parsed: {} }, { load: () => ({ config: { mode: "prod" as const }, diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 80390b921..fd0c30c19 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -102,8 +102,9 @@ function defaultEnvSource(key: string): string | undefined { * * When calling `context.getAnnotations()` manually, pass the returned * annotations to low-level APIs such as `parse()`, `parseAsync()`, - * `parser.complete()`, `suggest()`, or `getDocPage()`. Calling - * `getAnnotations()` by itself does not affect later parses. + * `parser.complete()`, `suggest()`, or `getDocPage()`. Since environment + * contexts are single-pass, `getAnnotations()` can still be called without + * a phase request. Calling it by itself does not affect later parses. * * @param options Environment context options. * @returns A context that provides environment source annotations. diff --git a/packages/inquirer/src/index.test.ts b/packages/inquirer/src/index.test.ts index 88e5f0a07..b34f279b4 100644 --- a/packages/inquirer/src/index.test.ts +++ b/packages/inquirer/src/index.test.ts @@ -8,7 +8,10 @@ import { } from "@optique/core/annotations"; import { concat, group, object, or, tuple } from "@optique/core/constructs"; import { dependency } from "@optique/core/dependency"; -import type { SourceContext } from "@optique/core/context"; +import type { + SourceContext, + SourceContextRequest, +} from "@optique/core/context"; import type { DocFragments } from "@optique/core/doc"; import { runWith } from "@optique/core/facade"; import { message } from "@optique/core/message"; @@ -33,6 +36,22 @@ const promptFunctionsOverrideSymbol = Symbol.for( let promptFunctionsOverrideQueue = Promise.resolve(); +function isPhase1ContextRequest(request: unknown): boolean { + return request == null || + (typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase1"); +} + +function getPhase2ContextParsed(request: unknown): T | undefined { + if (request != null && typeof request === "object" && "phase" in request) { + return (request as { readonly phase?: unknown }).phase === "phase2" + ? (request as SourceContextRequest & { readonly parsed: T }).parsed + : undefined; + } + return request as T | undefined; +} + async function withPromptFunctionsOverride( override: Record, callback: () => Promise, @@ -1341,7 +1360,7 @@ describe("prompt()", () => { schema: createPromptConfigSchema(), }); const annotations = await context.getAnnotations( - { any: true }, + { phase: "phase2", parsed: { any: true } }, { load: () => ({ config: { apiKey: "config-secret" }, @@ -1569,10 +1588,13 @@ describe("prompt()", () => { id: Symbol.for("@test/prompt-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(parsed); + if (phase2ParsedValue === undefined) { return {}; } - phase2Parsed = parsed as { readonly config: string }; + phase2Parsed = phase2ParsedValue; return {}; }, }; @@ -1608,12 +1630,13 @@ describe("prompt()", () => { id: Symbol.for("@test/config-prompt-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + const phase2ParsedValue = getPhase2ContextParsed<{ + readonly apiKey?: string | undefined; + }>(parsed); + if (phase2ParsedValue === undefined) { return {}; } - phase2Parsed = parsed as { - readonly apiKey?: string | undefined; - }; + phase2Parsed = phase2ParsedValue; return {}; }, }; @@ -1662,7 +1685,8 @@ describe("prompt()", () => { id: Symbol.for("@test/top-level-config-prompt-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - sawUndefined = parsed === undefined; + sawUndefined = !isPhase1ContextRequest(parsed) && + getPhase2ContextParsed(parsed) === undefined; return {}; }, }; @@ -1714,8 +1738,11 @@ describe("prompt()", () => { id: Symbol.for("@test/non-plain-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed !== undefined) { - phase2Parsed = parsed as ConfigInput; + const phase2ParsedValue = getPhase2ContextParsed( + parsed, + ); + if (phase2ParsedValue !== undefined) { + phase2Parsed = phase2ParsedValue; } return {}; }, @@ -1788,12 +1815,13 @@ describe("prompt()", () => { id: Symbol.for("@test/private-field-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed !== undefined) { - phase2SawSecretHolder = parsed instanceof SecretHolder; + const phase2Parsed = getPhase2ContextParsed(parsed); + if (phase2Parsed !== undefined) { + phase2SawSecretHolder = phase2Parsed instanceof SecretHolder; try { // Accessing the getter should not throw even though // the class uses private fields. - phase2Masked = (parsed as SecretHolder).masked; + phase2Masked = phase2Parsed.masked; } catch { phase2Threw = true; } @@ -1854,8 +1882,9 @@ describe("prompt()", () => { id: Symbol.for("@test/set-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed instanceof Set) { - phase2Values = [...parsed]; + const phase2Parsed = getPhase2ContextParsed>(parsed); + if (phase2Parsed instanceof Set) { + phase2Values = [...phase2Parsed]; } return {}; }, @@ -1915,9 +1944,10 @@ describe("prompt()", () => { id: Symbol.for("@test/set-own-prop-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed instanceof BoxSet) { + const phase2Parsed = getPhase2ContextParsed(parsed); + if (phase2Parsed instanceof BoxSet) { phase2WasBoxSet = true; - phase2ApiKey = parsed.apiKey; + phase2ApiKey = phase2Parsed.apiKey; } return {}; }, @@ -1975,8 +2005,11 @@ describe("prompt()", () => { id: Symbol.for("@test/nested-clean-collection-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed != null && typeof parsed === "object") { - phase2Set = (parsed as { readonly clean: BoxSet }).clean; + const phase2Parsed = getPhase2ContextParsed<{ + readonly clean: BoxSet; + }>(parsed); + if (phase2Parsed != null) { + phase2Set = phase2Parsed.clean; } return {}; }, @@ -2044,8 +2077,11 @@ describe("prompt()", () => { id: Symbol.for("@test/nested-clean-non-plain-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed != null && typeof parsed === "object") { - phase2Box = (parsed as { readonly clean: CleanBox }).clean; + const phase2Parsed = getPhase2ContextParsed<{ + readonly clean: CleanBox; + }>(parsed); + if (phase2Parsed != null) { + phase2Box = phase2Parsed.clean; phase2Value = phase2Box.getValue(); } return {}; @@ -2106,10 +2142,11 @@ describe("prompt()", () => { id: Symbol.for("@test/nested-non-plain-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed != null && typeof parsed === "object") { - phase2ApiKey = ( - parsed as { readonly inner: InnerInput } - ).inner.apiKey; + const phase2Parsed = getPhase2ContextParsed<{ + readonly inner: InnerInput; + }>(parsed); + if (phase2Parsed != null) { + phase2ApiKey = phase2Parsed.inner.apiKey; } return {}; }, @@ -2233,8 +2270,9 @@ describe("prompt()", () => { id: Symbol.for("@test/scrubbed-phase-two-identity"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed != null && typeof parsed === "object") { - metadataByParsed.set(parsed as object, "seen"); + const phase2Parsed = getPhase2ContextParsed(parsed); + if (phase2Parsed != null && typeof phase2Parsed === "object") { + metadataByParsed.set(phase2Parsed, "seen"); } return {}; }, @@ -2385,8 +2423,11 @@ describe("prompt()", () => { id: Symbol.for("@test/mapped-placeholder-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed != null && typeof parsed === "object") { - phase2Token = (parsed as { readonly token: string }).token; + const phase2Parsed = getPhase2ContextParsed<{ + readonly token: string; + }>(parsed); + if (phase2Parsed != null) { + phase2Token = phase2Parsed.token; } return {}; }, @@ -2445,7 +2486,7 @@ describe("prompt()", () => { id: Symbol.for("@test/mapped-throw-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - phase2Parsed = parsed; + phase2Parsed = getPhase2ContextParsed(parsed); return {}; }, }; @@ -2513,8 +2554,9 @@ describe("prompt()", () => { id: Symbol("spy"), phase: "two-pass", getAnnotations(parsed?: unknown) { - if (parsed !== undefined) { - phaseOneValues.push(parsed); + const phase2Parsed = getPhase2ContextParsed(parsed); + if (phase2Parsed !== undefined) { + phaseOneValues.push(phase2Parsed); } return {}; }, @@ -4999,7 +5041,7 @@ describe("prompt() with dependency sources", () => { }, }); const annotations = await configContext.getAnnotations( - {}, + { phase: "phase2", parsed: {} }, { load: () => ({ config: { mode: "prod" as const }, @@ -5208,7 +5250,7 @@ describe("prompt() with dependency sources", () => { }, }); const annotations = await configContext.getAnnotations( - {}, + { phase: "phase2", parsed: {} }, { load: () => ({ config: { mode: "prod" as const }, @@ -5299,7 +5341,7 @@ describe("prompt() with dependency sources", () => { }, }); const annotations = await configContext.getAnnotations( - {}, + { phase: "phase2", parsed: {} }, { load: () => ({ config: { mode: "prod" as const }, diff --git a/packages/run/src/run.test.ts b/packages/run/src/run.test.ts index a001763f8..cfbebe83f 100644 --- a/packages/run/src/run.test.ts +++ b/packages/run/src/run.test.ts @@ -2,6 +2,7 @@ import { longestMatch, object, or } from "@optique/core/constructs"; import type { ParserValuePlaceholder, SourceContext, + SourceContextRequest, } from "@optique/core/context"; import { message } from "@optique/core/message"; import { map, multiple, optional, withDefault } from "@optique/core/modifiers"; @@ -29,6 +30,22 @@ import { bindEnv, createEnvContext } from "../../env/src/index.ts"; const TEST_DIR = join(import.meta.dirname ?? ".", "test-configs"); +function isPhase1ContextRequest(request: unknown): boolean { + return request == null || + (typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase1"); +} + +function getPhase2ContextParsed(request: unknown): T | undefined { + if (request != null && typeof request === "object" && "phase" in request) { + return (request as { readonly phase?: unknown }).phase === "phase2" + ? (request as SourceContextRequest & { readonly parsed: T }).parsed + : undefined; + } + return request as T | undefined; +} + function createPassthroughConfigSchema(): Parameters< typeof createConfigContext >[0]["schema"] { @@ -1676,7 +1693,7 @@ describe("run with contexts", () => { phase: "two-pass", getAnnotations(_parsed?: unknown, options?: unknown) { receivedOptions = options; - if (!_parsed) return {}; + if (isPhase1ContextRequest(_parsed)) return {}; return { [key]: { value: true } }; }, }; @@ -1973,12 +1990,16 @@ describe("run with contexts", () => { id: Symbol.for("@test/program-run-dynamic-context-array"), phase: "two-pass", getAnnotations(parsed, options) { - if (parsed && options) { + const phase2Parsed = getPhase2ContextParsed<{ + config: string; + host: string; + }>(parsed); + if (phase2Parsed != null && options) { resolvedPath = ( options as { getPath: (parsed: { config: string; host: string }) => string; } - ).getPath(parsed as { config: string; host: string }); + ).getPath(phase2Parsed); } return {}; }, @@ -2087,12 +2108,16 @@ describe("run with contexts", () => { id: Symbol.for("@test/program-run-context"), phase: "two-pass", getAnnotations(parsed, options) { - if (parsed && options) { + const phase2Parsed = getPhase2ContextParsed<{ + config: string; + host: string; + }>(parsed); + if (phase2Parsed != null && options) { resolvedPath = ( options as { getPath: (parsed: { config: string; host: string }) => string; } - ).getPath(parsed as { config: string; host: string }); + ).getPath(phase2Parsed); } return {}; }, @@ -2139,12 +2164,16 @@ describe("run with contexts", () => { id: Symbol.for("@test/program-run-dynamic-contexts"), phase: "two-pass", getAnnotations(parsed, options) { - if (parsed && options) { + const phase2Parsed = getPhase2ContextParsed<{ + config: string; + host: string; + }>(parsed); + if (phase2Parsed != null && options) { resolvedPath = ( options as { getPath: (parsed: { config: string; host: string }) => string; } - ).getPath(parsed as { config: string; host: string }); + ).getPath(phase2Parsed); } return {}; }, @@ -2469,12 +2498,16 @@ describe("runSync with contexts", () => { id: Symbol.for("@test/program-runsync-context"), phase: "two-pass", getAnnotations(parsed, options) { - if (parsed && options) { + const phase2Parsed = getPhase2ContextParsed<{ + config: string; + host: string; + }>(parsed); + if (phase2Parsed != null && options) { resolvedPath = ( options as { getPath: (parsed: { config: string; host: string }) => string; } - ).getPath(parsed as { config: string; host: string }); + ).getPath(phase2Parsed); } return {}; }, @@ -2692,12 +2725,16 @@ describe("runAsync with contexts", () => { id: Symbol.for("@test/program-runasync-context"), phase: "two-pass", getAnnotations(parsed, options) { - if (parsed && options) { + const phase2Parsed = getPhase2ContextParsed<{ + config: string; + host: string; + }>(parsed); + if (phase2Parsed != null && options) { resolvedPath = ( options as { getPath: (parsed: { config: string; host: string }) => string; } - ).getPath(parsed as { config: string; host: string }); + ).getPath(phase2Parsed); } return {}; }, From c106d3ee798243e0d88c21a95120e2e8bd0c911c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 18:09:18 +0900 Subject: [PATCH 02/15] Update custom source context example Bring the custom source context example in line with the new SourceContextRequest API so it no longer demonstrates the removed parsed === undefined phase heuristic. Fixes https://github.com/dahlia/optique/issues/271 Co-Authored-By: OpenAI Codex --- examples/patterns/custom-source-context.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/patterns/custom-source-context.ts b/examples/patterns/custom-source-context.ts index 549503147..5d0621792 100644 --- a/examples/patterns/custom-source-context.ts +++ b/examples/patterns/custom-source-context.ts @@ -3,6 +3,7 @@ import type { Annotations, ParserValuePlaceholder, SourceContext, + SourceContextRequest, } from "@optique/core/context"; import { runWith } from "@optique/core/facade"; import { message } from "@optique/core/message"; @@ -41,21 +42,23 @@ interface ConfigContextOptions { getConfigPath: (parsed: ParserValuePlaceholder) => string | undefined; } -// The context type extends SourceContext with our required options -interface ConfigContext extends SourceContext { - getConfigPath?: (parsed: unknown) => string | undefined; -} +type ConfigContext = SourceContext; // Factory function to create the config context function createConfigContext(): ConfigContext { - const context: ConfigContext = { + return { id: configKey, phase: "two-pass", - async getAnnotations(parsed?: unknown): Promise { - if (parsed === undefined) return {}; // First pass - no config yet + async getAnnotations( + request?: SourceContextRequest, + options?: ConfigContextOptions, + ): Promise { + if (request == null || request.phase === "phase1") return {}; // Use the injected getConfigPath function - const configPath = context.getConfigPath?.(parsed); + const configPath = options?.getConfigPath( + request.parsed as ParserValuePlaceholder, + ); if (!configPath) return {}; // No config file specified try { @@ -80,7 +83,6 @@ function createConfigContext(): ConfigContext { } }, }; - return context; } // Define the CLI parser From 0c810e4ec7759e3e4cb46021304be34800e5bfef Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 21:34:00 +0900 Subject: [PATCH 03/15] Isolate phase requests per context call Create fresh SourceContextRequest objects for each context invocation in phase-one and phase-two collection so one context cannot mutate the request seen by another. Add async and sync regressions covering cross-context request mutation, and keep the phase-two parsed-value helper explicit in the associated facade tests. Fixes https://github.com/dahlia/optique/issues/271 Co-Authored-By: OpenAI Codex --- packages/core/src/facade.test.ts | 111 +++++++++++++++++++++++++++++-- packages/core/src/facade.ts | 20 +++--- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/packages/core/src/facade.test.ts b/packages/core/src/facade.test.ts index d31e40726..6f2d59c12 100644 --- a/packages/core/src/facade.test.ts +++ b/packages/core/src/facade.test.ts @@ -10,6 +10,7 @@ import { import { getAnnotations, inheritAnnotations } from "@optique/core/annotations"; import type { SourceContext, + SourceContextPhase2Request, SourceContextRequest, } from "@optique/core/context"; import type { DocSection } from "@optique/core/doc"; @@ -64,7 +65,7 @@ function isPhase1ContextRequest(request: unknown): boolean { function getPhase2ContextParsed(request: unknown): T | undefined { if (request != null && typeof request === "object" && "phase" in request) { return (request as { readonly phase?: unknown }).phase === "phase2" - ? (request as SourceContextRequest & { readonly parsed: T }).parsed + ? (request as SourceContextPhase2Request).parsed as T : undefined; } return request as T | undefined; @@ -5738,9 +5739,10 @@ describe("runWith", () => { const firstContext: SourceContext = { id: Symbol.for("@test/phase-two-identity-first"), phase: "two-pass", - getAnnotations(parsed?: unknown) { + getAnnotations(request?: unknown) { + const parsed = getPhase2ContextParsed(request); if (parsed != null && typeof parsed === "object") { - seenParsed.set(parsed as object, true); + seenParsed.set(parsed, true); } return {}; }, @@ -5749,10 +5751,11 @@ describe("runWith", () => { const secondContext: SourceContext = { id: Symbol.for("@test/phase-two-identity-second"), phase: "two-pass", - getAnnotations(parsed?: unknown) { + getAnnotations(request?: unknown) { + const parsed = getPhase2ContextParsed(request); reusedIdentity = parsed != null && typeof parsed === "object" && - seenParsed.has(parsed as object); + seenParsed.has(parsed); return {}; }, }; @@ -10231,6 +10234,55 @@ describe("branch coverage: facade.ts edge cases", () => { assert.ok(phase2Called, "phase 2 context should be called"); }); + it("runWith: isolates phase requests across two-pass contexts", async () => { + const key = Symbol.for("@test/two-pass-request-isolation-async"); + let secondContextParsed: { readonly config: string } | undefined; + + const parser = object({ + config: withDefault(option("--config", string()), "optique.json"), + }); + + const mutatingContext: SourceContext = { + id: Symbol.for("@test/two-pass-request-mutator-async"), + phase: "two-pass", + getAnnotations(request?: unknown) { + const parsed = getPhase2ContextParsed<{ readonly config: string }>( + request, + ); + if (parsed == null) return {}; + (request as SourceContextRequest & { parsed: unknown }).parsed = { + config: "mutated.json", + }; + return {}; + }, + }; + + const readingContext: SourceContext = { + id: key, + phase: "two-pass", + getAnnotations(request?: unknown) { + const parsed = getPhase2ContextParsed<{ readonly config: string }>( + request, + ); + if (parsed == null) return {}; + secondContextParsed = parsed; + return { [key]: parsed.config }; + }, + }; + + const result = await runWith(parser, "test", [ + mutatingContext, + readingContext, + ], { + args: [], + }); + + assert.deepEqual(secondContextParsed, { config: "optique.json" }); + assert.deepEqual(result, { + config: "optique.json", + }); + }); + it("runWith: phase two can recover from first-pass completion failure", async () => { const tokenKey = Symbol.for("@test/dyn-phase-two-recovery"); let phase2Parsed: unknown; @@ -11764,6 +11816,55 @@ describe("branch coverage: facade.ts edge cases", () => { assert.ok(phase2Called, "phase 2 context should be called"); }); + it("runWithSync: isolates phase requests across two-pass contexts", () => { + const key = Symbol.for("@test/two-pass-request-isolation-sync"); + let secondContextParsed: { readonly config: string } | undefined; + + const parser = object({ + config: withDefault(option("--config", string()), "optique.json"), + }); + + const mutatingContext: SourceContext = { + id: Symbol.for("@test/two-pass-request-mutator-sync"), + phase: "two-pass", + getAnnotations(request?: unknown) { + const parsed = getPhase2ContextParsed<{ readonly config: string }>( + request, + ); + if (parsed == null) return {}; + (request as SourceContextRequest & { parsed: unknown }).parsed = { + config: "mutated.json", + }; + return {}; + }, + }; + + const readingContext: SourceContext = { + id: key, + phase: "two-pass", + getAnnotations(request?: unknown) { + const parsed = getPhase2ContextParsed<{ readonly config: string }>( + request, + ); + if (parsed == null) return {}; + secondContextParsed = parsed; + return { [key]: parsed.config }; + }, + }; + + const result = runWithSync( + parser, + "test", + [mutatingContext, readingContext], + { args: [] }, + ); + + assert.deepEqual(secondContextParsed, { config: "optique.json" }); + assert.deepEqual(result, { + config: "optique.json", + }); + }); + it("runWithSync: should reject contexts without explicit phase", () => { const key = Symbol.for("@test/missing-phase-sync"); const parser = object({ diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index 7cc8fcc37..3a43647b6 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -2869,9 +2869,9 @@ async function collectPhase1Annotations( ): Promise { const annotationsList: Annotations[] = []; let snapshots: Annotations[] | undefined; - const request: SourceContextRequest = { phase: "phase1" }; for (const context of contexts) { + const request: SourceContextRequest = { phase: "phase1" }; const result = context.getAnnotations(request, options); const annotations = result instanceof Promise ? await result : result; const internalAnnotations = context.getInternalAnnotations?.( @@ -2923,10 +2923,6 @@ async function collectFinalAnnotations( deferred, deferredKeys, ); - const request: SourceContextRequest = { - phase: "phase2", - parsed: preparedParsed, - }; for (let index = 0; index < contexts.length; index++) { const context = contexts[index]; @@ -2935,6 +2931,10 @@ async function collectFinalAnnotations( continue; } + const request: SourceContextRequest = { + phase: "phase2", + parsed: preparedParsed, + }; const result = context.getAnnotations(request, options); const annotations = result instanceof Promise ? await result : result; const internalAnnotations = context.getInternalAnnotations?.( @@ -2967,9 +2967,9 @@ function collectPhase1AnnotationsSync( ): CollectedPhase1Annotations { const annotationsList: Annotations[] = []; let snapshots: Annotations[] | undefined; - const request: SourceContextRequest = { phase: "phase1" }; for (const context of contexts) { + const request: SourceContextRequest = { phase: "phase1" }; const result = context.getAnnotations(request, options); if (result instanceof Promise) { throw new Error( @@ -3027,10 +3027,6 @@ function collectFinalAnnotationsSync( deferred, deferredKeys, ); - const request: SourceContextRequest = { - phase: "phase2", - parsed: preparedParsed, - }; for (let index = 0; index < contexts.length; index++) { const context = contexts[index]; @@ -3039,6 +3035,10 @@ function collectFinalAnnotationsSync( continue; } + const request: SourceContextRequest = { + phase: "phase2", + parsed: preparedParsed, + }; const result = context.getAnnotations(request, options); if (result instanceof Promise) { throw new Error( From 122db4268b19421ba79927ae50a815a22a986c1f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 21:34:11 +0900 Subject: [PATCH 04/15] Clarify phase request naming in tests Rename a two-pass test callback parameter from parsed to request so its shape matches the explicit SourceContextRequest contract. Keep the associated parsed-value extraction helper aligned with the updated naming. Fixes https://github.com/dahlia/optique/issues/271 Co-Authored-By: OpenAI Codex --- packages/inquirer/src/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/inquirer/src/index.test.ts b/packages/inquirer/src/index.test.ts index b34f279b4..46f1ed3bf 100644 --- a/packages/inquirer/src/index.test.ts +++ b/packages/inquirer/src/index.test.ts @@ -1587,10 +1587,10 @@ describe("prompt()", () => { const dynamicContext: SourceContext = { id: Symbol.for("@test/prompt-phase-two"), phase: "two-pass", - getAnnotations(parsed?: unknown) { + getAnnotations(request?: SourceContextRequest) { const phase2ParsedValue = getPhase2ContextParsed< { readonly config: string } - >(parsed); + >(request); if (phase2ParsedValue === undefined) { return {}; } From b538dcfa1f21dd0c81b0f04c224c9061f41a227a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 21:37:33 +0900 Subject: [PATCH 05/15] Tighten phase request test helpers Tighten test-only phase request helpers so they only accept explicit SourceContextRequest shapes, and use TypeError for sync/async contract violations when a context returns a Promise in sync mode. This keeps the regression coverage aligned with the new request contract and makes the sync runner's error classification more precise. Fixes https://github.com/dahlia/optique/issues/271 Co-Authored-By: OpenAI Codex --- packages/core/src/facade.test.ts | 24 ++++++++++++++---------- packages/core/src/facade.ts | 4 ++-- packages/inquirer/src/index.test.ts | 22 ++++++++++------------ packages/run/src/run.test.ts | 26 +++++++++++++++----------- 4 files changed, 41 insertions(+), 35 deletions(-) diff --git a/packages/core/src/facade.test.ts b/packages/core/src/facade.test.ts index 6f2d59c12..b2b20dc11 100644 --- a/packages/core/src/facade.test.ts +++ b/packages/core/src/facade.test.ts @@ -56,19 +56,23 @@ import { bindEnv, createEnvContext } from "../../env/src/index.ts"; type AssertNever = T; function isPhase1ContextRequest(request: unknown): boolean { - return request == null || - (typeof request === "object" && - "phase" in request && - (request as { readonly phase?: unknown }).phase === "phase1"); + return request != null && + typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase1"; +} + +function isPhase2ContextRequest( + request: unknown, +): request is SourceContextPhase2Request { + return request != null && + typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase2"; } function getPhase2ContextParsed(request: unknown): T | undefined { - if (request != null && typeof request === "object" && "phase" in request) { - return (request as { readonly phase?: unknown }).phase === "phase2" - ? (request as SourceContextPhase2Request).parsed as T - : undefined; - } - return request as T | undefined; + return isPhase2ContextRequest(request) ? request.parsed as T : undefined; } function getRuntimeExtractPhase2SeedKey(): symbol { diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index 3a43647b6..dbce85ac9 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -2972,7 +2972,7 @@ function collectPhase1AnnotationsSync( const request: SourceContextRequest = { phase: "phase1" }; const result = context.getAnnotations(request, options); if (result instanceof Promise) { - throw new Error( + throw new TypeError( `Context ${String(context.id)} returned a Promise in sync mode. ` + "Use runWith() or runWithAsync() for async contexts.", ); @@ -3041,7 +3041,7 @@ function collectFinalAnnotationsSync( }; const result = context.getAnnotations(request, options); if (result instanceof Promise) { - throw new Error( + throw new TypeError( `Context ${String(context.id)} returned a Promise in sync mode. ` + "Use runWith() or runWithAsync() for async contexts.", ); diff --git a/packages/inquirer/src/index.test.ts b/packages/inquirer/src/index.test.ts index 46f1ed3bf..2d54e26ec 100644 --- a/packages/inquirer/src/index.test.ts +++ b/packages/inquirer/src/index.test.ts @@ -10,6 +10,7 @@ import { concat, group, object, or, tuple } from "@optique/core/constructs"; import { dependency } from "@optique/core/dependency"; import type { SourceContext, + SourceContextPhase2Request, SourceContextRequest, } from "@optique/core/context"; import type { DocFragments } from "@optique/core/doc"; @@ -36,20 +37,17 @@ const promptFunctionsOverrideSymbol = Symbol.for( let promptFunctionsOverrideQueue = Promise.resolve(); -function isPhase1ContextRequest(request: unknown): boolean { - return request == null || - (typeof request === "object" && - "phase" in request && - (request as { readonly phase?: unknown }).phase === "phase1"); +function isPhase2ContextRequest( + request: unknown, +): request is SourceContextPhase2Request { + return request != null && + typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase2"; } function getPhase2ContextParsed(request: unknown): T | undefined { - if (request != null && typeof request === "object" && "phase" in request) { - return (request as { readonly phase?: unknown }).phase === "phase2" - ? (request as SourceContextRequest & { readonly parsed: T }).parsed - : undefined; - } - return request as T | undefined; + return isPhase2ContextRequest(request) ? request.parsed as T : undefined; } async function withPromptFunctionsOverride( @@ -1685,7 +1683,7 @@ describe("prompt()", () => { id: Symbol.for("@test/top-level-config-prompt-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - sawUndefined = !isPhase1ContextRequest(parsed) && + sawUndefined = isPhase2ContextRequest(parsed) && getPhase2ContextParsed(parsed) === undefined; return {}; }, diff --git a/packages/run/src/run.test.ts b/packages/run/src/run.test.ts index cfbebe83f..30cc8fb55 100644 --- a/packages/run/src/run.test.ts +++ b/packages/run/src/run.test.ts @@ -2,7 +2,7 @@ import { longestMatch, object, or } from "@optique/core/constructs"; import type { ParserValuePlaceholder, SourceContext, - SourceContextRequest, + SourceContextPhase2Request, } from "@optique/core/context"; import { message } from "@optique/core/message"; import { map, multiple, optional, withDefault } from "@optique/core/modifiers"; @@ -31,19 +31,23 @@ import { bindEnv, createEnvContext } from "../../env/src/index.ts"; const TEST_DIR = join(import.meta.dirname ?? ".", "test-configs"); function isPhase1ContextRequest(request: unknown): boolean { - return request == null || - (typeof request === "object" && - "phase" in request && - (request as { readonly phase?: unknown }).phase === "phase1"); + return request != null && + typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase1"; +} + +function isPhase2ContextRequest( + request: unknown, +): request is SourceContextPhase2Request { + return request != null && + typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase2"; } function getPhase2ContextParsed(request: unknown): T | undefined { - if (request != null && typeof request === "object" && "phase" in request) { - return (request as { readonly phase?: unknown }).phase === "phase2" - ? (request as SourceContextRequest & { readonly parsed: T }).parsed - : undefined; - } - return request as T | undefined; + return isPhase2ContextRequest(request) ? request.parsed as T : undefined; } function createPassthroughConfigSchema(): Parameters< From d9a6086669041ec937fb26704719ea667dc0ddfd Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 21:49:41 +0900 Subject: [PATCH 06/15] Twoslash source context request examples Promote the explicit SourceContextRequest examples in *docs/concepts/extend.md* to twoslash blocks and add the missing fixture declarations and imports they need to typecheck. Co-Authored-By: OpenAI Codex --- docs/concepts/extend.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/concepts/extend.md b/docs/concepts/extend.md index b566c9fc9..231ef0271 100644 --- a/docs/concepts/extend.md +++ b/docs/concepts/extend.md @@ -884,8 +884,16 @@ Contexts can be either *single-pass* or *two-pass*: The difference lies in whether `getAnnotations()` needs the parsed result to do its work: -~~~~ typescript -import type { SourceContext } from "@optique/core/context"; +~~~~ typescript twoslash +declare const process: { + readonly env: Record; +}; +declare function loadConfigFile(path: string): Promise; +// ---cut-before--- +import type { + SourceContext, + SourceContextRequest, +} from "@optique/core/context"; // Single-pass context: data is always available const envContext: SourceContext = { @@ -1132,8 +1140,16 @@ A config file context is two-pass because it needs to know the file path from parsed arguments. The `getAnnotations()` method receives the parsed result and uses it to load the configuration: -~~~~ typescript -import type { SourceContext, Annotations } from "@optique/core/context"; +~~~~ typescript twoslash +declare const Deno: { + readTextFile(path: string): Promise; +}; +// ---cut-before--- +import type { + Annotations, + SourceContext, + SourceContextRequest, +} from "@optique/core/context"; const configKey = Symbol.for("@myapp/config"); @@ -1182,7 +1198,11 @@ The example above hardcodes how to extract the config path from parsed results For a more reusable approach, use `ParserValuePlaceholder` to declare that the caller must provide a `getConfigPath` function: -~~~~ typescript +~~~~ typescript twoslash +declare const Deno: { + readTextFile(path: string): Promise; +}; +// ---cut-before--- import type { Annotations, ParserValuePlaceholder, From ca449c90e56dbd4a419eb79a1ec336ae25aed5cb Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 22:00:43 +0900 Subject: [PATCH 07/15] Polish SourceContextRequest docs Clarify the discriminated-union shape of SourceContextRequest in *docs/concepts/extend.md* and align runWithSync() JSDoc with the actual TypeError thrown for sync/async contract violations. Co-Authored-By: OpenAI Codex --- docs/concepts/extend.md | 18 ++++++++---------- packages/core/src/facade.ts | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/concepts/extend.md b/docs/concepts/extend.md index 231ef0271..762cb377b 100644 --- a/docs/concepts/extend.md +++ b/docs/concepts/extend.md @@ -739,16 +739,14 @@ API reference `SourceContextRequest` : Request object passed to `getAnnotations()` and - `getInternalAnnotations()`. - - `phase: "phase1"` - -: Initial annotation collection before the first parse pass. - -`phase: "phase2"` -: Second annotation collection after a usable first pass. The - `parsed` field holds the first-pass value, which may itself be - `undefined`. + `getInternalAnnotations()`. A discriminated union based on the + `phase` field: + + - `phase: "phase1"` — Initial annotation collection before the + first parse pass. + - `phase: "phase2"` — Second annotation collection after a usable + first pass. The `parsed` field holds the first-pass value, which + may itself be `undefined`. Use `ParserValuePlaceholder` in `TRequiredOptions` when the options depend on the parser's result type. diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index dbce85ac9..63aca2786 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -3619,7 +3619,7 @@ function runWithSyncBody< * {@link SourceContext.id}. * @throws {TypeError} If any context omits `phase` or declares an invalid * phase value. - * @throws {Error} If any context returns a Promise or if a context's + * @throws {TypeError} If any context returns a Promise or if a context's * `[Symbol.asyncDispose]` returns a Promise. * @throws {SuppressedError} If the runner throws and a context's disposal * also throws. The original error is available via `.suppressed` and the From e3a5d0cd75b0a1b52c36e4a08a43c74fa8923d40 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 22:18:46 +0900 Subject: [PATCH 08/15] Reject malformed config context requests Reject malformed manual getAnnotations() request objects in createConfigContext() instead of silently treating them as phase-two requests. Add a regression test for the invalid request shape and update the source-context documentation examples so phase-two request.parsed values are treated as possibly undefined. Fixes https://github.com/dahlia/optique/issues/271 Co-Authored-By: OpenAI Codex --- docs/concepts/extend.md | 20 ++++++++++---------- packages/config/src/index.test.ts | 18 ++++++++++++++++++ packages/config/src/index.ts | 14 +++++++++++++- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/concepts/extend.md b/docs/concepts/extend.md index 762cb377b..0994f12b2 100644 --- a/docs/concepts/extend.md +++ b/docs/concepts/extend.md @@ -912,11 +912,11 @@ const configContext: SourceContext = { async getAnnotations(request?: SourceContextRequest) { if (request == null || request.phase === "phase1") return {}; - const result = request.parsed as { config?: string }; - if (!result.config) return {}; + const parsed = request.parsed as { config?: string } | undefined; + if (!parsed?.config) return {}; // Load config file asynchronously - const data = await loadConfigFile(result.config); + const data = await loadConfigFile(parsed.config); return { [Symbol.for("@myapp/config")]: data }; @@ -1166,11 +1166,11 @@ export function createConfigContext(): SourceContext { ): Promise { if (request == null || request.phase === "phase1") return {}; - const result = request.parsed as { config?: string }; - if (!result.config) return {}; // No config file specified + const parsed = request.parsed as { config?: string } | undefined; + if (!parsed?.config) return {}; // No config file specified try { - const content = await Deno.readTextFile(result.config); + const content = await Deno.readTextFile(parsed.config); const data: ConfigData = JSON.parse(content); return { [configKey]: data }; } catch { @@ -1234,12 +1234,12 @@ export function createConfigContext(): ConfigContext { ): Promise { if (request == null || request.phase === "phase1") return {}; - const parsed = request.parsed; + const parsed = request.parsed as ParserValuePlaceholder | undefined; // Use the injected getConfigPath function - const configPath = options?.getConfigPath( - parsed as ParserValuePlaceholder, - ); + const configPath = parsed == null + ? undefined + : options?.getConfigPath(parsed); if (!configPath) return {}; try { diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index dcc87101d..06066cd4b 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -1517,6 +1517,24 @@ describe("createConfigContext input validation", () => { assert.ok(result != null); }); + test("rejects malformed request object in getAnnotations", () => { + const schema = z.object({ host: z.string() }); + const context = createConfigContext({ schema }); + assert.throws( + () => + context.getAnnotations( + { any: 1 } as never, + { load: () => ({ config: { host: "ok" }, meta: undefined }) }, + ), + { + name: "TypeError", + message: "Expected getAnnotations() to receive no request or a " + + 'SourceContextRequest ({ phase: "phase1" } or ' + + '{ phase: "phase2", parsed }).', + }, + ); + }); + test("rejects non-string getConfigPath() return value (object)", () => { const schema = z.object({ host: z.string() }); const context = createConfigContext({ schema }); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e54cdd76b..f4b127a95 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -390,9 +390,21 @@ export function createConfigContext( // Runners add the phase-1 unresolved marker through // getInternalAnnotations() so prompt(bindConfig(...)) can defer // interactive fallback without exposing that marker as user data. - if (request == null || request.phase === "phase1") { + if (request == null) { return {}; } + if ( + typeof request !== "object" || + !("phase" in request) || + (request.phase !== "phase1" && request.phase !== "phase2") + ) { + throw new TypeError( + "Expected getAnnotations() to receive no request or a " + + 'SourceContextRequest ({ phase: "phase1" } or ' + + '{ phase: "phase2", parsed }).', + ); + } + if (request.phase === "phase1") return {}; const opts = runtimeOptions as | ConfigContextRequiredOptions From adb0b7bac2365af769671bb4a69490d3e2def533 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 22:19:22 +0900 Subject: [PATCH 09/15] Polish sync collector review follow-ups Rename a few remaining two-pass test callback parameters from parsed to request for consistency with the explicit request contract, and update the sync collector helper JSDoc to advertise TypeError for Promise-returning contexts. Co-Authored-By: OpenAI Codex --- packages/core/src/facade.test.ts | 12 ++++++------ packages/core/src/facade.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/facade.test.ts b/packages/core/src/facade.test.ts index b2b20dc11..c938fa778 100644 --- a/packages/core/src/facade.test.ts +++ b/packages/core/src/facade.test.ts @@ -5711,8 +5711,8 @@ describe("runWith", () => { const dynamicContext: SourceContext = { id: configKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - const result = getPhase2ContextParsed<{ config?: string }>(parsed); + getAnnotations(request?: unknown) { + const result = getPhase2ContextParsed<{ config?: string }>(request); if (result == null) return {}; phase2Called = true; if (!result.config) return {}; @@ -11442,8 +11442,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: key, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [key]: "phase2" }; }, }; @@ -11728,8 +11728,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: key, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [key]: "phase2" }; }, }; diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index 63aca2786..909efb881 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -2959,7 +2959,7 @@ async function collectFinalAnnotations( * @param contexts Source contexts to collect annotations from. * @param options Optional context-required options to pass to each context. * @returns Merged annotations, per-context snapshots, and a two-phase hint. - * @throws Error if any context returns a Promise. + * @throws {TypeError} If any context returns a Promise. */ function collectPhase1AnnotationsSync( contexts: readonly SourceContext[], @@ -3011,7 +3011,7 @@ function collectPhase1AnnotationsSync( * @param parsed Optional parsed result from a previous parse pass. * @param options Optional context-required options to pass to each context. * @returns Merged annotations. - * @throws Error if any context returns a Promise. + * @throws {TypeError} If any context returns a Promise. */ function collectFinalAnnotationsSync( contexts: readonly SourceContext[], From 185493c389aa2f8e0177291cf5b9b5aa11b21764 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 23:18:58 +0900 Subject: [PATCH 10/15] Require parsed in phase-two config requests Tighten createConfigContext().getAnnotations() so malformed manual phase-two request objects must include parsed, matching the public SourceContextRequest contract. Add a regression test for a { phase: "phase2" } request without parsed. Fixes https://github.com/dahlia/optique/issues/271 Co-Authored-By: OpenAI Codex --- packages/config/src/index.test.ts | 18 ++++++++++++++++++ packages/config/src/index.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index 06066cd4b..0b936db0c 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -1535,6 +1535,24 @@ describe("createConfigContext input validation", () => { ); }); + test("rejects phase2 request object without parsed in getAnnotations", () => { + const schema = z.object({ host: z.string() }); + const context = createConfigContext({ schema }); + assert.throws( + () => + context.getAnnotations( + { phase: "phase2" } as never, + { load: () => ({ config: { host: "ok" }, meta: undefined }) }, + ), + { + name: "TypeError", + message: "Expected getAnnotations() to receive no request or a " + + 'SourceContextRequest ({ phase: "phase1" } or ' + + '{ phase: "phase2", parsed }).', + }, + ); + }); + test("rejects non-string getConfigPath() return value (object)", () => { const schema = z.object({ host: z.string() }); const context = createConfigContext({ schema }); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index f4b127a95..dff2cbc83 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -396,7 +396,8 @@ export function createConfigContext( if ( typeof request !== "object" || !("phase" in request) || - (request.phase !== "phase1" && request.phase !== "phase2") + (request.phase !== "phase1" && request.phase !== "phase2") || + (request.phase === "phase2" && !("parsed" in request)) ) { throw new TypeError( "Expected getAnnotations() to receive no request or a " + From 7bef340eb4296a8a46e5c4ab79aaf83e54c8192c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 23:31:52 +0900 Subject: [PATCH 11/15] Tighten phase-two request type guards Require the test-only isPhase2ContextRequest() helpers to verify that phase-two requests also carry a parsed property, so their type guards match the SourceContextRequest contract instead of accepting malformed objects. Co-Authored-By: OpenAI Codex --- packages/core/src/facade.test.ts | 3 ++- packages/inquirer/src/index.test.ts | 3 ++- packages/run/src/run.test.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/facade.test.ts b/packages/core/src/facade.test.ts index c938fa778..d78842dc4 100644 --- a/packages/core/src/facade.test.ts +++ b/packages/core/src/facade.test.ts @@ -68,7 +68,8 @@ function isPhase2ContextRequest( return request != null && typeof request === "object" && "phase" in request && - (request as { readonly phase?: unknown }).phase === "phase2"; + (request as { readonly phase?: unknown }).phase === "phase2" && + "parsed" in request; } function getPhase2ContextParsed(request: unknown): T | undefined { diff --git a/packages/inquirer/src/index.test.ts b/packages/inquirer/src/index.test.ts index 2d54e26ec..6d1551613 100644 --- a/packages/inquirer/src/index.test.ts +++ b/packages/inquirer/src/index.test.ts @@ -43,7 +43,8 @@ function isPhase2ContextRequest( return request != null && typeof request === "object" && "phase" in request && - (request as { readonly phase?: unknown }).phase === "phase2"; + (request as { readonly phase?: unknown }).phase === "phase2" && + "parsed" in request; } function getPhase2ContextParsed(request: unknown): T | undefined { diff --git a/packages/run/src/run.test.ts b/packages/run/src/run.test.ts index 30cc8fb55..3fb318895 100644 --- a/packages/run/src/run.test.ts +++ b/packages/run/src/run.test.ts @@ -43,7 +43,8 @@ function isPhase2ContextRequest( return request != null && typeof request === "object" && "phase" in request && - (request as { readonly phase?: unknown }).phase === "phase2"; + (request as { readonly phase?: unknown }).phase === "phase2" && + "parsed" in request; } function getPhase2ContextParsed(request: unknown): T | undefined { From cf72302221b489181775d775bc7df20af8a1925e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 10 Apr 2026 23:51:45 +0900 Subject: [PATCH 12/15] Reject null config context requests Treat omitted requests as the only manual phase-1 convenience in createConfigContext(). A null request is malformed input, so reject it through the same TypeError path as other invalid SourceContextRequest shapes. Add a regression test that proves getAnnotations(null, options) no longer fails silently by returning phase-one annotations. Co-Authored-By: OpenAI Codex --- packages/config/src/index.test.ts | 18 ++++++++++++++++++ packages/config/src/index.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index 0b936db0c..af572c1f1 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -1553,6 +1553,24 @@ describe("createConfigContext input validation", () => { ); }); + test("rejects null request in getAnnotations", () => { + const schema = z.object({ host: z.string() }); + const context = createConfigContext({ schema }); + assert.throws( + () => + context.getAnnotations( + null as never, + { load: () => ({ config: { host: "ok" }, meta: undefined }) }, + ), + { + name: "TypeError", + message: "Expected getAnnotations() to receive no request or a " + + 'SourceContextRequest ({ phase: "phase1" } or ' + + '{ phase: "phase2", parsed }).', + }, + ); + }); + test("rejects non-string getConfigPath() return value (object)", () => { const schema = z.object({ host: z.string() }); const context = createConfigContext({ schema }); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index dff2cbc83..e5e44fd7d 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -390,10 +390,11 @@ export function createConfigContext( // Runners add the phase-1 unresolved marker through // getInternalAnnotations() so prompt(bindConfig(...)) can defer // interactive fallback without exposing that marker as user data. - if (request == null) { + if (request === undefined) { return {}; } if ( + request === null || typeof request !== "object" || !("phase" in request) || (request.phase !== "phase1" && request.phase !== "phase2") || From bba1d28571e1c94bfe14f6606ac91109b671ef14 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 11 Apr 2026 00:18:22 +0900 Subject: [PATCH 13/15] Rename facade test request parameters Rename remaining two-pass SourceContext callback parameters in packages/core/src/facade.test.ts from parsed to request. These tests now match the explicit SourceContextRequest contract more clearly and no longer suggest that getAnnotations() receives the raw parsed value. Co-Authored-By: OpenAI Codex --- packages/core/src/facade.test.ts | 92 ++++++++++++++++---------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/core/src/facade.test.ts b/packages/core/src/facade.test.ts index d78842dc4..ca32be3fb 100644 --- a/packages/core/src/facade.test.ts +++ b/packages/core/src/facade.test.ts @@ -5620,8 +5620,8 @@ describe("runWith", () => { const lateDynamicContext: SourceContext = { id: Symbol.for("@test/phase-merge-late"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -5674,8 +5674,8 @@ describe("runWith", () => { const clearingContext: SourceContext = { id: Symbol.for("@test/phase-clear-early"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return { [sharedKey]: "phase1-early" }; } return {}; @@ -5685,8 +5685,8 @@ describe("runWith", () => { const fallbackContext: SourceContext = { id: Symbol.for("@test/phase-clear-late"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -5796,8 +5796,8 @@ describe("runWith", () => { const dynamicContext: SourceContext = { id: configKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; phase2Called = true; return { [configKey]: { host: "config-host" } }; }, @@ -6972,9 +6972,9 @@ describe("runWith", () => { const context: SourceContext = { id: dynamicKey, phase: "two-pass", - getAnnotations(parsed?: unknown, options?: unknown) { + getAnnotations(request?: unknown, options?: unknown) { receivedOptionsPerCall.push(options); - if (isPhase1ContextRequest(parsed)) return {}; + if (isPhase1ContextRequest(request)) return {}; return { [dynamicKey]: { value: "loaded" } }; }, }; @@ -7310,8 +7310,8 @@ describe("runWithSync", () => { const mixedContext: SourceContext = { id: mixedKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; // sync (empty → dynamic) + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // sync (empty → dynamic) return Promise.resolve({ [mixedKey]: { value: "loaded" } }); }, }; @@ -7951,8 +7951,8 @@ describe("runWithSync", () => { const lateDynamicContext: SourceContext = { id: Symbol.for("@test/phase-merge-sync-late"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -8005,8 +8005,8 @@ describe("runWithSync", () => { const clearingContext: SourceContext = { id: Symbol.for("@test/phase-clear-sync-early"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return { [sharedKey]: "phase1-early" }; } return {}; @@ -8016,8 +8016,8 @@ describe("runWithSync", () => { const fallbackContext: SourceContext = { id: Symbol.for("@test/phase-clear-sync-late"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -10168,8 +10168,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; phase2Called = true; return { [dynKey]: {} }; }, @@ -10222,8 +10222,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: key, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return { [key]: { phase1: true } }; } phase2Called = true; @@ -10830,8 +10830,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: tokenKey, phase: "two-pass", - getAnnotations(parsed) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request) { + if (isPhase1ContextRequest(request)) return {}; phase2Called = true; return { [tokenKey]: "from-phase-two" }; }, @@ -10895,8 +10895,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: tokenKey, phase: "two-pass", - getAnnotations(parsed) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request) { + if (isPhase1ContextRequest(request)) return {}; phase2Called = true; return { [tokenKey]: "from-phase-two" }; }, @@ -11332,8 +11332,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [dynKey]: {} }; }, }; @@ -11355,8 +11355,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; // dynamic (no symbols → hasDynamic = true) + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // dynamic (no symbols → hasDynamic = true) return { [dynKey]: {} }; }, }; @@ -11460,8 +11460,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; // dynamic + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11530,8 +11530,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { phase1Calls++; return { [dynKey]: {} }; } @@ -11626,8 +11626,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; // dynamic + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11651,8 +11651,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [dynKey]: {} }; }, }; @@ -11746,8 +11746,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; // dynamic + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11804,8 +11804,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: key, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return { [key]: { phase1: true } }; } phase2Called = true; @@ -12072,8 +12072,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: Symbol.for("@test/dyn-throw-in-first-pass"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [Symbol.for("@test/dyn-throw-in-first-pass")]: {} }; }, }; @@ -12118,8 +12118,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: Symbol.for("@test/sync-dyn-throw-in-first-pass"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (isPhase1ContextRequest(parsed)) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [Symbol.for("@test/sync-dyn-throw-in-first-pass")]: {} }; }, }; From 2ab36573079ca7136e9eb601c10ce7c6b69695c3 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 11 Apr 2026 00:18:46 +0900 Subject: [PATCH 14/15] Clarify config request type errors Include the received request type in createConfigContext() getAnnotations() TypeError messages for malformed SourceContextRequest inputs. This keeps the public API boundary easier to debug while still rejecting invalid phase-one and phase-two request shapes. Update the input-validation regression tests to assert the new malformed-request messages. Co-Authored-By: OpenAI Codex --- packages/config/src/index.test.ts | 6 +++--- packages/config/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index af572c1f1..92e94cae2 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -1530,7 +1530,7 @@ describe("createConfigContext input validation", () => { name: "TypeError", message: "Expected getAnnotations() to receive no request or a " + 'SourceContextRequest ({ phase: "phase1" } or ' + - '{ phase: "phase2", parsed }).', + '{ phase: "phase2", parsed }), but got: object.', }, ); }); @@ -1548,7 +1548,7 @@ describe("createConfigContext input validation", () => { name: "TypeError", message: "Expected getAnnotations() to receive no request or a " + 'SourceContextRequest ({ phase: "phase1" } or ' + - '{ phase: "phase2", parsed }).', + '{ phase: "phase2", parsed }), but got: object.', }, ); }); @@ -1566,7 +1566,7 @@ describe("createConfigContext input validation", () => { name: "TypeError", message: "Expected getAnnotations() to receive no request or a " + 'SourceContextRequest ({ phase: "phase1" } or ' + - '{ phase: "phase2", parsed }).', + '{ phase: "phase2", parsed }), but got: null.', }, ); }); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e5e44fd7d..5804325d6 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -403,7 +403,7 @@ export function createConfigContext( throw new TypeError( "Expected getAnnotations() to receive no request or a " + 'SourceContextRequest ({ phase: "phase1" } or ' + - '{ phase: "phase2", parsed }).', + `{ phase: "phase2", parsed }), but got: ${getTypeName(request)}.`, ); } if (request.phase === "phase1") return {}; From 37b2eca8a15c88d94719bf080c0eae1cb8368248 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 11 Apr 2026 00:32:36 +0900 Subject: [PATCH 15/15] Align phase-one test helper semantics Treat an omitted SourceContext request as the documented manual phase-one path in the core and run test helpers. This keeps the helper semantics aligned with the public getAnnotations() contract without reopening the old raw-payload regression coverage. While touching those helpers, rename the remaining test callback parameters from _parsed to _request where they still received the request object. Co-Authored-By: OpenAI Codex --- packages/core/src/facade.test.ts | 27 ++++++++++++++------------- packages/run/src/run.test.ts | 17 +++++++++-------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/core/src/facade.test.ts b/packages/core/src/facade.test.ts index ca32be3fb..dc830a2f3 100644 --- a/packages/core/src/facade.test.ts +++ b/packages/core/src/facade.test.ts @@ -56,10 +56,11 @@ import { bindEnv, createEnvContext } from "../../env/src/index.ts"; type AssertNever = T; function isPhase1ContextRequest(request: unknown): boolean { - return request != null && - typeof request === "object" && - "phase" in request && - (request as { readonly phase?: unknown }).phase === "phase1"; + return request === undefined || + (request != null && + typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase1"); } function isPhase2ContextRequest( @@ -6296,7 +6297,7 @@ describe("runWith", () => { const dynamicContext: SourceContext = { id: Symbol("dynamic"), phase: "two-pass", - getAnnotations(_parsed?: unknown) { + getAnnotations(_request?: unknown) { return Promise.resolve({}); }, }; @@ -6941,9 +6942,9 @@ describe("runWith", () => { const context: SourceContext = { id: passthroughKey, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; - if (isPhase1ContextRequest(_parsed)) return {}; + if (isPhase1ContextRequest(_request)) return {}; return { [passthroughKey]: { value: "loaded" } }; }, }; @@ -7002,7 +7003,7 @@ describe("runWith", () => { const context: SourceContext<{ args: string[] }> = { id: key, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; return {}; }, @@ -7032,7 +7033,7 @@ describe("runWith", () => { const context: SourceContext<{ help: string; programName: string }> = { id: key, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; return {}; }, @@ -7085,7 +7086,7 @@ describe("runWith", () => { const context: SourceContext<{ profile?: string }> = { id: key, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; return {}; }, @@ -8043,9 +8044,9 @@ describe("runWithSync", () => { const context: SourceContext = { id: syncKey, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; - if (isPhase1ContextRequest(_parsed)) return {}; + if (isPhase1ContextRequest(_request)) return {}; return { [syncKey]: { value: "loaded" } }; }, }; @@ -8074,7 +8075,7 @@ describe("runWithSync", () => { const context: SourceContext<{ help: string; programName: string }> = { id: key, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; return {}; }, diff --git a/packages/run/src/run.test.ts b/packages/run/src/run.test.ts index 3fb318895..041e98ec1 100644 --- a/packages/run/src/run.test.ts +++ b/packages/run/src/run.test.ts @@ -31,10 +31,11 @@ import { bindEnv, createEnvContext } from "../../env/src/index.ts"; const TEST_DIR = join(import.meta.dirname ?? ".", "test-configs"); function isPhase1ContextRequest(request: unknown): boolean { - return request != null && - typeof request === "object" && - "phase" in request && - (request as { readonly phase?: unknown }).phase === "phase1"; + return request === undefined || + (request != null && + typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase1"); } function isPhase2ContextRequest( @@ -1696,9 +1697,9 @@ describe("run with contexts", () => { const context: SourceContext = { id: key, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; - if (isPhase1ContextRequest(_parsed)) return {}; + if (isPhase1ContextRequest(_request)) return {}; return { [key]: { value: true } }; }, }; @@ -1729,7 +1730,7 @@ describe("run with contexts", () => { const context: SourceContext<{ help: string }> = { id: key, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; return {}; }, @@ -1762,7 +1763,7 @@ describe("run with contexts", () => { const context: SourceContext<{ programName: string }> = { id: key, phase: "two-pass", - getAnnotations(_parsed?: unknown, options?: unknown) { + getAnnotations(_request?: unknown, options?: unknown) { receivedOptions = options; return {}; },