From 569b1d5b32ebf4c32d0b965bb956b16713533e07 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:43:58 -0400 Subject: [PATCH] fix: disable Claude co-author attribution by default (#2758) * fix: disable Claude co-author attribution by default * fix: harden default co-author opt-out and correct the docs Follow-up on the co-author default in this PR. - Remove the existsSync/writeFileSync race in the installer settings write (CodeQL js/file-system-race, high). A single guarded read now covers the fresh-install case, and unreadable or non-object settings are left untouched. - Respect `attribution` as an explicit user choice. It supersedes `includeCoAuthoredBy` in Claude Code 2.1.x, so a user who configured it would otherwise have had a dead key written into their settings. - Share one opt-out rule via scripts/lib/claude-commit-attribution.js instead of duplicating it across the installer and plugin setup. - Update the git-workflow rule and its nine mirrors and translations, which still told users ECC does not ship this setting. We keep writing the deprecated `includeCoAuthoredBy` key rather than `attribution`: unknown keys fail Claude Code settings validation, so writing `attribution` would break users on older versions. --- .cursor/rules/common-git-workflow.md | 2 +- .kiro/steering/git-workflow.md | 2 +- docs/es/rules/common/git-workflow.md | 2 +- docs/ja-JP/rules/common/git-workflow.md | 2 +- docs/ko-KR/rules/git-workflow.md | 2 +- docs/pt-BR/rules/git-workflow.md | 2 +- docs/tr/rules/common/git-workflow.md | 2 +- docs/zh-CN/rules/common/git-workflow.md | 2 +- docs/zh-TW/rules/git-workflow.md | 2 +- rules/common/git-workflow.md | 2 +- scripts/lib/claude-commit-attribution.js | 43 ++++++++++++ scripts/lib/claude-plugin-setup.js | 38 ++++++++-- scripts/lib/claude-scope-migration.js | 30 +++++--- scripts/lib/install/apply.js | 55 +++++++++++++++ tests/lib/claude-commit-attribution.test.js | 77 +++++++++++++++++++++ tests/lib/claude-plugin-setup.test.js | 54 +++++++++++++++ tests/lib/claude-scope-migration.test.js | 43 ++++++++++-- tests/scripts/install-apply.test.js | 76 ++++++++++++++++++-- 18 files changed, 398 insertions(+), 38 deletions(-) create mode 100644 scripts/lib/claude-commit-attribution.js create mode 100644 tests/lib/claude-commit-attribution.test.js diff --git a/.cursor/rules/common-git-workflow.md b/.cursor/rules/common-git-workflow.md index 591d45ddf..6d71b0a47 100644 --- a/.cursor/rules/common-git-workflow.md +++ b/.cursor/rules/common-git-workflow.md @@ -13,7 +13,7 @@ alwaysApply: true Types: feat, fix, refactor, docs, test, chore, perf, ci -Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting). +Note: ECC-managed installs set `"includeCoAuthoredBy": false` in `~/.claude/settings.json`, so commits carry no `Co-Authored-By` trailer by default. To keep Claude attribution, set `"includeCoAuthoredBy": true` or configure `attribution`; ECC never overwrites an explicit choice. ## Pull Request Workflow diff --git a/.kiro/steering/git-workflow.md b/.kiro/steering/git-workflow.md index 9fee1ab20..b78b2163d 100644 --- a/.kiro/steering/git-workflow.md +++ b/.kiro/steering/git-workflow.md @@ -15,7 +15,7 @@ description: Git workflow guidelines for conventional commits and pull request p Types: feat, fix, refactor, docs, test, chore, perf, ci -Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting). +Note: ECC-managed installs set `"includeCoAuthoredBy": false` in `~/.claude/settings.json`, so commits carry no `Co-Authored-By` trailer by default. To keep Claude attribution, set `"includeCoAuthoredBy": true` or configure `attribution`; ECC never overwrites an explicit choice. ## Pull Request Workflow diff --git a/docs/es/rules/common/git-workflow.md b/docs/es/rules/common/git-workflow.md index 3b48b772e..3806dab70 100644 --- a/docs/es/rules/common/git-workflow.md +++ b/docs/es/rules/common/git-workflow.md @@ -9,7 +9,7 @@ Tipos: feat, fix, refactor, docs, test, chore, perf, ci -Nota: Para desactivar la atribución de coautoría, configure `"includeCoAuthoredBy": false` en `~/.claude/settings.json`; Claude Code agrega `Co-Authored-By` de forma predeterminada y ECC no incluye esta configuración. +Nota: Las instalaciones gestionadas por ECC configuran `"includeCoAuthoredBy": false` en `~/.claude/settings.json`, por lo que los commits no incluyen `Co-Authored-By` de forma predeterminada. Para conservar la atribución de Claude, configure `"includeCoAuthoredBy": true` o `attribution`; ECC nunca sobrescribe una elección explícita. ## Flujo de Trabajo de Pull Request diff --git a/docs/ja-JP/rules/common/git-workflow.md b/docs/ja-JP/rules/common/git-workflow.md index 7fb2e7f1e..6767d0725 100644 --- a/docs/ja-JP/rules/common/git-workflow.md +++ b/docs/ja-JP/rules/common/git-workflow.md @@ -10,7 +10,7 @@ タイプ: feat, fix, refactor, docs, test, chore, perf, ci -注記: コミットの共同作成者の属性を無効にするには、`~/.claude/settings.json` に `"includeCoAuthoredBy": false` を設定します。Claude Code は既定で `Co-Authored-By` を付与し、ECC はこの設定を同梱しません。 +注記: ECC が管理するインストールは `~/.claude/settings.json` に `"includeCoAuthoredBy": false` を設定するため、コミットには既定で `Co-Authored-By` が付きません。Claude の属性を残す場合は `"includeCoAuthoredBy": true` または `attribution` を設定してください。ECC は明示的な設定を上書きしません。 ## Pull Request ワークフロー diff --git a/docs/ko-KR/rules/git-workflow.md b/docs/ko-KR/rules/git-workflow.md index 9ad47756c..dbf30ee3e 100644 --- a/docs/ko-KR/rules/git-workflow.md +++ b/docs/ko-KR/rules/git-workflow.md @@ -9,7 +9,7 @@ 타입: feat, fix, refactor, docs, test, chore, perf, ci -참고: 공동 작성자 표기를 비활성화하려면 `~/.claude/settings.json`에 `"includeCoAuthoredBy": false`를 설정하세요. Claude Code는 기본적으로 `Co-Authored-By`를 추가하며 ECC는 이 설정을 포함하지 않습니다. +참고: ECC가 관리하는 설치는 `~/.claude/settings.json`에 `"includeCoAuthoredBy": false`를 설정하므로 커밋에 기본적으로 `Co-Authored-By`가 붙지 않습니다. Claude 표기를 유지하려면 `"includeCoAuthoredBy": true`를 설정하거나 `attribution`을 구성하세요. ECC는 명시적인 선택을 덮어쓰지 않습니다. ## Pull Request 워크플로우 diff --git a/docs/pt-BR/rules/git-workflow.md b/docs/pt-BR/rules/git-workflow.md index 761ce3e2c..5b75622b1 100644 --- a/docs/pt-BR/rules/git-workflow.md +++ b/docs/pt-BR/rules/git-workflow.md @@ -9,7 +9,7 @@ Tipos: feat, fix, refactor, docs, test, chore, perf, ci -Nota: Para desativar a atribuição de coautoria, defina `"includeCoAuthoredBy": false` em `~/.claude/settings.json`; o Claude Code adiciona `Co-Authored-By` por padrão e o ECC não inclui essa configuração. +Nota: As instalações gerenciadas pelo ECC definem `"includeCoAuthoredBy": false` em `~/.claude/settings.json`, portanto os commits não incluem `Co-Authored-By` por padrão. Para manter a atribuição do Claude, defina `"includeCoAuthoredBy": true` ou configure `attribution`; o ECC nunca sobrescreve uma escolha explícita. ## Fluxo de Trabalho de Pull Request diff --git a/docs/tr/rules/common/git-workflow.md b/docs/tr/rules/common/git-workflow.md index 25b71cab9..5fab67267 100644 --- a/docs/tr/rules/common/git-workflow.md +++ b/docs/tr/rules/common/git-workflow.md @@ -9,7 +9,7 @@ Types: feat, fix, refactor, docs, test, chore, perf, ci -Not: Ortak yazar atfını devre dışı bırakmak için `~/.claude/settings.json` içinde `"includeCoAuthoredBy": false` ayarlayın; Claude Code varsayılan olarak `Co-Authored-By` ekler ve ECC bu ayarı içermez. +Not: ECC tarafından yönetilen kurulumlar `~/.claude/settings.json` içinde `"includeCoAuthoredBy": false` ayarlar, bu nedenle commitler varsayılan olarak `Co-Authored-By` içermez. Claude atfını korumak için `"includeCoAuthoredBy": true` veya `attribution` ayarlayın; ECC açık bir tercihin üzerine asla yazmaz. ## Pull Request İş Akışı diff --git a/docs/zh-CN/rules/common/git-workflow.md b/docs/zh-CN/rules/common/git-workflow.md index a575ee137..4cb6f079a 100644 --- a/docs/zh-CN/rules/common/git-workflow.md +++ b/docs/zh-CN/rules/common/git-workflow.md @@ -10,7 +10,7 @@ 类型:feat, fix, refactor, docs, test, chore, perf, ci -注意:若要禁用共同作者归因,请在 `~/.claude/settings.json` 中设置 `"includeCoAuthoredBy": false`;Claude Code 默认会附加 `Co-Authored-By`,而 ECC 不会附带此设置。 +注意:ECC 管理的安装会在 `~/.claude/settings.json` 中设置 `"includeCoAuthoredBy": false`,因此提交默认不带 `Co-Authored-By`。若要保留 Claude 的归因,请设置 `"includeCoAuthoredBy": true` 或配置 `attribution`;ECC 不会覆盖用户的显式选择。 ## 拉取请求工作流程 diff --git a/docs/zh-TW/rules/git-workflow.md b/docs/zh-TW/rules/git-workflow.md index 415a6b491..8c5dbb3ad 100644 --- a/docs/zh-TW/rules/git-workflow.md +++ b/docs/zh-TW/rules/git-workflow.md @@ -10,7 +10,7 @@ 類型:feat、fix、refactor、docs、test、chore、perf、ci -注意:若要停用共同作者歸屬,請在 `~/.claude/settings.json` 中設定 `"includeCoAuthoredBy": false`;Claude Code 預設會附加 `Co-Authored-By`,而 ECC 不會隨附這個設定。 +注意:ECC 管理的安裝會在 `~/.claude/settings.json` 中設定 `"includeCoAuthoredBy": false`,因此提交預設不會附帶 `Co-Authored-By`。若要保留 Claude 的歸屬,請設定 `"includeCoAuthoredBy": true` 或設定 `attribution`;ECC 不會覆寫使用者的明確選擇。 ## Pull Request 工作流程 diff --git a/rules/common/git-workflow.md b/rules/common/git-workflow.md index 304fba798..29a72e2ae 100644 --- a/rules/common/git-workflow.md +++ b/rules/common/git-workflow.md @@ -9,7 +9,7 @@ Types: feat, fix, refactor, docs, test, chore, perf, ci -Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting). +Note: ECC-managed installs set `"includeCoAuthoredBy": false` in `~/.claude/settings.json`, so commits carry no `Co-Authored-By` trailer by default. To keep Claude attribution, set `"includeCoAuthoredBy": true` or configure `attribution`; ECC never overwrites an explicit choice. ## Pull Request Workflow diff --git a/scripts/lib/claude-commit-attribution.js b/scripts/lib/claude-commit-attribution.js new file mode 100644 index 000000000..cac44d52a --- /dev/null +++ b/scripts/lib/claude-commit-attribution.js @@ -0,0 +1,43 @@ +'use strict'; + +// Claude Code appends a `Co-Authored-By` trailer to commits and PRs unless the +// user opts out, so ECC-managed installs default that off. +// +// Two settings control the trailer. `attribution: { commit, pr }` is the current +// one and wins when set; `includeCoAuthoredBy` is deprecated as of Claude Code +// 2.1.x but still honored, and is the only one older versions understand. We +// write the deprecated key because unknown keys fail settings validation, so +// writing `attribution` would break users on older Claude Code. Either key being +// present counts as a deliberate user choice that ECC must not overwrite. +const COAUTHOR_SETTING_KEY = 'includeCoAuthoredBy'; + +function hasExplicitCommitAttributionPreference(settings) { + if (!settings || typeof settings !== 'object') { + return false; + } + if (typeof settings[COAUTHOR_SETTING_KEY] === 'boolean') { + return true; + } + + const attribution = settings.attribution; + return Boolean(attribution) + && typeof attribution === 'object' + && !Array.isArray(attribution) + && (attribution.commit !== undefined || attribution.pr !== undefined); +} + +function withCommitAttributionDisabled(settings) { + if (hasExplicitCommitAttributionPreference(settings)) { + return settings; + } + return { + ...settings, + [COAUTHOR_SETTING_KEY]: false, + }; +} + +module.exports = { + COAUTHOR_SETTING_KEY, + hasExplicitCommitAttributionPreference, + withCommitAttributionDisabled, +}; diff --git a/scripts/lib/claude-plugin-setup.js b/scripts/lib/claude-plugin-setup.js index 45fe3a9e0..ac1bdd4aa 100644 --- a/scripts/lib/claude-plugin-setup.js +++ b/scripts/lib/claude-plugin-setup.js @@ -5,6 +5,10 @@ const path = require('path'); const { spawnSync } = require('child_process'); const { writeFileAtomic } = require('./atomic-write'); +const { + hasExplicitCommitAttributionPreference, + withCommitAttributionDisabled, +} = require('./claude-commit-attribution'); const { normalizeGitHubGitOrigin } = require('./github-origin'); const { CURRENT_PLUGIN_ID, @@ -320,21 +324,32 @@ function deriveHookMode(settings) { return options.hooks_enabled ? options.hook_profile : 'off'; } +function withClaudeCommitAttributionPreference(settings) { + return withCommitAttributionDisabled(settings); +} + +function needsClaudeCommitAttributionPreferenceWrite(settings) { + return !hasExplicitCommitAttributionPreference(settings); +} + function writeClaudePluginOptions(settingsPath, hooks) { const settings = readSettings(settingsPath); const pluginConfigs = settings.pluginConfigs || {}; const eccConfig = pluginConfigs[CURRENT_PLUGIN_ID] || {}; const options = eccConfig.options || {}; + const nextOptions = hooks === undefined + ? { ...options } + : { + ...options, + ...hookOptions(hooks), + }; const nextSettings = { - ...settings, + ...withClaudeCommitAttributionPreference(settings), pluginConfigs: { ...pluginConfigs, [CURRENT_PLUGIN_ID]: { ...eccConfig, - options: { - ...options, - ...hookOptions(hooks), - }, + options: nextOptions, }, }, }; @@ -609,8 +624,15 @@ function setupClaudePlugin(options = {}, dependencies = {}) { run, scope: inventory.scope, }); - if (options.hooks !== undefined || !inventory.installed) { - writeClaudePluginOptions(settingsPath, hooks); + const hooksToPersist = options.hooks !== undefined || !inventory.installed + ? hooks + : undefined; + if ( + options.hooks !== undefined + || !inventory.installed + || needsClaudeCommitAttributionPreferenceWrite(initialSettings) + ) { + writeClaudePluginOptions(settingsPath, hooksToPersist); } return { @@ -648,5 +670,7 @@ module.exports = { runClaude, setupClaudePlugin, verifyPluginAtScope, + needsClaudeCommitAttributionPreferenceWrite, + withClaudeCommitAttributionPreference, writeClaudePluginOptions, }; diff --git a/scripts/lib/claude-scope-migration.js b/scripts/lib/claude-scope-migration.js index ae7b926a6..8c442685f 100644 --- a/scripts/lib/claude-scope-migration.js +++ b/scripts/lib/claude-scope-migration.js @@ -16,6 +16,7 @@ const { ensurePluginAtScope, hookOptions, isOfficialMarketplace, + needsClaudeCommitAttributionPreferenceWrite, parseMarketplaceList, parsePluginList, readSettings, @@ -264,6 +265,7 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) { const hookConfiguration = options.hooks === undefined ? readStoredHookOptions(settings) : hookOptions(options.hooks); + const needsCommitAttributionPreference = needsClaudeCommitAttributionPreferenceWrite(settings); const marketplaces = parseMarketplaceList( run( @@ -296,14 +298,23 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) { ...result, dryRun: true, preferencesUpdated: false, - plannedActions: options.hooks === undefined ? [] : [{ - action: 'write-hook-preferences', - ...hookConfiguration, - }], + plannedActions: [ + ...(options.hooks === undefined ? [] : [{ + action: 'write-hook-preferences', + ...hookConfiguration, + }]), + ...(needsCommitAttributionPreference ? [{ + action: 'write-commit-attribution-preference', + includeCoAuthoredBy: false, + }] : []), + ], }; } - if (options.hooks !== undefined) { - writeClaudePluginOptions(settingsPath, options.hooks); + if (options.hooks !== undefined || needsCommitAttributionPreference) { + writeClaudePluginOptions( + settingsPath, + options.hooks !== undefined ? options.hooks : undefined + ); return { ...result, preferencesUpdated: true }; } return result; @@ -371,8 +382,11 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) { const warnings = uninstallSource(run, paths, migration, options.scope); verifyFinalState(run, paths, options.scope); - if (options.hooks !== undefined) { - writeClaudePluginOptions(settingsPath, options.hooks); + if (options.hooks !== undefined || needsCommitAttributionPreference) { + writeClaudePluginOptions( + settingsPath, + options.hooks !== undefined ? options.hooks : undefined + ); } const result = { diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 659ad18eb..91c70e7fe 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -4,6 +4,10 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); +const { + hasExplicitCommitAttributionPreference, + withCommitAttributionDisabled, +} = require('../claude-commit-attribution'); const { writeInstallState } = require('../install-state'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); const { assertWithinTrustedRoot } = require('../path-safety'); @@ -105,6 +109,52 @@ function formatJson(value) { return `${JSON.stringify(value, null, 2)}\n`; } +function shouldSetClaudeCommitAttributionPreference(plan) { + if (!plan?.adapter || !['claude', 'claude-project'].includes(plan.adapter.target)) { + return false; + } + + return plan.operations.some(operation => { + if (typeof operation?.destinationPath !== 'string') { + return false; + } + const relativePath = path.relative(plan.targetRoot, operation.destinationPath); + return relativePath && !relativePath.startsWith(`docs${path.sep}`) && relativePath !== 'docs'; + }); +} + +function writeClaudeCommitAttributionPreference(settingsPath) { + // Read once rather than probing with existsSync first. Checking for the file and + // then writing it is a file system race (CodeQL js/file-system-race), and a + // missing file is simply the fresh-install case. + let settings; + try { + settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + } catch (error) { + if (error.code !== 'ENOENT') { + // Unreadable or malformed settings belong to the user; leave them untouched. + return false; + } + settings = {}; + } + + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return false; + } + + if (hasExplicitCommitAttributionPreference(settings)) { + return false; + } + + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + formatJson(withCommitAttributionDisabled(settings)), + 'utf8' + ); + return true; +} + function replacePluginRootPlaceholders(value, pluginRoot) { if (!pluginRoot) { return value; @@ -325,6 +375,11 @@ function applyInstallPlan(plan, dependencies = {}) { if (hasLegacyMigration) { removeLegacyClaudeSkillFiles(migration, plan.targetRoot); } + + if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) { + writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json')); + } + const finalState = stateWithContentDigests(migration.finalState); if (typeof beforeInstallStateWrite === 'function') { beforeInstallStateWrite({ plan: appliedPlan, state: finalState }); diff --git a/tests/lib/claude-commit-attribution.test.js b/tests/lib/claude-commit-attribution.test.js new file mode 100644 index 000000000..60255757f --- /dev/null +++ b/tests/lib/claude-commit-attribution.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const assert = require('assert'); +const { + hasExplicitCommitAttributionPreference, + withCommitAttributionDisabled, +} = require('../../scripts/lib/claude-commit-attribution'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +console.log('\nClaude commit attribution preference'); + +if (test('treats absent settings as unconfigured', () => { + assert.strictEqual(hasExplicitCommitAttributionPreference(undefined), false); + assert.strictEqual(hasExplicitCommitAttributionPreference(null), false); + assert.strictEqual(hasExplicitCommitAttributionPreference({}), false); + assert.strictEqual(hasExplicitCommitAttributionPreference({ theme: 'dark' }), false); +})) passed++; else failed++; + +if (test('treats either includeCoAuthoredBy boolean as an explicit choice', () => { + assert.strictEqual(hasExplicitCommitAttributionPreference({ includeCoAuthoredBy: true }), true); + assert.strictEqual(hasExplicitCommitAttributionPreference({ includeCoAuthoredBy: false }), true); +})) passed++; else failed++; + +if (test('treats a configured attribution as an explicit choice', () => { + // `attribution` supersedes `includeCoAuthoredBy` in Claude Code, so a user who + // set it has already decided and ECC must not write a key that loses to it. + assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: { commit: '' } }), true); + assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: { pr: '' } }), true); + assert.strictEqual( + hasExplicitCommitAttributionPreference({ attribution: { commit: 'Co-Authored-By: Someone ' } }), + true + ); +})) passed++; else failed++; + +if (test('ignores attribution values that carry no commit or pr choice', () => { + assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: {} }), false); + assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: null }), false); + assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: [] }), false); + assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: 'off' }), false); + assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: { sessionUrl: false } }), false); +})) passed++; else failed++; + +if (test('disables attribution while preserving unrelated settings', () => { + assert.deepStrictEqual( + withCommitAttributionDisabled({ theme: 'dark' }), + { theme: 'dark', includeCoAuthoredBy: false } + ); + assert.deepStrictEqual(withCommitAttributionDisabled({}), { includeCoAuthoredBy: false }); +})) passed++; else failed++; + +if (test('returns explicit settings unchanged', () => { + const optIn = { includeCoAuthoredBy: true }; + assert.strictEqual(withCommitAttributionDisabled(optIn), optIn); + + const alreadyOff = { includeCoAuthoredBy: false }; + assert.strictEqual(withCommitAttributionDisabled(alreadyOff), alreadyOff); + + const customAttribution = { attribution: { commit: 'Signed-off-by: Someone ' } }; + assert.strictEqual(withCommitAttributionDisabled(customAttribution), customAttribution); +})) passed++; else failed++; + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/claude-plugin-setup.test.js b/tests/lib/claude-plugin-setup.test.js index 99efa54c0..bd7ed2db1 100644 --- a/tests/lib/claude-plugin-setup.test.js +++ b/tests/lib/claude-plugin-setup.test.js @@ -260,6 +260,7 @@ test('an existing single-scope install defaults to its detected scope', () => { ['plugin', 'list', '--json'], ]); const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); + assert.strictEqual(settings.includeCoAuthoredBy, false); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'minimal'); }); }); @@ -345,6 +346,7 @@ test('same-scope repeat setup updates ECC and changes durable user hook preferen setupClaudePlugin(setupOptions(fixture, { scope: 'local', hooks: 'off' })); const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); assert.strictEqual(settings.theme, 'dark'); + assert.strictEqual(settings.includeCoAuthoredBy, false); assert.deepStrictEqual(settings.pluginConfigs['another@market'], { enabled: false }); assert.deepStrictEqual(settings.pluginConfigs['ecc@ecc'].futureKey, { keep: true }); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.unknown, 'keep'); @@ -373,11 +375,63 @@ test('repeat setup preserves the current hook preference when --hooks is omitted const result = setupClaudePlugin(setupOptions(fixture, { hooks: undefined })); const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); assert.strictEqual(result.hooks, 'off'); + assert.strictEqual(settings.includeCoAuthoredBy, false); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, false); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict'); }); }); +test('setup preserves an explicit includeCoAuthoredBy opt-in', () => { + withFixture({ + plugins: [installedPlugin('user')], + marketplaces: [officialMarketplace('user')], + }, fixture => { + fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({ + includeCoAuthoredBy: true, + pluginConfigs: { + 'ecc@ecc': { + options: { + hooks_enabled: true, + hook_profile: 'minimal', + }, + }, + }, + }, null, 2)}\n`); + + setupClaudePlugin(setupOptions(fixture, { hooks: 'strict' })); + const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); + assert.strictEqual(settings.includeCoAuthoredBy, true); + assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict'); + }); +}); + +test('setup preserves an explicit attribution opt-in', () => { + withFixture({ + plugins: [installedPlugin('user')], + marketplaces: [officialMarketplace('user')], + }, fixture => { + // `attribution` wins over `includeCoAuthoredBy` in Claude Code, so ECC must not + // add a deprecated key that would silently lose to the user's own setting. + fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({ + attribution: { commit: 'Signed-off-by: Someone ' }, + pluginConfigs: { + 'ecc@ecc': { + options: { + hooks_enabled: true, + hook_profile: 'minimal', + }, + }, + }, + }, null, 2)}\n`); + + setupClaudePlugin(setupOptions(fixture, { hooks: 'strict' })); + const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); + assert.strictEqual(settings.includeCoAuthoredBy, undefined); + assert.deepStrictEqual(settings.attribution, { commit: 'Signed-off-by: Someone ' }); + assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict'); + }); +}); + test('malformed user settings fail preflight without provider mutation or corruption', () => { withFixture({}, fixture => { const malformed = '{"theme":'; diff --git a/tests/lib/claude-scope-migration.test.js b/tests/lib/claude-scope-migration.test.js index f7fe98f59..3936afe8b 100644 --- a/tests/lib/claude-scope-migration.test.js +++ b/tests/lib/claude-scope-migration.test.js @@ -321,11 +321,17 @@ test('destination-only migration honors explicit hook preferences and reports dr assert.strictEqual(result.action, 'already-migrated'); assert.strictEqual(result.dryRun, true); assert.strictEqual(result.preferencesUpdated, false); - assert.deepStrictEqual(result.plannedActions, [{ - action: 'write-hook-preferences', - hooks_enabled: false, - hook_profile: 'standard', - }]); + assert.deepStrictEqual(result.plannedActions, [ + { + action: 'write-hook-preferences', + hooks_enabled: false, + hook_profile: 'standard', + }, + { + action: 'write-commit-attribution-preference', + includeCoAuthoredBy: false, + }, + ]); assert.ok(!fs.existsSync(fixture.settingsPath)); }); }); @@ -614,7 +620,10 @@ test('migration preserves hook preferences unless --hooks is explicit', () => { assert.strictEqual(result.hooks, 'off'); assert.deepStrictEqual( JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')), - original + { + ...original, + includeCoAuthoredBy: false, + } ); assert.ok(readCalls(fixture).some(argv => ( JSON.stringify(argv) === JSON.stringify(installArgv('project', 'off', 'strict')) @@ -637,6 +646,7 @@ test('migration preserves hook preferences unless --hooks is explicit', () => { migrateClaudePluginScope(migrationOptions(fixture, 'project', { hooks: 'strict' })); const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); assert.strictEqual(settings.theme, 'dark'); + assert.strictEqual(settings.includeCoAuthoredBy, false); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].futureKey, true); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.unknown, 'keep'); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true); @@ -644,5 +654,26 @@ test('migration preserves hook preferences unless --hooks is explicit', () => { }); }); +test('migration preserves an explicit includeCoAuthoredBy opt-in', () => { + withFixture({ + plugins: [plugin('user')], + marketplaces: [marketplace('user')], + }, fixture => { + fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({ + includeCoAuthoredBy: true, + pluginConfigs: { + 'ecc@ecc': { + options: { hooks_enabled: true, hook_profile: 'minimal' }, + }, + }, + }, null, 2)}\n`); + + migrateClaudePluginScope(migrationOptions(fixture, 'project', { hooks: 'strict' })); + const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); + assert.strictEqual(settings.includeCoAuthoredBy, true); + assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict'); + }); +}); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 970c016e1..d8b4ec516 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -681,7 +681,7 @@ function runTests() { assert.ok(result.stderr.includes('Unknown install module: ghost-module')); })) passed++; else failed++; - if (test('installs claude hooks without generating settings.json', () => { + if (test('installs claude hooks and defaults commit attribution off', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -691,7 +691,10 @@ function runTests() { const claudeRoot = path.join(homeDir, '.claude'); assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied'); - assert.ok(!fs.existsSync(path.join(claudeRoot, 'settings.json')), 'settings.json should not be created just to install managed hooks'); + assert.deepStrictEqual( + readJson(path.join(claudeRoot, 'settings.json')), + { includeCoAuthoredBy: false } + ); } finally { cleanup(homeDir); cleanup(projectDir); @@ -742,7 +745,7 @@ function runTests() { } })) passed++; else failed++; - if (test('preserves existing settings.json without mutating it during claude install', () => { + if (test('preserves existing settings.json while disabling Claude co-author attribution', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -766,6 +769,7 @@ function runTests() { const settings = readJson(path.join(claudeRoot, 'settings.json')); assert.strictEqual(settings.effortLevel, 'high', 'existing effortLevel should be preserved'); + assert.strictEqual(settings.includeCoAuthoredBy, false, 'Claude co-author attribution should be disabled by default'); assert.deepStrictEqual(settings.env, { MY_VAR: '1' }, 'existing env should be preserved'); assert.deepStrictEqual( settings.hooks.UserPromptSubmit, @@ -857,7 +861,7 @@ function runTests() { } })) passed++; else failed++; - if (test('reinstall does not create settings.json when only managed hooks are installed', () => { + if (test('reinstall keeps commit attribution disabled when only managed hooks are installed', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -868,14 +872,17 @@ function runTests() { const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir }); assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); - assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'settings.json'))); + assert.deepStrictEqual( + readJson(path.join(homeDir, '.claude', 'settings.json')), + { includeCoAuthoredBy: false } + ); } finally { cleanup(homeDir); cleanup(projectDir); } })) passed++; else failed++; - if (test('reinstall leaves pre-existing hook-based settings.json untouched', () => { + if (test('reinstall leaves pre-existing hook-based settings.json untouched apart from co-author preference', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -894,7 +901,62 @@ function runTests() { assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); const afterSecondInstall = readJson(settingsPath); - assert.deepStrictEqual(afterSecondInstall, legacySettings); + assert.deepStrictEqual(afterSecondInstall, { + ...legacySettings, + includeCoAuthoredBy: false, + }); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('reinstall preserves an explicit includeCoAuthoredBy opt-in', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const claudeRoot = path.join(homeDir, '.claude'); + fs.mkdirSync(claudeRoot, { recursive: true }); + const settingsPath = path.join(claudeRoot, 'settings.json'); + const customSettings = { + includeCoAuthoredBy: true, + theme: 'dark', + }; + fs.writeFileSync(settingsPath, JSON.stringify(customSettings, null, 2)); + + const install = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(install.code, 0, install.stderr); + + const afterInstall = readJson(settingsPath); + assert.deepStrictEqual(afterInstall, customSettings); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('reinstall preserves an explicit attribution opt-in', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const claudeRoot = path.join(homeDir, '.claude'); + fs.mkdirSync(claudeRoot, { recursive: true }); + const settingsPath = path.join(claudeRoot, 'settings.json'); + // `attribution` supersedes `includeCoAuthoredBy` in Claude Code, so writing + // the deprecated key here would be dead config that loses to the user's choice. + const customSettings = { + attribution: { commit: 'Signed-off-by: Someone ' }, + theme: 'dark', + }; + fs.writeFileSync(settingsPath, JSON.stringify(customSettings, null, 2)); + + const install = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(install.code, 0, install.stderr); + + const afterInstall = readJson(settingsPath); + assert.deepStrictEqual(afterInstall, customSettings); } finally { cleanup(homeDir); cleanup(projectDir);