@skyramp/mcp 0.3.3 → 0.3.4

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.
Files changed (55) hide show
  1. package/build/playwright/registerPlaywrightTools.js +42 -1
  2. package/build/prompts/enhance-assertions/sharedAssertionRules.js +19 -0
  3. package/build/prompts/test-maintenance/actionsInstructions.js +2 -2
  4. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -4
  5. package/build/prompts/test-recommendation/recommendationSections.d.ts +1 -1
  6. package/build/prompts/test-recommendation/recommendationSections.js +5 -5
  7. package/build/prompts/test-recommendation/test-recommendation-prompt.js +13 -7
  8. package/build/prompts/testbot/testbot-prompts.js +6 -13
  9. package/build/recommendation/discriminators.d.ts +7 -1
  10. package/build/recommendation/discriminators.js +16 -3
  11. package/build/resources/testbotResource.js +0 -1
  12. package/build/services/ScenarioGenerationService.js +5 -2
  13. package/build/services/TestExecutionService.js +25 -1
  14. package/build/services/TestGenerationService.js +24 -9
  15. package/build/services/containerEnv.d.ts +12 -1
  16. package/build/services/containerEnv.js +94 -1
  17. package/build/tools/executeSkyrampTestTool.d.ts +9 -0
  18. package/build/tools/executeSkyrampTestTool.js +20 -6
  19. package/build/tools/execution-video-state.d.ts +21 -0
  20. package/build/tools/execution-video-state.js +51 -0
  21. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +31 -11
  22. package/build/tools/generate-tests/planGuard.d.ts +5 -5
  23. package/build/tools/generate-tests/planGuard.js +5 -17
  24. package/build/tools/submitReportTool.d.ts +83 -10
  25. package/build/tools/submitReportTool.js +169 -28
  26. package/build/tools/test-management/actionsTool.js +40 -39
  27. package/build/tools/test-management/analyzeChangesTool.d.ts +11 -0
  28. package/build/tools/test-management/analyzeChangesTool.js +37 -33
  29. package/build/tools/test-management/analyzeTestHealthTool.js +3 -3
  30. package/build/tools/test-management/registerTestPlanTool.js +113 -31
  31. package/build/types/TestExecution.d.ts +14 -0
  32. package/build/types/TestTypes.js +3 -2
  33. package/build/types/TestbotPromptOptions.d.ts +0 -1
  34. package/build/types/TestbotReport.d.ts +10 -0
  35. package/build/types/TestbotReport.js +10 -1
  36. package/build/types/index.d.ts +1 -0
  37. package/build/types/index.js +1 -0
  38. package/build/utils/AnalysisStateManager.d.ts +36 -2
  39. package/build/utils/AnalysisStateManager.js +34 -13
  40. package/build/utils/scenarioDrafting.js +7 -1
  41. package/build/utils/skyrampMdContent.d.ts +1 -1
  42. package/build/utils/skyrampMdContent.js +1 -1
  43. package/build/utils/urlPath.d.ts +37 -0
  44. package/build/utils/urlPath.js +55 -0
  45. package/build/utils/utils.d.ts +45 -0
  46. package/build/utils/utils.js +50 -0
  47. package/build/utils/versions.d.ts +3 -3
  48. package/build/utils/versions.js +1 -1
  49. package/build/utils/workspaceAuth.d.ts +15 -15
  50. package/build/utils/workspaceAuth.js +32 -17
  51. package/build/workspace/queryParamResolution.d.ts +93 -0
  52. package/build/workspace/queryParamResolution.js +201 -0
  53. package/build/workspace/workspace.d.ts +104 -0
  54. package/build/workspace/workspace.js +24 -0
  55. package/package.json +3 -2
