@optique/core 1.0.0-dev.1799 → 1.0.0-dev.1802

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@
17
17
  const annotationKey = Symbol.for("@optique/core/parser/annotation");
18
18
  /**
19
19
  * Internal marker attached during the first pass of `runWith()` so wrappers
20
- * with side effects can defer work until dynamic contexts have resolved.
20
+ * with side effects can defer work until two-pass contexts have resolved.
21
21
  *
22
22
  * @internal
23
23
  */
@@ -16,7 +16,7 @@
16
16
  declare const annotationKey: unique symbol;
17
17
  /**
18
18
  * Internal marker attached during the first pass of `runWith()` so wrappers
19
- * with side effects can defer work until dynamic contexts have resolved.
19
+ * with side effects can defer work until two-pass contexts have resolved.
20
20
  *
21
21
  * @internal
22
22
  */
@@ -16,7 +16,7 @@
16
16
  declare const annotationKey: unique symbol;
17
17
  /**
18
18
  * Internal marker attached during the first pass of `runWith()` so wrappers
19
- * with side effects can defer work until dynamic contexts have resolved.
19
+ * with side effects can defer work until two-pass contexts have resolved.
20
20
  *
21
21
  * @internal
22
22
  */
@@ -16,7 +16,7 @@
16
16
  const annotationKey = Symbol.for("@optique/core/parser/annotation");
17
17
  /**
18
18
  * Internal marker attached during the first pass of `runWith()` so wrappers
19
- * with side effects can defer work until dynamic contexts have resolved.
19
+ * with side effects can defer work until two-pass contexts have resolved.
20
20
  *
21
21
  * @internal
22
22
  */
package/dist/context.cjs CHANGED
@@ -1,23 +0,0 @@
1
-
2
- //#region src/context.ts
3
- /**
4
- * Checks whether a context is static (returns annotations without needing
5
- * parsed results).
6
- *
7
- * A context is considered static if it declares `mode: "static"` or if
8
- * `getAnnotations()` called without arguments returns a non-empty
9
- * annotations object synchronously.
10
- *
11
- * @param context The source context to check.
12
- * @returns `true` if the context is static, `false` otherwise.
13
- * @since 0.10.0
14
- */
15
- function isStaticContext(context) {
16
- if (context.mode !== void 0) return context.mode === "static";
17
- const result = context.getAnnotations();
18
- if (result instanceof Promise) return false;
19
- return Object.getOwnPropertySymbols(result).length > 0;
20
- }
21
-
22
- //#endregion
23
- exports.isStaticContext = isStaticContext;
@@ -3,16 +3,15 @@ import { Annotations } from "./annotations.cjs";
3
3
  //#region src/context.d.ts
4
4
 
5
5
  /**
6
- * Declares whether a {@link SourceContext} provides its annotations
7
- * immediately (`"static"`) or only after a prior parse pass (`"dynamic"`).
6
+ * Declares whether a {@link SourceContext} participates only in the initial
7
+ * annotation collection (`"single-pass"`) or is recollected after a usable
8
+ * first parse pass (`"two-pass"`).
8
9
  *
9
- * Used as the type of the optional `mode` field on {@link SourceContext}.
10
- * When set, {@link isStaticContext} reads this value directly instead of
11
- * calling `getAnnotations()`, preventing any side effects.
10
+ * Used as the type of the required `phase` field on {@link SourceContext}.
12
11
  *
13
12
  * @since 1.0.0
14
13
  */
15
- type SourceContextMode = "static" | "dynamic";
14
+ type SourceContextPhase = "single-pass" | "two-pass";
16
15
  /**
17
16
  * Brand symbol for ParserValuePlaceholder type.
18
17
  * @internal
@@ -50,12 +49,14 @@ type ParserValuePlaceholder = {
50
49
  /**
51
50
  * A source context that can provide data to parsers via annotations.
52
51
  *
53
- * Source contexts are used to inject external data (like environment variables
54
- * or config files) into the parsing process. They can be either:
52
+ * Source contexts are used to inject external data (like environment
53
+ * variables or config files) into the parsing process. They can be either:
55
54
  *
56
- * - *Static*: Data is immediately available (e.g., environment variables)
57
- * - *Dynamic*: Data depends on parsing results (e.g., config files whose path
58
- * is determined by a CLI option)
55
+ * - *Single-pass*: The runner collects annotations once before parsing
56
+ * (e.g., environment variables)
57
+ * - *Two-pass*: The runner collects annotations before parsing and then
58
+ * recollects them after a usable first parse pass (e.g., config files whose
59
+ * path is determined by a CLI option)
59
60
  *
60
61
  * Contexts may optionally implement `Disposable` or `AsyncDisposable` for
61
62
  * cleanup. When present, `runWith()` and `runWithSync()` call the dispose
@@ -68,9 +69,10 @@ type ParserValuePlaceholder = {
68
69
  *
69
70
  * @example
70
71
  * ```typescript
71
- * // Static context example (environment variables) - no extra options needed
72
+ * // Single-pass context example (environment variables)
72
73
  * const envContext: SourceContext = {
73
74
  * id: Symbol.for("@myapp/env"),
75
+ * phase: "single-pass",
74
76
  * getAnnotations() {
75
77
  * return {
76
78
  * [Symbol.for("@myapp/env")]: {
@@ -81,7 +83,7 @@ type ParserValuePlaceholder = {
81
83
  * }
82
84
  * };
83
85
  *
84
- * // Dynamic context that requires options from runWith()
86
+ * // Two-pass context that requires options from runWith()
85
87
  * interface ConfigContext extends SourceContext<{
86
88
  * getConfigPath: (parsed: ParserValuePlaceholder) => string | undefined;
87
89
  * }> {
@@ -107,42 +109,44 @@ interface SourceContext<TRequiredOptions = void> {
107
109
  */
108
110
  readonly $requiredOptions?: TRequiredOptions;
109
111
  /**
110
- * Optional declaration of whether this context is static or dynamic.
112
+ * Declares whether this context is collected once or recollected after a
113
+ * usable first parse pass.
111
114
  *
112
- * When present, {@link isStaticContext} reads this field directly instead
113
- * of calling {@link getAnnotations}, avoiding any side effects that
114
- * `getAnnotations` might have (such as mutating a global registry).
115
- *
116
- * If omitted, {@link isStaticContext} falls back to calling
117
- * `getAnnotations()` with no arguments to determine static-ness.
115
+ * `single-pass` contexts contribute only their phase-1 annotations to the
116
+ * final parse. `two-pass` contexts are called again with the first-pass
117
+ * parsed value (or a best-effort seed extracted from parser state) and that
118
+ * second return value becomes the context's final annotation snapshot.
118
119
  *
119
120
  * @since 1.0.0
120
121
  */
121
- readonly mode?: SourceContextMode;
122
+ readonly phase: SourceContextPhase;
122
123
  /**
123
124
  * Get annotations to inject into parsing.
124
125
  *
125
- * This method is called twice during `runWith()` execution:
126
+ * This method is called during phase 1 for every context and during phase 2
127
+ * only for `two-pass` contexts:
126
128
  *
127
- * 1. *First call*: `parsed` is `undefined`. Static contexts should return
128
- * their annotations, while dynamic contexts should return an empty object.
129
- * 2. *Second call*: `parsed` contains the first pass result, or a
130
- * best-effort partial value extracted from parser state when the first
131
- * pass reached a usable intermediate state but still did not complete
132
- * successfully. Dynamic contexts can use this to load external data
133
- * (e.g., reading a config file whose path was determined in the first
134
- * pass). Deferred or otherwise unresolved fields may be `undefined`.
135
- * If the runner cannot extract a usable value at all, this second call
136
- * is skipped and the original parse failure is reported instead.
129
+ * 1. *Phase 1*: `parsed` is `undefined`.
130
+ * 2. *Phase 2*: `parsed` contains the first pass result, or a best-effort
131
+ * partial value extracted from parser state when the first pass reached a
132
+ * usable intermediate state but still did not complete successfully.
133
+ * Deferred or otherwise unresolved fields may be `undefined`. This
134
+ * second return value is treated as the context's final annotation
135
+ * snapshot for the second parse pass, replacing that context's phase-one
136
+ * contribution. If the runner cannot extract a usable value at all, this
137
+ * second call is skipped and the original parse failure is reported
138
+ * instead.
137
139
  *
138
140
  * @param parsed Optional parsed result from a previous parse pass.
139
- * Static contexts can ignore this parameter.
140
- * Dynamic contexts use this to extract necessary data.
141
+ * `single-pass` contexts can ignore this parameter.
142
+ * `two-pass` contexts use this to extract or refine data.
141
143
  * @param options Optional context-required options provided by the caller
142
144
  * of `runWith()`. These are the options declared via the
143
145
  * `TRequiredOptions` type parameter.
144
- * @returns Annotations to merge into the parsing session. Can be a Promise
145
- * for async operations (e.g., loading config files).
146
+ * @returns Annotations to merge into the parsing session. During phase 2,
147
+ * returning `{}` clears any annotations this context contributed
148
+ * during phase 1. Can be a Promise for async operations (e.g.,
149
+ * loading config files).
146
150
  */
