189 lines
6.9 KiB
JavaScript
189 lines
6.9 KiB
JavaScript
/**
|
|
* The JS parser, as an oracle.
|
|
*
|
|
* Runs the REAL frontend module graph through Vite — `import.meta.glob`, the
|
|
* `@/` alias and raw Markdown loading behave exactly as they do in the app, the
|
|
* same technique the frontend's own `scripts/skill-check.mjs` uses (that file
|
|
* lives in krow-demo, not here). A mock of the registry would reproduce none of
|
|
* the behaviour this file exists to capture.
|
|
*
|
|
* Emits one JSON document: for every shipped definition and every adversarial
|
|
* case, what the JS parser did with it. That document is the fixture the Go
|
|
* conformance suite asserts against, so "the Go parser agrees with the JS
|
|
* parser" is a comparison against the JS parser's actual output rather than
|
|
* against anybody's description of it.
|
|
*/
|
|
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
|
|
import { join, relative } from 'node:path';
|
|
|
|
/* The frontend checkout. Overridable so this runs anywhere the two repos are
|
|
checked out side by side, which is the layout it defaults to. */
|
|
const FRONTEND = process.env.KROW_FRONTEND
|
|
|| new URL('../../krow-demo', import.meta.url).pathname;
|
|
const { createServer } = await import(join(FRONTEND, 'node_modules/vite/dist/node/index.js'));
|
|
const { CASES } = await import(new URL('./cases.mjs', import.meta.url).href);
|
|
|
|
const server = await createServer({
|
|
root: FRONTEND, server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
|
|
});
|
|
|
|
const skillReg = await server.ssrLoadModule('/src/lib/skills/registry.js');
|
|
const agentReg = await server.ssrLoadModule('/src/lib/agents/registry.js');
|
|
|
|
/** Every .md under a directory, recursively, repo-relative. */
|
|
function walk(dir) {
|
|
const out = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) out.push(...walk(full));
|
|
else if (entry.endsWith('.md')) out.push(full);
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
/** The fields the backend contract actually projects out of a definition. */
|
|
const projectSkill = (s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
description: s.description,
|
|
status: s.status,
|
|
pages: s.pages,
|
|
kind: s.kind,
|
|
category: s.category,
|
|
actions: s.actions,
|
|
triggers: s.triggers,
|
|
declaredTriggers: s.declaredTriggers,
|
|
prompt: s.prompt ?? null,
|
|
facets: s.facets,
|
|
skillId: s.skillId ?? null,
|
|
});
|
|
|
|
const projectAgent = (a) => ({
|
|
id: a.id,
|
|
name: a.name,
|
|
description: a.description,
|
|
status: a.status,
|
|
version: a.version,
|
|
pages: a.pages,
|
|
icon: a.icon,
|
|
reasoning: a.reasoning,
|
|
trigger: a.trigger,
|
|
webSearch: a.webSearch,
|
|
skills: a.skills,
|
|
subagents: a.subagents,
|
|
starters: a.starters,
|
|
permissions: a.permissions,
|
|
errors: a.errors,
|
|
});
|
|
|
|
/** One definition, as the JS side sees it end to end. */
|
|
function observe(raw, kind) {
|
|
const out = { kind };
|
|
|
|
/* Layer 1 — the fence. */
|
|
try {
|
|
out.hasFrontmatter = skillReg.hasFrontmatter(raw);
|
|
const { data, body } = skillReg.parseFrontmatter(raw);
|
|
out.frontmatter = { ok: true, data, body };
|
|
} catch (error) {
|
|
out.hasFrontmatter = (() => { try { return skillReg.hasFrontmatter(raw); } catch { return null; } })();
|
|
out.frontmatter = { ok: false, error: String(error?.message ?? error) };
|
|
}
|
|
|
|
/* Layer 2 — the definition. */
|
|
try {
|
|
const parsed = kind === 'agent'
|
|
? agentReg.parseAgent(raw, { custom: true })
|
|
: skillReg.parseSkill(raw, { custom: true });
|
|
out.parse = { ok: true };
|
|
out.normalized = kind === 'agent' ? projectAgent(parsed) : projectSkill(parsed);
|
|
out.markdownVerbatim = parsed.markdown === raw;
|
|
} catch (error) {
|
|
out.parse = { ok: false, error: String(error?.message ?? error) };
|
|
out.normalized = null;
|
|
out.markdownVerbatim = null;
|
|
}
|
|
|
|
/* Layer 3 — the save gate. This is the accept/reject contract. */
|
|
const problem = kind === 'agent'
|
|
? agentReg.validateAgentSource(raw)
|
|
: skillReg.validateSkillSource(raw);
|
|
out.accepted = problem === null;
|
|
out.rejection = problem;
|
|
|
|
return out;
|
|
}
|
|
|
|
/* ── The shipped corpus ───────────────────────────────────────────────────── */
|
|
const corpus = [];
|
|
const groups = [
|
|
{ dir: join(FRONTEND, 'src/agents'), type: 'agent', kind: 'agent' },
|
|
{ dir: join(FRONTEND, 'src/skills'), type: 'skill', kind: 'skill' },
|
|
{ dir: join(FRONTEND, 'skill-examples'), type: 'example', kind: 'skill' },
|
|
];
|
|
|
|
for (const { dir, type, kind } of groups) {
|
|
for (const file of walk(dir)) {
|
|
const raw = readFileSync(file, 'utf8');
|
|
corpus.push({
|
|
path: relative(FRONTEND, file),
|
|
type,
|
|
rawBase64: Buffer.from(raw, 'utf8').toString('base64'),
|
|
bytes: Buffer.byteLength(raw, 'utf8'),
|
|
...observe(raw, kind),
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ── The adversarial cases ────────────────────────────────────────────────── */
|
|
const cases = CASES.map((c) => ({
|
|
name: c.name,
|
|
rawBase64: Buffer.from(c.raw, 'utf8').toString('base64'),
|
|
bytes: Buffer.byteLength(c.raw, 'utf8'),
|
|
...observe(c.raw, c.kind),
|
|
}));
|
|
|
|
/* ── The closed vocabularies, read out of the JS tables themselves ────────── */
|
|
const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js');
|
|
const agentVocab = await server.ssrLoadModule('/src/lib/agents/vocabulary.js');
|
|
|
|
const vocabulary = {
|
|
pages: surfaces.SKILL_SURFACES.map((s) => ({ id: s.id, aliases: s.aliases || [] })),
|
|
agentStatuses: agentVocab.AGENT_STATUSES,
|
|
defaultAgentStatus: agentVocab.DEFAULT_AGENT_STATUS,
|
|
reasoning: agentVocab.SUPPORTED_REASONING,
|
|
defaultReasoning: agentVocab.DEFAULT_REASONING,
|
|
icons: agentVocab.AGENT_ICONS,
|
|
defaultIcon: agentVocab.DEFAULT_AGENT_ICON,
|
|
knowledgeKinds: agentVocab.KNOWLEDGE_KINDS,
|
|
defaultKnowledgeKind: agentVocab.DEFAULT_KNOWLEDGE_KIND,
|
|
access: agentVocab.AGENT_ACCESS,
|
|
defaultAccess: agentVocab.DEFAULT_AGENT_ACCESS,
|
|
roles: agentVocab.PERMISSION_ROLES,
|
|
defaultRole: agentVocab.DEFAULT_PERMISSION_ROLE,
|
|
};
|
|
|
|
await server.close();
|
|
|
|
const doc = {
|
|
generatedBy: 'scripts/oracle.mjs against the frontend module graph',
|
|
frontendParser: {
|
|
yaml: 'src/lib/skills/yaml.js (hand-written YAML subset, no dependency)',
|
|
frontmatter: 'src/lib/skills/registry.js — normalizeDefinition / hasFrontmatter / parseFrontmatter',
|
|
skill: 'src/lib/skills/registry.js — parseSkill / validateSkillSource',
|
|
agent: 'src/lib/agents/registry.js — parseAgent / validateAgentSource',
|
|
},
|
|
vocabulary,
|
|
corpus,
|
|
cases,
|
|
};
|
|
|
|
const target = process.argv[2];
|
|
writeFileSync(target, JSON.stringify(doc, null, 2) + '\n');
|
|
|
|
const acc = (xs) => xs.filter((x) => x.accepted).length;
|
|
console.log(`corpus ${corpus.length} files — ${acc(corpus)} accepted, ${corpus.length - acc(corpus)} rejected`);
|
|
console.log(`cases ${cases.length} — ${acc(cases)} accepted, ${cases.length - acc(cases)} rejected`);
|
|
console.log(`markdown verbatim: ${[...corpus, ...cases].every((x) => x.markdownVerbatim !== false)}`);
|
|
console.log(`written: ${target}`);
|