Files
fkysly d9a53bf020 decay-scan: separate "subpath gone" from "dsh.bundle removed"
Both reported as `dsh.bundle no longer found in any package.json`, and
the label is wrong for one of them in a way that costs a week.

A missing package.json at an entry's subpath means the DIRECTORY moved —
the author restructured and the bundle is usually alive one directory
over. The fix is repointing url/name/filename, which costs the author
nothing. A package.json that IS there but no longer declares dsh.bundle
is the real unbundling, and that one needs a deadline.

baosfeng's two entries sat under the wrong label for a week: they were
only `plugins/dsh-notify` → `plugins/dsh-my-notify`. I found them by
hand today while chasing rotten tarballs, not from the report that was
already carrying them.

Verified with a full dry-run over all 3,632 entries, 0 inconclusive:

  Subpath gone (repo alive, directory moved)   4
  `dsh.bundle` removed                         1

Before this change all five would have shared one section. The two I
repointed earlier today (ReMe, capital-generation) correctly no longer
appear at all.

The new section sorts above `unbundled` because it is the one a
maintainer can usually clear the same day, and the issue body now spells
out the whole repoint procedure — pin the new URL in added-dates.json,
and check screenshots.json and stars.json for keys still on the old URL.
A stale screenshots key fails the build outright; that one bit me today.
2026-09-13 17:40:19 +08:00