147
151
  getAnnotations(parsed?: unknown, options?: unknown): Promise<Annotations> | Annotations;
148
152
  /**
@@ -188,18 +192,5 @@ interface SourceContext<TRequiredOptions = void> {
188
192
  */
189
193
  [Symbol.asyncDispose]?(): void | PromiseLike<void>;
190
194
  }
191
- /**
192
- * Checks whether a context is static (returns annotations without needing
193
- * parsed results).
194
- *
195
- * A context is considered static if it declares `mode: "static"` or if
196
- * `getAnnotations()` called without arguments returns a non-empty
197
- * annotations object synchronously.
198
- *
199
- * @param context The source context to check.
200
- * @returns `true` if the context is static, `false` otherwise.
201
- * @since 0.10.0
202
- */
203
- declare function isStaticContext(context: SourceContext<unknown>): boolean;
204
195
  //#endregion
205
- export { type Annotations, ParserValuePlaceholder, SourceContext, SourceContextMode, isStaticContext };
196
+ export { type Annotations, ParserValuePlaceholder, SourceContext, SourceContextPhase };
package/dist/context.d.ts CHANGED
@@ -3,16 +3,15 @@ import { Annotations } from "./annotations.js";
3
3
  //#region src/context.d.ts
4
4
 
5
5
  /**
6
- * Declares whether a {@link SourceContext} provides its annotations
7
- * immediately (`"static"`) or only after a prior parse pass (`"dynamic"`).
6
+ * Declares whether a {@link SourceContext} participates only in the initial
7
+ * annotation collection (`"single-pass"`) or is recollected after a usable
8
+ * first parse pass (`"two-pass"`).
8
9
  *
9
- * Used as the type of the optional `mode` field on {@link SourceContext}.
10
- * When set, {@link isStaticContext} reads this value directly instead of
11
- * calling `getAnnotations()`, preventing any side effects.
10
+ * Used as the type of the required `phase` field on {@link SourceContext}.
12
11
  *
13
12
  * @since 1.0.0
14
13
  */
15
- type SourceContextMode = "static" | "dynamic";
14
+ type SourceContextPhase = "single-pass" | "two-pass";
16
15
  /**
17
16
  * Brand symbol for ParserValuePlaceholder type.
18
17
  * @internal
@@ -50,12 +49,14 @@ type ParserValuePlaceholder = {
50
49
  /**
51
50
  * A source context that can provide data to parsers via annotations.
52
51
  *
53
- * Source contexts are used to inject external data (like environment variables
54
- * or config files) into the parsing process. They can be either:
52
+ * Source contexts are used to inject external data (like environment
53
+ * variables or config files) into the parsing process. They can be either:
55
54
  *
56
- * - *Static*: Data is immediately available (e.g., environment variables)
57
- * - *Dynamic*: Data depends on parsing results (e.g., config files whose path
58
- * is determined by a CLI option)
55
+ * - *Single-pass*: The runner collects annotations once before parsing
56
+ * (e.g., environment variables)
57
+ * - *Two-pass*: The runner collects annotations before parsing and then
58
+ * recollects them after a usable first parse pass (e.g., config files whose
59
+ * path is determined by a CLI option)
59
60
  *
60
61
  * Contexts may optionally implement `Disposable` or `AsyncDisposable` for
61
62
  * cleanup. When present, `runWith()` and `runWithSync()` call the dispose
@@ -68,9 +69,10 @@ type ParserValuePlaceholder = {
68
69
  *
69
70
  * @example
70
71
  * ```typescript
71
- * // Static context example (environment variables) - no extra options needed
72
+ * // Single-pass context example (environment variables)
72
73
  * const envContext: SourceContext = {
73
74
  * id: Symbol.for("@myapp/env"),
75
+ * phase: "single-pass",
74
76
  * getAnnotations() {
75
77
  * return {
76
78
  * [Symbol.for("@myapp/env")]: {
@@ -81,7 +83,7 @@ type ParserValuePlaceholder = {
81
83
  * }
82
84
  * };
83
85
  *
84
- * // Dynamic context that requires options from runWith()
86
+ * // Two-pass context that requires options from runWith()
85
87
  * interface ConfigContext extends SourceContext<{
86
88
  * getConfigPath: (parsed: ParserValuePlaceholder) => string | undefined;
87
89
  * }> {
@@ -107,42 +109,44 @@ interface SourceContext<TRequiredOptions = void> {
107
109
  */
108
110
  readonly $requiredOptions?: TRequiredOptions;
109
111
  /**
110
- * Optional declaration of whether this context is static or dynamic.
112
+ * Declares whether this context is collected once or recollected after a
113
+ * usable first parse pass.
111
114
  *
112
- * When present, {@link isStaticContext} reads this field directly instead
113
- * of calling {@link getAnnotations}, avoiding any side effects that
114
- * `getAnnotations` might have (such as mutating a global registry).
115
- *
116
- * If omitted, {@link isStaticContext} falls back to calling
117
- * `getAnnotations()` with no arguments to determine static-ness.
115
+ * `single-pass` contexts contribute only their phase-1 annotations to the
116
+ * final parse. `two-pass` contexts are called again with the first-pass
117
+ * parsed value (or a best-effort seed extracted from parser state) and that
118
+ * second return value becomes the context's final annotation snapshot.
118
119
  *
119
120
  * @since 1.0.0
120
121
  */
121
- readonly mode?: SourceContextMode;
122
+ readonly phase: SourceContextPhase;
122
123
  /**
123
124
  * Get annotations to inject into parsing.
124
125
  *
125
- * This method is called twice during `runWith()` execution:
126
+ * This method is called during phase 1 for every context and during phase 2
127
+ * only for `two-pass` contexts:
126
128
  *
127
- * 1. *First call*: `parsed` is `undefined`. Static contexts should return
128
- * their annotations, while dynamic contexts should return an empty object.
129
- * 2. *Second call*: `parsed` contains the first pass result, or a
130
- * best-effort partial value extracted from parser state when the first
131
- * pass reached a usable intermediate state but still did not complete
132
- * successfully. Dynamic contexts can use this to load external data
133
- * (e.g., reading a config file whose path was determined in the first
134
- * pass). Deferred or otherwise unresolved fields may be `undefined`.
135
- * If the runner cannot extract a usable value at all, this second call
136
- * is skipped and the original parse failure is reported instead.
129
+ * 1. *Phase 1*: `parsed` is `undefined`.
130
+ * 2. *Phase 2*: `parsed` contains the first pass result, or a best-effort
131
+ * partial value extracted from parser state when the first pass reached a
132
+ * usable intermediate state but still did not complete successfully.
133
+ * Deferred or otherwise unresolved fields may be `undefined`. This
134
+ * second return value is treated as the context's final annotation
135
+ * snapshot for the second parse pass, replacing that context's phase-one
136
+ * contribution. If the runner cannot extract a usable value at all, this
137
+ * second call is skipped and the original parse failure is reported
138
+ * instead.
137
139
  *
138
140
  * @param parsed Optional parsed result from a previous parse pass.
139
- * Static contexts can ignore this parameter.
140
- * Dynamic contexts use this to extract necessary data.
141
+ * `single-pass` contexts can ignore this parameter.
142
+ * `two-pass` contexts use this to extract or refine data.
141
143
  * @param options Optional context-required options provided by the caller
142
144
  * of `runWith()`. These are the options declared via the
143
145
  * `TRequiredOptions` type parameter.
144
- * @returns Annotations to merge into the parsing session. Can be a Promise
145
- * for async operations (e.g., loading config files).
146
+ * @returns Annotations to merge into the parsing session. During phase 2,
147
+ * returning `{}` clears any annotations this context contributed
148
+ * during phase 1. Can be a Promise for async operations (e.g.,
149
+ * loading config files).
146
150
  */
