From 35bcb42d8ed22bd4fc26b61c9f5acc5f97c4d88a Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:37:58 -0700 Subject: [PATCH 1/5] fix(oas:sync): recognize OAS 3.1 webhooks so sync stops deleting their pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractOperations only read spec.paths, so an OAS 3.1 spec's top-level webhooks (calls the API itself makes to a client-registered URL — a separate, same-shaped sibling of paths, not a path the client calls) were invisible to it. Two symptoms, found while validating this against a repo synced by the real platform: - oas:sync's delete pass treats any existing page whose operationId isn't in its operation set as orphaned. A webhook-backed page's operationId is never in that set, so every sync run deleted it — reproduced against a real webhooks page and confirmed the deletion happens on unmodified main. - The oas-reference lint validator reported a false "Operation not found" for the same pages. extractOperations now also walks spec.webhooks, using the same synthetic `_` operationId scheme already used for paths (verified it reproduces the platform's own post_paymentcompleted / post_paymentfailed convention exactly), and marks generated pages with `api.webhook: true` to match what the platform stamps on them. Grouping (tag, or the webhook's own name when untagged) and page generation fall out of the existing operationGroup/buildPageContent machinery from #35 with no special-casing. Stacked on fix/oas-sync-upload-parity (#35): untagged webhook grouping reuses that branch's path-derived-group logic, so this targets that branch rather than main. Verified end-to-end against a repo synced by the real platform: wiping and regenerating all specs from scratch now reproduces the platform's webhook pages exactly (folder, filename, api.webhook, category title from the raw webhook name) with zero remaining diff beyond already-known, out-of-scope gaps (category ordering with no declared position, YAML quoting of brace-containing titles). --- src/commands/oas-sync.js | 54 +++++++++++++-------- test/oas-reference.test.js | 23 +++++++++ test/oas-sync.test.js | 96 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 19 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 4dcc0d0..d064f6c 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -56,30 +56,42 @@ function generateOperationId(method, pathStr) { } /** - * Extract operations from an OAS spec. - * Returns a Map of operationId -> { summary, description, tag, path, operationId }. - * For operations without an operationId, a synthetic one is generated from the method and path. + * Extract operations from an OAS spec's `paths`, plus its OAS 3.1 `webhooks` + * (callouts the API itself makes to a client-registered URL, not endpoints the + * API exposes — a separate top-level sibling of `paths` with the same + * Operation Object shape). The platform pages a webhook the same way it pages + * a path operation: a synthetic `post_` operationId when none is given, + * grouped by its own tag or, absent one, its own category keyed by its raw + * name — never merged with `paths` operations of the same name. + * Returns a Map of operationId -> { summary, description, tag, path, + * operationId, isWebhook }. For operations without an operationId, a synthetic + * one is generated from the method and path (or webhook name). */ export function extractOperations(spec) { const ops = new Map(); - const paths = spec.paths || {}; - for (const [pathStr, methods] of Object.entries(paths)) { - for (const [method, operation] of Object.entries(methods)) { - if (!HTTP_METHODS.has(method)) continue; - - const operationId = operation.operationId || generateOperationId(method, pathStr); - - ops.set(operationId, { - operationId, - summary: operation.summary || null, - description: operation.description || null, - tag: (operation.tags && operation.tags[0]) || null, - path: pathStr, - }); + function collect(entries, isWebhook) { + for (const [pathStr, methods] of Object.entries(entries)) { + for (const [method, operation] of Object.entries(methods)) { + if (!HTTP_METHODS.has(method)) continue; + + const operationId = operation.operationId || generateOperationId(method, pathStr); + + ops.set(operationId, { + operationId, + summary: operation.summary || null, + description: operation.description || null, + tag: (operation.tags && operation.tags[0]) || null, + path: pathStr, + isWebhook, + }); + } } } + collect(spec.paths || {}, false); + collect(spec.webhooks || {}, true); + return ops; } @@ -190,11 +202,15 @@ function stringifyFrontmatter(frontmatter) { return matter.stringify('', frontmatter).replace(/\n+$/, ''); } -function buildPageContent({ oasFilename, operationId }) { +function buildPageContent({ oasFilename, operationId, isWebhook }) { const frontmatter = { api: { file: oasFilename, operationId, + // Marks the page as a webhook (the API calling out to the client) + // rather than a path operation (the client calling the API), matching + // what the platform stamps on a page generated from `webhooks`. + ...(isWebhook ? { webhook: true } : {}), }, // Mirror the platform's OAS-upload behavior: a newly added endpoint is // always written `hidden: false`, even when its tag and siblings are @@ -445,7 +461,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { } fs.mkdirSync(pageDir, { recursive: true }); - const content = buildPageContent({ oasFilename, operationId: opId }); + const content = buildPageContent({ oasFilename, operationId: opId, isWebhook: op.isWebhook }); fs.writeFileSync(pagePath, content); addToOrder(path.join(pageDir, '_order.yaml'), slug); diff --git a/test/oas-reference.test.js b/test/oas-reference.test.js index 9ac2991..92a497d 100644 --- a/test/oas-reference.test.js +++ b/test/oas-reference.test.js @@ -41,3 +41,26 @@ test('operation not found is still reported', () => { rmRepo(root); } }); + +test('a page for a spec webhook is not reported as "Operation not found"', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + webhooks: { + paymentCompleted: { + post: { summary: 'Sent when a payment settles' }, + }, + }, + }); + const root = makeRepo({ + 'reference/payments.json': spec, + 'reference/Payments/paymentcompleted/post_paymentcompleted.md': + '---\napi:\n file: payments.json\n operationId: post_paymentcompleted\n webhook: true\nhidden: false\n---\n', + }); + try { + const res = validateAll(collectFiles(root), root, {}); + assert.ok(!res.some((r) => r.message.includes('Operation not found'))); + } finally { + rmRepo(root); + } +}); diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index fd0b0e4..0ad9235 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -575,3 +575,99 @@ test('deleting a legacy operation page literally named index.md releases its fol rmRepo(root); } }); + +test('sync generates a page for a webhook, marked with api.webhook: true', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + webhooks: { + paymentCompleted: { + post: { summary: 'Sent when a payment settles' }, + }, + }, + }); + const root = makeRepo({ 'reference/payments.json': spec }); + try { + syncOas(root); + const page = path.join(root, 'reference/Payments/paymentcompleted/post_paymentcompleted.md'); + assert.ok(fs.existsSync(page), 'expected a generated webhook page'); + const { data } = matter(fs.readFileSync(page, 'utf-8')); + assert.equal(data.api.file, 'payments.json'); + assert.equal(data.api.operationId, 'post_paymentcompleted'); + assert.equal(data.api.webhook, true); + + // The category page's title is the webhook's own name, not the folder. + const index = matter( + fs.readFileSync(path.join(root, 'reference/Payments/paymentcompleted/index.md'), 'utf-8'), + ).data; + assert.equal(index.title, 'paymentCompleted'); + } finally { + rmRepo(root); + } +}); + +test('a path operation is not stamped api.webhook', () => { + const root = makeRepo({ 'reference/pets.json': SPEC }); + try { + syncOas(root); + const { data } = matter( + fs.readFileSync(path.join(root, 'reference/Pets/pets/listpets.md'), 'utf-8'), + ); + assert.equal('webhook' in data.api, false); + } finally { + rmRepo(root); + } +}); + +test('sync no longer deletes an existing webhook page on every run', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + webhooks: { + paymentCompleted: { + post: { summary: 'Sent when a payment settles' }, + }, + }, + }); + const root = makeRepo({ + 'reference/payments.json': spec, + 'reference/Payments/paymentcompleted/post_paymentcompleted.md': + '---\napi:\n file: payments.json\n operationId: post_paymentcompleted\n webhook: true\nhidden: false\n---\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.ok( + fs.existsSync(path.join(root, 'reference/Payments/paymentcompleted/post_paymentcompleted.md')), + ); + } finally { + rmRepo(root); + } +}); + +test('an untagged webhook and an untagged path operation with the same sanitized name both get pages', () => { + // "/orders" and webhook "orders" sanitize to the same untagged group + // ("orders"), same as two untagged paths would; each still gets its own + // distinct operation page (they have different operationIds). + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + paths: { + '/orders': { get: { operationId: 'listOrders' } }, + }, + webhooks: { + orders: { + post: { summary: 'Sent when an order changes' }, + }, + }, + }); + const root = makeRepo({ 'reference/payments.json': spec }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Payments'); + assert.ok(fs.existsSync(path.join(refDir, 'orders/listorders.md'))); + assert.ok(fs.existsSync(path.join(refDir, 'orders/post_orders.md'))); + } finally { + rmRepo(root); + } +}); From 612efc4dc5d4d188fcf988c9592299929f54f76b Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:19:17 -0700 Subject: [PATCH 2/5] fix(oas:sync): disambiguate colliding path/webhook operationIds, resolve $ref webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from Greptile review: - Operation IDs collide: paths and webhooks are separate namespaces, but a synthetic _ id can legitimately be identical across them (e.g. POST /orders and webhook POST orders both omitting operationId both synthesize to post_orders). extractOperations collected both into one Map keyed only by operationId, so the second (webhook) pass silently overwrote the path entry — sync then omitted the path's page entirely, and both oas-sync's delete pass and oas-reference's lint checks lost visibility of it. Added operationKey({operationId, isWebhook}) and use it everywhere an operation or an existing page is looked up by id — in oas-sync.js's pagesByOpId/specOps, and oas-reference.js's "Operation not found"/"Missing page" checks, which had the identical vulnerability on the read side (two on-disk pages sharing an operationId would collapse to one coveredOps entry). The written operationId itself is untouched — only the internal lookup key changed. - Webhook references stay unresolved: an OAS 3.1 webhooks (or paths) entry can be a Reference Object (`{ $ref: '#/components/pathItems/Name' }`) rather than a literal Path Item. collect() iterated it directly, and since "$ref" isn't an HTTP method the whole entry was silently skipped. Added resolveLocalPathItemRef to resolve same-document #/components/pathItems/ refs before iterating methods (external refs and other pointer shapes are left unresolved, same graceful degradation as before). Applies uniformly to paths and webhooks since both go through the same collect() helper — this was a pre-existing gap for paths too, not unique to webhooks. Verified both new regression tests fail against the prior code and pass against this fix. --- src/commands/oas-sync.js | 57 ++++++++++++++++++++++++------ src/validators/oas-reference.js | 19 ++++++---- test/oas-reference.test.js | 27 +++++++++++++++ test/oas-sync.test.js | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index d064f6c..3471be7 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -55,6 +55,36 @@ function generateOperationId(method, pathStr) { return `${method.toLowerCase()}_${sanitized}`; } +/** + * Identity key for an operation record (or an existing page's frontmatter), + * used everywhere operations/pages are looked up by operationId. `paths` and + * `webhooks` are separate namespaces in an OAS document, but both can have + * operationId omitted, so their synthetic `_` ids can + * legitimately collide (e.g. `POST /orders` and webhook `POST orders` both + * synthesize to `post_orders`) — the isWebhook flag disambiguates them so + * neither silently overwrites the other in an operationId-only Map. + */ +export function operationKey({ operationId, isWebhook }) { + return `${isWebhook ? 'webhook' : 'path'}:${operationId}`; +} + +/** + * Resolve a `paths`/`webhooks` entry that's a Reference Object (OAS 3.1, + * `{ $ref: '#/components/pathItems/Name' }`) against the spec's own + * `components.pathItems`. Only same-document refs in that exact form are + * supported; anything else (external files, other pointer shapes) is left + * unresolved and quietly skipped by the caller, same as before this existed. + */ +function resolveLocalPathItemRef(entry, spec) { + if (!entry || typeof entry.$ref !== 'string') return entry; + + const match = entry.$ref.match(/^#\/components\/pathItems\/(.+)$/); + if (!match) return entry; + + const name = decodeURIComponent(match[1]).replace(/~1/g, '/').replace(/~0/g, '~'); + return spec.components?.pathItems?.[name] || entry; +} + /** * Extract operations from an OAS spec's `paths`, plus its OAS 3.1 `webhooks` * (callouts the API itself makes to a client-registered URL, not endpoints the @@ -63,21 +93,23 @@ function generateOperationId(method, pathStr) { * a path operation: a synthetic `post_` operationId when none is given, * grouped by its own tag or, absent one, its own category keyed by its raw * name — never merged with `paths` operations of the same name. - * Returns a Map of operationId -> { summary, description, tag, path, - * operationId, isWebhook }. For operations without an operationId, a synthetic - * one is generated from the method and path (or webhook name). + * Returns a Map keyed by `operationKey()` -> { summary, description, tag, + * path, operationId, isWebhook }. For operations without an operationId, a + * synthetic one is generated from the method and path (or webhook name). */ export function extractOperations(spec) { const ops = new Map(); function collect(entries, isWebhook) { - for (const [pathStr, methods] of Object.entries(entries)) { + for (const [pathStr, rawItem] of Object.entries(entries)) { + const methods = resolveLocalPathItemRef(rawItem, spec); + for (const [method, operation] of Object.entries(methods)) { if (!HTTP_METHODS.has(method)) continue; const operationId = operation.operationId || generateOperationId(method, pathStr); - ops.set(operationId, { + ops.set(operationKey({ operationId, isWebhook }), { operationId, summary: operation.summary || null, description: operation.description || null, @@ -354,7 +386,10 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { const pagesByOpId = new Map(); for (const page of existingPages) { - pagesByOpId.set(page.data.api.operationId, page); + pagesByOpId.set( + operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }), + page, + ); } const changes = { added: [], deleted: [], skipped: [] }; @@ -442,26 +477,26 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { // Adds: operation pages with no page yet. Title/excerpt are owned by the OAS // spec at render time, so generated pages carry only the api reference. Slugs // are lowercased to match the platform's OAS-upload output. - for (const [opId, op] of specOps) { - if (pagesByOpId.has(opId)) continue; + for (const [key, op] of specOps) { + if (pagesByOpId.has(key)) continue; const { folder } = operationGroup(op); const pageDir = path.join(refDir, infoTitle, folder); // Reference slugs share one flat namespace, so uniquify against every slug // already in reference/ — a collision (or the reserved `index` slug) gets a // numeric suffix rather than being skipped. - const slug = reserveSlug(takenSlugs, safeSegment(opId, 'operation').toLowerCase()); + const slug = reserveSlug(takenSlugs, safeSegment(op.operationId, 'operation').toLowerCase()); const pagePath = path.join(pageDir, `${slug}.md`); // Guard against a spec-crafted name escaping reference/, or a stale slug set // vs. disk. reserveSlug already prevents slug collisions. if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) { - changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: opId }); + changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: op.operationId }); continue; } fs.mkdirSync(pageDir, { recursive: true }); - const content = buildPageContent({ oasFilename, operationId: opId, isWebhook: op.isWebhook }); + const content = buildPageContent({ oasFilename, operationId: op.operationId, isWebhook: op.isWebhook }); fs.writeFileSync(pagePath, content); addToOrder(path.join(pageDir, '_order.yaml'), slug); diff --git a/src/validators/oas-reference.js b/src/validators/oas-reference.js index 409450a..a95d4f2 100644 --- a/src/validators/oas-reference.js +++ b/src/validators/oas-reference.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; -import { findOasFiles, extractOperations, collectExistingPages, syncOas } from '../commands/oas-sync.js'; +import { findOasFiles, extractOperations, collectExistingPages, syncOas, operationKey } from '../commands/oas-sync.js'; export const name = 'oas-reference'; @@ -34,6 +34,7 @@ export function validateAll(files, gitRoot, { fix } = {}) { const oasFilename = data.api.file; const operationId = data.api.operationId; + const isWebhook = !!data.api.webhook; const oas = oasMap.get(oasFilename); // Check: OAS file doesn't exist. @@ -50,7 +51,7 @@ export function validateAll(files, gitRoot, { fix } = {}) { if (!operationId) continue; // Check: operationId doesn't exist in the spec. - if (!oas.ops.has(operationId)) { + if (!oas.ops.has(operationKey({ operationId, isWebhook }))) { results.push({ file: relPath, rule: name, @@ -67,15 +68,19 @@ export function validateAll(files, gitRoot, { fix } = {}) { const existingPages = collectExistingPages(refDir); for (const [oasFilename, { ops }] of oasMap) { const pagesForOas = existingPages.filter((p) => p.data.api.file === oasFilename); - const coveredOps = new Set(pagesForOas.map((p) => p.data.api.operationId)); - - for (const [opId] of ops) { - if (!coveredOps.has(opId)) { + const coveredOps = new Set( + pagesForOas.map((p) => + operationKey({ operationId: p.data.api.operationId, isWebhook: !!p.data.api.webhook }), + ), + ); + + for (const op of ops.values()) { + if (!coveredOps.has(operationKey(op))) { results.push({ file: `reference/${oasFilename}`, rule: name, severity: 'warning', - message: `Missing page: no reference page found for operation "${opId}"`, + message: `Missing page: no reference page found for operation "${op.operationId}"`, fixable: true, }); } diff --git a/test/oas-reference.test.js b/test/oas-reference.test.js index 92a497d..704b24e 100644 --- a/test/oas-reference.test.js +++ b/test/oas-reference.test.js @@ -64,3 +64,30 @@ test('a page for a spec webhook is not reported as "Operation not found"', () => rmRepo(root); } }); + +test('a path page and a webhook page sharing an operationId are both recognized, neither flagged missing', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + paths: { + '/orders': { post: { operationId: 'sharedId' } }, + }, + webhooks: { + orderCreated: { post: { operationId: 'sharedId' } }, + }, + }); + const root = makeRepo({ + 'reference/payments.json': spec, + 'reference/Payments/orders/sharedid.md': + '---\napi:\n file: payments.json\n operationId: sharedId\nhidden: false\n---\n', + 'reference/Payments/ordercreated/sharedid.md': + '---\napi:\n file: payments.json\n operationId: sharedId\n webhook: true\nhidden: false\n---\n', + }); + try { + const res = validateAll(collectFiles(root), root, {}); + assert.ok(!res.some((r) => r.message.includes('Operation not found'))); + assert.ok(!res.some((r) => r.message.includes('Missing page'))); + } finally { + rmRepo(root); + } +}); diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 0ad9235..3eb3860 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -671,3 +671,64 @@ test('an untagged webhook and an untagged path operation with the same sanitized rmRepo(root); } }); + +test('a path and a webhook whose synthesized operationIds collide both still get pages', () => { + // Neither declares an operationId, both are POST, and both sanitize to + // the same synthetic id: post_orders. + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + paths: { + '/orders': { post: { summary: 'Create an order' } }, + }, + webhooks: { + orders: { post: { summary: 'Sent when an order changes' } }, + }, + }); + const root = makeRepo({ 'reference/payments.json': spec }); + try { + const [result] = syncOas(root); + // Both pages generated — neither silently dropped by an internal Map + // collision keyed only on the (identical) synthesized operationId. + const added = result.changes.added.filter((p) => !p.endsWith('index.md')); + assert.equal(added.length, 2, `expected 2 pages, got: ${JSON.stringify(added)}`); + + const refDir = path.join(root, 'reference/Payments/orders'); + const pathPage = matter(fs.readFileSync(path.join(refDir, 'post_orders.md'), 'utf-8')).data; + const webhookPage = matter( + fs.readFileSync(path.join(refDir, 'post_orders-1.md'), 'utf-8'), + ).data; + assert.equal('webhook' in pathPage.api, false); + assert.equal(webhookPage.api.webhook, true); + } finally { + rmRepo(root); + } +}); + +test('a webhook that is a $ref to components.pathItems is resolved', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + webhooks: { + paymentCompleted: { $ref: '#/components/pathItems/PaymentCompleted' }, + }, + components: { + pathItems: { + PaymentCompleted: { + post: { operationId: 'onPaymentCompleted', summary: 'Sent when a payment settles' }, + }, + }, + }, + }); + const root = makeRepo({ 'reference/payments.json': spec }); + try { + syncOas(root); + const page = path.join(root, 'reference/Payments/paymentcompleted/onpaymentcompleted.md'); + assert.ok(fs.existsSync(page), 'expected the $ref-resolved webhook to generate a page'); + const { data } = matter(fs.readFileSync(page, 'utf-8')); + assert.equal(data.api.operationId, 'onPaymentCompleted'); + assert.equal(data.api.webhook, true); + } finally { + rmRepo(root); + } +}); From 9cd1905d8039614f2d5289f141f2701e0625e72d Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:02:18 -0700 Subject: [PATCH 3/5] fix(oas:sync): follow chained pathItem $refs, guard against cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review from Greptile. resolveLocalPathItemRef only resolved one level: a webhook/path entry whose pathItems target was itself a $ref (rather than a literal Path Item) returned that intermediate Reference Object unchanged. collect() then skipped it (still just a "$ref" key, no HTTP methods), so sync deleted the existing page as orphaned and reference validation reported it missing. Now follows the chain until a literal Path Item is reached, tracking every $ref string seen so a cycle returns the current (still-unresolved) node instead of looping forever — same graceful degradation as an unresolvable name. Added tests for a two-hop chain and a circular reference (confirms it returns instead of hanging). --- src/commands/oas-sync.js | 29 +++++++++++++++------- test/oas-sync.test.js | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 3471be7..cbffb74 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -71,18 +71,31 @@ export function operationKey({ operationId, isWebhook }) { /** * Resolve a `paths`/`webhooks` entry that's a Reference Object (OAS 3.1, * `{ $ref: '#/components/pathItems/Name' }`) against the spec's own - * `components.pathItems`. Only same-document refs in that exact form are - * supported; anything else (external files, other pointer shapes) is left - * unresolved and quietly skipped by the caller, same as before this existed. + * `components.pathItems`, following chained refs (a pathItem that is itself + * a $ref to another) until a literal Path Item is reached. Only same-document + * refs in that exact form are supported; anything else (external files, + * other pointer shapes, an unresolvable name, or a cycle) is left unresolved + * and quietly skipped by the caller, same as before this existed. */ function resolveLocalPathItemRef(entry, spec) { - if (!entry || typeof entry.$ref !== 'string') return entry; + const seen = new Set(); + let current = entry; - const match = entry.$ref.match(/^#\/components\/pathItems\/(.+)$/); - if (!match) return entry; + while (current && typeof current.$ref === 'string') { + if (seen.has(current.$ref)) return current; + seen.add(current.$ref); - const name = decodeURIComponent(match[1]).replace(/~1/g, '/').replace(/~0/g, '~'); - return spec.components?.pathItems?.[name] || entry; + const match = current.$ref.match(/^#\/components\/pathItems\/(.+)$/); + if (!match) return current; + + const name = decodeURIComponent(match[1]).replace(/~1/g, '/').replace(/~0/g, '~'); + const resolved = spec.components?.pathItems?.[name]; + if (!resolved) return current; + + current = resolved; + } + + return current; } /** diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 3eb3860..0e3fafc 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -732,3 +732,55 @@ test('a webhook that is a $ref to components.pathItems is resolved', () => { rmRepo(root); } }); + +test('a $ref to a pathItem that is itself a $ref is followed to the literal Path Item', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + webhooks: { + // Chained: paymentCompleted -> Alias -> the literal Path Item. + paymentCompleted: { $ref: '#/components/pathItems/Alias' }, + }, + components: { + pathItems: { + Alias: { $ref: '#/components/pathItems/PaymentCompleted' }, + PaymentCompleted: { + post: { operationId: 'onPaymentCompleted', summary: 'Sent when a payment settles' }, + }, + }, + }, + }); + const root = makeRepo({ 'reference/payments.json': spec }); + try { + syncOas(root); + const page = path.join(root, 'reference/Payments/paymentcompleted/onpaymentcompleted.md'); + assert.ok(fs.existsSync(page), 'expected the chained $ref to be followed to the literal Path Item'); + assert.equal(matter(fs.readFileSync(page, 'utf-8')).data.api.operationId, 'onPaymentCompleted'); + } finally { + rmRepo(root); + } +}); + +test('a circular pathItem $ref is left unresolved rather than looping forever', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + webhooks: { + paymentCompleted: { $ref: '#/components/pathItems/A' }, + }, + components: { + pathItems: { + A: { $ref: '#/components/pathItems/B' }, + B: { $ref: '#/components/pathItems/A' }, + }, + }, + }); + const root = makeRepo({ 'reference/payments.json': spec }); + try { + // Must return (not hang) and simply generate nothing for the cycle. + const [result] = syncOas(root); + assert.equal(result.changes.added.filter((p) => !p.endsWith('index.md')).length, 0); + } finally { + rmRepo(root); + } +}); From 74c7e4f54b54c8e8ea50bd596c6bbec227678ef3 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:19:55 -0700 Subject: [PATCH 4/5] fix(oas:sync): don't let a malformed $ref percent-escape crash sync/lint Addresses review from Greptile. decodeURIComponent throws a URIError on a malformed percent-escape (e.g. a $ref segment containing "%zz"). That propagated straight up through resolveLocalPathItemRef -> collect -> extractOperations, aborting the entire oas:sync or lint run over one bad $ref, instead of the graceful degradation used for every other unresolvable case here (unknown name, external ref, cycle). Wrapped in try/catch; a malformed escape is now just left unresolved. Verified the added regression test throws against the prior code and passes against this fix. --- src/commands/oas-sync.js | 9 ++++++++- test/oas-sync.test.js | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index cbffb74..fb64d51 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -88,7 +88,14 @@ function resolveLocalPathItemRef(entry, spec) { const match = current.$ref.match(/^#\/components\/pathItems\/(.+)$/); if (!match) return current; - const name = decodeURIComponent(match[1]).replace(/~1/g, '/').replace(/~0/g, '~'); + let name; + try { + name = decodeURIComponent(match[1]).replace(/~1/g, '/').replace(/~0/g, '~'); + } catch { + // Malformed percent-escape — leave unresolved rather than throwing and + // aborting the whole sync/lint run over one bad $ref. + return current; + } const resolved = spec.components?.pathItems?.[name]; if (!resolved) return current; diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 0e3fafc..5446842 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -784,3 +784,23 @@ test('a circular pathItem $ref is left unresolved rather than looping forever', rmRepo(root); } }); + +test('a pathItem $ref with a malformed percent-escape is left unresolved rather than throwing', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + webhooks: { + // "%zz" is not a valid percent-escape — decodeURIComponent throws on it. + paymentCompleted: { $ref: '#/components/pathItems/%zz' }, + }, + components: { pathItems: {} }, + }); + const root = makeRepo({ 'reference/payments.json': spec }); + try { + // Must not throw; the malformed ref is simply left unresolved. + const [result] = syncOas(root); + assert.equal(result.changes.added.filter((p) => !p.endsWith('index.md')).length, 0); + } finally { + rmRepo(root); + } +}); From a2ed2cac13d6d4a401d7c567ab57b412bce8e516 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:01:51 -0700 Subject: [PATCH 5/5] fix(oas:sync): preserve inline sibling operations alongside a pathItem \$ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review from Greptile. OAS 3.1 explicitly permits sibling fields (e.g. an inline operation) alongside \$ref in a Path Item Object. resolveLocalPathItemRef replaced the entire entry with the referenced pathItem, discarding any inline sibling operation declared next to the \$ref — sync then deleted its existing page as orphaned, and reference lint couldn't recognize it. Now accumulates sibling fields from every hop in the chain and merges them over the final resolved (or last-reached, if unresolvable) node. An outer hop's field wins over the same field found deeper in the chain, since OAS itself leaves that case "undefined." Verified the added regression test (inline operation alongside a \$ref to a different pathItem) fails against the prior code and passes now. --- src/commands/oas-sync.js | 20 +++++++++++++++----- test/oas-sync.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index fb64d51..9aea4b7 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -80,13 +80,23 @@ export function operationKey({ operationId, isWebhook }) { function resolveLocalPathItemRef(entry, spec) { const seen = new Set(); let current = entry; + // Sibling fields (e.g. an inline operation) alongside a $ref are explicitly + // allowed in an OAS 3.1 Path Item Object — accumulate them from every hop + // in the chain so they aren't discarded once $ref is followed. A field + // declared at an outer/earlier hop wins over the same field found deeper + // in the chain (OAS itself leaves this "undefined" when both define it). + let overrides = {}; + const finish = () => ({ ...current, ...overrides }); while (current && typeof current.$ref === 'string') { - if (seen.has(current.$ref)) return current; + const { $ref, ...siblings } = current; + overrides = { ...siblings, ...overrides }; + + if (seen.has(current.$ref)) return finish(); seen.add(current.$ref); const match = current.$ref.match(/^#\/components\/pathItems\/(.+)$/); - if (!match) return current; + if (!match) return finish(); let name; try { @@ -94,15 +104,15 @@ function resolveLocalPathItemRef(entry, spec) { } catch { // Malformed percent-escape — leave unresolved rather than throwing and // aborting the whole sync/lint run over one bad $ref. - return current; + return finish(); } const resolved = spec.components?.pathItems?.[name]; - if (!resolved) return current; + if (!resolved) return finish(); current = resolved; } - return current; + return finish(); } /** diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 5446842..7d94144 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -804,3 +804,34 @@ test('a pathItem $ref with a malformed percent-escape is left unresolved rather rmRepo(root); } }); + +test('an inline operation alongside a $ref sibling is not discarded', () => { + // OAS 3.1 explicitly permits sibling fields (like an inline operation) + // alongside $ref in a Path Item Object. + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Payments' }, + webhooks: { + paymentCompleted: { + $ref: '#/components/pathItems/Base', + // Inline sibling operation, alongside the $ref. + put: { operationId: 'inlineUpdate', summary: 'Inline sibling op' }, + }, + }, + components: { + pathItems: { + Base: { post: { operationId: 'onPaymentCompleted', summary: 'From the referenced pathItem' } }, + }, + }, + }); + const root = makeRepo({ 'reference/payments.json': spec }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Payments/paymentcompleted'); + // Both the referenced pathItem's operation and the inline sibling exist. + assert.ok(fs.existsSync(path.join(refDir, 'onpaymentcompleted.md')), 'expected the referenced operation'); + assert.ok(fs.existsSync(path.join(refDir, 'inlineupdate.md')), 'expected the inline sibling operation'); + } finally { + rmRepo(root); + } +});