Files
jackwener--opencli/scripts/check-listing-id-pairing.mjs
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

194 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
/**
* check-listing-id-pairing.mjs — advisory report on listing↔detail id round-tripping.
*
* Soft convention (NOT a CI gate): when a site exposes both a listing-class
* command (search / hot / recent / trending / top / feed / popular / new /
* list) AND a detail-class command (read / article / paper / post / detail /
* view / job / page / book / movie / show / chapter / question / answer /
* tweet / video / track), it's usually nicer for agents if every listing row
* carries an id-shaped column whose value round-trips into the detail
* command. Without that, the agent has to re-search by title or scrape a URL
* to follow up.
*
* Why advisory and not a gate: whether a listing should pair with a detail
* is a case-by-case product/UX call (topic-string trending, profile-attribute
* key/value rows, UI-only sessions etc. legitimately don't pair). Forcing
* authors through an exempt list every PR was higher cognitive cost than the
* silent-loss bugs the rule actually catches. See PR #1311 thread for the
* "anti-pattern vs case-by-case" filter.
*
* What this script does:
* 1. Group cli-manifest.json entries by site.
* 2. For each site that has both classes, walk every listing entry and
* check `columns` for at least one id-shaped name.
* 3. Print a report. Always exits 0 — never fails CI.
*
* Usage:
* node scripts/check-listing-id-pairing.mjs # print advisory report
* npm run advise:listing-id-pairing
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MANIFEST = resolve(__dirname, '..', 'cli-manifest.json');
/**
* Listing-class commands. Each row represents a single fetchable entity
* (post / paper / job / ...). The id of that entity must round-trip into
* the site's detail command.
*/
const LISTING_NAMES = new Set([
'search', 'hot', 'recent', 'trending', 'top', 'feed', 'popular',
'list', 'best', 'newest', 'latest', 'rising', 'controversial',
'home', 'timeline', 'browse', 'discover', 'jobs',
'unanswered', 'bounties', 'tag', 'user', 'venue',
'category', 'subreddit', 'question',
]);
/**
* Listing-class commands whose rows are sub-resources within a parent
* thread/session, NOT independently fetchable. Excluded from the rule:
*
* - `comments` / `replies` / `reviews` / `answer-list` / `thread-list`
* — rows are comments under a parent post; the detail command fetches
* the parent, not the comment
* - `ask` / `new` / `show` for AI-chat / agent-session sites — rows are
* conversation turns within one session, not separately addressable
*
* These are intentionally NOT in `LISTING_NAMES` so the rule doesn't
* fire on them.
*/
const DETAIL_NAMES = new Set([
'read', 'article', 'paper', 'post', 'detail', 'view', 'job',
'page', 'book', 'movie', 'show-detail', 'chapter', 'tweet',
'video', 'track', 'note', 'review', 'item', 'product', 'episode',
'thread', 'comment-detail', 'profile-detail', 'shop',
]);
/** Columns whose name implies "this is an id you can pass to detail". */
const ID_COLUMN_PATTERNS = [
/^id$/i,
/_id$/i,
/Id$/,
/^short_id$/i,
/^jk$/i, // indeed
/^tid$/i, // hupu / thread id
/^bvid$/i, // bilibili
/^aid$/i, // anime / bilibili av
/^asin$/i, // amazon
/^sku$/i, // jd / retail product SKU
/^isbn$/i, // book sites
/^doi$/i, // arxiv / openreview
/^slug$/i, // dev.to / lobsters short slug
/^hn_id$/i,
/^username$/i, // user-keyed detail (profile commands)
/^handle$/i,
/^uri$/i, // bluesky AT URI (at://did:.../...)
];
function isUrlDetailCommand(entry) {
const args = Array.isArray(entry.args) ? entry.args : [];
const primaryArg = args.find((arg) => arg?.positional || arg?.required) ?? args[0];
if (!primaryArg) return false;
const name = String(primaryArg.name ?? '').toLowerCase();
if (name === 'url' || name === 'url-or-id') return true;
const help = String(primaryArg.help ?? '').toLowerCase();
if (!help) return false;
// Accept only explicit "this argument may be a URL" wording. Phrases
// like "id from URL" mean callers must extract an id before invoking
// the detail command, so listing.url must not satisfy the id-pair gate.
return (
/^full\b[^()]*\burl\b/.test(help) ||
/\burl\s+or\s+[^()]*\bid\b/.test(help) ||
/\bor\s+(?:a\s+)?full\b[^()]*\burl\b/.test(help) ||
/\bor\s+url\b/.test(help) ||
/\burl\s*,\s*or\b/.test(help)
);
}
function isIdColumn(col, detailCommands) {
if (ID_COLUMN_PATTERNS.some((re) => re.test(col))) return true;
if (/^url$/i.test(col)) {
return detailCommands.some(isUrlDetailCommand);
}
return false;
}
function classify(name) {
if (LISTING_NAMES.has(name)) return 'listing';
if (DETAIL_NAMES.has(name)) return 'detail';
return 'other';
}
function main() {
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
const bySite = new Map();
for (const entry of manifest) {
if (!entry?.site || !entry?.name) continue;
if (!bySite.has(entry.site)) bySite.set(entry.site, []);
bySite.get(entry.site).push(entry);
}
const findings = [];
let scannedSites = 0;
let scannedListings = 0;
for (const [site, entries] of bySite) {
// Only `access: 'read'` detail commands count — write commands like
// `instagram/post` or `instagram/note` create remote state, they don't
// fetch by id, so the listing→detail pairing rule doesn't apply.
const readDetail = entries.filter(
(e) => classify(e.name) === 'detail' && e.access === 'read',
);
const hasListing = entries.some((e) => classify(e.name) === 'listing');
if (!hasListing || readDetail.length === 0) continue;
scannedSites++;
for (const entry of entries) {
if (classify(entry.name) !== 'listing') continue;
scannedListings++;
const columns = Array.isArray(entry.columns) ? entry.columns : [];
if (!columns.some((col) => isIdColumn(col, readDetail))) {
findings.push({
site,
name: entry.name,
columns,
detail: readDetail.map((e) => e.name),
});
}
}
}
console.log(`Scanned ${scannedSites} site(s) with both listing and read-detail commands.`);
console.log(`Checked ${scannedListings} listing command(s).`);
if (findings.length === 0) {
console.log('OK — every listing carries an id-shaped column.');
return;
}
console.log('');
console.log(`Advisory: ${findings.length} listing(s) without a round-trippable id column.`);
console.log('Some of these are legitimate (topic strings, profile-attribute rows, UI-only');
console.log('sessions); others may be worth adding an id to. Use judgment, not a gate.');
console.log('');
for (const v of findings) {
console.log(` • ${v.site}/${v.name}`);
console.log(` columns: [${v.columns.join(', ')}]`);
console.log(` detail commands on this site: ${v.detail.join(', ')}`);
}
console.log('');
console.log('See docs/conventions/listing-detail-id-pairing.md for context and patterns.');
}
main();