From 6faaf3af7478ea1d4d64deae7a21a4947450d683 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:17:04 -0700 Subject: [PATCH] fix(lint): match moved code by ordered near-match, not strict contiguity (#16385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A diagnostic's span often reaches past the block a split moved — most commonly to a hook dependency array, which legitimately grows when closure variables become props. Requiring every line of the span to match contiguously reported the moved body as new. The block must still start at the same line in the base and appear in order, and >=90% of it must be present. Genuinely new code shares neither the anchor nor the ordering. --- config/scripts/check-changed-code-quality.mjs | 29 ++++++++++++++----- .../check-changed-code-quality.test.mjs | 19 ++++++++++-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index bc661dc67b7..5871557607b 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -178,17 +178,32 @@ export function isMovedCode(highlightedLines, baseBlocks) { if (needle.length === 0) { return false } + // Why a near-match rather than an exact contiguous one: a split moves a block + // verbatim but a diagnostic's span often reaches past it — most commonly to a + // hook dependency array, which legitimately grows when closure variables become + // props. Requiring every line to match would report the moved body as new. So: + // the block must still start at the same line in the base and appear IN ORDER, + // and nearly all of it must be present. Genuinely new code shares neither the + // anchor nor the ordering, so it stays reported. + const MIN_COVERAGE = 0.9 return baseBlocks.some((rawHaystack) => { const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '') - for (let start = 0; start + needle.length <= haystack.length; start += 1) { - let matched = true - for (let offset = 0; offset < needle.length; offset += 1) { - if (haystack[start + offset] !== needle[offset]) { - matched = false - break + for (let start = 0; start < haystack.length; start += 1) { + if (haystack[start] !== needle[0]) { + continue + } + let matched = 1 + let cursor = start + 1 + for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) { + while (cursor < haystack.length && haystack[cursor] !== needle[index]) { + cursor += 1 + } + if (cursor < haystack.length) { + matched += 1 + cursor += 1 } } - if (matched) { + if (matched / needle.length >= MIN_COVERAGE) { return true } } diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index 73cc12a78c6..76a25802e5c 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -75,9 +75,24 @@ describe('moved-code exemption', () => { expect(isMovedCode(['doThing()', 'newlyAddedSideEffect()'], base)).toBe(false) }) - it('does not exempt when the base lines are non-contiguous', () => { + it('tolerates a few lines appended inside the moved block', () => { + // A split commonly grows a hook dependency array when closure variables + // become props; the moved body around it is still moved. + const body = Array.from({ length: 20 }, (_, i) => `line${i}()`) + const base = [body] + const moved = [...body.slice(0, 19), 'newDep,', body[19]] + expect(isMovedCode(moved, base)).toBe(true) + }) + + it('does not exempt when the anchor line is absent from the base', () => { const base = [['doThing()', 'filler()', 'other()']] - expect(isMovedCode(['doThing()', 'other()'], base)).toBe(false) + expect(isMovedCode(['brandNewCall()', 'doThing()', 'other()'], base)).toBe(false) + }) + + it('does not exempt when most of the block is absent from the base', () => { + const base = [['keep0()', 'keep1()', 'unrelated()']] + const mostlyNew = ['keep0()', ...Array.from({ length: 18 }, (_, i) => `fresh${i}()`)] + expect(isMovedCode(mostlyNew, base)).toBe(false) }) it('ignores blank lines when matching', () => {