fix(hooks): never format installed plugin and marketplace clones (#2667)

The Stop hook formats every JS/TS file edited during a response, grouped by the project root each file happens to sit in. That includes trees under .claude/plugins, which are third-party checkouts we only read.

Formatting them writes to code the user does not own. It also does real damage when a repo's committed code has drifted from its own formatter config: the rewrite is not a no-op but a wholesale reformat, so an unrelated bugfix ends up carrying hundreds of untouched lines. I hit this contributing to this repo — a 162-line fix arrived as a 478-line diff, most of it reformatted code the change never went near.

Skips both the user-level install root and a project-local one, mirroring the lookup in scripts/harness-audit.js. Paths are resolved before the prefix comparison, and a sibling such as .claude/plugins-backup does not match. The user own .claude config outside plugins is still formatted.

Adds 7 tests for the predicate, plus an end-to-end check that a clone file listed in the accumulator is left byte-identical. Suite 16 to 23.

Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
This commit is contained in:
Peopleoftech
2026-08-04 17:14:37 -04:00
committed by GitHub
co-authored by haelyra
parent a8c6da485d
commit 7a5757e6c0
2 changed files with 67 additions and 2 deletions
+26 -1
View File
@@ -37,6 +37,29 @@ function parseAccumulator(raw) {
return [...new Set(raw.split('\n').map(l => l.trim()).filter(Boolean))];
}
/**
* Is this file part of an installed plugin or marketplace clone?
*
* Those trees are third-party checkouts we merely read. Formatting them writes
* to code the user does not own, and when a repo's committed code has drifted
* from its own formatter config the rewrite is large: an unrelated bugfix ends
* up carrying hundreds of reformatted lines it never touched, which is enough
* to sink the contribution it was meant to support.
*
* Checks both a project-local install root and the user-level one, mirroring
* the lookup in scripts/harness-audit.js.
*/
function isPluginClonePath(filePath, cwd = process.cwd(), homeDir = os.homedir()) {
const resolved = path.resolve(filePath);
const roots = [path.join(cwd, '.claude', 'plugins')];
if (homeDir) roots.push(path.join(homeDir, '.claude', 'plugins'));
return roots.some(root => {
const rel = path.relative(root, resolved);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
});
}
function getAccumFile() {
const raw =
process.env.CLAUDE_SESSION_ID ||
@@ -151,6 +174,7 @@ function main() {
const byProjectRoot = new Map();
for (const filePath of files) {
if (!/\.(ts|tsx|js|jsx)$/.test(filePath)) continue;
if (isPluginClonePath(filePath)) continue;
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue;
const root = findProjectRoot(path.dirname(resolved));
@@ -161,6 +185,7 @@ function main() {
const byTsConfigDir = new Map();
for (const filePath of files) {
if (!/\.(ts|tsx)$/.test(filePath)) continue;
if (isPluginClonePath(filePath)) continue;
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue;
const tsDir = findTsConfigDir(resolved);
@@ -223,4 +248,4 @@ if (require.main === module) {
});
}
module.exports = { run, parseAccumulator };
module.exports = { run, parseAccumulator, isPluginClonePath };