From 118129aeedbae55e0d61c2935a468db9fcb05ef8 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Mon, 25 May 2026 11:44:52 +1000 Subject: [PATCH 1/3] feat: support extraArgs in session defaults --- CHANGELOG.md | 6 ++ config.example.yaml | 1 + .../_defs/common.schema.json | 16 ++++- src/cli/__tests__/session-defaults.test.ts | 30 ++++++++++ .../__tests__/session_clear_defaults.test.ts | 12 ++++ .../__tests__/session_set_defaults.test.ts | 41 +++++++++++++ .../__tests__/session_show_defaults.test.ts | 8 +++ .../session_clear_defaults.ts | 1 + .../session_set_defaults.ts | 1 + .../session_show_defaults.ts | 1 + .../tools/xcode-ide/sync_xcode_defaults.ts | 1 + .../session-clear-defaults--success.json | 3 +- .../session-set-defaults--scheme.json | 3 +- .../session-set-defaults--success.json | 3 +- .../session-show-defaults--empty.json | 3 +- .../session-show-defaults--success.json | 6 +- .../session-sync-xcode-defaults--success.json | 6 +- .../session-set-defaults--scheme.txt | 3 +- .../session-set-defaults--success.txt | 3 +- .../session-show-defaults--empty.txt | 3 +- .../session-show-defaults--success.txt | 6 +- src/types/domain-results.ts | 1 + src/utils/__tests__/project-config.test.ts | 5 ++ .../session-aware-tool-factory.test.ts | 60 +++++++++++++++++++ src/utils/__tests__/session-store.test.ts | 20 +++++++ src/utils/renderers/domain-result-text.ts | 1 + src/utils/session-default-args.ts | 11 ++++ src/utils/session-defaults-schema.ts | 5 ++ src/utils/session-store.ts | 4 ++ 29 files changed, 250 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6037b622..08e9d0f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- Added `extraArgs` as a first-class session-default value. Repo config or runtime defaults can now carry common `xcodebuild` flags (for example `-skipPackagePluginValidation` or `-disableAutomaticPackageResolution`) so they don't need repeating on every build or test call. Per-call `extraArgs` append after the configured defaults, and an explicit empty array (`extraArgs: []`) clears the defaults for a single call. The session management tools show, set, sync, and clear `extraArgs` alongside the other defaults. + ## [2.6.2] ### Fixed diff --git a/config.example.yaml b/config.example.yaml index fc6ab8271..a1b517bf8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -26,3 +26,4 @@ sessionDefaults: preferXcodebuild: false platform: 'iOS' bundleId: 'io.sentry.myapp' + extraArgs: ['-skipPackagePluginValidation'] diff --git a/schemas/structured-output/_defs/common.schema.json b/schemas/structured-output/_defs/common.schema.json index e6354be2b..a5d6dbab5 100644 --- a/schemas/structured-output/_defs/common.schema.json +++ b/schemas/structured-output/_defs/common.schema.json @@ -375,6 +375,19 @@ } } ] + }, + "extraArgs": { + "anyOf": [ + { + "const": null + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } }, "required": [ @@ -393,7 +406,8 @@ "preferXcodebuild", "platform", "bundleId", - "env" + "env", + "extraArgs" ] }, "buildInvocationRequest": { diff --git a/src/cli/__tests__/session-defaults.test.ts b/src/cli/__tests__/session-defaults.test.ts index e2a99f0c8..8ece99fc9 100644 --- a/src/cli/__tests__/session-defaults.test.ts +++ b/src/cli/__tests__/session-defaults.test.ts @@ -126,4 +126,34 @@ describe('CLI session defaults', () => { }, }); }); + + it('appends explicit extraArgs after configured extraArgs', () => { + const merged = mergeCliSessionDefaults({ + defaults: { + extraArgs: ['-skipPackagePluginValidation'], + }, + explicitArgs: { + extraArgs: ['-quiet'], + }, + }); + + expect(merged).toEqual({ + extraArgs: ['-skipPackagePluginValidation', '-quiet'], + }); + }); + + it('allows an explicit empty extraArgs array to clear configured extraArgs', () => { + const merged = mergeCliSessionDefaults({ + defaults: { + extraArgs: ['-skipPackagePluginValidation'], + }, + explicitArgs: { + extraArgs: [], + }, + }); + + expect(merged).toEqual({ + extraArgs: [], + }); + }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index 6b1f77623..86f7a5379 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -63,6 +63,18 @@ describe('session-clear-defaults tool', () => { expect(current.scheme).toBe('MyScheme'); }); + it('should clear extraArgs when keys includes extraArgs', async () => { + sessionStore.setDefaults({ extraArgs: ['-skipPackagePluginValidation'] }); + + const result = await runLogic(() => sessionClearDefaultsLogic({ keys: ['extraArgs'] })); + + expect(result.isError).toBeFalsy(); + + const current = sessionStore.getAll(); + expect(current.extraArgs).toBeUndefined(); + expect(current.scheme).toBe('MyScheme'); + }); + it('should clear all profiles only when all=true', async () => { sessionStore.setActiveProfile('ios'); sessionStore.setDefaults({ scheme: 'IOS' }); diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 9718d2077..09fd0943c 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -421,6 +421,47 @@ describe('session-set-defaults tool', () => { expect(parsed.sessionDefaults?.env).toEqual(envVars); }); + it('should store extraArgs as a string array default', async () => { + const extraArgs = ['-skipPackagePluginValidation', '-skipMacroValidation']; + const result = await runLogic(() => sessionSetDefaultsLogic({ extraArgs }, createContext())); + + expect(result.isError).toBeFalsy(); + expect(sessionStore.getAll().extraArgs).toEqual(extraArgs); + }); + + it('should persist extraArgs to config when persist is true', async () => { + const yaml = ['schemaVersion: 1', 'sessionDefaults: {}', ''].join('\n'); + + const writes: { path: string; content: string }[] = []; + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async (targetPath: string) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + return yaml; + }, + writeFile: async (targetPath: string, content: string) => { + writes.push({ path: targetPath, content }); + }, + }); + + await initConfigStore({ cwd, fs }); + + const extraArgs = ['-skipPackagePluginValidation']; + const result = await runLogic(() => + sessionSetDefaultsLogic({ extraArgs, persist: true }, createContext()), + ); + + expect(result.isError).toBeFalsy(); + expect(writes.length).toBe(1); + + const parsed = parseYaml(writes[0].content) as { + sessionDefaults?: Record; + }; + expect(parsed.sessionDefaults?.extraArgs).toEqual(extraArgs); + }); + it('should not persist when persist is true but no defaults were provided', async () => { const writes: { path: string; content: string }[] = []; const fs = createMockFileSystemExecutor({ diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts index 3c22a5f7c..f435dd8f6 100644 --- a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -32,5 +32,13 @@ describe('session-show-defaults tool', () => { expect(result.isError).toBeFalsy(); expect(allText(result)).toContain('scheme: IOSScheme'); }); + + it('shows extraArgs defaults', async () => { + sessionStore.setDefaults({ extraArgs: ['-skipPackagePluginValidation'] }); + + const result = await callHandler(handler, {}); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('extraArgs: -skipPackagePluginValidation'); + }); }); }); diff --git a/src/mcp/tools/session-management/session_clear_defaults.ts b/src/mcp/tools/session-management/session_clear_defaults.ts index 583eefab2..f98ed0e46 100644 --- a/src/mcp/tools/session-management/session_clear_defaults.ts +++ b/src/mcp/tools/session-management/session_clear_defaults.ts @@ -58,6 +58,7 @@ function createSessionDefaultsProfile(profile: Record): Session platform: (profile.platform as string | undefined) ?? null, bundleId: (profile.bundleId as string | undefined) ?? null, env: (profile.env as Record | undefined) ?? null, + extraArgs: (profile.extraArgs as string[] | undefined) ?? null, }; } diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index c38f831ce..eb8f3ef8c 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -71,6 +71,7 @@ function createSessionDefaultsProfile(defaults: SessionDefaults): SessionDefault platform: defaults.platform ?? null, bundleId: defaults.bundleId ?? null, env: defaults.env ?? null, + extraArgs: defaults.extraArgs ?? null, }; } diff --git a/src/mcp/tools/session-management/session_show_defaults.ts b/src/mcp/tools/session-management/session_show_defaults.ts index a46327ba8..48e095978 100644 --- a/src/mcp/tools/session-management/session_show_defaults.ts +++ b/src/mcp/tools/session-management/session_show_defaults.ts @@ -38,6 +38,7 @@ function createSessionDefaultsProfile(profile: Record): Session platform: (profile.platform as string | undefined) ?? null, bundleId: (profile.bundleId as string | undefined) ?? null, env: (profile.env as Record | undefined) ?? null, + extraArgs: (profile.extraArgs as string[] | undefined) ?? null, }; } diff --git a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts index 86a2693be..fa5ab5529 100644 --- a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts +++ b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts @@ -58,6 +58,7 @@ function createSessionDefaultsProfile(profile: Record): Session platform: (profile.platform as string | undefined) ?? null, bundleId: (profile.bundleId as string | undefined) ?? null, env: (profile.env as Record | undefined) ?? null, + extraArgs: (profile.extraArgs as string[] | undefined) ?? null, }; } diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-clear-defaults--success.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-clear-defaults--success.json index 6723b2e64..392233d01 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-clear-defaults--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-clear-defaults--success.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--scheme.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--scheme.json index f3edab992..a72394765 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--scheme.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--scheme.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--success.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--success.json index 39125735e..8641033e2 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--success.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--empty.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--empty.json index 283e872c3..8d570b13c 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--empty.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--empty.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--success.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--success.json index 1984cb7bf..e7827f559 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--success.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null }, "MyCustomProfile": { "projectPath": null, @@ -40,7 +41,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-sync-xcode-defaults--success.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-sync-xcode-defaults--success.json index 10aaf1581..c0cc4a95a 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-sync-xcode-defaults--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-sync-xcode-defaults--success.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": "io.sentry.calculatorapp", - "env": null + "env": null, + "extraArgs": null }, "MyCustomProfile": { "projectPath": null, @@ -40,7 +41,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } } } diff --git a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--scheme.txt b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--scheme.txt index 7b2d21665..708e8627e 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--scheme.txt @@ -17,4 +17,5 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) diff --git a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--success.txt b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--success.txt index 51c8b54bc..b7d2b0128 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--success.txt @@ -17,4 +17,5 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) diff --git a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--empty.txt b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--empty.txt index 17b08a08a..2363f07f7 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--empty.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--empty.txt @@ -17,4 +17,5 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) diff --git a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--success.txt b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--success.txt index d6372556d..1cab04b12 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--success.txt @@ -17,7 +17,8 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) 📁 MyCustomProfile ├ projectPath: (not set) @@ -35,4 +36,5 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) diff --git a/src/types/domain-results.ts b/src/types/domain-results.ts index e67f2f59f..b71546674 100644 --- a/src/types/domain-results.ts +++ b/src/types/domain-results.ts @@ -127,6 +127,7 @@ export interface SessionDefaultsProfile { platform: string | null; bundleId: string | null; env: Record | null; + extraArgs: string[] | null; } export interface BuildLikeSummary extends StatusSummary { durationMs?: number; diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts index 130d8724e..aaba24b59 100644 --- a/src/utils/__tests__/project-config.test.ts +++ b/src/utils/__tests__/project-config.test.ts @@ -218,6 +218,8 @@ describe('project-config', () => { ' ios:', ' workspacePath: "~/Code/App.xcworkspace"', ' derivedDataPath: "~/.cache/dd"', + ' extraArgs:', + ' - "-skipPackagePluginValidation"', '', ].join('\n'); @@ -231,6 +233,9 @@ describe('project-config', () => { expect(result.config.sessionDefaultsProfiles?.ios?.derivedDataPath).toBe( path.join(homedir(), '.cache/dd'), ); + expect(result.config.sessionDefaultsProfiles?.ios?.extraArgs).toEqual([ + '-skipPackagePluginValidation', + ]); }); it('normalizes namespaced session defaults profiles and active profile', async () => { diff --git a/src/utils/__tests__/session-aware-tool-factory.test.ts b/src/utils/__tests__/session-aware-tool-factory.test.ts index 0e89f941c..6db48cba6 100644 --- a/src/utils/__tests__/session-aware-tool-factory.test.ts +++ b/src/utils/__tests__/session-aware-tool-factory.test.ts @@ -523,4 +523,64 @@ describe('createSessionAwareTool', () => { expect(result.isError).toBe(true); expect(result.text).toContain('Parameter validation failed'); }); + + it('appends explicit extraArgs after session default extraArgs', async () => { + const extraArgsSchema = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + extraArgs: z.array(z.string()).optional(), + }); + + const extraArgsHandler = createSessionAwareTool>({ + internalSchema: extraArgsSchema, + logicFunction: async (params) => { + const ctx = getHandlerContext(); + ctx.emit(statusFragment('success', JSON.stringify(params.extraArgs))); + }, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + extraArgs: ['-skipPackagePluginValidation'], + }); + + const result = await invokeAndCollect(extraArgsHandler, { extraArgs: ['-quiet'] }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); + expect(parsed).toEqual(['-skipPackagePluginValidation', '-quiet']); + }); + + it('allows explicit empty extraArgs to clear session default extraArgs', async () => { + const extraArgsSchema = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + extraArgs: z.array(z.string()).optional(), + }); + + const extraArgsHandler = createSessionAwareTool>({ + internalSchema: extraArgsSchema, + logicFunction: async (params) => { + const ctx = getHandlerContext(); + ctx.emit(statusFragment('success', JSON.stringify(params.extraArgs))); + }, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + extraArgs: ['-skipPackagePluginValidation'], + }); + + const result = await invokeAndCollect(extraArgsHandler, { extraArgs: [] }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); + expect(parsed).toEqual([]); + }); }); diff --git a/src/utils/__tests__/session-store.test.ts b/src/utils/__tests__/session-store.test.ts index ec373d559..e880fb794 100644 --- a/src/utils/__tests__/session-store.test.ts +++ b/src/utils/__tests__/session-store.test.ts @@ -122,6 +122,26 @@ describe('SessionStore', () => { expect(stored.env).toEqual({ API_KEY: 'secret' }); }); + it('does not retain external extraArgs array references passed into setDefaults', () => { + const extraArgs = ['-skipPackagePluginValidation']; + sessionStore.setDefaults({ extraArgs }); + + extraArgs.push('-quiet'); + + const stored = sessionStore.getAll(); + expect(stored.extraArgs).toEqual(['-skipPackagePluginValidation']); + }); + + it('getAll returns a detached copy of extraArgs so mutations do not affect stored defaults', () => { + sessionStore.setDefaults({ extraArgs: ['-skipPackagePluginValidation'] }); + + const copy = sessionStore.getAll(); + copy.extraArgs!.push('-quiet'); + + const stored = sessionStore.getAll(); + expect(stored.extraArgs).toEqual(['-skipPackagePluginValidation']); + }); + it('does not compute derivedDataPath from workspacePath', () => { sessionStore.setDefaults({ workspacePath: '/Users/dev/clone-1/MyApp.xcworkspace' }); diff --git a/src/utils/renderers/domain-result-text.ts b/src/utils/renderers/domain-result-text.ts index 97a3a5b2b..a117546e6 100644 --- a/src/utils/renderers/domain-result-text.ts +++ b/src/utils/renderers/domain-result-text.ts @@ -104,6 +104,7 @@ const SESSION_DEFAULT_KEYS = [ 'platform', 'bundleId', 'env', + 'extraArgs', ] as const; type CoverageTargetFile = { diff --git a/src/utils/session-default-args.ts b/src/utils/session-default-args.ts index d16a1810e..7b33256a0 100644 --- a/src/utils/session-default-args.ts +++ b/src/utils/session-default-args.ts @@ -45,6 +45,17 @@ export function mergeSessionDefaultArgs(opts: { const merged: Record = { ...opts.defaults, ...sanitizedArgs }; + if ( + Object.prototype.hasOwnProperty.call(sanitizedArgs, 'extraArgs') && + Array.isArray(opts.defaults.extraArgs) && + Array.isArray(sanitizedArgs.extraArgs) + ) { + merged.extraArgs = + sanitizedArgs.extraArgs.length === 0 + ? [] + : [...opts.defaults.extraArgs, ...sanitizedArgs.extraArgs]; + } + if ( opts.defaults.env && typeof opts.defaults.env === 'object' && diff --git a/src/utils/session-defaults-schema.ts b/src/utils/session-defaults-schema.ts index 2e99735f5..d8ab15730 100644 --- a/src/utils/session-defaults-schema.ts +++ b/src/utils/session-defaults-schema.ts @@ -19,6 +19,7 @@ export const sessionDefaultKeys = [ 'platform', 'bundleId', 'env', + 'extraArgs', ] as const; export type SessionDefaultKey = (typeof sessionDefaultKeys)[number]; @@ -57,4 +58,8 @@ export const sessionDefaultsSchema = z.object({ .record(nonEmptyString, z.string()) .optional() .describe('Default environment variables to pass to launched apps.'), + extraArgs: z + .array(z.string()) + .optional() + .describe('Default extra xcodebuild arguments for tools that accept extraArgs.'), }); diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts index 186b745a5..8793b54d5 100644 --- a/src/utils/session-store.ts +++ b/src/utils/session-store.ts @@ -21,6 +21,7 @@ export type SessionDefaults = { platform?: string; bundleId?: string; env?: Record; + extraArgs?: string[]; }; class SessionStore { @@ -34,6 +35,9 @@ class SessionStore { if (copy.env) { copy.env = { ...copy.env }; } + if (copy.extraArgs) { + copy.extraArgs = [...copy.extraArgs]; + } return copy; } From 1eb3e15ecb0ffcaa1e1d40f9149c7157ff8d61c4 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Thu, 9 Jul 2026 23:13:35 +1000 Subject: [PATCH 2/3] test(session): cover extraArgs merge ordering --- src/cli/__tests__/session-defaults.test.ts | 36 ++++++++++ .../session-aware-tool-factory.test.ts | 70 +++++++++++++++++++ src/utils/session-default-args.ts | 12 ++-- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/cli/__tests__/session-defaults.test.ts b/src/cli/__tests__/session-defaults.test.ts index 8ece99fc9..88e033a54 100644 --- a/src/cli/__tests__/session-defaults.test.ts +++ b/src/cli/__tests__/session-defaults.test.ts @@ -142,6 +142,42 @@ describe('CLI session defaults', () => { }); }); + it('keeps repeatable configured destination extraArgs when explicit args add another', () => { + const merged = mergeCliSessionDefaults({ + defaults: { + extraArgs: ['-destination', 'id=DEFAULT', '-skipPackagePluginValidation'], + }, + explicitArgs: { + extraArgs: ['-destination', 'id=EXPLICIT'], + }, + }); + + expect(merged).toEqual({ + extraArgs: [ + '-destination', + 'id=DEFAULT', + '-skipPackagePluginValidation', + '-destination', + 'id=EXPLICIT', + ], + }); + }); + + it('appends explicit scalar extraArgs after matching configured extraArgs', () => { + const merged = mergeCliSessionDefaults({ + defaults: { + extraArgs: ['-configuration', 'Debug'], + }, + explicitArgs: { + extraArgs: ['-configuration', 'Release'], + }, + }); + + expect(merged).toEqual({ + extraArgs: ['-configuration', 'Debug', '-configuration', 'Release'], + }); + }); + it('allows an explicit empty extraArgs array to clear configured extraArgs', () => { const merged = mergeCliSessionDefaults({ defaults: { diff --git a/src/utils/__tests__/session-aware-tool-factory.test.ts b/src/utils/__tests__/session-aware-tool-factory.test.ts index 6db48cba6..433aafc6b 100644 --- a/src/utils/__tests__/session-aware-tool-factory.test.ts +++ b/src/utils/__tests__/session-aware-tool-factory.test.ts @@ -554,6 +554,76 @@ describe('createSessionAwareTool', () => { expect(parsed).toEqual(['-skipPackagePluginValidation', '-quiet']); }); + it('keeps repeatable session default destination extraArgs when explicit args add another', async () => { + const extraArgsSchema = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + extraArgs: z.array(z.string()).optional(), + }); + + const extraArgsHandler = createSessionAwareTool>({ + internalSchema: extraArgsSchema, + logicFunction: async (params) => { + const ctx = getHandlerContext(); + ctx.emit(statusFragment('success', JSON.stringify(params.extraArgs))); + }, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + extraArgs: ['-destination', 'id=DEFAULT', '-skipPackagePluginValidation'], + }); + + const result = await invokeAndCollect(extraArgsHandler, { + extraArgs: ['-destination', 'id=EXPLICIT'], + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); + expect(parsed).toEqual([ + '-destination', + 'id=DEFAULT', + '-skipPackagePluginValidation', + '-destination', + 'id=EXPLICIT', + ]); + }); + + it('keeps repeatable session default test constraints when explicit args add another', async () => { + const extraArgsSchema = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + extraArgs: z.array(z.string()).optional(), + }); + + const extraArgsHandler = createSessionAwareTool>({ + internalSchema: extraArgsSchema, + logicFunction: async (params) => { + const ctx = getHandlerContext(); + ctx.emit(statusFragment('success', JSON.stringify(params.extraArgs))); + }, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + extraArgs: ['-only-testing', 'AppTests/testA'], + }); + + const result = await invokeAndCollect(extraArgsHandler, { + extraArgs: ['-only-testing', 'AppTests/testB'], + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); + expect(parsed).toEqual(['-only-testing', 'AppTests/testA', '-only-testing', 'AppTests/testB']); + }); + it('allows explicit empty extraArgs to clear session default extraArgs', async () => { const extraArgsSchema = z.object({ scheme: z.string(), diff --git a/src/utils/session-default-args.ts b/src/utils/session-default-args.ts index 7b33256a0..364fb2df4 100644 --- a/src/utils/session-default-args.ts +++ b/src/utils/session-default-args.ts @@ -30,6 +30,13 @@ export function pickSessionDefaultsForKeys( return pickedDefaults; } +function mergeExtraArgs( + defaultExtraArgs: readonly unknown[], + explicitExtraArgs: readonly unknown[], +): unknown[] { + return explicitExtraArgs.length === 0 ? [] : [...defaultExtraArgs, ...explicitExtraArgs]; +} + export function mergeSessionDefaultArgs(opts: { defaults: Record; explicitArgs: Record; @@ -50,10 +57,7 @@ export function mergeSessionDefaultArgs(opts: { Array.isArray(opts.defaults.extraArgs) && Array.isArray(sanitizedArgs.extraArgs) ) { - merged.extraArgs = - sanitizedArgs.extraArgs.length === 0 - ? [] - : [...opts.defaults.extraArgs, ...sanitizedArgs.extraArgs]; + merged.extraArgs = mergeExtraArgs(opts.defaults.extraArgs, sanitizedArgs.extraArgs); } if ( From 96b17de5420be41f98e67cf8257b70a9c07ee84d Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Fri, 10 Jul 2026 20:48:13 +1000 Subject: [PATCH 3/3] fix(session): let explicit extraArgs replace defaults --- CHANGELOG.md | 3 +- .../_defs/common.schema.json | 3 +- src/cli/__tests__/session-defaults.test.ts | 29 +- .../session-aware-tool-factory.test.ts | 42 +-- .../__tests__/session-default-args.test.ts | 122 +++++++++ src/utils/session-default-args.ts | 252 +++++++++++++++++- 6 files changed, 381 insertions(+), 70 deletions(-) create mode 100644 src/utils/__tests__/session-default-args.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e9d0f7b..506e4462c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added `extraArgs` as a first-class session-default value. Repo config or runtime defaults can now carry common `xcodebuild` flags (for example `-skipPackagePluginValidation` or `-disableAutomaticPackageResolution`) so they don't need repeating on every build or test call. Per-call `extraArgs` append after the configured defaults, and an explicit empty array (`extraArgs: []`) clears the defaults for a single call. The session management tools show, set, sync, and clear `extraArgs` alongside the other defaults. +- Added `extraArgs` as a first-class session-default value. Repo config or runtime defaults can now carry common `xcodebuild` flags (for example `-skipPackagePluginValidation` or `-disableAutomaticPackageResolution`) so they don't need repeating on every build or test call. Per-call `extraArgs` replace matching configured flags or build settings and append after non-matching defaults, while an explicit empty array (`extraArgs: []`) clears the defaults for a single call. The session management tools show, set, sync, and clear `extraArgs` alongside the other defaults. ## [2.6.2] @@ -689,4 +689,3 @@ Please note that the UI automation features are an early preview and currently i - Basic support for building iOS and macOS applications - diff --git a/schemas/structured-output/_defs/common.schema.json b/schemas/structured-output/_defs/common.schema.json index a5d6dbab5..8704d9bbd 100644 --- a/schemas/structured-output/_defs/common.schema.json +++ b/schemas/structured-output/_defs/common.schema.json @@ -406,8 +406,7 @@ "preferXcodebuild", "platform", "bundleId", - "env", - "extraArgs" + "env" ] }, "buildInvocationRequest": { diff --git a/src/cli/__tests__/session-defaults.test.ts b/src/cli/__tests__/session-defaults.test.ts index 88e033a54..f30f298c5 100644 --- a/src/cli/__tests__/session-defaults.test.ts +++ b/src/cli/__tests__/session-defaults.test.ts @@ -142,39 +142,18 @@ describe('CLI session defaults', () => { }); }); - it('keeps repeatable configured destination extraArgs when explicit args add another', () => { + it('lets explicit destination extraArgs replace matching configured extraArgs', () => { const merged = mergeCliSessionDefaults({ defaults: { - extraArgs: ['-destination', 'id=DEFAULT', '-skipPackagePluginValidation'], + extraArgs: ['-quiet', '-skipMacroValidation', '-destination', 'id=x'], }, explicitArgs: { - extraArgs: ['-destination', 'id=EXPLICIT'], + extraArgs: ['-quiet', '-destination', 'id=y'], }, }); expect(merged).toEqual({ - extraArgs: [ - '-destination', - 'id=DEFAULT', - '-skipPackagePluginValidation', - '-destination', - 'id=EXPLICIT', - ], - }); - }); - - it('appends explicit scalar extraArgs after matching configured extraArgs', () => { - const merged = mergeCliSessionDefaults({ - defaults: { - extraArgs: ['-configuration', 'Debug'], - }, - explicitArgs: { - extraArgs: ['-configuration', 'Release'], - }, - }); - - expect(merged).toEqual({ - extraArgs: ['-configuration', 'Debug', '-configuration', 'Release'], + extraArgs: ['-skipMacroValidation', '-quiet', '-destination', 'id=y'], }); }); diff --git a/src/utils/__tests__/session-aware-tool-factory.test.ts b/src/utils/__tests__/session-aware-tool-factory.test.ts index 433aafc6b..86222cb86 100644 --- a/src/utils/__tests__/session-aware-tool-factory.test.ts +++ b/src/utils/__tests__/session-aware-tool-factory.test.ts @@ -554,7 +554,7 @@ describe('createSessionAwareTool', () => { expect(parsed).toEqual(['-skipPackagePluginValidation', '-quiet']); }); - it('keeps repeatable session default destination extraArgs when explicit args add another', async () => { + it('lets explicit destination extraArgs replace matching session default extraArgs', async () => { const extraArgsSchema = z.object({ scheme: z.string(), projectPath: z.string().optional(), @@ -583,45 +583,7 @@ describe('createSessionAwareTool', () => { expect(result.isError).toBe(false); const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); - expect(parsed).toEqual([ - '-destination', - 'id=DEFAULT', - '-skipPackagePluginValidation', - '-destination', - 'id=EXPLICIT', - ]); - }); - - it('keeps repeatable session default test constraints when explicit args add another', async () => { - const extraArgsSchema = z.object({ - scheme: z.string(), - projectPath: z.string().optional(), - extraArgs: z.array(z.string()).optional(), - }); - - const extraArgsHandler = createSessionAwareTool>({ - internalSchema: extraArgsSchema, - logicFunction: async (params) => { - const ctx = getHandlerContext(); - ctx.emit(statusFragment('success', JSON.stringify(params.extraArgs))); - }, - getExecutor: () => createMockExecutor({ success: true }), - requirements: [{ allOf: ['scheme'] }], - }); - - sessionStore.setDefaults({ - scheme: 'App', - projectPath: '/a.xcodeproj', - extraArgs: ['-only-testing', 'AppTests/testA'], - }); - - const result = await invokeAndCollect(extraArgsHandler, { - extraArgs: ['-only-testing', 'AppTests/testB'], - }); - expect(result.isError).toBe(false); - - const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); - expect(parsed).toEqual(['-only-testing', 'AppTests/testA', '-only-testing', 'AppTests/testB']); + expect(parsed).toEqual(['-skipPackagePluginValidation', '-destination', 'id=EXPLICIT']); }); it('allows explicit empty extraArgs to clear session default extraArgs', async () => { diff --git a/src/utils/__tests__/session-default-args.test.ts b/src/utils/__tests__/session-default-args.test.ts new file mode 100644 index 000000000..c4047baa2 --- /dev/null +++ b/src/utils/__tests__/session-default-args.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { mergeSessionDefaultArgs } from '../session-default-args.ts'; + +function mergeExtraArgs(defaultExtraArgs: string[], explicitExtraArgs: string[]): unknown[] { + const merged = mergeSessionDefaultArgs({ + defaults: { extraArgs: defaultExtraArgs }, + explicitArgs: { extraArgs: explicitExtraArgs }, + }); + + return merged.extraArgs as unknown[]; +} + +describe('mergeSessionDefaultArgs extraArgs', () => { + it('appends explicit args after non-matching defaults', () => { + expect(mergeExtraArgs(['-skipPackagePluginValidation'], ['-quiet'])).toEqual([ + '-skipPackagePluginValidation', + '-quiet', + ]); + }); + + it('replaces matching configured options by key', () => { + expect( + mergeExtraArgs( + ['-quiet', '-skipMacroValidation', '-destination', 'id=x'], + ['-quiet', '-destination', 'id=y'], + ), + ).toEqual(['-skipMacroValidation', '-quiet', '-destination', 'id=y']); + }); + + it('clears configured args when explicit args are empty', () => { + expect(mergeExtraArgs(['-skipPackagePluginValidation'], [])).toEqual([]); + }); + + it('replaces separated value options without consuming following flags', () => { + expect( + mergeExtraArgs( + ['-configuration', 'Debug', '-sdk', 'iphonesimulator', '-quiet'], + ['-configuration', 'Release', '-sdk', 'iphoneos'], + ), + ).toEqual(['-quiet', '-configuration', 'Release', '-sdk', 'iphoneos']); + }); + + it('preserves a known flag after a malformed configured value option', () => { + expect( + mergeExtraArgs( + ['-configuration', '-skipPackagePluginValidation'], + ['-configuration', 'Release'], + ), + ).toEqual(['-skipPackagePluginValidation', '-configuration', 'Release']); + }); + + it('treats colon inline forms as the same option key', () => { + expect( + mergeExtraArgs(['-only-testing:AppTests/testA'], ['-only-testing:AppTests/testB']), + ).toEqual(['-only-testing:AppTests/testB']); + }); + + it('treats equals inline forms as the same option key', () => { + expect(mergeExtraArgs(['--foo=bar'], ['--foo=baz'])).toEqual(['--foo=baz']); + }); + + it('replaces build settings by key', () => { + expect(mergeExtraArgs(['SWIFT_VERSION=4', '-quiet'], ['SWIFT_VERSION=5'])).toEqual([ + '-quiet', + 'SWIFT_VERSION=5', + ]); + }); + + it('keeps conditional build settings with different conditions', () => { + expect( + mergeExtraArgs( + ['EXCLUDED_ARCHS[sdk=iphonesimulator*]=arm64'], + ['EXCLUDED_ARCHS[sdk=iphoneos*]=arm64'], + ), + ).toEqual([ + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]=arm64', + 'EXCLUDED_ARCHS[sdk=iphoneos*]=arm64', + ]); + }); + + it('replaces conditional build settings with the same condition', () => { + expect( + mergeExtraArgs( + ['EXCLUDED_ARCHS[sdk=iphonesimulator*]=arm64'], + ['EXCLUDED_ARCHS[sdk=iphonesimulator*]=x86_64'], + ), + ).toEqual(['EXCLUDED_ARCHS[sdk=iphonesimulator*]=x86_64']); + }); + + it('does not reinterpret dash-prefixed option values as option keys', () => { + expect( + mergeExtraArgs( + ['-Explicit.xcresult', '-resultBundlePath', 'Default.xcresult'], + ['-resultBundlePath', '-Explicit.xcresult'], + ), + ).toEqual(['-Explicit.xcresult', '-resultBundlePath', '-Explicit.xcresult']); + }); + + it('keeps build settings separate from values of current xcodebuild options', () => { + expect(mergeExtraArgs(['FOO=default', '-scheme', 'Old'], ['-scheme', 'FOO=Bar'])).toEqual([ + 'FOO=default', + '-scheme', + 'FOO=Bar', + ]); + }); + + it('keeps build settings separate from unknown valueless options', () => { + expect(mergeExtraArgs(['-futureFlag', 'FOO=default'], ['-futureFlag=on'])).toEqual([ + 'FOO=default', + '-futureFlag=on', + ]); + }); + + it('preserves repeated explicit args verbatim', () => { + expect( + mergeExtraArgs( + ['-destination', 'id=DEFAULT'], + ['-destination', 'id=ONE', '-destination', 'id=TWO'], + ), + ).toEqual(['-destination', 'id=ONE', '-destination', 'id=TWO']); + }); +}); diff --git a/src/utils/session-default-args.ts b/src/utils/session-default-args.ts index 364fb2df4..a466cd810 100644 --- a/src/utils/session-default-args.ts +++ b/src/utils/session-default-args.ts @@ -30,11 +30,261 @@ export function pickSessionDefaultsForKeys( return pickedDefaults; } +// Known options disambiguate separated values that start with '-' or contain '='. +const valuelessXcodebuildExtraArgKeys = new Set([ + '-alltargets', + '-allowProvisioningDeviceRegistration', + '-allowProvisioningUpdates', + '-checkFirstLaunchStatus', + '-create-xcframework', + '-disableAutomaticPackageResolution', + '-disablePackageRepositoryCache', + '-downloadAllPlatforms', + '-enumerate-tests', + '-exportArchive', + '-exportLocalizations', + '-exportNotarizedApp', + '-help', + '-hideShellScriptEnvironment', + '-importLocalizations', + '-includeScreenshots', + '-json', + '-license', + '-list', + '-mergeImport', + '-onlyUsePackageVersionsFromResolvedFile', + '-parallelizeTargets', + '-prepareDeviceSupport', + '-quiet', + '-resolvePackageDependencies', + '-retry-tests-on-failure', + '-runFirstLaunch', + '-run-tests-until-failure', + '-showBuildSettings', + '-showBuildSettingsForIndex', + '-showBuildTimingSummary', + '-showTestPlans', + '-showdestinations', + '-showsdks', + '-skipMacroValidation', + '-skipPackageSignatureValidation', + '-skipPackageUpdates', + '-skipPackagePluginValidation', + '-skipUnavailableActions', + '-usage', + '-verbose', + '-version', +]); + +const valueTakingXcodebuildExtraArgKeys = new Set([ + '-arch', + '-architecture', + '-architectureVariant', + '-archivePath', + '-authenticationKeyID', + '-authenticationKeyIssuerID', + '-authenticationKeyPath', + '-buildVersion', + '-clonedSourcePackagesDirPath', + '-collect-test-diagnostics', + '-configuration', + '-defaultLanguage', + '-defaultPackageRegistryURL', + '-default-test-execution-time-allowance', + '-deleteComponent', + '-derivedDataPath', + '-destination', + '-destination-timeout', + '-downloadComponent', + '-downloadPlatform', + '-enableAddressSanitizer', + '-enableCodeCoverage', + '-enablePerformanceTestsDiagnostics', + '-enableThreadSanitizer', + '-enableUndefinedBehaviorSanitizer', + '-exportLanguage', + '-exportOptionsPlist', + '-exportPath', + '-find-executable', + '-find-library', + '-framework', + '-headers', + '-importComponent', + '-importPath', + '-importPlatform', + '-jobs', + '-library', + '-localizationPath', + '-maximum-concurrent-test-device-destinations', + '-maximum-concurrent-test-simulator-destinations', + '-maximum-parallel-testing-workers', + '-maximum-test-execution-time-allowance', + '-modelCode', + '-only-test-configuration', + '-only-testing', + '-osVersion', + '-output', + '-packageAuthorizationProvider', + '-packageCachePath', + '-packageDependencySCMToRegistryTransformation', + '-packageFingerprintPolicy', + '-packageSigningEntityPolicy', + '-parallel-testing-enabled', + '-parallel-testing-worker-count', + '-platform', + '-project', + '-resultBundlePath', + '-resultBundleVersion', + '-resultStreamPath', + '-scheme', + '-scmProvider', + '-sdk', + '-showComponent', + '-skip-test-configuration', + '-skip-testing', + '-target', + '-test-enumeration-format', + '-test-enumeration-output-path', + '-test-enumeration-style', + '-test-iterations', + '-test-repetition-relaunch-enabled', + '-test-timeouts-enabled', + '-testLanguage', + '-testPlan', + '-testProductsPath', + '-testRegion', + '-toolchain', + '-workspace', + '-xcconfig', + '-xctestrun', +]); + +type ExtraArgGroup = { + key: string | null; + args: unknown[]; +}; + +function findBuildSettingAssignmentIndex(arg: string): number { + let bracketDepth = 0; + + for (let index = 0; index < arg.length; index += 1) { + const char = arg[index]; + if (char === '[') { + bracketDepth += 1; + continue; + } + + if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1); + continue; + } + + if (char === '=' && bracketDepth === 0) { + return index; + } + } + + return -1; +} + +function getExtraArgKey(arg: string): string | null { + if (arg.startsWith('-')) { + const separatorIndexes = [arg.indexOf(':'), arg.indexOf('=')].filter((index) => index !== -1); + const separatorIndex = Math.min(...separatorIndexes); + + return separatorIndexes.length === 0 ? arg : arg.slice(0, separatorIndex); + } + + const equalsIndex = findBuildSettingAssignmentIndex(arg); + return equalsIndex === -1 ? null : arg.slice(0, equalsIndex); +} + +function isStandaloneOption(arg: string): boolean { + return arg.startsWith('-') && !arg.includes(':') && !arg.includes('='); +} + +function hasInlineOptionValue(arg: string): boolean { + return arg.startsWith('-') && (arg.includes(':') || arg.includes('=')); +} + +function isKnownXcodebuildOption(arg: string): boolean { + const key = getExtraArgKey(arg); + return ( + key !== null && + (valuelessXcodebuildExtraArgKeys.has(key) || valueTakingXcodebuildExtraArgKeys.has(key)) + ); +} + +function canTreatAsSeparatedOptionValue(optionArg: string, valueArg: string): boolean { + const key = getExtraArgKey(optionArg); + if (key === null || !isStandaloneOption(optionArg) || valuelessXcodebuildExtraArgKeys.has(key)) { + return false; + } + + if (valueTakingXcodebuildExtraArgKeys.has(key)) { + return !isKnownXcodebuildOption(valueArg); + } + + return !valueArg.startsWith('-') && findBuildSettingAssignmentIndex(valueArg) === -1; +} + +// Consume each token once so values cannot also become override keys; preserve group and token order. +function groupExtraArgs(args: readonly unknown[]): ExtraArgGroup[] { + const groups: ExtraArgGroup[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (typeof arg !== 'string') { + groups.push({ key: null, args: [arg] }); + continue; + } + + const key = getExtraArgKey(arg); + const groupedArgs: unknown[] = [arg]; + const nextArg = args[index + 1]; + if ( + key !== null && + !hasInlineOptionValue(arg) && + typeof nextArg === 'string' && + canTreatAsSeparatedOptionValue(arg, nextArg) + ) { + groupedArgs.push(nextArg); + index += 1; + } + + groups.push({ key, args: groupedArgs }); + } + + return groups; +} + +function filterOverriddenExtraArgs( + defaultExtraArgs: readonly unknown[], + overriddenKeys: Set, +): unknown[] { + return groupExtraArgs(defaultExtraArgs) + .filter((group) => group.key === null || !overriddenKeys.has(group.key)) + .flatMap((group) => group.args); +} + function mergeExtraArgs( defaultExtraArgs: readonly unknown[], explicitExtraArgs: readonly unknown[], ): unknown[] { - return explicitExtraArgs.length === 0 ? [] : [...defaultExtraArgs, ...explicitExtraArgs]; + if (explicitExtraArgs.length === 0) { + return []; + } + + const overriddenKeys = new Set( + groupExtraArgs(explicitExtraArgs) + .map((group) => group.key) + .filter((key): key is string => key !== null), + ); + if (overriddenKeys.size === 0) { + return [...defaultExtraArgs, ...explicitExtraArgs]; + } + + return [...filterOverriddenExtraArgs(defaultExtraArgs, overriddenKeys), ...explicitExtraArgs]; } export function mergeSessionDefaultArgs(opts: {