Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions src/commands/oas-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,111 @@ function generateOperationId(method, pathStr) {
return `${method.toLowerCase()}_${sanitized}`;
}

/**
* JSON Pointer (`#/components/pathItems/Pets`, `#/paths/~1pets`) used by
* OAS path-item and operation `$ref`s. `~1` / `~0` are the spec's escapes
* for `/` and `~`.
*/
function decodeJsonPointerToken(token) {
return token.replace(/~1/g, '/').replace(/~0/g, '~');
}

function resolvePointer(root, pointer) {
if (pointer === '#' || pointer === '') return root;
if (typeof pointer !== 'string' || !pointer.startsWith('#/')) return undefined;
const parts = pointer.slice(2).split('/').map(decodeJsonPointerToken);
let current = root;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
// Own keys only — `in` would treat `__proto__` / `constructor` as hits
// and walk off the document into Object.prototype.
if (!Object.hasOwn(current, part)) return undefined;
current = current[part];
}
return current;
}

function ownSiblings(obj) {
const siblings = {};
for (const [key, value] of Object.entries(obj)) {
if (key !== '$ref') siblings[key] = value;
}
return siblings;
}

/**
* Follow an internal `#/…` `$ref` (and chains of them). External/file refs
* (`./paths/pets.yaml`) are left as-is so callers can treat them as unresolved.
*
* OAS 3.1 Path Item Objects may keep sibling fields next to `$ref`; those
* overlay the resolved target (local keys win) instead of being dropped.
*/
function resolveRefObject(root, obj, seen = new Set()) {
if (!obj || typeof obj !== 'object' || typeof obj.$ref !== 'string') return obj;
const ref = obj.$ref;
const siblings = ownSiblings(obj);
const hasSiblings = Object.keys(siblings).length > 0;

if (!ref.startsWith('#/') || seen.has(ref)) return obj;
seen.add(ref);
const target = resolvePointer(root, ref);
if (target == null) return obj;
const resolved = resolveRefObject(root, target, seen);
if (!hasSiblings) return resolved;
if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) return resolved;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Non-object targets discard siblings

When a path item has a sibling HTTP operation beside an internal $ref whose target is an array or scalar, resolveRefObject returns only that target and discards the sibling. The reference is then treated as resolved, so extraction omits the sibling operation while deletion remains enabled, causing synchronization to delete its existing page and validation to report it as missing.

return { ...resolved, ...siblings };
}

/**
* True when following `$ref` (including chained pointers) still lands on a
* `$ref` — external file, cycle, broken pointer, or `#/__proto__`-style miss.
* A single hop that lands on `{ $ref: './other.yaml' }` is unresolved.
*/
function isUnresolvedRef(root, obj) {
if (!obj || typeof obj !== 'object' || typeof obj.$ref !== 'string') return false;
const resolved = resolveRefObject(root, obj);
return !!(resolved && typeof resolved === 'object' && typeof resolved.$ref === 'string');
}

/**
* True when `paths` still has a `$ref` we could not inline. Used to skip the
* delete pass — missing operations after a failed resolve are "we couldn't
* see the spec", not "the operation was removed".
*/
export function hasUnresolvedOperationRefs(spec) {
for (const rawPathItem of Object.values(spec.paths || {})) {
if (isUnresolvedRef(spec, rawPathItem)) return true;
const pathItem = resolveRefObject(spec, rawPathItem);
if (!pathItem || typeof pathItem !== 'object') continue;
for (const [method, rawOp] of Object.entries(pathItem)) {
if (!HTTP_METHODS.has(method)) continue;
if (isUnresolvedRef(spec, rawOp)) return true;
}
}
return false;
}

/**
* Extract operations from an OAS spec.
* Returns a Map of operationId -> { summary, description, tag, operationId }.
* For operations without an operationId, a synthetic one is generated from the method and path.
*
* Path-item and operation `$ref`s that point inside the same document
* (`#/components/pathItems/…`, `#/paths/~1pets`) are resolved first. OAS 3.1
* `components.pathItems` is the documented way to reuse a path; walking the
* raw `$ref` stub would report zero operations and `oas:sync` / `lint --fix`
* would delete every matching reference page.
*/
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)) {
for (const [pathStr, rawPathItem] of Object.entries(paths)) {
const methods = resolveRefObject(spec, rawPathItem) || {};
for (const [method, rawOperation] of Object.entries(methods)) {
if (!HTTP_METHODS.has(method)) continue;

const operation = resolveRefObject(spec, rawOperation) || {};
const operationId = operation.operationId || generateOperationId(method, pathStr);

ops.set(operationId, {
Expand Down Expand Up @@ -210,10 +302,14 @@ function syncOneOas(refDir, oasFilename, spec) {
}

const changes = { added: [], deleted: [], skipped: [] };
// File $refs (and broken internal pointers) mean we cannot see the real
// operation set. Deleting "missing" pages would wipe valid reference docs.
const skipDeletes = hasUnresolvedOperationRefs(spec);

// Deletes: pages referencing operations that no longer exist.
for (const [opId, page] of pagesByOpId) {
if (!specOps.has(opId)) {
if (skipDeletes) continue;
fs.unlinkSync(page.filePath);

const pageDir = path.dirname(page.filePath);
Expand Down
27 changes: 27 additions & 0 deletions test/oas-reference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,33 @@ test('mismatched title/excerpt no longer reported as out of sync', () => {
}
});

test('path-item $ref is not reported as a missing operation', () => {
const spec = JSON.stringify({
openapi: '3.1.0',
info: { title: 'Pets', version: '1.0.0' },
paths: {
'/pets': { $ref: '#/components/pathItems/Pets' },
},
components: {
pathItems: {
Pets: { get: { operationId: 'listPets' } },
},
},
});
const root = makeRepo({
'reference/pets.json': spec,
'reference/Pets/Other/listPets.md':
'---\napi:\n file: pets.json\n operationId: listPets\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);
}
});

test('operation not found is still reported', () => {
const root = makeRepo({
'reference/pets.json': SPEC,
Expand Down
Loading