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 }); + }); +});