147
151
  getAnnotations(parsed?: unknown, options?: unknown): Promise<Annotations> | Annotations;
148
152
  /**
@@ -188,18 +192,5 @@ interface SourceContext<TRequiredOptions = void> {
188
192
  */
189
193
  [Symbol.asyncDispose]?(): void | PromiseLike<void>;
190
194
  }
191
- /**
192
- * Checks whether a context is static (returns annotations without needing
193
- * parsed results).
194
- *
195
- * A context is considered static if it declares `mode: "static"` or if
196
- * `getAnnotations()` called without arguments returns a non-empty
197
- * annotations object synchronously.
198
- *
199
- * @param context The source context to check.
200
- * @returns `true` if the context is static, `false` otherwise.
201
- * @since 0.10.0
202
- */
203
- declare function isStaticContext(context: SourceContext<unknown>): boolean;
204
195
  //#endregion
205
- export { type Annotations, ParserValuePlaceholder, SourceContext, SourceContextMode, isStaticContext };
196
+ export { type Annotations, ParserValuePlaceholder, SourceContext, SourceContextPhase };
package/dist/context.js CHANGED
@@ -1,22 +0,0 @@
1
- //#region src/context.ts
2
- /**
3
- * Checks whether a context is static (returns annotations without needing
4
- * parsed results).
5
- *
6
- * A context is considered static if it declares `mode: "static"` or if
7
- * `getAnnotations()` called without arguments returns a non-empty
8
- * annotations object synchronously.
9
- *
10
- * @param context The source context to check.
11
- * @returns `true` if the context is static, `false` otherwise.
12
- * @since 0.10.0
13
- */
14
- function isStaticContext(context) {
15
- if (context.mode !== void 0) return context.mode === "static";
16
- const result = context.getAnnotations();
17
- if (result instanceof Promise) return false;
18
- return Object.getOwnPropertySymbols(result).length > 0;
19
- }
20
-
21
- //#endregion
22
- export { isStaticContext };
package/dist/facade.cjs CHANGED
@@ -1081,42 +1081,62 @@ function mergeAnnotations(annotationsList) {
1081
1081
  }
1082
1082
  return result;
1083
1083
  }
1084
+ function validateContextPhases(contexts) {
1085
+ for (const context of contexts) {
1086
+ const phase = context.phase;
1087
+ if (phase !== "single-pass" && phase !== "two-pass") throw new TypeError(`Context ${String(context.id)} must declare phase as "single-pass" or "two-pass".`);
1088
+ }
1089
+ }
1084
1090
  /**
1085
1091
  * Collects phase 1 annotations from all contexts and determines whether
1086
1092
  * two-phase parsing is needed.
1087
1093
  *
1088
1094
  * @param contexts Source contexts to collect annotations from.
1089
1095
  * @param options Optional context-required options to pass to each context.
1090
- * @returns Promise with merged annotations and dynamic-context hint.
1096
+ * @returns Promise with merged annotations, per-context snapshots, and a
1097
+ * two-phase hint.
1091
1098
  */
1092
1099
  async function collectPhase1Annotations(contexts, options) {
1093
1100
  const annotationsList = [];
1094
- let hasDynamic = false;
1101
+ const snapshots = [];
1102
+ let needsTwoPhase = false;
1095
1103
  for (const context of contexts) {
1096
1104
  const result = context.getAnnotations(void 0, options);
1097
- hasDynamic ||= needsTwoPhaseContext(context, result);
1098
1105
  const annotations = result instanceof Promise ? await result : result;
1099
1106
  const internalAnnotations = context.getInternalAnnotations?.(void 0, annotations);
1100
- annotationsList.push(internalAnnotations == null ? annotations : mergeAnnotations([annotations, internalAnnotations]));
1107
+ const snapshot = internalAnnotations == null ? annotations : mergeAnnotations([annotations, internalAnnotations]);
1108
+ annotationsList.push(snapshot);
1109
+ snapshots.push(snapshot);
1110
+ needsTwoPhase ||= context.phase === "two-pass";
1101
1111
  }
1102
1112
  return {
1103
1113
  annotations: mergeAnnotations(annotationsList),
1104
- annotationsList,
1105
- hasDynamic
1114
+ needsTwoPhase,
1115
+ snapshots
1106
1116
  };
1107
1117
  }
1108
1118
  /**
1109
- * Collects annotations from all contexts.
1119
+ * Collects final annotations from all contexts.
1120
+ *
1121
+ * `single-pass` contexts reuse their phase-1 snapshot. `two-pass` contexts
1122
+ * are recollected with the parsed value and replace their own phase-1
1123
+ * snapshot in the final merge.
1110
1124
  *
1111
1125
  * @param contexts Source contexts to collect annotations from.
1126
+ * @param phase1Snapshots Per-context snapshots collected during phase 1.
1112
1127
  * @param parsed Optional parsed result from a previous parse pass.
1113
1128
  * @param options Optional context-required options to pass to each context.
1114
1129
  * @returns Promise that resolves to merged annotations.
1115
1130
  */
