mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-10-20 06:19:47 +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>
860 lines
32 KiB
JavaScript
860 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
"use strict";
|
|
|
|
// Unit tests for scripts/github-actions/check-plugin-contract.js.
|
|
//
|
|
// Run via: node scripts/github-actions/check-plugin-contract.test.js
|
|
// (also wired as `npm run test:github-actions`).
|
|
//
|
|
// Plain Node + assert, no external deps and no `node --test`, mirroring
|
|
// check-translation-sync.test.js so both run on node >= 14.
|
|
|
|
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const {
|
|
LINK_ALLOW_MARKER,
|
|
MARKETPLACES,
|
|
MIN_EXPECTED_LINKS,
|
|
PLUGIN_DECLARATIONS,
|
|
SKILL_ROOTS,
|
|
COMMAND_DIRS,
|
|
escapeAnnotationData,
|
|
emitAnnotation,
|
|
walkTextFiles,
|
|
checkCorpusFloor,
|
|
stripTrailingPunctuation,
|
|
toRepoPath,
|
|
extractRepoLinks,
|
|
validateRepoLinks,
|
|
parseFrontmatter,
|
|
unquoteScalar,
|
|
validateSkillFrontmatter,
|
|
validateCommandFrontmatter,
|
|
resolveDeclaredPath,
|
|
checkPluginDeclarations,
|
|
pluginEntries,
|
|
listEntries,
|
|
readFileOrNull,
|
|
runLinksCheck,
|
|
runManifestsCheck,
|
|
main,
|
|
} = require(path.join(__dirname, "check-plugin-contract.js"));
|
|
|
|
const RAW = "https://raw.githubusercontent.com/alibaba/open-code-review/main/";
|
|
const BLOB = "https://github.com/alibaba/open-code-review/blob/main/";
|
|
const TREE = "https://github.com/alibaba/open-code-review/tree/main/";
|
|
|
|
// --- link extraction -------------------------------------------------------
|
|
|
|
function testExtractsRawCurlUrl() {
|
|
// The exact shape the install docs use: a `curl -o` with the URL on the
|
|
// continuation line.
|
|
const md = [
|
|
"```bash",
|
|
"mkdir -p .claude/commands",
|
|
"curl -o .claude/commands/review.md \\",
|
|
` ${RAW}plugins/open-code-review/claude-code/commands/review.md`,
|
|
"```",
|
|
].join("\n");
|
|
const links = extractRepoLinks(md);
|
|
assert.deepStrictEqual(links.length, 1);
|
|
assert.strictEqual(
|
|
links[0].repoPath,
|
|
"plugins/open-code-review/claude-code/commands/review.md"
|
|
);
|
|
assert.strictEqual(links[0].kind, "file");
|
|
assert.strictEqual(links[0].line, 4);
|
|
}
|
|
|
|
function testBlobIsFileAndTreeIsDir() {
|
|
const md = [`[a](${BLOB}skills/x/SKILL.md)`, `[b](${TREE}plugins/open-code-review)`].join(
|
|
"\n"
|
|
);
|
|
const links = extractRepoLinks(md);
|
|
assert.deepStrictEqual(
|
|
links.map((l) => `${l.kind}:${l.repoPath}`),
|
|
["file:skills/x/SKILL.md", "tree:plugins/open-code-review"]
|
|
);
|
|
}
|
|
|
|
function testMarkdownLinkParenNotSwallowed() {
|
|
// A markdown inline link must not absorb the closing paren into the path.
|
|
const links = extractRepoLinks(`See [the manifest](${BLOB}skills/a/SKILL.md).`);
|
|
assert.deepStrictEqual(links.map((l) => l.repoPath), ["skills/a/SKILL.md"]);
|
|
}
|
|
|
|
function testTrailingProsePunctuationTrimmed() {
|
|
// Translated docs end sentences with fullwidth punctuation directly after a
|
|
// bare URL; ASCII prose ends with a period or comma.
|
|
const md = [
|
|
`context: ${BLOB}plugins/open-code-review/claude-code/commands/review.md\uFF0C`,
|
|
`see ${BLOB}skills/a/SKILL.md.`,
|
|
`and ${BLOB}skills/b/SKILL.md,`,
|
|
].join("\n");
|
|
assert.deepStrictEqual(
|
|
extractRepoLinks(md).map((l) => l.repoPath),
|
|
[
|
|
"plugins/open-code-review/claude-code/commands/review.md",
|
|
"skills/a/SKILL.md",
|
|
"skills/b/SKILL.md",
|
|
]
|
|
);
|
|
}
|
|
|
|
function testFragmentAndQueryStripped() {
|
|
const md = `[x](${BLOB}AGENTS.md#code-style) and [y](${BLOB}README.md?plain=1)`;
|
|
assert.deepStrictEqual(
|
|
extractRepoLinks(md).map((l) => l.repoPath),
|
|
["AGENTS.md", "README.md"]
|
|
);
|
|
}
|
|
|
|
function testTrailingSlashOnTreeUrl() {
|
|
assert.deepStrictEqual(
|
|
extractRepoLinks(`[d](${TREE}plugins/open-code-review/)`).map((l) => l.repoPath),
|
|
["plugins/open-code-review"]
|
|
);
|
|
}
|
|
|
|
function testBackticksAndQuotesTerminatePath() {
|
|
const md = [`\`${RAW}install.sh\``, `"${RAW}action.yml"`].join("\n");
|
|
assert.deepStrictEqual(
|
|
extractRepoLinks(md).map((l) => l.repoPath),
|
|
["install.sh", "action.yml"]
|
|
);
|
|
}
|
|
|
|
function testSchemelessUrlStillMatches() {
|
|
assert.deepStrictEqual(
|
|
extractRepoLinks(`bare github.com/alibaba/open-code-review/blob/main/Makefile here`).map(
|
|
(l) => l.repoPath
|
|
),
|
|
["Makefile"]
|
|
);
|
|
}
|
|
|
|
function testOtherReposIgnored() {
|
|
const md = [
|
|
"https://raw.githubusercontent.com/other/project/main/nope.md",
|
|
"https://github.com/anthropics/claude-code/blob/main/nope.md",
|
|
].join("\n");
|
|
assert.deepStrictEqual(extractRepoLinks(md), []);
|
|
}
|
|
|
|
function testNonPathUrlsIgnored() {
|
|
// Issue, PR, release and bare-repo URLs carry no in-repo path to verify.
|
|
const md = [
|
|
"https://github.com/alibaba/open-code-review/issues/1100",
|
|
"https://github.com/alibaba/open-code-review/pull/424",
|
|
"https://github.com/alibaba/open-code-review/releases/download/v1.0.0/x",
|
|
"https://github.com/alibaba/open-code-review",
|
|
].join("\n");
|
|
assert.deepStrictEqual(extractRepoLinks(md), []);
|
|
}
|
|
|
|
function testAllowMarkerSkipsLine() {
|
|
const md = `${BLOB}gone/forever.md <!-- ${LINK_ALLOW_MARKER}: fixture -->`;
|
|
assert.deepStrictEqual(extractRepoLinks(md), []);
|
|
}
|
|
|
|
function testMultipleLinksOnOneLine() {
|
|
const md = `[a](${BLOB}A.md) and [b](${BLOB}B.md)`;
|
|
assert.deepStrictEqual(
|
|
extractRepoLinks(md).map((l) => `${l.line}:${l.repoPath}`),
|
|
["1:A.md", "1:B.md"]
|
|
);
|
|
}
|
|
|
|
function testStripTrailingPunctuationAndToRepoPath() {
|
|
assert.strictEqual(stripTrailingPunctuation("a/b.md\uFF0C"), "a/b.md");
|
|
assert.strictEqual(stripTrailingPunctuation("a/b.md"), "a/b.md");
|
|
assert.strictEqual(toRepoPath("a/b.md#x?y=1"), "a/b.md");
|
|
assert.strictEqual(toRepoPath("a/b%20c.md"), "a/b c.md");
|
|
assert.strictEqual(toRepoPath("#anchor-only"), null);
|
|
// A malformed percent escape must not throw; the raw form is kept so the
|
|
// existence probe reports it.
|
|
assert.strictEqual(toRepoPath("a/%zz.md"), "a/%zz.md");
|
|
}
|
|
|
|
// --- link validation -------------------------------------------------------
|
|
|
|
function fakeProbe(map) {
|
|
return (p) => (Object.prototype.hasOwnProperty.call(map, p) ? map[p] : null);
|
|
}
|
|
|
|
function testMissingPathIsBlockingError() {
|
|
const { errors, warnings } = validateRepoLinks(
|
|
[{ file: "docs/a.md", repoPath: "gone.md", kind: "file", line: 3 }],
|
|
fakeProbe({})
|
|
);
|
|
assert.strictEqual(warnings.length, 0);
|
|
assert.strictEqual(errors.length, 1);
|
|
assert.strictEqual(errors[0].file, "docs/a.md");
|
|
assert.strictEqual(errors[0].line, 3);
|
|
assert.ok(/does not exist/.test(errors[0].message));
|
|
}
|
|
|
|
function testExistingPathsPass() {
|
|
const { errors, warnings } = validateRepoLinks(
|
|
[
|
|
{ file: "d.md", repoPath: "a.md", kind: "file", line: 1 },
|
|
{ file: "d.md", repoPath: "dir", kind: "tree", line: 2 },
|
|
],
|
|
fakeProbe({ "a.md": "file", dir: "dir" })
|
|
);
|
|
assert.deepStrictEqual(errors, []);
|
|
assert.deepStrictEqual(warnings, []);
|
|
}
|
|
|
|
function testKindMismatchIsOnlyWarning() {
|
|
// GitHub redirects blob<->tree, so a kind mismatch must not fail the build.
|
|
const { errors, warnings } = validateRepoLinks(
|
|
[
|
|
{ file: "d.md", repoPath: "internal/agent", kind: "file", line: 4 },
|
|
{ file: "d.md", repoPath: "Makefile", kind: "tree", line: 5 },
|
|
],
|
|
fakeProbe({ "internal/agent": "dir", Makefile: "file" })
|
|
);
|
|
assert.deepStrictEqual(errors, []);
|
|
assert.strictEqual(warnings.length, 2);
|
|
assert.ok(/prefer a `tree\/main\/` URL/.test(warnings[0].message));
|
|
assert.ok(/prefer a `blob\/main\/` URL/.test(warnings[1].message));
|
|
}
|
|
|
|
// --- frontmatter -----------------------------------------------------------
|
|
|
|
function testParseFrontmatterTopLevelKeysOnly() {
|
|
const md = [
|
|
"---",
|
|
"name: open-code-review",
|
|
"description: >",
|
|
" folded line one",
|
|
" folded line two",
|
|
"metadata:",
|
|
" author: alibaba",
|
|
"---",
|
|
"",
|
|
"# Body",
|
|
"name: not-frontmatter",
|
|
].join("\n");
|
|
const { ok, keys } = parseFrontmatter(md);
|
|
assert.strictEqual(ok, true);
|
|
assert.strictEqual(keys.name.value, "open-code-review");
|
|
// A folded scalar's value is the block that follows, not the `>` indicator.
|
|
assert.strictEqual(keys.description.value, "folded line one folded line two");
|
|
assert.ok(keys.metadata);
|
|
// Indented keys belong to the parent block, not the top level.
|
|
assert.strictEqual(keys.author, undefined);
|
|
}
|
|
|
|
function testMultiLineScalarFormsAreNotSeenAsEmpty() {
|
|
// A plain multi-line scalar is valid YAML and loaders read it fine; treating
|
|
// it as empty would be a blocking false positive.
|
|
const plain = [
|
|
"---",
|
|
"name: a",
|
|
"description:",
|
|
" Performs AI-powered code review on Git changes.",
|
|
"---",
|
|
].join("\n");
|
|
assert.strictEqual(
|
|
parseFrontmatter(plain).keys.description.value,
|
|
"Performs AI-powered code review on Git changes."
|
|
);
|
|
assert.deepStrictEqual(validateSkillFrontmatter("skills/a/SKILL.md", "a", plain), []);
|
|
|
|
// Literal and chomped block indicators are syntax too, not content.
|
|
for (const indicator of ["|", ">-", "|+", ">2", ">-2", ">2-", "|2+"]) {
|
|
const md = ["---", "name: a", `description: ${indicator}`, " body text", "---"].join("\n");
|
|
assert.strictEqual(parseFrontmatter(md).keys.description.value, "body text", indicator);
|
|
}
|
|
|
|
// The reverse miss: an indicator with nothing under it really is empty.
|
|
const emptyBlock = ["---", "name: a", "description: >", "---"].join("\n");
|
|
assert.strictEqual(parseFrontmatter(emptyBlock).keys.description.value, "");
|
|
assert.strictEqual(
|
|
validateSkillFrontmatter("skills/a/SKILL.md", "a", emptyBlock).length,
|
|
1
|
|
);
|
|
|
|
// A block scalar spanning a blank line keeps both halves.
|
|
const withBlank = [
|
|
"---",
|
|
"name: a",
|
|
"description: >",
|
|
" first",
|
|
"",
|
|
" second",
|
|
"---",
|
|
].join("\n");
|
|
assert.strictEqual(parseFrontmatter(withBlank).keys.description.value, "first second");
|
|
|
|
// A column-0 sequence belongs to the key above it, not the top level.
|
|
const seq = ["---", "name: a", "keywords:", "- one", "- two", "---"].join("\n");
|
|
const seqKeys = parseFrontmatter(seq).keys;
|
|
assert.strictEqual(seqKeys.keywords.value, "- one - two");
|
|
assert.strictEqual(seqKeys.one, undefined);
|
|
}
|
|
|
|
function testParseFrontmatterRejectsMissingAndUnterminated() {
|
|
assert.strictEqual(parseFrontmatter("# No frontmatter").ok, false);
|
|
assert.strictEqual(parseFrontmatter("---\nname: x\nbody with no close").ok, false);
|
|
}
|
|
|
|
function testSkillFrontmatterNameMustMatchDirectory() {
|
|
const content = "---\nname: wrong-name\ndescription: x\n---\n";
|
|
const errors = validateSkillFrontmatter("skills/right/SKILL.md", "right", content);
|
|
assert.strictEqual(errors.length, 1);
|
|
assert.ok(/declares `name: wrong-name`/.test(errors[0].message));
|
|
assert.ok(/lives in `right\/`/.test(errors[0].message));
|
|
}
|
|
|
|
function testQuotedScalarIsUnquoted() {
|
|
// YAML permits `name: "x"`; comparing the quoted form against a directory
|
|
// name would fail with a message nobody could act on.
|
|
assert.strictEqual(unquoteScalar('"open-code-review"'), "open-code-review");
|
|
assert.strictEqual(unquoteScalar("'open-code-review'"), "open-code-review");
|
|
// Unbalanced or inner quotes are left alone.
|
|
assert.strictEqual(unquoteScalar("\"mismatched'"), "\"mismatched'");
|
|
assert.strictEqual(unquoteScalar('say "hi"'), 'say "hi"');
|
|
assert.deepStrictEqual(
|
|
validateSkillFrontmatter("skills/a/SKILL.md", "a", '---\nname: "a"\ndescription: x\n---\n'),
|
|
[]
|
|
);
|
|
}
|
|
|
|
function testProtoKeyDoesNotHitThePrototype() {
|
|
// Collected into a null-prototype map, so a key named __proto__ is an
|
|
// ordinary entry rather than a prototype setter call.
|
|
const { ok, keys } = parseFrontmatter("---\n__proto__: x\nname: a\n---\n");
|
|
assert.strictEqual(ok, true);
|
|
assert.strictEqual(keys.__proto__.value, "x");
|
|
assert.strictEqual(keys.name.value, "a");
|
|
}
|
|
|
|
function testSkillDescriptionMustBeNonEmpty() {
|
|
// Present-but-empty is as useless to an agent as absent, and the command
|
|
// check already held this line.
|
|
const errors = validateSkillFrontmatter(
|
|
"skills/a/SKILL.md",
|
|
"a",
|
|
"---\nname: a\ndescription:\n---\n"
|
|
);
|
|
assert.strictEqual(errors.length, 1);
|
|
assert.ok(/missing a non-empty `description`/.test(errors[0].message));
|
|
}
|
|
|
|
function testSkillFrontmatterHappyPath() {
|
|
const content = "---\nname: right\ndescription: >\n text\n---\n";
|
|
assert.deepStrictEqual(
|
|
validateSkillFrontmatter("skills/right/SKILL.md", "right", content),
|
|
[]
|
|
);
|
|
}
|
|
|
|
function testSkillFrontmatterMissingKeys() {
|
|
const errors = validateSkillFrontmatter("skills/a/SKILL.md", "a", "---\nlicense: x\n---\n");
|
|
assert.strictEqual(errors.length, 2);
|
|
assert.ok(errors.some((e) => /missing `name`/.test(e.message)));
|
|
assert.ok(errors.some((e) => /missing a non-empty `description`/.test(e.message)));
|
|
}
|
|
|
|
function testSkillFrontmatterAbsentBlock() {
|
|
const errors = validateSkillFrontmatter("skills/a/SKILL.md", "a", "# Just a heading\n");
|
|
assert.strictEqual(errors.length, 1);
|
|
assert.ok(/no terminated YAML frontmatter/.test(errors[0].message));
|
|
}
|
|
|
|
function testCommandFrontmatter() {
|
|
assert.deepStrictEqual(
|
|
validateCommandFrontmatter("c/review.md", "---\ndescription: Run OCR.\n---\n"),
|
|
[]
|
|
);
|
|
// Present but empty is as useless to the host as absent.
|
|
assert.strictEqual(
|
|
validateCommandFrontmatter("c/review.md", "---\ndescription:\n---\n").length,
|
|
1
|
|
);
|
|
assert.strictEqual(validateCommandFrontmatter("c/review.md", "Run OCR.\n").length, 1);
|
|
}
|
|
|
|
// --- declared path resolution ---------------------------------------------
|
|
|
|
function testResolveDeclaredPath() {
|
|
const codex = "plugins/open-code-review/.codex-plugin/plugin.json";
|
|
// plugin-root = parent of the manifest directory.
|
|
assert.strictEqual(
|
|
resolveDeclaredPath(codex, "plugin-root", "./skills/"),
|
|
"plugins/open-code-review/skills"
|
|
);
|
|
// manifest-dir = the directory holding the manifest.
|
|
assert.strictEqual(
|
|
resolveDeclaredPath(codex, "manifest-dir", "../skills/"),
|
|
"plugins/open-code-review/skills"
|
|
);
|
|
assert.strictEqual(
|
|
resolveDeclaredPath(codex, "manifest-dir", "./skills/"),
|
|
"plugins/open-code-review/.codex-plugin/skills"
|
|
);
|
|
assert.strictEqual(
|
|
resolveDeclaredPath(
|
|
"plugins/open-code-review/claude-code/.claude-plugin/plugin.json",
|
|
"plugin-root",
|
|
"./commands"
|
|
),
|
|
"plugins/open-code-review/claude-code/commands"
|
|
);
|
|
}
|
|
|
|
const CODEX_SPEC = {
|
|
file: "p/.codex-plugin/plugin.json",
|
|
field: "skills",
|
|
base: "plugin-root",
|
|
target: "p/skills",
|
|
kind: "skills",
|
|
host: "Codex",
|
|
};
|
|
|
|
function testAbsoluteDeclaredPathIsRejected() {
|
|
// path.join would treat "/skills/" as a relative fragment and collapse it
|
|
// onto the expected target, certifying a manifest no host can load.
|
|
const codex = "plugins/open-code-review/.codex-plugin/plugin.json";
|
|
assert.strictEqual(resolveDeclaredPath(codex, "plugin-root", "/skills/"), "/skills");
|
|
const { errors } = checkPluginDeclarations([CODEX_SPEC], () => ({ skills: "/skills/" }));
|
|
assert.strictEqual(errors.length, 1);
|
|
assert.ok(/resolves to `\/skills`/.test(errors[0].message));
|
|
}
|
|
|
|
function testCheckPluginDeclarationsHappyPath() {
|
|
const { errors, warnings } = checkPluginDeclarations([CODEX_SPEC], () => ({
|
|
skills: "./skills/",
|
|
}));
|
|
assert.deepStrictEqual(errors, []);
|
|
assert.deepStrictEqual(warnings, []);
|
|
}
|
|
|
|
function testUnverifiedBaseAlwaysWarnsButNeverBlocks() {
|
|
// An assumption the check cannot validate must be stated on every run rather
|
|
// than passing silently, but it must not fail the build either.
|
|
const spec = { ...CODEX_SPEC, unverifiedBase: "spec not checked." };
|
|
const { errors, warnings } = checkPluginDeclarations([spec], () => ({
|
|
skills: "./skills/",
|
|
}));
|
|
assert.deepStrictEqual(errors, []);
|
|
assert.strictEqual(warnings.length, 1);
|
|
assert.ok(/UNVERIFIED resolution base \(plugin-root\)/.test(warnings[0].message));
|
|
assert.ok(/spec not checked\./.test(warnings[0].message));
|
|
}
|
|
|
|
function testRealCursorEntryCarriesTheUnverifiedFlag() {
|
|
// Guards the reasoning recorded in PLUGIN_DECLARATIONS: the Cursor manifest
|
|
// resolves `../skills/` from the manifest directory while its two siblings
|
|
// resolve from the plugin root, and that asymmetry is unverified. If someone
|
|
// confirms Cursor's spec and drops the flag, this test should be dropped with it.
|
|
const cursor = PLUGIN_DECLARATIONS.find((d) => d.host === "Cursor");
|
|
assert.ok(cursor, "expected a Cursor declaration");
|
|
assert.strictEqual(cursor.base, "manifest-dir");
|
|
assert.ok(
|
|
cursor.unverifiedBase,
|
|
"the Cursor base differs from its siblings and must stay flagged as unverified"
|
|
);
|
|
}
|
|
|
|
function testRealKimiEntriesResolveFromTheRepoRoot() {
|
|
// Guards the layout the Kimi plugin depends on: Kimi's GitHub install makes
|
|
// the repository root the plugin root, so the manifest must live at the repo
|
|
// root and its paths must resolve from there down into plugins/.
|
|
const kimi = PLUGIN_DECLARATIONS.filter((d) => d.host === "Kimi Code");
|
|
assert.strictEqual(kimi.length, 2);
|
|
for (const d of kimi) {
|
|
assert.strictEqual(d.file, ".kimi-plugin/plugin.json");
|
|
assert.strictEqual(d.base, "plugin-root");
|
|
}
|
|
assert.strictEqual(
|
|
resolveDeclaredPath(
|
|
".kimi-plugin/plugin.json",
|
|
"plugin-root",
|
|
"./plugins/open-code-review/skills/"
|
|
),
|
|
"plugins/open-code-review/skills"
|
|
);
|
|
assert.strictEqual(
|
|
resolveDeclaredPath(
|
|
".kimi-plugin/plugin.json",
|
|
"plugin-root",
|
|
"./plugins/open-code-review/kimi-code/commands/"
|
|
),
|
|
"plugins/open-code-review/kimi-code/commands"
|
|
);
|
|
}
|
|
|
|
function testCheckPluginDeclarationsCatchesDrift() {
|
|
const specs = [CODEX_SPEC];
|
|
// Someone renamed the tree in the manifest but not on disk (or vice versa).
|
|
const drifted = checkPluginDeclarations(specs, () => ({ skills: "./agent-skills/" }));
|
|
assert.strictEqual(drifted.errors.length, 1);
|
|
assert.ok(/resolves to `p\/agent-skills`/.test(drifted.errors[0].message));
|
|
|
|
// Field dropped entirely: installs clean, exposes nothing.
|
|
const dropped = checkPluginDeclarations(specs, () => ({}));
|
|
assert.strictEqual(dropped.errors.length, 1);
|
|
assert.ok(/does not declare a `skills` path/.test(dropped.errors[0].message));
|
|
|
|
// Non-string value.
|
|
const wrongType = checkPluginDeclarations(specs, () => ({ skills: ["./skills/"] }));
|
|
assert.strictEqual(wrongType.errors.length, 1);
|
|
|
|
// Manifest missing or unparseable.
|
|
const missing = checkPluginDeclarations(specs, () => null);
|
|
assert.strictEqual(missing.errors.length, 1);
|
|
assert.ok(/missing or is not valid JSON/.test(missing.errors[0].message));
|
|
}
|
|
|
|
// --- malformed marketplace manifests ---------------------------------------
|
|
|
|
function testPluginEntriesToleratesMalformedShapes() {
|
|
// A malformed manifest must degrade to "lists no installable plugin source",
|
|
// reported as an annotation, rather than throwing a TypeError and exiting on
|
|
// a stack trace.
|
|
assert.deepStrictEqual(pluginEntries(null), []);
|
|
assert.deepStrictEqual(pluginEntries({}), []);
|
|
assert.deepStrictEqual(pluginEntries({ plugins: {} }), []);
|
|
assert.deepStrictEqual(pluginEntries({ plugins: "nope" }), []);
|
|
assert.deepStrictEqual(pluginEntries({ plugins: [null, "x", 7] }), []);
|
|
assert.deepStrictEqual(pluginEntries({ plugins: [{ name: "a" }] }), [{ name: "a" }]);
|
|
}
|
|
|
|
function testMarketplaceReadersDoNotThrowOnMalformedInput() {
|
|
for (const mp of MARKETPLACES) {
|
|
for (const bad of [null, {}, { plugins: {} }, { plugins: [null] }, { plugins: [{}] }]) {
|
|
assert.doesNotThrow(() => mp.readSources(bad), `${mp.file} on ${JSON.stringify(bad)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- configuration sanity --------------------------------------------------
|
|
|
|
function testDeclarationTargetsAreCoveredByTheTreesWeValidate() {
|
|
// Every skills/commands target a manifest points at must also be a tree this
|
|
// script validates the contents of; otherwise a manifest could point at a
|
|
// directory nobody checks for well-formed skills.
|
|
const validated = new Set([...SKILL_ROOTS, ...COMMAND_DIRS]);
|
|
for (const spec of PLUGIN_DECLARATIONS) {
|
|
assert.ok(
|
|
validated.has(spec.target),
|
|
`${spec.file} targets ${spec.target}, which is not in SKILL_ROOTS or COMMAND_DIRS`
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- end-to-end over temp fixtures ----------------------------------------
|
|
//
|
|
// These runners are deliberately NOT pointed at the real repository. The two
|
|
// workflow steps already run them against the work tree, and doing it here too
|
|
// would mean a stale doc link anywhere fails this unit test with a stack trace
|
|
// instead of the `::error file=...,line=...` annotations the checks exist to
|
|
// produce — and would fail the unrelated Action Contract workflow, which also
|
|
// runs `npm run test:github-actions`.
|
|
|
|
function withTempDir(fn) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ocr-plugin-contract-"));
|
|
try {
|
|
return fn(dir);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function testListEntriesResolvesSymlinks() {
|
|
withTempDir((tmp) => {
|
|
// A skills tree whose entries are symlinks - the layout someone would reach
|
|
// for to stop maintaining two copies of the same skills. A Dirent reports a
|
|
// symlink as neither a file nor a directory, so filtering on isDirectory()
|
|
// alone would report this tree as empty and block the build.
|
|
fs.mkdirSync(path.join(tmp, "real", "skill-a"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmp, "real", "skill-a", "SKILL.md"),
|
|
"---\nname: skill-a\n---\n"
|
|
);
|
|
fs.writeFileSync(path.join(tmp, "real", "prompt.md"), "---\ndescription: x\n---\n");
|
|
fs.mkdirSync(path.join(tmp, "tree"));
|
|
fs.symlinkSync(path.join(tmp, "real", "skill-a"), path.join(tmp, "tree", "linked-skill"));
|
|
fs.symlinkSync(path.join(tmp, "real", "prompt.md"), path.join(tmp, "tree", "linked.md"));
|
|
fs.symlinkSync(path.join(tmp, "real", "gone"), path.join(tmp, "tree", "dangling"));
|
|
fs.mkdirSync(path.join(tmp, "tree", "plain-dir"));
|
|
fs.writeFileSync(path.join(tmp, "tree", "plain.md"), "x");
|
|
|
|
assert.deepStrictEqual(listEntries(tmp, "tree", "dir"), ["linked-skill", "plain-dir"]);
|
|
assert.deepStrictEqual(listEntries(tmp, "tree", "file", ".md"), ["linked.md", "plain.md"]);
|
|
// A dangling link belongs to neither list rather than crashing the walk.
|
|
assert.ok(!listEntries(tmp, "tree", "dir").includes("dangling"));
|
|
assert.ok(!listEntries(tmp, "tree", "file").includes("dangling"));
|
|
// An unreadable directory is null, distinct from an empty one.
|
|
assert.strictEqual(listEntries(tmp, "no-such-dir", "dir"), null);
|
|
assert.deepStrictEqual(listEntries(tmp, "tree/plain-dir", "dir"), []);
|
|
});
|
|
}
|
|
|
|
function testWalkFollowsSymlinksWithoutLooping() {
|
|
withTempDir((tmp) => {
|
|
// Symlinked docs must be scanned, matching listEntries; a link pointing at
|
|
// an ancestor must not loop, and a linked subtree must not be walked twice.
|
|
fs.mkdirSync(path.join(tmp, "real"));
|
|
fs.writeFileSync(path.join(tmp, "real", "doc.md"), "x");
|
|
fs.writeFileSync(path.join(tmp, "plain.md"), "x");
|
|
fs.symlinkSync(path.join(tmp, "real", "doc.md"), path.join(tmp, "linked.md"));
|
|
fs.symlinkSync(path.join(tmp, "real"), path.join(tmp, "linked-dir"));
|
|
fs.symlinkSync(tmp, path.join(tmp, "loop"));
|
|
fs.symlinkSync(path.join(tmp, "nowhere"), path.join(tmp, "dangling.md"));
|
|
|
|
const files = walkTextFiles(tmp);
|
|
assert.ok(files.includes("linked.md"), "a symlinked doc must be scanned");
|
|
assert.ok(files.includes("plain.md"));
|
|
// The directory alias and the real directory hold the same file. Whichever
|
|
// name the sorted walk reaches first wins and the other is skipped as
|
|
// already-seen, so `doc.md` is yielded exactly once under exactly one of
|
|
// the two paths — never twice, and never not at all.
|
|
const docPaths = files.filter((f) => f.endsWith("doc.md") && f !== "linked.md");
|
|
assert.deepStrictEqual(docPaths, ["linked-dir/doc.md"], JSON.stringify(files));
|
|
assert.ok(!files.includes("dangling.md"), "a dangling link is not a file");
|
|
// The self-loop terminated: the walk returned at all, and no path repeats.
|
|
assert.strictEqual(new Set(files).size, files.length);
|
|
// Nothing was reached through the self-loop alias.
|
|
assert.ok(!files.some((f) => f.startsWith("loop/")), JSON.stringify(files));
|
|
});
|
|
}
|
|
|
|
function testUnscannedDocsAreReportedNotSkippedSilently() {
|
|
withTempDir((tmp) => {
|
|
// A doc too large for the scan is a hole in the corpus; passing over it in
|
|
// silence would contradict the fail-closed floor.
|
|
fs.writeFileSync(path.join(tmp, "a.md"), "x");
|
|
fs.writeFileSync(path.join(tmp, "doc.md"), `link: ${BLOB}a.md`);
|
|
fs.writeFileSync(path.join(tmp, "huge.md"), "y".repeat(1024));
|
|
fs.writeFileSync(path.join(tmp, "blob.bin"), Buffer.from([0x00, 0x01]));
|
|
|
|
const { code, lines } = withCapturedStdout(() =>
|
|
runLinksCheck({ repoRoot: tmp, minLinks: 1, minFiles: 1, maxScanBytes: 512 })
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
const warnings = lines.filter((l) => l.startsWith("::warning"));
|
|
assert.strictEqual(warnings.length, 1, warnings.join("\n"));
|
|
assert.ok(/huge\.md/.test(warnings[0]));
|
|
assert.ok(/links were NOT verified/.test(warnings[0]));
|
|
// A binary file is not documentation, so it is not reported.
|
|
assert.ok(!warnings.some((l) => /blob\.bin/.test(l)));
|
|
assert.ok(lines.some((l) => /1 unscanned doc\(s\)/.test(l)));
|
|
});
|
|
}
|
|
|
|
function testReadFileOrNullSizeCapIsOptional() {
|
|
withTempDir((tmp) => {
|
|
// The size cap exists for the link scan; contract files are read whatever
|
|
// their size, so an oversized-but-valid SKILL.md is never mistaken for a
|
|
// missing one.
|
|
const file = path.join(tmp, "big.md");
|
|
const body = "x".repeat(4096);
|
|
fs.writeFileSync(file, body);
|
|
assert.strictEqual(readFileOrNull(file, { maxBytes: 128 }), null);
|
|
assert.strictEqual(readFileOrNull(file, { maxBytes: Infinity }), body);
|
|
assert.strictEqual(readFileOrNull(path.join(tmp, "absent.md")), null);
|
|
// A binary file is not text, whatever the cap.
|
|
const bin = path.join(tmp, "bin.dat");
|
|
fs.writeFileSync(bin, Buffer.from([0x61, 0x00, 0x62]));
|
|
assert.strictEqual(readFileOrNull(bin, { maxBytes: Infinity }), null);
|
|
// A directory is not a file.
|
|
assert.strictEqual(readFileOrNull(tmp, { maxBytes: Infinity }), null);
|
|
});
|
|
}
|
|
|
|
function withCapturedStdout(fn) {
|
|
const orig = console.log;
|
|
const lines = [];
|
|
console.log = (...args) => lines.push(args.join(" "));
|
|
try {
|
|
const code = fn();
|
|
return { code, lines };
|
|
} finally {
|
|
console.log = orig;
|
|
}
|
|
}
|
|
|
|
function testCorpusFloorFailsClosed() {
|
|
// A scan that finds nothing must fail rather than report success.
|
|
assert.strictEqual(checkCorpusFloor(146, 53, 50, 10), null);
|
|
assert.strictEqual(checkCorpusFloor(50, 10, 50, 10), null);
|
|
assert.ok(/below the floor of 50\/10/.test(checkCorpusFloor(0, 0, 50, 10)));
|
|
assert.ok(/Only 49 link\(s\)/.test(checkCorpusFloor(49, 20, 50, 10)));
|
|
// Enough links but concentrated in too few files: also a broken walk.
|
|
assert.ok(checkCorpusFloor(200, 9, 50, 10));
|
|
// The shipped floor must stay meaningful.
|
|
assert.ok(MIN_EXPECTED_LINKS >= 10);
|
|
}
|
|
|
|
function testLinksRunnerOnFixture() {
|
|
withTempDir((tmp) => {
|
|
fs.writeFileSync(path.join(tmp, "present.md"), "hello");
|
|
fs.mkdirSync(path.join(tmp, "adir"));
|
|
fs.writeFileSync(
|
|
path.join(tmp, "doc.md"),
|
|
[
|
|
`ok file: ${BLOB}present.md`,
|
|
`ok dir: ${TREE}adir`,
|
|
`kind warning: ${BLOB}adir`,
|
|
`broken: ${RAW}gone/missing.md`,
|
|
].join("\n")
|
|
);
|
|
// Floors lowered: the fixture corpus is intentionally tiny.
|
|
const { code, lines } = withCapturedStdout(() =>
|
|
runLinksCheck({ repoRoot: tmp, minLinks: 1, minFiles: 1 })
|
|
);
|
|
assert.strictEqual(code, 1);
|
|
const errors = lines.filter((l) => l.startsWith("::error"));
|
|
const warnings = lines.filter((l) => l.startsWith("::warning"));
|
|
// Exactly one blocking error (the missing path), and the kind mismatch
|
|
// stays advisory.
|
|
assert.strictEqual(errors.filter((l) => /does not exist/.test(l)).length, 1);
|
|
assert.ok(errors.some((l) => /gone\/missing\.md/.test(l)));
|
|
assert.strictEqual(warnings.length, 1);
|
|
assert.ok(/prefer a `tree\/main\/` URL/.test(warnings[0]));
|
|
});
|
|
}
|
|
|
|
function testLinksRunnerPassesAndReportsCounts() {
|
|
withTempDir((tmp) => {
|
|
fs.writeFileSync(path.join(tmp, "a.md"), "x");
|
|
fs.writeFileSync(path.join(tmp, "doc.md"), `link: ${BLOB}a.md`);
|
|
const { code, lines } = withCapturedStdout(() =>
|
|
runLinksCheck({ repoRoot: tmp, minLinks: 1, minFiles: 1 })
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.ok(lines.some((l) => /In-repo link check passed: 1 link\(s\) across 1 file/.test(l)));
|
|
});
|
|
}
|
|
|
|
function testLinksRunnerTripsTheFloorOnAnEmptyTree() {
|
|
withTempDir((tmp) => {
|
|
const { code, lines } = withCapturedStdout(() => runLinksCheck({ repoRoot: tmp }));
|
|
assert.strictEqual(code, 1);
|
|
assert.ok(lines.some((l) => /below the floor/.test(l)));
|
|
});
|
|
}
|
|
|
|
function testManifestsRunnerReportsMissingManifests() {
|
|
withTempDir((tmp) => {
|
|
// An empty tree: every declared manifest and tree is absent, and each is
|
|
// reported as an annotation rather than throwing.
|
|
const { code, lines } = withCapturedStdout(() => runManifestsCheck({ repoRoot: tmp }));
|
|
assert.strictEqual(code, 1);
|
|
assert.ok(lines.some((l) => /marketplace manifest .* is missing/.test(l)));
|
|
assert.ok(lines.some((l) => /Skill tree `skills` is missing/.test(l)));
|
|
assert.ok(lines.some((l) => /Command directory .* is missing/.test(l)));
|
|
});
|
|
}
|
|
|
|
function testAnnotationDataIsEscaped() {
|
|
// GitHub truncates an annotation at an unescaped newline, losing exactly the
|
|
// part worth reading. Manifest values and frontmatter scalars are
|
|
// interpolated into these messages, so any of them can carry one.
|
|
assert.strictEqual(escapeAnnotationData("a\nb"), "a%0Ab");
|
|
assert.strictEqual(escapeAnnotationData("a\r\nb"), "a%0D%0Ab");
|
|
assert.strictEqual(escapeAnnotationData("100%"), "100%25");
|
|
assert.strictEqual(escapeAnnotationData("plain"), "plain");
|
|
|
|
// Escaping belongs to the emitter, so no call site can forget it.
|
|
const { lines } = withCapturedStdout(() => {
|
|
emitAnnotation("warning", "a.json", "one\ntwo", 7);
|
|
return 0;
|
|
});
|
|
assert.strictEqual(lines[0], "::warning file=a.json,line=7::one%0Atwo");
|
|
}
|
|
|
|
function testUnknownModeIsAnError() {
|
|
const { code, lines } = withCapturedStdout(() => main(["nope"], {}));
|
|
assert.strictEqual(code, 2);
|
|
assert.ok(lines.some((l) => /Unknown mode/.test(l)));
|
|
}
|
|
|
|
function testMainDispatchesEachMode() {
|
|
withTempDir((tmp) => {
|
|
// Both modes reach their runner and fail closed on an empty fixture tree.
|
|
const links = withCapturedStdout(() => main(["links"], { OCR_REPO_ROOT: tmp }));
|
|
assert.strictEqual(links.code, 1);
|
|
assert.ok(links.lines.some((l) => /below the floor/.test(l)));
|
|
|
|
const manifests = withCapturedStdout(() => main(["manifests"], { OCR_REPO_ROOT: tmp }));
|
|
assert.strictEqual(manifests.code, 1);
|
|
assert.ok(manifests.lines.some((l) => /marketplace manifest/.test(l)));
|
|
|
|
// `all` fails if either does.
|
|
const all = withCapturedStdout(() => main([], { OCR_REPO_ROOT: tmp }));
|
|
assert.strictEqual(all.code, 1);
|
|
});
|
|
}
|
|
|
|
function main_() {
|
|
testExtractsRawCurlUrl();
|
|
testBlobIsFileAndTreeIsDir();
|
|
testMarkdownLinkParenNotSwallowed();
|
|
testTrailingProsePunctuationTrimmed();
|
|
testFragmentAndQueryStripped();
|
|
testTrailingSlashOnTreeUrl();
|
|
testBackticksAndQuotesTerminatePath();
|
|
testSchemelessUrlStillMatches();
|
|
testOtherReposIgnored();
|
|
testNonPathUrlsIgnored();
|
|
testAllowMarkerSkipsLine();
|
|
testMultipleLinksOnOneLine();
|
|
testStripTrailingPunctuationAndToRepoPath();
|
|
testMissingPathIsBlockingError();
|
|
testExistingPathsPass();
|
|
testKindMismatchIsOnlyWarning();
|
|
testParseFrontmatterTopLevelKeysOnly();
|
|
testParseFrontmatterRejectsMissingAndUnterminated();
|
|
testMultiLineScalarFormsAreNotSeenAsEmpty();
|
|
testSkillFrontmatterNameMustMatchDirectory();
|
|
testQuotedScalarIsUnquoted();
|
|
testProtoKeyDoesNotHitThePrototype();
|
|
testSkillDescriptionMustBeNonEmpty();
|
|
testSkillFrontmatterHappyPath();
|
|
testSkillFrontmatterMissingKeys();
|
|
testSkillFrontmatterAbsentBlock();
|
|
testCommandFrontmatter();
|
|
testResolveDeclaredPath();
|
|
testAbsoluteDeclaredPathIsRejected();
|
|
testCheckPluginDeclarationsHappyPath();
|
|
testUnverifiedBaseAlwaysWarnsButNeverBlocks();
|
|
testRealCursorEntryCarriesTheUnverifiedFlag();
|
|
testRealKimiEntriesResolveFromTheRepoRoot();
|
|
testCheckPluginDeclarationsCatchesDrift();
|
|
testPluginEntriesToleratesMalformedShapes();
|
|
testMarketplaceReadersDoNotThrowOnMalformedInput();
|
|
testListEntriesResolvesSymlinks();
|
|
testWalkFollowsSymlinksWithoutLooping();
|
|
testUnscannedDocsAreReportedNotSkippedSilently();
|
|
testReadFileOrNullSizeCapIsOptional();
|
|
testDeclarationTargetsAreCoveredByTheTreesWeValidate();
|
|
testCorpusFloorFailsClosed();
|
|
testLinksRunnerOnFixture();
|
|
testLinksRunnerPassesAndReportsCounts();
|
|
testLinksRunnerTripsTheFloorOnAnEmptyTree();
|
|
testManifestsRunnerReportsMissingManifests();
|
|
testAnnotationDataIsEscaped();
|
|
testUnknownModeIsAnError();
|
|
testMainDispatchesEachMode();
|
|
console.log("All check-plugin-contract tests passed.");
|
|
}
|
|
|
|
try {
|
|
main_();
|
|
} catch (err) {
|
|
console.error(err);
|
|
process.exit(1);
|
|
}
|