@@ -1,6 +1,51 @@
1
1
  import { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  export declare function readDiffFile(diffFilePath: string | undefined): string | undefined;
3
3
  export declare function toolError(message: string): CallToolResult;
4
+ /**
5
+ * Build a successful tool result whose payload is delivered on BOTH MCP result
6
+ * channels — `structuredContent` and `content[]` — carrying the identical text.
7
+ * The counterpart to `toolError` for any tool that returns a large text payload.
8
+ *
9
+ * Use this for every non-error return of such a tool, and declare a matching
10
+ * `outputSchema`. Two separate failures make that necessary:
11
+ *
12
+ * 1. A result with no `structuredContent` is persisted by the agent harness as a
13
+ * pretty-printed `.json` spill, which buries the payload inside an escaped
14
+ * JSON string. Coming through `structuredContent` instead lands it as a
15
+ * `.txt`. Measured on eval runs 32227152533 and 32283973875: the extension
16
+ * follows the result shape, with no exceptions either way.
17
+ *
18
+ * The extension is not cosmetic: it changes how the payload TOKENIZES. The
19
+ * reader refuses any spill over 25,000 tokens, and pretty-printed JSON with
20
+ * escaped quotes and newlines costs 2-3x the tokens of the same bytes in raw
21
+ * form. Measured on one payload either way: 141.2KB of `.json` counted 72,318
22
+ * tokens and was refused, while the same result at 142.0KB of `.txt` was read
23
+ * back in full. A second: 54,085 tokens as `.json`, 26,950 as `.txt`.
24
+ *
25
+ * So the shape raises the deliverable size ceiling by roughly 3x, but does not
26
+ * remove it. That second payload still missed the cap by 1,950 tokens, and
27
+ * `skyramp_actions` results of 178-621KB are 45,598-158,997 tokens even as
28
+ * `.txt` — the agent then falls back to slicing the file with `head -c`. The
29
+ * check covers the whole file, so a `limit:` on the read cannot evade it.
30
+ * Payloads that big have to shrink (SKYR-4188 and its sibling); this shape is
31
+ * what lets a shrunk payload actually arrive.
32
+ *
33
+ * 2. Each agent CLI testbot supports reads a different channel and none falls
34
+ * back to the other: Claude Code takes `structuredContent` and drops
35
+ * `content[]`; Cursor takes `content[]` and ignores `structuredContent`;
36
+ * GitHub Copilot CLI surfaces both and de-duplicates them only when the text
37
+ * is the literal JSON serialization of `structuredContent` (MCP spec 5.2.6),
38
+ * otherwise it concatenates. Hence `JSON.stringify` rather than the raw text,
39
+ * which would deliver the payload twice there. Trimming either channel
40
+ * silently starves a client.
41
+ *
42
+ * Declaring the outputSchema also obliges every non-error return to come through
43
+ * here: the SDK rejects a successful result without `structuredContent`
44
+ * ("has an output schema but no structured content was provided"). Error results
45
+ * are exempt — that check returns early on `isError` — so they keep using
46
+ * `toolError`.
47
+ */
48
+ export declare function dualChannelResult(structuredContent: Record<string, string>): CallToolResult;
4
49
  /**
5
50
  * Does `candidate` (an LLM- or caller-supplied name/path) identify `fullPath` (a known,
6
51
  * absolute test file path)? Exact match first, then a real path-segment boundary match
@@ -18,6 +18,56 @@ export function toolError(message) {
18
18
  isError: true,
19
19
  };
20
20
  }
21
+ /**
22
+ * Build a successful tool result whose payload is delivered on BOTH MCP result
23
+ * channels — `structuredContent` and `content[]` — carrying the identical text.
24
+ * The counterpart to `toolError` for any tool that returns a large text payload.
25
+ *
26
+ * Use this for every non-error return of such a tool, and declare a matching
27
+ * `outputSchema`. Two separate failures make that necessary:
28
+ *
29
+ * 1. A result with no `structuredContent` is persisted by the agent harness as a
30
+ * pretty-printed `.json` spill, which buries the payload inside an escaped
31
+ * JSON string. Coming through `structuredContent` instead lands it as a
32
+ * `.txt`. Measured on eval runs 32227152533 and 32283973875: the extension
33
+ * follows the result shape, with no exceptions either way.
34
+ *
35
+ * The extension is not cosmetic: it changes how the payload TOKENIZES. The
36
+ * reader refuses any spill over 25,000 tokens, and pretty-printed JSON with
37
+ * escaped quotes and newlines costs 2-3x the tokens of the same bytes in raw
38
+ * form. Measured on one payload either way: 141.2KB of `.json` counted 72,318
39
+ * tokens and was refused, while the same result at 142.0KB of `.txt` was read
40
+ * back in full. A second: 54,085 tokens as `.json`, 26,950 as `.txt`.
41
+ *
42
+ * So the shape raises the deliverable size ceiling by roughly 3x, but does not
43
+ * remove it. That second payload still missed the cap by 1,950 tokens, and
44
+ * `skyramp_actions` results of 178-621KB are 45,598-158,997 tokens even as
45
+ * `.txt` — the agent then falls back to slicing the file with `head -c`. The
46
+ * check covers the whole file, so a `limit:` on the read cannot evade it.
47
+ * Payloads that big have to shrink (SKYR-4188 and its sibling); this shape is
48
+ * what lets a shrunk payload actually arrive.
49
+ *
50
+ * 2. Each agent CLI testbot supports reads a different channel and none falls
51
+ * back to the other: Claude Code takes `structuredContent` and drops
52
+ * `content[]`; Cursor takes `content[]` and ignores `structuredContent`;
53
+ * GitHub Copilot CLI surfaces both and de-duplicates them only when the text
54
+ * is the literal JSON serialization of `structuredContent` (MCP spec 5.2.6),
55
+ * otherwise it concatenates. Hence `JSON.stringify` rather than the raw text,
56
+ * which would deliver the payload twice there. Trimming either channel
57
+ * silently starves a client.
58
+ *
59
+ * Declaring the outputSchema also obliges every non-error return to come through
60
+ * here: the SDK rejects a successful result without `structuredContent`
61
+ * ("has an output schema but no structured content was provided"). Error results
62
+ * are exempt — that check returns early on `isError` — so they keep using
63
+ * `toolError`.
64
+ */
65
+ export function dualChannelResult(structuredContent) {
66
+ return {
67
+ structuredContent,
68
+ content: [{ type: "text", text: JSON.stringify(structuredContent) }],
69
+ };
70
+ }
21
71
  /**
22
72
  * Does `candidate` (an LLM- or caller-supplied name/path) identify `fullPath` (a known,
23
73
  * absolute test file path)? Exact match first, then a real path-segment boundary match
@@ -1,4 +1,4 @@
1
- export declare const SKYRAMP_IMAGE_VERSION = "v1.3.38";
2
- export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.38";
3
- export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.38";
1
+ export declare const SKYRAMP_IMAGE_VERSION = "v1.3.40";
2
+ export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.40";
3
+ export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.40";
4
4
  export declare const WORKER_CONTROL_PORT = 35142;
@@ -1,4 +1,4 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.38";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.40";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
4
4
  // Control port the Skyramp worker listens on (SDK `CONTAINER_PORT`).
@@ -1,3 +1,4 @@
1
+ import { type ScopedQueryParams } from "../workspace/queryParamResolution.js";
1
2
  /**
2
3
  * Reads `.skyramp/workspace.yml`.
3
4
  *
@@ -133,20 +134,6 @@ export declare function getWorkspaceAuthConfig(repositoryPath: string): Promise<
133
134
  /** True when any workspace service declares api.skipTLSVerify — the SUT serves
134
135
  * self-signed TLS and generated tests must skip cert verification (SKYR-3961). */
135
136
  export declare function getWorkspaceSkipTLSVerify(repositoryPath: string): Promise<boolean>;
136
- /**
137
- * Resolve api.defaultQueryParams for test generation — query params always
138
- * attached to every generated request for this service (e.g. RBAC context
139
- * an authorization layer requires beyond the auth header). Same
140
- * first-matching-service convention as getWorkspaceAuthConfig (SKYR-4050).
141
- *
142
- * Unlike readWorkspaceConfigRaw (which delegates to WorkspaceConfigManager and
143
- * resolves EXACTLY `<repositoryPath>/.skyramp/workspace.yml`), this walks up
144
- * from `repositoryPath` to find the nearest `.skyramp/workspace.yml` — both
145
- * call sites pass a nested test-output directory (e.g. `<repo>/tests/skyramp`),
146
- * not the repo root, so an exact-match lookup would silently never find the
147
- * config. Mirrors the sync fs.existsSync walk-up in
148
- * generateBatchScenarioRestTool.ts.
149
- */
150
137
  /**
151
138
  * Walk up from `startDir` to find the nearest `.skyramp/workspace.yml`, checking
152
139
  * `startDir` itself first and continuing through every ancestor INCLUDING the
@@ -156,7 +143,20 @@ export declare function getWorkspaceSkipTLSVerify(repositoryPath: string): Promi
156
143
  * `existsSync` is injectable for testing; defaults to the real filesystem.
157
144
  */
158
145
  export declare function findWorkspaceConfigPath(startDir: string, existsSync?: (p: string) => boolean): string | null;
159
- export declare function getWorkspaceDefaultQueryParams(repositoryPath: string): Promise<Record<string, string> | undefined>;
146
+ /**
147
+ * Resolve api.defaultQueryParams together with api.queryParamOverrides for test
148
+ * generation (SKYR-4127). Same first-matching-service convention as
149
+ * getWorkspaceAuthConfig: the first service declaring EITHER key wins.
150
+ *
151
+ * Reads the YAML directly rather than through the Zod schema — the walk-up must
152
+ * stay cheap, and hand-rolled fixtures routinely omit fields the strict schema
153
+ * requires. normalizeOverrides therefore validates the override list itself,
154
+ * once per load rather than per request.
155
+ *
156
+ * Returns undefined when no config is found or no service declares either key,
157
+ * so callers can skip the merge entirely.
158
+ */
159
+ export declare function getWorkspaceScopedQueryParams(repositoryPath: string): Promise<ScopedQueryParams | undefined>;
160
160
  /**
161
161
  * Merge workspace-declared default query params into a comma-separated
162
162
  * "key=value,key2=value2" queryParams string (the format used by
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import path from "path";
3
3
  import yaml from "js-yaml";
4
4
  import { WorkspaceConfigManager } from "../workspace/workspace.js";
5
+ import { normalizeBaseMap, normalizeOverrides } from "../workspace/queryParamResolution.js";
5
6
  import { logger } from "./logger.js";
6
7
  /**
7
8
  * Reads `.skyramp/workspace.yml`.
@@ -363,20 +364,6 @@ export async function getWorkspaceSkipTLSVerify(repositoryPath) {
363
364
  return false;
364
365
  return (rawConfig.services ?? []).some((s) => s.api?.skipTLSVerify === true);
365
366
  }
366
- /**
367
- * Resolve api.defaultQueryParams for test generation — query params always
368
- * attached to every generated request for this service (e.g. RBAC context
369
- * an authorization layer requires beyond the auth header). Same
370
- * first-matching-service convention as getWorkspaceAuthConfig (SKYR-4050).
371
- *
372
- * Unlike readWorkspaceConfigRaw (which delegates to WorkspaceConfigManager and
373
- * resolves EXACTLY `<repositoryPath>/.skyramp/workspace.yml`), this walks up
374
- * from `repositoryPath` to find the nearest `.skyramp/workspace.yml` — both
375
- * call sites pass a nested test-output directory (e.g. `<repo>/tests/skyramp`),
376
- * not the repo root, so an exact-match lookup would silently never find the
377
- * config. Mirrors the sync fs.existsSync walk-up in
378
- * generateBatchScenarioRestTool.ts.
379
- */
380
367
  /**
381
368
  * Walk up from `startDir` to find the nearest `.skyramp/workspace.yml`, checking
382
369
  * `startDir` itself first and continuing through every ancestor INCLUDING the
@@ -398,15 +385,43 @@ export function findWorkspaceConfigPath(startDir, existsSync = fs.existsSync) {
398
385
  searchDir = parentDir;
399
386
  }
400
387
  }
401
- export async function getWorkspaceDefaultQueryParams(repositoryPath) {
388
+ /**
389
+ * Resolve api.defaultQueryParams together with api.queryParamOverrides for test
390
+ * generation (SKYR-4127). Same first-matching-service convention as
391
+ * getWorkspaceAuthConfig: the first service declaring EITHER key wins.
392
+ *
393
+ * Reads the YAML directly rather than through the Zod schema — the walk-up must
394
+ * stay cheap, and hand-rolled fixtures routinely omit fields the strict schema
395
+ * requires. normalizeOverrides therefore validates the override list itself,
396
+ * once per load rather than per request.
397
+ *
398
+ * Returns undefined when no config is found or no service declares either key,
399
+ * so callers can skip the merge entirely.
400
+ */
401
+ export async function getWorkspaceScopedQueryParams(repositoryPath) {
402
402
  const wsConfigPath = findWorkspaceConfigPath(repositoryPath);
403
403
  if (!wsConfigPath)
404
404
  return undefined;
405
405
  try {
406
406
  const raw = fs.readFileSync(wsConfigPath, "utf-8");
407
407
  const parsed = yaml.load(raw);
408
- const svc = parsed?.services?.find((s) => s.api?.defaultQueryParams !== undefined);
409
- return svc?.api?.defaultQueryParams;
408
+ const declaring = (parsed?.services ?? []).filter((s) => s?.api?.defaultQueryParams !== undefined || s?.api?.queryParamOverrides !== undefined);
409
+ const svc = declaring[0];
410
+ if (!svc)
411
+ return undefined;
412
+ // First-wins is deliberate (same convention as getWorkspaceAuthConfig) —
413
+ // but silently discarding a later service's config is easy to miss, so
414
+ // name what's being ignored (Finding 3, SKYR-4127 final review).
415
+ if (declaring.length > 1) {
416
+ logger.warning("Multiple services declare defaultQueryParams/queryParamOverrides — using the first, ignoring the rest", {
417
+ used: svc.serviceName,
418
+ ignored: declaring.slice(1).map((s) => s.serviceName),
419
+ });
420
+ }
421
+ return {
422
+ base: normalizeBaseMap(svc.api.defaultQueryParams),
423
+ overrides: normalizeOverrides(svc.api.queryParamOverrides),
424
+ };
410
425
  }
411
426
  catch {
412
427
  return undefined;
@@ -0,0 +1,93 @@
1
+ export interface QueryParamOverride {
2
+ /** picomatch glob matched against the request URL pathname. OpenAPI-style
3
+ * `{param}` placeholders are treated as a single-segment wildcard. */
4
+ pathPattern: string;
5
+ /**
6
+ * Known wart (Finding 6, SKYR-4127 final review): `values: {}` is a valid,
7
+ * deliberately-typed empty map. If its pathPattern is the single most
8
+ * specific match for a request, it wins and contributes nothing — silently
9
+ * suppressing a broader override that would otherwise have applied. Not
10
+ * validated against because an intentionally no-op override (e.g. "no
11
+ * extra params for this path, unlike its siblings") is a legitimate use.
12
+ */
13
+ values: Record<string, string>;
14
+ }
15
+ export interface ScopedQueryParams {
16
+ /** api.defaultQueryParams — applied to every request for the service. */
17
+ base: Record<string, string>;
18
+ /** api.queryParamOverrides — at most one applies to any given request. */
19
+ overrides: QueryParamOverride[];
20
+ }
21
+ /** Pathname of a full endpoint URL, or undefined if unparseable.
22
+ * `endpointURL` is contractually base URL + path, so the result includes any
23
+ * base-path prefix: `https://host/api/v1/persons/42` -> `/api/v1/persons/42`.
24
+ *
25
+ * Delegates to the shared helper so this module and the plan guard cannot
26
+ * disagree about what an endpointURL's path is — the shared version also
27
+ * restores `{param}` placeholders that `new URL()` percent-encodes, which
28
+ * patterns are authored with. */
29
+ export declare function extractPathname(endpointURL: string | undefined): string | undefined;
30
+ /** True when a resolved config would contribute no query params at all.
31
+ * Both call sites skip the merge entirely in that case, so an empty
32
+ * declaration leaves queryParams untouched rather than setting it to an empty
33
+ * value. Defined once so the two sites cannot drift on what "empty" means. */
34
+ export declare function contributesNothing(config: ScopedQueryParams): boolean;
35
+ /** Batch-scenario steps carry a bare path that may or may not be rooted. */
36
+ export declare function ensureLeadingSlash(p: string): string;
37
+ /** Rank for most-specific-wins: length of the literal prefix before the first
38
+ * wildcard, computed on the ORIGINAL pattern so `{id}` still counts. */
39
+ export declare function patternSpecificity(pathPattern: string): number;
40
+ /**
41
+ * Validate and coerce the raw `api.queryParamOverrides` value read from YAML.
42
+ *
43
+ * The read path in workspaceAuth.ts parses workspace.yml directly rather than
44
+ * through the Zod schema (the walk-up read must stay cheap), so nothing has
45
+ * validated this yet. Malformed entries are dropped with a warning instead of
46
+ * failing generation. Called once per load, not per request.
47
+ */
48
+ export declare function normalizeOverrides(raw: unknown): QueryParamOverride[];
49
+ /**
50
+ * Validate and coerce the raw `api.defaultQueryParams` value read from YAML.
51
+ *
52
+ * Same reason `normalizeOverrides` exists: this read path deliberately bypasses
53
+ * the Zod schema, so nothing has checked the shape. Without this, a scalar
54
+ * declaration (`defaultQueryParams: just-a-string`) is spread by key and yields
55
+ * one query param per character — `0=j&1=u&2=s&…` sent to the server. A list
56
+ * behaves the same way, and a map with non-string values would reach
57
+ * mergeQueryParams* and be dropped there with a less specific warning.
58
+ *
59
+ * Anything that is not a plain object of string values degrades to an empty
60
+ * base map with a warning naming what was found.
61
+ */
62
+ export declare function normalizeBaseMap(raw: unknown): Record<string, string>;
63
+ /**
64
+ * Effective query params for one request path: the base map with the single
65
+ * most-specific matching override layered on top.
66
+ *
67
+ * Overrides never merge with each other — at most one wins. Ranking is by
68
+ * literal prefix length, then total pattern length, then document order.
69
+ *
70
+ * Whenever the patterns differ in specificity the result is independent of how
71
+ * the YAML list is ordered. Document order is only reached when two patterns
72
+ * tie on BOTH earlier criteria, and reordering the list does change the winner
73
+ * in that case — which is why a tie emits an ambiguity warning naming both
74
+ * patterns rather than resolving silently.
75
+ */
76
+ export declare function resolveQueryParamsForPath(config: ScopedQueryParams, pathname: string): Record<string, string>;
77
+ /** Environment variables this workspace's query params defer to, in declaration
78
+ * order and deduplicated (SKYR-4127).
79
+ *
80
+ * These references resolve where the test RUNS, so under Testbot they resolve
81
+ * inside the executor container — whose environment is a fixed allowlist. A
82
+ * name that never reaches the container resolves to nothing, and an unresolved
83
+ * query param is dropped from the request silently, producing a passing test
84
+ * that exercised the wrong identity. The executor uses this list to forward
85
+ * exactly what the workspace asked for.
86
+ *
87
+ * Deliberately derived from the declarations rather than from the ambient
88
+ * environment: nothing is forwarded that workspace.yml did not name.
89
+ *
90
+ * Both maps are read even though `defaultQueryParams` documents itself as
91
+ * literal-only — that is a doc contract the core loader does not enforce, and
92
+ * a declaration there would otherwise reproduce the original silent failure. */
93
+ export declare function extractEnvVarNames(config: ScopedQueryParams): string[];
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Path-scoped resolution for workspace-declared query params (SKYR-4127).
3
+ *
4
+ * SKYR-4050 shipped a single flat `api.defaultQueryParams` map applied
5
+ * identically to every generated request. That is too coarse for APIs whose
6
+ * authorization context is per-endpoint (e.g. an RBAC role validated against a
7
+ * different allow-list per path). `api.queryParamOverrides` layers path-scoped
8
+ * values on top of that base map.
9
+ *
10
+ * Deliberately free of filesystem access so it unit-tests directly — reading
11
+ * workspace.yml lives in utils/workspaceAuth.ts.
12
+ */
13
+ import picomatch from "picomatch";
14
+ import { logger } from "../utils/logger.js";
15
+ import { pathFromEndpointURL } from "../utils/urlPath.js";
16
+ /** Characters that terminate a pattern's literal prefix.
17
+ *
18
+ * `{` counts because an OpenAPI-style `{id}` placeholder becomes `*` before
19
+ * matching. `[` and `(` count because picomatch also treats character classes
20
+ * (`[0-9]`) and extglobs (`+(a|b)`) as wildcards — without them a pattern like
21
+ * `/v1/users/[0-9]*` would be ranked as though `[` were literal, scoring it
22
+ * more specific than it is and letting it beat a genuinely narrower pattern. */
23
+ const WILDCARD_CHARS = ["*", "?", "{", "[", "("];
24
+ /** Pathname of a full endpoint URL, or undefined if unparseable.
25
+ * `endpointURL` is contractually base URL + path, so the result includes any
26
+ * base-path prefix: `https://host/api/v1/persons/42` -> `/api/v1/persons/42`.
27
+ *
28
+ * Delegates to the shared helper so this module and the plan guard cannot
29
+ * disagree about what an endpointURL's path is — the shared version also
30
+ * restores `{param}` placeholders that `new URL()` percent-encodes, which
31
+ * patterns are authored with. */
32
+ export function extractPathname(endpointURL) {
33
+ return pathFromEndpointURL(endpointURL);
34
+ }
35
+ /** True when a resolved config would contribute no query params at all.
36
+ * Both call sites skip the merge entirely in that case, so an empty
37
+ * declaration leaves queryParams untouched rather than setting it to an empty
38
+ * value. Defined once so the two sites cannot drift on what "empty" means. */
39
+ export function contributesNothing(config) {
40
+ return Object.keys(config.base).length === 0 && config.overrides.length === 0;
41
+ }
42
+ /** Batch-scenario steps carry a bare path that may or may not be rooted. */
43
+ export function ensureLeadingSlash(p) {
44
+ return p.startsWith("/") ? p : `/${p}`;
45
+ }
46
+ /** Rank for most-specific-wins: length of the literal prefix before the first
47
+ * wildcard, computed on the ORIGINAL pattern so `{id}` still counts. */
48
+ export function patternSpecificity(pathPattern) {
49
+ let earliest = pathPattern.length;
50
+ for (const ch of WILDCARD_CHARS) {
51
+ const idx = pathPattern.indexOf(ch);
52
+ if (idx !== -1 && idx < earliest)
53
+ earliest = idx;
54
+ }
55
+ return earliest;
56
+ }
57
+ /** Rewrite OpenAPI-style `{param}` placeholders to a single-segment wildcard.
58
+ * Without this, picomatch reads `{id}` as brace alternation and matches only
59
+ * the literal string `/v1/persons/id`. Brace alternation is therefore not
60
+ * supported in patterns — an accepted trade. */
61
+ function toGlob(pathPattern) {
62
+ return pathPattern.replace(/\{[^/}]*\}/g, "*");
63
+ }
64
+ function isStringMap(v) {
65
+ if (!v || typeof v !== "object" || Array.isArray(v))
66
+ return false;
67
+ return Object.values(v).every((x) => typeof x === "string");
68
+ }
69
+ /**
70
+ * Validate and coerce the raw `api.queryParamOverrides` value read from YAML.
71
+ *
72
+ * The read path in workspaceAuth.ts parses workspace.yml directly rather than
73
+ * through the Zod schema (the walk-up read must stay cheap), so nothing has
74
+ * validated this yet. Malformed entries are dropped with a warning instead of
75
+ * failing generation. Called once per load, not per request.
76
+ */
77
+ export function normalizeOverrides(raw) {
78
+ if (!Array.isArray(raw)) {
79
+ if (raw !== undefined) {
80
+ logger.warning("Ignoring api.queryParamOverrides — expected a list", { got: typeof raw });
81
+ }
82
+ return [];
83
+ }
84
+ const result = [];
85
+ for (const entry of raw) {
86
+ const e = entry;
87
+ if (!e || typeof e !== "object" || typeof e.pathPattern !== "string" || e.pathPattern.length === 0) {
88
+ logger.warning("Skipping api.queryParamOverrides entry with a missing or empty pathPattern");
89
+ continue;
90
+ }
91
+ if (!isStringMap(e.values)) {
92
+ logger.warning("Skipping api.queryParamOverrides entry whose values are not a string map", {
93
+ pathPattern: e.pathPattern,
94
+ });
95
+ continue;
96
+ }
97
+ result.push({ pathPattern: e.pathPattern, values: e.values });
98
+ }
99
+ return result;
100
+ }
101
+ /**
102
+ * Validate and coerce the raw `api.defaultQueryParams` value read from YAML.
103
+ *
104
+ * Same reason `normalizeOverrides` exists: this read path deliberately bypasses
105
+ * the Zod schema, so nothing has checked the shape. Without this, a scalar
106
+ * declaration (`defaultQueryParams: just-a-string`) is spread by key and yields
107
+ * one query param per character — `0=j&1=u&2=s&…` sent to the server. A list
108
+ * behaves the same way, and a map with non-string values would reach
109
+ * mergeQueryParams* and be dropped there with a less specific warning.
110
+ *
111
+ * Anything that is not a plain object of string values degrades to an empty
112
+ * base map with a warning naming what was found.
113
+ */
114
+ export function normalizeBaseMap(raw) {
115
+ if (raw === undefined || raw === null)
116
+ return {};
117
+ if (isStringMap(raw))
118
+ return raw;
119
+ logger.warning("Ignoring api.defaultQueryParams — expected a map of string values", { got: Array.isArray(raw) ? "array" : typeof raw });
120
+ return {};
121
+ }
122
+ /**
123
+ * Effective query params for one request path: the base map with the single
124
+ * most-specific matching override layered on top.
125
+ *
126
+ * Overrides never merge with each other — at most one wins. Ranking is by
127
+ * literal prefix length, then total pattern length, then document order.
128
+ *
129
+ * Whenever the patterns differ in specificity the result is independent of how
130
+ * the YAML list is ordered. Document order is only reached when two patterns
131
+ * tie on BOTH earlier criteria, and reordering the list does change the winner
132
+ * in that case — which is why a tie emits an ambiguity warning naming both
133
+ * patterns rather than resolving silently.
134
+ */
135
+ export function resolveQueryParamsForPath(config, pathname) {
136
+ const matches = config.overrides
137
+ .map((override, index) => ({ override, index }))
138
+ .filter(({ override }) => picomatch.isMatch(pathname, toGlob(override.pathPattern)));
139
+ if (matches.length === 0)
140
+ return { ...config.base };
141
+ matches.sort((a, b) => {
142
+ const specDiff = patternSpecificity(b.override.pathPattern) - patternSpecificity(a.override.pathPattern);
143
+ if (specDiff !== 0)
144
+ return specDiff;
145
+ // Known limitation (Finding 5, SKYR-4127 final review): at equal literal-
146
+ // prefix length, this tiebreak prefers the LONGER total pattern — which
147
+ // is not always the narrower one. E.g. "/v1/persons/**" (14 chars) beats
148
+ // "/v1/persons/*" (13 chars) even though "**" matches strictly more paths
149
+ // than "*". Length is a proxy for specificity, not a guarantee of it; not
150
+ // fixed here since it would change ranking behavior for existing configs.
151
+ const lenDiff = b.override.pathPattern.length - a.override.pathPattern.length;
152
+ if (lenDiff !== 0)
153
+ return lenDiff;
154
+ return a.index - b.index;
155
+ });
156
+ const [winner, runnerUp] = matches;
157
+ if (runnerUp &&
158
+ patternSpecificity(winner.override.pathPattern) === patternSpecificity(runnerUp.override.pathPattern) &&
159
+ winner.override.pathPattern.length === runnerUp.override.pathPattern.length) {
160
+ logger.warning("Ambiguous api.queryParamOverrides — two equally specific patterns match this path; using the first", {
161
+ pathname,
162
+ patterns: [winner.override.pathPattern, runnerUp.override.pathPattern],
163
+ });
164
+ }
165
+ return { ...config.base, ...winner.override.values };
166
+ }
167
+ /** `env.VAR` / legacy `envs.VAR`, anchored so a literal that merely contains
168
+ * the prefix (`prod.env.HOST`) is not mistaken for a reference, and requiring
169
+ * at least one character of variable name. Case-sensitive, matching the
170
+ * schema's documented behavior: `ENV.VAR` is an ordinary literal. */
171
+ const ENV_REFERENCE = /^envs?\.(.+)$/;
172
+ /** Environment variables this workspace's query params defer to, in declaration
173
+ * order and deduplicated (SKYR-4127).
174
+ *
175
+ * These references resolve where the test RUNS, so under Testbot they resolve
176
+ * inside the executor container — whose environment is a fixed allowlist. A
177
+ * name that never reaches the container resolves to nothing, and an unresolved
178
+ * query param is dropped from the request silently, producing a passing test
179
+ * that exercised the wrong identity. The executor uses this list to forward
180
+ * exactly what the workspace asked for.
181
+ *
182
+ * Deliberately derived from the declarations rather than from the ambient
183
+ * environment: nothing is forwarded that workspace.yml did not name.
184
+ *
185
+ * Both maps are read even though `defaultQueryParams` documents itself as
186
+ * literal-only — that is a doc contract the core loader does not enforce, and
187
+ * a declaration there would otherwise reproduce the original silent failure. */
188
+ export function extractEnvVarNames(config) {
189
+ const names = new Set();
190
+ const collect = (values) => {
191
+ for (const value of Object.values(values)) {
192
+ const match = typeof value === "string" ? ENV_REFERENCE.exec(value) : null;
193
+ if (match)
194
+ names.add(match[1]);
195
+ }
196
+ };
197
+ collect(config.base);
198
+ for (const override of config.overrides)
199
+ collect(override.values);
200
+ return [...names];
201
+ }