@optique/core 1.0.0-dev.1801 → 1.0.0-dev.1804

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,40 +109,37 @@ 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
- * This second return value is treated as the context's final annotation
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
136
135
  * snapshot for the second parse pass, replacing that context's phase-one
137
136
  * contribution. If the runner cannot extract a usable value at all, this
138
137
  * second call is skipped and the original parse failure is reported
139
138
  * instead.
140
139
  *
141
140
  * @param parsed Optional parsed result from a previous parse pass.
142
- * Static contexts can ignore this parameter.
143
- * 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.
144
143
  * @param options Optional context-required options provided by the caller
145
144
  * of `runWith()`. These are the options declared via the
146
145
  * `TRequiredOptions` type parameter.
@@ -193,18 +192,5 @@ interface SourceContext<TRequiredOptions = void> {
193
192
  */
194
193
  [Symbol.asyncDispose]?(): void | PromiseLike<void>;
195
194
  }
196
- /**
197
- * Checks whether a context is static (returns annotations without needing
198
- * parsed results).
199
- *
200
- * A context is considered static if it declares `mode: "static"` or if
201
- * `getAnnotations()` called without arguments returns a non-empty
202
- * annotations object synchronously.
203
- *
204
- * @param context The source context to check.
205
- * @returns `true` if the context is static, `false` otherwise.
206
- * @since 0.10.0
207
- */
208
- declare function isStaticContext(context: SourceContext<unknown>): boolean;
209
195
  //#endregion
210
- 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,40 +109,37 @@ 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
- * This second return value is treated as the context's final annotation
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
136
135
  * snapshot for the second parse pass, replacing that context's phase-one
137
136
  * contribution. If the runner cannot extract a usable value at all, this
138
137
  * second call is skipped and the original parse failure is reported
139
138
  * instead.
140
139
  *
141
140
  * @param parsed Optional parsed result from a previous parse pass.
142
- * Static contexts can ignore this parameter.
143
- * 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.
144
143
  * @param options Optional context-required options provided by the caller
145
144
  * of `runWith()`. These are the options declared via the
146
145
  * `TRequiredOptions` type parameter.
@@ -193,18 +192,5 @@ interface SourceContext<TRequiredOptions = void> {
193
192
  */
194
193
  [Symbol.asyncDispose]?(): void | PromiseLike<void>;
195
194
  }
196
- /**
197
- * Checks whether a context is static (returns annotations without needing
198
- * parsed results).
199
- *
200
- * A context is considered static if it declares `mode: "static"` or if
201
- * `getAnnotations()` called without arguments returns a non-empty
202
- * annotations object synchronously.
203
- *
204
- * @param context The source context to check.
205
- * @returns `true` if the context is static, `false` otherwise.
206
- * @since 0.10.0
207
- */
208
- declare function isStaticContext(context: SourceContext<unknown>): boolean;
209
195
  //#endregion
210
- 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,41 +1081,61 @@ 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
+ let snapshots;
1095
1102
  for (const context of contexts) {
1096
1103
  const result = context.getAnnotations(void 0, options);
1097
- hasDynamic ||= needsTwoPhaseContext(context, result);
1098
1104
  const annotations = result instanceof Promise ? await result : result;
1099
1105
  const internalAnnotations = context.getInternalAnnotations?.(void 0, annotations);
1100
- annotationsList.push(internalAnnotations == null ? annotations : mergeAnnotations([annotations, internalAnnotations]));
1106
+ const snapshot = internalAnnotations == null ? annotations : mergeAnnotations([annotations, internalAnnotations]);
1107
+ annotationsList.push(snapshot);
1108
+ if (snapshots != null) snapshots.push(snapshot);
1109
+ else if (context.phase === "two-pass") snapshots = [...annotationsList];
1101
1110
  }
1102
1111
  return {
1103
1112
  annotations: mergeAnnotations(annotationsList),
1104
- hasDynamic
1113
+ needsTwoPhase: snapshots != null,
1114
+ snapshots: snapshots ?? []
1105
1115
  };
1106
1116
  }
1107
1117
  /**
1108
- * Collects annotations from all contexts.
1118
+ * Collects final annotations from all contexts.
1119
+ *
1120
+ * `single-pass` contexts reuse their phase-1 snapshot. `two-pass` contexts
1121
+ * are recollected with the parsed value and replace their own phase-1
1122
+ * snapshot in the final merge.
1109
1123
  *
1110
1124
  * @param contexts Source contexts to collect annotations from.
1125
+ * @param phase1Snapshots Per-context snapshots collected during phase 1.
1111
1126
  * @param parsed Optional parsed result from a previous parse pass.
1112
1127
  * @param options Optional context-required options to pass to each context.
1113
1128
  * @returns Promise that resolves to merged annotations.
1114
1129
  */
