Summary
The external-plugin canvas-structure check requires the canvas extension entry point to be a flat file at <pluginRoot>/extensions/extension.mjs. But the documented and widely-used convention nests each extension in its own subfolder — <pluginRoot>/extensions/<extension-name>/extension.mjs. Plugins that follow the nested convention (which install and run correctly) are reported as failing with a false "missing required canvas extension entry point," forcing unnecessary maintainer follow-up.
Observed on PR #2394 (upgrade-agent):
- <sha>: missing required canvas extension entry point "plugins/upgrade-agent/extensions/extension.mjs".
...even though the install smoke test in the same run passed, confirming the extension installs and loads fine.
Where the check lives
The assumption is hardcoded in two places and both need the same change to stay consistent:
-
Quality gate: runCanvasStructureGate in eng/external-plugin-quality-gates.mjs — line ~542:
const extensionsDir = toPosixPath(normalizedPluginPath, "extensions");
const extensionEntryPoint = toPosixPath(extensionsDir, "extension.mjs"); // flat: extensions/extension.mjs
It confirms extensions/ is a tree, then requires a bare extensions/extension.mjs — it never scans subfolders.
-
Intake: eng/external-plugin-intake.mjs — line ~495:
const extensionEntryPath = joinRepoPath(pluginRoot, "extensions", "extension.mjs");
// error: `plugins tagged with "canvas" must include "extensions/extension.mjs"`
Why the flat assumption is wrong
The nested layout is the endorsed convention, not a deviation:
- AGENTS.md (Canvas Extensions): each extension folder (
extensions/<id>/) must include extension.mjs — i.e. extensions/<id>/extension.mjs.
- This repo's own discovery helpers already accept nested entry points:
eng/generate-marketplace.mjs (~L66-69) and eng/generate-website-data.mjs (~L40-42) both probe extensions/<name>/extension.mjs (and extensions/extension.mjs).
- Real submission
microsoft/upgrade-agent-plugins@a70e1ae1ff63dd19ff874e874b4b587a5291f68a lays out:
plugins/upgrade-agent/
├── plugin.json # keywords include "canvas"
└── extensions/
└── modernize-dashboard/
├── extension.mjs # ← real entry point
├── package.json, README.md, canvas/, lib/
- The install smoke test passes for this plugin — the CLI installs and loads the extension from its nested path. Only the structural check disagrees.
Suggested fix
Locate the canvas entry point by scanning <pluginRoot>/extensions/ for at least one immediate subfolder that contains an extension.mjs file (nested convention), while continuing to accept the flat extensions/extension.mjs form for backward compatibility. Apply the equivalent change to both the quality gate (git-object based lookups: git cat-file/git ls-tree over <locator>:<path>) and the intake (GitHub Contents API traversal). Keep the two checks behaviorally aligned.
Acceptance criteria
- A canvas-tagged plugin whose extension lives at
extensions/<name>/extension.mjs passes both the intake canvas check and the runCanvasStructureGate quality gate.
- The flat
extensions/extension.mjs layout continues to pass.
- A canvas-tagged plugin with an
extensions/ directory that contains no extension.mjs anywhere (neither flat nor in a subfolder) still fails.
- Regression coverage added in
eng/external-plugin-quality-gates.test.mjs (and, if practical, for the intake path) covering the nested-subfolder layout.
Notes
Summary
The external-plugin canvas-structure check requires the canvas extension entry point to be a flat file at
<pluginRoot>/extensions/extension.mjs. But the documented and widely-used convention nests each extension in its own subfolder —<pluginRoot>/extensions/<extension-name>/extension.mjs. Plugins that follow the nested convention (which install and run correctly) are reported as failing with a false "missing required canvas extension entry point," forcing unnecessary maintainer follow-up.Observed on PR #2394 (
upgrade-agent):...even though the install smoke test in the same run passed, confirming the extension installs and loads fine.
Where the check lives
The assumption is hardcoded in two places and both need the same change to stay consistent:
Quality gate:
runCanvasStructureGateineng/external-plugin-quality-gates.mjs— line ~542:It confirms
extensions/is a tree, then requires a bareextensions/extension.mjs— it never scans subfolders.Intake:
eng/external-plugin-intake.mjs— line ~495:Why the flat assumption is wrong
The nested layout is the endorsed convention, not a deviation:
extensions/<id>/) must includeextension.mjs— i.e.extensions/<id>/extension.mjs.eng/generate-marketplace.mjs(~L66-69) andeng/generate-website-data.mjs(~L40-42) both probeextensions/<name>/extension.mjs(andextensions/extension.mjs).microsoft/upgrade-agent-plugins@a70e1ae1ff63dd19ff874e874b4b587a5291f68alays out:Suggested fix
Locate the canvas entry point by scanning
<pluginRoot>/extensions/for at least one immediate subfolder that contains anextension.mjsfile (nested convention), while continuing to accept the flatextensions/extension.mjsform for backward compatibility. Apply the equivalent change to both the quality gate (git-object based lookups:git cat-file/git ls-treeover<locator>:<path>) and the intake (GitHub Contents API traversal). Keep the two checks behaviorally aligned.Acceptance criteria
extensions/<name>/extension.mjspasses both the intake canvas check and therunCanvasStructureGatequality gate.extensions/extension.mjslayout continues to pass.extensions/directory that contains noextension.mjsanywhere (neither flat nor in a subfolder) still fails.eng/external-plugin-quality-gates.test.mjs(and, if practical, for the intake path) covering the nested-subfolder layout.Notes
infra_errorfor valid tag refs (git fetch origin <tag>doesn't create a local ref) #2397 / PR #2399 (tag-refinfra_error), though both toucheng/external-plugin-quality-gates.mjs; coordinate to avoid conflicts (rebase onmainafter Fix external-plugin quality gates reporting infra_error for valid tag refs #2399 lands).eng/external-plugin-validation.mjs(canonical external-plugin data rules).