From 2c738b2587ba778a141af92e32bde36ccf07c5c9 Mon Sep 17 00:00:00 2001 From: rossrdme <168011594+rossrdme@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:03:34 -0500 Subject: [PATCH 01/10] fix(oas:sync): match the platform's OAS-upload output oas:sync generated a different tree than uploading the same spec to ReadMe, so a repo initialized by upload drifted after a local sync: - Tag category pages (/index.md) were never generated, silently dropping the tag's description from the spec's top-level tags array. - The root reference/_order.yaml was never written, losing top-level ordering. - Slugs kept the operationId casing (getUserById.md) where the platform lowercases (getuserbyid.md), diverging URLs. - Generated pages omitted hidden: false. Generate the tag index.md (title from tag name, excerpt from tag description, never overwriting an existing one), maintain the root _order.yaml, lowercase slugs, and include hidden: false. Co-Authored-By: Claude Fable 5 --- src/commands/oas-sync.js | 39 +++++++++++++++++++-- test/oas-sync.test.js | 76 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index b551960..100627f 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -185,11 +185,25 @@ function buildPageContent({ oasFilename, operationId }) { file: oasFilename, operationId, }, + hidden: false, }; return matter.stringify('', frontmatter); } +/** + * Build the category landing page for a tag (mirrors what the ReadMe platform + * generates on OAS upload): title from the tag name, excerpt from the tag's + * description in the spec's top-level `tags` array. + */ +function buildTagIndexContent(tagName, description) { + const frontmatter = { title: tagName }; + if (description) frontmatter.excerpt = description; + frontmatter.hidden = false; + + return matter.stringify('', frontmatter); +} + /** * Run the sync for a single OAS file. Returns changes for that file. */ @@ -211,6 +225,14 @@ function syncOneOas(refDir, oasFilename, spec) { const changes = { added: [], deleted: [], skipped: [] }; + // Tag descriptions from the spec's top-level `tags` array, used for the + // per-tag category landing page (index.md). + const tagDescriptions = new Map( + (Array.isArray(spec.tags) ? spec.tags : []) + .filter((t) => t && t.name) + .map((t) => [t.name, t.description || null]), + ); + // Deletes: pages referencing operations that no longer exist. for (const [opId, page] of pagesByOpId) { if (!specOps.has(opId)) { @@ -225,12 +247,14 @@ function syncOneOas(refDir, oasFilename, spec) { } // Adds: operations with no page yet. Title/excerpt are owned by the OAS spec - // at render time, so generated pages carry only the api reference. + // 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; - const tag = safeSegment(op.tag || 'Other', 'Other'); - const slug = safeSegment(opId, 'operation'); + const rawTag = op.tag || 'Other'; + const tag = safeSegment(rawTag, 'Other'); + const slug = safeSegment(opId, 'operation').toLowerCase(); const pageDir = path.join(refDir, infoTitle, tag); const pagePath = path.join(pageDir, `${slug}.md`); @@ -243,11 +267,20 @@ function syncOneOas(refDir, oasFilename, spec) { } fs.mkdirSync(pageDir, { recursive: true }); + // The tag's category landing page (index.md), like the platform generates + // on upload. Never overwrite one that already exists. + const indexPath = path.join(pageDir, 'index.md'); + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, buildTagIndexContent(rawTag, tagDescriptions.get(rawTag))); + changes.added.push(path.relative(refDir, indexPath)); + } + const content = buildPageContent({ oasFilename, operationId: opId }); fs.writeFileSync(pagePath, content); addToOrder(path.join(pageDir, '_order.yaml'), slug); addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag); + addToOrder(path.join(refDir, '_order.yaml'), infoTitle); changes.added.push(path.relative(refDir, pagePath)); } diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index b0b48cc..156a97c 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -20,11 +20,13 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () const root = makeRepo({ 'reference/pets.json': SPEC }); try { syncOas(root); - const page = path.join(root, 'reference/Pets/Other/listPets.md'); + // Slugs are lowercased to match the platform's OAS-upload output. + const page = path.join(root, 'reference/Pets/Other/listpets.md'); assert.ok(fs.existsSync(page), 'expected generated page'); const { data } = matter(fs.readFileSync(page, 'utf-8')); assert.equal(data.api.file, 'pets.json'); assert.equal(data.api.operationId, 'listPets'); + assert.equal(data.hidden, false); assert.equal('title' in data, false); assert.equal('excerpt' in data, false); } finally { @@ -32,6 +34,69 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () } }); +test('sync generates a tag index.md with the tag description from the spec', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Sample API' }, + tags: [{ name: 'users', description: 'User management operations' }], + paths: { + '/users': { get: { operationId: 'listUsers', tags: ['users'] } }, + }, + }); + const root = makeRepo({ 'reference/sample.json': spec }); + try { + syncOas(root); + const indexPath = path.join(root, 'reference/Sample API/users/index.md'); + assert.ok(fs.existsSync(indexPath), 'expected tag index.md'); + const { data } = matter(fs.readFileSync(indexPath, 'utf-8')); + assert.equal(data.title, 'users'); + assert.equal(data.excerpt, 'User management operations'); + assert.equal(data.hidden, false); + + // index must not be listed in the tag's _order.yaml. + const order = fs.readFileSync(path.join(root, 'reference/Sample API/users/_order.yaml'), 'utf-8'); + assert.equal(order.includes('index'), false); + assert.match(order, /- listusers/); + } finally { + rmRepo(root); + } +}); + +test('sync maintains the root reference/_order.yaml', () => { + const root = makeRepo({ 'reference/pets.json': SPEC }); + try { + syncOas(root); + const rootOrder = path.join(root, 'reference/_order.yaml'); + assert.ok(fs.existsSync(rootOrder), 'expected root _order.yaml'); + assert.match(fs.readFileSync(rootOrder, 'utf-8'), /- Pets/); + } finally { + rmRepo(root); + } +}); + +test('sync does not overwrite an existing tag index.md', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'Other', description: 'From the spec' }], + paths: { + '/pets': { get: { operationId: 'listPets', tags: ['Other'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/Other/index.md': '---\ntitle: Hand-written category\n---\n\nCustom intro.\n', + }); + try { + syncOas(root); + const content = fs.readFileSync(path.join(root, 'reference/Pets/Other/index.md'), 'utf-8'); + assert.match(content, /Hand-written category/); + assert.match(content, /Custom intro/); + } finally { + rmRepo(root); + } +}); + test('spec-derived names cannot escape the reference directory', () => { const spec = JSON.stringify({ openapi: '3.0.0', @@ -72,7 +137,9 @@ test('operations whose sanitized names collide do not overwrite each other', () const root = makeRepo({ 'reference/pets.json': spec }); try { const [first] = syncOas(root); - assert.equal(first.changes.added.length, 1); + // added = the op page plus the tag's generated index.md. + const addedPages = first.changes.added.filter((p) => !p.endsWith('index.md')); + assert.equal(addedPages.length, 1); assert.equal(first.changes.skipped.length, 1); const page = path.join(root, 'reference/Pets/Other/foo-bar.md'); @@ -91,13 +158,14 @@ test('operations whose sanitized names collide do not overwrite each other', () test('sync does not overwrite an existing page from another spec or author', () => { const root = makeRepo({ 'reference/pets.json': SPEC, - 'reference/Pets/Other/listPets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\n', + // The sync targets the lowercased slug. + 'reference/Pets/Other/listpets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\n', }); try { const [result] = syncOas(root); assert.equal(result.changes.added.length, 0); assert.equal(result.changes.skipped.length, 1); - const content = fs.readFileSync(path.join(root, 'reference/Pets/Other/listPets.md'), 'utf-8'); + const content = fs.readFileSync(path.join(root, 'reference/Pets/Other/listpets.md'), 'utf-8'); assert.match(content, /Hand-written page/); assert.match(content, /Custom content/); } finally { From 86bd1b46a3f627168f8915afb717dda78fa631a5 Mon Sep 17 00:00:00 2001 From: rossrdme <168011594+rossrdme@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:02:44 -0500 Subject: [PATCH 02/10] =?UTF-8?q?fix(oas:sync):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20reserve=20index.md,=20drop=20explicit=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #35: - Reserve /index.md exclusively for the tag category page. An operation whose slug normalizes to "index" now gets the first free numeric slug (index-1, index-2, ...) instead of clobbering the category page. Uses numeric suffixes rather than a fixed "index-operation" suffix, which could itself collide with a real operation. - Drop the explicit `hidden: false` from generated op and tag index pages; rely on the backend default instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/oas-sync.js | 17 +++++++++++++--- test/oas-sync.test.js | 44 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 100627f..5702525 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -185,7 +185,6 @@ function buildPageContent({ oasFilename, operationId }) { file: oasFilename, operationId, }, - hidden: false, }; return matter.stringify('', frontmatter); @@ -199,11 +198,23 @@ function buildPageContent({ oasFilename, operationId }) { function buildTagIndexContent(tagName, description) { const frontmatter = { title: tagName }; if (description) frontmatter.excerpt = description; - frontmatter.hidden = false; return matter.stringify('', frontmatter); } +/** + * `index.md` in a tag directory is reserved for the tag's category landing + * page, so an operation whose slug is `index` can't use it. Fall back to the + * first free `index-N` so it collides with neither the landing page nor another + * operation (including a second operation that also normalizes to `index`). + */ +function reserveOperationSlug(pageDir, slug) { + if (slug !== 'index') return slug; + let n = 1; + while (fs.existsSync(path.join(pageDir, `index-${n}.md`))) n += 1; + return `index-${n}`; +} + /** * Run the sync for a single OAS file. Returns changes for that file. */ @@ -254,8 +265,8 @@ function syncOneOas(refDir, oasFilename, spec) { const rawTag = op.tag || 'Other'; const tag = safeSegment(rawTag, 'Other'); - const slug = safeSegment(opId, 'operation').toLowerCase(); const pageDir = path.join(refDir, infoTitle, tag); + const slug = reserveOperationSlug(pageDir, safeSegment(opId, 'operation').toLowerCase()); const pagePath = path.join(pageDir, `${slug}.md`); // Never overwrite an existing file: it belongs to a manual page, another diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 156a97c..65f33c1 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -26,7 +26,8 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () const { data } = matter(fs.readFileSync(page, 'utf-8')); assert.equal(data.api.file, 'pets.json'); assert.equal(data.api.operationId, 'listPets'); - assert.equal(data.hidden, false); + // hidden is left to the backend default rather than written explicitly. + assert.equal('hidden' in data, false); assert.equal('title' in data, false); assert.equal('excerpt' in data, false); } finally { @@ -51,7 +52,7 @@ test('sync generates a tag index.md with the tag description from the spec', () const { data } = matter(fs.readFileSync(indexPath, 'utf-8')); assert.equal(data.title, 'users'); assert.equal(data.excerpt, 'User management operations'); - assert.equal(data.hidden, false); + assert.equal('hidden' in data, false); // index must not be listed in the tag's _order.yaml. const order = fs.readFileSync(path.join(root, 'reference/Sample API/users/_order.yaml'), 'utf-8'); @@ -97,6 +98,45 @@ test('sync does not overwrite an existing tag index.md', () => { } }); +test('an operation named "index" does not clobber the tag index.md', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'pets', description: 'Pet ops' }], + paths: { + // Two operations that both normalize to the reserved slug "index". + '/a': { get: { operationId: 'index', tags: ['pets'] } }, + '/b': { get: { operationId: 'INDEX', tags: ['pets'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const dir = path.join(root, 'reference/Pets/pets'); + + // index.md is the category page, never an operation. + const indexData = matter(fs.readFileSync(path.join(dir, 'index.md'), 'utf-8')).data; + assert.equal(indexData.title, 'pets'); + assert.equal('api' in indexData, false); + + // Each colliding operation gets a distinct numeric slug. + assert.ok(fs.existsSync(path.join(dir, 'index-1.md')), 'expected index-1.md'); + assert.ok(fs.existsSync(path.join(dir, 'index-2.md')), 'expected index-2.md'); + const opIds = ['index-1', 'index-2'].map( + (s) => matter(fs.readFileSync(path.join(dir, `${s}.md`), 'utf-8')).data.api.operationId, + ); + assert.deepEqual([...opIds].sort(), ['INDEX', 'index']); + + // _order.yaml lists the operation slugs but not the reserved index page. + const order = fs.readFileSync(path.join(dir, '_order.yaml'), 'utf-8'); + assert.match(order, /- index-1/); + assert.match(order, /- index-2/); + assert.equal(/^- index$/m.test(order), false); + } finally { + rmRepo(root); + } +}); + test('spec-derived names cannot escape the reference directory', () => { const spec = JSON.stringify({ openapi: '3.0.0', From 363d3deb6a6f61dc0bef4408aff18fe5107c792e Mon Sep 17 00:00:00 2001 From: rossrdme <168011594+rossrdme@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:33:40 -0500 Subject: [PATCH 03/10] =?UTF-8?q?fix(oas:sync):=20rework=20per=20review=20?= =?UTF-8?q?=E2=80=94=20reference-wide=20slugs,=20backfill=20index,=20keep?= =?UTF-8?q?=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the #35 review: - hidden: keep `hidden: false` on generated pages (operation + tag index). Testing the UI upload shows it always stamps hidden and forces false on a new endpoint even when the tag/siblings are hidden, so the backend default can't be relied on. Reverts the drop in 86bd1b4. Added an @todo to honor the x-internal extension (gitto#2095) once available. - index-slug collisions: replace the tag-local check with reference-wide, folder-aware slug reservation. Reference page slugs are one flat namespace (docs/ is separate), so uniquify against every slug in reference/ — files and category folders (folder/index.md) alike — appending -1, -2, ... The reserved `index` slug always yields a numbered operation page. - backfill: generate a tag's category index.md in its own pass over every tag in the spec, so references first synced by an older CLI (op pages, no index) get their category pages on the next sync. Never overwrites an existing index; idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/oas-sync.js | 136 ++++++++++++++++++++++++++++++--------- test/oas-sync.test.js | 134 ++++++++++++++++++++++++++++++++------ 2 files changed, 219 insertions(+), 51 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 5702525..32a2bc7 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -185,6 +185,19 @@ function buildPageContent({ oasFilename, operationId }) { file: oasFilename, operationId, }, + // Mirror the platform's OAS-upload behavior: a newly added endpoint is + // always written `hidden: false`, even when its tag and siblings are + // `hidden: true`. The backend does not infer this from a missing field, so + // it must be written explicitly. + // + // @todo Honor the `x-internal` OpenAPI extension for page visibility, to + // match gitto#2095 (RM-4616 / CX-3303): resolve `hidden` from operation-level + // `x-internal`, falling back to root-level, else false; and hide a tag's + // index page when all of its operations are `x-internal: true`. Deferred to + // keep oas:sync create-only — the resync-side rules (re-applying x-internal + // to existing pages, parent hide-ratchet) would require mutating existing + // pages, which this command intentionally never does. + hidden: false, }; return matter.stringify('', frontmatter); @@ -198,27 +211,70 @@ function buildPageContent({ oasFilename, operationId }) { function buildTagIndexContent(tagName, description) { const frontmatter = { title: tagName }; if (description) frontmatter.excerpt = description; + // As with operation pages, upload always stamps hidden: false on new pages. + frontmatter.hidden = false; return matter.stringify('', frontmatter); } /** - * `index.md` in a tag directory is reserved for the tag's category landing - * page, so an operation whose slug is `index` can't use it. Fall back to the - * first free `index-N` so it collides with neither the landing page nor another - * operation (including a second operation that also normalizes to `index`). + * Collect every slug already used across the entire reference/ tree. Reference + * page slugs share one flat namespace (docs/ is a separate namespace and is not + * consulted), so a generated operation slug must be unique against all of them. + * A page's slug is its filename without `.md`; a category page's slug (a folder + * containing `index.md`) is the folder name. Comparison is case-insensitive. */ -function reserveOperationSlug(pageDir, slug) { - if (slug !== 'index') return slug; - let n = 1; - while (fs.existsSync(path.join(pageDir, `index-${n}.md`))) n += 1; - return `index-${n}`; +function collectReferenceSlugs(refDir) { + const slugs = new Set(); + + function walk(dir) { + for (const entry of fs.readdirSync(dir)) { + const full = path.join(dir, entry); + let stat; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (stat.isDirectory()) { + walk(full); + } else if (entry.endsWith('.md')) { + // A folder's index.md contributes the folder name as a slug; any other + // page contributes its own filename. + const slug = entry === 'index.md' ? path.basename(dir) : path.basename(entry, '.md'); + slugs.add(slug.toLowerCase()); + } + } + } + + walk(refDir); + return slugs; +} + +/** + * Reserve a unique reference slug. `index` is never usable by an operation (it's + * reserved for the tag category page), and any slug already present in the + * reference namespace gets a numeric suffix (`-1`, `-2`, ...) until it's free. + * The chosen slug is added to `takenSlugs` so later operations see it. + */ +function reserveSlug(takenSlugs, base) { + let chosen = base; + if (base === 'index' || takenSlugs.has(base)) { + let n = 1; + while (takenSlugs.has(`${base}-${n}`)) n += 1; + chosen = `${base}-${n}`; + } + takenSlugs.add(chosen); + return chosen; } /** * Run the sync for a single OAS file. Returns changes for that file. + * + * `takenSlugs` is the reference-wide set of slugs already in use; it is read and + * mutated so slugs stay unique across every spec processed in one sync run. */ -function syncOneOas(refDir, oasFilename, spec) { +function syncOneOas(refDir, oasFilename, spec, takenSlugs) { const specOps = extractOperations(spec); const infoTitle = safeSegment( spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), @@ -252,46 +308,63 @@ function syncOneOas(refDir, oasFilename, spec) { const pageDir = path.dirname(page.filePath); const slug = path.basename(page.filePath, '.md'); removeFromOrder(path.join(pageDir, '_order.yaml'), slug); + takenSlugs.delete(slug.toLowerCase()); changes.deleted.push(page.relativePath); } } - // Adds: operations 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. + // Ensure every tag present in the spec has its category landing page (index.md) + // and is ordered — independent of whether its operation pages are new. Doing + // this as its own pass (rather than only when creating a new op page) backfills + // category pages for references first synced by a CLI version that didn't + // generate them, and recreates one that was deleted. + const specTags = new Set([...specOps.values()].map((op) => op.tag || 'Other')); + for (const rawTag of specTags) { + const tag = safeSegment(rawTag, 'Other'); + const pageDir = path.join(refDir, infoTitle, tag); + if (!isWithin(refDir, pageDir)) continue; + + const indexPath = path.join(pageDir, 'index.md'); + if (!fs.existsSync(indexPath)) { + // Never overwrite an existing index.md — it may be a hand-written category. + fs.mkdirSync(pageDir, { recursive: true }); + fs.writeFileSync(indexPath, buildTagIndexContent(rawTag, tagDescriptions.get(rawTag))); + changes.added.push(path.relative(refDir, indexPath)); + } + // The category page's slug is the tag folder name; reserve it so no operation + // takes it. Ordering entries are idempotent, so this is a no-op when present. + takenSlugs.add(tag.toLowerCase()); + addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag); + addToOrder(path.join(refDir, '_order.yaml'), infoTitle); + } + + // 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; - const rawTag = op.tag || 'Other'; - const tag = safeSegment(rawTag, 'Other'); + const tag = safeSegment(op.tag || 'Other', 'Other'); const pageDir = path.join(refDir, infoTitle, tag); - const slug = reserveOperationSlug(pageDir, safeSegment(opId, 'operation').toLowerCase()); + // 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 pagePath = path.join(pageDir, `${slug}.md`); - // Never overwrite an existing file: it belongs to a manual page, another - // spec, or a different operation whose sanitized name collides with this - // one. Skipping (rather than clobbering) keeps repeated syncs stable. + // 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 }); continue; } fs.mkdirSync(pageDir, { recursive: true }); - // The tag's category landing page (index.md), like the platform generates - // on upload. Never overwrite one that already exists. - const indexPath = path.join(pageDir, 'index.md'); - if (!fs.existsSync(indexPath)) { - fs.writeFileSync(indexPath, buildTagIndexContent(rawTag, tagDescriptions.get(rawTag))); - changes.added.push(path.relative(refDir, indexPath)); - } - const content = buildPageContent({ oasFilename, operationId: opId }); fs.writeFileSync(pagePath, content); addToOrder(path.join(pageDir, '_order.yaml'), slug); - addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag); - addToOrder(path.join(refDir, '_order.yaml'), infoTitle); changes.added.push(path.relative(refDir, pagePath)); } @@ -319,11 +392,14 @@ export function syncOas(input) { const oasFiles = findOasFiles(refDir); if (oasFiles.length === 0) return null; + // Reference slugs share one flat namespace across every spec, so build the set + // of in-use slugs once and let each spec read/extend it. + const takenSlugs = collectReferenceSlugs(refDir); const allChanges = []; for (const { filename, spec } of oasFiles) { const ops = extractOperations(spec); - const changes = syncOneOas(refDir, filename, spec); + const changes = syncOneOas(refDir, filename, spec, takenSlugs); allChanges.push({ filename, spec, opCount: ops.size, changes }); } diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 65f33c1..53c0461 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -26,8 +26,8 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () const { data } = matter(fs.readFileSync(page, 'utf-8')); assert.equal(data.api.file, 'pets.json'); assert.equal(data.api.operationId, 'listPets'); - // hidden is left to the backend default rather than written explicitly. - assert.equal('hidden' in data, false); + // Mirrors upload: new pages are always stamped hidden: false. + assert.equal(data.hidden, false); assert.equal('title' in data, false); assert.equal('excerpt' in data, false); } finally { @@ -52,7 +52,7 @@ test('sync generates a tag index.md with the tag description from the spec', () const { data } = matter(fs.readFileSync(indexPath, 'utf-8')); assert.equal(data.title, 'users'); assert.equal(data.excerpt, 'User management operations'); - assert.equal('hidden' in data, false); + assert.equal(data.hidden, false); // index must not be listed in the tag's _order.yaml. const order = fs.readFileSync(path.join(root, 'reference/Sample API/users/_order.yaml'), 'utf-8'); @@ -75,6 +75,43 @@ test('sync maintains the root reference/_order.yaml', () => { } }); +test('sync backfills a missing tag index.md even when all op pages already exist', () => { + // Simulates a reference synced by an older CLI: op pages exist, no index.md. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'API' }, + tags: [{ name: 'widgets', description: 'Widget operations' }], + paths: { + '/1': { get: { operationId: 'listWidgets', tags: ['widgets'] } }, + '/2': { get: { operationId: 'getWidget', tags: ['widgets'] } }, + }, + }); + const root = makeRepo({ + 'reference/api.json': spec, + 'reference/API/widgets/listwidgets.md': + '---\napi:\n file: api.json\n operationId: listWidgets\n---\n', + 'reference/API/widgets/getwidget.md': + '---\napi:\n file: api.json\n operationId: getWidget\n---\n', + }); + try { + const indexPath = path.join(root, 'reference/API/widgets/index.md'); + assert.equal(fs.existsSync(indexPath), false, 'precondition: no index.md yet'); + + syncOas(root); + + assert.ok(fs.existsSync(indexPath), 'expected the category index.md to be backfilled'); + const { data } = matter(fs.readFileSync(indexPath, 'utf-8')); + assert.equal(data.title, 'widgets'); + assert.equal(data.excerpt, 'Widget operations'); + + // A second run is a no-op (index now present). + const [second] = syncOas(root); + assert.equal(second.changes.added.length, 0); + } finally { + rmRepo(root); + } +}); + test('sync does not overwrite an existing tag index.md', () => { const spec = JSON.stringify({ openapi: '3.0.0', @@ -165,7 +202,7 @@ test('spec-derived names cannot escape the reference directory', () => { } }); -test('operations whose sanitized names collide do not overwrite each other', () => { +test('operations whose sanitized names collide get distinct suffixed slugs', () => { const spec = JSON.stringify({ openapi: '3.0.0', info: { title: 'Pets' }, @@ -177,37 +214,92 @@ test('operations whose sanitized names collide do not overwrite each other', () const root = makeRepo({ 'reference/pets.json': spec }); try { const [first] = syncOas(root); - // added = the op page plus the tag's generated index.md. - const addedPages = first.changes.added.filter((p) => !p.endsWith('index.md')); - assert.equal(addedPages.length, 1); - assert.equal(first.changes.skipped.length, 1); + // Both operations get their own page; the second collides and is suffixed. + const opPages = first.changes.added.filter((p) => !p.endsWith('index.md')); + assert.equal(opPages.length, 2); + assert.equal(first.changes.skipped.length, 0); - const page = path.join(root, 'reference/Pets/Other/foo-bar.md'); - const opIdOnDisk = matter(fs.readFileSync(page, 'utf-8')).data.api.operationId; + const base = path.join(root, 'reference/Pets/Other/foo-bar.md'); + const suffixed = path.join(root, 'reference/Pets/Other/foo-bar-1.md'); + assert.ok(fs.existsSync(base) && fs.existsSync(suffixed), 'expected foo-bar.md and foo-bar-1.md'); + const ops = [base, suffixed].map((p) => matter(fs.readFileSync(p, 'utf-8')).data.api.operationId); + assert.deepEqual([...ops].sort(), ['foo/bar', 'foo\\bar']); - // Re-running must not flip the page to the other colliding operation. + // Re-running is stable: both pages already exist (matched by operationId). const [second] = syncOas(root); assert.equal(second.changes.added.length, 0); - assert.equal(second.changes.skipped.length, 1); - assert.equal(matter(fs.readFileSync(page, 'utf-8')).data.api.operationId, opIdOnDisk); + assert.equal(second.changes.skipped.length, 0); } finally { rmRepo(root); } }); -test('sync does not overwrite an existing page from another spec or author', () => { +test('sync gives an operation a unique slug rather than overwriting a hand-written page', () => { const root = makeRepo({ 'reference/pets.json': SPEC, - // The sync targets the lowercased slug. + // A hand-written page (no api frontmatter) already occupies the slug. 'reference/Pets/Other/listpets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\n', }); try { - const [result] = syncOas(root); - assert.equal(result.changes.added.length, 0); - assert.equal(result.changes.skipped.length, 1); - const content = fs.readFileSync(path.join(root, 'reference/Pets/Other/listpets.md'), 'utf-8'); - assert.match(content, /Hand-written page/); - assert.match(content, /Custom content/); + syncOas(root); + // The hand-written page is untouched... + const hand = fs.readFileSync(path.join(root, 'reference/Pets/Other/listpets.md'), 'utf-8'); + assert.match(hand, /Hand-written page/); + assert.match(hand, /Custom content/); + // ...and the operation gets its own suffixed page. + const opPage = path.join(root, 'reference/Pets/Other/listpets-1.md'); + assert.ok(fs.existsSync(opPage), 'expected listpets-1.md for the operation'); + assert.equal(matter(fs.readFileSync(opPage, 'utf-8')).data.api.operationId, 'listPets'); + } finally { + rmRepo(root); + } +}); + +test('reference slugs are unique across tags (flat namespace), not per-folder', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/a': { get: { operationId: 'thing', tags: ['alpha'] } }, + '/b': { get: { operationId: 'Thing', tags: ['beta'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + // Same base slug in two different tags: the second is suffixed even though + // it's in a different folder, because reference slugs share one namespace. + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/alpha/thing.md'))); + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/beta/thing-1.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/beta/thing.md')), false); + } finally { + rmRepo(root); + } +}); + +test('a slug taken by a category folder (folder/index.md) is not reused by an operation', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/a': { get: { operationId: 'guides', tags: ['Other'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + // A category folder whose slug is its folder name: "guides". + 'reference/Pets/Other/guides/index.md': '---\ntitle: Guides\n---\n\nA sub-category.\n', + }); + try { + syncOas(root); + // The operation slug "guides" is taken by the folder, so it is suffixed. + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/Other/guides-1.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/Other/guides.md')), false); + // The category folder's index.md is untouched. + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/Other/guides/index.md'), 'utf-8'), + /A sub-category/, + ); } finally { rmRepo(root); } From 4328f22a6eb634c139238d5ab96e7036177846f6 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:12:12 -0700 Subject: [PATCH 04/10] fix(oas:sync): group untagged operations by path, not a shared "Other" folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a repo synced by the real ReadMe platform (OAS upload): an untagged operation's category folder is derived from its path (e.g. `/pets/{petId}` -> `petspetid/`, with the category page's title set to the raw path), one folder per unique path — never a single shared "Other" bucket. oas:sync instead lumped every untagged operation into one `Other/` folder, which the earlier commits in this PR's backfill pass made worse: it started generating an `Other/index.md` category page even for specs whose untagged operations already live in per-path folders on disk, producing an orphaned index page with no member operations. Add `path` to the operations extractOperations returns, and derive an operation's category grouping (folder + index.md title) from its tag when present, or its sanitized path otherwise, via the new operationGroup helper. Tagged operations are unaffected. --- src/commands/oas-sync.js | 56 ++++++++++++++++++--------- test/oas-sync.test.js | 82 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 32a2bc7..6ab2019 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -57,7 +57,7 @@ function generateOperationId(method, pathStr) { /** * Extract operations from an OAS spec. - * Returns a Map of operationId -> { summary, description, tag, operationId }. + * 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. */ export function extractOperations(spec) { @@ -75,6 +75,7 @@ export function extractOperations(spec) { summary: operation.summary || null, description: operation.description || null, tag: (operation.tags && operation.tags[0]) || null, + path: pathStr, }); } } @@ -204,12 +205,13 @@ function buildPageContent({ oasFilename, operationId }) { } /** - * Build the category landing page for a tag (mirrors what the ReadMe platform - * generates on OAS upload): title from the tag name, excerpt from the tag's - * description in the spec's top-level `tags` array. + * Build a category landing page (mirrors what the ReadMe platform generates on + * OAS upload): `title` is the tag name for a tagged group, or the raw path for + * an untagged path-derived group (see `operationGroup`); `excerpt`, when given, + * is the tag's description from the spec's top-level `tags` array. */ -function buildTagIndexContent(tagName, description) { - const frontmatter = { title: tagName }; +function buildTagIndexContent(title, description) { + const frontmatter = { title }; if (description) frontmatter.excerpt = description; // As with operation pages, upload always stamps hidden: false on new pages. frontmatter.hidden = false; @@ -217,6 +219,20 @@ function buildTagIndexContent(tagName, description) { return matter.stringify('', frontmatter); } +/** + * The category-folder grouping for an operation. A tagged operation groups + * under its own tag, as before. An untagged operation groups under a folder + * derived from its path, with the raw path as the category page's title — one + * folder per unique path, not a single shared bucket. This mirrors the + * platform's own OAS-upload output: untagged operations are never lumped into + * one "Other" folder. + */ +function operationGroup(op) { + if (op.tag) return { folder: safeSegment(op.tag, 'Other'), title: op.tag }; + const folder = safeSegment(op.path.replace(/[/{}]/g, ''), 'operation').toLowerCase(); + return { folder, title: op.path }; +} + /** * Collect every slug already used across the entire reference/ tree. Reference * page slugs share one flat namespace (docs/ is a separate namespace and is not @@ -314,28 +330,34 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { } } - // Ensure every tag present in the spec has its category landing page (index.md) + // Ensure every group (a tag, or a path-derived bucket for untagged + // operations) present in the spec has its category landing page (index.md) // and is ordered — independent of whether its operation pages are new. Doing // this as its own pass (rather than only when creating a new op page) backfills // category pages for references first synced by a CLI version that didn't // generate them, and recreates one that was deleted. - const specTags = new Set([...specOps.values()].map((op) => op.tag || 'Other')); - for (const rawTag of specTags) { - const tag = safeSegment(rawTag, 'Other'); - const pageDir = path.join(refDir, infoTitle, tag); + const groupsByFolder = new Map(); + for (const op of specOps.values()) { + const { folder, title } = operationGroup(op); + if (!groupsByFolder.has(folder)) { + groupsByFolder.set(folder, { title, description: op.tag ? tagDescriptions.get(op.tag) : null }); + } + } + for (const [folder, { title, description }] of groupsByFolder) { + const pageDir = path.join(refDir, infoTitle, folder); if (!isWithin(refDir, pageDir)) continue; const indexPath = path.join(pageDir, 'index.md'); if (!fs.existsSync(indexPath)) { // Never overwrite an existing index.md — it may be a hand-written category. fs.mkdirSync(pageDir, { recursive: true }); - fs.writeFileSync(indexPath, buildTagIndexContent(rawTag, tagDescriptions.get(rawTag))); + fs.writeFileSync(indexPath, buildTagIndexContent(title, description)); changes.added.push(path.relative(refDir, indexPath)); } - // The category page's slug is the tag folder name; reserve it so no operation + // The category page's slug is the folder name; reserve it so no operation // takes it. Ordering entries are idempotent, so this is a no-op when present. - takenSlugs.add(tag.toLowerCase()); - addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag); + takenSlugs.add(folder.toLowerCase()); + addToOrder(path.join(refDir, infoTitle, '_order.yaml'), folder); addToOrder(path.join(refDir, '_order.yaml'), infoTitle); } @@ -345,8 +367,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { for (const [opId, op] of specOps) { if (pagesByOpId.has(opId)) continue; - const tag = safeSegment(op.tag || 'Other', 'Other'); - const pageDir = path.join(refDir, infoTitle, tag); + 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. diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 53c0461..8ec4a74 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -20,8 +20,9 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () const root = makeRepo({ 'reference/pets.json': SPEC }); try { syncOas(root); - // Slugs are lowercased to match the platform's OAS-upload output. - const page = path.join(root, 'reference/Pets/Other/listpets.md'); + // Untagged operations group by path ("/pets" -> "pets"), not a shared + // "Other" folder. Slugs are lowercased to match the platform's OAS-upload output. + const page = path.join(root, 'reference/Pets/pets/listpets.md'); assert.ok(fs.existsSync(page), 'expected generated page'); const { data } = matter(fs.readFileSync(page, 'utf-8')); assert.equal(data.api.file, 'pets.json'); @@ -237,7 +238,8 @@ test('operations whose sanitized names collide get distinct suffixed slugs', () test('sync gives an operation a unique slug rather than overwriting a hand-written page', () => { const root = makeRepo({ 'reference/pets.json': SPEC, - // A hand-written page (no api frontmatter) already occupies the slug. + // A hand-written page (no api frontmatter) already occupies the slug, + // parked in an unrelated folder — slugs are reserved reference-wide. 'reference/Pets/Other/listpets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\n', }); try { @@ -246,8 +248,9 @@ test('sync gives an operation a unique slug rather than overwriting a hand-writt const hand = fs.readFileSync(path.join(root, 'reference/Pets/Other/listpets.md'), 'utf-8'); assert.match(hand, /Hand-written page/); assert.match(hand, /Custom content/); - // ...and the operation gets its own suffixed page. - const opPage = path.join(root, 'reference/Pets/Other/listpets-1.md'); + // ...and the operation gets its own suffixed page, under its path-derived + // group folder ("/pets" -> "pets"), since "listpets" is already taken. + const opPage = path.join(root, 'reference/Pets/pets/listpets-1.md'); assert.ok(fs.existsSync(opPage), 'expected listpets-1.md for the operation'); assert.equal(matter(fs.readFileSync(opPage, 'utf-8')).data.api.operationId, 'listPets'); } finally { @@ -325,3 +328,72 @@ test('existing reference page title is not overwritten by sync', () => { rmRepo(root); } }); + +test('untagged operations group by path, one folder per unique path, not a shared "Other" bucket', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { + get: { operationId: 'listPets' }, + post: { operationId: 'createPet' }, + }, + '/pets/{petId}': { + get: { operationId: 'getPet' }, + }, + '/search': { + get: { operationId: 'search' }, + }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + + // No shared "Other" folder — every unique path gets its own group. + assert.equal(fs.existsSync(path.join(refDir, 'Other')), false); + + // Operations sharing a path share a folder. + assert.ok(fs.existsSync(path.join(refDir, 'pets/listpets.md'))); + assert.ok(fs.existsSync(path.join(refDir, 'pets/createpet.md'))); + assert.ok(fs.existsSync(path.join(refDir, 'petspetid/getpet.md'))); + // The "search" folder itself reserves the slug "search" (it's the category + // page's slug), so the operationId "search" collides with its own folder + // name and is suffixed — matches real platform-upload output. + assert.ok(fs.existsSync(path.join(refDir, 'search/search-1.md'))); + assert.equal(fs.existsSync(path.join(refDir, 'search/search.md')), false); + + // The category page's title is the raw path, not the sanitized folder name. + const petsIndex = matter(fs.readFileSync(path.join(refDir, 'pets/index.md'), 'utf-8')).data; + assert.equal(petsIndex.title, '/pets'); + assert.equal('excerpt' in petsIndex, false); + + const petIdIndex = matter(fs.readFileSync(path.join(refDir, 'petspetid/index.md'), 'utf-8')).data; + assert.equal(petIdIndex.title, '/pets/{petId}'); + } finally { + rmRepo(root); + } +}); + +test('an operation with a real tag still groups under that tag, not its path', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'pets', description: 'Pet operations' }], + paths: { + '/pets': { get: { operationId: 'listPets', tags: ['pets'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + assert.ok(fs.existsSync(path.join(refDir, 'pets/listpets.md'))); + const index = matter(fs.readFileSync(path.join(refDir, 'pets/index.md'), 'utf-8')).data; + assert.equal(index.title, 'pets'); + assert.equal(index.excerpt, 'Pet operations'); + } finally { + rmRepo(root); + } +}); From c90584929f7fe11efc85f5c14b9889e3393cf0a7 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:32:23 -0700 Subject: [PATCH 05/10] fix(oas:sync): trim trailing blank line, order tags by the spec's own tags array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more fidelity gaps found while diffing CLI output against a repo synced by the real platform (byte-for-byte, after wiping and regenerating from the raw specs): - matter.stringify('', frontmatter) always appends a blank body after the closing fence, even when there's no body. The platform's generated pages end immediately after the fence with no trailing newline. Trim it. - Category order previously followed the order operations happen to appear in `paths`. The platform instead keeps a declared tag in the position it holds in the spec's own top-level `tags` array (confirmed via a spec whose `tags` order doesn't match its `paths` order). A group with no declared position — an untagged path-derived group, or a tag used by an operation but never listed in `tags` — keeps its natural encounter order, appended after every declared tag. Verified against the real synced repo: regenerating all four specs from scratch and diffing against the original tree, remaining differences are now only the two open, out-of-scope items (untagged-group ordering has no declared position to anchor to, YAML quoting of `{`-containing titles) plus two pre-existing, unrelated artifacts (a hand-authored page relocation, and OAS `webhooks` operations, which extractOperations doesn't read). --- src/commands/oas-sync.js | 32 +++++++++++++++++++-- test/oas-sync.test.js | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 6ab2019..405b238 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -180,6 +180,16 @@ function isWithin(baseDir, target) { ); } +/** + * Render a frontmatter-only page. `matter.stringify` always appends a blank + * body after the closing fence (even for an empty body); the platform's own + * generated pages end immediately after the fence with no trailing newline, + * so trim it to match. + */ +function stringifyFrontmatter(frontmatter) { + return matter.stringify('', frontmatter).replace(/\n+$/, ''); +} + function buildPageContent({ oasFilename, operationId }) { const frontmatter = { api: { @@ -201,7 +211,7 @@ function buildPageContent({ oasFilename, operationId }) { hidden: false, }; - return matter.stringify('', frontmatter); + return stringifyFrontmatter(frontmatter); } /** @@ -216,7 +226,7 @@ function buildTagIndexContent(title, description) { // As with operation pages, upload always stamps hidden: false on new pages. frontmatter.hidden = false; - return matter.stringify('', frontmatter); + return stringifyFrontmatter(frontmatter); } /** @@ -343,7 +353,23 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { groupsByFolder.set(folder, { title, description: op.tag ? tagDescriptions.get(op.tag) : null }); } } - for (const [folder, { title, description }] of groupsByFolder) { + + // Order groups the way the platform does: a tag keeps the position it's + // declared in the spec's own top-level `tags` array, not the order its + // operations happen to appear in `paths`. A group with no declared position + // (an untagged path-derived group, or a tag used by an operation but never + // listed in `tags`) keeps its natural encounter order, appended after every + // declared tag. + const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : []) + .filter((t) => t && t.name) + .map((t) => safeSegment(t.name, 'Other')); + const orderedFolders = [ + ...declaredOrder.filter((folder) => groupsByFolder.has(folder)), + ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)), + ]; + + for (const folder of orderedFolders) { + const { title, description } = groupsByFolder.get(folder); const pageDir = path.join(refDir, infoTitle, folder); if (!isWithin(refDir, pageDir)) continue; diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 8ec4a74..7540d0f 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -397,3 +397,63 @@ test('an operation with a real tag still groups under that tag, not its path', ( rmRepo(root); } }); + +test('generated pages end at the closing fence with no trailing blank line', () => { + const root = makeRepo({ 'reference/pets.json': SPEC }); + try { + syncOas(root); + // Matches platform-generated pages, which end immediately after "---" + // with no trailing newline. + const opContent = fs.readFileSync(path.join(root, 'reference/Pets/pets/listpets.md'), 'utf-8'); + assert.ok(opContent.endsWith('---'), `expected no trailing newline, got: ${JSON.stringify(opContent.slice(-5))}`); + + const indexContent = fs.readFileSync(path.join(root, 'reference/Pets/pets/index.md'), 'utf-8'); + assert.ok(indexContent.endsWith('---'), `expected no trailing newline, got: ${JSON.stringify(indexContent.slice(-5))}`); + } finally { + rmRepo(root); + } +}); + +test('tag order follows the spec\'s own `tags` array, not the order operations appear in `paths`', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + // Declared in "beta, alpha" order... + tags: [{ name: 'beta' }, { name: 'alpha' }], + paths: { + // ...even though "alpha"'s operation is declared first in paths. + '/a': { get: { operationId: 'aOp', tags: ['alpha'] } }, + '/b': { get: { operationId: 'bOp', tags: ['beta'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const order = fs.readFileSync(path.join(root, 'reference/Pets/_order.yaml'), 'utf-8'); + assert.deepEqual(order.trim().split('\n'), ['- beta', '- alpha']); + } finally { + rmRepo(root); + } +}); + +test('a tag used by an operation but not declared in `tags` is ordered after every declared tag', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'alpha' }], + paths: { + // "undeclared" is never listed in the spec's top-level tags array, and + // its operation appears before alpha's in paths. + '/a': { get: { operationId: 'aOp', tags: ['undeclared'] } }, + '/b': { get: { operationId: 'bOp', tags: ['alpha'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const order = fs.readFileSync(path.join(root, 'reference/Pets/_order.yaml'), 'utf-8'); + assert.deepEqual(order.trim().split('\n'), ['- alpha', '- undeclared']); + } finally { + rmRepo(root); + } +}); From a65e5a940849d0acc19f6d2b7a3410ed68345ce1 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:40:42 -0700 Subject: [PATCH 06/10] fix(oas:sync): reference-count reference slugs instead of a plain Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review from CodeRabbit (2026-07-17) and independently from Greptile: a Set collapses two existing owners of the same case-insensitive slug (a hand-authored page/category folder alongside a generated one, or any content that predates this uniqueness logic) into one entry. Deleting either owner's generated page then removed that single Set entry entirely, freeing the slug for reuse by a later operation in the same sync run even though the other owner still held it on disk — producing a real duplicate-slug collision. collectReferenceSlugs now returns a lowercase-slug -> owner-count Map; takeSlug/releaseSlug increment and decrement it, only fully freeing a slug once its count reaches zero. Verified the added regression test fails against the prior Set-based code (reuses the slug) and passes against this fix (suffixes instead). --- src/commands/oas-sync.js | 58 +++++++++++++++++++++++++++++----------- test/oas-sync.test.js | 43 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 405b238..599bd4e 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -244,14 +244,21 @@ function operationGroup(op) { } /** - * Collect every slug already used across the entire reference/ tree. Reference - * page slugs share one flat namespace (docs/ is a separate namespace and is not - * consulted), so a generated operation slug must be unique against all of them. - * A page's slug is its filename without `.md`; a category page's slug (a folder - * containing `index.md`) is the folder name. Comparison is case-insensitive. + * Collect every slug already used across the entire reference/ tree, as a + * lowercase-slug -> owner-count map. Reference page slugs share one flat + * namespace (docs/ is a separate namespace and is not consulted), so a + * generated operation slug must be unique against all of them. A page's slug + * is its filename without `.md`; a category page's slug (a folder containing + * `index.md`) is the folder name. + * + * A count, not a Set, because two existing pages or folders can already share + * a slug (hand-authored content, or content that predates this uniqueness + * logic) — a Set would collapse them to one entry, and releasing one owner + * (see `releaseSlug`) would incorrectly free the slug while the other owner + * still holds it. */ function collectReferenceSlugs(refDir) { - const slugs = new Set(); + const counts = new Map(); function walk(dir) { for (const entry of fs.readdirSync(dir)) { @@ -268,29 +275,47 @@ function collectReferenceSlugs(refDir) { // A folder's index.md contributes the folder name as a slug; any other // page contributes its own filename. const slug = entry === 'index.md' ? path.basename(dir) : path.basename(entry, '.md'); - slugs.add(slug.toLowerCase()); + takeSlug(counts, slug); } } } walk(refDir); - return slugs; + return counts; +} + +function isSlugTaken(takenSlugs, slug) { + return (takenSlugs.get(slug.toLowerCase()) || 0) > 0; +} + +/** Record one more owner of `slug`. */ +function takeSlug(takenSlugs, slug) { + const key = slug.toLowerCase(); + takenSlugs.set(key, (takenSlugs.get(key) || 0) + 1); +} + +/** Record one fewer owner of `slug`; only fully frees it once every owner is gone. */ +function releaseSlug(takenSlugs, slug) { + const key = slug.toLowerCase(); + const remaining = (takenSlugs.get(key) || 0) - 1; + if (remaining > 0) takenSlugs.set(key, remaining); + else takenSlugs.delete(key); } /** * Reserve a unique reference slug. `index` is never usable by an operation (it's * reserved for the tag category page), and any slug already present in the * reference namespace gets a numeric suffix (`-1`, `-2`, ...) until it's free. - * The chosen slug is added to `takenSlugs` so later operations see it. + * The chosen slug gains an owner in `takenSlugs` so later operations see it. */ function reserveSlug(takenSlugs, base) { let chosen = base; - if (base === 'index' || takenSlugs.has(base)) { + if (base === 'index' || isSlugTaken(takenSlugs, base)) { let n = 1; - while (takenSlugs.has(`${base}-${n}`)) n += 1; + while (isSlugTaken(takenSlugs, `${base}-${n}`)) n += 1; chosen = `${base}-${n}`; } - takenSlugs.add(chosen); + takeSlug(takenSlugs, chosen); return chosen; } @@ -334,7 +359,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { const pageDir = path.dirname(page.filePath); const slug = path.basename(page.filePath, '.md'); removeFromOrder(path.join(pageDir, '_order.yaml'), slug); - takenSlugs.delete(slug.toLowerCase()); + releaseSlug(takenSlugs, slug); changes.deleted.push(page.relativePath); } @@ -379,10 +404,11 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { fs.mkdirSync(pageDir, { recursive: true }); fs.writeFileSync(indexPath, buildTagIndexContent(title, description)); changes.added.push(path.relative(refDir, indexPath)); + // The category page's slug is the folder name; reserve it so no operation + // takes it. Only when just-created — an existing index.md was already + // counted by collectReferenceSlugs's initial disk walk. + takeSlug(takenSlugs, folder); } - // The category page's slug is the folder name; reserve it so no operation - // takes it. Ordering entries are idempotent, so this is a no-op when present. - takenSlugs.add(folder.toLowerCase()); addToOrder(path.join(refDir, infoTitle, '_order.yaml'), folder); addToOrder(path.join(refDir, '_order.yaml'), infoTitle); } diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 7540d0f..054afe7 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -457,3 +457,46 @@ test('a tag used by an operation but not declared in `tags` is ordered after eve rmRepo(root); } }); + +test('deleting one of two existing owners of a shared slug does not free it for reuse', () => { + // "shared" is already claimed by two pre-existing things: a leaf page + // backing an operation that's about to be removed from the spec, and an + // unrelated hand-authored category folder that survives. Deleting the + // former must not make the slug look free again. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + // "goneOp" (which used to back reference/Pets/a/shared.md) no longer + // exists in the spec. "newOp" is a new operation that would also want + // the base slug "shared". + '/new': { get: { operationId: 'shared', tags: ['a'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/a/shared.md': '---\napi:\n file: pets.json\n operationId: goneOp\n---\n', + 'reference/Pets/b/shared/index.md': '---\ntitle: Shared Category\n---\n\nHand-authored, unrelated to any operation.\n', + }); + try { + syncOas(root); + + // The orphaned page is gone... + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/a/shared.md')), false); + // ...but the still-existing category folder still owns "shared", so the + // new operation is suffixed rather than colliding with it. + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/a/shared-1.md'))); + const opId = matter( + fs.readFileSync(path.join(root, 'reference/Pets/a/shared-1.md'), 'utf-8'), + ).data.api.operationId; + assert.equal(opId, 'shared'); + + // The hand-authored survivor is untouched. + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/b/shared/index.md'), 'utf-8'), + /Hand-authored/, + ); + } finally { + rmRepo(root); + } +}); From dbb4ccaa36792ef40d1636b83f3ef000779448cc Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:18:38 -0700 Subject: [PATCH 07/10] fix(oas:sync): lowercase tag-derived category folders, matching the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a real platform upload with mixed-case tags (e.g. "MixedInternal"): the resulting on-disk category folder was lowercased ("mixedinternal"), but the category page's title frontmatter kept the tag's original casing. operationGroup's untagged/path branch already lowercased its folder; the tagged branch didn't, so a mixed-case tag name produced a folder that didn't match the platform's own output — and, more visibly, caused the tag-order fix's declaredOrder computation (also unlowercased) to never match groupsByFolder's keys for such a tag, silently appending a second, differently-cased _order.yaml entry for the same folder every sync run. No existing test used a mixed-case tag, which is why this went unnoticed until real upload data surfaced it. --- src/commands/oas-sync.js | 4 ++-- test/oas-sync.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 599bd4e..f6e45ac 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -238,7 +238,7 @@ function buildTagIndexContent(title, description) { * one "Other" folder. */ function operationGroup(op) { - if (op.tag) return { folder: safeSegment(op.tag, 'Other'), title: op.tag }; + if (op.tag) return { folder: safeSegment(op.tag, 'Other').toLowerCase(), title: op.tag }; const folder = safeSegment(op.path.replace(/[/{}]/g, ''), 'operation').toLowerCase(); return { folder, title: op.path }; } @@ -387,7 +387,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { // declared tag. const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : []) .filter((t) => t && t.name) - .map((t) => safeSegment(t.name, 'Other')); + .map((t) => safeSegment(t.name, 'Other').toLowerCase()); const orderedFolders = [ ...declaredOrder.filter((folder) => groupsByFolder.has(folder)), ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)), diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 054afe7..bec118b 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -500,3 +500,34 @@ test('deleting one of two existing owners of a shared slug does not free it for rmRepo(root); } }); + +test('a mixed-case tag gets a lowercased folder, but keeps its original case as the category title', () => { + // Confirmed against a real platform upload: a tag declared "MixedCaseTag" + // in the spec produces an on-disk folder "mixedcasetag", but the category + // page's title frontmatter keeps the original casing. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'MixedCaseTag', description: 'Ops under a mixed-case tag' }], + paths: { + '/a': { get: { operationId: 'getA', tags: ['MixedCaseTag'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + assert.ok(fs.existsSync(path.join(refDir, 'mixedcasetag/geta.md'))); + // Check the actual on-disk directory name (not just existsSync, which + // some filesystems like macOS's default APFS resolve case-insensitively). + assert.ok(fs.readdirSync(refDir).includes('mixedcasetag')); + + const index = matter(fs.readFileSync(path.join(refDir, 'mixedcasetag/index.md'), 'utf-8')).data; + assert.equal(index.title, 'MixedCaseTag'); + + const order = fs.readFileSync(path.join(refDir, '_order.yaml'), 'utf-8'); + assert.deepEqual(order.trim().split('\n'), ['- mixedcasetag']); + } finally { + rmRepo(root); + } +}); From 8308e5b774b8931df6aa27926ce15376ce6e7151 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:54:16 -0700 Subject: [PATCH 08/10] test(oas-sync): fix pre-existing tests broken by tag-folder lowercasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests hardcoded the folder "Other" (capital) for a tag literally named "Other" — accurate before the tag-lowercasing fix, stale after it (the real folder is now "other"). Passed locally on macOS regardless (APFS resolves paths case-insensitively) but correctly failed in CI on Linux, which is case-sensitive. Verified the actual on-disk folder name via readdirSync (bypassing existsSync's case-insensitive path resolution) to confirm "other" is correct before updating the assertions. --- test/oas-sync.test.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index bec118b..74294dd 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -124,11 +124,13 @@ test('sync does not overwrite an existing tag index.md', () => { }); const root = makeRepo({ 'reference/pets.json': spec, - 'reference/Pets/Other/index.md': '---\ntitle: Hand-written category\n---\n\nCustom intro.\n', + // "Other" is lowercased to "other" for a tag-derived folder — matches + // where the operation's own generated page (tag: 'Other') actually goes. + 'reference/Pets/other/index.md': '---\ntitle: Hand-written category\n---\n\nCustom intro.\n', }); try { syncOas(root); - const content = fs.readFileSync(path.join(root, 'reference/Pets/Other/index.md'), 'utf-8'); + const content = fs.readFileSync(path.join(root, 'reference/Pets/other/index.md'), 'utf-8'); assert.match(content, /Hand-written category/); assert.match(content, /Custom intro/); } finally { @@ -220,8 +222,9 @@ test('operations whose sanitized names collide get distinct suffixed slugs', () assert.equal(opPages.length, 2); assert.equal(first.changes.skipped.length, 0); - const base = path.join(root, 'reference/Pets/Other/foo-bar.md'); - const suffixed = path.join(root, 'reference/Pets/Other/foo-bar-1.md'); + // Tag "Other" is lowercased to the folder "other". + const base = path.join(root, 'reference/Pets/other/foo-bar.md'); + const suffixed = path.join(root, 'reference/Pets/other/foo-bar-1.md'); assert.ok(fs.existsSync(base) && fs.existsSync(suffixed), 'expected foo-bar.md and foo-bar-1.md'); const ops = [base, suffixed].map((p) => matter(fs.readFileSync(p, 'utf-8')).data.api.operationId); assert.deepEqual([...ops].sort(), ['foo/bar', 'foo\\bar']); @@ -290,17 +293,18 @@ test('a slug taken by a category folder (folder/index.md) is not reused by an op }); const root = makeRepo({ 'reference/pets.json': spec, - // A category folder whose slug is its folder name: "guides". - 'reference/Pets/Other/guides/index.md': '---\ntitle: Guides\n---\n\nA sub-category.\n', + // A category folder whose slug is its folder name: "guides". Tag "Other" + // is lowercased to "other", matching where the operation's own page goes. + 'reference/Pets/other/guides/index.md': '---\ntitle: Guides\n---\n\nA sub-category.\n', }); try { syncOas(root); // The operation slug "guides" is taken by the folder, so it is suffixed. - assert.ok(fs.existsSync(path.join(root, 'reference/Pets/Other/guides-1.md'))); - assert.equal(fs.existsSync(path.join(root, 'reference/Pets/Other/guides.md')), false); + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/other/guides-1.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/other/guides.md')), false); // The category folder's index.md is untouched. assert.match( - fs.readFileSync(path.join(root, 'reference/Pets/Other/guides/index.md'), 'utf-8'), + fs.readFileSync(path.join(root, 'reference/Pets/other/guides/index.md'), 'utf-8'), /A sub-category/, ); } finally { From eec9adec8288a685825953bbfe16479c7d3b2a98 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:59:45 -0700 Subject: [PATCH 09/10] fix(oas:sync): release a deleted legacy index.md operation's real slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review from Greptile (with a suggested diff matching what's applied here). A legacy operation stored literally as index.md — predating the "index is reserved for the category page" convention — claims its folder's name as its slug, same as any index.md (see collectReferenceSlugs). The delete path instead released the literal string "index", which usually isn't even a reserved key in takenSlugs, leaving the real folder-name slug falsely reserved forever. A later, unrelated operation in the same sync wanting that same slug then got an unnecessary numeric suffix. Verified the added regression test fails against the prior code (the unrelated operation gets suffixed to `sometag-1.md` instead of `sometag.md`) and passes against this fix. --- src/commands/oas-sync.js | 9 ++++++++- test/oas-sync.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index f6e45ac..45d48f8 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -357,7 +357,14 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { fs.unlinkSync(page.filePath); const pageDir = path.dirname(page.filePath); - const slug = path.basename(page.filePath, '.md'); + // A legacy operation page can be literally named index.md (predating + // the "index is reserved for the category page" convention) — its + // slug, like any index.md's, is its folder name (see + // collectReferenceSlugs), not the literal string "index". + const slug = + path.basename(page.filePath) === 'index.md' + ? path.basename(pageDir) + : path.basename(page.filePath, '.md'); removeFromOrder(path.join(pageDir, '_order.yaml'), slug); releaseSlug(takenSlugs, slug); diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 74294dd..9d1461f 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -535,3 +535,35 @@ test('a mixed-case tag gets a lowercased folder, but keeps its original case as rmRepo(root); } }); + +test('deleting a legacy operation page literally named index.md releases its folder-name slug, not "index"', () => { + // A legacy operation stored as index.md (predating the "index is reserved + // for the category page" convention) claims its folder's name as its slug, + // same as any index.md. The spec no longer has this operation, so it's + // deleted; a completely unrelated new operation elsewhere in the same sync + // run wants that exact same slug and must get it cleanly, not a suffix. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + // Unrelated new operation whose desired slug is "sometag" — the same + // string as the deleted legacy page's folder name. + '/new': { get: { operationId: 'sometag', tags: ['other-tag'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/sometag/index.md': + '---\napi:\n file: pets.json\n operationId: legacyOp\n---\n', + }); + try { + const [result] = syncOas(root); + assert.ok(result.changes.deleted.some((p) => p.endsWith('sometag/index.md'))); + + // The base slug is free again — no unnecessary numeric suffix. + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/other-tag/sometag.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/other-tag/sometag-1.md')), false); + } finally { + rmRepo(root); + } +}); From 6b08aa068940f8e6855609191eca0905074e64ad Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:59:41 -0700 Subject: [PATCH 10/10] fix(oas:sync): remove the right order.yaml entry when deleting a legacy index.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to eec9ade, per Greptile: that fix correctly changed which slug gets released reference-wide (a legacy index.md-backed operation's folder name), but wrongly reused the same value for removeFromOrder too. A pre-refactor tool would have written the literal filename ("index") into the folder's own _order.yaml, not the folder name — removeFromOrder needs that original value, while releaseSlug needs the folder name. Split them into pageSlug and referenceSlug. Strengthened the existing regression test to seed a pre-existing "- index" order entry and assert it's cleaned up; confirmed it fails against the prior (conflated) fix and passes against this one. --- src/commands/oas-sync.js | 21 ++++++++++++--------- test/oas-sync.test.js | 8 ++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 45d48f8..4dcc0d0 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -358,15 +358,18 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { const pageDir = path.dirname(page.filePath); // A legacy operation page can be literally named index.md (predating - // the "index is reserved for the category page" convention) — its - // slug, like any index.md's, is its folder name (see - // collectReferenceSlugs), not the literal string "index". - const slug = - path.basename(page.filePath) === 'index.md' - ? path.basename(pageDir) - : path.basename(page.filePath, '.md'); - removeFromOrder(path.join(pageDir, '_order.yaml'), slug); - releaseSlug(takenSlugs, slug); + // the "index is reserved for the category page" convention). Two + // different things need two different values here: pageSlug is what a + // pre-refactor tool would have actually written into pageDir's own + // _order.yaml ("index", the filename) — that's what removeFromOrder + // must remove. referenceSlug is what the reference-wide slug map + // reserved for it (its folder name, like any index.md — see + // collectReferenceSlugs) — that's what releaseSlug must free. + const isIndexPage = path.basename(page.filePath) === 'index.md'; + const pageSlug = path.basename(page.filePath, '.md'); + const referenceSlug = isIndexPage ? path.basename(pageDir) : pageSlug; + removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug); + releaseSlug(takenSlugs, referenceSlug); changes.deleted.push(page.relativePath); } diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 9d1461f..fd0b0e4 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -555,6 +555,9 @@ test('deleting a legacy operation page literally named index.md releases its fol 'reference/pets.json': spec, 'reference/Pets/sometag/index.md': '---\napi:\n file: pets.json\n operationId: legacyOp\n---\n', + // A pre-refactor tool would have written the literal filename "index" + // into this directory's own order file — not the folder name. + 'reference/Pets/sometag/_order.yaml': '- index\n', }); try { const [result] = syncOas(root); @@ -563,6 +566,11 @@ test('deleting a legacy operation page literally named index.md releases its fol // The base slug is free again — no unnecessary numeric suffix. assert.ok(fs.existsSync(path.join(root, 'reference/Pets/other-tag/sometag.md'))); assert.equal(fs.existsSync(path.join(root, 'reference/Pets/other-tag/sometag-1.md')), false); + + // The dangling "- index" entry is removed from the folder's own order + // file (not the folder name — that was never what was listed there). + const orderPath = path.join(root, 'reference/Pets/sometag/_order.yaml'); + assert.equal(fs.existsSync(orderPath), false, 'expected the now-empty _order.yaml to be removed'); } finally { rmRepo(root); }