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..0994f12b2 100644 --- a/docs/concepts/extend.md +++ b/docs/concepts/extend.md @@ -730,16 +730,23 @@ 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()`. 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. @@ -835,7 +842,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"), @@ -872,8 +882,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 = { @@ -891,14 +909,14 @@ 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 }; - if (!result.config) return {}; + async getAnnotations(request?: SourceContextRequest) { + if (request == null || request.phase === "phase1") 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 }; @@ -909,9 +927,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 +1006,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 +1095,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"); @@ -1115,8 +1138,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"); @@ -1130,14 +1161,16 @@ 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 }; - if (!result.config) return {}; // No config file specified + async getAnnotations( + request?: SourceContextRequest, + ): Promise { + if (request == null || request.phase === "phase1") return {}; + + 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 { @@ -1151,10 +1184,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 @@ -1163,11 +1196,16 @@ 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 { - SourceContext, Annotations, ParserValuePlaceholder, + SourceContext, + SourceContextRequest, } from "@optique/core/context"; const configKey = Symbol.for("@myapp/config"); @@ -1184,19 +1222,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 as ParserValuePlaceholder | undefined; + // Use the injected getConfigPath function - const configPath = context.getConfigPath?.(parsed); + const configPath = parsed == null + ? undefined + : options?.getConfigPath(parsed); if (!configPath) return {}; try { @@ -1208,7 +1251,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/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 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..92e94cae2 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,28 +1507,78 @@ 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 }, + const result = context.getAnnotations(phase2({ any: 1 }), { + load: () => ({ + config: { host: "ok" }, + meta: undefined, + }), + getConfigPath: "nope" as never, + }); + 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 }) }, + ), { - load: () => ({ - config: { host: "ok" }, - meta: undefined, - }), - getConfigPath: "nope" as never, + name: "TypeError", + message: "Expected getAnnotations() to receive no request or a " + + 'SourceContextRequest ({ phase: "phase1" } or ' + + '{ phase: "phase2", parsed }), but got: object.', }, ); - assert.ok(result != null); }); - test("rejects non-string getConfigPath() return value (object)", () => { + 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 }), but got: object.', + }, + ); + }); + + test("rejects null request in getAnnotations", () => { const schema = z.object({ host: z.string() }); const context = createConfigContext({ schema }); assert.throws( () => context.getAnnotations( - {}, - { getConfigPath: (() => ({ path: "./foo.json" })) as never }, + 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 }), but got: null.', + }, + ); + }); + + test("rejects non-string getConfigPath() return value (object)", () => { + const schema = z.object({ host: z.string() }); + const context = createConfigContext({ schema }); + assert.throws( + () => + context.getAnnotations(phase2({}), { + getConfigPath: (() => ({ path: "./foo.json" })) as never, + }), { name: "TypeError", message: @@ -1544,12 +1592,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 +1608,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 +1624,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 +1645,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 +1655,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 +1673,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 +1684,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 +1694,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 +1707,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 +1725,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 +1736,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 +1750,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 +1787,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 +1800,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 +1826,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 +1838,7 @@ describe("load() return value validation", () => { ); assert.deepEqual( - context.getAnnotations( - {}, - { getConfigPath: () => undefined }, - ), + context.getAnnotations(phase2({}), { getConfigPath: () => undefined }), {}, ); @@ -1835,11 +1854,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 +1866,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 +1880,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 +1906,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 +1923,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 +1940,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 +1958,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 +1981,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 +2005,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 +2027,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 +2049,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 +2063,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 +2077,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 +2096,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 +2124,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 +2148,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 +2181,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 +2203,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 +2216,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 +2236,7 @@ describe("createConfigContext error paths", () => { const context = createConfigContext({ schema }); const annotations = await context.getAnnotations( - { config: undefined }, + phase2({ config: undefined }), { getConfigPath: () => undefined }, ); @@ -2264,7 +2250,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 +2277,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 +2304,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 +2332,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 +2403,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 +2423,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 +2460,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 +2478,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..5804325d6 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,31 +370,43 @@ 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 === undefined) { return {}; } + if ( + request === null || + typeof request !== "object" || + !("phase" in request) || + (request.phase !== "phase1" && request.phase !== "phase2") || + (request.phase === "phase2" && !("parsed" in request)) + ) { + throw new TypeError( + "Expected getAnnotations() to receive no request or a " + + 'SourceContextRequest ({ phase: "phase1" } or ' + + `{ phase: "phase2", parsed }), but got: ${getTypeName(request)}.`, + ); + } + if (request.phase === "phase1") return {}; const opts = runtimeOptions as | ConfigContextRequiredOptions @@ -431,10 +436,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..dc830a2f3 100644 --- a/packages/core/src/facade.test.ts +++ b/packages/core/src/facade.test.ts @@ -8,7 +8,11 @@ import { tuple, } from "@optique/core/constructs"; import { getAnnotations, inheritAnnotations } from "@optique/core/annotations"; -import type { SourceContext } from "@optique/core/context"; +import type { + SourceContext, + SourceContextPhase2Request, + SourceContextRequest, +} from "@optique/core/context"; import type { DocSection } from "@optique/core/doc"; import { defineInheritedAnnotationParser, @@ -51,6 +55,28 @@ import { bindEnv, createEnvContext } from "../../env/src/index.ts"; type AssertNever = T; +function isPhase1ContextRequest(request: unknown): boolean { + return request === undefined || + (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" && + "parsed" in request; +} + +function getPhase2ContextParsed(request: unknown): T | undefined { + return isPhase2ContextRequest(request) ? request.parsed as T : undefined; +} + function getRuntimeExtractPhase2SeedKey(): symbol { const parser = command("probe", constant(null)); const key = Object.getOwnPropertySymbols(parser).find((symbol) => @@ -5595,8 +5621,8 @@ describe("runWith", () => { const lateDynamicContext: SourceContext = { id: Symbol.for("@test/phase-merge-late"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed == null) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -5649,8 +5675,8 @@ describe("runWith", () => { const clearingContext: SourceContext = { id: Symbol.for("@test/phase-clear-early"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed == null) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return { [sharedKey]: "phase1-early" }; } return {}; @@ -5660,8 +5686,8 @@ describe("runWith", () => { const fallbackContext: SourceContext = { id: Symbol.for("@test/phase-clear-late"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed == null) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -5687,10 +5713,10 @@ describe("runWith", () => { const dynamicContext: SourceContext = { id: configKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + getAnnotations(request?: unknown) { + const result = getPhase2ContextParsed<{ config?: string }>(request); + if (result == null) return {}; phase2Called = true; - const result = parsed as { config?: string }; if (!result.config) return {}; // Simulate loaded config return { [configKey]: { host: "dynamic-host" } }; @@ -5719,9 +5745,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 {}; }, @@ -5730,10 +5757,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 {}; }, }; @@ -5769,8 +5797,8 @@ describe("runWith", () => { const dynamicContext: SourceContext = { id: configKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; phase2Called = true; return { [configKey]: { host: "config-host" } }; }, @@ -6269,7 +6297,7 @@ describe("runWith", () => { const dynamicContext: SourceContext = { id: Symbol("dynamic"), phase: "two-pass", - getAnnotations(_parsed?: unknown) { + getAnnotations(_request?: unknown) { return Promise.resolve({}); }, }; @@ -6914,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 (!_parsed) return {}; + if (isPhase1ContextRequest(_request)) return {}; return { [passthroughKey]: { value: "loaded" } }; }, }; @@ -6945,9 +6973,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 (parsed === undefined) return {}; + if (isPhase1ContextRequest(request)) return {}; return { [dynamicKey]: { value: "loaded" } }; }, }; @@ -6975,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 {}; }, @@ -7005,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 {}; }, @@ -7058,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 {}; }, @@ -7283,8 +7311,8 @@ describe("runWithSync", () => { const mixedContext: SourceContext = { id: mixedKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // sync (empty → dynamic) + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // sync (empty → dynamic) return Promise.resolve({ [mixedKey]: { value: "loaded" } }); }, }; @@ -7924,8 +7952,8 @@ describe("runWithSync", () => { const lateDynamicContext: SourceContext = { id: Symbol.for("@test/phase-merge-sync-late"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed == null) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -7978,8 +8006,8 @@ describe("runWithSync", () => { const clearingContext: SourceContext = { id: Symbol.for("@test/phase-clear-sync-early"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed == null) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return { [sharedKey]: "phase1-early" }; } return {}; @@ -7989,8 +8017,8 @@ describe("runWithSync", () => { const fallbackContext: SourceContext = { id: Symbol.for("@test/phase-clear-sync-late"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed == null) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return {}; } return { [sharedKey]: "phase2-late" }; @@ -8016,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 (!_parsed) return {}; + if (isPhase1ContextRequest(_request)) return {}; return { [syncKey]: { value: "loaded" } }; }, }; @@ -8047,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 {}; }, @@ -10141,8 +10169,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; phase2Called = true; return { [dynKey]: {} }; }, @@ -10195,8 +10223,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: key, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return { [key]: { phase1: true } }; } phase2Called = true; @@ -10212,6 +10240,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; @@ -10254,16 +10331,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 +10410,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 +10494,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 +10623,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 +10752,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}` }; }, }; @@ -10739,8 +10831,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: tokenKey, phase: "two-pass", - getAnnotations(parsed) { - if (parsed == null) return {}; + getAnnotations(request) { + if (isPhase1ContextRequest(request)) return {}; phase2Called = true; return { [tokenKey]: "from-phase-two" }; }, @@ -10804,8 +10896,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: tokenKey, phase: "two-pass", - getAnnotations(parsed) { - if (parsed == null) return {}; + getAnnotations(request) { + if (isPhase1ContextRequest(request)) return {}; phase2Called = true; return { [tokenKey]: "from-phase-two" }; }, @@ -10862,15 +10954,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 +11032,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 +11111,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 +11179,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 +11289,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}` }; }, }; @@ -11226,8 +11333,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [dynKey]: {} }; }, }; @@ -11249,8 +11356,8 @@ describe("branch coverage: facade.ts edge cases", () => { const dynamicContext: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // dynamic (no symbols → hasDynamic = true) + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // dynamic (no symbols → hasDynamic = true) return { [dynKey]: {} }; }, }; @@ -11303,13 +11410,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(request?: unknown) { + if (isPhase1ContextRequest(request)) 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 + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11378,8 +11531,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { phase1Calls++; return { [dynKey]: {} }; } @@ -11474,8 +11627,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; // dynamic + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11499,8 +11652,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: dynKey, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [dynKey]: {} }; }, }; @@ -11543,13 +11696,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(request?: unknown) { + if (isPhase1ContextRequest(request)) 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 + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; // dynamic return { [dynKey]: {} }; }, }; @@ -11606,8 +11805,8 @@ describe("branch coverage: facade.ts edge cases", () => { const context: SourceContext = { id: key, phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) { return { [key]: { phase1: true } }; } phase2Called = true; @@ -11623,6 +11822,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({ @@ -11825,8 +12073,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 (parsed === undefined) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) return {}; return { [Symbol.for("@test/dyn-throw-in-first-pass")]: {} }; }, }; @@ -11871,8 +12119,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 (parsed === undefined) return {}; + getAnnotations(request?: unknown) { + if (isPhase1ContextRequest(request)) 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..909efb881 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[], @@ -2884,10 +2871,11 @@ async function collectPhase1Annotations( let snapshots: Annotations[] | undefined; for (const context of contexts) { - const result = context.getAnnotations(undefined, options); + const request: SourceContextRequest = { phase: "phase1" }; + 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 @@ -2943,21 +2931,19 @@ 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 request: SourceContextRequest = { + phase: "phase2", + parsed: preparedParsed, + }; + 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); } @@ -2973,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[], @@ -2983,15 +2969,16 @@ function collectPhase1AnnotationsSync( let snapshots: Annotations[] | undefined; for (const context of contexts) { - const result = context.getAnnotations(undefined, options); + 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.", ); } const internalAnnotations = context.getInternalAnnotations?.( - undefined, + request, result, ); const snapshot = internalAnnotations == null @@ -3024,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[], @@ -3048,26 +3035,24 @@ 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 request: SourceContextRequest = { + phase: "phase2", + parsed: preparedParsed, + }; + const result = context.getAnnotations(request, options); + if (result instanceof Promise) { + throw new TypeError( + `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. @@ -3633,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 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..6d1551613 100644 --- a/packages/inquirer/src/index.test.ts +++ b/packages/inquirer/src/index.test.ts @@ -8,7 +8,11 @@ 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, + SourceContextPhase2Request, + 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 +37,20 @@ const promptFunctionsOverrideSymbol = Symbol.for( let promptFunctionsOverrideQueue = Promise.resolve(); +function isPhase2ContextRequest( + request: unknown, +): request is SourceContextPhase2Request { + return request != null && + typeof request === "object" && + "phase" in request && + (request as { readonly phase?: unknown }).phase === "phase2" && + "parsed" in request; +} + +function getPhase2ContextParsed(request: unknown): T | undefined { + return isPhase2ContextRequest(request) ? request.parsed as T : undefined; +} + async function withPromptFunctionsOverride( override: Record, callback: () => Promise, @@ -1341,7 +1359,7 @@ describe("prompt()", () => { schema: createPromptConfigSchema(), }); const annotations = await context.getAnnotations( - { any: true }, + { phase: "phase2", parsed: { any: true } }, { load: () => ({ config: { apiKey: "config-secret" }, @@ -1568,11 +1586,14 @@ describe("prompt()", () => { const dynamicContext: SourceContext = { id: Symbol.for("@test/prompt-phase-two"), phase: "two-pass", - getAnnotations(parsed?: unknown) { - if (parsed === undefined) { + getAnnotations(request?: SourceContextRequest) { + const phase2ParsedValue = getPhase2ContextParsed< + { readonly config: string } + >(request); + if (phase2ParsedValue === undefined) { return {}; } - phase2Parsed = parsed as { readonly config: string }; + phase2Parsed = phase2ParsedValue; return {}; }, }; @@ -1608,12 +1629,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 +1684,8 @@ describe("prompt()", () => { id: Symbol.for("@test/top-level-config-prompt-phase-two"), phase: "two-pass", getAnnotations(parsed?: unknown) { - sawUndefined = parsed === undefined; + sawUndefined = isPhase2ContextRequest(parsed) && + getPhase2ContextParsed(parsed) === undefined; return {}; }, }; @@ -1714,8 +1737,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 +1814,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 +1881,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 +1943,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 +2004,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 +2076,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 +2141,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 +2269,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 +2422,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 +2485,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 +2553,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 +5040,7 @@ describe("prompt() with dependency sources", () => { }, }); const annotations = await configContext.getAnnotations( - {}, + { phase: "phase2", parsed: {} }, { load: () => ({ config: { mode: "prod" as const }, @@ -5208,7 +5249,7 @@ describe("prompt() with dependency sources", () => { }, }); const annotations = await configContext.getAnnotations( - {}, + { phase: "phase2", parsed: {} }, { load: () => ({ config: { mode: "prod" as const }, @@ -5299,7 +5340,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..041e98ec1 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, + SourceContextPhase2Request, } from "@optique/core/context"; import { message } from "@optique/core/message"; import { map, multiple, optional, withDefault } from "@optique/core/modifiers"; @@ -29,6 +30,28 @@ import { bindEnv, createEnvContext } from "../../env/src/index.ts"; const TEST_DIR = join(import.meta.dirname ?? ".", "test-configs"); +function isPhase1ContextRequest(request: unknown): boolean { + return request === undefined || + (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" && + "parsed" in request; +} + +function getPhase2ContextParsed(request: unknown): T | undefined { + return isPhase2ContextRequest(request) ? request.parsed as T : undefined; +} + function createPassthroughConfigSchema(): Parameters< typeof createConfigContext >[0]["schema"] { @@ -1674,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 (!_parsed) return {}; + if (isPhase1ContextRequest(_request)) return {}; return { [key]: { value: true } }; }, }; @@ -1707,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 {}; }, @@ -1740,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 {}; }, @@ -1973,12 +1996,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 +2114,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 +2170,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 +2504,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 +2731,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 {}; },