chore(catalog): remove plugins that are no longer published

Remove claude-design from the marketplace manifest and its catalog
README entry, and remove the playground-design-system entry from the
README. The catalog now lists 11 plugins.

Add scripts/check-public-links.mjs: fails when README.md or the
manifest links to an open/<name> repository missing from a tracked
PUBLIC_REPOS list. Chosen over the local retired-terms list because
that gate scans every tracked file, and historical docs plus
CONVENTIONS.md (which keep naming these repos on purpose) would hold
it red; a live visibility check could not be red before the repos
change visibility. Red on 5ba4a7a (6 hits), green after (0 of 36).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-23 14:18:19 +02:00
commit d0bc696da3
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 172 additions and 37 deletions

View file

@ -0,0 +1,85 @@
#!/usr/bin/env node
// Public-link gate: fails when the catalog's public surface (README.md, the marketplace
// manifest) links to an org repository that is not on the tracked list of public repos
// below. A repository that is made private or removed turns every link to it into a dead
// link — and a manifest entry into a broken install — so the catalog may only point at
// repositories that are deliberately listed as public here.
//
// Usage: node scripts/check-public-links.mjs [repo-root]
// exit 0 = checked, 0 hits · exit 1 = hit(s), or 0 links found (verified nothing)
//
// Adding a plugin to the catalog means adding its repository to PUBLIC_REPOS in the same
// commit; removing one means taking it out of both.
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
// Org repositories the catalog may link to — the published plugins plus this catalog.
export const PUBLIC_REPOS = [
'ai-psychosis',
'config-audit',
'graceful-handoff',
'human-friendly-style',
'ktg-plugin-marketplace',
'linkedin-studio',
'llm-security',
'ms-ai-architect',
'okr',
'repo-mailbox',
'repo-standard',
'voyage',
];
// The files a reader or an installer reaches from the catalog.
export const SURFACE_FILES = ['README.md', '.claude-plugin/marketplace.json'];
const LINK = /git\.fromaitochitta\.com\/open\/([A-Za-z0-9._-]+)/g;
// Returns one { line, repo } per org-repo link in `content`; a trailing `.git` is dropped.
export function extractRepoLinks(content) {
const links = [];
content.split('\n').forEach((text, i) => {
for (const m of text.matchAll(LINK)) links.push({ line: i + 1, repo: m[1].replace(/\.git$/, '') });
});
return links;
}
// files: [{ path, content }]. Returns every link plus the ones not on the public list.
export function scanFiles(files, publicRepos = PUBLIC_REPOS) {
const links = [];
for (const { path, content } of files) {
for (const l of extractRepoLinks(content)) links.push({ path, ...l });
}
return { links, findings: links.filter((l) => !publicRepos.includes(l.repo)) };
}
export function runCheck(root) {
const files = SURFACE_FILES.filter((p) => existsSync(join(root, p))).map((path) => ({
path,
content: readFileSync(join(root, path), 'utf8'),
}));
return { files: files.length, ...scanFiles(files) };
}
function main(argv) {
const root = argv[0] ?? join(HERE, '..');
const { files, links, findings } = runCheck(root);
for (const f of findings) {
console.log(`[ERROR] link to a repository not listed as public — ${f.path}:${f.line}: open/${f.repo}`);
}
console.log(
`check-public-links: ${links.length} org-repo links in ${files}/${SURFACE_FILES.length} files, ` +
`${PUBLIC_REPOS.length} public repos listed — ${findings.length} hit(s)`,
);
if (links.length === 0) {
console.log('check-public-links: 0 links found — verified nothing');
return 1;
}
return findings.length > 0 ? 1 : 0;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exit(main(process.argv.slice(2)));
}

View file

@ -0,0 +1,80 @@
// Tests for the public-link gate. Unit tests use synthetic content; the last test is the
// FERDIG criterion and reads this repository's own README and manifest.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PUBLIC_REPOS, extractRepoLinks, scanFiles, runCheck } from './check-public-links.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, '..');
const CLI = join(HERE, 'check-public-links.mjs');
test('extractRepoLinks finds every org-repo link with its line, dropping .git', () => {
const content = [
'# title',
'[a](https://git.fromaitochitta.com/open/voyage) and [b](https://git.fromaitochitta.com/open/okr/src/branch/main)',
'"url": "https://git.fromaitochitta.com/open/repo-mailbox.git"',
'https://example.com/open/not-ours',
].join('\n');
assert.deepEqual(extractRepoLinks(content), [
{ line: 2, repo: 'voyage' },
{ line: 2, repo: 'okr' },
{ line: 3, repo: 'repo-mailbox' },
]);
});
test('scanFiles flags only links to repositories off the public list', () => {
const { links, findings } = scanFiles(
[
{ path: 'README.md', content: 'https://git.fromaitochitta.com/open/voyage\nhttps://git.fromaitochitta.com/open/gone-private' },
{ path: 'm.json', content: '"https://git.fromaitochitta.com/open/gone-private.git"' },
],
['voyage'],
);
assert.equal(links.length, 3);
assert.deepEqual(findings.map((f) => `${f.path}:${f.line}:${f.repo}`), ['README.md:2:gone-private', 'm.json:1:gone-private']);
});
test('the public list is sorted and has no duplicates', () => {
assert.deepEqual([...PUBLIC_REPOS].sort(), PUBLIC_REPOS);
assert.equal(new Set(PUBLIC_REPOS).size, PUBLIC_REPOS.length);
});
function tmpRoot(files) {
const root = mkdtempSync(join(tmpdir(), 'public-links-'));
for (const [path, body] of Object.entries(files)) {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), body);
}
return root;
}
test('CLI exits 1 on a hit and 0 on a clean surface, always printing the denominator', () => {
const run = (root) => spawnSync(process.execPath, [CLI, root], { encoding: 'utf8' });
const dirty = run(tmpRoot({ 'README.md': 'https://git.fromaitochitta.com/open/voyage\nhttps://git.fromaitochitta.com/open/gone-private\n' }));
assert.equal(dirty.status, 1);
assert.match(dirty.stdout, /\[ERROR\] link to a repository not listed as public — README\.md:2: open\/gone-private/);
assert.match(dirty.stdout, /2 org-repo links in 1\/2 files/);
const clean = run(tmpRoot({ '.claude-plugin/marketplace.json': '"https://git.fromaitochitta.com/open/voyage.git"' }));
assert.equal(clean.status, 0);
assert.match(clean.stdout, /1 org-repo links in 1\/2 files, \d+ public repos listed — 0 hit/);
});
test('CLI fails when it finds no links at all: verified nothing', () => {
const r = spawnSync(process.execPath, [CLI, tmpRoot({ 'README.md': 'no links\n' })], { encoding: 'utf8' });
assert.equal(r.status, 1);
assert.match(r.stdout, /verified nothing/);
});
// The FERDIG criterion: the catalog links only to repositories listed as public, and every
// plugin in the manifest is one of them.
test('this repository: README and manifest link only to public repositories', () => {
const { files, links, findings } = runCheck(REPO);
assert.equal(files, 2, 'both surface files present');
assert.ok(links.length > 0, '0 links found — verified nothing');
assert.deepEqual(findings.map((f) => `${f.path}:${f.line}: open/${f.repo}`), []);
});