UNPKG

@yunarch/config-web

Version:

Shared configurations for web projects.

14 lines (12 loc) 9.42 kB
#!/usr/bin/env node import{n as e}from"../utils-B-9rP3qT.mjs";import{styleText as t}from"node:util";import{existsSync as n,readFileSync as r,statSync as i}from"node:fs";import a from"node:path";import o from"fast-glob";import s from"typescript";import{pathToFileURL as c}from"node:url";function l(e,n=[]){let r=new Map;for(let t of e)r.has(t.service.name)||r.set(t.service.name,{service:t.service,handlers:[]}),r.get(t.service.name)?.handlers.push(t);if(r.size>0){console.log(t(`red`,`✖ Missing handlers (${e.length})`));for(let{service:e,handlers:n}of r.values()){console.log(` ${t(`underline`,e.name)}${t(`gray`,` (${e.path})`)}`);for(let[e,r]of n.entries()){let i=e===n.length-1;console.log(` ${i?`└─`:`├─`} ${t(`cyan`,r.service.toHandleHttpMethod)} ${t(`yellow`,r.service.toHandleUrl)}`),console.log(` ${i?` `:`│`} ├─ ${t(`gray`,`Used in:`)}`);for(let[e,t]of r.usedIn.entries()){let n=e===r.usedIn.length-1;console.log(` ${i?` `:`│`} ${n?`│ └─`:`│ ├─`} ${t}`)}console.log(` ${i?` `:`│`} └─ Suggested handler:`),console.log(` ${i?` `:`│`} ${t(`dim`,`→`)} ${r.suggestedPath}`),e<n.length-1&&console.log(` ${i?` `:`│`}`)}console.log(``)}}let i=new Map;for(let e of n){let t=`${e.handler.httpMethod}:${e.handler.url}`;i.has(t)||i.set(t,[]),i.get(t)?.push(e)}if(i.size>0){console.log(t(`yellow`,`⚠ Disconnected handlers (${n.length})`),t(`gray`,`- Handler exists but is not registered in MSW setup`));for(let[e,n]of i.entries()){console.log(` ${t(`underline`,e)}`);for(let[e,{handler:r}]of n.entries()){let i=e===n.length-1;console.log(` ${i?`└─`:`├─`} ${t(`gray`,`Found in:`)} ${t(`dim`,r.filePath)}`)}console.log(``)}}let a=[];e.length>0&&a.push(`${e.length} missing ${e.length===1?`handler`:`handlers`}`),n.length>0&&a.push(`${n.length} disconnected ${n.length===1?`handler`:`handlers`}`),a.length>0?console.log(t(e.length>0?`red`:`yellow`,`Found ${a.join(`, `)}`)):console.log(t(`green`,`✔ No missing handlers found`))}const u=new Set([`get`,`post`,`put`,`delete`,`patch`,`options`,`head`]);function d(e,t,n){if(e.startsWith(`.`)){let r=a.resolve(a.dirname(t),e),i=a.resolve(n),o=a.join(i,`openapi-msw-http`);return r.replace(/\.(?:ts|tsx|js|jsx|mjs|cjs)$/,``)===o||r===o}return e.endsWith(`openapi-msw-http`)}function f(e){return!!(s.isCallExpression(e.parent)&&s.isPropertyAccessExpression(e.parent.expression)&&s.isIdentifier(e.parent.expression.name)&&e.parent.expression.name.text===`use`)}async function p({srcPath:e,genPath:t}){let n=[],i=await o(`**/*.{ts,tsx,js,jsx,mjs,cjs}`,{cwd:e,absolute:!0,ignore:[`**/node_modules/**`,`**/dist/**`,`**/build/**`]});for(let e of i){let i=r(e,`utf8`);if(!i.includes(`msw`))continue;let a=s.createSourceFile(e,i,s.ScriptTarget.Latest,!0),o,c;if(s.forEachChild(a,n=>{if(s.isImportDeclaration(n)&&s.isStringLiteral(n.moduleSpecifier)){let r=n.moduleSpecifier.text;if(d(r,e,t)&&n.importClause?.namedBindings&&s.isNamedImports(n.importClause.namedBindings))for(let e of n.importClause.namedBindings.elements)(e.propertyName??e.name).text===`http`&&(o=e.name.text);if(r===`msw`&&n.importClause?.namedBindings&&s.isNamedImports(n.importClause.namedBindings))for(let e of n.importClause.namedBindings.elements)(e.propertyName??e.name).text===`http`&&(c=e.name.text)}}),!o&&!c)continue;let l=t=>{if(s.isCallExpression(t)){if(o&&s.isIdentifier(t.expression)&&t.expression.text===o&&t.arguments.length>=2){let r=t.arguments[0],i=t.arguments[1];if(s.isStringLiteral(r)&&s.isStringLiteral(i)){let a=r.text,o=i.text.toUpperCase();n.push({httpMethod:o,url:a,filePath:e,isRuntimeOverride:f(t)})}}if(c&&s.isPropertyAccessExpression(t.expression)&&s.isIdentifier(t.expression.expression)&&t.expression.expression.text===c&&s.isIdentifier(t.expression.name)&&u.has(t.expression.name.text.toLowerCase())&&t.arguments.length>0){let r=t.arguments[0];if(s.isStringLiteral(r)){let i=t.expression.name.text.toUpperCase(),a=r.text.replaceAll(/:(?<temp1>[^/]+)/g,`{$1}`);n.push({httpMethod:i,url:a,filePath:e,isRuntimeOverride:f(t)})}}}s.forEachChild(t,l)};l(a)}return n}async function m(e){let t=[],n=await import(c(e).href),r;for(let e of Object.values(n))if(e&&typeof e==`object`&&`listHandlers`in e&&typeof e.listHandlers==`function`){r=e;break}if(!r)throw Error(`No export with a listHandlers() method found in the MSW setup file. Ensure the file exports a setupServer() or setupWorker() instance.`);let i=r.listHandlers();for(let e of i){if(!(`info`in e)||!e.info?.path||!e.info.method||typeof e.info.path!=`string`)continue;let n=e.info.path,r=e.info.method.toUpperCase(),i=n.replaceAll(/:(?<temp1>[^/]+)/g,`{$1}`);t.push({httpMethod:r,url:i})}return t}async function h(e){let t=[],i=a.join(e,`services`);if(!n(i))throw Error(`Services directory not found: ${i}`);let c=await o(`**/*Service.ts`,{cwd:i,absolute:!0,ignore:[`**/node_modules/**`]});for(let e of c){let n=a.basename(e,`.ts`),i=r(e,`utf8`),o=s.createSourceFile(e,i,s.ScriptTarget.Latest,!0);s.forEachChild(o,r=>{if(s.isClassDeclaration(r)&&r.name?.text===n){for(let i of r.members)if(s.isMethodDeclaration(i)&&s.isIdentifier(i.name)&&i.modifiers&&i.modifiers.some(e=>e.kind===s.SyntaxKind.PublicKeyword)&&i.modifiers.some(e=>e.kind===s.SyntaxKind.StaticKeyword)){let r=i.name.text,a,o,c=e=>{if(s.isReturnStatement(e)&&e.expression&&s.isCallExpression(e.expression)&&s.isIdentifier(e.expression.expression)&&e.expression.expression.text===`__request`){let t=e.expression.arguments[1];if(s.isObjectLiteralExpression(t))for(let e of t.properties)s.isPropertyAssignment(e)&&s.isIdentifier(e.name)&&e.name.text===`url`&&s.isStringLiteral(e.initializer)?a=e.initializer.text:s.isPropertyAssignment(e)&&s.isIdentifier(e.name)&&e.name.text===`method`&&s.isStringLiteral(e.initializer)&&(o=e.initializer.text.toUpperCase())}s.forEachChild(e,c)};i.body&&s.forEachChild(i.body,c),a&&o?t.push({path:e,name:n,methodName:r,toHandleUrl:a,toHandleHttpMethod:o}):(a||console.warn(`No URL found for ${r} request in service ${n} (${e})`),o||console.warn(`No HTTP method found for ${r} request in service ${n} (${e})`))}}})}return t}async function g({genPath:e,srcPath:t}){let n=await h(e),i=new Map;for(let e of n)i.has(e.name)||i.set(e.name,new Map),i.get(e.name)?.set(e.methodName,{serviceInfo:e,files:new Set});let a=await o(`**/*.{ts,tsx}`,{cwd:t,absolute:!0,ignore:[`**/node_modules/**`,`**/__tests__/**`]});for(let e of a)try{let t=r(e,`utf8`),n=s.createSourceFile(e,t,s.ScriptTarget.Latest,!0),a=t=>{if(s.isCallExpression(t)&&s.isPropertyAccessExpression(t.expression)&&s.isIdentifier(t.expression.expression)&&t.expression.expression.text.endsWith(`Service`)){let n=t.expression.expression.text,r=t.expression.name.text,a=i.get(n)?.get(r);if(!a)return;a.files.add(e)}s.forEachChild(t,a)};a(n)}catch(t){throw t instanceof Error?Error(`Error parsing ${e}: ${t.message}`):Error(`Error parsing ${e}: Unknown error`)}for(let[e,t]of i.entries()){for(let[e,n]of t.entries())n.files.size===0&&t.delete(e);t.size===0&&i.delete(e)}return i}function _(e,t){let n=new Set(t.map(e=>`${e.httpMethod.toLowerCase()}:${e.url}`)),r=[];for(let t of e){if(t.isRuntimeOverride)continue;let e=t.httpMethod.toLowerCase(),i=t.url;!n.has(`${e}:${i}`)&&!n.has(`${e}:*${i}`)&&r.push({type:`disconnected_handler`,handler:t})}return r}function v(e,t,n){let r=new Set(t.filter(e=>!e.isRuntimeOverride).map(e=>`${e.httpMethod.toLowerCase()}:${e.url}`)),i=[];for(let[t,o]of e.entries())for(let[e,s]of o.entries()){let{serviceInfo:o}=s,c=o.toHandleHttpMethod.toLowerCase(),l=o.toHandleUrl;!r.has(`${c}:${l}`)&&!r.has(`${c}:*${l}`)&&i.push({type:`missing_handler`,service:o,usedIn:[...s.files],suggestedPath:a.join(n,`handlers/services/${t}/${e}.ts`)})}return i}e().name(`openapi-msw-lint`).description(`Analyzes your codebase to detect missing and disconnected MSW handlers by cross-referencing service usages generated by \`openapi-gen\`. Reports missing handlers with file locations and suggested paths, and warns about handlers that exist but are not registered in the MSW setup. Note: Handlers defined inside MSW \`.use()\` calls are excluded from analysis. These are runtime overrides typically used for per-test network behavior (e.g. simulating error responses) and are not part of the base network description.`).requiredOption(`--gen <path>`,"The output folder from `openapi-gen` script. Where the generated models and openapi schema and type definitions are saved.").requiredOption(`--msw-setup-file <path>`,`Path to the MSW setup file (file that configures MSW setupServer or setupWorker).`).addHelpText(`after`,` Example usage: ${t(`dim`,`$`)} \ ${t(`cyan`,`openapi-msw-lint`)} \ ${t(`green`,`--gen`)} ${t(`yellow`,`./src/api/gen`)} \ ${t(`green`,`--msw-setup-file`)} ${t(`yellow`,`./src/api/msw/node.ts`)} Note: Requires a runtime that supports TypeScript (e.g. ${t(`yellow`,`tsx`)}, ${t(`yellow`,`ts-node`)}, ${t(`yellow`,`bun`)} or similar.) `).action(async({gen:e,mswSetupFile:t})=>{try{let r=process.cwd(),o=a.resolve(r,e),s=a.resolve(r,`.`),c=a.resolve(r,t);if(!n(o)||!i(o).isDirectory())throw Error(`Generated API folder does not exist or is not a directory`);if(!n(c)||!i(c).isFile())throw Error(`MSW setup file does not exist or is not a file`);let[u,d,f]=await Promise.all([g({genPath:o,srcPath:s}),p({srcPath:s,genPath:o}),m(c)]),h=v(u,d,a.dirname(c));l(h,_(d,f)),process.exit(+(h.length>0))}catch(e){console.error(e),process.exit(1)}}).parseAsync(process.argv);export{};