diff --git a/.github/scripts/check-autofix-contracts.sh b/.github/scripts/check-autofix-contracts.sh new file mode 100755 index 00000000000..ed4cc325bcd --- /dev/null +++ b/.github/scripts/check-autofix-contracts.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -uo pipefail + +fail() { + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "outcome=failed" >> "${GITHUB_OUTPUT}" + fi + exit 1 +} + +changed_files="$(cat)" + +if ! npm run check-i18n; then + echo '❌ i18n verification failed.' + fail +fi + +if grep -Fxq 'packages/core/src/tools/tool-names.ts' <<< "${changed_files}"; then + if ! npm run test --workspace packages/web-shell -- \ + client/components/messages/toolFormatting.drift.test.ts; then + echo '❌ Web Shell tool-display contract verification failed.' + fail + fi +fi diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index dc209d65ce8..4609f471b9a 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -644,6 +644,7 @@ jobs: - name: 'Stage trusted schema gate' run: |- cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh" + cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh" cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh" - name: 'Check bot credentials' @@ -1151,6 +1152,8 @@ jobs: # and kill the gate with no outcome), and the gate logic must come # from the trusted base, not the branch under verification. bash "${RUNNER_TEMP}/check-settings-schema.sh" + git diff --name-only "origin/main...${BRANCH}" \ + | bash "${RUNNER_TEMP}/check-autofix-contracts.sh" # Run changed/related tests for the packages this fix touches. # --changed follows the import graph so transitive breakage is caught. @@ -2480,6 +2483,7 @@ jobs: - name: 'Stage trusted schema gate and agent runner' run: |- cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh" + cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh" cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh" # The agent step runs AFTER prepare checks out the PR branch, so # invoking the runner from the working tree would execute @@ -3075,6 +3079,8 @@ jobs: # and kill the gate with no outcome), and the gate logic must come # from the trusted base, not the branch under verification. bash "${RUNNER_TEMP}/check-settings-schema.sh" + git diff --name-only "origin/main...${BRANCH}" \ + | bash "${RUNNER_TEMP}/check-autofix-contracts.sh" if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then # No new commit. That is only legitimate as a deliberate no-action. diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 1948bf8cb58..67fbc05d951 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -25,6 +25,8 @@ const sandboxImageResolverScript = readFileSync( '.github/scripts/resolve-sandbox-image.mjs', 'utf8', ); +const autofixContractsScriptPath = '.github/scripts/check-autofix-contracts.sh'; +const autofixContractsScript = readFileSync(autofixContractsScriptPath, 'utf8'); const autofixRunnerScriptPath = '.qwen/skills/autofix/scripts/run-agent.mjs'; const checkBotCredentialsStep = workflow.match( @@ -4103,6 +4105,12 @@ describe('qwen-autofix workflow', () => { expect(step).not.toContain( 'bash .github/scripts/check-settings-schema.sh', ); + expect(step).toContain( + 'bash "${RUNNER_TEMP}/check-autofix-contracts.sh"', + ); + expect(step).not.toContain( + 'bash .github/scripts/check-autofix-contracts.sh', + ); // The owning-package resolver is likewise a shared script staged from the // trusted base, invoked (not inlined) so the two gates cannot drift into // resolving packages differently. The old inline detection must be gone. @@ -4125,6 +4133,11 @@ describe('qwen-autofix workflow', () => { /cp \.github\/scripts\/check-settings-schema\.sh "\$\{RUNNER_TEMP\}\/check-settings-schema\.sh"/g, ) ?? [], ).toHaveLength(2); + expect( + workflow.match( + /cp \.github\/scripts\/check-autofix-contracts\.sh "\$\{RUNNER_TEMP\}\/check-autofix-contracts\.sh"/g, + ) ?? [], + ).toHaveLength(2); // The owning-package resolver is staged the same way, in the same steps. expect( workflow.match( @@ -4201,6 +4214,88 @@ describe('qwen-autofix workflow', () => { 'bash "${RUNNER_TEMP}/check-settings-schema.sh"', ), ).toBeLessThan(reviewVerifyGate.indexOf('outcome=noop')); + expect( + reviewVerifyGate.indexOf( + 'bash "${RUNNER_TEMP}/check-autofix-contracts.sh"', + ), + ).toBeLessThan(reviewVerifyGate.indexOf('outcome=noop')); + expect(autofixContractsScript).toContain('npm run check-i18n'); + expect(autofixContractsScript).toContain( + 'packages/core/src/tools/tool-names.ts', + ); + expect(autofixContractsScript).toContain( + 'client/components/messages/toolFormatting.drift.test.ts', + ); + expect(autofixContractsScript).toContain('outcome=failed'); + expect(ciWorkflow).toContain("run: 'npm run check-i18n'"); + expect(ciWorkflow).toContain('npm run test:ci'); + }); + + it('runs cross-package autofix contracts only when their source changes', () => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-contracts-')); + const npmLog = join(dir, 'npm.log'); + try { + writeFileSync( + join(dir, 'npm'), + [ + '#!/usr/bin/env bash', + 'printf \'%s\\n\' "$*" >> "${NPM_LOG}"', + 'if [[ "$*" == "run check-i18n" ]]; then', + ' exit "${I18N_EXIT:-0}"', + 'fi', + 'exit "${DRIFT_EXIT:-0}"', + '', + ].join('\n'), + ); + chmodSync(join(dir, 'npm'), 0o755); + + const run = (changedFiles, extraEnv = {}) => + spawnSync('bash', [resolve(autofixContractsScriptPath)], { + input: changedFiles, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + NPM_LOG: npmLog, + ...extraEnv, + }, + }); + + expect(run('packages/core/src/config/config.ts\n').status).toBe(0); + expect(readFileSync(npmLog, 'utf8').trim().split('\n')).toEqual([ + 'run check-i18n', + ]); + + writeFileSync(npmLog, ''); + expect(run('packages/core/src/tools/tool-names.ts\n').status).toBe(0); + expect(readFileSync(npmLog, 'utf8').trim().split('\n')).toEqual([ + 'run check-i18n', + 'run test --workspace packages/web-shell -- client/components/messages/toolFormatting.drift.test.ts', + ]); + + writeFileSync(npmLog, ''); + const output = join(dir, 'output'); + expect( + run('packages/core/src/tools/tool-names.ts\n', { + GITHUB_OUTPUT: output, + I18N_EXIT: '1', + }).status, + ).toBe(1); + expect(readFileSync(npmLog, 'utf8').trim()).toBe('run check-i18n'); + expect(readFileSync(output, 'utf8')).toContain('outcome=failed'); + + writeFileSync(npmLog, ''); + writeFileSync(output, ''); + expect( + run('packages/core/src/tools/tool-names.ts\n', { + GITHUB_OUTPUT: output, + DRIFT_EXIT: '1', + }).status, + ).toBe(1); + expect(readFileSync(output, 'utf8')).toContain('outcome=failed'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); it('passes model credentials directly to qwen subprocesses', () => {