diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4fecc6e65b..7f03c87929 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -171,6 +171,7 @@ jobs: - run: cd workers/agent-runtime && npm ci --include=dev - run: npm run verify:moltbot-tools - run: npm run verify:litellm-patch-runner + - run: npm run verify:readme-invariants # These node:test files lived only in the retired all-in-one job. - name: Run agent service tests run: | diff --git a/README.md b/README.md index f898dc2e6d..8557ff1101 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Bring your workflow and your agents.
Your team works with them in one pod. -**[Try the cloud agent →](https://commonly.me)**     **[Bring your own →](#bring-your-own)** +**[Try the cloud agent →](https://commonly.me)**     **[Bring your own →](#bring-your-own)**     **[Discord →](https://discord.gg/NsS3fzsJDw)** [![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/NsS3fzsJDw)   [Join the community](https://discord.gg/NsS3fzsJDw) diff --git a/package.json b/package.json index b5b87ab584..92de278f15 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "verify:litellm-patch-runner": "node scripts/verify-litellm-patch-runner.js", "verify:numbered-rules": "node scripts/verify-numbered-rules.js", "verify:ax-audit-numbering": "node scripts/verify-ax-audit-numbering.js", + "verify:readme-invariants": "node scripts/verify-readme-invariants.js && node scripts/verify-readme-invariants.js --self-test", "prepare": "husky" }, "devDependencies": { diff --git a/scripts/verify-readme-invariants.js b/scripts/verify-readme-invariants.js new file mode 100644 index 0000000000..19120f1500 --- /dev/null +++ b/scripts/verify-readme-invariants.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * README invariant check. + * + * The official Discord invite went into the README with a top badge on 07-19 + * (#694, #695). The 10-23 rewrite (#2477) dropped the badge, so Discord + * survived only as a footer link, and nothing noticed until Sam did. This check + * is the thing that notices: it fails, naming each missing item, when the + * README stops carrying what a visitor needs. + * + * The rules are below. To change them, edit RULES here and record the reason in + * the README benchmark brief (TASK-422), which is where the README's + * structure is argued. Do not add a rule without a reason there. + * + * Usage: node scripts/verify-readme-invariants.js [path] + * node scripts/verify-readme-invariants.js --self-test + */ + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_FILE = path.join(__dirname, '..', 'README.md'); +const TOP_LINES = 40; +const DISCORD_INVITE = 'https://discord.gg/NsS3fzsJDw'; +// The bare host/path, for messages and self-test labels. Never used to match a URL. +const DISCORD_LABEL = 'discord.gg/NsS3fzsJDw'; + +// Compare parsed URLs, not substrings: `includes(url)` would accept +// `https://evil.example/?x=https://discord.gg/NsS3fzsJDw`. +function hasDiscordInvite(lines) { + const urls = lines.join('\n').match(/https?:\/\/[^\s)"'<>]+/g) || []; + const invite = new URL(DISCORD_INVITE); + return urls.some((raw) => { + try { + const url = new URL(raw); + return url.protocol === invite.protocol + && url.hostname === invite.hostname + && url.pathname === invite.pathname; + } catch (err) { + return false; + } + }); +} + +// Link targets are parsed, not matched as prefixes: a prefix test accepts +// `https://docs.commonly.me@evil.example/` and `https://docs.commonly.me.evil.example/`. +function linkTargets(lines) { + return [...lines.join('\n').matchAll(/\]\(([^)\s]+)/g)].map((m) => m[1]); +} + +function isDocsTarget(target) { + if (target.startsWith('docs/')) return true; + try { + const url = new URL(target); + return url.protocol === 'https:' && url.hostname === 'docs.commonly.me'; + } catch (err) { + return false; + } +} + +// Each rule: a name a reader can act on, and a test over the README text. +const RULES = [ + { + name: `the Discord invite (${DISCORD_LABEL}) within the first ${TOP_LINES} lines`, + check: (lines) => hasDiscordInvite(lines.slice(0, TOP_LINES)), + }, + { + name: 'a link to the docs', + check: (lines) => linkTargets(lines).some(isDocsTarget), + }, + { + name: 'a link to the license (LICENSE)', + check: (lines) => /\]\((\.\/)?LICENSE\)/.test(lines.join('\n')), + }, + { + name: 'a quickstart section or link', + check: (lines) => /^#{1,6} .*quick ?start/im.test(lines.join('\n')) + || /\]\([^)]*quickstart[^)]*\)/i.test(lines.join('\n')), + }, +]; + +function checkReadme(text) { + const lines = text.split('\n'); + return RULES.filter((rule) => !rule.check(lines)).map((rule) => `missing ${rule.name}`); +} + +function selfTest() { + const complete = [ + `[![Discord](https://img.shields.io/badge/chat-discord-blue)](${DISCORD_INVITE})`, + '## Quick Start', + '[Docs](docs/README.md) · [License](LICENSE)', + ].join('\n'); + if (checkReadme(complete).length !== 0) { + throw new Error('self-test: a complete fixture was reported as missing something'); + } + + // A README without the invite must fail, and must name the invite. + const noInvite = complete.replace(DISCORD_INVITE, 'https://example.com'); + const problems = checkReadme(noInvite); + if (!problems.some((p) => p.includes(DISCORD_LABEL))) { + throw new Error(`self-test: a README without the invite did not fail naming it: ${JSON.stringify(problems)}`); + } + + // A docs link must resolve to the docs host, not merely start with it. + for (const bogus of ['https://docs.commonly.me@evil.example/', 'https://docs.commonly.me.evil.example/']) { + const fake = complete.replace('[Docs](docs/README.md)', `[Docs](${bogus})`); + if (!checkReadme(fake).some((p) => p.includes('a link to the docs'))) { + throw new Error(`self-test: a docs link to ${bogus} was accepted`); + } + } + + // The invite carried in another host's query string is not the invite. + const lookalike = complete.replace(DISCORD_INVITE, `https://example.com/?u=${DISCORD_INVITE}`); + if (!checkReadme(lookalike).some((p) => p.includes(DISCORD_LABEL))) { + throw new Error('self-test: a lookalike URL carrying the invite was accepted'); + } + + // The invite past line 40 is a footer, not a top link: it must fail too. + const padded = `${'filler\n'.repeat(TOP_LINES)}${DISCORD_INVITE}\n## Quick Start\n[D](https://docs.commonly.me/quickstart)\n[L](LICENSE)`; + if (!checkReadme(padded).some((p) => p.includes(`within the first ${TOP_LINES} lines`))) { + throw new Error('self-test: an invite below the top lines was accepted'); + } + console.log('readme invariants self-test: ok'); +} + +function main(argv) { + if (argv.includes('--self-test')) { + selfTest(); + return 0; + } + const file = argv.find((a) => !a.startsWith('--')) || DEFAULT_FILE; + const problems = checkReadme(fs.readFileSync(file, 'utf8')); + if (problems.length > 0) { + console.error(`README invariants failed for ${path.relative(process.cwd(), file)}:`); + for (const p of problems) console.error(` - ${p}`); + return 1; + } + console.log(`README invariants: ok (${RULES.length} rules)`); + return 0; +} + +if (require.main === module) { + process.exitCode = main(process.argv.slice(2)); +} + +module.exports = { checkReadme, RULES, DISCORD_INVITE };