1115
- async function collectAnnotations(contexts, parsed, options, deferred, deferredKeys) {
1130
+ async function collectFinalAnnotations(contexts, phase1Snapshots, parsed, options, deferred, deferredKeys) {
1116
1131
  const annotationsList = [];
1117
1132
  const preparedParsed = prepareParsedForContexts(parsed, deferred, deferredKeys);
1118
- for (const context of contexts) {
1133
+ for (let index = 0; index < contexts.length; index++) {
1134
+ const context = contexts[index];
1135
+ if (context.phase === "single-pass") {
1136
+ annotationsList.push(phase1Snapshots[index]);
1137
+ continue;
1138
+ }
1119
1139
  const mergedAnnotations = await withPreparedParsedForContext(context, preparedParsed, async (contextParsed) => {
1120
1140
  const result = context.getAnnotations(contextParsed, options);
1121
1141
  const annotations = result instanceof Promise ? await result : result;
@@ -1132,49 +1152,50 @@ async function collectAnnotations(contexts, parsed, options, deferred, deferredK
1132
1152
  *
1133
1153
  * @param contexts Source contexts to collect annotations from.
1134
1154
  * @param options Optional context-required options to pass to each context.
1135
- * @returns Merged annotations with dynamic-context hint.
1155
+ * @returns Merged annotations, per-context snapshots, and a two-phase hint.
1136
1156
  * @throws Error if any context returns a Promise.
1137
1157
  */
1138
1158
  function collectPhase1AnnotationsSync(contexts, options) {
1139
1159
  const annotationsList = [];
1140
- let hasDynamic = false;
1160
+ let snapshots;
1141
1161
  for (const context of contexts) {
1142
1162
  const result = context.getAnnotations(void 0, options);
1143
1163
  if (result instanceof Promise) throw new Error(`Context ${String(context.id)} returned a Promise in sync mode. Use runWith() or runWithAsync() for async contexts.`);
1144
- hasDynamic ||= needsTwoPhaseContext(context, result);
1145
1164
  const internalAnnotations = context.getInternalAnnotations?.(void 0, result);
1146
- annotationsList.push(internalAnnotations == null ? result : mergeAnnotations([result, internalAnnotations]));
1165
+ const snapshot = internalAnnotations == null ? result : mergeAnnotations([result, internalAnnotations]);
1166
+ annotationsList.push(snapshot);
1167
+ if (snapshots != null) snapshots.push(snapshot);
1168
+ else if (context.phase === "two-pass") snapshots = [...annotationsList];
1147
1169
  }
1148
1170
  return {
1149
1171
  annotations: mergeAnnotations(annotationsList),
1150
- hasDynamic
1172
+ needsTwoPhase: snapshots != null,
1173
+ snapshots: snapshots ?? []
1151
1174
  };
1152
1175
  }
1153
1176
  /**
1154
- * Determines whether a context requires a second parse pass.
1177
+ * Collects final annotations from all contexts synchronously.
1155
1178
  *
1156
- * Explicit `mode` declarations take precedence over legacy heuristics so
1157
- * static contexts are not forced into two-phase parsing when they return
1158
- * empty annotations or a Promise.
1159
- */
1160
- function needsTwoPhaseContext(context, result) {
1161
- if (context.mode !== void 0) return context.mode === "dynamic";
1162
- if (result instanceof Promise) return true;
1163
- return Object.getOwnPropertySymbols(result).length === 0;
1164
- }
1165
- /**
1166
- * Collects annotations from all contexts synchronously.
1179
+ * `single-pass` contexts reuse their phase-1 snapshot. `two-pass` contexts
1180
+ * are recollected with the parsed value and replace their own phase-1
1181
+ * snapshot in the final merge.
1167
1182
  *
1168
1183
  * @param contexts Source contexts to collect annotations from.
1184
+ * @param phase1Snapshots Per-context snapshots collected during phase 1.
1169
1185
  * @param parsed Optional parsed result from a previous parse pass.
1170
1186
  * @param options Optional context-required options to pass to each context.
1171
1187
  * @returns Merged annotations.
1172
1188
  * @throws Error if any context returns a Promise.
1173
1189
  */
1174
- function collectAnnotationsSync(contexts, parsed, options, deferred, deferredKeys) {
1190
+ function collectFinalAnnotationsSync(contexts, phase1Snapshots, parsed, options, deferred, deferredKeys) {
1175
1191
  const annotationsList = [];
1176
1192
  const preparedParsed = prepareParsedForContexts(parsed, deferred, deferredKeys);
1177
- for (const context of contexts) {
1193
+ for (let index = 0; index < contexts.length; index++) {
1194
+ const context = contexts[index];
1195
+ if (context.phase === "single-pass") {
1196
+ annotationsList.push(phase1Snapshots[index]);
1197
+ continue;
1198
+ }
1178
1199
  const mergedAnnotations = withPreparedParsedForContext(context, preparedParsed, (contextParsed) => {
1179
1200
  const result = context.getAnnotations(contextParsed, options);
1180
1201
  if (result instanceof Promise) throw new Error(`Context ${String(context.id)} returned a Promise in sync mode. Use runWith() or runWithAsync() for async contexts.`);
@@ -1228,12 +1249,13 @@ function disposeContextsSync(contexts) {
1228
1249
  */
1229
1250
  async function runWithBody(parser, programName, contexts, args, options) {
1230
1251
  require_validate.validateContextIds(contexts);
1252
+ validateContextPhases(contexts);
1231
1253
  if (needsEarlyExit(args, options)) {
1232
1254
  if (parser.$mode === "async") return runParser(parser, programName, args, options);
1233
1255
  return Promise.resolve(runParser(parser, programName, args, options));
1234
1256
  }
1235
1257
  const ctxOptions = options.contextOptions;
1236
- const { annotations: phase1Annotations, hasDynamic: needsTwoPhase } = await collectPhase1Annotations(contexts, ctxOptions);
1258
+ const { annotations: phase1Annotations, needsTwoPhase, snapshots: phase1Snapshots } = await collectPhase1Annotations(contexts, ctxOptions);
1237
1259
  if (!needsTwoPhase) {
1238
1260
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1239
1261
  if (parser.$mode === "async") return runParser(augmentedParser, programName, args, options);
@@ -1246,7 +1268,7 @@ async function runWithBody(parser, programName, contexts, args, options) {
1246
1268
  if (parser.$mode === "async") return runParser(augmentedParser, programName, args, options);
1247
1269
  return Promise.resolve(runParser(augmentedParser, programName, args, options));
1248
1270
  }
1249
- const { annotations: finalAnnotations } = await collectAnnotations(contexts, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1271
+ const { annotations: finalAnnotations } = await collectFinalAnnotations(contexts, phase1Snapshots, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1250
1272
  const augmentedParser2 = injectAnnotationsIntoParser(parser, finalAnnotations);
1251
1273
  if (parser.$mode === "async") return runParser(augmentedParser2, programName, args, options);
1252
1274
  return Promise.resolve(runParser(augmentedParser2, programName, args, options));
@@ -1254,28 +1276,28 @@ async function runWithBody(parser, programName, contexts, args, options) {
1254
1276
  /**
1255
1277
  * Runs a parser with multiple source contexts.
1256
1278
  *
1257
- * This function automatically handles static and dynamic contexts with proper
1258
- * priority. Earlier contexts in the array override later ones.
1279
+ * This function automatically handles single-pass and two-pass contexts with
1280
+ * proper priority. Earlier contexts in the array override later ones.
1259
1281
  *
1260
1282
  * The function uses a smart two-phase approach:
1261
1283
  *
1262
- * 1. *Phase 1*: Collect annotations from all contexts (static contexts return
1263
- * their data, dynamic contexts may return empty).
1284
+ * 1. *Phase 1*: Collect annotations from all contexts.
1264
1285
  * 2. *First parse*: Parse with Phase 1 annotations. If that pass finishes
1265
1286
  * successfully, its value becomes the phase-two input. If the parser
1266
1287
  * reaches a usable intermediate state but still does not complete
1267
1288
  * successfully, the runner extracts a best-effort seed from that state
1268
1289
  * instead.
1269
- * 3. *Phase 2*: Call `getAnnotations(parsed)` on all contexts with the first
1270
- * pass value. Deferred or otherwise unresolved fields in `parsed` may be
1271
- * `undefined`. Each context's phase-two return value replaces its own
1272
- * phase-one contribution for the final parse, so returning `{}` clears any
1273
- * annotations that context provided during phase 1.
1290
+ * 3. *Phase 2*: Call `getAnnotations(parsed)` on all two-pass contexts with
1291
+ * the first pass value. Deferred or otherwise unresolved fields in
1292
+ * `parsed` may be `undefined`. Each two-pass context's phase-two return
1293
+ * value replaces its own phase-one contribution for the final parse, so
1294
+ * returning `{}` clears any annotations that context provided during
1295
+ * phase 1. Single-pass contexts reuse their phase-one snapshot.
1274
1296
  * 4. *Second parse*: Parse again with the merged phase-two annotations.
1275
1297
  *
1276
- * If all contexts are static (no dynamic contexts), the second parse is
1277
- * skipped for optimization. Phase 2 is also skipped when the first pass does
1278
- * not yield any usable seed at all.
1298
+ * If all contexts are single-pass, the second parse is skipped for
1299
+ * optimization. Phase 2 is also skipped when the first pass does not yield
1300
+ * any usable seed at all.
1279
1301
  *
1280
1302
  * @template TParser The parser type.
1281
1303
  * @template THelp Return type when help is shown.
@@ -1287,6 +1309,8 @@ async function runWithBody(parser, programName, contexts, args, options) {
1287
1309
  * @returns Promise that resolves to the parsed result.
1288
1310
  * @throws {TypeError} If two or more contexts share the same
1289
1311
  * {@link SourceContext.id}.
1312
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
1313
+ * phase value.
1290
1314
  * @throws {SuppressedError} If the runner throws and a context's disposal
1291
1315
  * also throws. The original error is available via `.suppressed` and the
1292
1316
  * disposal error via `.error`.
@@ -1299,6 +1323,7 @@ async function runWithBody(parser, programName, contexts, args, options) {
1299
1323
  *
1300
1324
  * const envContext: SourceContext = {
1301
1325
  * id: Symbol.for("@myapp/env"),
1326
+ * phase: "single-pass",
1302
1327
  * getAnnotations() {
1303
1328
  * return { [Symbol.for("@myapp/env")]: process.env };
1304
1329
  * }
@@ -1342,9 +1367,10 @@ async function runWith(parser, programName, contexts, options) {
1342
1367
  */
1343
1368
  function runWithSyncBody(parser, programName, contexts, args, options) {
1344
1369
  require_validate.validateContextIds(contexts);
1370
+ validateContextPhases(contexts);
1345
1371
  if (needsEarlyExit(args, options)) return runParser(parser, programName, args, options);
1346
1372
  const ctxOptions = options.contextOptions;
1347
- const { annotations: phase1Annotations, hasDynamic: needsTwoPhase } = collectPhase1AnnotationsSync(contexts, ctxOptions);
1373
+ const { annotations: phase1Annotations, needsTwoPhase, snapshots: phase1Snapshots } = collectPhase1AnnotationsSync(contexts, ctxOptions);
1348
1374
  if (!needsTwoPhase) {
1349
1375
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1350
1376
  return runParser(augmentedParser, programName, args, options);
@@ -1355,7 +1381,7 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1355
1381
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1356
1382
  return runParser(augmentedParser, programName, args, options);
1357
1383
  }
1358
- const { annotations: finalAnnotations } = collectAnnotationsSync(contexts, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1384
+ const { annotations: finalAnnotations } = collectFinalAnnotationsSync(contexts, phase1Snapshots, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1359
1385
  const augmentedParser2 = injectAnnotationsIntoParser(parser, finalAnnotations);
1360
1386
  return runParser(augmentedParser2, programName, args, options);
1361
1387
  }
@@ -1364,10 +1390,10 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1364
1390
  *
1365
1391
  * This is the sync-only variant of {@link runWith}. All contexts must return
1366
1392
  * annotations synchronously (not Promises). It uses the same two-phase
1367
- * best-effort seed extraction as {@link runWith} when dynamic contexts are
1368
- * present. In two-phase runs, each context's phase-two return value replaces
1369
- * that context's phase-one contribution for the final parse, so returning `{}`
1370
- * clears any annotations that context provided during phase 1.
1393
+ * best-effort seed extraction as {@link runWith} when two-pass contexts are
1394
+ * present. In two-phase runs, each two-pass context's phase-two return value
1395
+ * replaces that context's phase-one contribution for the final parse, so
1396
+ * returning `{}` clears any annotations that context provided during phase 1.
1371
1397
  *
1372
1398
  * @template TParser The sync parser type.
1373
1399
  * @template THelp Return type when help is shown.
@@ -1381,6 +1407,8 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1381
1407
  * {@link runWith} or {@link runWithAsync} for async parsers.
1382
1408
  * @throws {TypeError} If two or more contexts share the same
1383
1409
  * {@link SourceContext.id}.
1410
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
1411
+ * phase value.
1384
1412
  * @throws {Error} If any context returns a Promise or if a context's
1385
1413
  * `[Symbol.asyncDispose]` returns a Promise.
1386
1414
  * @throws {SuppressedError} If the runner throws and a context's disposal
package/dist/facade.d.cts CHANGED
@@ -405,28 +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`. Each context's phase-two return value replaces its own
423
- * phase-one contribution for the final parse, so returning `{}` clears any
424
- * annotations that context provided during phase 1.
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
425
  * 4. *Second parse*: Parse again with the merged phase-two annotations.
426
426
  *
427
- * If all contexts are static (no dynamic contexts), the second parse is
428
- * skipped for optimization. Phase 2 is also skipped when the first pass does
429
- * 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.
430
430
  *
431
431
  * @template TParser The parser type.
432
432
  * @template THelp Return type when help is shown.
@@ -438,6 +438,8 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
438
438
  * @returns Promise that resolves to the parsed result.
439
439
  * @throws {TypeError} If two or more contexts share the same
440
440
  * {@link SourceContext.id}.
441
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
442
+ * phase value.
441
443
  * @throws {SuppressedError} If the runner throws and a context's disposal
442
444
  * also throws. The original error is available via `.suppressed` and the
443
445
  * disposal error via `.error`.
@@ -450,6 +452,7 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
450
452
  *
451
453
  * const envContext: SourceContext = {
452
454
  * id: Symbol.for("@myapp/env"),
455
+ * phase: "single-pass",
453
456
  * getAnnotations() {
454
457
  * return { [Symbol.for("@myapp/env")]: process.env };
455
458
  * }
@@ -469,10 +472,10 @@ declare function runWith<TParser extends Parser<Mode, unknown, unknown>, TContex
469
472
  *
470
473
  * This is the sync-only variant of {@link runWith}. All contexts must return
471
474
  * annotations synchronously (not Promises). It uses the same two-phase
472
- * best-effort seed extraction as {@link runWith} when dynamic contexts are
473
- * present. In two-phase runs, each context's phase-two return value replaces
474
- * that context's phase-one contribution for the final parse, so returning `{}`
475
- * clears any annotations that context provided during phase 1.
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.
476
479
  *
477
480
  * @template TParser The sync parser type.
478
481
  * @template THelp Return type when help is shown.
@@ -486,6 +489,8 @@ declare function runWith<TParser extends Parser<Mode, unknown, unknown>, TContex
486
489
  * {@link runWith} or {@link runWithAsync} for async parsers.
487
490
  * @throws {TypeError} If two or more contexts share the same
488
491
  * {@link SourceContext.id}.
492
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
493
+ * phase value.
489
494
  * @throws {Error} If any context returns a Promise or if a context's
490
495
  * `[Symbol.asyncDispose]` returns a Promise.
491
496
  * @throws {SuppressedError} If the runner throws and a context's disposal
package/dist/facade.d.ts CHANGED
@@ -405,28 +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`. Each context's phase-two return value replaces its own
423
- * phase-one contribution for the final parse, so returning `{}` clears any
424
- * annotations that context provided during phase 1.
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
425
  * 4. *Second parse*: Parse again with the merged phase-two annotations.
426
426
  *
427
- * If all contexts are static (no dynamic contexts), the second parse is
428
- * skipped for optimization. Phase 2 is also skipped when the first pass does
429
- * 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.
430
430
  *
431
431
  * @template TParser The parser type.
432
432
  * @template THelp Return type when help is shown.
@@ -438,6 +438,8 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
438
438
  * @returns Promise that resolves to the parsed result.
439
439
  * @throws {TypeError} If two or more contexts share the same
440
440
  * {@link SourceContext.id}.
441
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
442
+ * phase value.
441
443
  * @throws {SuppressedError} If the runner throws and a context's disposal
442
444
  * also throws. The original error is available via `.suppressed` and the
443
445
  * disposal error via `.error`.
@@ -450,6 +452,7 @@ type ContextOptionsParam<TContexts extends readonly SourceContext<unknown>[], TV
450
452
  *
451
453
  * const envContext: SourceContext = {
452
454
  * id: Symbol.for("@myapp/env"),
455
+ * phase: "single-pass",
453
456
  * getAnnotations() {
454
457
  * return { [Symbol.for("@myapp/env")]: process.env };
455
458
  * }
@@ -469,10 +472,10 @@ declare function runWith<TParser extends Parser<Mode, unknown, unknown>, TContex
469
472
  *
470
473
  * This is the sync-only variant of {@link runWith}. All contexts must return
471
474
  * annotations synchronously (not Promises). It uses the same two-phase
472
- * best-effort seed extraction as {@link runWith} when dynamic contexts are
473
- * present. In two-phase runs, each context's phase-two return value replaces
474
- * that context's phase-one contribution for the final parse, so returning `{}`
475
- * clears any annotations that context provided during phase 1.
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.
476
479
  *
477
480
  * @template TParser The sync parser type.
478
481
  * @template THelp Return type when help is shown.
@@ -486,6 +489,8 @@ declare function runWith<TParser extends Parser<Mode, unknown, unknown>, TContex
486
489
  * {@link runWith} or {@link runWithAsync} for async parsers.
487
490
  * @throws {TypeError} If two or more contexts share the same
488
491
  * {@link SourceContext.id}.
492
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
493
+ * phase value.
489
494
  * @throws {Error} If any context returns a Promise or if a context's
490
495
  * `[Symbol.asyncDispose]` returns a Promise.
491
496
  * @throws {SuppressedError} If the runner throws and a context's disposal
package/dist/facade.js CHANGED
@@ -1081,41 +1081,61 @@ 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
+ let snapshots;
1095
1102
  for (const context of contexts) {
1096
1103
  const result = context.getAnnotations(void 0, options);
1097
- hasDynamic ||= needsTwoPhaseContext(context, result);
1098
1104
  const annotations = result instanceof Promise ? await result : result;
1099
1105
  const internalAnnotations = context.getInternalAnnotations?.(void 0, annotations);
1100
- annotationsList.push(internalAnnotations == null ? annotations : mergeAnnotations([annotations, internalAnnotations]));
1106
+ const snapshot = internalAnnotations == null ? annotations : mergeAnnotations([annotations, internalAnnotations]);
1107
+ annotationsList.push(snapshot);
1108
+ if (snapshots != null) snapshots.push(snapshot);
1109
+ else if (context.phase === "two-pass") snapshots = [...annotationsList];
1101
1110
  }
1102
1111
  return {
1103
1112
  annotations: mergeAnnotations(annotationsList),
1104
- hasDynamic
1113
+ needsTwoPhase: snapshots != null,
1114
+ snapshots: snapshots ?? []
1105
1115
  };
1106
1116
  }
1107
1117
  /**
1108
- * Collects annotations from all contexts.
1118
+ * Collects final annotations from all contexts.
1119
+ *
1120
+ * `single-pass` contexts reuse their phase-1 snapshot. `two-pass` contexts
1121
+ * are recollected with the parsed value and replace their own phase-1
1122
+ * snapshot in the final merge.
1109
1123
  *
1110
1124
  * @param contexts Source contexts to collect annotations from.
1125
+ * @param phase1Snapshots Per-context snapshots collected during phase 1.
1111
1126
  * @param parsed Optional parsed result from a previous parse pass.
1112
1127
  * @param options Optional context-required options to pass to each context.
1113
1128
  * @returns Promise that resolves to merged annotations.
1114
1129
  */
1115
- async function collectAnnotations(contexts, parsed, options, deferred, deferredKeys) {
1130
+ async function collectFinalAnnotations(contexts, phase1Snapshots, parsed, options, deferred, deferredKeys) {
1116
1131
  const annotationsList = [];
1117
1132
  const preparedParsed = prepareParsedForContexts(parsed, deferred, deferredKeys);
1118
- for (const context of contexts) {
1133
+ for (let index = 0; index < contexts.length; index++) {
1134
+ const context = contexts[index];
1135
+ if (context.phase === "single-pass") {
1136
+ annotationsList.push(phase1Snapshots[index]);
1137
+ continue;
1138
+ }
1119
1139
  const mergedAnnotations = await withPreparedParsedForContext(context, preparedParsed, async (contextParsed) => {
1120
1140
  const result = context.getAnnotations(contextParsed, options);
1121
1141
  const annotations = result instanceof Promise ? await result : result;
@@ -1132,49 +1152,50 @@ async function collectAnnotations(contexts, parsed, options, deferred, deferredK
1132
1152
  *
1133
1153
  * @param contexts Source contexts to collect annotations from.
1134
1154
  * @param options Optional context-required options to pass to each context.
1135
- * @returns Merged annotations with dynamic-context hint.
1155
+ * @returns Merged annotations, per-context snapshots, and a two-phase hint.
1136
1156
  * @throws Error if any context returns a Promise.
1137
1157
  */
1138
1158
  function collectPhase1AnnotationsSync(contexts, options) {
1139
1159
  const annotationsList = [];
1140
- let hasDynamic = false;
1160
+ let snapshots;
1141
1161
  for (const context of contexts) {
1142
1162
  const result = context.getAnnotations(void 0, options);
1143
1163
  if (result instanceof Promise) throw new Error(`Context ${String(context.id)} returned a Promise in sync mode. Use runWith() or runWithAsync() for async contexts.`);
1144
- hasDynamic ||= needsTwoPhaseContext(context, result);
1145
1164
  const internalAnnotations = context.getInternalAnnotations?.(void 0, result);
1146
- annotationsList.push(internalAnnotations == null ? result : mergeAnnotations([result, internalAnnotations]));
1165
+ const snapshot = internalAnnotations == null ? result : mergeAnnotations([result, internalAnnotations]);
1166
+ annotationsList.push(snapshot);
1167
+ if (snapshots != null) snapshots.push(snapshot);
1168
+ else if (context.phase === "two-pass") snapshots = [...annotationsList];
1147
1169
  }
1148
1170
  return {
1149
1171
  annotations: mergeAnnotations(annotationsList),
1150
- hasDynamic
1172
+ needsTwoPhase: snapshots != null,
1173
+ snapshots: snapshots ?? []
1151
1174
  };
1152
1175
  }
1153
1176
  /**
1154
- * Determines whether a context requires a second parse pass.
1177
+ * Collects final annotations from all contexts synchronously.
1155
1178
  *
1156
- * Explicit `mode` declarations take precedence over legacy heuristics so
1157
- * static contexts are not forced into two-phase parsing when they return
1158
- * empty annotations or a Promise.
1159
- */
1160
- function needsTwoPhaseContext(context, result) {
1161
- if (context.mode !== void 0) return context.mode === "dynamic";
1162
- if (result instanceof Promise) return true;
1163
- return Object.getOwnPropertySymbols(result).length === 0;
1164
- }
1165
- /**
1166
- * Collects annotations from all contexts synchronously.
1179
+ * `single-pass` contexts reuse their phase-1 snapshot. `two-pass` contexts
1180
+ * are recollected with the parsed value and replace their own phase-1
1181
+ * snapshot in the final merge.
1167
1182
  *
1168
1183
  * @param contexts Source contexts to collect annotations from.
1184
+ * @param phase1Snapshots Per-context snapshots collected during phase 1.
1169
1185
  * @param parsed Optional parsed result from a previous parse pass.
1170
1186
  * @param options Optional context-required options to pass to each context.
1171
1187
  * @returns Merged annotations.
1172
1188
  * @throws Error if any context returns a Promise.
1173
1189
  */
1174
- function collectAnnotationsSync(contexts, parsed, options, deferred, deferredKeys) {
1190
+ function collectFinalAnnotationsSync(contexts, phase1Snapshots, parsed, options, deferred, deferredKeys) {
1175
1191
  const annotationsList = [];
1176
1192
  const preparedParsed = prepareParsedForContexts(parsed, deferred, deferredKeys);
1177
- for (const context of contexts) {
1193
+ for (let index = 0; index < contexts.length; index++) {
1194
+ const context = contexts[index];
1195
+ if (context.phase === "single-pass") {
1196
+ annotationsList.push(phase1Snapshots[index]);
1197
+ continue;
1198
+ }
1178
1199
  const mergedAnnotations = withPreparedParsedForContext(context, preparedParsed, (contextParsed) => {
1179
1200
  const result = context.getAnnotations(contextParsed, options);
1180
1201
  if (result instanceof Promise) throw new Error(`Context ${String(context.id)} returned a Promise in sync mode. Use runWith() or runWithAsync() for async contexts.`);
@@ -1228,12 +1249,13 @@ function disposeContextsSync(contexts) {
1228
1249
  */
1229
1250
  async function runWithBody(parser, programName, contexts, args, options) {
1230
1251
  validateContextIds(contexts);
1252
+ validateContextPhases(contexts);
1231
1253
  if (needsEarlyExit(args, options)) {
1232
1254
  if (parser.$mode === "async") return runParser(parser, programName, args, options);
1233
1255
  return Promise.resolve(runParser(parser, programName, args, options));
1234
1256
  }
1235
1257
  const ctxOptions = options.contextOptions;
1236
- const { annotations: phase1Annotations, hasDynamic: needsTwoPhase } = await collectPhase1Annotations(contexts, ctxOptions);
1258
+ const { annotations: phase1Annotations, needsTwoPhase, snapshots: phase1Snapshots } = await collectPhase1Annotations(contexts, ctxOptions);
1237
1259
  if (!needsTwoPhase) {
1238
1260
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1239
1261
  if (parser.$mode === "async") return runParser(augmentedParser, programName, args, options);
@@ -1246,7 +1268,7 @@ async function runWithBody(parser, programName, contexts, args, options) {
1246
1268
  if (parser.$mode === "async") return runParser(augmentedParser, programName, args, options);
1247
1269
  return Promise.resolve(runParser(augmentedParser, programName, args, options));
1248
1270
  }
1249
- const { annotations: finalAnnotations } = await collectAnnotations(contexts, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1271
+ const { annotations: finalAnnotations } = await collectFinalAnnotations(contexts, phase1Snapshots, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1250
1272
  const augmentedParser2 = injectAnnotationsIntoParser(parser, finalAnnotations);
1251
1273
  if (parser.$mode === "async") return runParser(augmentedParser2, programName, args, options);
1252
1274
  return Promise.resolve(runParser(augmentedParser2, programName, args, options));
@@ -1254,28 +1276,28 @@ async function runWithBody(parser, programName, contexts, args, options) {
1254
1276
  /**
1255
1277
  * Runs a parser with multiple source contexts.
1256
1278
  *
1257
- * This function automatically handles static and dynamic contexts with proper
1258
- * priority. Earlier contexts in the array override later ones.
1279
+ * This function automatically handles single-pass and two-pass contexts with
1280
+ * proper priority. Earlier contexts in the array override later ones.
1259
1281
  *
1260
1282
  * The function uses a smart two-phase approach:
1261
1283
  *
1262
- * 1. *Phase 1*: Collect annotations from all contexts (static contexts return
1263
- * their data, dynamic contexts may return empty).
1284
+ * 1. *Phase 1*: Collect annotations from all contexts.
1264
1285
  * 2. *First parse*: Parse with Phase 1 annotations. If that pass finishes
1265
1286
  * successfully, its value becomes the phase-two input. If the parser
1266
1287
  * reaches a usable intermediate state but still does not complete
1267
1288
  * successfully, the runner extracts a best-effort seed from that state
1268
1289
  * instead.
1269
- * 3. *Phase 2*: Call `getAnnotations(parsed)` on all contexts with the first
1270
- * pass value. Deferred or otherwise unresolved fields in `parsed` may be
1271
- * `undefined`. Each context's phase-two return value replaces its own
1272
- * phase-one contribution for the final parse, so returning `{}` clears any
1273
- * annotations that context provided during phase 1.
1290
+ * 3. *Phase 2*: Call `getAnnotations(parsed)` on all two-pass contexts with
1291
+ * the first pass value. Deferred or otherwise unresolved fields in
1292
+ * `parsed` may be `undefined`. Each two-pass context's phase-two return
1293
+ * value replaces its own phase-one contribution for the final parse, so
1294
+ * returning `{}` clears any annotations that context provided during
1295
+ * phase 1. Single-pass contexts reuse their phase-one snapshot.
1274
1296
  * 4. *Second parse*: Parse again with the merged phase-two annotations.
1275
1297
  *
1276
- * If all contexts are static (no dynamic contexts), the second parse is
1277
- * skipped for optimization. Phase 2 is also skipped when the first pass does
1278
- * not yield any usable seed at all.
1298
+ * If all contexts are single-pass, the second parse is skipped for
1299
+ * optimization. Phase 2 is also skipped when the first pass does not yield
1300
+ * any usable seed at all.
1279
1301
  *
1280
1302
  * @template TParser The parser type.
1281
1303
  * @template THelp Return type when help is shown.
@@ -1287,6 +1309,8 @@ async function runWithBody(parser, programName, contexts, args, options) {
1287
1309
  * @returns Promise that resolves to the parsed result.
1288
1310
  * @throws {TypeError} If two or more contexts share the same
1289
1311
  * {@link SourceContext.id}.
1312
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
1313
+ * phase value.
1290
1314
  * @throws {SuppressedError} If the runner throws and a context's disposal
1291
1315
  * also throws. The original error is available via `.suppressed` and the
1292
1316
  * disposal error via `.error`.
@@ -1299,6 +1323,7 @@ async function runWithBody(parser, programName, contexts, args, options) {
1299
1323
  *
1300
1324
  * const envContext: SourceContext = {
1301
1325
  * id: Symbol.for("@myapp/env"),
1326
+ * phase: "single-pass",
1302
1327
  * getAnnotations() {
1303
1328
  * return { [Symbol.for("@myapp/env")]: process.env };
1304
1329
  * }
@@ -1342,9 +1367,10 @@ async function runWith(parser, programName, contexts, options) {
1342
1367
  */
1343
1368
  function runWithSyncBody(parser, programName, contexts, args, options) {
1344
1369
  validateContextIds(contexts);
1370
+ validateContextPhases(contexts);
1345
1371
  if (needsEarlyExit(args, options)) return runParser(parser, programName, args, options);
1346
1372
  const ctxOptions = options.contextOptions;
1347
- const { annotations: phase1Annotations, hasDynamic: needsTwoPhase } = collectPhase1AnnotationsSync(contexts, ctxOptions);
1373
+ const { annotations: phase1Annotations, needsTwoPhase, snapshots: phase1Snapshots } = collectPhase1AnnotationsSync(contexts, ctxOptions);
1348
1374
  if (!needsTwoPhase) {
1349
1375
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1350
1376
  return runParser(augmentedParser, programName, args, options);
@@ -1355,7 +1381,7 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1355
1381
  const augmentedParser = injectAnnotationsIntoParser(parser, phase1Annotations);
1356
1382
  return runParser(augmentedParser, programName, args, options);
1357
1383
  }
1358
- const { annotations: finalAnnotations } = collectAnnotationsSync(contexts, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1384
+ const { annotations: finalAnnotations } = collectFinalAnnotationsSync(contexts, phase1Snapshots, firstPassSeed.value, ctxOptions, firstPassSeed.deferred, firstPassSeed.deferredKeys);
1359
1385
  const augmentedParser2 = injectAnnotationsIntoParser(parser, finalAnnotations);
1360
1386
  return runParser(augmentedParser2, programName, args, options);
1361
1387
  }
@@ -1364,10 +1390,10 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1364
1390
  *
1365
1391
  * This is the sync-only variant of {@link runWith}. All contexts must return
1366
1392
  * annotations synchronously (not Promises). It uses the same two-phase
1367
- * best-effort seed extraction as {@link runWith} when dynamic contexts are
1368
- * present. In two-phase runs, each context's phase-two return value replaces
1369
- * that context's phase-one contribution for the final parse, so returning `{}`
1370
- * clears any annotations that context provided during phase 1.
1393
+ * best-effort seed extraction as {@link runWith} when two-pass contexts are
1394
+ * present. In two-phase runs, each two-pass context's phase-two return value
1395
+ * replaces that context's phase-one contribution for the final parse, so
1396
+ * returning `{}` clears any annotations that context provided during phase 1.
1371
1397
  *
1372
1398
  * @template TParser The sync parser type.
1373
1399
  * @template THelp Return type when help is shown.
@@ -1381,6 +1407,8 @@ function runWithSyncBody(parser, programName, contexts, args, options) {
1381
1407
  * {@link runWith} or {@link runWithAsync} for async parsers.
1382
1408
  * @throws {TypeError} If two or more contexts share the same
1383
1409
  * {@link SourceContext.id}.
1410
+ * @throws {TypeError} If any context omits `phase` or declares an invalid
1411
+ * phase value.
1384
1412
  * @throws {Error} If any context returns a Promise or if a context's
1385
1413
  * `[Symbol.asyncDispose]` returns a Promise.
1386
1414
  * @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.1801+3b666460",
3
+ "version": "1.0.0-dev.1804+66a8f789",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",