From eb1e6110274294e53dc1730c36b52954d5f2ac60 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:18:19 -0400 Subject: [PATCH 1/2] fix: enrich docs for every operation, not the first three paths scripts/rewrite_docs.ts runs as the second half of `yarn codegen`. It iterated `Object.values(document.paths).slice(0, 3)`, so x-vf enrichment reached 7 of 151 operations and was silently skipped for the other 144. The slice was present in the file's initial commit (163f316), alongside no other limiting logic, so it is debug residue rather than a deliberate bound. Removing the slice alone crashes codegen. Three problems were masked by it: 1. `cli.command.replace(' ', '_')` substitutes only the first space, so every three-word command produced a path with a literal space -- `docs/vf_api-tool_variable create.md` -- which does not exist. 69 of 151 commands are three-word. cmd/gendocs builds the real filename with `strings.ReplaceAll(cmd.CommandPath(), " ", "_")`; this now matches it. 2. Operations marked `x-speakeasy-ignore: true` get no command and therefore no docs page. All 12 are `create-many` batch endpoints. Iterating them raised ENOENT on `docs/vf_variable_create-many.md` and aborted the run. 3. `OperationMetadata.parse(operation['x-vf'])` throws on an operation carrying no CLI metadata. Every operation in the current spec has `x-vf.cli`, but `StableEnvironmentController_export` is filtered out of the generated CLI while remaining in openapi.stable.json, so it is reachable here and untested. Operations without `x-vf.cli` are now skipped as "not a CLI command"; a malformed `cli` block still throws, because that is a spec error. A command that carries enrichment but has no docs page is now reported by name and skipped rather than aborting the run, so a cosmetic docs step cannot leave a half-generated tree behind. Pages are read only when there is something to inject and written only when content actually changed. The commented-out link-rewriting and sidebarTitle blocks are left untouched. Both were commented in the same initial commit with no recorded rationale, and neither is currently correct: the link rewrite calls `replaceAll` with a non-global RegExp, which throws TypeError, and the frontmatter block prepends unconditionally, so it would stack a new block on every run. No operation in the spec currently defines `cli.example` or `cli.description`, so this produces zero changes under docs/ today. Verified against the committed .speakeasy/out.openapi.yaml: the script reports 0 rewrites and docs/ is byte-identical. With enrichment injected into all 151 operations it rewrites 121 pages, up from 7. --- scripts/rewrite_docs.ts | 49 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/scripts/rewrite_docs.ts b/scripts/rewrite_docs.ts index ab7a023..2693f09 100644 --- a/scripts/rewrite_docs.ts +++ b/scripts/rewrite_docs.ts @@ -22,17 +22,43 @@ enum Method { const document: OpenAPIV3.Document = JSON.parse(await fs.readFile('openapi.stable.json', 'utf-8')); -for (const operations of Object.values(document.paths).slice(0, 3)) { +/** Commands that carry enrichment but whose docs page is absent — reported once at the end. */ +const missingDocsPages: string[] = []; +let rewrittenCount = 0; + +for (const operations of Object.values(document.paths)) { if (!operations) continue; for (const method of Object.values(Method)) { - const operation = operations[method]; + const operation = operations[method] as (OpenAPIV3.OperationObject & Record) | undefined; if (!operation) continue; - const { cli } = OperationMetadata.parse((operation as any)['x-vf']); + // Speakeasy generates no command for ignored operations, so they have no docs page to enrich. + if (operation['x-speakeasy-ignore'] === true) continue; + + const metadata = operation['x-vf']; + + // Operations without CLI metadata are not exposed as commands and have nothing to enrich. + // A malformed `cli` block is still a spec error, so parsing below stays strict. + if (!metadata || typeof metadata !== 'object' || !('cli' in metadata)) continue; + + const { cli } = OperationMetadata.parse(metadata); + + if (!cli.example && !cli.description) continue; + + // Mirrors the filename cmd/gendocs builds: strings.ReplaceAll(cmd.CommandPath(), " ", "_"). + // `replace` would substitute only the first space and break every command with a subgroup. + const docsPath = `docs/vf_${cli.command.replaceAll(' ', '_')}.md`; - const docsPath = `docs/vf_${cli.command.replace(' ', '_')}.md`; - let markdown = await fs.readFile(docsPath, 'utf-8'); + let markdown: string; + try { + markdown = await fs.readFile(docsPath, 'utf-8'); + } catch { + missingDocsPages.push(`${cli.command} -> ${docsPath}`); + continue; + } + + const original = markdown; // markdown = markdown.replaceAll(/\[([^\]]+)\]\(([^)]+)\.md\)/, '[$1](./$2)'); @@ -53,6 +79,19 @@ for (const operations of Object.values(document.paths).slice(0, 3)) { // ${markdown}`; // } + if (markdown === original) continue; + await fs.writeFile(docsPath, markdown, 'utf-8'); + rewrittenCount += 1; } } + +if (missingDocsPages.length > 0) { + console.warn( + `[rewrite_docs] ${missingDocsPages.length} command(s) carry x-vf enrichment but have no docs page. ` + + `Run \`speakeasy run\` to regenerate docs/ first:` + ); + for (const entry of missingDocsPages) console.warn(` - ${entry}`); +} + +console.log(`[rewrite_docs] rewrote ${rewrittenCount} docs page(s)`); From e19480f4e7c3628879ec69ac4ff177680d0b1c95 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:38:03 -0400 Subject: [PATCH 2/2] fix: make docs enrichment idempotent, $-safe, and fail-loud Addresses adversarial review of this PR. Two of the findings are corruption bugs that this PR would have armed rather than caused: it takes the enrichment path from unreachable to live on 121+ pages, so the multiplier must not land before the fixes. A correction first. This PR previously claimed the description non-idempotency "cannot surface in `yarn codegen`, which regenerates docs/ first". That is false. `docs/` is produced by cmd/gendocs, and nothing in the repository invokes it -- `codegen` is `speakeasy run && tsx ./scripts/rewrite_docs.ts`. Proof: commit 2868969 is a Speakeasy run that wrote 24 files under internal/cli/, including the entire `test` command tree, and zero files under docs/. docs/ was last touched 116 commits ago. So this script is always re-applied to its own previous output. - Injected descriptions are now delimited by `` markers and replaced in place. Previously each run appended another copy: measured at +1 copy and +33 bytes per run, unbounded. Also handles a changed description, which a "skip if already present" guard would not. - Both injections now use replacer functions instead of replacement strings. `$1`, `$&`, `$'` and `$$` are substitution directives inside a replacement string, and shell examples contain them routinely. An example containing `awk '{print $1}'` previously rewrote `$1` to the literal text `### Examples`, and `printf $'\n'` spliced the remainder of the document into the code fence, silently and with exit 0. - If no enriched command has a docs page at all, the script now throws instead of warning. That state means docs/ was never generated, and exiting 0 there turned a broken pipeline into a green build. - The read guard now rethrows anything that is not ENOENT. EACCES and EISDIR were being reported as "no docs page" with advice that could not help. - Parse failures name the operation. Across 151 operations a bare ZodError pointed only at rewrite_docs.ts. A missing key is the most likely authoring error in a hand-maintained extension. - Injected content carrying its own `### ` heading is rejected, since it would break the section anchors that this script and the next run depend on. - The warning now says `go run ./cmd/gendocs`, not `speakeasy run`. The old text named a command that does not write docs/ and, inside `yarn codegen`, had already run moments earlier. - `Record` replaced with a declared `OperationExtensions` interface, so a typo in an extension key is a compile error rather than a guard that silently never fires. Adds test/rewrite-docs.test.ts, the first coverage of scripts/. Nine hermetic cases driving the real script through execa in a temp directory. Six of them fail against the previous revision of this branch, including both corruption bugs. Verified with the repository's own pinned toolchain, recovered from .yarn/cache: tsc 7.0.2 with @voiceflow/tsconfig 1.17.0 reports 0 errors across the project, oxlint reports no correctness violations, and oxfmt is clean under the repo's style. Against the committed spec the script still rewrites 0 pages and leaves docs/ byte-identical. --- scripts/rewrite_docs.ts | 90 +++++++++++++++++--- test/rewrite-docs.test.ts | 174 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 test/rewrite-docs.test.ts diff --git a/scripts/rewrite_docs.ts b/scripts/rewrite_docs.ts index 2693f09..0139725 100644 --- a/scripts/rewrite_docs.ts +++ b/scripts/rewrite_docs.ts @@ -11,6 +11,15 @@ const OperationMetadata = z.looseObject({ }), }); +/** + * Extensions this script reads off an operation. Declaring them keeps a typo in an + * extension key a compile error rather than a guard that silently never fires. + */ +interface OperationExtensions { + 'x-speakeasy-ignore'?: unknown; + 'x-vf'?: unknown; +} + enum Method { GET = 'get', PUT = 'put', @@ -20,40 +29,75 @@ enum Method { DELETE = 'delete', } +/** + * Injected descriptions are delimited so a re-run replaces the previous block instead of + * appending a second copy. This matters because `docs/` is NOT regenerated between runs: + * it is produced by cmd/gendocs, which nothing in the repo invokes -- `yarn codegen` is + * `speakeasy run && tsx ./scripts/rewrite_docs.ts`. This script must therefore be + * idempotent against its own previous output. + */ +const DESCRIPTION_BLOCK = /[\s\S]*?\n?/; +const descriptionBlock = (body: string) => `\n${body}\n\n`; + +/** Injected content sits inside a section, so a heading of its own would break the anchors. */ +const HEADING_LINE = /^### /m; + const document: OpenAPIV3.Document = JSON.parse(await fs.readFile('openapi.stable.json', 'utf-8')); /** Commands that carry enrichment but whose docs page is absent — reported once at the end. */ const missingDocsPages: string[] = []; +let candidateCount = 0; let rewrittenCount = 0; -for (const operations of Object.values(document.paths)) { +for (const [path, operations] of Object.entries(document.paths)) { if (!operations) continue; for (const method of Object.values(Method)) { - const operation = operations[method] as (OpenAPIV3.OperationObject & Record) | undefined; + const operation = operations[method] as (OpenAPIV3.OperationObject & OperationExtensions) | undefined; if (!operation) continue; - // Speakeasy generates no command for ignored operations, so they have no docs page to enrich. + // Speakeasy generates no command for ignored operations, so they have no docs page. if (operation['x-speakeasy-ignore'] === true) continue; const metadata = operation['x-vf']; - // Operations without CLI metadata are not exposed as commands and have nothing to enrich. - // A malformed `cli` block is still a spec error, so parsing below stays strict. - if (!metadata || typeof metadata !== 'object' || !('cli' in metadata)) continue; + // Operations without CLI metadata are not exposed as commands and have nothing to + // enrich. Anything else malformed falls through to the parse below and fails loudly. + if (metadata === undefined || metadata === null) continue; + if (typeof metadata === 'object' && !('cli' in metadata)) continue; - const { cli } = OperationMetadata.parse(metadata); + let parsed: z.infer; + try { + parsed = OperationMetadata.parse(metadata); + } catch (cause) { + throw new Error(`Invalid x-vf.cli metadata on ${method.toUpperCase()} ${path}`, { cause }); + } + const { cli } = parsed; if (!cli.example && !cli.description) continue; + for (const field of ['example', 'description'] as const) { + const value = cli[field]; + if (value && HEADING_LINE.test(value)) { + throw new Error( + `x-vf.cli.${field} on ${method.toUpperCase()} ${path} contains a "### " heading, ` + + 'which would break the structure of the docs page it is injected into' + ); + } + } + + candidateCount += 1; + // Mirrors the filename cmd/gendocs builds: strings.ReplaceAll(cmd.CommandPath(), " ", "_"). - // `replace` would substitute only the first space and break every command with a subgroup. + // `replace` would substitute only the first space and break every subgrouped command. const docsPath = `docs/vf_${cli.command.replaceAll(' ', '_')}.md`; let markdown: string; try { markdown = await fs.readFile(docsPath, 'utf-8'); - } catch { + } catch (error) { + // Only a genuinely absent page is tolerable; a permissions or I/O fault is not. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; missingDocsPages.push(`${cli.command} -> ${docsPath}`); continue; } @@ -62,12 +106,25 @@ for (const operations of Object.values(document.paths)) { // markdown = markdown.replaceAll(/\[([^\]]+)\]\(([^)]+)\.md\)/, '[$1](./$2)'); + // Both injections use replacer functions rather than replacement strings: `$1`, `$&`, + // `$'` and `$$` are substitution directives inside a replacement string, and authored + // shell examples contain them routinely (`awk '{print $1}'`, `$$` for a PID). if (cli.example) { - markdown = markdown.replace(/(### Examples).+(### Options\n)/s, `$1\n\n${cli.example}\n$2`); + const { example } = cli; + markdown = markdown.replace( + /(### Examples).+?(### Options\n)/s, + (_match, heading: string, options: string) => `${heading}\n\n${example}\n${options}` + ); } if (cli.description) { - markdown = markdown.replace(/(### Synopsis.+)(### Examples\n)/s, `$1\n${cli.description}\n$2`); + const block = descriptionBlock(cli.description); + markdown = DESCRIPTION_BLOCK.test(markdown) + ? markdown.replace(DESCRIPTION_BLOCK, () => block) + : markdown.replace( + /(### Synopsis.+?)(### Examples\n)/s, + (_match, synopsis: string, examples: string) => `${synopsis}${block}${examples}` + ); } // const lastCommand = cli.command.split(' ').at(-1); @@ -86,10 +143,19 @@ for (const operations of Object.values(document.paths)) { } } +// Every page missing means docs/ was never generated, not that a few commands are new. +// Warning and exiting 0 there would turn a broken pipeline into a green build. +if (candidateCount > 0 && missingDocsPages.length === candidateCount) { + throw new Error( + `No docs page exists for any of the ${candidateCount} command(s) carrying x-vf enrichment. ` + + 'docs/ is generated by cmd/gendocs, which `yarn codegen` does not run — regenerate it with `go run ./cmd/gendocs`.' + ); +} + if (missingDocsPages.length > 0) { console.warn( `[rewrite_docs] ${missingDocsPages.length} command(s) carry x-vf enrichment but have no docs page. ` + - `Run \`speakeasy run\` to regenerate docs/ first:` + 'docs/ is generated by cmd/gendocs — regenerate it with `go run ./cmd/gendocs`:' ); for (const entry of missingDocsPages) console.warn(` - ${entry}`); } diff --git a/test/rewrite-docs.test.ts b/test/rewrite-docs.test.ts new file mode 100644 index 0000000..a21d52c --- /dev/null +++ b/test/rewrite-docs.test.ts @@ -0,0 +1,174 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { execa } from 'execa'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TSX = path.join(ROOT, 'node_modules/.bin/tsx'); +const SCRIPT = path.join(ROOT, 'scripts/rewrite_docs.ts'); + +/** Mirrors the shape cmd/gendocs emits: one each of Synopsis, Examples and Options. */ +const DOCS_PAGE = `## vf project get + +Get project + +### Synopsis + +Get a project by ID. + +\`\`\` +vf project get [flags] +\`\`\` + +### Examples + +\`\`\` +generated example +\`\`\` + +### Options + +\`\`\` + -h, --help help for get +\`\`\` + +### Options inherited from parent commands + +\`\`\` + --token string Voiceflow bearer token +\`\`\` + +### SEE ALSO + +* [vf project](vf_project.md) +`; + +const spec = (cli: Record, extras: Record = {}) => ({ + openapi: '3.0.0', + info: { title: 'test', version: '1.0.0' }, + paths: { '/v1/stable/project/{projectID}': { get: { 'x-vf': { cli }, ...extras, responses: {} } } }, +}); + +describe('scripts/rewrite_docs.ts', () => { + let cwd: string; + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'rewrite-docs-')); + await fs.mkdir(path.join(cwd, 'docs')); + await fs.writeFile(path.join(cwd, 'docs/vf_project_get.md'), DOCS_PAGE); + }); + + afterEach(() => fs.rm(cwd, { recursive: true, force: true })); + + const write = (document: unknown) => fs.writeFile(path.join(cwd, 'openapi.stable.json'), JSON.stringify(document)); + + const run = () => execa(TSX, [SCRIPT], { cwd, reject: false }); + const page = () => fs.readFile(path.join(cwd, 'docs/vf_project_get.md'), 'utf-8'); + + it('injects example and description into the generated page', async () => { + await write( + spec({ command: 'project get', example: 'vf project get --id abc', description: 'Fetches one project.' }) + ); + + const result = await run(); + + expect(result.exitCode).toBe(0); + await expect(page()).resolves.toContain('vf project get --id abc'); + await expect(page()).resolves.toContain('Fetches one project.'); + await expect(page()).resolves.not.toContain('generated example'); + }); + + // docs/ is produced by cmd/gendocs, which nothing in the repo runs, so this script is + // re-applied to its own previous output. Appending instead of replacing compounds forever. + it('is idempotent across repeated runs', async () => { + await write(spec({ command: 'project get', example: 'vf project get', description: 'Fetches one project.' })); + + await run(); + const first = await page(); + const second = await run(); + + expect(second.exitCode).toBe(0); + await expect(page()).resolves.toBe(first); + expect(first.match(/Fetches one project\./g)).toHaveLength(1); + }); + + it('replaces a previously injected description rather than accumulating', async () => { + await write(spec({ command: 'project get', description: 'First.' })); + await run(); + await write(spec({ command: 'project get', description: 'Second.' })); + await run(); + + const content = await page(); + expect(content).toContain('Second.'); + expect(content).not.toContain('First.'); + }); + + // `$1`, `$&`, `$'` and `$$` are substitution directives in a String.replace replacement + // string, and shell examples contain them routinely. + it('preserves $ sequences in authored examples verbatim', async () => { + const example = `vf project get | awk '{print $1}' && echo "run-$$" && printf $'\\n'`; + await write(spec({ command: 'project get', example })); + + await run(); + + await expect(page()).resolves.toContain(example); + }); + + it('skips operations Speakeasy is configured to ignore', async () => { + await write(spec({ command: 'project get', example: 'should not appear' }, { 'x-speakeasy-ignore': true })); + + const result = await run(); + + expect(result.exitCode).toBe(0); + await expect(page()).resolves.toBe(DOCS_PAGE); + }); + + it('warns but succeeds when only some commands lack a docs page', async () => { + await write({ + openapi: '3.0.0', + info: { title: 'test', version: '1.0.0' }, + paths: { + '/documented': { get: { 'x-vf': { cli: { command: 'project get', example: 'x' } }, responses: {} } }, + '/undocumented': { get: { 'x-vf': { cli: { command: 'project ghost', example: 'x' } }, responses: {} } }, + }, + }); + + const result = await run(); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('docs/vf_project_ghost.md'); + await expect(page()).resolves.toContain('x'); + }); + + // Failing soft here would turn a completely ungenerated docs/ into a green build. + it('fails when no enriched command has a docs page at all', async () => { + await fs.rm(path.join(cwd, 'docs/vf_project_get.md')); + await write(spec({ command: 'project get', example: 'x' })); + + const result = await run(); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('go run ./cmd/gendocs'); + }); + + it('names the offending operation when cli metadata is malformed', async () => { + await write(spec({ example: 'missing the command field' })); + + const result = await run(); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('GET /v1/stable/project/{projectID}'); + }); + + it('rejects injected content carrying its own heading', async () => { + await write(spec({ command: 'project get', description: 'Intro.\n### Examples\nbroken' })); + + const result = await run(); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('### '); + }); +});