Migrate extension plugin materialization to extensions container (#2334)

* Migrate extension plugin materialization layout

Materialize extension plugins into a dedicated extensions/ container, validate the new manifest convention, and bump extension plugin versions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Keep extension manifests source-compatible

Restore source extension manifests to "extensions": "." while preserving materialization-time rewrite to "extensions" in distribution output.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Validate canvas extension layout for external submissions

Add intake and quality-gate checks for canvas-tagged external plugins so they must include extensions/extension.mjs and optional manifest extensions is validated when present.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Fix plugin clean extension pass and typo guard text

Declare EXTENSIONS_DIR in clean-materialized-plugins and run extension cleanup once after plugin cleanup. Also normalize misspelled-key detection strings to satisfy spelling checks without changing validation behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Proper codespell fix

* Separate canvas structure quality gate status

Track canvas structure as its own gate status and output, include it in aggregate summaries, and enforce Git object types so extensions/ is a tree and extensions/extension.mjs is a blob.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-07-17 18:04:03 +10:00
committed by GitHub
parent 38ab1360a4
commit 40665c23b7
32 changed files with 583 additions and 49 deletions
+166 -1
View File
@@ -8,6 +8,7 @@ import { spawnSync } from "child_process";
import { runLint, LintConsoleReporter } from "@microsoft/vally";
const MAX_OUTPUT_LENGTH = 12000;
const EXTERNAL_CANVAS_KEYWORD = "canvas";
const INFRA_ERROR_PATTERNS = [
/\b401\b/,
@@ -69,6 +70,12 @@ function normalizePluginPath(pluginPath) {
return normalized;
}
function hasCanvasKeyword(plugin) {
return (plugin?.keywords ?? []).some(
(keyword) => String(keyword).trim().toLowerCase() === EXTERNAL_CANVAS_KEYWORD,
);
}
function resolveFetchSpec(pluginSource) {
if (pluginSource.sha) {
return pluginSource.sha;
@@ -467,6 +474,148 @@ function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) {
};
}
function checkPathExistsAtLocator(repoDir, locator, repoPath, expectedType) {
const result = runCommand("git", ["cat-file", "-e", `${locator}:${repoPath}`], { cwd: repoDir });
if (result.exitCode === 0) {
if (!expectedType) {
return { exists: true, output: "" };
}
const typeResult = runCommand("git", ["cat-file", "-t", `${locator}:${repoPath}`], { cwd: repoDir });
if (typeResult.exitCode !== 0) {
return {
exists: false,
output: `Unable to verify path "${repoPath}" type at "${locator}": ${typeResult.output}`,
};
}
const actualType = String(typeResult.stdout ?? "").trim();
if (actualType !== expectedType) {
return {
exists: false,
output: "",
kindMismatch: true,
actualType,
};
}
return { exists: true, output: "" };
}
const normalizedOutput = String(result.output ?? "").toLowerCase();
if (
normalizedOutput.includes("not a valid object name")
|| normalizedOutput.includes("path '")
|| normalizedOutput.includes("does not exist")
) {
return { exists: false, output: "" };
}
return {
exists: false,
output: `Unable to verify path "${repoPath}" at "${locator}": ${result.output}`,
};
}
export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
if (!hasCanvasKeyword(plugin)) {
return {
status: "not_run",
output: "Canvas structure gate skipped because plugin is not tagged with \"canvas\".",
};
}
const normalizedPluginPath = normalizePluginPath(plugin?.source?.path || "/");
const locators = [plugin?.source?.ref, plugin?.source?.sha]
.filter((value) => typeof value === "string" && value.trim().length > 0)
.map((value) => value.trim())
.filter((value, index, values) => values.indexOf(value) === index);
if (locators.length === 0) {
return {
status: "not_run",
output: "Canvas structure gate skipped because neither source.ref nor source.sha was provided.",
};
}
const extensionsDir = toPosixPath(normalizedPluginPath, "extensions");
const extensionEntryPoint = toPosixPath(extensionsDir, "extension.mjs");
let hasFailure = false;
let hasInfraError = false;
const messages = [];
for (const locator of locators) {
if (locator !== primaryFetchSpec) {
const fetchResult = fetchLocatorIntoRepo(repoDir, locator);
if (fetchResult.status === "fail") {
hasFailure = true;
messages.push(`- ${locator}: ${fetchResult.output}`);
continue;
}
if (fetchResult.status === "infra_error") {
hasInfraError = true;
messages.push(`- ${locator}: ${fetchResult.output}`);
continue;
}
}
const extensionDirCheck = checkPathExistsAtLocator(repoDir, locator, extensionsDir, "tree");
if (extensionDirCheck.output) {
hasInfraError = true;
messages.push(`- ${locator}: ${extensionDirCheck.output}`);
continue;
}
if (!extensionDirCheck.exists) {
hasFailure = true;
if (extensionDirCheck.kindMismatch) {
messages.push(`- ${locator}: "${extensionsDir}" must be a directory.`);
} else {
messages.push(`- ${locator}: missing required canvas extension directory "${extensionsDir}".`);
}
continue;
}
const extensionEntryCheck = checkPathExistsAtLocator(repoDir, locator, extensionEntryPoint, "blob");
if (extensionEntryCheck.output) {
hasInfraError = true;
messages.push(`- ${locator}: ${extensionEntryCheck.output}`);
continue;
}
if (!extensionEntryCheck.exists) {
hasFailure = true;
if (extensionEntryCheck.kindMismatch) {
messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`);
} else {
messages.push(`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}".`);
}
continue;
}
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`);
}
if (hasInfraError) {
return {
status: "infra_error",
output: messages.join("\n"),
};
}
if (hasFailure) {
return {
status: "fail",
output: messages.join("\n"),
};
}
return {
status: "pass",
output: messages.join("\n"),
};
}
function toOverallStatus(states) {
if (states.includes("infra_error")) {
return "infra_error";
@@ -497,11 +646,13 @@ export async function runExternalPluginQualityGates(plugin) {
vally_lint_status: "not_run",
smoke_status: "not_run",
version_match_status: "not_run",
canvas_structure_status: "not_run",
failure_class: "none",
summary: "",
vally_lint_output: "",
smoke_output: "",
version_match_output: "",
canvas_structure_output: "",
};
try {
@@ -513,10 +664,14 @@ export async function runExternalPluginQualityGates(plugin) {
result.vally_lint_status = "fail";
result.smoke_status = "fail";
result.version_match_status = "fail";
result.canvas_structure_status = hasCanvasKeyword(plugin) ? "fail" : "not_run";
result.overall_status = "fail";
result.failure_class = "submitter_fixes";
result.summary = `Plugin path "${plugin.source?.path || "/"}" was not found in the submitted repository snapshot.`;
result.version_match_output = result.summary;
if (hasCanvasKeyword(plugin)) {
result.canvas_structure_output = result.summary;
}
return result;
}
@@ -524,6 +679,10 @@ export async function runExternalPluginQualityGates(plugin) {
result.version_match_status = versionMatchResult.status;
result.version_match_output = versionMatchResult.output;
const canvasStructureResult = runCanvasStructureGate(repoDir, plugin, fetchSpec);
result.canvas_structure_status = canvasStructureResult.status;
result.canvas_structure_output = canvasStructureResult.output;
const vallyResult = await runVallyLintGate(pluginRoot);
result.vally_lint_status = vallyResult.status;
result.vally_lint_output = vallyResult.output;
@@ -532,12 +691,18 @@ export async function runExternalPluginQualityGates(plugin) {
result.smoke_status = smokeResult.status;
result.smoke_output = smokeResult.output;
result.overall_status = toOverallStatus([result.vally_lint_status, result.smoke_status, result.version_match_status]);
result.overall_status = toOverallStatus([
result.vally_lint_status,
result.smoke_status,
result.version_match_status,
result.canvas_structure_status,
]);
result.failure_class = toFailureClass(result.overall_status);
result.summary = [
`- vally lint: ${result.vally_lint_status}`,
`- install smoke test: ${result.smoke_status}`,
`- version match: ${result.version_match_status}`,
`- canvas structure: ${result.canvas_structure_status}`,
`- overall: ${result.overall_status}`,
].join("\n");