mirror of
https://github.com/awesome-dsh-plugin/awesome-dsh-plugin.git
synced 2026-09-28 13:23:16 +08:00
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.
95 lines
3.7 KiB
JavaScript
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(' |