mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-10-21 06:30:23 +08:00
* add kimi code plugin Signed-off-by: Isotr0py <2037008807@qq.com> * feat: Apply suggestion from @lizhengfeng101 --------- Signed-off-by: Isotr0py <2037008807@qq.com> Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
976 lines
36 KiB
JavaScript
976 lines
36 KiB
JavaScript
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
"use strict";
|
|
|
|
// OpenCodeReview plugin/distribution contract guardrails.
|
|
//
|
|
// Two independent BLOCKING checks, both runnable as plain Node (no npm deps,
|
|
// node >= 14, same convention as scripts/github-actions/check-translation-sync.js):
|
|
//
|
|
// 1. `links` — in-repo path links. The docs and READMEs embed dozens of
|
|
// `raw.githubusercontent.com/.../main/<path>` curl commands and
|
|
// `github.com/.../blob|tree/main/<path>` links. Nothing verified that
|
|
// `<path>` still exists, so moving or deleting a referenced file silently
|
|
// turned every one of those into a 404 that only users hit. This resolves
|
|
// each URL back to a repo-relative path and asserts it exists. No network
|
|
// access.
|
|
//
|
|
// A missing path is BLOCKING: the URL is served from the default branch,
|
|
// so it is a 404 for every reader. A kind mismatch (a `blob`/raw URL on a
|
|
// directory, or a `tree` URL on a file) is only a WARNING, because GitHub
|
|
// redirects between the two views — the link still opens, it is just
|
|
// written against the wrong form.
|
|
//
|
|
// 2. `manifests` — plugin packaging contract. Every path a plugin or
|
|
// marketplace manifest declares must resolve to a real, non-empty
|
|
// directory, and every SKILL.md / command prompt must carry the
|
|
// frontmatter its loader requires. Without this a rename produces a
|
|
// plugin that installs cleanly and exposes nothing.
|
|
//
|
|
// Invoked from .github/workflows/plugin-contract.yml:
|
|
// node scripts/github-actions/check-plugin-contract.js links
|
|
// node scripts/github-actions/check-plugin-contract.js manifests
|
|
// `all` runs both and fails if either does.
|
|
//
|
|
// The core logic is exported as pure functions so it can be unit-tested
|
|
// without touching the filesystem (see check-plugin-contract.test.js).
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const REPO_SLUG = "alibaba/open-code-review";
|
|
|
|
// Directories never scanned for links: VCS internals, installed/built output,
|
|
// editor state, and binary assets. `imgs/` holds only images, and coverage.out
|
|
// is a multi-hundred-KB generated artifact.
|
|
const SCAN_SKIP_DIRS = new Set([
|
|
".git",
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
".astro",
|
|
".idea",
|
|
".vscode-test",
|
|
"imgs",
|
|
".ossutil_checkpoint",
|
|
"out",
|
|
]);
|
|
|
|
// Files above this size are skipped: no hand-written doc link lives in a
|
|
// generated blob, and reading them on every CI run is pure cost.
|
|
const MAX_SCAN_BYTES = 512 * 1024;
|
|
|
|
// Extensions whose files are documentation, and whose exclusion from the link
|
|
// scan is therefore worth reporting rather than passing over quietly.
|
|
const DOC_EXTENSIONS = /\.(md|mdx|markdown)$/i;
|
|
|
|
// Per-line escape hatch, mirroring the `allow-non-english: <reason>` marker
|
|
// that scripts/verify-english-only.go recognises. Intended for fixtures that
|
|
// must reference a deliberately absent path.
|
|
const LINK_ALLOW_MARKER = "plugin-contract: allow-missing";
|
|
|
|
// Fail-closed floor for the link corpus. A scan that quietly stops finding
|
|
// anything — a broken walk, a pattern that no longer matches, a renamed docs
|
|
// tree — otherwise reports success, which is how a check like this rots into
|
|
// decoration. The floor sits far below the real corpus (146 links across 53
|
|
// files at the time of writing): tripping it means the scan broke, not that the
|
|
// docs shrank.
|
|
const MIN_EXPECTED_LINKS = 50;
|
|
const MIN_EXPECTED_LINK_FILES = 10;
|
|
|
|
// Marketplace manifests. `readSources` extracts the pointers to plugin
|
|
// directories however that host spells them, and `requiredManifest` is the file
|
|
// each directory must contain for the install to expose anything.
|
|
//
|
|
// Both readers go through pluginEntries(), because a malformed manifest must
|
|
// produce a `::error` annotation rather than an uncaught TypeError and a stack
|
|
// trace: "plugins is not an array" is a diagnosis, a stack trace is homework.
|
|
const MARKETPLACES = [
|
|
{
|
|
file: ".claude-plugin/marketplace.json",
|
|
host: "Claude Code",
|
|
// Claude Code marketplace entries: `source` is a repo-root-relative string.
|
|
readSources: (json) =>
|
|
pluginEntries(json).map((p) => ({
|
|
name: p.name,
|
|
value: typeof p.source === "string" ? p.source : null,
|
|
})),
|
|
requiredManifest: ".claude-plugin/plugin.json",
|
|
},
|
|
{
|
|
file: ".agents/plugins/marketplace.json",
|
|
host: "Codex",
|
|
// Codex marketplace entries: `source` is an object; local plugins carry
|
|
// `{ source: "local", path: "<repo-root-relative>" }`.
|
|
readSources: (json) =>
|
|
pluginEntries(json)
|
|
.filter((p) => p.source && p.source.source === "local")
|
|
.map((p) => ({
|
|
name: p.name,
|
|
value: typeof p.source.path === "string" ? p.source.path : null,
|
|
})),
|
|
requiredManifest: ".codex-plugin/plugin.json",
|
|
},
|
|
];
|
|
|
|
// The `plugins` array of a marketplace manifest, tolerating any malformed
|
|
// shape: a non-array `plugins`, or null/non-object entries, yield an empty list
|
|
// or are dropped, which the callers report as "lists no installable plugin
|
|
// source" instead of crashing.
|
|
function pluginEntries(json) {
|
|
if (!json || !Array.isArray(json.plugins)) return [];
|
|
return json.plugins.filter((p) => p && typeof p === "object");
|
|
}
|
|
|
|
// Path-declaring fields inside the plugin manifests themselves.
|
|
//
|
|
// `base` is the directory the declared value is resolved against, and it is
|
|
// NOT uniform across hosts — see the note on the Cursor entry. `target` is the
|
|
// directory the value is expected to land on. Pinning both ends is the point:
|
|
// `target` failing to exist catches someone moving the skills/commands tree,
|
|
// and the resolved value failing to equal `target` catches someone editing a
|
|
// manifest. Neither side can move without the other being made to follow.
|
|
const PLUGIN_DECLARATIONS = [
|
|
{
|
|
file: "plugins/open-code-review/claude-code/.claude-plugin/plugin.json",
|
|
field: "commands",
|
|
// Claude Code resolves plugin.json paths against the plugin root, i.e. the
|
|
// parent of `.claude-plugin/`.
|
|
base: "plugin-root",
|
|
target: "plugins/open-code-review/claude-code/commands",
|
|
kind: "commands",
|
|
host: "Claude Code",
|
|
},
|
|
{
|
|
file: "plugins/open-code-review/.codex-plugin/plugin.json",
|
|
field: "skills",
|
|
// Codex's plugin format follows Claude Code's: paths are plugin-root
|
|
// relative, which is why this one reads `./skills/`.
|
|
base: "plugin-root",
|
|
target: "plugins/open-code-review/skills",
|
|
kind: "skills",
|
|
host: "Codex",
|
|
},
|
|
{
|
|
file: "plugins/open-code-review/.cursor-plugin/plugin.json",
|
|
field: "skills",
|
|
// This manifest reads `../skills/`, which only lands on the skills tree if
|
|
// Cursor resolves relative to the MANIFEST directory rather than the plugin
|
|
// root its two siblings use. That asymmetry is unverified against Cursor's
|
|
// published plugin spec, so `base` records the assumption the file is
|
|
// currently written under and `unverifiedBase` makes the check say so out
|
|
// loud on every run. Silently passing would be worse than either outcome:
|
|
// it would certify a manifest that, if Cursor actually resolves from the
|
|
// plugin root, points at a `plugins/skills` directory that does not exist.
|
|
base: "manifest-dir",
|
|
unverifiedBase:
|
|
"Cursor's plugin spec has not been checked against this repo. If Cursor " +
|
|
"resolves plugin.json paths from the plugin root (as Claude Code and " +
|
|
"Codex do), `../skills/` points at the non-existent `plugins/skills` and " +
|
|
"must become `./skills/`. Confirm against https://cursor.com/docs/plugins, " +
|
|
"then set base to the verified value and drop this field.",
|
|
target: "plugins/open-code-review/skills",
|
|
kind: "skills",
|
|
host: "Cursor",
|
|
},
|
|
{
|
|
file: ".kimi-plugin/plugin.json",
|
|
field: "skills",
|
|
// Kimi Code installs a GitHub repository with the repo root as the plugin
|
|
// root and only looks for the manifest at <root>/kimi.plugin.json or
|
|
// <root>/.kimi-plugin/plugin.json — it cannot address a subdirectory of a
|
|
// repo. The manifest therefore lives at the repo root and its
|
|
// plugin-root-relative paths point down into the shared plugin tree.
|
|
base: "plugin-root",
|
|
target: "plugins/open-code-review/skills",
|
|
kind: "skills",
|
|
host: "Kimi Code",
|
|
},
|
|
{
|
|
file: ".kimi-plugin/plugin.json",
|
|
field: "commands",
|
|
// Same resolution base as the skills entry; commands live under the
|
|
// kimi-code/ subtree so the Kimi plugin never references the
|
|
// claude-code/ tree.
|
|
base: "plugin-root",
|
|
target: "plugins/open-code-review/kimi-code/commands",
|
|
kind: "commands",
|
|
host: "Kimi Code",
|
|
},
|
|
];
|
|
|
|
// Skill trees. Each direct subdirectory must hold a SKILL.md whose frontmatter
|
|
// `name` equals the directory name — `npx skills add --skill <name>` and every
|
|
// skill loader address a skill by that name.
|
|
const SKILL_ROOTS = ["skills", "plugins/open-code-review/skills"];
|
|
|
|
// Command prompt directories. Each `.md` needs a `description` for the host to
|
|
// render it in the slash-command list.
|
|
const COMMAND_DIRS = [
|
|
"plugins/open-code-review/claude-code/commands",
|
|
"plugins/open-code-review/kimi-code/commands",
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check 1: in-repo path links
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Matches the three URL shapes that point at a path inside this repository.
|
|
// The scheme is optional because prose sometimes omits it. The path run stops
|
|
// at whitespace, a closing paren (markdown link syntax), a backtick, or a
|
|
// quote; trailing prose punctuation is trimmed separately.
|
|
function repoLinkPattern() {
|
|
// The slug is interpolated into a regex source, so escape every character
|
|
// that would otherwise be read as a metacharacter. Backslash has to come
|
|
// first in the class so it is escaped before it can pair with a later
|
|
// replacement.
|
|
const slug = REPO_SLUG.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
return new RegExp(
|
|
"(?:https?:\\/\\/)?(?:" +
|
|
`raw\\.githubusercontent\\.com\\/${slug}\\/main\\/` +
|
|
"|" +
|
|
`github\\.com\\/${slug}\\/(blob|tree)\\/main\\/` +
|
|
")([^\\s)\\]`\"'<>]+)",
|
|
"g"
|
|
);
|
|
}
|
|
|
|
// Trailing characters that belong to the surrounding sentence, not the URL.
|
|
// The escaped codepoints are the CJK punctuation the translated docs end
|
|
// sentences with, written as escapes to keep this file ASCII-only per
|
|
// scripts/verify-english-only.go: U+FF0C fullwidth comma, U+3002 ideographic
|
|
// full stop, U+3001 ideographic comma, U+FF09 fullwidth right paren, U+3011
|
|
// right black lenticular bracket.
|
|
function stripTrailingPunctuation(s) {
|
|
return s.replace(/[.,;:!?*_\uFF0C\u3002\u3001\uFF09\u3011]+$/, "");
|
|
}
|
|
|
|
// Reduce a URL path run to the repo-relative path it addresses: drop any
|
|
// fragment or query, trim prose punctuation, and percent-decode.
|
|
function toRepoPath(rawPath) {
|
|
let p = rawPath.split("#")[0].split("?")[0];
|
|
p = stripTrailingPunctuation(p);
|
|
p = p.replace(/\/+$/, ""); // a trailing slash is directory notation, not a name
|
|
if (!p) return null;
|
|
try {
|
|
p = decodeURIComponent(p);
|
|
} catch (e) {
|
|
/* leave the raw form; a malformed escape will fail the existence check */
|
|
}
|
|
return p;
|
|
}
|
|
|
|
// Extract every in-repo link from one file's content.
|
|
// Returns [{ repoPath, kind: "file"|"tree", line, raw }], skipping lines that
|
|
// carry the allow-missing marker.
|
|
function extractRepoLinks(content) {
|
|
const lines = String(content).split(/\r?\n/);
|
|
const found = [];
|
|
// Compiled once per file, not once per line: the link scan walks the whole
|
|
// work tree, so per-line compilation would run tens of thousands of times.
|
|
// `lastIndex` is reset before each line because the regex is global.
|
|
const re = repoLinkPattern();
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.includes(LINK_ALLOW_MARKER)) continue;
|
|
re.lastIndex = 0;
|
|
let m;
|
|
while ((m = re.exec(line)) !== null) {
|
|
const repoPath = toRepoPath(m[2]);
|
|
if (!repoPath) continue;
|
|
found.push({
|
|
repoPath,
|
|
// Only `tree/main/` addresses a directory; `blob/main/` and the raw
|
|
// host both address a file.
|
|
kind: m[1] === "tree" ? "tree" : "file",
|
|
line: i + 1,
|
|
raw: m[0],
|
|
});
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// Validate extracted links against a filesystem probe.
|
|
// links: [{ file, repoPath, kind, line }]; probe(repoPath) -> "file" | "dir" | null.
|
|
// Returns { errors, warnings }, both [{ file, line, message }]: a missing path
|
|
// is an error, a blob/tree kind mismatch is only a warning (see the header).
|
|
function validateRepoLinks(links, probe) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
for (const l of links) {
|
|
const actual = probe(l.repoPath);
|
|
if (actual === null) {
|
|
errors.push({
|
|
file: l.file,
|
|
line: l.line,
|
|
message:
|
|
`Link points at \`${l.repoPath}\`, which does not exist in the ` +
|
|
`repository. Update the link, or restore the file it addresses ` +
|
|
`(this URL is served from the default branch, so a stale path is a ` +
|
|
`404 for every reader).`,
|
|
});
|
|
continue;
|
|
}
|
|
if (l.kind === "file" && actual !== "file") {
|
|
warnings.push({
|
|
file: l.file,
|
|
line: l.line,
|
|
message:
|
|
`Link addresses \`${l.repoPath}\` as a file (blob/raw URL) but that ` +
|
|
`path is a directory. GitHub redirects, so the link opens; prefer a ` +
|
|
`\`tree/main/\` URL for directories.`,
|
|
});
|
|
} else if (l.kind === "tree" && actual !== "dir") {
|
|
warnings.push({
|
|
file: l.file,
|
|
line: l.line,
|
|
message:
|
|
`Link addresses \`${l.repoPath}\` as a directory (tree URL) but that ` +
|
|
`path is a file. GitHub redirects, so the link opens; prefer a ` +
|
|
`\`blob/main/\` URL for files.`,
|
|
});
|
|
}
|
|
}
|
|
return { errors, warnings };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check 2: frontmatter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Parse the top-level keys of a leading YAML frontmatter block. Deliberately
|
|
// minimal: enough to answer "is this key present and what is its inline
|
|
// value", which is all the contract checks ask. A folded value (`key: >`) has
|
|
// an empty inline value but is still present, so presence and value are
|
|
// reported separately.
|
|
// Returns { ok, keys: { name: { value, line } } }.
|
|
function parseFrontmatter(content) {
|
|
const lines = String(content).split(/\r?\n/);
|
|
// A null-prototype map so a key literally named `__proto__` is recorded as an
|
|
// ordinary entry instead of hitting the Object prototype setter.
|
|
const keys = Object.create(null);
|
|
if (lines[0] !== "---") return { ok: false, keys };
|
|
let current = null;
|
|
let terminated = false;
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line === "---") {
|
|
terminated = true;
|
|
break;
|
|
}
|
|
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
if (m) {
|
|
// Only column-0 matches start a top-level key.
|
|
current = { inline: m[2].trim(), continuation: [], line: i + 1 };
|
|
keys[m[1]] = current;
|
|
continue;
|
|
}
|
|
// Anything indented, and a column-0 sequence dash, continues the current
|
|
// key's value. Capturing continuations matters because YAML lets a scalar
|
|
// live entirely on the following lines (`description:` then an indented
|
|
// paragraph, or `description: >` then a folded block) — reading only the
|
|
// inline part would call a perfectly valid value empty.
|
|
if (current && (/^\s+\S/.test(line) || /^-\s/.test(line))) {
|
|
current.continuation.push(line.trim());
|
|
}
|
|
}
|
|
const out = Object.create(null);
|
|
for (const key of Object.keys(keys)) {
|
|
const k = keys[key];
|
|
// A block indicator (`>`, `|`, with optional chomping and indentation
|
|
// modifiers, which YAML accepts in either order: `>-2` and `>2-` are both
|
|
// valid headers) is syntax, not content: the value is whatever follows on
|
|
// the next lines.
|
|
const inline = /^[>|](?:[-+]\d*|\d*[-+]?)$/.test(k.inline) ? "" : k.inline;
|
|
out[key] = {
|
|
value: unquoteScalar(inline || k.continuation.join(" ")),
|
|
line: k.line,
|
|
};
|
|
}
|
|
return { ok: terminated, keys: out };
|
|
}
|
|
|
|
// Strip one matching pair of surrounding quotes. YAML allows `name: "x"`, and
|
|
// comparing the quoted form against a directory name would fail with a message
|
|
// nobody could act on.
|
|
function unquoteScalar(value) {
|
|
const m = /^(["'])([\s\S]*)\1$/.exec(value);
|
|
return m ? m[2] : value;
|
|
}
|
|
|
|
// A skill's frontmatter must declare `name` (matching its directory, since
|
|
// that is how loaders address it) and a non-empty `description` (how agents
|
|
// decide to invoke it).
|
|
function validateSkillFrontmatter(file, dirName, content) {
|
|
const errors = [];
|
|
const { ok, keys } = parseFrontmatter(content);
|
|
if (!ok) {
|
|
errors.push({
|
|
file,
|
|
message:
|
|
`${file} has no terminated YAML frontmatter block. A SKILL.md must ` +
|
|
`open with \`---\`, declare \`name\` and \`description\`, and close ` +
|
|
`with \`---\`.`,
|
|
});
|
|
return errors;
|
|
}
|
|
if (!keys.name) {
|
|
errors.push({ file, message: `${file} frontmatter is missing \`name\`.` });
|
|
} else if (keys.name.value !== dirName) {
|
|
errors.push({
|
|
file,
|
|
line: keys.name.line,
|
|
message:
|
|
`${file} declares \`name: ${keys.name.value}\` but lives in ` +
|
|
`\`${dirName}/\`. Loaders and \`npx skills add --skill <name>\` ` +
|
|
`address a skill by its directory name, so the two must match.`,
|
|
});
|
|
}
|
|
// The value must be non-empty, not merely present: `description:` with
|
|
// nothing after it leaves an agent no basis for deciding to invoke the skill.
|
|
// A folded scalar (`description: >`) has value ">", so this does not misfire.
|
|
if (!keys.description || !keys.description.value) {
|
|
errors.push({
|
|
file,
|
|
message: `${file} frontmatter is missing a non-empty \`description\`.`,
|
|
});
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
// A command prompt needs a `description`; without it the host has nothing to
|
|
// show in the slash-command list.
|
|
function validateCommandFrontmatter(file, content) {
|
|
const { ok, keys } = parseFrontmatter(content);
|
|
if (!ok) {
|
|
return [
|
|
{
|
|
file,
|
|
message:
|
|
`${file} has no terminated YAML frontmatter block. A command prompt ` +
|
|
`must open with \`---\`, declare \`description\`, and close with \`---\`.`,
|
|
},
|
|
];
|
|
}
|
|
if (!keys.description || !keys.description.value) {
|
|
return [
|
|
{
|
|
file,
|
|
message:
|
|
`${file} frontmatter is missing a non-empty \`description\`, which ` +
|
|
`the host renders in the slash-command list.`,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check 2: declared path resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Resolve a manifest-declared relative path to a normalised repo-relative
|
|
// path. `manifestFile` is repo-relative; `base` selects the directory the
|
|
// value is resolved against ("manifest-dir" = the directory holding the
|
|
// manifest, "plugin-root" = its parent, i.e. the plugin directory).
|
|
function resolveDeclaredPath(manifestFile, base, value) {
|
|
const manifestDir = path.posix.dirname(manifestFile.replace(/\\/g, "/"));
|
|
const from = base === "plugin-root" ? path.posix.dirname(manifestDir) : manifestDir;
|
|
const raw = String(value);
|
|
// An absolute value is not a plugin-relative path, and `path.join` would
|
|
// quietly treat it as one: joining "/skills/" onto the plugin root collapses
|
|
// onto the expected target and would certify a manifest that every host
|
|
// resolves against the filesystem root instead. Return it unchanged so the
|
|
// comparison against `target` fails and says what was declared.
|
|
if (raw.startsWith("/")) return raw.replace(/\/+$/, "") || "/";
|
|
const joined = path.posix.join(from, raw);
|
|
return joined.replace(/\/+$/, "");
|
|
}
|
|
|
|
// Compare each declared field against its expected target.
|
|
// specs: PLUGIN_DECLARATIONS entries; readJson(file) -> object | null.
|
|
// Returns { errors, warnings }, both [{ file, message }]. A spec carrying
|
|
// `unverifiedBase` always contributes a warning, so an assumption the check
|
|
// cannot validate is stated on every run instead of passing silently.
|
|
function checkPluginDeclarations(specs, readJson) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
for (const spec of specs) {
|
|
if (spec.unverifiedBase) {
|
|
warnings.push({
|
|
file: spec.file,
|
|
message:
|
|
`${spec.file} declares \`${spec.field}\` against an UNVERIFIED ` +
|
|
`resolution base (${spec.base}). ${spec.unverifiedBase}`,
|
|
});
|
|
}
|
|
const json = readJson(spec.file);
|
|
if (json === null) {
|
|
errors.push({
|
|
file: spec.file,
|
|
message:
|
|
`${spec.host} plugin manifest ${spec.file} is missing or is not ` +
|
|
`valid JSON.`,
|
|
});
|
|
continue;
|
|
}
|
|
const value = json[spec.field];
|
|
if (typeof value !== "string" || value === "") {
|
|
errors.push({
|
|
file: spec.file,
|
|
message:
|
|
`${spec.file} does not declare a \`${spec.field}\` path. The ` +
|
|
`${spec.host} plugin would install without exposing any ` +
|
|
`${spec.kind}.`,
|
|
});
|
|
continue;
|
|
}
|
|
const resolved = resolveDeclaredPath(spec.file, spec.base, value);
|
|
if (resolved !== spec.target) {
|
|
errors.push({
|
|
file: spec.file,
|
|
message:
|
|
`${spec.file} declares \`${spec.field}: "${value}"\`, which resolves ` +
|
|
`to \`${resolved}\` (relative to the ${spec.base}), but the ` +
|
|
`${spec.kind} tree is at \`${spec.target}\`. Either fix the declared ` +
|
|
`path or update PLUGIN_DECLARATIONS in ` +
|
|
`scripts/github-actions/check-plugin-contract.js to match the new layout.`,
|
|
});
|
|
}
|
|
}
|
|
return { errors, warnings };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GitHub Actions annotation + IO helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GitHub workflow commands require `%`, CR and LF to be escaped in the message
|
|
// data. Messages here interpolate values read from manifests, SKILL.md
|
|
// frontmatter and URL paths, any of which can contain a newline or a stray
|
|
// percent sign; unescaped, the annotation is truncated at that point and the
|
|
// most diagnostic half of the message is lost.
|
|
function escapeAnnotationData(s) {
|
|
return String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
}
|
|
|
|
function emitAnnotation(level, file, message, line) {
|
|
const loc = file ? ` file=${file}${line ? `,line=${line}` : ""}` : "";
|
|
console.log(`::${level}${loc}::${escapeAnnotationData(message)}`);
|
|
}
|
|
|
|
const emitError = (file, message, line) => emitAnnotation("error", file, message, line);
|
|
const emitWarning = (file, message, line) => emitAnnotation("warning", file, message, line);
|
|
|
|
// Read a file as text, or null if it is absent, unreadable, binary, or larger
|
|
// than `maxBytes`. The size cap exists only to keep the link scan from reading
|
|
// generated blobs, so contract files (SKILL.md, command prompts) are read with
|
|
// no cap: silently treating an oversized-but-valid SKILL.md as "missing" would
|
|
// point the reader at the wrong problem.
|
|
function readFileOrNull(abs, { maxBytes = MAX_SCAN_BYTES } = {}) {
|
|
try {
|
|
const stat = fs.statSync(abs);
|
|
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
const buf = fs.readFileSync(abs);
|
|
// A NUL byte means binary; decoding it as text would produce noise.
|
|
if (buf.includes(0)) return null;
|
|
return buf.toString("utf8");
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readJsonOrNull(repoRoot, file) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), "utf8"));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Depth-first walk yielding repo-relative paths of candidate text files.
|
|
//
|
|
// Symlinks are resolved, matching listEntries: a Dirent reports a symlink as
|
|
// neither a file nor a directory, so skipping them would leave a symlinked doc
|
|
// (or a whole symlinked subtree) silently unscanned — a blind spot that opens
|
|
// the moment someone uses a link to de-duplicate documentation. `seen` holds
|
|
// the real paths of directories already walked, which keeps a link pointing at
|
|
// an ancestor from looping forever and stops a linked subtree being scanned
|
|
// twice.
|
|
function walkTextFiles(repoRoot, rel = "", seen = new Set()) {
|
|
const abs = path.join(repoRoot, rel);
|
|
try {
|
|
const real = fs.realpathSync(abs);
|
|
if (seen.has(real)) return [];
|
|
seen.add(real);
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
const out = [];
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
} catch (e) {
|
|
return out;
|
|
}
|
|
// Sorted so annotations come out in a stable order run to run.
|
|
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
for (const entry of entries) {
|
|
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
let isDir = entry.isDirectory();
|
|
let isFile = entry.isFile();
|
|
if (!isDir && !isFile) {
|
|
if (!entry.isSymbolicLink()) continue; // socket, FIFO, device
|
|
try {
|
|
const stat = fs.statSync(path.join(abs, entry.name)); // follows the link
|
|
isDir = stat.isDirectory();
|
|
isFile = stat.isFile();
|
|
} catch (e) {
|
|
continue; // dangling link
|
|
}
|
|
}
|
|
if (isDir) {
|
|
if (SCAN_SKIP_DIRS.has(entry.name)) continue;
|
|
out.push(...walkTextFiles(repoRoot, childRel, seen));
|
|
} else if (isFile) {
|
|
out.push(childRel);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function probeKind(repoRoot, repoPath) {
|
|
try {
|
|
const stat = fs.statSync(path.join(repoRoot, repoPath));
|
|
return stat.isDirectory() ? "dir" : "file";
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Sorted names of the entries of `rel` whose resolved kind is `want`
|
|
// ("dir" or "file"), or null if `rel` cannot be read.
|
|
//
|
|
// Symlinks are resolved rather than skipped. A Dirent reports a symlink as
|
|
// neither a directory nor a file, so filtering on `isDirectory()`/`isFile()`
|
|
// alone would hide a symlinked skill directory and report the tree as empty —
|
|
// a blocking false positive, and precisely the layout someone might reach for
|
|
// to stop maintaining two copies of the skills tree. Resolving here also keeps
|
|
// this consistent with probeKind, which stats (and so follows links) too.
|
|
function listEntries(repoRoot, rel, want, ext) {
|
|
const dir = path.join(repoRoot, rel);
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
const out = [];
|
|
for (const entry of entries) {
|
|
if (ext && !entry.name.endsWith(ext)) continue;
|
|
let kind;
|
|
if (entry.isDirectory()) kind = "dir";
|
|
else if (entry.isFile()) kind = "file";
|
|
else if (entry.isSymbolicLink()) {
|
|
// statSync follows the link; a dangling link throws and is skipped.
|
|
try {
|
|
kind = fs.statSync(path.join(dir, entry.name)).isDirectory() ? "dir" : "file";
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
} else continue; // socket, FIFO, device: not part of any contract
|
|
if (kind === want) out.push(entry.name);
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI runners
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Report a corpus too small to be believable. Pure so it can be tested without
|
|
// a filesystem; returns a message or null.
|
|
function checkCorpusFloor(linkCount, fileCount, minLinks, minFiles) {
|
|
if (linkCount >= minLinks && fileCount >= minFiles) return null;
|
|
return (
|
|
`Only ${linkCount} link(s) across ${fileCount} file(s) were found, below the ` +
|
|
`floor of ${minLinks}/${minFiles}. The scan itself looks broken (a failed ` +
|
|
`walk, a pattern that no longer matches, or a moved docs tree) — a check ` +
|
|
`that finds nothing must fail rather than report success.`
|
|
);
|
|
}
|
|
|
|
// BLOCKING. Returns a process exit code (0 ok, 1 on a broken link).
|
|
function runLinksCheck({
|
|
repoRoot = process.cwd(),
|
|
minLinks = MIN_EXPECTED_LINKS,
|
|
minFiles = MIN_EXPECTED_LINK_FILES,
|
|
maxScanBytes = MAX_SCAN_BYTES,
|
|
} = {}) {
|
|
const files = walkTextFiles(repoRoot);
|
|
const links = [];
|
|
const skipped = [];
|
|
for (const file of files) {
|
|
const content = readFileOrNull(path.join(repoRoot, file), { maxBytes: maxScanBytes });
|
|
if (content === null) {
|
|
// A documentation file that could not be read is a hole in the corpus,
|
|
// not a pass. Reporting it keeps this consistent with the fail-closed
|
|
// corpus floor below, which can only catch a scan that broke wholesale
|
|
// and would miss individual documents being skipped in silence.
|
|
if (DOC_EXTENSIONS.test(file)) skipped.push(file);
|
|
continue;
|
|
}
|
|
// Cheap prefilter: most files mention the slug nowhere.
|
|
if (!content.includes(REPO_SLUG)) continue;
|
|
for (const l of extractRepoLinks(content)) links.push({ ...l, file });
|
|
}
|
|
|
|
for (const file of skipped) {
|
|
emitWarning(
|
|
file,
|
|
`Skipped by the link scan (unreadable, binary, or larger than ` +
|
|
`${maxScanBytes} bytes), so its in-repo links were NOT verified.`
|
|
);
|
|
}
|
|
|
|
const fileCount = new Set(links.map((l) => l.file)).size;
|
|
const floor = checkCorpusFloor(links.length, fileCount, minLinks, minFiles);
|
|
if (floor) {
|
|
emitError(null, `In-repo link check failed: ${floor}`);
|
|
return 1;
|
|
}
|
|
|
|
const { errors, warnings } = validateRepoLinks(links, (p) => probeKind(repoRoot, p));
|
|
for (const err of errors) emitError(err.file, err.message, err.line);
|
|
for (const warn of warnings) emitWarning(warn.file, warn.message, warn.line);
|
|
|
|
if (errors.length === 0) {
|
|
const unique = new Set(links.map((l) => l.repoPath)).size;
|
|
console.log(
|
|
`In-repo link check passed: ${links.length} link(s) across ` +
|
|
`${fileCount} file(s) resolve to ` +
|
|
`${unique} existing path(s)` +
|
|
(warnings.length || skipped.length
|
|
? `, with ${warnings.length} blob/tree kind warning(s) and ` +
|
|
`${skipped.length} unscanned doc(s).`
|
|
: ".")
|
|
);
|
|
return 0;
|
|
}
|
|
emitError(
|
|
null,
|
|
`In-repo link check failed: ${errors.length} link(s) point at paths that do ` +
|
|
`not exist. These URLs are served from the default branch, so each one is ` +
|
|
`a 404 for readers.`
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
// BLOCKING. Returns a process exit code (0 ok, 1 on a contract violation).
|
|
function runManifestsCheck({ repoRoot = process.cwd() } = {}) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
let checked = 0;
|
|
|
|
// Marketplace entries -> plugin directories.
|
|
for (const mp of MARKETPLACES) {
|
|
const json = readJsonOrNull(repoRoot, mp.file);
|
|
if (json === null) {
|
|
errors.push({
|
|
file: mp.file,
|
|
message: `${mp.host} marketplace manifest ${mp.file} is missing or is not valid JSON.`,
|
|
});
|
|
continue;
|
|
}
|
|
const sources = mp.readSources(json);
|
|
if (sources.length === 0) {
|
|
errors.push({
|
|
file: mp.file,
|
|
message:
|
|
`${mp.file} lists no installable plugin source. The ${mp.host} ` +
|
|
`marketplace would resolve to nothing.`,
|
|
});
|
|
continue;
|
|
}
|
|
for (const src of sources) {
|
|
checked++;
|
|
if (src.value === null) {
|
|
errors.push({
|
|
file: mp.file,
|
|
message: `${mp.file} entry "${src.name}" has no usable source path.`,
|
|
});
|
|
continue;
|
|
}
|
|
const dir = path.posix.normalize(src.value).replace(/^\.\//, "").replace(/\/+$/, "");
|
|
if (probeKind(repoRoot, dir) !== "dir") {
|
|
errors.push({
|
|
file: mp.file,
|
|
message:
|
|
`${mp.file} entry "${src.name}" points at \`${dir}\`, which is not ` +
|
|
`a directory in this repository. \`/plugin marketplace add\` would fail.`,
|
|
});
|
|
continue;
|
|
}
|
|
if (probeKind(repoRoot, `${dir}/${mp.requiredManifest}`) !== "file") {
|
|
errors.push({
|
|
file: mp.file,
|
|
message:
|
|
`${mp.file} entry "${src.name}" points at \`${dir}\`, which has no ` +
|
|
`\`${mp.requiredManifest}\`. ${mp.host} would not recognise it as a plugin.`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Plugin manifests -> declared skills/commands paths.
|
|
const declared = checkPluginDeclarations(PLUGIN_DECLARATIONS, (file) =>
|
|
readJsonOrNull(repoRoot, file)
|
|
);
|
|
errors.push(...declared.errors);
|
|
warnings.push(...declared.warnings);
|
|
checked += PLUGIN_DECLARATIONS.length;
|
|
|
|
// Skill trees: non-empty, and every SKILL.md well-formed.
|
|
for (const root of SKILL_ROOTS) {
|
|
const dirs = listEntries(repoRoot, root, "dir");
|
|
if (dirs === null) {
|
|
errors.push({
|
|
file: root,
|
|
message: `Skill tree \`${root}\` is missing.`,
|
|
});
|
|
continue;
|
|
}
|
|
if (dirs.length === 0) {
|
|
errors.push({
|
|
file: root,
|
|
message:
|
|
`Skill tree \`${root}\` contains no skill directory. A plugin ` +
|
|
`declaring it would install without exposing any skill.`,
|
|
});
|
|
continue;
|
|
}
|
|
for (const dir of dirs) {
|
|
const file = `${root}/${dir}/SKILL.md`;
|
|
checked++;
|
|
// No size cap: a contract file is read whatever its size.
|
|
const content = readFileOrNull(path.join(repoRoot, file), { maxBytes: Infinity });
|
|
if (content === null) {
|
|
errors.push({
|
|
file,
|
|
message:
|
|
`\`${root}/${dir}/\` has no readable SKILL.md. Every directory in ` +
|
|
`a skill tree must be a loadable skill.`,
|
|
});
|
|
continue;
|
|
}
|
|
errors.push(...validateSkillFrontmatter(file, dir, content));
|
|
}
|
|
}
|
|
|
|
// Command directories: non-empty, and every prompt carries a description.
|
|
for (const dir of COMMAND_DIRS) {
|
|
const files = listEntries(repoRoot, dir, "file", ".md");
|
|
if (files === null) {
|
|
errors.push({ file: dir, message: `Command directory \`${dir}\` is missing.` });
|
|
continue;
|
|
}
|
|
if (files.length === 0) {
|
|
errors.push({
|
|
file: dir,
|
|
message:
|
|
`Command directory \`${dir}\` contains no \`.md\` prompt. The plugin ` +
|
|
`would install without exposing any slash command.`,
|
|
});
|
|
continue;
|
|
}
|
|
for (const name of files) {
|
|
const file = `${dir}/${name}`;
|
|
checked++;
|
|
const content = readFileOrNull(path.join(repoRoot, file), { maxBytes: Infinity });
|
|
if (content === null) {
|
|
errors.push({ file, message: `${file} is not readable.` });
|
|
continue;
|
|
}
|
|
errors.push(...validateCommandFrontmatter(file, content));
|
|
}
|
|
}
|
|
|
|
for (const err of errors) emitError(err.file, err.message, err.line);
|
|
for (const warn of warnings) emitWarning(warn.file, warn.message, warn.line);
|
|
|
|
if (errors.length === 0) {
|
|
console.log(
|
|
`Plugin manifest check passed: ${checked} declared path(s) and ` +
|
|
`manifest(s) resolve to real, non-empty targets` +
|
|
(warnings.length
|
|
? `, with ${warnings.length} unverified-assumption warning(s).`
|
|
: ".")
|
|
);
|
|
return 0;
|
|
}
|
|
emitError(
|
|
null,
|
|
`Plugin manifest check failed: ${errors.length} problem(s). A plugin whose ` +
|
|
`declared paths do not resolve installs cleanly and exposes nothing.`
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
function main(argv = process.argv.slice(2), env = process.env) {
|
|
const mode = (argv[0] || "all").toLowerCase();
|
|
const repoRoot = env.OCR_REPO_ROOT || process.cwd();
|
|
if (mode === "links" || mode === "link") return runLinksCheck({ repoRoot });
|
|
if (mode === "manifests" || mode === "manifest") return runManifestsCheck({ repoRoot });
|
|
if (mode === "all") {
|
|
const a = runLinksCheck({ repoRoot });
|
|
const b = runManifestsCheck({ repoRoot });
|
|
return a || b;
|
|
}
|
|
emitError(null, `Unknown mode "${mode}". Use "links", "manifests", or "all".`);
|
|
return 2;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
process.exit(main());
|
|
}
|
|
|
|
module.exports = {
|
|
REPO_SLUG,
|
|
LINK_ALLOW_MARKER,
|
|
MARKETPLACES,
|
|
PLUGIN_DECLARATIONS,
|
|
SKILL_ROOTS,
|
|
COMMAND_DIRS,
|
|
MIN_EXPECTED_LINKS,
|
|
MIN_EXPECTED_LINK_FILES,
|
|
DOC_EXTENSIONS,
|
|
MAX_SCAN_BYTES,
|
|
walkTextFiles,
|
|
repoLinkPattern,
|
|
escapeAnnotationData,
|
|
emitAnnotation,
|
|
checkCorpusFloor,
|
|
stripTrailingPunctuation,
|
|
toRepoPath,
|
|
extractRepoLinks,
|
|
validateRepoLinks,
|
|
parseFrontmatter,
|
|
unquoteScalar,
|
|
validateSkillFrontmatter,
|
|
validateCommandFrontmatter,
|
|
resolveDeclaredPath,
|
|
checkPluginDeclarations,
|
|
pluginEntries,
|
|
listEntries,
|
|
readFileOrNull,
|
|
runLinksCheck,
|
|
runManifestsCheck,
|
|
main,
|
|
};
|