diff --git a/docs/book/src/development/development.md b/docs/book/src/development/development.md
index c629cd511..f95277518 100644
--- a/docs/book/src/development/development.md
+++ b/docs/book/src/development/development.md
@@ -14,6 +14,59 @@ These can all be run from the command line in the root of the repository (with `
- `build:webview`: bundles and minifies the webview UX for consumption by the extension.
- `webpack`: builds and packages the extension.
- `test`: runs automated tests.
+- `test:scripts`: runs the unit tests for the `scripts/` tooling. Plain node and mocha, so no compile step.
+
+## Documentation
+
+These validate this book against what `package.json` actually contributes, so command IDs, setting names and menu paths in prose cannot drift from the extension.
+
+- `docs:check`: runs all documentation checks. Pass names to run a subset, for example `npm run docs:check menu-paths`.
+ - `identifiers`: flags an `aks.*` or `azure.*` identifier in prose that `package.json` does not contribute. Fenced code blocks are skipped, so a sample quoting another extension's settings is not an error.
+ - `menu-paths`: flags a menu breadcrumb that does not match the real menu.
+ - `menu-syntax`: flags menu navigation written as prose instead of `**A** > **B**`. See below.
+ - `coverage`: warns about a command documented nowhere in prose.
+ - `orphans`: warns about an image no page references.
+- `docs:reference`: regenerates the reference pages under `src/reference/`. These carry a `DO NOT EDIT` header — change the generator or `package.json`, not the output.
+- `docs:reference:check`: fails if those pages are stale. Run `docs:reference` and commit the result.
+
+Links, images, anchors and `SUMMARY.md` completeness are deliberately **not** checked here. `lychee --offline --include-fragments` covers the first three and handles raw HTML and URL fragments properly, and `mdbook build` with `create-missing = false` fails on a `SUMMARY.md` entry with no page.
+
+### Writing menu navigation
+
+Write navigation with `>` between the steps, and bold each one:
+
+```markdown
+Right-click your AKS cluster > **Troubleshoot & Diagnose** > **Troubleshoot Network Health** > **Collect TCP Dumps**
+```
+
+Not as prose:
+
+```markdown
+Right-click your AKS cluster and select **Troubleshoot & Diagnose** and then
+click on **Collect TCP Dumps**
+```
+
+`menu-paths` only recognises the first form, so an instruction written the second way is skipped rather than validated — the page can go stale and nothing reports it. `menu-syntax` exists to make that a visible error instead of silence.
+
+The convention is not only for the tooling. `>` states where the menu ends, which prose cannot: "click **Create Cluster** and select **Create Standard Cluster**" reads as two menu levels, but the second is a button in the wizard the first opens. Writing the menu part with `>` and leaving the rest as prose keeps that boundary clear for readers too.
+
+If a line mentions right-click without giving an instruction — naming the context menu, say — put this marker on the page:
+
+```markdown
+
+```
+
+### Documenting the classic menu
+
+The menu layout depends on the `aks.simplifiedMenuStructure` setting, which defaults to `true`. `menu-paths` validates breadcrumbs against that default.
+
+A page that deliberately documents the classic layout (the setting turned off) opts out by including this marker anywhere in the file, usually in an HTML comment:
+
+```markdown
+
+```
+
+Breadcrumbs on that page are then accepted if they match either menu. Use it only for pages genuinely about the classic layout — a breadcrumb that is simply out of date should be fixed, not marked.
## Not for Running Directly
diff --git a/package.json b/package.json
index a997024f6..8b3f0b8d7 100644
--- a/package.json
+++ b/package.json
@@ -1108,6 +1108,10 @@
"lint:all": "npx eslint . && cd webview-ui && npm run lint",
"lint-fix:all": "npx eslint . --fix && cd webview-ui && npm run lint-fix",
"prettier-format": "prettier --config .prettierrc . --write",
+ "docs:check": "node scripts/docs-check.js",
+ "docs:reference": "node scripts/generate-docs-reference.js",
+ "docs:reference:check": "node scripts/generate-docs-reference.js --check",
+ "test:scripts": "mocha 'scripts/**/*.test.js'",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
"vscode:prepublish": "npm run webpack",
diff --git a/scripts/docs-check.js b/scripts/docs-check.js
new file mode 100644
index 000000000..00a87234a
--- /dev/null
+++ b/scripts/docs-check.js
@@ -0,0 +1,496 @@
+#!/usr/bin/env node
+/**
+ * Documentation checks that need knowledge of package.json.
+ *
+ * Deliberately does NOT check links, images, anchors, or SUMMARY completeness.
+ * `lychee --offline --include-fragments` covers the first three and handles raw
+ * HTML and URL fragments properly; `mdbook build` with `create-missing = false`
+ * fails on a SUMMARY entry with no page. Both run in CI. Duplicating them here
+ * was worse, not better: an earlier version of this file missed images
+ * referenced with
tags, which lychee caught.
+ *
+ * Checks:
+ * identifiers command IDs in prose that package.json does not contribute
+ * menu-paths menu breadcrumbs that do not match the real menu
+ * coverage commands documented nowhere in prose (warning)
+ * orphans images no page references (warning)
+ *
+ * Usage:
+ * node scripts/docs-check.js all checks
+ * node scripts/docs-check.js menu-paths named checks only
+ *
+ * Errors exit 1. Warnings do not.
+ */
+
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { loc, contributes, walk: walkMenus, DEFAULT_SIMPLIFIED } = require("./lib/menu-graph");
+
+const REPO_ROOT = path.resolve(__dirname, "..");
+const DOCS_ROOT = path.join(REPO_ROOT, "docs");
+const BOOK_SRC = path.join(DOCS_ROOT, "book", "src");
+
+const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+// mdBook writes its rendered site into docs/book/book/, which mirrors every page
+// and image. Walking it would double-count everything.
+const BOOK_OUTPUT = path.join(DOCS_ROOT, "book", "book");
+const SKIP_DIRS = new Set(["node_modules", ".git"]);
+
+function walk(dir, predicate, found = []) {
+ if (!fs.existsSync(dir) || path.resolve(dir) === BOOK_OUTPUT) {
+ return found;
+ }
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (SKIP_DIRS.has(entry.name)) {
+ continue;
+ }
+ walk(full, predicate, found);
+ } else if (predicate(full)) {
+ found.push(full);
+ }
+ }
+ return found;
+}
+
+const markdownFiles = () => walk(DOCS_ROOT, (f) => f.endsWith(".md")).sort();
+const bookFiles = () => walk(BOOK_SRC, (f) => f.endsWith(".md")).sort();
+const imageFiles = () => walk(DOCS_ROOT, (f) => IMAGE_EXTENSIONS.includes(path.extname(f).toLowerCase())).sort();
+
+const rel = (p) => path.relative(REPO_ROOT, p);
+
+/** Line number of a character offset. */
+function lineAt(text, index) {
+ return text.slice(0, index).split("\n").length;
+}
+
+/**
+ * Blank out URLs and link targets, so hostnames like `dev.azure.com` are not mistaken
+ * for extension identifiers. Newlines are preserved so line numbers stay accurate.
+ */
+function withoutUrls(text) {
+ const blank = (m) => m.replace(/[^\n]/g, " ");
+ return text
+ .replace(/https?:\/\/\S+/g, blank)
+ .replace(/\]\([^)]*\)/g, blank)
+ .replace(/<[^>\s]+>/g, blank);
+}
+
+/**
+ * Blank out fenced code blocks, keeping the fences so line numbers stay accurate.
+ *
+ * Samples are quoted from elsewhere: a settings.json snippet naming another
+ * extension's `azure.*` key, or YAML carrying an `aks.*` annotation, is not a
+ * claim about what this extension contributes, and should not be a hard error.
+ * Inline code is left alone — the docs use it to name real commands, and the
+ * coverage check relies on that.
+ */
+function withoutFencedCode(text) {
+ const blank = (m) => m.replace(/[^\n]/g, " ");
+ return text.replace(/^([ \t]*)(`{3,}|~{3,})[^\n]*\n([\s\S]*?)^[ \t]*\2[^\n]*$/gm, (match, indent, fence, body) =>
+ match.replace(body, blank(body)),
+ );
+}
+
+/** Every `[text](target)` link in a file, with its line number. */
+function linksOf(file) {
+ const text = fs.readFileSync(file, "utf8");
+ const links = [];
+ for (const match of text.matchAll(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
+ links.push({ target: match[1], line: lineAt(text, match.index), isImage: match[0].startsWith("!") });
+ }
+ // markdown allows raw HTML, and several pages use
to control size.
+ // Missing these made referenced images look orphaned.
+ for (const match of text.matchAll(/
]*?\ssrc\s*=\s*["']([^"']+)["']/gi)) {
+ links.push({ target: match[1], line: lineAt(text, match.index), isImage: true });
+ }
+ for (const match of text.matchAll(/]*?\shref\s*=\s*["']([^"']+)["']/gi)) {
+ links.push({ target: match[1], line: lineAt(text, match.index), isImage: false });
+ }
+ return links;
+}
+
+// ---------------------------------------------------------------------------
+// package.json facts
+// ---------------------------------------------------------------------------
+
+// package.json is read once by lib/menu-graph, which also owns %placeholder%
+// resolution. Resolving titles a second time here meant two implementations that
+// disagreed on a missing NLS key: loc() throws, the old local copy fell back to
+// the raw "%key%" string and let it through.
+function contributions() {
+ const commands = new Map((contributes.commands ?? []).map((c) => [c.command, loc(c.title)]));
+
+ const settings = new Set();
+ const configuration = contributes.configuration ?? {};
+ for (const block of Array.isArray(configuration) ? configuration : [configuration]) {
+ for (const key of Object.keys(block.properties ?? {})) {
+ settings.add(key);
+ }
+ }
+
+ const submenus = new Set((contributes.submenus ?? []).map((s) => s.id));
+
+ return { commands, settings, submenus };
+}
+
+/** Command IDs registered in src/ but possibly not declared in contributes.commands. */
+function registeredInSource() {
+ const sources = walk(path.join(REPO_ROOT, "src"), (f) => f.endsWith(".ts"));
+ const ids = new Set();
+ for (const file of sources) {
+ const text = fs.readFileSync(file, "utf8");
+ for (const match of text.matchAll(/registerCommand(?:WithTelemetry)?\(\s*["']([\w.]+)["']/g)) {
+ ids.add(match[1]);
+ }
+ }
+ return ids;
+}
+
+// ---------------------------------------------------------------------------
+// checks
+// ---------------------------------------------------------------------------
+
+const checks = {};
+
+checks.identifiers = (report) => {
+ const { commands, settings, submenus } = contributions();
+ const registered = registeredInSource();
+
+ // Kubernetes annotation prefixes and similar non-extension namespaces.
+ const notIdentifiers = /^(azure\.workload\.identity|aks\.ghcp)$/;
+
+ for (const file of bookFiles()) {
+ const raw = fs.readFileSync(file, "utf8");
+ const text = withoutFencedCode(withoutUrls(raw));
+ for (const match of text.matchAll(
+ /(? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+checks.coverage = (report) => {
+ const { commands } = contributions();
+ // generated reference pages list every command, so counting them would make
+ // this check pass trivially; coverage means documented in prose
+ const corpus = bookFiles()
+ .filter((f) => !rel(f).includes(`${path.sep}reference${path.sep}`))
+ .map((f) => fs.readFileSync(f, "utf8"))
+ .join("\n");
+
+ for (const [id, title] of commands) {
+ // the command ID is distinctive enough to count on its own
+ if (corpus.includes(id)) {
+ continue;
+ }
+
+ const plain = String(title ?? "")
+ .replace(/^AKS:\s*/i, "")
+ .trim();
+ if (!plain) {
+ report.warn("coverage", `command documented nowhere: ${id}`);
+ continue;
+ }
+
+ // A title only counts when it stands alone as a UI string — **Bold**,
+ // `code`, a heading, or a list item naming just that command. A plain
+ // substring match treated any prose containing "storage" as
+ // documentation for the Storage detector, and likewise for Best
+ // Practices, Node Health and Profile CPU, so the check passed for
+ // commands that are documented nowhere.
+ // Titles ending in an ellipsis are conventionally written without it in
+ // prose ("AKS: Sign in to Azure" for "Sign in to Azure...").
+ const base = plain.replace(/\.{3}$/, "");
+ const t = escapeRe(base) + (base === plain ? "" : "(?:\\.{3})?");
+ const documented = new RegExp(
+ [
+ `\\*\\*\\s*(?:AKS:\\s*)?${t}\\s*\\*\\*`,
+ `\`\\s*(?:AKS:\\s*)?${t}\\s*\``,
+ `^#{1,6}\\s*(?:AKS:\\s*)?${t}\\s*$`,
+ `^\\s*[-*]\\s+(?:AKS:\\s*)?${t}\\s*$`,
+ ].join("|"),
+ "im",
+ );
+ if (documented.test(corpus)) {
+ continue;
+ }
+
+ report.warn("coverage", `command documented nowhere: ${id} ("${title}")`);
+ }
+};
+
+checks.orphans = (report) => {
+ const referenced = new Set();
+ for (const file of markdownFiles()) {
+ const dir = path.dirname(file);
+ for (const { target, isImage } of linksOf(file)) {
+ if (isImage && !/^https?:/.test(target)) {
+ referenced.add(path.resolve(dir, target.split("#")[0]));
+ }
+ }
+ }
+ for (const image of imageFiles()) {
+ if (!referenced.has(image)) {
+ report.warn("orphans", `image referenced by no page: ${rel(image)}`);
+ }
+ }
+};
+
+// Menu breadcrumbs written as **A** > **B** > **C**. Docs describe the default
+// (grouped) menu; a page that documents the classic layout opts out with the
+// marker below, which is documented for docs authors in docs/package-scripts.md.
+const CLASSIC_MARKER = "docs-check: classic-menu";
+const BOLD_CHAIN = /(?:\*\*[^*\n]+\*\*)(?:\s*>\s*\*\*[^*\n]+\*\*)+/g;
+// a single bold segment is only a menu path when the prose anchors it to a
+// right-click, which is what catches a command documented on the wrong node.
+// group 1 is the text naming the node, group 2 the breadcrumb.
+const RIGHT_CLICK = /right[- ]click([^.\n*]*?)>\s*(\*\*[^*\n]+\*\*(?:\s*>\s*\*\*[^*\n]+\*\*)*)/gi;
+
+/**
+ * Which tree node the prose leading up to a breadcrumb refers to.
+ *
+ * Only the text before the breadcrumb is considered. Scanning the whole line
+ * misread ordinary sentences: "Right-click your AKS cluster > **Manage Cluster**
+ * > **Delete Cluster**. This removes the fleet member too." resolved to the
+ * fleet node and failed a correct page.
+ */
+function nodeFromContext(before) {
+ if (/\bfleets?\b/i.test(before)) return "fleet";
+ if (/\bsubscriptions?\b/i.test(before)) return "subscription";
+ return "cluster";
+}
+
+/** Breadcrumbs in a line, each paired with the node its lead-in names. */
+function crumbsIn(line) {
+ const out = new Map();
+ for (const m of line.matchAll(RIGHT_CLICK)) {
+ out.set(m[2], nodeFromContext(m[1]));
+ }
+ for (const m of line.matchAll(BOLD_CHAIN)) {
+ if (!out.has(m[0])) out.set(m[0], nodeFromContext(line.slice(0, m.index)));
+ }
+ return [...out].map(([crumb, root]) => ({ crumb, root }));
+}
+
+checks["menu-paths"] = (report) => {
+ const dflt = walkMenus(DEFAULT_SIMPLIFIED).labelPaths;
+ const classic = walkMenus(!DEFAULT_SIMPLIFIED).labelPaths;
+
+ // a bold sequence is only treated as a menu path if at least one segment
+ // names a real command or submenu, so prose like **Submit** > **Next** is ignored
+ const menuLabels = new Set([
+ ...contributes.commands.map((c) => loc(c.title)),
+ ...contributes.submenus.map((sm) => loc(sm.label)),
+ ]);
+
+ // where each label actually lives, for the error message
+ const homeOf = new Map();
+ for (const key of dflt) {
+ const [node, crumb] = key.split("|");
+ const leaf = crumb.split(" > ").pop();
+ if (!homeOf.has(leaf)) homeOf.set(leaf, `${node}: ${crumb}`);
+ }
+
+ for (const file of bookFiles()) {
+ const text = fs.readFileSync(file, "utf8");
+ // Looked for outside code fences, so a page documenting the marker does
+ // not thereby opt itself out. Line scanning below still uses the raw
+ // text: a menu path quoted in a fence should still be correct.
+ const allowClassic = withoutFencedCode(text).includes(CLASSIC_MARKER);
+
+ text.split("\n").forEach((line, index) => {
+ for (const { crumb: match, root } of crumbsIn(line)) {
+ const labels = [...match.matchAll(/\*\*([^*]+)\*\*/g)].map((m) => m[1].trim());
+ if (!labels.some((l) => menuLabels.has(l))) {
+ continue;
+ }
+ const where = `${rel(file)}:${index + 1}`;
+ const crumb = labels.join(" > ");
+
+ if (dflt.has(`${root}|${crumb}`)) {
+ continue;
+ }
+ if (allowClassic && classic.has(`${root}|${crumb}`)) {
+ continue;
+ }
+
+ const otherRoot = [...dflt].find((k) => k.endsWith(`|${crumb}`));
+ if (otherRoot) {
+ report.error(where, `menu path is on the ${otherRoot.split("|")[0]} node, not ${root}: ${crumb}`);
+ continue;
+ }
+ if (classic.has(`${root}|${crumb}`)) {
+ report.error(where, `menu path describes the classic menu: ${crumb}`);
+ continue;
+ }
+ const leaf = labels[labels.length - 1];
+ const home = homeOf.get(leaf);
+ report.error(
+ where,
+ home ? `menu path is wrong: ${crumb} — ${leaf} is at ${home}` : `menu path not found: ${crumb}`,
+ );
+ }
+ });
+ }
+};
+
+/**
+ * Navigation must be written as **A** > **B**, not joined by prose.
+ *
+ * `menu-paths` recognises a breadcrumb only when `>` separates the steps, so an
+ * instruction written "and select **X** and then click on **Y**" is skipped
+ * rather than validated. That failure is silent, which is the same shape of
+ * problem the menu checks exist to prevent: 26 of the 28 right-click
+ * instructions naming a bold UI element were invisible to `menu-paths`.
+ *
+ * Teaching the parser the connector phrases was tried and rejected. Prose does
+ * not distinguish a menu hop from a step inside a wizard — both are written
+ * "and select" — so `**Create Cluster** and select **Create Standard Cluster**`
+ * reads as a two-level menu path when the second is a button in the dialog the
+ * first opens. `>` lets the author state that boundary, and the phrase list
+ * would never be complete anyway.
+ *
+ * This check knows nothing about the menu, only about the convention, so it
+ * cannot make that mistake.
+ */
+const NOT_A_MENU = "docs-check: not-a-menu";
+// Anchored at the end of the previous step, so group 1 is the connector that
+// led to this bold segment.
+const NAV_STEP = /([^*\n]*?)(\*\*[^*\n]+\*\*)/gy;
+
+/**
+ * The first prose-connected step in a right-click instruction, or null.
+ *
+ * A line `menu-paths` can already read is exempt whatever its connectors:
+ * reporting it here as well would duplicate, against the same line, an error
+ * `menu-paths` already raises.
+ */
+function proseNavIn(line) {
+ if (crumbsIn(line).length) {
+ return null;
+ }
+ for (const anchor of line.matchAll(/right[- ]click/gi)) {
+ // Only text after the right-click counts. A page describing "the **AKS
+ // cluster context menu** (right-click ...)" names a bold UI element
+ // without giving an instruction, and is not a breadcrumb.
+ NAV_STEP.lastIndex = anchor.index + anchor[0].length;
+ let step;
+ while ((step = NAV_STEP.exec(line))) {
+ const [, connector, bold] = step;
+ // a sentence boundary ends the instruction
+ if (connector.includes(".")) {
+ break;
+ }
+ if (!connector.trimEnd().endsWith(">")) {
+ return { connector: connector.trim(), bold };
+ }
+ NAV_STEP.lastIndex = step.index + step[0].length;
+ }
+ }
+ return null;
+}
+
+checks["menu-syntax"] = (report) => {
+ for (const file of bookFiles()) {
+ const raw = fs.readFileSync(file, "utf8");
+ // a bold chain inside a code sample is not an instruction
+ const text = withoutFencedCode(raw);
+ // Checked against the same blanked text, so the page documenting this
+ // marker does not opt itself out of the check it describes.
+ if (text.includes(NOT_A_MENU)) {
+ continue;
+ }
+ text.split("\n").forEach((line, index) => {
+ const hit = proseNavIn(line);
+ if (!hit) {
+ return;
+ }
+ report.error(
+ `${rel(file)}:${index + 1}`,
+ "write menu navigation as `**A** > **B**`, not prose: " +
+ `"...${hit.connector} ${hit.bold}" — otherwise menu-paths cannot check it`,
+ );
+ });
+ }
+};
+
+// ---------------------------------------------------------------------------
+// runner
+// ---------------------------------------------------------------------------
+
+function main() {
+ const requested = process.argv.slice(2).filter((a) => !a.startsWith("-"));
+ const names = requested.length ? requested : Object.keys(checks);
+
+ const unknown = names.filter((n) => !checks[n]);
+ if (unknown.length) {
+ console.error(`Unknown check(s): ${unknown.join(", ")}`);
+ console.error(`Available: ${Object.keys(checks).join(", ")}`);
+ process.exitCode = 2;
+ return;
+ }
+
+ let errors = 0;
+ let warnings = 0;
+
+ for (const name of names) {
+ const found = [];
+ checks[name]({
+ error: (where, message) => found.push({ level: "error", where, message }),
+ warn: (where, message) => found.push({ level: "warn", where, message }),
+ });
+
+ const e = found.filter((f) => f.level === "error").length;
+ const w = found.length - e;
+ errors += e;
+ warnings += w;
+
+ const status = e ? "FAIL" : w ? "warn" : "ok";
+ console.log(`\n[${status}] ${name}${found.length ? ` — ${e} error(s), ${w} warning(s)` : ""}`);
+ for (const f of found) {
+ console.log(` ${f.level === "error" ? "E" : "W"} ${f.where}\n ${f.message}`);
+ }
+ }
+
+ console.log(`\n${errors} error(s), ${warnings} warning(s)`);
+ // `process.exit()` here would discard buffered stdout when it is a pipe
+ // rather than a TTY, which is how CI runs this. Setting the code and
+ // returning lets node flush and exit on its own.
+ process.exitCode = errors ? 1 : 0;
+}
+
+// Running the file executes the checks; requiring it exposes the line-level
+// parsers so they can be tested without a book on disk.
+if (require.main === module) {
+ main();
+}
+
+module.exports = { crumbsIn, proseNavIn, withoutFencedCode, withoutUrls };
diff --git a/scripts/docs-check.test.js b/scripts/docs-check.test.js
new file mode 100644
index 000000000..e91ca053b
--- /dev/null
+++ b/scripts/docs-check.test.js
@@ -0,0 +1,140 @@
+"use strict";
+
+/**
+ * Unit tests for the line-level parsers in scripts/docs-check.js.
+ *
+ * These run on plain node with no compile step:
+ * npm run test:scripts
+ *
+ * Requiring the module does not execute the checks, so no book on disk is
+ * needed — each case is a single line of markdown.
+ */
+
+const assert = require("node:assert/strict");
+
+const { crumbsIn, proseNavIn, withoutFencedCode, withoutUrls } = require("./docs-check");
+
+describe("crumbsIn", () => {
+ it("reads a chain anchored to a right-click", () => {
+ const line = "Right-click your AKS cluster > **Manage Cluster** > **Delete Cluster**";
+ assert.deepEqual(crumbsIn(line), [{ crumb: "**Manage Cluster** > **Delete Cluster**", root: "cluster" }]);
+ });
+
+ it("reads a single bold segment when a right-click anchors it", () => {
+ const line = "Right-click your subscription > **Create Cluster**";
+ assert.deepEqual(crumbsIn(line), [{ crumb: "**Create Cluster**", root: "subscription" }]);
+ });
+
+ it("takes the node from the lead-in only", () => {
+ // scanning the whole line resolved this to the fleet node and failed a
+ // correct page
+ const line = "Right-click your AKS cluster > **Manage Cluster**. This removes the fleet member too.";
+ assert.deepEqual(crumbsIn(line), [{ crumb: "**Manage Cluster**", root: "cluster" }]);
+ });
+
+ it("returns nothing for a line with no bold", () => {
+ assert.deepEqual(crumbsIn("Right-click your AKS cluster and pick an action."), []);
+ });
+
+ // the gap the menu-syntax check exists to close
+ it("does not recognise prose connectors", () => {
+ const line = "Right click on your AKS cluster and select **Troubleshoot Network Health**";
+ assert.deepEqual(crumbsIn(line), []);
+ });
+});
+
+describe("proseNavIn", () => {
+ const prose = [
+ "Right click on your AKS cluster and select **Compare AKS Cluster** to diff two clusters.",
+ "Right click on your AKS cluster and click on **Run Kubectl Commands** to run them.",
+ "Right-click on your AKS cluster and select **Investigate DNS** to troubleshoot DNS.",
+ ];
+
+ for (const line of prose) {
+ it(`flags: ${line.slice(0, 56)}...`, () => {
+ const hit = proseNavIn(line);
+ assert.ok(hit, "expected a violation");
+ assert.match(hit.bold, /^\*\*.+\*\*$/);
+ assert.ok(!hit.connector.endsWith(">"), "connector should not be the convention");
+ });
+ }
+
+ it("accepts the convention", () => {
+ assert.equal(proseNavIn("Right click on your AKS cluster > **Troubleshoot Network Health**"), null);
+ });
+
+ it("accepts a multi-step chain in the convention", () => {
+ const line = "Right click on your AKS cluster > **Troubleshoot & Diagnose** > **Collect TCP Dumps**";
+ assert.equal(proseNavIn(line), null);
+ });
+
+ // a line menu-paths can already read is checked, so flagging it here would
+ // duplicate an error raised against the same line
+ it("defers to menu-paths when the line yields a breadcrumb", () => {
+ const line = "Right-click your AKS cluster and select **Troubleshoot Network Health** > **Run Retina Capture**";
+ assert.ok(crumbsIn(line).length, "precondition: menu-paths can read this line");
+ assert.equal(proseNavIn(line), null);
+ });
+
+ it("ignores bold that precedes the right-click", () => {
+ // naming a UI element is not an instruction
+ const line = "- The **AKS cluster context menu** (right-click on a cluster in the Cloud Explorer).";
+ assert.equal(proseNavIn(line), null);
+ });
+
+ it("does not cross a sentence boundary", () => {
+ const line = "You can right click on your AKS cluster to see actions. Then read **Some Heading** below.";
+ assert.equal(proseNavIn(line), null);
+ });
+
+ it("ignores a line with no right-click", () => {
+ assert.equal(proseNavIn("Open the palette and run **AKS: Create Cluster**."), null);
+ });
+});
+
+describe("withoutFencedCode", () => {
+ it("blanks a fenced block but keeps line numbers", () => {
+ const input = ["before aks.realCommand", "```json", '{ "azure.other.setting": true }', "```", "after"].join(
+ "\n",
+ );
+ const out = withoutFencedCode(input);
+ assert.equal(out.split("\n").length, input.split("\n").length);
+ assert.ok(!out.includes("azure.other.setting"), "fenced content should be blanked");
+ assert.ok(out.includes("aks.realCommand"), "prose outside the fence is untouched");
+ });
+
+ it("handles tilde fences", () => {
+ const out = withoutFencedCode(["~~~yaml", "aks.fake/annotation: x", "~~~"].join("\n"));
+ assert.ok(!out.includes("aks.fake"));
+ });
+
+ it("leaves inline code alone, which coverage relies on", () => {
+ const line = "Run `aks.periscope` from the palette.";
+ assert.equal(withoutFencedCode(line), line);
+ });
+});
+
+describe("withoutUrls", () => {
+ // Asserted as exact output rather than a substring check on the host.
+ // `!out.includes("dev.azure.com")` reads to CodeQL as an incomplete URL
+ // sanitiser (js/incomplete-url-substring-sanitization), and equality is the
+ // stronger assertion anyway: it pins the blanking width, not just absence.
+ const blanked = (s) => " ".repeat(s.length);
+
+ it("blanks a URL so its host is not read as an identifier", () => {
+ const url = "https://dev.azure.com/foo";
+ assert.equal(withoutUrls(`See ${url} for details`), `See ${blanked(url)} for details`);
+ });
+
+ it("blanks a relative link target but keeps the text", () => {
+ // the `](...)` branch: a target that is not a URL, so the URL rule
+ // above does not consume it first
+ const target = "](./other-page.md)";
+ assert.equal(withoutUrls(`[the docs]${target.slice(1)}`), `[the docs${blanked(target)}`);
+ });
+
+ it("preserves line count", () => {
+ const input = "a\nhttps://example.com/x\nb";
+ assert.equal(withoutUrls(input).split("\n").length, 3);
+ });
+});
diff --git a/scripts/generate-docs-reference.js b/scripts/generate-docs-reference.js
new file mode 100644
index 000000000..1c0f52069
--- /dev/null
+++ b/scripts/generate-docs-reference.js
@@ -0,0 +1,355 @@
+#!/usr/bin/env node
+/**
+ * Generates the docs reference pages from package.json and friends.
+ *
+ * Inputs:
+ * package.json contributes.commands / submenus / menus / configuration
+ * package.nls.json resolves %placeholder% titles and submenu labels
+ * resources/**\/*.y*ml pinned third-party versions
+ *
+ * Outputs (each with a DO NOT EDIT header):
+ * docs/book/src/reference/commands.md
+ * docs/book/src/reference/settings.md
+ * docs/book/src/reference/pinned-versions.md
+ *
+ * Usage:
+ * node scripts/generate-docs-reference.js write the files
+ * node scripts/generate-docs-reference.js --check fail if the files are stale
+ */
+
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = path.resolve(__dirname, "..");
+const OUT_DIR = path.join(ROOT, "docs", "book", "src", "reference");
+
+const read = (p) => fs.readFileSync(path.join(ROOT, p), "utf8");
+
+const {
+ contributes,
+ loc,
+ splitTop,
+ stripOuterParens,
+ normaliseBoolean,
+ submenuById,
+ menus,
+ walk: walkMenus,
+} = require("./lib/menu-graph");
+
+const HEADER = (script) =>
+ `\n\n`;
+
+/** command id -> array of { node, breadcrumb, flags } for one menu mode. */
+const buildPaths = (simplified) => walkMenus(simplified).commandPaths;
+
+// ---------------------------------------------------------------------------
+// palette
+// ---------------------------------------------------------------------------
+
+/**
+ * A commandPalette entry hides its command when the `when` clause can never be
+ * true. This repo uses both the documented `false` and the idiomatic `never`
+ * (an undefined context key, so falsy). Any other clause is a real condition:
+ * the command is in the palette, but only when it holds.
+ *
+ * Returns { hidden:Set, conditional:Map }.
+ */
+function buildPaletteVisibility() {
+ const NEVER = new Set(["false", "never"]);
+ const hidden = new Set();
+ const conditional = new Map();
+ for (const entry of menus.commandPalette || []) {
+ const when = String(entry.when).trim();
+ if (NEVER.has(when)) hidden.add(entry.command);
+ else if (entry.when) conditional.set(entry.command, entry.when);
+ }
+ return { hidden, conditional };
+}
+
+// ---------------------------------------------------------------------------
+// page: commands
+// ---------------------------------------------------------------------------
+
+const NODE_LABEL = {
+ cluster: "AKS cluster node",
+ subscription: "Subscription node",
+ fleet: "Fleet node",
+ "k8s-cluster": "Kubernetes explorer cluster node",
+ azure: "Azure (Cloud Explorer root)",
+ // `nodeOf` falls back to "any" when a clause names no tree node. Anything
+ // rendered under this label is reported as an unresolved node below.
+ any: "Any",
+};
+
+function renderPath(p) {
+ const root = NODE_LABEL[p.node] || p.node;
+ return [root, ...p.breadcrumb].join(" > ");
+}
+
+const code = (s) => `\`${s}\``;
+
+/**
+ * How a gating condition is phrased for a reader, in match order.
+ * The first matching rule wins; the last is the fallback.
+ */
+const CONDITION_RULES = [
+ { match: /^config\./, render: (c) => code(c.replace(/^config\./, "")) },
+ { match: /^workspaceFolderCount/, render: () => "an open workspace folder" },
+ { match: /resourceExtname/, render: () => "a YAML file in the active editor" },
+ { match: /.*/, render: code },
+];
+
+function renderCondition(condition) {
+ const { key, negated } = normaliseBoolean(condition);
+ const rule = CONDITION_RULES.find((r) => r.match.test(key));
+ return (negated ? "not " : "") + rule.render(key);
+}
+
+/** Renders a raw `when` clause into prose conditions. */
+function describeWhen(when) {
+ return splitTop(when, "&&")
+ .map((c) => renderCondition(stripOuterParens(c).trim()))
+ .join(", ");
+}
+
+function commandsPage() {
+ const simplifiedPaths = buildPaths(true);
+ const classicPaths = buildPaths(false);
+ const { hidden: hiddenFromPalette, conditional } = buildPaletteVisibility();
+
+ const rows = contributes.commands.map((cmd) => {
+ const title = loc(cmd.title);
+ const category = cmd.category ? loc(cmd.category) : "";
+ const sPaths = simplifiedPaths.get(cmd.command) || [];
+ const cPaths = classicPaths.get(cmd.command) || [];
+ const flags = [...new Set([...sPaths, ...cPaths].flatMap((p) => p.flags))];
+ return {
+ id: cmd.command,
+ title,
+ // VS Code renders "category: title" in the palette
+ full: category ? `${category}: ${title}` : title,
+ palette: !hiddenFromPalette.has(cmd.command),
+ paletteWhen: conditional.get(cmd.command) || null,
+ simplified: sPaths.map(renderPath),
+ classic: cPaths.map(renderPath),
+ flags,
+ };
+ });
+
+ const cell = (list) => (list.length ? [...new Set(list)].join("
") : "\u2014");
+
+ let md = HEADER("generate-docs-reference.js");
+ md += "# Commands\n\n";
+ md += `Every command the extension contributes, with where it appears in the tree-view menus.\n\n`;
+ md += `The menu layout depends on the \`aks.simplifiedMenuStructure\` setting, which defaults to \`true\`.\n`;
+ md += `The **Default menu** column reflects that default; **Classic menu** applies when the setting is \`false\`.\n`;
+ md += `A dash means the command has no entry in that menu and is reachable only from the Command Palette.\n\n`;
+
+ md += `## Command Palette\n\n`;
+ const paletteRows = [...rows].filter((r) => r.palette).sort((a, b) => a.full.localeCompare(b.full));
+ const hiddenRows = rows.filter((r) => !r.palette);
+ md += `${paletteRows.length} of ${rows.length} commands are available from the Command Palette (\`Ctrl+Shift+P\` / \`Cmd+Shift+P\`).\n\n`;
+ md += "| Title | Command ID | Shown when |\n|---|---|---|\n";
+ for (const r of paletteRows) {
+ md += `| ${r.full} | \`${r.id}\` | ${r.paletteWhen ? describeWhen(r.paletteWhen) : "always"} |\n`;
+ }
+ if (hiddenRows.length) {
+ md += `\nHidden from the palette (invoked from a menu or another command): `;
+ md += hiddenRows.map((r) => `\`${r.id}\``).join(", ") + ".\n";
+ }
+
+ md += `\n## Menu placement\n\n`;
+ md += "| Title | Command ID | Default menu | Classic menu | Requires |\n|---|---|---|---|---|\n";
+ for (const r of [...rows].sort((a, b) => a.id.localeCompare(b.id))) {
+ const requires = r.flags.length ? r.flags.map(renderCondition).join(", ") : "\u2014";
+ md += `| ${r.title} | \`${r.id}\` | ${cell(r.simplified)} | ${cell(r.classic)} | ${requires} |\n`;
+ }
+
+ return { md, rows, simplifiedPaths, classicPaths };
+}
+
+// ---------------------------------------------------------------------------
+// page: settings
+// ---------------------------------------------------------------------------
+
+function settingsPage() {
+ const props = contributes.configuration.properties;
+ let md = HEADER("generate-docs-reference.js");
+ md += "# Settings\n\n";
+ md += `Configure these in **Settings** (\`Ctrl+,\` / \`Cmd+,\`) or in \`settings.json\`.\n\n`;
+ md += "| Setting | Type | Default | Description |\n|---|---|---|---|\n";
+ for (const key of Object.keys(props).sort()) {
+ const p = props[key];
+ const type = Array.isArray(p.type) ? p.type.join(" \\| ") : p.type || "\u2014";
+ const def = "default" in p ? "`" + JSON.stringify(p.default) + "`" : "\u2014";
+ const desc = (p.markdownDescription || p.description || "\u2014").replace(/\n+/g, " ").trim();
+ md += `| \`${key}\` | ${type} | ${def} | ${desc} |\n`;
+ }
+ return md;
+}
+
+// ---------------------------------------------------------------------------
+// page: pinned versions
+// ---------------------------------------------------------------------------
+
+function walk(dir, out = []) {
+ for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
+ const rel = path.join(dir, e.name);
+ if (e.isDirectory()) walk(rel, out);
+ else out.push(rel);
+ }
+ return out;
+}
+
+function pinnedVersionsPage() {
+ const files = walk("resources").filter((f) => /\.ya?ml$/.test(f));
+ // key "owner/repo|version" -> { action, version, sha, files:Set }
+ const actions = new Map();
+
+ for (const f of files) {
+ read(f)
+ .split("\n")
+ .forEach((line) => {
+ const m = /^\s*(?:-\s*)?uses:\s*([\w.-]+\/[\w.-]+)@([\w.-]+)\s*(?:#\s*(\S+))?/.exec(line);
+ if (!m) return;
+ const [, action, ref, comment] = m;
+ // SHA pins carry the readable tag in a trailing comment
+ const isSha = /^[0-9a-f]{40}$/.test(ref);
+ const version = isSha ? comment || ref : ref;
+ const key = `${action}|${version}`;
+ if (!actions.has(key)) actions.set(key, { action, version, isSha, files: new Set() });
+ actions.get(key).files.add(f);
+ });
+ }
+
+ // every ".releaseTag" setting, so a new tool appears without a code change
+ const props = contributes.configuration.properties;
+ const tools = Object.keys(props)
+ .filter((k) => /\.releaseTag$/i.test(k))
+ .sort();
+
+ let md = HEADER("generate-docs-reference.js");
+ md += "# Pinned versions\n\n";
+ md += "Third-party versions the extension pins.\n\n";
+
+ md += "## Tools\n\n";
+ md += "Each is overridable in settings.\n\n";
+ md += "| Setting | Pinned version |\n|---|---|\n";
+ for (const k of tools) md += `| \`${k}\` | \`${props[k].default}\` |\n`;
+
+ md += "\n## GitHub Actions in generated workflows\n\n";
+ md += "Actions pinned to a commit SHA are shown with the tag from their trailing comment.\n\n";
+ md += "| Action | Version | Pin | Template |\n|---|---|---|---|\n";
+ const sorted = [...actions.values()].sort(
+ (a, b) => a.action.localeCompare(b.action) || a.version.localeCompare(b.version),
+ );
+ for (const a of sorted) {
+ const where = [...a.files]
+ .sort()
+ .map((f) => `\`${f}\``)
+ .join("
");
+ md += `| \`${a.action}\` | \`${a.version}\` | ${a.isSha ? "SHA" : "tag"} | ${where} |\n`;
+ }
+
+ return { md, actions: sorted };
+}
+
+// ---------------------------------------------------------------------------
+// main
+// ---------------------------------------------------------------------------
+
+function main() {
+ const check = process.argv.includes("--check");
+ const { md: commandsMd, rows, simplifiedPaths, classicPaths } = commandsPage();
+ const { md: pinnedMd, actions } = pinnedVersionsPage();
+ const outputs = {
+ "commands.md": commandsMd,
+ "settings.md": settingsPage(),
+ "pinned-versions.md": pinnedMd,
+ };
+
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ let stale = 0;
+ for (const [name, content] of Object.entries(outputs)) {
+ const file = path.join(OUT_DIR, name);
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
+ if (check) {
+ if (existing !== content) {
+ console.error(`[stale] docs/book/src/reference/${name}`);
+ stale++;
+ }
+ } else if (existing !== content) {
+ fs.writeFileSync(file, content);
+ console.log(`[write] docs/book/src/reference/${name}`);
+ } else {
+ console.log(`[same] docs/book/src/reference/${name}`);
+ }
+ }
+
+ if (check) {
+ if (stale) {
+ console.error(`\n${stale} generated file(s) out of date. Run: node scripts/generate-docs-reference.js`);
+ // see the note in docs-check.js: process.exit() can truncate piped stdout
+ process.exitCode = 1;
+ return;
+ }
+ console.log("generated reference is up to date");
+ return;
+ }
+
+ // findings the menu walk surfaces for free
+ const referenced = new Set();
+ for (const bucket of Object.keys(menus)) {
+ for (const e of menus[bucket]) if (e.submenu) referenced.add(e.submenu);
+ }
+ const deadSubmenus = [...submenuById.keys()].filter((id) => !referenced.has(id));
+ const noMenu = rows.filter((r) => !r.simplified.length && !r.classic.length);
+ const paletteOnlyAndNoMenu = noMenu.filter((r) => !r.palette);
+
+ const byTitle = new Map();
+ for (const r of rows.filter((r) => r.palette)) {
+ if (!byTitle.has(r.full)) byTitle.set(r.full, []);
+ byTitle.get(r.full).push(r.id);
+ }
+ const dupes = [...byTitle.entries()].filter(([, ids]) => ids.length > 1);
+
+ const doublePrefixed = rows.filter((r) => {
+ const cmd = contributes.commands.find((c) => c.command === r.id);
+ if (!cmd || !cmd.category) return false;
+ const cat = loc(cmd.category);
+ return loc(cmd.title).toLowerCase().startsWith(cat.toLowerCase());
+ });
+
+ console.log(`\n--- menu walk findings ---`);
+ console.log(
+ `commands: ${rows.length}, in default menu: ${simplifiedPaths.size}, in classic menu: ${classicPaths.size}`,
+ );
+ if (deadSubmenus.length) console.log(`dead submenus (declared, never referenced): ${deadSubmenus.join(", ")}`);
+ if (paletteOnlyAndNoMenu.length)
+ console.log(`unreachable (no menu, hidden from palette): ${paletteOnlyAndNoMenu.map((r) => r.id).join(", ")}`);
+ for (const [title, ids] of dupes)
+ console.log(`duplicate palette title ${JSON.stringify(title)}: ${ids.join(", ")}`);
+ for (const r of doublePrefixed)
+ console.log(`title repeats its category, palette shows ${JSON.stringify(r.full)}: ${r.id}`);
+ // `nodeOf` returns "any" when a clause names no tree node, which renders as
+ // NODE_LABEL.any. That is the value signalling an unresolved node; "Other"
+ // is never produced, so filtering on it made this diagnostic dead code.
+ const orphanNode = rows.filter((r) =>
+ [...r.simplified, ...r.classic].some((p) => p.startsWith(`${NODE_LABEL.any} >`)),
+ );
+ if (orphanNode.length) console.log(`unresolved menu node: ${orphanNode.map((r) => r.id).join(", ")}`);
+
+ // same action pinned to different versions across templates
+ const byAction = new Map();
+ for (const a of actions) {
+ if (!byAction.has(a.action)) byAction.set(a.action, []);
+ byAction.get(a.action).push(a);
+ }
+ for (const [action, uses] of byAction) {
+ if (uses.length > 1) console.log(`version skew ${action}: ${uses.map((u) => u.version).join(" vs ")}`);
+ }
+}
+
+main();
diff --git a/scripts/lib/menu-graph.js b/scripts/lib/menu-graph.js
new file mode 100644
index 000000000..23b86df8a
--- /dev/null
+++ b/scripts/lib/menu-graph.js
@@ -0,0 +1,243 @@
+"use strict";
+
+/**
+ * The AKS cluster context menu graph, derived from package.json.
+ *
+ * Shared by scripts/generate-docs-reference.js (which renders it) and
+ * scripts/docs-check.js (which validates menu breadcrumbs in prose against it),
+ * so the two can never disagree about the menu layout.
+ */
+
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = path.resolve(__dirname, "..", "..");
+const readJson = (p) => JSON.parse(fs.readFileSync(path.join(ROOT, p), "utf8"));
+
+const pkg = readJson("package.json");
+const nls = readJson("package.nls.json");
+const contributes = pkg.contributes;
+
+/** Resolves a `%key%` placeholder against package.nls.json. */
+function loc(value) {
+ if (typeof value !== "string") return value;
+ const m = /^%(.+)%$/.exec(value);
+ if (!m) return value;
+ if (!(m[1] in nls)) throw new Error(`package.nls.json has no entry for %${m[1]}%`);
+ return nls[m[1]];
+}
+
+const MENU_MODE_KEY = "config.aks.simplifiedMenuStructure";
+
+/** Splits on `sep` at paren depth 0. */
+function splitTop(expr, sep) {
+ const parts = [];
+ let depth = 0;
+ let start = 0;
+ for (let i = 0; i < expr.length; i++) {
+ const ch = expr[i];
+ if (ch === "(") depth++;
+ else if (ch === ")") depth--;
+ else if (depth === 0 && expr.startsWith(sep, i)) {
+ parts.push(expr.slice(start, i));
+ i += sep.length - 1;
+ start = i + 1;
+ }
+ }
+ parts.push(expr.slice(start));
+ return parts.map((s) => s.trim()).filter(Boolean);
+}
+
+function stripOuterParens(s) {
+ let out = s.trim();
+ while (out.startsWith("(") && out.endsWith(")")) {
+ let depth = 0;
+ let matched = true;
+ for (let i = 0; i < out.length; i++) {
+ if (out[i] === "(") depth++;
+ else if (out[i] === ")") depth--;
+ if (depth === 0 && i < out.length - 1) {
+ matched = false;
+ break;
+ }
+ }
+ if (!matched) break;
+ out = out.slice(1, -1).trim();
+ }
+ return out;
+}
+
+/**
+ * Tree nodes a `when` clause can target, in match order. Adding a node type is
+ * a new row here rather than a new branch.
+ *
+ * Every row accepts both the regex form (`viewItem =~ /aks\.cluster/i`) and the
+ * equality form (`viewItem == aks.subscription`), which package.json already uses.
+ * When only the subscription row tolerated `==`, the equality form of the others
+ * fell through to "any" and rendered as `Any > ...` with nothing flagged.
+ */
+const NODE_PATTERNS = [
+ [/viewItem\s*[=~]+\s*\/?aks\\?\.cluster/i, "cluster"],
+ [/viewItem\s*[=~]+\s*\/?aks\\?\.subscription/i, "subscription"],
+ [/viewItem\s*[=~]+\s*\/?aks\\?\.fleet/i, "fleet"],
+ [/vsKubernetes/i, "k8s-cluster"],
+ [/viewItem\s*[=~]+\s*\/?Azure/, "azure"],
+];
+
+/**
+ * Which tree node a clause targets. Returns "any" when the clause names none,
+ * so nested entries inherit the node their parent submenu was attached to.
+ */
+function nodeOf(conjuncts) {
+ const joined = conjuncts.join(" && ");
+ const hit = NODE_PATTERNS.find(([pattern]) => pattern.test(joined));
+ return hit ? hit[1] : "any";
+}
+
+/**
+ * Collapses the equivalent boolean forms VS Code accepts into `{ key, negated }`:
+ * `key`, `!key`, `key == true`, `key != false`, `key == false`, `key != true`.
+ *
+ * Without this, `config.aks.simplifiedMenuStructure == true` would not match the
+ * menu-mode rule below and would be reported as an ordinary runtime flag, putting
+ * the command in both menu columns. package.json already uses the `== true` form
+ * for other settings, so this is one edit away from happening.
+ */
+function normaliseBoolean(conjunct) {
+ let negated = false;
+ let key = conjunct.trim();
+ while (key.startsWith("!")) {
+ negated = !negated;
+ key = key.slice(1).trim();
+ }
+ const comparison = /^(.*?)\s*(==|!=)\s*(true|false)$/.exec(key);
+ if (comparison) {
+ key = comparison[1].trim();
+ const assertsTrue = (comparison[2] === "==") === (comparison[3] === "true");
+ if (!assertsTrue) negated = !negated;
+ }
+ return { key, negated };
+}
+
+/**
+ * Context keys VS Code treats as permanently false. `false` is the documented
+ * form; `never` is an undefined context key, so falsy, and this repo uses both.
+ * An entry gated on either is hidden, and must not be reported as reachable.
+ */
+const NEVER_KEYS = new Set(["false", "never"]);
+
+/**
+ * How a single conjunct is interpreted, in match order:
+ * satisfied false means the disjunct cannot hold in this menu mode
+ * flag true means it is a runtime condition to report, not to evaluate
+ * A conjunct matching nothing here is a view/viewItem predicate, handled by nodeOf.
+ */
+const CONJUNCT_RULES = [
+ {
+ match: (n) => NEVER_KEYS.has(n.key.toLowerCase()),
+ satisfied: (_simplified, n) => n.negated,
+ },
+ {
+ match: (n) => n.key === MENU_MODE_KEY,
+ satisfied: (simplified, n) => (n.negated ? !simplified : simplified),
+ },
+ { match: (n) => /^config\./.test(n.key) || /^workspaceFolderCount/.test(n.key), flag: true },
+];
+
+/** Satisfiable disjuncts of a `when` clause under a fixed menu mode. */
+function evaluateWhen(when, simplified) {
+ if (!when) {
+ return [{ node: "any", flags: [] }];
+ }
+ const results = [];
+ for (const disjunct of splitTop(when, "||")) {
+ const conjuncts = splitTop(stripOuterParens(disjunct), "&&");
+ const flags = [];
+ let ok = true;
+ for (const raw of conjuncts) {
+ const c = stripOuterParens(raw);
+ const normalised = normaliseBoolean(c);
+ const rule = CONJUNCT_RULES.find((r) => r.match(normalised));
+ if (!rule) {
+ continue;
+ }
+ if (rule.flag) {
+ flags.push(c);
+ } else if (!rule.satisfied(simplified, normalised)) {
+ ok = false;
+ }
+ }
+ if (ok) {
+ results.push({ node: nodeOf(conjuncts), flags });
+ }
+ }
+ return results;
+}
+
+const submenuById = new Map(contributes.submenus.map((s) => [s.id, s]));
+const menus = contributes.menus;
+
+/**
+ * Walks the tree-view menus for one menu mode.
+ *
+ * Returns:
+ * commandPaths Map
+ * labelPaths Set<"node|Label > Label">, every reachable point in the tree,
+ * including intermediate submenus, so a breadcrumb that stops at
+ * a submenu still validates.
+ */
+function walk(simplified) {
+ const commandPaths = new Map();
+ const labelPaths = new Set();
+ const active = new Set();
+
+ const visit = (bucket, breadcrumb, node, flags) => {
+ // `active` is the set of submenus on the current stack. Keying it on the
+ // bucket alone is what makes the guard work: the earlier key included the
+ // breadcrumb, which grows on every hop, so it was fresh on each recursion
+ // and an A -> B -> A pair recursed until the stack overflowed.
+ active.add(bucket);
+
+ for (const entry of menus[bucket] || []) {
+ for (const hit of evaluateWhen(entry.when, simplified)) {
+ const childNode = hit.node === "any" ? node : hit.node;
+ const childFlags = [...new Set([...flags, ...hit.flags])];
+ if (entry.submenu) {
+ const sub = submenuById.get(entry.submenu);
+ if (!sub) throw new Error(`menu references unknown submenu ${entry.submenu}`);
+ // Checked before the label is recorded, not just before the
+ // recursion, so a cycle contributes no path at all rather
+ // than one dangling breadcrumb that re-enters a submenu.
+ if (active.has(entry.submenu)) continue; // submenu cycle guard
+ const crumb = [...breadcrumb, loc(sub.label)];
+ labelPaths.add(`${childNode}|${crumb.join(" > ")}`);
+ visit(entry.submenu, crumb, childNode, childFlags);
+ } else if (entry.command) {
+ const cmd = contributes.commands.find((c) => c.command === entry.command);
+ if (cmd) labelPaths.add(`${childNode}|${[...breadcrumb, loc(cmd.title)].join(" > ")}`);
+ if (!commandPaths.has(entry.command)) commandPaths.set(entry.command, []);
+ commandPaths.get(entry.command).push({ node: childNode, breadcrumb, flags: childFlags });
+ }
+ }
+ }
+ active.delete(bucket);
+ };
+
+ visit("view/item/context", [], "any", []);
+ return { commandPaths, labelPaths };
+}
+
+module.exports = {
+ pkg,
+ contributes,
+ loc,
+ splitTop,
+ stripOuterParens,
+ normaliseBoolean,
+ evaluateWhen,
+ submenuById,
+ menus,
+ walk,
+ /** Menu mode the documentation is written against. */
+ DEFAULT_SIMPLIFIED: true,
+};
diff --git a/scripts/lib/menu-graph.test.js b/scripts/lib/menu-graph.test.js
new file mode 100644
index 000000000..8761f18b0
--- /dev/null
+++ b/scripts/lib/menu-graph.test.js
@@ -0,0 +1,218 @@
+"use strict";
+
+/**
+ * Unit tests for the `when`-clause parser behind the docs tooling.
+ *
+ * These run on plain node with no compile step:
+ * npm run test:scripts
+ *
+ * The cases below are grouped as parser primitives first, then the three
+ * regressions found in review: the submenu cycle guard, `false`/`never` reading
+ * as reachable, and the equality form of a node predicate resolving to "any".
+ */
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const graph = require("./menu-graph");
+const { splitTop, stripOuterParens, normaliseBoolean, evaluateWhen } = graph;
+
+const MENU_MODE = "config.aks.simplifiedMenuStructure";
+
+describe("splitTop", () => {
+ it("splits on the separator at depth 0", () => {
+ assert.deepEqual(splitTop("a && b && c", "&&"), ["a", "b", "c"]);
+ });
+
+ it("does not split inside parentheses", () => {
+ assert.deepEqual(splitTop("(a && b) || c", "||"), ["(a && b)", "c"]);
+ });
+
+ it("keeps a nested separator with its group", () => {
+ assert.deepEqual(splitTop("(a || (b || c)) || d", "||"), ["(a || (b || c))", "d"]);
+ });
+
+ it("trims and drops empty segments", () => {
+ assert.deepEqual(splitTop(" a && && b ", "&&"), ["a", "b"]);
+ });
+
+ it("returns a single element when the separator is absent", () => {
+ assert.deepEqual(splitTop("viewItem == aks.cluster", "||"), ["viewItem == aks.cluster"]);
+ });
+});
+
+describe("stripOuterParens", () => {
+ it("removes one wrapping pair", () => {
+ assert.equal(stripOuterParens("(a && b)"), "a && b");
+ });
+
+ it("removes repeated wrapping pairs", () => {
+ assert.equal(stripOuterParens("((a))"), "a");
+ });
+
+ it("leaves adjacent groups alone", () => {
+ // the outer parens here are not a matching pair around the whole string
+ assert.equal(stripOuterParens("(a) && (b)"), "(a) && (b)");
+ });
+
+ it("is a no-op on an unwrapped clause", () => {
+ assert.equal(stripOuterParens(" a && b "), "a && b");
+ });
+});
+
+describe("normaliseBoolean", () => {
+ const cases = [
+ ["key", "key", false],
+ ["!key", "key", true],
+ ["!!key", "key", false],
+ ["key == true", "key", false],
+ ["key != false", "key", false],
+ ["key == false", "key", true],
+ ["key != true", "key", true],
+ ["!key == false", "key", false],
+ ];
+
+ for (const [input, key, negated] of cases) {
+ it(`${input} -> ${negated ? "!" : ""}${key}`, () => {
+ assert.deepEqual(normaliseBoolean(input), { key, negated });
+ });
+ }
+
+ it("collapses the equivalent forms of the menu-mode key to one key", () => {
+ // package.json already uses the `== true` form for other settings, so
+ // treating it as an ordinary runtime flag would put a command in both
+ // menu columns.
+ for (const form of [MENU_MODE, `${MENU_MODE} == true`, `${MENU_MODE} != false`]) {
+ assert.deepEqual(normaliseBoolean(form), { key: MENU_MODE, negated: false });
+ }
+ for (const form of [`!${MENU_MODE}`, `${MENU_MODE} == false`, `${MENU_MODE} != true`]) {
+ assert.deepEqual(normaliseBoolean(form), { key: MENU_MODE, negated: true });
+ }
+ });
+});
+
+describe("evaluateWhen", () => {
+ it("treats an absent clause as reachable on any node", () => {
+ assert.deepEqual(evaluateWhen(undefined, true), [{ node: "any", flags: [] }]);
+ });
+
+ it("honours the menu mode", () => {
+ const when = `viewItem =~ /aks\\.cluster/i && ${MENU_MODE}`;
+ assert.deepEqual(evaluateWhen(when, true), [{ node: "cluster", flags: [] }]);
+ assert.deepEqual(evaluateWhen(when, false), []);
+ });
+
+ it("honours a negated menu mode", () => {
+ const when = `viewItem =~ /aks\\.cluster/i && !${MENU_MODE}`;
+ assert.deepEqual(evaluateWhen(when, false), [{ node: "cluster", flags: [] }]);
+ assert.deepEqual(evaluateWhen(when, true), []);
+ });
+
+ it("reports other config keys as runtime flags rather than evaluating them", () => {
+ const [hit] = evaluateWhen("viewItem =~ /aks\\.cluster/i && config.aks.argoCDEnabled", true);
+ assert.equal(hit.node, "cluster");
+ assert.deepEqual(hit.flags, ["config.aks.argoCDEnabled"]);
+ });
+
+ it("keeps each satisfiable disjunct", () => {
+ const when = "viewItem =~ /aks\\.cluster/i || viewItem =~ /aks\\.fleet/i";
+ assert.deepEqual(
+ evaluateWhen(when, true).map((h) => h.node),
+ ["cluster", "fleet"],
+ );
+ });
+
+ // regression: a conjunct matching no rule used to be skipped, leaving the
+ // disjunct satisfiable, so a hidden entry was reported as present in the menu
+ for (const never of ["never", "false"]) {
+ it(`treats \`${never}\` as unsatisfiable`, () => {
+ assert.deepEqual(evaluateWhen(`viewItem =~ /aks\\.cluster/i && ${never}`, true), []);
+ assert.deepEqual(evaluateWhen(never, true), []);
+ });
+ }
+
+ it("treats a negated never as satisfiable", () => {
+ assert.deepEqual(evaluateWhen("viewItem =~ /aks\\.cluster/i && !never", true), [
+ { node: "cluster", flags: [] },
+ ]);
+ });
+
+ // regression: only the subscription row tolerated `==`, so the equality form
+ // of the other nodes fell through to "any" and rendered as `Any > ...`
+ describe("resolves a node from both the regex and equality forms", () => {
+ const nodes = [
+ ["aks.cluster", "cluster"],
+ ["aks.subscription", "subscription"],
+ ["aks.fleet", "fleet"],
+ ];
+ for (const [viewItem, node] of nodes) {
+ it(viewItem, () => {
+ const escaped = viewItem.replace(".", "\\.");
+ const regexForm = `view == kubernetes.cloudExplorer && viewItem =~ /${escaped}/i`;
+ const equalityForm = `view == kubernetes.cloudExplorer && viewItem == ${viewItem}`;
+ assert.deepEqual(evaluateWhen(regexForm, true), [{ node, flags: [] }]);
+ assert.deepEqual(evaluateWhen(equalityForm, true), [{ node, flags: [] }]);
+ });
+ }
+ });
+
+ it("falls back to any when no node is named", () => {
+ assert.deepEqual(evaluateWhen("workspaceFolderCount >= 1", true), [
+ { node: "any", flags: ["workspaceFolderCount >= 1"] },
+ ]);
+ });
+});
+
+describe("walk", () => {
+ it("produces a menu for both modes", () => {
+ for (const simplified of [true, false]) {
+ const { commandPaths, labelPaths } = graph.walk(simplified);
+ assert.ok(commandPaths.size > 0, "expected commands in the menu");
+ assert.ok(labelPaths.size > 0, "expected reachable label paths");
+ }
+ });
+
+ it("records intermediate submenus, so a breadcrumb stopping at one validates", () => {
+ const { labelPaths } = graph.walk(true);
+ const nested = [...labelPaths].find((p) => p.includes(" > "));
+ assert.ok(nested, "expected at least one nested path");
+ const [node, crumb] = nested.split("|");
+ const parent = crumb.split(" > ").slice(0, -1).join(" > ");
+ assert.ok(labelPaths.has(`${node}|${parent}`), `parent path ${parent} should also be reachable`);
+ });
+
+ // regression: the guard keyed on bucket + breadcrumb, and the breadcrumb grows
+ // on every hop, so the key was always fresh and an A -> B -> A pair recursed
+ // until the stack overflowed
+ it("stops on a submenu cycle instead of overflowing the stack", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "menu-graph-cycle-"));
+ fs.mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true });
+
+ const root = path.resolve(__dirname, "..", "..");
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
+ pkg.contributes.submenus.push({ id: "cycleA", label: "A" }, { id: "cycleB", label: "B" });
+ pkg.contributes.menus["view/item/context"].push({
+ submenu: "cycleA",
+ when: "viewItem =~ /aks\\.cluster/i",
+ });
+ pkg.contributes.menus.cycleA = [{ submenu: "cycleB" }];
+ pkg.contributes.menus.cycleB = [{ submenu: "cycleA" }];
+
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg));
+ fs.copyFileSync(path.join(root, "package.nls.json"), path.join(dir, "package.nls.json"));
+ fs.copyFileSync(
+ path.join(root, "scripts", "lib", "menu-graph.js"),
+ path.join(dir, "scripts", "lib", "menu-graph.js"),
+ );
+
+ const cyclic = require(path.join(dir, "scripts", "lib", "menu-graph.js"));
+ const { labelPaths } = cyclic.walk(true);
+ assert.ok(labelPaths.has("cluster|A"), "expected the cycle entry point to be reachable");
+ assert.ok(labelPaths.has("cluster|A > B"), "expected one hop through the cycle");
+ assert.ok(!labelPaths.has("cluster|A > B > A"), "expected the walk to stop before repeating a submenu");
+
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+});