feat(plugins): add source-control extension contracts (#2117)
This commit is contained in:
@@ -318,6 +318,35 @@ export function findAbsenceClaims(content) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split prose into sentence-sized claim scopes. API names elsewhere in the
|
||||
* same wrapped Markdown paragraph must not inherit an unrelated absence claim.
|
||||
* Dots inside identifiers such as `host.storage` are preserved because a
|
||||
* boundary requires following whitespace or the end of the paragraph.
|
||||
*
|
||||
* @param {string} text Paragraph text.
|
||||
* @returns {string[]} Sentence-sized fragments.
|
||||
*/
|
||||
function splitClaimScopes(text) {
|
||||
return text.split(/[.!?](?:\s+|$)/).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* API claims in harness Markdown name contracts as inline code. Restricting
|
||||
* matching to those spans prevents common host words such as `context` from
|
||||
* being attributed to unrelated phrases like "thread context is unavailable."
|
||||
*
|
||||
* @param {string} scope Sentence-sized absence-claim scope.
|
||||
* @param {RegExp} boundary Escaped live-name matcher.
|
||||
* @returns {boolean} Whether an inline-code span names the live API.
|
||||
*/
|
||||
function scopeMentionsLiveAPI(scope, boundary) {
|
||||
for (const match of scope.matchAll(/`([^`]+)`/g)) {
|
||||
if (boundary.test(match[1])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the fixed set of harness files this guard scans, de-duplicated by
|
||||
* physical (symlink-resolved) path.
|
||||
@@ -423,11 +452,14 @@ export async function validatePluginApiClaims({
|
||||
|
||||
const relFile = path.relative(root, file);
|
||||
for (const claim of claims) {
|
||||
const claimScopes = splitClaimScopes(claim.text).filter((scope) =>
|
||||
CLAIM_PATTERNS.some((pattern) => pattern.test(scope)),
|
||||
);
|
||||
for (const name of liveNames) {
|
||||
const boundary = new RegExp(
|
||||
`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
||||
);
|
||||
if (boundary.test(claim.text)) {
|
||||
if (claimScopes.some((scope) => scopeMentionsLiveAPI(scope, boundary))) {
|
||||
findings.push({ file: relFile, line: claim.line, api: name });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,43 @@ for the full contract.
|
||||
assert.deepEqual(findings, []);
|
||||
});
|
||||
|
||||
test("validatePluginApiClaims keeps unrelated absence prose sentence-scoped", async () => {
|
||||
const dir = await createFixture({
|
||||
"types.ts": FIXTURE_TYPES_TS,
|
||||
"AGENTS.md": `# Frontend
|
||||
|
||||
The Host can be unavailable during construction, so resolve it when handling
|
||||
work. Honor context cancellation and use host.storage for user settings.
|
||||
`,
|
||||
});
|
||||
|
||||
const { findings } = await validatePluginApiClaims({
|
||||
repoRoot: dir,
|
||||
typesPath: path.join(dir, "types.ts"),
|
||||
scanFiles: [path.join(dir, "AGENTS.md")],
|
||||
});
|
||||
|
||||
assert.deepEqual(findings, []);
|
||||
});
|
||||
|
||||
test("validatePluginApiClaims ignores ordinary words that share an API name", async () => {
|
||||
const dir = await createFixture({
|
||||
"types.ts": FIXTURE_TYPES_TS,
|
||||
"AGENTS.md": `# Reviews
|
||||
|
||||
Expand the review before deciding whether its thread context is unavailable.
|
||||
`,
|
||||
});
|
||||
|
||||
const { findings } = await validatePluginApiClaims({
|
||||
repoRoot: dir,
|
||||
typesPath: path.join(dir, "types.ts"),
|
||||
scanFiles: [path.join(dir, "AGENTS.md")],
|
||||
});
|
||||
|
||||
assert.deepEqual(findings, []);
|
||||
});
|
||||
|
||||
test("validatePluginApiClaims exits clean against the real kandev tree", async () => {
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const { findings } = await validatePluginApiClaims({ repoRoot });
|
||||
|
||||
@@ -298,14 +298,16 @@ function assertFeatureClipShape(clip) {
|
||||
"filenames",
|
||||
Boolean(
|
||||
clip.filenames &&
|
||||
typeof clip.filenames === "object" &&
|
||||
!Array.isArray(clip.filenames),
|
||||
typeof clip.filenames === "object" &&
|
||||
!Array.isArray(clip.filenames),
|
||||
),
|
||||
],
|
||||
[
|
||||
"files",
|
||||
Boolean(
|
||||
clip.files && typeof clip.files === "object" && !Array.isArray(clip.files),
|
||||
clip.files &&
|
||||
typeof clip.files === "object" &&
|
||||
!Array.isArray(clip.files),
|
||||
),
|
||||
],
|
||||
];
|
||||
@@ -335,10 +337,7 @@ function hasCompleteFeatureMediaEmbed(markdown, filenames) {
|
||||
...match[0].matchAll(
|
||||
/\b(webm|mp4|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
|
||||
),
|
||||
].map((attribute) => [
|
||||
attribute[1],
|
||||
attribute[2] ?? attribute[3],
|
||||
]),
|
||||
].map((attribute) => [attribute[1], attribute[2] ?? attribute[3]]),
|
||||
);
|
||||
|
||||
return Object.entries(filenames).every(
|
||||
@@ -738,7 +737,9 @@ async function collectFilesWithExtension(dir, extension) {
|
||||
function assertCompleteSurfaceCoverage(shipped, covered, excluded, label) {
|
||||
for (const value of covered) {
|
||||
if (excluded.has(value)) {
|
||||
throw new Error(`coverage.json both covers and excludes ${label}: ${value}`);
|
||||
throw new Error(
|
||||
`coverage.json both covers and excludes ${label}: ${value}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const value of shipped) {
|
||||
@@ -871,9 +872,7 @@ function assertExperimentalCalloutPlacement(file, markdown) {
|
||||
const heading = headings[headingIndex];
|
||||
if (
|
||||
!heading ||
|
||||
source
|
||||
.slice(heading.index + heading[0].length, callout.index)
|
||||
.trim()
|
||||
source.slice(heading.index + heading[0].length, callout.index).trim()
|
||||
) {
|
||||
throw new Error(
|
||||
`${file} experimental callouts must immediately follow a descriptive heading`,
|
||||
@@ -888,9 +887,7 @@ function assertExperimentalCalloutPlacement(file, markdown) {
|
||||
const sectionEnd = nextHeading?.index ?? source.length;
|
||||
const calloutBlock = source
|
||||
.slice(callout.index)
|
||||
.match(
|
||||
/^>\s*\[!EXPERIMENTAL\]\s*\r?\n(?:^>.*(?:\r?\n|$))*/im,
|
||||
)?.[0];
|
||||
.match(/^>\s*\[!EXPERIMENTAL\]\s*\r?\n(?:^>.*(?:\r?\n|$))*/im)?.[0];
|
||||
const sectionContent = source
|
||||
.slice(
|
||||
callout.index + (calloutBlock?.length ?? callout[0].length),
|
||||
|
||||
@@ -54,7 +54,9 @@ async function createCoverageRepo({
|
||||
"docs/public/index.md": validPage,
|
||||
"apps/web/src/settings-routes.tsx": `const SETTINGS_ROUTES = {\n${settingsRoutes
|
||||
.map((route) => ` "${route}": () => null,`)
|
||||
.join("\n")}\n};\n\nexport const SETTINGS_ROUTE_PATHS = new Set(Object.keys(SETTINGS_ROUTES));\n\nexport function SettingsRoutes() {}`,
|
||||
.join(
|
||||
"\n",
|
||||
)}\n};\n\nexport const SETTINGS_ROUTE_PATHS = new Set(Object.keys(SETTINGS_ROUTES));\n\nexport function SettingsRoutes() {}`,
|
||||
"apps/web/src/settings-routes.test.ts": "// settings route coverage",
|
||||
"apps/backend/internal/mcp/server/server.go": mcpTools
|
||||
.map((tool) => `mcp.NewTool("${tool}")`)
|
||||
@@ -214,6 +216,34 @@ test("accepts explicitly ordered pages with required frontmatter", async () => {
|
||||
await assert.doesNotReject(validatePublicDocs(dir));
|
||||
});
|
||||
|
||||
test("plugin authoring reference documents authenticated actions and live host writes", async () => {
|
||||
const [authoring, manifest, integrations] = await Promise.all([
|
||||
fs.readFile(
|
||||
path.join(process.cwd(), "docs/public/plugins-authoring.md"),
|
||||
"utf8",
|
||||
),
|
||||
fs.readFile(
|
||||
path.join(process.cwd(), "docs/public/plugins-manifest.md"),
|
||||
"utf8",
|
||||
),
|
||||
fs.readFile(
|
||||
path.join(process.cwd(), "docs/public/integrations.md"),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
|
||||
assert.match(authoring, /Authenticated declared actions/);
|
||||
assert.match(authoring, /workspaceId/);
|
||||
assert.match(authoring, /AbortSignal/);
|
||||
assert.match(authoring, /CreateTask/);
|
||||
assert.match(manifest, /\bscope\b/);
|
||||
assert.match(manifest, /repository_providers/);
|
||||
assert.match(manifest, /reference_sources/);
|
||||
assert.match(manifest, /webhooks\[\]\.access/);
|
||||
assert.match(manifest, /webhooks\[\]\.max_body_bytes/);
|
||||
assert.match(integrations, /Bitbucket/);
|
||||
});
|
||||
|
||||
test("rejects public pages that contain an em dash", async () => {
|
||||
const dir = await createDocs(
|
||||
{ "index.md": validPage.replace("Page body.", "Public — copy.") },
|
||||
|
||||
Reference in New Issue
Block a user