diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47bb1d9..01a0d82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: - run: npm run render -- --check # Frontmatter, skill references, manifests against their JSON Schemas, and overrides.lock. - run: npm run validate + # The mistakes render and validate must catch (scripts/test.mjs). + - run: npm test workflows: name: Lint workflows diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d66be52..a18f613 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,20 +1,21 @@ # Contributing -Each host's plugin in `dist//` is rendered from shared skills plus that host's own files. Edit the sources, never `dist/`. +Each host's plugin in `dist//` is rendered from shared skills plus that host's own files. Code hosts (a piece for another project, a marketplace listing) are rendered the same way from their own source and the shared text. Edit the sources, never `dist/`. ## Layout ``` skills//SKILL.md shared skills; {{var}} and {{> slot}} mark what differs per host -shared/assets/ files every host ships as they are (logo) +shared/assets/ files every plugin host ships as they are (logo) +shared/text/.md one fact in plain text, used by skills ({{< name}}) and code hosts ({{text:name}}) platforms// vars.yaml values for {{var}} skills//.md the host's text for a {{> slot}} in that skill overrides.lock whole-file overrides and the shared version each was copied from (only once one exists) everything else the host's manifests, README, …, copied into dist// ({{version}} comes from release-please) -dist// GENERATED: the plugin the host installs +dist// GENERATED: the plugin the host installs, or a code host's files .github/plugin/marketplace.json Copilot's marketplace catalog (see below) -scripts/ render, validate, override, new-host, mirror +scripts/ render, validate, override, new-host, mirror, test ``` ### Marketplace catalogs @@ -38,7 +39,19 @@ Requires Node 22. Run `npm ci` once. - **A shared skill a host shouldn't ship:** list it under `skip_skills:` in that host's `vars.yaml` (Cursor uses a rules file instead of `using-zenrows`). A `{{skill:…}}` reference to a skipped skill fails the render. - **A reference to another skill:** write `{{skill:batch}}`, never the plain name. It renders as that skill's name on the host: `skill_prefix` plus the shared name, unless `skill_names` in `vars.yaml` renames it (OpenClaw ships `scrape-webpage` as `zenrows-scrape`). The skill's own `name:` in its frontmatter uses it too. -Unknown variables, unknown skill names, missing slots and unused slot files fail the render, and `npm run validate` fails on a reference to a skill the host doesn't ship, and on a skill name written plainly instead of as `{{skill:…}}`. +Unknown variables, unknown skill names, missing slots, unused slot files, unknown snippets and any of these tokens left in a rendered file (a typo, or a file type the render doesn't fill) fail the render. Other `{{…}}`, such as an n8n or Make expression or JSX's `style={{…}}`, isn't one of these tokens and ships as written, and `npm run validate` fails on a reference to a skill the host doesn't ship, and on a skill name written plainly instead of as `{{skill:…}}`. + +## Shared text + +A fact that more than one place states (what an error code means, what a request costs) lives once in `shared/text/.md`, as one line of plain text with no `{{…}}`. A skill or a text file inlines it with `{{< name}}`; code uses `{{text:name}}`, which renders as a quoted string, and adds its own words around it. Snippets stay plain text so they read right in both, and `npm run validate` fails on markdown in one (backticks, `*`, a link or a heading), or on one nothing uses. + +## Code hosts + +`kind: code` in `vars.yaml` makes a host whose output isn't a plugin but files for somewhere else: code for another project (a piece as it sits in that project's repo), or a listing a marketplace reads. A code host ships only its own files, with `{{…}}` filled in source files too: no shared skills, `LICENSE` or assets, and no `plugin.json`. Its `README.md` documents it here and isn't shipped or rendered; `npm run validate` requires one, and the render fails if the host has a `skills/` folder. It needs no release-please entry until it's released from this repo. + +## Tests + +`npm test` (CI runs it too) checks that render and validate catch the mistakes they're meant to: each case in `scripts/test.mjs` makes one change in a temporary copy of the repo (a throwaway code host, a snippet, …) and expects a given error, or a pass. When you add a rule, add a case that breaks it. ## Manifest schemas diff --git a/package.json b/package.json index 2ec4b1b..11d8c95 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "render": "node scripts/render.mjs", "validate": "node scripts/validate.mjs", + "test": "node --test scripts/test.mjs", "override": "node scripts/override.mjs create", "override:ack": "node scripts/override.mjs ack", "new-host": "node scripts/new-host.mjs", diff --git a/scripts/lib.mjs b/scripts/lib.mjs index f607988..a88f921 100644 --- a/scripts/lib.mjs +++ b/scripts/lib.mjs @@ -10,6 +10,7 @@ export const root = fileURLToPath(new URL("..", import.meta.url)); export const skillsDir = join(root, "skills"); export const platformsDir = join(root, "platforms"); export const distDir = join(root, "dist"); +export const sharedTextDir = join(root, "shared", "text"); // Files copied into every host's plugin as they are. const sharedFiles = { LICENSE: "LICENSE" }; @@ -17,8 +18,15 @@ const sharedDirs = { "shared/assets": "assets" }; // In platforms//, these are inputs to the render, not files of the plugin. // Everything else there (manifests, README, …) is copied into dist//. const reserved = ["vars.yaml", "overrides.lock", "skills/"]; +// What's left of one of this tool's tokens ({{name}}, {{skill:x}}, {{text:x}}, +// {{< x}}, {{> x}}) after the render, for example in a file type it doesn't +// fill, or with a stray space, capital letter, _ or . the fill regexes don't +// take. A colon is ours only after skill or text, so JSX's style={{ flex: 1 }} +// isn't matched; nor is other tools' {{…}} (Make's {{a.b}}, n8n's {{ $json.x }}), +// which ships as written. scripts/test.mjs lists both sides. +export const leftoverToken = /\{\{\s*(?:[<>]\??\s*[a-z0-9_.-]+|(?:skill|text)\s*:\s*[a-z0-9_.-]+|[a-z_][a-z0-9_-]*)\s*\}\}/i; // {{vars}} are filled in files with these extensions; others are copied as bytes. -const textFile = /\.(json|md|mdc|ya?ml|txt|toml)$/; +export const textFile = /\.(json|md|mdc|ya?ml|txt|toml|[cm]?[jt]s)$/; // Errors here are for whoever edited a skill or platform file: print the // message, not a stack trace. @@ -52,9 +60,15 @@ function versions() { export function vars(host) { const v = parseYaml(readFileSync(join(platformsDir, host, "vars.yaml"), "utf8")) ?? {}; + if (typeof v !== "object" || Array.isArray(v)) throw new Error(`platforms/${host}/vars.yaml must be a list of name: value lines`); + if ("kind" in v && v.kind !== "plugin" && v.kind !== "code") { + throw new Error(`platforms/${host}/vars.yaml: kind is ${JSON.stringify(v.kind)}; use plugin or code`); + } const version = versions()[`dist/${host}`]; - if (!version) throw new Error(`.release-please-manifest.json has no "dist/${host}" entry`); - return { ...v, version }; + // A code host may not be released from here (yet), so it needs no + // release-please entry; it then has no {{version}}. + if (!version && !isCodeHost(v)) throw new Error(`.release-please-manifest.json has no "dist/${host}" entry`); + return version ? { ...v, version } : v; } // overrides.lock: ": " per line; # starts a comment. @@ -68,6 +82,13 @@ export function readLock(host) { // the host's own skills (platforms//skills//SKILL.md with no shared // skill of that name). Returns [{ name, own }]. export function hostSkills(host, values = vars(host)) { + // A code host (kind: code) ships no skills, so a skills/ folder there would be ignored. + if (isCodeHost(values)) { + if (listFiles(join(platformsDir, host, "skills")).length) { + throw new Error(`platforms/${host}/skills/: a code host (kind: code) ships no skills; move these files out of skills/`); + } + return []; + } const shared = skillNames(); const skip = values.skip_skills ?? []; for (const name of skip) { @@ -83,22 +104,52 @@ export function hostSkills(host, values = vars(host)) { ].sort((a, b) => a.name.localeCompare(b.name)); } +// kind: code in vars.yaml marks a host whose output isn't a plugin but files for +// somewhere else (code for another project, a listing a marketplace reads): it +// ships only its own files, with {{…}} filled in, and no shared skills, LICENSE +// or assets. +export function isCodeHost(values) { + // vars() has already rejected any other kind, naming the file. + return values.kind === "code"; +} + // A skill's name on a host: skill_prefix + the shared name, unless vars.yaml // renames it in skill_names (OpenClaw ships scrape-webpage as zenrows-scrape). export function hostSkillName(values, name) { return values.skill_names?.[name] ?? `${values.skill_prefix ?? ""}${name}`; } -// {{name}} is a variable from vars.yaml; {{skill:name}} is that skill's name on the host. +// shared/text/.md: one fact in plain text, used by skills and by code +// hosts alike, so a wording fix reaches both. +export function sharedText(name, where) { + const file = join(sharedTextDir, `${name}.md`); + if (!existsSync(file)) throw new Error(`${where}: there's no shared/text/${name}.md`); + // Trimmed, so a trailing blank line or a CRLF ending doesn't end up in a string. + const text = readFileSync(file, "utf8").trim(); + if (text.includes("{{")) { + throw new Error(`shared/text/${name}.md uses {{…}}; a snippet is a plain fact, and the text around it adds what differs per host`); + } + return text; +} + +// {{name}} is a variable from vars.yaml; {{skill:name}} is that skill's name on +// the host; {{< name}} inlines shared/text/.md, and {{text:name}} inserts it +// as a quoted string literal (JSON), for code. function fill(text, values, where) { - return text.replace(/\{\{\s*([a-z_]+)(?::([a-z0-9-]+))?\s*\}\}/g, (token, name, arg) => { + text = text.replace(/\{\{<\s*([a-z0-9-]+)\s*\}\}/g, (_, name) => sharedText(name, where)); + return text.replace(/\{\{\s*([a-z_][a-z0-9_]*)(?::([a-z0-9-]+))?\s*\}\}/g, (token, name, arg) => { if (name === "skill") { if (!values.skills.includes(arg)) { - const why = skillNames().includes(arg) ? "this host skips it (skip_skills)" : "there's no such skill"; + const why = !skillNames().includes(arg) + ? "there's no such skill" + : isCodeHost(values) + ? "a code host (kind: code) ships no skills" + : "this host skips it (skip_skills)"; throw new Error(`${where}: ${token} names a skill this host doesn't ship: ${why}`); } return hostSkillName(values, arg); } + if (name === "text" && arg !== undefined) return JSON.stringify(sharedText(arg, where)); if (arg !== undefined || !(name in values) || typeof values[name] === "object") { throw new Error(`${where}: unknown variable ${token}`); } @@ -136,14 +187,26 @@ export function renderHost(host) { if (!usedSlots.has(path)) throw new Error(`${path}: no skill this host ships has this slot`); } - for (const [from, to] of Object.entries(sharedFiles)) out.set(to, readFileSync(join(root, from))); - for (const [from, to] of Object.entries(sharedDirs)) { + for (const [from, to] of isCodeHost(values) ? [] : Object.entries(sharedFiles)) out.set(to, readFileSync(join(root, from))); + for (const [from, to] of isCodeHost(values) ? [] : Object.entries(sharedDirs)) { for (const file of listFiles(join(root, from))) out.set(`${to}/${file}`, readFileSync(join(root, from, file))); } for (const file of listFiles(platform)) { if (reserved.some((r) => file === r || file.startsWith(r))) continue; + // A code host's README documents the integration here; it isn't part of the code. + if (isCodeHost(values) && file === "README.md") continue; out.set(file, renderFile(join(platform, file), values)); } + + // A token the render didn't fill (a mistyped one, or in a file type outside + // textFile) would otherwise ship as it is. Binary files (not valid UTF-8) are skipped. + for (const [path, buf] of out) { + const text = buf.toString("utf8"); + const token = Buffer.from(text, "utf8").equals(buf) && text.match(leftoverToken); + if (token) { + throw new Error(`${host}: ${path} still has an unrendered ${token[0]}`); + } + } return out; } diff --git a/scripts/render.mjs b/scripts/render.mjs index 940d551..eff864e 100644 --- a/scripts/render.mjs +++ b/scripts/render.mjs @@ -4,7 +4,7 @@ // render. import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; -import { distDir, hosts, listFiles, renderHost, root, vars } from "./lib.mjs"; +import { distDir, hosts, isCodeHost, listFiles, renderHost, root, vars } from "./lib.mjs"; const check = process.argv.includes("--check"); const only = process.argv.slice(2).filter((a) => !a.startsWith("--")); @@ -26,7 +26,10 @@ function hostsList() { const rows = hosts() .map((host) => [vars(host).host_full ?? host, host]) .sort(([a], [b]) => a.localeCompare(b)) - .map(([name, host]) => `| ${name} | [dist/${host}/README.md](dist/${host}/README.md) |`); + .map(([name, host]) => { + const guide = isCodeHost(vars(host)) ? `platforms/${host}/README.md` : `dist/${host}/README.md`; + return `| ${name} | [${guide}](${guide}) |`; + }); const table = ["| Host | Setup guide |", "|---|---|", ...rows].join("\n"); const head = readme.slice(0, readme.indexOf("\n", start) + 1); return Buffer.from(`${head}${table}\n${readme.slice(end)}`); diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..bf0be02 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,142 @@ +// npm test: the mistakes the render and validate must catch. Each case copies +// the repo to a temporary folder, makes one change (a throwaway code host, a +// snippet, …), runs npm run render and npm run validate there, and checks the +// first failure, or that both pass. Add a case whenever a new rule is added. +import { execFileSync, spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { root } from "./lib.mjs"; + +// Every tracked file (-z keeps non-ASCII names as they are), so catalogs at the +// top of the repo (.claude-plugin/, …) come too. One deleted locally is left out. +const copied = execFileSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8" }) + .split("\0") + .filter((path) => path && existsSync(join(root, path))); + +// A code host that passes as it is; cases add or remove files from it. +const piece = { + "platforms/piece/vars.yaml": "host_full: Piece\nkind: code\n", + "platforms/piece/README.md": "# Piece\n", + "platforms/piece/src/index.ts": "export const host = \"{{host_full}}\";\n", +}; + +// Runs render then validate in a copy of the repo with `files` written over it +// (null deletes). Returns { ok, out, dist }: out is the failing step's output, +// dist(path) reads a rendered file. +function run(files) { + const dir = mkdtempSync(join(tmpdir(), "zenrows-integrations-test-")); + try { + for (const path of copied) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + cpSync(join(root, path), join(dir, path)); + } + symlinkSync(join(root, "node_modules"), join(dir, "node_modules")); + mkdirSync(join(root, ".cache"), { recursive: true }); + symlinkSync(join(root, ".cache"), join(dir, ".cache")); + for (const [path, content] of Object.entries(files)) { + if (content === null) rmSync(join(dir, path), { recursive: true, force: true }); + else { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), content); + } + } + for (const script of ["render", "validate"]) { + const r = spawnSync("node", [join("scripts", `${script}.mjs`)], { cwd: dir, encoding: "utf8" }); + if (r.status !== 0) return { ok: false, out: `${r.stdout}${r.stderr}` }; + } + const rendered = new Map(Object.keys(files).filter((p) => p.startsWith("platforms/")).map((p) => { + const out = p.replace(/^platforms\//, "dist/"); + return [out, existsSync(join(dir, out)) ? readFileSync(join(dir, out), "utf8") : undefined]; + })); + return { ok: true, out: "", dist: (path) => rendered.get(path) }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const passes = (files) => () => { + const r = run(files); + assert.ok(r.ok, r.out); +}; +const fails = (files, message) => () => { + const r = run(files); + assert.ok(!r.ok, "expected a failure, but render and validate passed"); + assert.ok(r.out.includes(message), `expected "${message}" in:\n${r.out}`); +}; + +test("the repo as it is passes", passes({})); +test("a code host passes", passes(piece)); + +// The guard on rendered files, both sides: every form of this tool's tokens +// must be caught (here in a .tsx, which the render doesn't fill, so each one +// is a leftover), and other tools' {{…}} must ship as written. Known limit: a +// mistyped keyword ({{txet:cost}}) looks like another tool's {{a:b}} and ships. +const ours = [ + "{{host_full}}", "{{ host_full }}", "{{Host_full}}", "{{host-full}}", "{{api_v2}}", + "{{skill:batch}}", "{{skill: batch}}", "{{Skill:batch}}", "{{skill:scrape_webpage}}", + "{{text:cost}}", "{{text: cost}}", "{{text:cost.md}}", "{{text:cost_credits}}", + "{{< cost}}", "{{ < cost}}", "{{ connect}}", "{{>? connect}}", "{{> connect.md}}", +]; +test("every form of this tool's tokens is caught", () => { + for (const token of ours) { + fails({ ...piece, "platforms/piece/src/view.tsx": `export const a = ${token};\n` }, `src/view.tsx still has an unrendered ${token}`)(); + } +}); +test("a {{…}} in a file type the render doesn't fill fails", fails({ ...piece, "platforms/piece/setup.py": "name = \"{{host_full}}\"\n" }, "setup.py still has an unrendered {{host_full}}")); +test("a mistyped snippet token fails on the way through the render", fails({ + ...piece, + "shared/text/cost.md": "A request with js_render costs 5 credits.\n", + "platforms/piece/src/index.ts": "export const cost = {{ < cost}};\n", +}, "src/index.ts still has an unrendered {{ < cost}}")); +test("a variable with a digit in its name is filled", () => { + const r = run({ ...piece, "platforms/piece/vars.yaml": "host_full: Piece\nkind: code\napi_v2: two\n", "platforms/piece/src/index.ts": "export const v = \"{{api_v2}}\";\n" }); + assert.ok(r.ok, r.out); + assert.equal(r.dist("dist/piece/src/index.ts"), "export const v = \"two\";\n"); +}); +test("an unknown variable fails", fails({ ...piece, "platforms/piece/src/index.ts": "export const a = \"{{hots_full}}\";\n" }, "unknown variable {{hots_full}}")); + +test("a code host with no README fails", fails({ ...piece, "platforms/piece/README.md": null }, "a code host needs platforms/piece/README.md")); +test("a code host with a skills/ folder fails", fails({ ...piece, "platforms/piece/skills/scrape/SKILL.md": "x\n" }, "a code host (kind: code) ships no skills")); +test("a skill reference in a code host fails", fails({ ...piece, "platforms/piece/src/index.ts": "export const s = \"{{skill:batch}}\";\n" }, "a code host (kind: code) ships no skills")); +test("an unknown kind fails", () => { + fails({ ...piece, "platforms/piece/vars.yaml": "host_full: Piece\nkind: Code\n" }, 'platforms/piece/vars.yaml: kind is "Code"')(); + fails({ ...piece, "platforms/piece/vars.yaml": "host_full: Piece\nkind: [code]\n" }, 'platforms/piece/vars.yaml: kind is ["code"]')(); + fails({ ...piece, "platforms/piece/vars.yaml": "host_full: Piece\nkind:\n" }, "platforms/piece/vars.yaml: kind is null")(); +}); +test("a vars.yaml that isn't name: value lines fails", fails({ ...piece, "platforms/piece/vars.yaml": "- host_full: Piece\n" }, "platforms/piece/vars.yaml must be a list of name: value lines")); + +// {{…}} that isn't one of this tool's tokens ships as written. +const theirs = { + // JSX, with a string, a number and a variable value, spaced or not. + "platforms/piece/src/view.tsx": "export const v =

