feat: build local skills registry

This commit is contained in:
2026-06-05 09:30:00 +02:00
parent f0a948aec9
commit 370aeec2a0
18 changed files with 1334 additions and 16 deletions

View File

@@ -0,0 +1,231 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import ts from "typescript";
const schemaPath = join(process.cwd(), "convex", "schema.ts");
const auditsPath = join(process.cwd(), "convex", "audits.ts");
const schemaSource = readFileSync(schemaPath, "utf8");
const auditsSource = readFileSync(auditsPath, "utf8");
const sourceFile = ts.createSourceFile(
"audits.ts",
auditsSource,
ts.ScriptTarget.ES2022,
true,
);
function extractTableSection(tableName: string) {
const marker = `${tableName}: defineTable({`;
const markerIndex = schemaSource.indexOf(marker);
assert.notEqual(
markerIndex,
-1,
`Expected schema table definition for ${tableName}.`,
);
const objectStart = schemaSource.indexOf("{", markerIndex);
let depth = 0;
let objectEnd = -1;
for (let index = objectStart; index < schemaSource.length; index += 1) {
if (schemaSource[index] === "{") {
depth += 1;
} else if (schemaSource[index] === "}") {
depth -= 1;
if (depth === 0) {
objectEnd = index;
break;
}
}
}
assert.notEqual(
objectEnd,
-1,
`Could not parse schema object for ${tableName}.`,
);
const objectBlock = schemaSource.slice(objectStart, objectEnd + 1);
return { objectBlock };
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = auditsSource.indexOf(marker);
assert.notEqual(
declarationIndex,
-1,
`Expected declaration for ${name}.`,
);
const openBraceIndex = auditsSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < auditsSource.length; index += 1) {
const char = auditsSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(
end,
-1,
`Expected balanced braces for export ${name}.`,
);
return auditsSource.slice(openBraceIndex, end + 1);
}
function extractFieldSection(source: string, fieldName: string, nextFieldName: string) {
const match = source.match(
new RegExp(
`${fieldName}:\\s*v\\.optional\\([\\s\\S]*?(?=\\s*${nextFieldName}:)`,
),
);
assert.notEqual(
match,
null,
`Expected ${fieldName} field with expected object structure in schema.`,
);
return match![0];
}
function hasPattern(source: string, pattern: RegExp, message: string) {
assert.equal(pattern.test(source), true, message);
}
test("audits schema stores compact usedSkills metadata", () => {
const { objectBlock } = extractTableSection("audits");
const usedSkillsSection = extractFieldSection(
objectBlock,
"usedSkills",
"skillSummaries",
);
const skillSummariesSection = extractFieldSection(
objectBlock,
"skillSummaries",
"multimodalSummary",
);
hasPattern(usedSkillsSection, /usedSkills:\s*v\.optional\(/, "usedSkills should be optional.");
hasPattern(
usedSkillsSection,
/name:\s*v\.string\(\)/,
"usedSkills.name should be string.",
);
hasPattern(
usedSkillsSection,
/category:\s*v\.string\(\)/,
"usedSkills.category should be string.",
);
hasPattern(
usedSkillsSection,
/version:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"usedSkills.version should be optional string.",
);
hasPattern(
usedSkillsSection,
/source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"usedSkills.source should be optional string.",
);
hasPattern(
usedSkillsSection,
/v\.array\(/,
"usedSkills should be an optional array of objects.",
);
hasPattern(
usedSkillsSection,
/v\.object\(/,
"usedSkills should be defined with v.object fields.",
);
hasPattern(skillSummariesSection, /skillSummaries:/, "skillSummaries should still exist.");
hasPattern(
skillSummariesSection,
/name:\s*v\.string\(\)/,
"skillSummaries.name should stay string.",
);
hasPattern(
skillSummariesSection,
/purpose:\s*v\.string\(\)/,
"skillSummaries.purpose should stay string.",
);
hasPattern(
skillSummariesSection,
/summary:\s*v\.string\(\)/,
"skillSummaries.summary should stay string.",
);
});
test("audits.create accepts usedSkills validator and persists metadata payloads", () => {
const createSource = extractExportSource("create");
hasPattern(
auditsSource,
/const usedSkillsValidator\s*=\s*v\.array\(/,
"audits.ts should define a reusable usedSkillsValidator.",
);
hasPattern(
auditsSource,
/v\.object\([\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.string\(\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"audits.ts should define a reusable usedSkillsValidator.",
);
hasPattern(
createSource,
/usedSkills:\s*v\.optional\(usedSkillsValidator\)/,
"create args should include optional usedSkills field.",
);
hasPattern(
createSource,
/ctx\.db\.insert\(\s*["']audits["'][\s\S]*?args[\s\S]*\}/,
"create should persist audit payload from args (so usedSkills is stored when provided).",
);
});
test("audits.getDetail returns audit + lead context with null-safe lead lookup", () => {
const getDetailSource = extractExportSource("getDetail");
hasPattern(
getDetailSource,
/args:\s*{[\s\S]*id:\s*v\.id\(["']audits["']\)[\s\S]*}/,
"getDetail should require id argument for audits.",
);
hasPattern(
getDetailSource,
/const\s+audit\s*=\s*await\s+ctx\.db\.get\s*\(\s*args\.id\s*\)/,
"getDetail should load audit by id.",
);
hasPattern(
getDetailSource,
/if\s*\(\s*!audit\s*\)\s*{\s*return null;\s*}/,
"getDetail should return null when audit is missing.",
);
hasPattern(
getDetailSource,
/const\s+lead\s*=\s*await\s+ctx\.db\.get\s*\(\s*audit\.leadId\s*\)/,
"getDetail should load lead by leadId from the audit.",
);
hasPattern(
getDetailSource,
/return\s*{\s*audit,\s*lead\s*}/,
"getDetail should return { audit, lead }.",
);
hasPattern(
sourceFile.getFullText(),
/export const getDetail = query\(/,
"audits.ts should export a getDetail query.",
);
});

View File

@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("audits dashboard page uses a dedicated board component", async () => {
const dashboardPageSource = await source("app/dashboard/audits/page.tsx");
assert.doesNotMatch(
dashboardPageSource,
/DashboardPlaceholderPage/i,
"Dashboard audits route should not render the placeholder page.",
);
assert.match(
dashboardPageSource,
/<AuditsBoard \/>/,
"Audits board should be mounted from route page.",
);
assert.match(
dashboardPageSource,
/"@\/components\/audits\/audits-board"/,
"Audits board should be imported from components.",
);
});
test("audits board renders compact list with convex list query and core columns", async () => {
const boardSource = await source("components/audits/audits-board.tsx");
assert.match(
boardSource,
/\"use client\"/,
"AuditsBoard must be a Client Component for useQuery.",
);
assert.match(
boardSource,
/useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/,
"AuditsBoard should call api.audits.list with { limit: 100 }.",
);
assert.match(
boardSource,
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/,
"Audits should be sorted newest first.",
);
assert.match(boardSource, /Loading|lädt|Lade/i);
assert.match(boardSource, /Keine Audits|keine Audits/i);
assert.match(boardSource, /Slug/);
assert.match(boardSource, /Domain/);
assert.match(boardSource, /Status/);
assert.match(boardSource, /Seiten/);
assert.match(
boardSource,
/href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/,
"Each audit row should link to /dashboard/audits/{id}.",
);
});
test("audit detail component uses getDetail query and renders skills overview section", async () => {
const detailSource = await source("components/audits/audit-detail.tsx");
assert.match(
detailSource,
/\"use client\"/,
"AuditDetail must be client-side for Convex query calls.",
);
assert.match(
detailSource,
/api\.audits[\s\S]{0,80}getDetail/,
"AuditDetail should use api.audits.getDetail query.",
);
assert.match(
detailSource,
/useQuery\(\s*api\.audits\.getDetail,\s*\{/,
"AuditDetail should call useQuery with api.audits.getDetail directly.",
);
assert.doesNotMatch(
detailSource,
/const\s+auditDetailQueryRef/,
"AuditDetail should not use a cast-based query fallback variable.",
);
assert.match(
detailSource,
/const\s+audit\s*=\s*result\?\.audit;/,
"AuditDetail should destructure audit from result.audit.",
);
assert.match(
detailSource,
/const\s+lead\s*=\s*result\?\.lead;/,
"AuditDetail should destructure lead from result.lead.",
);
assert.match(
detailSource,
/leadSummary\(\s*lead\s*\)/,
"AuditDetail should pass lead into leadSummary from result.lead.",
);
assert.match(
detailSource,
/usedSkills/,
"AuditDetail should inspect usedSkills for overview rendering.",
);
assert.match(
detailSource,
/Keine Skills gespeichert/,
"AuditDetail should show fallback text when no skills are saved.",
);
assert.match(
detailSource,
/Verwendete Skills/,
"AuditDetail should render Verwendete Skills heading.",
);
assert.match(
detailSource,
/Lead|lead/,
"AuditDetail should surface lead context when available.",
);
assert.doesNotMatch(
detailSource,
/<p[^>]*>\s*\{leadSummary\(\s*lead\|[\s\S]*?\)\s*\}\s*<\/p>/,
"Lead summary should not wrap leadSummary output in a nested <p>.",
);
assert.doesNotMatch(
detailSource,
/<p[^>]*>\s*\{leadSummary\(\s*audit\.lead\)\s*\}\s*<\/p>/,
"Lead summary should not wrap leadSummary output in a nested <p>.",
);
});
test("audits detail route passes id to AuditDetail via Promise params", async () => {
const pageSource = await source("app/dashboard/audits/[id]/page.tsx");
assert.match(
pageSource,
/params:\s*Promise<\{\s*id:\s*string\s*\}>/,
"Audit detail route should accept params as Promise in Next.js 16 style.",
);
assert.match(
pageSource,
/const \{\s*id\s*\}\s*=\s*await params/,
"Audit detail route should unwrap Promise params.",
);
assert.match(
pageSource,
/<AuditDetail\s+id=/,
"Audit detail route should pass id prop into AuditDetail.",
);
});
test("public audit page does not expose used skills", async () => {
const publicAuditSource = await source("app/audit/[slug]/page.tsx");
assert.doesNotMatch(
publicAuditSource,
/Verwendete Skills|usedSkills/i,
"Public audit page must not show used skills.",
);
});

View File

@@ -0,0 +1,250 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, sep } from "node:path";
import test from "node:test";
import {
type AuditUsedSkill,
loadSkillsRegistry,
parseSkillsRegistry,
toAuditUsedSkill,
SKILL_CATEGORIES,
} from "../lib/skills-registry";
function assertIncludes(values: readonly string[], value: string) {
assert.ok(values.includes(value), `Expected ${value} in [${values.join(", ")}]`);
}
function withTempProjectRegistry(
source: string,
run: () => Promise<void> | void,
) {
return mkdtemp(`${tmpdir()}${sep}`).then(async (projectRoot) => {
const registryPath = join(projectRoot, "skills.md");
const originalCwd = process.cwd();
await writeFile(registryPath, source, "utf8");
process.chdir(projectRoot);
try {
await run();
} finally {
process.chdir(originalCwd);
await rm(projectRoot, { recursive: true, force: true });
}
});
}
test("parseSkillsRegistry parses valid entries, trims whitespace, and normalizes category", async () => {
const registrySource = `
## Design Audit
Purpose: Evaluate layout, visual hierarchy, and CTA clarity.
When to use: Use when a homepage is available and should be assessed for conversion quality.
When not to use: Don't run during technical outages or non-web touchpoints.
Required input: Homepage URL, top-level page sections, style language, brand context.
Expected output: Prioritized improvement list with concrete design changes.
Category: design
Version: 2026.06
Source: skills/design-audit.md
`;
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, 1);
const entry = parsed.at(0);
assert.ok(entry);
assert.equal(entry!.name, "Design Audit");
assert.equal(entry!.purpose, "Evaluate layout, visual hierarchy, and CTA clarity.");
assert.equal(entry!.category, "design");
assert.equal(entry!.version, "2026.06");
assert.equal(entry!.source, "skills/design-audit.md");
});
test("parseSkillsRegistry accepts indented field labels", () => {
const registrySource = `
## Local SEO Boost
Purpose: Evaluate visibility for local search with nearby intent.
When to use: Use for local business pages and service locations.
When not to use: Avoid for non-local marketing pages.
Required input: City, address, NAP consistency.
Expected output: Prioritized local SEO recommendations.
Category: seo
`;
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, 1);
const entry = parsed.at(0);
assert.ok(entry);
assert.equal(entry!.name, "Local SEO Boost");
assert.equal(entry!.purpose, "Evaluate visibility for local search with nearby intent.");
assert.equal(entry!.category, "seo");
});
test("parseSkillsRegistry throws for missing required fields", () => {
const registrySource = `
## UX Friction Review
Purpose: Review interaction patterns for friction points.
When to use: Use for lead capture and booking flows.
When not to use: Use only when there is a user journey.
Required input: Session flow and target action.
Category: ux
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/missing required field "Expected output"/i,
);
});
test("parseSkillsRegistry throws for unknown category", () => {
const registrySource = `
## Bad Category Example
Purpose: Example.
When to use: Example scenario.
When not to use: Never for this case.
Required input: Example data.
Expected output: Example output.
Category: analytics
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/unknown category "analytics"/i,
);
});
test("parseSkillsRegistry throws for duplicate skill names", () => {
const registrySource = `
## Local SEO Boost
Purpose: Strengthen local SERPs.
When to use: Use for local service businesses.
When not to use: Not for international-only landing pages.
Required input: Name, address, service area.
Expected output: Local SEO gaps and quick wins.
Category: seo
## Local SEO Boost
Purpose: Another local SEO pass.
When to use: Use for new regions.
When not to use: Skip for pure lead-gen pages.
Required input: Name, address, service area.
Expected output: Competitor baseline.
Category: seo
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/duplicate skill name "Local SEO Boost"/i,
);
});
test("parseSkillsRegistry accepts all configured categories", () => {
assertIncludes(SKILL_CATEGORIES, "design");
assertIncludes(SKILL_CATEGORIES, "ux");
assertIncludes(SKILL_CATEGORIES, "marketing");
assertIncludes(SKILL_CATEGORIES, "copy");
assertIncludes(SKILL_CATEGORIES, "seo");
assertIncludes(SKILL_CATEGORIES, "offer");
const registrySource = SKILL_CATEGORIES.map(
(category) => `
## ${category}-skill
Purpose: Valid for ${category}.
When to use: Use for ${category} tasks.
When not to use: Skip when ${category} is not in scope.
Required input: Category inputs.
Expected output: Category-specific recommendations.
Category: ${category}
`,
).join("\n\n");
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, SKILL_CATEGORIES.length);
for (const category of SKILL_CATEGORIES) {
const match = parsed.find((entry) => entry.name === `${category}-skill`);
assert.ok(match, `Expected parsed entry for ${category}`);
assert.equal(match.category, category);
}
});
test("loadSkillsRegistry reads skills.md from process.cwd() by default", async () => {
await withTempProjectRegistry(
`
## Offer Writing
Purpose: Build offer-focused copy for outreach.
When to use: Use before drafting proposals.
When not to use: Avoid when no offer exists.
Required input: Offer structure and pricing envelope.
Expected output: Offer draft and pricing emphasis.
Category: offer
`,
async () => {
const parsed = await loadSkillsRegistry();
const parsedEntry = parsed.find((entry) => entry.name === "Offer Writing");
assert.ok(parsedEntry);
assert.equal(parsedEntry.category, "offer");
},
);
});
test("loadSkillsRegistry accepts an explicit registry path", async () => {
const projectRoot = await mkdtemp(`${tmpdir()}${sep}`);
const registryPath = join(projectRoot, "seed-skills.md");
await writeFile(
registryPath,
`
## Design Audit
Purpose: Validate design quality for local business pages.
When to use: Use for a quick visual prioritization pass.
When not to use: Skip when no public page exists.
Required input: Homepage URL and target conversion goal.
Expected output: Ranked design actions with confidence.
Category: design
`,
"utf8",
);
try {
const parsed = await loadSkillsRegistry(registryPath);
assert.equal(parsed.at(0)?.name, "Design Audit");
} finally {
await rm(projectRoot, { recursive: true, force: true });
}
});
test("toAuditUsedSkill returns only required audit-facing fields", () => {
const skill = {
name: "Copy Clarity",
purpose: "Reduce complexity and improve readability.",
whenToUse: "When existing copy is verbose.",
whenNotToUse: "Skip if website is plain text-only.",
requiredInput: "Page sections and CTAs.",
expectedOutput: "A concise writing pass.",
category: "copy",
version: "1.0",
source: "skills/copy-clarity.md",
} satisfies {
name: string;
purpose: string;
whenToUse: string;
whenNotToUse: string;
requiredInput: string;
expectedOutput: string;
category: "copy";
version: string;
source: string;
};
const auditUsed = toAuditUsedSkill(skill);
const expected: AuditUsedSkill = {
name: "Copy Clarity",
category: "copy",
version: "1.0",
source: "skills/copy-clarity.md",
};
assert.deepEqual(auditUsed, expected);
});