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
194 lines
7.4 KiB
JavaScript
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();
|