;\nexport const w =

;\nexport const x =

;\nexport const y =

;\n", + // Make, Zapier, n8n and a Handlebars block, plus an empty pair. + "platforms/piece/communication.json": "{ \"make\": \"{{parameters.url}} {{1.name}}\", \"zapier\": \"{{bundle.inputData.url}}\", \"n8n\": \"{{ $json.url }}\", \"hbs\": \"{{#if x}}{{/if}}\", \"empty\": \"{{}}\" }\n", +}; +test("other tools' {{…}} ship as written", () => { + const r = run({ ...piece, ...theirs }); + assert.ok(r.ok, r.out); + for (const [path, text] of Object.entries(theirs)) assert.equal(r.dist(path.replace(/^platforms\//, "dist/")), text); +}); + +// Snippets: plain text, one line, used somewhere. +const snippet = (text) => ({ ...piece, "shared/text/cost.md": text, "platforms/piece/src/index.ts": "export const cost = {{text:cost}};\n" }); +test("a snippet with parameter names and brackets passes", passes(snippet("A request with js_render returns [{...}] and costs 5 credits.\n"))); +test("a snippet with backticks fails", fails(snippet("A request with `js_render` costs 5 credits.\n"), "has markdown")); +test("a snippet with a link fails", fails(snippet("See [pricing](https://www.zenrows.com/pricing).\n"), "has markdown")); +test("a snippet with a blank last line or CRLF renders clean", () => { + for (const text of ["A request costs 5 credits.\n\n", "A request costs 5 credits.\r\n"]) { + const r = run(snippet(text)); + assert.ok(r.ok, r.out); + assert.equal(r.dist("dist/piece/src/index.ts"), "export const cost = \"A request costs 5 credits.\";\n"); + } +}); +test("a snippet with a {{…}} fails", fails(snippet("Ask {{host}} to sign in.\n"), "uses {{…}}; a snippet is a plain fact")); +test("a snippet named only in a vars.yaml comment fails", fails({ ...piece, "shared/text/cost.md": "A request costs 5 credits.\n", "platforms/piece/vars.yaml": "host_full: Piece\nkind: code\n# shared text goes in as {{< cost}}\n" }, "isn't used by any skill or host")); +test("a snippet on two lines fails", fails(snippet("One fact.\nAnother fact.\n"), "has more than one line")); +test("a snippet named only in a code host README fails", fails({ ...piece, "shared/text/cost.md": "A request costs 5 credits.\n", "platforms/piece/README.md": "# Piece\n\nUses `{{< cost}}`.\n" }, "isn't used by any skill or host")); +test("an unused snippet fails", fails({ ...piece, "shared/text/cost.md": "A request costs 5 credits.\n" }, "isn't used by any skill or host")); +test("a missing snippet fails", fails({ ...piece, "platforms/piece/src/index.ts": "export const cost = {{text:nope}};\n" }, "there's no shared/text/nope.md")); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 97aeec3..8aa12b4 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import Ajv2020 from "ajv/dist/2020.js"; import { parse as parseYaml } from "yaml"; -import { catalogs, hostSkillName, hostSkills, hosts, listFiles, platformsDir, readLock, renderHost, root, sha256, skillNames, vars } from "./lib.mjs"; +import { catalogs, hostSkillName, hostSkills, hosts, isCodeHost, listFiles, platformsDir, readLock, renderHost, root, sha256, sharedTextDir, skillNames, textFile, vars } from "./lib.mjs"; const errors = []; const report = []; @@ -157,7 +157,7 @@ for (const host of hosts()) { if (!json[key]) errors.push(`${host}: ${file} has no "${key}"`); } } - if (!manifests.length) errors.push(`${host}: no plugin.json in platforms/${host}/`); + if (!manifests.length && !isCodeHost(values)) errors.push(`${host}: no plugin.json in platforms/${host}/`); // Marketplace catalogs at the top of the repo must point at this host's // dist/ folder, with this host's version. @@ -217,6 +217,16 @@ for (const host of hosts()) { if (text.includes("TODO(new-host)")) errors.push(`${host}: platforms/${host}/${file} still has a TODO(new-host)`); } + // A code host's README stays here (it isn't shipped) and the repo's hosts + // list links to it. + if (isCodeHost(values)) { + if (!existsSync(join(platformsDir, host, "README.md"))) { + errors.push(`${host}: a code host needs platforms/${host}/README.md; the hosts list in README.md links to it`); + } + report.push(`${host}: code host, ships only its own files`); + continue; + } + // How much skill text the host owns instead of sharing: its slot files and // whole-file overrides. Information only; a growing number is worth a look. const owned = listFiles(join(platformsDir, host, "skills")) @@ -225,6 +235,32 @@ for (const host of hosts()) { report.push(`${host}: ${Object.keys(lock).length} whole-file overrides, ${owned} skill lines of its own (slots and overrides)`); } +// shared/text/ snippets are plain text, so they read the same in a skill and in +// a code string, and each must be used somewhere ({{< name}} or {{text:name}}). +// A code host's README isn't rendered, and vars.yaml and overrides.lock are +// inputs, so a snippet named only there isn't used. +const codeHostReadmes = hosts().filter((h) => isCodeHost(vars(h))).map((h) => `${h}/README.md`); +const snippetSources = [ + ...skillNames().map((n) => join(root, "skills", n, "SKILL.md")), + ...listFiles(platformsDir) + .filter((f) => !codeHostReadmes.includes(f) && !/\/(vars\.yaml|overrides\.lock)$/.test(f)) + .map((f) => join(platformsDir, f)), +]; +const used = new Set(); +for (const file of snippetSources) { + if (!textFile.test(file)) continue; + for (const [, , name] of readFileSync(file, "utf8").matchAll(/\{\{(<\s*|\s*text:)([a-z0-9-]+)\s*\}\}/g)) used.add(name); +} +for (const file of listFiles(sharedTextDir)) { + const name = file.replace(/\.md$/, ""); + const text = readFileSync(join(sharedTextDir, file), "utf8"); + if (!used.has(name)) errors.push(`shared/text/${file} isn't used by any skill or host`); + // Backticks, *, links and headings would show up as is in a code string. + // _ and [ ] don't count: parameter names (js_render) and [{...}] use them. + if (/[`*]/.test(text) || /\]\(/.test(text) || /^#/.test(text.trim())) errors.push(`shared/text/${file} has markdown; keep snippets plain text so code can use them`); + if (text.trim().includes("\n")) errors.push(`shared/text/${file} has more than one line; keep one fact per snippet`); +} + console.log(report.join("\n")); if (errors.length) { console.error(`\n${errors.map((e) => `❌ ${e}`).join("\n")}`);