MUST-FIX - isSourceUniqueViolation read the wrong error field: kysely-postgres-js (postgres@3.4.8) puts the violated constraint on `constraint_name`, not node-postgres' `.constraint`, so a concurrent same-slug+language import's 23505 was never recognized as a source-collision and surfaced a false "name already exists" error. Now read `constraint_name` (with `.constraint` as a fallback for other drivers). Fix the faked test fixture (it built the error with the same wrong `.constraint` field, masking the bug): it now uses `constraint_name`, so the test genuinely exercises the skip path and FAILS against the unfixed code. - Extract the catalog modal's role-state computation into a pure `catalogRoleInstallState(role, workspaceRoles, language)` helper (mirrors role-launch.ts) and cover it with vitest: import / installed / update / same-slug-different-language. SUGGESTIONS - Restore IAiRoleUpdateFromCatalogResult as a discriminated union mirroring the server; narrow the consumer via `"reason" in result` (the boolean discriminant does not narrow under strictNullChecks:false). - README: add a "How it's served" section documenting AI_AGENT_ROLES_CATALOG_URL (remote http(s) base / local path / empty => in-repo folder). - check.mjs: drop the redundant `const key = slug` alias. - Cover the reason->message mapping in useUpdateAiRoleFromCatalogMutation (4 branches) via renderHook with a mocked service. - Cover importFromCatalog "bundle not in index" => BadGateway. - Cover updateFromCatalog "slug in index but missing in bundle file" => not-in-catalog. ARCHITECTURE - Extract the shared catalog read prefix: a private `loadBundleById` (fetchIndex -> meta -> fetchBundle -> versionMap) reused by getCatalogBundle and importFromCatalog, and a `catalogRoleContentFields` mapper shared by the import insert and update patch. The three orchestrations and their distinct write paths stay separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
4.1 KiB
JavaScript
131 lines
4.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Validates the agent roles catalog.
|
|
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
|
|
// between a bundle's index roles[] and the slugs present in each language
|
|
// file, a missing declared language file, or a role missing required fields.
|
|
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const catalogDir = join(__dirname, "..");
|
|
|
|
const errors = [];
|
|
|
|
function readJson(path) {
|
|
try {
|
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (err) {
|
|
errors.push(`Cannot read/parse ${path}: ${err.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const indexPath = join(catalogDir, "index.json");
|
|
if (!existsSync(indexPath)) {
|
|
console.error(`Missing index.json at ${indexPath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const index = readJson(indexPath);
|
|
if (!index) {
|
|
for (const e of errors) console.error(e);
|
|
process.exit(1);
|
|
}
|
|
|
|
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
|
|
if (bundles.length === 0) {
|
|
errors.push("index.json has no bundles[]");
|
|
}
|
|
|
|
// Track every slug seen across the whole catalog to detect duplicates.
|
|
const slugSeen = new Map(); // slug -> "bundleId/lang"
|
|
|
|
for (const bundle of bundles) {
|
|
const bundleId = bundle.id;
|
|
if (!bundleId) {
|
|
errors.push("A bundle in index.json is missing an id");
|
|
continue;
|
|
}
|
|
|
|
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
|
|
// Duplicate slugs inside the bundle index roles[].
|
|
const indexSlugSet = new Set(indexSlugs);
|
|
if (indexSlugSet.size !== indexSlugs.length) {
|
|
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
|
|
}
|
|
|
|
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
|
|
if (languages.length === 0) {
|
|
errors.push(`Bundle "${bundleId}" declares no languages`);
|
|
}
|
|
|
|
for (const lang of languages) {
|
|
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
|
|
if (!existsSync(langPath)) {
|
|
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
|
|
continue;
|
|
}
|
|
|
|
const langFile = readJson(langPath);
|
|
if (!langFile) continue;
|
|
|
|
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
|
|
const fileSlugs = roles.map((r) => r && r.slug);
|
|
|
|
// (d) Required fields per role.
|
|
for (const role of roles) {
|
|
for (const field of ["slug", "name", "instructions"]) {
|
|
if (role == null || role[field] == null || role[field] === "") {
|
|
errors.push(
|
|
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// (b) index roles[] must match the slugs present in each language file.
|
|
const fileSlugSet = new Set(fileSlugs);
|
|
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
|
|
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
|
|
if (missingInFile.length > 0) {
|
|
errors.push(
|
|
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
|
|
);
|
|
}
|
|
if (extraInFile.length > 0) {
|
|
errors.push(
|
|
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
|
|
);
|
|
}
|
|
|
|
// (a) Duplicate slugs across the whole catalog.
|
|
for (const slug of fileSlugs) {
|
|
if (!slug) continue;
|
|
const where = `${bundleId}/${lang}`;
|
|
// Only flag duplicates across DIFFERENT bundles or files; the same slug
|
|
// is expected to appear once per language file of the same bundle.
|
|
if (slugSeen.has(slug)) {
|
|
const prev = slugSeen.get(slug);
|
|
const prevBundle = prev.split("/")[0];
|
|
if (prevBundle !== bundleId) {
|
|
errors.push(
|
|
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
|
|
);
|
|
}
|
|
} else {
|
|
slugSeen.set(slug, where);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
console.error("Catalog check FAILED:");
|
|
for (const e of errors) console.error(` - ${e}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("OK");
|