1116
- async function collectAnnotations(contexts, parsed, options, deferred, deferredKeys) {
1131
+ async function collectFinalAnnotations(contexts, phase1Snapshots, parsed, options, deferred, deferredKeys) {
1117
1132
  const annotationsList = [];
1118
1133
  const preparedParsed = prepareParsedForContexts(parsed, deferred, deferredKeys);
1119
- for (const context of contexts) {
1134
+ for (let index = 0; index < contexts.length; index++) {
1135
+ const context = contexts[index];
1136
+ if (context.phase === "single-pass") {
1137
+ annotationsList.push(phase1Snapshots[index]);
1138
+ continue;
1139
+ }
1120
1140
  const mergedAnnotations = await withPreparedParsedForContext(context, preparedParsed, async (contextParsed) => {
1121
1141
  const result = context.getAnnotations(contextParsed, options);
1122
1142
  const annotations = result instanceof Promise ? await result : result;
@@ -1125,10 +1145,7 @@ async function collectAnnotations(contexts, parsed, options, deferred, deferredK
1125
1145
  });
1126
1146
  annotationsList.push(mergedAnnotations);
1127
1147
  }
1128
- return {
1129
- annotations: mergeAnnotations(annotationsList),
1130
- annotationsList
1131
- };
1148
+ return { annotations: mergeAnnotations(annotationsList) };
1132
1149
  }
1133
1150
  /**
1134
1151
  * Collects phase 1 annotations from all contexts synchronously and determines
@@ -1136,50 +1153,51 @@ async function collectAnnotations(contexts, parsed, options, deferred, deferredK
1136
1153
  *
1137
1154
  * @param contexts Source contexts to collect annotations from.
1138
1155
  * @param options Optional context-required options to pass to each context.
1139
- * @returns Merged annotations with dynamic-context hint.
1156
+ * @returns Merged annotations, per-context snapshots, and a two-phase hint.
1140
1157
  * @throws Error if any context returns a Promise.
1141
1158
  */
1142
1159
  function collectPhase1AnnotationsSync(contexts, options) {
1143
1160
  const annotationsList = [];
1144
- let hasDynamic = false;
1161
+ const snapshots = [];
1162
+ let needsTwoPhase = false;
1145
1163
  for (const context of contexts) {
1146
1164
  const result = context.getAnnotations(void 0, options);
1147
1165
  if (result instanceof Promise) throw new Error(`Context ${String(context.id)} returned a Promise in sync mode. Use runWith() or runWithAsync() for async contexts.`);
1148
- hasDynamic ||= needsTwoPhaseContext(context, result);
1149
1166
  const internalAnnotations = context.getInternalAnnotations?.(void 0, result);
1150
- annotationsList.push(internalAnnotations == null ? result : mergeAnnotations([result, internalAnnotations]));
1167
+ const snapshot = internalAnnotations == null ? result : mergeAnnotations([result, internalAnnotations]);
1168
+ annotationsList.push(snapshot);
1169
+ snapshots.push(snapshot);
1170
+ needsTwoPhase ||= context.phase === "two-pass";
1151
1171
  }
1152
1172
  return {
1153
1173
  annotations: mergeAnnotations(annotationsList),
1154
- annotationsList,
1155
- hasDynamic
1174
+ needsTwoPhase,
1175
+ snapshots
1156
1176
  };
1157
1177
  }
1158
1178
  /**
1159
- * Determines whether a context requires a second parse pass.
1179
+ * Collects final annotations from all contexts synchronously.
1160
1180
  *
1161
- * Explicit `mode` declarations take precedence over legacy heuristics so
1162
- * static contexts are not forced into two-phase parsing when they return
1163
- * empty annotations or a Promise.
1164
- */
1165
- function needsTwoPhaseContext(context, result) {
1166
- if (context.mode !== void 0) return context.mode === "dynamic";
1167
- if (result instanceof Promise) return true;
1168
- return Object.getOwnPropertySymbols(result).length === 0;
1169
- }
1170
- /**
1171
- * Collects annotations from all contexts synchronously.
1181
+ * `single-pass` contexts reuse their phase-1 snapshot. `two-pass` contexts
1182
+ * are recollected with the parsed value and replace their own phase-1
1183
+ * snapshot in the final merge.
1172
1184
  *
1173
1185
  * @param contexts Source contexts to collect annotations from.
1186
+ * @param phase1Snapshots Per-context snapshots collected during phase 1.
1174
1187
  * @param parsed Optional parsed result from a previous parse pass.
1175
1188
  * @param options Optional context-required options to pass to each context.
1176
1189
  * @returns Merged annotations.
1177
1190
  * @throws Error if any context returns a Promise.
1178
1191
  */
1179
- function collectAnnotationsSync(contexts, parsed, options, deferred, deferredKeys) {
1192
+ function collectFinalAnnotationsSync(contexts, phase1Snapshots, parsed, options, deferred, deferredKeys) {
1180
1193
  const annotationsList = [];
1181
1194
  const preparedParsed = prepareParsedForContexts(parsed, deferred, deferredKeys);
1182
- for (const context of contexts) {
1195
+ for (let index = 0; index < contexts.length; index++) {
1196
+ const context = contexts[index];
1197
+ if (context.phase === "single-pass") {
1198
+ annotationsList.push(phase1Snapshots[index]);
1199
+ continue;
1200
+ }
1183
1201
  const mergedAnnotations = withPreparedParsedForContext(context, preparedParsed, (contextParsed) => {
1184
1202
  const result = context.getAnnotations(contextParsed, options);
1185
1203
  if (result instanceof Promise) throw new Error(`Context ${String(context.id)} returned a Promise in sync mode. Use runWith() or runWithAsync() for async contexts.`);
@@ -1188,16 +1206,7 @@ function collectAnnotationsSync(contexts, parsed, options, deferred, deferredKey
1188
1206
  });
1189
1207
  annotationsList.push(mergedAnnotations);
1190
1208
  }
1191
- return {
1192
- annotations: mergeAnnotations(annotationsList),
1193
- annotationsList
1194
- };
1195
- }
1196
- function mergeTwoPhaseAnnotations(phase1AnnotationsList, phase2AnnotationsList) {
1197
- const mergedPerContext = [];
1198
- const length = Math.max(phase1AnnotationsList.length, phase2AnnotationsList.length);
1199
- for (let i = 0; i < length; i++) mergedPerContext.push(mergeAnnotations([phase2AnnotationsList[i] ?? {}, phase1AnnotationsList[i] ?? {}]));
1200
- return mergeAnnotations(mergedPerContext);
1209
+ return { annotations: mergeAnnotations(annotationsList) };
1201
1210
  }
1202
1211
  /**
1203
1212
  * Disposes all contexts that implement `AsyncDisposable` or `Disposable`.
@@ -1242,12 +1251,13 @@ function disposeContextsSync(contexts) {
1242
1251
  */
1243
1252
  async function runWithBody(parser, programName, contexts, args, options) {
1244
1253
  require_validate.validateContextIds(contexts);
1254
+ validateContextPhases(contexts);
1245
1255
  if (needsEarlyExit(args, options)) {
1246
1256
  if (parser.$mode === "async") return runParser(parser, programName, args, options);
1247
1257
  return Promise.resolve(runParser(parser, programName, args, options));
1248
1258
  }
1249
1259
  const ctxOptions = options.contextOptions;
1250
- const { annotations: phase1Annotations, annotationsList: phase1AnnotationsList, hasDynamic: needsTwoPhase } = await collectPhase1Annotations(contexts, ctxOptions);
1260
+ const { annotations: phase1Annotations, needsTwoPhase, snapshots: phase1Snapshots } = await collectPhase1Annotations(contexts, ctxOptions);
1251
1261
  if (!needsTwoPhase) {
1252
1262
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1253
1263
  if (parser.$mode === "async") return runParser(augmentedParser, programName, args, options);
@@ -1260,8 +1270,7 @@ async function runWithBody(parser, programName, contexts, args, options) {
1260
1270
  if (parser.$mode === "async") return runParser(augmentedParser, programName, args, options);
1261
1271
  return Promise.resolve(runParser(augmentedParser, programName, args, options));
1262
1272
  }
1263
- const { annotationsList: phase2AnnotationsList } = await collectAnnotations(contexts, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1264
- const finalAnnotations = mergeTwoPhaseAnnotations(phase1AnnotationsList, phase2AnnotationsList);
1273
+ const { annotations: finalAnnotations } = await collectFinalAnnotations(contexts, phase1Snapshots, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1265
1274
  const augmentedParser2 = injectAnnotationsIntoParser(parser, finalAnnotations);
1266
1275
  if (parser.$mode === "async") return runParser(augmentedParser2, programName, args, options);
1267
1276
  return Promise.resolve(runParser(augmentedParser2, programName, args, options));
@@ -1269,26 +1278,28 @@ async function runWithBody(parser, programName, contexts, args, options) {
1269
1278
  /**
1270
1279
  * Runs a parser with multiple source contexts.
1271
1280
  *
1272
- * This function automatically handles static and dynamic contexts with proper
1273
- * priority. Earlier contexts in the array override later ones.
1281
+ * This function automatically handles single-pass and two-pass contexts with
1282
+ * proper priority. Earlier contexts in the array override later ones.
1274
1283
  *
1275
1284
  * The function uses a smart two-phase approach:
1276
1285
  *
1277
- * 1. *Phase 1*: Collect annotations from all contexts (static contexts return
1278
- * their data, dynamic contexts may return empty).
1286
+ * 1. *Phase 1*: Collect annotations from all contexts.
1279
1287
  * 2. *First parse*: Parse with Phase 1 annotations. If that pass finishes
1280
1288
  * successfully, its value becomes the phase-two input. If the parser
1281
1289
  * reaches a usable intermediate state but still does not complete
1282
1290
  * successfully, the runner extracts a best-effort seed from that state
1283
1291
  * instead.
1284
- * 3. *Phase 2*: Call `getAnnotations(parsed)` on all contexts with the first
1285
- * pass value. Deferred or otherwise unresolved fields in `parsed` may be
1286
- * `undefined`.
1287
- * 4. *Second parse*: Parse again with merged annotations from both phases.
1292
+ * 3. *Phase 2*: Call `getAnnotations(parsed)` on all two-pass contexts with
1293
+ * the first pass value. Deferred or otherwise unresolved fields in
1294
+ * `parsed` may be `undefined`. Each two-pass context's phase-two return
1295
+ * value replaces its own phase-one contribution for the final parse, so
1296
+ * returning `{}` clears any annotations that context provided during
1297
+ * phase 1. Single-pass contexts reuse their phase-one snapshot.
1298
+ * 4. *Second parse*: Parse again with the merged phase-two annotations.
1288
1299
  *
1289
- * If all contexts are static (no dynamic contexts), the second parse is
1290
- * skipped for optimization. Phase 2 is also skipped when the first pass does
1291
- * not yield any usable seed at all.
1300
+ * If all contexts are single-pass, the second parse is skipped for
1301
+ * optimization. Phase 2 is also skipped when the first pass does not yield
1302
+ * any usable seed at all.
1292
1303
  *
1293
1304
  * @template TParser The parser type.
1294
1305
  * @template THelp Return type when help is shown.
@@ -1300,6 +1311,8 @@ async function runWithBody(parser, programName, contexts, args, options) {
1300
1311
  * @returns Promise that resolves to the parsed result.
1301
1312
  * @throws {TypeError} If two or more contexts share the same
1302
1313
  * {@link SourceContext.id}.
1314
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
1315
+ * phase value.
1303
1316
  * @throws {SuppressedError} If the runner throws and a context's disposal
1304
1317
  * also throws. The original error is available via `.suppressed` and the
1305
1318
  * disposal error via `.error`.
@@ -1312,6 +1325,7 @@ async function runWithBody(parser, programName, contexts, args, options) {
1312
1325
  *
1313
1326
  * const envContext: SourceContext = {
1314
1327
  * id: Symbol.for("@myapp/env"),
1328
+ * phase: "single-pass",
1315
1329
  * getAnnotations() {
1316
1330
  * return { [Symbol.for("@myapp/env")]: process.env };
1317
1331
  * }
@@ -1355,9 +1369,10 @@ async function runWith(parser, programName, contexts, options) {
1355
1369
  */
1356
1370
  function runWithSyncBody(parser, programName, contexts, args, options) {
1357
1371
  require_validate.validateContextIds(contexts);
1372
+ validateContextPhases(contexts);
1358
1373
  if (needsEarlyExit(args, options)) return runParser(parser, programName, args, options);
1359
1374
  const ctxOptions = options.contextOptions;
1360
- const { annotations: phase1Annotations, annotationsList: phase1AnnotationsList, hasDynamic: needsTwoPhase } = collectPhase1AnnotationsSync(contexts, ctxOptions);
1375
+ const { annotations: phase1Annotations, needsTwoPhase, snapshots: phase1Snapshots } = collectPhase1AnnotationsSync(contexts, ctxOptions);
1361
1376
  if (!needsTwoPhase) {
1362
1377
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1363
1378
  return runParser(augmentedParser, programName, args, options);
@@ -1368,8 +1383,7 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1368
1383
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1369
1384
  return runParser(augmentedParser, programName, args, options);
1370
1385
  }
1371
- const { annotationsList: phase2AnnotationsList } = collectAnnotationsSync(contexts, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1372
- const finalAnnotations = mergeTwoPhaseAnnotations(phase1AnnotationsList, phase2AnnotationsList);
1386
+ const { annotations: finalAnnotations } = collectFinalAnnotationsSync(contexts, phase1Snapshots, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1373
1387
  const augmentedParser2 = injectAnnotationsIntoParser(parser, finalAnnotations);
1374
1388
  return runParser(augmentedParser2, programName, args, options);
1375
1389
  }
@@ -1378,8 +1392,10 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1378
1392
  *
1379
1393
  * This is the sync-only variant of {@link runWith}. All contexts must return
1380
1394
  * annotations synchronously (not Promises). It uses the same two-phase
1381
- * best-effort seed extraction as {@link runWith} when dynamic contexts are
1382
- * present.
1395
+ * best-effort seed extraction as {@link runWith} when two-pass contexts are
1396
+ * present. In two-phase runs, each two-pass context's phase-two return value
1397
+ * replaces that context's phase-one contribution for the final parse, so
1398
+ * returning `{}` clears any annotations that context provided during phase 1.
1383
1399
  *
1384
1400
  * @template TParser The sync parser type.
1385
1401
  * @template THelp Return type when help is shown.
@@ -1393,6 +1409,8 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1393
1409
  * {@link runWith} or {@link runWithAsync} for async parsers.
1394
1410
  * @throws {TypeError} If two or more contexts share the same
1395
1411
  * {@link SourceContext.id}.
1412
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
1413
+ * phase value.
1396
1414
  * @throws {Error} If any context returns a Promise or if a context's
1397
1415
  * `[Symbol.asyncDispose]` returns a Promise.
1398
1416
  * @throws {SuppressedError} If the runner throws and a context's disposal
package/dist/facade.d.cts CHANGED
@@ -405,26 +405,28 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
405
405
  /**
406
406
  * Runs a parser with multiple source contexts.
407
407
  *
408
- * This function automatically handles static and dynamic contexts with proper
409
- * priority. Earlier contexts in the array override later ones.
408
+ * This function automatically handles single-pass and two-pass contexts with
409
+ * proper priority. Earlier contexts in the array override later ones.
410
410
  *
411
411
  * The function uses a smart two-phase approach:
412
412
  *
413
- * 1. *Phase 1*: Collect annotations from all contexts (static contexts return
414
- * their data, dynamic contexts may return empty).
413
+ * 1. *Phase 1*: Collect annotations from all contexts.
415
414
  * 2. *First parse*: Parse with Phase 1 annotations. If that pass finishes
416
415
  * successfully, its value becomes the phase-two input. If the parser
417
416
  * reaches a usable intermediate state but still does not complete
418
417
  * successfully, the runner extracts a best-effort seed from that state
419
418
  * instead.
420
- * 3. *Phase 2*: Call `getAnnotations(parsed)` on all contexts with the first
421
- * pass value. Deferred or otherwise unresolved fields in `parsed` may be
422
- * `undefined`.
423
- * 4. *Second parse*: Parse again with merged annotations from both phases.
419
+ * 3. *Phase 2*: Call `getAnnotations(parsed)` on all two-pass contexts with
420
+ * the first pass value. Deferred or otherwise unresolved fields in
421
+ * `parsed` may be `undefined`. Each two-pass context's phase-two return
422
+ * value replaces its own phase-one contribution for the final parse, so
423
+ * returning `{}` clears any annotations that context provided during
424
+ * phase 1. Single-pass contexts reuse their phase-one snapshot.
425
+ * 4. *Second parse*: Parse again with the merged phase-two annotations.
424
426
  *
425
- * If all contexts are static (no dynamic contexts), the second parse is
426
- * skipped for optimization. Phase 2 is also skipped when the first pass does
427
- * not yield any usable seed at all.
427
+ * If all contexts are single-pass, the second parse is skipped for
428
+ * optimization. Phase 2 is also skipped when the first pass does not yield
429
+ * any usable seed at all.
428
430
  *
429
431
  * @template TParser The parser type.
430
432
  * @template THelp Return type when help is shown.
@@ -436,6 +438,8 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
436
438
  * @returns Promise that resolves to the parsed result.
437
439
  * @throws {TypeError} If two or more contexts share the same
438
440
  * {@link SourceContext.id}.
441
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
442
+ * phase value.
439
443
  * @throws {SuppressedError} If the runner throws and a context's disposal
440
444
  * also throws. The original error is available via `.suppressed` and the
441
445
  * disposal error via `.error`.
@@ -448,6 +452,7 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
448
452
  *
449
453
  * const envContext: SourceContext = {
450
454
  * id: Symbol.for("@myapp/env"),
455
+ * phase: "single-pass",
451
456
  * getAnnotations() {
452
457
  * return { [Symbol.for("@myapp/env")]: process.env };
453
458
  * }
@@ -467,8 +472,10 @@ declare function runWith<TParser extends Parser<Mode, unknown, unknown>, TContex
467
472
  *
468
473
  * This is the sync-only variant of {@link runWith}. All contexts must return
469
474
  * annotations synchronously (not Promises). It uses the same two-phase
470
- * best-effort seed extraction as {@link runWith} when dynamic contexts are
471
- * present.
475
+ * best-effort seed extraction as {@link runWith} when two-pass contexts are
476
+ * present. In two-phase runs, each two-pass context's phase-two return value
477
+ * replaces that context's phase-one contribution for the final parse, so
478
+ * returning `{}` clears any annotations that context provided during phase 1.
472
479
  *
473
480
  * @template TParser The sync parser type.
474
481
  * @template THelp Return type when help is shown.
@@ -482,6 +489,8 @@ declare function runWith<TParser extends Parser<Mode, unknown, unknown>, TContex
482
489
  * {@link runWith} or {@link runWithAsync} for async parsers.
483
490
  * @throws {TypeError} If two or more contexts share the same
484
491
  * {@link SourceContext.id}.
492
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
493
+ * phase value.
485
494
  * @throws {Error} If any context returns a Promise or if a context's
486
495
  * `[Symbol.asyncDispose]` returns a Promise.
487
496
  * @throws {SuppressedError} If the runner throws and a context's disposal
package/dist/facade.d.ts CHANGED
@@ -405,26 +405,28 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
405
405
  /**
406
406
  * Runs a parser with multiple source contexts.
407
407
  *
408
- * This function automatically handles static and dynamic contexts with proper
409
- * priority. Earlier contexts in the array override later ones.
408
+ * This function automatically handles single-pass and two-pass contexts with
409
+ * proper priority. Earlier contexts in the array override later ones.
410
410
  *
411
411
  * The function uses a smart two-phase approach:
412
412
  *
413
- * 1. *Phase 1*: Collect annotations from all contexts (static contexts return
414
- * their data, dynamic contexts may return empty).
413
+ * 1. *Phase 1*: Collect annotations from all contexts.
415
414
  * 2. *First parse*: Parse with Phase 1 annotations. If that pass finishes
416
415
  * successfully, its value becomes the phase-two input. If the parser
417
416
  * reaches a usable intermediate state but still does not complete
418
417
  * successfully, the runner extracts a best-effort seed from that state
419
418
  * instead.
420
- * 3. *Phase 2*: Call `getAnnotations(parsed)` on all contexts with the first
421
- * pass value. Deferred or otherwise unresolved fields in `parsed` may be
422
- * `undefined`.
423
- * 4. *Second parse*: Parse again with merged annotations from both phases.
419
+ * 3. *Phase 2*: Call `getAnnotations(parsed)` on all two-pass contexts with
420
+ * the first pass value. Deferred or otherwise unresolved fields in
421
+ * `parsed` may be `undefined`. Each two-pass context's phase-two return
422
+ * value replaces its own phase-one contribution for the final parse, so
423
+ * returning `{}` clears any annotations that context provided during
424
+ * phase 1. Single-pass contexts reuse their phase-one snapshot.
425
+ * 4. *Second parse*: Parse again with the merged phase-two annotations.
424
426
  *
425
- * If all contexts are static (no dynamic contexts), the second parse is
426
- * skipped for optimization. Phase 2 is also skipped when the first pass does
427
- * not yield any usable seed at all.
427
+ * If all contexts are single-pass, the second parse is skipped for
428
+ * optimization. Phase 2 is also skipped when the first pass does not yield
429
+ * any usable seed at all.
428
430
  *
429
431
  * @template TParser The parser type.
430
432
  * @template THelp Return type when help is shown.
@@ -436,6 +438,8 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
436
438
  * @returns Promise that resolves to the parsed result.
437
439
  * @throws {TypeError} If two or more contexts share the same
438
440
  * {@link SourceContext.id}.
441
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
442
+ * phase value.
439
443
  * @throws {SuppressedError} If the runner throws and a context's disposal
440
444
  * also throws. The original error is available via `.suppressed` and the
441
445
  * disposal error via `.error`.
@@ -448,6 +452,7 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
448
452
  *
449
453
  * const envContext: SourceContext = {
450
454
  * id: Symbol.for("@myapp/env"),
455
+ * phase: "single-pass",
451
456
  * getAnnotations() {
452
457
  * return { [Symbol.for("@myapp/env")]: process.env };
453
458
  * }
@@ -467,8 +472,10 @@ declare function runWith<TParser extends Parser<Mode, unknown, unknown>, TContex
467
472
  *
468
473
  * This is the sync-only variant of {@link runWith}. All contexts must return
469
474
  * annotations synchronously (not Promises). It uses the same two-phase
470
- * best-effort seed extraction as {@link runWith} when dynamic contexts are
471
- * present.
475
+ * best-effort seed extraction as {@link runWith} when two-pass contexts are
476
+ * present. In two-phase runs, each two-pass context's phase-two return value
477
+ * replaces that context's phase-one contribution for the final parse, so
478
+ * returning `{}` clears any annotations that context provided during phase 1.
472
479
  *
473
480
  * @template TParser The sync parser type.
474
481
  * @template THelp Return type when help is shown.
@@ -482,6 +489,8 @@ declare function runWith<TParser extends Parser<Mode, unknown, unknown>, TContex
482
489
  * {@link runWith} or {@link runWithAsync} for async parsers.
483
490
  * @throws {TypeError} If two or more contexts share the same
484
491
  * {@link SourceContext.id}.
492
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
493
+ * phase value.
485
494
  * @throws {Error} If any context returns a Promise or if a context's
486
495
  * `[Symbol.asyncDispose]` returns a Promise.
487
496
  * @throws {SuppressedError} If the runner throws and a context's disposal
package/dist/facade.js CHANGED
@@ -1081,42 +1081,62 @@ function mergeAnnotations(annotationsList) {
1081
1081
  }
1082
1082
  return result;
1083
1083
  }
1084
+ function validateContextPhases(contexts) {
1085
+ for (const context of contexts) {
1086
+ const phase = context.phase;
1087
+ if (phase !== "single-pass" && phase !== "two-pass") throw new TypeError(`Context ${String(context.id)} must declare phase as "single-pass" or "two-pass".`);
1088
+ }
1089
+ }
1084
1090
  /**
1085
1091
  * Collects phase 1 annotations from all contexts and determines whether
1086
1092
  * two-phase parsing is needed.
1087
1093
  *
1088
1094
  * @param contexts Source contexts to collect annotations from.
1089
1095
  * @param options Optional context-required options to pass to each context.
1090
- * @returns Promise with merged annotations and dynamic-context hint.
1096
+ * @returns Promise with merged annotations, per-context snapshots, and a
1097
+ * two-phase hint.
1091
1098
  */
1092
1099
  async function collectPhase1Annotations(contexts, options) {
1093
1100
  const annotationsList = [];
1094
- let hasDynamic = false;
1101
+ const snapshots = [];
1102
+ let needsTwoPhase = false;
1095
1103
  for (const context of contexts) {
1096
1104
  const result = context.getAnnotations(void 0, options);
1097
- hasDynamic ||= needsTwoPhaseContext(context, result);
1098
1105
  const annotations = result instanceof Promise ? await result : result;
1099
1106
  const internalAnnotations = context.getInternalAnnotations?.(void 0, annotations);
1100
- annotationsList.push(internalAnnotations == null ? annotations : mergeAnnotations([annotations, internalAnnotations]));
1107
+ const snapshot = internalAnnotations == null ? annotations : mergeAnnotations([annotations, internalAnnotations]);
1108
+ annotationsList.push(snapshot);
1109
+ snapshots.push(snapshot);
1110
+ needsTwoPhase ||= context.phase === "two-pass";
1101
1111
  }
1102
1112
  return {
1103
1113
  annotations: mergeAnnotations(annotationsList),
1104
- annotationsList,
1105
- hasDynamic
1114
+ needsTwoPhase,
1115
+ snapshots
1106
1116
  };
1107
1117
  }
1108
1118
  /**
1109
- * Collects annotations from all contexts.
1119
+ * Collects final annotations from all contexts.
1120
+ *
1121
+ * `single-pass` contexts reuse their phase-1 snapshot. `two-pass` contexts
1122
+ * are recollected with the parsed value and replace their own phase-1
1123
+ * snapshot in the final merge.
1110
1124
  *
1111
1125
  * @param contexts Source contexts to collect annotations from.
1126
+ * @param phase1Snapshots Per-context snapshots collected during phase 1.
1112
1127
  * @param parsed Optional parsed result from a previous parse pass.
1113
1128
  * @param options Optional context-required options to pass to each context.
1114
1129
  * @returns Promise that resolves to merged annotations.
1115
1130
  */
1116
- async function collectAnnotations(contexts, parsed, options, deferred, deferredKeys) {
1131
+ async function collectFinalAnnotations(contexts, phase1Snapshots, parsed, options, deferred, deferredKeys) {
1117
1132
  const annotationsList = [];
1118
1133
  const preparedParsed = prepareParsedForContexts(parsed, deferred, deferredKeys);
1119
- for (const context of contexts) {
1134
+ for (let index = 0; index < contexts.length; index++) {
1135
+ const context = contexts[index];
1136
+ if (context.phase === "single-pass") {
1137
+ annotationsList.push(phase1Snapshots[index]);
1138
+ continue;
1139
+ }
1120
1140
  const mergedAnnotations = await withPreparedParsedForContext(context, preparedParsed, async (contextParsed) => {
1121
1141
  const result = context.getAnnotations(contextParsed, options);
1122
1142
  const annotations = result instanceof Promise ? await result : result;
@@ -1125,10 +1145,7 @@ async function collectAnnotations(contexts, parsed, options, deferred, deferredK
1125
1145
  });
1126
1146
  annotationsList.push(mergedAnnotations);
1127
1147
  }
1128
- return {
1129
- annotations: mergeAnnotations(annotationsList),
1130
- annotationsList
1131
- };
1148
+ return { annotations: mergeAnnotations(annotationsList) };
1132
1149
  }
1133
1150
  /**
1134
1151
  * Collects phase 1 annotations from all contexts synchronously and determines
@@ -1136,50 +1153,51 @@ async function collectAnnotations(contexts, parsed, options, deferred, deferredK
1136
1153
  *
1137
1154
  * @param contexts Source contexts to collect annotations from.
1138
1155
  * @param options Optional context-required options to pass to each context.
1139
- * @returns Merged annotations with dynamic-context hint.
1156
+ * @returns Merged annotations, per-context snapshots, and a two-phase hint.
1140
1157
  * @throws Error if any context returns a Promise.
1141
1158
  */
1142
1159
  function collectPhase1AnnotationsSync(contexts, options) {
1143
1160
  const annotationsList = [];
1144
- let hasDynamic = false;
1161
+ const snapshots = [];
1162
+ let needsTwoPhase = false;
1145
1163
  for (const context of contexts) {
1146
1164
  const result = context.getAnnotations(void 0, options);
1147
1165
  if (result instanceof Promise) throw new Error(`Context ${String(context.id)} returned a Promise in sync mode. Use runWith() or runWithAsync() for async contexts.`);
1148
- hasDynamic ||= needsTwoPhaseContext(context, result);
1149
1166
  const internalAnnotations = context.getInternalAnnotations?.(void 0, result);
1150
- annotationsList.push(internalAnnotations == null ? result : mergeAnnotations([result, internalAnnotations]));
1167
+ const snapshot = internalAnnotations == null ? result : mergeAnnotations([result, internalAnnotations]);
1168
+ annotationsList.push(snapshot);
1169
+ snapshots.push(snapshot);
1170
+ needsTwoPhase ||= context.phase === "two-pass";
1151
1171
  }
1152
1172
  return {
1153
1173
  annotations: mergeAnnotations(annotationsList),
1154
- annotationsList,
1155
- hasDynamic
1174
+ needsTwoPhase,
1175
+ snapshots
1156
1176
  };
1157
1177
  }
1158
1178
  /**
1159
- * Determines whether a context requires a second parse pass.
1179
+ * Collects final annotations from all contexts synchronously.
1160
1180
  *
1161
- * Explicit `mode` declarations take precedence over legacy heuristics so
1162
- * static contexts are not forced into two-phase parsing when they return
1163
- * empty annotations or a Promise.
1164
- */
1165
- function needsTwoPhaseContext(context, result) {
1166
- if (context.mode !== void 0) return context.mode === "dynamic";
1167
- if (result instanceof Promise) return true;
1168
- return Object.getOwnPropertySymbols(result).length === 0;
1169
- }
1170
- /**
1171
- * Collects annotations from all contexts synchronously.
1181
+ * `single-pass` contexts reuse their phase-1 snapshot. `two-pass` contexts
1182
+ * are recollected with the parsed value and replace their own phase-1
1183
+ * snapshot in the final merge.
1172
1184
  *
1173
1185
  * @param contexts Source contexts to collect annotations from.
1186
+ * @param phase1Snapshots Per-context snapshots collected during phase 1.
1174
1187
  * @param parsed Optional parsed result from a previous parse pass.
1175
1188
  * @param options Optional context-required options to pass to each context.
1176
1189
  * @returns Merged annotations.
1177
1190
  * @throws Error if any context returns a Promise.
1178
1191
  */
1179
- function collectAnnotationsSync(contexts, parsed, options, deferred, deferredKeys) {
1192
+ function collectFinalAnnotationsSync(contexts, phase1Snapshots, parsed, options, deferred, deferredKeys) {
1180
1193
  const annotationsList = [];
1181
1194
  const preparedParsed = prepareParsedForContexts(parsed, deferred, deferredKeys);
1182
- for (const context of contexts) {
1195
+ for (let index = 0; index < contexts.length; index++) {
1196
+ const context = contexts[index];
1197
+ if (context.phase === "single-pass") {
1198
+ annotationsList.push(phase1Snapshots[index]);
1199
+ continue;
1200
+ }
1183
1201
  const mergedAnnotations = withPreparedParsedForContext(context, preparedParsed, (contextParsed) => {
1184
1202
  const result = context.getAnnotations(contextParsed, options);
1185
1203
  if (result instanceof Promise) throw new Error(`Context ${String(context.id)} returned a Promise in sync mode. Use runWith() or runWithAsync() for async contexts.`);
@@ -1188,16 +1206,7 @@ function collectAnnotationsSync(contexts, parsed, options, deferred, deferredKey
1188
1206
  });
1189
1207
  annotationsList.push(mergedAnnotations);
1190
1208
  }
1191
- return {
1192
- annotations: mergeAnnotations(annotationsList),
1193
- annotationsList
1194
- };
1195
- }
1196
- function mergeTwoPhaseAnnotations(phase1AnnotationsList, phase2AnnotationsList) {
1197
- const mergedPerContext = [];
1198
- const length = Math.max(phase1AnnotationsList.length, phase2AnnotationsList.length);
1199
- for (let i = 0; i < length; i++) mergedPerContext.push(mergeAnnotations([phase2AnnotationsList[i] ?? {}, phase1AnnotationsList[i] ?? {}]));
1200
- return mergeAnnotations(mergedPerContext);
1209
+ return { annotations: mergeAnnotations(annotationsList) };
1201
1210
  }
1202
1211
  /**
1203
1212
  * Disposes all contexts that implement `AsyncDisposable` or `Disposable`.
@@ -1242,12 +1251,13 @@ function disposeContextsSync(contexts) {
1242
1251
  */
1243
1252
  async function runWithBody(parser, programName, contexts, args, options) {
1244
1253
  validateContextIds(contexts);
1254
+ validateContextPhases(contexts);
1245
1255
  if (needsEarlyExit(args, options)) {
1246
1256
  if (parser.$mode === "async") return runParser(parser, programName, args, options);
1247
1257
  return Promise.resolve(runParser(parser, programName, args, options));
1248
1258
  }
1249
1259
  const ctxOptions = options.contextOptions;
1250
- const { annotations: phase1Annotations, annotationsList: phase1AnnotationsList, hasDynamic: needsTwoPhase } = await collectPhase1Annotations(contexts, ctxOptions);
1260
+ const { annotations: phase1Annotations, needsTwoPhase, snapshots: phase1Snapshots } = await collectPhase1Annotations(contexts, ctxOptions);
1251
1261
  if (!needsTwoPhase) {
1252
1262
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1253
1263
  if (parser.$mode === "async") return runParser(augmentedParser, programName, args, options);
@@ -1260,8 +1270,7 @@ async function runWithBody(parser, programName, contexts, args, options) {
1260
1270
  if (parser.$mode === "async") return runParser(augmentedParser, programName, args, options);
1261
1271
  return Promise.resolve(runParser(augmentedParser, programName, args, options));
1262
1272
  }
1263
- const { annotationsList: phase2AnnotationsList } = await collectAnnotations(contexts, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1264
- const finalAnnotations = mergeTwoPhaseAnnotations(phase1AnnotationsList, phase2AnnotationsList);
1273
+ const { annotations: finalAnnotations } = await collectFinalAnnotations(contexts, phase1Snapshots, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1265
1274
  const augmentedParser2 = injectAnnotationsIntoParser(parser, finalAnnotations);
1266
1275
  if (parser.$mode === "async") return runParser(augmentedParser2, programName, args, options);
1267
1276
  return Promise.resolve(runParser(augmentedParser2, programName, args, options));
@@ -1269,26 +1278,28 @@ async function runWithBody(parser, programName, contexts, args, options) {
1269
1278
  /**
1270
1279
  * Runs a parser with multiple source contexts.
1271
1280
  *
1272
- * This function automatically handles static and dynamic contexts with proper
1273
- * priority. Earlier contexts in the array override later ones.
1281
+ * This function automatically handles single-pass and two-pass contexts with
1282
+ * proper priority. Earlier contexts in the array override later ones.
1274
1283
  *
1275
1284
  * The function uses a smart two-phase approach:
1276
1285
  *
1277
- * 1. *Phase 1*: Collect annotations from all contexts (static contexts return
1278
- * their data, dynamic contexts may return empty).
1286
+ * 1. *Phase 1*: Collect annotations from all contexts.
1279
1287
  * 2. *First parse*: Parse with Phase 1 annotations. If that pass finishes
1280
1288
  * successfully, its value becomes the phase-two input. If the parser
1281
1289
  * reaches a usable intermediate state but still does not complete
1282
1290
  * successfully, the runner extracts a best-effort seed from that state
1283
1291
  * instead.
1284
- * 3. *Phase 2*: Call `getAnnotations(parsed)` on all contexts with the first
1285
- * pass value. Deferred or otherwise unresolved fields in `parsed` may be
1286
- * `undefined`.
1287
- * 4. *Second parse*: Parse again with merged annotations from both phases.
1292
+ * 3. *Phase 2*: Call `getAnnotations(parsed)` on all two-pass contexts with
1293
+ * the first pass value. Deferred or otherwise unresolved fields in
1294
+ * `parsed` may be `undefined`. Each two-pass context's phase-two return
1295
+ * value replaces its own phase-one contribution for the final parse, so
1296
+ * returning `{}` clears any annotations that context provided during
1297
+ * phase 1. Single-pass contexts reuse their phase-one snapshot.
1298
+ * 4. *Second parse*: Parse again with the merged phase-two annotations.
1288
1299
  *
1289
- * If all contexts are static (no dynamic contexts), the second parse is
1290
- * skipped for optimization. Phase 2 is also skipped when the first pass does
1291
- * not yield any usable seed at all.
1300
+ * If all contexts are single-pass, the second parse is skipped for
1301
+ * optimization. Phase 2 is also skipped when the first pass does not yield
1302
+ * any usable seed at all.
1292
1303
  *
1293
1304
  * @template TParser The parser type.
1294
1305
  * @template THelp Return type when help is shown.
@@ -1300,6 +1311,8 @@ async function runWithBody(parser, programName, contexts, args, options) {
1300
1311
  * @returns Promise that resolves to the parsed result.
1301
1312
  * @throws {TypeError} If two or more contexts share the same
1302
1313
  * {@link SourceContext.id}.
1314
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
1315
+ * phase value.
1303
1316
  * @throws {SuppressedError} If the runner throws and a context's disposal
1304
1317
  * also throws. The original error is available via `.suppressed` and the
1305
1318
  * disposal error via `.error`.
@@ -1312,6 +1325,7 @@ async function runWithBody(parser, programName, contexts, args, options) {
1312
1325
  *
1313
1326
  * const envContext: SourceContext = {
1314
1327
  * id: Symbol.for("@myapp/env"),
1328
+ * phase: "single-pass",
1315
1329
  * getAnnotations() {
1316
1330
  * return { [Symbol.for("@myapp/env")]: process.env };
1317
1331
  * }
@@ -1355,9 +1369,10 @@ async function runWith(parser, programName, contexts, options) {
1355
1369
  */
1356
1370
  function runWithSyncBody(parser, programName, contexts, args, options) {
1357
1371
  validateContextIds(contexts);
1372
+ validateContextPhases(contexts);
1358
1373
  if (needsEarlyExit(args, options)) return runParser(parser, programName, args, options);
1359
1374
  const ctxOptions = options.contextOptions;
1360
- const { annotations: phase1Annotations, annotationsList: phase1AnnotationsList, hasDynamic: needsTwoPhase } = collectPhase1AnnotationsSync(contexts, ctxOptions);
1375
+ const { annotations: phase1Annotations, needsTwoPhase, snapshots: phase1Snapshots } = collectPhase1AnnotationsSync(contexts, ctxOptions);
1361
1376
  if (!needsTwoPhase) {
1362
1377
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1363
1378
  return runParser(augmentedParser, programName, args, options);
@@ -1368,8 +1383,7 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1368
1383
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1369
1384
  return runParser(augmentedParser, programName, args, options);
1370
1385
  }
1371
- const { annotationsList: phase2AnnotationsList } = collectAnnotationsSync(contexts, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1372
- const finalAnnotations = mergeTwoPhaseAnnotations(phase1AnnotationsList, phase2AnnotationsList);
1386
+ const { annotations: finalAnnotations } = collectFinalAnnotationsSync(contexts, phase1Snapshots, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1373
1387
  const augmentedParser2 = injectAnnotationsIntoParser(parser, finalAnnotations);
1374
1388
  return runParser(augmentedParser2, programName, args, options);
1375
1389
  }
@@ -1378,8 +1392,10 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1378
1392
  *
1379
1393
  * This is the sync-only variant of {@link runWith}. All contexts must return
1380
1394
  * annotations synchronously (not Promises). It uses the same two-phase
1381
- * best-effort seed extraction as {@link runWith} when dynamic contexts are
1382
- * present.
1395
+ * best-effort seed extraction as {@link runWith} when two-pass contexts are
1396
+ * present. In two-phase runs, each two-pass context's phase-two return value
1397
+ * replaces that context's phase-one contribution for the final parse, so
1398
+ * returning `{}` clears any annotations that context provided during phase 1.
1383
1399
  *
1384
1400
  * @template TParser The sync parser type.
1385
1401
  * @template THelp Return type when help is shown.
@@ -1393,6 +1409,8 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1393
1409
  * {@link runWith} or {@link runWithAsync} for async parsers.
1394
1410
  * @throws {TypeError} If two or more contexts share the same
1395
1411
  * {@link SourceContext.id}.
1412
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
1413
+ * phase value.
1396
1414
  * @throws {Error} If any context returns a Promise or if a context's
1397
1415
  * `[Symbol.asyncDispose]` returns a Promise.
1398
1416
  * @throws {SuppressedError} If the runner throws and a context's disposal
package/dist/parser.d.cts CHANGED
@@ -215,7 +215,7 @@ interface Parser<M extends Mode = "sync", TValue = unknown, TState = unknown> {
215
215
  * A type-appropriate default value used as a stand-in during deferred
216
216
  * prompt resolution. When present, combinators like `prompt()` use this
217
217
  * value instead of an internal sentinel during two-phase parsing, so that
218
- * `map()` transforms and dynamic contexts always receive a valid value
218
+ * `map()` transforms and two-pass contexts always receive a valid value
219
219
  * of type {@link TValue}.
220
220
  *
221
221
  * This property is set automatically by `option()` and `argument()` from
package/dist/parser.d.ts CHANGED
@@ -215,7 +215,7 @@ interface Parser<M extends Mode = "sync", TValue = unknown, TState = unknown> {
215
215
  * A type-appropriate default value used as a stand-in during deferred
216
216
  * prompt resolution. When present, combinators like `prompt()` use this
217
217
  * value instead of an internal sentinel during two-phase parsing, so that
218
- * `map()` transforms and dynamic contexts always receive a valid value
218
+ * `map()` transforms and two-pass contexts always receive a valid value
219
219
  * of type {@link TValue}.
220
220
  *
221
221
  * This property is set automatically by `option()` and `argument()` from
@@ -100,7 +100,7 @@ interface ValueParser<M extends Mode = "sync", T = unknown> {
100
100
  * A type-appropriate default value used as a stand-in during deferred
101
101
  * prompt resolution. When an interactive prompt is deferred during
102
102
  * two-phase parsing, this value is used instead of an internal sentinel
103
- * so that `map()` transforms and dynamic contexts always receive a valid
103
+ * so that `map()` transforms and two-pass contexts always receive a valid
104
104
  * value of type {@link T}.
105
105
  *
106
106
  * The placeholder does not need to be meaningful; it only needs to be
@@ -100,7 +100,7 @@ interface ValueParser<M extends Mode = "sync", T = unknown> {
100
100
  * A type-appropriate default value used as a stand-in during deferred
101
101
  * prompt resolution. When an interactive prompt is deferred during
102
102
  * two-phase parsing, this value is used instead of an internal sentinel
103
- * so that `map()` transforms and dynamic contexts always receive a valid
103
+ * so that `map()` transforms and two-pass contexts always receive a valid
104
104
  * value of type {@link T}.
105
105
  *
106
106
  * The placeholder does not need to be meaningful; it only needs to be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/core",
3
- "version": "1.0.0-dev.1799+bc120a8c",
3
+ "version": "1.0.0-dev.1802+accfecbe",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",