389 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Weekly decay scan over every listed entry. Flags — never removes:
//
// gone repo 404
// archived repo archived
// dormant no push in DORMANT_MONTHS months
// subpath-gone the entry's subdirectory is gone (repo alive, moved)
// unbundled dsh.bundle no longer found anywhere in the tree
//
// Findings go into one tracking issue (created or updated in place, matched
// by title) for a maintainer to review; removal stays a human decision
// because it is irreversible and a scan can be wrong — an outage, a rename,
// a rate-limit blip. Anything inconclusive (API errors) is skipped, not
// flagged: a decay report must only ever contain evidence, not doubt.
//
// GITHUB_TOKEN=... node scripts/scan-decay.mjs # scan + issue
// GITHUB_TOKEN=... node scripts/scan-decay.mjs --dry-run # scan, print only
// GITHUB_TOKEN=... node scripts/scan-decay.mjs --limit=50 # first N entries
//
// Budget: repository metadata goes through GraphQL in batches of fifty (one
// point each), and manifests are read from raw.githubusercontent.com, which
// does not count against the quota. A full pass costs on the order of a
// hundred REST calls rather than the ~4,900 it used to — which matters because
// the Actions GITHUB_TOKEN this runs under in CI gets 1,000 per hour.
import { readEntries } from './lib/entries.mjs'
const DORMANT_MONTHS = 6
// A pass over the whole list is 4-5 calls per entry. At eight in flight that is
// a burst GitHub's secondary limiter answers with 403s — which, before the
// retry below existed, silently became "checked, healthy" for every entry
// caught in it. Four keeps a 1,700-entry pass under the limiter while still
// finishing in a few minutes.
const CONCURRENCY = 4
// GraphQL charges one point for a query however many repositories it names, so
// the batch size is bounded by response size and readability, not by cost.
const META_BATCH = 50
const ISSUE_TITLE = 'Decay scan: entries that need review'
const ISSUE_REPO = process.env.GITHUB_REPOSITORY ?? 'awesome-dsh-plugin/awesome-dsh-plugin'
const DRY = process.argv.includes('--dry-run')
const MAX_TREE_PKGS = 40
const TOKEN = process.env.GITHUB_TOKEN
if (!TOKEN) {
console.log('no GITHUB_TOKEN — skipping decay scan')
process.exit(0)
}
const HEADERS = { accept: 'application/vnd.github+json', authorization: `Bearer ${TOKEN}`, 'user-agent': 'awesome-dsh-plugin-decay-scan' }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// A full pass is several thousand calls, which trips GitHub's *secondary* rate
// limit — a 403 that arrives while `rate_limit` still reports the core quota
// untouched, so there is nothing to check before firing. Left unhandled it
// degrades the whole scan: every 403 became an unchecked entry that the report
// then presented as healthy. Back off and retry before giving up on an entry.
async function api(pathname, opts = {}) {
for (let attempt = 0; ; attempt++) {
const r = await fetch(`https://api.github.com/${pathname}`, { headers: HEADERS, signal: AbortSignal.timeout(20000), ...opts })
if (r.status === 404) return { status: 404 }
if (r.ok) return { status: 200, body: await r.json().catch(() => null) }
const retryable = r.status === 403 || r.status === 429 || r.status >= 500
if (!retryable || attempt >= 5) return { status: r.status }
const after = Number(r.headers.get('retry-after'))
await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 5000 * 2 ** attempt)
}
}
async function graphql(query) {
for (let attempt = 0; ; attempt++) {
const r = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: { ...HEADERS, 'content-type': 'application/json' },
body: JSON.stringify({ query }),
signal: AbortSignal.timeout(30000),
})
if (r.ok) return await r.json().catch(() => null)
const retryable = r.status === 403 || r.status === 429 || r.status >= 500
if (!retryable || attempt >= 5) return null
const after = Number(r.headers.get('retry-after'))
await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 5000 * 2 ** attempt)
}
}
/**
* Repository metadata for every listed repo, in batches.
*
* One REST call per repository was the other half of this scan's cost. GraphQL
* takes fifty per request and charges one point for the lot, so the whole list
* costs ~34 requests instead of ~1,650.
*
* Map values: `{gone}`, `{archived, pushedAt, branch}`, or null for
* inconclusive.
*/
async function fetchMeta(repos) {
const out = new Map()
for (let i = 0; i < repos.length; i += META_BATCH) {
const chunk = repos.slice(i, i + META_BATCH)
const query = `query {\n${chunk
.map((full, j) => {
const [owner, name] = full.split('/')
return ` r${j}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { isArchived pushedAt defaultBranchRef { name } }`
})
.join('\n')}\n}`
const res = await graphql(query)
const notFound = new Set()
for (const e of res?.errors ?? []) {
if (e?.type === 'NOT_FOUND' && Array.isArray(e.path)) notFound.add(e.path[0])
}
for (let j = 0; j < chunk.length; j++) {
const node = res?.data?.[`r${j}`]
if (node) {
out.set(chunk[j], {
archived: node.isArchived,
pushedAt: node.pushedAt,
branch: node.defaultBranchRef?.name ?? 'HEAD',
})
} else if (notFound.has(`r${j}`)) {
// Not resolvable by GraphQL is not the same as gone. REST follows a
// rename with a 301 and GraphQL is not documented to; flagging a
// renamed repository as a dead one would propose deleting a healthy
// entry. Confirm the handful of these against REST before believing it.
const meta = await api(`repos/${chunk[j]}`)
if (meta.status === 404) out.set(chunk[j], { gone: true })
else if (meta.status === 200) {
out.set(chunk[j], {
archived: meta.body.archived,
pushedAt: meta.body.pushed_at,
branch: meta.body.default_branch ?? 'HEAD',
})
} else out.set(chunk[j], null)
} else {
out.set(chunk[j], null)
}
}
console.log(` metadata ${Math.min(i + META_BATCH, repos.length)}/${repos.length}`)
}
return out
}
function decompose(url) {
const p = url.replace(/^https:\/\/github\.com\//, '').replace(/\/+$/, '')
return {
repo: p.split('/').slice(0, 2).join('/'),
sub: p.includes('/tree/') ? p.split('/tree/')[1].replace(/^[^/]+\//, '') : null,
}
}
// raw.githubusercontent.com serves the same file the contents API does and
// **does not count against the API quota at all** — the pattern probe-npm.mjs
// has used since it was written. Reading manifests through the contents API
// was what made this scan cost ~4,900 calls a pass: more than the 5,000/hour a
// user token gets, and five times the 1,000/hour the Actions GITHUB_TOKEN gets
// per repository. So the weekly run could never reach the end of the list.
//
// Returns the parsed manifest, `false` for a definite 404, or null when the
// fetch itself failed and nothing can be concluded.
async function manifest(repo, branch, path) {
for (let attempt = 0; ; attempt++) {
let r
try {
r = await fetch(`https://raw.githubusercontent.com/${repo}/${branch}/${path}`, {
headers: { 'user-agent': 'awesome-dsh-plugin-decay-scan' },
signal: AbortSignal.timeout(20000),
})
} catch {
if (attempt >= 3) return null
await sleep(2000 * 2 ** attempt)
continue
}
if (r.status === 404) return false
if (!r.ok) {
if (attempt >= 3) return null
await sleep(2000 * 2 ** attempt)
continue
}
const text = await r.text().catch(() => null)
if (text === null) return null
try {
// Strip the BOM before parsing. A package.json saved by a Windows editor
// starts U+FEFF, `JSON.parse` throws on it, and this used to be read as
// "no dsh.bundle" — three healthy entries (dsh-mcpguard,
// dsh-koboldcpp-hands, dsh-web-launcher) were flagged for removal over a
// byte order mark npm does not care about.
return JSON.parse(text.replace(/^/, ''))
} catch {
// A manifest that won't parse is a broken repository, not a manifest
// whose `dsh.bundle` was removed. This flag ends in a removal proposal,
// so it has to carry evidence of the thing it claims.
return null
}
}
}
/** true / false / null (inconclusive — never flag on null) */
async function hasBundle(repo, branch, sub) {
// An entry pointing at a subpackage is a claim about that subpackage. Its
// siblings' manifests say nothing about it: dsh-desktop-base and
// dsh-skill-explorer both sit in repositories with dozens of bundled
// packages, and neither is one of them.
// Two different failures hide behind one `false` here, and they need
// different handling, so tell them apart. A missing package.json at the
// subpath means the DIRECTORY is gone — the author restructured, and the
// bundle is usually alive and well one directory over. A package.json that
// is there but no longer declares dsh.bundle is the real unbundling.
//
// Reporting the first as "dsh.bundle no longer found in any package.json"
// sends the maintainer to open a deadline for the author when the actual fix
// is a one-line repoint that costs the author nothing. Both baosfeng
// subpaths sat on the wrong label for a week before someone chased them by
// hand: `plugins/dsh-notify` had simply become `plugins/dsh-my-notify`.
if (sub) {
const m = await manifest(repo, branch, `${sub}/package.json`)
if (m === false) return 'subpath-gone'
if (m === null) return null
return Boolean(m?.dsh?.bundle)
}
// The overwhelmingly common shape is one plugin, one manifest, at the root —
// and that costs zero API calls to settle.
const root = await manifest(repo, branch, 'package.json')
if (root === null) return null
if (root && root.dsh?.bundle) return true
// Only a repository whose root manifest does not carry the bundle is worth a
// tree call to find where it went.
const tree = await api(`repos/${repo}/git/trees/${branch}?recursive=1`)
if (tree.status !== 200) return null
// A recursive tree is capped by the API (~100k entries / 7MB) and says so with
// `truncated` while still returning 200. A partial listing, or one with more
// package.json files than the cap reads, is doubt, not evidence — reading it as
// "no bundle" would flag a healthy entry as unbundled. Inconclusive, not absent.
if (tree.body?.truncated) return null
const found = (tree.body?.tree ?? [])
.filter((t) => t.path?.endsWith('package.json') && t.path !== 'package.json')
.map((t) => t.path)
if (!found.length) return false
const pkgs = found.slice(0, MAX_TREE_PKGS)
// A manifest we could not read is not a manifest without a bundle. A
// transient failure or unparseable JSON used to fall through to "no bundle",
// so a scan that failed to look was indistinguishable from one that looked
// and found nothing — and the difference is a proposal to delist someone.
let unreadable = 0
for (const p of pkgs) {
const m = await manifest(repo, branch, p)
if (m === null) { unreadable++; continue }
if (m && m.dsh?.bundle) return true
}
if (found.length > pkgs.length || unreadable) return null
return false
}
// Returned when the scan could not reach a verdict. Distinct from `null`,
// which means "checked, and healthy". Conflating the two is how a scan that
// was rate-limited on 1,000 entries still printed "0 inconclusive".
const INCONCLUSIVE = { kind: 'inconclusive' }
async function scan(entry, meta) {
const { repo, sub } = decompose(entry.url)
const m = meta.get(repo)
if (!m) return INCONCLUSIVE
if (m.gone) return { kind: 'gone', detail: 'repository returns 404' }
if (m.archived) return { kind: 'archived', detail: `archived, last push ${m.pushedAt?.slice(0, 10)}` }
const pushed = new Date(m.pushedAt)
const monthsIdle = (Date.now() - pushed.getTime()) / (30.44 * 86400000)
if (monthsIdle >= DORMANT_MONTHS) {
return { kind: 'dormant', detail: `no push since ${m.pushedAt.slice(0, 10)} (${monthsIdle.toFixed(1)} months)` }
}
const bundled = await hasBundle(repo, m.branch, sub)
if (bundled === null) return INCONCLUSIVE
if (bundled === 'subpath-gone') {
return {
kind: 'subpath-gone',
detail: `\`${sub}/\` no longer exists in the repository — the published install command points at \`#path:/${sub}\``,
}
}
if (bundled === false) return { kind: 'unbundled', detail: 'dsh.bundle no longer found in any package.json' }
return null
}
const LIMIT = Number(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1]) || 0
const entries = LIMIT ? readEntries().slice(0, LIMIT) : readEntries()
console.log(`scanning ${entries.length} entries`)
const repos = [...new Set(entries.map((e) => decompose(e.url).repo))]
console.log(`fetching metadata for ${repos.length} repositories`)
const meta = await fetchMeta(repos)
const findings = []
let inconclusive = 0
// Naming what was not checked is the difference between "the list is clean"
// and "the part I could see is clean". A bare count leaves the reader unable
// to go look at the rest themselves.
const unchecked = []
for (let i = 0; i < entries.length; i += CONCURRENCY) {
const batch = entries.slice(i, i + CONCURRENCY)
const results = await Promise.all(
batch.map(async (e) => {
try {
return [e, await scan(e, meta)]
} catch (err) {
return [e, INCONCLUSIVE]
}
}),
)
for (const [e, f] of results) {
if (f === INCONCLUSIVE) { inconclusive++; unchecked.push(e.url); continue }
if (f) findings.push({ ...f, url: e.url, file: e.file })
}
if ((i + CONCURRENCY) % 200 < CONCURRENCY) console.log(` ${Math.min(i + CONCURRENCY, entries.length)}/${entries.length}`)
}
const KINDS = [
['gone', 'Repository gone (404)'],
['archived', 'Archived'],
// Above `unbundled` on purpose: this is the section a maintainer can usually
// clear the same day by repointing the url, while `unbundled` means opening
// a deadline and waiting a week.
['subpath-gone', 'Subpath gone (repo alive, directory moved)'],
['unbundled', '`dsh.bundle` removed'],
['dormant', `Dormant (no push in ${DORMANT_MONTHS}+ months)`],
]
console.log(`\n${findings.length} finding(s), ${inconclusive} inconclusive (skipped)`)
for (const [kind, label] of KINDS) {
const rows = findings.filter((f) => f.kind === kind)
if (!rows.length) continue
console.log(`\n## ${label} (${rows.length})`)
for (const f of rows) console.log(`- ${f.url} — ${f.detail}`)
}
if (unchecked.length) {
console.log(`\n## Not checked (${unchecked.length}) — no verdict reached, re-run to cover these`)
for (const u of unchecked) console.log(`- ${u}`)
}
// A scan that could not look at a meaningful share of the list has not
// produced a decay report; it has produced a shorter one. Publishing it as
// though it were complete is the failure this whole file is supposed to avoid,
// since the reader's next action is deleting entries. Say so and stop.
const INCONCLUSIVE_LIMIT = Math.ceil(entries.length * 0.05)
if (inconclusive > INCONCLUSIVE_LIMIT) {
console.error(
`\nERROR: ${inconclusive} of ${entries.length} entries could not be checked ` +
`(limit ${INCONCLUSIVE_LIMIT}). The findings above are incomplete — not publishing. ` +
`This is usually GitHub's secondary rate limit; re-run in a few minutes.`,
)
process.exit(1)
}
if (DRY) process.exit(0)
const stamp = new Date().toISOString().slice(0, 10)
const body = [
`Weekly scan of every listed entry, ${stamp}. **Nothing has been removed** — each item below needs a human decision: remove the entry (delete its \`data/plugins/\` file and regenerate), or keep it and note why.`,
'',
'**Subpath gone** is the exception, and usually the cheapest section to clear: the repository is alive and the directory simply moved, so the fix is normally to repoint `url` / `name` / the filename at the new location rather than to remove anything. Renaming an entry file resets its derived added-date, so pin the new URL in `data/added-dates.json`, and check `data/screenshots.json` and `data/stars.json` for keys that still carry the old URL — a stale screenshots key fails the build outright.',
'',
...KINDS.flatMap(([kind, label]) => {
const rows = findings.filter((f) => f.kind === kind)
if (!rows.length) return []
return [`### ${label} (${rows.length})`, '', ...rows.map((f) => `- [ ] ${f.url} — ${f.detail} (\`${f.file}\`)`), '']
}),
findings.length === 0 ? '_Nothing to review this week._' : '',
...(unchecked.length
? [
`<details><summary>Not checked (${unchecked.length}) — no verdict reached</summary>`,
'',
...unchecked.map((u) => `- ${u}`),
'',
'</details>',
'',
]
: []),
`<sub>${entries.length} entries scanned, ${inconclusive} inconclusive and skipped. Generated by \`scripts/scan-decay.mjs\`.</sub>`,
].join('\n')
const [owner, repo] = ISSUE_REPO.split('/')
const q = await api(`search/issues?q=${encodeURIComponent(`repo:${ISSUE_REPO} is:issue in:title "${ISSUE_TITLE}"`)}`)
const existing = q.status === 200 ? (q.body?.items ?? []).find((i) => i.title === ISSUE_TITLE && i.state === 'open') : null
if (existing) {
const r = await api(`repos/${owner}/${repo}/issues/${existing.number}`, { method: 'PATCH', body: JSON.stringify({ body }) })
console.log(r.status === 200 ? `\nupdated issue #${existing.number}` : `\nfailed to update issue #${existing.number} (HTTP ${r.status})`)
} else if (findings.length) {
const r = await api(`repos/${owner}/${repo}/issues`, { method: 'POST', body: JSON.stringify({ title: ISSUE_TITLE, body, labels: ['list'] }) })
console.log(r.status === 200 ? `\nopened issue #${r.body?.number}` : `\nfailed to open issue (HTTP ${r.status})`)
} else {
console.log('\nno findings and no open tracking issue — nothing to do')
}