Files
fkysly 322ae98c30 Restore an entry a PR overwrote, and surface that class of edit
Reported in #1348. A PR whose stated purpose was refreshing the dsh-notifier
entry also appended dsh-notifier's new sentence to AKS1st/dsh-cyber-particle,
an unrelated plugin. It happened twice — v0.6.2 and again v0.7.1 — and both
times every check passed: valid YAML, READMEs regenerated, lint clean. The
author's own entry never received the Chinese update at all; both edits landed
on someone else's line.

cyber-particle's description is restored to the text it was submitted with.

The mechanism is the one the YAML migration exists to kill: hand-editing
README.zh.md against a stale checkout, where line positions have shifted and
the edit hits a neighbour. Two guards added, and it is worth being precise
about what each does:

- pr-gate now lists every *existing* entry a PR modifies, in the check output.
  That is the signal that would have caught this — a reviewer sees a
  dsh-notifier PR rewriting dsh-cyber-particle and asks why.
- scripts/check-bleed.mjs flags two entries sharing 40+ characters of
  description. This would NOT have caught #1348: the text was misplaced, not
  duplicated, so it existed in only one entry. It covers the adjacent case of
  copied text, reports rather than blocks, and skips same-owner pairs since
  sibling plugins share boilerplate by design. Five pairs on the current list,
  all genuine similarity.

The review checklist in contributing.md gains the question directly, plus a
note asking contributors to change only their own entry.
2026-08-17 17:05:28 +08:00

95 lines
3.7 KiB
JavaScript

// Detect one entry's description bleeding into another's.
//
// Reported in #1348: a PR whose stated purpose was refreshing the dsh-notifier
// entry also appended dsh-notifier's new sentence to AKS1st/dsh-cyber-particle,
// an unrelated plugin. It happened twice, and every existing check passed both
// times — the YAML was valid, the READMEs regenerated, lint was clean. Nothing
// looked at whether a PR touched entries it had no business touching.
//
// node scripts/check-bleed.mjs # scan the whole list
// node scripts/check-bleed.mjs --base <sha> # only entries this PR changed
//
// Two entries sharing a long run of description text is the fingerprint. It is
// reported, not enforced: similar plugins legitimately get described similarly
// (two web-search plugins, two notification-sound plugins), so this is a signal
// for a reviewer rather than a gate. Same-owner pairs are skipped — sibling
// plugins share boilerplate by design.
import { execSync } from 'node:child_process'
import path from 'node:path'
import { LOCALE_CODES, PLUGINS_DIR, readEntries } from './lib/entries.mjs'
const arg = (n) => {
const i = process.argv.indexOf(n)
return i === -1 ? null : process.argv[i + 1]
}
const BASE = arg('--base')
// Long enough that shared prose is meaningful rather than a common phrase.
// At 40, a full pass over ~1170 entries surfaced five pairs, all genuine
// similarity; the real pollution shared far more than that.
const RUN = 40
const ownerOf = (url) => url.replace(/^https:\/\/github\.com\//, '').split('/')[0]
const squash = (s) => s.replace(/\s+/g, '')
function runsOf(text) {
const t = squash(text)
const out = new Set()
for (let i = 0; i + RUN <= t.length; i++) out.add(t.slice(i, i + RUN))
return out
}
let changed = null
if (BASE) {
try {
const out = execSync(`git diff --name-only --diff-filter=d ${BASE}...HEAD -- ${PLUGINS_DIR}`, { encoding: 'utf8' })
changed = new Set(out.split('\n').map((s) => s.trim()).filter(Boolean))
} catch (e) {
console.error(`could not diff against ${BASE} (${e.message}) — scanning everything`)
}
}
const entries = readEntries()
const index = new Map() // run -> [{file, url, loc}]
for (const e of entries) {
for (const loc of LOCALE_CODES) {
const d = e.description?.[loc]
if (typeof d !== 'string') continue
for (const r of runsOf(d)) {
if (!index.has(r)) index.set(r, [])
index.get(r).push({ file: e.file, url: e.url, loc })
}
}
}
const pairs = new Map()
for (const [run, hits] of index) {
const files = [...new Set(hits.map((h) => h.file))]
if (files.length < 2) continue
for (let i = 0; i < files.length; i++) {
for (let j = i + 1; j < files.length; j++) {
const a = entries.find((e) => e.file === files[i])
const b = entries.find((e) => e.file === files[j])
if (ownerOf(a.url) === ownerOf(b.url)) continue // siblings share boilerplate
// When scoped to a PR, only care if the PR actually touched one of them.
if (changed && !changed.has(a.file) && !changed.has(b.file)) continue
const key = [a.file, b.file].sort().join('')
if (!pairs.has(key)) pairs.set(key, { a, b, run })
}
}
}
if (!pairs.size) {
console.log(changed ? 'no cross-entry text bleed among the entries this PR changed' : 'no cross-entry text bleed found')
process.exit(0)
}
console.log(`${pairs.size} entry pair(s) share ${RUN}+ characters of description:\n`)
for (const { a, b, run } of pairs.values()) {
console.log(` ${path.basename(a.file)}`)
console.log(` ${path.basename(b.file)}`)
console.log(` shared: ${run.slice(0, 60)}…\n`)
}
console.log('Similar plugins are often described similarly — check whether one entry')
console.log("picked up another's text by accident before treating this as a defect.")