import { Location, isRef } from './ref-utils'; import { pushStack, popStack } from './utils'; import { YamlParseError, makeRefId } from './resolve'; import { isNamedType, SpecExtension } from './types'; import type { SpecVersion } from './oas-types'; import type { ResolveError, Source, ResolvedRefMap, Document } from './resolve'; import type { Referenced } from './typings/openapi'; import type { VisitorLevelContext, NormalizedOasVisitors, VisitorSkippedLevelContext, VisitFunction, BaseVisitor, NormalizeVisitor, VisitorNode, } from './visitors'; import type { NormalizedNodeType } from './types'; import type { RuleSeverity } from './config'; export type NonUndefined = | string | number | boolean | symbol | bigint | object | Record; export type ResolveResult = | { node: T; location: Location; error?: ResolveError | YamlParseError } | { node: undefined; location: undefined; error?: ResolveError | YamlParseError }; export type ResolveFn = ( node: Referenced, from?: string ) => ResolveResult; export type UserContext = { report(problem: Problem): void; location: Location; rawNode: any; rawLocation: Location; resolve: ResolveFn; parentLocations: Record; type: NormalizedNodeType; key: string | number; parent: any; oasVersion: SpecVersion; getVisitorData: () => Record; }; export type Loc = { line: number; col: number; }; export type PointerLocationObject = { source: Source; reportOnKey?: boolean; pointer: string; }; export type LineColLocationObject = Omit & { pointer: undefined; start: Loc; end?: Loc; }; export type LocationObject = LineColLocationObject | PointerLocationObject; export type ProblemSeverity = 'error' | 'warn'; export type Problem = { message: string; suggest?: string[]; location?: Partial | Array>; from?: LocationObject; forceSeverity?: RuleSeverity; ruleId?: string; }; export type NormalizedProblem = { message: string; ruleId: string; severity: ProblemSeverity; location: LocationObject[]; from?: LocationObject; suggest: string[]; ignored?: boolean; }; export type WalkContext = { problems: NormalizedProblem[]; oasVersion: SpecVersion; visitorsData: Record>; // custom data store that visitors can use for various purposes refTypes?: Map; }; function collectParents(ctx: VisitorLevelContext) { const parents: Record = {}; while (ctx.parent) { parents[ctx.parent.type.name] = ctx.parent.activatedOn?.value.node; ctx = ctx.parent; } return parents; } function collectParentsLocations(ctx: VisitorLevelContext) { const locations: Record = {}; while (ctx.parent) { if (ctx.parent.activatedOn?.value.location) { locations[ctx.parent.type.name] = ctx.parent.activatedOn?.value.location; } ctx = ctx.parent; } return locations; } export function walkDocument(opts: { document: Document; rootType: NormalizedNodeType; normalizedVisitors: NormalizedOasVisitors; resolvedRefMap: ResolvedRefMap; ctx: WalkContext; }) { const { document, rootType, normalizedVisitors, resolvedRefMap, ctx } = opts; const seenNodesPerType: Record> = {}; const ignoredNodes = new Set(); walkNode(document.parsed, rootType, new Location(document.source, '#/'), undefined, ''); function walkNode( node: any, type: NormalizedNodeType, location: Location, parent: any, key: string | number ) { const resolve: ResolveFn = (ref, from = currentLocation.source.absoluteRef) => { if (!isRef(ref)) return { location, node: ref }; const refId = makeRefId(from, ref.$ref); const resolvedRef = resolvedRefMap.get(refId); if (!resolvedRef) { return { location: undefined, node: undefined, }; } const { resolved, node, document, nodePointer, error } = resolvedRef; const newLocation = resolved ? new Location(document!.source, nodePointer!) : error instanceof YamlParseError ? new Location(error.source, '') : undefined; return { location: newLocation, node, error }; }; const rawLocation = location; let currentLocation = location; const { node: resolvedNode, location: resolvedLocation, error } = resolve(node); const enteredContexts: Set = new Set(); if (isRef(node)) { const refEnterVisitors = normalizedVisitors.ref.enter; for (const { visit: visitor, ruleId, severity, message, context } of refEnterVisitors) { enteredContexts.add(context); const report = reportFn.bind(undefined, ruleId, severity, message); visitor( node, { report, resolve, rawNode: node, rawLocation, location, type, parent, key, parentLocations: {}, oasVersion: ctx.oasVersion, getVisitorData: getVisitorDataFn.bind(undefined, ruleId), }, { node: resolvedNode, location: resolvedLocation, error } ); if (resolvedLocation?.source.absoluteRef && ctx.refTypes) { ctx.refTypes.set(resolvedLocation?.source.absoluteRef, type); } } } if (resolvedNode !== undefined && resolvedLocation && type.name !== 'scalar') { currentLocation = resolvedLocation; const isNodeSeen = seenNodesPerType[type.name]?.has?.(resolvedNode); let visitedBySome = false; const anyEnterVisitors = normalizedVisitors.any.enter; const currentEnterVisitors = anyEnterVisitors.concat( (normalizedVisitors[type.name]?.enter as NormalizeVisitor[]>) || [] ); const activatedContexts: Array = []; for (const { context, visit, skip, ruleId, severity, message } of currentEnterVisitors) { if (ignoredNodes.has(`${currentLocation.absolutePointer}${currentLocation.pointer}`)) break; if (context.isSkippedLevel) { if ( context.parent.activatedOn && !context.parent.activatedOn.value.nextLevelTypeActivated && !context.seen.has(node) ) { // TODO: test for walk through duplicated $ref-ed node context.seen.add(node); visitedBySome = true; activatedContexts.push(context); } } else { if ( (context.parent && // if nested context.parent.activatedOn && context.activatedOn?.value.withParentNode !== context.parent.activatedOn.value.node && // do not enter if visited by parent children (it works thanks because deeper visitors are sorted before) context.parent.activatedOn.value.nextLevelTypeActivated?.value !== type) || (!context.parent && !isNodeSeen) // if top-level visit each node just once ) { activatedContexts.push(context); const activatedOn = { node: resolvedNode, location: resolvedLocation, nextLevelTypeActivated: null, withParentNode: context.parent?.activatedOn?.value.node, skipped: (context.parent?.activatedOn?.value.skipped || skip?.(resolvedNode, key, { location, rawLocation, resolve, rawNode: node, })) ?? false, }; context.activatedOn = pushStack(context.activatedOn, activatedOn); let ctx: VisitorLevelContext | null = context.parent; while (ctx) { ctx.activatedOn!.value.nextLevelTypeActivated = pushStack( ctx.activatedOn!.value.nextLevelTypeActivated, type ); ctx = ctx.parent; } if (!activatedOn.skipped) { visitedBySome = true; enteredContexts.add(context); visitWithContext(visit, resolvedNode, node, context, ruleId, severity, message); } } } } if (visitedBySome || !isNodeSeen) { seenNodesPerType[type.name] = seenNodesPerType[type.name] || new Set(); seenNodesPerType[type.name].add(resolvedNode); if (Array.isArray(resolvedNode)) { const itemsType = type.items; if (itemsType !== undefined) { const isTypeAFunction = typeof itemsType === 'function'; for (let i = 0; i < resolvedNode.length; i++) { const itemType = isTypeAFunction ? itemsType(resolvedNode[i], resolvedLocation.child([i]).absolutePointer) : itemsType; if (isNamedType(itemType)) { walkNode(resolvedNode[i], itemType, resolvedLocation.child([i]), resolvedNode, i); } } } } else if (typeof resolvedNode === 'object' && resolvedNode !== null) { // visit in order from type-tree first const props = Object.keys(type.properties); if (type.additionalProperties) { props.push(...Object.keys(resolvedNode).filter((k) => !props.includes(k))); } else if (type.extensionsPrefix) { props.push( ...Object.keys(resolvedNode).filter((k) => k.startsWith(type.extensionsPrefix as string) ) ); } if (isRef(node)) { props.push(...Object.keys(node).filter((k) => k !== '$ref' && !props.includes(k))); // properties on the same level as $ref } for (const propName of props) { let value = resolvedNode[propName]; let loc = resolvedLocation; if (value === undefined) { value = node[propName]; loc = location; // properties on the same level as $ref should resolve against original location, not target } let propType = type.properties[propName]; if (propType === undefined) propType = type.additionalProperties; if (typeof propType === 'function') propType = propType(value, propName); if ( propType === undefined && type.extensionsPrefix && propName.startsWith(type.extensionsPrefix) ) { propType = SpecExtension; } if (!isNamedType(propType) && propType?.directResolveAs) { propType = propType.directResolveAs; value = { $ref: value }; } if (propType && propType.name === undefined && propType.resolvable !== false) { propType = { name: 'scalar', properties: {} }; } if (!isNamedType(propType) || (propType.name === 'scalar' && !isRef(value))) { continue; } walkNode(value, propType, loc.child([propName]), resolvedNode, propName); } } } const anyLeaveVisitors = normalizedVisitors.any.leave; const currentLeaveVisitors = (normalizedVisitors[type.name]?.leave || []).concat( anyLeaveVisitors ); for (const context of activatedContexts.reverse()) { if (context.isSkippedLevel) { context.seen.delete(resolvedNode); } else { context.activatedOn = popStack(context.activatedOn); if (context.parent) { let ctx: VisitorLevelContext | null = context.parent; while (ctx) { ctx.activatedOn!.value.nextLevelTypeActivated = popStack( ctx.activatedOn!.value.nextLevelTypeActivated ); ctx = ctx.parent; } } } } for (const { context, visit, ruleId, severity, message } of currentLeaveVisitors) { if (!context.isSkippedLevel && enteredContexts.has(context)) { visitWithContext(visit, resolvedNode, node, context, ruleId, severity, message); } } } currentLocation = location; if (isRef(node)) { const refLeaveVisitors = normalizedVisitors.ref.leave; for (const { visit: visitor, ruleId, severity, context, message } of refLeaveVisitors) { if (enteredContexts.has(context)) { const report = reportFn.bind(undefined, ruleId, severity, message); visitor( node, { report, resolve, rawNode: node, rawLocation, location, type, parent, key, parentLocations: {}, oasVersion: ctx.oasVersion, getVisitorData: getVisitorDataFn.bind(undefined, ruleId), }, { node: resolvedNode, location: resolvedLocation, error } ); } } } // returns true ignores all the next visitors on the specific node function visitWithContext( visit: VisitFunction, resolvedNode: unknown, node: unknown, context: VisitorLevelContext, ruleId: string, severity: ProblemSeverity, customMessage: string | undefined ) { const report = reportFn.bind(undefined, ruleId, severity, customMessage); visit( resolvedNode, { report, resolve, rawNode: node, location: currentLocation, rawLocation, type, parent, key, parentLocations: collectParentsLocations(context), oasVersion: ctx.oasVersion, ignoreNextVisitorsOnNode: () => { ignoredNodes.add(`${currentLocation.absolutePointer}${currentLocation.pointer}`); }, getVisitorData: getVisitorDataFn.bind(undefined, ruleId), }, collectParents(context), context ); } function reportFn( ruleId: string, severity: ProblemSeverity, customMessage: string, opts: Problem ) { const normalizedLocation = opts.location ? Array.isArray(opts.location) ? opts.location : [opts.location] : [{ ...currentLocation, reportOnKey: false }]; const location = normalizedLocation.map((l) => ({ ...currentLocation, reportOnKey: false, ...l, })); const ruleSeverity = opts.forceSeverity || severity; if (ruleSeverity !== 'off') { ctx.problems.push({ ruleId: opts.ruleId || ruleId, severity: ruleSeverity, ...opts, message: customMessage ? customMessage.replace('{{message}}', opts.message) : opts.message, suggest: opts.suggest || [], location, }); } } function getVisitorDataFn(ruleId: string) { ctx.visitorsData[ruleId] = ctx.visitorsData[ruleId] || {}; return ctx.visitorsData[ruleId]; } } }